mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
777 lines
31 KiB
Lua
777 lines
31 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 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", "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 MODS row is the manager's discoverable home
|
|
local mgGame = optGame()
|
|
om = OptionsMenu.new(mgGame)
|
|
om.rows[13].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[14].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()
|