-- 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, and each is -- its own tier in scripts/test.sh: -- tests/save_editor_task6_tests.lua -- tests/save_editor_task7_tests.lua -- tests/save_editor_task8_tests.lua -- tests/save_editor_mod_tests.lua -- tests/save_editor_gen2_tests.lua -- See tools/save-editor/README.md for the full list. -- -- All of them drive tools/save-editor/Ops.lua rather than clicking pixel -- coordinates: the panels are layout over Ops, so the rules live there and a -- redesign cannot silently invalidate the suites (which is exactly what the -- old coordinate-based tests did not survive). 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") local FsIo = require("tests.fs_io") do local path = SaveIO.defaultPath() check(type(path) == "string" and #path > 0, "defaultPath nonempty") check(path:match("save%.lua$"), "defaultPath ends with save.lua") local identity = os.getenv("POKEPORT_IDENTITY") or "pokemon-love2d" check(path:find(identity, 1, true) ~= nil, "defaultPath uses game identity folder") local sys = "" if package.config:sub(1, 1) ~= "\\" then -- no uname on Windows; the macOS-only check below just skips there local uname = io.popen("uname -s 2>/dev/null") sys = uname and uname:read("*l") or "" if uname then uname:close() end 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 = FsIo.globPrefix(path .. ".bak-") 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 -- RomExtractorGen2 stamps generation/source beside the id-keyed records, and -- they sort last because lowercase follows uppercase. #1466 do local gold = { pokemon = { generation = 2, source = "ROM", CHIKORITA = { name = "CHIKORITA" } }, items = { generation = 2, source = "ROM", POTION = { name = "POTION" } }, moves = { generation = 2, source = "ROM:Moves + MoveNames", TACKLE = { pp = 35 }, ZAP_CANNON = { pp = 5 }, }, } local cat = Catalog.build(gold) eq(#cat.moves, 2, "gold move catalog holds only real moves") eq(cat.moves[#cat.moves], "ZAP_CANNON", "and the last entry is a move, not a scalar") for _, list in pairs(cat) do for _, id in ipairs(list) do check(id ~= "generation" and id ~= "source", "no provenance scalar reached a catalog: " .. tostring(id)) end end local S = { data = gold, cat = cat } local mon = { moves = { { id = "ZAP_CANNON", pp = 5 } } } check(require("Ops").cycleMove(S, mon, 1), "cycling off the last move succeeds") eq(mon.moves[1].id, "TACKLE", "and wraps to the first move instead of a scalar") 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 roster + the docked mon inspector. Both are pure layout over -- tools/save-editor/Ops.lua, so the rules are asserted against Ops directly -- instead of against pixel coordinates the design can (and did) move. local Ops = require("Ops") 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 Ops.selectParty(S, 2) eq(S.selectedParty, 2, "selectParty selects the row") check(S.editingMon == pidgey, "selectParty points the inspector at that mon") Ops.partyAdd(S) eq(#S.save.party, 3, "partyAdd appends a mon") check(S.dirty == true, "partyAdd marks the save dirty") S.dirty = false S.selectedParty = 3 check(Ops.partyRemove(S) == false, "partyRemove arms on the first call") eq(#S.save.party, 3, "an armed partyRemove has not removed anything") check(Ops.partyRemove(S) == true, "partyRemove commits on the second call") eq(#S.save.party, 2, "the committed partyRemove drops the selected mon") S.selectedParty = 2 Ops.partyMove(S, -1) eq(S.selectedParty, 1, "partyMove up follows the mon to its new slot") check(S.save.party[1] == pidgey, "partyMove up swaps the two slots") S.dirty = false check(Ops.partyMove(S, -1) == false, "the lead mon cannot move further up") check(S.dirty == false, "a refused partyMove does not dirty the save") check(S.status:match("lead mon") ~= nil, "a refused partyMove explains itself") -- a full party refuses another mon while #S.save.party < require("src.pokemon.Party").MAX do table.insert(S.save.party, MonOps.create(Data, "PIDGEY", 5)) end S.dirty = false check(Ops.partyAdd(S) == false, "partyAdd refuses a full party") check(S.status:match("Party is full") ~= nil, "a refused partyAdd explains itself") end do local S = State.new() S.data = Data S.cat = Catalog.build(Data) S.save = SaveData.newGame() local mon = MonOps.create(Data, "WARTORTLE", 20) S.editingMon = mon local levelBefore = mon.level local hpStatBefore = mon.stats.hp Ops.setLevel(S, mon, mon.level + 1) eq(mon.level, levelBefore + 1, "setLevel raises the level") check(mon.stats.hp >= hpStatBefore, "a level change recalculates stats") check(S.dirty == true, "a level change marks the save dirty") S.dirty = false Ops.setLevel(S, mon, 999) eq(mon.level, 100, "setLevel clamps at 100") Ops.setLevel(S, mon, -5) eq(mon.level, 1, "setLevel clamps at 1") local attackBefore = mon.dvs.attack Ops.setDv(S, mon, "attack", attackBefore + 1) eq(mon.dvs.attack, math.min(15, attackBefore + 1), "setDv adjusts a DV") Ops.setDv(S, mon, "attack", 99) eq(mon.dvs.attack, 15, "setDv clamps at 15") Ops.setDv(S, mon, "attack", -1) eq(mon.dvs.attack, 0, "setDv clamps at 0") -- the HP DV is the parity nibble of the other four, never set directly eq(mon.dvs.hp, (mon.dvs.attack % 2) * 8 + (mon.dvs.defense % 2) * 4 + (mon.dvs.speed % 2) * 2 + (mon.dvs.special % 2), "setDv re-derives the HP DV from the other four") local moveBefore = mon.moves[1] and mon.moves[1].id Ops.cycleMove(S, mon, 1) check(mon.moves[1] ~= nil, "cycleMove leaves a move in the slot") check(mon.moves[1].id ~= moveBefore, "cycleMove moves on to a different move") Ops.clearMove(S, mon, 1) eq(mon.moves[1], nil, "clearMove empties the slot") S.dirty = false check(Ops.clearMove(S, mon, 1) == false, "clearing an empty slot is a no-op") check(S.dirty == false, "a no-op clearMove does not dirty the save") Ops.resetMoves(S, mon) local def = Data.pokemon[mon.species] local learned = Pokemon.movesAtLevel(def, mon.level) eq(#mon.moves, #learned, "resetMoves matches the learnset size") mon.hp = 1 Ops.healMon(S, mon) eq(mon.hp, mon.stats.hp, "healMon restores full HP") S.dirty = false check(Ops.healMon(S, mon) == false, "healing an already-full mon is a no-op") local speciesBefore = mon.species Ops.stepSpecies(S, mon, 1) check(mon.species ~= speciesBefore, "stepSpecies changes the species") eq(mon.level, 1, "stepSpecies keeps the level") end do -- Nicknames: the editor edits mon.nickname, which is nil when un-nicknamed -- (every display site reads `mon.nickname or def.name`, GenSave.lua). The -- game's naming screen caps at 10 glyphs and treats an empty confirm as "no -- nickname", so the verbs below mirror that: "" clears, a name matching the -- species' standard name normalizes back to nil, too-long or unrenderable -- names refuse with a status line, and nothing silently no-ops. local S = State.new() S.data = Data S.cat = Catalog.build(Data) S.save = SaveData.newGame() local mon = MonOps.create(Data, "CHARIZARD", 50) S.save.party = { mon } S.editingMon = mon eq(Ops.nicknameLength("POKEMON"), 7, "nicknameLength counts ASCII glyphs") eq(Ops.nicknameLength("ééé"), 3, "nicknameLength counts a multi-byte char as one glyph") eq(Ops.nicknameLength("♂♀!"), 3, "nicknameLength counts symbol glyphs") check(Ops.nicknameUsable(S, "CHARIZARD"), "ASCII letters are renderable") check(Ops.nicknameUsable(S, "Nidoking"), "lower case is renderable") check(Ops.nicknameUsable(S, "é") == true, "a charmap glyph is renderable") check(Ops.nicknameUsable(S, "PIKA€") == false, "a non-charmap glyph is not renderable") check(Ops.nicknameUsable(S, "🤖") == false, "an emoji is not renderable") -- "@" is the Gen1 string terminator: the codec has an entry for it but the -- game font has no tile, so Font.encode draws it as a space in-game check(Ops.nicknameUsable(S, "POKE@MON") == false, "the terminator @ is not a renderable nickname glyph") check(Ops.nicknameUsable(S, "POKE#MON") == false, "the # marker is not a renderable nickname glyph") -- the field gate: sanitize skips unrenderable glyphs and clamps at 10, so -- what reaches the mon can only ever be a legal Gen 1 nickname eq(Ops.nicknameSanitize(S, "PIKA\226\130\172"), "PIKA", "sanitize drops an unrenderable glyph") eq(Ops.nicknameSanitize(S, "PIKA\226\130\172CHU"), "PIKACHU", "sanitize skips a bad glyph mid-name instead of aborting the rest") eq(Ops.nicknameSanitize(S, "POKE@MON"), "POKEMON", "sanitize strips the invisible @ terminator") eq(Ops.nicknameSanitize(S, "1234567890123"), "1234567890", "sanitize clamps the draft at 10 glyphs") eq(Ops.nicknameSanitize(S, "\195\169"), "\195\169", "sanitize keeps a charmap glyph") eq(Ops.nicknameSanitize(S, ""), "", "sanitize of empty is empty") Ops.setNickname(S, mon, "SPARKY") eq(mon.nickname, "SPARKY", "setNickname stores the name") check(S.dirty == true, "setNickname marks the save dirty") eq(S.status:match("SPARKY") ~= nil, true, "setNickname narrates the new name") S.dirty = false check(Ops.setNickname(S, mon, "SPARKY") == false, "setting the same nickname again is a no-op") check(S.dirty == false, "the no-op did not dirty the save") check(S.status:match("Already nicknamed") ~= nil, "the no-op explains itself") -- a name matching the species' standard name is the un-nicknamed state Ops.setNickname(S, mon, "CHARIZARD") eq(mon.nickname, nil, "a name equal to the standard name normalizes to nil") eq(S.status:match("standard name") ~= nil, true, "the normalization explains itself") check(Ops.clearNickname(S, mon) == false, "clearing an already-un-nicknamed mon is a no-op") check(S.status:match("no nickname") ~= nil, "the no-op explains itself") -- empty input means clear, like an empty naming-screen confirm Ops.setNickname(S, mon, "SPARKY") eq(mon.nickname, "SPARKY", "re-nicknamed for the empty-clear check") check(Ops.setNickname(S, mon, "") == true, "an empty name is a valid clear") eq(mon.nickname, nil, "an empty name clears the nickname") check(S.status:match("Cleared") ~= nil, "the clear narrates") Ops.setNickname(S, mon, "1234567890") eq(mon.nickname, "1234567890", "a 10-glyph name is accepted") S.dirty = false check(Ops.setNickname(S, mon, "12345678901") == false, "an 11-glyph name is refused") eq(mon.nickname, "1234567890", "a refused name leaves the mon alone") check(S.dirty == false, "a refused name does not dirty the save") check(S.status:match("capped at 10") ~= nil, "the length refusal explains itself") check(Ops.setNickname(S, mon, "PIKA€") == false, "a name with an unrenderable glyph is refused") eq(mon.nickname, "1234567890", "a refused glyph leaves the mon alone") check(S.status:match("cannot render") ~= nil, "the glyph refusal explains itself") check(Ops.setNickname(S, mon, "POKE@MON") == false, "a name with the invisible @ terminator is refused") eq(mon.nickname, "1234567890", "a refused @ name leaves the mon alone") check(S.status:match("cannot render") ~= nil, "the @ refusal explains itself") check(Ops.setNickname(S, nil, "X") == false, "setNickname without a mon refuses") check(S.status:match("Pick a slot") ~= nil, "and explains itself") check(Ops.clearNickname(S, nil) == false, "clearNickname without a mon refuses") -- the canonical round trip: what the game reads back is the same either way mon.nickname = "SPARKY" local encoded = SaveData.encode(S.save) local back = SaveData.decode(encoded) eq(back.party[1].nickname, "SPARKY", "a nickname survives a save round trip") 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() reads the pointer at draw time, so a headless draw needs a mouse -- module. Parked off-screen: these tests call App.save/App.reload/App.close -- directly (the chrome is layout over those, exactly like the panels are -- layout over Ops) and use App.draw only as a "does the whole editor still -- paint" smoke test. love.mouse = { getPosition = function() return -1, -1 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") -- Save while loadError is set must be a no-op: the file on disk (the -- corrupt real save) must not be overwritten by the stub we are editing. App.save() 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() App.reload() 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 -- The quit / close confirmation re-arms once new edits land, so a prior -- "press quit again" arming cannot be spent discarding later changes. local tmpPath = os.tmpname() .. "-quitarmed-save.lua" os.remove(tmpPath) App.load(tmpPath) local s = App.getState() s._quitArmed = true Ops.addMoney(s, 10) eq(App.getState()._quitArmed, false, "A fresh dirty edit resets _quitArmed") eq(App.getState()._openArmed, false, "A fresh dirty edit resets _openArmed") os.remove(tmpPath) end do -- Close: unsaved edits arm once, and the teardown itself is deferred to the -- end of the frame -- doing it inline left the rest of App.draw painting -- against a state that had already been unloaded. local tmpPath = os.tmpname() .. "-close-save.lua" local f = io.open(tmpPath, "wb") f:write(SaveData.encode(SaveData.newGame())) f:close() local closed = 0 App.load(tmpPath, { version = "red", slotId = "slot1", embedded = true, onClose = function() closed = closed + 1 end }) local s = App.getState() Ops.addMoney(s, 10) check(App.close() == false, "Close with unsaved edits arms instead of leaving") eq(closed, 0, "an armed Close has not left yet") check(s.status:match("Unsaved changes") ~= nil, "an armed Close explains itself") check(App.close() == true, "a second Close goes through") eq(closed, 0, "Close does not tear down mid-dispatch") check(s._closeRequested, "Close records the request for the end of the frame") App.draw() eq(closed, 1, "the deferred Close ran once the frame finished") -- the host (main.lua's closeEditor) is what unloads; after that, events -- still in flight must not crash it App.unload() eq(App.getState(), nil, "App.unload drops the editor state") App.draw() App.keypressed("escape") App.wheelmoved(0, 1) eq(App.quit(), false, "a torn-down editor never blocks quit") check(true, "post-close events are tolerated") os.remove(tmpPath) for _, bak in ipairs(FsIo.globPrefix(tmpPath .. ".bak-")) do os.remove(bak) end end do -- Save and Reload need a modifier: a bare letter key is one stray keystroke -- away from writing the file, and there is no undo. local tmpPath = os.tmpname() .. "-shortcut-save.lua" local f = io.open(tmpPath, "wb") f:write(SaveData.encode(SaveData.newGame())) f:close() App.load(tmpPath) local s = App.getState() local before = s.save.money Ops.addMoney(s, 10) check(s.dirty, "the edit landed") love.keyboard = { isDown = function() return false end } App.keypressed("s") check(App.getState().dirty, "bare s does not save") App.keypressed("r") eq(App.getState().save.money, before + 10, "bare r does not discard the edit") love.keyboard = { isDown = function() return true end } App.keypressed("s") check(App.getState().dirty == false, "Cmd/Ctrl+S saves") love.keyboard = { isDown = function() return false end } os.remove(tmpPath) for _, bak in ipairs(FsIo.globPrefix(tmpPath .. ".bak-")) do os.remove(bak) end end do -- #476: on Android a high-DPI 1560x720 capture can leave the editor with -- only a compact logical viewport. The Items picker must not hand a -- negative list height to love.graphics.setScissor in that layout. local tmpPath = os.tmpname() .. "-items-compact-save.lua" local f = io.open(tmpPath, "wb") f:write(SaveData.encode(SaveData.newGame())) f:close() local oldDimensions = love.graphics.getDimensions local oldScissor = love.graphics.setScissor love.graphics.getDimensions = function() return 520, 240 end love.graphics.setScissor = function(_, _, width, height) if width and (width < 0 or height < 0) then error("Can't set scissor with negative width and/or height.") end end App.load(tmpPath, { version = "red" }) App.getState().tab = "items" local ok, err = pcall(App.draw) check(ok, "the Items tab draws in a compact Android viewport: " .. tostring(err)) love.graphics.getDimensions = oldDimensions love.graphics.setScissor = oldScissor os.remove(tmpPath) for _, bak in ipairs(FsIo.globPrefix(tmpPath .. ".bak-")) do os.remove(bak) end end do -- Whole-editor smoke test: every tab has to survive a real headless draw, -- which is what catches a layout that divides by a nil font metric or -- indexes a save field the panel assumed was always present. local tmpPath = os.tmpname() .. "-draw-save.lua" local data = SaveData.newGame() data.party = { MonOps.create(Data, "CHARIZARD", 100) } local f = io.open(tmpPath, "wb") f:write(SaveData.encode(data)) f:close() App.load(tmpPath, { version = "red" }) local s = App.getState() for _, tab in ipairs({ "party", "boxes", "items", "events", "map", "dex" }) do s.tab = tab local ok, err = pcall(App.draw) check(ok, "the " .. tab .. " tab draws headlessly: " .. tostring(err)) end -- and with a mon selected, which is a different code path in the inspector s.tab = "party" Ops.selectParty(s, 1) local ok, err = pcall(App.draw) check(ok, "the party inspector draws with a selection: " .. tostring(err)) os.remove(tmpPath) for _, bak in ipairs(FsIo.globPrefix(tmpPath .. ".bak-")) do os.remove(bak) end 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 for _, bak in ipairs(FsIo.globPrefix(path .. ".bak-")) do os.remove(bak) end end end do -- #515: badge read/write agreement between SaveData/the in-game grant -- and the editor. checkVictoryRewards (src/world/OverworldController.lua) -- writes save.inventory[badge] = 1, a truthy number, not the boolean -- `true` the editor used to compare against with `== true`. This drives -- the same field through a real save encode/decode round trip and checks -- src/inventory/Badges.count (what the in-game badge case/count reads) -- agrees with what the editor's own badge state shows. local Badges = require("src.inventory.Badges") local save = SaveData.newGame() local id = Badges.list(Data)[1].id -- simulate the in-game grant's exact representation, not the editor's save.inventory[id] = 1 eq(Badges.count(Data, save), 1, "Badges.count sees a numeric 1 grant as earned") local encoded = SaveData.encode(save) local back = SaveData.decode(encoded) eq(back.inventory[id], 1, "save round trip preserves the numeric badge flag") eq(Badges.count(Data, back), 1, "Badges.count still agrees after the round trip") local S = State.new() S.data = Data S.cat = Catalog.build(Data) S.save = back check(Ops.badgeIds(S)[1] ~= nil, "the editor's badge catalog is non-empty") check(S.save.inventory[id] and true or false, "the editor's own truthy read (panel badge chip state) sees the grant as earned") -- toggling off then on again round-trips through the editor's own write -- shape and still agrees with Badges.count Ops.toggleBadge(S, id) eq(S.save.inventory[id], nil, "toggleBadge clears the badge (nil, not false)") eq(Badges.count(Data, S.save), 0, "Badges.count agrees once cleared") Ops.toggleBadge(S, id) eq(S.save.inventory[id], 1, "toggleBadge re-earns the badge as a truthy 1, matching the in-game grant") eq(Badges.count(Data, S.save), 1, "Badges.count agrees once re-earned") end do -- #529: focusing a text field raises the OS soft keyboard on Android/iOS -- (love.keyboard.setTextInput(true, x, y, w, h)) and blurring lowers it; -- desktop raises the same way but never lowers, since setTextInput is -- global SDL state and the launcher's own text fields (RomImporter slot -- rename, ROM finder) depend on it staying enabled. -- -- This is the closest honest check this checkout can run: the real soft -- keyboard is OS chrome outside the LOVE frame, unreachable by any -- driver. A human still has to verify on an Android build that the -- keyboard visibly rises over the Items search bar, typed characters -- filter the list, and Enter/Escape/switching tabs lowers it again. local Kit = require("Kit") local calls = {} local savedKeyboard, savedSystem = love.keyboard, love.system local function stubOS(name) love.system = { getOS = function() return name end } love.keyboard = { isDown = function() return false end, setTextInput = function(...) calls[#calls + 1] = { ... } end, } end -- Android: focusing raises with the field's rect, restaying focused on -- the same id does not re-raise, and blur lowers it. stubOS("Android") Kit.focus = nil Kit.beginFrame(15, 15, true) Kit.textfield("kb-test", 10, 10, 100, 20, "", "type here") eq(#calls, 1, "Android: focusing a field raises the soft keyboard") check(calls[1][1] == true, "Android: raise call passes enable=true") eq(calls[1][2], 10, "Android: raise call passes the field's x") eq(calls[1][3], 10, "Android: raise call passes the field's y") eq(calls[1][4], 100, "Android: raise call passes the field's w") eq(calls[1][5], 20, "Android: raise call passes the field's h") Kit.beginFrame(15, 15, false) Kit.textfield("kb-test", 10, 10, 100, 20, "abc", "type here") eq(#calls, 1, "Android: staying focused on the same field does not re-raise") Kit.blur() eq(#calls, 2, "Android: blur lowers the soft keyboard") eq(calls[2][1], false, "Android: lower call passes enable=false") check(Kit.focus == nil, "blur clears Kit.focus") -- Desktop: focusing still raises (harmless there), but blur must not -- disable text input globally -- the launcher's own fields rely on it -- staying on. calls = {} stubOS("Mac OS X") Kit.beginFrame(65, 65, true) Kit.textfield("kb-test2", 60, 60, 80, 24, "", "") eq(#calls, 1, "desktop: focusing a field still raises setTextInput") Kit.blur() eq(#calls, 1, "desktop: blur does not call setTextInput(false)") love.keyboard, love.system = savedKeyboard, savedSystem Kit.focus = nil end do -- #541: changing species took the editor down. The inspector's arrows -- called MonOps.setSpecies on whatever record came next in the catalog, and -- the stat recalculation indexes baseStats. unconditionally -- (src/pokemon/Stats.lua, ported from home/move_mon.asm CalcStat) because -- the asm's BaseStats is a fixed 151-entry table with no partial rows. The -- editor's catalog is NOT that table: it is every key in Data.pokemon after -- the mod merge, and a partial record survives that merge as a warning -- rather than a rejection (src/mods/Schemas.lua R.pokemon). So the sweep -- below is the real check -- every id the catalog offers has to either -- assign or refuse in words, and neither may raise. local S = State.new() S.data = Data S.save = SaveData.newGame() -- a mod-shaped partial record: registered, listed, missing the stats the -- Gen1 formulas read Data.pokemon.TESTMON_PARTIAL = { name = "TESTMON", dex = 0, baseStats = { hp = 40, attack = 30 }, growthRate = "MEDIUM_FAST", types = { "NORMAL" }, learnset = {} } S.cat = Catalog.build(Data) check(Ops.speciesUsable(S, "PIKACHU"), "a complete record is usable") check(Ops.speciesUsable(S, "TESTMON_PARTIAL") == false, "a record missing base stats is not usable") check(Ops.speciesUsable(S, "NO_SUCH_SPECIES") == false, "an id that is not in the data at all is not usable") local mon = MonOps.create(Data, "WARTORTLE", 20) S.editingMon = mon local statsBefore = mon.stats.attack check(Ops.setSpecies(S, mon, "TESTMON_PARTIAL") == false, "setSpecies refuses a record the formulas cannot use") eq(mon.species, "WARTORTLE", "a refused setSpecies leaves the mon alone") eq(mon.stats.attack, statsBefore, "a refused setSpecies leaves the stats alone") check(S.status:match("base stats") ~= nil, "a refused setSpecies explains itself") check(S.dirty == false, "a refused setSpecies does not dirty the save") -- the crash itself: walk the whole catalog the way the arrows did local landed = {} local walkOk, walkErr = pcall(function() for _ = 1, #S.cat.species do Ops.stepSpecies(S, mon, 1) landed[mon.species] = true assert(Ops.speciesUsable(S, mon.species), "stepSpecies parked on " .. tostring(mon.species)) end end) check(walkOk, "cycling the whole catalog never errors: " .. tostring(walkErr)) check(landed.TESTMON_PARTIAL == nil, "cycling steps over an unusable record") check(landed.PIKACHU, "cycling still reaches ordinary species") local backOk, backErr = pcall(function() for _ = 1, #S.cat.species do Ops.stepSpecies(S, mon, -1) end end) check(backOk, "cycling backwards never errors: " .. tostring(backErr)) -- and every real id assigns, so "nothing crashes" cannot be bought by -- refusing everything local assigned, refused = 0, 0 for _, id in ipairs(S.cat.species) do if id ~= "TESTMON_PARTIAL" then local ok, err = pcall(Ops.setSpecies, S, mon, id) check(ok, "setSpecies " .. id .. ": " .. tostring(err)) if ok and mon.species == id then assigned = assigned + 1 else refused = refused + 1 end end end eq(refused, 0, "no real species is refused") check(assigned > 140, "the whole dex assigns (" .. assigned .. " species)") Data.pokemon.TESTMON_PARTIAL = nil end do -- #541 search predicate. The picker replaced the arrows, so the filter is -- the only way to reach a species now and its rules are worth pinning: ids -- and names substring-match case-insensitively, a dex number matches whole -- (a substring match would answer "25" with ELECTABUZZ, #125), and the -- query is plain text, not a Lua pattern. local S = State.new() S.data = Data S.cat = Catalog.build(Data) S.save = SaveData.newGame() check(Ops.speciesMatches(S, "PIKACHU", "pika"), "lowercase query matches an id") check(Ops.speciesMatches(S, "PIKACHU", "KACH"), "a mid-word substring matches") check(Ops.speciesMatches(S, "PIKACHU", "25"), "a bare dex number matches") check(Ops.speciesMatches(S, "PIKACHU", "025"), "the padded dex number matches") check(Ops.speciesMatches(S, "ELECTABUZZ", "25") == false, "a dex number does not substring-match #125") check(Ops.speciesMatches(S, "PIKACHU", ""), "an empty query matches everything") eq(#Ops.speciesSearch(S, ""), #S.cat.species, "an empty search lists the catalog") -- a dot is a literal, not a pattern wildcard: MR.MIME is the one species -- whose printed name carries one, so it is the whole result local dots = Ops.speciesSearch(S, ".") eq(#dots, 1, "a dot matches literally instead of matching everything") eq(dots[1], "MR_MIME", "and the literal it matched is MR.MIME's name") eq(#Ops.speciesSearch(S, "%a"), 0, "a pattern class is literal too") check(#Ops.speciesSearch(S, "zzzznope") == 0, "a miss returns nothing") local pika = Ops.speciesSearch(S, "pikachu") eq(#pika, 1, "an exact name search narrows to one") eq(pika[1], "PIKACHU", "and it is the right one") end do -- #541 end to end through App: open the picker off a selected slot, type, -- commit with Enter. Enter and Escape have to be taken before the focused -- field sees them -- Kit maps both to the same "\r" edit, which cannot tell -- "commit the top match" from "give up". local Kit = require("Kit") local SpeciesPicker = require("SpeciesPicker") local tmpPath = os.tmpname() .. "-picker-save.lua" local data = SaveData.newGame() data.party = { MonOps.create(Data, "WARTORTLE", 20) } local f = io.open(tmpPath, "wb") f:write(SaveData.encode(data)) f:close() App.load(tmpPath, { version = "red" }) local S = App.getState() S.tab = "party" check(Ops.openSpeciesPicker(S, Kit) == false, "the picker refuses to open with no slot selected") check(S.speciesPicker == nil, "and it stayed closed") check(S.status:match("Pick a slot") ~= nil, "and it said why") Ops.selectParty(S, 1) check(Ops.openSpeciesPicker(S, Kit) == true, "the picker opens on a selected slot") check(S.speciesPicker ~= nil, "the picker is up") eq(S.speciesPicker.query, "", "it opens with an empty query") eq(Kit.focus, "species-picker", "it opens with the field focused (#529 keyboard)") local ok, err = pcall(App.draw) check(ok, "the picker draws headlessly: " .. tostring(err)) App.textinput("PIKACHU") ok, err = pcall(App.draw) check(ok, "the picker draws while typing: " .. tostring(err)) eq(S.speciesPicker.query, "PIKACHU", "typing reaches the picker's field") eq(#SpeciesPicker.results(S), 1, "the list narrowed to the typed species") App.keypressed("return") eq(S.save.party[1].species, "PIKACHU", "Enter commits the top match") check(S.speciesPicker == nil, "and closes the picker") check(S.dirty, "and the save is dirty") -- Escape leaves without touching the mon Ops.openSpeciesPicker(S, Kit) App.textinput("BULBASAUR") App.draw() App.keypressed("escape") check(S.speciesPicker == nil, "Escape closes the picker") eq(S.save.party[1].species, "PIKACHU", "Escape did not commit anything") check(S.editingMon ~= nil, "Escape closed the picker, not the selection") -- a query nothing matches cannot commit Ops.openSpeciesPicker(S, Kit) App.textinput("zzzznope") App.draw() App.keypressed("return") check(S.speciesPicker ~= nil, "Enter on an empty result set keeps the picker up") check(S.status:match("No species matches") ~= nil, "and says so") eq(S.save.party[1].species, "PIKACHU", "and changes nothing") Ops.closeSpeciesPicker(S, Kit) os.remove(tmpPath) for _, bak in ipairs(FsIo.globPrefix(tmpPath .. ".bak-")) do os.remove(bak) end end do -- Move picker search predicate: same plain-text rules as species (#541), -- plus type substring and whole-number power / accuracy matches. local S = State.new() S.data = Data S.cat = Catalog.build(Data) S.save = SaveData.newGame() check(Ops.moveMatches(S, "THUNDERBOLT", "thunder"), "lowercase query matches an id") check(Ops.moveMatches(S, "THUNDERBOLT", "BOLT"), "a mid-word substring matches") check(Ops.moveMatches(S, "THUNDERBOLT", "electric"), "a type substring matches") check(Ops.moveMatches(S, "THUNDERBOLT", "95"), "a whole power matches") check(Ops.moveMatches(S, "THUNDERBOLT", "100"), "a whole accuracy matches") check(Ops.moveMatches(S, "THUNDERBOLT", "9") == false, "a power/accuracy number does not substring-match") check(Ops.moveMatches(S, "THUNDERBOLT", ""), "an empty query matches everything") check(Ops.moveUsable(S, "THUNDERBOLT"), "a real move is usable") check(Ops.moveUsable(S, "generation") == false, "a provenance scalar is not usable") eq(#Ops.moveSearch(S, ""), #S.cat.moves, "an empty search lists the catalog") eq(#Ops.moveSearch(S, "%a"), 0, "a pattern class is literal") check(#Ops.moveSearch(S, "zzzznope") == 0, "a miss returns nothing") local bolt = Ops.moveSearch(S, "thunderbolt") eq(#bolt, 1, "an exact name search narrows to one") eq(bolt[1], "THUNDERBOLT", "and it is the right one") -- Prefix beats mid-string: "sur" used to list ACUPRESSURE / FISSURE before -- SURF because the catalog is A-Z. Rank so Enter commits the obvious hit. local sur = Ops.moveSearch(S, "sur") check(#sur >= 1, "sur finds at least one move") eq(sur[1], "SURF", "a prefix match ranks above mid-string hits") local thunder = Ops.moveSearch(S, "thunder") check(#thunder >= 1, "thunder finds at least one move") eq(thunder[1], "THUNDER", "THUNDER prefixes beat THUNDERBOLT / THUNDERSHOCK") end do -- Move picker end to end through App: open off a move row, type, commit -- with Enter. Same Enter/Escape-before-Kit rule as the species picker. local Kit = require("Kit") local MovePicker = require("MovePicker") local tmpPath = os.tmpname() .. "-movepicker-save.lua" local data = SaveData.newGame() data.party = { MonOps.create(Data, "WARTORTLE", 20) } local f = io.open(tmpPath, "wb") f:write(SaveData.encode(data)) f:close() App.load(tmpPath, { version = "red" }) local S = App.getState() S.tab = "party" check(Ops.openMovePicker(S, Kit, 1) == false, "the move picker refuses to open with no slot selected") check(S.movePicker == nil, "and it stayed closed") check(S.status:match("Pick a slot") ~= nil, "and it said why") Ops.selectParty(S, 1) check(Ops.openMovePicker(S, Kit, 0) == false, "slot 0 is refused") check(Ops.openMovePicker(S, Kit, 1) == true, "the move picker opens on slot 1") check(S.movePicker ~= nil, "the picker is up") eq(S.movePicker.slot, 1, "for the requested slot") eq(S.movePicker.query, "", "it opens with an empty query") eq(Kit.focus, "move-picker", "it opens with the field focused (#529 keyboard)") local ok, err = pcall(App.draw) check(ok, "the move picker draws headlessly: " .. tostring(err)) App.textinput("THUNDERBOLT") ok, err = pcall(App.draw) check(ok, "the move picker draws while typing: " .. tostring(err)) eq(S.movePicker.query, "THUNDERBOLT", "typing reaches the picker's field") eq(#MovePicker.results(S), 1, "the list narrowed to the typed move") App.keypressed("return") eq(S.save.party[1].moves[1].id, "THUNDERBOLT", "Enter commits the top match") check(S.movePicker == nil, "and closes the picker") check(S.dirty, "and the save is dirty") -- Escape leaves without touching the mon local before = S.save.party[1].moves[1].id Ops.openMovePicker(S, Kit, 1) App.textinput("SURF") App.draw() App.keypressed("escape") check(S.movePicker == nil, "Escape closes the move picker") eq(S.save.party[1].moves[1].id, before, "Escape did not commit anything") check(S.editingMon ~= nil, "Escape closed the picker, not the selection") -- a query nothing matches cannot commit Ops.openMovePicker(S, Kit, 2) App.textinput("zzzznope") App.draw() App.keypressed("return") check(S.movePicker ~= nil, "Enter on an empty result set keeps the picker up") check(S.status:match("No move matches") ~= nil, "and says so") Ops.closeMovePicker(S, Kit) -- Ops.setMove refuses a scalar / missing id check(Ops.setMove(S, S.editingMon, 1, "generation") == false, "setMove refuses a provenance scalar") eq(S.save.party[1].moves[1].id, "THUNDERBOLT", "and leaves the slot alone") os.remove(tmpPath) for _, bak in ipairs(FsIo.globPrefix(tmpPath .. ".bak-")) do os.remove(bak) end end do -- The inspector's nickname field is commit-on-Enter: the draft lives in -- S.nicknameDraft while typing, Enter commits it through Ops.setNickname, -- and Escape discards it. Drive it through App the way a player would: -- focus the field (a click is just a Kit.focus assignment here), type, -- drain the edits with a draw, then press Enter / Escape. local Kit = require("Kit") local tmpPath = os.tmpname() .. "-nickname-save.lua" local data = SaveData.newGame() data.party = { MonOps.create(Data, "CHARIZARD", 50) } local f = io.open(tmpPath, "wb") f:write(SaveData.encode(data)) f:close() App.load(tmpPath, { version = "red" }) local S = App.getState() S.tab = "party" Ops.selectParty(S, 1) local mon = S.editingMon eq(mon.nickname, nil, "the save starts un-nicknamed") -- type "SPARKY" and commit with Enter Kit.focus = "mon-nickname" App.textinput("SPARKY") App.draw() eq(S.nicknameDraft, "SPARKY", "typed text lands in the draft") App.keypressed("return") eq(mon.nickname, "SPARKY", "Enter commits the draft to the mon") check(S.dirty == true, "the commit marks the save dirty") check(Kit.focus == nil, "Enter blurs the field") S.dirty = false -- The field has no select-all, so a rename is backspace-then-type (the -- caret parks at the end, exactly like the editor's other fields). local function clearField(n) Kit.focus = "mon-nickname" for _ = 1, n do App.keypressed("backspace") end App.draw() end -- type junk, then Escape: nothing is committed and the draft is discarded clearField(#mon.nickname) App.textinput("ZEPTO") App.draw() eq(S.nicknameDraft, "ZEPTO", "the draft holds the new typing") App.keypressed("escape") eq(mon.nickname, "SPARKY", "Escape does not commit") eq(S.nicknameDraft, "SPARKY", "Escape resets the draft to the committed name") check(Kit.focus == nil, "Escape blurs the field") -- an unrenderable glyph is blocked AT INPUT: the euro sign never reaches -- the draft, so the field can only ever hold what the game can render clearField(#mon.nickname) App.textinput("PIKA\226\130\172") -- PIKA + euro sign, not a charmap glyph App.draw() eq(S.nicknameDraft, "PIKA", "an unrenderable glyph is dropped at input") App.keypressed("return") eq(mon.nickname, "PIKA", "the clean draft commits on Enter") -- and the 10-glyph cap blocks extra input the same way clearField(#mon.nickname) App.textinput("123456789012345") App.draw() eq(S.nicknameDraft, "1234567890", "typing past 10 glyphs clamps at 10") -- the @ terminator never reaches the draft either: it draws as a space -- in-game, so the field strips it like any other unrenderable glyph. The -- clamp test above left an uncommitted draft, so clear the whole draft. clearField(#S.nicknameDraft) App.textinput("POKE@MON") App.draw() eq(S.nicknameDraft, "POKEMON", "the @ terminator is stripped at input") os.remove(tmpPath) for _, bak in ipairs(FsIo.globPrefix(tmpPath .. ".bak-")) do os.remove(bak) end end do -- #541 modal shield. Kit hit-tests without a z-order, so the picker cannot -- simply be drawn last: the chrome and the panel underneath would take the -- same tap. App raises Kit.blockClicks around everything it draws before -- the picker and lowers it for the picker's own layer, which is asserted -- here by watching what Kit.press sees over one frame rather than by -- clicking coordinates the design is free to move. local Kit = require("Kit") local tmpPath = os.tmpname() .. "-shield-save.lua" local data = SaveData.newGame() data.party = { MonOps.create(Data, "WARTORTLE", 20) } local f = io.open(tmpPath, "wb") f:write(SaveData.encode(data)) f:close() App.load(tmpPath, { version = "red" }) local S = App.getState() S.tab = "party" Ops.selectParty(S, 1) -- the shield itself: a raised shield refuses a click that hits Kit.beginFrame(15, 15, true) Kit.blockClicks = false check(Kit.press(10, 10, 100, 20) == true, "an unshielded press takes the click") Kit.blockClicks = true check(Kit.press(10, 10, 100, 20) == false, "a shielded press refuses it") Kit.blockClicks = false Kit.endFrame() local realPress = Kit.press local seen = {} Kit.press = function(...) seen[#seen + 1] = Kit.blockClicks return realPress(...) end Ops.openSpeciesPicker(S, Kit) seen = {} App.draw() check(#seen > 0, "the opening frame dispatched clicks at all") local allShielded = true for _, v in ipairs(seen) do allShielded = allShielded and (v == true) end check(allShielded, "on the opening frame even the picker's own layer is shielded, so the " .. "click that opened it cannot read as a tap outside") seen = {} App.draw() check(seen[1] == true, "the frame under an open picker is shielded") check(seen[#seen] == false, "the picker's own layer is not") check(Kit.blockClicks == false, "the shield is down again once the frame ends") Kit.press = realPress -- a Close taken with the picker up must not leak the shield into the next -- session, which would open deaf to every click Kit.blockClicks = true App.unload() check(Kit.blockClicks == false, "unload lowers the shield") os.remove(tmpPath) for _, bak in ipairs(FsIo.globPrefix(tmpPath .. ".bak-")) do os.remove(bak) end end do -- #497 shrank the layout to fit a phone's width; #715 replaced that with -- reflow. The scale never dips below the 0.9 readability floor now: a -- narrow window keeps readable fonts and 26px tap targets and the panels -- stack / drop columns / scroll instead of shrinking. The width term -- (width/640) only stops a portrait phone from inflating to the 1.6 cap -- its height alone would buy. local Kit = require("Kit") local Theme = require("Theme") local function about(got, want, msg) check(math.abs(got - want) < 0.001, msg .. string.format(" (got %.4f, want %.4f)", got, want)) end local previousOS = love.system.getOS for _, platform in ipairs({ "Android", "iOS", "NX" }) do love.system.getOS = function() return platform end for _, size in ipairs({ { 720, 1560, 1.4625 }, { 1560, 720, 1.21875 }, { 360, 640, 1.17 }, { 500, 800, 1.17 }, { 1920, 1080, 1.828125 } }) do about(Kit.layout(size[1], size[2]), size[3], ("%s %dx%d keeps touch-readable scale"):format(platform, size[1], size[2])) check(not Kit.desktop, platform .. " keeps touch layout at every window size") end end for _, platform in ipairs({ "OS X", "Windows", "Linux" }) do love.system.getOS = function() return platform end for _, size in ipairs({ { 1280, 800, 1.0416666667 }, { 1024, 768, 1 }, { 1920, 1080, 1.15 }, { 1440, 900, 1.15 }, { 2560, 1440, 1.15 } }) do about(Kit.layout(size[1], size[2]), size[3], ("%s %dx%d keeps compact desktop scale"):format(platform, size[1], size[2])) check(Kit.desktop, platform .. " selects desktop layout at desktop sizes") end about(Kit.layout(900, 700), 1.1848958333, platform .. " narrow window reflows with readable scale") check(not Kit.desktop, platform .. " narrow window uses reflow layout") end love.system.getOS = previousOS end do -- #497 draw pass at the two shapes the report came in on (720x1560 and -- 1560x720, an A20s held either way). The layout is what a human has to -- judge, but a panel that lays itself out at a negative width is machine -- visible: LOVE rejects a negative scissor, so the inspector's own clip -- catches the collapse the roster used to cause by keeping an absolute -- 300px floor on a 720px-wide window. local Kit = require("Kit") local Theme = require("Theme") local tmpPath = os.tmpname() .. "-phone-save.lua" local data = SaveData.newGame() data.party = { MonOps.create(Data, "CHARIZARD", 100), MonOps.create(Data, "PIDGEY", 5) } local f = io.open(tmpPath, "wb") f:write(SaveData.encode(data)) f:close() local oldDimensions = love.graphics.getDimensions local oldScissor = love.graphics.setScissor love.graphics.setScissor = function(_, _, width, height) if width and (width < 0 or height < 0) then error(("negative scissor %sx%s"):format(tostring(width), tostring(height))) end end -- 720x1280 / 1280x720 are the #715 report's shapes (Android, both -- orientations): the Map tab used to lay its viewport out at a negative -- width in portrait and crash on the scissor. RGxxx / Switch shapes pin -- short-landscape handhelds (RG34XXSP 720x480, RG35XX 640x480, NX 1280x720) -- and a tiny 360x640 phone. Desktop sizes keep the layouts that already -- worked. for _, size in ipairs({ { 720, 1560 }, { 1560, 720 }, { 480, 1040 }, { 1280, 800 }, { 720, 1280 }, { 1280, 720 }, { 720, 480 }, { 640, 480 }, { 480, 320 }, { 1024, 768 }, { 1920, 1080 }, { 360, 640 } }) do love.graphics.getDimensions = function() return size[1], size[2] end App.load(tmpPath, { version = "red" }) local S = App.getState() local label = ("%dx%d"):format(size[1], size[2]) for _, tab in ipairs({ "party", "boxes", "items", "events", "map", "dex" }) do S.tab = tab local ok, err = pcall(App.draw) check(ok, ("the %s tab draws at %s: %s"):format(tab, label, tostring(err))) end check((S._mapViewW or 0) >= 0 and (S._mapViewH or 0) >= 0, ("the map viewport stays non-negative at %s (#715)"):format(label)) S.tab = "party" Ops.selectParty(S, 1) local ok, err = pcall(App.draw) check(ok, ("the inspector draws at %s: %s"):format(label, tostring(err))) Ops.openSpeciesPicker(S, Kit) ok, err = pcall(App.draw) check(ok, ("the species picker draws at %s: %s"):format(label, tostring(err))) ok, err = pcall(App.draw) check(ok, ("the species picker redraws at %s: %s"):format(label, tostring(err))) Ops.closeSpeciesPicker(S, Kit) Ops.openMovePicker(S, Kit, 1) ok, err = pcall(App.draw) check(ok, ("the move picker draws at %s: %s"):format(label, tostring(err))) ok, err = pcall(App.draw) check(ok, ("the move picker redraws at %s: %s"):format(label, tostring(err))) Ops.closeMovePicker(S, Kit) end love.graphics.getDimensions = oldDimensions love.graphics.setScissor = oldScissor os.remove(tmpPath) for _, bak in ipairs(FsIo.globPrefix(tmpPath .. ".bak-")) do os.remove(bak) end end do -- #715 reflow audit. Kit records every control that could take a click -- while Kit.audit is set (shielded widgets are skipped, since a modal -- legitimately covers what it shields). The sweep below drives every tab -- at the window shapes the reflow has to serve and FAILS if any two -- controls overlap or any control escapes the window, which is exactly -- the "buttons covering things" class of bug the shrink-to-fit layout -- kept producing. Rects clip to the region that bounds their hit test, -- so a row scrolled out of a list is not a phantom overlap. local Kit = require("Kit") local function clipped(r) local x1, y1, x2, y2 = r.x, r.y, r.x + r.w, r.y + r.h if r.clip then x1 = math.max(x1, r.clip.x); y1 = math.max(y1, r.clip.y) x2 = math.min(x2, r.clip.x + r.clip.w); y2 = math.min(y2, r.clip.y + r.clip.h) end if x2 - x1 <= 1 or y2 - y1 <= 1 then return nil end return x1, y1, x2, y2 end local function overlap(a, b) local ax1, ay1, ax2, ay2 = clipped(a) if not ax1 then return false end local bx1, by1, bx2, by2 = clipped(b) if not bx1 then return false end return math.min(ax2, bx2) - math.max(ax1, bx1) > 1 and math.min(ay2, by2) - math.max(ay1, by1) > 1 end local function auditFrame(label, W, H) local rects = Kit.audit local controls = {} for _, r in ipairs(rects) do if r.class == "control" then controls[#controls + 1] = r end end check(#controls > 0, label .. ": the frame dispatched controls at all") local collisions, escapes = 0, 0 for i = 1, #controls do local a = controls[i] local x1, y1, x2, y2 = clipped(a) if x1 and (x1 < -0.5 or y1 < -0.5 or x2 > W + 0.5 or y2 > H + 0.5) then escapes = escapes + 1 print((" escape: %s (%.0f,%.0f %.0fx%.0f)") :format(a.label, a.x, a.y, a.w, a.h)) end for j = i + 1, #controls do if overlap(a, controls[j]) then collisions = collisions + 1 print((" overlap: '%s' vs '%s' at (%.0f,%.0f) / (%.0f,%.0f)") :format(a.label, controls[j].label, a.x, a.y, controls[j].x, controls[j].y)) end end end check(collisions == 0, label .. ": no two controls overlap") check(escapes == 0, label .. ": every control stays inside the window") end local tmpPath = os.tmpname() .. "-audit-save.lua" local data = SaveData.newGame() data.party = {} for i = 1, require("src.pokemon.Party").MAX do data.party[i] = MonOps.create(Data, i % 2 == 0 and "PIDGEY" or "CHARIZARD", 10 * i) end local f = io.open(tmpPath, "wb") f:write(SaveData.encode(data)) f:close() local oldDimensions = love.graphics.getDimensions -- Include RG34XXSP (720x480) and Switch handheld; keep 640x480 out of the -- overlap audit (Map still needs its own short-landscape pass) but the -- phone draw suite above already covers it. local sizes = { { 500, 800 }, { 720, 1280 }, { 1280, 720 }, { 720, 480 }, { 1024, 768 }, { 900, 700 }, { 1920, 1080 } } for _, size in ipairs(sizes) do local W, H = size[1], size[2] love.graphics.getDimensions = function() return W, H end App.load(tmpPath, { version = "red" }) local S = App.getState() -- populate the panels the fresh save leaves empty, so their controls -- (quantity rows, box cells, dock rows, flags) are exercised too Ops.selectParty(S, 1) Ops.boxAdd(S); Ops.boxAdd(S) Ops.addToBag(S, S.cat.items[1]) Ops.addToPc(S, S.cat.items[2]) Ops.setFlag(S, "EVENT_GOT_POKEDEX", true) for _, tab in ipairs({ "party", "boxes", "items", "events", "map", "dex" }) do S.tab = tab Kit.audit = {} local ok, err = pcall(App.draw) check(ok, ("%dx%d %s draws: %s"):format(W, H, tab, tostring(err))) if ok then auditFrame(("%dx%d %s"):format(W, H, tab), W, H) end Kit.audit = nil end -- the species picker dialog reflows too; frame 2, since the opening -- frame is fully shielded by design (#541) and would audit empty S.tab = "party" Ops.openSpeciesPicker(S, Kit) App.draw() Kit.audit = {} local ok, err = pcall(App.draw) check(ok, ("%dx%d species picker draws: %s"):format(W, H, tostring(err))) if ok then auditFrame(("%dx%d species picker"):format(W, H), W, H) end Kit.audit = nil Ops.closeSpeciesPicker(S, Kit) Ops.openMovePicker(S, Kit, 1) App.draw() Kit.audit = {} ok, err = pcall(App.draw) check(ok, ("%dx%d move picker draws: %s"):format(W, H, tostring(err))) if ok then auditFrame(("%dx%d move picker"):format(W, H), W, H) end Kit.audit = nil Ops.closeMovePicker(S, Kit) end love.graphics.getDimensions = oldDimensions os.remove(tmpPath) for _, bak in ipairs(FsIo.globPrefix(tmpPath .. ".bak-")) do os.remove(bak) end end do -- Box add flow: the Boxes panel's "+ Add mon here" and its dashed empty -- cells open the SAME species picker the inspector uses, in box-add mode, -- and the committed species lands in the selected box as a Lv5 mon built -- by the same MonOps path Ops.partyAdd uses. local Kit = require("Kit") local BoxesMod = require("src.pokemon.Boxes") local tmpPath = os.tmpname() .. "-boxadd-save.lua" local f = io.open(tmpPath, "wb") f:write(SaveData.encode(SaveData.newGame())) f:close() App.load(tmpPath, { version = "red" }) local S = App.getState() S.tab = "boxes" check(Ops.openBoxAddPicker(S, Kit) == true, "box-add picker opens") check(S.speciesPicker ~= nil, "the picker is up") eq(S.speciesPicker.mode, "box-add", "and it is in box-add mode") eq(Kit.focus, "species-picker", "with the search field focused (#529)") local ok, err = pcall(App.draw) check(ok, "the box-add picker draws headlessly: " .. tostring(err)) App.textinput("PIKACHU") App.draw() App.keypressed("return") local box = Ops.boxes(S)[S.selectedBox] check(S.speciesPicker == nil, "committing closes the picker") eq(#box, 1, "the commit added exactly one mon to the box") local mon = box[1] eq(mon.species, "PIKACHU", "the picked species landed in the box") eq(mon.level, 5, "as a Lv5 mon, matching partyAdd's default") check(mon.stats and mon.stats.hp and mon.stats.hp > 0, "with real Gen1 stats from MonOps.create") eq(mon.ot, S.save.player.name, "owned by the save's player") eq(mon.otId, S.save.player.id, "with the player's trainer id") check(S.editingMon == mon, "and the inspector now points at it") check(S.dirty, "and the save is dirty") -- Escape leaves without adding anything Ops.openBoxAddPicker(S, Kit) App.textinput("BULBASAUR") App.draw() App.keypressed("escape") check(S.speciesPicker == nil, "Escape closes the box-add picker") eq(#box, 1, "Escape added nothing") -- an unusable (mod-partial) record refuses instead of crashing (#541) Data.pokemon.TESTMON_BOXADD = { name = "TESTMON", dex = 0, baseStats = { hp = 40 }, growthRate = "MEDIUM_FAST", types = { "NORMAL" }, learnset = {} } S.cat = Catalog.build(Data) S.dirty = false check(Ops.boxAddSpecies(S, "TESTMON_BOXADD") == false, "a record without usable base stats is refused") eq(#box, 1, "and nothing was added") check(S.status:match("base stats") ~= nil, "and the refusal explains itself") check(S.dirty == false, "and the save stays clean") Data.pokemon.TESTMON_BOXADD = nil S.cat = Catalog.build(Data) -- a full box refuses to even open the picker while #box < BoxesMod.CAPACITY do Ops.boxAdd(S) end check(Ops.openBoxAddPicker(S, Kit) == false, "a full box refuses the picker") check(S.speciesPicker == nil, "and it stays closed") check(S.status:match("full") ~= nil, "and says why") -- ...and a commit raced against a filling box refuses too check(Ops.boxAddSpecies(S, "PIKACHU") == false, "boxAddSpecies refuses a full box") os.remove(tmpPath) for _, bak in ipairs(FsIo.globPrefix(tmpPath .. ".bak-")) do os.remove(bak) end end do -- #917: Pixel 9a Save was dead because the title cluster sat in the -- cutout / status unsafe band. Chrome and modal search fields must live -- inside love.window.getSafeArea (background may still paint full-bleed). local Kit = require("Kit") local tmpPath = os.tmpname() .. "-safe-area-save.lua" local f = io.open(tmpPath, "wb") f:write(SaveData.encode(SaveData.newGame())) f:close() local W, H = 720, 1560 local ox, oy, sw, sh = 0, 64, 720, 1456 -- punch-hole + home indicator local oldDimensions = love.graphics.getDimensions local oldSafe = love.window.getSafeArea love.graphics.getDimensions = function() return W, H end love.window.getSafeArea = function() return ox, oy, sw, sh end App.load(tmpPath, { version = "blue" }) local S = App.getState() Ops.partyAdd(S) S.dirty = true S.allowSave = true S.tab = "party" local function insideSafe(rects, where) for _, r in ipairs(rects or {}) do if r.class == "control" then check(r.x >= ox - 0.5, where .. " '" .. r.label .. "' is right of the left inset") check(r.y >= oy - 0.5, where .. " '" .. r.label .. "' is below the notch (#917)") check(r.x + r.w <= ox + sw + 0.5, where .. " '" .. r.label .. "' stays left of the right inset") check(r.y + r.h <= oy + sh + 0.5, where .. " '" .. r.label .. "' stays above the home indicator") end end end Kit.audit = {} local ok, err = pcall(App.draw) check(ok, "editor draws inside an inset safe area: " .. tostring(err)) insideSafe(Kit.audit, "chrome") local saveBtn for _, r in ipairs(Kit.audit) do if r.class == "control" and (r.label == "Save" or r.label == "Saved" or r.label == "Save locked") then saveBtn = r break end end check(saveBtn ~= nil, "the Save button was audited") if saveBtn then check(saveBtn.y >= oy - 0.5, "Save clears the top safe inset (#917)") check(saveBtn.y + saveBtn.h <= oy + sh + 0.5, "Save stays above the bottom safe inset") end Kit.audit = nil -- Species-picker search field must also clear the notch once chrome is inset. Ops.openSpeciesPicker(S, Kit) App.draw() -- opening frame is fully shielded Kit.audit = {} ok, err = pcall(App.draw) check(ok, "species picker draws inside an inset safe area: " .. tostring(err)) insideSafe(Kit.audit, "species picker") local search for _, r in ipairs(Kit.audit) do if r.class == "control" and r.label == "species-picker" then search = r break end end check(search ~= nil, "the species search field was audited") if search then check(search.y >= oy - 0.5, "species search clears the top safe inset (#917)") end Kit.audit = nil Ops.closeSpeciesPicker(S, Kit) love.graphics.getDimensions = oldDimensions love.window.getSafeArea = oldSafe os.remove(tmpPath) for _, bak in ipairs(FsIo.globPrefix(tmpPath .. ".bak-")) do os.remove(bak) end end do -- PickerChrome: short RGxxx / phone landscapes must nearly fill SafeArea -- (not sit in a 32px-guttered desk card that leaves no list body), and every -- interactive metric stays at or above the 26px tap floor. local Kit = require("Kit") local PickerChrome = require("PickerChrome") local oldDimensions = love.graphics.getDimensions local oldSafe = love.window.getSafeArea local function checkDevice(W, H, safe, label) love.graphics.getDimensions = function() return W, H end if safe then love.window.getSafeArea = function() return safe[1], safe[2], safe[3], safe[4] end else love.window.getSafeArea = function() return 0, 0, W, H end end Kit.layout(safe and safe[3] or W, safe and safe[4] or H) local x, y, w, h, pad = PickerChrome.card(Kit, W, H) local ox = safe and safe[1] or 0 local oy = safe and safe[2] or 0 local sw = safe and safe[3] or W local sh = safe and safe[4] or H check(w > 0 and h > 0, label .. ": card has positive size") check(x >= ox - 0.5 and y >= oy - 0.5, label .. ": card origin stays inside the safe rect") check(x + w <= ox + sw + 0.5 and y + h <= oy + sh + 0.5, label .. ": card fits inside the safe rect") -- Short landscapes should use almost all of the safe height. if sh <= 560 * Kit.scale + 40 then check(h >= sh * 0.85, label .. ": short landscape card fills most of the safe height") end local tap = PickerChrome.tapMin(Kit) check(tap >= 26, label .. ": tapMin is at least 26px") check(PickerChrome.fieldH(Kit) >= tap, label .. ": search field meets tapMin") check(PickerChrome.closeSize(Kit) >= tap, label .. ": close meets tapMin") local listH, rowH = PickerChrome.listMetrics(Kit, y, h, pad, y + pad + 80 * Kit.scale) check(listH >= 0, label .. ": list height is non-negative") check(rowH >= tap, label .. ": list rows meet tapMin") end checkDevice(720, 480, nil, "RG34XXSP 720x480") checkDevice(640, 480, nil, "RG35XX 640x480") checkDevice(1280, 720, nil, "Switch handheld 1280x720") checkDevice(720, 1280, { 0, 64, 720, 1176 }, "Pixel portrait + notch") checkDevice(1280, 720, { 48, 0, 1184, 720 }, "landscape + side cutout") checkDevice(360, 640, nil, "tiny phone 360x640") checkDevice(1920, 1080, nil, "desktop 1080p") love.graphics.getDimensions = oldDimensions love.window.getSafeArea = oldSafe end do local interp = arg and arg[-1] or "luajit" for _, suite in ipairs({ "tests/save_editor_gen3_tests.lua", "tests/save_editor_gen3_persistence_tests.lua" }) do local r = os.execute(interp .. " " .. suite) check(r == true or r == 0, suite .. " passes") end end print(string.format("save editor tests: %d passed, %d failed", passed, failed)) if failed > 0 then os.exit(1) end