aboutsummaryrefslogtreecommitdiffstats
path: root/test/test_data.lua
blob: 37a302e29c92f7bbbef79b5eb7ba16ad54cc6f88 (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
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
-- 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