-- 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 -- === 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 ============================================================= -- Samplers write 'key value' lines; only the FIRST space is the separator. local kv = data.kv_parse('title Long break\nremain 125\n') assert(kv.title == 'Long break', 'value keeps its spaces, got ' .. tostring(kv.title)) assert(kv.remain == '125', 'remain, got ' .. tostring(kv.remain)) -- 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') -- upcoming: the table's week out of a month-long cache, across a month end. local up = data.calendar_upcoming({ { y = 2026, m = 10, d = 18 }, { y = 2026, m = 10, d = 25 }, { y = 2026, m = 10, d = 26 }, { y = 2026, m = 10, d = 29 } }, { 2026, 10, 18 }, 8) assert(#up == 2 and up[2].d == 25, 'eight days from the 18th end on the 25th') assert(#data.calendar_upcoming({ { y = 2026, m = 11, d = 2 } }, { 2026, 10, 28 }, 8) == 1, 'week crosses into next month') -- 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') -- 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') -- 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