Files
gen1recomp/tests/mod_ui_tests.lua
T
2026-07-23 14:20:23 -04:00

815 lines
33 KiB
Lua

-- UI extensibility (M8): the Screens factory and its cache invalidation,
-- StateStack screen events, the three menu-injection hooks with non-table
-- degrade, the mod.ui helper surface, theme defaults, branding reads from
-- field.*, and ManagerState v2 -- error surfacing, toggle resolution with
-- dependency dialogs, staged apply/discard, options auto-UI, profiles and
-- the safe-mode banner.
package.path = "./?.lua;./?/init.lua;" .. package.path
local S = require("tests.harness").suite("mod ui")
local check = S.check
local love = _G.love or require("tests.love_stub")
_G.love = love
local Events = require("src.mods.Events")
local Hooks = require("src.mods.Hooks")
local Runtime = require("src.mods.Runtime")
local Logger = require("src.core.Logger")
local Assets = require("src.render.Assets")
local Screens = require("src.ui.Screens")
local StateStack = require("src.core.StateStack")
local ManagerState = require("src.mods.ManagerState")
local ModUI = require("src.ui.ModUI")
local Theme = require("src.ui.Theme")
local FrameCap = require("src.core.FrameCap")
local savedEvents, savedHooks, savedErrors =
Runtime.events, Runtime.hooks, Runtime.errors
local savedSafeMode = Runtime.safeMode
local function logged(fragment)
for _, line in ipairs(Logger.history) do
if line:find(fragment, 1, true) then return true end
end
return false
end
-- minimal stack/input doubles matching the StateStack and Input surfaces
local function newStack()
local stack = { states = {} }
function stack:push(state, ...)
table.insert(self.states, state)
if state.enter then state:enter(...) end
end
function stack:pop()
local state = table.remove(self.states)
if state and state.exit then state:exit() end
return state
end
function stack:top() return self.states[#self.states] end
return stack
end
local function newInput()
local input = { queue = {} }
function input:wasPressed(btn) return self.queue[btn] or false end
return input
end
local function press(state, btn)
state.game.input.queue = { [btn] = true }
state:update(1 / 60)
state.game.input.queue = {}
end
-- ------- Screens: resolution identity (parity)
Screens.invalidate()
local sgame = { data = {}, stack = newStack() }
for _, id in ipairs({ "TitleState", "IntroMovie", "OakSpeech", "NamingScreen",
"StartMenu", "PokedexMenu", "DexEntryMenu", "TownMap", "PartyMenu",
"BagMenu", "SummaryMenu", "TrainerCard", "OptionsMenu", "ShopMenu",
"BoxMenu", "PlayerPC", "MoveLearnMenu", "EvolutionState", "HallOfFame",
"Credits", "SlotMachine", "TradeAnim", "FlyMenu", "BindingsMenu" }) do
check(Screens.get(sgame, id) == require("src.ui." .. id),
"empty registry resolves the require module: " .. id)
end
check(Screens.get(sgame, "ManagerState") == ManagerState,
"ManagerState resolves from src.mods")
-- ------- Screens: override, screenId stamp, broken-factory fallback
Screens.invalidate()
sgame.data.screens = {
TitleState = { new = function(game) return { marker = true } end },
}
local inst = Screens.push(sgame, "TitleState")
check(inst.marker == true, "registry record wins over the builtin")
check(inst.screenId == "TitleState", "push stamps screenId")
check(sgame.stack:top() == inst, "push lands the instance on the stack")
Screens.invalidate()
sgame.data.screens = { TitleState = { new = function() error("boom") end } }
local fallback = Screens.push(sgame, "TitleState")
check(getmetatable(fallback) == require("src.ui.TitleState"),
"a throwing mod factory degrades to the builtin")
check(logged("mod screen 'TitleState' failed"),
"the failed factory is logged")
-- ------- Screens: cache flush rides the Assets invalidation fan-out
Screens.invalidate()
sgame.data.screens = nil
local cached = Screens.get(sgame, "TitleState")
sgame.data.screens = {
TitleState = { new = function() return { modded = true } end },
}
check(Screens.get(sgame, "TitleState") == cached,
"the factory cache holds between resolutions")
Assets.invalidate()
check(Screens.get(sgame, "TitleState") ~= cached,
"Assets.invalidate flushes the screens cache")
sgame.data.screens = nil
Screens.invalidate()
-- ------- StateStack events
local events, hooks = Events.new(), Hooks.new()
local errors = {}
Runtime.install(events, hooks, errors)
StateStack:init()
local order = {}
events:on("screen.pushed", function(e)
order[#order + 1] = "pushed:" .. tostring(e.state.entered)
end, 0, "t")
events:on("screen.popped", function(e)
order[#order + 1] = "popped:" .. tostring(e.state.exited)
end, 0, "t")
local probe = {}
function probe:enter() self.entered = true end
function probe:exit() self.exited = true end
StateStack:push(probe)
StateStack:pop()
check(order[1] == "pushed:true", "screen.pushed fires after enter")
check(order[2] == "popped:true", "screen.popped fires after exit")
local seenId
events:on("screen.pushed", function(e) seenId = e.state.screenId end, 0, "t")
Screens.push({ data = {}, stack = StateStack }, "TrainerCard")
check(seenId == "TrainerCard", "listeners match by screenId via Screens.push")
StateStack:pop()
events:removeOwner("t")
check(not Runtime.wants("screen.pushed"),
"no listeners left: the emit guard skips payload construction")
StateStack:push({}) -- no listener, no payload, no error
StateStack:pop()
-- ------- ui.start_menu.items
local StartMenu = require("src.ui.StartMenu")
local function startGame()
return {
data = {},
stack = newStack(),
input = newInput(),
save = { flags = { EVENT_GOT_POKEDEX = true }, party = { {} },
player = { name = "RED" }, options = {},
pokedex = { owned = {} } },
}
end
local VANILLA_START = { "POKéDEX", "POKéMON", "ITEM", "RED", "SAVE",
"OPTION", "LINK", "QUIT" }
local menu = StartMenu.new(startGame())
check(#menu.items == #VANILLA_START, "vanilla start menu row count")
for i, label in ipairs(VANILLA_START) do
check(menu.items[i].label == label, "vanilla start menu row " .. i)
end
check(menu.th == #menu.items * 2 + 2, "menu height derives from the item count")
local gated = startGame()
gated.modStatus = { available = { { id = "m" } } }
menu = StartMenu.new(gated)
check(menu.items[#menu.items - 1].label == "MODS",
"pause-menu MODS entry appears once a mod is discovered")
hooks:wrap("ui.start_menu.items", function(nextFn, game, items)
ModUI.insertBefore(items, "ITEM", { label = "QUESTS",
onSelect = function() end })
return nextFn(game, items)
end, 0, "fixture")
menu = StartMenu.new(startGame())
check(menu.items[3].label == "QUESTS" and menu.items[4].label == "ITEM",
"hook inserts a start-menu entry before its anchor")
hooks:removeOwner("fixture")
hooks:wrap("ui.start_menu.items", function() return 42 end, 0, "bad")
menu = StartMenu.new(startGame())
check(#menu.items == #VANILLA_START and menu.items[3].label == "ITEM",
"a non-table hook result degrades to the vanilla items")
check(logged("ui.start_menu.items returned"), "the degrade is logged")
hooks:removeOwner("bad")
-- ------- ui.options.rows and the descriptor refactor
local OptionsMenu = require("src.ui.OptionsMenu")
local function optGame()
return {
data = {
rulesets = {
gen1_faithful = { name = "GEN 1" },
modern_clean = { name = "MODERN" },
secret = { name = "SECRET", hidden = true },
},
constants = {},
},
save = { options = {} },
stack = newStack(),
input = newInput(),
modStatus = { available = {} },
}
end
local om = OptionsMenu.new(optGame())
local WANT_IDS = { "textSpeed", "animations", "battleStyle", "ruleset",
"musicVol", "sfxVol", "musicFilter", "colors", "tilt",
"gbcfx", "videoMode", "fpsCap", "speed", "mods", "controls" }
check(#om.rows == #WANT_IDS, "vanilla options row count (plus MODS/CONTROLS)")
for i, id in ipairs(WANT_IDS) do
check(om.rows[i].id == id, "options row order: " .. id)
end
-- ruleset row cycles the sorted non-hidden registry ids showing name
om.game.save.options.ruleset = "gen1_faithful"
check(om.rows[4].value(om.game) == "GEN 1", "ruleset row shows record.name")
om.rows[4].step(om.game, 1)
check(om.game.save.options.ruleset == "modern_clean",
"ruleset row cycles sorted registry ids")
om.rows[4].step(om.game, 1)
check(om.game.save.options.ruleset == "gen1_faithful",
"hidden rulesets are excluded from the cycle")
-- stepping parity with the old per-index ladder
om.rows[1].step(om.game, 1)
check(om.game.save.options.textSpeed == 5, "text speed MEDIUM steps to SLOW")
om.rows[1].step(om.game, 1)
check(om.game.save.options.textSpeed == 1, "then wraps to FAST")
om.rows[2].step(om.game, 1)
check(om.game.save.options.animations == false, "animations toggles off")
om.rows[3].step(om.game, 1)
check(om.game.save.options.battleStyle == "set", "battle style flips to SET")
om.rows[5].step(om.game, -1)
check(om.game.save.options.musicVol == 6, "music volume steps down")
for _ = 1, 10 do om.rows[5].step(om.game, -1) end
check(om.game.save.options.musicVol == 0, "music volume clamps at 0")
-- the MAX FPS row cycles the render-cap steps and shows the value plain
om.game.save.options.fpsCap = nil
check(om.rows[12].value(om.game) == "60",
"MAX FPS row defaults to 60 with no saved cap")
om.rows[12].step(om.game, 1)
check(om.game.save.options.fpsCap == 75, "MAX FPS steps up from 60 to 75")
check(om.rows[12].value(om.game) == "75", "the MAX FPS row renders the cap")
om.game.save.options.fpsCap = 160
om.rows[12].step(om.game, 1)
check(om.game.save.options.fpsCap == 30, "MAX FPS wraps past the ceiling to 30")
om.rows[12].step(om.game, -1)
check(om.game.save.options.fpsCap == 160, "MAX FPS wraps back down to the ceiling")
-- ------- FrameCap normalize / cycle (issue #88)
check(FrameCap.normalize(nil) == 60, "FrameCap defaults nil to 60")
check(FrameCap.normalize("junk") == 60, "FrameCap defaults garbage to 60")
check(FrameCap.normalize(60) == 60, "FrameCap keeps an exact step")
check(FrameCap.normalize(58) == 60, "FrameCap snaps 58 to the nearest step 60")
check(FrameCap.normalize(72) == 75, "FrameCap snaps 72 to the nearest step 75")
check(FrameCap.normalize(0) == 30, "FrameCap clamps below the floor to 30")
check(FrameCap.normalize(9999) == 160, "FrameCap clamps above the ceiling to 160")
check(FrameCap.normalize(30) == 30 and FrameCap.normalize(160) == 160,
"FrameCap keeps the exact floor and ceiling")
check(FrameCap.label(nil) == "60" and FrameCap.label(144) == "144",
"FrameCap.label renders the normalized cap as plain text")
check(FrameCap.cycle(60, 1) == 75, "FrameCap cycles 60 up to 75")
check(FrameCap.cycle(60, -1) == 50, "FrameCap cycles 60 down to 50")
check(FrameCap.cycle(160, 1) == 30, "FrameCap cycle wraps the ceiling to the floor")
check(FrameCap.cycle(30, -1) == 160, "FrameCap cycle wraps the floor to the ceiling")
check(FrameCap.cycle(nil, 1) == 75,
"FrameCap cycle normalizes a nil cap (60) before stepping")
-- apply drives the live value the run loop paces to; never touches love.timer
FrameCap.apply(144)
check(FrameCap.current == 144, "FrameCap.apply stores the live cap")
FrameCap.applyOptions({})
check(FrameCap.current == 60, "FrameCap.applyOptions defaults a missing key to 60")
-- the MODS row is the manager's discoverable home
local mgGame = optGame()
om = OptionsMenu.new(mgGame)
om.rows[14].activate(mgGame)
check(getmetatable(mgGame.stack:top()) == ManagerState,
"the MODS row opens the manager")
check(mgGame.stack:top().screenId == "ManagerState",
"the pushed manager carries its screen id")
-- ------- the CONTROLS row and BindingsMenu (gap C2's file-12 half)
local BindingsMenu = require("src.ui.BindingsMenu")
local cbGame = optGame()
om = OptionsMenu.new(cbGame)
om.rows[15].activate(cbGame)
local bm = cbGame.stack:top()
check(getmetatable(bm) == BindingsMenu,
"the CONTROLS row opens the rebind list")
check(bm.screenId == "BindingsMenu",
"the pushed rebind screen carries its screen id")
check(#bm.items == 8, "one row per logical button")
check(bm.items[1].label == "UP" and bm.items[1].right == "UP"
and bm.items[5].label == "A" and bm.items[5].right == "Z"
and bm.items[7].label == "START" and bm.items[7].right == "ESCAPE",
"with no rebind the rows mirror the fixed map")
check(cbGame.save.options.bindings == nil,
"opening the screen alone writes nothing")
check(bm.onKeyPressed == nil and bm.onGamepadPressed == nil,
"no raw-input claim until a capture is armed")
press(bm, "a")
check(bm.capture == bm.items[1] and bm.onKeyPressed ~= nil,
"A on a row arms the capture")
local wroteOptions = false
function cbGame:writeOptions() wroteOptions = true end
bm:onKeyPressed("j")
check(cbGame.save.options.bindings.up.key == "j",
"a captured key lands in options.bindings")
check(bm.items[1].right == "J", "the row shows the new key")
check(wroteOptions, "a rebind persists through writeOptions")
check(bm.capture == nil and bm.onKeyPressed == nil
and bm.onGamepadPressed == nil, "the capture disarms after one input")
bm.index = 5
press(bm, "a")
bm:onGamepadPressed("y")
check(cbGame.save.options.bindings.a.pad == "y",
"a captured pad button lands beside the key slot")
check(bm.items[5].right == "Z", "a pad rebind keeps the key column")
press(bm, "b")
check(#cbGame.stack.states == 0, "B closes the rebind screen")
-- Game routes pad buttons to a capturing top state and nowhere else
local Game = require("src.core.Game")
local Input = require("src.core.Input")
local gpGame = { stack = newStack() }
local sawPad
gpGame.stack:push({ onGamepadPressed = function(_, b) sawPad = b end })
Game.gamepadpressed(gpGame, nil, "y")
check(sawPad == "y", "pad buttons reach a capturing top state")
Input:init()
gpGame.stack:pop()
Game.gamepadpressed(gpGame, nil, "a")
Input:step()
check(Input:wasPressed("a"),
"without a capturing state pad input still feeds the mapped path")
local hookSawCancel = false
hooks:wrap("ui.options.rows", function(nextFn, game, rows)
for _, row in ipairs(rows) do
if row.label == "CANCEL" then hookSawCancel = true end
end
rows[#rows + 1] = { id = "quest_pace", label = "QUEST PACE",
value = function() return "OFF" end,
step = function() return true end }
return nextFn(game, rows)
end, 0, "fixture")
om = OptionsMenu.new(optGame())
check(om.rows[#om.rows].id == "quest_pace", "hook appends an options row")
check(not hookSawCancel, "CANCEL is appended after the hook, unreachable")
hooks:removeOwner("fixture")
hooks:wrap("ui.options.rows", function() return "nope" end, 0, "bad")
om = OptionsMenu.new(optGame())
check(#om.rows == #WANT_IDS, "a non-table rows result keeps the vanilla rows")
hooks:removeOwner("bad")
-- ------- ui.party.submenu
local PartyMenu = require("src.ui.PartyMenu")
local function partyGame()
return {
data = { pokemon = { PIKACHU = { name = "PIKACHU" } } },
save = {
party = { { species = "PIKACHU", hp = 10, stats = { hp = 10 },
level = 5, moves = { { id = "TACKLE" } } } },
inventory = {}, options = {},
},
stack = newStack(),
input = newInput(),
}
end
local pgame = partyGame()
local pm = PartyMenu.new(pgame)
pm.game = pgame
pgame.stack:push(pm)
press(pm, "a")
check(pm.submenu and #pm.subItems == 2
and pm.subItems[1].label == "STATS" and pm.subItems[2].label == "SWITCH",
"vanilla party submenu unchanged with no hooks")
pm.submenu = nil
local ranWith
hooks:wrap("ui.party.submenu", function(nextFn, game, items, mon, ctx)
table.insert(items, { label = "QUESTS",
onSelect = function(m) ranWith = m end })
return nextFn(game, items, mon, ctx)
end, 0, "fixture")
press(pm, "a")
check(#pm.subItems == 3 and pm.subItems[3].label == "QUESTS",
"hook appends a party submenu entry")
pm.subIndex = 3
press(pm, "a")
check(ranWith == pgame.save.party[1],
"an injected entry's onSelect runs with the focused mon")
check(not pm.submenu, "the submenu closes after an injected entry runs")
hooks:removeOwner("fixture")
hooks:wrap("ui.party.submenu", function() return nil end, 0, "bad")
press(pm, "a")
check(#pm.subItems == 2, "a non-table submenu result keeps the vanilla list")
hooks:removeOwner("bad")
pm.submenu = nil
-- ------- mod.ui helpers and theme defaults
local items = { { label = "A" }, { label = "B" } }
ModUI.insertAfter(items, "A", { label = "X" })
check(items[2].label == "X", "insertAfter lands behind its anchor")
ModUI.insertBefore(items, "A", { label = "Y" })
check(items[1].label == "Y", "insertBefore lands ahead of its anchor")
ModUI.removeLabel(items, "X")
check(#items == 3 and items[3].label == "B", "removeLabel drops the entry")
ModUI.insertBefore(items, "MISSING", { label = "Z" })
check(items[#items].label == "Z", "a missing anchor appends")
check(ModUI.Menu == require("src.ui.Menu"), "mod.ui exposes the widgets")
check(ModUI.TextBox == require("src.render.TextBox"),
"mod.ui exposes TextBox")
check(type(ModUI.push) == "function", "mod.ui.push opens screens")
check(Theme.cursor == 0xED and Theme.cursorHollow == 0xEC
and Theme.moreArrow == 0xEE, "theme defaults are the old literals")
check(Theme.choiceBox.tx == 0 and Theme.choiceBox.ty == 7
and Theme.choiceBox.tw == 6 and Theme.choiceBox.th == 5,
"choice box geometry keeps its vanilla tiles")
Theme.load({ field = { theme = { cursor = 0xAA } } })
check(Theme.cursor == 0xAA, "field.theme restyles the cursor glyph")
Theme.cursor = 0xED
-- ------- branding reads from field.*
local TitleState = require("src.ui.TitleState")
local tgame = { data = { field = { title = {
cycleSpecies = { "MEW" }, music = "My_Song", copyrightText = "HELLO",
} }, pokemon = { MEW = {} } } }
local title = TitleState.new(tgame, {})
check(#title.cycleSpecies == 1 and title.cycleSpecies[1] == "MEW",
"field.title.cycleSpecies replaces the literal list")
check(title.title.music == "My_Song", "field.title.music is read")
title = TitleState.new({ data = {} }, {})
check(#title.cycleSpecies == 16 and title.cycleSpecies[1] == "CHARMANDER",
"no data keeps the vanilla cycle list")
check(title.logo and title.logo.path == "assets/logo/pokemon_logo.png",
"no data keeps the shipped logo")
-- the importer seeds field.title with {path,width,height} descriptors
-- (data/generated/field.lua); they must load via their path, and the
-- file-12 plain-string shape must keep working
title = TitleState.new({ data = { field = { title = {
logo = { path = "assets/generated/title/pokemon_logo.png",
width = 128, height = 56 },
version = { path = "assets/generated/title/red_version.png",
width = 80, height = 8 },
} } } }, {})
check(title.logo and title.logo.path
== "assets/generated/title/pokemon_logo.png",
"a {path} logo descriptor loads its image")
check(title.version and title.version.path
== "assets/generated/title/red_version.png",
"the importer's version descriptor feeds the ribbon")
title = TitleState.new({ data = { field = { title = {
logo = "mods/x/logo.png", versionRibbon = "mods/x/ribbon.png",
} } } }, {})
check(title.logo and title.logo.path == "mods/x/logo.png",
"a plain-string logo path loads directly")
check(title.version and title.version.path == "mods/x/ribbon.png",
"versionRibbon wins as the file-12 patch key")
-- pin against the shipped data itself: a real boot must load the logo
-- art, never fall back to the ASCII placeholder
title = TitleState.new({ data = { field = dofile("data/generated/field.lua") } },
{})
check(title.logo and title.logo.path
== "assets/generated/title/pokemon_logo.png",
"the shipped field.title.logo loads its art")
check(title.version and title.version.path
== "assets/generated/title/red_version.png",
"the shipped version ribbon loads")
local OakSpeech = require("src.ui.OakSpeech")
local ogame = { data = {
field = { oakSpeech = { music = "X_Song", demoSpecies = "PIKACHU" } },
pokemon = { PIKACHU = {} }, trainers = {},
constants = { playerNameLength = 10 },
} }
local oak = OakSpeech.new(ogame, nil)
check(oak.demoSpecies == "PIKACHU", "field.oakSpeech.demoSpecies is read")
check(oak.nameLen == 10, "constants.playerNameLength caps the naming screen")
check(oak.cfg.music == "X_Song", "field.oakSpeech.music is read")
oak = OakSpeech.new({ data = {} }, nil)
check(oak.demoSpecies == "NIDORINO" and oak.nameLen == 7,
"no data keeps the vanilla speech values")
local IntroMovie = require("src.ui.IntroMovie")
local introDone = false
local igame = { data = { field = { intro = {
studio = { card = "MY STUDIO", credit = "ME" }, skip = true,
music = "Alt_Battle",
} } }, stack = newStack(), input = newInput() }
local movie = IntroMovie.new(igame, function() introDone = true end)
check(movie.studio.card == "MY STUDIO" and movie.studio.credit == "ME",
"field.intro.studio strings are read")
check(movie.introCfg.music == "Alt_Battle", "field.intro.music is read")
igame.stack:push(movie)
movie:update(1 / 60)
check(introDone and #igame.stack.states == 0,
"field.intro.skip jumps straight past the movie")
local Credits = require("src.ui.Credits")
local credits = Credits.new({ data = { field = {
credits = { music = "My_Credits" } } } }, nil, nil)
check(credits.music == "My_Credits", "field.credits.music is read")
credits = Credits.new({ data = {} }, nil, nil)
check(credits.music == "Music_Credits", "no data keeps the vanilla song")
-- ------- ManagerState v2
check(ManagerState.onKeyPressed == nil,
"the manager reads mapped input, not raw keys")
check(ManagerState.screenId == "ManagerState",
"the manager carries its screen id for the F10 toggle")
local function manifest(id, over)
local m = { id = id, name = id:upper(), version = "1.0.0",
category = "OTHER", state = "loaded", enabled = true,
dependencySpecs = {}, conflictSpecs = {}, permissions = {},
description = "a mod" }
for k, v in pairs(over or {}) do m[k] = v end
return m
end
local function fakeLoader(available, loadErrors)
local loader = { optionSchemas = {}, modOptions = {},
events = Events.new(), errors = loadErrors or {} }
function loader:status()
return { available = available, loaded = {}, errors = self.errors,
order = {} }
end
function loader:setEnabled(id, enabled)
for _, m in ipairs(available) do
if m.id == id then m.enabled = enabled end
end
return true
end
return loader
end
local function managerGame(loader)
return { data = {}, stack = newStack(), input = newInput(),
save = { options = { mods = {} } }, mods = loader,
modStatus = loader:status() }
end
-- resolveToggle: the table-driven dependency cases
local RT = ManagerState.resolveToggle
local rtMods = {
base = manifest("base"),
addon = manifest("addon", { dependencySpecs = { { id = "base" } } }),
rival = manifest("rival", { conflictSpecs = { { id = "base" } } }),
old = manifest("old", { game_version = ">=99.0.0" }),
strict = manifest("strict",
{ dependencySpecs = { { id = "base", range = ">=2.0.0" } } }),
ghostly = manifest("ghostly", { dependencySpecs = { { id = "ghost" } } }),
}
local r = RT(rtMods, "base", false, { base = true })
check(r.apply.base == false and #r.alsoDisable == 0 and #r.missing == 0,
"clean flip: no cascade")
r = RT(rtMods, "base", false, { base = true, addon = true })
check(r.apply.base == false and r.apply.addon == false
and r.alsoDisable[1] == "addon", "disabling a dep cascades to dependents")
r = RT(rtMods, "addon", true, {})
check(r.apply.addon == true and r.apply.base == true
and r.alsoEnable[1] == "base", "enabling pulls hard deps in")
r = RT(rtMods, "ghostly", true, {})
check(r.missing[1] == "ghost", "a missing dep blocks")
r = RT(rtMods, "rival", true, { base = true })
check(r.conflicts[1] == "base", "a co-enabled conflict blocks")
r = RT(rtMods, "old", true, {})
check(#r.badVersion == 1 and r.badVersion[1].engine,
"an engine version mismatch blocks")
r = RT(rtMods, "strict", true, { base = true })
check(#r.badVersion == 1 and r.badVersion[1].id == "base",
"a dep range mismatch blocks")
-- errors are visible: glyph on the roster, message on the errors screen
local avail = {
manifest("badmod", { state = "failed", error = "boom" }),
manifest("okmod"),
}
local loader = fakeLoader(avail, { "badmod: boom" })
local mgame = managerGame(loader)
-- production wiring: the runtime error feed is the loader's error list
Runtime.errors = loader.errors
local ms = ManagerState.new(mgame)
mgame.stack:push(ms)
local rows = ms:modRows()
check(rows[1].header and rows[1].label == "OTHER",
"categories are section headers")
check(rows[2].mod.id == "badmod" and rows[2].glyph == "!",
"an errored mod carries the ! glyph")
check(rows[3].mod.id == "okmod" and rows[3].glyph == " ",
"a healthy mod has a clear gutter")
local lines = ms:errorLines(nil)
check(lines[1]:find("badmod: boom", 1, true) ~= nil,
"loader errors finally render in the manager")
check(ms:errorLines(avail[1])[1]:find("FAILED: boom", 1, true) ~= nil,
"the per-mod error leads its own view")
-- select stages a clean toggle; discard reverts it
check(ms.cursor == 2, "the cursor skips the category header")
press(ms, "select")
check(avail[1].enabled == false, "SELECT quick-toggles the focused mod")
check(ms:isStaged(avail[1]), "a flip against boot state is staged")
check(ms:glyphFor(avail[1]) == ".", "staged mods show the staged glyph")
check(mgame.save.options.mods.badmod == false,
"the live options table mirrors the flip")
check(ms.restartPending, "staged changes arm the apply screen")
ms:discardChanges()
check(avail[1].enabled == true and not ms.restartPending,
"discard restores the boot enable set")
-- cascade dialog: disabling a dep asks before flipping both
local avail2 = {
manifest("base"),
manifest("addon", { dependencySpecs = { { id = "base" } } }),
}
local mgame2 = managerGame(fakeLoader(avail2))
local ms2 = ManagerState.new(mgame2)
mgame2.stack:push(ms2)
ms2:beginToggle(ms2.byId.base)
check(ms2.overlay and ms2.overlay.kind == "confirm",
"a cascading toggle opens the consent dialog")
check(avail2[1].enabled and avail2[2].enabled,
"nothing flips before consent")
press(ms2, "a") -- YES
check(avail2[1].enabled == false and avail2[2].enabled == false,
"consent flips the whole closure")
-- blocked dialog: a missing dep explains and refuses
local avail3 = { manifest("lonely", { enabled = false, state = "disabled",
dependencySpecs = { { id = "ghost" } } }) }
local mgame3 = managerGame(fakeLoader(avail3))
local ms3 = ManagerState.new(mgame3)
mgame3.stack:push(ms3)
ms3:beginToggle(ms3.byId.lonely)
check(ms3.overlay and ms3.overlay.kind == "ok", "a blocked toggle explains")
check(ms3.overlay.lines[1] == "NEEDS ghost", "the dialog names the dep")
check(avail3[1].enabled == false, "a blocked toggle never flips")
press(ms3, "a")
check(ms3.overlay == nil, "the blocked dialog dismisses")
-- options auto-UI: schema rows edit, persist, emit, reset
local schema = {
{ key = "hardcore", label = "NUZLOCKE", type = "toggle", default = false },
{ key = "odds", label = "ODDS", type = "choice",
choices = { { "STD", "std" }, { "BOOST", "boosted" } }, default = "std" },
{ key = "startMoney", label = "START", type = "number",
min = 0, max = 9000, step = 1000, default = 3000 },
{ key = "tag", label = "RIVAL", type = "text", maxLen = 7,
default = "BLUE" },
{ bad = "row" },
}
loader.optionSchemas.okmod = schema
local heardOpt
loader.events:on("mod.options_changed", function(e) heardOpt = e end, 0, "t")
ms.currentMod = ms.byId.okmod
ms:openOptions(ms.byId.okmod)
check(ms.screen == "options", "OPTIONS.. routes to the options screen")
check(#ms.optionRows == 5, "four typed rows plus RESET; malformed skipped")
check(loader.errors[#loader.errors]:find("options row skipped", 1, true),
"the malformed row lands in the error feed")
ms.optionRows[1].step(mgame, 1)
check(mgame.save.options.modOptions.okmod.hardcore == true,
"a toggle edit persists to options.modOptions")
check(loader.modOptions.okmod.hardcore == true,
"the live value is visible to mod.options:get")
check(heardOpt and heardOpt.mod == "okmod" and heardOpt.key == "hardcore"
and heardOpt.value == true, "mod.options_changed fires on edit")
check(ms.optionRows[1].value(mgame) == "ON", "the toggle renders its state")
ms.optionRows[2].step(mgame, 1)
check(loader.modOptions.okmod.odds == "boosted"
and ms.optionRows[2].value(mgame) == "BOOST",
"a choice edit cycles and renders its label")
ms.optionRows[3].step(mgame, -1)
check(loader.modOptions.okmod.startMoney == 2000, "a number edit steps")
for _ = 1, 5 do ms.optionRows[3].step(mgame, -1) end
check(loader.modOptions.okmod.startMoney == 0, "number edits clamp at min")
ms.optionRows[4].activate()
check(getmetatable(mgame.stack:top()) == require("src.ui.NamingScreen"),
"a text row opens the naming screen")
mgame.stack:top().onDone("REDD")
mgame.stack:pop()
check(loader.modOptions.okmod.tag == "REDD", "the typed text persists")
ms.optionRows[5].activate()
check(loader.modOptions.okmod.hardcore == false
and loader.modOptions.okmod.odds == "std"
and loader.modOptions.okmod.startMoney == 3000
and loader.modOptions.okmod.tag == "BLUE",
"RESET DEFAULTS restores every schema default")
press(ms, "b")
check(ms.screen == "list", "B leaves the options screen")
-- profiles: save, drift to ad-hoc, apply, rename, delete
ms.tab = 2
ms:saveCurrentAs()
mgame.stack:top().onDone("EASY")
mgame.stack:pop()
local easy = ms:findProfile("EASY")
check(easy ~= nil and easy.enabled.badmod == true,
"SAVE CURRENT AS snapshots the enable set")
check(ms:optionsTable().activeProfile == "EASY", "the new profile is active")
ms:commitToggle({ okmod = false })
check(ms:optionsTable().activeProfile == nil,
"an off-profile toggle reverts to the ad-hoc set")
ms:applyProfile(easy)
check(ms.byId.okmod.enabled == true
and ms:optionsTable().activeProfile == "EASY",
"applying a profile stages the flips back")
ms:renameProfile(easy)
mgame.stack:top().onDone("HARD")
mgame.stack:pop()
check(easy.name == "HARD" and ms:optionsTable().activeProfile == "HARD",
"rename keeps the active pointer")
ms:deleteProfile(easy)
check(ms:findProfile("HARD") == nil
and ms:optionsTable().activeProfile == nil, "delete clears the profile")
-- permissions rows
local permy = manifest("permy", { permissions = { "network" } })
local msP = ManagerState.new(managerGame(fakeLoader({ permy })))
msP.game.stack:push(msP)
local prows = msP:permissionRows(permy)
check(prows[1].glyph == "!" and prows[1].label == "USES THE NETWORK",
"declared permissions render with risk glyphs")
check(msP:permissionRows(manifest("pure"))[1].label == "DATA & API ONLY",
"no permissions shows the synthetic clean row")
-- safe mode: the banner rides Runtime.safeMode, never an option
Runtime.safeMode = true
local msS = ManagerState.new(mgame)
msS:enter()
check(msS.banner == "SAFE MODE - ALL MODS OFF",
"safe mode shows the recovery banner")
Runtime.safeMode = nil
local msN = ManagerState.new(mgame)
msN:enter()
check(msN.banner == nil, "no safe mode, no banner")
-- empty roster shows the empty state and B closes the manager
local msE = ManagerState.new(managerGame(fakeLoader({})))
msE.game.stack:push(msE)
check(msE:modRows()[1].label == "NO MODS INSTALLED", "empty-state row")
press(msE, "b")
check(#msE.game.stack.states == 0, "B on the roster closes the manager")
-- ------- mod.ui through a loader-built api
-- the worked example in 12 6 does mod.ui.insertBefore / mod.ui.push /
-- mod.ui.Theme on the api the loader hands the entry chunk, so the facade
-- has to arrive wired there, not just exist as a module
local Loader = require("src.mods.Loader")
local uiFiles = {
["mods/uikit/manifest.json"] =
'{"id":"uikit","name":"uikit","version":"1.0.0","entry":"main.lua","api":2}',
["mods/uikit/main.lua"] = "return function(mod) _G.MOD_UI_API = mod end",
}
local uiFs = {
read = function(path) return uiFiles[path] end,
getInfo = function(path)
if uiFiles[path] then return { type = "file" } end
local prefix = path .. "/"
for key in pairs(uiFiles) do
if key:sub(1, #prefix) == prefix then return { type = "directory" } end
end
return nil
end,
load = function(path)
if not uiFiles[path] then return nil, "no file: " .. path end
return load(uiFiles[path], path)
end,
getDirectoryItems = function(path)
local seen, names = {}, {}
local prefix = path .. "/"
for key in pairs(uiFiles) do
if key:sub(1, #prefix) == prefix then
local child = key:sub(#prefix + 1):match("^[^/]+")
if child and not seen[child] then
seen[child] = true
names[#names + 1] = child
end
end
end
table.sort(names)
return names
end,
}
local uiLoader = Loader.new({ fs = uiFs })
check(uiLoader:load({}) == true, "the uikit fixture loads clean")
local uiApi = _G.MOD_UI_API
_G.MOD_UI_API = nil
check(uiApi ~= nil, "the entry chunk received its api")
check(uiApi.ui == ModUI, "mod.ui is the toolkit facade")
check(uiApi.ui.Theme == Theme, "mod.ui.Theme reaches the theme module")
check(uiApi.ui.Menu == require("src.ui.Menu"),
"mod.ui widgets resolve through the loader-built api")
local uiItems = { { label = "ITEM" } }
uiApi.ui.insertBefore(uiItems, "ITEM", { label = "QUESTS" })
check(uiItems[1].label == "QUESTS" and uiItems[2].label == "ITEM",
"mod.ui.insertBefore works as documented")
local uiGame = { data = {}, stack = newStack() }
local uiPushed = uiApi.ui.push(uiGame, "TrainerCard")
check(uiGame.stack:top() == uiPushed and uiPushed.screenId == "TrainerCard",
"mod.ui.push opens a screen from a loader-built api")
Runtime.safeMode = savedSafeMode
Runtime.install(savedEvents, savedHooks, savedErrors)
S.finish()