mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 16:31:05 +02:00
141 lines
5.2 KiB
Lua
141 lines
5.2 KiB
Lua
-- T4: the mod lifecycle through the public API only
|
|
-- (21-testing-and-ci "the modkit test harness").
|
|
--
|
|
-- This is the case a mod author copies: synthesize (or point at) a mod,
|
|
-- load it headlessly against the fixture dataset, and assert on what
|
|
-- reached Data and the buses. Nothing here reaches into loader internals
|
|
-- that a mod could not reach itself.
|
|
|
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
|
|
|
local T = require("tests.modkit")
|
|
|
|
-- ------- a mod that exercises content, events, hooks, save and exports
|
|
|
|
local GOOD = {
|
|
["mods/fix_kitchen_sink/manifest.json"] = [[{
|
|
"id": "fix_kitchen_sink",
|
|
"name": "Fixture Kitchen Sink",
|
|
"version": "2.1.0",
|
|
"entry": "main.lua",
|
|
"api": 2,
|
|
"description": "Exercises the public mod surface."
|
|
}]],
|
|
["mods/fix_kitchen_sink/main.lua"] = [[
|
|
local mod = ...
|
|
mod.content.items:register("FIX_SODA", {
|
|
id = "FIX_SODA", index = 90, name = "FIX SODA", price = 400,
|
|
tossable = true,
|
|
})
|
|
-- patch an existing record instead of replacing it
|
|
mod.content.items:patch("FIX_POTION", { price = 250 })
|
|
mod.events:on("game.ready", function(ev) mod.exports.sawReady = ev ~= nil end)
|
|
mod.hooks:wrap("catch.rate", function(nextFn, ctx)
|
|
local base = nextFn(ctx)
|
|
return base
|
|
end)
|
|
mod.save:set("charge", 3)
|
|
mod.exports.marker = "kitchen-sink"
|
|
]],
|
|
}
|
|
|
|
do
|
|
local data = T.fixtures.fresh()
|
|
local run = T.sdk.loadMods({ "mods/fix_kitchen_sink" },
|
|
{ data = data, fs = T.sdk.memfs(GOOD) })
|
|
|
|
T.eq(#run.errors, 0, "the mod loads with no errors (" .. tostring(run.errors[1]) .. ")")
|
|
local mod = run.mods.fix_kitchen_sink
|
|
T.check(mod ~= nil, "the loader discovered the mod by its manifest id")
|
|
T.eq(mod and mod.state, "loaded", "the mod reached the loaded state")
|
|
T.eq(mod and mod.manifest.version, "2.1.0", "the manifest version is read")
|
|
|
|
-- content reached Data through the merge
|
|
T.check(data.items.FIX_SODA ~= nil, "a registered item merged into Data")
|
|
T.eq(data.items.FIX_SODA.price, 400, "the registered item kept its fields")
|
|
T.eq(data.items.FIX_SODA.tossable, true, "record fields survive the merge")
|
|
|
|
-- patch is a deep merge over the base record, not a replacement
|
|
T.eq(data.items.FIX_POTION.price, 250, "patch overwrote the field it named")
|
|
T.eq(data.items.FIX_POTION.tossable, true,
|
|
"patch left the fields it did not name alone")
|
|
T.eq(data.items.FIX_POTION.name, "FIX POTION", "patch left the record's name alone")
|
|
|
|
-- the hook is wrapped, and with exactly one link
|
|
local hooks = T.record.hooks(run.loader)
|
|
T.eq(hooks:depth("catch.rate"), 1, "the mod's hook is wrapped once")
|
|
T.eq(hooks:owners("catch.rate")[1], "fix_kitchen_sink", "the hook link is attributed to the mod")
|
|
|
|
-- the event recorder sees what the engine emits
|
|
local rec = T.record.events(run.loader)
|
|
run.loader.events:emit("game.ready", { game = { marker = true } })
|
|
T.eq(rec:count("game.ready"), 1, "the recorder captured the emit")
|
|
T.check(rec:first("game.ready").game ~= nil, "game.ready carries { game = Game }")
|
|
rec:stop()
|
|
|
|
run.release()
|
|
end
|
|
|
|
-- ------- a mod that throws in its entry chunk rolls back completely
|
|
|
|
local BAD = {
|
|
["mods/fix_broken/manifest.json"] = [[{
|
|
"id": "fix_broken",
|
|
"name": "Fixture Broken",
|
|
"version": "1.0.0",
|
|
"entry": "main.lua",
|
|
"api": 2
|
|
}]],
|
|
["mods/fix_broken/main.lua"] = [[
|
|
local mod = ...
|
|
mod.content.items:register("FIX_GHOST", {
|
|
id = "FIX_GHOST", index = 91, name = "FIX GHOST", price = 1,
|
|
})
|
|
mod.events:on("game.ready", function() end)
|
|
error("entry chunk exploded")
|
|
]],
|
|
}
|
|
|
|
do
|
|
local data = T.fixtures.fresh()
|
|
local run = T.sdk.loadMods({ "mods/fix_broken" },
|
|
{ data = data, fs = T.sdk.memfs(BAD) })
|
|
|
|
T.check(#run.errors > 0, "a throwing entry chunk is reported as an error")
|
|
T.check(tostring(run.errors[1]):find("exploded", 1, true) ~= nil,
|
|
"the error names the failure (" .. tostring(run.errors[1]) .. ")")
|
|
T.eq(run.mods.fix_broken and run.mods.fix_broken.state, "failed",
|
|
"the mod is marked failed")
|
|
|
|
-- rollback: neither its content nor its subscription survived
|
|
T.eq(data.items.FIX_GHOST, nil, "a failed mod's content is rolled back out of Data")
|
|
local hooks = T.record.hooks(run.loader)
|
|
T.eq(hooks:depth("catch.rate"), 0, "a failed mod leaves no hook links")
|
|
local listeners = run.loader.events.listeners["game.ready"]
|
|
T.eq(#(listeners or {}), 0, "a failed mod leaves no event listeners")
|
|
|
|
-- and the engine still works: a failed mod is "not installed", not fatal
|
|
T.check(data.items.FIX_POTION ~= nil, "base content survives a failed mod")
|
|
|
|
run.release()
|
|
end
|
|
|
|
-- ------- the no-mod parity baseline for this same path
|
|
|
|
do
|
|
local data = T.fixtures.fresh()
|
|
local run = T.sdk.loadNone({ data = data })
|
|
T.eq(#run.errors, 0, "loading no mods produces no errors")
|
|
T.eq(next(run.mods), nil, "loading no mods discovers no mods")
|
|
T.eq(data.items.FIX_SODA, nil, "no mod means no mod content in Data")
|
|
T.eq(data.items.FIX_POTION.price, 300, "the base item keeps its unpatched price")
|
|
|
|
local hooks = T.record.hooks(run.loader)
|
|
for _, name in ipairs(T.catalog.hooks()) do
|
|
T.eq(hooks:depth(name), 0, "no mod means an empty chain: " .. name)
|
|
end
|
|
run.release()
|
|
end
|
|
|
|
T.finish("mod_lifecycle")
|