aboutsummaryrefslogtreecommitdiffstats
path: root/test/test_data.lua
blob: 1b6c5802eeb7b6dca01bec460ded96f70e4f2b95 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
-- Parser checks for lib/data.lua.
-- Run from the repo root:  lua test/test_data.lua
-- Parsers are what break silently on a kernel or hardware change, so they are
-- what gets a test. Everything else in this project is verified by screenshot.

package.path = './?.lua;' .. package.path
local data = require 'lib.data'

-- lib.card uses Cairo globals (cairo_*, CAIRO_*) without requiring 'cairo'
-- itself, so those globals must exist before it loads. Conky provides them;
-- plain lua needs its loadable Cairo binding on the cpath first, exactly as
-- test/render.lua does.
package.cpath = '/usr/lib64/conky/lib?.so;' .. package.cpath
require 'cairo'

local function read(path)
  local f = assert(io.open(path, 'r'))
  local s = f:read('*a')
  f:close()
  return s
end

-- cpu_times: total and idle jiffies from the aggregate "cpu " line.
local total, idle = data.cpu_times(read('test/fixtures/proc_stat'))
-- 209094+2199+135469+11604898+24408+0+946 = 11977014
assert(total == 11977014, 'cpu total, got ' .. tostring(total))
-- idle field is the 4th value, 11604898; iowait (24408) counts as idle too
assert(idle == 11629306, 'cpu idle, got ' .. tostring(idle))

-- mem_info: values in kB, as the file gives them.
local mem = data.mem_info(read('test/fixtures/proc_meminfo'))
assert(mem.total == 31943076, 'mem total, got ' .. tostring(mem.total))
assert(mem.available == 25627088, 'mem available, got ' .. tostring(mem.available))
-- used is total minus available, which is what a user means by "used"
assert(mem.used == 6315988, 'mem used, got ' .. tostring(mem.used))

-- millidegrees: hwmon temp*_input is millidegrees C, rounded to whole degrees.
assert(data.millidegrees(read('test/fixtures/temp1_input')) == 53,
  'temp, got ' .. tostring(data.millidegrees(read('test/fixtures/temp1_input'))))
-- A missing or unreadable sensor must yield nil, not an error and not 0:
-- 0 degrees is a plausible reading and would be indistinguishable from failure.
assert(data.millidegrees(nil) == nil, 'nil input must give nil')
assert(data.millidegrees('') == nil, 'empty input must give nil')
assert(data.millidegrees('garbage') == nil, 'unparseable input must give nil')

-- CPU load is a delta between two samples, so the counter holds state.
-- The first call has no previous sample and must report nil, not a number:
-- any number it invented would be wrong, and 100% on startup looks like a
-- real spike.
local c = data.new_cpu_counter()
assert(c:sample(1000, 900) == nil, 'first sample must give nil')

-- Second sample: 100 more total jiffies, 50 of them idle, so 50% busy.
-- Held in a local first: calling sample() again inside the assert message would
-- advance the counter a third time.
local busy = c:sample(1100, 950)
assert(busy == 50.0, 'second sample, got ' .. tostring(busy))

-- A counter that did not advance means no elapsed time, not 0% load.
local c2 = data.new_cpu_counter()
c2:sample(1000, 900)
assert(c2:sample(1000, 900) == nil, 'zero delta must give nil, not a division by zero')

