fix testsZ

This commit is contained in:
bryanthaboi
2026-08-13 06:05:27 -04:00
parent 62b9f04191
commit 52e36ad7e4
15 changed files with 148 additions and 48 deletions
+5
View File
@@ -252,10 +252,15 @@ do
local outcome
screen.onDone = function(result) outcome = result end
-- Battle lines end in `prompt` and PromptButton waits on A/B with no
-- countdown (home/joypad.asm:383-412), so the drain presses.
local function drain(cap)
for _ = 1, (cap or 3000) do
local waiting = (screen.messageTimer or 0) > 0
if waiting then Input:overlayPressed("a") end
Input:step()
screen:update(1 / 60)
if waiting then Input:overlayReleased("a") end
if screen.phase == "menu" or screen.phase == "done" then return true end
end
return false
+6
View File
@@ -159,10 +159,16 @@ local function newScreen(opts)
return screen, battle, player, save, pushed
end
-- Battle lines end in `prompt`, and PromptButton waits on A or B with no
-- frame countdown (home/joypad.asm:383-412), so a drain has to press like a
-- player does rather than wait for a timer that never runs out.
local function runToMenu(screen, cap)
for _ = 1, (cap or 3000) do
local waiting = (screen.messageTimer or 0) > 0
if waiting then Input:overlayPressed("a") end
Input:step()
screen:update(1 / 60)
if waiting then Input:overlayReleased("a") end
if screen.phase == "menu" then return true end
end
return false
+6
View File
@@ -157,10 +157,16 @@ local function newBattleOverWorld(inventory)
return screen, battle, world, save, pushed, player
end
-- Battle lines end in `prompt`, and PromptButton waits on A or B with no
-- frame countdown (home/joypad.asm:383-412), so a drain has to press like a
-- player does rather than wait for a timer that never runs out.
local function runToMenu(screen, cap)
for _ = 1, (cap or 3000) do
local waiting = (screen.messageTimer or 0) > 0
if waiting then Input:overlayPressed("a") end
Input:step()
screen:update(1 / 60)
if waiting then Input:overlayReleased("a") end
if screen.phase == "menu" then return true end
end
return false
+39 -21
View File
@@ -262,10 +262,21 @@ local function run(screen, frames)
end
end
-- One frame of a drain. Battle lines end in `prompt` and PromptButton waits
-- on A or B with no countdown (home/joypad.asm:383-412); the stats box waits
-- the same way (engine/battle/core.asm:7069), so a drain presses rather than
-- idling for a timer that never runs out.
local function drainStep(screen)
local waiting = (screen.messageTimer or 0) > 0 or screen.phase == "stats-box"
if waiting then Input:overlayPressed("a") end
Input:step()
screen:update(1 / 60)
if waiting then Input:overlayReleased("a") end
end
local function runToMenu(screen, cap)
for _ = 1, (cap or 3000) do
Input:step()
screen:update(1 / 60)
drainStep(screen)
if screen.phase == "menu" then return true end
end
return false
@@ -366,8 +377,7 @@ do
local crawled, restarted = false, false
for _ = 1, 3000 do
Input:step()
screen:update(1 / 60)
drainStep(screen)
if screen.shownLevel == 5 and (screen.shownExp or 0) > startExp then
crawled = true
end
@@ -404,8 +414,7 @@ do
local maxBefore = player.maxHp
screen:submit({ kind = "move", move = "TACKLE" })
for _ = 1, 3000 do
Input:step()
screen:update(1 / 60)
drainStep(screen)
if screen.phase == "done" then break end
end
check(player.maxHp > maxBefore, "the level-up raised the maximum")
@@ -601,8 +610,7 @@ do
local function runToPhase(screen, phase, cap)
for _ = 1, (cap or 4000) do
Input:step()
screen:update(1 / 60)
drainStep(screen)
if screen.phase == phase then return true end
end
return false
@@ -622,9 +630,15 @@ do
noBattle.enemy.hp = 1
local noTurn = noBattle.turn
noScreen:submit({ kind = "move", move = "TACKLE" })
check(runToPhase(noScreen, "ask-shift"), "the KO stops on OfferSwitch")
-- The `para` in BattleText_EnemyIsAboutToUseWillPlayerChangeMon splits the
-- offer (data/text/battle.asm:222-231): the incoming mon is named on an
-- earlier page, and only the last one carries the yes/no box (#1158).
check(runToPhase(noScreen, "shift-intro"), "the KO stops on OfferSwitch")
check((noScreen.message or ""):find("is about to use"),
"with BattleText_EnemyIsAboutToUseWillPlayerChangeMon")
check(runToPhase(noScreen, "ask-shift"), "and its pages reach the question")
check((noScreen.message or ""):find("change POK"),
"whose last page is the one YesNoBox opens over")
-- NO falls through to the send-out with nothing switched and nothing spent.
noScreen.messageTimer = 0
@@ -1083,8 +1097,7 @@ do
-- not ring is anything from the faint onward.
local latched, rangAfterFaint, sawExpLine = false, false, false
for _ = 1, 3000 do
Input:step()
screen:update(1 / 60)
drainStep(screen)
if screen.lowHealthAlarmDisabled then latched = true end
if latched and siren then rangAfterFaint = true end
if latched and (screen.message or ""):find("EXP") then sawExpLine = true end
@@ -1155,7 +1168,9 @@ do
eq(screen.phase, "refuse-move", "the disabled row is refused")
eq(screen.message, "The move is DISABLED!", "with BattleText_TheMoveIsDisabled")
eq(wild.hp, before, "and the enemy got no free turn")
run(screen, 120)
-- Both refusal lines end in `prompt` (data/text/battle.asm:315-323), so the
-- list comes back on a press, not on a timer.
tap("a")
eq(screen.phase, "moves", "the list comes back")
-- The same for a spent row.
@@ -1168,7 +1183,7 @@ do
eq(screen.message, "There's no PP left for this move!",
"with BattleText_TheresNoPPLeftForThisMove")
eq(wild.hp, before, "and still no enemy turn")
run(screen, 120)
tap("a")
eq(screen.phase, "moves", "and the list comes back again")
end
@@ -1221,8 +1236,7 @@ end
-- so the question is reached by pages rather than by one button.
local function runToPhase(screen, phase, cap)
for _ = 1, (cap or 900) do
Input:step()
screen:update(1 / 60)
drainStep(screen)
if screen.phase == phase then return true end
end
return false
@@ -1236,11 +1250,15 @@ do
check(runToPhase(screen, "ask-forget"), "and its pages run into the question")
eq(screen.message, "move to make room\nfor EMBER?",
"whose last page is the one YesNoBox opens over")
run(screen, 60)
local tap = tapper(screen)
-- The question's own `prompt` is read first and YesNoBox opens after it,
-- exactly as OfferSwitch does (engine/battle/core.asm:3298-3305), so the
-- answer is the second press, not the first.
tap("a")
eq(screen.phase, "ask-forget", "the last page holds until it is read")
tap("b")
eq(screen.phase, "stop-learning", "NO there asks whether to stop learning")
run(screen, 60)
tap("a")
tap("b")
eq(screen.phase, "learn-intro", "and NO to THAT reprints the ask")
check(runToPhase(screen, "ask-forget"), "which is a loop, not an exit")
@@ -1251,8 +1269,8 @@ end
do
local screen, lead = learnScreen()
check(runToPhase(screen, "ask-forget"), "the pages reach the question")
run(screen, 60)
local tap = tapper(screen)
tap("a") -- read the question
tap("a") -- YES
eq(screen.phase, "choose-forget", "YES opens the picker")
tap("a") -- slot 1
@@ -1263,9 +1281,9 @@ end
do
local screen, lead = learnScreen()
check(runToPhase(screen, "ask-forget"), "the pages reach the question")
run(screen, 60)
local tap = tapper(screen)
tap("a")
tap("a") -- read the question
tap("a") -- YES
eq(screen.phase, "choose-forget", "the picker is up")
screen.forgetIndex = 4
tap("a")
@@ -1273,7 +1291,7 @@ do
eq(screen.message, "HM moves can't be\nforgotten now.",
"with MoveCantForgetHMText")
eq(screen.forgetIndex, 1, "and `jr .loop` puts the cursor back on slot 1")
run(screen, 60)
tap("a")
eq(screen.phase, "choose-forget", "the picker is still up after the line")
eq(lead.moves[4].id, "SURF", "and SURF is still there")
end
+10 -5
View File
@@ -356,11 +356,14 @@ local optionsGame, optionsInput = newGame(Save.newGame())
local options = OptionsMenu.new(optionsGame, {
options = Save.defaultOptions(),
})
-- The cart's seven value rows, then the port's audio, speed and display
-- rows, then CANCEL -- which is what makes this screen scroll.
check("sixteen rows", #OptionsMenu.ROWS, 16)
-- The cart's seven value rows, then CONTROLS, the port's audio, speed and
-- display rows, the three touch rows and CANCEL -- which is what makes this
-- screen scroll. The touch three are gated to mobile by buildRows; ROWS
-- itself carries every descriptor.
check("twenty rows", #OptionsMenu.ROWS, 20)
check("the cart's rows come first", OptionsMenu.ROWS[7].key, "frame")
check("then the port's audio group", OptionsMenu.ROWS[8].key, "musicVol")
check("then the rebind screen", OptionsMenu.ROWS[8].id, "controls")
check("then the port's audio group", OptionsMenu.ROWS[9].key, "musicVol")
check("last row is CANCEL", OptionsMenu.ROWS[#OptionsMenu.ROWS].cancel, true)
check("starts on TEXT SPEED", options:row().key, "textSpeed")
check("default text speed", options.options.textSpeed, "MID")
@@ -401,7 +404,9 @@ local exiting = OptionsMenu.new(exitGame, {
options = Save.defaultOptions(),
onDone = function(o) savedOptions = o end,
})
exiting.index = #OptionsMenu.ROWS
-- The screen's own rows, not ROWS: buildRows drops the touch three off a
-- desktop, so the raw descriptor count overshoots CANCEL.
exiting.index = #exiting.rows
exitInput:press("a")
exiting:update(0)
check("CANCEL leaves", savedOptions ~= nil, true)
+5 -2
View File
@@ -17,14 +17,17 @@ local Nests = require("src.core.gen2.Nests")
-- Landmark indices from constants/landmark_constants.asm: Johto runs below
-- PALLET_TOWN ($2e), Kanto from it up to ROUTE_28.
local DATA = {
maps = {
-- gen2Maps, matching Game2's key for the Gold map table.
gen2Maps = {
ROUTE_29 = { landmark = 2 }, -- Johto
ILEX_FOREST = { landmark = 11 }, -- Johto
ROUTE_1 = { landmark = 0x2f }, -- Kanto
UNION_CAVE_1F = { landmark = 9 }, -- Johto
NOWHERE = { }, -- no landmark at all
},
encounters = {
-- Game2 loads the encounter tables under gen2Encounters, not `encounters`:
-- the flat name is Gen 1's and Gen2Compat only maps it for mods.
gen2Encounters = {
grass = {
ROUTE_29 = { slots = {
MORN = { { species = "PIDGEY" }, { species = "SENTRET" } },
+8 -5
View File
@@ -417,22 +417,25 @@ check("only the written byte is stored", memCount, 1)
local _, _, _, loadReport = Save.load("gold")
check("load reports a clean save", Save.emptyReport(loadReport), true)
-- A second write backs the first up, so the previous file is always recoverable.
-- A second write backs the first up, so the previous file is always
-- recoverable. The file lives at saves/<version>/<slot>.lua since Gold grew
-- launcher slots (#1107); the flat save_gold.lua is only the migration source.
local SLOT = "saves/gold/slot1.lua"
written.player.money = 5555
Save.save(written)
check("backup written", files["save_gold.lua.bak"] ~= nil, true)
check("backup written", files[SLOT .. ".bak"] ~= nil, true)
check("new value loads", Save.load("gold").player.money, 5555)
-- A corrupt main file falls back to the backup rather than losing the game.
files["save_gold.lua"] = "this is not a lua table"
files[SLOT] = "this is not a lua table"
local recoveredSave, how = Save.load("gold")
check("recovered from the backup", recoveredSave ~= nil, true)
check("recovery reported", how, "bak")
check("backup held the previous money", recoveredSave.player.money, 4321)
-- A staged .tmp is preferred over the backup: it is the newer of the two.
files["save_gold.lua"] = nil
files["save_gold.lua.tmp"] = files["save_gold.lua.bak"]
files[SLOT] = nil
files[SLOT .. ".tmp"] = files[SLOT .. ".bak"]
local staged, stagedHow = Save.load("gold")
check("recovered from the staged copy", staged ~= nil, true)
check("staged recovery reported", stagedHow, "tmp")
+2 -1
View File
@@ -3315,7 +3315,8 @@ local function grassLatchChecks()
check(hp.jumping, "and it is a jump")
check(not hp.grassShake, "no rustle spawns for the airborne cells")
check(not hp.inGrass, "and IN_GRASS is clear for the whole hop")
for _ = 1, PlayerMod.STEP_FRAMES + 1 do hopGrass:step() end
-- A hop clears two cells, so it runs two step-times, not one (#1165).
for _ = 1, PlayerMod.STEP_FRAMES * 2 + 1 do hopGrass:step() end
check(hp.inGrass, "the landing tile latches it once the hop ends")
end
grassLatchChecks()
@@ -48,10 +48,15 @@ function fs.load(path)
end
function fs.getDirectoryItems(path)
local items = {}
-- -print plus a basename in Lua, not -printf: that is a GNU extension and
-- BSD find (macOS) fails the whole call, which silently emptied the listing
-- and left the probe mod undiscovered.
local pipe = io.popen("find " .. quote(full(path))
.. " -mindepth 1 -maxdepth 1 -printf '%f\\n' 2>/dev/null")
.. " -mindepth 1 -maxdepth 1 -print 2>/dev/null")
if pipe then
for item in pipe:lines() do items[#items + 1] = item end
for item in pipe:lines() do
items[#items + 1] = item:match("[^/]+$") or item
end
pipe:close()
end
table.sort(items)
+12 -3
View File
@@ -21,6 +21,7 @@ local Net = require("src.link.Net")
local Pokemon = require("src.pokemon.Pokemon")
local Protocol = require("src.link.Protocol")
local Runtime = require("src.mods.Runtime")
local Session = require("src.link.Session")
local S = require("tests.harness").suite("mod link")
local check, eq = S.check, S.eq
@@ -529,11 +530,19 @@ local function linkGame(name, species, data)
return { data = data or Data, save = save, stack = stack, input = mkInput() }
end
-- LinkState talks to a Session, not to a raw transport (src/link/LinkState.lua
-- :75 wraps every backend the same way), so a loopback end has to be wrapped
-- here too or :update reaches for a getStatus the transport does not have.
local function linkSession(transport, role)
return Session.new(transport, { role = role, kind = "link" })
end
-- two paired states, host already listening and guest already dialling
local function pairStates(gameA, gameB)
local netA, netB = Net.loopbackPair()
local host, guest = LinkState.new(gameA), LinkState.new(gameB)
host.net, guest.net = netA, netB
host.net = linkSession(netA, "host")
guest.net = linkSession(netB, "guest")
host.stage, guest.stage = "hosting", "joining"
gameA.stack:push(host)
gameB.stack:push(guest)
@@ -573,7 +582,7 @@ check(host.trade.strict, "a v2 verdict unpacks strictly")
local gameOld = linkGame("RED", "PIDGEY")
local oldNet, peerNet = Net.loopbackPair()
local v1guest = LinkState.new(gameOld)
v1guest.net = oldNet
v1guest.net = linkSession(oldNet, "guest")
v1guest.stage = "joining"
gameOld.stack:push(v1guest)
v1guest:update(1 / 60)
@@ -591,7 +600,7 @@ check(not v1guest.trade.strict, "the v1 path keeps the old unpack rules")
local gameLone = linkGame("RED", "PIDGEY")
local loneNet, silentNet = Net.loopbackPair()
local v1host = LinkState.new(gameLone)
v1host.net = loneNet
v1host.net = linkSession(loneNet, "host")
v1host.stage = "hosting"
gameLone.stack:push(v1host)
v1host:update(1 / 60)
+16
View File
@@ -64,6 +64,17 @@ local function freshGame(mapX, mapY)
worldViewSize = function() return 160, 144 end,
setSGBZones = function() end,
}
-- OverworldState is a singleton and :enter does not clear the movement
-- latches, so a scenario that runs after a few thousand other checks can
-- inherit a half-finished script and never walk. Reset them here: this
-- suite asserts a frame budget, so it has to start from a known state.
OverworldState.scriptMoves = {}
OverworldState.pendingScripts = {}
OverworldState.emote = nil
OverworldState.engaging = false
OverworldState.teleportOut = nil
OverworldState.transitioning = nil
OverworldState.wildEncounterGraceSteps = 0
StateStack:push(OverworldState, "PALLET_TOWN", mapX, mapY, "up")
Game.overworld = OverworldState
return pressed
@@ -114,6 +125,11 @@ end
-- keeps running straight into the lab.
-- =====================================================================
scenario(function()
-- The trigger tile is in Pallet's north grass, so the escort walk rolls for
-- wild encounters as it goes. Pin the stream: run standalone this suite got
-- one draw sequence and run inside tests/run_tests.lua another, and one of
-- them dropped a battle on top of the escort and ate the frame budget.
math.randomseed(require("tests.harness").SEED)
GameVersion.set("red")
local pressed = freshGame(8, 2)
local played = {}
+12 -4
View File
@@ -2788,15 +2788,23 @@ do
press("down")
eq(om.index, 25, "cursor reaches CONTROLS")
press("down")
eq(om.index, 26, "CANCEL stays the fixed final row")
eq(om.scroll, 21, "CANCEL keeps the last option boxes on screen")
eq(om.index, 26, "cursor reaches DATE FORMAT")
press("down")
eq(om.index, 27, "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
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
press("a")
check(popped, "A on CANCEL closes the options menu")
local om2 = OptionsMenu.new(og)
OInput.pressed = { up = true }; om2:update(1 / 60); OInput.pressed = {}
eq(om2.index, 26, "up from the top wraps to CANCEL")
eq(om2.scroll, 21, "wrapping to CANCEL scrolls to the tail")
eq(om2.index, cancelRow, "up from the top wraps to CANCEL")
eq(om2.scroll, cancelRow - 5, "wrapping to CANCEL scrolls to the tail")
-- headless-safe: no love.audio, setters only update internal state
require("src.core.Music").applyOptions(og.save.options)
require("src.core.Sound").applyOptions(og.save.options)