mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
big ui moment
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
-- Launcher Delete affordance (src/import/RomImporter.lua): the per-frame hit
|
||||
-- rects a draw() clears, and the two-click arm that guards both save-slot and
|
||||
-- mod deletes (#433). Drives RomImporter:mousepressed / :_resetFrameRects on a
|
||||
-- bare instance, so no window, no cache and no real save files are involved.
|
||||
-- Launcher Delete affordance (src/import/RomImporter.lua): the two-click arm
|
||||
-- that guards both save-slot and mod deletes (#433). Every Delete control in
|
||||
-- the FlexLove view routes through RomImporter:pressDelete, and every other
|
||||
-- queued action clears self._confirmDelete (LauncherView's queueAction), so
|
||||
-- the guarantees live on this seam: the first press only arms, the second
|
||||
-- press on the SAME target commits, any other target or a cleared arm asks
|
||||
-- again, and a stale arm expires instead of committing much later.
|
||||
-- luajit tests/engine/launcher_delete_confirm.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
@@ -10,125 +13,88 @@ local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
-- mousepressed timestamps the arm and expires it, so the clock has to move
|
||||
-- pressDelete timestamps the arm and expires it, so the clock has to move
|
||||
local clock = 1000
|
||||
love.timer.getTime = function() return clock end
|
||||
|
||||
local RomImporter = require("src.import.RomImporter")
|
||||
|
||||
local function rect(id, y)
|
||||
return { x = 100, y = y or 200, width = 40, height = 14, id = id }
|
||||
end
|
||||
|
||||
-- Only the fields mousepressed reads on its way to the Delete loops, plus
|
||||
-- recorders in place of the two destructive calls.
|
||||
local function launcher()
|
||||
local self = setmetatable({}, RomImporter)
|
||||
self.android = false
|
||||
self.panelVersion = "red"
|
||||
self.tab = "red"
|
||||
self.slotScroll = {}
|
||||
self.deletedSlots = {}
|
||||
self.deletedMods = {}
|
||||
self.selected = {}
|
||||
self._deleteSlot = function(_, version, id)
|
||||
table.insert(self.deletedSlots, version .. "/" .. id)
|
||||
end
|
||||
self._deleteMod = function(_, id) table.insert(self.deletedMods, id) end
|
||||
self._selectSlot = function(_, version, id)
|
||||
table.insert(self.selected, version .. "/" .. id)
|
||||
end
|
||||
self.deleted = {}
|
||||
return self
|
||||
end
|
||||
|
||||
local function clickDelete(self, r)
|
||||
self:mousepressed(r.x + 2, r.y + 2, 1)
|
||||
end
|
||||
|
||||
-- ------- a frame that draws no panel leaves no Delete rect behind
|
||||
|
||||
do
|
||||
local self = launcher()
|
||||
self.slotDeleteRects = { rect("slot1") }
|
||||
self.modDeleteRects = { rect("bigmod", 260) }
|
||||
self.slotRects = { rect("slot1") }
|
||||
self.modRects = { rect("bigmod", 260) }
|
||||
self:_resetFrameRects()
|
||||
eq(self.slotDeleteRects, nil, "a frame reset drops the save Delete rects")
|
||||
eq(self.modDeleteRects, nil, "a frame reset drops the mod Delete rects")
|
||||
eq(self.slotRects, nil, "and the slot rows they sit on")
|
||||
eq(self.modRects, nil, "and the mod toggles")
|
||||
|
||||
-- the reporter's click: mods tab is up, the press lands where the game tab
|
||||
-- drew Delete last time it was shown
|
||||
self.tab = "mods"
|
||||
clickDelete(self, rect("slot1"))
|
||||
eq(#self.deletedSlots, 0, "a press on a stale Delete spot deletes nothing")
|
||||
local function press(self, kind, id, version)
|
||||
return self:pressDelete(kind, id, version, function()
|
||||
table.insert(self.deleted, tostring(kind) .. "/" .. tostring(version)
|
||||
.. "/" .. tostring(id))
|
||||
end)
|
||||
end
|
||||
|
||||
-- ------- a save Delete needs two clicks on the same row
|
||||
|
||||
do
|
||||
local self = launcher()
|
||||
local r = rect("slot1")
|
||||
self.slotDeleteRects = { r }
|
||||
clickDelete(self, r)
|
||||
eq(#self.deletedSlots, 0, "the first click on Delete does not delete")
|
||||
eq(press(self, "slot", "slot1", "red"), false,
|
||||
"the first click on Delete does not delete")
|
||||
eq(#self.deleted, 0, "nothing was committed by the arm")
|
||||
check(self._confirmDelete ~= nil and self._confirmDelete.id == "slot1",
|
||||
"the first click arms that row")
|
||||
clickDelete(self, r)
|
||||
eq(self.deletedSlots[1], "red/slot1", "the second click on it deletes")
|
||||
eq(press(self, "slot", "slot1", "red"), true,
|
||||
"the second click on it deletes")
|
||||
eq(self.deleted[1], "slot/red/slot1", "the commit ran for that row")
|
||||
eq(self._confirmDelete, nil, "the arm is spent")
|
||||
end
|
||||
|
||||
-- ------- the arm is per row, per version, and any other press clears it
|
||||
-- ------- the arm is per row, per version
|
||||
|
||||
do
|
||||
local self = launcher()
|
||||
local one, two = rect("slot1", 200), rect("slot2", 230)
|
||||
self.slotDeleteRects = { one, two }
|
||||
clickDelete(self, one)
|
||||
clickDelete(self, two)
|
||||
eq(#self.deletedSlots, 0, "a click on another row's Delete only arms that row")
|
||||
press(self, "slot", "slot1", "red")
|
||||
eq(press(self, "slot", "slot2", "red"), false,
|
||||
"a click on another row's Delete only arms that row")
|
||||
eq(self._confirmDelete.id, "slot2", "the arm moved to the row just clicked")
|
||||
|
||||
self.slotRects = { rect("slot3", 260) }
|
||||
clickDelete(self, one) -- re-arm slot1
|
||||
self:mousepressed(102, 262, 1) -- press somewhere else entirely
|
||||
clickDelete(self, one)
|
||||
eq(#self.deletedSlots, 0, "a press elsewhere disarms, so Delete asks again")
|
||||
press(self, "slot", "slot1", "red") -- re-arm slot1
|
||||
eq(press(self, "slot", "slot1", "blue"), false,
|
||||
"an arm from one game's tab cannot fire on another")
|
||||
eq(#self.deleted, 0, "no cross-target pair ever committed")
|
||||
end
|
||||
|
||||
self:mousepressed(102, 262, 1) -- clear the arm left by the pair above
|
||||
clickDelete(self, one)
|
||||
self.panelVersion = "blue"
|
||||
clickDelete(self, one)
|
||||
eq(#self.deletedSlots, 0, "an arm from one game's tab cannot fire on another")
|
||||
-- ------- any other action press disarms (the view clears the arm)
|
||||
|
||||
do
|
||||
local self = launcher()
|
||||
press(self, "slot", "slot1", "red")
|
||||
self._confirmDelete = nil -- what queueAction does on any
|
||||
-- non-delete action press
|
||||
eq(press(self, "slot", "slot1", "red"), false,
|
||||
"a press elsewhere disarms, so Delete asks again")
|
||||
eq(#self.deleted, 0, "and the cleared arm never committed")
|
||||
end
|
||||
|
||||
-- ------- a stale arm expires instead of committing much later
|
||||
|
||||
do
|
||||
local self = launcher()
|
||||
local r = rect("slot1")
|
||||
self.slotDeleteRects = { r }
|
||||
clickDelete(self, r)
|
||||
press(self, "slot", "slot1", "red")
|
||||
clock = clock + 30
|
||||
clickDelete(self, r)
|
||||
eq(#self.deletedSlots, 0, "an arm older than the confirm window is dead")
|
||||
clickDelete(self, r)
|
||||
eq(self.deletedSlots[1], "red/slot1", "and the fresh pair still deletes")
|
||||
eq(press(self, "slot", "slot1", "red"), false,
|
||||
"an arm older than the confirm window is dead")
|
||||
eq(press(self, "slot", "slot1", "red"), true,
|
||||
"and the fresh pair still deletes")
|
||||
end
|
||||
|
||||
-- ------- mods delete arms the same way
|
||||
-- ------- mods delete arms the same way (version is nil for mods)
|
||||
|
||||
do
|
||||
local self = launcher()
|
||||
local r = rect("bigmod", 260)
|
||||
self.modDeleteRects = { r }
|
||||
clickDelete(self, r)
|
||||
eq(#self.deletedMods, 0, "the first click on a mod's Delete does not delete")
|
||||
clickDelete(self, r)
|
||||
eq(self.deletedMods[1], "bigmod", "the second click removes the mod")
|
||||
eq(press(self, "mod", "bigmod", nil), false,
|
||||
"the first click on a mod's Delete does not delete")
|
||||
eq(press(self, "mod", "bigmod", nil), true,
|
||||
"the second click removes the mod")
|
||||
eq(self.deleted[1], "mod/nil/bigmod", "the mod commit ran")
|
||||
end
|
||||
|
||||
T.finish("launcher delete confirm")
|
||||
|
||||
@@ -75,14 +75,10 @@ check(ri.findNotice and ri.findNotice.ok == false,
|
||||
-- ---- PASTE chip: same entry point the touch screen uses -------------------
|
||||
|
||||
ri:_promptAddIndex()
|
||||
-- the chip rect is what draw() published last frame (pinned: modal chrome
|
||||
-- ignores the page-scroll band); mousepressed hit-tests it while the
|
||||
-- prompt is up and everywhere else the prompt swallows the press
|
||||
ri._indexPasteRect = { x = 10, y = 10, width = 60, height = 24, pinned = true }
|
||||
-- the prompt's Paste button (LauncherView) queues _pasteIndexUrl, the same
|
||||
-- funnel ctrl/cmd+V uses, so both paths share the strip and the cap
|
||||
clipboard = " https://example.com/mods/index.json\n"
|
||||
ri:mousepressed(200, 200, 1)
|
||||
eq(ri._indexPrompt.text, "", "a press outside the chip pastes nothing")
|
||||
ri:mousepressed(20, 20, 1)
|
||||
ri:_pasteIndexUrl()
|
||||
eq(ri._indexPrompt.text, "https://example.com/mods/index.json",
|
||||
"the PASTE chip lands the clipboard with whitespace stripped (#578)")
|
||||
|
||||
@@ -90,7 +86,7 @@ eq(ri._indexPrompt.text, "https://example.com/mods/index.json",
|
||||
-- overflow MAX_INDEX_URL (200)
|
||||
ri._indexPrompt.text = ""
|
||||
clipboard = string.rep("a", 300)
|
||||
ri:mousepressed(20, 20, 1)
|
||||
ri:_pasteIndexUrl()
|
||||
eq(#ri._indexPrompt.text, 200, "the PASTE chip enforces MAX_INDEX_URL")
|
||||
|
||||
-- and through ctrl/cmd+V, which used to skip the cap entirely
|
||||
@@ -117,22 +113,25 @@ ri:keypressed("return")
|
||||
eq(lastArm(), false, "committing the rename disarms setTextInput")
|
||||
eq(renamed and renamed[3], "OLD!", "the commit reaches SaveData.renameSlot")
|
||||
|
||||
-- ---- find-search field: arm on rect press, disarm on escape ---------------
|
||||
-- ---- find-search field: arm on focus, disarm on escape / tab change -------
|
||||
|
||||
ri.findSearchRect = { x = 100, y = 100, width = 80, height = 20 }
|
||||
ri:mousepressed(110, 110, 1)
|
||||
check(ri._findSearchFocus == true, "pressing the search field takes focus")
|
||||
eq(lastArm(), true, "and arms setTextInput")
|
||||
-- the search field's click handler (LauncherView) takes focus and arms;
|
||||
-- drive the same pair the handler queues
|
||||
ri._findSearchFocus = true
|
||||
ri:_armTextInput()
|
||||
eq(lastArm(), true, "focusing the search field arms setTextInput")
|
||||
ri:keypressed("escape")
|
||||
check(ri._findSearchFocus == false, "escape drops the search caret")
|
||||
eq(lastArm(), false, "and disarms setTextInput")
|
||||
|
||||
-- a press elsewhere on the find tab also drops the caret and disarms
|
||||
ri:mousepressed(110, 110, 1)
|
||||
eq(lastArm(), true, "refocus for the click-away case")
|
||||
ri:mousepressed(400, 400, 1)
|
||||
check(ri._findSearchFocus == false, "a click away drops the caret")
|
||||
-- switching tabs (chips, shoulder buttons) also drops the caret and disarms
|
||||
ri._findSearchFocus = true
|
||||
ri:_armTextInput()
|
||||
eq(lastArm(), true, "refocus for the tab-change case")
|
||||
ri:_switchTab("mods")
|
||||
check(ri._findSearchFocus == false, "a tab change drops the caret")
|
||||
eq(lastArm(), false, "and disarms setTextInput")
|
||||
ri.tab = "find"
|
||||
|
||||
-- ---- desktop contract (#529): disarm never lowers off Android -------------
|
||||
|
||||
|
||||
@@ -145,14 +145,18 @@ for os, forwards in pairs(touchForwardsToImporter) do
|
||||
os .. ": the synthesized mouse press is dropped only where touch already forwarded")
|
||||
end
|
||||
|
||||
-- The FlexLove view polls love.touch itself and dedupes a tap's synthesized
|
||||
-- mouse click in its action layer (LauncherView queueAction), so the
|
||||
-- host-forwarded touch events are inert stubs: they must accept any id
|
||||
-- without capturing state or throwing.
|
||||
local touch = importer("iOS")
|
||||
touch:touchpressed(101, 20, 20)
|
||||
check(touch._activeTouch == 101, "iOS touch press captures the active touch")
|
||||
touch:touchmoved(101, 22, 22)
|
||||
touch:touchreleased(202, 20, 20)
|
||||
check(touch._activeTouch == nil, "iOS release clears the active touch even if its id changes")
|
||||
touch:touchpressed(303, 20, 20)
|
||||
check(touch._activeTouch == 303, "iOS accepts the next touch after release")
|
||||
touch:touchreleased(303, 20, 20)
|
||||
check(touch._activeTouch == nil,
|
||||
"touch events stay inert: the view's own polling owns touch input")
|
||||
|
||||
love.system.getOS = saved.getOS
|
||||
love.system.pickFile = saved.pickFile
|
||||
|
||||
+213
-16
@@ -864,11 +864,12 @@ do
|
||||
end
|
||||
|
||||
do
|
||||
-- #497: the editor drew a desktop layout into a phone window. Kit.layout
|
||||
-- scaled off height alone, and a phone in portrait (720x1560) is TALLER
|
||||
-- than the 768px desktop reference while being barely half as wide, so the
|
||||
-- scale came back clamped at 1.6 and every right-aligned cluster in the
|
||||
-- chrome landed on top of the block to its left. Both axes now pay.
|
||||
-- #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)
|
||||
@@ -876,19 +877,21 @@ do
|
||||
msg .. string.format(" (got %.4f, want %.4f)", got, want))
|
||||
end
|
||||
|
||||
about(Kit.layout(720, 1560), 0.72, "portrait phone scales off its width")
|
||||
check(Kit.layout(720, 1560) < 1.0,
|
||||
"a portrait phone no longer draws a larger-than-desktop layout")
|
||||
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.62, "a tiny window stops at the floor")
|
||||
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 have to be pixel-identical to before the fix:
|
||||
-- everything at or above the 1000px reference width lands on the height
|
||||
-- term, exactly as it always did
|
||||
-- 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 } }) do
|
||||
about(Kit.layout(size[1], size[2]), Theme.clamp(size[2] / 768, 0.7, 1.6),
|
||||
("%dx%d keeps its old height-only scale"):format(size[1], size[2]))
|
||||
{ 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
|
||||
|
||||
@@ -917,8 +920,13 @@ do
|
||||
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. The desktop sizes pin that
|
||||
-- the responsive reflow does not disturb the layouts that already worked.
|
||||
for _, size in ipairs({ { 720, 1560 }, { 1560, 720 }, { 480, 1040 },
|
||||
{ 1280, 800 } }) do
|
||||
{ 1280, 800 }, { 720, 1280 }, { 1280, 720 },
|
||||
{ 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()
|
||||
@@ -928,6 +936,8 @@ do
|
||||
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)
|
||||
@@ -947,5 +957,192 @@ do
|
||||
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
|
||||
local sizes = { { 500, 800 }, { 720, 1280 }, { 1280, 720 },
|
||||
{ 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)
|
||||
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
|
||||
|
||||
print(string.format("save editor tests: %d passed, %d failed", passed, failed))
|
||||
if failed > 0 then os.exit(1) end
|
||||
|
||||
@@ -49,6 +49,49 @@ Kit.blockClicks = false
|
||||
Kit.endFrame()
|
||||
eq(Kit.wheelY, 0, "an unclaimed notch retires with the frame")
|
||||
|
||||
-- #715: a phone has no wheel, so Kit.scroll also follows a held pointer
|
||||
-- dragging vertically over the list body. Kit.beginFrame polls
|
||||
-- love.mouse.isDown for this (neither host routes mousereleased), so the
|
||||
-- stub grows one here.
|
||||
local held = false
|
||||
love.mouse = love.mouse or {}
|
||||
love.mouse.getPosition = love.mouse.getPosition or function() return 0, 0 end
|
||||
love.mouse.isDown = function() return held end
|
||||
|
||||
held = true
|
||||
Kit.beginFrame(50, 90, false, 0)
|
||||
eq(Kit.scroll(0, 0, 100, 100, 0, 250, 10), 0,
|
||||
"the press frame starts a drag without moving the list")
|
||||
Kit.endFrame()
|
||||
|
||||
Kit.beginFrame(50, 60, false, 0) -- dragged 30px up, 10 rows / 100px = 3 rows
|
||||
eq(Kit.scroll(0, 0, 100, 100, 0, 250, 10), 3,
|
||||
"dragging upward reveals lower rows")
|
||||
Kit.endFrame()
|
||||
|
||||
Kit.beginFrame(50, -2910, false, 0) -- a wild fling clamps like the wheel does
|
||||
eq(Kit.scroll(0, 0, 100, 100, 3, 250, 10), 240,
|
||||
"a drag past the end clamps to the last page")
|
||||
Kit.endFrame()
|
||||
|
||||
held = false
|
||||
Kit.beginFrame(50, 60, false, 0)
|
||||
eq(Kit.scroll(0, 0, 100, 100, 3, 250, 10), 3,
|
||||
"releasing the pointer ends the drag")
|
||||
Kit.endFrame()
|
||||
|
||||
held = true
|
||||
Kit.beginFrame(500, 500, false, 0) -- press outside the list body
|
||||
Kit.scroll(0, 0, 100, 100, 0, 250, 10)
|
||||
Kit.endFrame()
|
||||
Kit.beginFrame(500, 400, false, 0)
|
||||
eq(Kit.scroll(0, 0, 100, 100, 0, 250, 10), 0,
|
||||
"a drag that never entered the list does not scroll it")
|
||||
Kit.endFrame()
|
||||
held = false
|
||||
Kit.beginFrame(0, 0, false, 0)
|
||||
Kit.endFrame()
|
||||
|
||||
-- The wheel has to reach the Items lists without touching the map camera,
|
||||
-- which is the only thing App.wheelmoved used to drive (#595). Loading the
|
||||
-- whole editor needs data/generated/, so pin the routing at the source seam
|
||||
|
||||
Reference in New Issue
Block a user