mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-17 03:02:39 +02:00
The new experience (#201)
* new launcher and save converts and pipeline * fixing bugs
This commit is contained in:
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user