mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-17 19:24:01 +02:00
262 lines
9.9 KiB
Lua
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")
|