Centralize ROM cache publication contract

This commit is contained in:
david
2026-08-22 18:10:05 -07:00
parent d54f9a02e0
commit bdbdbf2ed6
5 changed files with 413 additions and 220 deletions
+176
View File
@@ -0,0 +1,176 @@
-- The cache contract is the shared Lua-side publication boundary. A writer
-- may stage outputs in any order, but readiness is published only after the
-- version-specific required set exists.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check = T.check
local eq = T.eq
local CacheContract = require("src.import.CacheContract")
local fs = { prefix = "initial/", files = {} }
local writes = {}
function fs.exists(path)
return fs.files[fs.prefix .. path] ~= nil
end
function fs.read(path)
return fs.files[fs.prefix .. path]
end
function fs.write(path, value)
writes[#writes + 1] = fs.prefix .. path
fs.files[fs.prefix .. path] = value
return true
end
function fs.remove(path)
fs.files[fs.prefix .. path] = nil
end
local required, isOverride = CacheContract.requiredFilesFor("red")
check(not isOverride, "Red uses the shared required-file list")
check(#required > 0, "Red has required outputs")
eq(CacheContract.markerFor("red"),
CacheContract.FORMAT .. "ea9bcae617fdf159b045185467ae58b2e4a48b9a",
"marker contains format and Red SHA-1")
for index = 1, #required - 1 do
fs.files["red/" .. required[index]] = true
end
local complete, missing = CacheContract.allRequiredFilesExist("red", fs)
check(not complete, "missing output keeps cache incomplete")
eq(missing, required[#required], "missing output is reported")
local published, publishError = CacheContract.publish("red", fs)
check(not published, "incomplete cache is not published")
check(publishError ~= nil, "incomplete publication explains the missing output")
check(fs.files["red/" .. CacheContract.MARKER_PATH] == nil,
"incomplete cache has no completion marker")
-- Publication must remove a stale marker left by an interrupted replacement,
-- and must restore the caller prefix on both the success and failure paths.
fs.files["red/" .. CacheContract.MARKER_PATH] = "old-marker"
local removedMarker = CacheContract.publish("red", fs)
check(not removedMarker, "incomplete retry is still rejected")
check(fs.files["red/" .. CacheContract.MARKER_PATH] == nil,
"incomplete retry removes a stale completion marker")
eq(fs.prefix, "initial/", "incomplete publication restores the caller prefix")
fs.files["red/" .. required[#required]] = true
fs.prefix = "caller/prefix/"
fs.files["red/" .. required[#required]] = true
for _, path in ipairs(required) do fs.files["red/" .. path] = true end
published, publishError = CacheContract.publish("red", fs)
check(published, "complete cache is published")
eq(publishError, nil, "complete publication has no error")
eq(fs.prefix, "caller/prefix/", "publication restores the caller prefix")
eq(fs.files["red/" .. CacheContract.MARKER_PATH], CacheContract.markerFor("red"),
"marker is written under the version prefix")
local marker = CacheContract.readMarker("red", fs)
eq(marker, CacheContract.markerFor("red"), "marker reads through the version prefix")
eq(writes[#writes], "red/" .. CacheContract.MARKER_PATH,
"the marker is the only publication write and comes last")
-- Every supported version gets its own marker and complete cache semantics;
-- Yellow adds its three outputs, while Gold/Silver replace the Gen 1 set.
for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver" }) do
local versionFiles, override = CacheContract.requiredFilesFor(version)
for _, path in ipairs(versionFiles) do
fs.files[version .. "/" .. path] = true
end
if not override then
for _, path in ipairs(CacheContract.VERSION_REQUIRED_FILES[version] or {}) do
fs.files[version .. "/" .. path] = true
end
end
local ready, missing = CacheContract.allRequiredFilesExist(version, fs)
check(ready, version .. " complete cache is ready (" .. tostring(missing) .. ")")
local didPublish = CacheContract.publish(version, fs)
check(didPublish, version .. " complete cache publishes")
eq(fs.files[version .. "/" .. CacheContract.MARKER_PATH],
CacheContract.markerFor(version), version .. " marker is version-scoped")
eq(fs.prefix, "caller/prefix/", version .. " publication restores prefix")
check(CacheContract.isReady(version, fs), version .. " complete cache is ready")
end
local gold, goldOverride = CacheContract.requiredFilesFor("gold")
check(goldOverride, "Gold uses a version-specific required set")
local goldSet = {}
for _, path in ipairs(gold) do goldSet[path] = true end
check(goldSet["assets/generated/battle/hud/balls.png"],
"Gold required set includes trainer HUD art")
check(not goldSet["assets/generated/trade/game_boy.png"],
"Gold required set excludes Gen 1 trade art")
check(goldSet["data/generated/rom_text.lua"],
"Gold required set includes the Gen 2 engine text table")
local silver = CacheContract.requiredFilesFor("silver")
local silverSet = {}
for _, path in ipairs(silver) do silverSet[path] = true end
check(silverSet["data/generated/rom_text.lua"],
"Silver required set includes the Gen 2 engine text table")
check(not silverSet["assets/generated/trade/game_boy.png"],
"Silver required set excludes Gen 1 trade art")
check(CacheContract.VERSION_REQUIRED_FILES.yellow ~= nil,
"Yellow has version-specific required outputs")
-- A throwing adapter must not strand the process in its temporary prefix.
local throwingFs = { prefix = "before/" }
function throwingFs.exists() error("probe failed") end
local probed, probeError = CacheContract.allRequiredFilesExist("blue", throwingFs)
check(not probed and probeError ~= nil, "filesystem probe errors are returned")
eq(throwingFs.prefix, "before/", "probe errors restore the caller prefix")
function throwingFs.write() error("write failed") end
function throwingFs.remove() end
for _, path in ipairs(CacheContract.REQUIRED_FILES) do
throwingFs.files = throwingFs.files or {}
throwingFs.files["blue/" .. path] = true
end
function throwingFs.exists(path)
return throwingFs.files[throwingFs.prefix .. path] ~= nil
end
local wrote = CacheContract.publish("blue", throwingFs)
check(not wrote, "write errors are returned")
eq(throwingFs.prefix, "before/", "write errors restore the caller prefix")
-- Source-tree readiness must use the same version lists and reject a cache
-- when LÖVE cannot identify a real source directory.
local oldLove = love
love = nil
check(not CacheContract.sourceTreeHasData("red"),
"source-tree check is safe without LÖVE")
local sourceFiles = {}
love = {
filesystem = {
getRealDirectory = function(path) return sourceFiles[path] end,
getSource = function() return "/source" end,
getInfo = function(path)
return sourceFiles[path] and { type = "file" } or nil
end,
},
}
for _, path in ipairs(CacheContract.REQUIRED_FILES) do sourceFiles[path] = "/source" end
check(CacheContract.sourceTreeHasData("red"),
"Red source tree uses the shared required set")
sourceFiles[CacheContract.REQUIRED_FILES[2]] = "/save"
check(not CacheContract.sourceTreeHasData("red"),
"source-tree readiness rejects a cache-overlaid required file")
sourceFiles = {}
local goldFiles = CacheContract.requiredFilesFor("gold")
for _, path in ipairs(goldFiles) do sourceFiles["gold/" .. path] = "/source" end
check(CacheContract.sourceTreeHasData("gold"),
"Gold source tree uses its override set")
love = oldLove
-- Both importer completion paths must call the shared publication boundary.
local importerFile = assert(io.open("src/import/RomImporter.lua", "r"))
local importerSource = importerFile:read("*a")
importerFile:close()
local completionCalls = 0
for _ in importerSource:gmatch("CacheContract%.publish%(%s*version") do
completionCalls = completionCalls + 1
end
eq(completionCalls, 1,
"both thread and coroutine paths converge on one publishing helper")
check(importerSource:find("self:_completeImport%(version, prefix, displayName%)")
~= nil, "coroutine completion uses the shared helper")
check(importerSource:find("pcall%(self%._completeImport") ~= nil,
"thread completion uses the shared helper")
T.finish("rom cache contract")
+20 -18
View File
@@ -1,32 +1,34 @@
-- sourceTreeHasData must use each version's required-file list. Gold's
-- cache has no Gen 1 trade art / pikachu.png; validating it against
-- REQUIRED_FILES made a Gold source tree look incomplete forever.
-- sourceTreeHasData must use the engine-owned cache contract. Gold's cache has
-- no Gen 1 trade art; validating it against the Gen 1 list made a Gold source
-- tree look incomplete forever.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check = T.check
local CacheContract = require("src.import.CacheContract")
local f = assert(io.open("src/import/RomImporter.lua", "r"))
local src = f:read("*a")
f:close()
local start = src:find("local function sourceTreeHasData", 1, true)
check(start ~= nil, "sourceTreeHasData is defined")
local finish = src:find("\nfunction RomImporter.isReady", start, true)
check(finish ~= nil, "sourceTreeHasData ends before isReady")
local body = src:sub(start, finish)
local readyStart = src:find("function RomImporter.isReady", 1, true)
check(readyStart ~= nil, "isReady is defined")
local readyEnd = src:find("\nfunction RomImporter.syncAndroidShortcuts", readyStart, true)
check(readyEnd ~= nil, "isReady ends before the next importer helper")
local readyBody = src:sub(readyStart, readyEnd)
check(body:find("requiredFilesFor", 1, true) ~= nil,
"sourceTreeHasData uses requiredFilesFor (Gold override, not Gen 1 only)")
check(body:find("ipairs(REQUIRED_FILES)", 1, true) == nil,
"sourceTreeHasData does not iterate the Gen 1 REQUIRED_FILES list raw")
check(readyBody:find("CacheContract.isReady", 1, true) ~= nil,
"isReady delegates source-tree and cache readiness to the contract")
check(readyBody:find("ipairs(REQUIRED_FILES)", 1, true) == nil,
"isReady does not iterate the Gen 1 REQUIRED_FILES list raw")
local helperStart = src:find("local function requiredFilesFor", 1, true)
check(helperStart ~= nil, "requiredFilesFor helper exists")
local helper = src:sub(helperStart, start)
check(helper:find("VERSION_REQUIRED_FILES_OVERRIDE", 1, true) ~= nil,
"requiredFilesFor consults VERSION_REQUIRED_FILES_OVERRIDE")
check(src:find('"assets/generated/battle/hud/balls.png"', 1, true) ~= nil,
local required, isOverride = CacheContract.requiredFilesFor("gold")
check(isOverride, "Gold uses the override required-file list")
local requiredSet = {}
for _, path in ipairs(required) do requiredSet[path] = true end
check(requiredSet["assets/generated/battle/hud/balls.png"],
"Gold caches require the trainer HUD ball sheet")
check(not requiredSet["assets/generated/trade/game_boy.png"],
"Gold does not inherit the Gen 1 trade-art requirement")
T.finish()
+7 -7
View File
@@ -84,15 +84,15 @@ if extractor then
end
-- a cache imported before #750 has none of the art; listing one of the
-- files in REQUIRED_FILES is what makes it re-import
local importer = readFile("src/import/RomImporter.lua")
T.check(importer ~= nil, "src/import/RomImporter.lua is readable")
if importer then
local required = importer:match("local REQUIRED_FILES = {(.-)\n}")
T.check(required ~= nil, "REQUIRED_FILES parses")
-- files in the engine-owned cache contract is what makes it re-import
local contract = readFile("src/import/CacheContract.lua")
T.check(contract ~= nil, "src/import/CacheContract.lua is readable")
if contract then
local required = contract:match("CacheContract.REQUIRED_FILES = {(.-)\n}")
T.check(required ~= nil, "CacheContract.REQUIRED_FILES parses")
T.check(required ~= nil and required:find(
'"assets/generated/trade/game_boy.png"', 1, true) ~= nil,
"REQUIRED_FILES makes pre-#750 caches re-import the trade art")
"cache contract makes pre-#750 caches re-import the trade art")
end
T.finish("trade art import")