The new experience (#201)

* new launcher and save converts and pipeline

* fixing bugs
This commit is contained in:
bryanthaboi
2026-07-25 12:36:53 -04:00
committed by GitHub
parent 6625391f76
commit 3069b2e2a9
135 changed files with 16596 additions and 1508 deletions
+193
View File
@@ -0,0 +1,193 @@
-- Love-free coverage for the pure halves of src/mods/LauncherMods.lua: the
-- status derivation (deriveList) over a synthetic manifest list + options
-- table, and the archive-root location logic (locateRoot). The discovery and
-- installZip paths need love.filesystem and are exercised by the launcher; the
-- decision logic under them lives here so a bad range/conflict/root call fails
-- one line instead of the app.
-- luajit tests/engine/launcher_mods_tests.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
local Manifest = require("src.mods.Manifest")
local Version = require("src.core.Version")
local LauncherMods = require("src.mods.LauncherMods")
-- validated manifests are the exact shape deriveList/resolveToggle read
local function mf(raw)
return Manifest.validate(raw)
end
-- index a deriveList result by mod id for assertions
local function byId(list)
local m = {}
for _, row in ipairs(list) do m[row.id] = row end
return m
end
-- ------- badge derivation: category, then profile, then MOD (uppercased)
do
local list = LauncherMods.deriveList({
mf({ id = "cat", name = "Cat Mod", version = "1.0.0", entry = "m.lua",
category = "gameplay" }),
mf({ id = "prof", name = "Prof Mod", version = "1.0.0", entry = "m.lua",
profile = "overhaul" }),
mf({ id = "plain", name = "Plain", version = "1.0.0", entry = "m.lua" }),
}, { mods = {} })
local m = byId(list)
eq(m.cat.badge, "GAMEPLAY", "badge uses the manifest category, uppercased")
eq(m.prof.badge, "OVERHAUL", "badge falls back to the profile when no category")
-- no category field, so the fallback reaches the profile default ("content")
eq(m.plain.badge, "CONTENT", "bare manifest badge falls back to the profile")
eq(#list, 3, "every discovered manifest yields one row")
check(m.cat.id < m.plain.id and m.plain.id < m.prof.id,
"rows come back sorted by id (cat < plain < prof)")
end
-- ------- enabled defaults to true; a false entry disables
do
local manifests = {
mf({ id = "aaa", name = "A", version = "1.0.0", entry = "m.lua" }),
mf({ id = "bbb", name = "B", version = "1.0.0", entry = "m.lua" }),
}
local m = byId(LauncherMods.deriveList(manifests, { mods = { bbb = false } }))
check(m.aaa.enabled, "a mod with no options entry defaults to enabled")
check(not m.bbb.enabled, "an explicit false disables the mod")
eq(m.aaa.status, "ok", "a healthy enabled mod is ok")
eq(m.aaa.statusDetail, "Ready", "ok detail reads Ready")
end
-- ------- conflict: only when this mod is enabled and the other is too
do
local manifests = {
mf({ id = "alpha", name = "Alpha", version = "1.0.0", entry = "m.lua",
conflicts = { "beta" } }),
mf({ id = "beta", name = "Beta", version = "1.0.0", entry = "m.lua" }),
}
-- both enabled: the declaring side (and, symmetrically, the other) conflict
local both = byId(LauncherMods.deriveList(manifests, { mods = {} }))
eq(both.alpha.status, "conflict", "enabled mod conflicting with an enabled mod")
check(both.alpha.statusDetail:find("Beta", 1, true) ~= nil,
"conflict detail names the other mod")
eq(both.beta.status, "conflict",
"resolveToggle conflict is bidirectional: the target is flagged too")
-- disable beta: alpha no longer conflicts (nothing enabled to conflict with)
local off = byId(LauncherMods.deriveList(manifests, { mods = { beta = false } }))
eq(off.alpha.status, "ok", "no conflict once the other side is disabled")
eq(off.beta.status, "ok", "a disabled mod is never a conflict")
end
-- ------- warn: unsatisfied game_version range against Version.engine
do
-- a range the -dev engine cannot satisfy (needs a released >=1.0.0)
local manifests = {
mf({ id = "future", name = "Future", version = "1.0.0", entry = "m.lua",
game_version = ">=1.0.0" }),
}
local m = byId(LauncherMods.deriveList(manifests, { mods = {} }))
eq(m.future.status, "warn", "engine outside the game_version range warns")
check(m.future.statusDetail:find(">=1.0.0", 1, true) ~= nil,
"version warn detail quotes the required range")
check(m.future.statusDetail:find(Version.engine, 1, true) ~= nil,
"version warn detail quotes the engine version")
end
-- ------- warn: hard dependency missing, disabled, or wrong version
do
local base = { id = "base", name = "Base", version = "1.0.0", entry = "m.lua" }
local needsMissing = { id = "needy", name = "Needy", version = "1.0.0",
entry = "m.lua", dependencies = { "ghost" } }
local m = byId(LauncherMods.deriveList({ mf(needsMissing) }, { mods = {} }))
eq(m.needy.status, "warn", "a missing hard dependency warns")
check(m.needy.statusDetail:find("not installed", 1, true) ~= nil,
"missing-dep detail says not installed")
-- present but disabled
local m2 = byId(LauncherMods.deriveList(
{ mf(base), mf({ id = "needy", name = "Needy", version = "1.0.0",
entry = "m.lua", dependencies = { "base" } }) },
{ mods = { base = false } }))
eq(m2.needy.status, "warn", "a disabled hard dependency warns")
check(m2.needy.statusDetail:find("disabled", 1, true) ~= nil,
"disabled-dep detail says disabled")
-- present, enabled, but the version is out of range
local m3 = byId(LauncherMods.deriveList(
{ mf(base), mf({ id = "needy", name = "Needy", version = "1.0.0",
entry = "m.lua", dependencies = { "base@>=2.0.0" } }) },
{ mods = {} }))
eq(m3.needy.status, "warn", "a dependency below the required range warns")
eq(m3.base.status, "ok", "the satisfied dependency itself stays ok")
-- the same dep satisfied: needy is ok
local m4 = byId(LauncherMods.deriveList(
{ mf(base), mf({ id = "needy", name = "Needy", version = "1.0.0",
entry = "m.lua", dependencies = { "base@>=1.0.0" } }) },
{ mods = {} }))
eq(m4.needy.status, "ok", "a satisfied dependency clears the warn")
end
-- ------- conflict outranks warn when a mod trips both
do
local manifests = {
mf({ id = "alpha", name = "Alpha", version = "1.0.0", entry = "m.lua",
conflicts = { "beta" }, game_version = ">=1.0.0" }),
mf({ id = "beta", name = "Beta", version = "1.0.0", entry = "m.lua" }),
}
local m = byId(LauncherMods.deriveList(manifests, { mods = {} }))
eq(m.alpha.status, "conflict",
"conflict is reported ahead of a version warn on the same mod")
end
-- ------- locateRoot: manifest at the archive root
do
local root, err = LauncherMods.locateRoot({ "manifest.json", "main.lua" })
eq(root, "", "a root-level manifest.json resolves to the empty prefix")
eq(err, nil, "no error for a root-level manifest")
end
-- ------- locateRoot: manifest inside a single top-level folder
do
local root = LauncherMods.locateRoot({
"mymod/manifest.json", "mymod/main.lua", "mymod/assets/x.png" })
eq(root, "mymod", "a single wrapping folder resolves to that folder name")
end
-- ------- locateRoot: no manifest anywhere
do
local root, err = LauncherMods.locateRoot({ "readme.txt", "stuff/x.lua" })
eq(root, nil, "an archive with no manifest.json resolves to nil")
check(err:find("no manifest.json", 1, true) ~= nil,
"the no-manifest reason is user-presentable")
end
-- ------- locateRoot: multiple top-level folders is ambiguous
do
local root, err = LauncherMods.locateRoot({
"one/manifest.json", "two/manifest.json" })
eq(root, nil, "two candidate mod folders resolves to nil")
check(err:find("single mod folder", 1, true) ~= nil,
"the ambiguous reason asks for a single mod folder")
end
-- ------- locateRoot: a lone folder without a manifest is not a root
do
local root, err = LauncherMods.locateRoot({ "assets/x.png" })
eq(root, nil, "a single folder with no manifest is not a mod root")
check(err ~= nil, "the no-root case carries a reason")
end
T.finish("launcher_mods")
+33
View File
@@ -0,0 +1,33 @@
-- Bill's PC vs player's PC top-menu origin/size (#176).
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local T = require("tests.harness")
local Data = require("src.core.Data")
if not (Data.pokemon and next(Data.pokemon)) then Data:load() end
local SaveData = require("src.core.SaveData")
local BoxMenu = require("src.ui.BoxMenu")
local PlayerPC = require("src.ui.PlayerPC")
local game = {
data = Data,
save = SaveData.newGame(),
stack = { push = function() end },
}
local bills = BoxMenu.new(game)
local player = PlayerPC.new(game)
T.eq(bills.tx, 0, "Bill's PC TextBoxBorder at x=0")
T.eq(player.tx, 0, "Player's PC TextBoxBorder at x=0")
T.eq(player.ty, 0, "Player's PC at y=0")
T.eq(player.tw, 16, "Player's PC width (players_pc.asm c=$e +2)")
T.eq(player.th, 10, "Player's PC height (players_pc.asm b=$8 +2)")
T.check(player.tx == bills.tx and player.ty == bills.ty,
"both PC menus share the same top-left origin")
local labelTiles = 2 + #"WITHDRAW ITEM"
T.check(player.tx + labelTiles <= player.tx + player.tw - 1,
"WITHDRAW ITEM fits inside the Player's PC border")
T.finish("pc_menu_sides")
+92
View File
@@ -0,0 +1,92 @@
-- Headless regression: two Bill's PC releases in one list session (#171).
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local T = require("tests.harness")
local Data = require("src.core.Data")
if not (Data.pokemon and Data.pokemon.RATTATA) then Data:load() end
require("src.render.Font").load(Data)
local Pokemon = require("src.pokemon.Pokemon")
local Boxes = require("src.pokemon.Boxes")
local BoxMenu = require("src.ui.BoxMenu")
local ListMenu = require("src.ui.ListMenu")
local ChoiceBox = require("src.ui.ChoiceBox")
local SaveData = require("src.core.SaveData")
local Sound = require("src.core.Sound")
local realCry, realPlay = Sound.playCry, Sound.play
Sound.playCry = function() end
Sound.play = function() end
local stack = { states = {} }
function stack:push(s) self.states[#self.states + 1] = s end
function stack:pop()
local t = self.states[#self.states]
self.states[#self.states] = nil
return t
end
function stack:top() return self.states[#self.states] end
function stack:update(dt)
local t = self:top()
if t and t.update then t:update(dt) end
end
local pressed = {}
local game = {
data = Data,
save = SaveData.newGame(),
stack = stack,
input = {
wasPressed = function(_, key) return pressed[key] or false end,
isDown = function() return false end,
},
}
game.save.options = game.save.options or {}
game.save.options.textSpeed = 1
local box = Boxes.active(game.save)
box[1] = Pokemon.new(Data, "RATTATA", 5)
box[2] = Pokemon.new(Data, "PIDGEY", 6)
box[3] = Pokemon.new(Data, "CATERPIE", 4)
local function press(btn)
pressed = { [btn] = true }
stack:update(1 / 60)
pressed = {}
end
local function topMt() return getmetatable(stack:top()) end
local function mash(btn, cond, n)
for _ = 1, (n or 400) do
if cond() then return true end
press(btn)
end
return false
end
stack:push(BoxMenu.new(game))
press("down"); press("down"); press("a")
T.check(topMt() == ListMenu, "RELEASE opens the box list")
T.eq(#box, 3, "box still has 3 before releases")
local function releaseCurrent()
local before = #box
press("a")
T.check(mash("a", function() return topMt() == ChoiceBox end),
"confirm choice opens")
press("up") -- defaultNo -> YES
press("a")
T.check(mash("a", function() return topMt() == ListMenu end),
"returns to RELEASE list")
T.eq(#box, before - 1, "one mon removed from the box")
end
releaseCurrent()
releaseCurrent()
T.eq(#box, 1, "two releases leave one mon")
T.check(topMt() == ListMenu, "still on RELEASE list after the second")
T.eq(box[1].species, "CATERPIE", "remaining mon is the third seeded one")
Sound.playCry, Sound.play = realCry, realPlay
T.finish("pc_release")
+233
View File
@@ -0,0 +1,233 @@
-- Launcher save Import/Export glue (src/import/SaveFileIO.lua): the end-to-end
-- importToSlot -> listSlots roundtrip and the exportActiveSlot output-byte
-- sanity check, driven love-free through the same in-memory filesystem stub
-- tests/engine/save_slots.lua uses. A synthetic 32KB SRAM image is built via
-- GenSave.encode (no real save checked in); a fixture-gated case exercises the
-- real .sav when POKEPORT_SAV_FIXTURE points at one.
-- luajit tests/engine/save_file_io_tests.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")
local GenSave = require("src.save_convert.GenSave")
local SaveConvert = require("src.save_convert.SaveConvert")
local SaveData = require("src.core.SaveData")
local GameVersion = require("src.core.GameVersion")
local SaveFileIO = require("src.import.SaveFileIO")
local realFS = love.filesystem
-- A love.filesystem stub keyed by full path, extended past save_slots' memfs
-- with the export surface SaveFileIO reaches for (createDirectory /
-- getSaveDirectory). A directory key is implied by any file under it.
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
local prefix = path .. "/"
for key in pairs(files) do
if key:sub(1, #prefix) == prefix then return { type = "directory" } end
end
return nil
end,
createDirectory = function() return true end,
getSaveDirectory = function() return "/fake/save" end,
}
end
local function fresh()
local files = {}
love.filesystem = memfs(files)
SaveData.resetSlotState()
GameVersion.set("red")
return files
end
-- ---- crosswalk data + synthetic 32KB save (built the way the codec tests do)
GenSave.setCharmap(loadfile("src/save_convert/data/charmap.lua")())
local data = {
pokemon = loadfile("data/generated/pokemon.lua")(),
moves = loadfile("data/generated/moves.lua")(),
items = loadfile("data/generated/items.lua")(),
maps = loadfile("data/generated/maps.lua")(),
eventFlags = loadfile("src/save_convert/data/event_flags.lua")(),
}
-- independent checksum re-derivation (complement of the additive byte sum) so
-- the export sanity check does not trust the encoder that wrote it
local bit = require("bit")
local OFF = GenSave.OFFSETS
local function rawChecksum(bytes, from, to)
local sum = 0
for i = from, to - 1 do sum = bit.band(sum + bytes:byte(i + 1), 0xFF) end
return bit.band(bit.bnot(sum), 0xFF)
end
local function mainChecksumValid(bytes)
return rawChecksum(bytes, OFF.checksumStart, OFF.checksumEnd)
== bytes:byte(OFF.mainChecksum + 1)
end
local function syntheticSave(name)
local seed = SaveData.newGame({ playerName = name, rivalName = "BLUE" })
seed.money = 4321
seed.inventory = { POTION = 2, POKE_BALL = 7, BOULDERBADGE = 1 }
seed.bagOrder = { "POTION", "POKE_BALL" }
seed.party = { {
species = "SQUIRTLE", level = 6, exp = 200,
dvs = { hp = 1, attack = 2, defense = 3, speed = 4, special = 5 },
statExp = { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 },
stats = { hp = 22, attack = 12, defense = 13, speed = 11, special = 12 },
hp = 22, status = nil,
moves = { { id = "TACKLE", pp = 35, ppUps = 0 } },
nickname = "SQ", ot = name, otId = seed.player.id, catchRate = 45,
} }
return GenSave.encode(seed, data, nil)
end
-- ---------------------------------------------- importToSlot -> listSlots
do
fresh()
local bytes = syntheticSave("IMP")
eq(#bytes, GenSave.SAVE_SIZE, "the synthetic save is 32768 bytes")
local ok, slotId = SaveFileIO.importToSlot(bytes, "red")
eq(ok, true, "importToSlot succeeds on a valid 32KB save")
eq(slotId, "slot1", "the first import registers slot1")
local slots = SaveData.listSlots("red")
eq(#slots, 1, "the imported save shows up as exactly one slot")
eq(slots[1].id, "slot1", "the listed slot is slot1")
eq(slots[1].exists, true, "the imported slot reports a save present")
eq(slots[1].name, "IMP", "the imported slot surfaces the decoded player name")
eq(SaveData.activeSlot("red"), "slot1", "the imported slot is made active")
-- the slot loads cleanly (meta re-stamped from gen1_import to the numeric
-- format, so runMigrations does not choke)
local loaded = SaveData.load("red")
check(loaded ~= nil, "the imported slot loads back")
eq(loaded and loaded.player.name, "IMP", "loaded save keeps the player name")
eq(loaded and loaded.money, 4321, "loaded save keeps the money")
eq(loaded and #loaded.party, 1, "loaded save keeps the party")
-- a second import allocates a fresh slot and makes it active
local ok2, slot2 = SaveFileIO.importToSlot(syntheticSave("TWO"), "red")
eq(ok2, true, "a second import succeeds")
eq(slot2, "slot2", "the second import allocates slot2")
eq(#SaveData.listSlots("red"), 2, "both imported slots are listed")
eq(SaveData.activeSlot("red"), "slot2", "the newest import becomes active")
end
-- ---------------------------------------------- exportActiveSlot byte sanity
do
local files = fresh()
SaveFileIO.importToSlot(syntheticSave("EXP"), "red")
local ok, path = SaveFileIO.exportActiveSlot("red")
eq(ok, true, "exportActiveSlot succeeds for an active slot with a save")
eq(path, "/fake/save/exports/gen1recomp-red-slot1.sav",
"the export path is absolute and names the version + slot")
local outBytes = files["exports/gen1recomp-red-slot1.sav"]
check(outBytes ~= nil, "the export file lands in the save-dir exports/ folder")
eq(outBytes and #outBytes, GenSave.SAVE_SIZE, "the export is exactly 32768 bytes")
check(outBytes and mainChecksumValid(outBytes),
"the export carries a valid main-data checksum")
-- the export re-imports to an equivalent save
local re = SaveConvert.importSav(outBytes, "red")
check(re ~= nil, "the export re-imports through SaveConvert")
eq(re and re.player and re.player.name, "EXP", "the export round-trips the player name")
eq(re and re.party[1] and re.party[1].species, "SQUIRTLE",
"the export round-trips the party")
eq(re and re.inventory and re.inventory.BOULDERBADGE, 1,
"the export round-trips a badge")
end
-- ---------------------------------------------- failure UX (never raises)
do
fresh()
-- wrong size via a DroppedFile-shaped source (100 bytes)
local shortFile = {
_bytes = string.rep("\0", 100),
open = function() return true end,
getSize = function(self) return #self._bytes end,
read = function(self) return self._bytes end,
close = function() return true end,
}
local ok, err = SaveFileIO.importToSlot(shortFile, "red")
eq(ok, false, "a wrong-size save is rejected, not imported")
check(type(err) == "string" and err:find("32", 1, true) ~= nil,
"the wrong-size error names the required size")
eq(#SaveData.listSlots("red"), 0, "a rejected import creates no slot")
-- bad checksum: flip a modeled byte in an otherwise valid image
local good = syntheticSave("BAD")
local corrupt = good:sub(1, OFF.money)
.. string.char((good:byte(OFF.money + 1) + 1) % 256)
.. good:sub(OFF.money + 2)
local okc, errc = SaveFileIO.importToSlot(corrupt, "red")
eq(okc, false, "a bad-checksum save is rejected")
check(type(errc) == "string" and errc:find("checksum", 1, true) ~= nil,
"the bad-checksum error mentions the checksum")
eq(#SaveData.listSlots("red"), 0, "a rejected checksum creates no slot")
-- export with nothing to export
local oke, erre = SaveFileIO.exportActiveSlot("red")
eq(oke, false, "exportActiveSlot fails cleanly when there is no save")
check(type(erre) == "string", "the empty-export failure carries a message")
end
-- ---------------------------------------------- fixture-gated real save
do
local fixturePath = os.getenv("POKEPORT_SAV_FIXTURE")
local fixtureBytes
if fixturePath then
local ff = io.open(fixturePath, "rb")
if ff then
fixtureBytes = ff:read("*a")
ff:close()
if #fixtureBytes ~= GenSave.SAVE_SIZE then fixtureBytes = nil end
end
end
if not fixtureBytes then
print("save_file_io fixture case skipped (set POKEPORT_SAV_FIXTURE to a 32KB .sav)")
else
local files = fresh()
local ok, slotId = SaveFileIO.importToSlot(fixtureBytes, "red")
eq(ok, true, "fixture: a real .sav imports to a slot")
check(slotId ~= nil, "fixture: the import returns a slot id")
local slots = SaveData.listSlots("red")
eq(#slots, 1, "fixture: the real save shows as one slot")
check(slots[1].exists and type(slots[1].name) == "string" and #slots[1].name > 0,
"fixture: the imported slot has a non-empty player name")
local loaded = SaveData.load("red")
check(loaded ~= nil and #loaded.party >= 1 and #loaded.party <= 6,
"fixture: the imported slot loads with a 1..6 party")
local eok, path = SaveFileIO.exportActiveSlot("red")
eq(eok, true, "fixture: the imported real save exports")
local rel = path:gsub("^/fake/save/", "")
local outBytes = files[rel]
eq(outBytes and #outBytes, GenSave.SAVE_SIZE, "fixture: the export is 32768 bytes")
check(outBytes and mainChecksumValid(outBytes),
"fixture: the export has a valid main-data checksum")
end
end
love.filesystem = realFS
T.finish("save_file_io")
+211
View File
@@ -0,0 +1,211 @@
-- Save-slot backend (src/core/SaveData.lua): legacy migration, the slot
-- registry in options.lua, listSlots/setActiveSlot/createSlot, and the
-- active-slot resolution behind saveNames/save/load. Self-contained: it
-- installs the love stub only for a swappable in-memory filesystem, the
-- same way tests/mod_save_tests isolates its save round-trips.
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
-- an isolated love.filesystem: keys are full paths, so "saves/red/slot1.lua"
-- needs no directory support (createDirectory is deliberately absent, which
-- is exactly what the ensureParentDir no-op path handles)
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
-- a fresh filesystem + cleared process globals: each scenario is a first boot
local function fresh()
local files = {}
love.filesystem = memfs(files)
SaveData.resetSlotState()
GameVersion.set("red")
return files
end
-- a minimal but fully decodable Red save
local function legacySave(name, dexOwned, badges, playTime)
local owned = {}
for _, id in ipairs(dexOwned or {}) do owned[id] = true end
local inv = {}
for _, id in ipairs(badges or {}) do inv[id] = true end
return {
version = "red",
player = { name = name, map = "PALLET_TOWN", x = 1, y = 1 },
pokedex = { seen = {}, owned = owned },
inventory = inv,
playTime = playTime or 0,
}
end
-- ---------------------------------------------- slotSummary (pure)
do
local name, meta = SaveData.slotSummary(
legacySave("ASH", { "PIKACHU", "PIDGEY", "RATTATA" },
{ "BOULDERBADGE", "CASCADEBADGE" }, 3661))
T.eq(name, "ASH", "slotSummary reads the player name")
T.eq(meta.dexCount, 3, "slotSummary counts owned dex entries")
T.eq(meta.badges, 2, "slotSummary counts vanilla badges from inventory")
T.eq(meta.timeText, "1:01", "slotSummary formats playTime as H:MM")
local n2, m2 = SaveData.slotSummary(nil)
T.eq(n2, nil, "slotSummary of an empty slot has no name")
T.eq(m2, nil, "slotSummary of an empty slot has no meta")
end
-- ---------------------------------------------- legacy migration happy path
do
local files = fresh()
files["save.lua"] = SaveSerializer.encode(
legacySave("RED", { "BULBASAUR", "CHARMANDER" }, { "BOULDERBADGE" }, 7325))
local slots = SaveData.listSlots("red")
T.eq(#slots, 1, "legacy save migrates into exactly one slot")
T.eq(slots[1].id, "slot1", "the migrated slot is slot1")
T.eq(slots[1].exists, true, "the migrated slot reports a save present")
T.eq(slots[1].name, "RED", "the migrated slot surfaces the player name")
T.eq(slots[1].meta.badges, 1, "migrated slot meta carries the badge count")
T.eq(slots[1].meta.dexCount, 2, "migrated slot meta carries the dex count")
T.eq(slots[1].meta.timeText, "2:02", "migrated slot meta carries the time")
T.eq(files["save.lua"], nil, "the flat legacy file is removed after migration")
T.check(files["saves/red/slot1.lua"] ~= nil, "the slot file now holds the save")
local opts = SaveSerializer.decode(files["options.lua"])
T.eq(opts.saveSlots.red.active, "slot1", "options registers slot1 as active")
T.eq(opts.saveSlots.red.list[1], "slot1", "options lists the migrated slot")
-- load() now resolves the active slot and reads the migrated save
local loaded = SaveData.load("red")
T.check(loaded and loaded.player.name == "RED", "load reads the active slot")
end
-- ---------------------------------------------- migration idempotence
do
local files = fresh()
files["save.lua"] = SaveSerializer.encode(legacySave("ONCE", { "MEW" }, {}, 0))
SaveData.listSlots("red") -- first boot: migrates
local slotBytes = files["saves/red/slot1.lua"]
-- a second boot: registry exists, no flat file, so nothing re-migrates
SaveData.resetSlotState()
local slots = SaveData.listSlots("red")
T.eq(#slots, 1, "a re-boot does not duplicate the migrated slot")
T.eq(files["save.lua"], nil, "no flat file reappears on re-boot")
T.eq(files["saves/red/slot1.lua"], slotBytes, "the slot bytes are untouched")
local opts = SaveSerializer.decode(files["options.lua"])
T.eq(#opts.saveSlots.red.list, 1, "the registry still lists exactly one slot")
end
-- ---------------------------------------------- mixed real / empty slots
do
local files = fresh()
files["save.lua"] = SaveSerializer.encode(
legacySave("REAL", { "EEVEE" }, { "BOULDERBADGE" }, 60))
SaveData.listSlots("red") -- slot1 = the migrated real save
local empty = SaveData.createSlot("red")
T.eq(empty, "slot2", "createSlot allocates slot2 alongside the migrated slot1")
local slots = SaveData.listSlots("red")
T.eq(#slots, 2, "both the real and empty slots are listed")
T.eq(slots[1].exists, true, "the migrated slot still reports a save")
T.eq(slots[1].name, "REAL", "the real slot keeps its name")
T.eq(slots[2].exists, false, "the freshly created slot is empty")
T.eq(slots[2].name, nil, "an empty slot has no name")
T.eq(slots[2].meta, nil, "an empty slot has no meta")
end
-- ---------------------------------------------- setActiveSlot persistence
do
local files = fresh()
SaveData.createSlot("red") -- slot1
SaveData.createSlot("red") -- slot2
SaveData.setActiveSlot("red", "slot2")
local opts = SaveSerializer.decode(files["options.lua"])
T.eq(opts.saveSlots.red.active, "slot2", "setActiveSlot persists the active id")
T.eq(opts.saveSlots.red.list[1], "slot1", "the slot list is preserved")
T.eq(opts.saveSlots.red.list[2], "slot2", "the target slot stays in the list")
-- selecting a slot that was never registered adds it
SaveData.setActiveSlot("red", "slot7")
opts = SaveSerializer.decode(files["options.lua"])
T.eq(opts.saveSlots.red.active, "slot7", "an unregistered active slot is added")
T.eq(opts.saveSlots.red.list[3], "slot7", "the added slot lands in the list")
end
-- ---------------------------------------------- createSlot id allocation
do
fresh()
T.eq(SaveData.createSlot("red"), "slot1", "first slot is slot1")
T.eq(SaveData.createSlot("red"), "slot2", "second slot is slot2")
T.eq(SaveData.createSlot("red"), "slot3", "ids increment past the highest")
end
-- ---------------------------------------------- saveNames follows the slot
do
local files = fresh()
SaveData.createSlot("red") -- slot1
SaveData.createSlot("red") -- slot2
SaveData.setActiveSlot("red", "slot2")
local save = SaveData.newGame()
save.player.name = "SLOT2"
T.check(SaveData.save(save), "save writes to the active slot")
T.check(files["saves/red/slot2.lua"] ~= nil, "bytes land in slot2's file")
T.eq(files["saves/red/slot1.lua"], nil, "slot1 is untouched by a slot2 save")
T.eq(files["save.lua"], nil, "no flat file is written once a slot is active")
local loaded = SaveData.load("red")
T.check(loaded and loaded.player.name == "SLOT2", "load reads back from slot2")
end
-- ---------------------------------------------- a version with no slots
do
local files = fresh()
local slots = SaveData.listSlots("red")
T.eq(#slots, 0, "a fresh install with no legacy save lists no slots")
-- with nothing registered, save/load use the flat legacy path, exactly
-- as they did before slots existed
local save = SaveData.newGame()
save.player.name = "FLAT"
T.check(SaveData.save(save), "a slotless version saves to the flat file")
T.check(files["save.lua"] ~= nil, "the flat save.lua is written")
T.eq(files["saves/red/slot1.lua"], nil, "no slot directory is created")
local loaded = SaveData.load("red")
T.check(loaded and loaded.player.name == "FLAT", "load reads the flat file")
T.eq(SaveData.saveFilename("red"), "save.lua",
"saveFilename still resolves the flat name with no slot in use")
end
love.filesystem = realFS
T.finish("save_slots")
+58
View File
@@ -0,0 +1,58 @@
-- TradeAnim InternalClockTradeFuncSequence completes under A-skip and
-- exposes the cable-trade phases (engine/movie/trade.asm).
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
local S = require("tests.harness").suite("trade anim")
local check, eq = S.check, S.eq
local Game = require("src.core.Game")
local Input = require("src.core.Input")
local StateStack = require("src.core.StateStack")
local SaveData = require("src.core.SaveData")
local Pokemon = require("src.pokemon.Pokemon")
local TradeAnim = require("src.ui.TradeAnim")
Game.data = Data
Game.input = Input; Input:init()
Game.stack = StateStack; StateStack:init()
Game.save = SaveData.newGame()
require("src.render.Font").load(Data)
local sent = Pokemon.new(Data, "SPEAROW", 10)
local recv = Pokemon.new(Data, "FARFETCHD", 10)
recv.nickname = "DUX"
recv.ot = "TRAINER"
recv.otId = 8193
local done = false
local anim = TradeAnim.new(Game, {
sent = sent, received = recv, enemyName = "TRAINER",
onDone = function() done = true end,
})
Game.stack:push(anim)
if anim.enter then anim:enter() end
local seen = {}
local guard = 0
while not done and guard < 20000 do
guard = guard + 1
seen[anim.phase] = true
Input.pressed = { a = true }
StateStack:update(1 / 60)
Input.pressed = {}
end
check(done, "TradeAnim reaches onDone")
for _, phase in ipairs({
"show_player", "open_cable", "ball_enter", "transfer_lr",
"went_to", "transfer_rl", "show_enemy",
}) do
check(seen[phase], "saw phase " .. phase)
end
eq(Game.stack:top(), nil, "TradeAnim pops itself")
S.finish()
+65
View File
@@ -0,0 +1,65 @@
-- Pure-surface coverage for src/update/Check.lua (the self-update release
-- check / payload download module). The network, hashing and archive-probe
-- logic lives in src/update/check_worker.lua and needs love + curl; these are
-- the love-free extraction/parsing seams the worker and UI both trust.
-- luajit tests/engine/update_check_tests.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
local Check = require("src.update.Check")
local Json = require("src.link.Json")
-- releaseUrl is the fixed public landing page the UI links on needs_full
eq(Check.releaseUrl(),
"https://github.com/bryanthaboi/pokemon-gen1-recomp-project/releases/latest",
"releaseUrl points at the repo's latest release")
-- parseRelease: a well-formed release with the .love payload and its sums
local body = Json.encode({
tag_name = "v1.4.2",
assets = {
{ name = "gen1recomp-1.4.2-macos.zip", browser_download_url = "http://x/mac", size = 10 },
{ name = "gen1recomp-1.4.2.love", browser_download_url = "http://x/love", size = 12345 },
{ name = "sha256sums.txt", browser_download_url = "http://x/sums", size = 99 },
},
})
local rel = Check.parseRelease(body)
check(rel ~= nil, "parseRelease accepts a valid release")
eq(rel.version, "1.4.2", "leading v stripped from tag_name")
eq(rel.payloadName, "gen1recomp-1.4.2.love", "payload name derived from version")
eq(rel.payload.url, "http://x/love", "payload asset url picked")
eq(rel.payload.size, 12345, "payload asset size picked")
eq(rel.sums.url, "http://x/sums", "sums asset url picked")
-- a newer release that ships no .love yet: parses, but the payload/sums are nil
-- so the worker will route to needs_full rather than an in-place update
local noPayload = Check.parseRelease(Json.encode({ tag_name = "2.0.0", assets = {} }))
check(noPayload ~= nil, "parseRelease accepts a payload-less release")
eq(noPayload.version, "2.0.0", "version parsed without assets")
eq(noPayload.payload, nil, "no payload asset -> nil")
eq(noPayload.sums, nil, "no sums asset -> nil")
-- rejects: non-semver tag, and a document with no tag at all
local bad, badErr = Check.parseRelease(Json.encode({ tag_name = "nightly" }))
eq(bad, nil, "non-X.Y.Z tag rejected")
check(badErr ~= nil, "rejection carries an error string")
eq(Check.parseRelease(Json.encode({ foo = 1 })), nil, "missing tag_name rejected")
-- parseSums: shasum -a 256 format, tolerating the '*' binary marker, a './'
-- prefix and CRLF line endings; unrelated lines are skipped
local sums =
"aaaa1111 gen1recomp-1.4.2.love\n" ..
"BBBB2222 *./sha256sums.txt\r\n" ..
"not a checksum line\n"
local map = Check.parseSums(sums)
eq(map["gen1recomp-1.4.2.love"], "aaaa1111", "bare-name sum parsed")
eq(map["sha256sums.txt"], "bbbb2222", "* marker and ./ prefix stripped, lowered")
eq(Check.parseSums(sums, "gen1recomp-1.4.2.love"), "aaaa1111", "targeted lookup returns the hash")
eq(Check.parseSums(sums, "missing.love"), nil, "targeted lookup misses cleanly")
-- pickAsset guards a non-table assets field
eq(Check.pickAsset(nil, "x"), nil, "pickAsset tolerates a nil asset list")
T.finish("update_check")
+300
View File
@@ -0,0 +1,300 @@
-- Pure-logic coverage for the self-updater (src/update/*). Every export
-- exercised here is love-free: Semver's parse/compare, Boot's select()
-- decision function, and Check's release-JSON / sha256sums / asset parsers.
-- The love-bound halves (Boot.run's mount+chainload, Check's thread worker,
-- curl, hashing) need a real LOVE process and are covered elsewhere; this
-- suite is the plain-Lua seam the whole updater trusts.
-- luajit tests/engine/update_tests.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
local Semver = require("src.update.Semver")
local Boot = require("src.update.Boot")
local Check = require("src.update.Check")
local Json = require("src.link.Json")
-- ---------------------------------------------------------------------------
-- Semver.parse
-- ---------------------------------------------------------------------------
-- valid triples decode to numeric fields, not strings
local p = Semver.parse("1.2.3")
check(p ~= nil, "parse accepts a plain X.Y.Z")
eq(p.major, 1, "parse major")
eq(p.minor, 2, "parse minor")
eq(p.patch, 3, "parse patch")
eq(type(p.major), "number", "parse yields numbers, not strings")
local zero = Semver.parse("0.0.0")
eq(zero.major, 0, "parse zeros: major")
eq(zero.patch, 0, "parse zeros: patch")
local big = Semver.parse("10.20.30")
eq(big.major, 10, "parse multi-digit major")
eq(big.minor, 20, "parse multi-digit minor")
eq(big.patch, 30, "parse multi-digit patch")
-- an optional leading lowercase "v" is stripped
local v = Semver.parse("v2.5.9")
check(v ~= nil, "parse accepts a leading v")
eq(v.major, 2, "leading v: major")
eq(v.minor, 5, "leading v: minor")
eq(v.patch, 9, "leading v: patch")
-- rejects: partial versions, extra components, non-numeric parts, suffixes,
-- a bare v, whitespace, empties, and non-string inputs -- all return nil, not
-- a raise (the safe answer for the updater is "not a real version")
eq(Semver.parse("1.2"), nil, "parse rejects a two-part version")
eq(Semver.parse("1"), nil, "parse rejects a one-part version")
eq(Semver.parse("1.2.3.4"), nil, "parse rejects a four-part version")
eq(Semver.parse("1.2.x"), nil, "parse rejects a non-numeric part")
eq(Semver.parse("1.2.3-dev"), nil, "parse rejects a pre-release suffix")
eq(Semver.parse("0.0.0-dev"), nil, "parse rejects the working-tree placeholder")
eq(Semver.parse("v"), nil, "parse rejects a bare v")
eq(Semver.parse(" 1.2.3"), nil, "parse rejects leading whitespace (anchored)")
eq(Semver.parse("1.2.3 "), nil, "parse rejects trailing whitespace (anchored)")
eq(Semver.parse(""), nil, "parse rejects the empty string")
eq(Semver.parse("nightly"), nil, "parse rejects a non-numeric tag")
eq(Semver.parse(nil), nil, "parse rejects nil")
eq(Semver.parse(123), nil, "parse rejects a number")
eq(Semver.parse({}), nil, "parse rejects a table")
-- ---------------------------------------------------------------------------
-- Semver.compare
-- ---------------------------------------------------------------------------
-- ordering is major, then minor, then patch
eq(Semver.compare("2.0.0", "1.9.9"), 1, "compare: major dominates (a > b)")
eq(Semver.compare("1.0.0", "2.0.0"), -1, "compare: major dominates (a < b)")
eq(Semver.compare("1.2.0", "1.1.9"), 1, "compare: minor breaks a major tie (a > b)")
eq(Semver.compare("1.1.0", "1.2.0"), -1, "compare: minor breaks a major tie (a < b)")
eq(Semver.compare("1.1.2", "1.1.1"), 1, "compare: patch breaks a minor tie (a > b)")
eq(Semver.compare("1.1.1", "1.1.2"), -1, "compare: patch breaks a minor tie (a < b)")
-- equality
eq(Semver.compare("1.2.3", "1.2.3"), 0, "compare: identical versions are equal")
eq(Semver.compare("v1.2.3", "1.2.3"), 0, "compare: leading v does not change value")
-- string and already-parsed-table inputs interoperate on either side
eq(Semver.compare(Semver.parse("1.2.3"), "1.2.4"), -1, "compare: parsed-table a vs string b")
eq(Semver.compare("1.3.0", Semver.parse("1.2.9")), 1, "compare: string a vs parsed-table b")
eq(Semver.compare({ major = 2, minor = 0, patch = 0 },
{ major = 1, minor = 9, patch = 9 }), 1, "compare: raw tables on both sides")
eq(Semver.compare(Semver.parse("4.4.4"), Semver.parse("4.4.4")), 0, "compare: equal parsed tables")
-- an unparseable side sorts as the lowest possible version, so a bogus value
-- never wins a "newer" test; two unparseable sides are equal
eq(Semver.compare("garbage", "1.0.0"), -1, "compare: unparseable a loses to a real version")
eq(Semver.compare("1.0.0", "garbage"), 1, "compare: a real version beats an unparseable b")
eq(Semver.compare("garbage", "junk"), 0, "compare: two unparseable sides are equal")
eq(Semver.compare(nil, "1.0.0"), -1, "compare: nil a sorts lowest")
eq(Semver.compare("1.0.0", nil), 1, "compare: nil b sorts lowest")
-- ---------------------------------------------------------------------------
-- Boot.select (pure: no love.*)
-- ---------------------------------------------------------------------------
-- membership helper: toDelete order is deterministic, but assert on the set so
-- the tests document intent rather than iteration accidents
local function nameSet(list)
local s = {}
for _, n in ipairs(list) do s[n] = true end
return s
end
-- empty candidate list: nothing to run, nothing to delete
do
local chosen, del = Boot.select({}, "1.0.0", 1)
eq(chosen, nil, "select: empty candidate list picks nothing")
eq(#del, 0, "select: empty candidate list deletes nothing")
end
-- picks the highest eligible payload and marks the lower runnable ones (still
-- newer than bundled, but superseded by the winner) for deletion
do
local candidates = {
{ name = "a.love", engine = "1.1.0" }, -- no minShell -> defaults to 1
{ name = "b.love", engine = "1.3.0", minShell = 1 },
{ name = "c.love", engine = "1.2.0", minShell = 1 },
}
local chosen, del = Boot.select(candidates, "1.0.0", 1)
eq(chosen, "b.love", "select: picks the highest eligible engine")
local d = nameSet(del)
eq(#del, 2, "select: both losers are marked for deletion")
check(d["a.love"] and d["c.love"], "select: superseded runnable payloads are deleted")
check(not d["b.love"], "select: the chosen payload is never deleted")
end
-- skips payloads whose minShell is above the bundled shell, and KEEPS an
-- otherwise-newer one for a future shell upgrade instead of deleting it
do
local candidates = {
{ name = "future.love", engine = "2.0.0", minShell = 2 }, -- unrunnable at shell 1
{ name = "ok.love", engine = "1.5.0", minShell = 1 },
}
local chosen, del = Boot.select(candidates, "1.0.0", 1)
eq(chosen, "ok.love", "select: skips a payload whose minShell exceeds the bundled shell")
eq(#del, 0, "select: a newer-but-unrunnable payload is kept, not deleted")
check(not nameSet(del)["future.love"], "select: unrunnable-newer payload survives")
end
-- skips payloads not strictly newer than bundled (older AND equal) and marks
-- them stale for deletion
do
local candidates = {
{ name = "old.love", engine = "0.9.0", minShell = 1 }, -- older than bundled
{ name = "same.love", engine = "1.0.0", minShell = 1 }, -- equal to bundled
{ name = "new.love", engine = "1.1.0", minShell = 1 }, -- the only real update
}
local chosen, del = Boot.select(candidates, "1.0.0", 1)
eq(chosen, "new.love", "select: only a strictly-newer payload is eligible")
local d = nameSet(del)
eq(#del, 2, "select: older and equal payloads are both stale")
check(d["old.love"], "select: an older payload is deleted")
check(d["same.love"], "select: a same-version payload is deleted")
check(not d["new.love"], "select: the winner is not in the delete list")
end
-- no eligible payload at all (all older or equal): pick nothing, delete every
-- stale candidate
do
local candidates = {
{ name = "old.love", engine = "0.5.0", minShell = 1 },
{ name = "same.love", engine = "1.0.0", minShell = 1 },
}
local chosen, del = Boot.select(candidates, "1.0.0", 1)
eq(chosen, nil, "select: no strictly-newer payload -> nothing chosen")
eq(#del, 2, "select: every stale candidate is cleaned up when nothing wins")
end
-- the full mix in one pass: a superseded runnable one and a stale old one are
-- deleted; the chosen winner and a newer-but-unrunnable payload both survive
do
local candidates = {
{ name = "sup.love", engine = "1.2.0", minShell = 1 }, -- newer, runnable, < winner
{ name = "win.love", engine = "1.4.0", minShell = 1 }, -- the winner
{ name = "future.love", engine = "2.0.0", minShell = 5 }, -- newer than winner, unrunnable
{ name = "old.love", engine = "0.1.0", minShell = 1 }, -- stale
}
local chosen, del = Boot.select(candidates, "1.1.0", 1)
eq(chosen, "win.love", "select(mix): highest runnable-newer engine wins")
local d = nameSet(del)
eq(#del, 2, "select(mix): exactly the superseded and stale payloads are deleted")
check(d["sup.love"], "select(mix): a runnable payload below the winner is superseded")
check(d["old.love"], "select(mix): a stale payload is cleaned up")
check(not d["future.love"], "select(mix): a newer-but-unrunnable payload is kept")
check(not d["win.love"], "select(mix): the winner is kept")
end
-- ---------------------------------------------------------------------------
-- Check.pickAsset
-- ---------------------------------------------------------------------------
local assets = {
{ name = "gen1recomp-1.4.2-macos.zip", browser_download_url = "http://x/mac", size = 10 },
{ name = "gen1recomp-1.4.2.love", browser_download_url = "http://x/love", size = 12345 },
{ name = "sha256sums.txt", browser_download_url = "http://x/sums", size = 99 },
}
local picked = Check.pickAsset(assets, "gen1recomp-1.4.2.love")
check(picked ~= nil, "pickAsset finds an asset by exact name")
eq(picked.url, "http://x/love", "pickAsset returns the download url")
eq(picked.size, 12345, "pickAsset returns the numeric size")
eq(Check.pickAsset(assets, "does-not-exist.love"), nil, "pickAsset misses cleanly on an unknown name")
-- coerces a string size to a number and tolerates non-table junk entries mixed
-- into the asset list
local coerced = Check.pickAsset({ "junk", 42, { name = "w", browser_download_url = "U", size = "7" } }, "w")
eq(coerced.size, 7, "pickAsset coerces a string size to a number")
eq(type(coerced.size), "number", "pickAsset size is a number after coercion")
-- guards a non-table / nil assets field instead of raising
eq(Check.pickAsset(nil, "x"), nil, "pickAsset tolerates a nil asset list")
eq(Check.pickAsset("nope", "x"), nil, "pickAsset tolerates a non-table asset list")
-- ---------------------------------------------------------------------------
-- Check.parseRelease (release-JSON extraction)
-- ---------------------------------------------------------------------------
-- a well-formed release: version (leading v stripped), derived payload name,
-- and both the .love payload and its sums asset with url + size
local body = Json.encode({
tag_name = "v1.4.2",
assets = assets,
})
local rel = Check.parseRelease(body)
check(rel ~= nil, "parseRelease accepts a valid release")
eq(rel.version, "1.4.2", "parseRelease strips the leading v from tag_name")
eq(rel.payloadName, "gen1recomp-1.4.2.love", "parseRelease derives the payload name from the version")
eq(rel.payload.url, "http://x/love", "parseRelease picks the payload asset url")
eq(rel.payload.size, 12345, "parseRelease picks the payload asset size")
eq(rel.sums.url, "http://x/sums", "parseRelease picks the sums asset url")
eq(rel.sums.size, 99, "parseRelease picks the sums asset size")
-- a release with no .love yet still parses; payload/sums are nil so the worker
-- routes to a full reinstall rather than an in-place update
local noPayload = Check.parseRelease(Json.encode({ tag_name = "2.0.0", assets = {} }))
check(noPayload ~= nil, "parseRelease accepts a payload-less release")
eq(noPayload.version, "2.0.0", "parseRelease reads the version without any assets")
eq(noPayload.payload, nil, "parseRelease reports a missing payload asset as nil")
eq(noPayload.sums, nil, "parseRelease reports a missing sums asset as nil")
-- rejections carry an error string and never raise
local badTag, badTagErr = Check.parseRelease(Json.encode({ tag_name = "nightly" }))
eq(badTag, nil, "parseRelease rejects a non-X.Y.Z tag")
check(badTagErr ~= nil, "parseRelease rejection carries an error string")
local noTag, noTagErr = Check.parseRelease(Json.encode({ foo = 1 }))
eq(noTag, nil, "parseRelease rejects a document with no tag_name")
check(noTagErr ~= nil, "parseRelease missing-tag rejection carries an error string")
-- malformed input returns nil rather than raising (Json.decode yields nil, and
-- a bare non-object literal has no tag_name)
local ok1, garbage = pcall(Check.parseRelease, "this is not json {{{")
check(ok1, "parseRelease does not raise on unparseable JSON")
eq(garbage, nil, "parseRelease returns nil on unparseable JSON")
local ok2, empty = pcall(Check.parseRelease, "")
check(ok2, "parseRelease does not raise on empty input")
eq(empty, nil, "parseRelease returns nil on empty input")
local ok3, literal = pcall(Check.parseRelease, "42")
check(ok3, "parseRelease does not raise on a bare JSON literal")
eq(literal, nil, "parseRelease returns nil on a non-object JSON literal")
-- ---------------------------------------------------------------------------
-- Check.parseSums (shasum -a 256 line parsing)
-- ---------------------------------------------------------------------------
-- standard "<hex> <file>" lines, the '*' binary marker, a './' prefix, CRLF
-- endings and mixed hash case; junk lines are skipped
local sums =
"aaaa1111 gen1recomp-1.4.2.love\n" ..
"BBBB2222 *./sha256sums.txt\r\n" ..
"deadBEEF ./nested.love\n" ..
"not a checksum line at all\n"
local map = Check.parseSums(sums)
eq(map["gen1recomp-1.4.2.love"], "aaaa1111", "parseSums reads a bare-name line")
eq(map["sha256sums.txt"], "bbbb2222", "parseSums strips the * marker and ./ prefix and lowercases")
eq(map["nested.love"], "deadbeef", "parseSums lowercases a mixed-case hash and strips ./")
eq(map["not a checksum line at all"], nil, "parseSums skips lines that are not checksums")
-- the target form returns just that file's hash (hit / miss)
eq(Check.parseSums(sums, "gen1recomp-1.4.2.love"), "aaaa1111", "parseSums(target) returns the matching hash")
eq(Check.parseSums(sums, "missing.love"), nil, "parseSums(target) misses cleanly on an unknown file")
-- degenerate inputs: empty text yields an empty map, a targeted miss is nil,
-- and a nil text does not raise
local emptyMap = Check.parseSums("")
eq(type(emptyMap), "table", "parseSums('') returns an (empty) table")
eq(next(emptyMap), nil, "parseSums('') has no entries")
eq(Check.parseSums(nil, "anything"), nil, "parseSums(nil, target) returns nil without raising")
-- ---------------------------------------------------------------------------
-- Check.releaseUrl (the fixed public landing page)
-- ---------------------------------------------------------------------------
eq(Check.releaseUrl(),
"https://github.com/bryanthaboi/pokemon-gen1-recomp-project/releases/latest",
"releaseUrl points at the repo's latest release")
T.finish("update")