-- 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")