Files
gen1recomp/tests/engine/skin_studio_test.lua
T
2026-08-17 06:47:33 -04:00

262 lines
9.9 KiB
Lua

-- Skin Studio model layer (#1386): canvas presets, control editing, the
-- drag/resize math and the pixel coordinate fields. Drawing is exercised by
-- tests/drivers/skin_studio_shot.lua; this pins the state machine underneath.
-- luajit tests/engine/skin_studio_test.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 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
-- a studio session without touching options or the filesystem
local function session()
Studio.skin = TouchSkin.newSkin("t")
Studio.pageIndex = 1
Studio.selected = nil
Studio.canvasIndex = 1
Studio.aspectLock = true
Studio.drag = nil
Studio.dirty = false
Studio.images = {}
return Studio.skin
end
-- ------------------------------------------------------------- new skin
session()
eq(#Studio.skin.pages, 1, "a new skin starts with one page")
eq(#Studio.page().controls, 0, "and no controls")
check(Studio.page().viewport ~= nil, "and a screen cutout to place")
check(Studio.selectedControl() == nil, "nothing selected yet")
-- ------------------------------------------------------------- controls
Studio.addControl()
eq(#Studio.page().controls, 1, "addControl appends")
eq(Studio.selected, 1, "and selects what it added")
check(Studio.dirty, "editing marks the skin dirty")
local ctl = Studio.selectedControl()
eq(ctl.spec, "a", "new controls default to GB a")
eq(ctl.buttons[1], "a", "and carry the parsed bind")
Studio.cycleBind(1)
check(ctl.spec ~= "a", "cycleBind moves off the current bind")
check(#ctl.buttons + #ctl.hotkeys + #ctl.keys > 0 or ctl.decorative,
"cycleBind reparses the bind")
Studio.cycleBind(-1)
eq(ctl.spec, "a", "cycleBind is reversible")
Studio.duplicateControl()
eq(#Studio.page().controls, 2, "duplicate adds a second control")
eq(Studio.selected, 2, "and selects the copy")
check(Studio.page().controls[2] ~= Studio.page().controls[1],
"the copy is a separate table")
near(Studio.page().controls[2].x, Studio.page().controls[1].x + 0.04, 1e-6,
"the copy is offset so it is visible")
Studio.deleteControl()
eq(#Studio.page().controls, 1, "delete removes it")
Studio.deleteControl()
eq(#Studio.page().controls, 0, "delete works down to empty")
eq(Studio.selected, nil, "and clears the selection")
-- ------------------------------------------------------- canvas presets
local ids = {}
for _, c in ipairs(Studio.CANVASES) do
check(c.w > 0 and c.h > 0, c.id .. " has real pixel dimensions")
check(not ids[c.id], c.id .. " appears once")
ids[c.id] = true
end
check(ids.phone_portrait, "a phone portrait preset exists")
check(ids.desktop_1080, "a desktop preset exists")
check(ids.ultrawide, "an ultrawide preset exists")
check(ids.sgb_border, "a Super Game Boy border preset exists")
session()
local sgbIndex
for i, c in ipairs(Studio.CANVASES) do
if c.id == "sgb_border" then sgbIndex = i end
end
Studio.setCanvas(sgbIndex)
local canvas = Studio.canvas()
eq(canvas.w, 256, "SGB canvas is 256 wide")
eq(canvas.h, 224, "SGB canvas is 224 tall")
local vp = Studio.page().viewport
near(vp.x * 256, 48, 1e-6, "SGB screen sits 48px from the left")
near(vp.y * 224, 40, 1e-6, "SGB screen sits 40px from the top")
near(vp.w * 256, 160, 1e-6, "SGB screen is 160px wide")
near(vp.h * 224, 144, 1e-6, "SGB screen is 144px tall")
-- the locked preset refuses to let the cutout be moved or removed
Studio.toggleViewport()
check(Studio.page().viewport ~= nil, "the SGB cutout cannot be toggled off")
-- presets wrap rather than running off the end of the list
Studio.setCanvas(#Studio.CANVASES + 1)
eq(Studio.canvasIndex, 1, "canvas selection wraps")
-- ------------------------------------------------------------ canvas fit
Studio.setCanvas(1)
local cx, cy, cw, ch = Studio.canvasRect(0, 0, 800, 600)
near(cw / ch, Studio.canvas().w / Studio.canvas().h, 1e-6,
"the mock device keeps its aspect")
check(cw <= 800 + 1e-6 and ch <= 600 + 1e-6, "and fits inside the workspace")
near(cx + cw / 2, 400, 1e-6, "centred horizontally")
near(cy + ch / 2, 300, 1e-6, "centred vertically")
-- ---------------------------------------------------------------- drag
session()
Studio.addControl()
ctl = Studio.selectedControl()
local r = { x = 0, y = 0, w = 1000, h = 1000 }
local startX, startY = ctl.x, ctl.y
Studio.drag = { kind = "control-move", mx = 500, my = 500,
bx = 420, by = 455, bw = 160, bh = 90 }
Studio.updateDrag(600, 500, r)
near(ctl.x, startX + 0.1, 1e-6, "dragging right moves the control right")
near(ctl.y, startY, 1e-6, "and leaves y alone")
Studio.drag = { kind = "control-resize", handle = "se", mx = 500, my = 500,
bx = 400, by = 400, bw = 100, bh = 100 }
Studio.updateDrag(600, 600, r)
near(ctl.rangeX * 2 * r.w, 200, 1e-6, "the se handle widens the control")
near(ctl.rangeY * 2 * r.h, 200, 1e-6, "and heightens it")
-- the viewport keeps the Game Boy's 10:9 while the lock is on
Studio.aspectLock = true
Studio.selected = nil
Studio.drag = { kind = "viewport-resize", handle = "se", mx = 0, my = 0,
bx = 0, by = 0, bw = 400, bh = 400 }
Studio.updateDrag(100, 0, r)
local v = Studio.page().viewport
near(v.w / v.h, 160 / 144, 1e-6, "10:9 lock holds the screen aspect")
Studio.aspectLock = false
Studio.drag = { kind = "viewport-resize", handle = "se", mx = 0, my = 0,
bx = 0, by = 0, bw = 400, bh = 400 }
Studio.updateDrag(100, 200, r)
v = Studio.page().viewport
near(v.w * r.w, 500, 1e-6, "unlocked, width follows the pointer")
near(v.h * r.h, 600, 1e-6, "and height is free")
-- controls never escape the canvas
Studio.selected = 1
Studio.drag = { kind = "control-move", mx = 0, my = 0,
bx = 0, by = 0, bw = 100, bh = 100 }
Studio.updateDrag(-100000, -100000, r)
check(ctl.x >= 0 and ctl.y >= 0, "a control cannot be dragged off the top-left")
Studio.updateDrag(100000, 100000, r)
check(ctl.x <= 1 and ctl.y <= 1, "or off the bottom-right")
-- ------------------------------------------------------- pixel fields
session()
Studio.addControl()
ctl = Studio.selectedControl()
canvas = Studio.canvas()
eq(canvas.w, 1080, "phone portrait is 1080 wide")
Studio.commitField("numW", "216")
near(ctl.rangeX * 2 * canvas.w, 216, 1e-6, "W is typed in canvas pixels")
Studio.commitField("numX", "100")
near((ctl.x - ctl.rangeX) * canvas.w, 100, 1e-4, "X is the left edge in pixels")
Studio.commitField("numH", "180")
near(ctl.rangeY * 2 * canvas.h, 180, 1e-6, "H is typed in canvas pixels")
Studio.commitField("numY", "640")
near((ctl.y - ctl.rangeY) * canvas.h, 640, 1e-4, "Y is the top edge in pixels")
-- X must not have drifted when the later fields were set
near((ctl.x - ctl.rangeX) * canvas.w, 100, 1e-4, "X survives edits to the others")
local keptX = ctl.x
Studio.commitField("numX", "not a number")
near(ctl.x, keptX, 1e-9, "garbage in a field is ignored")
-- ---------------------------------------------------------------- pages
session()
Studio.addPage()
eq(#Studio.skin.pages, 2, "addPage appends a page")
eq(Studio.pageIndex, 2, "and switches to it")
eq(Studio.page().name, "page2", "the new page is named in sequence")
Studio.addControl()
eq(#Studio.skin.pages[2].controls, 1, "controls land on the active page")
eq(#Studio.skin.pages[1].controls, 0, "and not on the other one")
-- --------------------------------------------------------------- clone
local source = TouchSkin.load("assets/skins/gb_anim", "gb_anim")
check(source ~= nil, "bundled skin loads for cloning")
if source then
local copy = TouchSkin.clone(source)
eq(#copy.pages, #source.pages, "clone keeps every page")
eq(#copy.pages[1].controls, #source.pages[1].controls, "and every control")
check(copy.pages[1] ~= source.pages[1], "pages are fresh tables")
check(copy.pages[1].controls[1] ~= source.pages[1].controls[1],
"controls are fresh tables")
eq(copy.pages[1].image, source.pages[1].image, "loaded images are shared, not reloaded")
copy.pages[1].controls[1].x = 0.123
check(source.pages[1].controls[1].x ~= 0.123,
"editing the clone does not touch the loaded skin")
end
-- ------------------------------------------------------------ play handoff
-- Play used to call the host handler straight out of the Kit button, which
-- unloaded the studio inside its own draw pass; the rest of that frame then
-- indexed a nil skin and took the app down. The handoff is deferred to
-- update, and a torn-down studio has to survive a draw either way.
session()
Studio.available, Studio.images = {}, {}
Studio.skinIdField, Studio.onClose = "t", function() end
local fired, gotVersion = 0, nil
Studio.version = "red"
Studio.onPlay = function(v)
fired = fired + 1
gotVersion = v
Studio.unload()
end
Studio.play()
check(Studio.pendingPlay, "play queues the handoff")
eq(fired, 0, "play does not hand off during the click")
check(Studio.skin ~= nil, "and leaves the skin alive for the rest of the frame")
check(pcall(Studio.draw), "the frame that queued play still draws")
Studio.update()
eq(fired, 1, "update performs the handoff")
eq(gotVersion, "red", "and passes the launcher tab through")
eq(Studio.skin, nil, "the studio is unloaded by then")
check(pcall(Studio.draw), "a torn-down studio still survives a draw")
Studio.update()
eq(fired, 1, "the handoff does not repeat")
-- unload must clear a queued handoff, or closing then reopening would boot
session()
Studio.onPlay = function() fired = fired + 1 end
Studio.play()
check(Studio.pendingPlay, "queued again")
Studio.unload()
check(not Studio.pendingPlay, "unload drops a queued handoff")
Studio.update()
eq(fired, 1, "closing the studio does not start the game")
-- the studio is a desktop workspace; the launcher only offers it there
check(type(Studio.available_desktop) == "function", "desktop gate is exported")
check(Studio.available_desktop(), "headless/desktop reports available")
T.finish("skin_studio")