-- 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")
  check(path:match("pokemon%-love2d"), "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.<stat> 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

  about(Kit.layout(720, 1560), 720 / 640,
    "portrait phone scales off its width, gently")
  check(Kit.layout(720, 1560) >= 0.9,
        "a portrait phone never drops below the readability floor")
  about(Kit.layout(1560, 720), 720 / 768, "landscape phone still scales off height")
  about(Kit.layout(360, 640), 0.9,
    "a tiny window stops at the readable floor and reflows instead of shrinking")
  about(Kit.layout(500, 800), 0.9, "500px wide sits on the floor too")

  -- desktop and laptop sizes keep the height-only scale they always had
  for _, size in ipairs({ { 1280, 800 }, { 1024, 768 }, { 1920, 1080 },
                          { 1440, 900 }, { 2560, 1440 }, { 900, 700 } }) do
    about(Kit.layout(size[1], size[2]),
      Theme.clamp(math.min(size[1] / 640, size[2] / 768), 0.9, 1.6),
      ("%dx%d keeps its height-based scale"):format(size[1], size[2]))
  end
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

print(string.format("save editor tests: %d passed, %d failed", passed, failed))
if failed > 0 then os.exit(1) end
