summaryrefslogtreecommitdiff
path: root/_selfcheck.lua
blob: ed9245801d85048d7af258e339a4b2a5dcbb3678 (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
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
local here = (arg and arg[0] or "_selfcheck.lua"):match("^(.*)/") or "."
package.path = here .. "/?.lua;" .. here .. "/?/init.lua;" .. package.path

local config = require("rsvp.config")
local module = require("rsvp.player")
local Player = module.Player

local defaults = assert(config.read())
assert(defaults.wpm == 250 and defaults.wpm_step == 25 and defaults.min_wpm == 50)
assert(config.read({ wpm = 25 }).wpm == 50, "configured speed respects lower bound")
assert(config.read({ typo = 1 }) == nil, "unknown settings are rejected")
for _, bad in ipairs({ 0, -1, 0 / 0, math.huge, "fast" }) do
    local settings, err = config.read({ wpm = bad })
    assert(settings == nil and err:find("rsvp.wpm"), "invalid WPM rejected")
end

local function plain(text)
    return (text or ""):gsub("\27%[[0-9;:]*m", ""):gsub("\27%]8;;.-\27\\", "")
end

local function char_width(char)
    local cp = utf8.codepoint(char)
    if (cp >= 0x0300 and cp <= 0x036f) or cp == 0x200d
        or (cp >= 0xfe00 and cp <= 0xfe0f) then return 0 end
    if cp >= 0x1100 and (cp <= 0x115f or cp == 0x2329 or cp == 0x232a
        or (cp >= 0x2e80 and cp <= 0xa4cf) or (cp >= 0xac00 and cp <= 0xd7a3)
        or (cp >= 0xf900 and cp <= 0xfaff) or (cp >= 0xfe10 and cp <= 0xfe6f)
        or (cp >= 0xff00 and cp <= 0xff60) or (cp >= 0x1f300 and cp <= 0x1faff)) then return 2 end
    return 1
end

local function display_width(text)
    local stripped, width = plain(text), 0
    for _, cp in utf8.codes(stripped) do width = width + char_width(utf8.char(cp)) end
    return width
end

local function truncate_display(text, max_width)
    local output, width, i = {}, 0, 1
    while i <= #text do
        if text:sub(i, i) == "\27" then
            local sequence = text:match("^(\27%[[0-9;:]*m)", i) or text:match("^(\27%]8;;.-\27\\)", i)
            if sequence then output[#output + 1] = sequence; i = i + #sequence
            else output[#output + 1] = text:sub(i, i); i = i + 1 end
        else
            local cp = utf8.codepoint(text, i)
            local char = utf8.char(cp)
            local char_width = char_width(char)
            if width + char_width > max_width then break end
            output[#output + 1] = char
            width, i = width + char_width, i + #char
        end
    end
    output[#output + 1] = "\27]8;;\27\\\27[0m"
    return table.concat(output)
end

local function host(options)
    options = options or {}
    local h = { timers = {}, modes = {}, mounts = {}, highlighted = {}, styles = { welcome = "a", tool_header = "b" } }
    function h.new_timer()
        local timer = { active = false, closing = false, close_count = 0 }
        function timer:start(timeout, repeat_ms, callback)
            assert(repeat_ms == 0, "word timers must be one-shot")
            self.timeout, self.callback, self.active = timeout, callback, true
        end
        function timer:stop() self.active = false end
        function timer:is_active() return self.active end
        function timer:is_closing() return self.closing end
        function timer:close()
            if not self.closing then self.closing, self.active, self.close_count = true, false, self.close_count + 1 end
        end
        h.timers[#h.timers + 1] = timer
        return timer
    end
    function h.input_mode(spec)
        local mode = { spec = spec, closed = false }
        function mode:close()
            if self.closed then return end
            self.closed = true
            self.spec.on_exit("closed")
        end
        function mode:exit(reason, message)
            if self.closed then return end
            self.closed = true
            self.spec.on_exit(reason, message)
        end
        h.modes[#h.modes + 1] = mode
        return mode
    end
    function h.mount(component, placement)
        assert(placement == "above_input")
        local mount = { component = component, invalidations = 0, unmounts = 0 }
        function mount:invalidate() self.invalidations = self.invalidations + 1 end
        function mount:unmount() self.unmounts = self.unmounts + 1 end
        h.mounts[#h.mounts + 1] = mount
        return mount
    end
    h.options = options
    h.player = Player.new {
        settings = options.settings or { wpm = 250, wpm_step = 25, min_wpm = 50 },
        save_wpm = options.save_wpm or function() return true end,
        persistence_error = options.persistence_error,
        highlight = function(body)
            h.highlighted[#h.highlighted + 1] = body
            if options.highlight then return options.highlight(body) end
            return body
        end,
        width = display_width,
        truncate = truncate_display,
        style = function() return h.styles end,
        new_timer = h.new_timer,
        mount = h.mount,
        input_mode = h.input_mode,
    }
    return h
end

local function key(h, chord)
    for _, binding in ipairs(h.modes[#h.modes].spec.keys) do
        if binding.key == chord then binding.run(h.modes[#h.modes]); return end
    end
    error("missing mode key " .. chord)
end

local function fire(timer)
    assert(timer and timer.callback, "expected a scheduled timer")
    timer.active = false
    timer.callback()
end

local function finish_paragraph(player)
    while player.playback do fire(player.timer) end
end

-- A new block, even an empty one, replaces the prior source. Delta updates
-- retain generation/cursor; only newline-terminated segments are streamable.
do
    local h, p = host(), nil
    p = h.player
    p:set_source("older text\n", true, 4)
    local first_generation = p.generation
    assert(p:activate())
    local idle = p:render(20)
    assert(not p.playback and p.timer == nil and idle[1]:sub(11, 11) == "|" and idle[3] == idle[1],
        "activation stays idle with stationary centered focus markers")
    assert(plain(p:render(80)[4]):find("Ready · Ctrl+S to start", 1, true)
        and h.modes[1].spec.keys[1].desc == "start/next", "idle prompt and control description explain playback")
    key(h, "ctrl+s")
    local stale = p.timer
    assert(plain(p.current_word) == "older")

    p:set_source("", false, 0)
    assert(p.generation == first_generation + 1 and p.current_word == nil)
    assert(not p:update_source("stale block", true, 4) and p.source == "", "older block updates are rejected")
    assert(stale.closing, "source replacement closes the old timer")
    stale.callback()
    assert(p.current_word == nil and p.playback == nil, "stale generation cannot paint")
    assert(not p:next(), "empty streaming block has no eligible segment")

    local generation = p.generation
    p:update_source("first", false, 0)
    assert(p.generation == generation and not p:next(), "unterminated segment waits")
    p:update_source("first\nsecond", false, 0)
    assert(p:next() and plain(p.current_word) == "first", "newline makes the first segment eligible")
    local word_timer = p.timer
    key(h, "ctrl+s")
    assert(plain(p.current_word) == "first" and p.timer == word_timer, "Ctrl+S cannot skip during playback")
    p:update_source("first\nsecond\n", true, 0)
    finish_paragraph(p)
    assert(p.cursor == 2, "paragraph read advances only after its final interval")
    assert(p:next() and plain(p.current_word) == "second", "completed block exposes its final segment")
    finish_paragraph(p)
    assert(p.status == "Nothing more to read")
    p:cancel("session_end")
end

-- Paragraph progress skips blank source segments but an interrupted active
-- paragraph remains the next unread segment.
do
    local h = host()
    local p = h.player
    p:set_source("\n\nrestart me\n", true, 2)
    assert(p:activate())
    key(h, "ctrl+s")
    assert(plain(p.current_word) == "restart")
    assert(p.cursor == 3, "cursor retains the original source segment index")
    p:cancel("closed")
    assert(p.cursor == 3, "cancel does not mark the active paragraph read")
    assert(p:activate())
    key(h, "ctrl+s")
    assert(plain(p.current_word) == "restart", "next activation restarts unfinished paragraph")
    p:cancel("closed")
end

-- Matched backtick/tilde fences are omitted line-for-line; mismatched closers
-- do not end a fence, and an unclosed fence suppresses the remainder.
do
    local source = "read\n```lua\nprint(1)\n~~~\nstill code\n```\nafter\n~~~mermaid\ngraph TD\n~~~\nlast"
    local filtered = module.filter_source(source)
    assert(filtered == "read\n\n\n\n\n\nafter\n\n\n\nlast", "matched fences preserve source lines")
    local function newlines(s) local _, n = s:gsub("\n", ""); return n end
    assert(newlines(filtered) == newlines(source), "fence omission retains newline boundaries")
    local unclosed = module.filter_source("spoken\n~~~mermaid\nsecret\nstill secret\n")
    assert(unclosed == "spoken\n\n\n\n", "unclosed fence suppresses to end")

    local h, p = host(), nil
    p = h.player
    p:set_source(source, true, 1)
    assert(p:activate())
    key(h, "ctrl+s")
    assert(plain(p.current_word) == "read")
    assert(h.highlighted[1] == filtered, "highlight receives the complete filtered body")
    finish_paragraph(p)
    assert(p:next() and plain(p.current_word) == "after")
    finish_paragraph(p)
    assert(p:next() and plain(p.current_word) == "last")
    finish_paragraph(p)
    assert(p.status == "Nothing more to read")
    p:cancel("closed")
end

-- ANSI spans survive word splitting and source-line splitting. Theme changes
-- re-highlight the active snapshot without moving its word cursor.
do
    local esc = "\27"
    local h = host {
        highlight = function(body)
            local lines = #body > 0 and (select(2, body:gsub("\n", "")) + 1) or 1
            if lines == 1 then return esc .. "[1mone two" .. esc .. "[0m" end
            return esc .. "[1mone two" .. esc .. "[0m\n" .. esc .. "[1mthree four" .. esc .. "[0m"
        end,
    }
    local p = h.player
    p:set_source("one two\nthree four", true, 1)
    assert(p:activate())
    key(h, "ctrl+s")
    assert(p.current_word:find(esc .. "%[1m"))
    local first_timer = p.timer
    fire(first_timer)
    assert(p.current_word:find("two") and p.current_word:find(esc .. "%[1m"), "style replays at the next word")
    local before_cursor, before_word, before_highlights = p.cursor, p.playback.word_index, #h.highlighted
    h.styles.welcome = "changed"
    p.component:render(40)
    assert(#h.highlighted > before_highlights, "theme change rebuilds Markdown styling")
    assert(p.cursor == before_cursor and p.playback.word_index == before_word, "theme refresh preserves progress")
    fire(p.timer)
    assert(p:next() and p.current_word:find("three") and p.current_word:find(esc .. "%[1m"), "style continues on the next source line")
    p:cancel("closed")
end

-- Pause/resume gets a fresh interval; speed changes affect the next interval,
-- not the currently displayed word. The final interval commits read progress.
do
    local h = host { settings = { wpm = 100, wpm_step = 25, min_wpm = 50 } }
    local p = h.player
    p:set_source("one two", true, 1)
    assert(p:activate())
    assert(p.timer == nil and p.current_word == nil, "activation does not schedule playback")
    key(h, "ctrl+s")
    local first_timer = p.timer
    assert(first_timer.timeout == 600)
    key(h, "ctrl+s")
    assert(p.timer == first_timer and plain(p.current_word) == "one", "Ctrl+S is ignored during playback")

    key(h, "space")
    assert(p.paused and first_timer.closing and plain(p.current_word) == "one")
    key(h, "-")
    assert(p.wpm == 75 and p.paused and p.timer == nil, "speed change stays paused")
    key(h, "space")
    local resumed = p.timer
    assert(resumed.timeout == 800 and plain(p.current_word) == "one", "resume gives the current word a fresh interval")
    key(h, "+")
    assert(p.wpm == 100 and p.timer == resumed, "speed does not restart the current interval")
    assert(p.cursor == 1, "not read before the final word interval")
    fire(resumed)
    assert(plain(p.current_word) == "two" and p.timer.timeout == 600 and p.cursor == 1)
    local final = p.timer
    fire(final)
    assert(p.cursor == 2 and p.status == "Nothing more to read", "final interval marks the paragraph read")
    for _ = 1, 10 do p:adjust(-1) end
    assert(p.wpm == 50, "speed adjustments stop at the configured lower bound")
    p:cancel("closed")
end

-- Markdown failure is visible and never falls back to speaking raw source.
do
    local h = host { highlight = function() error("renderer unavailable") end }
    local p = h.player
    p:set_source("**not raw playback**", true, 1)
    assert(p:activate())
    key(h, "ctrl+s")
    assert(p.playback == nil and p.current_word == nil)
    key(h, "ctrl+s")
    assert(p.playback == nil and p.current_word == nil)
    assert(p.status:find("Markdown rendering failed", 1, true))
    p:cancel("closed")
end

-- Suspension stops playback and requires the same explicit Space resume.
do
    local h = host()
    local p = h.player
    p:set_source("stay visible\n", true, 1)
    assert(p:activate())
    key(h, "ctrl+s")
    local timer, word = p.timer, p.current_word
    p:suspend()
    assert(p.paused and timer.closing and p.current_word == word)
    key(h, "space")
    assert(not p.paused and p.timer ~= timer and p.current_word == word)
    p:cancel("closed")
end

-- Every host exit/cancel path closes stale timers and unmounts exactly once.
for _, exit in ipairs({ "closed", "turn", "session", "error", "teardown", "session_end" }) do
    local h = host()
    local p = h.player
    p:set_source("unfinished paragraph\n", true, 1)
    assert(p:activate())
    key(h, "ctrl+s")
    local stale, mode, mount = p.timer, h.modes[1], h.mounts[1]
    local paints = mount.invalidations
    if exit == "closed" then
        key(h, "escape")
    elseif exit == "session_end" then
        p:cancel(exit)
    else
        mode:exit(exit, exit == "error" and "test error" or nil)
    end
    assert(stale.closing and p.timer == nil and p.mount == nil, exit .. " closes timer and mount")
    stale.callback()
    assert(p.playback == nil and mount.invalidations == paints, exit .. " stale callback is inert")
    assert(mount.unmounts == 1, exit .. " unmounts exactly once")
end

-- Focus buckets count visible non-punctuation characters, not ANSI bytes, and
-- alignment uses display columns including leading punctuation and Unicode width.
do
    local h = host()
    local p = h.player
    p:activate()
    local cases = {
        { "x", 1 }, { "ab", 2 }, { "abcde", 2 }, { "abcdef", 3 },
        { "abcdefghi", 3 }, { "abcdefghij", 4 }, { "abcdefghijklm", 4 },
        { "abcdefghijklmn", 5 }, { "well-known,", 3 },
        { "'books,'", 3 }, { "“books,”", 3 }, { "!!!", 1 },
        { "\27[1mabcdef\27[0m", 3 }, { "e\204\129abcd", 3 },
        { "界界界abc", 3 }, { "「界界界。」", 3 },
    }
    for _, case in ipairs(cases) do
        p.current_word = case[1]
        local rows = p:render(40)
        assert(rows[1]:sub(21, 21) == "|" and rows[3] == rows[1], "focus markers stay centered and stationary")
        local visible = plain(rows[2])
        local lead = #(visible:match("^( *)") or "")
        local word = visible:sub(lead + 1)
        local chars = {}
        for _, cp in utf8.codes(word) do chars[#chars + 1] = utf8.char(cp) end
        local before = 0
        for i = 1, case[2] - 1 do before = before + char_width(chars[i]) end
        assert(lead + before + 1 == 21, "focus column correct for " .. case[1])
        if case[1]:find("\27", 1, true) then assert(rows[2]:find("\27[1m", 1, true), "ANSI styling survives alignment") end
    end

    p.current_word = "\27[1mabcdefghijklmnopqrstuv\27[0m"
    local narrow = p:render(8)
    assert(narrow[1] == "    |" and narrow[3] == narrow[1], "narrow view bounds centered marker")
    for _, row in ipairs(narrow) do assert(display_width(row) <= 8, "rendered row stays within viewport") end
    assert(narrow[2]:find("\27[1m", 1, true), "narrow clipping preserves ANSI styling")

    p.current_word = "界界界界界界"
    narrow = p:render(3)
    assert(narrow[1] == " |" and narrow[3] == narrow[1], "marker clamps to the viewport center")
    for _, row in ipairs(narrow) do assert(display_width(row) <= 3, "wide glyph clipping stays bounded") end
    for _, width in ipairs({ 0, 1, 2, 3, 8, 40 }) do
        for _, row in ipairs(p:render(width)) do
            assert(display_width(row) <= width, "all rows fit even a zero-width viewport")
        end
    end
    p:cancel("closed")
end

-- Speed changes persist only when the value really changes; failures keep the
-- in-memory speed and remain visible without interrupting RSVP.
do
    local writes = {}
    local h = host { settings = { wpm = 100, wpm_step = 25, min_wpm = 50 },
        save_wpm = function(value) writes[#writes + 1] = value; return nil, "disk unavailable" end }
    local p = h.player
    assert(p:adjust(-1) == 75 and writes[1] == 75, "changed speed is persisted")
    assert(p.persistence_error:find("disk unavailable", 1, true), "save failure is retained")
    assert(plain(p:render(80)[5]):find("WPM save failed", 1, true), "save failure is surfaced in the view")
    p:adjust(1)
    assert(p.wpm == 100 and p.persistence_error, "failed save does not roll back speed")
    local count = #writes
    for _ = 1, 10 do p:adjust(-1) end
    assert(p.wpm == 50 and #writes == count + 2, "clamped no-op adjustments are not persisted")
    p.save_wpm = function() return true end
    p:adjust(1)
    assert(p.persistence_error == nil, "successful save clears the previous warning")
end

-- Init reads/writes only through ext.state and opens in idle mode. Invalid saved
-- values use configured WPM; storage errors stay visible but do not block use.
do
    local function launch(saved, read_error, write_error)
        local h, events, persisted = host(), {}, saved
        local command
        local ext = {
            config = { rsvp = { wpm = 100, wpm_step = 25, min_wpm = 50 } },
            state = {
                get = function(key)
                    assert(key == "rsvp.wpm")
                    return persisted, read_error
                end,
                set = function(key, value)
                    assert(key == "rsvp.wpm")
                    if write_error then return nil, write_error end
                    persisted = value
                    return true
                end,
            },
            on = function(name, callback) events[name] = callback end,
            highlight = function(body) return body end,
            style = function() return h.styles end,
            mount = h.mount,
            input_mode = h.input_mode,
            register_command = function(spec) command = spec end,
        }
        package.loaded.panto = { ext = ext, text = { width = display_width, truncate = truncate_display } }
        package.loaded.luv = { new_timer = function() return h.new_timer() end }
        local extension = dofile(here .. "/init.lua")
        extension.activate()
        assert(command and command.name == "rsvp")
        command.handler("")
        return h, events, function() return persisted end
    end

    local h, events, persisted = launch(30)
    local idle = h.mounts[1].component:render(40)
    assert(h.timers[1] == nil and idle[1]:sub(21, 21) == "|" and idle[3] == idle[1],
        "extension activation is visible but schedules no playback")
    assert(plain(idle[4]):find("50 WPM", 1, true), "saved speed clamps to configured minimum")
    events.render_assistant_text({ text = "first paragraph\nsecond", index = 1 })
    key(h, "ctrl+s")
    assert(h.timers[1] and plain(h.mounts[1].component:render(40)[2]):find("first", 1, true),
        "first Ctrl+S starts the first paragraph")
    key(h, "+")
    assert(persisted() == 75, "extension saves WPM through ext.state.set")
    key(h, "escape")

    h = launch(175)
    assert(plain(h.mounts[1].component:render(40)[4]):find("175 WPM", 1, true),
        "finite positive saved WPM overrides configured fallback")
    key(h, "escape")

    for _, invalid in ipairs({ 0, -1, 0 / 0, math.huge, "fast" }) do
        h = launch(invalid)
        local view = h.mounts[1].component:render(40)
        assert(plain(view[4]):find("100 WPM", 1, true), "invalid saved WPM uses configured fallback")
        key(h, "escape")
    end

    h = launch(nil, "read denied")
    local warning = h.mounts[1].component:render(60)
    assert(plain(warning[4]):find("100 WPM", 1, true) and plain(warning[5]):find("read denied", 1, true),
        "read failure falls back and is displayed")
    key(h, "+")
    assert(#h.mounts[1].component:render(60) == 4, "successful save clears the resolved restore warning")
    key(h, "escape")

    h = launch(nil, nil, "write denied")
    key(h, "+")
    assert(plain(h.mounts[1].component:render(60)[5]):find("write denied", 1, true),
        "extension displays ext.state.set failures")
    key(h, "escape")
end

print("panto-rsvp self-check passed")