Files
alchemist-accessibility/accessibility/testbed.lua
T

386 lines
12 KiB
Lua
Raw Normal View History

2026-08-01 20:53:29 +02:00
-- 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.
2026-08-01 21:26:35 +02:00
local function press(scancode, note, mods)
2026-08-01 20:53:29 +02:00
local before = focusText()
2026-08-01 21:26:35 +02:00
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
2026-08-01 20:53:29 +02:00
love.event.push("keypressed", scancode, scancode, false)
love.event.push("keyreleased", scancode, scancode)
wait(6)
2026-08-01 21:26:35 +02:00
hotkeys.isCtrlDown, hotkeys.isShiftDown = realCtrl, realShift
2026-08-01 20:53:29 +02:00
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
2026-08-01 20:53:29 +02:00
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
2026-08-01 20:53:29 +02:00
press("return", "advancing intro (" .. currentState() .. ") " .. i)
wait(10)
end
check("dialogue was described", sawDialogue and dialogueText ~= nil,
"text=" .. tostring(dialogueText))
2026-08-01 20:53:29 +02:00
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")
2026-08-01 21:26:35 +02:00
-- 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})
2026-08-01 21:34:13 +02:00
-- 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")
2026-08-01 22:00:52 +02:00
-- 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
2026-08-01 21:34:13 +02:00
-- The game's own examine mode.
2026-08-01 21:26:35 +02:00
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
2026-08-01 20:53:29 +02:00
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