Game-file footprint unchanged: still one line in main.lua. Adds descriptors for actions_menu, worldmap, alchemy_table, journal, character, inventory, and a shared one for the full-screen prose states (menu_text, ingame_message, menu_message). The prose states also push their text into the console history, so the existing review keys walk the manual paragraph by paragraph rather than delivering it as one unusable utterance. Two driver improvements, both prompted by real failures the self-test caught: - menus.getAnyUpvalue finds a file-local across all of a state's functions and one level into inner closures. Safe because a Lua file-local is a single shared upvalue. Needed because actions_menu and worldmap build their lists in open() and never reference them in keypressed, so the named probe function found nothing. The generic probe path now falls back to this. - menus.hasUpvalue checks that a local exists rather than that it holds a value. getUpvalue cannot distinguish 'no such local' from 'declared but currently nil', which made the self-test report PROBE BROKEN for worldmap, whose 'local options' is nil until the screen is first opened. patches/popups.lua now stands down for any state that has a descriptor. inventoryCursorPopup.set only fires when an item is under the cursor, so empty slots and empty tabs were silent and you could not tell the cursor had moved. The inventory descriptor reports item-or-empty plus slot and tab instead. 21/21 testbed checks, history unit tests passing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
386 lines
12 KiB
Lua
386 lines
12 KiB
Lua
-- 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")
|
|
|
|
-- Phase 4: the menu screens reachable from gameplay.
|
|
--
|
|
-- Each is opened, navigated once, and closed. `back` is bound to x/escape/
|
|
-- backspace, but x also opens examine mode, so use escape.
|
|
local screens = {
|
|
{key = "j", state = "journal", nav = {"down", "right"}},
|
|
{key = "c", state = "character", nav = {"right", "right"}},
|
|
{key = "a", state = "actions_menu", nav = {"down"}},
|
|
{key = "i", state = "inventory", nav = {"right", "down"}},
|
|
}
|
|
|
|
for _, screen in ipairs(screens) do
|
|
press(screen.key, "open " .. screen.state)
|
|
wait(10)
|
|
|
|
local reached = currentState() == screen.state
|
|
check("opened " .. screen.state, reached, "got " .. currentState())
|
|
|
|
if reached then
|
|
for _, key in ipairs(screen.nav) do
|
|
press(key, screen.state .. ": " .. key)
|
|
end
|
|
check(screen.state .. " describes focus", focusText() ~= nil,
|
|
"focus=" .. tostring(focusText()))
|
|
press("escape", "close " .. screen.state)
|
|
wait(15)
|
|
end
|
|
end
|
|
|
|
-- 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
|