From bd3e02986d210e5a91e30f65017130ff2afe48e6 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Thu, 27 Aug 2026 07:11:43 -0400 Subject: [PATCH] test fix and iOS close hide --- src/import/LauncherView.lua | 10 ++- tests/parity_I_M.lua | 4 +- tests/parity_applying_attack_anim.lua | 13 +-- tests/parity_celadon_roof_readables.lua | 46 +++++++---- tests/parity_lance.lua | 2 +- tests/parity_rare_candy_menu.lua | 3 + tests/parity_rocket3_sight_bug1814.lua | 5 ++ tests/rom_importer_choose_version_test.lua | 26 ++++++ tests/run_tests.lua | 92 ++++++++++++++-------- 9 files changed, 141 insertions(+), 60 deletions(-) diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 796868f3..d23badb0 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -1394,7 +1394,9 @@ local function buildHeader(imp, m) -- under the gear and the quit X -- "the settings is covering the logo". -- Reserving the space on both sides costs a little width and cannot -- overlap at any window size. - local clusterW = 2 * gear + math.floor(6 * m.s) + m.pad + -- iOS has no quit button (the OS owns app exit), so the cluster is the + -- gear alone and the wordmark gets that width back + local clusterW = (imp.ios and gear or 2 * gear + math.floor(6 * m.s)) + m.pad local boxX = m.x + clusterW local boxW = math.max(0, m.w - 2 * clusterW) if imp.logo and boxW > 0 then @@ -1428,8 +1430,8 @@ local function buildHeader(imp, m) -- inboard of it -- but the two are REGISTERED gear first, because the first -- focusable of the first frame adopts the keyboard ring and that must not be -- the button that exits the app. - local quitX = rx - gear - rx = quitX - math.floor(6 * m.s) + local quitX = not imp.ios and rx - gear or nil + if quitX then rx = quitX - math.floor(6 * m.s) end -- Settings gear. It now also owns the CONTROL settings (touch overlay -- editor, reset rebinds), which used to be buttons stacked in the game @@ -1441,7 +1443,7 @@ local function buildHeader(imp, m) chrome.gear.image = imp._gearIcon btn(imp, rx, by, gear, gear, "gear", "", chrome.gear) - btn(imp, quitX, by, gear, gear, "quit", "", chrome.quit) + if quitX then btn(imp, quitX, by, gear, gear, "quit", "", chrome.quit) end -- The self-update control lives in the FOOTER next to the BCG mark (small, -- out of the wordmark's way -- it used to overlap the logo on a phone). It diff --git a/tests/parity_I_M.lua b/tests/parity_I_M.lua index d40e1a64..84400482 100644 --- a/tests/parity_I_M.lua +++ b/tests/parity_I_M.lua @@ -303,7 +303,9 @@ Game.save.forcedBike = true ow = pushOW("ROUTE_17", 4, 10, "down") ow:flyTo("PALLET_TOWN") eq(Game.save.forcedBike, nil, "Fly clears BIT_ALWAYS_ON_BIKE") -ow.flyAnim, ow.flyDest, ow.player.inputLocked = nil, nil, false -- undo flyTo +-- undo flyTo, flyFade included: it re-arms flyAnim when it drains +ow.flyAnim, ow.flyDest, ow.flyFade = nil, nil, nil +ow.player.inputLocked = false Game.save.forcedBike = true ow:warpToHealPoint() eq(Game.save.forcedBike, nil, "blackout/escape warps clear BIT_ALWAYS_ON_BIKE") diff --git a/tests/parity_applying_attack_anim.lua b/tests/parity_applying_attack_anim.lua index 9b7e5dea..14256557 100644 --- a/tests/parity_applying_attack_anim.lua +++ b/tests/parity_applying_attack_anim.lua @@ -35,12 +35,12 @@ local check, eq = S.check, S.eq -- rng floor: accuracyRoll compares rng(0, 255) against the scaled accuracy, -- so the lowest roll lands HYPNOSIS (60%) every run -local function freshBattle() +local function freshBattle(rng) Game.save.options.animations = true Game.save.party = { Pokemon.new(Data, "SQUIRTLE", 30) } local tb = BattleState.newWild(Game, "PIDGEY", 10) tb.queue, tb.nextInsert = {}, 0 - tb.rng = function(a) return a end + tb.rng = rng or function(a) return a end return tb end @@ -53,8 +53,8 @@ local function hitRows(tb) return out end -local function typeOf(moveId, isPlayer) - local tb = freshBattle() +local function typeOf(moveId, isPlayer, rng) + local tb = freshBattle(rng) local user = isPlayer and tb.player or tb.enemy local target = isPlayer and tb.enemy or tb.player tb:performMove(user, target, { id = moveId, pp = 10 }, false) @@ -111,7 +111,10 @@ do eq(typeOf("HYPNOSIS", false), 3, "and type 3 from the foe") eq(typeOf("GROWL", true), 6, "GROWL is a primary stat drop: type 6") eq(typeOf("TAIL_WHIP", true), 6, "so is TAIL_WHIP") - eq(typeOf("SAND_ATTACK", false), 3, "the foe's SAND-ATTACK is type 3") + -- StatModifierDownEffect misses on a roll under $40 on the enemy's + -- turn -- engine/battle/effects.asm:552 + eq(typeOf("SAND_ATTACK", false, function() return 0x40 end), 3, + "the foe's SAND-ATTACK is type 3") eq(typeOf("POISONPOWDER", true), 6, "POISONPOWDER is type 6") eq(typeOf("CONFUSE_RAY", true), 6, "CONFUSE RAY is type 6") eq(typeOf("DISABLE", true), 6, "DISABLE is type 6") diff --git a/tests/parity_celadon_roof_readables.lua b/tests/parity_celadon_roof_readables.lua index a8e922b2..bf02d104 100644 --- a/tests/parity_celadon_roof_readables.lua +++ b/tests/parity_celadon_roof_readables.lua @@ -90,7 +90,7 @@ end eq(hooks.onInteract(game, ow, 2, 0), false, "the wall left of the blackboard stays silent") --- walk the loop: intro -> prompt -> heading box -> blurb -> prompt again +-- walk the loop: intro -> held prompt -> heading menu over it stack = {} hooks.onInteract(game, ow, 3, 0) local intro = stack[#stack] @@ -100,36 +100,52 @@ local prompt = stack[#stack] check(getmetatable(prompt) == TextBox and pages(prompt):find("heading", 1, true) ~= nil, "the intro leads into the which-heading prompt") -prompt.onDone() +-- #591: LinkCableHelpText2 ends in `text_end`, so .linkHelpLoop leaves it on +-- screen and runs HandleMenuInput under it +check(prompt.onDone == nil and prompt.stay ~= nil, + "the prompt is held open instead of waiting for A and popping") +prompt.stay.onShown() local menu = stack[#stack] -check(getmetatable(menu) == Menu, "the prompt opens the heading menu") +check(getmetatable(menu) == Menu, "the held prompt opens the heading menu") +eq(stack[#stack - 1], prompt, "the menu sits on the still-visible prompt box") eq(#menu.items, 4, "four headings, as in HowToLinkText") local labels = {} for i, item in ipairs(menu.items) do labels[i] = item.label end eq(table.concat(labels, "/"), "HOW TO LINK/COLOSSEUM/TRADE CENTER/STOP READING", "the headings read in HowToLinkText order") -eq(menu.items[4].onSelect, nil, "STOP READING just closes the menu") check(menu.tw == 15 and menu.th == 10 and menu.tx == 0 and menu.ty == 0, "the box is the asm's 15x10 at the top left") for i = 1, 3 do - stack = {} + stack = { prompt, menu } + menu.index = i + eq(menu.items[i].keepOpen, true, + labels[i] .. " leaves the menu up, as `jp .linkHelpLoop` does") menu.items[i].onSelect() local blurb = stack[#stack] - check(getmetatable(blurb) == TextBox, - labels[i] .. " prints a text box") + check(getmetatable(blurb) == TextBox, labels[i] .. " prints a text box") local want = Data.text["_LinkCableInfoText" .. i]:match("^[^\n\011\012]+") check(pages(blurb):find(want, 1, true) ~= nil, labels[i] .. " prints _LinkCableInfoText" .. i) - check(type(blurb.onDone) == "function", - labels[i] .. " returns to the prompt instead of dropping out") - blurb.onDone() - check(getmetatable(stack[#stack]) == TextBox, - "the prompt comes back after " .. labels[i]) - stack[#stack].onDone() - check(getmetatable(stack[#stack]) == Menu, - "the menu comes back after " .. labels[i]) + eq(blurb.onDone, nil, + labels[i] .. " pops itself back onto the menu instead of dropping out") + table.remove(stack) -- the blurb pops itself once it has been read + eq(stack[#stack], menu, "the same menu comes back after " .. labels[i]) + eq(menu.index, i, "the cursor stays on the heading that was just read") + eq(stack[#stack - 1], prompt, "the held prompt is still under it") end +-- STOP READING and B share .exit: both close the menu and the prompt box +eq(menu.items[4].keepOpen, nil, "STOP READING closes the menu") +stack = { prompt, menu } +table.remove(stack) -- Menu pops itself before a non-keepOpen onSelect runs +menu.items[4].onSelect() +eq(#stack, 0, "STOP READING closes the menu and the held prompt together") +check(type(menu.onCancel) == "function", "B is watched (PAD_A | PAD_B)") +stack = { prompt, menu } +table.remove(stack) +menu.onCancel() +eq(#stack, 0, "B closes the menu and the held prompt together") + S.finish() diff --git a/tests/parity_lance.lua b/tests/parity_lance.lua index c22a4b76..960a0a5e 100644 --- a/tests/parity_lance.lua +++ b/tests/parity_lance.lua @@ -138,4 +138,4 @@ do eq(#pushed, 0, "loss does not push after-battle text") end -print("parity_lance: ok") +S.finish() diff --git a/tests/parity_rare_candy_menu.lua b/tests/parity_rare_candy_menu.lua index d9e188da..4268ce31 100644 --- a/tests/parity_rare_candy_menu.lua +++ b/tests/parity_rare_candy_menu.lua @@ -29,8 +29,11 @@ local Bag = require("src.inventory.Bag") local realTextBox = package.loaded["src.render.TextBox"] local realBag = package.loaded["src.ui.BagMenu"] local realParty = package.loaded["src.ui.PartyMenu"] +-- soundOpts only builds an opts table, so the real one runs against the stub. +local soundOpts = require("src.render.TextBox").soundOpts package.loaded["src.render.TextBox"] = { new = function(_, text, done) return { textBox = true, text = text, done = done } end, + soundOpts = soundOpts, } package.loaded["src.ui.BagMenu"] = nil package.loaded["src.ui.PartyMenu"] = nil diff --git a/tests/parity_rocket3_sight_bug1814.lua b/tests/parity_rocket3_sight_bug1814.lua index 6145cd0c..e9b506be 100644 --- a/tests/parity_rocket3_sight_bug1814.lua +++ b/tests/parity_rocket3_sight_bug1814.lua @@ -50,6 +50,9 @@ check(hideout.noSight == nil or hideout.noSight[TEXT] ~= true, -- the LIFT KEY half: after the battle -- however it started -- the next talk -- reveals the ball +-- put the real module back at the bottom: the tier dofiles every parity file +-- into one process, so a stub left behind here breaks every later suite +local realTextBox = package.loaded["src.render.TextBox"] package.loaded["src.render.TextBox"] = { new = function(_, text, done) return { text = text, done = done } end, soundOpts = function() return nil end, @@ -77,4 +80,6 @@ check(toggles and toggles.ROCKETHIDEOUTB4F_LIFT_KEY == true, "the LIFT KEY ball is shown") check(finished, "and the talk hands control back") +package.loaded["src.render.TextBox"] = realTextBox + S.finish() diff --git a/tests/rom_importer_choose_version_test.lua b/tests/rom_importer_choose_version_test.lua index 35b5bacc..1f8402e2 100644 --- a/tests/rom_importer_choose_version_test.lua +++ b/tests/rom_importer_choose_version_test.lua @@ -8,6 +8,11 @@ -- findPendingRom, answered with the first dump whose SHA-1 mapped to any -- not-yet-ready version, so the selection was dropped on the floor. -- +-- Since 592daafa the in-launcher Kit.FileBrowser is tried ahead of that scan, +-- so the save-dir scan is now what a build without the Kit browser takes. +-- Kit is stubbed away below to reach it; the last block covers the browser +-- being present. +-- -- Self-contained: `luajit tests/rom_importer_choose_version_test.lua` package.path = "./?.lua;./?/init.lua;" .. package.path if not _G.love then _G.love = require("tests.love_stub") end @@ -51,6 +56,9 @@ local saved = { -- to a dialog that may or may not exist on the machine running the suite. -- The fallback under test is the same one every pickerless device takes. love.system.getOS = function() return "Unknown" end +-- No Kit browser in this build, so Choose falls through to the save-dir scan. +local savedKit = package.loaded["src.ui.kit.Kit"] +package.loaded["src.ui.kit.Kit"] = { FileBrowser = nil } love.filesystem.getSaveDirectory = function() return "/tmp/pokemon-love2d" end love.filesystem.getDirectoryItems = function() return LISTING end love.filesystem.getInfo = function(name, filter) @@ -116,6 +124,24 @@ ri:choose("red") check(ri._started == nil, "and a version already imported is not extracted a second time") +-- ------- with the Kit browser present it opens instead of scanning + +local opened = nil +package.loaded["src.ui.kit.Kit"] = { + FileBrowser = { + open = function(opts) opened = opts end, + }, +} +ri = freshImporter() +ri:choose("blue") +check(ri._started == nil and opened ~= nil, + "a build with the Kit browser opens it rather than scanning the save dir") +eq(opened and opened.title, "Select " + .. (GameVersion.info("blue").displayName or "ROM"), + "titled for the version that was chosen") + +package.loaded["src.ui.kit.Kit"] = savedKit + for name, fn in pairs(saved) do if name == "getOS" then love.system.getOS = fn elseif name == "hash" then love.data.hash = fn diff --git a/tests/run_tests.lua b/tests/run_tests.lua index 64adac34..716bf5bf 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -12,6 +12,9 @@ love = require("tests.love_stub") -- assertions and the RNG seed come off the shared harness; this file keeps -- its own tail because the verdict line ("N FAILURES") is what CI greps local T = require("tests.harness") +-- every suite below is dofile'd in this process, so a suite that reaches for +-- T.finish must raise into runSuites' pcall instead of os.exit(0)-ing the tier +_G.POKEPORT_TEST_CHILD = true -- this suite has always streamed a line per check, and it is the one a -- developer watches for progress through ~1600 assertions T.verbose = true @@ -437,11 +440,13 @@ check(Damage.compute(ruleset, confused, confused, confMove, -- (StatModifierDownEffect's side-effect branch skips MoveHitTest) local MoveEffects = require("src.battle.MoveEffects") local sideRng = { rng = function() return 0 end } +-- player attacker: effects.asm:552's 25 percent miss is the enemy's branch +local sideUser = { isPlayer = true, stages = {}, mon = {} } local misted = { stages = {}, mist = true, name = "MISTY", mon = {} } -MoveEffects.secondary.ATTACK_DOWN_SIDE_EFFECT(sideRng, nil, misted) +MoveEffects.secondary.ATTACK_DOWN_SIDE_EFFECT(sideRng, sideUser, misted) eq(misted.stages.attack, -1, "secondary stat drop pierces MIST") local misted2 = { stages = {}, mist = true, name = "MISTY", mon = {} } -local mistMsgs = MoveEffects.primary.ATTACK_DOWN1_EFFECT(sideRng, nil, misted2) +local mistMsgs = MoveEffects.primary.ATTACK_DOWN1_EFFECT(sideRng, sideUser, misted2) check(misted2.stages.attack == nil and mistMsgs[1]:find("MIST", 1, true) ~= nil, "primary stat drop still blocked by MIST") @@ -2644,7 +2649,8 @@ do -- the text-speed cursor clamps at its ends (.pressedLeftInTextSpeed), -- MUSIC FILTER cycles OFF/1X/2X/3X, and COLORS / TILT / VIDEO MODE -- cycle their display modes (SHADER FX activates a pushed screen instead). -do +-- Nested function so LuaJIT's 200-local main-chunk limit is not hit. +(function() local OptionsMenu = require("src.ui.OptionsMenu") local OInput = require("src.core.Input") local PaletteFX = require("src.render.PaletteFX") @@ -2656,8 +2662,19 @@ do -- Isolate from earlier save/options writes in this suite SD.saveOptions(SD.defaultOptions()) local popped = false + local om + -- the rows sit on group pages now, so the stub needs a real push/pop/top + -- stack for a group opener to have anywhere to go + local ostack = { states = {} } + function ostack:push(s) self.states[#self.states + 1] = s end + function ostack:pop() + local s = table.remove(self.states) + if s == om then popped = true end + return s + end + function ostack:top() return self.states[#self.states] end local og = { data = Data, save = SD.newGame(), - input = OInput, stack = { pop = function() popped = true end }, + input = OInput, stack = ostack, writeOptions = function(self) SD.saveOptions(self.save.options) end, -- the PERFORMANCE row routes through Game:applyOptions; the -- stub carries the headless slice of it (the tier record), @@ -2665,24 +2682,29 @@ do applyOptions = function(self, o) require("src.core.Performance").applyOptions(o) end } - local om = OptionsMenu.new(og) + om = OptionsMenu.new(og) + ostack:push(om) + local OptionRows = require("src.ui.OptionRows") + -- cur is whichever screen the cursor is on: the top level, or the group + -- page seek() walked into + local cur = om local function press(btn) OInput.pressed = { [btn] = true } - om:update(1 / 60) + cur:update(1 / 60) OInput.pressed = {} end - -- walk the cursor down to a row by id, so a row added to OptionsMenu - -- shifts these blocks instead of silently retargeting them + local function rowAt(s) + local rows = s.view or s.rows + return rows[s.index] + end + -- put the cursor on a row by id, opening its group page first, so a row + -- added to OptionsMenu shifts these blocks instead of silently + -- retargeting them local function seek(id) - local want = -1 - for i, row in ipairs(om.rows) do - if row.id == id then want = i end - end - for _ = 1, #om.rows do - if om.index == want then break end - press("down") - end - return om.index == want + while ostack:top() ~= om do ostack:pop() end + cur = om:focusRow(id) or om + local row = rowAt(cur) + return row ~= nil and row.id == id end eq(og.save.options.textSpeed, 3, "new saves default to MEDIUM text (InitOptions TEXT_DELAY_MEDIUM)") @@ -2699,9 +2721,11 @@ do "A switches the battle screen to the WIDE layout") press("a") eq(og.save.options.battleLayout, "og", "BATTLE LAYOUT wraps back to OG") + -- the BATTLE page's sixth row sits past the 4-box viewport + check(seek("battleBg"), "cursor reaches BATTLE BG") + eq(cur.scroll, cur.index - OptionRows.VISIBLE, + "viewport scrolls to keep a deep group row on screen") check(seek("musicVol"), "cursor reaches MUSIC VOL") - eq(om.scroll, om.index - require("src.ui.OptionRows").VISIBLE, - "viewport scrolls to keep MUSIC VOL on screen") press("left") eq(og.save.options.musicVol, 6, "left lowers MUSIC VOL") press("right") @@ -2733,19 +2757,18 @@ do eq(og.save.options.tilt, 0, "TILT wraps back to OFF") check(seek("shaderfx"), "cursor reaches SHADER FX") -- this row activates a pushed ShaderFXScreen rather than cycling in - -- place like the rest of this suite's rows; `og.stack` above only - -- stubs `pop`, not a real push/top stack, so activate() is not - -- called here -- tests/mod_ui_tests.lua exercises it end to end against - -- a real stack. - check(om.rows[om.index].step == nil, "SHADER FX row has no step()") - check(type(om.rows[om.index].activate) == "function", + -- place like the rest of this suite's rows, so activate() is not called + -- here -- tests/mod_ui_tests.lua exercises it end to end against a real + -- game. + check(rowAt(cur).step == nil, "SHADER FX row has no step()") + check(type(rowAt(cur).activate) == "function", "SHADER FX row has an activate()") check(seek("shaderfx2"), "cursor reaches SHADER FX 2") -- the dual-shader secondary slot: same shared ShaderFXScreen, opened on -- "secondary" instead -- see the SHADER FX row above for why activate() - -- isn't exercised against this stub stack either. - check(om.rows[om.index].step == nil, "SHADER FX 2 row has no step() either") - check(type(om.rows[om.index].activate) == "function", + -- isn't exercised here either. + check(rowAt(cur).step == nil, "SHADER FX 2 row has no step() either") + check(type(rowAt(cur).activate) == "function", "SHADER FX 2 row has an activate()") check(seek("zoom"), "cursor reaches ZOOM") local ZoomOpt = require("src.render.Zoom") @@ -2804,10 +2827,10 @@ do check(seek("dateFormat"), "cursor reaches DATE FORMAT") check(seek("timeFormat"), "cursor reaches TIME FORMAT") press("down") - -- CANCEL is appended after the descriptor list rather than living in it, so - -- it lands one past #rows and the window holds the last six boxes. Counted - -- off #rows so the next row added here is not read as a wrap bug. - local cancelRow = #om.rows + 1 + -- CANCEL is appended after the top-level view rather than living in it, so + -- it lands one past #view and the window holds the last six boxes. Counted + -- off #view so the next row added here is not read as a wrap bug. + local cancelRow = #om.view + 1 eq(om.index, cancelRow, "CANCEL stays the fixed final row") eq(om.scroll, cancelRow - 5, "CANCEL keeps the last option boxes on screen") om:draw() -- smoke: scrolled layout draws under the headless stub @@ -2826,7 +2849,7 @@ do require("src.render.Zoom").applyOptions(og.save.options) require("src.render.TileRenderer").applyOptions(og.save.options) require("src.core.VideoMode").applyOptions(og.save.options) -end +end)() end -- ------------------------------------------------------------------ @@ -3224,7 +3247,8 @@ do local TileRenderer = require("src.render.TileRenderer") TileRenderer.setSpinning(true) local before = TileRenderer.spinBlurActive() - for _ = 1, 8 do TileRenderer.tick(1 / 60) end + -- the blur alternates once per simulated-joypad step, 16 frames (#1831) + for _ = 1, 16 do TileRenderer.tick(1 / 60) end check(before ~= TileRenderer.spinBlurActive(), "tick(1/60) advances water/spinner clock at fixed 60Hz") local mid = TileRenderer.spinBlurActive()