Files
gen1recomp/tests/engine/launcher_sync_modal.lua
T
2026-08-20 20:36:23 -04:00

419 lines
16 KiB
Lua

package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
love.graphics.setLineJoin = love.graphics.setLineJoin or function() end
love.graphics.polygon = love.graphics.polygon or function() end
love.graphics.newShader = love.graphics.newShader or function() return {} end
local Kit = require("src.ui.kit.Kit")
local RomImporter = require("src.import.RomImporter")
local LauncherView = require("src.import.LauncherView")
local function read(path)
local f = assert(io.open(path, "r"))
local src = f:read("*a")
f:close()
return src
end
eq(RomImporter.syncDigits("1234-5678"), "12345678",
"the dash people read the code with is not part of it")
eq(RomImporter.syncDigits(" 12 34 "), "1234", "spaces are dropped")
eq(RomImporter.syncDigits("abc9"), "9", "letters cannot enter a digit code")
eq(RomImporter.syncDigits("123456789012"), "12345678",
"a code is eight digits and no more")
eq(RomImporter.syncDigits(nil), "", "an empty field stays empty")
eq(RomImporter.syncShareCode("abc234"), "ABC234", "share codes are upper case")
eq(RomImporter.syncShareCode("A1B0C-D"), "ABCD",
"1 and 0 are not in the share alphabet")
eq(RomImporter.syncShareCode("ABCDEFGH"), "ABCDEF",
"a share code is six characters")
local function fakeEngine(over)
local eng = {
phase = "idle", status = "Ready", conflicts = {}, state = { enabled = true },
calls = {},
isLinked = false,
linked = function(self) return self.isLinked end,
busy = function(self) return self.isBusy == true end,
createAccount = function(self, label)
self.calls[#self.calls + 1] = { "create", label }
self.isLinked = true
self.codes = { code1 = "1234-5678", code2 = "8765-4321" }
return true
end,
linkDevice = function(self, a, b, label)
self.calls[#self.calls + 1] = { "link", a, b, label }
if #tostring(a) ~= 8 or #tostring(b) ~= 8 then return false end
self.isLinked = true
return true
end,
syncNow = function(self)
self.calls[#self.calls + 1] = { "syncNow" }
return true
end,
unlink = function(self)
self.calls[#self.calls + 1] = { "unlink" }
self.isLinked, self.codes = false, nil
return true
end,
shareMods = function(self, withOptions)
self.calls[#self.calls + 1] = { "shareMods", withOptions }
self.shareCode = "K7QW3M"
return true
end,
answerModOptions = function(self, importThem)
self.calls[#self.calls + 1] = { "answerModOptions", importThem }
if self.modPlan then self.modPlan.applyOptions = importThem and true or false end
return importThem
end,
fetchShare = function(self, code)
self.calls[#self.calls + 1] = { "fetchShare", code }
return true
end,
applyModPlan = function(self, progress)
self.calls[#self.calls + 1] = { "applyModPlan" }
if progress then progress(1, 2, "a") progress(2, 2, "b") end
return true
end,
resolveConflict = function(self, key, choice)
self.calls[#self.calls + 1] = { "resolve", key, choice }
return true
end,
}
for k, v in pairs(over or {}) do eng[k] = v end
return eng
end
local function launcher(eng)
local imp = RomImporter.new(function() end, { launcher = true })
imp._sync = eng
imp._syncTransportOk = true
return imp
end
local eng = fakeEngine()
local imp = launcher(eng)
eq(imp._syncModal, nil, "the modal is closed until the header button opens it")
imp:_openSync()
check(imp._syncModal ~= nil, "the header button opens the modal")
eq(imp._syncModal.view, "home", "and lands on the home view")
eq(imp._syncFocus, nil, "with no field taking the keyboard")
imp:_syncView("link")
eq(imp._syncModal.view, "link", "Link this device swaps the view")
imp:_syncFocusField("code1")
eq(imp._syncFocus, "code1", "tapping a field focuses it")
imp:textinput("12ab34")
eq(imp._syncModal.code1, "1234", "typed letters never reach a code field")
imp:textinput("5678")
eq(imp._syncModal.code1, "12345678", "the field fills to eight digits")
imp:textinput("9")
eq(imp._syncModal.code1, "12345678", "and refuses a ninth")
imp:keypressed("backspace")
eq(imp._syncModal.code1, "1234567", "backspace drops one digit")
imp:textinput("8")
imp:_syncFocusField("code2")
eq(imp._syncFocus, "code2", "focus moves to the second code")
eq(imp._syncModal.code1, "12345678", "without disturbing the first")
imp:_syncFocusField("code2")
eq(imp._syncFocus, nil, "tapping the focused field again releases it")
imp:_syncFocusField("code2")
imp:textinput("87654321")
imp:_syncLink()
eq(eng.calls[#eng.calls][1], "link", "Link sends both codes to the engine")
eq(eng.calls[#eng.calls][2], "12345678", "the first code as typed")
eq(eng.calls[#eng.calls][3], "87654321", "and the second")
eq(imp._syncModal.view, "home", "a linked device comes back to the home view")
eq(imp._syncModal.code1, "", "and the codes are not left lying in the field")
eq(imp._syncModal.code2, "", "either of them")
eq(imp._syncFocus, nil, "with the keyboard released")
local short = launcher(fakeEngine())
short:_openSync()
short:_syncView("link")
short._syncModal.code1, short._syncModal.code2 = "1234", "87654321"
eq(short:_syncLink(), false, "a short code does not link")
eq(short._syncModal.code1, "1234",
"and what was typed stays put to be corrected")
imp:_syncFocusField("code1")
imp:keypressed("escape")
eq(imp._syncFocus, nil, "escape out of a field releases the keyboard")
check(imp._syncModal ~= nil, "and leaves the modal up")
imp:keypressed("escape")
eq(imp._syncModal, nil, "escape closes the modal")
imp:_openSync()
imp:_syncView("mods")
eq(imp._syncModal.withOptions, true,
"sharing carries the options that go with the mods by default")
imp:_syncShareMods()
eq(eng.calls[#eng.calls][2], true, "so the engine is told to include them")
imp:_syncToggleShareOptions()
eq(imp._syncModal.withOptions, false, "the toggle turns them off")
imp:_syncShareMods()
eq(eng.calls[#eng.calls][2], false,
"and a list can be shared with no options at all")
imp:_syncToggleShareOptions()
eq(eng.shareCode, "K7QW3M", "Share mod list asks the engine for a code")
imp:_syncFocusField("share")
imp:textinput("k7qw3m")
eq(imp._syncModal.share, "K7QW3M", "a typed share code is normalized")
imp:_syncGetShare()
eq(eng.calls[#eng.calls][2], "K7QW3M", "and handed to the engine as typed")
imp._syncModal.progress = nil
imp:_syncApplyMods()
eq(imp._syncModal.progress, nil,
"the progress line is cleared once the apply returns")
imp:_syncResolve("red/abc", "both")
eq(eng.calls[#eng.calls][1], "resolve", "the conflict buttons call the engine")
eq(eng.calls[#eng.calls][3], "both", "with the choice the player pressed")
imp:_syncUnlink()
eq(eng.isLinked, false, "Unlink drops the device")
eq(imp._syncModal.view, "home", "and the modal returns to the home view")
local bare = RomImporter.new(function() end, { launcher = true })
bare._sync = false
bare._syncTransportOk = true
bare:_openSync()
check(bare._syncModal ~= nil, "the modal opens without an engine")
bare:_closeSync()
eq(bare._syncModal, nil, "and closes again")
local function controls(imp2)
love.graphics.getDimensions = function() return 900, 780 end
love.graphics.getPixelDimensions = love.graphics.getDimensions
Kit.audit = {}
local ok, err = pcall(LauncherView.draw, imp2)
local labels = {}
for _, r in ipairs(Kit.audit or {}) do
if r.class == "control" then labels[r.label] = true end
end
Kit.audit = nil
check(ok, "the sync modal draws: " .. tostring(err))
return labels
end
local rEng = fakeEngine()
local rImp = launcher(rEng)
rImp:_openSync()
local labels = controls(rImp)
check(labels["Create sync account"], "an unlinked device is offered an account")
check(labels["Link this device"], "and the link road")
rEng:createAccount("mac")
labels = controls(rImp)
check(labels["Sync now"], "a linked device can sync on demand")
check(labels["Unlink this device"], "and unlink")
check(labels["Share or get a mod list"], "and reach the mod list road")
rImp:_syncView("link")
labels = controls(rImp)
check(labels["Back"], "the link view can back out")
rImp:_syncView("mods")
rEng.shareCode = "K7QW3M"
rEng.modPlan = { indexes = { "https://x" }, toInstall = { { id = "a" } },
toEnable = {}, missing = {} }
labels = controls(rImp)
check(labels["Share mod list"], "the mod view shares a list")
check(labels["Get mod list"], "and fetches one")
check(labels["Apply these mods"], "a fetched plan can be applied")
rEng.modPlan = { indexes = {}, toInstall = {}, toEnable = {}, missing = {},
options = { { id = "biggermod", values = { speed = 1 } },
{ id = "another-one", values = { theme = "dark" } } } }
labels = controls(rImp)
check(labels["Import their options"],
"a list that carries options asks before importing them")
check(labels["Keep my options"], "and offers to leave this device alone")
check(not labels["Apply these mods"],
"the question is answered before anything is applied")
rImp:_syncAnswerModOptions(false)
eq(rEng.calls[#rEng.calls][2], false, "the answer reaches the engine")
labels = controls(rImp)
check(labels["Apply these mods"], "and the apply road opens again")
check(not labels["Import their options"], "with the question gone")
rEng.modPlan = nil
rImp:_syncView("home")
rEng.devices = {
{ id = "0a1b2c3d", label = "OS X", current = true },
{ id = "99998888", label = "Android" },
}
labels = controls(rImp)
check(labels["Unlink Android"], "the other linked devices can be revoked here")
check(labels["OS X \194\183 this device"],
"and this one is named rather than offered twice")
local devRows = LauncherView.syncDeviceRows(rEng)
eq(#devRows, 2, "the modal reads the device list off the engine")
eq(devRows[1].current, true, "knowing which one is this device")
eq(#LauncherView.syncDeviceRows({}), 0,
"an engine that has not synced yet lists nothing")
local offline = launcher(fakeEngine())
offline._syncTransportOk = false
offline:_openSync()
labels = controls(offline)
check(not labels["Create sync account"],
"a device with no way to send signed requests is not offered an account")
check(labels["Close"], "it just explains itself and closes")
rEng.devices = nil
rEng.phase = "conflict"
rEng.conflicts = { {
key = "red/abc", version = "red", overlap = true,
localMeta = { savedAt = 1700000000, sessionStart = 1699999000,
summary = { name = "ASH", badges = 3, timeText = "5:42", dexCount = 40 } },
remoteMeta = { savedAt = 1700000500, sessionStart = 1699999500,
summary = { name = "ASH", badges = 4, timeText = "6:10", dexCount = 44 } },
} }
labels = controls(rImp)
check(labels["Keep this device"], "a conflict offers this device")
check(labels["Keep the other device"], "the other device")
check(labels["Keep both"], "and keeping both")
check(not labels["Sync now"],
"a conflict takes over the modal until it is answered")
local side = LauncherView.syncSideText({ savedAt = 1700000000,
summary = { name = "ASH", badges = 3, timeText = "5:42", dexCount = 40 } })
check(side:find("ASH", 1, true) ~= nil, "a side summary names the trainer")
check(side:find("3 badges", 1, true) ~= nil, "counts badges")
check(side:find("5:42", 1, true) ~= nil, "and shows play time")
eq(LauncherView.syncSideText(nil), "no details",
"a side with no metadata says so rather than drawing blank")
local quiet = launcher(fakeEngine())
quiet:_pumpSync(0.016)
eq(quiet._syncModal, nil, "a quiet auto-sync never interrupts the launcher")
local raised = launcher(fakeEngine({ phase = "conflict",
conflicts = { { key = "red/abc", version = "red" } } }))
raised:_pumpSync(0.016)
check(raised._syncModal ~= nil,
"a conflict found by the boot sync opens the prompt on its own")
raised:_closeSync()
raised:_pumpSync(0.016)
eq(raised._syncModal, nil,
"and a prompt the player dismissed does not reopen every frame")
local view = read("src/import/LauncherView.lua")
local impSrc = read("src/import/RomImporter.lua")
check(view:find('"tab-sync"', 1, true) ~= nil,
"the header tab row carries a Save Sync button")
local header = view:match("local HEADER_TABS = %{(.-)%}\n")
check(header and header:find('id = "skins"', 1, true) ~= nil,
"and it sits beside the skins tab")
check(view:find('"BETA"', 1, true) ~= nil,
"the button and the modal are labelled BETA")
check(view:find("buildSyncModal", 1, true) ~= nil,
"the sync UI is a modal, so it works from any tab")
local modals = view:match("local function modalUp%(imp%)(.-)\nend")
check(modals and modals:find("_syncModal", 1, true) ~= nil,
"the modal raises the click shield like every other one")
check(view:find("if imp._syncModal then buildSyncModal", 1, true) ~= nil,
"and buildModals routes it")
check(impSrc:find("_pumpSync(dt)", 1, true) ~= nil,
"the launcher pumps the sync engine every frame")
local pump = impSrc:match("function RomImporter:_pumpSync%(dt%)(.-)\nend\n")
check(pump and pump:find("self.launcher", 1, true) ~= nil,
"only the interactive launcher boots an engine of its own")
check(impSrc:find("_syncTypeInto", 1, true) ~= nil,
"text input is routed through the code filter")
do
local realCard = Kit.card
local card
Kit.card = function(x, y, w, h, variant)
card = { x = x, y = y, w = w, h = h }
realCard(x, y, w, h, variant)
end
local sizes = {
{ 1080, 2400 }, { 2400, 1080 }, { 1280, 720 }, { 720, 1280 },
{ 640, 960 }, { 480, 800 }, { 960, 540 }, { 800, 480 },
}
local views = {
{ "home", function() end },
{ "devices", function(_, e)
e.codes = { code1 = "1234-5678", code2 = "8765-4321" }
e.devices = { { id = "0a1b2c3d", label = "OS X", current = true },
{ id = "99998888", label = "Android" },
{ id = "77776666", label = "Steam Deck" } }
end },
{ "link", function(i) i:_syncView("link") end },
{ "mods", function(i, e)
i:_syncView("mods")
e.shareCode = "K7QW3M"
e.modPlan = { indexes = { "https://x" }, toInstall = { { id = "a" } },
toEnable = {}, missing = { { id = "z" } }, options = {} }
end },
{ "mod options", function(i, e)
i:_syncView("mods")
e.modPlan = { indexes = {}, toInstall = {}, toEnable = {}, missing = {},
options = { { id = "biggermod" }, { id = "another-one" },
{ id = "a-third-one" } } }
end },
{ "busy", function(i, e)
i:_syncView("mods")
e.isBusy = true
e.status = "Uploading the mod list and options..."
end },
{ "conflict", function(_, e)
e.phase = "conflict"
e.conflicts = { { key = "red/abc", version = "red", overlap = true,
localMeta = { savedAt = 1700000000, sessionStart = 1699999000,
summary = { name = "ASH", badges = 3, timeText = "5:42", dexCount = 40 } },
remoteMeta = { savedAt = 1700000500, sessionStart = 1699999500,
summary = { name = "ASH", badges = 4, timeText = "6:10", dexCount = 44 } } } }
end },
}
local worst = { over = 0 }
for _, view in ipairs(views) do
for _, size in ipairs(sizes) do
love.graphics.getDimensions = function() return size[1], size[2] end
love.graphics.getPixelDimensions = love.graphics.getDimensions
local e = fakeEngine({ isLinked = true })
local i = launcher(e)
i:_openSync()
view[2](i, e)
Kit.audit = {}
card = nil
local ok = pcall(LauncherView.draw, i)
local rows = Kit.audit or {}
Kit.audit = nil
check(ok, ("the %s panel draws at %dx%d"):format(view[1], size[1], size[2]))
for _, r in ipairs(rows) do
if r.class == "control" and card then
local over = math.max((r.x + r.w) - (card.x + card.w),
(r.y + r.h) - (card.y + card.h))
if over > worst.over then
worst = { over = over, view = view[1], w = size[1], h = size[2],
label = r.label }
end
end
end
end
end
check(worst.over <= 0, ("no sync button leaves its card%s"):format(
worst.over > 0 and (": %s %dx%d overflows by %d at '%s'"):format(
worst.view, worst.w, worst.h, worst.over, worst.label) or ""))
Kit.card = realCard
end
T.finish("launcher_sync_modal")