skin studio updates, save sync CLOSES #1533

This commit is contained in:
bryanthaboi
2026-08-19 05:57:44 -04:00
parent fd73ab2a11
commit 93374fbbbb
54 changed files with 9762 additions and 271 deletions
+83
View File
@@ -0,0 +1,83 @@
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")
local SaveData = require("src.core.SaveData")
local Save = require("src.core.gen2.Save")
local function memfs()
local files = {}
return {
files = files,
write = function(path, content) files[path] = content return true end,
read = function(path) return files[path] end,
remove = function(path) files[path] = nil return true end,
getInfo = function(path)
if files[path] ~= nil then return { type = "file" } end
return nil
end,
}
end
local fs = memfs()
SaveData.saveOptions(SaveData.defaultOptions(), fs)
local opts = Save.loadOptions(fs)
opts.modOptions = { nuzlocke = { dupes = true } }
opts.modProfiles = { { name = "casual", enabled = {} } }
opts.activeProfile = "casual"
opts.mods = { nuzlocke = true }
opts.modsByVersion = { gold = { hardmode = true } }
opts.textSpeed = "SLOW"
check(Save.saveOptions(opts, fs), "gold options write lands")
local file = SaveData.loadOptions(fs)
eq(file.modOptions and file.modOptions.nuzlocke and file.modOptions.nuzlocke.dupes,
true, "modOptions lands flat where gen1 and the launcher read it")
eq(file.activeProfile, "casual", "activeProfile lands flat")
eq(file.modProfiles and file.modProfiles[1] and file.modProfiles[1].name,
"casual", "modProfiles lands flat")
eq(file.mods and file.mods.nuzlocke, true, "enable flags land flat")
eq(file.modsByVersion and file.modsByVersion.gold
and file.modsByVersion.gold.hardmode, true, "per-version flags land flat")
eq(file[Save.OPTIONS_KEY].modOptions, nil, "gold block no longer traps modOptions")
eq(file[Save.OPTIONS_KEY].activeProfile, nil,
"gold block no longer traps activeProfile")
eq(file[Save.OPTIONS_KEY].textSpeed, "SLOW", "gold-only keys stay in the gold block")
local back = Save.loadOptions(fs)
eq(back.modOptions.nuzlocke.dupes, true, "flat modOptions round-trips into gold's table")
eq(back.activeProfile, "casual", "flat activeProfile round-trips")
local fs2 = memfs()
fs2.files["options.lua"] = [[return { gold = { textSpeed = "FAST",
modOptions = { nuzlocke = { dupes = true } }, activeProfile = "old" } }]]
local legacy = Save.loadOptions(fs2)
eq(legacy.modOptions and legacy.modOptions.nuzlocke
and legacy.modOptions.nuzlocke.dupes, true,
"modOptions trapped in the gold block migrates out")
eq(legacy.activeProfile, "old", "trapped activeProfile migrates")
eq(legacy.textSpeed, "FAST", "gold-only keys still merge")
check(Save.saveOptions(legacy, fs2), "migrated write lands")
local migrated = SaveData.loadOptions(fs2)
eq(migrated.modOptions and migrated.modOptions.nuzlocke.dupes, true,
"migration lands the trapped store flat")
eq(migrated[Save.OPTIONS_KEY].modOptions, nil, "migration empties the trap")
local fs3 = memfs()
fs3.files["options.lua"] = [[return { modOptions = { nuzlocke = { dupes = false } },
gold = { modOptions = { nuzlocke = { dupes = true } } } }]]
local both = Save.loadOptions(fs3)
eq(both.modOptions.nuzlocke.dupes, false, "flat modOptions wins over a trapped copy")
local Game2 = require("src.core.Game2")
check(type(Game2.writeOptions) == "function", "Game2 exposes writeOptions")
eq(Game2.writeOptions, Game2.persistOptions, "writeOptions is the persist path")
local ManagerState = require("src.mods.ManagerState")
local wrote = false
ManagerState.persistOptions({ game = { writeOptions = function() wrote = true end } })
check(wrote, "ManagerState:persistOptions writes through game.writeOptions")
T.finish("gen2_mod_options_persist")
+85
View File
@@ -0,0 +1,85 @@
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")
local SaveData = require("src.core.SaveData")
local Save = require("src.core.gen2.Save")
local function memfs()
local files = {}
return {
files = files,
write = function(path, content) files[path] = content return true end,
read = function(path) return files[path] end,
remove = function(path) files[path] = nil return true end,
getInfo = function(path)
if files[path] ~= nil then return { type = "file" } end
return nil
end,
}
end
local fs = memfs()
local seed = SaveData.defaultOptions()
seed.touchControls = { enabled = true, skin = "gb_anim" }
seed.haptics = "off"
seed[Save.OPTIONS_KEY] = { textSpeed = "FAST", touchControls = { enabled = false } }
check(SaveData.saveOptions(seed, fs) ~= nil, "seed write lands")
local opts = Save.loadOptions(fs)
eq(opts.touchControls and opts.touchControls.skin, "gb_anim",
"gold sees the skin the launcher picked")
eq(opts.touchControls.enabled, true, "top-level touchControls wins over the gold block")
eq(opts.haptics, "off", "top-level haptics wins over the gold default")
eq(opts.textSpeed, "FAST", "gold-block keys still merge")
local fs2 = memfs()
fs2.files["options.lua"] =
"return { gold = { touchControls = { enabled = false } } }"
local opts2 = Save.loadOptions(fs2)
eq(opts2.touchControls and opts2.touchControls.enabled, true,
"shared touchControls (default-folded) wins over a stale gold-block copy")
local fs3 = memfs()
SaveData.saveOptions(SaveData.defaultOptions(), fs3)
local gopts = Save.loadOptions(fs3)
gopts.touchControls = { enabled = true, skin = "tv_crt" }
gopts.haptics = "strong"
gopts.textSpeed = "SLOW"
check(Save.saveOptions(gopts, fs3), "gold options write lands")
local file = SaveData.loadOptions(fs3)
eq(file.touchControls and file.touchControls.skin, "tv_crt",
"gold's touch pick lands on the shared top-level key")
eq(file.haptics, "strong", "gold's haptics lands on the shared top-level key")
eq(file[Save.OPTIONS_KEY].touchControls, nil, "gold block no longer shadows touchControls")
eq(file[Save.OPTIONS_KEY].haptics, nil, "gold block no longer shadows haptics")
eq(file[Save.OPTIONS_KEY].textSpeed, "SLOW", "gold-only keys stay in the gold block")
local g1 = Save.loadOptions(fs3)
eq(g1.touchControls.skin, "tv_crt", "hoisted value round-trips back into gold")
local TouchSkin = require("src.core.TouchSkin")
local Chrome = require("src.ui.gen2.Chrome")
local savedViewport = TouchSkin.viewport
TouchSkin.viewport = function() return nil end
eq(Chrome.fitScale(640, 576), 4, "no cutout: integer fit against the window")
local ox, oy = Chrome.fitOrigin(640, 576)
eq(ox, 0, "no cutout: centred x")
eq(oy, 0, "no cutout: centred y")
TouchSkin.viewport = function(w, h) return w * 0.25, h * 0.125, w * 0.5, h * 0.5 end
eq(Chrome.fitScale(640, 576), 2, "cutout: integer fit against the cutout rect")
local cx, cy = Chrome.fitOrigin(640, 576)
eq(cx, 160 + (320 - 320) / 2, "cutout: origin starts at the cutout")
eq(cy, 72 + math.floor((288 - 288) / 2), "cutout: origin starts at the cutout y")
TouchSkin.viewport = function() error("boom") end
eq(Chrome.fitScale(640, 576), 4, "a throwing viewport degrades to the window fit")
TouchSkin.viewport = savedViewport
T.finish("gen2_touch_skin_options")
+125
View File
@@ -0,0 +1,125 @@
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")
local HostShell = require("src.core.HostShell")
local TOKEN = "0123456789abcdef0123456789abcdef"
local BODY = '{"blob":"return {}"}'
HostShell.haveCurl = function() return false end
love.system.getOS = function() return "Android" end
local calls = {}
local reply = "STATUS 200\n" .. '{"ok":true}'
love.system.httpRequest = function(url, method, headers, body, userAgent)
calls[#calls + 1] = { url = url, method = method, headers = headers,
body = body, userAgent = userAgent }
if type(reply) == "function" then return reply() end
return reply
end
check(HostShell.canHttpRequest(),
"the bridge counts as a request transport where curl does not exist")
local body, err, code = HostShell.httpRequest("https://sync.example/sync/save", {
method = "PUT",
body = BODY,
headers = {
["x-sync-account"] = "aa11bb22cc33dd44",
["x-sync-token"] = TOKEN,
["Content-Type"] = "application/json",
},
})
eq(code, 200, "a bridge request completes: " .. tostring(err))
eq(body, '{"ok":true}', "and the body arrives with the status line stripped")
eq(err, nil, "with no error alongside it")
eq(#calls, 1, "the bridge is called once")
local sent = calls[1]
eq(sent.url, "https://sync.example/sync/save", "the url goes through untouched")
eq(sent.method, "PUT", "and so does the method curl would have taken with -X")
eq(sent.body, BODY, "the save blob rides the body argument, not the url")
eq(sent.userAgent, "gen1recomp", "with the default user agent")
local seen = {}
for i = 1, #sent.headers, 2 do seen[sent.headers[i]] = sent.headers[i + 1] end
eq(seen["x-sync-token"], TOKEN, "auth headers arrive as flat name, value pairs")
eq(seen["x-sync-account"], "aa11bb22cc33dd44", "for the account id too")
eq(seen["Content-Type"], "application/json", "and for the content type")
eq(seen["User-Agent"], nil,
"the user agent stays its own argument rather than a duplicate header")
calls = {}
reply = "STATUS 409\n" .. '{"error":"the save moved on"}'
body, err, code = HostShell.httpRequest("https://sync.example/sync/save", {
method = "PUT", body = BODY, headers = { ["Accept"] = "application/json" },
})
eq(code, 409, "a conflict comes back as a status, not as a transport failure")
eq(body, '{"error":"the save moved on"}',
"and its body survives, which is the whole point of the request arm")
eq(err, nil, "a 4xx is the caller's to interpret")
calls = {}
reply = "ERROR the reply was too large\n"
body, err, code = HostShell.httpRequest("https://sync.example/sync/state", {
method = "GET",
})
eq(code, nil, "an ERROR envelope has no status")
eq(body, nil, "and no body")
check(err and err:find("the reply was too large", 1, true) ~= nil,
"the bridge's own complaint reaches the caller: " .. tostring(err))
check(err and err:find("https://sync.example/sync/state", 1, true) ~= nil,
"named with the url that failed")
calls = {}
reply = "STATUS 200\n" .. '{"ok":true}'
body, err, code = HostShell.httpRequest("https://sync.example/sync/save", {
method = "PUT", body = BODY,
headers = { ["x-sync-token"] = TOKEN .. "\r\nx-sync-account: stolen" },
})
eq(code, nil, "a header value carrying CRLF is refused")
eq(err, "bad request header", "with the same complaint the curl branch gives")
eq(#calls, 0, "and the bridge is never reached")
calls = {}
body, err, code = HostShell.httpRequest("https://sync.example/sync/save", {
method = "PATCH", body = BODY,
})
eq(code, nil, "a method the bridge cannot express is refused")
check(err and err:find("PATCH", 1, true) ~= nil,
"naming the method: " .. tostring(err))
eq(#calls, 0, "without calling the bridge")
calls = {}
reply = function() return nil end
body, err, code = HostShell.httpRequest("https://sync.example/sync/save", {
method = "PUT", body = BODY,
})
eq(code, nil, "an old app under a newer engine returns nothing")
check(err and err:find("update the app", 1, true) ~= nil,
"and degrades to an update notice rather than a crash: " .. tostring(err))
love.system.httpRequest = nil
love.system.httpDownload = function() return false end
check(not HostShell.canHttpRequest(),
"a build with only the download bridge cannot make signed requests")
body, err, code = HostShell.httpRequest("https://sync.example/sync/save", {
method = "PUT", body = BODY,
})
eq(code, nil, "so the request does not go out")
check(err and err:find("update the app", 1, true) ~= nil,
"and says what to do about it: " .. tostring(err))
love.system.httpDownload = nil
body, err, code = HostShell.httpRequest("https://sync.example/sync/save", {
method = "PUT", body = BODY,
})
eq(err, "no request transport on this platform",
"a platform with no bridge at all keeps its old answer")
T.finish("host shell bridge request")
@@ -0,0 +1,95 @@
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")
local HostShell = require("src.core.HostShell")
local MARK = "\n__gen1recomp_http__"
local SAVE_DIR = "/tmp/pokeport-stub-save"
local TOKEN = "0123456789abcdef0123456789abcdef"
local BODY = '{"blob":"return {}"}'
local realOpen = io.open
local realPopen = io.popen
local realRemove = os.remove
local realHaveCurl = HostShell.haveCurl
local files, removed = {}, {}
local popenCommand
HostShell.haveCurl = function() return true end
os.remove = function(path)
removed[path] = true
return true
end
io.open = function(path, mode)
local entry = { path = path, mode = mode, text = "" }
files[#files + 1] = entry
return {
write = function(_, value)
entry.text = entry.text .. value
return true
end,
close = function() return true end,
}
end
io.popen = function(command)
popenCommand = command
return {
read = function() return '{"ok":true}' .. MARK .. "200" end,
close = function() return true end,
}
end
local body, err, code = HostShell.httpRequest("https://sync.example/sync/save", {
method = "PUT",
body = BODY,
headers = {
["x-sync-account"] = "aa11bb22cc33dd44",
["x-sync-token"] = TOKEN,
["Content-Type"] = "application/json",
},
})
io.open = realOpen
io.popen = realPopen
os.remove = realRemove
HostShell.haveCurl = realHaveCurl
eq(code, 200, "the request completes: " .. tostring(err))
eq(body, '{"ok":true}', "and the response body comes back without the marker")
check(popenCommand:find(TOKEN, 1, true) == nil,
"the device token never reaches the command line")
check(popenCommand:find("aa11bb22cc33dd44", 1, true) == nil,
"and neither does the account id")
check(popenCommand:find(BODY, 1, true) == nil,
"the save blob stays out of the command line too")
local headerFile, bodyFile
for _, entry in ipairs(files) do
if entry.text:find("x-sync-token", 1, true) then headerFile = entry end
if entry.text == BODY then bodyFile = entry end
end
check(headerFile ~= nil, "the headers are staged in a file")
check(bodyFile ~= nil, "and so is the body")
eq(headerFile.mode, "wb", "the header file is written as bytes")
check(headerFile.text:find("x%-sync%-token: " .. TOKEN) ~= nil,
"with one header per line for curl to read")
check(headerFile.text:find("User%-Agent: ") ~= nil,
"including the user agent curl would otherwise take on argv")
check(popenCommand:find("-H '@" .. headerFile.path .. "'", 1, true) ~= nil,
"and curl is pointed at that file")
check(headerFile.path:find(SAVE_DIR, 1, true) == 1,
"staging happens in the user-private save directory, not shared /tmp")
check(bodyFile.path:find(SAVE_DIR, 1, true) == 1,
"for the body as well")
check(headerFile.path ~= bodyFile.path,
"two concurrent requests cannot collide on one name")
eq(removed[headerFile.path], true, "the staged headers are deleted afterwards")
eq(removed[bodyFile.path], true, "and so is the staged body")
T.finish("host shell request headers")
+324
View File
@@ -0,0 +1,324 @@
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.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 window(w, h)
love.graphics.getDimensions = function() return w, h end
love.graphics.getPixelDimensions = function() return w, h end
end
local function pointer(x, y)
love.mouse.getPosition = function() return x, y end
end
eq(Kit.scrollExtent(800, 500), 300, "the extent is exactly the overflow")
eq(Kit.scrollExtent(400, 500), 0, "content that fits has no extent")
eq(Kit.scrollExtent(400, -50), 400, "a negative viewport is no room, not more")
eq(Kit.scrollExtent(nil, nil), 0, "an unmeasured region has no extent")
eq(Kit.scrollClamp(-10, 300), 0, "an offset above the top clamps to it")
eq(Kit.scrollClamp(5000, 300), 300, "an offset past the end clamps to it")
eq(Kit.scrollClamp(120, 0), 0, "a region with no travel sits at the top")
local at, left = Kit.scrollHandoff(0, 300, 120)
eq(at, 120, "a move inside the extent is taken in full")
eq(left, 0, "and hands nothing on")
at, left = Kit.scrollHandoff(250, 300, 120)
eq(at, 300, "a move past the end stops at the end")
eq(left, 70, "and hands the remainder to whatever is behind it")
at, left = Kit.scrollHandoff(0, 300, -80)
eq(at, 0, "a move above the top stops at the top")
eq(left, -80, "and hands that remainder on with its sign")
local function wheelCase(offset, maxScroll, wheel, mx, my)
Kit.blockClicks = false
Kit.mouseX, Kit.mouseY = mx, my
Kit.wheelY = wheel
Kit._clipRect = nil
local moved, took = Kit.scrollWheel(offset, maxScroll, 0, 0, 100, 100, 50)
return moved, took, Kit.wheelY
end
local moved, took, leftWheel = wheelCase(0, 300, -1, 50, 50)
eq(moved, 50, "a notch over the region moves it by one step")
eq(took, true, "and reports the region took it")
eq(leftWheel, 0, "so nothing reaches the surface behind it")
moved, took, leftWheel = wheelCase(300, 300, -1, 50, 50)
eq(moved, 300, "a region already at its bottom does not move")
eq(took, false, "and does not claim the notch")
eq(leftWheel, -1, "which is what lets the page scroll take over")
moved, took, leftWheel = wheelCase(0, 300, 1, 50, 50)
eq(moved, 0, "a region at the top ignores an upward notch")
eq(leftWheel, 1, "and passes it on")
moved, took, leftWheel = wheelCase(0, 300, -1, 400, 400)
eq(took, false, "a notch outside the region is not the region's")
eq(leftWheel, -1, "and stays queued")
Kit.blockClicks = true
Kit.mouseX, Kit.mouseY, Kit.wheelY = 50, 50, -1
moved, took = Kit.scrollWheel(0, 300, 0, 0, 100, 100, 50)
eq(took, false, "a shielded frame (modal up) leaves the region alone")
eq(Kit.wheelY, -1, "so the modal's own scroller still sees the notch")
Kit.blockClicks = false
local function skinLauncher(count)
local imp = RomImporter.new(function() end, { launcher = true })
imp.tab = "skins"
local skins = {}
for i = 1, count do
skins[i] = { id = "skin" .. i, source = "user", controls = 8, pages = 1 }
end
imp._skins = skins
imp._ensureSkins = function() return skins end
return imp
end
window(360, 780)
local imp = skinLauncher(12)
LauncherView.draw(imp)
LauncherView.draw(imp)
local rect = imp._tabRegionRect
check(rect ~= nil, "the panel publishes the rect its region occupies")
check((imp._tabScrollMax.skins or 0) > 0,
"a panel with more rows than its viewport scrolls")
eq(imp._tabScroll.skins, 0, "a freshly drawn panel sits at the top")
check(imp._tabContentH.skins > rect.h,
"the region's content is taller than the viewport it is clipped to")
pointer(rect.x + 10, rect.y + 10)
imp._wheelY = -1
LauncherView.draw(imp)
local step = math.floor(48 * Kit.scale)
eq(imp._tabScroll.skins, step, "one notch scrolls the panel by one step")
eq(imp._pageScroll, 0,
"and the page under it does not move while the panel still can")
for _ = 1, 30 do
imp._wheelY = -1
LauncherView.draw(imp)
end
eq(imp._tabScroll.skins, imp._tabScrollMax.skins,
"held down, the panel reaches its own bottom")
eq(imp._pageScroll, imp._pageScrollMax,
"and only then does the leftover scroll the page")
for _ = 1, 40 do
imp._wheelY = 1
LauncherView.draw(imp)
end
eq(imp._tabScroll.skins, 0, "scrolling back up returns the panel to the top")
eq(imp._pageScroll, 0, "and the page with it")
imp._wheelY = -1
LauncherView.draw(imp)
local parked = imp._tabScroll.skins
check(parked > 0, "the skins panel is parked mid-scroll")
imp:_switchTab("red")
LauncherView.draw(imp)
eq(imp._tabScroll.red or 0, 0, "the game tab has its own offset")
eq(imp._tabScroll.skins, parked, "and the skins offset survives the switch")
imp:_switchTab("skins")
LauncherView.draw(imp)
eq(imp._tabScroll.skins, parked, "coming back lands where the player left")
imp._skins = {}
imp._ensureSkins = function() return {} end
LauncherView.draw(imp)
LauncherView.draw(imp)
eq(imp._tabScrollMax.skins, 0, "a panel that now fits has no travel")
eq(imp._tabScroll.skins, 0, "and its offset comes back with it")
local mods = {}
for i = 1, 60 do
mods[#mods + 1] = {
id = "mod" .. i, name = "Mod " .. i, version = "1.0.0",
status = "ok", badge = "gameplay", description = "a mod",
enabledByVersion = { red = true },
}
end
window(360, 780)
local modImp = RomImporter.new(function() end, { launcher = true })
modImp.tab = "mods"
modImp.mods = mods
modImp._ensureMods = function() return mods end
LauncherView.draw(modImp)
LauncherView.draw(modImp)
check((modImp._modScrollMax or 0) > 0,
"60 mods overflow the list viewport inside the panel")
local list = modImp._modListRect
check(list.x + list.w
<= modImp._tabRegionRect.x + modImp._tabRegionRect.w - Kit.scrollBarW(),
"the rows stop short of the region's scrollbar gutter")
pointer(list.x + 10, list.y + 10)
modImp._wheelY = -1
LauncherView.draw(modImp)
check((modImp.modScroll or 0) > 0, "a notch over the mod list scrolls the list")
eq(modImp._tabScroll.mods or 0, 0, "not the panel region around it")
eq(modImp._pageScroll, 0, "and not the page behind that")
modImp._modActions = "mod1"
local shielded = modImp.modScroll
local shieldedPage = modImp._pageScroll
pointer(list.x + 10, list.y + 10)
modImp._wheelY = -1
LauncherView.draw(modImp)
eq(modImp.modScroll, shielded, "a shielded mod list ignores the notch")
eq(modImp._tabScroll.mods or 0, 0, "and so does the region under the scrim")
eq(modImp._pageScroll, shieldedPage, "and the page behind that")
modImp._modActions = nil
modImp._wheelY = 0
LauncherView.draw(modImp)
window(360, 780)
local gameImp = RomImporter.new(function() end, { launcher = true })
gameImp.tab = "red"
gameImp.ready = { red = true }
gameImp.slots = { red = {} }
for i = 1, 8 do
gameImp.slots.red[i] = { id = "slot" .. i, name = "Slot " .. i }
end
gameImp._ensureSlots = function() end
LauncherView.draw(gameImp)
LauncherView.draw(gameImp)
check((gameImp._tabScrollMax.red or 0) > 0,
"a game tab whose cart and slots outgrow the viewport scrolls too")
check(gameImp._tabContentH.red > gameImp._tabRegionRect.h,
"because it reports its NATURAL height, not the height it was given")
pointer(gameImp._tabRegionRect.x + 10, gameImp._tabRegionRect.y + 10)
gameImp._wheelY = -1
LauncherView.draw(gameImp)
check((gameImp._tabScroll.red or 0) > 0, "and a notch over it moves it")
window(360, 780)
local touchImp = skinLauncher(12)
LauncherView.draw(touchImp)
LauncherView.draw(touchImp)
local treg = touchImp._tabRegionRect
local tmax = touchImp._tabScrollMax.skins
check(tmax > 0, "the touched panel has travel")
LauncherView.touchpressed(touchImp, 1, treg.x + 20, treg.y + 40)
LauncherView.touchmoved(touchImp, 1, treg.x + 20, treg.y + 40 - 200)
eq(touchImp._tabScroll.skins, math.min(200, tmax),
"dragging up scrolls the panel by the finger's travel")
eq(touchImp._pageScroll, 0, "while the panel still has travel, the page waits")
LauncherView.touchmoved(touchImp, 1, treg.x + 20, treg.y + 40 - 200 - tmax * 2)
eq(touchImp._tabScroll.skins, tmax, "a longer drag reaches the panel's bottom")
check((touchImp._pageScroll or 0) > 0, "and spills into the page from there")
LauncherView.touchreleased(touchImp, 1, treg.x + 20, treg.y - 400)
window(360, 780)
local dragMods = RomImporter.new(function() end, { launcher = true })
dragMods.tab = "mods"
dragMods.mods = mods
dragMods._ensureMods = function() return mods end
LauncherView.draw(dragMods)
LauncherView.draw(dragMods)
local dlist = dragMods._modListRect
local dListMax = dragMods._modScrollMax
local dRegionMax = dragMods._tabScrollMax.mods
check(dListMax > 0 and dRegionMax > 0,
"the mods tab has both an inner list and a region to scroll")
LauncherView.touchpressed(dragMods, 7, dlist.x + 20, dlist.y + 30)
LauncherView.touchmoved(dragMods, 7, dlist.x + 20, dlist.y + 30 - 60)
eq(dragMods.modScroll, math.min(60, dListMax),
"the first pixels of the drag move the list")
eq(dragMods._tabScroll.mods or 0, 0, "and nothing else")
LauncherView.touchmoved(dragMods, 7, dlist.x + 20,
dlist.y + 30 - 60 - dListMax - dRegionMax * 2)
eq(dragMods.modScroll, dListMax, "carrying on saturates the list")
eq(dragMods._tabScroll.mods, dRegionMax,
"then the same gesture walks the region to its bottom")
check((dragMods._pageScroll or 0) > 0, "and only then reaches the page")
LauncherView.touchreleased(dragMods, 7, dlist.x + 20, dlist.y - 900)
dragMods._skins = { { id = "s1", source = "user", controls = 8, pages = 1 } }
for i = 2, 12 do
dragMods._skins[i] = { id = "s" .. i, source = "user", controls = 8, pages = 1 }
end
dragMods._ensureSkins = function() return dragMods._skins end
dragMods.modScroll = 0
local heldModScroll = dragMods.modScroll
local overList = dlist.y + 30
dragMods:_switchTab("skins")
LauncherView.draw(dragMods)
LauncherView.draw(dragMods)
local sreg = dragMods._tabRegionRect
check((dragMods._tabScrollMax.skins or 0) > 0, "the skins tab has travel")
LauncherView.touchpressed(dragMods, 9, sreg.x + 20, overList)
LauncherView.touchmoved(dragMods, 9, sreg.x + 20, overList - 200)
check((dragMods._tabScroll.skins or 0) > 0,
"a drag on the skins tab scrolls the skins tab")
eq(dragMods.modScroll, heldModScroll,
"and leaves the mod list where the player parked it")
LauncherView.touchreleased(dragMods, 9, sreg.x + 20, sreg.y - 400)
love.graphics.polygon = love.graphics.polygon or function() end
window(360, 780)
local padImp = skinLauncher(12)
LauncherView.draw(padImp)
LauncherView.draw(padImp)
local preg = padImp._tabRegionRect
padImp._padCursorActive = true
padImp._padCursor = { x = preg.x + 10, y = preg.y + preg.h - 4 }
LauncherView.wheelmoved(padImp, 0, -1)
LauncherView.draw(padImp)
check((padImp._tabScroll.skins or 0) > 0,
"the pad's synthesized wheel scrolls the region its cursor sits in")
eq(padImp._pageScroll, 0, "and not the page behind it")
window(1280, 720)
local edgeImp = skinLauncher(40)
LauncherView.draw(edgeImp)
LauncherView.draw(edgeImp)
local ereg = edgeImp._tabRegionRect
check((edgeImp._tabScrollMax.skins or 0) > 0, "the wide window still overflows")
eq(edgeImp._pageScrollMax, 0, "with no page scroll left to catch the notch")
check(ereg.y + ereg.h < 720, "and a region that ends above the safe area")
edgeImp._padCursorActive = true
edgeImp._padCursor = { x = ereg.x + 20, y = 719 }
edgeImp._padAxis = { lefty = 1 }
edgeImp._padDir = {}
pointer(ereg.x + 20, 719)
edgeImp:_updatePadCursor(0.5)
check((edgeImp._wheelY or 0) < 0, "the edge push synthesizes a notch")
LauncherView.draw(edgeImp)
check((edgeImp._tabScroll.skins or 0) > 0,
"which reaches the tab region even though the cursor is below it")
local function read(path)
local f = assert(io.open(path, "r"))
local src = f:read("*a")
f:close()
return src
end
local view = read("src/import/LauncherView.lua")
check(view:find("Kit.scrollBegin(", 1, true) ~= nil,
"the panel dispatch opens a scroll region")
check(view:find("Kit.scrollEnd(", 1, true) ~= nil, "and closes it")
check(view:find("modListWantsWheel", 1, true) ~= nil,
"the nested mod list is asked before the region takes a notch")
check(view:find("start.region", 1, true) ~= nil,
"a touch drag that began in the region scrolls the region")
check(view:find("Kit.scrollGutter(", 1, true) ~= nil,
"the panels lay out inside a gutter, so the thumb covers no control")
check(view:find("Kit.scrollHandoff(tabScrollAt(imp)", 1, true) ~= nil,
"and hands its leftover to the page, like the wheel does")
local kit = read("src/ui/kit/Kit.lua")
check(kit:find("function Kit.scrollWheel", 1, true) ~= nil,
"the kit owns the wheel rule, so no panel hand-rolls a fifth copy")
T.finish("launcher scroll regions")
+268
View File
@@ -0,0 +1,268 @@
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.newShader = love.graphics.newShader or function() return {} end
local RomImporter = require("src.import.RomImporter")
local LauncherView = require("src.import.LauncherView")
local TouchSkin = require("src.core.TouchSkin")
local function read(path)
local f = assert(io.open(path, "r"))
local src = f:read("*a")
f:close()
return src
end
local function window(w, h)
love.graphics.getDimensions = function() return w, h end
love.graphics.getPixelDimensions = function() return w, h end
end
local function launcher()
return RomImporter.new(function() end, { launcher = true })
end
eq(RomImporter.skinUrlName("https://example.com/pads/gbc.zip"), "gbc.zip",
"a direct .zip keeps its name")
eq(RomImporter.skinUrlName("https://example.com/pads/Neon.deltaskin"),
"Neon.deltaskin", "and so does a .deltaskin")
eq(RomImporter.skinUrlName("https://example.com/overlay.cfg"), "overlay.cfg",
"a bare RetroArch cfg is kept as a cfg")
eq(RomImporter.skinUrlName("https://example.com/pads/gbc.zip?raw=1"), "gbc.zip",
"a query string is not part of the name")
eq(RomImporter.skinUrlName("https://example.com/pads/gbc.zip#frag"), "gbc.zip",
"nor is a fragment")
eq(RomImporter.skinUrlName("https://example.com/download"), "download.zip",
"an extension-less link is treated as an archive")
eq(RomImporter.skinUrlName("https://example.com/a b/../pad.tar"), "pad.zip",
"an unknown extension is replaced, and the name is sanitized")
check(RomImporter.skinUrlName("https://example.com/"):match("^[%w%._%-]+$"),
"the download name can never escape the skins folder")
local name, payload = RomImporter.wrapSkinPayload("overlay.cfg",
"overlays = 1\noverlay0_descs = 0\n")
eq(name, "overlay.zip", "a downloaded .cfg is wrapped into an archive")
eq(payload:sub(1, 2), "PK", "which is a real zip")
check(payload:find("overlays = 1", 1, true) ~= nil,
"carrying the cfg text inside it")
check(payload:find("overlay.cfg", 1, true) ~= nil,
"under the name RetroArch parsing expects")
local zipName, zipData = RomImporter.wrapSkinPayload("pad.zip", "PK\3\4stuff")
eq(zipName, "pad.zip", "a zip is passed through untouched")
eq(zipData, "PK\3\4stuff", "bytes and all")
eq(select(1, RomImporter.wrapSkinPayload("pad.deltaskin", "PK\3\4x")),
"pad.deltaskin", "and so is a .deltaskin")
local pkName, pkData = RomImporter.wrapSkinPayload("overlay.cfg", "PK\3\4real")
eq(pkName, "overlay.zip", "a .cfg link that serves zip bytes is renamed, not refused")
eq(pkData, "PK\3\4real", "and its bytes are left alone")
local imp = launcher()
check(not imp:_addSkinFromUrl(""), "an empty link is refused")
check(imp._skinNotice and not imp._skinNotice.ok, "with a visible error")
eq(imp._skinFetch, nil, "and no download is started")
check(not imp:_addSkinFromUrl("file:///etc/passwd"),
"a non-http link is refused")
eq(imp._skinFetch, nil, "and still starts nothing")
check(not imp:_addSkinFromUrl("skins/local.zip"),
"a bare path is not a link either")
local Fetch = require("src.net.Fetch")
local realDownload, realPoll, realRelease = Fetch.download, Fetch.poll,
Fetch.release
local asked
Fetch.download = function(url, dest) asked = { url = url, dest = dest } return 7 end
Fetch.poll = function() return { status = "pending", progress = 0.5 } end
Fetch.release = function() end
imp = launcher()
imp.skinUrl = "https://example.com/pads/neon.deltaskin"
check(imp:_addSkinFromUrl(), "a good link starts a download")
check(imp._skinFetch ~= nil, "and parks the job on the importer")
eq(asked.url, "https://example.com/pads/neon.deltaskin", "the url is fetched")
check(asked.dest:find("neon.deltaskin", 1, true) ~= nil,
"into a file named after the link")
check(asked.dest:find("%.%.") == nil, "with no traversal in the path")
check(not imp:_addSkinFromUrl("https://example.com/other.zip"),
"a second add while one is in flight is ignored")
imp:_pumpSkinFetch()
check(imp._skinFetch ~= nil, "a pending download stays in flight")
local installed
imp._installSkinData = function(_, n, d) installed = { name = n, data = d } return "neon" end
Fetch.poll = function()
return { status = "ok", path = "skins/_download/neon.deltaskin" }
end
love.filesystem.write("skins/_download/neon.deltaskin", "PK\3\4payload")
imp:_pumpSkinFetch()
eq(imp._skinFetch, nil, "a finished download is released")
check(installed ~= nil, "and its bytes go to the installer")
eq(installed.name, "neon.deltaskin", "under the downloaded name")
eq(love.filesystem.read("skins/_download/neon.deltaskin"), nil,
"the temporary download is cleaned up")
eq(imp.skinUrl, "", "and the field is cleared for the next one")
imp = launcher()
imp._installSkinData = function() return nil end
Fetch.download = function() return 8 end
Fetch.poll = function() return { status = "error", err = "404" } end
imp:_addSkinFromUrl("https://example.com/missing.zip")
imp:_pumpSkinFetch()
eq(imp._skinFetch, nil, "a failed download is released too")
check(imp._skinNotice and not imp._skinNotice.ok, "and reported")
check(tostring(imp._skinNotice.text):find("404", 1, true) ~= nil,
"with the reason attached")
Fetch.download, Fetch.poll, Fetch.release = realDownload, realPoll, realRelease
imp = launcher()
eq(imp:_installSkinData("pad.zip", ""), nil, "an empty payload is refused")
check(imp._skinNotice and not imp._skinNotice.ok, "and says so")
eq(imp:_installSkinData("notes.txt", "hello"), nil, "a non-archive is refused")
love.filesystem.write("skins/warny.zip/overlay.cfg", [[
overlays = 1
overlay0_name = "warny"
overlay0_normalized = true
overlay0_descs = 2
overlay0_desc0 = "a,0.5,0.5,rect,0.05,0.05"
]])
imp = launcher()
eq(imp:_installSkinData("warny.zip", "PK\3\4stub"), "warny", "a skin installs")
check(imp._skinNotice.ok, "with an ok notice")
check(tostring(imp._skinNotice.text):find("missing desc", 1, true) ~= nil,
"that repeats what the importer had to complain about")
love.filesystem.write("skins/vecty.deltaskin/info.json", [[
{ "name": "Vecty", "gameTypeIdentifier": "com.rileytestut.delta.game.gbc",
"representations": { "iphone": { "standard": { "portrait": {
"assets": { "resizable": "iphone_portrait.pdf" },
"mappingSize": {"width":320,"height":480},
"items": [ { "inputs": ["a"], "frame": {"x":0,"y":0,"width":32,"height":32} } ]
} } } } }
]])
imp = launcher()
eq(imp:_installSkinData("vecty.deltaskin", "PK\3\4stub"), nil,
"a PDF-only Delta skin does not install silently")
check(imp._skinNotice and not imp._skinNotice.ok, "the tab reports the refusal")
check(tostring(imp._skinNotice.text):find("PDF artwork", 1, true) ~= nil,
"and says why, instead of listing a skin with no buttons")
local function dropped(fileName)
return { getFilename = function() return fileName end,
open = function() return false end }
end
local routed
local function routeDrop(tab, fileName)
routed = nil
local drop = launcher()
drop.tab = tab
drop._installSkinZip = function() routed = "skin" end
drop._installMod = function() routed = "mod" end
drop.startData = function() routed = "rom" end
drop:filedropped(dropped(fileName))
return routed
end
eq(routeDrop("mods", "pad.deltaskin"), "skin",
"a dropped .deltaskin installs as a skin from any tab")
eq(routeDrop("skins", "pad.deltaskin"), "skin", "and from the skins tab")
eq(routeDrop("skins", "Neon.DeltaSkin"), "skin", "whatever its case")
eq(routeDrop("skins", "pad.zip"), "skin", "a zip on the skins tab is still a skin")
eq(routeDrop("mods", "pad.zip"), "mod", "and a mod anywhere else")
check(TouchSkin.saveTo(TouchSkin.newSkin("uxskin"), "uxskin") ~= nil,
"a skin to list")
imp = launcher()
local entries = imp:_ensureSkins(true)
check(#entries > 0, "the installed skins are listed")
local byId = {}
for _, entry in ipairs(entries) do
byId[entry.id] = entry
check(type(entry.format) == "string",
entry.id .. " reports the format it was parsed from")
end
eq(byId.uxskin and byId.uxskin.format, "native",
"a skin.lua skin is badged as the native format")
imp = launcher()
eq(imp:_exportSkin("no-such-skin", "native"), nil, "exporting a ghost fails")
check(imp._skinNotice and not imp._skinNotice.ok, "with an error notice")
local first = entries[1]
imp = launcher()
local path = imp:_exportSkin(first.id, "delta")
check(path ~= nil and path:match("%.deltaskin$") ~= nil,
"a bundled skin exports as a .deltaskin")
check(imp._skinNotice.ok, "and the tab reports where it landed")
check(tostring(imp._skinNotice.text):find(path, 1, true) ~= nil,
"naming the path, which is the whole mobile story")
check(imp._skinExport ~= nil and imp._skinExport.path == path,
"the export is remembered so Show file can reveal it")
path = imp:_exportSkin(first.id, "retroarch")
check(path ~= nil and path:match("%.zip$") ~= nil,
"and as a RetroArch .zip")
path = imp:_exportSkin(first.id, "native")
check(path ~= nil and path:match("%.zip$") ~= nil, "and as a gen1recomp .zip")
window(420, 900)
imp = launcher()
imp.tab = "skins"
LauncherView.draw(imp)
LauncherView.draw(imp)
check(true, "the skins tab draws with the URL row")
imp._skinActions = { id = entries[1].id }
LauncherView.draw(imp)
check(imp._skinActions ~= nil, "the actions sheet stays up while it draws")
imp._skinFetch = { name = "neon.zip" }
LauncherView.draw(imp)
imp._skinFetch = nil
window(320, 640)
LauncherView.draw(imp)
LauncherView.draw(imp)
check(true, "and on a phone-width window, where Paste gives up its room")
local view = read("src/import/LauncherView.lua")
local rom = read("src/import/RomImporter.lua")
check(view:find('"skins-url"', 1, true) ~= nil,
"the skins tab carries an add-by-URL field")
check(view:find('"skins-url-add"', 1, true) ~= nil, "with a button to submit it")
check(view:find('"skins-url-paste"', 1, true) ~= nil,
"and a paste button, because a phone cannot type a URL")
check(view:find("_addSkinFromUrl", 1, true) ~= nil,
"which reaches the importer's downloader")
check(view:find("Loader.inline", 1, true) ~= nil,
"and the row shows progress while it runs")
check(view:find("SKIN_FORMAT_LABEL", 1, true) ~= nil,
"rows carry a format badge")
check(view:find("buildSkinActionsModal", 1, true) ~= nil,
"the gear opens an actions sheet")
check(view:find("_exportSkin", 1, true) ~= nil, "which can export the skin")
check(view:find("skinact-exp-delta", 1, true) ~= nil,
"including as a Delta skin")
local modals = view:match("local function modalUp%(imp%)(.-)\nend")
check(modals and modals:find("_skinActions", 1, true) ~= nil,
"the sheet raises the modal shield like every other popup")
check(view:find("imp.onOpenSkinStudio(imp.modScope or \"red\", id)", 1, true)
~= nil, "and still hands the studio a real game version")
check(rom:find("deltaskin", 1, true) ~= nil,
"the desktop file picker offers .deltaskin")
check(rom:find("_pumpSkinFetch", 1, true) ~= nil,
"the skin download is pumped from update()")
local update = rom:match("function RomImporter:update%(dt%)(.-)\nend\n")
check(update and update:find("_pumpSkinFetch", 1, true) ~= nil,
"from inside update itself, not just declared")
check(TouchSkin.ARCHIVE_EXTS.deltaskin == true,
"and the installer accepts the extension")
T.finish("launcher_skins_ux")
+307
View File
@@ -0,0 +1,307 @@
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)
self.calls[#self.calls + 1] = { "shareMods" }
self.shareCode = "K7QW3M"
return true
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")
imp:_syncShareMods()
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")
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")
T.finish("launcher_sync_modal")
+626
View File
@@ -0,0 +1,626 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local T = require("tests.harness")
local check, eq = T.check, T.eq
local TouchSkin = require("src.core.TouchSkin")
local DeltaSkin = require("src.core.DeltaSkin")
local Json = require("src.link.Json")
local function near(got, want, msg)
return check(type(got) == "number" and math.abs(got - want) < 1e-6,
("%s (got %s, want %s)"):format(msg, tostring(got), tostring(want)))
end
local function hasWarning(skin, fragment)
for _, w in ipairs(skin.warnings or {}) do
if tostring(w):find(fragment, 1, true) then return true end
end
return false
end
local function unzip(bytes)
local out, i = {}, 1
while bytes:sub(i, i + 3) == "PK\3\4" do
local function u16(off)
local a, b = bytes:byte(i + off, i + off + 1)
return a + b * 256
end
local function u32(off)
local a, b, c, d = bytes:byte(i + off, i + off + 3)
return a + b * 256 + c * 65536 + d * 16777216
end
local size, nameLen, extraLen = u32(18), u16(26), u16(28)
local name = bytes:sub(i + 30, i + 29 + nameLen)
local start = i + 30 + nameLen + extraLen
out[name] = bytes:sub(start, start + size - 1)
out[#out + 1] = name
i = start + size
end
return out
end
local function readBytes(path)
local f = assert(io.open(path, "rb"))
local data = f:read("*a")
f:close()
return data
end
local GAMEBOY_CFG = [[
overlays = 4
overlay0_name = "landscape"
overlay0_full_screen = true
overlay0_normalized = true
overlay0_range_mod = 1.5
overlay0_alpha_mod = 2.0
overlay0_aspect_ratio = 2.22222222222222
overlay0_descs = 13
overlay0_desc0 = "nul,0.0985,0.6825,rect,0.0525,0.0875"
overlay0_desc0_overlay = img/dpad.png
overlay0_desc1 = "up,0.0985,0.5950,rect,0.0175,0.0292"
overlay0_desc2 = "down,0.0985,0.7700,rect,0.0175,0.0292"
overlay0_desc3 = "left,0.0460,0.6825,rect,0.0175,0.0292"
overlay0_desc4 = "right,0.1510,0.6825,rect,0.0175,0.0292"
overlay0_desc5 = "left|up,0.0460,0.5950,rect,0.0175,0.0292"
overlay0_desc6 = "right|up,0.1510,0.5950,rect,0.0175,0.0292"
overlay0_desc7 = "left|down,0.0460,0.7700,rect,0.0175,0.0292"
overlay0_desc8 = "right|down,0.1510,0.7700,rect,0.0175,0.0292"
overlay0_desc9 = "a,0.8975,0.6300,radial,0.0525,0.0875"
overlay0_desc9_overlay = img/a.png
overlay0_desc10 = "b,0.8100,0.7350,radial,0.0525,0.0875"
overlay0_desc10_overlay = img/b.png
overlay0_desc11 = "start,0.5500,0.9000,rect,0.0500,0.0400"
overlay0_desc12 = "select,0.4500,0.9000,rect,0.0500,0.0400"
overlay1_name = "portrait"
overlay1_full_screen = true
overlay1_normalized = true
overlay1_aspect_ratio = 0.45
overlay1_descs = 2
overlay1_desc0 = "a,0.8975,0.6300,radial,0.0875,0.0525"
overlay1_desc1 = "b,0.8100,0.7350,radial,0.0875,0.0525"
overlay2_name = "menu"
overlay2_full_screen = true
overlay2_normalized = true
overlay2_descs = 1
overlay2_desc0 = "menu_toggle,0.5,0.5,rect,0.1,0.1"
overlay3_name = "hide"
overlay3_full_screen = true
overlay3_normalized = true
overlay3_descs = 1
overlay3_desc0 = "overlay_next,0.95,0.05,radial,0.04,0.04"
overlay3_desc0_next_target = "landscape"
]]
local gameboy = assert(TouchSkin.parse(GAMEBOY_CFG))
eq(#gameboy.pages, 4, "the canonical gameboy overlay has four pages")
eq(gameboy.pages[1].orient, "landscape", "page 1 auto-rotates landscape")
eq(gameboy.pages[2].orient, "portrait", "page 2 auto-rotates portrait")
check(TouchSkin.hasOrientPair(gameboy), "so it is an auto-rotate overlay")
eq(gameboy.pages[3].orient, nil, "the menu page is not part of the pair")
eq(#gameboy.pages[1].controls, 13, "every landscape desc parsed")
check(gameboy.pages[1].controls[1].decorative, "the d-pad art desc binds nothing")
eq(gameboy.pages[1].controls[1].imagePath, "img/dpad.png", "and carries the art")
eq(gameboy.pages[1].imagePath, nil, "the overlay ships no page background")
eq(gameboy.pages[4].controls[1].nextTarget, "landscape", "hide jumps back by name")
local named = {}
for _, ctl in ipairs(gameboy.pages[1].controls) do
for _, btn in ipairs(ctl.buttons) do named[btn] = true end
end
for _, btn in ipairs({ "a", "b", "start", "select", "up", "down", "left", "right" }) do
check(named[btn], "landscape binds GB " .. btn)
end
eq(#gameboy.warnings, 0, "a well-formed overlay warns about nothing")
local SPACED_CFG = [[
overlays = 1
overlay0_name = "spaced"
overlay0_normalized = true
overlay0_descs = 2
overlay0_desc0 = "a 0.5 0.5 rect 0.05 0.05"
overlay0_desc0_saturate_pct = 0.6
overlay0_desc0_exclusive = true
overlay0_desc0_movable = true
overlay0_desc1 = b,0.25,0.5,radial,0.05,0.05
]]
local spaced = assert(TouchSkin.parse(SPACED_CFG))
near(spaced.pages[1].controls[1].saturatePct, 0.6, "_saturate_pct is parsed")
check(spaced.pages[1].controls[1].exclusive, "_exclusive is parsed")
check(spaced.pages[1].controls[1].movable, "_movable is parsed on a plain desc")
eq(spaced.pages[1].controls[2].exclusive, nil, "and is not inherited")
eq(#spaced.pages[1].controls, 2, "a space-separated desc still parses")
eq(spaced.pages[1].controls[1].buttons[1], "a", "space-separated bind")
near(spaced.pages[1].controls[1].x, 0.5, "space-separated position")
eq(spaced.pages[1].controls[2].buttons[1], "b", "an unquoted desc parses too")
eq(spaced.pages[1].controls[2].shape, "radial", "and keeps its hitbox shape")
local SHORT_CFG = [[
overlays = 1
overlay0_name = "short"
overlay0_normalized = true
overlay0_descs = 2
overlay0_desc0 = "a,0.5,0.5,rect,0.05,0.05"
]]
local short = assert(TouchSkin.parse(SHORT_CFG))
eq(#short.pages[1].controls, 1, "a missing desc is skipped, not faked")
check(hasWarning(short, "missing desc 1"), "and the importer says so")
eq(select(1, TouchSkin.parse("overlay0_descs = 1\n")), nil,
"a cfg without the overlays key is refused")
local AREA_CFG = [[
overlays = 1
overlay0_name = "portrait"
overlay0_full_screen = true
overlay0_normalized = true
overlay0_descs = 3
overlay0_desc0 = "dpad_area,0.2,0.7,rect,0.15,0.1"
overlay0_desc0_overlay = img/dpad.png
overlay0_desc0_reach_x = 1.5
overlay0_desc0_movable = true
overlay0_desc1 = "abxy_area,0.8,0.7,radial,0.12,0.08"
overlay0_desc1_up = "start"
overlay0_desc2 = "analog_left,0.2,0.3,radial,0.1,0.1"
overlay0_desc2_saturate_pct = 0.6
overlay0_desc2_exclusive = true
]]
local area = assert(TouchSkin.parse(AREA_CFG))
local ap = area.pages[1]
near(ap.aspect, 0.5625, "a portrait-named overlay defaults to 9:16")
check(not ap.aspectFromCfg, "and that default is not a cfg aspect lock")
eq(#ap.controls, 1 + 8 + 8 + 8, "each area desc expands into eight hitboxes")
local art = ap.controls[1]
check(art.decorative, "the dpad_area art is carried by a decoration")
eq(art.imagePath, "img/dpad.png", "with the desc's own overlay image")
near(art.rangeX, 0.15, "sized like the area it replaces")
local sectorE = ap.controls[2]
eq(sectorE.spec, "right", "the first sector is the one pointing right")
near(sectorE.x, 0.2, "every sector sits on the area centre")
near(sectorE.y, 0.7, "on both axes")
near(sectorE.rangeX, 0.15, "and covers the whole area, not a ninth of it")
near(sectorE.reachLeft, 1.5, "the desc reach_x rides onto the sectors as it is")
near(sectorE.reachRight, 1.5, "on both sides")
eq(sectorE.sector, 1, "the sector index is kept for the hit test")
eq(ap.controls[3].spec, "right|down", "the next sector is the lower-right corner")
eq(ap.controls[4].spec, "down", "then straight down, y growing downwards")
eq(ap.controls[8].spec, "up", "and straight up seven sectors along")
check(ap.controls[1].movable, "_movable is parsed")
local abxy = ap.controls[10]
eq(abxy.spec, "a", "abxy right is RetroPad a, which is GB A")
eq(abxy.buttons[1], "a", "and reaches that GB button")
eq(ap.controls[16].spec, "start", "abxy_area honours an _up override")
eq(ap.controls[15].spec, "y|start", "the up-left sector combines both sides")
check(ap.controls[15].exclusive == nil, "and inherits nothing the desc did not set")
check(ap.controls[14].decorative, "RetroPad Y has no GB button, so that sector is inert")
eq(abxy.shape, "radial", "a radial area keeps its ellipse")
eq(ap.controls[18].spec, "right", "analog_left degrades to a directional pad")
check(ap.controls[18].exclusive, "_exclusive rides onto the expanded sectors")
eq(ap.controls[23].spec, "left|up", "with all eight sectors")
near(ap.controls[18].rangeX, 0.1, "analog sectors share the whole stick area")
local sq = { x = 0.5, y = 0.5, rangeX = 0.25, rangeY = 0.25, shape = "rect",
rangeMod = 1, alphaMod = 1,
reachUp = 1, reachDown = 1, reachLeft = 1, reachRight = 1 }
local sectors = TouchSkin.expandSectors(sq, TouchSkin.AREA_DEFAULTS.dpad_area)
eq(#sectors, 8, "a dpad area expands into eight sector hitboxes")
local page = { rect = { x = 0, y = 0, w = 1, h = 1 }, fullScreen = true,
aspect = 1, controls = sectors }
local function hitSpecs(px, py)
local out = {}
for _, ctl in ipairs(sectors) do
if TouchSkin.hits(page, ctl, 100, 100, px, py, 0, 0) then out[#out + 1] = ctl.spec end
end
return table.concat(out, "+")
end
eq(hitSpecs(50, 50), "right", "the exact centre still fires a direction: no dead zone")
eq(hitSpecs(60, 50), "right", "a touch to the right of centre is right")
eq(hitSpecs(50, 60), "down", "a touch below centre is down, y growing downwards")
eq(hitSpecs(50, 40), "up", "a touch above centre is up")
eq(hitSpecs(40, 40), "left|up", "a diagonal touch fires both directions")
eq(hitSpecs(58, 52), "right", "17 degrees off the axis is still a pure direction")
eq(hitSpecs(55, 53), "right|down", "and 31 degrees is the diagonal, not a grid corner")
eq(hitSpecs(50, 80), "", "outside the area nothing fires")
local PIXEL_NO_IMAGE = [[
overlays = 1
overlay0_name = "pixels"
overlay0_descs = 1
overlay0_desc0 = "a,120,80,rect,20,10"
]]
local noImage = assert(TouchSkin.parse(PIXEL_NO_IMAGE))
check(hasWarning(noImage, "no base image"),
"pixel coords without a base image are called out")
check(noImage.pages[1].pixelCoords == false,
"and read as normalized rather than dividing by nothing")
love.filesystem.write("skins/px/overlay.cfg", [[
overlays = 1
overlay0_name = "px"
overlay0_overlay = img/base.png
overlay0_full_screen = true
overlay0_descs = 2
overlay0_desc0 = "a,4,4,rect,2,1"
overlay0_desc1 = "b,6,2,rect,1,1"
overlay0_desc1_normalized = true
]])
local px = assert(TouchSkin.load("skins/px", "px"))
local pxPage = px.pages[1]
check(pxPage.image ~= nil, "the base overlay image loads")
local iw, ih = pxPage.image:getDimensions()
near(pxPage.controls[1].x, 4 / iw, "pixel x is divided by the base image width")
near(pxPage.controls[1].y, 4 / ih, "pixel y is divided by the base image height")
near(pxPage.controls[1].rangeX, 2 / iw, "and so are the half extents")
near(pxPage.controls[2].x, 6, "a per-desc normalized flag opts that desc out")
check(pxPage.pixelCoords == false, "the page is normalized once converted")
love.filesystem.write("skins/pxbad/overlay.cfg", [[
overlays = 1
overlay0_name = "pxbad"
overlay0_overlay = img/broken.png
overlay0_descs = 1
overlay0_desc0 = "a,4,4,rect,2,1"
]])
local savedNewImage = love.graphics.newImage
love.graphics.newImage = function() error("unreadable image") end
local badPx, badPxErr = TouchSkin.load("skins/pxbad", "pxbad")
love.graphics.newImage = savedNewImage
eq(badPx, nil, "a skin whose pixel coordinates have no base image fails to load")
check(tostring(badPxErr):find("img/broken.png", 1, true) ~= nil,
"and the error names the image it could not read")
local DELTA_JSON = [[
{
"name": "Test GBC",
"identifier": "com.example.gbc.test",
"gameTypeIdentifier": "com.rileytestut.delta.game.gbc",
"debug": false,
"representations": {
"iphone": {
"edgeToEdge": {
"portrait": {
"assets": { "small": "p_small.png", "medium": "p_medium.png",
"large": "p_large.png" },
"items": [
{ "inputs": ["a"], "frame": {"x":240,"y":320,"width":64,"height":64},
"mask": "circle" },
{ "inputs": ["b"], "frame": {"x":160,"y":360,"width":64,"height":64},
"extendedEdges": {"right":16} },
{ "inputs": {"up":"up","down":"down","left":"left","right":"right"},
"frame": {"x":16,"y":320,"width":96,"height":96} },
{ "inputs": ["start","select"],
"frame": {"x":128,"y":448,"width":64,"height":32} },
{ "inputs": ["menu"], "frame": {"x":0,"y":0,"width":32,"height":32} },
{ "inputs": ["quickSave"],
"frame": {"x":288,"y":0,"width":32,"height":32} }
],
"mappingSize": {"width":320,"height":480},
"extendedEdges": {"top":8,"bottom":8,"left":8,"right":8},
"translucent": false,
"screens": [{ "inputFrame": {"x":0,"y":0,"width":160,"height":144},
"outputFrame": {"x":0,"y":32,"width":320,"height":288} }]
}
},
"standard": {
"portrait": { "items": [], "mappingSize": {"width":320,"height":480} }
}
}
}
}
]]
local delta = assert(TouchSkin ~= nil and DeltaSkin.parse(DELTA_JSON))
eq(delta.format, "delta", "a .deltaskin parses into the native model")
eq(delta.name, "Test GBC", "info.json name")
eq(delta.system, "gbc", "gbc covers both GB and GBC")
eq(#delta.pages, 1, "only the orientations present become pages")
local dp = delta.pages[1]
eq(dp.name, "portrait", "the page is named for its orientation")
eq(dp.orient, "portrait", "and locked to it")
eq(dp.imagePath, "p_large.png", "the PNG ladder picks the largest for a phone")
check(dp.fullScreen, "Delta stretches its skin over the whole surface")
check(not dp.aspectFromCfg, "so nothing letterboxes it")
near(dp.aspect, 320 / 480, "the page aspect is the mappingSize aspect")
eq(#dp.controls, 13, "edgeToEdge wins over standard, so all six items parsed")
local dA = dp.controls[1]
eq(dA.buttons[1], "a", "an inputs array binds its button")
eq(dA.shape, "radial", 'mask "circle" becomes a radial hitbox')
near(dA.x, 0.85, "frame top-left plus half width is the native centre")
near(dA.y, 352 / 480, "and the same for y")
near(dA.rangeX, 0.1, "frame width halves into the native half extent")
near(dA.reachLeft, 1.25, "orientation extendedEdges become reach")
local dB = dp.controls[2]
near(dB.reachRight, 1.5, "a per-item extendedEdges key overrides that side")
near(dB.reachLeft, 1.25, "and leaves the others inherited")
eq(dp.controls[3].spec, "left|up", "a dpad input object expands to a 3x3 grid")
near(dp.controls[3].x, 0.1, "dpad top-left cell x")
near(dp.controls[3].rangeX, 0.05, "dpad cells are a third of the frame")
near(dp.controls[3].reachLeft, 1.5, "with the extended edge re-scaled onto them")
eq(dp.controls[4].spec, "up", "dpad top-centre cell")
eq(dp.controls[10].spec, "right|down", "dpad bottom-right cell")
eq(dp.controls[11].spec, "start|select", "a multi-input item fires both")
eq(dp.controls[12].hotkeys[1], "menu", "the Delta menu button becomes a hotkey")
check(dp.controls[13].decorative,
"quickSave has no engine hotkey, so it is inert rather than a game button")
check(dp.viewport ~= nil, "screens[] places the emulator picture")
near(dp.viewport.y, 32 / 480, "outputFrame y normalizes by mappingSize")
near(dp.viewport.h, 288 / 480, "outputFrame height normalizes by mappingSize")
local bx, by, bw, bh = TouchSkin.pageBox(dp, 1000, 500)
eq(bx, 0, "delta page box x") eq(by, 0, "delta page box y")
eq(bw, 1000, "delta page box fills the width")
eq(bh, 500, "delta page box fills the height")
local LEGACY_SCREEN = [[
{ "gameTypeIdentifier": "public.aoshuang.game.gbc",
"representations": { "iphone": { "standard": { "landscape": {
"mappingSize": {"width":640,"height":320},
"gameScreenFrame": {"x":160,"y":0,"width":320,"height":288},
"translucent": true,
"items": [ { "inputs": {"up":"analogStickUp","down":"analogStickDown",
"left":"analogStickLeft","right":"analogStickRight"},
"frame": {"x":0,"y":0,"width":120,"height":120} } ] } } } } }
]]
local legacy = assert(DeltaSkin.parse(LEGACY_SCREEN))
eq(legacy.system, "gbc", "the Manic public.aoshuang prefix is accepted")
eq(#legacy.pages, 1, "landscape only")
eq(legacy.pages[1].orient, "landscape", "orientation key drives the lock")
near(legacy.pages[1].viewport.x, 0.25, "gameScreenFrame is the legacy screen rect")
near(legacy.pages[1].alphaMod, 0.7, "translucent dims the controls")
eq(#legacy.pages[1].controls, 8, "a thumbstick degrades to a directional pad")
eq(legacy.pages[1].controls[1].spec, "left|up", "with the analog names mapped")
local snes = assert(DeltaSkin.parse([[
{ "gameTypeIdentifier": "com.rileytestut.delta.game.snes",
"representations": { "iphone": { "standard": { "portrait": {
"mappingSize": {"width":320,"height":480}, "items": [] } } } } }
]]))
check(hasWarning(snes, "not Game Boy"), "a non Game Boy skin warns")
eq(#snes.pages, 1, "but still imports")
eq(select(1, DeltaSkin.parse([[
{ "name": "old", "gameTypeIdentifier": "com.rileytestut.GBA4iOS.gba",
"representations": { "iphone": { "portrait": { "assets": {} } } } }
]])), nil, "a GBA4iOS skin is refused")
local _, gbaErr = DeltaSkin.parse([[
{ "gameTypeIdentifier": "com.rileytestut.GBA4iOS.gbc", "representations": {} }
]])
check(tostring(gbaErr):find("GBA4iOS", 1, true) ~= nil,
"and the message names the old format")
local _, noTypeErr = DeltaSkin.parse('{ "representations": {} }')
check(tostring(noTypeErr):find("gameTypeIdentifier", 1, true) ~= nil,
"info.json without a gameTypeIdentifier is refused by name")
eq(select(1, DeltaSkin.parse("not json at all")), nil, "garbage is refused")
eq(select(1, DeltaSkin.parse([[
{ "gameTypeIdentifier": "com.rileytestut.delta.game.gbc", "representations": {} }
]])), nil, "an empty representations tree is refused")
local PDF_JSON = [[
{ "name": "Vector", "gameTypeIdentifier": "com.rileytestut.delta.game.gbc",
"representations": { "iphone": { "standard": { "portrait": {
"assets": { "resizable": "iphone_portrait.pdf" },
"mappingSize": {"width":320,"height":480},
"items": [ { "inputs": ["a"], "frame": {"x":0,"y":0,"width":32,"height":32} } ]
} } } } }
]]
local pdf = assert(DeltaSkin.parse(PDF_JSON))
eq(pdf.pages[1].imagePath, nil, "a PDF asset is not pretended to be art")
local convert = DeltaSkin.needsConversion(pdf)
check(convert ~= nil, "PDF-only skins report that they need conversion")
if convert then
check(convert.pdfOnly, "the report is flagged pdfOnly")
eq(convert.files[1], "iphone_portrait.pdf", "and names the file to convert")
end
eq(DeltaSkin.needsConversion(delta), nil, "a PNG skin needs no conversion")
local mixed = assert(DeltaSkin.parse([[
{ "gameTypeIdentifier": "com.rileytestut.delta.game.gb",
"representations": { "iphone": { "standard": { "portrait": {
"assets": { "resizable": "art.pdf", "medium": "art.png" },
"mappingSize": {"width":320,"height":480}, "items": [] } } } } }
]]))
eq(mixed.pages[1].imagePath, "art.png", "a raster asset beats the PDF")
eq(DeltaSkin.needsConversion(mixed), nil, "so no conversion is needed")
eq(DeltaSkin.pickAsset({ small = "s.png" }, { targetWidth = 1080 }, {}), "s.png",
"the ladder falls back to the largest shipped asset")
eq(DeltaSkin.pickAsset({ small = "s.png", medium = "m.png", large = "l.png" },
{ targetWidth = 640 }, {}), "s.png",
"a small target takes the small asset")
eq(DeltaSkin.pickAsset({ normal = "n.png" }, { targetWidth = 640 }, {}), "n.png",
'the Manic "normal" alias is accepted')
love.filesystem.write("skins/wrapped.deltaskin/MySkin/info.json", [[
{ "name": "Wrapped", "gameTypeIdentifier": "com.rileytestut.delta.game.gbc",
"representations": { "iphone": { "standard": { "portrait": {
"assets": { "large": "Portrait.PNG" },
"mappingSize": {"width":320,"height":480},
"items": [ { "inputs": ["a"], "frame": {"x":0,"y":0,"width":64,"height":64} } ]
} } } } }
]])
love.filesystem.write("skins/wrapped.deltaskin/MySkin/portrait.png", "\137PNG\r\n\26\n")
local wrappedId, wrappedErr = TouchSkin.installArchive("wrapped.deltaskin", "PK\3\4stub")
eq(wrappedId, "wrapped", "a .deltaskin installs under its bare name: " .. tostring(wrappedErr))
local wrapped = assert(TouchSkin.load("skins/_mounted/wrapped", "wrapped"))
eq(wrapped.format, "delta", "the mounted archive is recognised as a Delta skin")
eq(wrapped.name, "Wrapped", "and its name comes from info.json")
eq(wrapped.pages[1].imagePath, "MySkin/portrait.png",
"the wrapping folder is prefixed onto assets and the real file name wins")
eq(#wrapped.pages[1].controls, 1, "the wrapped items parsed")
love.filesystem.write("skins/vector.deltaskin/info.json", PDF_JSON)
local vectorId, vectorErr = TouchSkin.installArchive("vector.deltaskin", "PK\3\4stub")
eq(vectorId, nil, "a PDF-only skin is refused instead of installing invisible")
check(tostring(vectorErr):find("PDF artwork", 1, true) ~= nil,
"with the message that asks for a PNG version")
eq(love.filesystem.read("skins/vector.deltaskin"), nil,
"and the refused archive is not left behind")
eq(select(1, TouchSkin.installArchive("skin.gbcskin", "PK\3\4stub")), nil,
"a GBA4iOS .gbcskin is refused at the door")
local _, legacyErr = TouchSkin.installArchive("skin.gbaskin", "PK\3\4stub")
check(tostring(legacyErr):find("GBA4iOS", 1, true) ~= nil,
"with a message that names the format")
eq(select(1, TouchSkin.installArchive("skin.rar", "PK\3\4stub")), nil,
"an unknown archive extension is refused")
eq(TouchSkin.archiveId("pad.deltaskin"), "pad", "archiveId strips .deltaskin")
eq(TouchSkin.archiveId("pad.zip"), "pad", "archiveId strips .zip")
eq(TouchSkin.archiveId("pad"), nil, "a bare name is not an archive")
local AUTHORED = [[
return { name = "Authored", pages = {
{ name = "portrait", orient = "portrait", fullScreen = true,
viewport = { x = 0, y = 0, w = 1, h = 0.5 },
controls = {
{ bind = "a", x = 0.8, y = 0.75, w = 0.2, h = 0.1, shape = "radial" },
{ bind = "b", x = 0.6, y = 0.8, w = 0.2, h = 0.1, shape = "radial",
reachRight = 1.5 },
{ bind = "start", x = 0.5, y = 0.95, w = 0.1, h = 0.04 },
{ bind = "menu_toggle", x = 0.05, y = 0.05, w = 0.08, h = 0.04 },
{ bind = "nul", x = 0.2, y = 0.7, w = 0.3, h = 0.2, image = "img/dpad.png" },
} },
{ name = "landscape", orient = "landscape", fullScreen = true,
controls = {
{ bind = "a", x = 0.9, y = 0.8, w = 0.1, h = 0.15, shape = "radial" },
} },
} }
]]
local authored = assert(TouchSkin.parseNative(AUTHORED))
authored.id = "authored"
authored.root = "skins/authored"
local cfgText = TouchSkin.toRetroArchConfig(authored)
check(cfgText:find("overlays = 2", 1, true) ~= nil, "the cfg declares its overlays")
local reparsed = assert(TouchSkin.parse(cfgText))
eq(#reparsed.pages, 2, "the generated cfg round-trips both pages")
eq(reparsed.pages[1].name, "portrait", "and their names")
eq(#reparsed.pages[1].controls, 5, "and every desc")
eq(reparsed.pages[1].controls[1].spec, "a", "binds survive the round trip")
near(reparsed.pages[1].controls[1].x, 0.8, "centres survive the round trip")
near(reparsed.pages[1].controls[1].rangeX, 0.1, "half extents survive")
eq(reparsed.pages[1].controls[1].shape, "radial", "hitbox shape survives")
near(reparsed.pages[1].controls[2].reachRight, 1.5, "per-side reach survives")
eq(reparsed.pages[1].controls[4].hotkeys[1], "menu", "hotkeys survive")
check(reparsed.pages[1].controls[5].decorative, "decoration stays decoration")
eq(reparsed.pages[1].controls[5].imagePath, "img/dpad.png", "and keeps its art")
near(reparsed.pages[1].viewport.h, 0.5, "the screen cutout survives")
eq(reparsed.pages[1].orient, "portrait", "the orientation lock survives by name")
local KEY_SKIN = [[
return { name = "Keys", pages = {
{ name = "portrait", fullScreen = true, controls = {
{ bind = "key:escape", x = 0.5, y = 0.5, w = 0.1, h = 0.1 },
} },
} }
]]
local keySkin = assert(TouchSkin.parseNative(KEY_SKIN))
local keyCfg = TouchSkin.toRetroArchConfig(keySkin)
check(keyCfg:find("retrok_escape", 1, true) ~= nil,
"a key bind exports in the grammar RetroArch understands")
check(keyCfg:find("key:escape", 1, true) == nil, "and not in the native spelling")
eq(assert(TouchSkin.parse(keyCfg)).pages[1].controls[1].keys[1], "escape",
"which this importer still reads back as the same key")
local areaCfg = TouchSkin.toRetroArchConfig({ pages = area.pages })
local areaBack = assert(TouchSkin.parse(areaCfg))
eq(#areaBack.pages[1].controls, #ap.controls,
"an area desc exports as one desc, not eight overlapping ones")
check(areaCfg:find("dpad_area", 1, true) ~= nil, "the area kind is written back")
check(areaCfg:find('_up = "start"', 1, true) ~= nil, "with its output override")
eq(areaBack.pages[1].controls[16].spec, "start", "which survives the round trip")
local areaInfo = assert(DeltaSkin.build({ id = "area", pages = area.pages }))
local areaRep = areaInfo.representations.iphone.edgeToEdge.portrait
local dpadItem
for _, item in ipairs(areaRep.items) do
if not dpadItem and type(item.inputs) == "table" and item.inputs.up then
dpadItem = item
end
end
check(dpadItem ~= nil, "the same area exports to Delta as one d-pad item")
eq(dpadItem.inputs.left, "left", "carrying each direction")
eq(#areaRep.items, 3, "one per area desc, not eight stacked on one another")
local raPath = os.tmpname() .. "-ra.zip"
local raWritten, raMissing = TouchSkin.exportRetroArch(authored, raPath)
eq(raWritten, raPath, "exportRetroArch writes where it was told")
eq(raMissing[1], "img/dpad.png", "and reports art it could not find")
local raZip = unzip(readBytes(raPath))
eq(raZip[1], "overlay.cfg", "the RetroArch zip leads with overlay.cfg")
check(raZip["overlay.cfg"] ~= nil, "and the entry has bytes")
check(TouchSkin.parse(raZip["overlay.cfg"]) ~= nil, "which RetroArch grammar accepts")
os.remove(raPath)
local dsPath = os.tmpname() .. ".deltaskin"
local dsWritten, _, dsWarnings = TouchSkin.exportDelta(authored, { path = dsPath })
eq(dsWritten, dsPath, "exportDelta writes where it was told")
check(#dsWarnings > 0, "and warns that per-button art has nowhere to go")
local dsZip = unzip(readBytes(dsPath))
eq(dsZip[1], "info.json", "the .deltaskin leads with info.json")
local info = assert(Json.decode(dsZip["info.json"]))
eq(info.gameTypeIdentifier, "com.rileytestut.delta.game.gbc",
"the export claims the GBC game type")
eq(info.name, "Authored", "and carries the skin name")
check(info.identifier:find("authored", 1, true) ~= nil, "identifier names the skin")
local rep = info.representations.iphone.edgeToEdge.portrait
check(rep ~= nil, "an iPhone edgeToEdge portrait representation is emitted")
eq(info.representations.iphone.standard.portrait.mappingSize.width, 1080,
"standard portrait maps 1080 wide")
eq(rep.mappingSize.height, 1920, "portrait maps 1920 tall")
eq(#rep.items, 4, "only bound controls become Delta items")
eq(rep.items[1].inputs[1], "a", "the first item is A")
eq(rep.items[1].mask, "circle", "a radial hitbox exports as a circle mask")
eq(rep.items[1].frame.x, 756, "frame x is top-left, not centre")
eq(rep.items[1].frame.width, 216, "frame width is the full extent")
eq(rep.items[2].extendedEdges.right, 54, "reach exports as extendedEdges")
eq(rep.items[4].inputs[1], "menu", "the menu hotkey exports as a Delta host input")
eq(rep.screens[1].inputFrame.width, 160, "the screen crop is a full GB frame")
eq(rep.screens[1].outputFrame.height, 960, "and the output frame follows the viewport")
eq(info.representations.iphone.edgeToEdge.landscape.mappingSize.width, 1920,
"the landscape page maps 1920 wide")
local back = assert(DeltaSkin.parse(dsZip["info.json"]))
eq(#back.pages, 2, "the exported skin re-imports both orientations")
local bp = back.pages[1]
eq(#bp.controls, 4, "with every bound control")
near(bp.controls[1].x, 0.8, "and the same centres it started with")
near(bp.controls[1].rangeX, 0.1, "and the same half extents")
eq(bp.controls[1].shape, "radial", "and the same hitbox shape")
near(bp.controls[2].reachRight, 1.5, "and the same reach")
near(bp.viewport.h, 0.5, "and the same screen cutout")
os.remove(dsPath)
love.filesystem.write("skins/collide/overlay.cfg", [[
overlays = 1
overlay0_name = "collide"
overlay0_descs = 1
overlay0_desc0 = "a,0.5,0.5,rect,0.05,0.05"
]])
local collide = assert(TouchSkin.load("skins/collide", "collide"))
local defaultDelta = assert(TouchSkin.exportDelta(collide))
eq(defaultDelta, "skins/_export/collide.deltaskin",
"a default export lands outside the folder the skin list scans")
local listedRoot, listedExport
for _, entry in ipairs(TouchSkin.list()) do
if entry.id == "collide" then listedRoot = entry.root end
if entry.id == "_export" then listedExport = true end
end
eq(listedRoot, "skins/collide", "so the export cannot shadow the skin it came from")
check(not listedExport, "and the export folder is not a skin of its own")
T.finish("skin_format_import")
+339
View File
@@ -0,0 +1,339 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local T = require("tests.harness")
local check, eq = T.check, T.eq
local TouchSkin = require("src.core.TouchSkin")
local Studio = require("src.ui.SkinStudio")
local function near(a, b, tol, msg)
check(math.abs(a - b) <= (tol or 1e-6), msg .. " (got " .. tostring(a) ..
", want " .. tostring(b) .. ")")
end
local function session()
Studio.skin = TouchSkin.newSkin("t")
Studio.skinIdField = "t"
Studio.pageIndex = 1
Studio.selected = nil
Studio.canvasIndex = 1
Studio.aspectLock = true
Studio.drag = nil
Studio.dirty = false
Studio.images = {}
Studio.thumbs = {}
Studio.available = {}
Studio.availableMeta = {}
Studio.undoStack, Studio.redoStack = {}, {}
Studio.undoTag, Studio.undoAt = nil, nil
Studio.modal, Studio.confirm = nil, nil
Studio.status, Studio.statusErr = nil, false
Studio.imageTarget = "idle"
return Studio.skin
end
session()
check(not Studio.canUndo(), "a fresh session has nothing to undo")
Studio.addControl()
eq(#Studio.page().controls, 1, "a control was added")
check(Studio.canUndo(), "adding a control is undoable")
Studio.undo()
eq(#Studio.page().controls, 0, "undo takes the control back off")
check(Studio.canRedo(), "and offers a redo")
Studio.redo()
eq(#Studio.page().controls, 1, "redo puts it back")
check(not Studio.canRedo(), "the redo stack is spent")
Studio.addControl()
check(not Studio.canRedo(), "a fresh edit clears the redo stack")
session()
Studio.addControl()
local before = Studio.page().controls[1]
Studio.pushUndo()
before.x = 0.9
Studio.undo()
check(Studio.page().controls[1] ~= before,
"undo restores a copy, not the edited table")
near(Studio.page().controls[1].x, 0.5, 1e-6, "with the pre-edit position")
session()
for _ = 1, Studio.UNDO_CAP + 10 do Studio.pushUndo() end
eq(#Studio.undoStack, Studio.UNDO_CAP, "the undo stack is capped")
session()
check(not Studio.undo(), "undo on an empty stack reports nothing to do")
check(not Studio.redo(), "and so does redo")
local realIsDown = love.keyboard and love.keyboard.isDown
love.keyboard = love.keyboard or {}
local held = {}
love.keyboard.isDown = function(...)
for _, k in ipairs({ ... }) do if held[k] then return true end end
return false
end
session()
Studio.addControl()
Studio.addControl()
held.lctrl = true
Studio.keypressed("z")
eq(#Studio.page().controls, 1, "ctrl+Z undoes one step")
Studio.keypressed("z")
eq(#Studio.page().controls, 0, "and again")
held.lshift = true
Studio.keypressed("z")
eq(#Studio.page().controls, 1, "ctrl+shift+Z redoes instead of undoing further")
held.lshift = nil
Studio.keypressed("y")
eq(#Studio.page().controls, 2, "and ctrl+Y redoes as well")
held.lctrl = nil
if realIsDown then love.keyboard.isDown = realIsDown end
session()
local ran = 0
check(Studio.guard("lose it?", function() ran = ran + 1 end),
"a clean skin runs the action straight away")
eq(ran, 1, "and does not prompt")
check(Studio.confirm == nil, "no prompt is left up")
Studio.dirty = true
check(not Studio.guard("lose it?", function() ran = ran + 1 end),
"a dirty skin defers the action")
eq(ran, 1, "the action has not run yet")
check(Studio.confirm ~= nil, "and a prompt is up")
Studio.confirmNo()
eq(ran, 1, "cancelling drops the action")
check(Studio.confirm == nil, "and closes the prompt")
Studio.guard("lose it?", function() ran = ran + 1 end)
Studio.confirmYes()
eq(ran, 2, "confirming runs it")
check(Studio.confirm == nil, "and closes the prompt")
session()
Studio.dirty = true
Studio.openLoadPicker()
check(Studio.confirm ~= nil, "Load prompts over unsaved work")
check(Studio.modal == nil, "and does not open the picker yet")
Studio.confirmYes()
check(Studio.modal ~= nil and Studio.modal.kind == "open",
"confirming opens the picker")
Studio.closeModal()
eq(Studio.toggleBindPart("nul", "left"), "left",
"a bind starts from decoration")
eq(Studio.toggleBindPart("left", "up"), "left|up",
"directions combine in the canonical order")
eq(Studio.toggleBindPart("up", "left"), "left|up",
"and the order does not depend on which was added first")
eq(Studio.toggleBindPart("left|up", "up"), "left",
"toggling a part off removes it")
eq(Studio.toggleBindPart("left", "left"), "nul",
"removing the last part leaves decoration")
eq(Studio.toggleBindPart("a", "b"), "a|b", "buttons combine as well")
check(Studio.hasBindPart("left|down", "down"), "hasBindPart finds a part")
check(not Studio.hasBindPart("left|down", "up"), "and misses one that is absent")
session()
check(not Studio.openBindPicker(), "the bind picker needs a selected control")
check(Studio.statusErr, "and says so as an error")
Studio.addControl()
check(Studio.openBindPicker(), "with a control selected it opens")
eq(Studio.modal.kind, "bind", "as the bind modal")
Studio.setBindSpec("start")
eq(Studio.selectedControl().spec, "start", "picking a bind writes the spec")
eq(Studio.selectedControl().buttons[1], "start", "and reparses it")
Studio.undo()
eq(Studio.selectedControl().spec, "a", "the bind change is undoable")
Studio.closeModal()
Studio.toggleSelectedBindPart("b")
eq(Studio.selectedControl().spec, "a|b", "the combine chips build a pipe bind")
eq(#Studio.selectedControl().buttons, 2, "which fires both buttons")
local specs = {}
for _, group in ipairs(Studio.BIND_GROUPS) do
check(#group.specs > 0, group.title .. " lists at least one bind")
for _, spec in ipairs(group.specs) do specs[spec] = true end
end
check(specs["a"] and specs["start"], "the GB buttons are reachable")
check(specs["overlay_previous"], "overlay_previous is reachable at last")
check(specs["pause_toggle"] and specs["exit_emulator"],
"so are the hotkeys the old cycle could not reach")
check(specs["key:escape"], "and a keyboard bind can be picked")
check(specs["nul"], "decoration is still an option")
session()
Studio.addControl()
Studio.addControl()
Studio.selected = 1
local first = Studio.page().controls[1]
check(Studio.moveControlOrder(1), "bring forward moves the control up")
eq(Studio.selected, 2, "and follows it with the selection")
check(Studio.page().controls[2] == first, "the control really moved")
check(not Studio.moveControlOrder(1), "the front control cannot go further")
check(Studio.moveControlOrder(-1), "send back moves it down again")
eq(Studio.selected, 1, "selection follows back")
check(not Studio.moveControlOrder(-1), "and the back control stays put")
session()
Studio.addControl()
local ctl = Studio.selectedControl()
local canvas = Studio.canvas()
local startX, startY = ctl.x, ctl.y
Studio.nudge(1, 0)
near(ctl.x, startX + 1 / canvas.w, 1e-9, "an arrow moves one canvas pixel")
Studio.nudge(0, 1, true)
near(ctl.y, startY + 10 / canvas.h, 1e-9, "shift moves ten")
Studio.undo()
near(Studio.selectedControl().x, startX, 1e-9, "nudging is undoable")
Studio.selected = nil
check(not Studio.nudge(1, 0), "nothing selected, nothing nudged")
check(Studio.NUDGES.up[2] == -1 and Studio.NUDGES.down[2] == 1,
"up is negative y on the canvas")
check(Studio.NUDGES.left[1] == -1 and Studio.NUDGES.right[1] == 1,
"and left is negative x")
local off, line = Studio.snapOffset({ 100, 150, 200 }, { 152, 400 }, 4)
near(off, 2, 1e-9, "an edge within tolerance snaps to the guide")
near(line, 152, 1e-9, "and reports the line it snapped to")
off, line = Studio.snapOffset({ 100 }, { 400 }, 4)
eq(off, 0, "a line out of range does not move anything")
eq(line, nil, "and reports no guide")
off = Studio.snapOffset({ 100, 200 }, { 203, 101 }, 4)
near(off, 1, 1e-9, "the nearest candidate wins")
session()
Studio.addControl()
local r = { x = 0, y = 0, w = 1000, h = 1000 }
local xs, ys = Studio.snapLines(Studio.page(), r, nil)
check(#xs >= 6 and #ys >= 6,
"snap lines cover the page box and every other control")
local skipped = select(1, Studio.snapLines(Studio.page(), r, 1))
eq(#skipped, 3, "the dragged control is not a guide for itself")
session()
Studio.addControl()
Studio.page().controls[1].x = 0.25
Studio.addControl()
Studio.selected = 2
local moving = Studio.selectedControl()
moving.x = 0.6
local target = Studio.page().controls[1]
local bx, by, bw, bh = 0, 0, 0, 0
local cx, cy, hw, hh = TouchSkin.controlGeometry(Studio.page(), moving,
r.w, r.h, r.x, r.y)
bx, by, bw, bh = cx - hw, cy - hh, hw * 2, hh * 2
local tcx = select(1, TouchSkin.controlGeometry(Studio.page(), target,
r.w, r.h, r.x, r.y))
Studio.drag = { kind = "control-move", mx = 0, my = 0,
bx = bx, by = by, bw = bw, bh = bh }
Studio.updateDrag((tcx - cx) + 3, 0, r)
local cx2 = select(1, TouchSkin.controlGeometry(Studio.page(), moving,
r.w, r.h, r.x, r.y))
near(cx2, tcx, 1e-6, "a near miss snaps onto the other control's centre")
check(Studio.guides ~= nil and Studio.guides.x ~= nil,
"and a guide line is recorded for the canvas to draw")
Studio.drag = nil
session()
Studio.addPage()
Studio.addPage()
eq(#Studio.skin.pages, 3, "three pages")
check(Studio.setPage(1), "setPage jumps to a page by index")
eq(Studio.pageIndex, 1, "and lands there")
check(not Studio.setPage(9), "an index past the end is refused")
Studio.nextPage()
eq(Studio.pageIndex, 2, "next page still cycles")
local name, detail = Studio.pageLabel(2)
eq(name, "page2", "the page list shows the page name")
check(detail:find("controls", 1, true) ~= nil, "and what is on it")
check(Studio.renamePage("landscape"), "a page can be renamed")
eq(Studio.page().name, "landscape", "and keeps the new name")
check(not Studio.renamePage(" "), "an empty name is refused")
Studio.undo()
eq(Studio.page().name, "page2", "renaming is undoable")
Studio.pageIndex = 2
check(Studio.deletePage(2), "a page can be deleted")
eq(#Studio.skin.pages, 2, "and the skin loses it")
Studio.deletePage(1)
check(not Studio.deletePage(1), "the last page cannot be deleted")
check(Studio.statusErr, "and the studio says why")
session()
check(not Studio.openImagePicker("idle"), "art needs a selected control")
Studio.addControl()
check(Studio.openImagePicker("idle"), "with one selected the grid opens")
eq(Studio.modal.kind, "image", "as the image modal")
eq(Studio.imageTarget, "idle", "aimed at the idle art")
check(Studio.openImagePicker("bezel"), "the bezel needs no selection")
eq(Studio.currentImagePath(), nil, "a new page has no bezel yet")
Studio.imageTarget = "idle"
Studio.selectedControl().imagePath = "img/a.png"
eq(Studio.currentImagePath(), "img/a.png", "the picker marks the current art")
Studio.chooseImage(nil)
eq(Studio.selectedControl().imagePath, nil, "picking (none) clears the art")
eq(Studio.modal, nil, "and closes the picker")
Studio.undo()
eq(Studio.selectedControl().imagePath, "img/a.png", "clearing art is undoable")
session()
Studio.setStatus("boom", true)
check(Studio.statusErr, "an error status is flagged")
Studio.addControl()
eq(Studio.status, "boom", "a later edit does not wipe the error off the footer")
Studio.setStatus("fine")
Studio.addControl()
eq(Studio.status, nil, "an ordinary status still clears on the next edit")
Studio.setStatus("boom", true)
Studio.statusAt = -1000
Studio.expireStatus()
eq(Studio.status, nil, "and an error clears itself after a few seconds")
local ids = {}
for _, spec in ipairs(Studio.EXPORTS) do ids[spec.id] = spec.label end
check(ids.native and ids.retroarch and ids.delta,
"the export menu offers all three formats")
session()
Studio.skinIdField = "uxtest"
local nativePath = Studio.exportAs("native")
check(nativePath ~= nil and nativePath:match("%.zip$") ~= nil,
"the native export writes a .zip")
local raPath = Studio.exportAs("retroarch")
check(raPath ~= nil and raPath:match("%.zip$") ~= nil,
"the RetroArch export writes a .zip")
local deltaPath = Studio.exportAs("delta")
check(deltaPath ~= nil and deltaPath:match("%.deltaskin$") ~= nil,
"the Delta export writes a .deltaskin")
check(love.filesystem.read(deltaPath) ~= nil, "and the archive is on disk")
eq(Studio.lastExport, deltaPath, "the last export is remembered for Show file")
eq(Studio.skinFormat({ format = "retroarch" }), "RetroArch",
"a format badge reads in words")
eq(Studio.skinFormat({ format = "delta" }), "Delta", "Delta included")
love.graphics.getDimensions = love.graphics.getDimensions
or function() return 1280, 720 end
session()
Studio.addControl()
for _, kind in ipairs({ "bind", "image", "open", "page", "export" }) do
Studio.openModal(kind)
check(pcall(Studio.draw), "the studio draws with the " .. kind .. " modal up")
end
Studio.closeModal()
Studio.ask("sure?", function() end)
check(pcall(Studio.draw), "and with the confirm prompt up")
Studio.confirmNo()
Studio.openModal("bind")
Studio.lastCanvas = { x = 0, y = 0, w = 100, h = 100 }
Studio.mousepressed(50, 50, 1)
eq(Studio.drag, nil, "a click under an open modal does not grab a control")
Studio.closeModal()
T.finish("skin_studio_ux")
+214
View File
@@ -0,0 +1,214 @@
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")
local TouchSkin = require("src.core.TouchSkin")
local Playfield = require("src.render.Playfield")
local Renderer = require("src.render.Renderer")
local Chrome = require("src.ui.gen2.Chrome")
local Zoom = require("src.render.Zoom")
local EPS = 1e-6
local function setWindow(w, h)
love.graphics.getDimensions = function() return w, h end
love.graphics.getPixelDimensions = function() return w, h end
end
local function cfg(viewport, extra)
return ([[
overlays = 1
overlay0_name = "bezel"
overlay0_full_screen = true
overlay0_normalized = true
overlay0_viewport = "%s"
%s
overlay0_descs = 1
overlay0_desc0 = "nul,0.5,0.5,rect,0.02,0.02"
]]):format(viewport, extra or "")
end
local function useSkin(viewport, extra)
local skin = assert(TouchSkin.parse(cfg(viewport, extra)))
TouchSkin.setActive(skin)
TouchSkin.setOverlayLive(false)
return skin
end
local function inside(x, y, w, h, bx, by, bw, bh)
return x >= bx - EPS and y >= by - EPS
and x + w <= bx + bw + EPS and y + h <= by + bh + EPS
end
setWindow(640, 576)
TouchSkin.setActive(nil)
Renderer:init()
local plain = Renderer:frameRects()
eq(plain.cut, false, "no skin: no cutout")
eq(plain.vux, 0, "no skin: the picture starts at the window origin")
eq(plain.vuw, 640, "no skin: the picture is the whole window")
eq(plain.Sp, 4, "no skin: 640x576 fits four whole GB pixels")
eq(plain.uox, 0, "no skin: the UI letterbox fills the window")
eq(select(3, Playfield.rect(640, 576)), 640, "no skin: the playfield is the window")
eq(Chrome.fitScale(640, 576), 4, "no skin: Gold fits the window the same way")
local WINDOWS = {
{ 640, 576 }, { 1280, 720 }, { 1920, 1080 },
{ 800, 480 }, { 480, 800 }, { 360, 640 },
}
local VIEWPORTS = {
"0.2335,0.0855,0.5335,0.830",
"0.2,0.15,0.6,0.5",
"0.05,0.05,0.9,0.35",
"0.3,0.1,0.4,0.8",
}
local UI_SIZES = { { 160, 144 }, { 304, 144 } }
local escapes, uncut, cases = 0, 0, 0
for _, win in ipairs(WINDOWS) do
setWindow(win[1], win[2])
for _, vp in ipairs(VIEWPORTS) do
useSkin(vp)
Renderer:init()
for _, size in ipairs(UI_SIZES) do
Renderer:setUISize(size[1], size[2])
for off = -8, 8 do
Zoom.offset = off
for _, fill in ipairs({ false, true }) do
for _, centered in ipairs({ true, false }) do
Renderer.uiFill = fill
Renderer.uiCentered = centered
Renderer.worldActive = true
cases = cases + 1
local r = Renderer:frameRects()
local ux, uy, uw, uh = Renderer.clipToView(r, r.uox, r.uoy,
r.uvpw, r.uvph)
if not inside(ux, uy, uw, uh, r.vux, r.vuy, r.vuw, r.vuh) then
escapes = escapes + 1
end
if uw < r.uvpw - EPS or uh < r.uvph - EPS then uncut = uncut + 1 end
local vw, vh = Renderer:worldViewSize()
local sp = Zoom.scale(r.Sp)
if vw * sp > r.vuw + 2 * sp + EPS
or vh * sp > r.vuh + 2 * sp + EPS then
escapes = escapes + 1
end
end
end
end
end
end
end
check(cases > 1000, "the sweep covers every window x cutout x zoom x layout")
eq(escapes, 0, "no zoom, battle surface or UI layout puts a rect past the cutout")
eq(uncut, 0, "and the UI was sized to fit, so the clip never has to cut it")
setWindow(1280, 720)
useSkin("0.25,0.1,0.5,0.6")
Renderer:init()
Renderer:setUISize(160, 144)
Renderer.uiFill, Renderer.uiCentered = false, true
Zoom.offset = 0
local r = Renderer:frameRects()
eq(r.cut, true, "the skin's cutout is folded into the frame")
eq(r.vux, 320, "cutout x")
eq(r.vuy, 72, "cutout y")
eq(r.vuw, 640, "cutout width")
eq(r.vuh, 432, "cutout height")
eq(r.Sp, 3, "the fit is measured against the cutout, not the window")
check(inside(r.uox, r.uoy, r.uvpw, r.uvph, r.vux, r.vuy, r.vuw, r.vuh),
"the UI letterbox sits inside the cutout")
check(inside(r.ox, r.oy, r.vpw, r.vph, r.vux, r.vuy, r.vuw, r.vuh),
"so does the world letterbox")
local lo, hi = Zoom.offsetRange(r.Sp)
for off = lo, hi do
Zoom.offset = off
local z = Renderer:frameRects()
check(inside(z.uox, z.uoy, z.uvpw, z.uvph, z.vux, z.vuy, z.vuw, z.vuh),
"zoom " .. Zoom.offsetLabel(off) .. " keeps the UI in the cutout")
local vw, vh = Renderer:worldViewSize()
local sp = Zoom.scale(z.Sp)
check(vw * sp <= z.vuw + 2 * sp and vh * sp <= z.vuh + 2 * sp,
"zoom " .. Zoom.offsetLabel(off) .. " keeps the world pass capped")
end
Zoom.offset = 0
local capped = select(1, Renderer:worldViewSize())
useSkin("0.25,0.1,0.5,0.6", "overlay0_viewport_expand = true")
local expanded = select(1, Renderer:worldViewSize())
check(expanded > capped,
"viewport_expand lets the survey world fill the cutout instead of the GB box")
useSkin("0.25,0.1,0.5,0.6")
setWindow(480, 800)
useSkin("0.3,0.1,0.4,0.3")
Renderer:init()
Renderer:setUISize(304, 144)
Renderer.uiFill, Renderer.uiCentered = false, true
local tight = Renderer:frameRects()
check(tight.vuw < 304, "the cutout cannot hold the WIDE battle at 1x")
check(inside(tight.uox, tight.uoy, tight.uvpw, tight.uvph,
tight.vux, tight.vuy, tight.vuw, tight.vuh),
"so the surface is scaled down to the cutout rather than over the bezel")
Renderer:setUISize(160, 144)
setWindow(1280, 720)
useSkin("0.25,0.1,0.5,0.6")
local px, py, pw, ph, active = Playfield.rect(1280, 720)
eq(active, true, "Gold sees the cutout too")
eq(pw, 480, "the playfield is a whole multiple of 160")
eq(ph, 432, "and of 144")
check(inside(px, py, pw, ph, 320, 72, 640, 432),
"centred inside the cutout")
eq(Chrome.fitScale(1280, 720), 3, "Chrome fits the playfield")
local cox, coy = Chrome.fitOrigin(1280, 720)
eq(cox, px, "and centres the panel on it")
eq(coy, py, "on both axes")
useSkin("0.25,0.1,0.5,0.6", "overlay0_viewport_expand = true")
local ex, ey, ew, eh = Playfield.rect(1280, 720)
eq(ew, 640, "expand hands the picture the full cutout width")
eq(eh, 432, "and its full height")
eq(ex, 320, "at the cutout origin")
eq(ey, 72, "on both axes")
useSkin("0.25,0.1,0.5,0.6")
local ew2, eh2, ex2, ey2, act2 = Playfield.push(1280, 720)
eq(act2, true, "push reports the frame is contained")
eq(ex2, px, "push translates to the playfield origin")
eq(ey2, py, "on both axes")
eq(ew2, pw, "and hands the scene the playfield size")
eq(Playfield.cutout(ew2, eh2), nil, "inside the frame there is no cutout left")
eq(select(3, Playfield.rect(ew2, eh2)), pw, "so the playfield is the surface")
eq(Chrome.fitScale(ew2, eh2), 3, "and Chrome fits it without re-applying")
eq(select(1, Chrome.fitOrigin(ew2, eh2)), 0, "at a local origin")
eq(select(1, Playfield.dimensions()), pw, "screens read the playfield as the display")
Playfield.pop()
eq(Playfield.entered, false, "pop leaves the frame")
eq(select(1, Playfield.cutout(1280, 720)), 320, "and the cutout is visible again")
useSkin("0.4,0.4,0.1,0.1")
local sx, sy, sw, sh = Playfield.rect(1280, 720)
check(inside(sx, sy, sw, sh, 512, 288, 128, 72),
"a cutout smaller than 160x144 still bounds the playfield")
check(sw <= 128 and sh <= 72, "the playfield never exceeds the cutout")
TouchSkin.setActive(nil)
eq(Playfield.cutout(1280, 720), nil, "no skin, no cutout")
eq(select(3, Playfield.rect(1280, 720)), 1280, "and the playfield is the window")
local saved = TouchSkin.viewport
TouchSkin.viewport = function() error("boom") end
eq(Playfield.cutout(1280, 720), nil, "a throwing viewport is no cutout")
TouchSkin.viewport = function() return 10, 10, 0, 0 end
eq(Playfield.cutout(1280, 720), nil, "a zero-sized cutout is no cutout")
TouchSkin.viewport = function() return -50, -50, 200, 200 end
eq(select(1, Playfield.cutout(1280, 720)), 0, "a cutout off the surface is clamped")
eq(select(3, Playfield.cutout(1280, 720)), 150, "to what is left of it")
TouchSkin.viewport = saved
TouchSkin.setActive(nil)
setWindow(640, 576)
T.finish("skin_viewport_containment")
+175
View File
@@ -0,0 +1,175 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
love = love or require("tests.love_stub")
local Json = require("src.link.Json")
local SyncClient = require("src.sync.SyncClient")
local function recorder()
local t = { sent = {}, replies = {}, released = 0 }
function t:begin(req)
self.sent[#self.sent + 1] = req
return #self.sent
end
function t:poll(handle)
local reply = self.replies[handle]
if not reply then return { status = "pending" } end
return reply
end
function t:release() self.released = self.released + 1 end
function t:answer(handle, code, body)
self.replies[handle] = { status = "ok", code = code, body = body }
end
function t:fail(handle, err)
self.replies[handle] = { status = "error", err = err }
end
return t
end
local function client(transport)
return SyncClient.new({ baseUrl = "http://sync.test/", transport = transport })
end
do
T.eq(SyncClient.normalizeCode("1234-5678"), "12345678",
"a dashed code normalizes to digits")
T.eq(SyncClient.normalizeCode(" 1234 5678 "), "12345678",
"and so does a spaced one")
T.eq(SyncClient.normalizeCode("1234567"), nil, "seven digits is not a code")
T.eq(SyncClient.normalizeCode("123456789"), nil, "nor is nine")
T.eq(SyncClient.normalizeCode("abcdefgh"), nil, "nor letters")
T.eq(SyncClient.formatCode("12345678"), "1234-5678",
"codes present as two groups of four")
T.eq(SyncClient.formatCode("nope"), nil, "a bad code has no presentation")
end
do
local t = recorder()
local c = client(t)
T.eq(c:isLinked(), false, "a new client is not linked")
local handle = c:create("laptop")
local req = t.sent[1]
T.eq(req.method, "POST", "create posts")
T.eq(req.url, "http://sync.test/sync/create", "to /sync/create")
T.eq(req.headers["x-sync-account"], nil,
"and carries no auth header before there is an account")
T.eq(Json.decode(req.body).device, "laptop", "the device label rides along")
t:answer(handle, 200,
'{"account":"aa11","code1":"11112222","code2":"33334444","deviceToken":"tok"}')
local res = c:poll(handle)
T.eq(res.status, "ok", "a 200 with JSON reads as ok")
T.eq(res.data.account, "aa11", "and the account comes back decoded")
c:setAuth(res.data.account, res.data.deviceToken)
T.eq(c:isLinked(), true, "storing the token links the client")
local stateHandle = c:fetchState()
local stateReq = t.sent[2]
T.eq(stateReq.method, "GET", "state is a GET")
T.eq(stateReq.headers["x-sync-account"], "aa11", "with the account header")
T.eq(stateReq.headers["x-sync-token"], "tok", "and the device token header")
T.eq(stateReq.body, nil, "and no body")
T.eq(c:poll(stateHandle).status, "pending", "an unanswered request is pending")
local bad, err = c:link("123", "456", "phone")
T.eq(bad, nil, "a short code never reaches the network")
T.check(tostring(err):find("8 digits", 1, true) ~= nil,
"and says what a code looks like")
T.eq(#t.sent, 2, "no request was sent for the bad codes")
end
do
local t = recorder()
local c = client(t)
c:setAuth("aa11", "tok")
local handle = c:putSave({ version = "red", slot = "slot1",
meta = { savedAt = 100, sessionStart = 50 }, blob = "return {}",
baseRev = 4 })
local req = t.sent[1]
T.eq(req.method, "PUT", "a save upload is a PUT")
T.eq(req.url, "http://sync.test/sync/save", "to /sync/save")
local body = Json.decode(req.body)
T.eq(body.version, "red", "the version rides in the body")
T.eq(body.baseRev, 4, "with the rev the client last synced")
T.eq(body.meta.sessionStart, 50, "and the session start in the meta")
t:answer(handle, 409,
'{"conflict":true,"rev":9,"remoteMeta":{"savedAt":200,"sessionStart":60}}')
local res = c:poll(handle)
T.eq(res.status, "error", "a 409 is not a success")
T.eq(res.code, 409, "the status code is reported")
T.eq(res.data.remoteMeta.savedAt, 200,
"and the conflict body is still readable")
local tooBig, why = c:putSave({ version = "red", slot = "slot1",
blob = string.rep("x", SyncClient.MAX_BLOB + 1) })
T.eq(tooBig, nil, "an oversized save is refused before it is sent")
T.check(tostring(why):find("too large", 1, true) ~= nil,
"with a reason the UI can show")
local getHandle = c:getSave("red", "abc def")
T.eq(t.sent[2].url, "http://sync.test/sync/save?id=abc%20def&version=red",
"a download escapes its query parameters")
t:answer(getHandle, 200, '{"meta":{"savedAt":200},"blob":"return {}","rev":9}')
T.eq(c:poll(getHandle).data.rev, 9, "the download reports the served rev")
end
do
local t = recorder()
local c = client(t)
c:setAuth("aa11", "tok")
local h1 = c:fetchState()
t:fail(h1, "no route to host")
local res = c:poll(h1)
T.eq(res.status, "error", "a transport failure is an error")
T.check(res.err:find("no route", 1, true) ~= nil, "and keeps the reason")
local h2 = c:fetchState()
t:answer(h2, 200, "<html>nope</html>")
local html = c:poll(h2)
T.eq(html.status, "error", "an HTML reply is not a sync reply")
T.check(html.err:find("HTML", 1, true) ~= nil, "and says so")
local h3 = c:fetchState()
t:answer(h3, 401, '{"error":"bad_token"}')
local denied = c:poll(h3)
T.eq(denied.status, "error", "a 401 is an error")
T.eq(denied.err, "bad_token", "carrying the server's own reason")
local h4 = c:fetchState()
t:answer(h4, 200, '{"ok":true,"error":"stale"}')
T.eq(c:poll(h4).status, "error",
"an error field in a 200 body still fails the call")
c:clearAuth()
local nope, err = c:fetchState()
T.eq(nope, nil, "an unlinked client refuses an authenticated call")
T.check(tostring(err):find("not linked", 1, true) ~= nil,
"and says the device is not linked")
end
do
local t = recorder()
local c = client(t)
c:setAuth("aa11", "tok")
local handle = c:fetchShare("ab3d9k")
T.eq(t.sent[1].headers["x-sync-token"], nil,
"reading a share code needs no auth")
T.eq(t.sent[1].url, "http://sync.test/sync/modshare?code=AB3D9K",
"and the code is upper-cased in the query")
t:answer(handle, 200, '{"manifest":{"rev":1,"mods":[],"indexes":[]}}')
T.eq(c:poll(handle).data.manifest.rev, 1, "the shared manifest decodes")
local bad, err = c:fetchShare("12")
T.eq(bad, nil, "a short share code never reaches the network")
T.check(tostring(err):find("6 characters", 1, true) ~= nil,
"and says how long one is")
end
T.finish("sync_client")
+459
View File
@@ -0,0 +1,459 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
love = love or require("tests.love_stub")
local Json = require("src.link.Json")
local SyncState = require("src.sync.SyncState")
local SyncEngine = require("src.sync.SyncEngine")
local function scripted(routes)
local t = { sent = {}, routes = routes, handles = {} }
function t:begin(req)
self.sent[#self.sent + 1] = req
local path = req.url:match("^[^?]*"):gsub("^http://sync%.test", "")
local route = self.routes[req.method .. " " .. path]
local reply
if type(route) == "function" then
reply = route(req, self)
else
reply = route
end
reply = reply or { code = 404, body = '{"error":"no route"}' }
self.handles[#self.sent] = {
status = "ok", code = reply.code or 200,
body = reply.body or Json.encode(reply.data or {}),
}
return #self.sent
end
function t:poll(handle) return self.handles[handle] end
function t:release() end
return t
end
local function pump(eng, times)
for _ = 1, (times or 24) do eng:update(0.05) end
end
local function linkedState()
local state = SyncState.defaults()
state.account = "aa11bb22cc33dd44"
state.deviceToken = "tok"
state.enabled = true
return state
end
local function saveEntry(version, id, savedAt, sessionStart, slot)
return {
version = version, slot = slot or "slot1", playthroughId = id,
blob = "return { player = { name = 'ASH' } }",
meta = { savedAt = savedAt, sessionStart = sessionStart,
playthroughId = id, summary = { name = "ASH", badges = 2 } },
}
end
local function fakeSaves(entries)
local writes = {}
return {
writes = writes,
list = function() return entries end,
write = function(version, id, blob, mode)
writes[#writes + 1] = { version = version, playthroughId = id,
blob = blob, mode = mode }
return mode == "new" and "slot9" or "slot1"
end,
}
end
local function engine(routes, entries, state)
local saves = fakeSaves(entries or {})
local transport = scripted(routes)
local eng = SyncEngine.new({
baseUrl = "http://sync.test",
transport = transport,
state = state or linkedState(),
saves = saves,
persist = false,
now = function() return 1700001000 end,
})
return eng, transport, saves
end
do
T.eq(SyncEngine.overlaps({ sessionStart = 10, savedAt = 20 },
{ sessionStart = 15, savedAt = 30 }), true,
"two sessions that ran over the same minutes overlap")
T.eq(SyncEngine.overlaps({ sessionStart = 10, savedAt = 20 },
{ sessionStart = 21, savedAt = 30 }), false,
"a session that started after the other ended does not")
T.eq(SyncEngine.overlaps({ savedAt = 20 }, { sessionStart = 1, savedAt = 30 }),
false, "a save with no session start cannot claim an overlap")
end
do
local eng, transport = engine({
["POST /sync/create"] = { code = 200, body =
'{"account":"aa11","code1":"11112222","code2":"33334444","deviceToken":"tok"}' },
}, {}, SyncState.defaults())
T.eq(eng:linked(), false, "a fresh engine is not linked")
T.eq(eng.status, "Not set up", "and says so")
eng:createAccount("laptop")
pump(eng, 3)
T.eq(eng:linked(), true, "creating an account links this device")
T.eq(eng.state.account, "aa11", "and stores the account id")
T.eq(eng.codes.code1, "1111-2222", "the first code is shown grouped")
T.eq(eng.codes.code2, "3333-4444", "and so is the second")
T.eq(eng.state.code1, nil, "codes never enter the persisted state")
T.eq(eng.phase, "idle", "and the engine settles")
T.eq(#transport.sent, 1, "one request was made")
end
do
local eng, transport = engine({
["POST /sync/link"] = { code = 200,
body = '{"account":"aa11","deviceToken":"tok"}' },
["GET /sync/state"] = { code = 200, body = '{"saves":{}}' },
["PUT /sync/save"] = { code = 200, body = '{"ok":true,"rev":1}' },
}, { saveEntry("red", "abc", 500, 400) }, SyncState.defaults())
eng:linkDevice("1111-2222", "3333 4444", "phone")
pump(eng)
T.eq(eng:linked(), true, "linking with both codes links the device")
T.eq(transport.sent[2].url, "http://sync.test/sync/state",
"and a sync starts immediately")
T.eq(transport.sent[3].method, "PUT",
"the local save the server has never seen is uploaded")
T.eq(SyncState.rev(eng.state, "red/abc"), 1, "the served rev is remembered")
T.eq(SyncState.stamp(eng.state, "red/abc"), 500,
"along with the savedAt that was uploaded")
T.eq(eng.phase, "idle", "and the engine settles")
T.eq(eng.state.lastSyncAt, 1700001000, "the sync time is stamped")
end
do
local eng, transport = engine({}, {}, SyncState.defaults())
eng:linkDevice("12", "34", "phone")
T.eq(#transport.sent, 0, "a malformed code pair is refused locally")
T.eq(eng.phase, "error", "and the engine reports the problem")
T.check(eng.status:find("8 digits", 1, true) ~= nil,
"with copy that says what a code is")
end
do
local eng, transport, saves = engine({
["GET /sync/state"] = { code = 200,
body = '{"saves":{"gold/xyz":{"rev":4,"meta":{"savedAt":900}}}}' },
["GET /sync/save"] = { code = 200,
body = '{"rev":4,"meta":{"savedAt":900},"blob":"return { player = {} }"}' },
}, {})
eng:syncNow()
pump(eng)
T.eq(#saves.writes, 1, "the remote-only save is written locally")
T.eq(saves.writes[1].version, "gold", "into the right game")
T.eq(saves.writes[1].mode, "replace", "as that playthrough's slot")
T.eq(SyncState.rev(eng.state, "gold/xyz"), 4, "and its rev is remembered")
T.eq(eng.phase, "idle", "the engine settles")
T.eq(transport.sent[2].url, "http://sync.test/sync/save?id=xyz&version=gold",
"the download names the playthrough, not the slot")
end
do
local state = linkedState()
SyncState.setRev(state, "red/abc", 7, 500)
local eng, transport = engine({
["GET /sync/state"] = { code = 200,
body = '{"saves":{"red/abc":{"rev":7,"meta":{"savedAt":500}}}}' },
}, { saveEntry("red", "abc", 500, 400) }, state)
eng:syncNow()
pump(eng)
T.eq(#transport.sent, 1, "an unchanged save is neither uploaded nor downloaded")
T.eq(eng.phase, "idle", "and the sync ends idle")
end
local function conflictEngine()
local state = linkedState()
SyncState.setRev(state, "red/abc", 7, 500)
return engine({
["GET /sync/state"] = { code = 200,
body = '{"saves":{"red/abc":{"rev":9,"meta":{"savedAt":760,' ..
'"sessionStart":600,"summary":{"name":"BLUE","badges":4}}}}}' },
["PUT /sync/save"] = { code = 200, body = '{"ok":true,"rev":10}' },
["GET /sync/save"] = { code = 200,
body = '{"rev":9,"meta":{"savedAt":760},"blob":"return { player = {} }"}' },
}, { saveEntry("red", "abc", 700, 650) }, state)
end
do
local eng, transport = conflictEngine()
eng:syncNow()
pump(eng)
T.eq(eng.phase, "conflict", "both sides changing is a conflict")
T.eq(#eng.conflicts, 1, "one conflict is raised")
T.eq(eng.conflicts[1].overlap, true,
"the two sessions ran over the same minutes")
T.eq(eng.status, "These saves were played at the same time.",
"and the status is the wording the player was promised")
T.eq(eng.conflicts[1].remoteMeta.summary.name, "BLUE",
"the other device's save is summarized for the prompt")
T.eq(#transport.sent, 1, "nothing is uploaded while the player decides")
T.eq(#eng.state.pendingConflicts, 1, "the conflict survives in the state")
end
do
local eng, transport = conflictEngine()
eng:syncNow()
pump(eng)
eng:resolveConflict("red/abc", "local")
pump(eng)
local put = transport.sent[2]
T.eq(put.method, "PUT", "keep this device uploads")
T.eq(Json.decode(put.body).force, true, "with the force flag")
T.eq(SyncState.rev(eng.state, "red/abc"), 10, "and adopts the new rev")
T.eq(eng.phase, "idle", "the conflict is cleared")
T.eq(#eng.state.pendingConflicts, 0, "and dropped from the state")
end
do
local eng, transport, saves = conflictEngine()
eng:syncNow()
pump(eng)
eng:resolveConflict("red/abc", "remote")
pump(eng)
T.eq(transport.sent[2].method, "GET", "keep the other device downloads")
T.eq(#saves.writes, 1, "and writes it locally")
T.eq(saves.writes[1].mode, "replace", "over this playthrough's slot")
T.eq(SyncState.rev(eng.state, "red/abc"), 9, "adopting the remote rev")
T.eq(eng.phase, "idle", "the conflict is cleared")
end
do
local eng, transport, saves = conflictEngine()
eng:syncNow()
pump(eng)
eng:resolveConflict("red/abc", "both")
pump(eng)
T.eq(#saves.writes, 1, "keep both imports the other save")
T.eq(saves.writes[1].mode, "new", "into a new slot")
local put = transport.sent[3]
T.eq(put.method, "PUT", "and still uploads this device's save")
T.eq(Json.decode(put.body).force, true, "forcing past the stale rev")
T.eq(eng.phase, "idle", "the conflict is cleared")
end
do
local eng = engine({
["GET /sync/state"] = { code = 200, body = '{"saves":{}}' },
["PUT /sync/save"] = { code = 409, body =
'{"conflict":true,"rev":3,"remoteMeta":{"savedAt":710,"sessionStart":600}}' },
}, { saveEntry("red", "abc", 700, 650) })
eng:syncNow()
pump(eng)
T.eq(eng.phase, "conflict", "a 409 on upload becomes a conflict, not an error")
T.eq(eng.conflicts[1].overlap, true, "with the overlap worked out")
end
do
local eng = engine({
["GET /sync/state"] = function()
return { code = 500, body = '{"error":"server on fire"}' }
end,
}, {})
eng:syncNow()
pump(eng, 3)
T.eq(eng.phase, "error", "a server error stops the sync")
T.check(eng.status:find("server on fire", 1, true) ~= nil,
"and shows what the server said")
end
do
local eng, transport = engine({
["GET /sync/state"] = { code = 200, body = '{"saves":{}}' },
["PUT /sync/save"] = { code = 200, body = '{"ok":true,"rev":1}' },
}, { saveEntry("red", "abc", 500, 400) })
eng:noteSaveWritten()
eng:update(1)
T.eq(#transport.sent, 0, "an in-game save does not sync straight away")
eng:update(SyncEngine.UPLOAD_DEBOUNCE)
T.eq(#transport.sent, 1, "it syncs once the debounce has passed")
pump(eng)
T.eq(transport.sent[2].method, "PUT", "and the save goes up")
end
do
local eng, transport = engine({}, { saveEntry("red", "abc", 500, 400) })
eng:setEnabled(false)
eng:noteSaveWritten()
eng:update(60)
T.eq(#transport.sent, 0, "with sync off an in-game save uploads nothing")
end
do
local eng = engine({
["POST /sync/create"] = { code = 200, body =
'{"account":"aa11","code1":"11112222","code2":"33334444",' ..
'"deviceToken":"tok","device":"0a1b2c3d"}' },
}, {}, SyncState.defaults())
eng:createAccount("laptop")
pump(eng, 3)
T.eq(eng.state.deviceId, "0a1b2c3d",
"creating an account records the id the server gave this device")
T.eq(SyncState.sanitize(eng.state).deviceId, "0a1b2c3d",
"and it survives being persisted")
end
do
local eng = engine({
["POST /sync/link"] = { code = 200,
body = '{"account":"aa11","deviceToken":"tok","device":"beefcafe"}' },
["GET /sync/state"] = { code = 200, body = '{"saves":{}}' },
}, {}, SyncState.defaults())
eng:linkDevice("11112222", "33334444", "phone")
pump(eng)
T.eq(eng.state.deviceId, "beefcafe", "so does linking a second device")
end
do
local state = linkedState()
state.deviceId = "0a1b2c3d"
local eng, transport = engine({
["POST /sync/unlink"] = { code = 200, body = '{"ok":true,"devices":1}' },
}, {}, state)
eng:unlink()
T.eq(eng:linked(), true, "unlink waits for the server before forgetting")
local sent = transport.sent[1]
T.eq(sent.url, "http://sync.test/sync/unlink", "it asks the server first")
T.eq(Json.decode(sent.body).device, "0a1b2c3d",
"naming the device id the server knows, not the platform label")
pump(eng, 3)
T.eq(eng:linked(), false, "and only then drops the credentials")
T.eq(eng.status, "Not set up", "reporting the device as unlinked")
end
do
local state = linkedState()
state.deviceId = "0a1b2c3d"
local eng = engine({
["POST /sync/unlink"] = { code = 500, body = '{"error":"nope"}' },
}, {}, state)
eng:unlink()
pump(eng, 3)
T.eq(eng.phase, "error", "a failed revocation is surfaced")
T.eq(eng:linked(), true,
"and the device stays linked rather than lying about it")
end
do
local state = linkedState()
state.deviceId = "0a1b2c3d"
local eng = engine({
["POST /sync/unlink"] = { code = 401, body = '{"error":"unauthorized"}' },
}, {}, state)
eng:unlink()
pump(eng, 3)
T.eq(eng:linked(), false,
"a token the server already revoked is dropped rather than stuck forever")
end
do
local state = linkedState()
state.deviceId = "0a1b2c3d"
local eng, transport = engine({
["POST /sync/unlink"] = { code = 200, body = '{"ok":true,"devices":1}' },
["GET /sync/state"] = { code = 200, body = '{"saves":{}}' },
}, {}, state)
eng:unlinkDevice("99998888")
T.eq(Json.decode(transport.sent[1].body).device, "99998888",
"another device is revoked by its id")
pump(eng, 3)
T.eq(eng:linked(), true, "without logging this device out")
end
do
local state = linkedState()
state.deviceId = "0a1b2c3d"
local eng = engine({
["GET /sync/state"] = { code = 200, body =
'{"saves":{},"devices":[{"id":"0a1b2c3d","label":"OS X","current":true},' ..
'{"id":"99998888","label":"Android"}]}' },
}, {}, state)
eng:syncNow()
pump(eng)
T.eq(#eng.devices, 2, "the linked devices are kept for the modal to show")
T.eq(eng.devices[1].current, true, "this device is marked")
T.eq(eng.devices[2].label, "Android", "and the others are named")
end
do
local eng = conflictEngine()
eng:syncNow()
pump(eng)
eng:syncNow()
pump(eng)
eng:syncNow()
pump(eng)
T.eq(#eng.state.pendingConflicts, 1,
"syncing again over the same conflict does not stack up rows")
T.eq(#eng.conflicts, 1, "and the prompt still has exactly one to answer")
end
do
local eng = engine({}, {})
local order, seen = {}, {}
eng.modDeps = {
installed = function() return {} end,
indexes = function() return {} end,
addIndex = function(url) order[#order + 1] = "index" return { feed = url } end,
findEntry = function() return nil end,
install = function(entry) order[#order + 1] = "install:" .. entry.id return true end,
setEnabled = function(id) order[#order + 1] = "enable:" .. id return true end,
}
eng.modPlan = {
indexes = { "https://mods.example/i.json" },
toInstall = { { id = "beta", entry = { id = "beta" } } },
toEnable = { { id = "beta", version = "red" } },
missing = {},
}
eng:applyModPlan(function(done, total, label, finished)
seen[#seen + 1] = ("%d/%d %s"):format(done, total, tostring(finished))
end)
T.eq(#order, 0, "starting an apply installs nothing on the spot")
T.eq(eng:busy(), true, "the launcher can see it is working")
eng:update(0.016)
T.eq(#order, 1, "one step runs per frame, so the progress line can draw")
eng:update(0.016)
eng:update(0.016)
T.eq(#order, 3, "until the whole plan has run")
T.eq(order[3], "enable:beta", "in plan order")
T.eq(eng.modApply, nil, "the job is done")
T.eq(eng.modPlan, nil, "and the plan is spent")
T.eq(eng.status, "Mods applied", "the status says so")
T.eq(seen[#seen], "3/3 true", "and the last progress call reports the end")
end
do
local eng = engine({}, {})
eng.modDeps = {
installed = function() return {} end,
indexes = function() return {} end,
addIndex = function() return true end,
findEntry = function() return nil end,
install = function() return nil, "download failed" end,
setEnabled = function() return true end,
}
eng.modPlan = { indexes = {}, toInstall = { { id = "beta", entry = { id = "beta" } } },
toEnable = {}, missing = {} }
eng:applyModPlan()
eng:update(0.016)
T.eq(eng.modApply, nil, "a failing step still ends the job")
T.check(eng.status:find("download failed", 1, true) ~= nil,
"and the failure reaches the status line")
end
T.finish("sync_engine")
+148
View File
@@ -0,0 +1,148 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
love = love or require("tests.love_stub")
local SyncMods = require("src.sync.SyncMods")
local function row(id, version, enabled, github)
return { id = id, version = version, github = github,
enabledByVersion = enabled }
end
local function deps(installed, indexes, catalog)
local calls = { installed = {}, enabled = {}, indexes = {} }
return calls, {
installed = function() return installed end,
indexes = function() return indexes or {} end,
addIndex = function(url)
calls.indexes[#calls.indexes + 1] = url
return { feed = url }
end,
findEntry = function(id) return (catalog or {})[id] end,
install = function(entry)
calls.installed[#calls.installed + 1] = entry.id
return true
end,
setEnabled = function(id, enabled, version)
calls.enabled[#calls.enabled + 1] = id .. ":" .. tostring(version)
return true
end,
}
end
do
local _, d = deps({
row("zeta", "1.0.0", { red = true, blue = false, yellow = false, gold = false }),
row("alpha", "2.1.0", { red = true, gold = true }, "someone/alpha"),
}, { { url = "https://mods.example/index.json",
feed = "https://mods.example/index.json" } })
local manifest = SyncMods.build(d)
T.eq(manifest.rev, SyncMods.REV, "the manifest carries its shape revision")
T.eq(#manifest.indexes, 1, "the player's index list rides along")
T.eq(manifest.indexes[1], "https://mods.example/index.json",
"as the url they typed")
T.eq(#manifest.mods, 2, "every installed mod is listed")
T.eq(manifest.mods[1].id, "alpha", "sorted by id so the manifest is stable")
T.eq(manifest.mods[1].source, "github:someone/alpha",
"a github mod records where it came from")
T.eq(manifest.mods[2].source, "local",
"a hand-installed mod is marked local rather than invented")
T.eq(#manifest.mods[1].enabledFor, 2, "alpha is on for two games")
T.eq(manifest.mods[1].enabledFor[1], "red", "in GameVersion order")
T.eq(manifest.mods[1].enabledFor[2], "gold", "red then gold")
T.eq(#manifest.mods[2].enabledFor, 1, "zeta is on for one")
end
do
local manifest = {
rev = 1,
indexes = { "https://mods.example/index.json", "https://other.example/i.json" },
mods = {
{ id = "alpha", version = "2.1.0", enabledFor = { "red", "gold" } },
{ id = "beta", version = "1.0.0", enabledFor = { "red" } },
{ id = "ghost", version = "0.1.0", source = "local", enabledFor = { "red" } },
},
}
local _, d = deps(
{ row("alpha", "2.1.0", { red = true }) },
{ { url = "https://mods.example/index.json",
feed = "https://mods.example/index.json" } },
{ beta = { id = "beta" } })
local plan = SyncMods.plan(manifest, d)
T.eq(#plan.indexes, 1, "only the index this device is missing is planned")
T.eq(plan.indexes[1], "https://other.example/i.json", "the new one")
T.eq(#plan.toInstall, 1, "one mod can be fetched from an index")
T.eq(plan.toInstall[1].id, "beta", "the one the catalog knows")
T.eq(#plan.missing, 1, "the mod nobody publishes is reported, not invented")
T.eq(plan.missing[1].id, "ghost", "by id")
T.eq(#plan.toEnable, 2, "every game answer that differs is planned")
for _, want in ipairs(plan.toEnable) do
T.check(want.id ~= "ghost",
"a mod that cannot be installed is never enabled")
end
T.eq(SyncMods.planEmpty(plan), false, "a plan with work is not empty")
local same = SyncMods.plan({ rev = 1, indexes = {}, mods = {
{ id = "alpha", version = "2.1.0", enabledFor = { "red" } } } }, d)
T.eq(SyncMods.planEmpty(same), true, "a matching device plans nothing")
end
do
local calls, d = deps({}, {}, { beta = { id = "beta" } })
local plan = {
indexes = { "https://other.example/i.json" },
toInstall = { { id = "beta", entry = { id = "beta" } } },
toEnable = { { id = "beta", version = "red" } },
missing = { { id = "ghost" } },
}
local seen = {}
local ok = SyncMods.apply(plan, function(done, total, label)
seen[#seen + 1] = ("%d/%d %s"):format(done, total, label)
end, d)
T.eq(ok, true, "applying a plan reports success")
T.eq(calls.indexes[1], "https://other.example/i.json", "the index is added")
T.eq(calls.installed[1], "beta", "the mod is installed through the launcher path")
T.eq(calls.enabled[1], "beta:red", "and enabled for the game that wanted it")
T.eq(#seen, 3, "progress is reported once per step")
T.eq(seen[3], "3/3 beta", "counting up to the total")
end
do
local _, d = deps({}, {}, {})
d.install = function() return nil, "download failed" end
local ok, err = SyncMods.apply({
toInstall = { { id = "beta", entry = { id = "beta" } } } }, nil, d)
T.eq(ok, false, "a failed install fails the apply")
T.check(tostring(err):find("download failed", 1, true) ~= nil,
"naming the mod and the reason")
end
do
local calls, d = deps({}, {}, {})
d.install = function() return nil, "download failed" end
local ok = SyncMods.apply({
toInstall = { { id = "beta", entry = { id = "beta" } } },
toEnable = { { id = "beta", version = "red" } },
}, nil, d)
T.eq(ok, false, "the apply still reports the failure")
T.eq(#calls.enabled, 0,
"a mod whose install failed is not switched on regardless")
end
do
local calls, d = deps({}, {}, {})
local steps = SyncMods.steps({
indexes = { "https://other.example/i.json" },
toInstall = { { id = "beta", entry = { id = "beta" } } },
toEnable = { { id = "beta", version = "red" } },
}, d)
T.eq(#steps, 3, "a plan splits into one step per unit of work")
T.eq(steps[1].run(), true, "steps run one at a time")
T.eq(#calls.indexes, 1, "so the caller can draw between them")
T.eq(#calls.installed, 0, "without the rest of the plan having run yet")
end
T.finish("sync_mods")
+171
View File
@@ -0,0 +1,171 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
love = love or require("tests.love_stub")
local SaveData = require("src.core.SaveData")
local GameVersion = require("src.core.GameVersion")
local SyncEngine = require("src.sync.SyncEngine")
local realFS = love.filesystem
local function memfs(files)
return {
files = files,
write = function(path, content) files[path] = content return true end,
read = function(path) return files[path] end,
remove = function(path) files[path] = nil return true end,
getInfo = function(path)
if files[path] then return { type = "file" } end
return nil
end,
}
end
local function fresh()
local files = {}
love.filesystem = memfs(files)
SaveData.resetSlotState()
GameVersion.set("red")
return files
end
do
local plain = SaveData.buildMeta({})
T.check(type(plain.savedAt) == "number", "a save still records when it ended")
T.eq(plain.sessionStart, nil,
"and records no session start when nobody supplied one")
local started = os.time() - 600
local meta = SaveData.buildMeta({}, nil, started)
T.eq(meta.sessionStart, started, "the session start is stamped when given")
T.check(meta.savedAt >= meta.sessionStart,
"and savedAt is the end of that session")
local carried = SaveData.buildMeta({}, { sessionStart = started })
T.eq(carried.sessionStart, started,
"a rewrite with no session keeps the previous start")
local future = SaveData.buildMeta({}, nil, os.time() + 9999)
T.check(future.sessionStart <= future.savedAt,
"a clock that ran backwards cannot start a session after it ended")
local nan = SaveData.buildMeta({}, nil, 0 / 0)
T.eq(nan.sessionStart, nil, "a NaN session start is refused")
local kept = SaveData.buildMeta(nil, { playthroughId = "abc", mods = {},
sessionStart = 42 })
T.eq(kept.playthroughId, "abc", "the playthrough id still rides on the meta")
T.eq(kept.sessionStart, 42, "next to the session start")
end
do
local files = fresh()
T.eq(SaveData.readSlotSource("red", "slot1"), nil,
"an empty slot has no bytes to upload")
local save = SaveData.newGame()
save.player.name = "ASH"
save.meta = SaveData.buildMeta({}, { playthroughId = "abc" }, os.time() - 60)
T.check(SaveData.writeSlot("red", "slot1", save), "a slot write lands")
local source = SaveData.readSlotSource("red", "slot1")
T.check(type(source) == "string" and #source > 0, "the raw bytes read back")
local decoded = SaveData.decode(source)
T.eq(decoded.player.name, "ASH", "and decode to the same save")
T.eq(decoded.meta.playthroughId, "abc", "carrying the playthrough id")
files["saves/red/slot1.lua"] = "this is not a save"
T.eq(SaveData.readSlotSource("red", "slot1"), nil,
"a corrupt slot never hands undecodable bytes to the uploader")
files["saves/red/slot1.lua.bak"] = source
T.eq(SaveData.readSlotSource("red", "slot1"), source,
"and the backup copy is used instead")
T.eq(SaveData.readSlotSource("nosuchgame", "slot1"), nil,
"an unknown version has no slots to read")
end
do
fresh()
local provider = SyncEngine.defaultSaves()
T.eq(#provider.list(), 0, "a fresh install has nothing to sync")
local slotId = SaveData.createSlot("red")
local save = SaveData.newGame()
save.player.name = "ASH"
save.meta = SaveData.buildMeta({}, { playthroughId = "abc" }, os.time() - 120)
SaveData.writeSlot("red", slotId, save)
local entries = provider.list()
T.eq(#entries, 1, "a written slot becomes one sync entry")
T.eq(entries[1].version, "red", "keyed by its game")
T.eq(entries[1].playthroughId, "abc", "and its playthrough id")
T.eq(entries[1].slot, slotId, "remembering which slot it came from")
T.eq(entries[1].meta.summary.name, "ASH",
"with the launcher summary the conflict prompt shows")
T.check(entries[1].meta.sessionStart ~= nil, "and the session start")
T.check(entries[1].blob:find("ASH", 1, true) ~= nil,
"the blob is the encoded save itself")
local other = SaveData.newGame()
other.player.name = "BLUE"
other.meta = SaveData.buildMeta({}, { playthroughId = "xyz" }, os.time() - 30)
local newSlot = provider.write("red", "xyz", SaveData.encode(other), "new")
T.check(newSlot ~= nil and newSlot ~= slotId,
"keep both imports the other device's save into a new slot")
local after = provider.list()
T.eq(#after, 2, "and both playthroughs are now local")
local ids = {}
for _, entry in ipairs(after) do ids[entry.playthroughId] = true end
T.eq(ids["abc"], true, "this device's playthrough is untouched")
T.eq(ids["xyz"], nil,
"and the imported copy gets its own identity so the two never merge")
end
do
local source = assert(io.open("src/core/Game.lua")):read("*a")
T.check(source:find("self.sessionStartedAt = os.time()", 1, true) ~= nil,
"Game stamps when a play session began")
T.check(source:find("self.sessionStartedAt)", 1, true) ~= nil,
"and hands it to buildMeta when the save is written")
local _, stamps = source:gsub("self%.sessionStartedAt = os%.time%(%)", "")
T.eq(stamps, 3,
"boot, NEW GAME and CONTINUE each start a session")
end
do
fresh()
local Game = require("src.core.Game")
local notes, pumped = 0, 0
SyncEngine._shared = {
state = { enabled = true },
linked = function() return true end,
busy = function() return false end,
noteSaveWritten = function() notes = notes + 1 end,
update = function(_, dt) pumped = pumped + dt end,
}
local game = setmetatable({ save = SaveData.newGame(),
sessionStartedAt = os.time() - 60 }, { __index = Game })
T.eq(Game.writeSave(game), true, "an in-game save still writes")
T.eq(notes, 1, "and tells the sync engine, so the 5s debounce can start")
Game.updateSync(game, 0.5)
T.eq(pumped, 0.5, "the running game pumps the engine, not only the launcher")
SyncEngine._shared = {
state = { enabled = false },
linked = function() return false end,
busy = function() return false end,
noteSaveWritten = function() notes = notes + 1 end,
update = function() pumped = pumped + 1 end,
}
game._syncOff, game._syncEngineRef = nil, nil
Game.updateSync(game, 0.5)
T.eq(pumped, 0.5, "with sync off the engine is left alone")
SyncEngine.forgetShared()
end
love.filesystem = realFS
T.finish("sync_session_meta")
+120
View File
@@ -0,0 +1,120 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
love = love or require("tests.love_stub")
local SaveData = require("src.core.SaveData")
local SyncState = require("src.sync.SyncState")
local realFS = love.filesystem
local function memfs(files)
return {
files = files,
write = function(path, content) files[path] = content return true end,
read = function(path) return files[path] end,
remove = function(path) files[path] = nil return true end,
getInfo = function(path)
if files[path] then return { type = "file" } end
return nil
end,
}
end
local function fresh()
local files = {}
love.filesystem = memfs(files)
SaveData.resetSlotState()
return files
end
do
local opts = SaveData.defaultOptions()
T.check(type(opts.saveSync) == "table", "defaultOptions carries saveSync")
T.eq(opts.saveSync.enabled, false, "sync is off until the player sets it up")
T.check(type(opts.saveSync.revs) == "table", "and starts with no synced revs")
T.eq(opts.saveSync.account, nil, "and no account")
local state = SyncState.defaults()
T.eq(SyncState.linked(state), false, "a default state is not linked")
T.eq(state.lastSyncAt, 0, "and has never synced")
end
do
local dirty = SyncState.sanitize({
enabled = "yes",
account = "aa11bb22cc33dd44",
deviceToken = "tok",
deviceLabel = "",
lastSyncAt = 0 / 0,
code1 = "12345678",
code2 = "87654321",
revs = { ["red/aaa"] = 4, [7] = 9, ["red/bad"] = "no" },
stamps = { ["red/aaa"] = 1700 },
pendingConflicts = { { key = "red/aaa", version = "red", overlap = true },
{ nope = true } },
})
T.eq(dirty.enabled, false, "a non-boolean enabled reads as off")
T.eq(dirty.account, "aa11bb22cc33dd44", "the account id survives")
T.eq(dirty.deviceLabel, nil, "an empty device label is dropped")
T.eq(dirty.lastSyncAt, 0, "a NaN lastSyncAt is refused")
T.eq(dirty.code1, nil, "the first account code is never kept")
T.eq(dirty.code2, nil, "nor the second")
T.eq(dirty.revs["red/aaa"], 4, "numeric revs survive")
T.eq(dirty.revs["red/bad"], nil, "a non-numeric rev is dropped")
T.eq(dirty.revs[7], nil, "a non-string rev key is dropped")
T.eq(dirty.stamps["red/aaa"], 1700, "the savedAt stamp survives")
T.eq(#dirty.pendingConflicts, 1, "only well-formed conflicts are kept")
T.eq(dirty.pendingConflicts[1].overlap, true, "with their overlap flag")
end
do
local files = fresh()
local state = SyncState.load()
T.eq(SyncState.linked(state), false, "a first boot has no linked account")
state.account = "aa11bb22cc33dd44"
state.deviceToken = "feedface"
state.deviceId = "0a1b2c3d"
state.deviceLabel = "laptop"
state.enabled = true
state.code1 = "12345678"
SyncState.setRev(state, SyncState.key("red", "abc"), 3, 1700000000)
SyncState.save(state)
T.check(files["options.lua"] ~= nil, "the state lands in options.lua")
T.eq(files["options.lua"]:find("12345678", 1, true), nil,
"the account codes are never written to disk")
local back = SyncState.load()
T.eq(SyncState.linked(back), true, "the linked account survives a reload")
T.eq(back.deviceLabel, "laptop", "and the device label")
T.eq(back.deviceId, "0a1b2c3d",
"and the device id the server revokes tokens by")
T.eq(SyncState.rev(back, "red/abc"), 3, "and the last synced rev")
T.eq(SyncState.stamp(back, "red/abc"), 1700000000, "and the savedAt stamp")
T.eq(back.code1, nil, "the code is gone from the reloaded state")
local opts = SaveData.loadOptions()
T.eq(opts.textSpeed, 3, "writing sync state leaves other options alone")
SyncState.forget(back, "red/abc")
T.eq(SyncState.rev(back, "red/abc"), nil, "forget drops the rev")
T.eq(SyncState.stamp(back, "red/abc"), nil, "and the stamp")
SyncState.clear()
T.eq(SyncState.linked(SyncState.load()), false, "clear unlinks the device")
end
do
T.eq(SyncState.key("red", "abc"), "red/abc", "keys join version and id")
T.eq(SyncState.key("red", ""), nil, "an empty playthrough id has no key")
T.eq(SyncState.key(nil, "abc"), nil, "and neither does a missing version")
local version, id = SyncState.splitKey("gold/deadbeef")
T.eq(version, "gold", "splitKey reads the version back")
T.eq(id, "deadbeef", "and the playthrough id")
end
love.filesystem = realFS
T.finish("sync_state")
+199
View File
@@ -0,0 +1,199 @@
-- RetroArch dpad_area / abxy_area descs (#1533): one hitbox whose fired
-- input is resolved by the angle of the touch from the area centre, and
-- range_mod growing a hitbox only while it is held. The cfg below is the
-- reporter's GBA skin, trimmed to the d-pad and face buttons.
-- luajit tests/engine/touch_skin_dpad_area.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local T = require("tests.harness")
local check, eq = T.check, T.eq
local TouchSkin = require("src.core.TouchSkin")
local TouchControls = require("src.core.TouchControls")
local Input = require("src.core.Input")
local CFG = [[
overlays = 2
overlay0_name = "portrait"
overlay0_full_screen = true
overlay0_normalized = true
overlay0_range_mod = 1.5
overlay0_alpha_mod = 1
overlay0_aspect_ratio = 0.45
overlay0_descs = 13
overlay0_desc0 = "select,0.41944,0.58793,radial,0.06667,0.03"
overlay0_desc0_overlay = p-btn-select.png
overlay0_desc0_reach_x = 1.25
overlay0_desc0_reach_y = 1.25
overlay0_desc1 = "start,0.58056,0.58793,radial,0.06667,0.03"
overlay0_desc1_overlay = p-btn-start.png
overlay0_desc1_reach_x = 1.25
overlay0_desc1_reach_y = 1.25
overlay0_desc2 = "up,0.25,0.6625,radial,0.07778,0.035"
overlay0_desc2_overlay = p-btn-dpad-up.png
overlay0_desc2_reach_x = 0
overlay0_desc3 = "left,0.12222,0.72,radial,0.07778,0.035"
overlay0_desc3_overlay = p-btn-dpad-left.png
overlay0_desc3_reach_x = 0
overlay0_desc4 = "right,0.37778,0.72,radial,0.07778,0.035"
overlay0_desc4_overlay = p-btn-dpad-right.png
overlay0_desc4_reach_x = 0
overlay0_desc5 = "down,0.25,0.7775,radial,0.07778,0.035"
overlay0_desc5_overlay = p-btn-dpad-down.png
overlay0_desc5_reach_x = 0
overlay0_desc6 = "up|left,0.11644,0.6599,radial,0.01667,0.0075"
overlay0_desc6_overlay = p-btn-corner.png
overlay0_desc6_reach_x = 0
overlay0_desc7 = "up|right,0.38356,0.6599,radial,0.01667,0.0075"
overlay0_desc7_overlay = p-btn-corner.png
overlay0_desc7_reach_x = 0
overlay0_desc8 = "down|left,0.11644,0.7801,radial,0.01667,0.0075"
overlay0_desc8_overlay = p-btn-corner.png
overlay0_desc8_reach_x = 0
overlay0_desc9 = "down|right,0.38356,0.7801,radial,0.01667,0.0075"
overlay0_desc9_overlay = p-btn-corner.png
overlay0_desc9_reach_x = 0
overlay0_desc10 = "dpad_area,0.25,0.72,radial,0.22778,0.1025"
overlay0_desc10_overlay = p-area-dpad.png
overlay0_desc10_reach_x = 1.25
overlay0_desc10_reach_y = 1.25
overlay0_desc11 = "a,0.84382,0.69563,radial,0.09722,0.04375"
overlay0_desc11_overlay = p-btn-act2-a.png
overlay0_desc11_reach_x = 1.25
overlay0_desc11_reach_y = 1.25
overlay0_desc12 = "b,0.65618,0.74438,radial,0.09722,0.04375"
overlay0_desc12_overlay = p-btn-act2-b.png
overlay0_desc12_reach_x = 1.25
overlay0_desc12_reach_y = 1.25
overlay1_name = "areas"
overlay1_full_screen = true
overlay1_normalized = true
overlay1_descs = 2
overlay1_desc0 = "dpad_area,0.25,0.5,rect,0.2,0.2"
overlay1_desc0_up = "start"
overlay1_desc0_down = "select"
overlay1_desc0_left = "nul"
overlay1_desc1 = "abxy_area,0.75,0.5,rect,0.2,0.2"
]]
local skin = assert(TouchSkin.parse(CFG))
local page = skin.pages[1]
eq(#page.controls, 12 + 1 + 8, "the dpad_area expands into eight sector controls")
local sectors = {}
for _, ctl in ipairs(page.controls) do
if ctl.sector then sectors[ctl.sector] = ctl end
end
eq(#sectors, 8, "eight sectors, one per direction")
eq(sectors[1].spec, "right", "sector 1 is right")
eq(sectors[2].spec, "right|down", "sector 2 is the down-right diagonal")
eq(sectors[3].spec, "down", "sector 3 is down, y growing downwards")
eq(sectors[7].spec, "up", "sector 7 is up")
eq(sectors[1].x, 0.25, "every sector keeps the area centre")
eq(sectors[5].y, 0.72, "on both axes")
eq(sectors[3].rangeX, 0.22778, "and the whole area range")
eq(sectors[3].shape, "radial", "and the declared hitbox shape")
local art = page.controls[11]
eq(art.imagePath, "p-area-dpad.png", "the area art rides a decorative desc")
check(art.decorative, "which presses nothing")
TouchControls:init()
TouchControls.active = true
TouchControls.enabled = true
TouchSkin.setOverlayLive(true)
TouchSkin.setActive(skin)
Input:init()
local W, H = 720, 1600
TouchSkin.setSurface(0, 0, W, H)
eq(TouchSkin.page().name, "portrait", "the portrait page is live at 720x1600")
local BUTTONS = { "up", "down", "left", "right", "a", "b", "start", "select" }
local function heldNow()
local out = {}
for _, btn in ipairs(BUTTONS) do
if Input:isDown(btn) then out[#out + 1] = btn end
end
return table.concat(out, "+")
end
local function press(nx, ny)
TouchControls:touchpressed("f1", nx * W, ny * H)
local got = heldNow()
TouchControls:touchreleased("f1", nx * W, ny * H)
return got
end
local function fires(nx, ny, want, why)
eq(press(nx, ny), want, why)
end
fires(0.25, 0.6625, "up", "the d-pad up arrow fires up alone")
fires(0.12222, 0.72, "left", "the left arrow fires left alone")
fires(0.37778, 0.72, "right", "the right arrow fires right alone")
fires(0.25, 0.7775, "down", "the down arrow fires down alone")
fires(0.11644, 0.6599, "up+left", "the up-left corner fires both, and only both")
fires(0.38356, 0.7801, "down+right", "as does the down-right corner")
fires(0.32, 0.77, "down+right",
"a spot inside the area but off every arrow resolves by angle: "
.. "(50.4, 80) pixels out is 57.8 degrees, the down-right sector")
fires(0.84382, 0.69563, "a", "A fires alone")
fires(0.65618, 0.74438, "b", "B fires alone: the 1.5x range_mod does not grow the resting d-pad area over it")
fires(0.58056, 0.58793, "start", "START fires alone")
fires(0.41944, 0.58793, "select", "SELECT fires alone, with no phantom direction")
fires(0.5, 0.3, "", "the screen area presses nothing")
TouchControls:touchpressed("f2", 0.25 * W, 0.6625 * H)
eq(heldNow(), "up", "slide starts on up")
TouchControls:touchmoved("f2", 0.37778 * W, 0.72 * H)
eq(heldNow(), "right", "sliding across the area swaps direction")
TouchControls:touchmoved("f2", 0.38356 * W, 0.7801 * H)
eq(heldNow(), "down+right", "and picks up the diagonal")
TouchControls:touchmoved("f2", 0.5 * W, 0.3 * H)
eq(heldNow(), "", "sliding out of the area releases it")
TouchControls:touchreleased("f2", 0.5 * W, 0.3 * H)
local area = sectors[1]
local bx = 0.65618 * W
local by = 0.74438 * H
check(not TouchSkin.hits(page, area, W, H, bx, by, 0, 0, false),
"at rest the area hitbox stops short of B")
check(TouchSkin.hits(page, area, W, H, bx, by, 0, 0, true),
"a held area grows over B so the finger keeps its direction")
TouchControls:touchpressed("f3", 0.37778 * W, 0.72 * H)
TouchControls:touchmoved("f3", bx, by)
eq(heldNow(), "right+b", "sliding from the held area onto B keeps right held")
TouchControls:touchreleased("f3", bx, by)
eq(heldNow(), "", "and lifting clears both")
TouchSkin.autoOrient = false
TouchSkin.setPage("areas")
eq(TouchSkin.page().name, "areas", "second page is live")
W, H = 1000, 1000
TouchSkin.setSurface(0, 0, W, H)
fires(0.25, 0.35, "start", "_up rebinds the up sector of a dpad_area")
fires(0.25, 0.65, "select", "_down rebinds the down sector")
fires(0.12, 0.5, "", "_left = nul makes that sector inert")
fires(0.38, 0.5, "right", "an unset side keeps the d-pad default")
fires(0.88, 0.5, "a", "abxy_area right is GB A")
fires(0.75, 0.62, "b", "abxy_area down is GB B")
fires(0.88, 0.62, "a+b", "the down-right sector fires both")
fires(0.75, 0.38, "", "RetroPad X has no GB button, so up is inert")
fires(0.94, 0.68, "a+b", "a rect area still hits inside its corner")
fires(0.75, 0.75, "", "and nothing past its edge")
TouchSkin.setSurface(nil)
TouchSkin.setActive(nil)
TouchSkin.autoOrient = true
T.finish("touch_skin_dpad_area")