Files
gen1recomp/tests/engine/launcher_modal_scroll_2437.lua
2026-09-23 16:34:15 -04:00

511 lines
19 KiB
Lua

package.path = "./?.lua;./?/init.lua;" .. package.path
love = require("tests.love_stub")
love.graphics.setLineJoin = function() end
love.graphics.newShader = function() return {} end
local T = require("tests.harness")
local Kit = require("src.ui.kit.Kit")
local View = require("src.import.LauncherView")
local Transition = require("src.ui.kit.Transition")
local Importer = require("src.import.RomImporter")
local GameVersion = require("src.core.GameVersion")
local width, height = 853, 480
love.graphics.getDimensions = function() return width, height end
love.graphics.getPixelDimensions = love.graphics.getDimensions
local mouseX, mouseY = -100, -100
love.mouse.getPosition = function() return mouseX, mouseY end
local now = 100
love.timer.getTime = function() return now end
local CATEGORIES = { "graphics", "gameplay", "audio", "qol", "translation", "challenge", "ui" }
local function fixture(tab)
local imp = Importer.new(function() end, { launcher = true, onEditSave = function() end })
for _, v in ipairs(GameVersion.ORDER) do imp.ready[v] = true end
imp._ensureSlots, imp._ensureMods = function() end, function() end
imp._refreshMods = function() end
imp._resetModOrder = function(self) self.didReset = true end
imp.mods = {}
imp.tab = tab
return imp
end
local MODALS = {
{ key = "_modScopePopup", tab = "mods", close = "Close",
open = function(imp) imp._modScopePopup = true end },
{ key = "_filterPopup", tab = "find", close = "Close",
open = function(imp)
imp.findIndex = { categories = CATEGORIES, baseGames = {} }
imp._filterPopup = true
end },
{ key = "_sortPopup", tab = "mods", close = "Close",
open = function(imp) imp._sortPopup = "mods" end },
{ key = "_indexManage", tab = "find", close = "Close",
open = function(imp)
imp.findSources = {}
for i = 1, 6 do imp.findSources[i] = { feed = "https://x/" .. i, label = "Index " .. i } end
imp._indexManage = true
end },
}
local function draw(imp)
Kit.audit = {}
View.draw(imp)
Transition.reset()
Kit.audit = {}
View.draw(imp)
local rects = Kit.audit
Kit.audit = nil
return rects
end
local function opened(spec)
local imp = fixture(spec.tab)
View.draw(imp)
spec.open(imp)
return imp, draw(imp)
end
local function inside(r, W, H)
return r.x >= 0 and r.y >= 0 and r.x + r.w <= W + 0.5 and r.y + r.h <= H + 0.5
end
local function within(inner, outer)
return inner.x >= outer.x - 0.5 and inner.y >= outer.y - 0.5
and inner.x + inner.w <= outer.x + outer.w + 0.5
and inner.y + inner.h <= outer.y + outer.h + 0.5
end
for _, size in ipairs({ { 853, 480 }, { 780, 360 }, { 1280, 720 } }) do
width, height = size[1], size[2]
for _, spec in ipairs(MODALS) do
local imp, rects = opened(spec)
local tag = ("%s at %dx%d"):format(spec.key, width, height)
T.eq(View.modalKey(imp), spec.key, tag .. " is the open modal")
local state = imp._modalScroll and imp._modalScroll[spec.key]
T.check(state ~= nil and state.rect ~= nil, tag .. " records a scroll rect")
local closeSeen, escaped = false, {}
for _, r in ipairs(rects) do
if r.clip then
if not inside(r.clip, width, height) then escaped[#escaped + 1] = r.label .. " clip" end
if state and not within(r.clip, state.rect) then escaped[#escaped + 1] = r.label .. " clip outside body" end
elseif not inside(r, width, height) then
escaped[#escaped + 1] = r.label
end
if r.label == spec.close and not r.clip then
closeSeen = true
T.check(inside(r, width, height), tag .. " Close is on screen")
end
end
T.check(closeSeen, tag .. " draws a pinned Close")
T.check(#escaped == 0, tag .. " keeps every control on screen or in the scroll clip: "
.. table.concat(escaped, ", "))
if state and state.rect then
T.check(inside(state.rect, width, height), tag .. " scroll body is on screen")
end
end
end
width, height = 1280, 720
do
local imp = opened(MODALS[1])
T.eq(imp._modalScroll._modScopePopup.maxScroll, 0, "Show-for fits without scrolling at 1280x720")
end
for _, size in ipairs({ { 853, 480 }, { 780, 360 } }) do
width, height = size[1], size[2]
local imp = opened(MODALS[1])
local state = imp._modalScroll._modScopePopup
T.check(state.maxScroll > 0, ("Show-for list overflows at %dx%d"):format(width, height))
local r = state.rect
local scopeBefore = imp.modScope
now = now + 1
View.touchpressed(imp, 7, r.x + 20, r.y + r.h - 5)
View.touchmoved(imp, 7, r.x + 20, r.y + 5)
draw(imp)
now = now + 1
View.touchreleased(imp, 7, r.x + 20, r.y + 5)
draw(imp)
View.update(imp, 1 / 60)
T.check(state.scroll > 0, ("touch drag scrolls Show-for at %dx%d"):format(width, height))
T.check(imp._modScopePopup ~= nil, "drag leaves Show-for open")
T.eq(imp.modScope, scopeBefore, "drag does not pick a scope")
state.scroll = 0
mouseX, mouseY = r.x + 20, r.y + 10
imp._wheelY = -1
draw(imp)
mouseX, mouseY = -100, -100
T.check(state.scroll > 0, ("mouse wheel scrolls Show-for at %dx%d"):format(width, height))
state.scroll = 0
now = now + 1
Kit.setFocus("scopepop-leafgreen")
local rects = draw(imp)
local shown = false
for _, a in ipairs(rects) do
if a.label == GameVersion.info("leafgreen").label and a.clip
and a.y >= a.clip.y - 0.5 and a.y + a.h <= a.clip.y + a.clip.h + 0.5 then
shown = true
end
end
T.check(shown, ("focusing LeafGreen scrolls it into view at %dx%d"):format(width, height))
Kit.activateFocused()
View.draw(imp)
View.update(imp, 1 / 60)
T.eq(imp.modScope, "leafgreen", "LeafGreen is selectable from the Show-for list")
T.eq(imp._modScopePopup, nil, "choosing a scope closes Show-for")
draw(imp)
T.eq(imp._modalScroll and imp._modalScroll._modScopePopup, nil, "closing drops the scroll state")
Kit.setFocus(nil)
end
width, height = 780, 360
do
local imp = opened(MODALS[2])
local state = imp._modalScroll._filterPopup
T.check(state.maxScroll > 0, "FIND filter overflows at 780x360")
local r = state.rect
now = now + 1
View.touchpressed(imp, 9, r.x + 20, r.y + r.h - 5)
View.touchmoved(imp, 9, r.x + 20, r.y + 5)
draw(imp)
now = now + 1
View.touchreleased(imp, 9, r.x + 20, r.y + 5)
draw(imp)
View.update(imp, 1 / 60)
T.check(state.scroll > 0, "touch drag scrolls the FIND filter")
T.check(imp._filterPopup ~= nil, "drag leaves the FIND filter open")
now = now + 1
Kit.setFocus("filterpop-ui")
draw(imp)
Kit.activateFocused()
View.draw(imp)
View.update(imp, 1 / 60)
T.eq(imp.findCategory, "ui", "last FIND category is selectable")
Kit.setFocus(nil)
end
do
local imp = opened(MODALS[3])
local state = imp._modalScroll._sortPopup
T.check(state.maxScroll > 0, "sort list overflows at 780x360")
now = now + 1
Kit.setFocus("sortpop-reset-order")
draw(imp)
Kit.activateFocused()
View.draw(imp)
View.update(imp, 1 / 60)
T.check(imp.didReset == true, "Reset load order is reachable at 780x360")
Kit.setFocus(nil)
end
local LONG = "This paragraph is long on purpose so it wraps across several lines of the"
.. " modal body at phone widths and pushes the controls below it off a short window."
local function richMods(imp)
local imports = {}
for i = 1, 6 do
imports[i] = { id = "f" .. i, name = "File " .. i, file = "file" .. i .. ".bin",
required = i % 2 == 1, description = "Needed piece " .. i, present = i == 2 }
end
imp.mods = {
{ id = "alpha", name = "Alpha", version = "1.0.0", status = "enabled", github = "o/alpha",
dependencySpecs = { { id = "beta" } }, imports = imports, loadRank = 1,
missingRequiredImports = 3 },
{ id = "beta", name = "Beta", version = "1.0.0", status = "enabled", loadRank = 2 },
{ id = "gamma", name = "Gamma", version = "1.0.0", status = "enabled", loadRank = 3 },
}
imp.requiredImportNotice = { modId = "alpha", importId = "f1", text = LONG }
end
local function profiles(imp, n)
local list = {}
for i = 1, n do list[i] = { name = "Profile " .. i } end
imp._profileCache = { options = {}, list = list, active = "Profile 1" }
end
local function syncEngine(linked)
local devices = {}
for i = 1, 4 do devices[i] = { id = "d" .. i, label = "Device " .. i, current = i == 1 } end
return {
phase = "idle", status = "Last sync a minute ago", devices = devices,
codes = linked and { code1 = "ABCD-EFGH", code2 = "IJKL-MNOP" } or nil,
shareCode = "QRSTUV", conflicts = {},
busy = function() return false end,
linked = function() return linked end,
}
end
local SWEEP = {
{ key = "_saveExport", tab = "red", open = function(imp)
imp._saveExport = { scope = "red", version = "red", label = LONG, slotId = "a" }
end },
{ key = "_rename", tab = "red", foot = "Cancel", open = function(imp)
imp._rename = { text = "slot" }
end },
{ key = "_indexPrompt", tab = "find", foot = "Cancel", open = function(imp)
imp._indexPrompt = { text = "" }
end },
{ key = "_profileSavePrompt", tab = "mods", foot = "Cancel", open = function(imp)
imp._profileSavePrompt = { text = "P" }
end },
{ key = "_modConfirm", tab = "mods", foot = "Cancel", scrolls = true, open = function(imp)
imp._modConfirm = { title = "Enable this mod for every game in the list below",
lines = { LONG, LONG, LONG, LONG, LONG, LONG, LONG, LONG }, toggle = { label = "Remember this", on = false } }
end },
{ key = "_singleProfileActions", tab = "mods", foot = "Close", open = function(imp)
profiles(imp, 3)
imp._singleProfileActions = { name = "Profile 2" }
end },
{ key = "_profilesPopup", tab = "mods", foot = "Close", scrolls = true, open = function(imp)
profiles(imp, 8)
imp._profilesPopup = true
end },
{ key = "_modHeaderActionsPopup", tab = "mods", foot = "Close", scrolls = true, open = function(imp)
imp._modHeaderActionsPopup = true
end },
{ key = "_gamePopup", tab = "red", foot = "Close", open = function(imp)
imp._gamePopup = true
end },
{ key = "_cartSave", tab = "mods", foot = "Cancel", scrolls = true, open = function(imp)
local pins = {}
for i = 1, 6 do pins[i] = { id = "m" .. i, name = "Mod " .. i, reason = LONG } end
imp._cartSave = { version = "red", count = 6, text = "My cart", cartVersion = "1.0.0",
author = "me", unresolved = pins, publishable = false, error = LONG }
end },
{ key = "_skinActions", tab = "skins", foot = "Close", scrolls = true, open = function(imp)
imp._skins = { { id = "pad", format = "native", pages = 1, controls = 8 } }
imp._ensureSkins = function(self) return self._skins end
imp.onOpenSkinStudio = function() end
imp._skinExport = { dir = "/tmp" }
imp._skinActions = { id = "pad" }
end },
{ key = "_modActions", tab = "mods", foot = "Close", scrolls = true, open = function(imp)
richMods(imp)
imp._modActions = "alpha"
end },
{ key = "_modImports", tab = "mods", foot = "Close", scrolls = true, open = function(imp)
richMods(imp)
imp._modImports = "alpha"
end },
{ key = "_findEntry", tab = "find", foot = "Close", open = function(imp)
imp._findEntry = { id = "alpha", title = "Alpha", version = "1.0.0", author = "someone",
categories = { "qol" }, repo = "https://example.invalid/alpha" }
end },
{ key = "_gameManage", tab = "red", foot = "Close", open = function(imp)
imp._webClipNotice = { key = "red:", ok = false, text = LONG }
imp._gameManage = "red"
end },
{ key = "_syncModal", tab = "red", foot = "Close", label = "sync home", scrolls = true,
open = function(imp)
imp._syncTransportOk = true
imp._sync = syncEngine(true)
imp._syncModal = { view = "home" }
end },
{ key = "_syncModal", tab = "red", foot = "Back", label = "sync mods", open = function(imp)
imp._syncTransportOk = true
imp._sync = syncEngine(true)
imp._sync.modPlan = { toInstall = { 1, 2 }, indexes = { 1 } }
imp._syncModal = { view = "mods" }
end },
{ key = "_syncModal", tab = "red", foot = "Back", label = "sync link", open = function(imp)
imp._syncTransportOk = true
imp._sync = syncEngine(false)
imp._syncModal = { view = "link" }
end },
}
local function sweepTag(spec)
return ("%s at %dx%d"):format(spec.label or spec.key, width, height)
end
local sawScroll = {}
for _, size in ipairs({ { 853, 480 }, { 780, 360 }, { 1280, 720 } }) do
width, height = size[1], size[2]
for _, spec in ipairs(SWEEP) do
local imp, rects = opened(spec)
local tag = sweepTag(spec)
T.eq(View.modalKey(imp), spec.key, tag .. " is the open modal")
local state = imp._modalScroll and imp._modalScroll[spec.key]
T.check(state ~= nil and state.rect ~= nil, tag .. " lays out through a scroll body")
local escaped, footSeen = {}, not spec.foot
for _, r in ipairs(rects) do
if r.clip then
if not inside(r.clip, width, height) then escaped[#escaped + 1] = r.label .. " clip" end
if state and not within(r.clip, state.rect) then
escaped[#escaped + 1] = r.label .. " clip outside body"
end
elseif not inside(r, width, height) then
escaped[#escaped + 1] = r.label
end
if spec.foot and r.label == spec.foot and not r.clip and inside(r, width, height) then
footSeen = true
end
end
T.check(footSeen, tag .. " pins " .. tostring(spec.foot) .. " on screen")
T.check(#escaped == 0, tag .. " keeps every control on screen or in the scroll clip: "
.. table.concat(escaped, ", "))
if state and state.rect then
T.check(inside(state.rect, width, height), tag .. " scroll body is on screen")
if width == 1280 and not spec.scrolls then
T.eq(state.maxScroll, 0, tag .. " fits without scrolling")
end
if state.maxScroll > 0 then sawScroll[spec.label or spec.key] = true end
end
end
end
local PAGED = {
{ key = "_modVersions", tab = "mods", open = function(imp)
local rel = {}
for i = 1, 8 do rel[i] = { version = "1." .. i, body = LONG } end
imp._modVersions = { id = "alpha", name = "Alpha", current = "1.1", releases = rel }
end },
{ key = "_modDepResolver", tab = "mods", open = function(imp)
local deps = {}
for i = 1, 6 do deps[i] = { id = "d" .. i, name = "Dep " .. i, status = "missing", github = "o/d" } end
imp._modDepResolver = { targetMod = { id = "alpha", name = "Alpha" }, deps = deps }
end },
{ key = "_modReleaseNotes", tab = "mods", open = function(imp)
imp._modReleaseNotes = { version = "1.0", body = LONG .. LONG .. LONG }
end },
{ key = "_cartPopup", tab = "red", open = function(imp) imp._cartPopup = "red" end },
}
for _, size in ipairs({ { 853, 480 }, { 780, 360 } }) do
width, height = size[1], size[2]
for _, spec in ipairs(PAGED) do
local imp, rects = opened(spec)
local tag = sweepTag(spec)
T.eq(View.modalKey(imp), spec.key, tag .. " is the open modal")
local escaped = {}
for _, r in ipairs(rects) do
if not inside(r.clip or r, width, height) then escaped[#escaped + 1] = r.label end
end
T.check(#escaped == 0, tag .. " keeps every control on screen: " .. table.concat(escaped, ", "))
end
end
for _, spec in ipairs(SWEEP) do
if spec.scrolls then
T.check(sawScroll[spec.label or spec.key] == true,
(spec.label or spec.key) .. " overflows somewhere in the sweep")
end
end
width, height = 780, 360
do
local imp = opened(SWEEP[8])
local state = imp._modalScroll._modHeaderActionsPopup
T.check(state.maxScroll > 0, "More Mod Actions overflows at 780x360")
local r = state.rect
now = now + 1
View.touchpressed(imp, 11, r.x + 20, r.y + r.h - 5)
View.touchmoved(imp, 11, r.x + 20, r.y + 5)
draw(imp)
now = now + 1
View.touchreleased(imp, 11, r.x + 20, r.y + 5)
draw(imp)
View.update(imp, 1 / 60)
T.check(state.scroll > 0, "touch drag scrolls More Mod Actions")
T.check(imp._modHeaderActionsPopup ~= nil, "drag leaves More Mod Actions open")
state.scroll = 0
now = now + 1
Kit.setFocus("modheadact-6")
local rects = draw(imp)
local shown = false
for _, a in ipairs(rects) do
if a.label == "Sort mods..." and a.clip
and a.y >= a.clip.y - 0.5 and a.y + a.h <= a.clip.y + a.clip.h + 0.5 then
shown = true
end
end
T.check(shown, "focusing Sort mods scrolls it into view")
Kit.activateFocused()
View.draw(imp)
View.update(imp, 1 / 60)
T.eq(imp._sortPopup, "mods", "Sort mods is reachable from More Mod Actions at 780x360")
Kit.setFocus(nil)
end
do
local imp = opened(SWEEP[7])
local state = imp._modalScroll._profilesPopup
T.check(state.maxScroll > 0, "profile list scrolls at 780x360")
now = now + 1
Kit.setFocus("prof-sw-8")
draw(imp)
Kit.activateFocused()
View.draw(imp)
View.update(imp, 1 / 60)
T.check(state.scroll > 0, "focusing the last profile scrolls the list")
Kit.setFocus(nil)
end
do
local imp = opened(SWEEP[5])
local state = imp._modalScroll._modConfirm
T.check(state.maxScroll > 0, "long confirm scrolls at 780x360")
local yes
for _, r in ipairs(draw(imp)) do
if r.label == "OK" and not r.clip then yes = r end
end
T.check(yes ~= nil and inside(yes, width, height), "confirm OK stays pinned on screen")
end
do
local imp = fixture("red")
View.draw(imp)
local rows = {}
for i = 1, 40 do
rows[i] = { label = "OPTION " .. i, value = function() return "ON" end,
step = function() return true end }
end
imp._settings = { opts = {}, sections = { { title = "Options", rows = rows } },
save = function() end }
draw(imp)
local model = imp._settings
T.check(model.maxScroll > 0, "settings list overflows at 780x360")
local lines = {}
for i = 1, 10 do lines[i] = LONG end
model.confirm = { title = "Reset everything?", lines = lines,
yesLabel = "Yes", noLabel = "No", close = function() model.confirm = nil end }
draw(imp)
local all = imp._modalScroll or {}
local cs = all._settingsConfirm or all._settings
T.check(all._settingsConfirm ~= nil, "settings confirm keeps its own scroll state")
T.check(cs ~= nil and cs.maxScroll > 0, "long settings confirm scrolls at 780x360")
if cs and cs.rect then
local r, before = cs.rect, model.scroll or 0
now = now + 1
View.touchpressed(imp, 21, r.x + 20, r.y + r.h - 5)
View.touchmoved(imp, 21, r.x + 20, r.y + 5)
draw(imp)
now = now + 1
View.touchreleased(imp, 21, r.x + 20, r.y + 5)
draw(imp)
T.check(cs.scroll > 0, "touch drag scrolls the settings confirm body")
T.eq(model.scroll or 0, before, "confirm drag leaves the settings list alone")
T.check(model.confirm ~= nil, "drag leaves the settings confirm open")
T.check(imp._modalScroll._settingsConfirm == cs, "confirm scroll survives redraw")
end
model.confirm = nil
draw(imp)
T.eq(imp._modalScroll._settingsConfirm, nil, "closing the confirm drops its scroll state")
end
width, height = 1280, 720
do
local spec
for _, s in ipairs(SWEEP) do if s.key == "_modActions" then spec = s end end
local imp = opened(spec)
local state = imp._modalScroll._modActions
local del = state.rows["modact-del"]
T.check(state.rows["modact-top"] ~= nil, "mod actions shows the reorder rows")
T.eq(state.maxScroll, 0, "mod actions fits at 1280x720")
T.eq(del[1] + del[2], state.rect.h, "mod actions body ends at Delete with no blank row")
end
T.finish("launcher modal scroll")