Files
gen1recomp/tests/engine/box_storage_test.lua
T
2026-10-06 11:00:16 -04:00

264 lines
14 KiB
Lua

package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.harness")
local Serializer = require("src.core.SaveSerializer")
local GameVersion = require("src.core.GameVersion")
local CacheFs = require("src.import.CacheFs")
local Store = require("src.box.Store")
local Service = require("src.box.Service")
local Transaction = require("src.box.Transaction")
local Records = require("src.box.Records")
local Catalog = require("src.box.Catalog")
local tables = {}
local function put(version, path, value)
tables[GameVersion.cachePrefix(version) .. path] = Serializer.encode(value)
end
for _, version in ipairs(GameVersion.ORDER) do
if GameVersion.generation(version) == 3 then
put(version, "data/generated/gba/pokemon/names.lua", { [25] = "PIKACHU" })
put(version, "data/generated/gba/pokemon/national.lua", { toNational = { [25] = 25 }, toSpecies = { [25] = 25 } })
put(version, "data/generated/gba/pokemon/move_names.lua", { [84] = "THUNDERSHOCK" })
put(version, "data/generated/gba/items/pack.lua", { items = { [139] = { name = "ORAN BERRY" } } })
else
put(version, "data/generated/pokemon.lua", { PIKACHU = { dex = 25, name = "PIKACHU", types = { "ELECTRIC" } } })
put(version, "data/generated/moves.lua", { THUNDERSHOCK = { name = "THUNDERSHOCK" } })
put(version, "data/generated/items.lua", {})
end
end
local readAt = CacheFs.readAt
CacheFs.readAt = function(path) return tables[path] end
Catalog.reset()
local function filesystem()
local files, writes, mode = {}, 0, {}
local fs = {
read = function(path) return files[path] end,
getInfo = function(path) return files[path] and { type = "file" } or nil end,
createDirectory = function() return true end,
remove = function(path)
if mode.remove == path then return nil, "remove fault" end
files[path] = nil; return true
end,
write = function(path, body)
writes = writes + 1
if writes == mode.at then
if mode.kind == "partial" then files[path] = body:sub(1, math.floor(#body / 2)) end
if mode.kind == "crash" then files[path] = body; error("simulated interruption") end
return nil, "write fault"
end
files[path] = body
return true
end,
}
return fs, files, mode
end
local function fixture(version)
local generation = GameVersion.generation(version)
local mon = { species = generation == 3 and 25 or "PIKACHU", nickname = "Sparky", level = 12,
hp = 31, maxHp = 31, otId = 42, otName = "Player", personality = 987654,
otSecretId = 92, moves = { generation == 3 and { move = 84, pp = 17 } or "THUNDERSHOCK" },
dvs = { attack = 7, defense = 3, speed = 9, special = 4 },
opaque = { raw = "\000\255\127", unknown = { [7] = "keep" } } }
if generation == 3 then mon.heldItem = 139 end
local save = { version = version, generation = generation, party = { Store.copy(mon) },
money = 19374, unknown = { keep = true }, rawImport = "original-template" }
if generation == 3 then
save.storage = { currentBox = 1, boxes = { { name = "First", wallpaper = 3, mons = { [4] = mon } } } }
else save.boxes = { { mon } }; save.currentBox = 1 end
return save, mon, { where = "box", box = 1, index = generation == 3 and 4 or 1 }
end
for _, version in ipairs(GameVersion.ORDER) do
local fs, files = filesystem()
local save, mon, ref = fixture(version)
local source = { version = version, slotId = "slot1", path = "saves/" .. version .. "/slot1.lua" }
local original = Serializer.encode(save)
files[source.path] = original
local service = assert(Service.open(fs))
T.eq(Store.count(service.state), 0, version .. " begins with empty Box")
T.check(service:deposit(source, { ref }, 1, original), version .. " deposits")
T.eq(Store.count(service.state), 1, version .. " stored exactly once")
T.same(Store.at(service.state, 1, 1).mon, mon, version .. " preserves the entire native record")
T.same(Serializer.decode(files[source.path]).unknown, save.unknown, version .. " retains unrelated save fields")
T.check(service:withdraw(source, { { box = 1, slot = 1 } }, 1), version .. " withdraws")
T.eq(Store.count(service.state), 0, version .. " leaves no duplicate in Box")
local after = Serializer.decode(files[source.path])
local returned = Records.list(after, GameVersion.generation(version), 1)
returned = returned[1]
T.same(returned, mon, version .. " returned record is unchanged")
T.eq(after.money, save.money, version .. " keeps money")
T.eq(after.rawImport, save.rawImport, version .. " keeps cartridge template")
T.eq(files[Transaction.PATH], nil, version .. " journal is cleared")
end
for _, kind in ipairs({ "fail", "partial", "crash" }) do
for at = 1, 6 do
local fs, files, mode = filesystem()
local save, mon, ref = fixture("ruby")
local source = { version = "ruby", slotId = "slot1", path = "saves/ruby/slot1.lua" }
files[source.path] = Serializer.encode(save)
local original = files[source.path]
local service = assert(Service.open(fs))
mode.at, mode.kind = at, kind
pcall(service.deposit, service, source, { ref }, 1, original)
mode.at = nil
local reopened = Service.open(fs)
if kind == "crash" and at == 1 then
T.check(reopened ~= nil, "journal interruption recovers")
else T.check(reopened ~= nil, kind .. " at " .. at .. " can reopen") end
if reopened then
local count = Store.count(reopened.state)
local native = Serializer.decode(files[source.path])
local inPC = Records.at(native, 3, ref) ~= nil
T.check((count == 1 and not inPC) or (count == 0 and inPC), kind .. " at " .. at .. " retains exactly one copy")
T.eq(files[Transaction.PATH], nil, kind .. " at " .. at .. " completes recovery")
if count == 1 then
T.eq(files[source.path .. ".bak"], files[source.path], kind .. " at " .. at .. " keeps game recovery consistent")
T.eq(files[Store.PATH .. ".bak"], files[Store.PATH], kind .. " at " .. at .. " keeps Box recovery consistent")
end
end
end
end
do
local fs, files = filesystem()
local save, _, ref = fixture("gold")
local source = { version = "gold", slotId = "slot1", path = "saves/gold/slot1.lua" }
local body = Serializer.encode(save); files[source.path] = body
local service = assert(Service.open(fs))
files[source.path] = Serializer.encode({ version = "gold", generation = 2, party = {}, boxes = {} })
T.check(not service:deposit(source, { ref }, 1, body), "stale selection is refused")
T.eq(Store.count(service.state), 0, "stale selection cannot put a different mon in Box")
files[source.path] = body
T.check(not service:deposit(source, { { where = "party", index = 1 } }, 1, body), "last healthy party mon stays")
T.eq(files[source.path], body, "party refusal is nonmutating")
T.check(service:deposit(source, { ref }, 1, body), "valid box deposit still succeeds")
local red = { version = "red", slotId = "slot1", path = "saves/red/slot1.lua" }
local redSave = fixture("red"); files[red.path] = Serializer.encode(redSave)
local original = files[red.path]
T.check(not service:withdraw(red, { { box = 1, slot = 1 } }, 1), "generation conversion is refused")
T.eq(files[red.path], original, "generation refusal preserves destination")
T.eq(Store.count(service.state), 1, "generation refusal preserves stored record")
T.check(service:move(1, 1, 25, 60), "last storage slot is reachable")
T.check(Store.at(service.state, 25, 60) ~= nil, "move retains entry")
T.check(service:rename(25, "Rare Pokémon"), "Box can be renamed")
T.eq(#Store.search(service.state, "THUNDERSHOCK"), 1, "move names are searchable")
T.eq(#Store.search(service.state, "Rare Pokémon"), 1, "Box names are searchable")
T.eq(#Store.search(service.state, "type:electric level<50 move:thundershock -egg"), 1,
"type, level, move and egg filters combine")
T.eq(#Store.search(service.state, "level>=50"), 0, "numeric level filter excludes lower levels")
T.eq(#Store.search(service.state, "game:gold species:pikachu"), 1, "game and species filters work")
T.eq(#Store.search(service.state, "shiny"), 0, "shiny filter respects native metadata")
end
do
local save = fixture("gold")
save.party[2] = { species = "PIKACHU", hp = 40, item = "FLOWER_MAIL" }
save.mail = { party = { [2] = { message = "hello" } }, box = { { message = "keep" } } }
local after = assert(Records.remove(save, 2, { { where = "party", index = 1 } }))
T.eq(after.mail.party[1].message, "hello", "remaining party mail shifts with its Pokémon")
T.same(after.mail.box, save.mail.box, "mailbox stays intact")
T.eq(save.mail.party[2].message, "hello", "source save is not modified during planning")
end
do
local fs, files = filesystem()
files[Store.PATH] = "return { broken = true }"
T.check(not Service.open(fs), "corrupt Box storage is refused")
T.eq(files[Store.PATH], "return { broken = true }", "corrupt storage is not overwritten with an empty Box")
end
do
local fs, files = filesystem()
local save, _, ref = fixture("emerald")
local source = { version = "emerald", slotId = "slot1", path = "saves/emerald/slot1.lua" }
local body = Serializer.encode(save); files[source.path] = body
local service = assert(Service.open(fs))
local Trade = require("src.online.Trade")
local previous = Trade.pendingSentAt
Trade.pendingSentAt = function() return { { species = 25 } } end
T.check(not service:deposit(source, { ref }, 1, body), "pending outgoing trade blocks deposit")
T.eq(files[source.path], body, "pending trade refusal preserves the game save")
T.eq(Store.count(service.state), 0, "pending trade refusal preserves Box")
Trade.pendingSentAt = previous
T.check(service:deposit(source, { ref }, 1, body), "deposit succeeds after pending trade finishes")
local full = Serializer.decode(files[source.path])
for i = 1, 30 do full.storage.boxes[1].mons[i] = Store.copy(save.party[1]) end
local fullBody = Serializer.encode(full); files[source.path] = fullBody
local boxBody = files[Store.PATH]
T.check(not service:withdraw(source, { { box = 1, slot = 1 } }, 1), "full destination PC box blocks withdrawal")
T.eq(files[source.path], fullBody, "full PC refusal preserves every destination record")
T.eq(files[Store.PATH], boxBody, "full PC refusal preserves the stored record")
end
do
local fs, files, mode = filesystem()
local save, _, ref = fixture("ruby")
local source = { version = "ruby", slotId = "slot1", path = "saves/cart_ruby.v1-hack/slot1.lua" }
local body = Serializer.encode(save); files[source.path] = body
local service = assert(Service.open(fs))
mode.at, mode.kind = 2, "fail"
T.check(not service:deposit(source, { ref }, 1, body), "target write fault leaves a recoverable journal")
T.check(files[Transaction.PATH] ~= nil, "journal remains durable before target recovery")
mode.at = nil
local changed = Store.copy(save); changed.money = 1
local changedBody = Serializer.encode(changed); files[source.path] = changedBody
local boxBody = files[Store.PATH]
local recovered = Service.open(fs)
T.check(recovered and recovered.recoveryNotice, "externally changed save undoes the pending transfer")
T.eq(files[source.path], changedBody, "undone transfer leaves the changed game intact")
T.eq(files[Store.PATH], boxBody, "undone transfer restores the Box before image")
T.eq(Store.count(recovered.state), 0, "undone transfer does not duplicate the Pokémon")
T.eq(files[Transaction.PATH], nil, "undone transfer no longer blocks boot")
end
do
local fs, files = filesystem()
local save, _, ref = fixture("ruby")
local source = { version = "ruby", slotId = "slot1", path = "saves/cart_ruby.v1-hack/slot1.lua" }
local body = Serializer.encode(save); files[source.path] = body
local service = assert(Service.open(fs))
T.check(service:deposit(source, { ref }, 1, body), "cart ID with dots and hyphens deposits")
local boxBody, gameBody = files[Store.PATH], files[source.path]
local nextState = Serializer.decode(boxBody); nextState.boxes[1].name = "next"; nextState.revision = nextState.revision + 1
local nextSave = Serializer.decode(gameBody); nextSave.money = 5
local journal = Serializer.encode({ format = 1, jobs = {
{ path = Store.PATH, before = boxBody, after = Serializer.encode(nextState) },
{ path = source.path, before = gameBody, after = Serializer.encode(nextSave) } } })
files[Store.PATH] = Serializer.encode(nextState)
files[Store.PATH .. ".bak"] = files[Store.PATH]
files[source.path] = Serializer.encode({ version = "ruby", generation = 3, party = {}, money = 1 })
files[Transaction.PATH] = journal
assert(Service.open(fs))
T.eq(files[Store.PATH], boxBody, "conflict rolls an already written Box back to its before image")
T.eq(files[Store.PATH .. ".bak"], boxBody, "conflict rolls the Box recovery copy back too")
files[Transaction.PATH] = journal:sub(1, math.floor(#journal / 2))
local reopened = Service.open(fs)
T.check(reopened and reopened.recoveryNotice, "truncated journal is set aside instead of blocking boot")
T.eq(files[Transaction.PATH], nil, "truncated journal is cleared")
T.eq(files[Store.PATH], boxBody, "truncated journal leaves targets untouched")
end
do
local fs, files = filesystem()
local save, _, ref = fixture("emerald")
local source = { version = "emerald", slotId = "slot1", path = "saves/emerald/slot1.lua" }
local body = Serializer.encode(save); files[source.path] = body
local service = assert(Service.open(fs))
T.check(service:deposit(source, { ref }, 1, body), "deposit before storage loss")
local boxBody = files[Store.PATH]
files[Store.PATH] = nil
local reopened = assert(Service.open(fs))
T.eq(Store.count(reopened.state), 1, "missing storage recovers from its backup")
T.eq(files[Store.PATH], boxBody, "backup is promoted back to storage")
files[Store.PATH], files[Store.PATH .. ".bak"] = nil, "return {"
T.check(not Service.open(fs), "missing storage with an unreadable backup is refused")
T.eq(files[Store.PATH .. ".bak"], "return {", "unreadable backup is not overwritten")
end
CacheFs.readAt = readAt
Catalog.reset()
T.finish()