Add custom carts: named, version-pinned mod sets that play as their own game

A custom cart pairs an identity (title, shell colour, label art) with a base
game, a list of mods pinned to exact builds with their option values frozen,
a load order, and a seal. It ships no code of its own: every mod it names is
a separately published mod, which is what keeps a cart auditable before it
runs and reproducible after an author's repo disappears.

Format and storage:
- src/carts/CartManifest.lua parses and validates cart.json, canonicalises it
  for hashing and reads/writes the .g1rcart bundle. The bundle is a data-only
  serialised table read through SaveSerializer, so an imported cart can never
  execute code. Canonical strings are length-prefixed because option keys and
  values are author-controlled and could otherwise forge a record boundary and
  collide two different carts onto one hash.
- Pins name a public source: a GitHub release with its sha256, a GameBanana
  file id with its md5, or "local" for a capture that only exists on this
  install. A local pin is unpublishable by construction, which is what makes
  "build it here, publish later" possible without inventing a hash.
- Label art rides alongside the manifest rather than inside its identity, so
  re-arting a cart does not tell every player their run is out of date.
  src/core/Base64.lua decodes it; strict, with no whitespace tolerance.

Saves:
- Cart playthroughs live in the cart's own slot namespace (saves/cart_<id>/),
  so a cart's file never sits beside a vanilla one and uninstalling a cart
  never orphans a save. Every save records the cart build it was made under.

The seal:
- A sealed cart loads its pinned list, in its order, with its options, and
  nothing else. A pinned mod with no frozen options gets an empty bucket so
  unfrozen keys fall to schema defaults, identical for everyone; otherwise two
  players on one cart quietly run different games.
- A sealed cart refuses to load when a pin is missing or installed at another
  version. Playing a subset of the cart is the exact dishonesty the seal
  exists to prevent, so the refusal loads nothing at all.
- Breaking the seal is permanent, marked per save slot, and downgrades that
  playthrough to open behaviour. It cannot be cleared through any public API.

Launcher:
- A game's page carries a Custom Carts control and a picker; choosing a cart
  turns the page into that cart's page, with its own cartridge, title and save
  slots. The rail of five games never grows and a cart id never reaches
  imp.tab or imp.panelVersion.
- Loader.planCart runs before boot so a refusal is visible on the page instead
  of being discovered as an error after launch.
- Save as cart captures the enabled mods for a game and names, before the
  player confirms, every mod that could only be pinned to this install and
  whether the result can be shared at all.

Authoring:
- tools/cartkit.py scaffolds, validates, pins and packs a cart, and installs a
  release workflow. Its writer is byte-identical to the engine's serialiser.