-- === Per-core CPU =========================================================
-- The aggregate line answers "how busy is the machine"; the equaliser needs
-- one entry per core. Both come from the same file, so they are parsed by the
-- same rules and differ only in which lines they read.
local percore = read('test/fixtures/proc_stat_percore')
local cores = data.per_cpu_times(percore)
assert(#cores == 4, 'one entry per cpuN line, got ' .. tostring(#cores))

-- cpu0: 6441+24+3225+1056496+1932+0+760 = 1068878 total, idle+iowait = 1058428
assert(cores[1].total == 1068878, 'core 0 total, got ' .. tostring(cores[1].total))
assert(cores[1].idle == 1058428, 'core 0 idle, got ' .. tostring(cores[1].idle))

-- Ordering matters: bar N must be core N, so the list follows the file.
assert(cores[2].total == 1066406, 'core 1 total, got ' .. tostring(cores[2].total))

-- The aggregate 'cpu ' line must NOT be counted as a core: it would draw a
-- bar for the aggregate line showing the average, which looks like a real core.
for i, c in ipairs(cores) do
  assert(c.total < 2000000, 'entry ' .. i .. ' looks like the aggregate line')
end

-- A counter per core is just another instance, which is why new_cpu_counter
-- holds its own previous sample rather than using a module-level one.
local c0 = data.new_cpu_counter()
assert(c0:sample(cores[1].total, cores[1].idle) == nil, 'first sample is nil')
local busy = c0:sample(cores[1].total + 100, cores[1].idle + 25)
assert(busy == 75.0, 'second sample 75%, got ' .. tostring(busy))

-- Malformed input yields an empty list, never an error: a raise here is a
-- blank dashboard.
assert(#data.per_cpu_times('') == 0, 'empty input gives an empty list')
assert(#data.per_cpu_times('garbage\nlines\n') == 0, 'garbage gives an empty list')
assert(#data.per_cpu_times(nil) == 0, 'nil gives an empty list')
-- A truncated line (fewer than the 5 fields the maths needs) is skipped
-- rather than producing a nonsense total.
assert(#data.per_cpu_times('cpu0 1 2\n') == 0, 'a short line is skipped')

-- === Cache files ==========================================================
-- df -P guarantees one line per mount with the mountpoint LAST, which is why
-- the parser takes the last field rather than the sixth: an NFS device is
-- 'server:/export' and a device name can carry surprises, but the mountpoint
-- is always at the end.
local fs = data.df_parse(read('test/fixtures/df_output'))
assert(#fs == 6, 'one entry per mount, got ' .. tostring(#fs))
assert(fs[1].mount == '/', 'first mount, got ' .. tostring(fs[1].mount))
assert(fs[1].pct == 84, 'root percent, got ' .. tostring(fs[1].pct))
assert(fs[1].size == 263174213632, 'root size in bytes, got ' .. tostring(fs[1].size))
assert(fs[1].used == 208111570944, 'root used, got ' .. tostring(fs[1].used))

-- The NFS rows are the reason the last field matters: a colon in the device
-- would break a parser splitting on punctuation.
assert(fs[4].mount == '/mnt/nfs/Library', 'nfs mount, got ' .. tostring(fs[4].mount))
assert(fs[4].pct == 79, 'nfs percent, got ' .. tostring(fs[4].pct))

-- The device field carries the server, which is what lets the disks card
-- label the two shares without an address hardcoded in the repo.
assert(fs[4].host == 'server-a', 'nfs host, got ' .. tostring(fs[4].host))
assert(fs[1].host == nil, 'a local device has no host, got ' .. tostring(fs[1].host))

-- A full filesystem is a real state, not an error.
assert(fs[6].pct == 100, 'a full mount reads 100, got ' .. tostring(fs[6].pct))

-- The header line must not become a row.
for _, e in ipairs(fs) do
  assert(e.mount ~= 'on' and e.mount ~= 'Mounted', 'the header leaked in as a row')
end

assert(#data.df_parse('') == 0, 'empty input gives an empty list')
assert(#data.df_parse(nil) == 0, 'nil gives an empty list')
-- A header with no rows is what a failed df produces.
assert(#data.df_parse('Filesystem 1-blocks Used Available Capacity Mounted on\n') == 0,
  'a header alone gives an empty list')

-- du -sh: total first, then children largest-first. The suffixes must be
-- converted, not string-sorted: '1.1G' outranks '245M' numerically and loses
-- to it alphabetically.
local cache = data.du_parse(read('test/fixtures/du_output'))
assert(cache.total, 'a total is parsed')
assert(cache.total.label == '1.6G', 'total label, got ' .. tostring(cache.total.label))
assert(#cache.items == 4, 'four children, got ' .. tostring(#cache.items))
assert(cache.items[1].name == 'mozilla', 'largest child, got ' .. tostring(cache.items[1].name))
assert(cache.items[1].label == '1.1G', 'its label, got ' .. tostring(cache.items[1].label))
assert(cache.items[2].name == 'pip', 'second child, got ' .. tostring(cache.items[2].name))

-- Sizes are compared as bytes, which is what makes 'biggest' meaningful.
assert(cache.items[1].bytes > cache.items[2].bytes, '1.1G must outrank 245M')
assert(cache.items[1].bytes > 1e9, 'a G suffix is about a billion bytes')
assert(cache.items[4].bytes < 1e8, 'an M suffix is far smaller')

-- Share of the total drives the highlight colour.
assert(cache.items[1].bytes / cache.total.bytes > 0.5,
  'mozilla dominates this fixture, which is what the highlight keys on')

assert(data.du_parse('') == nil, 'empty input gives nil')
assert(data.du_parse(nil) == nil, 'nil gives nil')

-- === card.truncate ========================================================
-- Truncation is by CHARACTER, not byte: slicing a UTF-8 string mid-sequence
-- emits an invalid byte that Cairo draws as a replacement box, and the name
-- that needed shortening is exactly the kind that carries accents.
local card = require 'lib.card'

assert(card.truncate('short', 10) == 'short', 'a short string is unchanged')
assert(card.truncate('exactlyten', 10) == 'exactlyten', 'a string at the limit is unchanged')
assert(card.truncate('abcdefghijkl', 10) == 'abcdefghi\u{2026}',
  'a long string is cut to limit-1 plus an ellipsis, got ' .. tostring(card.truncate('abcdefghijkl', 10)))

-- The multi-byte case: ten accented characters are 20 bytes, so a byte-based
-- slice would cut one in half and produce invalid UTF-8.
local accented = string.rep('\u{00E9}', 12)
local cut = card.truncate(accented, 10)
assert(cut == string.rep('\u{00E9}', 9) .. '\u{2026}',
  'accented input must cut on a character boundary, got ' .. tostring(cut))

assert(card.truncate(nil, 10) == '', 'nil truncates to empty')
assert(card.truncate('abc', 0) == '', 'a zero limit gives empty')

-- === Network rate =========================================================
-- Interface byte counters are cumulative, so a rate is a delta over elapsed
-- time. The first call has no previous sample and must report nil: any number
-- it invented would be wrong, and a spike at startup looks real.
local r = data.new_rate_counter()
assert(r:sample(1000, 100) == nil, 'first sample must give nil')

-- 2048 bytes over 2 seconds is 1024 B/s.
local rate = r:sample(3048, 102)
assert(rate == 1024, 'second sample, got ' .. tostring(rate))

-- A counter that went backwards means a wrap or an interface reset. It must
-- clamp to zero, never produce the huge positive an unsigned wrap implies:
-- one bogus sample poisons the shared autoscale for the whole window.
local r2 = data.new_rate_counter()
r2:sample(5000, 100)
assert(r2:sample(10, 102) == 0, 'a counter reset must give 0')

-- Two samples inside the same clock second. os.time() has whole-second
-- resolution against a 2s draw interval, so this happens in normal operation
-- and must not divide by zero.
local r3 = data.new_rate_counter()
r3:sample(1000, 500)
local same_second = r3:sample(2000, 500)
assert(same_second ~= nil and same_second >= 0,
  'a zero time delta must fall back to the draw interval, got ' .. tostring(same_second))

-- A missing interface reads nil, which must propagate rather than raise.
local r4 = data.new_rate_counter()
assert(r4:sample(nil, 100) == nil, 'a nil byte count gives nil')

-- The fallback interval is a constructor argument, and the widget passes
-- conky's real update_interval rather than letting it default. That is the
-- path that ships, so it is the path that gets a test: at a 5s interval the
-- same 2048 bytes must read as 409.6 B/s, not 1024.
local r5 = data.new_rate_counter(5)
r5:sample(1000, 100)
assert(r5:sample(3048, 100) == 2048 / 5,
  'an explicit fallback interval must be used, got ' .. tostring(r5:sample(3048, 100)))

print('test_data: all assertions passed')