-- 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, mods) local before = focusText() print(string.format("[testbed] (%s) press %-8s%s focus_before=%s %s", currentState(), scancode, mods and (mods.ctrl and " +ctrl" or mods.shift and " +shift" or "") or "", before and ('"' .. before .. '"') or "nil", note or "")) -- Modifier state is read from real hardware, which synthetic events cannot -- affect -- so override the hotkey layer's modifier probes for this press. local hotkeys = require("accessibility.hotkeys") local realCtrl, realShift = hotkeys.isCtrlDown, hotkeys.isShiftDown if mods then hotkeys.isCtrlDown = function() return mods.ctrl == true end hotkeys.isShiftDown = function() return mods.shift == true end end love.event.push("keypressed", scancode, scancode, false) love.event.push("keyreleased", scancode, scancode) wait(6) hotkeys.isCtrlDown, hotkeys.isShiftDown = realCtrl, realShift 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. local sawDialogue = false local dialogueText for i = 1, 20 do if currentState() == "ingame" then break end if currentState() == "ingame_dialogue" and not sawDialogue then sawDialogue = true dialogueText = focusText() -- Exercise response navigation before advancing. press("down", "dialogue: next response") press("up", "dialogue: previous response") end press("return", "advancing intro (" .. currentState() .. ") " .. i) wait(10) end check("dialogue was described", sawDialogue and dialogueText ~= nil, "text=" .. tostring(dialogueText)) 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") -- Phase 2: surroundings probing (no state change, no game side effects). press("/", "scan all eight neighbours") press(";", "where am I") press("kp8", "probe north", {ctrl = true}) press("kp6", "probe east", {ctrl = true}) press("kp5", "probe standing-on", {ctrl = true}) -- Phase 3: object tracker. local tracker = require("accessibility.tracker") local scanData = tracker.rescan(true) check("tracker scan returns data", scanData ~= nil, "scan = " .. tostring(scanData)) if scanData then local summary = {} for _, category in ipairs(require("accessibility.categories").list) do local bucket = scanData[category.key] local kinds = bucket and #bucket.kinds or 0 local instances = 0 for _, kind in ipairs(bucket and bucket.kinds or {}) do instances = instances + #kind.instances end summary[#summary + 1] = string.format("%s=%dk/%di", category.key, kinds, instances) if kinds > 0 then local names = {} for _, kind in ipairs(bucket.kinds) do names[#names + 1] = kind.name .. "(" .. kind.key .. ")x" .. #kind.instances end print("[testbed] " .. category.key .. ": " .. table.concat(names, ", ")) end end print("[testbed] tracker contents: " .. table.concat(summary, " ")) local found = false for _, category in ipairs(require("accessibility.categories").list) do local bucket = scanData[category.key] if bucket and #bucket.kinds > 0 then found = true end end check("tracker found something", found, "at least one non-empty category") end -- Cycle every category so each announcement path runs, including empties. for i = 1, 6 do press("pagedown", "tracker: category " .. i, {ctrl = true}) end -- Land on a populated category before exercising kind/instance navigation, -- otherwise every announcement is just "none" and proves nothing. local trackerCategories = require("accessibility.categories").list for i, category in ipairs(trackerCategories) do local bucket = scanData and scanData[category.key] if bucket and #bucket.kinds > 0 then -- Cycling wrapped back to index 1, so step forward to this one. for _ = 2, i do press("pagedown", "tracker: to " .. category.key, {ctrl = true}) end break end end press("pagedown", "tracker: next kind") press("pagedown", "tracker: next kind") press("pagedown", "tracker: next instance", {shift = true}) press("pagedown", "tracker: next instance", {shift = true}) press("home", "tracker: announce location") -- The game's own examine mode. press("x", "enter examine mode") wait(10) check("entered examine mode", currentState() == "ingame_observe", "got " .. currentState()) if currentState() == "ingame_observe" then press("right", "examine: cursor east") press("down", "examine: cursor south") press("escape", "leave examine mode") wait(10) check("left examine mode", currentState() == "ingame", "got " .. currentState()) end 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