-- Love-free coverage for the pure halves of src/mods/LauncherMods.lua: the -- status derivation (deriveList) over a synthetic manifest list + options -- table, and the archive-root location logic (locateRoot). The discovery and -- installZip paths need love.filesystem and are exercised by the launcher; the -- decision logic under them lives here so a bad range/conflict/root call fails -- one line instead of the app. -- luajit tests/engine/launcher_mods_tests.lua package.path = "./?.lua;./?/init.lua;" .. package.path local T = require("tests.harness") local check, eq = T.check, T.eq local Manifest = require("src.mods.Manifest") local Version = require("src.core.Version") local LauncherMods = require("src.mods.LauncherMods") -- validated manifests are the exact shape deriveList/resolveToggle read local function mf(raw) return Manifest.validate(raw) end -- index a deriveList result by mod id for assertions local function byId(list) local m = {} for _, row in ipairs(list) do m[row.id] = row end return m end -- ------- badge derivation: category, then profile, then MOD (uppercased) do local list = LauncherMods.deriveList({ mf({ id = "cat", name = "Cat Mod", version = "1.0.0", entry = "m.lua", category = "gameplay" }), mf({ id = "prof", name = "Prof Mod", version = "1.0.0", entry = "m.lua", profile = "overhaul" }), mf({ id = "plain", name = "Plain", version = "1.0.0", entry = "m.lua" }), }, { mods = {} }) local m = byId(list) eq(m.cat.badge, "GAMEPLAY", "badge uses the manifest category, uppercased") eq(m.prof.badge, "OVERHAUL", "badge falls back to the profile when no category") -- no category field, so the fallback reaches the profile default ("content") eq(m.plain.badge, "CONTENT", "bare manifest badge falls back to the profile") eq(#list, 3, "every discovered manifest yields one row") check(m.cat.id < m.plain.id and m.plain.id < m.prof.id, "rows come back sorted by id (cat < plain < prof)") end -- ------- enabled defaults to true; a false entry disables do local manifests = { mf({ id = "aaa", name = "A", version = "1.0.0", entry = "m.lua" }), mf({ id = "bbb", name = "B", version = "1.0.0", entry = "m.lua" }), } local m = byId(LauncherMods.deriveList(manifests, { mods = { bbb = false } })) check(m.aaa.enabled, "a mod with no options entry defaults to enabled") check(not m.bbb.enabled, "an explicit false disables the mod") eq(m.aaa.status, "ok", "a healthy enabled mod is ok") eq(m.aaa.statusDetail, "Ready", "ok detail reads Ready") end -- ------- experimental defaults to disabled; github surfaces on the row do local manifests = { mf({ id = "lab", name = "Lab", version = "1.2.0", entry = "m.lua", experimental = true, github = "acme/lab" }), mf({ id = "lab_on", name = "Lab On", version = "1.0.0", entry = "m.lua", experimental = true }), } local m = byId(LauncherMods.deriveList(manifests, { mods = { lab_on = true }, })) check(not m.lab.enabled, "experimental with no options entry stays off") check(m.lab_on.enabled, "experimental can still be explicitly enabled") eq(m.lab.badge, "EXPERIMENTAL", "experimental badge overrides category") eq(m.lab.github, "acme/lab", "github is exposed on the panel row") check(m.lab.experimental, "experimental flag is exposed on the panel row") end -- ------- conflict: only when this mod is enabled and the other is too do local manifests = { mf({ id = "alpha", name = "Alpha", version = "1.0.0", entry = "m.lua", conflicts = { "beta" } }), mf({ id = "beta", name = "Beta", version = "1.0.0", entry = "m.lua" }), } -- both enabled: the declaring side (and, symmetrically, the other) conflict local both = byId(LauncherMods.deriveList(manifests, { mods = {} })) eq(both.alpha.status, "conflict", "enabled mod conflicting with an enabled mod") check(both.alpha.statusDetail:find("Beta", 1, true) ~= nil, "conflict detail names the other mod") eq(both.beta.status, "conflict", "resolveToggle conflict is bidirectional: the target is flagged too") -- disable beta: alpha no longer conflicts (nothing enabled to conflict with) local off = byId(LauncherMods.deriveList(manifests, { mods = { beta = false } })) eq(off.alpha.status, "ok", "no conflict once the other side is disabled") eq(off.beta.status, "ok", "a disabled mod is never a conflict") end -- ------- warn: unsatisfied game_version range against Version.engine do -- Stamped like a shipped build, because the 0.0.0-dev placeholder is not a -- compatibility statement and both LauncherMods and Loader.devEngine skip -- the range check on it. Unstamped, this row is "ok" on purpose. local was = Version.engine Version.engine = "1.4.0" local manifests = { mf({ id = "future", name = "Future", version = "1.0.0", entry = "m.lua", game_version = ">=9.9.9" }), } local m = byId(LauncherMods.deriveList(manifests, { mods = {} })) eq(m.future.status, "warn", "engine outside the game_version range warns") check(m.future.statusDetail:find(">=9.9.9", 1, true) ~= nil, "version warn detail quotes the required range") check(m.future.statusDetail:find("1.4.0", 1, true) ~= nil, "version warn detail quotes the engine version") Version.engine = was -- and the dev placeholder agrees with the loader instead of warning local dev = byId(LauncherMods.deriveList(manifests, { mods = {} })) eq(dev.future.status, "ok", "a dev checkout does not warn where the loader loads") end -- ------- warn: hard dependency missing, disabled, or wrong version do local base = { id = "base", name = "Base", version = "1.0.0", entry = "m.lua" } local needsMissing = { id = "needy", name = "Needy", version = "1.0.0", entry = "m.lua", dependencies = { "ghost" } } local m = byId(LauncherMods.deriveList({ mf(needsMissing) }, { mods = {} })) eq(m.needy.status, "warn", "a missing hard dependency warns") check(m.needy.statusDetail:find("not installed", 1, true) ~= nil, "missing-dep detail says not installed") -- present but disabled local m2 = byId(LauncherMods.deriveList( { mf(base), mf({ id = "needy", name = "Needy", version = "1.0.0", entry = "m.lua", dependencies = { "base" } }) }, { mods = { base = false } })) eq(m2.needy.status, "warn", "a disabled hard dependency warns") check(m2.needy.statusDetail:find("disabled", 1, true) ~= nil, "disabled-dep detail says disabled") -- present, enabled, but the version is out of range local m3 = byId(LauncherMods.deriveList( { mf(base), mf({ id = "needy", name = "Needy", version = "1.0.0", entry = "m.lua", dependencies = { "base@>=2.0.0" } }) }, { mods = {} })) eq(m3.needy.status, "warn", "a dependency below the required range warns") eq(m3.base.status, "ok", "the satisfied dependency itself stays ok") -- the same dep satisfied: needy is ok local m4 = byId(LauncherMods.deriveList( { mf(base), mf({ id = "needy", name = "Needy", version = "1.0.0", entry = "m.lua", dependencies = { "base@>=1.0.0" } }) }, { mods = {} })) eq(m4.needy.status, "ok", "a satisfied dependency clears the warn") end -- ------- conflict outranks warn when a mod trips both do local manifests = { mf({ id = "alpha", name = "Alpha", version = "1.0.0", entry = "m.lua", conflicts = { "beta" }, game_version = ">=1.0.0" }), mf({ id = "beta", name = "Beta", version = "1.0.0", entry = "m.lua" }), } local m = byId(LauncherMods.deriveList(manifests, { mods = {} })) eq(m.alpha.status, "conflict", "conflict is reported ahead of a version warn on the same mod") end -- ------- which game a row is answered for (src/mods/ModTargets.lua) do local manifests = { mf({ id = "one", name = "One", version = "1.0.0", entry = "m.lua" }), mf({ id = "two", name = "Two", version = "1.0.0", entry = "m.lua", games = { "gen2" } }), mf({ id = "both", name = "Both", version = "1.0.0", entry = "m.lua", games = { "all" } }), } -- no game named: the pre-per-game view, where every row is just ready local all = byId(LauncherMods.deriveList(manifests, { mods = {} })) eq(all.one.targets, "GEN 1", "the chip says which games the mod is for") eq(all.two.targets, "GEN 2", "for each of them") eq(all.both.targets, "GEN 1+2", "including both") eq(all.one.targetsHere, nil, "with no game to answer for, nothing is claimed") eq(all.two.status, "ok", "and no row is judged against a game") local onGold = byId(LauncherMods.deriveList(manifests, { mods = {} }, "gold")) eq(onGold.one.status, "other_game", "a Gen 1 mod is not for Gold") eq(onGold.one.statusDetail, "For Gen 1, not Gold", "and says so in one line") eq(onGold.one.targetsHere, false, "the row carries the verdict too") eq(onGold.two.status, "ok", "a Gen 2 mod is ready there") eq(onGold.both.targetsHere, true, "and so is one that claims both") local onRed = byId(LauncherMods.deriveList(manifests, { mods = {} }, "red")) eq(onRed.two.status, "other_game", "the same rule points the other way") eq(onRed.one.status, "ok", "without touching the Gen 1 mod") end -- ------- the row is a verdict, not a decoration: the loader enforces it -- -- "For Blue, not Red" has to be what the boot does, per VERSION and not only -- per generation, or the panel is reporting a claim while the mod runs anyway. -- Real loader, real gate (Loader:_gateGeneration), no love. do local Sdk = require("tests.modkit.sdk") local GameVersion = require("src.core.GameVersion") local function manifestFile(id, games) return ("{\"id\":\"%s\",\"name\":\"%s\",\"version\":\"1.0.0\"," .. "\"entry\":\"main.lua\",\"api\":2,\"games\":[\"%s\"]}"):format(id, id, games) end local FILES = { ["mods/blueonly/manifest.json"] = manifestFile("blueonly", "blue"), ["mods/blueonly/main.lua"] = "local mod = ...\n", ["mods/goldonly/manifest.json"] = manifestFile("goldonly", "gold"), ["mods/goldonly/main.lua"] = "local mod = ...\n", ["mods/anygame/manifest.json"] = manifestFile("anygame", "all"), ["mods/anygame/main.lua"] = "local mod = ...\n", } local paths = { "mods/blueonly", "mods/goldonly", "mods/anygame" } local was = GameVersion.get() GameVersion.set("red") local run = Sdk.loadMods(paths, { fs = Sdk.memfs(FILES), generation = 1 }) local rows = byId(LauncherMods.deriveList({ mf({ id = "blueonly", name = "blueonly", version = "1.0.0", entry = "main.lua", games = { "blue" } }), mf({ id = "goldonly", name = "goldonly", version = "1.0.0", entry = "main.lua", games = { "gold" } }), mf({ id = "anygame", name = "anygame", version = "1.0.0", entry = "main.lua", games = { "all" } }), }, { mods = {} }, "red")) for _, id in ipairs({ "blueonly", "goldonly", "anygame" }) do local ran = run.loader.mods[id].state ~= "wrong_generation" eq(ran, rows[id].targetsHere, "the loader and the panel agree about " .. id .. " on Red") end eq(run.loader.mods.blueonly.state, "wrong_generation", "a Blue-only mod does not run on Red") eq(run.loader.mods.blueonly.skipReason, "For Blue, not Red", "and the skip line is the launcher's own line") eq(run.loader.mods.anygame.state, "loaded", "a mod for every game still runs") run.release() -- the override answers for ONE game. A version-blind flag forced a mod -- past the gate on a game whose owner was never asked (SaveData.modForced). local Serializer = require("src.core.SaveSerializer") local function bootWith(modsGen2, generation) local fs = Sdk.memfs(FILES) fs.write("options.lua", Serializer.encode({ mods = {}, modsGen2 = modsGen2 })) local r = Sdk.loadMods(paths, { fs = fs, generation = generation }) local state = r.loader.mods.blueonly.state r.release() return state end eq(bootWith({ blueonly = { red = true } }, 1), "loaded", "an override for Red runs the Blue-only mod on Red") eq(bootWith({ blueonly = { blue = true } }, 1), "wrong_generation", "an override for another game does not answer for Red") eq(bootWith({ blueonly = true }, 1), "wrong_generation", "a pre-per-game flag keeps its old meaning: Gen 2 only, never Red") GameVersion.set("gold") eq(bootWith({ blueonly = true }, 2), "loaded", "and on the Gen 2 game it always meant, it still forces") eq(bootWith({}, 2), "wrong_generation", "with no override the gate holds") if was then GameVersion.set(was) end end do -- the player's override is the one thing that outranks the author's claim, -- and it must read as the untested thing it is (Loader:_gateGeneration) local manifests = { mf({ id = "one", name = "One", version = "1.0.0", entry = "m.lua" }), } local m = byId(LauncherMods.deriveList(manifests, { mods = {}, modsGen2 = { one = true } }, "gold")) eq(m.one.status, "warn", "a forced mod is a warning, not a wrong game") check(m.one.statusDetail:find("Gold", 1, true) ~= nil, "and the line names the game it was forced onto") eq(m.one.targetsHere, true, "it will run there") end -- ------- enable flags: the panel reads exactly what the switch writes -- -- One scope for both halves (SaveData.modScope). While per-game flags are a -- preview the shared flag is the whole answer, so a modsByVersion overlay -- -- which an imported .g1rmodlist can plant, ModProfile.restoreVersions -- can -- never leave the switch showing an answer no writer can reach. local SaveData = require("src.core.SaveData") local function flip(options, id, enabled, version) SaveData.setModEnabled(options, id, enabled, SaveData.modScope(version)) end do local manifests = { mf({ id = "one", name = "One", version = "1.0.0", entry = "m.lua", games = { "all" } }), } local planted = { mods = { one = true }, modsByVersion = { gold = { one = false } } } local expected = SaveData.PER_VERSION_MODS and false or true eq(byId(LauncherMods.deriveList(manifests, planted, "gold")).one.enabled, expected, "the overlay is read exactly when a write can reach it") -- the round trip, the thing the dead switch failed: flip it, re-derive local options = { mods = {} } for _, version in ipairs({ "red", "gold" }) do flip(options, "one", false, version) eq(byId(LauncherMods.deriveList(manifests, options, version)).one.enabled, false, "switching off reads back off on " .. version) flip(options, "one", true, version) eq(byId(LauncherMods.deriveList(manifests, options, version)).one.enabled, true, "and switching on reads back on on " .. version) end -- the same round trip through the planted overlay: no write is ignored flip(planted, "one", false, "gold") eq(byId(LauncherMods.deriveList(manifests, planted, "gold")).one.enabled, false, "a planted overlay cannot outrank the player's own write") flip(planted, "one", true, "gold") eq(byId(LauncherMods.deriveList(manifests, planted, "gold")).one.enabled, true, "in either direction") -- and what the loader will do agrees with the row, per game eq(SaveData.modEnabled(planted, "one", SaveData.modScope("gold")), true, "the loader resolves the flag under the same scope the panel read") end -- ------- a dependency that does not run here is a dependency problem -- -- The loader's target skip is contagious (Loader:_enforceDependencies), so a -- mod that runs on every game still does not run on Gold when the mod it -- needs is Gen 1 only. do local manifests = { mf({ id = "base", name = "Base", version = "1.0.0", entry = "m.lua", games = { "gen1" } }), mf({ id = "user", name = "User", version = "1.0.0", entry = "m.lua", games = { "all" }, dependencies = { "base" } }), } local onGold = byId(LauncherMods.deriveList(manifests, { mods = {} }, "gold")) eq(onGold.user.status, "warn", "a dependency that cannot run here is a warning, not Ready") check(onGold.user.statusDetail:find("base", 1, true) ~= nil and onGold.user.statusDetail:find("Gold", 1, true) ~= nil, "and the line names the dependency and the game") eq(byId(LauncherMods.deriveList(manifests, { mods = {} }, "red")).user.status, "ok", "while the same pair is Ready where both run") -- the player's override on the DEPENDENCY clears it: same scope the loader -- resolves the override under (SaveData.modForced) local forced = { mods = {}, modsGen2 = { base = { gold = true } } } eq(byId(LauncherMods.deriveList(manifests, forced, "gold")).user.status, "ok", "forcing the dependency onto Gold clears the dependent's warning") eq(byId(LauncherMods.deriveList(manifests, { mods = {}, modsGen2 = { base = { red = true } } }, "gold")).user.status, "warn", "an override for another game does not answer for this one") end -- ------- locateRoot: manifest at the archive root do local root, err = LauncherMods.locateRoot({ "manifest.json", "main.lua" }) eq(root, "", "a root-level manifest.json resolves to the empty prefix") eq(err, nil, "no error for a root-level manifest") end -- ------- locateRoot: manifest inside a single top-level folder do local root = LauncherMods.locateRoot({ "mymod/manifest.json", "mymod/main.lua", "mymod/assets/x.png" }) eq(root, "mymod", "a single wrapping folder resolves to that folder name") end -- ------- locateRoot: no manifest anywhere do local root, err = LauncherMods.locateRoot({ "readme.txt", "stuff/x.lua" }) eq(root, nil, "an archive with no manifest.json resolves to nil") check(err:find("no manifest.json", 1, true) ~= nil, "the no-manifest reason is user-presentable") end -- ------- locateRoot: multiple top-level folders is ambiguous do local root, err = LauncherMods.locateRoot({ "one/manifest.json", "two/manifest.json" }) eq(root, nil, "two candidate mod folders resolves to nil") check(err:find("single mod folder", 1, true) ~= nil, "the ambiguous reason asks for a single mod folder") end -- ------- locateRoot: a lone folder without a manifest is not a root do local root, err = LauncherMods.locateRoot({ "assets/x.png" }) eq(root, nil, "a single folder with no manifest is not a mod root") check(err ~= nil, "the no-root case carries a reason") end -- ------- uninstall: rejects bad ids without needing a real mods tree do local ok, err = LauncherMods.uninstall("") eq(ok, nil, "empty id is rejected") check(tostring(err):find("missing", 1, true) ~= nil, "empty-id reason") ok, err = LauncherMods.uninstall("../escape") eq(ok, nil, "path-like ids are rejected") check(tostring(err):find("invalid", 1, true) ~= nil, "path-id reason") ok, err = LauncherMods.uninstall("ghost") -- Without a mods/ghost tree (and with the love stub's getInfo), uninstall -- either needs LOVE or reports not installed -- never silently succeeds. eq(ok, nil, "a missing mod does not uninstall") check(err ~= nil, "missing-mod uninstall carries a reason") end -- ------- issue #325: the Windows pickers must not hand back mangled paths do -- PowerShell writes the pick in the console's OEM codepage by default -- (Pokémon -> Pok\x82mon), which broke the open AND crashed the mods -- panel's UTF-8-validating text draw. Every Windows picker script must -- force UTF-8 output, and the mod picker must return an ASCII temp copy -- since io.open on Windows needs ANSI bytes to open the file at all. local f = assert(io.open("src/import/RomImporter.lua", "rb")) local src = f:read("*a") f:close() local utf8, copies = 0, 0 for _ in src:gmatch("OutputEncoding=%[Text%.Encoding%]::UTF8") do utf8 = utf8 + 1 end check(utf8 >= 3, "all three Windows pickers force UTF-8 output") check(src:find("pokeport_mod_pick.zip", 1, true) ~= nil, "the mod picker copies the pick to an ASCII temp name") check(src:find("Copy%-Item %-LiteralPath") ~= nil, "the copy uses the literal picked path") end -- ------- pickStrays: which mods dropped beside the game are worth adopting do -- the case this exists for: a player unzipped a mod next to the executable -- of a non-portable install, where the game has no way to read it local rows = LauncherMods.pickStrays({ { id = "b_mod", name = "B", folder = "/game", path = "m/b_mod" }, { id = "a_mod", name = "A", folder = "/game", path = "m/a_mod" }, }, {}) eq(#rows, 2, "an uninstalled stray is worth adopting") eq(rows[1].id, "a_mod", "rows come back sorted by id") eq(rows[2].id, "b_mod", "both of them") eq(rows[1].path, "m/a_mod", "carrying the path the copy reads from") eq(rows[1].folder, "/game", "and the folder it was found in, for the notice") end do -- already installed: the player has a working copy and the loose folder is -- just where they first put it. Silence is right -- adopting would make a -- second copy, and warning would nag on every open. local rows = LauncherMods.pickStrays({ { id = "have", name = "Have" }, { id = "want", name = "Want" }, }, { have = true }) eq(#rows, 1, "a stray the game can already see is not a stray") eq(rows[1].id, "want", "only the one it cannot see is adopted") end do -- two game folders can both hold the same id (a launcher install plus an -- older manual one). First wins, matching discover()'s duplicate rule. local rows = LauncherMods.pickStrays({ { id = "dup", name = "First", folder = "/a" }, { id = "dup", name = "Second", folder = "/b" }, }, {}) eq(#rows, 1, "a duplicate id across two game folders is adopted once") eq(rows[1].name, "First", "and the first one found wins") end do eq(#LauncherMods.pickStrays({}, {}), 0, "no candidates, nothing to adopt") eq(#LauncherMods.pickStrays(nil, nil), 0, "and nil is not an error") eq(#LauncherMods.pickStrays({ { name = "no id" } }, {}), 0, "a row with no id is dropped rather than crashing the panel") local rows = LauncherMods.pickStrays({ { id = "bare" } }, {}) eq(rows[1].name, "bare", "a nameless row falls back to its id") end -- ------- manifest strings are scrubbed to valid UTF-8 (MODS panel crash: -- LÖVE's printf raises "Invalid UTF-8" on a mangled name/description, so -- validate must drop bad bytes before any panel draws them) do local m = mf({ id = "utf", entry = "m.lua", -- BOM-prefixed name (a real manifest shipped this way), a Latin-1 e-acute -- (\233, invalid as UTF-8) in the description, and a lone continuation -- byte in the version name = "\239\187\191Run Mode", version = "1.0\128.0", description = "caf\233 latt\233", category = "UI\255" }) eq(m.name, "Run Mode", "a leading BOM is stripped from the name") eq(m.version, "1.0.0", "invalid bytes are dropped from the version") eq(m.description, "caf latt", "Latin-1 bytes are dropped, not replaced") eq(m.raw.category, "UI", "raw.category is scrubbed in place for the badge") local ok2 = mf({ id = "utf2", name = "Vers\195\163oVermelha", version = "1.0.0", entry = "m.lua", description = "Pok\195\169mon \240\159\148\165" }) eq(ok2.name, "Vers\195\163oVermelha", "valid two-byte sequences survive") eq(ok2.description, "Pok\195\169mon \240\159\148\165", "valid three- and four-byte sequences survive") -- surrogate half (ED A0 80) and overlong slash (C0 AF) are invalid even -- though their lead bytes look plausible local bad = mf({ id = "utf3", name = "a\237\160\128b\192\175c", version = "1.0.0", entry = "m.lua" }) eq(bad.name, "abc", "surrogates and overlongs are dropped") end -- ------- pre-boot translation strings (deriveStrings) -- -- The launcher draws before Game:load, so a translation mod's catalog has to -- reach Strings without the loader running. These are the rules that decide -- what it may contribute, with the filesystem read injected. do local manifests = { { id = "aaa", name = "A", version = "1.0.0", path = "mods/aaa" }, { id = "zzz", name = "Z", version = "1.0.0", path = "mods/zzz" }, } local catalogs = { ["mods/aaa"] = { ["Import ROM"] = "A-rom", ["Delete"] = "A-del", ["Cancel"] = "" }, ["mods/zzz"] = { ["Import ROM"] = "Z-rom" }, } local function read(path) return catalogs[path] end local function byIdMap(ms) local m = {} for _, x in ipairs(ms) do m[x.id] = x end return m end local rows = LauncherMods.deriveList(manifests, { mods = {} }) local merged = LauncherMods.deriveStrings(rows, byIdMap(manifests), read) eq(merged["Delete"], "A-del", "an enabled mod contributes its catalog") eq(merged["Import ROM"], "Z-rom", "later id wins a shared key, as it would at boot") eq(merged["Cancel"], nil, "an empty value is untranslated, never a blank translation") local offRows = LauncherMods.deriveList(manifests, { mods = { zzz = false } }) local off = LauncherMods.deriveStrings(offRows, byIdMap(manifests), read) eq(off["Import ROM"], "A-rom", "a disabled mod contributes nothing") local none = LauncherMods.deriveStrings( LauncherMods.deriveList(manifests, { mods = { aaa = false, zzz = false } }), byIdMap(manifests), read) eq(none, nil, "no enabled catalog leaves the launcher on its English source") eq(LauncherMods.deriveStrings(rows, byIdMap(manifests), function() return nil end), nil, "a mod that ships no catalog is skipped, not an error") end T.finish("launcher_mods")