This commit is contained in:
bryanthaboi
2026-08-22 21:41:19 -04:00
parent ca4d3d283c
commit ebf44f20d3
16 changed files with 6256 additions and 201 deletions
+600
View File
@@ -0,0 +1,600 @@
-- Custom carts in the launcher.
-- luajit tests/engine/cart_launcher.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
love.graphics.setLineJoin = love.graphics.setLineJoin or function() end
love.graphics.newShader = love.graphics.newShader or function() return {} end
love.graphics.polygon = love.graphics.polygon or function() end
local Kit = require("src.ui.kit.Kit")
local SaveData = require("src.core.SaveData")
local CartManifest = require("src.carts.CartManifest")
local CartStore = require("src.carts.CartStore")
local RomImporter = require("src.import.RomImporter")
local LauncherView = require("src.import.LauncherView")
local SHA = ("a1b2c3d4"):rep(8)
local function window(w, h)
love.graphics.getDimensions = function() return w, h end
love.graphics.getPixelDimensions = function() return w, h end
end
local function freshLauncher(onComplete)
return RomImporter.new(onComplete or function() end, { launcher = true })
end
local realPrint = love.graphics.print
local function drawAndCapture(imp)
local seen = {}
love.graphics.print = function(str, ...)
seen[#seen + 1] = tostring(str)
return realPrint(str, ...)
end
local ok, err = pcall(LauncherView.draw, imp)
love.graphics.print = realPrint
check(ok, "the frame draws: " .. tostring(err))
return table.concat(seen, "\n")
end
local realSetColor = love.graphics.setColor
local function drawColors(imp)
local seen = {}
love.graphics.setColor = function(r, g, b, a)
if type(r) == "number" and type(g) == "number" and type(b) == "number" then
seen[("%d,%d,%d"):format(math.floor(r * 255 + 0.5),
math.floor(g * 255 + 0.5), math.floor(b * 255 + 0.5))] = true
end
return realSetColor(r, g, b, a)
end
local ok, err = pcall(LauncherView.draw, imp)
love.graphics.setColor = realSetColor
check(ok, "the frame draws: " .. tostring(err))
return seen
end
local function cartTable(over)
local tbl = {
id = "kanto_plus", title = "Kanto Plus", version = "1.2.0",
author = "Ren", shell = "#3fa9f5", base = "red", seal = "sealed",
mods = { { id = "rare_soda", source = "github", repo = "ren/rare-soda",
version = "0.4.1", sha256 = SHA } },
}
for key, value in pairs(over or {}) do tbl[key] = value end
return tbl
end
local function install(over)
over = over or {}
local cart, parseErr = CartManifest.parse(cartTable(over))
check(cart ~= nil, "fixture parses: " .. tostring(parseErr))
cart.labelArt = over.labelArt
local ok, err = CartStore.install(CartManifest.encode(cart))
check(ok ~= nil, "fixture installs: " .. tostring(err))
return cart
end
install()
install({ id = "zeta_open", title = "Zeta Open", version = "0.9.0",
seal = "open", shell = "#112233" })
install({ id = "johto_lite", title = "Johto Lite", base = "blue",
shell = "#7a5c2e" })
window(1280, 720)
local imp = freshLauncher()
local red = imp:_ensureCarts("red")
eq(#red, 2, "red lists exactly the carts based on red")
eq(red[1].id, "kanto_plus", "the list is sorted by title")
eq(red[2].id, "zeta_open", "the list is sorted by title")
eq(#imp:_ensureCarts("blue"), 1, "blue lists only its own cart")
eq(imp:_ensureCarts("blue")[1].id, "johto_lite", "and that one is Johto Lite")
eq(#imp:_ensureCarts("yellow"), 0, "a game with no carts lists none")
imp.tab = "red"
imp.ready.red = true
imp._cartPopup = "red"
local picker = drawAndCapture(imp)
check(picker:find("Pokemon Red", 1, true) ~= nil,
"the picker offers the base game as the first row")
check(picker:find("Kanto Plus", 1, true) ~= nil, "the picker lists Kanto Plus")
check(picker:find("Zeta Open", 1, true) ~= nil, "the picker lists Zeta Open")
check(picker:find("Johto Lite", 1, true) == nil,
"the picker does NOT list a cart based on another game")
check(picker:find("v1.2.0", 1, true) ~= nil, "a cart row carries its version")
check(picker:find("sealed", 1, true) ~= nil, "a cart row carries its seal state")
check(picker:find("open", 1, true) ~= nil, "including an open one")
check(picker:find("Get more carts", 1, true) ~= nil,
"the last row is the browse placeholder")
imp._cartPopup = nil
local vanillaColors = drawColors(imp)
check(vanillaColors["63,169,245"] == nil,
"a vanilla page never paints the cart's shell colour")
imp:_selectCart("red", "kanto_plus")
eq(imp.activeCart.red, "kanto_plus", "the pick lands on activeCart")
eq(imp._cartPopup, nil, "picking closes the picker")
local titled = drawAndCapture(imp)
check(titled:find("Kanto Plus", 1, true) ~= nil,
"the panel title becomes the cart's title")
local cartColors = drawColors(imp)
eq(cartColors["63,169,245"], true,
"the cartridge takes the cart's shell colour")
eq(imp.tab, "red", "a cart id never reaches imp.tab")
eq(imp.panelVersion, "red", "a cart id never reaches imp.panelVersion")
check(imp._cartridge["cart:kanto_plus"] ~= nil,
"the cart spins on its own cartridge state")
check(imp._cartridge["cart:kanto_plus"] ~= imp._cartridge.red,
"which is not the base game's")
eq(imp._cartridgeLabels["cart:kanto_plus"], false,
"a cart carrying no label art falls through to bare plastic")
local scope = imp:slotScope("red")
eq(scope, "cart_kanto_plus", "an active cart scopes the panel's save slots")
imp:_newSlot(scope)
imp:_newSlot(scope)
eq(#SaveData.listCartSlots("kanto_plus"), 2, "both slots land in the cart")
eq(#SaveData.listSlots("red"), 0, "and none of them in the base game")
imp:_ensureSlots(scope)
eq(#imp.slots[scope], 2, "the panel reads the cart's slots back")
imp:_beginRename(scope, "slot1")
imp._rename.text = "Nuzlocke"
imp:_commitRename()
eq(SaveData.listCartSlots("kanto_plus")[1].label, "Nuzlocke",
"a rename writes into the cart's registry")
imp:_selectSlot(scope, "slot2")
eq(SaveData.activeCartSlot("kanto_plus"), "slot2",
"selecting a row moves the cart's active slot")
imp:_deleteSlot(scope, "slot2")
eq(#SaveData.listCartSlots("kanto_plus"), 1, "a delete removes the cart's slot")
local withCart = drawAndCapture(imp)
check(withCart:find("Nuzlocke", 1, true) ~= nil,
"the slot card shows the cart's slots while the cart is active")
imp:_selectCart("red", nil)
eq(imp.activeCart.red, nil, "choosing the base game clears the active cart")
eq(imp:slotScope("red"), "red", "and the slots go back to the version's own")
imp:_newSlot("red")
eq(#SaveData.listSlots("red"), 1, "a vanilla slot lands in the version")
eq(#SaveData.listCartSlots("kanto_plus"), 1, "and not in the cart")
local backHome = drawAndCapture(imp)
check(backHome:find("Nuzlocke", 1, true) == nil,
"the slot card no longer shows the cart's slots")
check(backHome:find("Kanto Plus", 1, true) == nil,
"and the panel title is the base game's again")
local homeColors = drawColors(imp)
check(homeColors["63,169,245"] == nil,
"the cartridge is back to the base game's shell")
local handed = {}
local player = freshLauncher(function(version, cartId)
handed.version, handed.cart = version, cartId
end)
player.ready.red = true
player:_selectCart("red", "kanto_plus")
player:play("red")
eq(handed.version, "red", "Play still boots the base game")
eq(handed.cart, "kanto_plus", "and names the cart it is running")
local opts = SaveData.loadOptions()
eq(opts.lastVersion, "red", "play still remembers the version")
eq(type(opts.activeCart) == "table" and opts.activeCart.red or nil, "kanto_plus",
"play persists the active cart beside it")
local restored = freshLauncher()
eq(restored.activeCart.red, "kanto_plus",
"a fresh launcher restores the cart its page was on")
eq(restored.tab, "red", "and a cart id still never reaches the tab")
local uninstalled = freshLauncher()
uninstalled.activeCart.red = nil
uninstalled:_restoreActiveCarts({ activeCart = { red = "gone_forever" } })
eq(uninstalled.activeCart.red, nil,
"a remembered cart that is no longer installed is dropped")
local Base64 = require("src.core.Base64")
local PNG = CartManifest.PNG_SIGNATURE .. ("labelart"):rep(4)
local function artOf()
return { encoding = "base64", bytes = #PNG, data = Base64.encode(PNG) }
end
install({ id = "art_cart", title = "Art Cart", base = "yellow",
shell = "#204060", labelArt = artOf() })
install({ id = "bare_cart", title = "Bare Cart", base = "yellow",
shell = "#405060" })
install({ id = "bad_cart", title = "Bad Cart", base = "yellow",
shell = "#605040", labelArt = artOf() })
check(CartStore.labelArt("art_cart") == PNG,
"the store hands back the cart's own PNG bytes")
check(CartStore.labelArt("bare_cart") == nil,
"and nothing for a cart that carries none")
window(1280, 720)
local realNewImage = love.graphics.newImage
local madeImages = 0
love.graphics.newImage = function(...)
madeImages = madeImages + 1
return realNewImage(...)
end
local art = freshLauncher()
art.tab = "yellow"
art.ready.yellow = true
art:_selectCart("yellow", "art_cart")
drawAndCapture(art)
local artLabel = art._cartridgeLabels["cart:art_cart"]
check(type(artLabel) == "table" and artLabel.image ~= nil,
"a cart's own label art becomes a cached cartridge image")
local afterFirst = madeImages
drawAndCapture(art)
eq(madeImages, afterFirst, "the decode happens once, not every frame")
art:_selectCart("yellow", "bare_cart")
drawAndCapture(art)
eq(art._cartridgeLabels["cart:bare_cart"], false,
"a cart with no art renders as bare plastic")
check(art._cartridgeLabels["cart:art_cart"] ~= art._cartridgeLabels["cart:bare_cart"],
"and the two carts never share one label cache entry")
love.graphics.newImage = function(a, ...)
if type(a) == "table" and a._fileData then error("not a PNG this engine reads") end
return realNewImage(a, ...)
end
local bad = freshLauncher()
bad.tab = "yellow"
bad.ready.yellow = true
bad:_selectCart("yellow", "bad_cart")
drawAndCapture(bad)
love.graphics.newImage = realNewImage
eq(bad._cartridgeLabels["cart:bad_cart"], false,
"art that will not decode leaves the cart bare instead of throwing")
drawAndCapture(bad)
eq(bad._cartridgeLabels["cart:bad_cart"], false,
"and it is not retried on the next frame")
local LauncherMods = require("src.mods.LauncherMods")
local realModList = LauncherMods.list
local function fakeRow(over)
local row = { id = "x", name = "X", version = "1.0.0", badge = "MOD",
description = "", enabled = true, status = "ok",
statusDetail = "", experimental = false, targetsHere = true,
targets = nil, safeMode = false, requiredImports = {},
imports = {}, missingRequiredImports = 0,
missingOptionalImports = 0,
enabledByVersion = { red = true, blue = true, yellow = true,
gold = true, silver = true } }
for key, value in pairs(over) do row[key] = value end
row.manifest = row.manifest or { id = row.id, name = row.name,
version = row.version }
return row
end
local FAKE_MODS = {
fakeRow({ id = "rare_soda", name = "Rare Soda", version = "0.4.1",
github = "ren/rare-soda", sha256 = SHA }),
fakeRow({ id = "wide_gym", name = "Wide Gym", version = "dev" }),
fakeRow({ id = "off_mod", name = "Off Mod", enabled = false,
enabledByVersion = { red = false } }),
}
local modsView = freshLauncher()
modsView.tab = "mods"
local modsText = drawAndCapture(modsView)
check(modsText:find("Save as cart", 1, true) ~= nil,
"the mods tab carries the Save as cart control")
LauncherMods.list = function() return FAKE_MODS end
local maker = freshLauncher()
maker.tab = "red"
maker.ready.red = true
maker:_setModScope("red")
eq(maker:_cartCaptureCount("red"), 2,
"the control counts only the mods enabled for this game")
maker:_beginCartSave("red")
check(maker._cartSave ~= nil, "Save as cart opens a form")
eq(maker._cartSave.count, 2, "the form reports the captured mod count")
eq(maker._cartSave.version, "red", "scoped to the game the panel is showing")
eq(#maker._cartSave.unresolved, 1, "capture reports one pin it could not resolve")
eq(maker._cartSave.unresolved[1].id, "wide_gym", "naming the mod it belongs to")
check(tostring(maker._cartSave.unresolved[1].reason):find("semantic", 1, true) ~= nil,
"and why it could only be pinned locally")
eq(maker._cartSave.publishable, false, "a local pin makes the cart unpublishable")
local form = drawAndCapture(maker)
check(form:find("Save as cart", 1, true) ~= nil, "the form is titled")
check(form:find("Wide Gym", 1, true) ~= nil,
"the unresolved pin is named BEFORE the player confirms")
check(form:find("could only be pinned to this install", 1, true) ~= nil,
"under a heading that says what a local pin means")
check(form:find("cannot be shared", 1, true) ~= nil,
"and the form says plainly that the result cannot be shared")
maker._cartSave.text = "Kanto Plus"
maker:_commitCartSave()
check(maker._cartSave ~= nil, "a title that collides with an installed cart refuses")
check(tostring(maker._cartSave.error):find("kanto_plus", 1, true) ~= nil,
"and names the id that is already taken")
eq(CartStore.get("kanto_plus").title, "Kanto Plus",
"the installed cart is untouched")
maker._cartSave.text = "Soda Run"
maker:_commitCartSave()
eq(maker._cartSave, nil, "a free title saves the cart and closes the form")
eq(maker._cartPopup, "red", "and drops the player straight into the picker")
local made = CartStore.get("soda_run")
check(made ~= nil, "the cart is installed under the id derived from the title")
eq(made.base, "red", "based on the game the panel was showing")
eq(made.version, "1.0.0", "at the default cart version")
eq(made.seal, "sealed", "sealed by default")
eq(made.shell, "#ff3c48", "wearing the base game's rail colour")
eq(#made.mods, 2, "pinning exactly the enabled mods")
local listedNow = false
for _, row in ipairs(maker:_ensureCarts("red")) do
if row.id == "soda_run" then listedNow = true end
end
check(listedNow, "and the picker lists it immediately")
local picked = drawAndCapture(maker)
check(picked:find("Soda Run", 1, true) ~= nil, "including on screen")
LauncherMods.list = function() return { FAKE_MODS[1] } end
local pure = freshLauncher()
pure.tab = "red"
pure.ready.red = true
pure:_beginCartSave("red")
eq(#pure._cartSave.unresolved, 0, "a fully pinned capture has no local pins")
eq(pure._cartSave.publishable, true, "and it is publishable")
local pureForm = drawAndCapture(pure)
check(pureForm:find("can be shared", 1, true) ~= nil,
"which the form says before the player confirms")
check(pureForm:find("cannot be shared", 1, true) == nil,
"instead of the local-pin warning")
pure._cartSave.text = "Pure Soda"
pure:_commitCartSave()
eq(pure._cartSave, nil, "a fully pinned capture saves too")
local pureOk, pureWhy = CartManifest.publishable(CartStore.get("pure_soda"))
eq(pureOk, true, "and the saved cart really is publishable")
eq(pureWhy, nil, "with nothing holding it back")
LauncherMods.list = function() return FAKE_MODS end
local blank = freshLauncher()
blank:_beginCartSave("red")
blank:_commitCartSave()
check(blank._cartSave ~= nil, "an empty title does not save")
check(tostring(blank._cartSave.error):find("title", 1, true) ~= nil,
"and asks for one")
blank:_cancelCartSave()
eq(blank._cartSave, nil, "Cancel closes the form")
LauncherMods.list = realModList
local exporter = freshLauncher()
exporter:exportCart("kanto_plus")
check(tostring(exporter._cartNotice):find("Exported", 1, true) ~= nil,
"Export reports where the cart file went: " .. tostring(exporter._cartNotice))
local wroteBytes = love.filesystem.read("exports/carts/kanto_plus" .. CartStore.EXT)
check(type(wroteBytes) == "string" and wroteBytes ~= "",
"and the bytes land in the same exports tree a save export uses")
eq(wroteBytes, (CartStore.export("kanto_plus")),
"byte for byte what CartStore.export returned")
local roundTrip = CartManifest.decode(wroteBytes)
check(roundTrip ~= nil and roundTrip.id == "kanto_plus",
"and the file decodes back into the cart")
exporter:exportCart("no_such_cart")
check(tostring(exporter._cartNotice):find("not installed", 1, true) ~= nil,
"exporting a cart that is not installed says so")
window(1920, 1080)
local FULL_MODS = { fakeRow({ id = "rare_soda", name = "Rare Soda",
version = "0.4.1", github = "ren/rare-soda",
sha256 = SHA }) }
LauncherMods.list = function() return FULL_MODS end
local okCart = freshLauncher()
okCart.tab = "red"
okCart.ready.red = true
okCart:_selectCart("red", "kanto_plus")
local okPlan = okCart:cartPlan("red")
eq(okPlan.refused, false, "a cart whose pin is installed is playable")
eq(okPlan.sealed, true, "and is still sealed")
local okText = drawAndCapture(okCart)
check(okText:find("Sealed", 1, true) ~= nil,
"the verdict is on the page before the player commits")
check(okText:find("Break the seal", 1, true) ~= nil,
"and a sealed cart's page offers the escape hatch")
LauncherMods.list = function() return {} end
local gapCart = freshLauncher()
gapCart.tab = "red"
gapCart.ready.red = true
gapCart:_selectCart("red", "kanto_plus")
local gapPlan = gapCart:cartPlan("red")
eq(gapPlan.refused, true, "a cart with an uninstalled pin refuses")
eq(gapPlan.missing[1].id, "rare_soda", "naming the pin it cannot find")
local gapText = drawAndCapture(gapCart)
check(gapText:find("will not start", 1, true) ~= nil,
"which the page says without the player booting into an error")
check(gapText:find("rare_soda", 1, true) ~= nil, "and names the pin")
check(gapText:find("Break the seal", 1, true) ~= nil,
"with the escape hatch beside the refusal")
local sealScope = gapCart:slotScope("red")
gapCart:_ensureSlots(sealScope)
local sealSlot = gapCart.activeSlot[sealScope]
check(type(sealSlot) == "string", "the cart page has a loaded save slot")
eq(gapCart:pressBreakSeal("red"), false, "the first press only arms the confirm")
eq(SaveData.slotSealBroken("kanto_plus", sealSlot), false,
"so one press breaks nothing")
local armedText = drawAndCapture(gapCart)
check(armedText:find("Kanto Plus", 1, true) ~= nil,
"the armed confirm names the cart")
check(armedText:find("save slot", 1, true) ~= nil, "and the save slot")
check(armedText:find("cannot be undone", 1, true) ~= nil,
"says it is permanent")
check(armedText:find("marked modified", 1, true) ~= nil,
"says the file is marked modified from then on")
check(armedText:find("pinned mods first", 1, true) ~= nil,
"and that the cart's own list still loads first")
eq(gapCart:pressBreakSeal("red"), true, "a second press breaks the seal")
eq(SaveData.slotSealBroken("kanto_plus", sealSlot), true,
"which lands on that slot, durably")
eq(gapCart:cartPlan("red").refused, false, "and the cart is no longer refused")
local brokenText = drawAndCapture(gapCart)
check(brokenText:find("Seal broken", 1, true) ~= nil,
"the cart page shows the broken state afterwards")
check(brokenText:find("seal broken", 1, true) ~= nil,
"and so does the cart's slot row")
gapCart:_newSlot(sealScope)
local freshSlot = gapCart.activeSlot[sealScope]
check(freshSlot ~= sealSlot, "a new save slot under the same cart")
eq(SaveData.slotSealBroken("kanto_plus", freshSlot), false,
"starts sealed again")
eq(gapCart:cartPlan("red").refused, true, "so the cart refuses that one")
LauncherMods.list = realModList
window(1280, 720)
local function clipped(r)
local x1, y1, x2, y2 = r.x, r.y, r.x + r.w, r.y + r.h
if r.clip then
x1 = math.max(x1, r.clip.x); y1 = math.max(y1, r.clip.y)
x2 = math.min(x2, r.clip.x + r.clip.w); y2 = math.min(y2, r.clip.y + r.clip.h)
end
if x2 - x1 <= 1 or y2 - y1 <= 1 then return nil end
return x1, y1, x2, y2
end
local function overlap(a, b)
local ax1, ay1, ax2, ay2 = clipped(a)
if not ax1 then return false end
local bx1, by1, bx2, by2 = clipped(b)
if not bx1 then return false end
return math.min(ax2, bx2) - math.max(ax1, bx1) > 1
and math.min(ay2, by2) - math.max(ay1, by1) > 1
end
local function auditFrame(label, want)
local controls, found = {}, false
for _, r in ipairs(Kit.audit or {}) do
if r.class == "control" then
controls[#controls + 1] = r
if want and tostring(r.label):find(want, 1, true) then found = true end
end
end
check(#controls > 0, label .. ": the frame dispatched controls at all")
local collisions = 0
for i = 1, #controls do
for j = i + 1, #controls do
if overlap(controls[i], controls[j]) then
collisions = collisions + 1
print((" overlap: '%s' vs '%s' at (%.0f,%.0f) / (%.0f,%.0f)")
:format(tostring(controls[i].label), tostring(controls[j].label),
controls[i].x, controls[i].y, controls[j].x, controls[j].y))
end
end
end
check(collisions == 0, label .. ": no two controls overlap")
if want then check(found, label .. ": drew " .. want) end
end
local SIZES = {
{ 360, 780 }, { 412, 915 }, { 480, 900 }, { 720, 1280 },
{ 1280, 720 }, { 1024, 768 }, { 900, 700 }, { 1920, 1080 },
}
for _, size in ipairs(SIZES) do
local W, H = size[1], size[2]
window(W, H)
for _, cart in ipairs({ false, true }) do
local page = freshLauncher()
page.tab = "red"
page.ready.red = true
page:_selectCart("red", cart and "kanto_plus" or nil)
LauncherView.draw(page)
Kit.audit = {}
local ok, err = pcall(LauncherView.draw, page)
Kit.audit = ok and Kit.audit or nil
check(ok, ("%dx%d %s draws: %s")
:format(W, H, cart and "cart" or "vanilla", tostring(err)))
if ok then
auditFrame(("%dx%d %s"):format(W, H, cart and "cart" or "vanilla"),
"Custom Carts")
end
Kit.audit = nil
page._cartPopup = "red"
if cart then
page._cartNotice = "Browsing for carts arrives in a later update."
end
LauncherView.draw(page)
Kit.audit = {}
ok, err = pcall(LauncherView.draw, page)
Kit.audit = ok and Kit.audit or nil
check(ok, ("%dx%d picker draws: %s"):format(W, H, tostring(err)))
if ok then
auditFrame(("%dx%d picker"):format(W, H), "Kanto Plus")
for _, r in ipairs(Kit.audit or {}) do
local label = tostring(r.label)
if label == "Get more carts" or label == "Close" then
check(r.y >= -0.5 and r.y + r.h <= H + 0.5,
("%dx%d picker: %q stays inside the window"):format(W, H, label))
end
end
end
Kit.audit = nil
end
end
LauncherMods.list = function() return FAKE_MODS end
for _, size in ipairs(SIZES) do
local W, H = size[1], size[2]
window(W, H)
local form = freshLauncher()
form.tab = "mods"
form.ready.red = true
form:_setModScope("red")
form:_beginCartSave("red")
form._cartSave.text = "Soda Run"
form:_commitCartSave()
check(form._cartSave ~= nil,
("%dx%d save form stays open on a colliding title"):format(W, H))
LauncherView.draw(form)
Kit.audit = {}
local ok, err = pcall(LauncherView.draw, form)
Kit.audit = ok and Kit.audit or nil
check(ok, ("%dx%d save form draws: %s"):format(W, H, tostring(err)))
if ok then
auditFrame(("%dx%d save form"):format(W, H), "Save as cart")
for _, r in ipairs(Kit.audit or {}) do
if tostring(r.label) == "Cancel" then
check(r.y >= -0.5 and r.y + r.h <= H + 0.5,
("%dx%d save form: Cancel stays inside the window"):format(W, H))
end
end
end
Kit.audit = nil
end
LauncherMods.list = realModList
T.finish("cart launcher")
+413
View File
@@ -0,0 +1,413 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
if not rawget(_G, "bit") and not rawget(_G, "bit32") then
local ok, bit32 = pcall(require, "bit32")
if ok then _G.bit32 = bit32 end
end
local T = require("tests.harness")
local Base64 = require("src.core.Base64")
local CartManifest = require("src.carts.CartManifest")
local SaveSerializer = require("src.core.SaveSerializer")
local SHA = ("a1b2c3d4"):rep(8)
local MD5 = ("0123456789abcdef"):rep(2)
local function baseCart()
return {
id = "kanto_plus",
title = " Kanto Plus ",
version = "1.2.0",
author = "Ren",
repo = "ren/kanto-plus",
summary = "A sealed set of five",
shell = "#3FA9F5",
label = "./art/label.png",
base = "red",
engine = ">=1.4.0",
mods = {
{ id = "rare_soda", source = "github", repo = "ren/rare-soda",
version = "0.4.1", sha256 = SHA,
options = { sweetness = 3, flavour = "grape", fizzy = true } },
{ id = "hard_mode", source = "gamebanana", mod = 4821, file = 99123,
md5 = MD5 },
},
}
end
local function rejects(mutate, fragment, what)
local tbl = baseCart()
mutate(tbl)
local cart, err = CartManifest.parse(tbl)
T.eq(cart, nil, what .. " is rejected")
T.check(type(err) == "string" and err:find(fragment, 1, true) ~= nil,
("%s says why (got %s)"):format(what, tostring(err)))
end
local raw = baseCart()
local cart, err = CartManifest.parse(raw)
T.check(cart ~= nil, "a good manifest parses: " .. tostring(err))
T.eq(cart.title, "Kanto Plus", "title is trimmed")
T.eq(cart.shell, "#3fa9f5", "shell normalises to lowercase")
T.eq(cart.label, "art/label.png", "label normalises through SafePath")
T.eq(cart.seal, "sealed", "seal defaults to sealed")
T.eq(cart.base, "red", "base survives")
T.eq(cart.engine, ">=1.4.0", "engine range is kept unevaluated")
T.eq(#cart.mods, 2, "both pins survive")
T.eq(cart.mods[1].sha256, SHA, "the github pin keeps its sha256")
T.eq(cart.mods[1].repo, "ren/rare-soda", "the github pin keeps its repo")
T.eq(cart.mods[1].version, "0.4.1", "the github pin keeps its version")
T.eq(cart.mods[1].options.flavour, "grape", "frozen option values survive")
T.eq(cart.mods[2].source, "gamebanana", "the gamebanana pin keeps its source")
T.eq(cart.mods[2].mod, 4821, "the gamebanana pin keeps its mod id")
T.eq(cart.mods[2].file, 99123, "the gamebanana pin keeps its file id")
T.eq(cart.mods[2].md5, MD5, "the gamebanana pin keeps its md5")
T.eq(cart.mods[2].sha256, nil, "a gamebanana pin carries no sha256")
T.eq(cart.load_order[1], "rare_soda", "load_order defaults to the mods order")
T.eq(cart.load_order[2], "hard_mode", "load_order defaults to the mods order")
T.eq(raw.title, " Kanto Plus ", "parse does not trim the input in place")
T.eq(raw.shell, "#3FA9F5", "parse does not recolour the input in place")
T.eq(raw.load_order, nil, "parse does not add load_order to the input")
T.neq(cart.mods, raw.mods, "the parsed mods array is a fresh table")
T.neq(cart.mods[1].options, raw.mods[1].options, "options are copied")
local again = CartManifest.parse(baseCart())
T.eq(CartManifest.canonical(again), CartManifest.canonical(cart),
"two independent parses encode identically")
T.eq(CartManifest.hash(again), CartManifest.hash(cart),
"two independent parses hash identically")
T.eq(#CartManifest.hash(cart), 32, "the cart hash is an MD5 hex digest")
local bumped = baseCart()
bumped.mods[1].version = "0.4.2"
T.neq(CartManifest.hash(CartManifest.parse(bumped)), CartManifest.hash(cart),
"a bumped mod version moves the cart hash")
local retuned = baseCart()
retuned.mods[1].options.sweetness = 4
T.neq(CartManifest.hash(CartManifest.parse(retuned)), CartManifest.hash(cart),
"a changed option value moves the cart hash")
local reordered = baseCart()
reordered.load_order = { "hard_mode", "rare_soda" }
local reorderedCart = CartManifest.parse(reordered)
T.eq(reorderedCart.load_order[1], "hard_mode", "an explicit load_order is kept")
T.neq(CartManifest.hash(reorderedCart), CartManifest.hash(cart),
"a different load order moves the cart hash")
local encoded = CartManifest.encode(cart)
local decoded, decodeErr = CartManifest.decode(encoded)
T.check(decoded ~= nil, "an encoded cart decodes: " .. tostring(decodeErr))
T.same(decoded, cart, "the round trip is lossless")
T.eq(CartManifest.hash(decoded), CartManifest.hash(cart),
"the round trip keeps the cart hash")
T.eq(CartManifest.decode(nil), nil, "decode refuses a non-string")
T.eq(CartManifest.decode(""), nil, "decode refuses an empty file")
T.eq(CartManifest.decode("return { }"), nil, "decode refuses an untagged file")
T.eq(CartManifest.decode('return { format = "g1rmodlist" }'), nil,
"decode refuses another format's file")
T.eq(CartManifest.decode(
('return { format = "%s", formatVersion = 99, cart = {} }')
:format(CartManifest.FORMAT)), nil, "decode refuses an unknown schema")
T.eq(CartManifest.decode('return os.exit(1)'), nil,
"decode refuses a file that tries to call out")
T.eq(CartManifest.decode(
('return { format = "%s", formatVersion = 1, cart = { id = "x" } }')
:format(CartManifest.FORMAT)), nil, "decode validates the cart it carries")
rejects(function(c) c.id = nil end, "cart id", "a missing id")
rejects(function(c) c.id = "kanto plus" end, "cart id", "an id with a space")
rejects(function(c) c.id = ("k"):rep(65) end, "cart id", "a 65 character id")
rejects(function(c) c.title = nil end, "cart title", "a missing title")
rejects(function(c) c.title = " " end, "cart title", "a blank title")
rejects(function(c) c.title = ("T"):rep(49) end, "cart title", "a 49 character title")
rejects(function(c) c.version = nil end, "cart version", "a missing version")
rejects(function(c) c.version = "one" end, "cart version", "a non-semver version")
rejects(function(c) c.author = nil end, "cart author", "a missing author")
rejects(function(c) c.author = "" end, "cart author", "an empty author")
rejects(function(c) c.author = ("A"):rep(65) end, "cart author", "a 65 character author")
rejects(function(c) c.repo = "ren" end, "cart repo", "a repo with no owner")
rejects(function(c) c.repo = "ren/kanto/plus" end, "cart repo", "a three part repo")
rejects(function(c) c.summary = ("s"):rep(121) end, "cart summary", "a 121 character summary")
rejects(function(c) c.shell = nil end, "cart shell", "a missing shell colour")
rejects(function(c) c.shell = "3FA9F5" end, "cart shell", "a shell colour with no hash")
rejects(function(c) c.shell = "#3FA9F" end, "cart shell", "a five digit shell colour")
rejects(function(c) c.shell = "#gggggg" end, "cart shell", "a non-hex shell colour")
rejects(function(c) c.label = "../../etc/passwd" end, "cart label", "a climbing label path")
rejects(function(c) c.label = "/etc/passwd" end, "cart label", "an absolute label path")
rejects(function(c) c.label = ("a"):rep(129) end, "cart label", "a 129 character label path")
rejects(function(c) c.base = nil end, "cart base", "a missing base game")
rejects(function(c) c.base = "crystal" end, "cart base", "an unknown base game")
rejects(function(c) c.engine = "" end, "cart engine", "an empty engine range")
rejects(function(c) c.engine = 3 end, "cart engine", "a numeric engine range")
rejects(function(c) c.seal = "welded" end, "cart seal", "an unknown seal")
rejects(function(c) c.mods = nil end, "cart mods", "a cart with no mods array")
rejects(function(c) c.mods = {} end, "cart must pin", "a cart that pins nothing")
rejects(function(c)
for i = 1, 65 do
c.mods[i] = { id = "mod" .. i, source = "gamebanana", mod = i, file = i, md5 = MD5 }
end
end, "cart must pin", "a cart that pins 65 mods")
rejects(function(c) c.mods[1] = "rare_soda" end, "must be a table", "a string mod entry")
rejects(function(c) c.mods[1].id = nil end, "id must be", "a pin with no id")
rejects(function(c) c.mods[1].id = "rare soda" end, "id must be", "a pin id with a space")
rejects(function(c) c.mods[2].id = "rare_soda" end, "pinned twice", "the same mod pinned twice")
rejects(function(c) c.mods[1].source = nil end, "source must be", "a pin with no source")
rejects(function(c) c.mods[1].source = "dropbox" end, "source must be", "a pin from an unknown source")
rejects(function(c) c.mods[1].repo = nil end, "repo must be", "a github pin with no repo")
rejects(function(c) c.mods[1].version = "latest" end, "version must be", "a github pin with no semver")
rejects(function(c) c.mods[1].sha256 = nil end, "sha256", "a github pin with no sha256")
rejects(function(c) c.mods[1].sha256 = SHA:upper() end, "sha256", "an uppercase sha256")
rejects(function(c) c.mods[1].sha256 = SHA:sub(1, 63) end, "sha256", "a short sha256")
rejects(function(c) c.mods[2].mod = nil end, "mod must be", "a gamebanana pin with no mod id")
rejects(function(c) c.mods[2].mod = 0 end, "mod must be", "a gamebanana mod id of zero")
rejects(function(c) c.mods[2].mod = 12.5 end, "mod must be", "a fractional gamebanana mod id")
rejects(function(c) c.mods[2].file = nil end, "file must be", "a gamebanana pin with no file id")
rejects(function(c) c.mods[2].file = -3 end, "file must be", "a negative gamebanana file id")
rejects(function(c) c.mods[2].md5 = nil end, "md5", "a gamebanana pin with no md5")
rejects(function(c) c.mods[2].md5 = MD5:upper() end, "md5", "an uppercase md5")
rejects(function(c) c.mods[2].md5 = MD5 .. "00" end, "md5", "an overlong md5")
rejects(function(c) c.mods[1].options = "grape" end, "options must be a table",
"a non-table options block")
rejects(function(c) c.mods[1].options = { [("k"):rep(65)] = 1 } end,
"option keys", "a 65 character option key")
rejects(function(c) c.mods[1].options = { [1] = "grape" } end,
"option keys", "a numeric option key")
rejects(function(c) c.mods[1].options.nested = { 1, 2 } end,
"must be a string, number or boolean", "a table option value")
rejects(function(c) c.mods[1].options.flavour = ("g"):rep(257) end,
"characters or fewer", "a 257 character option value")
rejects(function(c)
local options = {}
for i = 1, 65 do options["opt" .. i] = i end
c.mods[1].options = options
end, "more than 64 options", "a pin with 65 options")
rejects(function(c) c.load_order = "rare_soda" end, "load_order must be an array",
"a string load_order")
rejects(function(c) c.load_order = { "rare_soda" } end, "exactly once",
"a load_order that drops a mod")
rejects(function(c) c.load_order = { "rare_soda", "hard_mode", "hard_mode" } end,
"exactly once", "a load_order longer than the mods array")
rejects(function(c) c.load_order = { "rare_soda", "rare_soda" } end, "twice",
"a load_order that repeats a mod")
rejects(function(c) c.load_order = { "rare_soda", "master_ball" } end,
"does not pin", "a load_order naming an unpinned mod")
T.eq(CartManifest.parse(nil), nil, "parse refuses a non-table")
local open = baseCart()
open.seal = "open"
open.repo = nil
open.summary = nil
open.label = nil
open.engine = nil
local openCart = CartManifest.parse(open)
T.check(openCart ~= nil, "an open cart with no optional fields parses")
T.eq(openCart.seal, "open", "an open seal survives")
T.eq(openCart.label, nil, "an absent label stays absent")
T.same(CartManifest.decode(CartManifest.encode(openCart)), openCart,
"an open cart round trips")
T.neq(CartManifest.hash(openCart), CartManifest.hash(cart),
"the seal is part of the cart hash")
T.eq(CartManifest.publishable(cart), true,
"a cart pinned entirely to github and gamebanana is publishable")
T.eq(CartManifest.publishable(nil), false, "publishable refuses a non-cart")
T.eq(CartManifest.publishable({}), false, "publishable refuses an unparsed cart")
local localised = baseCart()
localised.mods[1] = { id = "rare_soda", source = "local", version = "0.4.1",
repo = "ren/rare-soda", sha256 = SHA,
options = { flavour = "grape" } }
local localCart, localErr = CartManifest.parse(localised)
T.check(localCart ~= nil, "a local pin parses: " .. tostring(localErr))
T.eq(localCart.mods[1].source, "local", "the local pin keeps its source")
T.eq(localCart.mods[1].version, "0.4.1", "the local pin keeps its version")
T.eq(localCart.mods[1].repo, nil, "a local pin carries no repo")
T.eq(localCart.mods[1].sha256, nil, "a local pin carries no sha256")
T.eq(localCart.mods[1].md5, nil, "a local pin carries no md5")
T.eq(localCart.mods[1].options.flavour, "grape", "a local pin still freezes options")
T.eq(localCart.mods[2].source, "gamebanana", "the sibling pin is untouched")
T.eq(CartManifest.canonical(CartManifest.parse(localised)),
CartManifest.canonical(localCart), "two parses of a local pin encode identically")
T.eq(CartManifest.hash(CartManifest.parse(localised)), CartManifest.hash(localCart),
"two parses of a local pin hash identically")
T.neq(CartManifest.hash(localCart), CartManifest.hash(cart),
"a local pin hashes differently from the github pin it replaced")
local localBump = baseCart()
localBump.mods[1] = { id = "rare_soda", source = "local", version = "0.4.2",
options = { flavour = "grape" } }
T.neq(CartManifest.hash(CartManifest.parse(localBump)), CartManifest.hash(localCart),
"a bumped local pin version moves the cart hash")
T.same(CartManifest.decode(CartManifest.encode(localCart)), localCart,
"a cart holding a local pin round trips")
local publishableLocal, localWhy = CartManifest.publishable(localCart)
T.eq(publishableLocal, false, "a cart holding a local pin is not publishable")
T.check(type(localWhy) == "string" and localWhy:find("rare_soda", 1, true) ~= nil,
"the reason names the local pin (got " .. tostring(localWhy) .. ")")
T.check(localWhy:find("hard_mode", 1, true) == nil,
"the reason leaves the publishable pins out")
local allLocal = baseCart()
allLocal.mods = {
{ id = "rare_soda", source = "local", version = "0.4.1" },
{ id = "hard_mode", source = "local", version = "2.0.0" },
}
local _, allWhy = CartManifest.publishable(CartManifest.parse(allLocal))
T.check(allWhy:find("hard_mode", 1, true) ~= nil and allWhy:find("rare_soda", 1, true) ~= nil,
"the reason names every local pin (got " .. tostring(allWhy) .. ")")
rejects(function(c) c.mods[1] = { id = "rare_soda", source = "local" } end,
"version must be", "a local pin with no version")
rejects(function(c)
c.mods[1] = { id = "rare_soda", source = "local", version = "latest" }
end, "version must be", "a local pin with a non-semver version")
rejects(function(c) c.mods[1].source = "localhost" end, "source must be",
"a source that merely starts like local")
local VECTORS = {
{ "", "" }, { "f", "Zg==" }, { "fo", "Zm8=" }, { "foo", "Zm9v" },
{ "foob", "Zm9vYg==" }, { "fooba", "Zm9vYmE=" }, { "foobar", "Zm9vYmFy" },
{ "\0\255\0", "AP8A" }, { "\255\255\255\255", "/////w==" },
}
for _, row in ipairs(VECTORS) do
T.eq(Base64.encode(row[1]), row[2],
("base64 encodes %q as %s"):format(row[1], row[2]))
T.eq(Base64.decode(row[2]), row[1],
("base64 decodes %s back"):format(row[2] == "" and "an empty string" or row[2]))
end
local seed = 7
local function nextByte()
seed = (seed * 75 + 74) % 65537
return seed % 256
end
for n = 0, 24 do
local chunk = {}
for i = 1, n do chunk[i] = string.char(nextByte()) end
local raw = table.concat(chunk)
local text = Base64.encode(raw)
T.eq(#text % 4, 0, ("base64 pads %d bytes to a multiple of four"):format(n))
T.eq(Base64.decode(text), raw, ("base64 round trips %d random bytes"):format(n))
end
T.eq(Base64.encode(nil), nil, "base64 encode refuses a non-string")
T.eq(Base64.decode(nil), nil, "base64 decode refuses a non-string")
T.eq(Base64.decode("TWF"), nil, "base64 refuses a length that is not a multiple of four")
T.eq(Base64.decode("TW*u"), nil, "base64 refuses a character outside the alphabet")
T.eq(Base64.decode("TWFu\n\n\n\n"), nil, "base64 refuses embedded whitespace")
T.eq(Base64.decode("TW=u"), nil, "base64 refuses padding inside a group")
T.eq(Base64.decode("=WFu"), nil, "base64 refuses a leading pad character")
T.eq(Base64.decode("TWFu===="), nil, "base64 refuses a group that is all padding")
T.eq(Base64.decode("TR=="), nil, "base64 refuses one-byte padding that carries data bits")
T.eq(Base64.decode("Zm9vYmF="), nil, "base64 refuses two-byte padding that carries data bits")
T.eq(Base64.decode("TWFu"), "Man", "base64 decodes a known vector")
local PNG = CartManifest.PNG_SIGNATURE .. "\0\0\0\13IHDRtiny label art"
local ART_DATA = Base64.encode(PNG)
local NONE = {}
local function artTable(over)
local art = { name = "label.png", encoding = "base64", bytes = #PNG,
data = ART_DATA }
for key, value in pairs(over or {}) do
if value == NONE then art[key] = nil else art[key] = value end
end
return art
end
local function bundle(body, art)
return SaveSerializer.encode({ format = CartManifest.FORMAT,
formatVersion = CartManifest.SCHEMA, cart = body, labelArt = art })
end
local arted = CartManifest.parse(baseCart())
arted.labelArt = artTable()
local artedBytes = CartManifest.encode(arted)
local artedRound, artedErr = CartManifest.decode(artedBytes)
T.check(artedRound ~= nil, "a cart with label art decodes: " .. tostring(artedErr))
T.same(artedRound, arted, "the label art survives the encode and decode round trip")
T.eq(artedRound.labelArt.data, ART_DATA, "the base64 payload is preserved verbatim")
T.eq(artedRound.labelArt.bytes, #PNG, "the declared byte count is preserved")
T.eq(artedRound.labelArt.name, "label.png", "the art name is preserved")
T.eq(CartManifest.encode(artedRound), artedBytes,
"re-encoding a decoded cart writes the same bytes")
local artBytes, artName = CartManifest.labelArtBytes(artedRound)
T.eq(artBytes, PNG, "labelArtBytes hands back the PNG that was packed")
T.eq(artName, "label.png", "labelArtBytes hands back the art name")
T.eq(CartManifest.labelArtBytes(cart), nil, "a cart with no art has no art bytes")
T.eq(CartManifest.hash(arted), CartManifest.hash(cart),
"label art is not part of the cart hash")
T.check(CartManifest.canonical(arted):find(ART_DATA, 1, true) == nil,
"the canonical form leaves the art payload out")
local repainted = CartManifest.parse(baseCart())
local REPAINT = PNG .. "repainted"
repainted.labelArt = artTable({ data = Base64.encode(REPAINT), bytes = #REPAINT })
T.eq(CartManifest.hash(repainted), CartManifest.hash(arted),
"changing only the art leaves the cart hash alone")
T.eq(CartManifest.labelArtBytes(CartManifest.decode(CartManifest.encode(repainted))),
REPAINT, "the repainted art round trips")
local plain = CartManifest.decode(CartManifest.encode(cart))
T.eq(plain.labelArt, nil, "a cart with no art still decodes without art")
T.check(CartManifest.encode(cart):find("labelArt", 1, true) == nil,
"a cart with no art writes no labelArt key")
T.same(plain, cart, "a cart with no art round trips exactly as before")
local OVERSIZE = PNG .. string.rep("\0", CartManifest.MAX_LABEL_ART)
local OVERSIZE_DATA = Base64.encode(OVERSIZE)
local function drops(art, what)
local decodedArt, dropErr = CartManifest.decode(bundle(cart, art))
T.check(decodedArt ~= nil, what .. " still loads the cart: " .. tostring(dropErr))
T.eq(decodedArt.labelArt, nil, what .. " drops the art")
T.same(decodedArt, cart, what .. " leaves the rest of the cart untouched")
end
drops("label.png", "art that is not a table")
drops(artTable({ encoding = "hex" }), "art in an encoding we do not support")
drops(artTable({ encoding = NONE }), "art with no encoding")
drops(artTable({ data = "not base64!!" }), "art whose payload is not base64")
drops(artTable({ data = NONE }), "art with no payload")
drops(artTable({ data = "" }), "art with an empty payload")
drops(artTable({ bytes = #PNG + 1 }), "art whose byte count is too high")
drops(artTable({ bytes = #PNG - 1 }), "art whose byte count is too low")
drops(artTable({ bytes = NONE }), "art with no byte count")
drops(artTable({ bytes = tostring(#PNG) }), "art whose byte count is a string")
drops(artTable({ data = Base64.encode("GIF89a not a png at all"),
bytes = #"GIF89a not a png at all" }), "art that is not a PNG")
drops(artTable({ data = OVERSIZE_DATA, bytes = #OVERSIZE }), "art past the size cap")
drops(artTable({ name = "../../etc/passwd" }), "art whose name climbs out")
drops(artTable({ name = ("a"):rep(129) }), "art with a 129 character name")
drops(artTable({ name = 7 }), "art with a numeric name")
local unnamed = CartManifest.decode(bundle(cart, artTable({ name = NONE })))
T.check(unnamed ~= nil and unnamed.labelArt ~= nil, "art with no name is still kept")
T.eq(unnamed.labelArt.name, nil, "the missing art name stays missing")
T.eq(CartManifest.labelArtBytes(unnamed), PNG, "unnamed art still decodes")
T.eq(CartManifest.parseLabelArt(nil), nil, "parseLabelArt refuses an absent payload")
local _, capErr = CartManifest.parseLabelArt(
artTable({ data = OVERSIZE_DATA, bytes = #OVERSIZE }))
T.check(type(capErr) == "string" and capErr:find("bytes or fewer", 1, true) ~= nil,
"the size cap says why (got " .. tostring(capErr) .. ")")
local _, pngErr = CartManifest.parseLabelArt(
artTable({ data = Base64.encode("nope"), bytes = 4 }))
T.check(type(pngErr) == "string" and pngErr:find("PNG", 1, true) ~= nil,
"a non-PNG payload says why (got " .. tostring(pngErr) .. ")")
T.finish("cart_manifest")
+269
View File
@@ -0,0 +1,269 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
love = love or require("tests.love_stub")
local SaveSerializer = require("src.core.SaveSerializer")
local SaveData = require("src.core.SaveData")
local GameVersion = require("src.core.GameVersion")
local realFS = love.filesystem
local function memfs(files)
return {
files = files,
write = function(path, content) files[path] = content return true end,
read = function(path) return files[path] end,
remove = function(path) files[path] = nil return true end,
getInfo = function(path)
if files[path] then return { type = "file" } end
return nil
end,
}
end
local function fresh()
local files = {}
love.filesystem = memfs(files)
SaveData.resetSlotState()
GameVersion.set("red")
return files
end
local function options(files)
return SaveSerializer.decode(files["options.lua"] or "") or {}
end
local function plainSave(name, hash)
return {
version = "red",
meta = hash and { cartHash = hash } or nil,
player = { name = name, map = "PALLET_TOWN", x = 1, y = 1 },
pokedex = { seen = {}, owned = {} },
inventory = {},
playTime = 0,
}
end
do
fresh()
T.eq(SaveData.getCart(), nil, "no cart is active by default")
T.eq(SaveData.setCart("nuzlocke", "abc123"), "nuzlocke", "setCart returns the id")
T.eq(SaveData.getCart(), "nuzlocke", "getCart reads the active cart back")
T.eq(SaveData.getCartHash(), "abc123", "the build hash rides along")
T.eq(SaveData.setCart(nil), nil, "setCart(nil) returns to vanilla play")
T.eq(SaveData.getCart(), nil, "and getCart says so")
T.eq(SaveData.getCartHash(), nil, "clearing the cart clears its build hash")
T.eq(SaveData.setCart("../evil"), nil, "a path-climbing cart id is refused")
T.eq(SaveData.setCart(".."), nil, "so is a bare parent reference")
T.eq(SaveData.setCart("has spaces"), nil, "so is a non-word id")
T.eq(SaveData.setCart(42), nil, "so is a non-string id")
T.eq(SaveData.getCart(), nil, "none of them become the active cart")
end
do
local files = fresh()
T.eq(#SaveData.listCartSlots("nuzlocke"), 0, "a cart starts with no slots")
T.eq(SaveData.activeCartSlot("nuzlocke"), nil, "and no active slot")
T.eq(SaveData.slotCartHash("nuzlocke", "slot1"), nil, "and no stamped hash")
T.eq(files["options.lua"], nil, "listing an empty cart writes no options file")
T.eq(#SaveData.listCartSlots("../evil"), 0, "an unusable cart id lists nothing")
T.eq(SaveData.createCartSlot("../evil"), nil, "and can allocate no slot")
local ok, err = SaveData.deleteCartSlot("../evil", "slot1")
T.check(not ok, "and deleting from it fails")
T.check(tostring(err):find("unknown cart", 1, true) ~= nil,
"with a user-presentable reason")
end
do
local files = fresh()
T.eq(SaveData.createCartSlot("nuzlocke"), "slot1", "first cart slot is slot1")
T.eq(SaveData.createCartSlot("nuzlocke"), "slot2", "ids increment as they do per version")
T.eq(SaveData.setActiveCartSlot("nuzlocke", "slot2"), "slot2",
"setActiveCartSlot returns the chosen id")
T.eq(SaveData.activeCartSlot("nuzlocke"), "slot2", "and the choice is live")
local opts = options(files)
T.eq(opts.cartSlots.nuzlocke.active, "slot2", "the active id persists in options.lua")
T.eq(opts.cartSlots.nuzlocke.list[1], "slot1", "the slot list persists with it")
T.eq(opts.cartSlots.nuzlocke.list[2], "slot2", "in allocation order")
T.eq(opts.saveSlots, nil, "no per-version registry is created by cart work")
T.check(SaveData.renameCartSlot("nuzlocke", "slot1", " Hardcore "),
"renameCartSlot labels a registered slot")
T.eq(options(files).cartSlots.nuzlocke.names.slot1, "Hardcore",
"the label is trimmed and persisted")
T.eq(SaveData.listCartSlots("nuzlocke")[1].label, "Hardcore",
"listCartSlots carries the label")
T.check(SaveData.renameCartSlot("nuzlocke", "slot1", ""), "an empty name clears it")
T.eq(options(files).cartSlots.nuzlocke.names, nil,
"the names table leaves the registry once empty")
local bad, badErr = SaveData.renameCartSlot("nuzlocke", "slot99", "x")
T.check(not bad, "renaming an unregistered cart slot fails")
T.check(tostring(badErr):find("not registered", 1, true) ~= nil,
"unknown-slot rename error is user-presentable")
T.check(SaveData.writeCartSlot("nuzlocke", "slot2", plainSave("NUZ")),
"seed the active cart slot")
T.check(files["saves/cart_nuzlocke/slot2.lua"] ~= nil,
"the bytes land in the cart's own directory")
T.check(SaveData.deleteCartSlot("nuzlocke", "slot2"), "deleteCartSlot removes it")
T.eq(files["saves/cart_nuzlocke/slot2.lua"], nil, "the slot file is gone")
opts = options(files)
T.eq(#opts.cartSlots.nuzlocke.list, 1, "the id is dropped from the registry")
T.eq(opts.cartSlots.nuzlocke.active, "slot1",
"active falls back to the remaining slot")
T.eq(SaveData.activeCartSlot("nuzlocke"), "slot1", "and the live cache follows")
T.check(SaveData.deleteCartSlot("nuzlocke", "slot1"), "deleting the last slot works")
T.eq(#SaveData.listCartSlots("nuzlocke"), 0, "the cart is empty again")
T.eq(options(files).cartSlots.nuzlocke.active, nil, "active clears with the list")
end
do
local files = fresh()
T.eq(SaveData.createCartSlot("alpha"), "slot1", "alpha allocates its own slot1")
T.eq(SaveData.createCartSlot("beta"), "slot1", "beta allocates its own slot1")
T.check(SaveData.writeCartSlot("alpha", "slot1", plainSave("AAA", "aaa111")),
"seed alpha's slot")
T.check(SaveData.writeCartSlot("beta", "slot1", plainSave("BBB", "bbb222")),
"seed beta's slot")
T.eq(#SaveData.listCartSlots("alpha"), 1, "alpha lists only its own slot")
T.eq(#SaveData.listCartSlots("beta"), 1, "beta lists only its own slot")
T.eq(SaveData.listCartSlots("alpha")[1].name, "AAA", "alpha reads its own save")
T.eq(SaveData.listCartSlots("beta")[1].name, "BBB", "beta reads its own save")
T.check(files["saves/cart_alpha/slot1.lua"] ~= files["saves/cart_beta/slot1.lua"],
"two carts with the same slot id write different files")
T.eq(SaveData.slotCartHash("alpha", "slot1"), "aaa111", "alpha's build stamp")
T.eq(SaveData.slotCartHash("beta", "slot1"), "bbb222", "beta's build stamp")
T.check(SaveData.deleteCartSlot("alpha", "slot1"), "delete alpha's only slot")
T.eq(#SaveData.listCartSlots("alpha"), 0, "alpha is empty")
T.eq(#SaveData.listCartSlots("beta"), 1, "beta is untouched")
T.check(files["saves/cart_beta/slot1.lua"] ~= nil, "beta's file survives")
T.eq(SaveData.slotCartHash("beta", "slot1"), "bbb222", "as does its stamp")
end
do
local files = fresh()
T.eq(SaveData.createSlot("red"), "slot1", "red allocates a version slot")
T.eq(SaveData.createCartSlot("red"), "slot1",
"a cart may even be named after a version")
T.check(SaveData.writeSlot("red", "slot1", plainSave("VANILLA")),
"seed the version slot")
T.check(SaveData.writeCartSlot("red", "slot1", plainSave("CARTRED")),
"seed the cart slot")
T.eq(#SaveData.listSlots("red"), 1, "the version lists only its own slot")
T.eq(SaveData.listSlots("red")[1].name, "VANILLA", "with the vanilla save in it")
T.eq(#SaveData.listCartSlots("red"), 1, "the cart lists only its own slot")
T.eq(SaveData.listCartSlots("red")[1].name, "CARTRED", "with the cart save in it")
T.check(files["saves/red/slot1.lua"] ~= nil, "the version path is saves/red/")
T.check(files["saves/cart_red/slot1.lua"] ~= nil, "the cart path is saves/cart_red/")
T.eq(SaveData.listSlots("red")[1].cartHash, nil,
"a version slot carries no cart hash")
T.check(SaveData.deleteCartSlot("red", "slot1"), "uninstall-style cart slot delete")
T.eq(#SaveData.listSlots("red"), 1, "the vanilla slot is still registered")
T.check(files["saves/red/slot1.lua"] ~= nil, "and its file is not orphaned")
T.eq(options(files).saveSlots.red.list[1], "slot1",
"the version registry is independent of the cart one")
end
do
local files = fresh()
SaveData.setCart("nuzlocke", "abc123")
T.eq(SaveData.saveFilename("red"), "save_cart_nuzlocke.lua",
"with no cart slot yet, the flat path follows the version suffix scheme")
SaveData.createCartSlot("nuzlocke")
SaveData.setActiveCartSlot("nuzlocke", "slot1")
T.eq(SaveData.saveFilename("red"), "saves/cart_nuzlocke/slot1.lua",
"the active cart slot owns the save path")
local save = SaveData.newGame()
save.player.name = "NUZ"
T.check(SaveData.save(save, {}), "an in-game save under a cart writes")
T.check(files["saves/cart_nuzlocke/slot1.lua"] ~= nil, "into the cart's slot")
T.eq(files["save.lua"], nil, "never into the base game's flat file")
T.eq(files["saves/red/slot1.lua"], nil, "never into the base game's slot")
T.eq(#SaveData.listSlots("red"), 0, "and the base game still has no slots")
local loaded = SaveData.load("red")
T.check(loaded and loaded.player.name == "NUZ", "load reads the cart's slot back")
T.eq(loaded.meta.cartHash, "abc123", "the save records the build it was made under")
T.eq(SaveData.slotCartHash("nuzlocke", "slot1"), "abc123",
"and the registry mirrors it for a listing with no save decode")
T.eq(SaveData.listCartSlots("nuzlocke")[1].cartHash, "abc123",
"listCartSlots surfaces the stamp")
end
do
fresh()
SaveData.setCart("nuzlocke", "abc123")
SaveData.createCartSlot("nuzlocke")
SaveData.setActiveCartSlot("nuzlocke", "slot1")
T.check(SaveData.save(SaveData.newGame(), {}), "first save under build abc123")
local loaded = SaveData.load("red")
T.check(SaveData.save(loaded, {}), "a re-save rebuilds meta")
T.eq(SaveData.load("red").meta.cartHash, "abc123",
"buildMeta carries the stamp instead of dropping it")
SaveData.setCartHash("def456")
T.eq(SaveData.slotCartHash("nuzlocke", "slot1"), "abc123",
"installing an update does not restamp the slot")
loaded = SaveData.load("red")
T.eq(loaded.meta.cartHash, "abc123", "nor the in-progress save")
T.check(SaveData.save(loaded, {}), "save under the new build")
T.eq(SaveData.load("red").meta.cartHash, "def456", "now the save carries it")
T.eq(SaveData.slotCartHash("nuzlocke", "slot1"), "def456", "and so does the registry")
local ok, err = SaveData.setSlotCartHash("nuzlocke", "slot9", "zzz")
T.check(not ok, "an unregistered slot cannot be stamped")
T.check(tostring(err):find("not registered", 1, true) ~= nil,
"with a user-presentable reason")
end
do
local files = fresh()
SaveData.createSlot("red")
SaveData.setActiveSlot("red", "slot1")
local save = SaveData.newGame()
save.player.name = "VANILLA"
T.check(SaveData.save(save, {}), "a vanilla save with no cart set")
local bytes = files["saves/red/slot1.lua"]
T.check(bytes:find("cartHash", 1, true) == nil, "records no cartHash")
T.check(files["options.lua"]:find("cartSlots", 1, true) == nil,
"and options.lua grows no cart registry")
T.eq(options(files).cartSlots, nil, "which decodes as absent, not empty")
local loaded = SaveData.load("red")
T.check(SaveData.save(loaded), "re-save the migrated shape once")
local before = files["saves/red/slot1.lua"]
SaveData.setCart("nuzlocke", "abc123")
T.eq(SaveData.saveFilename("red"), "save_cart_nuzlocke.lua",
"the cart takes over the path while it is active")
SaveData.setCart(nil)
T.eq(SaveData.saveFilename("red"), "saves/red/slot1.lua",
"and hands it straight back")
T.check(SaveData.save(loaded), "re-save after the cart round trip")
T.eq(files["saves/red/slot1.lua"], before, "the vanilla bytes are identical")
T.check(files["options.lua"]:find("cartSlots", 1, true) == nil,
"and options.lua still carries no cart registry")
local again = SaveData.load("red")
T.check(again and again.player.name == "VANILLA", "the vanilla save still loads")
T.eq(again.meta.cartHash, nil, "with no cart stamp on it")
end
love.filesystem = realFS
T.finish("cart_saves")
+470
View File
@@ -0,0 +1,470 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
love = love or require("tests.love_stub")
local Loader = require("src.mods.Loader")
local CartManifest = require("src.carts.CartManifest")
local SaveData = require("src.core.SaveData")
local SaveSerializer = require("src.core.SaveSerializer")
local GameVersion = require("src.core.GameVersion")
local realFS = love.filesystem
local function memfs(files)
local fs
fs = {
files = files,
read = function(path) return files[path] end,
write = function(path, content) files[path] = content return true end,
remove = function(path) files[path] = nil 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 load(files[path], path)
end,
createDirectory = function() return true 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,
}
return fs
end
local function manifestJson(id)
return ([[{"id":"%s","name":"%s","version":"1.0.0","entry":"main.lua"}]])
:format(id, id)
end
local function entry(record)
return ([[
return function(mod)
mod.options:define({ { key = "tint", default = "base" } })
mod.content.pokemon:register("%s", { name = tostring(mod.options:get("tint")) })
end
]]):format(record)
end
local function install()
local files = {
["options.lua"] = SaveSerializer.encode({
mods = { beta = false },
modOptions = { alpha = { tint = "player" }, beta = { tint = "player" } },
}),
}
for _, id in ipairs({ "alpha", "beta", "gamma" }) do
files["mods/" .. id .. "/manifest.json"] = manifestJson(id)
files["mods/" .. id .. "/main.lua"] = entry(id:upper())
end
SaveData.resetSlotState()
GameVersion.set("red")
return files
end
local function writeCart(files, tbl)
local cart, err = CartManifest.parse(tbl)
assert(cart, err)
files["carts/" .. tbl.id .. CartManifest.EXT] = CartManifest.encode(cart)
return cart
end
local function cartTable(id, seal, mods, order)
return { id = id, title = id, version = "1.0.0", author = "tester",
shell = "#102030", base = "red", seal = seal,
mods = mods, load_order = order }
end
local function pin(id, version, options)
return { id = id, source = "local", version = version or "1.0.0",
options = options }
end
local function boot(files)
local data = { pokemon = {} }
local loader = Loader.new({ fs = memfs(files) })
local ok = loader:load(data)
return loader, data, ok
end
local function options(files)
return SaveSerializer.decode(files["options.lua"] or "") or {}
end
local function names(list)
return table.concat(list, ",")
end
-- ------- a sealed cart loads its pins, in its order, with its options
do
local files = install()
writeCart(files, cartTable("sealed", "sealed",
{ pin("alpha", "1.0.0", { tint = "cart" }), pin("beta") },
{ "beta", "alpha" }))
SaveData.setCart("sealed", "hash1")
local loader, data, ok = boot(files)
T.check(ok, "a sealed cart whose pins are all installed loads cleanly")
T.eq(names(loader.order), "beta,alpha",
"the cart's load_order beats priority and the id tie-break")
T.eq(data.pokemon.ALPHA.name, "cart",
"a frozen option overrides the player's saved value")
T.eq(data.pokemon.BETA.name, "base",
"a pin that froze no options falls to the schema default, not the player's")
T.eq(data.pokemon.GAMMA, nil, "an enabled mod the cart does not pin never runs")
T.eq(loader.mods.gamma.enabled, false, "and is reported as inactive")
T.eq(loader.mods.gamma.state, "disabled", "with the disabled row state")
T.eq(loader.mods.beta.enabled, true, "a pin the player switched off still loads")
local report = loader:cartStatus()
T.eq(report.id, "sealed", "the report names the cart")
T.eq(report.seal, "sealed", "and its seal")
T.eq(report.enforced, true, "which is enforced")
T.eq(report.refused, false, "and not refused")
T.eq(#report.missing, 0, "with no missing pins")
T.eq(#report.mismatched, 0, "and no version mismatches")
T.eq(loader:status().cart, report, "status() carries the same report")
local opts = options(files)
T.eq(SaveData.modEnabled(opts, "beta", "red"), false,
"the player's disable flag is untouched on disk")
T.eq(SaveData.modEnabled(opts, "gamma", "red") == false, false,
"and so is the enable flag of the mod the seal left out")
T.eq(opts.modOptions.alpha.tint, "player",
"the frozen option never overwrote the player's saved value")
T.eq(opts.modOptions.beta.tint, "player", "for any pinned mod")
end
-- ------- an open cart layers the player's mods on top
do
local files = install()
writeCart(files, cartTable("open", "open",
{ pin("beta", "1.0.0", { tint = "cart" }), pin("gamma", "1.0.0", { tint = "cart" }) },
{ "beta", "gamma" }))
SaveData.setCart("open", "hash2")
local loader, data, ok = boot(files)
T.check(ok, "an open cart loads")
T.eq(names(loader.order), "beta,gamma,alpha",
"the cart's mods come first in its order, then the player's own")
T.eq(data.pokemon.BETA.name, "player",
"an open cart's option is a starting value the player's own setting beats")
T.eq(data.pokemon.GAMMA.name, "cart",
"and it stands where the player set nothing")
T.eq(data.pokemon.ALPHA.name, "player", "the player's extra mod loads normally")
T.eq(loader:cartStatus().enforced, false, "an open cart enforces nothing")
end
-- ------- a missing pin: refusal when sealed, warning when open
do
local files = install()
writeCart(files, cartTable("gap", "sealed",
{ pin("alpha"), pin("delta", "2.0.0") }, { "alpha", "delta" }))
SaveData.setCart("gap", "hash3")
local loader, data, ok = boot(files)
T.check(not ok, "a sealed cart with an uninstalled pin fails the load")
T.eq(#loader.order, 0, "and plays no subset of itself")
T.eq(data.pokemon.ALPHA, nil, "not even the pin that is installed")
T.eq(data.pokemon.GAMMA, nil, "and certainly not the player's own mods")
local report = loader:cartStatus()
T.eq(report.refused, true, "the report refuses the cart")
T.eq(#report.missing, 1, "naming one missing pin")
T.eq(report.missing[1].id, "delta", "by id")
T.eq(report.missing[1].version, "2.0.0", "and by the version it pins")
T.eq(report.missing[1].source, "local", "with the source it would come from")
T.check(report.message:find("delta 2.0.0 is not installed", 1, true) ~= nil,
"and a message the launcher can show")
T.eq(loader.errors[1], report.message, "the refusal is on the boot error list")
local opts = options(files)
T.eq(SaveData.modEnabled(opts, "beta", "red"), false,
"a refusal still leaves the player's flags alone")
end
do
local files = install()
writeCart(files, cartTable("gap", "open",
{ pin("alpha"), pin("delta", "2.0.0") }, { "alpha", "delta" }))
SaveData.setCart("gap", "hash4")
local loader, data, ok = boot(files)
T.check(ok, "an open cart with an uninstalled pin still loads")
T.eq(names(loader.order), "alpha,gamma", "with the pins it does have, then the rest")
T.eq(data.pokemon.ALPHA.name, "player", "the surviving pin runs")
local report = loader:cartStatus()
T.eq(report.refused, false, "the missing pin is a warning, not a refusal")
T.eq(report.missing[1].id, "delta", "and is still reported by id")
end
-- ------- a pin installed at another version
do
local files = install()
writeCart(files, cartTable("skew", "sealed",
{ pin("alpha", "2.0.0") }, { "alpha" }))
SaveData.setCart("skew", "hash5")
local loader, _, ok = boot(files)
T.check(not ok, "a sealed cart refuses a pin installed at another version")
T.eq(#loader.order, 0, "and loads nothing")
local report = loader:cartStatus()
T.eq(report.mismatched[1].id, "alpha", "the mismatch names the mod")
T.eq(report.mismatched[1].version, "2.0.0", "the version the cart pins")
T.eq(report.mismatched[1].installed, "1.0.0", "and the version installed")
T.check(report.message:find("alpha is pinned at 2.0.0 but 1.0.0 is installed",
1, true) ~= nil, "with a message the launcher can show")
end
do
local files = install()
writeCart(files, cartTable("skew", "open",
{ pin("alpha", "2.0.0") }, { "alpha" }))
SaveData.setCart("skew", "hash6")
local loader, data, ok = boot(files)
T.check(ok, "an open cart warns about a version skew instead of refusing")
T.eq(data.pokemon.ALPHA.name, "player", "and loads the version that is there")
T.eq(loader:cartStatus().mismatched[1].installed, "1.0.0", "while reporting it")
end
-- ------- an unreadable cart
do
local files = install()
SaveData.setCart("ghost", "hash7")
local loader, data, ok = boot(files)
T.check(not ok, "a cart that is not installed cannot be played as that cart")
T.eq(data.pokemon.GAMMA, nil, "so nothing loads under its name")
T.check(loader:cartStatus().message:find("ghost", 1, true) ~= nil,
"and the report names the cart that went missing")
end
-- ------- breaking the seal downgrades a sealed cart to the open answer
do
local files = install()
writeCart(files, cartTable("sealed", "sealed",
{ pin("beta", "1.0.0", { tint = "cart" }), pin("delta", "2.0.0") },
{ "beta", "delta" }))
SaveData.setCart("sealed", "hash8")
SaveData.breakSeal()
local loader, data, ok = boot(files)
T.check(ok, "a broken seal no longer refuses over a missing pin")
T.eq(names(loader.order), "beta,alpha,gamma",
"the player's own mods load alongside the cart's")
T.eq(data.pokemon.BETA.name, "player",
"and the player's option values come back with them")
local report = loader:cartStatus()
T.eq(report.broken, true, "the report says the seal is broken")
T.eq(report.enforced, false, "so the seal enforces nothing")
end
-- ------- vanilla is untouched when no cart is active
do
local files = install()
SaveData.resetSlotState()
local loader, data, ok = boot(files)
T.check(ok, "a vanilla boot with no cart loads")
T.eq(loader:cartStatus(), nil, "and reports no cart at all")
T.eq(names(loader.order), "alpha,gamma", "with the player's enabled set, in id order")
T.eq(data.pokemon.ALPHA.name, "player", "and the player's option values")
T.eq(data.pokemon.BETA, nil, "the mod the player switched off stays off")
end
-- ------- planCart on its own
do
local report = Loader.planCart(nil, {})
T.eq(report.refused, true, "planCart refuses a cart it was handed nothing for")
T.check(report.message:find("not installed", 1, true) ~= nil,
"with a presentable reason")
local unpinned = Loader.planCart(
cartTable("c", "sealed", { pin("gamma", "0.0.0") }, { "gamma" }),
{ { id = "gamma", version = "whatever" } })
T.eq(#unpinned.mismatched, 0,
"a local pin captured with no semantic version makes no version claim")
T.eq(unpinned.refused, false, "so it cannot refuse over one")
local skew = Loader.planCart(
cartTable("c", "sealed", { pin("gamma", "1.0.0") }, { "gamma" }),
{ gamma = { manifest = { id = "gamma", version = "1.0.0-beta" } } })
T.eq(skew.mismatched[1].installed, "1.0.0-beta",
"a prerelease is a different version to a sealed cart")
local broken = Loader.planCart(
cartTable("c", "sealed", { pin("gamma", "1.0.0") }, { "gamma" }), {}, true)
T.eq(broken.refused, false, "a broken seal downgrades the refusal to a warning")
T.eq(broken.missing[1].id, "gamma", "while still reporting the missing pin")
end
-- ------- the broken-seal stamp
local function plainSave(name)
return {
version = "red",
player = { name = name, map = "PALLET_TOWN", x = 1, y = 1 },
pokedex = { seen = {}, owned = {} },
inventory = {},
playTime = 0,
}
end
do
local files = {}
love.filesystem = memfs(files)
SaveData.resetSlotState()
GameVersion.set("red")
T.eq(SaveData.isSealBroken(), false, "a fresh session has no broken seal")
local save = plainSave("INTACT")
T.eq(SaveData.isSealBroken(save), false, "and neither does a fresh save")
T.check(SaveData.save(save, {}), "write a save under an intact seal")
T.eq(SaveData.load("red").meta.sealBroken, nil, "which carries no stamp")
local loaded = SaveData.load("red")
T.check(SaveData.breakSeal(loaded), "breaking the seal stamps the save")
T.eq(SaveData.isSealBroken(loaded), true, "the save reads back as modified")
T.eq(SaveData.isSealBroken(), true, "and the session is armed")
T.check(SaveData.save(loaded, {}), "save the stamped file")
T.eq(SaveData.load("red").meta.sealBroken, true,
"the stamp survives a save/load round trip")
local again = SaveData.load("red")
T.check(SaveData.save(again, {}), "re-save with a rebuilt meta stamp")
T.eq(SaveData.load("red").meta.sealBroken, true, "buildMeta carries the stamp")
T.eq(SaveData.unbreakSeal, nil, "there is no public unset")
T.eq(SaveData.clearSeal, nil, "under any spelling")
T.eq(SaveData.setSealBroken, nil, "and no setter that takes a value")
T.check(SaveData.breakSeal(again, false), "the setter takes no argument that clears")
T.eq(SaveData.isSealBroken(again), true, "so the stamp is still there")
SaveData.resetSlotState()
T.eq(SaveData.isSealBroken(), false, "a new session starts unarmed")
local reread = SaveData.load("red")
T.eq(reread.meta.sealBroken, true, "but the file it stamped is modified for good")
T.check(SaveData.save(reread, {}), "and re-saving it under a fresh session")
T.eq(SaveData.load("red").meta.sealBroken, true, "does not un-modify it")
local fresh = plainSave("FRESH")
T.check(SaveData.save(fresh, {}), "a save written while the session is unarmed")
T.eq(SaveData.load("red").meta.sealBroken, nil, "carries no stamp of its own")
end
-- ------- the durable per-slot broken mark
do
local files = {}
love.filesystem = memfs(files)
SaveData.resetSlotState()
GameVersion.set("red")
local first = SaveData.createCartSlot("kanto")
T.eq(first, "slot1", "a cart's first save slot")
T.eq(SaveData.slotSealBroken("kanto", first), false, "starts sealed")
T.eq(SaveData.listCartSlots("kanto")[1].sealBroken, false,
"which its launcher row reports without loading a save")
T.check(SaveData.markSlotSealBroken("kanto", first), "break that slot's seal")
T.eq(SaveData.slotSealBroken("kanto", first), true, "the slot reads as broken")
T.eq(SaveData.listCartSlots("kanto")[1].sealBroken, true,
"and the launcher row carries it")
SaveData.resetSlotState()
T.eq(SaveData.slotSealBroken("kanto", first), true,
"the mark survives a restart")
local second = SaveData.createCartSlot("kanto")
T.eq(SaveData.slotSealBroken("kanto", second), false,
"a new slot under the same cart starts sealed again")
T.eq(SaveData.clearSlotSealBroken, nil, "there is no public unset")
T.eq(SaveData.unmarkSlotSealBroken, nil, "under any spelling")
T.eq(SaveData.setSlotSealBroken, nil, "and no setter that takes a value")
T.check(SaveData.markSlotSealBroken("kanto", first, false),
"the setter takes no argument that clears")
T.eq(SaveData.slotSealBroken("kanto", first), true, "so the mark stands")
T.eq(SaveData.markSlotSealBroken("kanto", "slot9"), false,
"a slot that is not registered cannot be marked")
SaveData.setCart("kanto", "hash9")
SaveData.setActiveCartSlot("kanto", second)
T.eq(SaveData.adoptCartSeal("kanto"), false,
"booting an unmarked slot leaves the session sealed")
T.eq(SaveData.isSealBroken(), false, "so the loader still enforces the cart")
SaveData.setActiveCartSlot("kanto", first)
T.eq(SaveData.adoptCartSeal("kanto"), true,
"booting the marked slot breaks the seal for the session")
T.eq(SaveData.isSealBroken(), true, "which is what the loader reads")
SaveData.deleteCartSlot("kanto", first)
T.eq(SaveData.slotSealBroken("kanto", first), false,
"deleting the playthrough takes its mark with it")
end
-- ------- a marked slot loads the cart's pins first, then the player's mods
do
local files = install()
writeCart(files, cartTable("marked", "sealed",
{ pin("beta", "1.0.0", { tint = "cart" }), pin("delta", "2.0.0") },
{ "beta", "delta" }))
love.filesystem = memfs(files)
SaveData.resetSlotState()
GameVersion.set("red")
SaveData.setCart("marked", "hash10")
local slot = SaveData.createCartSlot("marked")
SaveData.setActiveCartSlot("marked", slot)
local intact, _, intactOk = boot(files)
T.check(not intactOk, "an unmarked slot still refuses the missing pin")
T.eq(intact:cartStatus().refused, true, "with the refusal on its report")
SaveData.resetSlotState()
SaveData.setCart("marked", "hash10")
T.check(SaveData.markSlotSealBroken("marked", slot), "mark that slot broken")
T.check(SaveData.adoptCartSeal("marked"), "boot adopts the mark")
local loader, data, ok = boot(files)
T.check(ok, "and the cart loads")
T.eq(names(loader.order), "beta,alpha,gamma",
"the cart's pins load first, then the player's own enabled mods")
T.eq(data.pokemon.BETA.name, "player",
"with the player's own option values back")
T.eq(loader:cartStatus().broken, true, "the report says the seal is broken")
local stamped = plainSave("BROKEN")
T.check(SaveData.save(stamped, {}), "a save written under the adopted mark")
T.eq(SaveData.load("red").meta.sealBroken, true,
"carries the save's own permanent stamp")
end
love.filesystem = realFS
T.finish("cart_seal")
+393
View File
@@ -0,0 +1,393 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
if not rawget(_G, "bit") and not rawget(_G, "bit32") then
local ok, bit32 = pcall(require, "bit32")
if ok then _G.bit32 = bit32 end
end
local T = require("tests.harness")
love = love or require("tests.love_stub")
local Base64 = require("src.core.Base64")
local SaveData = require("src.core.SaveData")
local CartManifest = require("src.carts.CartManifest")
local CartStore = require("src.carts.CartStore")
local SHA = ("a1b2c3d4"):rep(8)
local SHA2 = ("beefcafe"):rep(8)
local MD5 = ("0123456789abcdef"):rep(2)
local function memfs()
local files, dirs = {}, {}
local fs
fs = {
files = files,
write = function(path, body) files[path] = body return true end,
read = function(path) return files[path] end,
remove = function(path) files[path] = nil return true end,
createDirectory = function(path) dirs[path] = true return true end,
getInfo = function(path)
if files[path] ~= nil then return { type = "file" } end
if dirs[path] then return { type = "directory" } end
for name in pairs(files) do
if name:sub(1, #path + 1) == path .. "/" then return { type = "directory" } end
end
return nil
end,
getDirectoryItems = function(path)
local prefix = (path == "" or path == nil) and "" or (path .. "/")
local out, seen = {}, {}
for name in pairs(files) do
if name:sub(1, #prefix) == prefix then
local child = name:sub(#prefix + 1):match("^([^/]+)")
if child and not seen[child] then
seen[child] = true
out[#out + 1] = child
end
end
end
table.sort(out)
return out
end,
}
return fs
end
local function cartTable(over)
local tbl = {
id = "kanto_plus",
title = "Kanto Plus",
version = "1.2.0",
author = "Ren",
shell = "#3fa9f5",
base = "red",
seal = "sealed",
mods = {
{ id = "rare_soda", source = "github", repo = "ren/rare-soda",
version = "0.4.1", sha256 = SHA,
options = { flavour = "grape", sweetness = 3 } },
{ id = "hard_mode", source = "gamebanana", mod = 4821, file = 99123,
md5 = MD5 },
},
}
for key, value in pairs(over or {}) do tbl[key] = value end
return tbl
end
local function bytesOf(over)
local cart, err = CartManifest.parse(cartTable(over))
if not cart then error("fixture does not parse: " .. tostring(err)) end
return CartManifest.encode(cart), cart
end
local fs = memfs()
local bytes, fixture = bytesOf()
local installed, hash = CartStore.install(bytes, fs)
T.check(installed ~= nil, "a good cart installs: " .. tostring(hash))
T.eq(installed.id, "kanto_plus", "install returns the parsed cart")
T.eq(hash, CartManifest.hash(fixture), "install returns the cart hash")
T.check(fs.files["carts/kanto_plus.g1rcart"] ~= nil,
"install writes carts/<id>.g1rcart")
local reg = SaveData.loadOptions(fs).carts
T.check(type(reg) == "table" and type(reg.kanto_plus) == "table",
"install registers the cart in options.carts")
T.eq(reg.kanto_plus.title, "Kanto Plus", "the registry carries the title")
T.eq(reg.kanto_plus.base, "red", "the registry carries the base game")
T.eq(reg.kanto_plus.version, "1.2.0", "the registry carries the cart version")
T.eq(reg.kanto_plus.hash, hash, "the registry carries the cart hash")
T.eq(reg.kanto_plus.file, "carts/kanto_plus.g1rcart",
"the registry names the cart file")
local index = CartStore.index(fs)
T.eq(#index, 1, "index lists the registry without reading the files")
T.eq(index[1].base, "red", "an index row carries the base game")
local rows = CartStore.list(fs)
T.eq(#rows, 1, "list returns the installed cart")
T.eq(rows[1].id, "kanto_plus", "the row names the cart")
T.eq(rows[1].base, "red", "the row carries the base")
T.eq(rows[1].cartHash, hash, "the row carries the cart hash")
T.check(type(rows[1].cart) == "table" and rows[1].cart.mods ~= nil,
"the row carries the parsed cart")
T.eq(rows[1].cart.mods[1].sha256, SHA, "the parsed cart keeps its pins")
local got, gotHash = CartStore.get("kanto_plus", fs)
T.check(got ~= nil, "get returns the cart")
T.eq(gotHash, hash, "get returns the cart hash")
T.same(got, fixture, "get returns exactly what was installed")
T.eq(CartStore.get("nothing_here", fs), nil, "get refuses an unknown id")
T.eq(CartStore.get("../etc/passwd", fs), nil, "get refuses a climbing id")
local exported, exportHash = CartStore.export("kanto_plus", fs)
T.check(type(exported) == "string", "export hands back bytes")
T.eq(exportHash, hash, "export reports the cart hash")
T.same(CartManifest.decode(exported), fixture, "the exported bytes decode back")
T.eq(CartStore.export("nothing_here", fs), nil, "export refuses an unknown id")
local blueBytes = bytesOf({ id = "johto_lite", title = "Aaa Johto Lite",
base = "blue" })
T.check(CartStore.install(blueBytes, fs) ~= nil, "a blue cart installs")
T.eq(#CartStore.list(fs), 2, "list returns both carts")
T.eq(CartStore.list(fs)[1].id, "johto_lite", "list sorts by title")
T.eq(#CartStore.listFor("red", fs), 1, "listFor red returns one cart")
T.eq(CartStore.listFor("red", fs)[1].id, "kanto_plus", "listFor red picks the red cart")
T.eq(#CartStore.listFor("blue", fs), 1, "listFor blue returns one cart")
T.eq(#CartStore.listFor("yellow", fs), 0, "listFor yellow returns nothing")
local newer, newerErr = CartStore.install(
bytesOf({ version = "1.3.0", title = "Kanto Plus" }), fs)
T.check(newer ~= nil, "a newer cart replaces the installed one: " .. tostring(newerErr))
T.eq(CartStore.get("kanto_plus", fs).version, "1.3.0",
"the newer version is what is installed")
T.eq(#CartStore.list(fs), 2, "replacing does not add a second row")
T.eq(SaveData.loadOptions(fs).carts.kanto_plus.version, "1.3.0",
"the registry follows the replacement")
local same, sameErr = CartStore.install(bytesOf({ version = "1.3.0" }), fs)
T.check(same ~= nil, "the same version reinstalls: " .. tostring(sameErr))
local older, olderErr = CartStore.install(bytesOf({ version = "1.1.0" }), fs)
T.eq(older, nil, "an older cart is refused")
T.check(type(olderErr) == "string" and olderErr:find("older", 1, true) ~= nil,
"the refusal says why (got " .. tostring(olderErr) .. ")")
T.eq(CartStore.get("kanto_plus", fs).version, "1.3.0",
"the refused install leaves the newer cart in place")
T.eq(CartStore.install("return { }", fs), nil, "install refuses an untagged file")
T.eq(CartStore.install(nil, fs), nil, "install refuses a non-string")
T.eq(CartStore.install("\1\2\3 not lua", fs), nil, "install refuses noise")
fs.files["saves/cart_kanto_plus/slot1.lua"] = "return { player = { name = \"RED\" } }"
local opts = SaveData.loadOptions(fs)
opts.cartSlots = { kanto_plus = { list = { "slot1" }, active = "slot1" } }
SaveData.saveOptions(opts, fs)
T.check(CartStore.uninstall("kanto_plus", fs), "uninstall reports success")
T.eq(fs.files["carts/kanto_plus.g1rcart"], nil, "uninstall removes the cart file")
T.eq(SaveData.loadOptions(fs).carts.kanto_plus, nil,
"uninstall clears the registry entry")
T.eq(#CartStore.list(fs), 1, "the uninstalled cart is gone from the list")
T.check(fs.files["saves/cart_kanto_plus/slot1.lua"] ~= nil,
"uninstall leaves the cart's save file alone")
local slots = SaveData.loadOptions(fs).cartSlots
T.check(type(slots) == "table" and type(slots.kanto_plus) == "table",
"uninstall leaves the cart's slot registry alone")
T.eq(slots.kanto_plus.active, "slot1", "the active slot survives an uninstall")
local gone, goneErr = CartStore.uninstall("kanto_plus", fs)
T.eq(gone, nil, "uninstalling twice is refused")
T.check(type(goneErr) == "string" and goneErr:find("not installed", 1, true) ~= nil,
"the second uninstall says why (got " .. tostring(goneErr) .. ")")
T.eq(CartStore.uninstall("../etc/passwd", fs), nil, "uninstall refuses a climbing id")
T.check(CartStore.install(bytes, fs) ~= nil, "the cart reinstalls after removal")
T.same(CartStore.get("kanto_plus", fs), fixture, "reinstalling restores the cart")
T.check(fs.files["saves/cart_kanto_plus/slot1.lua"] ~= nil,
"the old playthrough is still there for the reinstalled cart")
fs.files["carts/kanto_plus.g1rcart"] = "return { format = \"nonsense\" }"
local damaged = CartStore.list(fs)
T.eq(#damaged, 1, "a corrupt cart file is skipped and the rest still list")
T.eq(damaged[1].id, "johto_lite", "the healthy cart survives a corrupt sibling")
T.check(fs.files["carts/kanto_plus.g1rcart"] ~= nil,
"listing never deletes the file it could not read")
T.eq(CartStore.get("kanto_plus", fs), nil, "get reports the corrupt cart as unreadable")
fs.files["carts/kanto_plus.g1rcart"] = nil
opts = SaveData.loadOptions(fs)
opts.carts = opts.carts or {}
opts.carts.ghost = { id = "ghost", title = "Ghost", base = "red",
version = "1.0.0", file = "carts/ghost.g1rcart" }
SaveData.saveOptions(opts, fs)
local haunted = CartStore.list(fs)
T.eq(#haunted, 1, "a registry entry with no file is skipped")
T.eq(haunted[1].id, "johto_lite", "the rest of the list still comes back")
T.eq(SaveData.loadOptions(fs).carts.ghost, nil,
"listing prunes the registry entry whose file is gone")
local strayCart = select(2, bytesOf({ id = "wanderer", title = "Zzz Wanderer" }))
fs.files["carts/wanderer.g1rcart"] = CartManifest.encode(strayCart)
local adopted = CartStore.list(fs)
T.eq(#adopted, 2, "a cart file with no registry entry is still listed")
T.eq(adopted[2].id, "wanderer", "the stray cart sorts in by title")
T.eq(adopted[2].cartHash, CartManifest.hash(strayCart),
"the stray cart is hashed from its own file")
T.check(SaveData.loadOptions(fs).carts.wanderer ~= nil,
"listing registers the stray cart it adopted")
fs.files["carts/readme.txt"] = "hello"
fs.files["carts/half written.g1rcart"] = "return { }"
T.eq(#CartStore.list(fs), 2, "junk in carts/ is ignored")
local empty = memfs()
T.eq(#CartStore.list(empty), 0, "a fresh install lists no carts")
T.eq(#CartStore.listFor("red", empty), 0, "listFor is empty on a fresh install")
local function rowSet()
return {
{ id = "hard_mode", name = "Hard Mode", version = "2.0.0", enabled = true,
github = "ren/hard-mode",
manifest = { id = "hard_mode", version = "2.0.0",
github = "ren/hard-mode", sha256 = SHA } },
{ id = "off_mode", name = "Off Mode", version = "1.0.0", enabled = false,
github = "ren/off-mode",
manifest = { id = "off_mode", version = "1.0.0",
github = "ren/off-mode", sha256 = SHA2 } },
{ id = "rare_soda", name = "Rare Soda", version = "0.4.1", enabled = true,
github = "ren/rare-soda",
manifest = { id = "rare_soda", version = "0.4.1",
github = "ren/rare-soda" } },
{ id = "sprite_pack", name = "Sprite Pack", version = "beta", enabled = true,
manifest = { id = "sprite_pack", version = "beta" } },
}
end
local identity = { id = "my_cart", title = "My Cart", version = "0.1.0",
author = "Ren", base = "red", shell = "#FF8800",
seal = "open", summary = "Built in the launcher" }
local modOptions = {
rare_soda = { flavour = "grape", sweetness = 3, nested = { 1, 2 } },
off_mode = { unused = true },
}
local captured, unresolved = CartStore.capture(identity, rowSet(), modOptions)
T.check(captured ~= nil, "capture builds a cart: " .. tostring(unresolved))
T.eq(captured.id, "my_cart", "the captured cart keeps the identity id")
T.eq(captured.title, "My Cart", "the captured cart keeps the title")
T.eq(captured.shell, "#ff8800", "the captured shell normalises")
T.eq(captured.seal, "open", "the captured seal is the author's choice")
T.eq(captured.base, "red", "the captured base is the identity's")
T.eq(#captured.mods, 3, "only the enabled mods are pinned")
T.eq(captured.load_order[1], "hard_mode", "load order follows the row order")
T.eq(captured.load_order[2], "rare_soda", "load order follows the row order")
T.eq(captured.load_order[3], "sprite_pack", "load order follows the row order")
for _, entry in ipairs(captured.mods) do
T.neq(entry.id, "off_mode", "a disabled mod is never pinned")
end
T.eq(captured.mods[1].source, "github", "a mod with repo, version and hash pins to github")
T.eq(captured.mods[1].repo, "ren/hard-mode", "the github pin keeps the repo")
T.eq(captured.mods[1].sha256, SHA, "the github pin keeps the recorded hash")
T.eq(captured.mods[2].source, "local", "a mod with no archive hash pins locally")
T.eq(captured.mods[2].version, "0.4.1", "the local pin keeps the installed version")
T.eq(captured.mods[2].repo, nil, "a local pin carries no repo")
T.eq(captured.mods[2].sha256, nil, "a local pin carries no hash")
T.eq(captured.mods[2].options.flavour, "grape", "the author's option values are frozen in")
T.eq(captured.mods[2].options.sweetness, 3, "every scalar option is frozen in")
T.eq(captured.mods[2].options.nested, nil, "a table option value is dropped")
T.eq(captured.mods[3].source, "local", "a mod with no repo pins locally")
T.eq(captured.mods[3].version, "0.0.0", "an unparsable version pins as 0.0.0")
T.eq(captured.mods[1].options, nil, "a mod with no options freezes none")
T.eq(#unresolved, 2, "capture reports every locally pinned mod")
T.eq(unresolved[1].id, "rare_soda", "the first unresolved mod is named")
T.eq(unresolved[1].name, "Rare Soda", "the unresolved row carries the mod name")
T.check(unresolved[1].reason:find("archive hash", 1, true) ~= nil,
"a missing hash is the reason (got " .. tostring(unresolved[1].reason) .. ")")
T.eq(unresolved[2].id, "sprite_pack", "the second unresolved mod is named")
T.check(unresolved[2].reason:find("GitHub repo", 1, true) ~= nil,
"a missing repo is a reason (got " .. tostring(unresolved[2].reason) .. ")")
T.check(unresolved[2].reason:find("semantic version", 1, true) ~= nil,
"an unpinnable version is a reason (got " .. tostring(unresolved[2].reason) .. ")")
local publishable, why = CartManifest.publishable(captured)
T.eq(publishable, false, "a captured cart with local pins cannot be published")
T.check(why:find("rare_soda", 1, true) ~= nil, "the reason names rare_soda")
T.check(why:find("sprite_pack", 1, true) ~= nil, "the reason names sprite_pack")
T.check(why:find("hard_mode", 1, true) == nil, "the reason leaves the pinned mod out")
local storeFs = memfs()
local roundTrip, roundHash = CartStore.install(CartManifest.encode(captured), storeFs)
T.check(roundTrip ~= nil, "a captured cart installs: " .. tostring(roundHash))
T.same(roundTrip, captured, "a captured cart survives the file round trip")
T.eq(roundHash, CartManifest.hash(captured), "a captured cart hashes the same on disk")
local pinned = rowSet()
pinned[3].manifest.sha256 = SHA2
pinned[4] = nil
local full, fullUnresolved = CartStore.capture(identity, pinned, modOptions)
T.check(full ~= nil, "a fully pinned capture builds a cart")
T.eq(#fullUnresolved, 0, "a fully pinned capture reports nothing unresolved")
T.eq(full.mods[2].source, "github", "a recorded hash promotes the pin to github")
T.eq(full.mods[2].sha256, SHA2, "the promoted pin uses the recorded hash")
T.eq(CartManifest.publishable(full), true, "a fully pinned cart is publishable")
local noMods, noModsErr = CartStore.capture(identity, { rowSet()[2] }, modOptions)
T.eq(noMods, nil, "a capture with nothing enabled is refused")
T.check(type(noModsErr) == "string" and noModsErr:find("cart must pin", 1, true) ~= nil,
"the empty capture says why (got " .. tostring(noModsErr) .. ")")
T.eq(CartStore.capture({ id = "bad id" }, rowSet(), modOptions), nil,
"a capture with a bad identity is refused")
T.eq(CartStore.capture(nil, rowSet(), modOptions), nil,
"a capture with no identity is refused")
local PNG = CartManifest.PNG_SIGNATURE .. "\0\0\0\13IHDRa cart label"
local ART_DATA = Base64.encode(PNG)
local function artedCart()
local plain = select(2, bytesOf({ id = "art_cart", title = "Art Cart",
label = "label.png" }))
plain.labelArt = { name = "label.png", encoding = "base64", bytes = #PNG,
data = ART_DATA }
return plain
end
local artFs = memfs()
local artFixture = artedCart()
local packed = CartManifest.encode(artFixture)
local artInstalled, artHash = CartStore.install(packed, artFs)
T.check(artInstalled ~= nil, "a cart with label art installs: " .. tostring(artHash))
T.eq(artInstalled.labelArt.data, ART_DATA, "install returns the cart with its art")
T.check(artFs.files["carts/art_cart.g1rcart"]:find(ART_DATA, 1, true) ~= nil,
"install writes the art payload to the cart file")
T.same(CartStore.get("art_cart", artFs), artFixture,
"the installed cart reads back with its art")
local artless = select(2, bytesOf({ id = "art_cart", title = "Art Cart",
label = "label.png" }))
T.eq(artHash, CartManifest.hash(artless),
"the art is not part of the hash a save pins itself to")
local artExported, artExportHash = CartStore.export("art_cart", artFs)
T.eq(artExportHash, artHash, "export reports the same hash for an arted cart")
T.eq(artExported, packed, "export hands back the bytes that were packed")
local reread = CartManifest.decode(artExported)
T.same(reread, artFixture, "the exported bytes decode to the same cart and art")
T.eq(reread.labelArt.data, ART_DATA, "the exported payload is byte identical")
local sharedFs = memfs()
T.check(CartStore.install(artExported, sharedFs) ~= nil,
"an exported cart installs somewhere else")
T.same(CartStore.get("art_cart", sharedFs), artFixture,
"pack, install, export and install again leaves the cart unchanged")
local shownBytes, shownName = CartStore.labelArt("art_cart", sharedFs)
T.eq(shownBytes, PNG, "labelArt hands back the decoded PNG")
T.eq(shownName, "label.png", "labelArt hands back the art name")
T.eq(CartStore.labelArt("nothing_here", sharedFs), nil,
"labelArt refuses an unknown id")
T.eq(CartStore.labelArt("../etc/passwd", sharedFs), nil,
"labelArt refuses a climbing id")
local plainFs = memfs()
T.check(CartStore.install(CartManifest.encode(artless), plainFs) ~= nil,
"a cart with no art still installs")
T.eq(CartStore.labelArt("art_cart", plainFs), nil,
"a cart with no art has no label art")
T.same(CartStore.get("art_cart", plainFs), artless,
"a cart with no art round trips exactly as before")
local tamperedBytes = (packed:gsub("bytes = " .. #PNG, "bytes = " .. (#PNG + 1), 1))
T.neq(tamperedBytes, packed, "the tampered bundle really changed")
local tamperedFs = memfs()
local tampered, tamperedErr = CartStore.install(tamperedBytes, tamperedFs)
T.check(tampered ~= nil, "bad art never fails the install: " .. tostring(tamperedErr))
T.eq(tampered.labelArt, nil, "the bad art is dropped")
T.eq(CartStore.labelArt("art_cart", tamperedFs), nil,
"the installed cart shows no art")
T.eq(tamperedFs.files["carts/art_cart.g1rcart"], CartManifest.encode(artless),
"the bad art is not written back to disk")
T.eq(#CartStore.list(tamperedFs), 1, "the cart still lists without its art")
T.finish("cart_store")