mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-25 23:11:15 +02:00
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:
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user