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