CLOSES #575, CLOSES #578, CLOSES #584, CLOSES #589

This commit is contained in:
bryanthaboi
2026-08-01 12:56:13 -04:00
parent ca1beaefe7
commit 2009df3dd1
17 changed files with 819 additions and 54 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")
+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")
+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")