Merge branch 'dev' into feat/switch-nx

Sync upstream v0.1.52/v0.1.53 fixes: Oak PC flow, bindings, Android host restart, UTF-8 mod manifests.
This commit is contained in:
Andrew Quenehen
2026-08-01 15:48:00 -03:00
21 changed files with 1104 additions and 58 deletions
@@ -0,0 +1,59 @@
-- #575: HostShell.restart on Android must never reach love.event.quit
-- ("restart") -- the vendored love.cpp loops runlove() in-process on
-- "restart" and the second PHYSFS_init crashes ("already initialized").
-- The fix prefers the love.system.restartApp JNI bridge (which kills the
-- process, so a true return is never observed live) and, on an old APK
-- whose liblove lacks the bridge, falls back to a CLEAN quit with no
-- argument. Desktop keeps the in-process quit("restart").
-- luajit tests/engine/host_restart_android_bug575.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
local HostShell = require("src.core.HostShell")
local quits = {}
love.event = {
-- record the argument distinctly from "called with none": quit() and
-- quit("restart") are the whole difference this test pins
quit = function(...)
quits[#quits + 1] = { n = select("#", ...), arg = (...) }
end,
}
local osName = "Android"
local restartCalls = 0
love.system = love.system or {}
love.system.getOS = function() return osName end
-- bridge present and schedulable: restart goes through it, quit untouched
love.system.restartApp = function() restartCalls = restartCalls + 1 return true end
HostShell.restart()
eq(restartCalls, 1, "Android restart prefers the restartApp bridge (#575)")
eq(#quits, 0, "a scheduled relaunch never touches love.event.quit")
-- bridge present but could not schedule: clean quit, never quit("restart")
love.system.restartApp = function() restartCalls = restartCalls + 1 return false end
HostShell.restart()
eq(restartCalls, 2, "the bridge is still tried first")
eq(#quits, 1, "a failed schedule falls back to one quit")
eq(quits[1].n, 0, "and it is a bare quit(), not quit(\"restart\")")
-- old APK, no bridge compiled in: same clean quit fallback
love.system.restartApp = nil
HostShell.restart()
eq(#quits, 2, "a bridge-less APK quits cleanly instead of crashing")
eq(quits[2].n, 0, "again with no restart argument")
-- desktop (no AppImage in a test environment) keeps the in-process restart
if not os.getenv("APPIMAGE") then
osName = "OS X"
HostShell.restart()
eq(quits[3] and quits[3].arg, "restart",
"non-Android still restarts in-process")
end
T.finish("host_restart_android_bug575")
+31
View File
@@ -297,4 +297,35 @@ do
eq(rows[1].name, "bare", "a nameless row falls back to its id")
end
-- ------- manifest strings are scrubbed to valid UTF-8 (MODS panel crash:
-- LÖVE's printf raises "Invalid UTF-8" on a mangled name/description, so
-- validate must drop bad bytes before any panel draws them)
do
local m = mf({ id = "utf", entry = "m.lua",
-- BOM-prefixed name (a real manifest shipped this way), a Latin-1 e-acute
-- (\233, invalid as UTF-8) in the description, and a lone continuation
-- byte in the version
name = "\239\187\191Run Mode",
version = "1.0\128.0",
description = "caf\233 latt\233",
category = "UI\255" })
eq(m.name, "Run Mode", "a leading BOM is stripped from the name")
eq(m.version, "1.0.0", "invalid bytes are dropped from the version")
eq(m.description, "caf latt", "Latin-1 bytes are dropped, not replaced")
eq(m.raw.category, "UI", "raw.category is scrubbed in place for the badge")
local ok2 = mf({ id = "utf2", name = "Vers\195\163oVermelha", version = "1.0.0",
entry = "m.lua", description = "Pok\195\169mon \240\159\148\165" })
eq(ok2.name, "Vers\195\163oVermelha", "valid two-byte sequences survive")
eq(ok2.description, "Pok\195\169mon \240\159\148\165",
"valid three- and four-byte sequences survive")
-- surrogate half (ED A0 80) and overlong slash (C0 AF) are invalid even
-- though their lead bytes look plausible
local bad = mf({ id = "utf3", name = "a\237\160\128b\192\175c",
version = "1.0.0", entry = "m.lua" })
eq(bad.name, "abc", "surrogates and overlongs are dropped")
end
T.finish("launcher_mods")
+148
View File
@@ -0,0 +1,148 @@
-- #578: the launcher's "Add an index" prompt (and the rename / find-search
-- fields) accepted no typing on Android, because nothing ever called
-- love.keyboard.setTextInput(true) -- mobile LOVE only delivers
-- love.textinput while it is armed. Every site that opens a text field must
-- arm, every site that closes one must disarm, and disarm must be a no-op on
-- desktop where the hosted save editor depends on text input staying on
-- (tools/save-editor/Kit.lua, #529). A touch screen also has no ctrl+V, so
-- the prompt grew a PASTE chip; both paste paths share _pasteIndexUrl and
-- both honor the whitespace strip and the MAX_INDEX_URL cap.
-- luajit tests/engine/launcher_text_input_bug578.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
-- record every setTextInput transition; the assertions read this log
local textInputLog = {}
love.keyboard.setTextInput = function(on)
textInputLog[#textInputLog + 1] = on
end
local function lastArm() return textInputLog[#textInputLog] end
local clipboard = ""
love.system = love.system or {}
love.system.getClipboardText = function() return clipboard end
-- _commitAddIndex hands the typed URL to ModIndex.addSource; a canned
-- failure keeps the commit path off the network and out of options.lua
local addSourceUrl = nil
package.loaded["src.mods.ModIndex"] = {
addSource = function(url) addSourceUrl = url return nil, "offline" end,
}
-- _commitRename goes through SaveData.renameSlot; record the call
local renamed = nil
package.loaded["src.core.SaveData"] = {
renameSlot = function(version, id, text) renamed = { version, id, text } end,
}
local RomImporter = require("src.import.RomImporter")
local ri = setmetatable({
android = true, workState = nil, tab = "find",
ready = {}, slots = { red = { { id = "s1", label = "OLD" } } },
slotScroll = {}, activeSlot = {},
}, RomImporter)
ri._refreshSlots = function() end -- rename commit relists; nothing to relist
-- ---- index prompt: arm on open, disarm on escape and on commit ------------
ri:_promptAddIndex()
check(ri._indexPrompt ~= nil, "the add-index prompt opens")
eq(lastArm(), true, "opening the prompt arms setTextInput (#578)")
-- typed input strips whitespace (URLs never contain a literal space)
ri:textinput("https://ex ample.com\n/idx")
eq(ri._indexPrompt.text, "https://example.com/idx",
"typed input lands with whitespace stripped")
ri:keypressed("escape")
check(ri._indexPrompt == nil, "escape closes the prompt")
eq(lastArm(), false, "and disarms setTextInput")
ri:_promptAddIndex()
ri._indexPrompt.text = "https://example.com/index.json"
ri:keypressed("return")
check(ri._indexPrompt == nil, "enter commits and closes the prompt")
eq(lastArm(), false, "commit disarms setTextInput too")
eq(addSourceUrl, "https://example.com/index.json",
"the committed text reaches ModIndex.addSource")
check(ri.findNotice and ri.findNotice.ok == false,
"a rejected source surfaces as a notice, not a crash")
-- ---- PASTE chip: same entry point the touch screen uses -------------------
ri:_promptAddIndex()
-- the chip rect is what draw() published last frame (pinned: modal chrome
-- ignores the page-scroll band); mousepressed hit-tests it while the
-- prompt is up and everywhere else the prompt swallows the press
ri._indexPasteRect = { x = 10, y = 10, width = 60, height = 24, pinned = true }
clipboard = " https://example.com/mods/index.json\n"
ri:mousepressed(200, 200, 1)
eq(ri._indexPrompt.text, "", "a press outside the chip pastes nothing")
ri:mousepressed(20, 20, 1)
eq(ri._indexPrompt.text, "https://example.com/mods/index.json",
"the PASTE chip lands the clipboard with whitespace stripped (#578)")
-- the cap holds through the button path: a 300-char clipboard cannot
-- overflow MAX_INDEX_URL (200)
ri._indexPrompt.text = ""
clipboard = string.rep("a", 300)
ri:mousepressed(20, 20, 1)
eq(#ri._indexPrompt.text, 200, "the PASTE chip enforces MAX_INDEX_URL")
-- and through ctrl/cmd+V, which used to skip the cap entirely
ri._indexPrompt.text = ""
local savedIsDown = love.keyboard.isDown
love.keyboard.isDown = function() return true end
ri:keypressed("v")
love.keyboard.isDown = savedIsDown
eq(#ri._indexPrompt.text, 200, "ctrl+V routes through the same cap (#578)")
ri:keypressed("escape")
-- ---- rename field: arm on open, disarm on escape and on commit ------------
ri:_beginRename("red", "s1")
check(ri._rename ~= nil, "the rename modal opens")
eq(lastArm(), true, "opening the rename arms setTextInput")
ri:keypressed("escape")
check(ri._rename == nil, "escape closes the rename")
eq(lastArm(), false, "and disarms setTextInput")
ri:_beginRename("red", "s1")
ri:textinput("!")
ri:keypressed("return")
eq(lastArm(), false, "committing the rename disarms setTextInput")
eq(renamed and renamed[3], "OLD!", "the commit reaches SaveData.renameSlot")
-- ---- find-search field: arm on rect press, disarm on escape ---------------
ri.findSearchRect = { x = 100, y = 100, width = 80, height = 20 }
ri:mousepressed(110, 110, 1)
check(ri._findSearchFocus == true, "pressing the search field takes focus")
eq(lastArm(), true, "and arms setTextInput")
ri:keypressed("escape")
check(ri._findSearchFocus == false, "escape drops the search caret")
eq(lastArm(), false, "and disarms setTextInput")
-- a press elsewhere on the find tab also drops the caret and disarms
ri:mousepressed(110, 110, 1)
eq(lastArm(), true, "refocus for the click-away case")
ri:mousepressed(400, 400, 1)
check(ri._findSearchFocus == false, "a click away drops the caret")
eq(lastArm(), false, "and disarms setTextInput")
-- ---- desktop contract (#529): disarm never lowers off Android -------------
ri.android = false
ri:_promptAddIndex()
eq(lastArm(), true, "desktop still arms (harmless, already on)")
local before = #textInputLog
ri:keypressed("escape")
eq(#textInputLog, before,
"desktop disarm is a no-op: the hosted save editor keeps text input on "
.. "(#529)")
T.finish("launcher_text_input_bug578")
+163
View File
@@ -0,0 +1,163 @@
-- Prof. Oak's PC session (#576): engine/menus/oaks_pc.asm OpenOaksPC --
-- the access text, "Want to get your #DEX rated?" with a YES/NO, the dex
-- rating (completion line + tier text), the rating jingle only once the
-- rating text has printed (DisplayDexRating -> PlayPokedexRatingSfx), and
-- the "Closed link to PROF.OAK's PC." tail before control returns. The
-- old flow played the jingle the moment the entry was picked and skipped
-- both the intro and the closing link.
-- ROM-free: uses the fixture dataset so CI (no data/generated/) stays green.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.load()
-- the ROM-extracted strings the fixture text table does not carry; labels
-- and wording match pokered text/pokedex_ratings.asm + oaks_pc.asm
Data.text._AccessedOaksPCText =
"Accessed PROF.\nOAK's PC.\fAccessed #DEX\nRating System."
Data.text._GetDexRatedText = "Want to get your\n#DEX rated?"
Data.text._ClosedOaksPCText = "Closed link to\nPROF.OAK's PC."
Data.text._DexCompletionText =
"#DEX comp-\nletion is:\f{NUM:hDexRatingNumMonsSeen} #MON seen\n" ..
"{NUM:hDexRatingNumMonsOwned} #MON owned\fPROF.OAK's\nRating:"
Data.text._DexRatingText_Own50To59 =
"You finally got at\nleast 50 species!"
local SaveData = require("src.core.SaveData")
local OW = require("src.world.OverworldController")
local function setUpvalue(fn, name, val)
local i = 1
while true do
local n = debug.getupvalue(fn, i)
if not n then return false end
if n == name then debug.setupvalue(fn, i, val); return true end
i = i + 1
end
end
local pushed = {}
local plays = {}
local stackStub = {
push = function(_, item)
pushed[#pushed + 1] = item
end,
}
local textBoxStub = {
new = function(_, text, onDone, opts)
return { kind = "text", text = text, onDone = onDone, opts = opts }
end,
}
local menuStub = {
new = function(_, items, opts)
return { kind = "menu", items = items, opts = opts or {} }
end,
}
-- Sound / Menu are required lazily at the call sites; stub via package.loaded
local realSound = package.loaded["src.core.Sound"]
package.loaded["src.core.Sound"] = {
play = function(_, name)
plays[#plays + 1] = name
end,
playCry = function() end,
}
local realMenu = package.loaded["src.ui.Menu"]
package.loaded["src.ui.Menu"] = menuStub
local fakeGame = {
data = Data,
save = SaveData.newGame(),
stack = stackStub,
}
for _, name in ipairs({ "openOaksPC", "dexRating" }) do
T.check(setUpvalue(OW[name], "TextBox", textBoxStub),
("TextBox upvalue on %s"):format(name))
T.check(setUpvalue(OW[name], "Game", fakeGame),
("Game upvalue on %s"):format(name))
end
T.check(setUpvalue(OW.openPC, "Game", fakeGame), "Game upvalue on openPC")
local fakeSelf = setmetatable({}, { __index = OW })
local function lastPush()
return pushed[#pushed]
end
local function reset()
fakeGame.save = SaveData.newGame()
fakeGame.save.flags.EVENT_GOT_POKEDEX = true
local seen, owned = {}, {}
for i = 1, 55 do seen[i] = true; owned[i] = true end
fakeGame.save.pokedex = { seen = seen, owned = owned }
pushed = {}
plays = {}
end
local function runChain()
-- A through every box that is up; the choice box answers YES
local guard = 0
while lastPush() and lastPush().kind == "text" and guard < 10 do
guard = guard + 1
local box = lastPush()
if box.opts and box.opts.choice then
box.opts.choice(true)
elseif box.onDone then
box.onDone()
else
break
end
end
end
-- === full session from the launcher menu: intro, YES, rating, jingle, close
reset()
local done = false
fakeSelf:openPC(function() done = true end)
local menu = lastPush()
T.eq(menu.kind, "menu", "openPC pushes the PC menu")
local oak
for _, item in ipairs(menu.items) do
if item.label == "PROF.OAK's PC" then oak = item end
end
T.check(oak ~= nil, "PROF.OAK's PC is offered once the Pokédex is had")
plays = {} -- drop the menu's Turn_On_PC; the session's jingle is what counts
oak.onSelect()
T.eq(pushed[2].kind, "text", "selection opens the access text")
T.check(tostring(pushed[2].text):find("Accessed", 1, true) ~= nil,
"first session box is the access text")
runChain()
T.check(done, "session completes")
T.check(pushed[3].opts ~= nil and pushed[3].opts.choice ~= nil,
"the rated question carries the YES/NO choice")
T.check(tostring(pushed[3].text):find("rated", 1, true) ~= nil,
"second session box asks for the rating")
local ratingBox = pushed[4]
T.check(ratingBox.opts and ratingBox.opts.auto ~= nil
and ratingBox.opts.auto.wait ~= nil,
"rating box sounds the jingle then waits for a button")
T.check(tostring(ratingBox.text):find("55", 1, true) ~= nil,
"completion line carries the seen/owned counts")
T.check(tostring(ratingBox.text):find("least 50 species", 1, true) ~= nil,
"rating box carries the Own50To59 tier text")
T.eq(#plays, 0, "no jingle while the evaluation is printing")
ratingBox.opts.auto.sound()
T.eq(#plays, 1, "jingle fires once the rating text is printed")
T.eq(plays[1], "Pokedex_Rating", "jingle is the Pokedex_Rating fanfare")
T.check(tostring(pushed[5].text):find("Closed link", 1, true) ~= nil,
"the closing link prints at the end of the session")
-- === declining the rating skips the evaluation but still closes the link
reset()
done = false
fakeSelf:openOaksPC(function() done = true end)
T.eq(pushed[1].kind, "text", "openOaksPC opens with the access text")
pushed[1].onDone()
T.check(pushed[2].opts and pushed[2].opts.choice ~= nil,
"rated question is a YES/NO")
pushed[2].opts.choice(false)
T.check(tostring(lastPush().text):find("Closed link", 1, true) ~= nil,
"NO skips the rating and closes the PC")
T.eq(#plays, 0, "declining plays no jingle")
package.loaded["src.ui.Menu"] = realMenu
if realSound ~= nil then package.loaded["src.core.Sound"] = realSound end
T.finish("oaks_pc_flow")
+32 -4
View File
@@ -2,7 +2,10 @@
-- screen that captured it is still steering by that map (#510). Swapping A
-- and B used to close the screen mid-swap, because Input:applyBindings ran
-- inside BindingsMenu:storeBinding and turned the player's next confirm
-- press into a cancel. No pokered cite: rebinding is port-only (gap C2).
-- press into a cancel. A capture commits on the RELEASE of its press, a
-- second held input cancels it, and a captured input that another row owns
-- swaps rather than steals (#589). No pokered cite: rebinding is
-- port-only (gap C2).
-- luajit tests/engine/rebind_capture_bug510.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
@@ -73,19 +76,29 @@ eq(game.wroteOptions, 0, "and does not touch options on disk")
bm.index = ROW_B
press(bm, "a")
bm:onKeyPressed("z")
eq(game.save.options.bindings.b.key, "z", "the capture stores B = Z")
eq(bm.items[ROW_B].right, "Z", "the row shows the new key straight away")
check(game.save.options.bindings == nil,
"a capture holds its press; nothing stores before the release (#589)")
bm:onKeyReleased("z")
eq(game.save.options.bindings.b.key, "z", "releasing the press stores B = Z")
eq(bm.items[ROW_B].right, "Z/B", "the row shows the new key straight away")
eq(game.wroteOptions, 1, "the choice persists immediately")
eq(Input.keyBindings["z"], "a",
"but the live map still reads Z as A while the screen is open (#510)")
eq(#game.stack.states, 1, "capturing Z does not close the screen")
-- Z was the A row's effective key, so the steal became a swap: the A row
-- inherits B's previous key and no key serves two rows (#589)
eq(game.save.options.bindings.a.key, "x",
"capturing A's key for B hands A the old B key")
eq(bm.items[ROW_A].right, "X/A", "and the A row redraws with it")
-- the next Z the player presses is still confirm, so the A row can be armed
bm.index = ROW_A
press(bm, "a")
eq(bm.capture, bm.items[ROW_A], "the A row arms instead of the screen closing")
bm:onKeyPressed("x")
eq(game.save.options.bindings.a.key, "x", "the swap's other half stores")
bm:onKeyReleased("x")
eq(game.save.options.bindings.a.key, "x", "re-capturing A's own key keeps it")
eq(Input.keyBindings["x"], "b", "and X is still cancel until the screen closes")
-- closing commits both halves at once, through ListMenu's onCancel
@@ -107,8 +120,23 @@ local padBm = openMenu(padGame)
padBm.index = ROW_B
press(padBm, "a")
padBm:onGamepadPressed("y")
padBm:onGamepadReleased("y")
eq(padGame.save.options.bindings.b.pad, "y", "a pad capture stores")
eq(Input.padBindings["y"], nil, "and stays out of the live pad map until close")
-- a second input going down while the first is held backs the capture out
-- with no keyboard in reach, the pad's Escape (#589)
padBm.index = ROW_A
press(padBm, "a")
padBm:onGamepadPressed("x")
padBm:onGamepadPressed("b")
check(padBm.capture == nil, "a second press cancels the armed capture")
-- Game only calls the hook while it is armed; the straggling release of
-- the first button reaches a disarmed menu and stores nothing
if padBm.onGamepadReleased then padBm:onGamepadReleased("x") end
eq(padGame.save.options.bindings.a, nil,
"a cancelled capture's release writes no binding")
press(padBm, "b")
eq(Input.padBindings["y"], "b", "closing commits the pad half too")
+169
View File
@@ -0,0 +1,169 @@
-- CONTROLS rebinding, the #589 feature set beyond the capture deferral that
-- tests/engine/rebind_capture_bug510.lua pins: a captured pad button another
-- row effectively owns SWAPS with that row (no input serves two rows, no row
-- goes empty), a second input of either kind cancels an armed capture,
-- SELECT forgets one row's rebind so it falls back to the default, and START
-- confirms then drops the whole overlay. Everything is driven through the
-- entry points Game routes raw input to (onKeyPressed/onKeyReleased/
-- onGamepadPressed/onGamepadReleased) and through update() for the menu
-- keys. No pokered cite: rebinding is port-only (gap C2).
-- luajit tests/engine/rebind_swap_clear_bug589.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
local Input = require("src.core.Input")
local Strings = require("src.core.Strings")
local BindingsMenu = require("src.ui.BindingsMenu")
-- same doubles as rebind_capture_bug510: a stack the menu can pop itself
-- off and an input whose queue is one fixed step of edges. data = {} keeps
-- ChoiceBox's un-guarded Sound.play on the headless no-audio path.
local function newGame()
local game = { save = { options = {} }, data = {}, wroteOptions = 0 }
game.stack = {
states = {},
push = function(self, s) table.insert(self.states, s) end,
pop = function(self) return table.remove(self.states) end,
top = function(self) return self.states[#self.states] end,
}
game.input = {
queue = {},
wasPressed = function(self, btn) return self.queue[btn] or false end,
isDown = function() return false end,
}
function game:writeOptions() self.wroteOptions = self.wroteOptions + 1 end
return game
end
local function press(state, btn)
state.game.input.queue = { [btn] = true }
state:update(1 / 60)
state.game.input.queue = {}
end
-- rows are BindingsMenu's BUTTONS order
local ROW_A, ROW_B, ROW_SELECT = 5, 6, 8
-- ---- (a) pad capture swaps with the row that owns the button --------------
Input:init()
local game = newGame()
local bm = BindingsMenu.new(game)
game.stack:push(bm)
bm.index = ROW_A
press(bm, "a")
eq(bm.capture, bm.items[ROW_A], "A arms the A row")
bm:onGamepadPressed("b")
check(game.save.options.bindings == nil,
"a pad capture holds its press; nothing stores before the release")
bm:onGamepadReleased("b")
local bindings = game.save.options.bindings
eq(bindings.a.pad, "b", "releasing pad B stores it on the A row")
eq(bindings.b.pad, "a",
"and the B row, which owned pad B, inherits the A row's old pad (#589)")
eq(bm.items[ROW_A].right, "Z/B", "the A row redraws with the new pad")
eq(bm.items[ROW_B].right, "X/A", "so does the B row")
-- after applying, every row's effective pad is unique and none is lost
Input:applyBindings(bindings)
eq(Input.padBindings["b"], "a", "applied: pad B is action A")
eq(Input.padBindings["a"], "b", "applied: pad A is action B")
local seen, actions = {}, {}
for button, action in pairs(Input.padBindings) do
check(not seen[action], "no action is reachable from two pad buttons: "
.. tostring(action))
seen[action] = button
actions[#actions + 1] = action
end
eq(#actions, 8, "all eight actions still have exactly one pad button")
Input:init()
-- ---- (b) a second input while the first is held cancels -------------------
-- key then key: the straggling release of the first press writes nothing
bm.index = ROW_SELECT
press(bm, "a")
bm:onKeyPressed("q")
bm:onKeyPressed("w")
check(bm.capture == nil, "a second key cancels the armed capture")
if bm.onKeyReleased then bm:onKeyReleased("q") end
check(bindings.select == nil, "the cancelled key capture wrote nothing")
-- key then pad: cancel crosses input kinds too
press(bm, "a")
bm:onKeyPressed("q")
bm:onGamepadPressed("x")
check(bm.capture == nil, "a pad press cancels a held key capture")
if bm.onKeyReleased then bm:onKeyReleased("q") end
if bm.onGamepadReleased then bm:onGamepadReleased("x") end
check(bindings.select == nil, "and still nothing stored")
local writesAfterSwap = game.wroteOptions
-- ---- (c) commit happens on release, not press -----------------------------
press(bm, "a")
bm:onKeyPressed("q")
check(bindings.select == nil, "press alone commits nothing (#589)")
eq(game.wroteOptions, writesAfterSwap, "and touches nothing on disk")
bm:onKeyReleased("q")
eq(bindings.select.key, "q", "the release is the commit")
eq(bm.items[ROW_SELECT].right, "Q/BACK", "the row shows the new key")
-- ---- (d) SELECT on a row forgets its rebind -------------------------------
press(bm, "select")
check(bindings.select == nil, "SELECT drops the row's overlay entry (#589)")
eq(bm.items[ROW_SELECT].right, "TAB/BACK", "the row falls back to the default")
-- the swapped B row clears the same way; a second SELECT on the now
-- default row is a no-op, not a write
local writes = game.wroteOptions
bm.index = ROW_B
press(bm, "select")
eq(game.wroteOptions, writes + 1, "clearing the swapped B row writes once")
check(game.save.options.bindings.b == nil, "and drops its overlay entry")
press(bm, "select")
eq(game.wroteOptions, writes + 1, "clearing an already-default row writes nothing")
-- ---- (e) START confirms, then drops the whole overlay ---------------------
-- rebuild a dirty overlay to reset
bm.index = ROW_A
press(bm, "a")
bm:onKeyPressed("p")
bm:onKeyReleased("p")
eq(game.save.options.bindings.a.key, "p", "fixture rebind in place")
press(bm, "start")
local box = game.stack:top()
check(box ~= bm, "START pushes the confirm box instead of resetting outright")
eq(bm.footer, Strings("RESET ALL BINDINGS?"),
"the footer doubles as the prompt")
-- the box starts on NO: a bare A press must keep the overlay
press(box, "a")
eq(game.stack:top(), bm, "answering pops the box")
check(game.save.options.bindings ~= nil, "NO keeps the bindings (defaultNo)")
eq(bm.items[ROW_A].right, "P/B", "and the rows keep showing them")
-- again, flip to YES: the overlay goes away and every row reads default
press(bm, "start")
box = game.stack:top()
press(box, "up")
press(box, "a")
check(game.save.options.bindings == nil, "YES clears options.bindings (#589)")
eq(bm.items[ROW_A].right, "Z/A", "the A row reads its default again")
eq(bm.items[ROW_B].right, "X/B", "so does the B row the swap had touched")
-- closing after the reset leaves the live map at the defaults
press(bm, "b")
eq(#game.stack.states, 0, "B closes the screen")
eq(Input.keyBindings["z"], "a", "the live map is back to Z = A")
eq(Input.padBindings["b"], "b", "and pad B = B")
Input:init()
T.finish("rebind_swap_clear_bug589")
+8 -6
View File
@@ -397,11 +397,11 @@ check(getmetatable(bm) == BindingsMenu,
check(bm.screenId == "BindingsMenu",
"the pushed rebind screen carries its screen id")
check(#bm.items == 8, "one row per logical button")
check(bm.items[1].label == "UP" and bm.items[1].right == "UP"
and bm.items[5].label == "A" and bm.items[5].right == "Z"
and bm.items[7].label == "START" and bm.items[7].right == "ESCAPE"
check(bm.items[1].label == "UP" and bm.items[1].right == "UP/D-UP"
and bm.items[5].label == "A" and bm.items[5].right == "Z/A"
and bm.items[7].label == "START" and bm.items[7].right == "ESC/START"
and bm.items[8].label == "SELECT" and bm.items[8].right == "TAB/BACK",
"with no rebind the rows mirror the fixed map")
"with no rebind the rows mirror the fixed map, key and pad both (#589)")
check(cbGame.save.options.bindings == nil,
"opening the screen alone writes nothing")
check(bm.onKeyPressed == nil and bm.onGamepadPressed == nil,
@@ -412,18 +412,20 @@ check(bm.capture == bm.items[1] and bm.onKeyPressed ~= nil,
local wroteOptions = false
function cbGame:writeOptions() wroteOptions = true end
bm:onKeyPressed("j")
bm:onKeyReleased("j") -- a capture commits on the press's release (#589)
check(cbGame.save.options.bindings.up.key == "j",
"a captured key lands in options.bindings")
check(bm.items[1].right == "J", "the row shows the new key")
check(bm.items[1].right == "J/D-UP", "the row shows the new key")
check(wroteOptions, "a rebind persists through writeOptions")
check(bm.capture == nil and bm.onKeyPressed == nil
and bm.onGamepadPressed == nil, "the capture disarms after one input")
bm.index = 5
press(bm, "a")
bm:onGamepadPressed("y")
bm:onGamepadReleased("y")
check(cbGame.save.options.bindings.a.pad == "y",
"a captured pad button lands beside the key slot")
check(bm.items[5].right == "Z", "a pad rebind keeps the key column")
check(bm.items[5].right == "Z/Y", "a pad rebind keeps the key column")
press(bm, "b")
check(#cbGame.stack.states == 0, "B closes the rebind screen")