-- Headless tests for the save editor's Boxes + Items behaviour. -- Run from repo root: luajit tests/save_editor_task6_tests.lua -- (also chained from tests/run_save_editor_tests.lua so CI covers it) -- -- These drive tools/save-editor/Ops.lua rather than clicking pixel -- coordinates. The panels are pure layout now: every rule the old click -- tests were really asserting (party/box capacity, the money clamp, the bag -- slot cap, arm-then-confirm on destructive verbs) lives in Ops, so asserting -- it there means a panel redesign cannot silently invalidate the suite. 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 task 6 tests (Boxes + Items) ==") local Data = require("src.core.Data") Data:load() local Catalog = require("Catalog") local MonOps = require("MonOps") local Ops = require("Ops") local State = require("State") local SaveData = require("src.core.SaveData") local BoxesMod = require("src.pokemon.Boxes") local PartyMod = require("src.pokemon.Party") local Bag = require("src.inventory.Bag") local function newState() local S = State.new() S.data = Data S.cat = Catalog.build(Data) S.save = SaveData.newGame() BoxesMod.ensure(S.save) return S end -- Boxes --------------------------------------------------------------- do local S = newState() local boxes = Ops.boxes(S) Ops.boxAdd(S) eq(#boxes[1], 1, "boxAdd puts a mon in the current box") check(S.dirty, "boxAdd marks the save dirty") check(S.status:match("box 1 slot 1") ~= nil, "boxAdd says where the mon landed") check(S.editingMon == boxes[1][1], "boxAdd selects the new mon for the inspector") Ops.selectBoxSlot(S, 1) eq(S.selectedBoxSlot, 1, "selectBoxSlot selects the slot") check(S.editingMon == boxes[1][1], "selectBoxSlot points the inspector at the mon") -- withdraw moves box -> party Ops.withdraw(S) eq(#boxes[1], 0, "withdraw empties the box slot") eq(#S.save.party, 1, "withdraw appends to the party") eq(S.selectedParty, 1, "withdraw selects the withdrawn mon's party slot") -- deposit moves party -> box (Boxes.deposit picks the box) Ops.deposit(S) eq(#S.save.party, 0, "deposit removes the mon from the party") eq(#boxes[1], 1, "deposit fills the current box first") check(S.status:match("into box 1") ~= nil, "deposit reports the destination box") end do -- withdraw refuses (and says so) when the party is already full local S = newState() for _ = 1, PartyMod.MAX do table.insert(S.save.party, MonOps.create(Data, S.cat.species[1], 5)) end Ops.boxAdd(S) S.dirty = false Ops.selectBoxSlot(S, 1) local ok = Ops.withdraw(S) check(ok == false, "withdraw returns false when the party is full") eq(#S.save.party, PartyMod.MAX, "withdraw with a full party leaves the party alone") eq(#Ops.boxes(S)[1], 1, "withdraw with a full party leaves the box alone") check(S.dirty == false, "a refused withdraw does not dirty the save") check(S.status:match("Party is full") ~= nil, "a refused withdraw explains itself") end do -- release is destructive: first click arms, second commits local S = newState() Ops.boxAdd(S) Ops.selectBoxSlot(S, 1) S.dirty = false check(Ops.release(S) == false, "release arms on the first call") eq(#Ops.boxes(S)[1], 1, "an armed release has not released anything yet") eq(Ops.armLabel(S, "box-release", "Release"), "Confirm?", "an armed release relabels its button") check(Ops.release(S) == true, "release commits on the second call") eq(#Ops.boxes(S)[1], 0, "the committed release empties the slot") check(S.dirty, "the committed release dirties the save") eq(Ops.armLabel(S, "box-release", "Release"), "Release", "committing disarms the button label") end do -- a full box refuses another mon local S = newState() local box = Ops.boxes(S)[1] for _ = 1, BoxesMod.CAPACITY do table.insert(box, MonOps.create(Data, S.cat.species[1], 5)) end S.dirty = false check(Ops.boxAdd(S) == false, "boxAdd refuses a full box") eq(#box, BoxesMod.CAPACITY, "a refused boxAdd adds nothing") check(S.status:match("full") ~= nil, "a refused boxAdd explains itself") end do -- box navigation wraps in both directions and follows the save's currentBox local S = newState() Ops.stepBox(S, -1) eq(S.selectedBox, BoxesMod.COUNT, "stepping back from box 1 wraps to the last box") eq(S.save.currentBox, BoxesMod.COUNT, "the save's currentBox follows the selection") Ops.stepBox(S, 1) eq(S.selectedBox, 1, "stepping forward from the last box wraps to box 1") end -- Items --------------------------------------------------------------- do local S = newState() S.save.money = 100 Ops.addMoney(S, 1000) eq(S.save.money, 1100, "addMoney adds") Ops.addMoney(S, -100000) eq(S.save.money, 0, "addMoney clamps at zero") Ops.maxMoney(S) eq(S.save.money, Ops.MONEY_MAX, "maxMoney tops the wallet out") S.dirty = false check(Ops.addMoney(S, 1000) == false, "addMoney at the cap is a no-op") check(S.dirty == false, "a no-op addMoney does not dirty the save") end do -- Game Corner coins are a Gen 1 save field too (ram/wram.asm:1908) local S = newState() Ops.addCoins(S, 250) eq(S.save.coins, 250, "addCoins writes save.coins on a Gen 1 save") Ops.addCoins(S, -1000) eq(S.save.coins, 0, "addCoins clamps at zero") Ops.maxCoins(S) eq(S.save.coins, Ops.COIN_MAX, "maxCoins fills the case") eq(S.save.player and S.save.player.coins, nil, "Gen 1 coins never land on player.coins") S.dirty = false check(Ops.addCoins(S, 100) == false, "addCoins at the cap is a no-op") check(S.dirty == false, "a no-op addCoins does not dirty the save") end do local S = newState() local id = S.cat.items[1] Ops.addToBag(S, id) eq(S.save.inventory[id], 1, "addToBag adds one") Ops.bagAdjust(S, id, 1) eq(S.save.inventory[id], 2, "bagAdjust +1 increments") Ops.bagAdjust(S, id, -1) eq(S.save.inventory[id], 1, "bagAdjust -1 decrements") Ops.bagAdjust(S, id, -1) eq(S.save.inventory[id], nil, "bagAdjust to zero clears the slot") check(S.status:match("Removed the last") ~= nil, "emptying a bag slot says so rather than printing x0") Ops.addToBag(S, id) Ops.bagAdjust(S, id, 1) Ops.bagDrop(S, id) eq(S.save.inventory[id], nil, "bagDrop removes the whole stack") eq(#Bag.order(S.save), 0, "bagDrop removes the bag order entry too") end do -- the bag has a hard slot cap; the picker must refuse past it local S = newState() local capacity = Bag.capacity(S.data) local added = 0 for _, id in ipairs(S.cat.items) do if not Ops.isBadgeId(id) and Ops.addToBag(S, id) then added = added + 1 end if added >= capacity then break end end eq(Bag.slots(S.save), capacity, "the bag filled to its cap") S.dirty = false local spare for _, id in ipairs(S.cat.items) do if not Ops.isBadgeId(id) and not S.save.inventory[id] then spare = id break end end check(spare ~= nil, "there is an item left over to try to add") check(Ops.addToBag(S, spare) == false, "addToBag refuses once the bag is full") check(S.dirty == false, "a refused addToBag does not dirty the save") check(S.status:match("Bag is full") ~= nil, "a refused addToBag explains itself") end do -- PC storage is a plain dict with a per-stack cap and no slot limit. -- A new game already seeds one item there, so the assertions below track -- the delta rather than assuming an empty dict. local S = newState() local seeded = #Ops.pcOrder(S) local id for _, candidate in ipairs(S.cat.items) do if not Ops.pcItems(S)[candidate] then id = candidate break end end check(id ~= nil, "found an item not already in PC storage") Ops.addToPc(S, id) eq(#Ops.pcOrder(S), seeded + 1, "addToPc grows the PC order by one") eq(Ops.pcItems(S)[id], 1, "addToPc creates the entry") Ops.pcAdjust(S, id, 1) eq(Ops.pcItems(S)[id], 2, "pcAdjust +1 increments") Ops.pcAdjust(S, id, -2) eq(Ops.pcItems(S)[id], nil, "pcAdjust down to zero removes the entry") Ops.addToPc(S, id) Ops.pcItems(S)[id] = Ops.STACK_MAX S.dirty = false check(Ops.pcAdjust(S, id, 1) == false, "pcAdjust refuses past the 99 stack cap") eq(Ops.pcItems(S)[id], Ops.STACK_MAX, "a refused pcAdjust changes nothing") Ops.pcDrop(S, id) eq(Ops.pcItems(S)[id], nil, "pcDrop removes the entry") eq(#Ops.pcOrder(S), seeded, "pcDrop shrinks the PC order back") end -- key item and one HM (home/list_menu.asm:474 prints no quantity for either) local function itemFixtures(S) local stack, key, hm = {}, nil, nil for _, id in ipairs(S.cat.items) do local def = S.data.items[id] if Ops.isBadgeId(id) then elseif id:find("^HM_") then hm = hm or id elseif def and def.keyItem then key = key or id elseif #stack < 3 then stack[#stack + 1] = id end end return stack, key, hm end do -- (engine/items/inventory.asm:74) local S = newState() local stack, key, hm = itemFixtures(S) check(#stack == 3 and key ~= nil and hm ~= nil, "the catalog has stackable, key and HM items to max") for _, id in ipairs(stack) do Ops.addToBag(S, id) end Ops.addToBag(S, key) Ops.addToBag(S, hm) local rows = #Bag.order(S.save) check(Ops.itemStacks(S, stack[1]) == true, "a plain item carries a quantity") check(Ops.itemStacks(S, key) == false, "a key item has no quantity to max") check(Ops.itemStacks(S, hm) == false, "an HM has no quantity to max") check(Ops.bagMax(S, stack[1]) ~= false, "bagMax fills a stack") eq(S.save.inventory[stack[1]], Ops.STACK_MAX, "bagMax lands on the 99 cap") S.dirty = false check(Ops.bagMax(S, stack[1]) == false, "bagMax at the cap is a no-op") check(S.status:match("already at x99") ~= nil, "a refused bagMax says why") check(Ops.bagMax(S, key) == false, "bagMax refuses a key item") check(Ops.bagMax(S, hm) == false, "bagMax refuses an HM") check(S.dirty == false, "a refused bagMax does not dirty the save") check(Ops.bagCanMax(S) == true, "bagCanMax sees the rows still under 99") check(Ops.bagMaxAll(S) ~= false, "bagMaxAll tops up the rest of the bag") check(S.status:match("^Maxed %d+ bag stack") ~= nil, "bagMaxAll reports how many stacks it filled") eq(S.save.inventory[stack[2]], Ops.STACK_MAX, "bagMaxAll fills the second stack") eq(S.save.inventory[stack[3]], Ops.STACK_MAX, "bagMaxAll fills the third stack") eq(S.save.inventory[key], 1, "bagMaxAll leaves a key item alone") eq(S.save.inventory[hm], 1, "bagMaxAll leaves an HM alone") eq(#Bag.order(S.save), rows, "maxing adds no bag rows") S.dirty = false check(Ops.bagMaxAll(S) == false, "a second bagMaxAll is a no-op") check(Ops.bagCanMax(S) == false, "bagCanMax is false once every stack is 99") check(S.dirty == false, "a no-op bagMaxAll does not dirty the save") local badge = Ops.badgeIds(S)[1] Ops.toggleBadge(S, badge) Ops.bagMaxAll(S) eq(S.save.inventory[badge], 1, "bagMaxAll never stacks a badge") end do local S = newState() local stack = itemFixtures(S) for _, id in ipairs(stack) do Ops.addToPc(S, id) end local pc = Ops.pcItems(S) check(Ops.pcMax(S, stack[1]) ~= false, "pcMax fills a PC stack") eq(pc[stack[1]], Ops.STACK_MAX, "pcMax lands on the 99 cap") S.dirty = false check(Ops.pcMax(S, stack[1]) == false, "pcMax at the cap is a no-op") check(Ops.pcMax(S, "NOT_A_REAL_ITEM") == false, "pcMax refuses an id that is not in PC storage") check(S.dirty == false, "a refused pcMax does not dirty the save") check(Ops.pcCanMax(S) == true, "pcCanMax sees the rows still under 99") check(Ops.pcMaxAll(S) ~= false, "pcMaxAll tops up the rest of PC storage") eq(pc[stack[2]], Ops.STACK_MAX, "pcMaxAll fills the second PC stack") eq(pc[stack[3]], Ops.STACK_MAX, "pcMaxAll fills the third PC stack") S.dirty = false check(Ops.pcMaxAll(S) == false, "a second pcMaxAll is a no-op") check(Ops.pcCanMax(S) == false, "pcCanMax is false once every PC stack is 99") check(S.dirty == false, "a no-op pcMaxAll does not dirty the save") end do local S = newState() local ids = {} for _, id in ipairs(S.cat.items) do if not Ops.isBadgeId(id) and #ids < 6 then ids[#ids + 1] = id end end for i = #ids, 1, -1 do Ops.addToBag(S, ids[i]) end Ops.bagAdjust(S, ids[1], 1) local qty = S.save.inventory[ids[1]] local order = Bag.order(S.save) local n = #order local before = {} for _, id in ipairs(order) do before[id] = true end S.dirty = false check(Ops.bagSort(S, "bogus") == false, "bagSort refuses an unknown mode") check(S.dirty == false, "a refused bagSort does not dirty the save") check(Ops.bagSort(S, "index") ~= false, "bagSort by index runs") check(Bag.order(S.save) == order, "bagSort rewrites save.bagOrder in place") eq(#order, n, "sorting drops no rows") eq(S.save.inventory[ids[1]], qty, "sorting leaves the quantities alone") local kept, ascending = true, true local seen = {} for i, id in ipairs(order) do if not before[id] or seen[id] then kept = false end seen[id] = true if i > 1 then local a = S.data.items[order[i - 1]] local b = S.data.items[id] if ((a and a.index) or math.huge) > ((b and b.index) or math.huge) then ascending = false end end end check(kept, "sorting keeps exactly the ids the bag had") check(ascending, "bagSort index leaves the bag ascending by item number") Ops.bagSort(S, "name") local byName = true for i = 2, #order do local a = S.data.items[order[i - 1]] local b = S.data.items[order[i]] local an = tostring((a and a.name) or order[i - 1]):lower() local bn = tostring((b and b.name) or order[i]):lower() if an > bn then byName = false end end check(byName, "bagSort name leaves the bag alphabetical") local first = table.concat(order, ",") Ops.bagSort(S, "name") eq(table.concat(order, ","), first, "sorting twice by the same mode is stable") local empty = newState() empty.save.inventory = {} empty.save.bagOrder = nil empty.dirty = false check(Ops.bagSort(empty, "index") == false, "an empty bag has nothing to sort") check(empty.dirty == false, "a refused bagSort on an empty bag stays clean") end do local S = newState() local ids = {} for _, id in ipairs(S.cat.items) do if not Ops.isBadgeId(id) and #ids < 4 then ids[#ids + 1] = id end end for _, id in ipairs(ids) do Ops.addToPc(S, id) end eq(S.pcSort, "index", "the PC list starts in item-number order") local byIndex = Ops.pcOrder(S) S.pcOffset = 3 S.dirty = false check(Ops.pcSort(S, "name") == true, "pcSort switches the PC view order") eq(S.pcOffset, 0, "switching the PC sort resets its scroll") check(S.dirty == false, "a view-only PC sort does not dirty the save") check(Ops.pcSort(S, "name") == false, "re-picking the same PC mode is a no-op") check(Ops.pcSort(S, "bogus") == false, "pcSort refuses an unknown mode") local byName = Ops.pcOrder(S) eq(#byName, #byIndex, "both PC orders list the same rows") local differs = false for i = 1, #byName do if byName[i] ~= byIndex[i] then differs = true end end check(differs, "the two PC orders are not the same list") S.save.pcOrder = { "STALE_ID" } S.dirty = false check(Ops.pcSort(S, "index") ~= false, "pcSort re-runs after a mode change") check(S.dirty == true, "rewriting an imported PC order dirties the save") eq(#S.save.pcOrder, #byIndex, "the imported PC order is rebuilt from the view") eq(S.save.pcOrder[1], byIndex[1], "and matches what the panel draws") end do local S = newState() local ids = {} for _, id in ipairs(S.cat.items) do if not Ops.isBadgeId(id) and #ids < 4 then ids[#ids + 1] = id end end for _, id in ipairs(ids) do Ops.addToPc(S, id) end local byIndex = Ops.pcOrder(S) S.save.pcOrder = { byIndex[#byIndex], byIndex[1] } S.dirty = false eq(S.pcSort, "index", "an imported save opens in item-number order") check(Ops.pcSort(S, "index") ~= false, "the first press rebuilds an imported order already in the default mode") check(S.dirty == true, "that rebuild dirties the save") eq(#S.save.pcOrder, #byIndex, "the rebuilt list holds every PC row") eq(S.save.pcOrder[1], byIndex[1], "in the order the panel draws") S.dirty = false check(Ops.pcSort(S, "index") == false, "a second press changes nothing") check(S.dirty == false, "and leaves the save clean") end do -- badges are truthy flags on inventory, toggled not stacked; the engine -- writes them as 1 (#515), so the editor must too local S = newState() local ids = Ops.badgeIds(S) check(#ids > 0, "the catalog exposes badge ids") local id = ids[1] Ops.toggleBadge(S, id) eq(S.save.inventory[id], 1, "toggleBadge earns the badge") Ops.toggleBadge(S, id) eq(S.save.inventory[id], nil, "toggleBadge removes the badge (nil, not false)") end print(string.format("save editor task 6 tests: %d passed, %d failed", passed, failed)) if failed > 0 then os.exit(1) end