Files
gen1recomp/tests/engine/sync_mods_test.lua
T
2026-08-20 20:36:23 -04:00

273 lines
11 KiB
Lua

package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
love = love or require("tests.love_stub")
local SyncMods = require("src.sync.SyncMods")
local function row(id, version, enabled, github)
return { id = id, version = version, github = github,
enabledByVersion = enabled }
end
local function deps(installed, indexes, catalog, modOptions)
local calls = { installed = {}, enabled = {}, indexes = {}, options = {} }
return calls, {
modOptions = function() return modOptions or {} end,
setOptions = function(id, values)
calls.options[id] = values
return true
end,
installed = function() return installed end,
indexes = function() return indexes or {} end,
addIndex = function(url)
calls.indexes[#calls.indexes + 1] = url
return { feed = url }
end,
findEntry = function(id) return (catalog or {})[id] end,
install = function(entry)
calls.installed[#calls.installed + 1] = entry.id
return true
end,
setEnabled = function(id, enabled, version)
calls.enabled[#calls.enabled + 1] = id .. ":" .. tostring(version)
return true
end,
}
end
do
local _, d = deps({
row("zeta", "1.0.0", { red = true, blue = false, yellow = false, gold = false }),
row("alpha", "2.1.0", { red = true, gold = true }, "someone/alpha"),
}, { { url = "https://mods.example/index.json",
feed = "https://mods.example/index.json" } })
local manifest = SyncMods.build(d)
T.eq(manifest.rev, SyncMods.REV, "the manifest carries its shape revision")
T.eq(#manifest.indexes, 1, "the player's index list rides along")
T.eq(manifest.indexes[1], "https://mods.example/index.json",
"as the url they typed")
T.eq(#manifest.mods, 2, "every installed mod is listed")
T.eq(manifest.mods[1].id, "alpha", "sorted by id so the manifest is stable")
T.eq(manifest.mods[1].source, "github:someone/alpha",
"a github mod records where it came from")
T.eq(manifest.mods[2].source, "local",
"a hand-installed mod is marked local rather than invented")
T.eq(#manifest.mods[1].enabledFor, 2, "alpha is on for two games")
T.eq(manifest.mods[1].enabledFor[1], "red", "in GameVersion order")
T.eq(manifest.mods[1].enabledFor[2], "gold", "red then gold")
T.eq(#manifest.mods[2].enabledFor, 1, "zeta is on for one")
end
do
local manifest = {
rev = 1,
indexes = { "https://mods.example/index.json", "https://other.example/i.json" },
mods = {
{ id = "alpha", version = "2.1.0", enabledFor = { "red", "gold" } },
{ id = "beta", version = "1.0.0", enabledFor = { "red" } },
{ id = "ghost", version = "0.1.0", source = "local", enabledFor = { "red" } },
},
}
local _, d = deps(
{ row("alpha", "2.1.0", { red = true }) },
{ { url = "https://mods.example/index.json",
feed = "https://mods.example/index.json" } },
{ beta = { id = "beta" } })
local plan = SyncMods.plan(manifest, d)
T.eq(#plan.indexes, 1, "only the index this device is missing is planned")
T.eq(plan.indexes[1], "https://other.example/i.json", "the new one")
T.eq(#plan.toInstall, 1, "one mod can be fetched from an index")
T.eq(plan.toInstall[1].id, "beta", "the one the catalog knows")
T.eq(#plan.missing, 1, "the mod nobody publishes is reported, not invented")
T.eq(plan.missing[1].id, "ghost", "by id")
T.eq(#plan.toEnable, 2, "every game answer that differs is planned")
for _, want in ipairs(plan.toEnable) do
T.check(want.id ~= "ghost",
"a mod that cannot be installed is never enabled")
end
T.eq(SyncMods.planEmpty(plan), false, "a plan with work is not empty")
local same = SyncMods.plan({ rev = 1, indexes = {}, mods = {
{ id = "alpha", version = "2.1.0", enabledFor = { "red" } } } }, d)
T.eq(SyncMods.planEmpty(same), true, "a matching device plans nothing")
end
do
local calls, d = deps({}, {}, { beta = { id = "beta" } })
local plan = {
indexes = { "https://other.example/i.json" },
toInstall = { { id = "beta", entry = { id = "beta" } } },
toEnable = { { id = "beta", version = "red" } },
missing = { { id = "ghost" } },
}
local seen = {}
local ok = SyncMods.apply(plan, function(done, total, label)
seen[#seen + 1] = ("%d/%d %s"):format(done, total, label)
end, d)
T.eq(ok, true, "applying a plan reports success")
T.eq(calls.indexes[1], "https://other.example/i.json", "the index is added")
T.eq(calls.installed[1], "beta", "the mod is installed through the launcher path")
T.eq(calls.enabled[1], "beta:red", "and enabled for the game that wanted it")
T.eq(#seen, 3, "progress is reported once per step")
T.eq(seen[3], "3/3 beta", "counting up to the total")
end
do
local _, d = deps({}, {}, {})
d.install = function() return nil, "download failed" end
local ok, err = SyncMods.apply({
toInstall = { { id = "beta", entry = { id = "beta" } } } }, nil, d)
T.eq(ok, false, "a failed install fails the apply")
T.check(tostring(err):find("download failed", 1, true) ~= nil,
"naming the mod and the reason")
end
do
local calls, d = deps({}, {}, {})
d.install = function() return nil, "download failed" end
local ok = SyncMods.apply({
toInstall = { { id = "beta", entry = { id = "beta" } } },
toEnable = { { id = "beta", version = "red" } },
}, nil, d)
T.eq(ok, false, "the apply still reports the failure")
T.eq(#calls.enabled, 0,
"a mod whose install failed is not switched on regardless")
end
do
local calls, d = deps({}, {}, {})
local steps = SyncMods.steps({
indexes = { "https://other.example/i.json" },
toInstall = { { id = "beta", entry = { id = "beta" } } },
toEnable = { { id = "beta", version = "red" } },
}, d)
T.eq(#steps, 3, "a plan splits into one step per unit of work")
T.eq(steps[1].run(), true, "steps run one at a time")
T.eq(#calls.indexes, 1, "so the caller can draw between them")
T.eq(#calls.installed, 0, "without the rest of the plan having run yet")
end
do
local live = {
alpha = { speed = 3, name = "ASH", on = true, bad = {} },
zeta = {},
}
local _, d = deps({
row("alpha", "2.1.0", { red = true }),
row("zeta", "1.0.0", { red = true }),
}, {}, {}, live)
local plain = SyncMods.build(d)
T.eq(plain.mods[1].options, nil,
"a mod list shares no options unless the player asks for it")
T.eq(plain.hasOptions, nil, "and is not flagged as carrying any")
local full = SyncMods.build(d, true)
T.eq(full.hasOptions, true, "opting in flags the list as carrying options")
T.eq(full.mods[1].options.speed, 3, "the player's own values ride along")
T.eq(full.mods[1].options.name, "ASH", "text values too")
T.eq(full.mods[1].options.on, true, "and toggles")
T.eq(full.mods[1].options.bad, nil,
"a nested table is never sent: only scalars cross the wire")
T.eq(full.mods[2].options, nil, "a mod with nothing set sends no bucket")
end
do
local wide = {}
for i = 1, SyncMods.MAX_OPTION_KEYS + 20 do wide["k" .. i] = i end
wide.huge = string.rep("x", SyncMods.MAX_OPTION_TEXT + 100)
local _, d = deps({ row("alpha", "1.0.0", { red = true }) }, {}, {},
{ alpha = wide })
local manifest = SyncMods.build(d, true)
local n = 0
for _ in pairs(manifest.mods[1].options) do n = n + 1 end
T.eq(n, SyncMods.MAX_OPTION_KEYS, "an option bucket is capped")
local kept = manifest.mods[1].options.huge
T.check(kept == nil or #kept == SyncMods.MAX_OPTION_TEXT,
"and a long string is clamped when it makes the cut")
end
do
local manifest = { rev = 2, indexes = {}, hasOptions = true, mods = {
{ id = "alpha", version = "2.1.0", enabledFor = { "red" },
options = { speed = 1, name = "MISTY" } },
{ id = "beta", version = "1.0.0", enabledFor = { "red" },
options = { theme = "dark" } },
{ id = "same", version = "1.0.0", enabledFor = { "red" },
options = { pitch = 5 } },
{ id = "ghost", version = "0.1.0", enabledFor = { "red" },
options = { anything = 1 } },
} }
local calls, d = deps(
{ row("alpha", "2.1.0", { red = true }), row("same", "1.0.0", { red = true }) },
{}, { beta = { id = "beta" } },
{ alpha = { speed = 3, name = "ASH" }, same = { pitch = 5 } })
local plan = SyncMods.plan(manifest, d)
T.eq(#plan.options, 2, "only mods this device can actually run are listed")
T.eq(plan.options[1].id, "alpha", "the installed one whose values differ")
T.eq(plan.options[2].id, "beta", "and the one this plan installs")
for _, row in ipairs(plan.options) do
T.check(row.id ~= "same", "a mod already set that way is not busywork")
T.check(row.id ~= "ghost", "and a mod that cannot be installed is skipped")
end
T.eq(plan.applyOptions, nil, "nobody's options are imported unasked")
T.eq(SyncMods.planHasOptions(plan), true, "the plan reports it has some")
T.eq(SyncMods.optionsAnswered(plan), false, "and that the question is open")
local steps = SyncMods.steps(plan, d)
local labels = 0
for _, step in ipairs(steps) do
if step.label == "alpha" then labels = labels + 1 end
end
T.eq(labels, 0, "an unanswered plan writes no options")
SyncMods.answerOptions(plan, false)
T.eq(SyncMods.optionsAnswered(plan), true, "declining answers the question")
SyncMods.apply(plan, nil, d)
T.eq(next(calls.options), nil, "and keeps the options this device already had")
SyncMods.answerOptions(plan, true)
T.eq(plan.applyOptions, true, "accepting arms the option steps")
SyncMods.apply(plan, nil, d)
T.eq(calls.options.alpha.speed, 1, "the sharer's values are written")
T.eq(calls.options.alpha.name, "MISTY", "every key they set")
T.eq(calls.options.beta.theme, "dark", "including a mod installed by the plan")
end
do
local manifest = { rev = 2, indexes = {}, mods = {
{ id = "alpha", version = "1.0.0", enabledFor = { "red" },
options = { speed = 1 } } } }
local calls, d = deps({ row("alpha", "1.0.0", { red = true }) }, {}, {},
{ alpha = { speed = 3 } })
local plan = SyncMods.plan(manifest, d)
T.eq(SyncMods.planEmpty(plan), true,
"a list that only differs in options plans no mod work")
SyncMods.answerOptions(plan, true)
T.eq(SyncMods.planEmpty(plan), false,
"until the options are accepted, and then there is work to do")
d.install = function() return nil, "download failed" end
SyncMods.apply(plan, nil, d)
T.eq(calls.options.alpha.speed, 1, "which is just the option write")
end
do
local manifest = { rev = 2, indexes = {}, mods = {
{ id = "beta", version = "1.0.0", enabledFor = { "red" },
options = { speed = 1 } } } }
local calls, d = deps({}, {}, { beta = { id = "beta" } })
d.install = function() return nil, "download failed" end
local plan = SyncMods.plan(manifest, d)
SyncMods.answerOptions(plan, true)
local ok = SyncMods.apply(plan, nil, d)
T.eq(ok, false, "a failed install still fails the apply")
T.eq(next(calls.options), nil,
"and the options of a mod that never installed are not written")
end
T.finish("sync_mods")