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")
|