Files
gen1recomp/tests/game3_mods_menu_test.lua
T

592 lines
24 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 restarts the process like Gen 1/2 (#2691)")
local restarts = 0
local realHostShell = package.loaded["src.core.HostShell"]
package.loaded["src.core.HostShell"] = { restart = function() restarts = restarts + 1 end }
local returned = false
local g2 = stubGame()
g2.returnToTitle = function() returned = true end
ModManager.show({ game = g2 })
ModManager._mgr:restartGame()
check(restarts == 1, "restartGame reaches HostShell.restart")
check(not returned, "restartGame does not soft-return to the Gen 3 title")
check(not ModManager.isOpen(), "restart closes the manager (open flag not stuck)")
local Game3 = require("src.core.Game3")
check(type(Game3.restartWithMods) == "function", "Game3 defines restartWithMods")
local g3r = stubGame()
g3r.restartWithMods = Game3.restartWithMods
local titled = false
g3r.returnToTitle = function() titled = true end
restarts = 0
ModManager.show({ game = g3r })
ModManager._mgr:restartGame()
check(restarts == 1 and not titled, "Game3:restartWithMods goes through HostShell.restart")
check(not ModManager.isOpen(), "Game3 restart path closes the manager")
package.loaded["src.core.HostShell"] = realHostShell
ModManager.show({ game = stubGame() })
check(ModManager.isOpen(), "manager can re-open after APPLY & RESTART")
ModManager.close()
print("[test] 9b. Title-screen manager (no save yet) writes the live Game3.options (#2691)")
do
local SaveData = require("src.core.SaveData")
local status = { available = {
{ id = "a", name = "A", category = "TWEAK", enabled = true, state = "loaded" },
{ id = "b", name = "B", category = "TWEAK", enabled = true, state = "loaded" },
} }
local written
local gt = {
modStatus = status,
mods = {
status = function() return status end,
setEnabled = function(_, id, en)
for _, m in ipairs(status.available) do
if m.id == id then m.enabled = en end
end
return true
end,
},
save = nil,
options = {},
input = { wasPressed = function() return false end },
writeOptions = function(self) written = self.options end,
}
ModManager.show({ game = gt })
local m = ModManager._mgr
m:commitToggle({ a = false })
check(SaveData.modEnabled(gt.options, "a", m:enableScope()) == false,
"toggle from the title mirrors into Game3.options")
m:moveOrder(m.byId.b, -1)
local order = SaveData.modOrder(gt.options) or {}
check(order[1] == "b", "load-order move from the title lands in Game3.options")
check(written == gt.options, "persistOptions writes the live Game3.options table")
ModManager.close()
end
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] 11b. Overlays print inside their own frames (#2690)")
do
local Chrome = require("src.ui.game3.chrome")
local FrlgFont = require("src.ui.game3.frlg_font")
local log = {}
local real = {
std = Chrome.stdFrame, fixed = Chrome.fixedStdFrame, user = Chrome.userFrame,
dlg = Chrome.dialogueFrame, draw = FrlgFont.draw, glyph = FrlgFont.drawGlyph,
}
Chrome.stdFrame = function(l, t, w, h) log[#log + 1] = { frame = true, l = l, t = t, w = w, h = h } end
Chrome.fixedStdFrame = function() end
Chrome.userFrame = function() end
Chrome.dialogueFrame = function()
local l, t, w, h = Chrome.dialogueWindow()
log[#log + 1] = { frame = true, dlg = true, l = l, t = t, w = w, h = h }
end
FrlgFont.draw = function(s, x, y) log[#log + 1] = { s = tostring(s), x = x, y = y } return 0 end
FrlgFont.drawGlyph = function(id, x, y) log[#log + 1] = { glyph = id, x = x, y = y } return 0 end
local function inside(e, f)
return f and e.x >= f.l * 8 and e.x < (f.l + f.w) * 8
and e.y >= f.t * 8 and e.y + FrlgFont.GLYPH_HEIGHT <= (f.t + f.h) * 8
end
local function find(s)
for _, e in ipairs(log) do if e.s == s then return e end end
end
local function layout(label, setup, expect, confirm)
for i = #log, 1, -1 do log[i] = nil end
ModManager.show({ game = stubGame() })
local m = ModManager._mgr
setup(m)
ModManager.draw()
local msgFrame, ynFrame
for _, e in ipairs(log) do
if e.frame and e.l == 21 then ynFrame = e
elseif e.frame and e.l == 2 then msgFrame = e end
end
check(msgFrame ~= nil, label .. ": message frame drawn")
local prevY
for _, s in ipairs(expect) do
local e = find(s)
check(e and inside(e, msgFrame), label .. ": '" .. s .. "' inside the message frame"
.. (e and (" (y=" .. e.y .. ")") or " (missing)"))
if e and prevY then
check(e.y - prevY >= 14, label .. ": message rows at least 14px apart")
end
prevY = e and e.y
end
if confirm then
check(ynFrame ~= nil and ynFrame.w == 6 and ynFrame.h == 4,
label .. ": YES/NO std frame at left 21")
local yes, no = find("YES"), find("NO")
check(yes and no and inside(yes, ynFrame) and inside(no, ynFrame),
label .. ": YES/NO printed inside the YES/NO frame")
check(ynFrame and msgFrame and (ynFrame.t + ynFrame.h < msgFrame.t - 1
or msgFrame.l + msgFrame.w < ynFrame.l - 1),
label .. ": YES/NO frame clears the message frame border")
check(ynFrame and ynFrame.t - 1 > 5, label .. ": YES/NO frame clears the title frame border"
.. (ynFrame and (" (top=" .. ynFrame.t .. ")") or ""))
local arrow
for _, e in ipairs(log) do
if e.glyph and ynFrame and e.x == ynFrame.l * 8 then arrow = e end
end
check(arrow and yes and arrow.y == yes.y, label .. ": cursor sits on YES")
elseif confirm == false then
check(ynFrame == nil, label .. ": no YES/NO frame for an OK notice")
end
check(msgFrame and msgFrame.t - 1 > 5, label .. ": message frame clears the title frame border"
.. (msgFrame and (" (top=" .. msgFrame.t .. ")") or ""))
for _, e in ipairs(log) do
if e.s and e.y then
check(e.y + FrlgFont.GLYPH_HEIGHT <= 160, label .. ": '" .. e.s .. "' on screen")
end
end
ModManager._prompt = nil
ModManager.close()
end
layout("restart", function(m)
m:openConfirm({ "RESTART NOW?" }, function() end)
end, { "RESTART NOW?" }, true)
layout("not made for", function(m)
m:openConfirm({ "NOT MADE FOR", "THIS GAME.", "TRY IT ANYWAY?" }, function() end)
end, { "NOT MADE FOR", "THIS GAME.", "TRY IT ANYWAY?" }, true)
layout("experimental", function(m)
m:openConfirm({ "EXPERIMENTAL MOD", "THIS MOD IS MARKED", "EXPERIMENTAL.", "ENABLE ANYWAY?" },
function() end)
end, { "EXPERIMENTAL MOD", "THIS MOD IS MARKED", "EXPERIMENTAL.", "ENABLE ANYWAY?" }, true)
layout("blocked", function(m)
m:openBlocked({ missing = { "dep" }, conflicts = { "zz" }, badVersion = {} })
end, { "NEEDS dep", "NOT INSTALLED", "CONFLICTS WITH", "zz", "DISABLE IT FIRST" }, false)
layout("blocked long", function(m)
m:openBlocked({ missing = { "d1", "d2", "d3", "d4" }, conflicts = {}, badVersion = {} })
end, { "NEEDS d1", "NOT INSTALLED", "NEEDS d2" }, false)
layout("qty", function(m)
m.game.stack:push(QuantityBox.new(m.game, { max = 9, start = 4, onDone = function() end }))
end, { "HOW MANY?" }, nil)
local qtyValue = find("4")
local qtyFrame
for _, e in ipairs(log) do
if e.frame and e.l == 21 then qtyFrame = e end
end
check(qtyFrame and qtyValue and inside(qtyValue, qtyFrame),
"qty: value printed inside its own std frame")
local wraps = 0
local realWrap = FrlgFont.wrap
FrlgFont.wrap = function(...) wraps = wraps + 1 return realWrap(...) end
ModManager.show({ game = stubGame() })
ModManager._mgr:openConfirm({ "EXPERIMENTAL MOD", "THIS MOD IS MARKED", "EXPERIMENTAL.", "ENABLE ANYWAY?" },
function() end)
ModManager.draw()
check(wraps > 0, "overlay: first draw wraps the message lines")
local afterFirst = wraps
ModManager.draw()
ModManager.draw()
check(wraps == afterFirst, "overlay: later frames reuse the wrapped layout (wraps=" .. wraps .. ")")
ModManager._mgr.game.stack:push(QuantityBox.new(ModManager._mgr.game, { max = 9, start = 4, onDone = function() end }))
ModManager.draw()
afterFirst = wraps
ModManager.draw()
check(wraps == afterFirst, "qty prompt: later frames reuse the wrapped layout")
ModManager._prompt = nil
ModManager.close()
FrlgFont.wrap = realWrap
Chrome.stdFrame, Chrome.fixedStdFrame, Chrome.userFrame = real.std, real.fixed, real.user
Chrome.dialogueFrame, FrlgFont.draw, FrlgFont.drawGlyph = real.dlg, real.draw, real.glyph
end
print("[test] 11c. Keypad icons fall back to button words without a keypad sheet (Ruby/Sapphire)")
do
local FrlgFont = require("src.ui.game3.frlg_font")
local real = { sync = FrlgFont.sync, draw = FrlgFont.draw, measure = FrlgFont.measure, keypad = FrlgFont._keypad }
local texts = {}
FrlgFont.draw = function(s, x, y, opts) texts[#texts + 1] = { s = tostring(s), opts = opts } return 0 end
FrlgFont.measure = function(s) return 6 * #tostring(s) end
FrlgFont.sync = function() return { nativeLayout = "rs" } end
check(FrlgFont.hasKeypadIcons() == false, "rs: no keypad icon sheet")
local colors = { fg = { 1, 1, 1, 1 } }
local ok, w = pcall(FrlgFont.drawKeypadIcon, 0x00, 10, 2, { colors = colors, small = true })
check(ok, "rs: drawKeypadIcon does not raise " .. tostring(not ok and w or ""))
check(texts[1] and texts[1].s == "A " and texts[1].opts.colors == colors and texts[1].opts.small == true,
"rs: A_BUTTON draws the word 'A' in the caller's colors and face")
check(w == 12, "rs: drawKeypadIcon returns the word width")
check(FrlgFont.keypadIconWidth(0x0A) == 6 * #"UP/DN ", "rs: measure uses the word width")
FrlgFont.sync = function() return nil end
FrlgFont._keypad = nil
check(FrlgFont.keypadIconWidth(0x04) == 6 * #"START " and FrlgFont._keypad == false,
"missing sheet on a FRLG cache: measure-only caller gets the word width before any draw")
FrlgFont._keypad = false
texts = {}
ok = pcall(FrlgFont.drawKeypadIcon, 0x04, 0, 0)
check(ok and texts[1] and texts[1].s == "START ", "missing sheet on a FRLG cache: START word, no raise")
FrlgFont._keypad = { getDimensions = function() return 128, 32 end }
check(FrlgFont.hasKeypadIcons() == true, "frlg with a sheet keeps the icons")
check(FrlgFont.keypadIconWidth(0x04) == FrlgFont.KEYPAD_ICONS[0x04].w, "frlg: icon width unchanged")
FrlgFont.draw, FrlgFont.measure = real.draw, real.measure
local font = { fg = FrlgFont._fg, widths = FrlgFont._widths, quads = FrlgFont._quads, icon = FrlgFont.drawKeypadIcon }
FrlgFont._fg, FrlgFont._widths, FrlgFont._quads = font.fg or {}, font.widths or {}, font.quads or {}
local seen = {}
FrlgFont.drawKeypadIcon = function(id, _, _, opts)
seen[#seen + 1] = { opts = opts, colors = opts and opts.colors, fg = opts and opts.colors and opts.colors.fg }
return FrlgFont.KEYPAD_ICONS[id].w
end
local white = { fg = { 1, 1, 1, 1 } }
FrlgFont.draw("{A_BUTTON}{B_BUTTON}", 0, 0, { colors = white })
FrlgFont.draw("{START_BUTTON}", 0, 0, { colors = white })
check(#seen == 3 and seen[1].opts == seen[2].opts and seen[2].opts == seen[3].opts
and seen[1].colors == seen[3].colors and seen[1].opts ~= nil,
"draw: icon opts/colors tables are reused across icons and calls (no per-icon allocation)")
check(seen[1] and seen[1].fg == white.fg, "draw: icon colors carry the caller's fg")
FrlgFont._fg, FrlgFont._widths, FrlgFont._quads, FrlgFont.drawKeypadIcon = font.fg, font.widths, font.quads, font.icon
FrlgFont.sync, FrlgFont._keypad = real.sync, real.keypad
local msrc = io.open("src/ui/game3/mod_manager.lua", "r"):read("*a")
check(not msrc:find("KEY_WORDS", 1, true) and not msrc:find("_keypadOk", 1, true),
"mod manager has no per-screen keypad fallback")
end
print("[test] 11d. Named colors map to the RS font palette slots on Ruby/Sapphire only")
do
local FrlgFont = require("src.ui.game3.frlg_font")
local P, C = FrlgFont.STDPAL, FrlgFont.COLOR
FrlgFont.applyColorSlots(true)
check(C.DARK_GRAY.fg == P[1] and C.DARK_GRAY.shadow == P[8], "rs: DARK_GRAY is DARK_GREY/LIGHT_GREY")
check(C.RED.fg == P[2] and C.RED.shadow == P[8], "rs: RED is slot 2")
check(C.BLUE.fg == P[4] and C.GREEN.fg == P[3], "rs: BLUE slot 4, GREEN slot 3")
check(C.WHITE.fg == P[12] and C.WHITE.shadow == P[1], "rs: WHITE slot 12 over DARK_GREY")
check(FrlgFont.COLOR_IDS.RED == 2 and FrlgFont.COLOR_IDS.BLUE == 4 and FrlgFont.COLOR_IDS.WHITE == 12,
"rs: {COLOR} names use pokeruby charmap ids")
FrlgFont.applyColorSlots(false)
check(C.DARK_GRAY.fg == P[2] and C.DARK_GRAY.shadow == P[3], "frlg: DARK_GRAY back to 2/3")
check(C.RED.fg == P[4] and C.RED.shadow == P[5] and C.BLUE.fg == P[8] and C.BLUE.shadow == P[3],
"frlg: RED 4/5, BLUE 8/3")
check(C.MALE.fg == P[9] and C.FEMALE.fg == P[5] and C.WHITE.fg == P[1] and C.WHITE.shadow == P[2],
"frlg: gender and WHITE slots unchanged")
check(FrlgFont.COLOR_IDS.RED == 4 and FrlgFont.COLOR_IDS.BLUE == 8 and FrlgFont.COLOR_IDS.WHITE == 1
and FrlgFont.COLOR_IDS.DARK_GREY == nil, "frlg: COLOR_IDS restored")
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)