mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-19 20:20:19 +02:00
skin studio updates, save sync CLOSES #1533
This commit is contained in:
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user