-- 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')