diff --git a/accessibility/docs/scratch.md b/accessibility/docs/scratch.md index b7f529c..070f5a5 100644 --- a/accessibility/docs/scratch.md +++ b/accessibility/docs/scratch.md @@ -150,6 +150,61 @@ the more urgent fact). Flip the flag in `walltones.lua` if it reads backwards. Respects `FoV`/`rememberedFoV` — never leaks unseen map geometry. +### Done — Menu narration (Phase 4, pulled forward) + testbed + +Pulled forward because menus were a **blocker**: Talon could hear the screen name +but not the selected option, so the game could not be started at all. + +- `accessibility/menus.lua` — descriptor driver. Supports both D3 backends + (`probe` via `debug.getupvalue`, `patched` via injected announce calls); the + per-screen `render` is shared. Includes the probe self-test. +- `accessibility/states/{main_menu,settings_menu,load_game,new_game,narration}.lua` +- `accessibility/testbed.lua` — drives the game by code. `lovec.exe . --a11y-test` + +**Verified end to end: main menu → new game → name entry → intro narration → +gameplay, 8/8 checks passing.** Console patch smoke-tested including the `(x2)` +fold and review keys. + +#### Three real bugs the testbed caught + +1. **`love.keypressed` was bound to the pre-wrapper function.** `states.setup()` + does `love[callback] = love[callback] or states[callback]`, capturing **by + value**, and it runs before us. `main.lua` defines `love.load/update/ + mousepressed/mousereleased/quit` but NOT `keypressed`/`textinput` — so those + were frozen to the original. Replacing the `states` table field alone did + nothing. **Fix: reassign `love.keypressed`/`love.textinput` after wrapping.** + `states.switch`/`states.update` were fine — looked up on the table at call time. + ⚠ Any future callback we wrap needs the same treatment. + +2. **Focus moves with no keypress.** `main_menu.mousemoved` assigns + `selectedOption` straight from cursor position, so wherever the mouse rests + silently overrides selection — including at startup. **Fix: poll focus in the + update hook** rather than announcing only on keypress. `announceScreen` now + speaks only the screen name and lets the poll add focus a frame later + (`justSwitched` makes that first one non-interrupting so it appends). + +3. **Probes may need chaining.** `narration.keypressed` doesn't touch + `displayData`/`currentPage`/`currentLoadedLine` — it only calls + `continueNarration()`, so they are upvalues of *that* function. The descriptor + reaches `continueNarration` as an upvalue of `keypressed`, then reads through + it. Expect this pattern in other states. + +#### Also learned +- `states.lua` loads states via `require("states/"..name)` — a **SLASH**. + `require()` caches on the exact string, so `require("states.main_menu")` would + load a *second independent copy* with its own upvalues. Always match the slash. +- **Plain movement is silent** — `actions/move.lua` only prints when a move fails + (bound, immobilized). Walking proves nothing about the console patch; drive + `console.print` directly to test it. + +### Known gaps (next up) +- **`ingame_dialogue` announces "Game", not "Conversation", and reads no dialogue + text.** It appears to be entered via a path that announces `ingame` first. + Needs a descriptor + investigation of how it's switched to. **This is the next + blocker for actually playing.** +- `ingame` itself has no descriptor — that's Phase 2 (tile examination). +- Wall tones still unverified by ear. + ### Next — Phase 2: tile examination Patch `views/levelCursorPopup.set` to narrate the game's existing examine mode, augmented with distance/direction/passability. Then the surroundings explorer diff --git a/accessibility/init.lua b/accessibility/init.lua index df89878..7cda114 100644 --- a/accessibility/init.lua +++ b/accessibility/init.lua @@ -17,6 +17,19 @@ local speech = require("accessibility.speech") local stateNames = require("accessibility.stateNames") local hotkeys = require("accessibility.hotkeys") local walltones = require("accessibility.walltones") +local menus = require("accessibility.menus") +local testbed = require("accessibility.testbed") + +local testbedActive = false + +-- States we ship focus descriptors for. Also the list the self-test walks. +local describedStates = { + "main_menu", + "settings_menu", + "load_game", + "new_game", + "narration", +} local initialized = false @@ -59,6 +72,16 @@ function accessibility.selfTest() for name, result in pairs(patchResults) do print("[accessibility] patch " .. name .. ": " .. result) end + + -- Menu focus probes. A renamed local in a future game version surfaces here at + -- startup rather than as silence mid-playthrough. + local ok, results = pcall(menus.selfTest, describedStates) + if ok then + for _, stateName in ipairs(describedStates) do + print("[accessibility] menu " .. stateName .. ": " .. tostring(results[stateName])) + end + end + print("[accessibility] --- end self test ---") end @@ -74,10 +97,19 @@ local function hookStates() states.switch = function(newState, params) local result = realSwitch(newState, params) - -- After the switch, so a state's open() has already set up its focus. + -- After the switch, so the state's open() has already set up its focus -- + -- main_menu, for one, only picks selectedOption inside open(). pcall(function() - local name = stateNames[newState] or newState - speech.say(name, true) + local descriptor = menus.loadDescriptor(newState) + + if descriptor then + menus.announceScreen(newState) + if descriptor.onOpen then + speech.say(descriptor.onOpen, false) + end + else + speech.say(stateNames[newState] or newState, true) + end end) return result @@ -88,8 +120,25 @@ local function hookStates() local realUpdate = states.update states.update = function(dt) local result = realUpdate(dt) + + -- Poll focus rather than relying on keypress alone. + -- + -- Focus can move with no key involved: main_menu.mousemoved assigns + -- selectedOption straight from the cursor position, so wherever the mouse + -- happens to rest silently overrides the selection -- including at startup, + -- before the player has touched anything. Polling also closes the race + -- where a state's open() sets focus after we have already announced. + -- + -- Cheap: two debug.getupvalue lookups, and only for states we describe. + pcall(function() + menus.announceFocusIfChanged(states.getCurrent()) + end) + pcall(walltones.update) pcall(speech.flush) + if testbedActive then + pcall(testbed.update, dt) + end return result end @@ -108,8 +157,43 @@ local function hookStates() end pcall(hotkeys.onGameKey) - return realKeypressed(key, scancode) + + local keyResult = realKeypressed(key, scancode) + + -- After the game handler, so we read the focus it just moved to. + pcall(function() + menus.announceFocusIfChanged(states.getCurrent()) + end) + + return keyResult end + + -- Text entry (character naming) arrives via textinput, not keypressed. + local realTextinput = states.textinput + states.textinput = function(text) + local result = realTextinput(text) + + pcall(function() + menus.announceFocusIfChanged(states.getCurrent()) + end) + + return result + end + + -- CRITICAL: re-point the love callbacks at our wrappers. + -- + -- states.setup() runs before us and does: + -- love[callback] = love[callback] or states[callback] + -- which captures the function BY VALUE. main.lua defines love.load, update, + -- mousepressed, mousereleased and quit itself, but NOT keypressed/textinput -- + -- so those were bound to the original states.keypressed at setup time. + -- Replacing the states table field alone leaves love.keypressed pointing at + -- the old function and our wrapper never runs. + -- + -- states.switch and states.update are unaffected because they are looked up on + -- the table at call time (from state files and from main.lua's love.update). + love.keypressed = states.keypressed + love.textinput = states.textinput end -------------------------------------------------------------------------------- @@ -137,6 +221,11 @@ function accessibility.initialize() end accessibility.selfTest() + + if testbed.isEnabled() then + testbedActive = true + testbed.start() + end end return accessibility diff --git a/accessibility/menus.lua b/accessibility/menus.lua new file mode 100644 index 0000000..3786670 --- /dev/null +++ b/accessibility/menus.lua @@ -0,0 +1,239 @@ +-- menus.lua — narrates focus in the game's menu screens. +-- +-- Most menu states keep their focus in file-local variables (`selectedOption`, +-- `optionsList`, ...) which are not reachable through the table the state returns. +-- They ARE reachable as upvalues of the state's own functions, via +-- debug.getupvalue. +-- +-- Per docs/05-decisions.md (D3) this driver supports two interchangeable +-- backends, so we can move between them without rewriting per-screen logic: +-- +-- mode "probe" -- read upvalues. Zero edits to game files. Depends on +-- internal variable names, so it is checked by the self-test. +-- mode "patched" -- game files call accessibility.announce() directly. Robust, +-- but must be re-applied on each game version. +-- +-- The `render` function in each descriptor -- which holds all the per-screen +-- knowledge -- is shared by both. Switching modes is a config flag. + +local speech = require("accessibility.speech") +local stateNames = require("accessibility.stateNames") + +local menus = {} + +menus.mode = "probe" + +-------------------------------------------------------------------------------- +-- Upvalue access +-------------------------------------------------------------------------------- + +local function getUpvalue(fn, name) + if type(fn) ~= "function" then + return nil + end + + local i = 1 + while true do + local n, v = debug.getupvalue(fn, i) + if not n then + return nil + end + if n == name then + return v, i + end + i = i + 1 + end +end + +menus.getUpvalue = getUpvalue + +-- IMPORTANT: states.lua loads states with require("states/"..name) -- a SLASH. +-- require() caches on the exact string, so require("states.main_menu") would load +-- a SECOND, independent copy of the file with its own upvalues, and we would be +-- reading focus from a state the player is not interacting with. Always match the +-- game's spelling. +local function getStateTable(stateName) + local ok, stateTable = pcall(require, "states/" .. stateName) + if not ok then + return nil + end + return stateTable +end + +menus.getStateTable = getStateTable + +-------------------------------------------------------------------------------- +-- Descriptors +-------------------------------------------------------------------------------- + +local descriptors = {} + +local function loadDescriptor(stateName) + if descriptors[stateName] ~= nil then + return descriptors[stateName] or nil + end + + local ok, descriptor = pcall(require, "accessibility.states." .. stateName) + descriptors[stateName] = ok and descriptor or false + return descriptors[stateName] or nil +end + +menus.loadDescriptor = loadDescriptor + +-------------------------------------------------------------------------------- +-- Reading focus +-------------------------------------------------------------------------------- + +-- Returns: text to speak, focus key (for change detection). +local function readFocus(stateName) + local descriptor = loadDescriptor(stateName) + if not descriptor then + return nil, nil + end + + local stateTable = getStateTable(stateName) + if not stateTable then + return nil, nil + end + + -- A descriptor may take full control, for screens that aren't a simple list + -- (text entry, tabbed pages). + if descriptor.read then + local ok, text, key = pcall(descriptor.read, stateTable, menus) + if ok then + return text, key + end + return nil, nil + end + + local probe = descriptor.probe + if not probe then + return nil, nil + end + + local probeFn = stateTable[probe.fn or "keypressed"] + local index = getUpvalue(probeFn, probe.focus) + local list = getUpvalue(probeFn, probe.list) + + if type(index) ~= "number" or type(list) ~= "table" then + return nil, nil + end + + local entry = list[index] + if entry == nil then + return nil, nil + end + + local total = #list + local ok, text = pcall(descriptor.render, entry, index, total) + if not ok or not text then + return nil, nil + end + + return text, tostring(index) .. "/" .. tostring(total) +end + +menus.readFocus = readFocus + +-------------------------------------------------------------------------------- +-- Announcing +-------------------------------------------------------------------------------- + +local lastKey +local lastState +local justSwitched = false + +-- Called after a state switch: announce the screen name only. +-- +-- Focus is deliberately left to the poll in the update hook rather than read +-- here. A state's open() may set focus after the switch returns, and +-- mousemoved can override it on the very first frame -- so reading now would +-- often announce the wrong entry. The poll picks it up a frame later, and +-- `justSwitched` makes that first announcement non-interrupting so it appends +-- to the screen name ("Main menu. Continue, 1 of 7") instead of cutting it off. +function menus.announceScreen(stateName) + lastState = stateName + lastKey = nil + justSwitched = true + + speech.say(stateNames[stateName] or stateName, true) +end + +-- Called after a keypress: announce the focused entry if it changed. +function menus.announceFocusIfChanged(stateName) + if stateName ~= lastState then + lastState = stateName + lastKey = nil + end + + local text, key = readFocus(stateName) + if not text then + return + end + + if key ~= nil and key == lastKey then + return + end + + lastKey = key + + -- First announcement after a switch appends to the screen name; later ones + -- interrupt, so holding an arrow key doesn't queue a backlog of options. + speech.say(text, not justSwitched) + justSwitched = false +end + +-- Force a re-read, e.g. for a "where am I" key. +function menus.repeatFocus(stateName) + local text = readFocus(stateName) + if text then + speech.say(text, true) + end +end + +function menus.resetFocusTracking() + lastKey = nil +end + +-------------------------------------------------------------------------------- +-- Self test — the version-bump canary +-------------------------------------------------------------------------------- + +-- Reports, for every descriptor we ship, whether its probe still resolves against +-- the current game code. A renamed local shows up here at startup instead of as +-- silence three screens into a playthrough. +function menus.selfTest(stateList) + local results = {} + + for _, stateName in ipairs(stateList) do + local descriptor = loadDescriptor(stateName) + if not descriptor then + results[stateName] = "no descriptor" + elseif descriptor.read then + results[stateName] = "custom read()" + else + local stateTable = getStateTable(stateName) + if not stateTable then + results[stateName] = "STATE NOT LOADABLE" + else + local probeFn = stateTable[descriptor.probe.fn or "keypressed"] + local index = getUpvalue(probeFn, descriptor.probe.focus) + local list = getUpvalue(probeFn, descriptor.probe.list) + + -- index is often nil until the screen is opened; the list existing + -- is the real signal that the probe still points at something. + if list == nil then + results[stateName] = "PROBE BROKEN: no upvalue '" .. tostring(descriptor.probe.list) .. "'" + elseif index == nil and not descriptor.probe.focusMayBeNil then + results[stateName] = "probe partial: '" .. tostring(descriptor.probe.focus) .. "' nil (may be normal before open)" + else + results[stateName] = "ok" + end + end + end + end + + return results +end + +return menus diff --git a/accessibility/states/load_game.lua b/accessibility/states/load_game.lua new file mode 100644 index 0000000..24a6ff3 --- /dev/null +++ b/accessibility/states/load_game.lua @@ -0,0 +1,44 @@ +-- states/load_game.lua +-- +-- Entries are save metadata. The screen draws three columns per row: character +-- name, in-game elapsed time, and the real-world save timestamp. We speak all +-- three, in that order -- the character name is what identifies a save, but the +-- timestamps are how you tell two runs of the same character apart. + +local capitalize = require("functions.capitalize") +local turnsToTime = require("functions.turnsToTime") + +return { + probe = { + fn = "keypressed", + focus = "selectedOption", + list = "metadataList", + focusMayBeNil = true, + }, + + render = function(entry, index, total) + local parts = {} + + parts[#parts + 1] = entry.characterName or "Unnamed" + + if entry.turn then + local ok, turnString = pcall(function() + return capitalize(turnsToTime(entry.turn)) + end) + if ok and turnString then + parts[#parts + 1] = turnString + end + end + + if entry.lastUpdatedTimestamp then + local ok, dateString = pcall(os.date, "%X %x", entry.lastUpdatedTimestamp) + if ok and dateString then + parts[#parts + 1] = dateString + end + end + + parts[#parts + 1] = index .. " of " .. total + + return table.concat(parts, ", ") + end, +} diff --git a/accessibility/states/main_menu.lua b/accessibility/states/main_menu.lua new file mode 100644 index 0000000..ed5bb3f --- /dev/null +++ b/accessibility/states/main_menu.lua @@ -0,0 +1,25 @@ +-- states/main_menu.lua +-- +-- Options are static (Continue / New game / Load game / Options / Manual / +-- Credits / Exit game); `isEnabled` is recomputed in open(), so "Continue" and +-- "Load game" are unavailable on a fresh install. Announcing that matters -- +-- otherwise pressing enter appears to do nothing. + +return { + probe = { + fn = "keypressed", + focus = "selectedOption", + list = "optionsList", + focusMayBeNil = true, -- nil until open() picks the first enabled option + }, + + render = function(entry, index, total) + local text = entry.title or "?" + + if entry.isEnabled == false then + text = text .. ", unavailable" + end + + return text .. ", " .. index .. " of " .. total + end, +} diff --git a/accessibility/states/narration.lua b/accessibility/states/narration.lua new file mode 100644 index 0000000..d24f31d --- /dev/null +++ b/accessibility/states/narration.lua @@ -0,0 +1,42 @@ +-- states/narration.lua +-- +-- The intro and interlude screens. Text is revealed one line at a time: pressing +-- enter advances `currentLoadedLine` within `displayData[currentPage]`, and rolls +-- over to the next page at the end. +-- +-- Not a list menu, so this uses a custom read(). The "focus" is the most recently +-- revealed line, keyed by page:line so each newly revealed line is announced once. + +return { + read = function(stateTable, menus) + -- Two-level probe. narration.keypressed does not touch these locals + -- directly -- it just calls continueNarration(), so they are upvalues of + -- THAT function. We reach continueNarration as an upvalue of keypressed, + -- then read through it. + local probeFn = menus.getUpvalue(stateTable.keypressed, "continueNarration") + + if type(probeFn) ~= "function" then + return nil, nil + end + + local displayData = menus.getUpvalue(probeFn, "displayData") + local page = menus.getUpvalue(probeFn, "currentPage") + local line = menus.getUpvalue(probeFn, "currentLoadedLine") + + if type(displayData) ~= "table" or type(page) ~= "number" or type(line) ~= "number" then + return nil, nil + end + + local pageData = displayData[page] + if type(pageData) ~= "table" then + return nil, nil + end + + local entry = pageData[line] + if type(entry) ~= "table" or not entry.text then + return nil, nil + end + + return entry.text, page .. ":" .. line + end, +} diff --git a/accessibility/states/new_game.lua b/accessibility/states/new_game.lua new file mode 100644 index 0000000..dcb2f1d --- /dev/null +++ b/accessibility/states/new_game.lua @@ -0,0 +1,30 @@ +-- states/new_game.lua +-- +-- Not a list — a single text field for the character's name. So this uses a +-- custom read() rather than the focus/list probe. +-- +-- `inputText` is an upvalue of keypressed (which handles backspace and tab). +-- Note the game only enables textinput here, so our hotkey layer stays out of +-- this state (see hotkeys.lua textEntryStates) and typed brackets reach the game. + +return { + -- Spoken when the screen opens, since the on-screen hint window is the only + -- thing explaining what to do. + onOpen = 'Enter your character name. Tab suggests a name, Enter confirms.', + + read = function(stateTable, menus) + local inputText = menus.getUpvalue(stateTable.keypressed, "inputText") + + if type(inputText) ~= "string" then + return nil, nil + end + + if inputText == "" then + return "Name, blank", "blank" + end + + -- Key is the text itself, so the driver re-announces on every change + -- (typing, backspace, tab-suggest) but not on keys that change nothing. + return inputText, inputText + end, +} diff --git a/accessibility/states/settings_menu.lua b/accessibility/states/settings_menu.lua new file mode 100644 index 0000000..996f107 --- /dev/null +++ b/accessibility/states/settings_menu.lua @@ -0,0 +1,22 @@ +-- states/settings_menu.lua +-- +-- Same shape as main_menu: a static optionsList with an isEnabled flag. + +return { + probe = { + fn = "keypressed", + focus = "selectedOption", + list = "optionsList", + focusMayBeNil = true, + }, + + render = function(entry, index, total) + local text = entry.title or "?" + + if entry.isEnabled == false then + text = text .. ", unavailable" + end + + return text .. ", " .. index .. " of " .. total + end, +} diff --git a/accessibility/testbed.lua b/accessibility/testbed.lua new file mode 100644 index 0000000..56e1f98 --- /dev/null +++ b/accessibility/testbed.lua @@ -0,0 +1,246 @@ +-- testbed.lua — drives the game by code so mod behaviour can be verified without +-- a human at the keyboard. +-- +-- Enable with: lovec.exe . --a11y-test +-- +-- Key events go through love.event.push, so input travels the exact same path as +-- a human keypress (love.keypressed -> states.keypressed -> our wrapper -> the +-- game's handler). Nothing is stubbed. +-- +-- The scenario is a coroutine: it yields to wait for frames, so it can *read* the +-- game's actual focus and navigate accordingly rather than counting keypresses +-- blind. Blind counting drifts the moment an option is disabled or a screen +-- inserts itself. + +local menus = require("accessibility.menus") + +local testbed = {} + +local co +local waitFrames = 0 +local finished = false +local failures = 0 +local checks = 0 + +-------------------------------------------------------------------------------- +-- Primitives (called from inside the scenario coroutine) +-------------------------------------------------------------------------------- + +local function wait(frames) + for _ = 1, frames or 1 do + coroutine.yield() + end +end + +local function currentState() + local ok, states = pcall(require, "states") + if not ok then + return "?" + end + return states.getCurrent() +end + +local function focusText() + local ok, text = pcall(menus.readFocus, currentState()) + if not ok then + return nil + end + return text +end + +-- Presses a key and waits long enough for the event to be pumped, handled, and +-- for our end-of-frame speech flush to run. +local function press(scancode, note) + local before = focusText() + print(string.format("[testbed] (%s) press %-8s focus_before=%s %s", + currentState(), scancode, before and ('"' .. before .. '"') or "nil", note or "")) + + love.event.push("keypressed", scancode, scancode, false) + love.event.push("keyreleased", scancode, scancode) + + wait(6) + + local after = focusText() + print(string.format("[testbed] -> state=%s focus_after=%s", + currentState(), after and ('"' .. after .. '"') or "nil")) +end + +local function typeText(text) + print(string.format("[testbed] (%s) type %q", currentState(), text)) + for char in text:gmatch(".") do + love.event.push("textinput", char) + wait(3) + end + wait(3) +end + +local function check(label, condition, detail) + checks = checks + 1 + if not condition then + failures = failures + 1 + end + print(string.format("[testbed] %s %s%s", + condition and "PASS" or "FAIL", label, detail and (" -- " .. detail) or "")) +end + +local function waitForState(stateName, maxFrames) + local waited = 0 + while currentState() ~= stateName and waited < (maxFrames or 300) do + wait(1) + waited = waited + 1 + end + return currentState() == stateName +end + +-- Navigate a list menu to the option whose spoken text matches `pattern`, by +-- pressing down and reading focus each time. Robust to disabled options and to +-- the list changing length between versions. +local function selectByText(pattern, maxSteps) + for _ = 1, maxSteps or 15 do + local text = focusText() + if text and text:lower():find(pattern:lower(), 1, true) then + return true + end + press("down", "searching for '" .. pattern .. "'") + end + return false +end + +-------------------------------------------------------------------------------- +-- The scenario +-------------------------------------------------------------------------------- + +local function scenario() + wait(30) -- let the main menu settle + + check("reached main menu", currentState() == "main_menu", "got " .. currentState()) + + -- Regression guard for the bug where love.keypressed was still bound to the + -- pre-wrapper states.keypressed, so arrow keys announced nothing. + local before = focusText() + press("down", "first arrow key after startup") + local after = focusText() + check("first arrow key changes focus", before ~= after, + string.format("before=%s after=%s", tostring(before), tostring(after))) + + press("up", "back up") + + check("found New game", selectByText("New game"), "menu navigation by text") + press("return", "confirm New game") + + check("reached new_game", waitForState("new_game"), "got " .. currentState()) + + typeText("Tester") + local nameFocus = focusText() + check("name field reads back typed text", + nameFocus ~= nil and nameFocus:find("Tester") ~= nil, + "focus=" .. tostring(nameFocus)) + + press("return", "confirm name") + wait(30) + + -- Intro sequence: narration / dialogue / message screens vary, so just advance + -- until we land in gameplay. + for i = 1, 20 do + if currentState() == "ingame" then + break + end + press("return", "advancing intro (" .. currentState() .. ") " .. i) + wait(10) + end + + check("reached gameplay", currentState() == "ingame", "got " .. currentState()) + + if currentState() == "ingame" then + -- Movement drives console messages and wall tones. + press("right", "move east") + press("right", "move east") + press("down", "move south") + press("left", "move west") + press("up", "move north") + + -- Try a wider set of directions: the start position may be walled in, and a + -- blocked move produces no console output. + for _, dir in ipairs({"up", "left", "down", "right", "up", "right", "down", "left"}) do + press(dir, "probing movement") + end + + -- Direct smoke test of the console.print patch. + -- + -- Plain movement is silent in this game (actions/move.lua only prints when + -- the move FAILS -- bound, immobilized), so walking around proves nothing + -- about the wrapper. Drive console.print itself instead. + local history = require("accessibility.history") + local console = require("modules.console") + local baseline = history.count() + + console.print("Accessibility smoke test message.") + wait(3) + check("console.print reaches history", history.count() == baseline + 1, + "count " .. baseline .. " -> " .. history.count()) + + -- The game folds a repeated identical message into "(x2)"; we must match. + console.print("Accessibility smoke test message.") + wait(3) + check("repeat folds instead of appending", history.count() == baseline + 1, + "count = " .. history.count()) + + -- Console review keys. + press("[", "review previous") + press("]", "review next") + press("\\", "repeat current") + end + + print(string.format("[testbed] === %d checks, %d failures ===", checks, failures)) +end + +-------------------------------------------------------------------------------- +-- Driver +-------------------------------------------------------------------------------- + +function testbed.start() + co = coroutine.create(scenario) + print("[testbed] starting scenario") +end + +function testbed.update() + if finished or not co then + return + end + + if waitFrames > 0 then + waitFrames = waitFrames - 1 + return + end + + local ok, err = coroutine.resume(co) + + if not ok then + print("[testbed] scenario ERROR: " .. tostring(err)) + finished = true + love.event.quit() + return + end + + if coroutine.status(co) == "dead" then + finished = true + print("[testbed] scenario complete, quitting") + love.event.quit() + end +end + +function testbed.isEnabled() + if not arg then + return false + end + + for _, value in ipairs(arg) do + if value == "--a11y-test" then + return true + end + end + + return false +end + +return testbed