Menu narration + code-driven testbed
Pulls menu focus narration forward from Phase 4: it was a blocker, since the screen name was spoken but not the selected option, so the game could not be started at all. Game-file footprint unchanged: still one line in main.lua. - menus.lua: descriptor driver supporting both D3 backends (upvalue probe and patched-announce), sharing one render per screen. Probe self-test included. - Descriptors for main_menu, settings_menu, load_game, new_game, narration. - testbed.lua: drives the game by code via love.event.push, so input takes the same path as a human keypress. Coroutine-based, and navigates by READING focus rather than counting keypresses, which drifts when options are disabled. Three bugs found by the testbed and fixed: 1. love.keypressed was still bound to the pre-wrapper states.keypressed. states.setup() captures love[callback] by value and runs before us, and main.lua does not define love.keypressed itself, so wrapping the states table field alone had no effect. Now reassigns love.keypressed/textinput. 2. Focus can move with no keypress: main_menu.mousemoved assigns selectedOption from cursor position, silently overriding selection including at startup. Focus is now polled in the update hook instead of announced only on keypress. 3. Probes sometimes need chaining. narration.keypressed only calls continueNarration(), so the text locals are upvalues of that inner function. Verified: main menu -> new game -> name entry -> intro narration -> gameplay, 8/8 checks passing, including console.print capture and its (x2) fold. Known gap: ingame_dialogue announces 'Game' and reads no dialogue text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
fe872be683
commit
9945703fb7
@@ -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
|
||||
Reference in New Issue
Block a user