-- The parity-guarantee meta-test (21-testing-and-ci "parity gate for every -- extension point"; 26 M14 "the suite tests itself"). -- -- The rule D14 states is that every extension point ships three artifacts -- in the same change: a unit test through the public mod API, a no-mod -- parity test, and docs. This file is what makes that a gate instead of a -- convention. -- -- Two directions are enforced: -- -- parity -- structural. gate_hooks/gate_events/gate_registries each -- walk the live catalog rather than a hand-kept list, so -- every seam is parity-gated the moment its call site exists. -- This file asserts those gates really do iterate the -- catalog, which is the property that makes the coverage -- automatic. -- -- unit -- a ratchet. A seam is "covered" when some test names it. -- Seams that predate this gate are listed in DEBT below with -- the milestone that owes them. A seam missing from both the -- corpus and DEBT fails -- that is the gate on new work. A -- seam in DEBT that has since been covered ALSO fails, so the -- ledger cannot rot into a permanent excuse; it only shrinks. package.path = "./?.lua;./?/init.lua;" .. package.path local T = require("tests.modkit") local Catalog = T.catalog -- ------- parity side local PARITY_GATES = { { kind = "hooks", file = "tests/engine/gate_hooks.lua", accessor = "Catalog.hooks()" }, { kind = "events", file = "tests/engine/gate_events.lua", accessor = "Catalog.events()" }, { kind = "registries", file = "tests/engine/gate_registries.lua", accessor = "T.catalog.registries()" }, } local function slurp(path) local handle = io.open(path, "r") if not handle then return nil end local body = handle:read("*a") handle:close() return body end for _, gate in ipairs(PARITY_GATES) do local body = slurp(gate.file) T.check(body ~= nil, "a no-mod parity gate exists for " .. gate.kind .. ": " .. gate.file) if body then -- the gate must derive its subjects from the catalog; a gate that -- inlined its own list would silently stop covering new seams T.check(body:find(gate.accessor, 1, true) ~= nil, ("the %s gate walks the live catalog (%s)"):format(gate.kind, gate.accessor)) T.check(body:find("T.finish", 1, true) ~= nil, "the " .. gate.kind .. " gate reports through the shared harness") end end -- ------- unit side -- every file that can hold a seam's unit test: the mod-API suites, the -- engine tier, the SDK cases, and any tests a shipped mod carries local function testCorpus() local files, bodies = {}, {} local FsIo = require("tests.fs_io") local function addLuaFrom(dir) for _, name in ipairs(FsIo.listDir(dir)) do if name:match("%.lua$") then files[#files + 1] = dir .. "/" .. name end end end addLuaFrom("tests") addLuaFrom("tests/engine") addLuaFrom("tests/modkit/cases") for _, mod in ipairs(FsIo.listDir("mods")) do if not mod:find(".", 1, true) then addLuaFrom("mods/" .. mod .. "/tests") end end for _, path in ipairs(files) do bodies[path] = slurp(path) or "" end return bodies end local corpus = testCorpus() local corpusCount = 0 for _ in pairs(corpus) do corpusCount = corpusCount + 1 end T.check(corpusCount > 0, "the test corpus is non-empty") -- a seam is covered when a test names it in quotes -- registrations and -- subscriptions are written too many ways (literal, table-driven loop, -- built-up string) for a syntactic match to be reliable, but a test that -- exercises a seam always names it local function coveredBy(name) local needle = '"' .. name .. '"' for path, body in pairs(corpus) do if body:find(needle, 1, true) then return path end end return nil end -- Coverage debt inherited from the milestones that introduced these seams -- (M14 adds the gate; it does not retro-fit other milestones' unit tests). -- Removing a name from this list is the only way to close its entry, and -- the staleness check below forces that the moment a test lands. -- -- The Gen 2 tier drained most of this ledger: tests/engine/gate_gen2_mod_api.lua -- names each seam Gold raises and holds it to having a call site in BOTH -- generations under the one shared name, which is a test that fails if the -- seam is deleted or renamed. It is not a payload case, so a per-seam case -- through the public mod API is still worth writing for those names -- it is -- simply no longer owed as debt, because the ledger's rule is that an entry -- closes the moment any test names the seam. local DEBT = { -- registry:link_fields closed the same way: the Gen 2 gate names it in the -- gated list, because link play is Gen 1 only and Gold has nowhere to put a -- link field. Still not a payload case, still worth one. -- -- hook:render.zones was the last M9 entry and closed when Gold grew its own -- zone pass: src/core/Game2.lua:blitZones raises the hook with the same rect -- list shape src/render/Renderer.lua does, so gate_gen2_mod_api names it in -- the shared-hook list and the ledger's rule ("an entry closes the moment -- any test names the seam") retired it. ["event:link.connected"] = "M12", ["event:link.ended"] = "M12", ["event:pokemon.before_give"] = "M7", ["event:trade.completed"] = "M12", } local seen = {} local function requireUnitTest(kind, name) local key = kind .. ":" .. name seen[key] = true local where = coveredBy(name) if DEBT[key] then -- the ratchet: a debt entry that is now covered must be deleted, or -- the ledger drifts into fiction T.check(where == nil, ("%s is covered by %s -- remove the DEBT entry %s (owed by %s)") :format(key, tostring(where), key, DEBT[key])) return end T.check(where ~= nil, ("%s has no unit test naming it through the public mod API " .. "(add one, or add a DEBT entry saying which milestone owes it)"):format(key)) end for _, name in ipairs(Catalog.registries()) do requireUnitTest("registry", name) end for _, name in ipairs(Catalog.hooks()) do requireUnitTest("hook", name) end for _, name in ipairs(Catalog.events()) do if not Catalog.isModEvent(name) then requireUnitTest("event", name) end end -- a DEBT key for a seam that no longer exists is dead weight; drop it with -- the seam so the ledger stays a description of the present for key, owed in pairs(DEBT) do T.check(seen[key], ("DEBT lists %s (owed by %s) but no such seam is in the catalog -- remove it") :format(key, owed)) end -- ------- docs side -- the third artifact. The generated reference is what keeps registry docs -- from drifting, so assert the generator exists and covers the catalog -- rather than diffing prose. do local generator = slurp("tools/gen_registry_docs.lua") T.check(generator ~= nil, "the registry doc generator exists") if generator then T.check(generator:find("REGISTRIES", 1, true) ~= nil, "the doc generator renders from Schemas.REGISTRIES, not a hand-kept list") end end T.finish("gate_meta_coverage")