Files
gen1recomp/tests/run_save_editor_tests.lua
T
2026-07-17 20:30:02 -04:00

389 lines
14 KiB
Lua

-- Headless tests for tools/save-editor pure logic.
-- Run from repo root: lua5.4 tests/run_save_editor_tests.lua
-- (If lua5.4 is missing, use the same interpreter as tests/run_tests.lua.)
--
-- Panel suites (Boxes/Items, Events/Dex, Map) live in separate files so each
-- can define its own harness without colliding with this runner:
-- tests/save_editor_task6_tests.lua
-- tests/save_editor_task7_tests.lua
-- tests/save_editor_task8_tests.lua
-- See tools/save-editor/README.md for the full list.
package.path = package.path .. ";./?.lua;./?/init.lua;./tools/save-editor/?.lua"
.. ";./tools/save-editor/panels/?.lua"
local love_stub = require("tests.love_stub")
love = love_stub
local passed, failed = 0, 0
local function check(cond, msg)
if cond then
passed = passed + 1
else
failed = failed + 1
print("FAIL: " .. msg)
end
end
local function eq(a, b, msg)
check(a == b, msg .. string.format(" (got %s, want %s)", tostring(a), tostring(b)))
end
print("== save editor tests ==")
local SaveData = require("src.core.SaveData")
do
local data = SaveData.newGame()
data.player.map = "VIRIDIAN_CITY"
data.money = 1234
data.flags.EVENT_GOT_POKEDEX = true
local encoded = SaveData.encode(data)
check(type(encoded) == "string", "encode returns string")
check(encoded:match("^return "), "encode starts with return")
local back, err = SaveData.decode(encoded)
check(back ~= nil, "decode ok: " .. tostring(err))
eq(back.player.map, "VIRIDIAN_CITY", "decode map")
eq(back.money, 1234, "decode money")
check(back.flags.EVENT_GOT_POKEDEX == true, "decode flag")
end
do
local bad, err = SaveData.decode("not lua {{{")
check(bad == nil, "decode rejects garbage")
check(type(err) == "string", "decode returns err string")
end
local SaveIO = require("SaveIO")
do
local path = SaveIO.defaultPath()
check(type(path) == "string" and #path > 0, "defaultPath nonempty")
check(path:match("save%.lua$"), "defaultPath ends with save.lua")
check(path:match("pokemon%-love2d"), "defaultPath uses game identity folder")
local uname = io.popen and io.popen("uname -s 2>/dev/null")
local sys = uname and uname:read("*l") or ""
if uname then uname:close() end
if sys == "Darwin" then
check(path:match("/LOVE/"), "defaultPath on macOS includes LOVE folder")
end
check(type(SaveIO.choosePath) == "function", "choosePath exists")
end
do
local path = os.tmpname() .. "-gamesave.lua"
local data = SaveData.newGame()
data.money = 42
local ok, err = SaveIO.save(path, data)
check(ok, "SaveIO.save ok: " .. tostring(err))
local f = io.open(path, "r")
check(f ~= nil, "save file exists")
if f then f:close() end
local loaded, lerr = SaveIO.load(path)
check(loaded ~= nil, "SaveIO.load ok: " .. tostring(lerr))
eq(loaded.money, 42, "SaveIO round trip money")
data.money = 99
ok, err = SaveIO.save(path, data)
check(ok, "second save ok: " .. tostring(err))
loaded = assert(SaveIO.load(path))
eq(loaded.money, 99, "second save money")
local bakFiles = {}
local bakGlob = io.popen('ls -1 "' .. path .. '.bak-"* 2>/dev/null')
if bakGlob then
for line in bakGlob:lines() do
bakFiles[#bakFiles + 1] = line
end
bakGlob:close()
end
check(#bakFiles >= 1, "second save creates .bak-* sibling")
if #bakFiles >= 1 then
local bakData, berr = SaveIO.load(bakFiles[1])
check(bakData ~= nil, "backup load ok: " .. tostring(berr))
if bakData then eq(bakData.money, 42, "backup preserves previous money") end
end
os.remove(path)
for _, bak in ipairs(bakFiles) do
os.remove(bak)
end
end
local Catalog = require("Catalog")
local MonOps = require("MonOps")
local Data = require("src.core.Data")
Data:load()
do
local cat = Catalog.build(Data)
check(#cat.species > 140, "species catalog size")
check(#cat.items > 100, "items catalog size")
check(#cat.moves > 150, "moves catalog size")
check(cat.species[1] < cat.species[2], "species sorted")
end
do
local events = Catalog.scrapeEvents("data/scripts", "data/generated/trainer_headers.lua")
check(#events > 50, "scraped events")
check(events[1]:match("^EVENT_"), "event prefix")
end
do
local mon = MonOps.create(Data, "PIDGEY", 10)
eq(mon.species, "PIDGEY", "create species")
eq(mon.level, 10, "create level")
local hpBefore = mon.stats.hp
MonOps.setLevel(Data, mon, 20)
eq(mon.level, 20, "setLevel")
check(mon.stats.hp > hpBefore, "stats grew on level")
check(mon.hp <= mon.stats.hp, "hp clamped")
MonOps.setMove(Data, mon, 1, "GUST")
eq(mon.moves[1].id, "GUST", "setMove id")
check(mon.moves[1].pp > 0, "setMove pp")
end
do
-- Magikarp is SLOW, Butterfree is MEDIUM_FAST, same level, different exp
local mon = MonOps.create(Data, "MAGIKARP", 20)
local expSlow = mon.exp
MonOps.setSpecies(Data, mon, "BUTTERFREE")
eq(mon.species, "BUTTERFREE", "setSpecies id")
eq(mon.level, 20, "setSpecies keeps level")
check(mon.exp ~= expSlow, "setSpecies resyncs exp for new growth curve")
eq(mon.exp, require("src.pokemon.Growth").expForLevel(
Data.pokemon.BUTTERFREE.growthRate, 20), "setSpecies exp matches curve")
MonOps.setDv(Data, mon, "attack", 15)
eq(mon.dvs.attack, 15, "setDv attack")
check(mon.dvs.hp >= 8, "syncHpDv sets high bit from odd attack")
end
local State = require("State")
do
local s = State.new()
eq(s.tab, "party", "State.new default tab")
eq(s.dirty, false, "State.new default dirty")
eq(s.selectedParty, 1, "State.new default selectedParty")
eq(s.selectedBox, 1, "State.new default selectedBox")
check(s.editingMon == nil, "State.new default editingMon nil")
State.markDirty(s)
check(s.dirty == true, "State.markDirty sets dirty")
end
-- Party/MonEditor panels: drive Kit's immediate-mode hit-testing by placing
-- the "mouse" at the exact coordinates each panel draws its widgets at
-- (mirroring the layout constants in panels/{Party,MonEditor}.lua), so the
-- click handlers run for real without a live window.
local Kit = require("Kit")
local Party = require("Party")
local MonEditor = require("MonEditor")
local Pokemon = require("src.pokemon.Pokemon")
do
local S = State.new()
S.data = Data
S.cat = Catalog.build(Data)
S.save = SaveData.newGame()
local wartortle = MonOps.create(Data, "WARTORTLE", 20)
local pidgey = MonOps.create(Data, "PIDGEY", 5)
S.save.party = { wartortle, pidgey }
S.selectedParty = 1
local px, py = 12, 80
Kit.beginFrame(px + 10, py + 24 + 22 + 5, true) -- row 2 of the list
Party.draw(S, Kit, px, py)
eq(S.selectedParty, 2, "Party list click selects row")
check(S.editingMon == pidgey, "Party list click sets editingMon")
Kit.beginFrame(px + 10, py + 200 + 10, true) -- Add button
Party.draw(S, Kit, px, py)
eq(#S.save.party, 3, "Party Add appends a mon")
check(S.dirty == true, "Party Add marks dirty")
S.dirty = false
S.selectedParty = 3
Kit.beginFrame(px + 110 + 10, py + 200 + 10, true) -- Remove button
Party.draw(S, Kit, px, py)
eq(#S.save.party, 2, "Party Remove drops selected mon")
S.selectedParty = 2
Kit.beginFrame(px + 220 + 10, py + 200 + 10, true) -- Move Up button
Party.draw(S, Kit, px, py)
eq(S.selectedParty, 1, "Party Move Up updates selection")
check(S.save.party[1] == pidgey, "Party Move Up swaps order")
end
do
local S = State.new()
S.data = Data
S.cat = Catalog.build(Data)
local mon = MonOps.create(Data, "WARTORTLE", 20)
S.editingMon = mon
local mx, my = 640, 80
local levelBefore = mon.level
local hpStatBefore = mon.stats.hp
Kit.beginFrame(mx + 148 + 10, my + 84 + 10, true) -- "+1" level button
MonEditor.draw(S, Kit, mx, my)
eq(mon.level, levelBefore + 1, "MonEditor +1 level button")
check(mon.stats.hp >= hpStatBefore, "MonEditor level up recalcs stats")
check(S.dirty == true, "MonEditor level change marks dirty")
S.dirty = false
local dvY = my + 154
local attackBefore = mon.dvs.attack
Kit.beginFrame(mx + 160 + 5, dvY + 5, true) -- attack DV "+" button
MonEditor.draw(S, Kit, mx, my)
eq(mon.dvs.attack, math.min(15, attackBefore + 1), "MonEditor DV attack + button")
local hpDvY = dvY + 4 * 30 + 6
local movesY = hpDvY + 34
local slot1Y = movesY + 24
local moveBefore = mon.moves[1] and mon.moves[1].id
Kit.beginFrame(mx + 10, slot1Y + 10, true) -- move slot 1
MonEditor.draw(S, Kit, mx, my)
check(mon.moves[1] ~= nil, "MonEditor move slot has a move after cycle")
check(mon.moves[1].id ~= moveBefore, "MonEditor move slot cycles to a different move")
local actionsY = movesY + 24 + 4 * 30 + 10
Kit.beginFrame(mx + 10, actionsY + 10, true) -- Reset moves to learnset
MonEditor.draw(S, Kit, mx, my)
local def = Data.pokemon[mon.species]
local learned = Pokemon.movesAtLevel(def, mon.level)
eq(#mon.moves, #learned, "MonEditor reset moves matches learnset size")
Kit.beginFrame(mx + 10, actionsY + 38 + 10, true) -- Close
MonEditor.draw(S, Kit, mx, my)
check(S.editingMon == nil, "MonEditor Close clears editingMon")
end
-- App.load corrupt-save vs missing-save (Important fix #2): App.load takes
-- an optional path override precisely so tests can drive this without
-- touching the real default save file.
local App = require("App")
-- App.draw() sources its click state from App.mousepressed() + the mouse
-- position at draw time (not from a Kit.beginFrame call made by the test),
-- so simulating a click means moving the mouse and pressing before drawing.
local appMouseX, appMouseY = 0, 0
love.mouse = { getPosition = function() return appMouseX, appMouseY end }
local function clickApp(x, y)
appMouseX, appMouseY = x, y
App.mousepressed(x, y, 1)
App.draw()
end
do
local tmpPath = os.tmpname() .. "-missing-save.lua"
os.remove(tmpPath)
App.load(tmpPath)
local s = App.getState()
eq(s.loadError, false, "App.load missing-file: loadError stays false")
eq(s.allowSave, true, "App.load missing-file: allowSave stays true")
check(s.status:match("No save at") ~= nil, "App.load missing-file status mentions no save")
end
do
local tmpPath = os.tmpname() .. "-corrupt-save.lua"
local f = io.open(tmpPath, "wb")
f:write("not valid lua {{{")
f:close()
App.load(tmpPath)
local s = App.getState()
eq(s.loadError, true, "App.load corrupt-file: loadError set true")
eq(s.allowSave, false, "App.load corrupt-file: allowSave set false")
check(s.status:match("Corrupt save") ~= nil, "App.load corrupt-file status mentions corrupt save")
-- Clicking Save while loadError is set must be a no-op: file on disk
-- (the corrupt real save) must not be overwritten by the stub.
clickApp(110 + 10, 6 + 10) -- Save button
local unchanged = io.open(tmpPath, "rb")
local contents = unchanged:read("*a")
unchanged:close()
eq(contents, "not valid lua {{{", "Save no-op leaves the corrupt file on disk untouched")
check(App.getState().status:match("disabled") ~= nil, "Save no-op reports a disabled status")
-- Fixing the file and Reloading must re-enable Save.
local fixed = io.open(tmpPath, "wb")
fixed:write(SaveData.encode(SaveData.newGame()))
fixed:close()
clickApp(200 + 10, 6 + 10) -- Reload button
eq(App.getState().loadError, false, "Reload after fixing the file clears loadError")
eq(App.getState().allowSave, true, "Reload after fixing the file re-enables allowSave")
os.remove(tmpPath)
end
do
-- Optional fix: quit-confirmation re-arms once new edits land, so a
-- prior "press quit again" arming doesn't leak across separate edits.
local tmpPath = os.tmpname() .. "-quitarmed-save.lua"
os.remove(tmpPath)
App.load(tmpPath)
local s = App.getState()
s._quitArmed = true
s.tab = "items"
clickApp(12 + 132 + 10, 80 + 22 + 10) -- Items panel "+10" money button
eq(App.getState()._quitArmed, false, "A fresh dirty edit resets _quitArmed")
os.remove(tmpPath)
end
do
-- Open... / App.openPath: switch to another save; dirty needs a second open.
local a = os.tmpname() .. "-open-a.lua"
local b = os.tmpname() .. "-open-b.lua"
local dataA = SaveData.newGame(); dataA.money = 111
local dataB = SaveData.newGame(); dataB.money = 222
assert(SaveIO.save(a, dataA))
assert(SaveIO.save(b, dataB))
App.load(a)
eq(App.getState().save.money, 111, "openPath setup: loaded A")
eq(App.getState().path, a, "openPath setup: path is A")
check(App.openPath(b) == true, "openPath clean switch succeeds")
eq(App.getState().path, b, "openPath updates path to B")
eq(App.getState().save.money, 222, "openPath loads B money")
eq(App.getState().dirty, false, "openPath clears dirty")
App.getState().dirty = true
check(App.openPath(a) == false, "openPath dirty first call arms confirm")
eq(App.getState().path, b, "openPath dirty first call keeps current path")
check(App.getState().status:match("Unsaved changes") ~= nil,
"openPath dirty first call status warns")
check(App.openPath(a) == true, "openPath dirty second call proceeds")
eq(App.getState().path, a, "openPath dirty second call switches path")
eq(App.getState().save.money, 111, "openPath dirty second call loads A")
check(App.openPath(b, true) == true, "openPath force=true skips arming")
eq(App.getState().path, b, "openPath force switches immediately")
-- Drag-drop uses the File:getFilename() API.
local dropped = { getFilename = function() return a end }
App.filedropped(dropped)
eq(App.getState().path, a, "filedropped opens the dropped path")
os.remove(a); os.remove(b)
for _, path in ipairs({ a, b }) do
local bak = io.popen('ls -1 "' .. path .. '.bak-"* 2>/dev/null')
if bak then
for line in bak:lines() do os.remove(line) end
bak:close()
end
end
end
print(string.format("save editor tests: %d passed, %d failed", passed, failed))
if failed > 0 then os.exit(1) end