diff options
| author | t <t@tjp.lol> | 2026-10-27 20:41:49 -0600 |
|---|---|---|
| committer | t <t@tjp.lol> | 2026-10-27 21:11:55 -0600 |
| commit | ff6d9a4c30ec4218c559960741745a4b190fb063 (patch) | |
| tree | 40cafda55d349d5ab3020dc36f07626bb0c4f4be /_selfcheck.lua | |
| parent | 009d5705a59bcf86f3579a78e25c8b3fcd4cd476 (diff) | |
Diffstat (limited to '_selfcheck.lua')
| -rw-r--r-- | _selfcheck.lua | 226 |
1 files changed, 213 insertions, 13 deletions
diff --git a/_selfcheck.lua b/_selfcheck.lua index bbcf380..ed92458 100644 --- a/_selfcheck.lua +++ b/_selfcheck.lua @@ -14,6 +14,47 @@ for _, bad in ipairs({ 0, -1, 0 / 0, math.huge, "fast" }) do 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" } } @@ -58,16 +99,15 @@ local function host(options) 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 = 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, + width = display_width, + truncate = truncate_display, style = function() return h.styles end, new_timer = h.new_timer, mount = h.mount, @@ -76,10 +116,6 @@ local function host(options) 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 @@ -105,6 +141,12 @@ do 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") @@ -139,11 +181,15 @@ 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: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() and plain(p.current_word) == "restart", "next activation restarts unfinished paragraph") + assert(p:activate()) + key(h, "ctrl+s") + assert(plain(p.current_word) == "restart", "next activation restarts unfinished paragraph") p:cancel("closed") end @@ -161,7 +207,9 @@ do local h, p = host(), nil p = h.player p:set_source(source, true, 1) - assert(p:activate() and plain(p.current_word) == "read") + 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") @@ -185,7 +233,9 @@ do } local p = h.player p:set_source("one two\nthree four", true, 1) - assert(p:activate() and p.current_word:find(esc .. "%[1m")) + 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") @@ -206,6 +256,8 @@ do 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") @@ -237,6 +289,9 @@ do 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") @@ -248,6 +303,7 @@ do 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) @@ -262,6 +318,7 @@ for _, exit in ipairs({ "closed", "turn", "session", "error", "teardown", "sessi 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 @@ -277,4 +334,147 @@ for _, exit in ipairs({ "closed", "turn", "session", "error", "teardown", "sessi 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") |
