Files
gen1recomp/tests/engine/mod_load_order_bug2177.lua

476 lines
19 KiB
Lua

package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
love = love or require("tests.love_stub")
love.graphics.setLineJoin = love.graphics.setLineJoin or function() end
love.graphics.newShader = love.graphics.newShader or function() return {} end
local Loader = require("src.mods.Loader")
local CartManifest = require("src.carts.CartManifest")
local SaveData = require("src.core.SaveData")
local SaveSerializer = require("src.core.SaveSerializer")
local GameVersion = require("src.core.GameVersion")
local Manifest = require("src.mods.Manifest")
local ModProfile = require("src.mods.ModProfile")
local LauncherMods = require("src.mods.LauncherMods")
local LoadOrder = require("src.mods.LoadOrder")
local Logger = require("src.core.Logger")
Logger.warn = function() end
Logger.info = function() end
local function names(list) return table.concat(list or {}, ",") end
local function memfs(files)
return {
files = files,
read = function(path) return files[path] end,
write = function(path, content) files[path] = content return true end,
remove = function(path) files[path] = nil return true end,
getInfo = function(path)
if files[path] then return { type = "file" } end
local prefix = path .. "/"
for key in pairs(files) do
if key:sub(1, #prefix) == prefix then return { type = "directory" } end
end
return nil
end,
load = function(path)
if not files[path] then return nil, "no file: " .. path end
return load(files[path], path)
end,
createDirectory = function() return true end,
getDirectoryItems = function(path)
local seen, items = {}, {}
local prefix = path .. "/"
for key in pairs(files) 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
items[#items + 1] = child
end
end
end
table.sort(items)
return items
end,
}
end
local function manifestJson(id, extra)
return ([[{"id":"%s","name":"%s","version":"1.0.0","entry":"main.lua"%s}]])
:format(id, id, extra or "")
end
local function entry(label)
return ([[
return function(mod)
mod.content.pokemon:override("PIKACHU", { name = "%s" })
end
]]):format(label)
end
local function install(opts, extras)
extras = extras or {}
local files = { ["options.lua"] = SaveSerializer.encode(opts or {}) }
for _, id in ipairs({ "alpha", "beta", "gamma" }) do
files["mods/" .. id .. "/manifest.json"] = manifestJson(id, extras[id])
files["mods/" .. id .. "/main.lua"] = entry(id:upper())
end
SaveData.resetSlotState()
GameVersion.set("red")
return files
end
local function boot(files, mode)
local data = { pokemon = { PIKACHU = { name = "PIKACHU" } } }
local loader = Loader.new({ fs = memfs(files) })
local ok = loader:load(data, mode and { mode = mode } or nil)
return loader, data, ok
end
do
T.check(type(SaveData.defaultOptions().modOrder) == "table",
"defaultOptions carries a modOrder list")
T.eq(names(SaveData.modOrder({ modOrder = { "b", 3, "a", "b", "", "c" } })),
"b,a,c", "modOrder keeps strings once, in order")
T.eq(#SaveData.modOrder({}), 0, "no modOrder reads as empty")
local o = SaveData.setModOrder({}, { "x", "x", "y" })
T.eq(names(o.modOrder), "x,y", "setModOrder stores the sanitized list")
end
do
local entries = {
{ id = "gamma", priority = 0 }, { id = "alpha", priority = 5 },
{ id = "beta", priority = 0 },
}
T.eq(names(LoadOrder.materialize({}, entries)), "beta,gamma,alpha",
"an empty saved order materializes to (priority, id)")
T.eq(names(LoadOrder.materialize({ "alpha", "gone" }, entries)),
"beta,gamma,alpha,gone",
"unlisted ids merge in by priority, uninstalled saved ids keep their slot")
T.eq(names(LoadOrder.materialize({ "gamma" }, entries)), "gamma,beta,alpha",
"an unlisted id tied on priority follows the saved one")
local grown = {
{ id = "a", priority = 0 }, { id = "b", priority = 0 }, { id = "c", priority = 0 },
{ id = "fw", priority = -1000 }, { id = "late", priority = 50 },
}
T.eq(names(LoadOrder.materialize({ "b", "a", "c" }, grown)), "fw,b,a,c,late",
"a mod installed after a reorder still lands by its manifest priority")
local l = { "a", "b", "c", "d" }
T.eq(names(LoadOrder.move(l, "b", -1)), "b,a,c,d", "move up one")
T.eq(names(LoadOrder.move(l, "a", -1)), "a,b,c,d", "move up clamps at the top")
T.eq(names(LoadOrder.move(l, "d", 1)), "a,b,c,d", "move down clamps at the bottom")
T.eq(names(LoadOrder.move(l, "c", -math.huge)), "c,a,b,d", "move to top")
T.eq(names(LoadOrder.move(l, "a", math.huge)), "b,c,d,a", "move to bottom")
T.eq(names(l), "a,b,c,d", "move never edits its input")
local full, changed = LoadOrder.moveWithin({ "a", "x", "b", "c" },
{ "a", "b", "c" }, "b", -1)
T.check(changed, "moveWithin reports a change")
T.eq(names(full), "b,x,a,c",
"moveWithin swaps visible neighbours and keeps hidden ids in their slots")
end
do
local files = install()
local loader, data = boot(files)
T.eq(names(loader.order), "alpha,beta,gamma",
"no modOrder keeps the (priority, id) order")
T.eq(data.pokemon.PIKACHU.name, "GAMMA", "and the last id wins")
end
do
local files = install({ modOrder = { "gamma", "alpha", "beta" } })
local loader, data = boot(files)
T.eq(names(loader.order), "gamma,alpha,beta", "modOrder decides the load order")
T.eq(data.pokemon.PIKACHU.name, "BETA", "the last mod in the player's order wins")
end
do
local files = install({ modOrder = { "beta" } })
local loader = boot(files)
T.eq(names(loader.order), "beta,alpha,gamma",
"a partial list leads, the rest follow by priority and id")
end
do
local files = install({ modOrder = { "nope", "gamma" } })
local loader, _, ok = boot(files)
T.check(ok ~= false, "an unknown id in modOrder is not an error")
T.eq(names(loader.order), "gamma,alpha,beta", "unknown ids are skipped")
end
do
local files = install({ modOrder = { "alpha", "beta", "gamma" } },
{ alpha = [[,"priority":9]], gamma = [[,"priority":-4]] })
local loader = boot(files)
T.eq(names(loader.order), "alpha,beta,gamma",
"the player's order outranks manifest priority")
end
do
local files = install({ modOrder = { "beta", "alpha" } },
{ gamma = [[,"priority":-1000]] })
local loader = boot(files)
T.eq(names(loader.order), "gamma,beta,alpha",
"a low-priority mod missing from the saved order still loads first")
end
do
local files = install({ modOrder = { "alpha", "beta", "gamma" } },
{ alpha = [[,"dependencies":["gamma"] ]] })
local loader = boot(files)
T.eq(names(loader.order), "beta,gamma,alpha",
"a dependency still loads before the mod that needs it")
end
do
local files = install({ modOrder = { "alpha", "beta", "gamma" } })
local cart = assert(CartManifest.parse({ id = "cartx", title = "cartx",
version = "1.0.0", author = "t", shell = "#102030", base = "red",
seal = "sealed",
mods = { { id = "alpha", source = "local", version = "1.0.0" },
{ id = "gamma", source = "local", version = "1.0.0" } },
load_order = { "gamma", "alpha" } }))
files["carts/cartx" .. CartManifest.EXT] = CartManifest.encode(cart)
SaveData.setCart("cartx", "hashx")
local loader = boot(files)
T.eq(names(loader.order), "gamma,alpha", "a cart's load_order beats modOrder")
SaveData.setCart(nil)
end
do
local files = install({ modOrder = { "gamma", "alpha", "beta" } })
local loader = boot(files, "disableAll")
T.eq(loader.playerRank and next(loader.playerRank), nil,
"arena modes ignore the player's order")
end
do
local p = ModProfile.capture({ { id = "a", enabled = true } }, {}, {},
{ "b", "a", "b" })
T.eq(names(p.order), "b,a", "capture copies the sanitized order")
local back = ModProfile.decode(ModProfile.encode({ name = "P", enabled = {},
options = {}, slots = {}, enabledByVersion = {}, order = { "b", "a" } }))
T.eq(names(back and back.order), "b,a", "encode/decode round-trips order")
local bad = SaveSerializer.encode({ format = ModProfile.FORMAT,
formatVersion = 1, profile = { name = "Q", order = { "x", 4, "x", "y" } } })
T.eq(names(ModProfile.decode(bad).order), "x,y",
"decode drops non-strings and duplicates")
local live = { modOrder = { "z" } }
T.check(not ModProfile.matchesOrder({ name = "old" }, live),
"a profile without an order does not match a custom order")
ModProfile.restoreOrder({ name = "old" }, live)
T.eq(names(live.modOrder), "", "a profile without an order resets to the default order")
T.check(ModProfile.matchesOrder({ name = "old" }, live),
"and matches once the order is back to default")
ModProfile.restoreOrder(back, live)
T.eq(names(live.modOrder), "b,a", "restoreOrder writes the profile's order")
T.check(ModProfile.matchesOrder(back, live), "matchesOrder agrees after restore")
T.check(not ModProfile.matchesOrder(back, { modOrder = { "a", "b" } }),
"matchesOrder notices a moved mod")
end
local function mf(raw) return Manifest.validate(raw) end
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",
dependencies = { "ccc" } }),
mf({ id = "ccc", name = "C", version = "1.0.0", entry = "m.lua" }),
}
local rows = LauncherMods.deriveList(manifests,
{ mods = {}, modOrder = { "bbb", "ccc", "aaa" } }, "red")
local by = {}
for _, r in ipairs(rows) do by[r.id] = r end
T.eq(by.bbb.loadRank, 1, "deriveList stamps the saved position")
T.eq(by.aaa.loadRank, 3, "for every row")
T.eq(by.bbb.orderAfter, "ccc", "a dependency placed later is flagged")
T.check(type(by.bbb.orderNote) == "string" and by.bbb.orderNote:find("C", 1, true),
"with a note naming it")
T.eq(by.ccc.orderNote, nil, "no note when nothing forces a move")
T.eq(names(LauncherMods.orderedIds(rows, { modOrder = { "ccc" } })),
"ccc,aaa,bbb", "orderedIds materializes over rows")
T.eq(names(LauncherMods.moveInOrder({ "a", "b" }, "b", -1)), "b,a",
"moveInOrder swaps")
end
do
local fs = love.filesystem
for _, id in ipairs({ "lo_one", "lo_two", "lo_three" }) do
fs.write("mods/" .. id .. "/manifest.json", manifestJson(id))
fs.write("mods/" .. id .. "/main.lua", "return function() end")
end
SaveData.saveOptions(SaveData.defaultOptions())
local ok, at = LauncherMods.moveMod("lo_one", 1)
T.check(ok, "moveMod moves a mod down")
T.eq(at, 2, "and reports its new position")
T.eq(names(SaveData.loadOptions().modOrder), "lo_three,lo_one,lo_two",
"moveMod persists the whole materialized list")
T.check(not LauncherMods.moveMod("lo_two", 1), "the last mod cannot move down")
T.check(not LauncherMods.moveMod("missing_mod", -1), "an unknown id is refused")
local opts = SaveData.loadOptions()
opts.safeMode = true
SaveData.saveOptions(opts)
T.check(not LauncherMods.moveMod("lo_one", -1), "safe mode refuses a move")
opts = SaveData.loadOptions()
opts.safeMode = false
SaveData.saveOptions(opts)
fs.write("mods/lo_zero/manifest.json", manifestJson("lo_zero", [[,"priority":-1000]]))
fs.write("mods/lo_zero/main.lua", "return function() end")
T.check(LauncherMods.moveMod("lo_two", -1), "a move after a new install")
T.eq(names(SaveData.loadOptions().modOrder), "lo_zero,lo_three,lo_two,lo_one",
"the new low-priority mod was placed by priority, not forced last")
opts = SaveData.loadOptions()
opts.modProfiles = { { name = "PLAIN", enabled = {}, options = {}, slots = {},
enabledByVersion = {} } }
SaveData.saveOptions(opts)
T.check(LauncherMods.applyProfile("PLAIN"), "applyProfile takes a profile with no order")
T.eq(#SaveData.loadOptions().modOrder, 0, "and resets the load order to default")
LauncherMods.moveMod("lo_one", 1)
T.check(LauncherMods.resetOrder(), "resetOrder clears a saved order")
T.eq(#SaveData.loadOptions().modOrder, 0, "back to author priority")
opts = SaveData.loadOptions()
opts.modProfiles, opts.activeProfile = nil, nil
SaveData.saveOptions(opts)
for _, id in ipairs({ "lo_one", "lo_two", "lo_three", "lo_zero" }) do
fs.remove("mods/" .. id .. "/manifest.json")
fs.remove("mods/" .. id .. "/main.lua")
end
end
do
local ManagerState = require("src.mods.ManagerState")
local writes = 0
local game = {
save = { options = { mods = {}, modOrder = {} } },
modStatus = { errors = {}, available = {
{ id = "ma", name = "MA", enabled = true, state = "loaded", priority = 0 },
{ id = "mb", name = "MB", enabled = true, state = "loaded", priority = 0 },
{ id = "mc", name = "MC", enabled = true, state = "loaded", priority = 0 },
} },
writeOptions = function() writes = writes + 1 end,
stack = { pop = function() end, push = function() end },
}
local ms = ManagerState.new(game)
ms:refresh()
local function labels(rows)
local out = {}
for _, r in ipairs(rows) do out[#out + 1] = r.label end
return table.concat(out, "|")
end
local function rowNamed(rows, label)
for _, r in ipairs(rows) do if r.label == label then return r end end
end
local rows = ms:detailRows(ms.byId.ma)
local orderRow = rowNamed(rows, "< LOAD #1/3 >")
T.check(orderRow and orderRow.adjust, "the detail screen has one load position row")
T.eq(labels(rows):find("LOAD EARLIER", 1, true), nil, "no separate earlier/later rows")
ms.currentMod, ms.screen, ms.scroll = ms.byId.ma, "detail", 1
for i, r in ipairs(rows) do if r == orderRow then ms.cursor = i end end
ms:adjustOrTab(1)
T.eq(names(game.save.options.modOrder), "mb,ma,mc", "RIGHT on the row loads it later")
T.check(writes > 0, "and persists the options")
T.check(ms.restartPending, "a moved mod stages a restart")
rows = ms:detailRows(ms.byId.ma)
T.check(rowNamed(rows, "< LOAD #2/3 >") ~= nil, "the row follows the move")
ms:adjustOrTab(-1)
T.check(not ms.restartPending, "moving back to where it booted clears the restart")
T.eq(ms.descScroll, 1, "left/right on the row does not scroll the description")
rowNamed(ms:detailRows(ms.byId.mc), "< LOAD #3/3 >").adjust(-1)
ms:discardChanges()
T.eq(names(game.save.options.modOrder), "", "discard restores the boot order")
T.check(not ms.restartPending, "and clears the pending restart")
local Font = require("src.render.Font")
local busy = { id = "md", name = "MD", enabled = true, state = "loaded", priority = 0,
github = "someone/md", experimental = true, error = "boom", permissions = { "net" } }
game.modStatus.available[#game.modStatus.available + 1] = busy
ms:refresh()
ms.currentMod, ms.screen, ms.cursor, ms.scroll = ms.byId.md, "detail", 1, 1
local all = ms:rowsForScreen()
T.check(#all > 4, "a busy mod has more detail rows than fit above the footer")
local function drawAll()
local drawn = {}
local realDraw, realCode = Font.draw, Font.drawCode
Font.draw = function(text, x, y) drawn[#drawn + 1] = { text = tostring(text), x = x, y = y } end
Font.drawCode = function() end
ms:drawDetail()
Font.draw, Font.drawCode = realDraw, realCode
local low, seen = true, {}
for _, d in ipairs(drawn) do
if d.x == 32 then
if d.y >= 15 * 8 then low = false end
seen[d.text] = true
end
end
return low, seen
end
local clear, seen = drawAll()
T.check(clear, "no detail row draws on the footer")
T.check(seen["FOR " .. require("src.mods.ModTargets").chip(busy)] == nil,
"FOR is a header line, not a row")
T.check(not seen["BACK"], "BACK starts below the scroll window")
for _ = 1, #all - 1 do ms:moveCursor(1) end
T.eq(all[ms.cursor].label, "BACK", "the cursor reaches BACK")
clear, seen = drawAll()
T.check(clear and seen["BACK"], "and BACK scrolls into view above the footer")
end
do
local Kit = require("src.ui.kit.Kit")
local RomImporter = require("src.import.RomImporter")
local LauncherView = require("src.import.LauncherView")
local function window(w, h)
love.graphics.getDimensions = function() return w, h end
love.graphics.getPixelDimensions = function() return w, h end
end
local manifests = {
mf({ id = "va", name = "Alpha", version = "1.0.0", entry = "m.lua" }),
mf({ id = "vb", name = "Beta", version = "1.0.0", entry = "m.lua" }),
mf({ id = "vc", name = "Gamma", version = "1.0.0", entry = "m.lua" }),
}
local rows = LauncherMods.deriveList(manifests,
{ mods = {}, modOrder = { "vc", "va", "vb" } })
local seen, labels = {}, {}
local realButton = Kit.button
Kit.button = function(x, y, w, h, label, opts)
if opts and opts.id then
seen[opts.id] = { x = x, y = y, w = w, h = h, opts = opts }
labels[opts.id] = label
end
return realButton(x, y, w, h, label, opts)
end
local texts = {}
local realText = Kit.text
Kit.text = function(font, s, ...)
texts[#texts + 1] = s
return realText(font, s, ...)
end
for _, size in ipairs({ { 1280, 2400 }, { 360, 3200 } }) do
window(size[1], size[2])
seen, labels, texts = {}, {}, {}
local imp = RomImporter.new(function() end, { launcher = true })
imp.tab = "mods"
imp.ready = { red = true }
imp.mods = rows
imp.modSort = "order"
local moved
imp._moveMod = function(_, id, delta) moved = { id, delta } end
local ok, err = pcall(LauncherView.draw, imp)
T.check(ok, ("%dx%d draws: %s"):format(size[1], size[2], tostring(err)))
local tag = size[1] .. "x" .. size[2]
T.check(seen["mod-up-va"] ~= nil and seen["mod-down-va"] ~= nil,
tag .. ": up/down buttons are drawn for a visible row")
T.check(seen["mod-up-vc"] and seen["mod-up-vc"].opts.enabled == false,
tag .. ": the first mod cannot move up")
T.check(seen["mod-down-vb"] and seen["mod-down-vb"].opts.enabled == false,
tag .. ": the last mod cannot move down")
local found = false
for _, s in ipairs(texts) do
if type(s) == "string" and s:find("^#1 Gamma") then found = true end
end
T.check(found, tag .. ": the list is sorted by load order with #n badges")
local det, up = seen["mod-row-va"], seen["mod-up-va"]
if det and up then
T.check(det.y == up.y and det.x + det.w <= up.x,
tag .. ": the move buttons share the Details line, row height unchanged")
end
seen["mod-down-va"].opts.action()
T.eq(moved and moved[1], "va", tag .. ": Down moves that mod")
T.eq(moved and moved[2], 1, tag .. ": by one slot")
imp._modActions = "va"
seen, texts = {}, {}
imp._uiActions = {}
ok, err = pcall(LauncherView.draw, imp)
T.check(ok, tag .. " details modal draws: " .. tostring(err))
T.check(seen["modact-up"] and seen["modact-down"] and seen["modact-top"]
and seen["modact-bottom"], tag .. ": Details offers the four Move buttons")
T.check(seen["modact-top"] and seen["modact-top"].opts.enabled == true,
tag .. ": the middle mod can move to the top")
seen["modact-bottom"].opts.action()
T.eq(moved and moved[2], math.huge, tag .. ": Move to bottom")
imp._modActions = nil
imp._sortPopup = "mods"
seen = {}
pcall(LauncherView.draw, imp)
T.check(seen["sortpop-order"] ~= nil, tag .. ": Load order is a MODS sort")
T.check(seen["sortpop-reset-order"] ~= nil, tag .. ": with a reset button")
imp._sortPopup = "find"
seen = {}
pcall(LauncherView.draw, imp)
T.check(seen["sortpop-order"] == nil, tag .. ": but not a FIND sort")
end
Kit.button, Kit.text = realButton, realText
end
T.finish("mod load order (#2177)")