summaryrefslogtreecommitdiff
path: root/_selfcheck.lua
blob: bbcf3800f058bca582bc147592ad426896bcc3f4 (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
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 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 },
        highlight = function(body)
            h.highlighted[#h.highlighted + 1] = body
            if options.highlight then return options.highlight(body) end
            return body
        end,
        width = function(text)
            text = text:gsub("\27%[[0-9;]*m", ""):gsub("\27%]8;;.-\27\\", "")
            return #text
        end,
        truncate = function(text, width) return text:sub(1, width) end,
        style = function() return h.styles end,
        new_timer = h.new_timer,
        mount = h.mount,
        input_mode = h.input_mode,
    }
    return h
end

local function plain(text)
    return (text or ""):gsub("\27%[[0-9;:]*m", "")
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 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() and 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() and 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() and 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() and 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())
    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())
    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())
    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())
    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

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