This commit is contained in:
bryanthaboi
2026-08-19 06:41:12 -04:00
66 changed files with 1716 additions and 64 deletions
@@ -0,0 +1,80 @@
-- Test returning to launcher from Gen 1 and Gen 2 on Android without closing the process
-- luajit tests/engine/android_exit_to_launcher_test.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 TitleState = require("src.ui.TitleState")
local Gen2MainMenu = require("src.ui.gen2.MainMenu")
local Runtime = require("src.mods.Runtime")
-- 1. Test Gen 1 TitleState onExit callback support
do
local exitCalled = false
local dummyGame = {
data = { field = {} },
stack = {
states = {},
push = function(self, state) table.insert(self.states, state) end,
top = function(self) return self.states[#self.states] end,
pop = function(self) return table.remove(self.states) end,
},
}
local state = TitleState.new(dummyGame, {
onExit = function()
exitCalled = true
end,
})
state:openMenu()
local menu = dummyGame.stack:top()
check(menu ~= nil, "TitleState:openMenu opens a menu")
local exitItem = nil
for _, item in ipairs(menu.items or {}) do
if tostring(item.label):find("EXIT", 1, true) then
exitItem = item
break
end
end
check(exitItem ~= nil, "Gen 1 TitleState menu contains an EXIT GAME item")
if exitItem and exitItem.onSelect then
exitItem.onSelect()
end
check(exitCalled, "Selecting EXIT GAME in Gen 1 TitleState invokes onExit callback")
end
-- 2. Test Gen 2 MainMenu onExit callback support
do
local exitCalled = false
local dummyGame2 = {
data = {},
stack = {
states = {},
push = function(self, state) table.insert(self.states, state) end,
top = function(self) return self.states[#self.states] end,
pop = function(self) return table.remove(self.states) end,
},
}
local menu = Gen2MainMenu.new(dummyGame2, {
hasSave = false,
onExit = function()
exitCalled = true
end,
})
menu:choose("exit")
check(exitCalled, "Selecting EXIT GAME in Gen 2 MainMenu invokes onExit callback")
end
-- 3. Test Runtime.reset restores NullEvents and NullHooks
do
Runtime.install({ emit = function() end }, { call = function() end }, { "err" })
check(Runtime.errors ~= nil, "Runtime has errors list after install")
Runtime.reset()
check(Runtime.errors == nil, "Runtime.reset clears errors")
check(Runtime.currentMod == nil, "Runtime.reset clears currentMod")
check(Runtime.modRequire == nil, "Runtime.reset clears modRequire")
end
T.finish("android_exit_to_launcher_test")
@@ -0,0 +1,81 @@
-- tests/engine/android_shortcuts_payload_test.lua
-- Tests Android dynamic shortcuts synchronization, launch options intent resolution,
-- and love.handlers.intent_game in-process game hot-swapping.
-- luajit tests/engine/android_shortcuts_payload_test.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")
require("main")
-- 1. Verify LaunchOptions handles getLaunchGame on Android
local LaunchOptions = require("src.core.LaunchOptions")
local savedOS = love.system and love.system.getOS
local savedGetLaunchGame = love.system and love.system.getLaunchGame
love.system = love.system or {}
love.system.getOS = function() return "Android" end
love.system.getLaunchGame = function() return "gold" end
local game, slot = LaunchOptions.resolve({})
check(game == "gold", "LaunchOptions resolves intent game from love.system.getLaunchGame on Android")
local gameCli, slotCli = LaunchOptions.resolve({ "--game=red" })
check(gameCli == "red", "CLI --game flag overrides intent game")
-- 2. Verify RomImporter.syncAndroidShortcuts ranking and 4-item cap
local RomImporter = require("src.import.RomImporter")
local originalIsReady = RomImporter.isReady
local capturedShortcuts = nil
love.system.updateShortcuts = function(versions)
capturedShortcuts = versions
return true
end
-- Mock isReady
RomImporter.isReady = function(v)
return v == "red" or v == "gold" or v == "blue" or v == "yellow"
end
local ok = RomImporter.syncAndroidShortcuts("gold")
check(ok == true, "syncAndroidShortcuts returns true on Android")
check(#capturedShortcuts == 4, "syncAndroidShortcuts caps at 4 items")
check(capturedShortcuts[1] == "gold", "activeVersion 'gold' is placed first")
-- Test with subset of ready games (e.g. only Red and Gold)
RomImporter.isReady = function(v)
return v == "red" or v == "gold"
end
capturedShortcuts = nil
RomImporter.syncAndroidShortcuts("red")
check(#capturedShortcuts == 2, "syncAndroidShortcuts only includes ready ROMs")
check(capturedShortcuts[1] == "red" and capturedShortcuts[2] == "gold", "ready ROMs correctly passed")
-- Test on non-Android platform (safe no-op)
love.system.getOS = function() return "Linux" end
capturedShortcuts = nil
local nonAndroidOk = RomImporter.syncAndroidShortcuts("red")
check(nonAndroidOk == false, "syncAndroidShortcuts safely no-ops on non-Android")
check(capturedShortcuts == nil, "no shortcuts updated on non-Android")
-- 3. Verify love.handlers.intent_game definition
check(type(love.handlers.intent_game) == "function", "main.lua defines love.handlers.intent_game")
-- Restore
RomImporter.isReady = originalIsReady
if savedOS then
love.system.getOS = savedOS
else
love.system.getOS = nil
end
love.system.getLaunchGame = savedGetLaunchGame
love.system.updateShortcuts = nil
print("8/8 checks passed (android_shortcuts_payload_test)")
+2 -1
View File
@@ -397,7 +397,8 @@ local GEN2_HOOKS = {
"world.tod", "map.palette", "fieldmove.eligibility",
-- menus and the battle intro
"ui.start_menu.items", "ui.title_menu.items", "ui.options.rows",
"ui.party.submenu", "ui.naming.grid", "ui.pc.items", "ui.list_menu",
"ui.party.submenu", "ui.party.grid_navigation", "ui.naming.grid",
"ui.pc.items", "ui.list_menu",
"transition.style",
-- battle
"battle.damage", "battle.crit", "battle.accuracy", "battle.turn_order",
@@ -0,0 +1,106 @@
-- BoxMenu's Yellow-only "Pikachu looks unhappy" release path
-- (release()'s isYellow()/species=="PIKACHU"/otId/ot branch) pushes its
-- TextBox with `TextBox.new(game, (...):gsub(...))` -- the gsub call is
-- the last argument, unparenthesized, so Lua expands its second return
-- value (the substitution count) into TextBox.new's third parameter,
-- onDone. TextBox.lua later calls onDone() unconditionally once the box
-- is dismissed, and a number is not callable: every release of your own
-- caught Pikachu in Yellow crashed, regardless of its nickname (unlike
-- the separate %-escape gsub bug, this needs no special save content --
-- ordinary play reaches it every time). ROM-free: registers a fake
-- Data.pokemon.PIKACHU cloned from the fixture species so the species ==
-- "PIKACHU" check can be exercised without a real ROM import.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
local ids = T.fixtures.ids
require("src.render.Font").load(Data)
-- clone a real fixture species under the literal id release() checks for
Data.pokemon.PIKACHU = Data.pokemon[ids.species[1]]
local Pokemon = require("src.pokemon.Pokemon")
local Boxes = require("src.pokemon.Boxes")
local TextBox = require("src.render.TextBox")
local BoxMenu = require("src.ui.BoxMenu")
local ListMenu = require("src.ui.ListMenu")
local ChoiceBox = require("src.ui.ChoiceBox")
local SaveData = require("src.core.SaveData")
local GameVersion = require("src.core.GameVersion")
local Sound = require("src.core.Sound")
local realCry, realPlay = Sound.playCry, Sound.play
Sound.playCry = function() end
Sound.play = function() end
local stack = { states = {} }
function stack:push(s) self.states[#self.states + 1] = s end
function stack:pop()
local t = self.states[#self.states]
self.states[#self.states] = nil
return t
end
function stack:top() return self.states[#self.states] end
function stack:update(dt)
local t = self:top()
if t and t.update then t:update(dt) end
end
local pressed = {}
local function press(btn)
pressed = { [btn] = true }
stack:update(1 / 60)
pressed = {}
end
local function topMt() return getmetatable(stack:top()) end
local function mash(btn, cond, n)
for _ = 1, (n or 400) do
if cond() then return true end
press(btn)
end
return false
end
GameVersion.set("yellow")
local save = SaveData.newGame()
local game = {
data = Data,
save = save,
stack = stack,
input = {
wasPressed = function(_, key) return pressed[key] or false end,
isDown = function() return false end,
},
}
game.save.options = game.save.options or {}
game.save.options.textSpeed = 1
local box = Boxes.active(save)
local mon = Pokemon.new(Data, "PIKACHU", 5)
mon.otId = save.player.id
mon.ot = save.player.name
box[1] = mon
stack:push(BoxMenu.new(game))
press("down"); press("down"); press("a") -- open RELEASE list
T.check(topMt() == ListMenu, "RELEASE opens the box list")
-- release() calls Sound.playCry (stubbed) then pushes the "unhappy"
-- TextBox before any confirmation prompt -- pre-fix this line itself
-- raises "attempt to call field 'onDone' (a number value)" the moment
-- TextBox.new stores the leaked count and something dismisses the box.
local ok, err = pcall(function()
press("a") -- choose the Pikachu; release() runs synchronously here
T.check(topMt() == TextBox, "the unhappy-Pikachu TextBox opens directly, no confirm prompt")
-- dismiss it: this is what calls onDone, which is where the pre-fix
-- leaked count used to crash
mash("a", function() return topMt() ~= TextBox end)
end)
T.check(ok, "releasing your own caught Pikachu in Yellow does not crash: " .. tostring(err))
GameVersion.set("red")
Sound.playCry, Sound.play = realCry, realPlay
T.finish("pikachu_unhappy_release_crash")
@@ -26,5 +26,7 @@ check(helperStart ~= nil, "requiredFilesFor helper exists")
local helper = src:sub(helperStart, start)
check(helper:find("VERSION_REQUIRED_FILES_OVERRIDE", 1, true) ~= nil,
"requiredFilesFor consults VERSION_REQUIRED_FILES_OVERRIDE")
check(src:find('"assets/generated/battle/hud/balls.png"', 1, true) ~= nil,
"Gold caches require the trainer HUD ball sheet")
T.finish()
+137
View File
@@ -0,0 +1,137 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local S = require("tests.harness").suite("safe mode and issue report")
local check = S.check
local SaveData = require("src.core.SaveData")
local LauncherMods = require("src.mods.LauncherMods")
local IssueReport = require("src.core.IssueReport")
local Version = require("src.core.Version")
local options = SaveData.defaultOptions()
check(not SaveData.isSafeMode(options), "safe mode defaults off")
SaveData.setSafeMode(options, true)
check(SaveData.isSafeMode(options), "safe mode can be enabled")
local manifests = {
{ id = "alpha", name = "Alpha", version = "1.0.0", experimental = false,
raw = {}, dependencySpecs = {}, conflictSpecs = {} },
{ id = "beta", name = "Beta", version = "1.0.0", experimental = false,
raw = {}, dependencySpecs = {}, conflictSpecs = {} },
}
options.mods.alpha = false
options.mods.beta = true
local rows = LauncherMods.deriveList(manifests, options, "red")
check(#rows == 2, "safe mode keeps installed mods visible")
check(not rows[1].enabled and not rows[2].enabled,
"safe mode disables every launcher mod row")
check(rows[1].status == "safe_mode" and rows[2].status == "safe_mode",
"safe mode explains every disabled launcher row")
SaveData.setSafeMode(options, false)
rows = LauncherMods.deriveList(manifests, options, "red")
local byId = {}
for _, row in ipairs(rows) do byId[row.id] = row end
check(not byId.alpha.enabled and byId.beta.enabled,
"turning safe mode off restores saved mod choices")
local previousLove = _G.love
local openedURL
_G.love = {
getVersion = function() return 12, 0, 0, "Mysterious Mysteries" end,
system = {
getOS = function() return "iOS" end,
getModel = function() return "iPad Test" end,
openURL = function(url) openedURL = url end,
},
graphics = {
getRendererInfo = function()
return "Metal", "3.0", "Apple", "Simulator GPU"
end,
getDimensions = function() return 1024, 768 end,
getPixelDimensions = function() return 2048, 1536 end,
},
window = {
getMode = function() return 1024, 768, { fullscreen = false } end,
},
}
local url, fields, info = IssueReport.build({
safeMode = true,
lastVersion = "gold",
}, {
version = "gold",
mods = { { id = "alpha", name = "Alpha", enabled = true } },
})
check(url:find("template=bug_report.yml", 1, true) ~= nil,
"report URL selects the bug form")
check(url:find("title=bug%3A%20replace%20this%20with%20a%20meaningful%20title", 1, true) ~= nil,
"report URL uses the requested bug title")
check(info.os == "iOS",
"report metadata maps the platform")
check(fields.mods_which == "",
"safe mode leaves the optional mod list blank")
check(not url:find("game=", 1, true)
and not url:find("os=", 1, true)
and not url:find("mods_enabled=", 1, true),
"report URL omits unsupported dropdown and checkbox prefills")
check(fields.summary == "" and fields.location == "" and fields.screenshot == ""
and fields.steps == "" and fields.expected == "",
"report leaves user-entered fields blank")
check(info.metadata:find("Device: iPad Test", 1, true) ~= nil
and info.metadata:find("LÖVE: 12.0.0", 1, true) ~= nil
and info.metadata:find("Safe mode: on", 1, true) ~= nil,
"report metadata includes device and app details")
check(not info.metadata:find("unknown", 1, true),
"report metadata omits unknown values")
check(not info.metadata:find("Game id", 1, true)
and not info.metadata:find("Game:", 1, true)
and not info.metadata:find("Mods:", 1, true)
and not info.metadata:find("Processors", 1, true)
and not info.metadata:find("Power", 1, true),
"report metadata omits redundant system fields")
local previousEngine = Version.engine
Version.engine = "0.1.50"
local _, versionFields, versionInfo = IssueReport.build({}, { mods = {} })
check(versionFields.version == "0.1.50"
and versionInfo.metadata:find("App: gen1recomp v0.1.50", 1, true) ~= nil,
"report uses the stamped app version")
Version.engine = previousEngine
local _, _, developmentInfo = IssueReport.build({}, { mods = {} })
check(not developmentInfo.metadata:find("0.0.0-dev", 1, true),
"report omits an unstamped development version")
local previousOS = love.system.getOS
local previousModel = love.system.getModel
local previousIO = _G.io
love.system.getOS = function() return "OS X" end
love.system.getModel = nil
_G.io = {
popen = function()
return {
read = function() return "MacBookPro18,3" end,
close = function() end,
}
end,
}
local desktopInfo = IssueReport.metadata({}, { mods = {} })
check(desktopInfo.device == "MacBookPro18,3",
"report finds desktop device model when LOVE has no model")
love.system.getOS = function() return "UWP" end
local xboxInfo = IssueReport.metadata({}, { mods = {} })
check(xboxInfo.os == "Xbox", "report maps the Xbox runtime platform")
love.system.getOS = previousOS
love.system.getModel = previousModel
_G.io = previousIO
local opened = IssueReport.open({ safeMode = false }, {
version = "red",
mods = {},
})
check(opened and openedURL and openedURL:find("title=bug%3A%20replace%20this%20with%20a%20meaningful%20title", 1, true) ~= nil,
"report action opens the generated URL")
_G.love = previousLove
S.finish()
@@ -0,0 +1,166 @@
-- SummaryMenu.lua:148 and PartyMenu.lua:824 used to draw mon.status as a
-- bare literal ("PSN", "PAR", "BRN", "FRZ", "SLP"), invisible to any
-- translation a mod supplies. Unlike the strings catalog, a mod translates
-- status abbreviations through the statuses content registry
-- (mod.content.statuses:patch(id, { label = value }), label only), the
-- same registry src/battle/BattleState.lua:statusLabel already reads in
-- battle. This test drives both screens' status draw with a mod-patched
-- registry and checks the patched label reaches Font.draw, not the raw
-- status id.
--
-- It also guards a second bug found alongside the first: Status.RECORDS'
-- five vanilla entries used to set hudLabel to the same literal as label
-- ("FRZ", hudLabel = "FRZ", ...). Since Status.hudLabelFor (and
-- BattleState:statusLabel before it) reads "hudLabel or label", and
-- Registry:patch only overrides the fields a mod actually passes, a
-- real label-only patch left the untouched vanilla hudLabel shadowing it
-- forever -- the translation was stored but never displayed anywhere,
-- in or out of battle.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
love = love or {}
love.graphics = {
setColor = function() end,
rectangle = function() end,
draw = function() end,
push = function() end, pop = function() end,
translate = function() end, scale = function() end,
}
package.loaded["src.render.Font"] = {
draw = function() end,
drawCode = function() end,
drawBox = function() end,
}
package.loaded["src.render.HudTiles"] = {
statusTile = function() end,
tile = function() end,
drawHPBar = function() end,
}
package.loaded["src.render.PaletteFX"] = {
shader = function() return nil end,
pal = function() return nil end,
markTrueColor = function() end,
}
package.loaded["src.ui.Theme"] = { cursor = 0, cursorHollow = 0 }
package.loaded["src.render.Assets"] = {}
package.loaded["src.world.FieldDefaults"] = {}
package.loaded["src.world.Map"] = {}
package.loaded["src.mods.Runtime"] = { wantsHook = function() return false end }
package.loaded["src.ui.Screens"] = {}
package.loaded["src.core.Logger"] = { warn = function() end }
local Status = require("src.battle.Status")
-- a mod's registered status translation, same shape mod.content.statuses:
-- patch(id, { label = ..., hudLabel = ... }) merges into Data.statuses
local function moddedStatuses()
local statuses = {}
for id, record in pairs(Status.RECORDS) do statuses[id] = record end
statuses.PSN = { id = "PSN", label = "PSN", hudLabel = "TOX" }
return statuses
end
local Font = package.loaded["src.render.Font"]
local drawn
local origDraw = Font.draw
Font.draw = function(text, x, y)
drawn[#drawn + 1] = { text = text, x = x, y = y }
return origDraw(text, x, y)
end
local function mkDef()
return { name = "BULBASAUR", dex = 1, types = { "GRASS" } }
end
local function mkMon(status)
return {
nickname = "SAUR", species = "BULBASAUR", level = 5,
hp = 10, stats = { hp = 10, attack = 5, defense = 5, speed = 5, special = 5 },
status = status,
}
end
-- ---- SummaryMenu: page 1's STATUS/ line (~line 148-151) ----
do
local SummaryMenu = assert(loadfile("src/ui/SummaryMenu.lua"))()
local game = {
data = { pokemon = { BULBASAUR = mkDef() }, statuses = moddedStatuses() },
save = { player = { id = 1, name = "RED" } },
}
local menu = setmetatable(
{ game = game, mon = mkMon("PSN"), page = 1 }, SummaryMenu)
drawn = {}
menu:draw()
local statusDraw
for _, d in ipairs(drawn) do
if d.x == 128 and d.y == 48 then statusDraw = d end
end
T.check(statusDraw ~= nil, "SummaryMenu draws a status label at (128,48)")
T.eq(statusDraw.text, "TOX",
"SummaryMenu draws the mod-patched hudLabel, not the raw status id")
end
-- vanilla (no mod): falls back to the plain id, same as before the fix
do
local SummaryMenu = assert(loadfile("src/ui/SummaryMenu.lua"))()
local game = {
data = { pokemon = { BULBASAUR = mkDef() }, statuses = nil },
save = { player = { id = 1, name = "RED" } },
}
local menu = setmetatable(
{ game = game, mon = mkMon("PSN"), page = 1 }, SummaryMenu)
drawn = {}
menu:draw()
local statusDraw
for _, d in ipairs(drawn) do
if d.x == 128 and d.y == 48 then statusDraw = d end
end
T.eq(statusDraw.text, "PSN",
"SummaryMenu still shows the vanilla PSN label with no mod loaded")
end
-- ---- PartyMenu: the roster row's status column (~line 824-827) ----
do
local PartyMenu = assert(loadfile("src/ui/PartyMenu.lua"))()
PartyMenu.drawIcon = function() end
local game = {
data = { pokemon = { BULBASAUR = mkDef() }, statuses = moddedStatuses(),
text = {} },
save = { party = { mkMon("PSN") } },
}
local list = setmetatable({ game = game, index = 1 }, PartyMenu)
drawn = {}
list:draw()
local statusDraw
for _, d in ipairs(drawn) do
if d.x == 136 then statusDraw = d end
end
T.check(statusDraw ~= nil, "PartyMenu draws a status label at x=136")
T.eq(statusDraw.text, "TOX",
"PartyMenu draws the mod-patched hudLabel, not the raw status id")
end
-- ---- real Registry:patch, not a hand-built table: a label-only patch (the
-- shape a translation mod would send for every one of the 5 vanilla
-- statuses) must reach the HUD despite the vanilla record already
-- defining hudLabel ----
do
local Registry = require("src.mods.Registry")
local reg = Registry.new("statuses", { semantics = "record", target = "statuses" })
reg.base = function() return Status.RECORDS end
local LABEL_ONLY_PATCH = { SLP = "SOM", FRZ = "GEL", PSN = "PSN", BRN = "BRU", PAR = "PAR" }
for id, translated in pairs(LABEL_ONLY_PATCH) do
reg:patch(id, { label = translated }, "mod")
end
local merged = {}
for id in pairs(Status.RECORDS) do merged[id] = reg:get(id) end
for id, translated in pairs(LABEL_ONLY_PATCH) do
T.eq(Status.hudLabelFor(merged, id), translated,
"a label-only mod patch on " .. id .. " reaches the HUD label")
end
end
T.finish("status_abbreviation_translation_test")
+11
View File
@@ -14,6 +14,7 @@ local NamingScreen = require("src.ui.NamingScreen")
local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox")
local PartyMenu = require("src.ui.PartyMenu")
local Gen2PartyMenu = require("src.ui.gen2.PartyMenu")
local Player = require("src.world.Player")
local Music = require("src.core.Music")
@@ -295,6 +296,16 @@ do
menu:update(0)
check(menu.index == 4,
"removing the hook restores native list navigation immediately")
local gold = Gen2PartyMenu.new(game, { battle = true })
unsub = wrap("ui.party.grid_navigation", function() return true end)
gold:update(0)
check(gold.index == 3,
"a Gold battle party can follow the same companion grid")
unsub()
gold:update(0)
check(gold.index == 4,
"Gold restores native party list navigation without the hook")
end
-- ------- music.volume (distance / indoor muffling)