summaryrefslogtreecommitdiff
path: root/_selfcheck.lua
diff options
context:
space:
mode:
Diffstat (limited to '_selfcheck.lua')
-rw-r--r--_selfcheck.lua280
1 files changed, 280 insertions, 0 deletions
diff --git a/_selfcheck.lua b/_selfcheck.lua
new file mode 100644
index 0000000..bbcf380
--- /dev/null
+++ b/_selfcheck.lua
@@ -0,0 +1,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")