mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-13 00:40:59 +02:00
668 lines
26 KiB
Lua
668 lines
26 KiB
Lua
-- M13 developer tooling: the fixture dataset under the headless loader,
|
|
-- hot reload (teardown + re-merge + cache invalidation), the dev console
|
|
-- (repl, verbs, tracer), the quarantine report screen, and the modkit CLI
|
|
-- (scaffold / validate / lint). Self-contained like the sibling mod
|
|
-- suites: own bootstrap, assert-based checks, error() on failure.
|
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
|
|
|
-- parity first: nothing before this suite may have dragged dev code in
|
|
assert(package.loaded["src.dev.HotReload"] == nil,
|
|
"no src/dev module loads without the dev hotkeys")
|
|
assert(package.loaded["src.dev.Console"] == nil,
|
|
"no console load without the dev hotkeys")
|
|
|
|
local Loader = require("src.mods.Loader")
|
|
local Runtime = require("src.mods.Runtime")
|
|
local Assets = require("src.render.Assets")
|
|
local fixture = require("tests.fixture_data")
|
|
|
|
local savedEvents, savedHooks = Runtime.events, Runtime.hooks
|
|
local savedErrors = Runtime.errors
|
|
local savedWants, savedWantsHook = Runtime.wants, Runtime.wantsHook
|
|
|
|
local S = require("tests.harness").suite("modkit")
|
|
local check = S.check
|
|
|
|
local function deepEqual(a, b)
|
|
if a == b then return true end
|
|
if type(a) ~= "table" or type(b) ~= "table" then return false end
|
|
for k, v in pairs(a) do
|
|
if not deepEqual(v, b[k]) then return false end
|
|
end
|
|
for k in pairs(b) do
|
|
if a[k] == nil then return false end
|
|
end
|
|
return true
|
|
end
|
|
|
|
local function memfs(files)
|
|
return {
|
|
read = function(path) return files[path] end,
|
|
write = function(path, body) files[path] = body 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 loadstring(files[path], path)
|
|
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
|
|
|
|
-- ------- carried handoff: the data-driven move repair floor
|
|
|
|
check(require("src.core.Data").constants.fallbackMove == "TACKLE",
|
|
"CONSTANT_DEFAULTS seeds fallbackMove = TACKLE")
|
|
|
|
-- ------- fixture dataset: complete, ROM-free, loader-ready
|
|
|
|
local data = fixture.load()
|
|
for _, name in ipairs(fixture.MODULES) do
|
|
check(type(data[name]) == "table", "fixture module present: " .. name)
|
|
end
|
|
check(data.pokemon.FIXMON_A.evolutions[1].species == "FIXMON_B",
|
|
"fixture evolution chain")
|
|
check(data.maps.FIX_TOWN.width * data.maps.FIX_TOWN.height
|
|
== #data.maps.FIX_TOWN.blocks, "fixture map blocks match dimensions")
|
|
for id, def in pairs(data.pokemon) do
|
|
check(def.spriteFront:find("tests/fixture_data/", 1, true) == 1,
|
|
"fixture sprite stays ROM-free: " .. id)
|
|
local handle = io.open(def.spriteFront, "rb")
|
|
check(handle ~= nil, "fixture sprite file exists: " .. def.spriteFront)
|
|
handle:close()
|
|
end
|
|
|
|
-- the loader runs over the fixture with no love global and no ROM
|
|
local savedLove = love
|
|
love = nil
|
|
local fixtureOk, fixtureErr = pcall(function()
|
|
local files = {
|
|
["mods/fix_mod/manifest.json"] =
|
|
[[{"id":"fix_mod","name":"Fix","version":"1.0.0","entry":"main.lua","api":2}]],
|
|
["mods/fix_mod/main.lua"] = [[
|
|
return function(mod)
|
|
mod.content.pokemon:patch("FIXMON_A", { baseStats = { speed = 99 } })
|
|
end
|
|
]],
|
|
}
|
|
local headless = Loader.new({ fs = memfs(files) })
|
|
local freshData = fixture.load()
|
|
check(headless:load(freshData) == true, "fixture loader run is clean")
|
|
check(freshData.pokemon.FIXMON_A.baseStats.speed == 99,
|
|
"fixture merge applies the patch")
|
|
check(freshData.pokemon.FIXMON_A.baseStats.hp == 45,
|
|
"fixture merge keeps unpatched fields")
|
|
end)
|
|
love = savedLove
|
|
if not fixtureOk then error(fixtureErr) end
|
|
love = love or require("tests.love_stub")
|
|
|
|
-- ------- hot reload: edit -> F5 -> live change, pristine base, caches flushed
|
|
|
|
local hotFiles = {
|
|
["mods/hot_mod/manifest.json"] =
|
|
[[{"id":"hot_mod","name":"Hot","version":"1.0.0","entry":"main.lua","api":2}]],
|
|
["mods/hot_mod/main.lua"] = [[
|
|
return function(mod)
|
|
mod.content.pokemon:patch("FIXMON_A", { baseStats = { speed = 99 } })
|
|
mod.events:on("game.ready", function()
|
|
_G.MODKIT_TEST_READY = (_G.MODKIT_TEST_READY or 0) + 1
|
|
end)
|
|
end
|
|
]],
|
|
}
|
|
local hotFs = memfs(hotFiles)
|
|
|
|
-- a Data-shaped table whose reloadGenerated rebuilds from the fixture, the
|
|
-- same restore-to-pristine contract src/core/Data.lua implements for the
|
|
-- generated cache
|
|
local function freshHotData()
|
|
local d = fixture.load()
|
|
function d:reloadGenerated()
|
|
local pristine = fixture.load()
|
|
for key in pairs(self) do
|
|
if key ~= "reloadGenerated" then self[key] = nil end
|
|
end
|
|
for key, value in pairs(pristine) do self[key] = value end
|
|
end
|
|
return d
|
|
end
|
|
|
|
_G.MODKIT_TEST_READY = 0
|
|
local hotData = freshHotData()
|
|
local game = { data = hotData, save = { modData = {} } }
|
|
local bootLoader = Loader.new({ fs = hotFs })
|
|
bootLoader.game = game
|
|
game.mods = bootLoader
|
|
check(bootLoader:load(hotData) == true, "hot-reload boot load is clean")
|
|
game.modStatus = bootLoader:status()
|
|
check(hotData.pokemon.FIXMON_A.baseStats.speed == 99, "boot merge applied")
|
|
|
|
-- content froze at boot; the reload path must still work because it swaps
|
|
-- in a fresh loader instead of writing into the frozen registries
|
|
local frozen = pcall(function()
|
|
bootLoader.content.pokemon:patch("FIXMON_A", { baseStats = { hp = 1 } }, "x")
|
|
end)
|
|
check(frozen == false, "boot registries are frozen")
|
|
|
|
local flushed = 0
|
|
Assets.register(function() flushed = flushed + 1 end)
|
|
|
|
-- the edit: same mod file, new value
|
|
hotFiles["mods/hot_mod/main.lua"] = hotFiles["mods/hot_mod/main.lua"]
|
|
:gsub("speed = 99", "speed = 123")
|
|
|
|
-- the audio caches ride the same bus (20 §2, audio rows): a cached sfx
|
|
-- source and the chip music state must not survive the flush
|
|
local Sound = require("src.core.Sound")
|
|
local ChipAudio = require("src.core.ChipAudio")
|
|
local savedAudio = love.audio
|
|
local sourcesMade = 0
|
|
love.audio = { newSource = function()
|
|
sourcesMade = sourcesMade + 1
|
|
local src = {}
|
|
function src:play() self.playing = true end
|
|
function src:stop() self.playing = false end
|
|
function src:setVolume() end
|
|
function src:isPlaying() return self.playing end
|
|
return src
|
|
end }
|
|
local beepData = { audio = { sfx = { Fix_Beep = "assets/fix_beep.wav" } } }
|
|
Sound.play(beepData, "Fix_Beep")
|
|
Sound.play(beepData, "Fix_Beep")
|
|
check(sourcesMade == 1, "a played sfx source is cached")
|
|
-- invalidate reaches stopMusic through the module table, so a swap here
|
|
-- observes the bus call without touching ChipAudio internals
|
|
local savedStopMusic = ChipAudio.stopMusic
|
|
local musicStops = 0
|
|
ChipAudio.stopMusic = function() musicStops = musicStops + 1 end
|
|
|
|
local HotReload = require("src.dev.HotReload")
|
|
local reloaded, summary = HotReload.run(game, { fs = hotFs })
|
|
check(reloaded ~= bootLoader, "reload builds a fresh loader")
|
|
check(game.mods == reloaded, "game adopts the fresh loader")
|
|
check(hotData.pokemon.FIXMON_A.baseStats.speed == 123,
|
|
"edited value is live after reload")
|
|
check(hotData.pokemon.FIXMON_A.baseStats.hp == 45,
|
|
"unedited field survives reload")
|
|
check(deepEqual(hotData.pokemon.FIXMON_B, fixture.load().pokemon.FIXMON_B),
|
|
"untouched base record is byte-identical after reload")
|
|
check(flushed >= 1, "reload flushed the registered caches")
|
|
check(summary:find("reloaded 1 mods", 1, true) ~= nil, "reload summary counts")
|
|
check(_G.MODKIT_TEST_READY >= 1, "game.ready re-reaches re-subscribed mods")
|
|
ChipAudio.stopMusic = savedStopMusic
|
|
check(musicStops >= 1, "reload stops chip music through the cache bus")
|
|
Sound.play(beepData, "Fix_Beep")
|
|
check(sourcesMade == 2, "reload evicted the cached sfx source")
|
|
Sound.invalidate()
|
|
love.audio = savedAudio
|
|
|
|
-- reload twice: invalidate is idempotent, the merge converges
|
|
local again = HotReload.run(game, { fs = hotFs })
|
|
check(hotData.pokemon.FIXMON_A.baseStats.speed == 123, "second reload converges")
|
|
check(#again.errors == 0, "second reload is clean")
|
|
|
|
-- a broken edit surfaces as an attributed error, not a crash
|
|
hotFiles["mods/hot_mod/main.lua"] = "return function(mod) error('boom') end"
|
|
local broken = HotReload.run(game, { fs = hotFs })
|
|
check(#broken.errors > 0, "broken edit lands in the error feed")
|
|
check(hotData.pokemon.FIXMON_A.baseStats.speed == 45,
|
|
"broken mod rolls back to pristine base")
|
|
_G.MODKIT_TEST_READY = nil
|
|
|
|
-- ------- dev console: repl, verbs, tracer, input isolation
|
|
|
|
local Console = require("src.dev.Console")
|
|
local popped = 0
|
|
local stubStack = { states = {} }
|
|
function stubStack:top() return self.states[#self.states] end
|
|
function stubStack:pop()
|
|
popped = popped + 1
|
|
return table.remove(self.states)
|
|
end
|
|
function stubStack:push(state) table.insert(self.states, state) end
|
|
|
|
local inputMarker = {}
|
|
local consoleGame = {
|
|
data = hotData,
|
|
mods = game.mods,
|
|
modStatus = game.mods:status(),
|
|
save = { flags = {}, party = {}, inventory = {}, modData = {} },
|
|
stack = stubStack,
|
|
input = { marker = inputMarker,
|
|
wasPressed = function() return false end },
|
|
}
|
|
local console = Console.new(consoleGame)
|
|
|
|
local function lastLine()
|
|
return console.lines[#console.lines]
|
|
end
|
|
|
|
-- console output wraps to the canvas width, so recent-line checks scan a
|
|
-- window instead of the tail chunk alone
|
|
local function sawRecent(text)
|
|
for i = math.max(1, #console.lines - 7), #console.lines do
|
|
if console.lines[i]:find(text, 1, true) then return true end
|
|
end
|
|
return false
|
|
end
|
|
|
|
console:exec("1+1")
|
|
check(lastLine() == "2", "repl evaluates expressions")
|
|
console:exec("data.pokemon.FIXMON_A.baseStats.hp")
|
|
check(lastLine() == "45", "repl reads live data")
|
|
console:exec("nosuchfunction()")
|
|
check(sawRecent("error"), "repl reports errors")
|
|
|
|
console:exec("flag TEST_FLAG on")
|
|
check(consoleGame.save.flags.TEST_FLAG == true, "flag verb sets")
|
|
console:exec("flag TEST_FLAG off")
|
|
check(consoleGame.save.flags.TEST_FLAG == nil, "flag verb clears")
|
|
|
|
console:exec("give FIX_POTION 3")
|
|
check(consoleGame.save.inventory.FIX_POTION == 3, "give verb adds items")
|
|
console:exec("give FIXMON_C 7")
|
|
check(consoleGame.save.party[1] and consoleGame.save.party[1].species == "FIXMON_C"
|
|
and consoleGame.save.party[1].level == 7, "give verb builds a party mon")
|
|
|
|
console:exec("mods")
|
|
local sawMod = false
|
|
for _, line in ipairs(console.lines) do
|
|
if line:find("hot_mod", 1, true) then sawMod = true end
|
|
end
|
|
check(sawMod, "mods verb lists the loaded set")
|
|
|
|
-- tracer: events log with payloads, hooks log in -> out, wants widens
|
|
console:exec("trace fix.*")
|
|
Runtime.emit("fix.ping", { n = 7 })
|
|
check(sawRecent("fix.ping"), "tracer logs a matching event")
|
|
check(Runtime.wants("fix.anything") == true, "tracer widens wants()")
|
|
check(Runtime.wants("battle.unrelated") == false,
|
|
"tracer leaves other names alone")
|
|
local hooked = Runtime.call("fix.hook", function(v) return v + 1 end, 2)
|
|
check(hooked == 3, "traced hook still returns the vanilla value")
|
|
local sawOut = false
|
|
for _, line in ipairs(console.lines) do
|
|
if line:find("fix.hook", 1, true) and line:find("out", 1, true) then
|
|
sawOut = true
|
|
end
|
|
end
|
|
check(sawOut, "tracer logs the hook transformation")
|
|
Runtime.emit("mod.other.event", { x = 1 })
|
|
console:exec("trace off")
|
|
check(Runtime.wants("fix.anything") == false, "trace off restores wants()")
|
|
|
|
-- typed input: keys become buffer text, return executes, backtick closes
|
|
console:onKeyPressed("p")
|
|
console:onKeyPressed("p")
|
|
check(console.buffer == "pp", "letter keys append to the buffer")
|
|
console:onKeyPressed("backspace")
|
|
check(console.buffer == "p", "backspace edits the buffer")
|
|
console.buffer = "1+2"
|
|
console:onKeyPressed("return")
|
|
check(lastLine() == "3", "return executes the buffer")
|
|
console:onKeyPressed("up")
|
|
check(console.buffer == "1+2", "history recall")
|
|
console:onKeyPressed("`")
|
|
check(popped == 1, "backtick closes the console")
|
|
check(consoleGame.input.marker == inputMarker
|
|
and consoleGame.input.state == nil,
|
|
"console leaves game input untouched")
|
|
|
|
-- ------- quarantine report screen
|
|
|
|
local QuarantineReport = require("src.ui.QuarantineReport")
|
|
local report = {
|
|
lostMons = { { species = "ZORUA", from = "party" } },
|
|
lostItems = { { id = "MOD_ITEM", count = 3, from = "inventory" } },
|
|
remappedMaps = { { id = "MOD_TOWN", to = "PALLET_TOWN", field = "player" } },
|
|
restoredMons = { { species = "MEWTHREE", box = 2 } },
|
|
restoredItems = {},
|
|
recovered = "bak",
|
|
modsDiff = { added = {}, removed = { "illusion_pack" }, changed = {} },
|
|
}
|
|
local rgame = {
|
|
save = { meta = { mods = { { id = "illusion_pack", version = "1.1.0" } } } },
|
|
stack = stubStack,
|
|
input = { wasPressed = function(_, btn) return btn == "a" end },
|
|
}
|
|
local screen = QuarantineReport.new(rgame, report)
|
|
local blob = table.concat(screen.lines, "\n")
|
|
check(blob:find("ZORUA", 1, true) ~= nil, "report names the lost mon")
|
|
check(blob:find("MOD_ITEM x3", 1, true) ~= nil, "report names the lost item")
|
|
check(blob:find("MOD_TOWN", 1, true) ~= nil, "report names the remapped map")
|
|
check(blob:find("MEWTHREE", 1, true) ~= nil, "report names the restored mon")
|
|
check(blob:find("bak", 1, true) ~= nil, "report notes the backup recovery")
|
|
check(blob:find("no longer active", 1, true) ~= nil, "report carries the mods diff")
|
|
local before = popped
|
|
screen:update()
|
|
check(popped == before + 1, "CONTINUE pops the report screen")
|
|
-- draw must not throw under the stub (Font pages already loaded upstream)
|
|
local drawOk = pcall(function() screen:draw() end)
|
|
check(drawOk, "report draws headless")
|
|
|
|
-- an empty report never builds content rows
|
|
local empty = QuarantineReport.new(rgame, { lostMons = {}, lostItems = {},
|
|
remappedMaps = {}, restoredMons = {}, restoredItems = {} })
|
|
check(#empty.lines == 0, "empty report renders no rows")
|
|
|
|
-- ------- modkit CLI: scaffold -> validate green, lint gate red
|
|
|
|
-- luajit's pclose drops the exit status, so the shell reports it in-band
|
|
local function run(command)
|
|
local pipe = io.popen(command .. ' 2>&1; echo "EXIT:$?"')
|
|
local output = pipe:read("*a")
|
|
pipe:close()
|
|
local code = tonumber(output:match("EXIT:(%d+)%s*$")) or -1
|
|
return output, code
|
|
end
|
|
|
|
local python = "python3"
|
|
local haveTools = run(python .. " --version")
|
|
check(haveTools:find("Python 3", 1, true) ~= nil, "python3 available for modkit")
|
|
|
|
local tmp = os.tmpname()
|
|
os.remove(tmp)
|
|
local root = tmp .. "_modkit"
|
|
check(os.execute(("mkdir -p %q"):format(root)) == 0
|
|
or os.execute(("mkdir -p %q"):format(root)) == true, "scratch dir")
|
|
|
|
local out, code = run(("%s tools/modkit.py scaffold scaffy --dest %q")
|
|
:format(python, root))
|
|
check(code == 0, "modkit scaffold succeeds: " .. out)
|
|
local manifest = io.open(root .. "/scaffy/manifest.json", "rb")
|
|
check(manifest ~= nil, "scaffold writes a manifest")
|
|
manifest:close()
|
|
|
|
out, code = run(("%s tools/modkit.py validate %q")
|
|
:format(python, root .. "/scaffy"))
|
|
check(code == 0, "scaffolded mod validates clean: " .. out)
|
|
|
|
-- the template patches MEW, which only the player's imported dataset carries.
|
|
-- Against that dataset MK103 resolves it; against the three-species fixture
|
|
-- the rule has no evidence either way, so it reports itself skipped rather
|
|
-- than guessing -- a warning there would be fatal under pack and --strict
|
|
local haveImported = io.open("data/generated/pokemon.lua", "rb")
|
|
if haveImported then
|
|
haveImported:close()
|
|
out, code = run(("%s tools/modkit.py validate %q --base imported")
|
|
:format(python, root .. "/scaffy"))
|
|
check(code == 0, "template validates against the imported dataset: " .. out)
|
|
check(out:find("MK103", 1, true) == nil,
|
|
"template patch target resolves in the imported dataset")
|
|
end
|
|
out, code = run(("%s tools/modkit.py validate %q --base fixture")
|
|
:format(python, root .. "/scaffy"))
|
|
check(code == 0, "template stays a pass against the fixture stand-in")
|
|
check(out:find("MK103 not checked", 1, true) ~= nil,
|
|
"the fixture base names MK103 as skipped instead of reporting it")
|
|
|
|
-- the ROM-free path CI actually runs (M13 criterion 4): the tool's own
|
|
-- onboarding example must strict-validate AND pack with no ROM imported
|
|
out, code = run(("%s tools/modkit.py validate %q --base fixture --strict")
|
|
:format(python, root .. "/scaffy"))
|
|
check(code == 0, "template strict-validates ROM-free: " .. out)
|
|
|
|
local pkg = root .. "/scaffy.modpkg"
|
|
out, code = run(("%s tools/modkit.py pack %q -o %q --base fixture")
|
|
:format(python, root .. "/scaffy", pkg))
|
|
check(code == 0, "template packs ROM-free: " .. out)
|
|
local packed = io.open(pkg, "rb")
|
|
check(packed ~= nil, "packing the template writes a .modpkg")
|
|
if packed then packed:close() end
|
|
|
|
-- a bad mod trips the schema rule and the no-ROM-content gate
|
|
local bad = root .. "/badmod"
|
|
os.execute(("mkdir -p %q"):format(bad))
|
|
local function write(path, body)
|
|
local handle = assert(io.open(path, "wb"))
|
|
handle:write(body)
|
|
handle:close()
|
|
end
|
|
write(bad .. "/manifest.json",
|
|
[[{"id":"badmod","name":"Bad","version":"1.0.0","api":2,"entry":"main.lua"}]])
|
|
write(bad .. "/main.lua", [[
|
|
return function(mod)
|
|
mod.content.pokemon:patch("FIXMON_A", { base_stats = { speed = 130 } })
|
|
end
|
|
]])
|
|
write(bad .. "/hack.gb", "GBDATA")
|
|
write(bad .. "/cachepath.lua",
|
|
'return { pic = "assets/generated/battle/front/mew.png" }')
|
|
|
|
-- pinned to the fixture so the expectations hold with or without an import
|
|
out, code = run(("%s tools/modkit.py validate %q --base fixture")
|
|
:format(python, bad))
|
|
check(code ~= 0, "bad mod fails validate")
|
|
check(out:find("MK101", 1, true) ~= nil, "schema typo reported as MK101")
|
|
check(out:find("base_stats", 1, true) ~= nil, "MK101 names the bad field")
|
|
check(out:find("MK301", 1, true) ~= nil, "cache reference reported as MK301")
|
|
check(out:find("MK303", 1, true) ~= nil, "ROM patch file reported as MK303")
|
|
|
|
out, code = run(("%s tools/modkit.py pack %q -o %q --base fixture")
|
|
:format(python, bad, root .. "/bad.modpkg"))
|
|
check(code ~= 0, "pack refuses a failing mod")
|
|
check(io.open(root .. "/bad.modpkg", "rb") == nil, "no package written on refusal")
|
|
|
|
-- ------- the completed rule table: MK005, MK006, MK103, MK104
|
|
|
|
-- every case below runs against the fixture so the verdicts do not depend on
|
|
-- whether the machine has an imported dataset
|
|
local function ruleMod(id, manifestExtra, body)
|
|
local dir = root .. "/" .. id
|
|
os.execute(("mkdir -p %q"):format(dir))
|
|
write(dir .. "/manifest.json",
|
|
('{"id":"%s","name":"%s","version":"1.0.0","api":2,"entry":"main.lua"%s}')
|
|
:format(id, id, manifestExtra or ""))
|
|
write(dir .. "/main.lua", body)
|
|
return dir
|
|
end
|
|
|
|
local function validate(dir, extra)
|
|
return run(("%s tools/modkit.py validate %q --base fixture %s")
|
|
:format(python, dir, extra or ""))
|
|
end
|
|
|
|
-- MK103: a patch whose target nothing defines is a no-op, almost always a typo
|
|
local orphan = ruleMod("orphanpatch", nil, [[
|
|
return function(mod)
|
|
mod.content.pokemon:patch("NOSUCHMON", { baseStats = { speed = 99 } })
|
|
end
|
|
]])
|
|
-- only the imported dataset owns the vanilla id space, so only there does a
|
|
-- miss prove a typo -- and there it is an error, not an advisory warning
|
|
if haveImported then
|
|
out, code = run(("%s tools/modkit.py validate %q --base imported")
|
|
:format(python, orphan))
|
|
check(out:find("MK103", 1, true) ~= nil,
|
|
"orphan patch target reported as MK103: " .. out)
|
|
check(out:find("NOSUCHMON", 1, true) ~= nil, "MK103 names the missing target")
|
|
check(code ~= 0, "MK103 fails validate against the authoritative base")
|
|
end
|
|
-- the fixture cannot tell NOSUCHMON from MEW, so it says so and stays out of
|
|
-- the exit code under both plain and --strict runs
|
|
out, code = validate(orphan)
|
|
check(code == 0, "MK103 is skipped, not guessed, against the fixture: " .. out)
|
|
check(out:find("MK103 not checked", 1, true) ~= nil,
|
|
"the skip names the rule that did not run")
|
|
out, code = validate(orphan, "--strict")
|
|
check(code == 0, "--strict cannot promote a rule that never ran: " .. out)
|
|
|
|
-- ...and stays quiet for a target the base defines, or one the load set
|
|
-- registers itself
|
|
local anchored = ruleMod("anchoredpatch", nil, [[
|
|
return function(mod)
|
|
mod.content.pokemon:patch("FIXMON_A", { baseStats = { speed = 99 } })
|
|
mod.content.tokens:register("MK103_TOK", function() return "a" end)
|
|
mod.content.tokens:patch("MK103_TOK", function() return "b" end)
|
|
end
|
|
]])
|
|
out, code = validate(anchored)
|
|
check(code == 0, "a grounded patch validates clean: " .. out)
|
|
check(out:find("MK103", 1, true) == nil,
|
|
"MK103 spares a base id and a self-registered id")
|
|
|
|
-- MK104: a tombstone that strands a live reference is its own rule, not an
|
|
-- unclassified cross-ref failure
|
|
local orphanRemove = ruleMod("orphanremove", nil, [[
|
|
return function(mod)
|
|
mod.content.pokemon:remove("FIXMON_B")
|
|
end
|
|
]])
|
|
out, code = validate(orphanRemove)
|
|
check(code ~= 0, "a remove that strands a reference fails validate")
|
|
check(out:find("MK104", 1, true) ~= nil, "orphaning remove reported as MK104: " .. out)
|
|
check(out:find("FIXMON_B", 1, true) ~= nil, "MK104 names the removed id")
|
|
|
|
-- a plain dangling reference is still MK102; MK104 must not swallow it
|
|
local dangling = ruleMod("danglingref", nil, [[
|
|
return function(mod)
|
|
mod.content.pokemon:patch("FIXMON_A", {
|
|
evolutions = { { method = "LEVEL", level = 9, species = "NOPEMON" } },
|
|
})
|
|
end
|
|
]])
|
|
out, code = validate(dangling)
|
|
check(code ~= 0, "a dangling reference fails validate")
|
|
check(out:find("MK102", 1, true) ~= nil, "plain dangling reference stays MK102")
|
|
check(out:find("MK104", 1, true) == nil, "MK104 does not swallow plain dangling refs")
|
|
|
|
-- MK005: the permission vocabulary is the engine's own
|
|
local badPerm = ruleMod("badperm", ',"permissions":["network","warp_drive"]',
|
|
"return function(mod) end\n")
|
|
out, code = validate(badPerm)
|
|
check(code ~= 0, "an unknown permission fails validate")
|
|
check(out:find("MK005", 1, true) ~= nil, "unknown permission reported as MK005")
|
|
check(out:find("warp_drive", 1, true) ~= nil, "MK005 names the bad permission")
|
|
local mk005 = 0
|
|
for _ in out:gmatch("MK005") do mk005 = mk005 + 1 end
|
|
check(mk005 == 1, "MK005 is reported once, not echoed again by the loader")
|
|
|
|
local goodPerm = ruleMod("goodperm",
|
|
',"permissions":["network","filesystem","engine_internals"]',
|
|
"return function(mod) end\n")
|
|
out, code = validate(goodPerm)
|
|
check(code == 0, "the known permission set validates clean: " .. out)
|
|
check(out:find("MK005", 1, true) == nil, "MK005 spares declared, known permissions")
|
|
|
|
-- MK006: the require never runs, so only a static scan can see it -- which is
|
|
-- why this is not left to the loader's dev-mode tripwire
|
|
local reachy = ruleMod("reachy", nil, [[
|
|
-- a commented require("src.core.Data") is not a reach past the API
|
|
local Semver = require("src.mods.Semver")
|
|
local function lazy()
|
|
return require("src.core.Logger")
|
|
end
|
|
return function(mod)
|
|
local doc = "call require('src.core.Data') at your peril"
|
|
if Semver == nil or lazy == nil or doc == nil then error("unreachable") end
|
|
end
|
|
]])
|
|
out, code = validate(reachy)
|
|
check(out:find("MK006", 1, true) ~= nil, "undeclared engine require reported as MK006")
|
|
check(out:find("src.core.Logger", 1, true) ~= nil, "MK006 names the module")
|
|
check(out:find("main.lua:4", 1, true) ~= nil, "MK006 reports file:line")
|
|
check(out:find("src.core.Data", 1, true) == nil,
|
|
"a require in a comment or a string literal does not fire MK006")
|
|
check(out:find("src.mods.Semver", 1, true) == nil,
|
|
"a supported require does not fire MK006")
|
|
check(code == 0, "MK006 is a warning by default")
|
|
out, code = validate(reachy, "--strict")
|
|
check(code ~= 0, "--strict makes MK006 fatal")
|
|
|
|
local declared = ruleMod("declaredreach", ',"permissions":["engine_internals"]',
|
|
[[
|
|
local function lazy()
|
|
return require("src.core.Logger")
|
|
end
|
|
return function(mod)
|
|
if lazy == nil then error("unreachable") end
|
|
end
|
|
]])
|
|
out, code = validate(declared)
|
|
check(code == 0, "a declared engine_internals require validates clean: " .. out)
|
|
check(out:find("MK006", 1, true) == nil,
|
|
"MK006 spares a require the manifest declares")
|
|
|
|
-- pack runs validate --strict, so a warn-only mod passes validate and is still
|
|
-- refused by the distribution path -- otherwise MK006 and MK3xx have no teeth
|
|
-- where they matter most
|
|
local warnPkg = root .. "/reachy.modpkg"
|
|
out, code = run(("%s tools/modkit.py pack %q -o %q --base fixture")
|
|
:format(python, reachy, warnPkg))
|
|
check(code ~= 0, "pack refuses a mod whose only finding is a warning: " .. out)
|
|
check(out:find("MK006", 1, true) ~= nil, "pack names the warn-severity rule")
|
|
check(io.open(warnPkg, "rb") == nil, "no package written for a warn-only mod")
|
|
|
|
local cleanPkg = root .. "/declared.modpkg"
|
|
out, code = run(("%s tools/modkit.py pack %q -o %q --base fixture")
|
|
:format(python, declared, cleanPkg))
|
|
check(code == 0, "a finding-free mod still packs: " .. out)
|
|
local packed = io.open(cleanPkg, "rb")
|
|
check(packed ~= nil, "pack writes the package")
|
|
if packed then packed:close() end
|
|
|
|
-- MK305 diffs shipped tables against the imported dataset; fake one under
|
|
-- a scratch repo root so the check exercises the same on ROM-less machines
|
|
local fake = root .. "/fakerepo"
|
|
os.execute(("mkdir -p %q %q"):format(
|
|
fake .. "/data/generated", fake .. "/mods/dumper"))
|
|
local rows = {}
|
|
for index = 1, 12 do
|
|
rows[#rows + 1] = (" FAKE_%02d = { index = %d, power = %d },")
|
|
:format(index, index, index * 5)
|
|
end
|
|
local dump = "return {\n" .. table.concat(rows, "\n") .. "\n}\n"
|
|
write(fake .. "/data/generated/moves.lua", dump)
|
|
write(fake .. "/mods/dumper/manifest.json",
|
|
[[{"id":"dumper","name":"Dumper","version":"1.0.0","api":2,"entry":"main.lua"}]])
|
|
write(fake .. "/mods/dumper/main.lua", "return function(mod) end\n")
|
|
write(fake .. "/mods/dumper/moves.lua", dump)
|
|
|
|
out, code = run(("%s tools/modkit.py --repo %q lint %q")
|
|
:format(python, fake, fake .. "/mods/dumper"))
|
|
check(code ~= 0, "bulk data-table dump fails lint: " .. out)
|
|
check(out:find("MK305", 1, true) ~= nil, "dump reported as MK305")
|
|
|
|
-- no interpreter means no verdict; the gate fails closed, never open
|
|
out, code = run(("MODKIT_LUAJIT=%q %s tools/modkit.py --repo %q lint %q")
|
|
:format(fake .. "/no-such-luajit", python, fake, fake .. "/mods/dumper"))
|
|
check(code ~= 0, "lint fails when luajit is missing")
|
|
check(out:find("MK100", 1, true) ~= nil, "missing luajit reported as MK100")
|
|
|
|
-- without an imported dataset the skip is visible, not silent
|
|
out, code = run(("%s tools/modkit.py --repo %q lint %q")
|
|
:format(python, root, fake .. "/mods/dumper"))
|
|
check(code == 0, "dump check without a dataset stays a warning")
|
|
check(out:find("MK305 WARN", 1, true) ~= nil, "skipped dump check is reported")
|
|
|
|
os.execute(("rm -rf %q"):format(root))
|
|
|
|
-- ------- restore shared runtime state for the suites that follow
|
|
|
|
Runtime.events, Runtime.hooks = savedEvents, savedHooks
|
|
Runtime.errors = savedErrors
|
|
Runtime.wants, Runtime.wantsHook = savedWants, savedWantsHook
|
|
|
|
S.finish()
|