-- 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 -- === Cache files ========================================================== -- 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') -- === kv_parse ============================================================= -- The slackware sampler writes 'key value' lines. The format is deliberately -- dumber than the data: epochs stay integers rather than becoming formatted -- dates, so the widget decides how to render an age and the shell never -- reconstructs a date string. local kv = data.kv_parse(read('test/fixtures/slackware_cache')) assert(kv.version == 'Slackware 15.0+', 'version, got ' .. tostring(kv.version)) -- The value keeps its internal spaces: only the FIRST space is the separator. assert(kv.packages == '1234', 'packages, got ' .. tostring(kv.packages)) assert(kv.changelog == '1758000000', 'changelog, got ' .. tostring(kv.changelog)) assert(kv.kernel == '6.12.1', 'kernel, got ' .. tostring(kv.kernel)) -- A key the sampler did not write reads nil, which is how the widget tells -- "the sampler ran but this fact was unavailable" from "no sampler". assert(kv.nonexistent == nil, 'a missing key is nil') -- Malformed input must not raise: a Lua error in conky is a blank screen. assert(type(data.kv_parse('')) == 'table', 'empty input gives a table') assert(type(data.kv_parse(nil)) == 'table', 'nil gives a table') assert(data.kv_parse('keyonly\n').keyonly == nil, 'a line with no value is skipped') -- === Calendar ============================================================= -- The fixture uses generic event titles. This repository is public: a real -- appointment names a real person or a real appointment, and neither belongs -- in a committed file. local cal = data.calendar_parse(read('test/fixtures/calendar_cache')) assert(cal, 'calendar cache parses') assert(cal.today[1] == 2026 and cal.today[2] == 9 and cal.today[3] == 18, 'today parses') assert(cal.colors.personal == 'light green', 'calendar colour, got ' .. tostring(cal.colors.personal)) assert(#cal.events == 5, 'event count, got ' .. tostring(#cal.events)) -- A timed event keeps its times; an all-day one has neither, and the card -- shows 'all day' rather than an invented 00:00. assert(cal.events[1].start == '13:00' and cal.events[1].finish == '13:30', 'timed event keeps its times') assert(cal.events[2].start == nil and cal.events[2].finish == nil, 'all-day event has no times') -- A title containing a pipe survives: only the first four separators split. assert(cal.events[3].title == 'Team sync | notes', 'title keeps its pipe, got ' .. tostring(cal.events[3].title)) -- No 'generated' key means the sampler never ran, which is not the same as -- having run and found nothing. assert(data.calendar_parse('today 2026-09-18\n') == nil, 'no generated key: nil') assert(data.calendar_parse('') == nil, 'empty cache: nil') assert(data.calendar_parse(nil) == nil, 'nil cache: nil') local empty = data.calendar_parse('generated 1\ntoday 2026-09-18\n') assert(empty and #empty.events == 0, 'ran with no events: empty list, not nil') -- calendar_date: the year is the 4-digit group wherever it sits, and the -- day/month pair is resolved by magnitude. local function ymd(s) local t = data.calendar_date(s) return t and table.concat(t, '-') or nil end assert(ymd('18.09.2026') == '2026-9-18', 'day-first, got ' .. tostring(ymd('18.09.2026'))) assert(ymd('09/18/2026') == '2026-9-18', 'month-first resolved by magnitude') assert(ymd('2026-09-18') == '2026-9-18', 'ISO, year leading') -- Genuinely ambiguous: both below 13. Day-first, matching khal's defaults. assert(ymd('03.04.2026') == '2026-4-3', 'ambiguous reads day-first') assert(ymd('18.09.26') == nil, 'two-digit year is refused, not guessed') assert(ymd('nonsense') == nil, 'unparseable date: nil') assert(ymd('18.13.2026') == nil, 'impossible month: nil') -- month_days: first calendar of a day wins, and only the asked-for month. local days = data.calendar_month_days(cal.events, 2026, 9) assert(days[18] == 'personal', 'first event of the day owns it, got ' .. tostring(days[18])) assert(days[24] == 'work' and days[26] == 'birthdays', 'later days marked') assert(days[2] == nil, 'October event does not mark September') -- month_shape: September 2026 has 30 days and starts on a Tuesday. -- Monday = 1, so Tuesday = 2. local ndays, first = data.month_shape(2026, 9) assert(ndays == 30 and first == 2, 'september 2026, got ' .. ndays .. '/' .. first) -- February in a leap year, and a December that must roll into January. assert((data.month_shape(2024, 2)) == 29, 'leap february has 29 days') assert((data.month_shape(2026, 12)) == 31, 'december rolls over correctly') -- === breaktimer =========================================================== -- The phase-to-word and phase-to-colour decisions live in breaktimer_parse so -- they can be checked here; the card only draws the result. local bt = data.breaktimer_parse(read('test/fixtures/breaktimer_cache')) assert(bt, 'breaktimer cache parses') assert(bt.countdown == '24:31', 'countdown, got ' .. tostring(bt.countdown)) assert(bt.phase_label == 'working', 'phase label, got ' .. tostring(bt.phase_label)) assert(bt.next == 'pausa', 'next during work is a break, got ' .. tostring(bt.next)) assert(bt.role == 'ok', 'working is ok, got ' .. tostring(bt.role)) -- Every phase has its own word and colour role. Build caches rather than files: -- the sampler's output is six plain lines and the parse is the seam that -- matters. local function bt_cache(fields) local out = { 'generated 1', 'running ' .. (fields.running or 'yes'), 'state ' .. (fields.state or 'running'), 'phase ' .. (fields.phase or 'working'), 'remain ' .. (fields.remain or 60), 'frozen ' .. (fields.frozen or 'no') } return table.concat(out, '\n') .. '\n' end local b b = data.breaktimer_parse(bt_cache({ phase = 'breaking', remain = 125 })) assert(b.phase_label == 'micro-pausa' and b.role == 'heading' and b.next == 'lavoro', 'micro-pause: ' .. b.phase_label .. '/' .. b.role .. '/' .. b.next) assert(b.countdown == '2:05', 'micro-pause countdown, got ' .. b.countdown) b = data.breaktimer_parse(bt_cache({ phase = 'longbreak', remain = 600 })) assert(b.phase_label == 'pausa lunga' and b.role == 'highlight' and b.next == 'lavoro', 'long pause: ' .. b.phase_label .. '/' .. b.role) -- Paused freezes the number, but the phase underneath still decides what the -- countdown leads to. b = data.breaktimer_parse(bt_cache({ state = 'paused', phase = 'working' })) assert(b.phase_label == 'in pausa' and b.role == 'warning' and b.next == 'pausa', 'paused: ' .. b.phase_label .. '/' .. b.role .. '/' .. b.next) -- Frozen: the clock stopped while the phase stayed 'working'. Outside working -- hours, or a hung daemon; both read the same. b = data.breaktimer_parse(bt_cache({ frozen = 'yes' })) assert(b.phase_label == 'outside working hours' and b.role == 'label', 'frozen: ' .. b.phase_label .. '/' .. b.role) assert(b.next == 'pausa', 'frozen working still leads to a break') -- A dead daemon: the sampler's liveness check sets running no even when the -- state file was left saying running. b = data.breaktimer_parse(bt_cache({ running = 'no' })) assert(b.countdown == '--' and b.phase_label == 'fermo' and b.next == '--' and b.role == 'label', 'stopped reads as fermo, got ' .. b.phase_label) -- A negative remain must clamp, not print '-1:59'. b = data.breaktimer_parse(bt_cache({ remain = -5 })) assert(b.countdown == '0:00', 'negative remain clamps, got ' .. b.countdown) -- No 'generated' key means the sampler never ran, which is different from a -- stopped daemon (which still writes a cache). assert(data.breaktimer_parse('running no\n') == nil, 'no generated key: nil') assert(data.breaktimer_parse('') == nil, 'empty cache: nil') assert(data.breaktimer_parse(nil) == nil, 'nil cache: nil') -- immich_parse: one entry per photo line, place optional, garbage skipped. local im = data.immich_parse(read('test/fixtures/immich_index')) assert(im and #im == 3, 'immich photo count, got ' .. tostring(im and #im)) assert(im[1].year == 2014 and im[1].place == 'Example City, Italy', 'immich first photo') assert(table.concat(im[1].taken, ' ') == '2014 9 29 19 33', 'immich taken parses') assert(im.url == 'http://immich.example.org:2283', 'immich url parses') assert(im[2].place == nil, 'empty place: nil, so the card prints --') assert(im[3].taken == nil and im[3].place == '40.123, 9.456', 'missing date, coords place') assert(data.immich_parse('photo a|2014||\n') == nil, 'no generated key: nil') assert(data.immich_parse(nil) == nil, 'nil index: nil') local none = data.immich_parse('generated 1\n') assert(none and #none == 0, 'no memories today: empty list, not nil') assert(not im.week, 'no scope line: today') assert(data.immich_parse('generated 1\nscope week\n').week, 'scope week parses') print('test_data: all assertions passed') -- nvtable_parse: nvtable -C -E rows. The marker column is positional, so an -- empty installed or repo column ('--') must not shift it. local up = data.nvtable_parse(read('test/fixtures/updates_cache')) assert(up and #up == 6, 'updates rows, got ' .. tostring(up and #up)) local r = up[1] assert(r.repo == 'personal' and r.name == 'foo-bin' and r.installed == '1.14.9' and r.shipped == '1.14.9' and r.behind and r.bump and r.upstream == '1.14.10', 'updates row 1') assert(up[2].behind and not up[2].bump and up[2].upstream == '2.1.284', 'updates row 2') assert(up[3].upstream == nil and up[3].shipped == '3.16.31', 'updates row 3') assert(up[4].shipped == nil and up[4].repo == nil, 'updates row 4: -- is nil') assert(up[5].installed == nil and not up[5].behind, 'updates row 5: not installed') assert(not up[6].behind and not up[6].bump, 'updates row 6: current') assert(up.upgrade == 3 and up.bump == 1 and up.current == 46 and up.missing == 18, 'updates summary') assert(data.nvtable_parse('') == nil, 'updates: empty cache is nil') -- Emoji fall back to card.FONT_EMOJI: the toy API has no per-glyph fallback, -- so without the run split "x🦷" measured a tofu box in Noto Sans. do local s = cairo_image_surface_create(CAIRO_FORMAT_ARGB32, 10, 10) local cr = cairo_create(s) card.font(cr, 'Noto Emoji', 20, false) local tooth = card.advance(cr, '🦷') card.font(cr, card.FONT_UI, 20, false) local x = card.advance(cr, 'x') assert(math.abs(card.advance(cr, 'x🦷') - (x + tooth)) < 0.01, 'emoji run uses FONT_EMOJI') assert(card.advance(cr, 'x') == x, 'caller face restored after an emoji run') assert(pcall(card.advance, cr, '\240\159'), 'invalid UTF-8 must not raise') cairo_destroy(cr) cairo_surface_destroy(s) end