mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-09-26 13:33:27 -04:00
d2fdd3becc
# Conflicts: # src/ui/game3/start_menu.lua
333 lines
12 KiB
Lua
333 lines
12 KiB
Lua
#!/usr/bin/env luajit
|
|
-- Gen 3 MODS entry + FRLG mod-manager shell (docs/compose/spec/game3-mods-menu.md).
|
|
-- ROM-free: pure option rows, start-menu gating, ManagerState row models, and
|
|
-- the game3.stack push/close path. No GBA cache required.
|
|
|
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
|
local function romTextKey(n, i, j) return j and (n .. "[" .. i .. "][" .. j .. "]") or (n .. "[" .. i .. "]") end
|
|
local function romTextPlain(key) return key end
|
|
package.loaded["src.core.game3.rom_text"] = {
|
|
plain = romTextPlain, box = romTextPlain, ascii = romTextPlain, has = function() return true end,
|
|
key = romTextKey, at = function(n, i, j) return romTextKey(n, i, j) end,
|
|
count = function() return 0 end, list = function() return {} end,
|
|
lazy = function(map) return setmetatable({}, { __index = function(_, k) return map[k] end }) end,
|
|
}
|
|
|
|
local love = _G.love or require("tests.love_stub")
|
|
_G.love = love
|
|
|
|
local failed = 0
|
|
local function check(cond, msg)
|
|
if cond then
|
|
print("[ok] " .. msg)
|
|
else
|
|
failed = failed + 1
|
|
print("[FAIL] " .. msg)
|
|
end
|
|
end
|
|
|
|
print("[test] 1. OPTION row is always present with installed count")
|
|
local Rows = require("src.ui.game3.option_rows")
|
|
local function optionRow(ctx)
|
|
for _, row in ipairs(Rows.build(ctx)) do
|
|
if row.id == "mods" then return row end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
local vanillaCtx = {
|
|
options = { musicVol = 7, sfxVol = 7, musicFilter = 0, buttonMode = 0 },
|
|
game = { modStatus = { available = {} } },
|
|
}
|
|
local modsRow = optionRow(vanillaCtx)
|
|
check(modsRow ~= nil, "mods row present on vanilla install")
|
|
check(modsRow and modsRow.activate ~= nil and modsRow.step == nil,
|
|
"mods row is activate-only")
|
|
check(modsRow and tostring(modsRow.value(vanillaCtx)):find("0"),
|
|
"vanilla value reports 0 INSTALLED (got " ..
|
|
tostring(modsRow and modsRow.value(vanillaCtx)) .. ")")
|
|
|
|
local withModsCtx = {
|
|
options = vanillaCtx.options,
|
|
game = {
|
|
modStatus = {
|
|
available = {
|
|
{ id = "a", name = "A", category = "TWEAK", enabled = true, state = "loaded" },
|
|
{ id = "b", name = "B", category = "TWEAK", enabled = true, state = "loaded" },
|
|
},
|
|
},
|
|
},
|
|
}
|
|
local modsRow2 = optionRow(withModsCtx)
|
|
check(modsRow2 and tostring(modsRow2.value(withModsCtx)):find("2"),
|
|
"value reports 2 INSTALLED")
|
|
|
|
local orderHasMods = false
|
|
for _, id in ipairs(Rows.ORDER) do
|
|
if id == "mods" then orderHasMods = true end
|
|
end
|
|
check(orderHasMods, "mods listed in Rows.ORDER")
|
|
|
|
print("[test] 2. START menu MODS gate (normal field only)")
|
|
local StartMenu = require("src.ui.game3.start_menu")
|
|
local function entryIds()
|
|
local out = {}
|
|
for _, e in ipairs(StartMenu.ENTRIES or {}) do out[#out + 1] = e.id end
|
|
return out
|
|
end
|
|
local function hasId(list, id)
|
|
for _, v in ipairs(list) do
|
|
if v == id then return true end
|
|
end
|
|
return false
|
|
end
|
|
|
|
StartMenu._game = { modStatus = { available = {} } }
|
|
StartMenu.show({ session = { bag = {}, party = {} }, game = StartMenu._game })
|
|
local ids = entryIds()
|
|
check(not hasId(ids, "mods"), "vanilla START has no MODS row")
|
|
check(hasId(ids, "option") and hasId(ids, "exit"), "vanilla START keeps OPTION/EXIT")
|
|
StartMenu.close(true)
|
|
|
|
StartMenu._game = {
|
|
modStatus = {
|
|
available = {
|
|
{ id = "a", name = "A", category = "TWEAK", enabled = true, state = "loaded" },
|
|
},
|
|
},
|
|
}
|
|
StartMenu.show({ session = { bag = {}, party = {} }, game = StartMenu._game })
|
|
ids = entryIds()
|
|
check(hasId(ids, "mods"), "START shows MODS when mods are discovered")
|
|
local oi, mi, ei
|
|
for i, id in ipairs(ids) do
|
|
if id == "option" then oi = i end
|
|
if id == "mods" then mi = i end
|
|
if id == "exit" then ei = i end
|
|
end
|
|
check(oi and mi and ei and oi < mi and mi < ei, "MODS sits after OPTION, before EXIT")
|
|
StartMenu.close(true)
|
|
|
|
print("[test] 3. Manager list rows come from ManagerState.modRows")
|
|
local ManagerState = require("src.mods.ManagerState")
|
|
local statusStub = withModsCtx.game.modStatus
|
|
local modsStub = {
|
|
status = function() return statusStub end,
|
|
}
|
|
local mgrGame = {
|
|
modStatus = statusStub,
|
|
mods = modsStub,
|
|
save = { options = {} },
|
|
stack = { pop = function() end },
|
|
input = { wasPressed = function() return false end },
|
|
}
|
|
local mgr = ManagerState.new(mgrGame)
|
|
mgr:refresh()
|
|
local listRows = mgr:modRows()
|
|
local sawHeader, sawA = false, false
|
|
for _, row in ipairs(listRows) do
|
|
if row.header then sawHeader = true end
|
|
if row.mod and row.mod.id == "a" then sawA = true end
|
|
end
|
|
check(sawHeader and sawA, "modRows groups by category and lists mods")
|
|
check(mgr:rowsForScreen() ~= nil, "rowsForScreen exposes the model to Gen 3 chrome")
|
|
|
|
print("[test] 4. Toggle path still uses ManagerState.resolveToggle")
|
|
local r = ManagerState.resolveToggle({
|
|
a = { id = "a", dependencySpecs = {}, conflictSpecs = {} },
|
|
}, "a", true, {})
|
|
check(r.apply.a == true, "resolveToggle enable lands on the target")
|
|
check(#r.alsoEnable == 0, "no phantom cascade for a leaf mod")
|
|
|
|
print("[test] 5. ModManager pushes/pops game3.stack")
|
|
local Stack = require("src.ui.game3.stack")
|
|
local ModManager = require("src.ui.game3.mod_manager")
|
|
local function stubGame(available)
|
|
local status = { available = available or statusStub.available }
|
|
return {
|
|
modStatus = status,
|
|
mods = { status = function() return status end },
|
|
save = { options = {} },
|
|
options = {},
|
|
input = { wasPressed = function() return false end },
|
|
writeOptions = function() end,
|
|
returnToTitle = function() end,
|
|
}
|
|
end
|
|
Stack.clear()
|
|
check(not ModManager.isOpen(), "closed before show")
|
|
local openerDepthAfterShow
|
|
do
|
|
-- opener stays under the manager so B-close can return to it
|
|
Stack.push("start", StartMenu, { hideBelow = false })
|
|
ModManager.show({ game = stubGame() })
|
|
openerDepthAfterShow = Stack.depth()
|
|
end
|
|
check(ModManager.isOpen(), "open after show")
|
|
check(Stack.has("mod_manager"), "mod_manager layer on the game3 stack")
|
|
check(openerDepthAfterShow == 2, "opener still on the stack under the manager")
|
|
ModManager.draw()
|
|
check(true, "draw with FRLG chrome did not raise")
|
|
ModManager.handleInput({ wasPressed = function() return false end })
|
|
check(ModManager.isOpen(), "inert input keeps the manager open")
|
|
-- B at the manager root is ManagerState:goBack → proxy stack:pop → close
|
|
ModManager.handleInput({
|
|
wasPressed = function(_, btn) return btn == "b" end,
|
|
})
|
|
check(not ModManager.isOpen(), "B at root closes the manager")
|
|
check(not Stack.has("mod_manager"), "close pops the manager layer")
|
|
check(Stack.has("start") and Stack.depth() == 1,
|
|
"stack depth returns to the opener")
|
|
Stack.clear()
|
|
|
|
print("[test] 6. START confirm and OPTION activate open the manager")
|
|
StartMenu._game = stubGame()
|
|
StartMenu.show({ session = { bag = {}, party = {} }, game = StartMenu._game })
|
|
local modsIndex
|
|
for i, e in ipairs(StartMenu.ENTRIES) do
|
|
if e.id == "mods" then modsIndex = i end
|
|
end
|
|
check(modsIndex ~= nil, "MODS entry present before confirm")
|
|
StartMenu.cursor = modsIndex
|
|
StartMenu.confirm()
|
|
check(ModManager.isOpen() and Stack.has("mod_manager"),
|
|
"START confirm on MODS opens the manager")
|
|
ModManager.close()
|
|
StartMenu.close(true)
|
|
|
|
local activateRow = optionRow(withModsCtx)
|
|
local activated = false
|
|
local actGame = stubGame()
|
|
activateRow.activate({ game = actGame, session = {}, options = vanillaCtx.options })
|
|
check(ModManager.isOpen(), "OPTION activate opens the manager")
|
|
ModManager.close()
|
|
check(not ModManager.isOpen(), "manager closed after OPTION path")
|
|
|
|
print("[test] 7. Link / Union / Safari start lists stay retail")
|
|
package.loaded["src.core.game3.link"] = { link = {}, inLinkRoom = function() return true end }
|
|
StartMenu.show({ session = { bag = {}, party = {} }, game = stubGame() })
|
|
check(not hasId(entryIds(), "mods"), "link start menu has no MODS")
|
|
StartMenu.close(true)
|
|
package.loaded["src.core.game3.link"] = nil
|
|
|
|
package.loaded["src.core.game3.map"] = { current = "FR_UNION_ROOM" }
|
|
StartMenu.show({ session = { bag = {}, party = {}, map = "FR_UNION_ROOM" }, game = stubGame() })
|
|
check(not hasId(entryIds(), "mods"), "union room start menu has no MODS")
|
|
StartMenu.close(true)
|
|
package.loaded["src.core.game3.map"] = nil
|
|
|
|
package.loaded["src.core.game3.safari"] = { isActive = function() return true end }
|
|
StartMenu.show({ session = { bag = {}, party = {} }, game = stubGame() })
|
|
check(not hasId(entryIds(), "mods"), "safari start menu has no MODS")
|
|
StartMenu.close(true)
|
|
package.loaded["src.core.game3.safari"] = nil
|
|
|
|
print("[test] 8. Nested NamingScreen / QuantityBox are bridged, not dropped")
|
|
local NamingScreen = require("src.ui.NamingScreen")
|
|
local QuantityBox = require("src.ui.QuantityBox")
|
|
local named, qtyDone = nil, nil
|
|
local g = stubGame()
|
|
ModManager.show({ game = g })
|
|
local mproxy = ModManager._mgr.game
|
|
mproxy.stack:push(NamingScreen.new(mproxy, {
|
|
title = "NAME?", maxLen = 10,
|
|
onDone = function(name) named = name end,
|
|
}))
|
|
check(named == nil and require("src.ui.game3.naming").isOpen(),
|
|
"NamingScreen routes to Gen 3 naming modal")
|
|
require("src.ui.game3.naming").close("ALPHA")
|
|
check(named == "ALPHA", "naming onDone delivers the typed name")
|
|
|
|
mproxy.stack:push(QuantityBox.new(mproxy, {
|
|
max = 9, start = 3,
|
|
onDone = function(q) qtyDone = q end,
|
|
}))
|
|
check(ModManager._prompt and ModManager._prompt.kind == "qty",
|
|
"QuantityBox becomes an FRLG qty prompt")
|
|
ModManager.handleInput({ wasPressed = function(_, b) return b == "a" end })
|
|
check(qtyDone == 3, "qty prompt confirms the current value")
|
|
ModManager.close()
|
|
|
|
print("[test] 9. APPLY & RESTART soft-returns to Gen 3 title")
|
|
local returned = false
|
|
local g2 = stubGame()
|
|
g2.returnToTitle = function() returned = true end
|
|
ModManager.show({ game = g2 })
|
|
ModManager._mgr:restartGame()
|
|
check(returned, "restartGame calls Game3:returnToTitle")
|
|
check(not ModManager.isOpen(), "restart closes the manager (open flag not stuck)")
|
|
-- A second show must work after restart without a manual close
|
|
ModManager.show({ game = stubGame() })
|
|
check(ModManager.isOpen(), "manager can re-open after APPLY & RESTART")
|
|
ModManager.close()
|
|
|
|
print("[test] 10. Options scroll uses ManagerState's 0-based window")
|
|
do
|
|
local g3 = stubGame({
|
|
{ id = "o1", name = "O1", category = "TWEAK", enabled = true, state = "loaded" },
|
|
{ id = "o2", name = "O2", category = "TWEAK", enabled = true, state = "loaded" },
|
|
{ id = "o3", name = "O3", category = "TWEAK", enabled = true, state = "loaded" },
|
|
{ id = "o4", name = "O4", category = "TWEAK", enabled = true, state = "loaded" },
|
|
{ id = "o5", name = "O5", category = "TWEAK", enabled = true, state = "loaded" },
|
|
{ id = "o6", name = "O6", category = "TWEAK", enabled = true, state = "loaded" },
|
|
})
|
|
ModManager.show({ game = g3 })
|
|
local m = ModManager._mgr
|
|
m.screen = "options"
|
|
m.optionRows = {
|
|
{ id = "a", label = "A", value = function() return "1" end },
|
|
{ id = "b", label = "B", value = function() return "2" end },
|
|
{ id = "c", label = "C", value = function() return "3" end },
|
|
{ id = "d", label = "D", value = function() return "4" end },
|
|
{ id = "e", label = "E", value = function() return "5" end },
|
|
{ id = "f", label = "F", value = function() return "6" end },
|
|
}
|
|
m.cursor = 5
|
|
m.scroll = 1
|
|
ModManager.draw()
|
|
check(true, "options draw with 0-based scroll did not raise")
|
|
check((m.scroll or 0) + 1 == 2, "options first row is scroll+1 (0-based scroll)")
|
|
ModManager.close()
|
|
end
|
|
|
|
print("[test] 11. List view is a sticky 7-row window (not ManagerState's 11)")
|
|
do
|
|
local many = {}
|
|
for i = 1, 15 do
|
|
many[i] = {
|
|
id = "m" .. i, name = "M" .. i, category = "TWEAK",
|
|
enabled = true, state = "loaded",
|
|
}
|
|
end
|
|
ModManager.show({ game = stubGame(many) })
|
|
local m = ModManager._mgr
|
|
-- ManagerState clamps to LIST_ROWS=11; we must stay on a 7-row follow
|
|
for step = 1, 8 do
|
|
m:moveCursor(1)
|
|
end
|
|
ModManager.draw()
|
|
local first = ModManager._viewFirst or 1
|
|
check(m.cursor >= first and m.cursor <= first + 6,
|
|
"cursor stays inside the 7-row view (cursor=" .. tostring(m.cursor) ..
|
|
" first=" .. tostring(first) .. ")")
|
|
check(first >= 1 and first <= m.cursor,
|
|
"view scrolls one row at a time, cursor never parks off screen")
|
|
ModManager.close()
|
|
end
|
|
|
|
print("[test] 12. New manager module does not draw with Gen 1 Font/Theme")
|
|
local src = io.open("src/ui/game3/mod_manager.lua", "r"):read("*a")
|
|
check(not src:find('require("src.render.Font")', 1, true),
|
|
"no src.render.Font require")
|
|
check(not src:find('require("src.ui.Theme")', 1, true),
|
|
"no src.ui.Theme require")
|
|
-- Match Gen 1 Font.draw, not FrlgFont.draw (shared substring).
|
|
check(not src:find("[^%w]Font%.draw"), "no Gen 1 Font.draw call")
|
|
|
|
if failed > 0 then
|
|
print(string.format("[FAIL] %d check(s) failed", failed))
|
|
os.exit(1)
|
|
end
|
|
print("ALL TESTS PASSED")
|
|
os.exit(0)
|