mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-19 04:06:10 +02:00
api upgrades, windows dev tools, and android fixes (#299)
* pop up a fake save * add CI to dev branch * Make ROM-free test tiers actually run on Windows (#267) Suite discovery, the extension-point catalog scan, mod test-dir pickup, and the meta-coverage corpus all shelled out to ls/find/test -d, which do not exist in cmd.exe. Every listing came back empty on Windows, so tiers ran 0 suites and still reported ALL TESTS PASSED. Add portable probes to tests/fs_io.lua (same Unix commands on Linux/macOS; dir /b and a shell-free rename-self existence check on Windows) and rewire the four call sites to them. 15/15 engine suites and 2/2 modkit suites now genuinely run and pass on Windows. Fixes #266 Co-authored-by: johnjohto <johnjohto@users.noreply.github.com> * intro api upgrade (#294) * intro api upgrade * api updates * fix tests * updated templates * android fixes for mods and saves (#297) * Android SCALING fixes (#298) * android fixes for mods and saves * perhaps this is the true scaling android issue fix --------- Co-authored-by: johnjohto <johtoboy@atomicmail.io> Co-authored-by: johnjohto <johnjohto@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
-- Driver: drops a fake 3-mon party into the overworld and then gets out
|
||||
-- of the way -- yields forever without ever touching input itself, so
|
||||
-- real keyboard/controller play works normally from here on. This is
|
||||
-- just a quick way to pop up a playable window with Pokemon already in
|
||||
-- the party, not a scripted playthrough.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
|
||||
game.save.party = {
|
||||
Pokemon.new(game.data, "CHARIZARD", 50),
|
||||
Pokemon.new(game.data, "PIKACHU", 30),
|
||||
Pokemon.new(game.data, "SNORLAX", 77),
|
||||
}
|
||||
game.save.player.name = "bryan"
|
||||
|
||||
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
|
||||
U.log("popup_fake_save: party ready, handing off to real input")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,243 @@
|
||||
-- Driver: NEW GAME through the silly Oak intro (example_silly_oak must be
|
||||
-- installed under mods/). Mashes text, picks every menu option in a fixed
|
||||
-- pattern, screenshots key beats, then asserts mod.save answers.
|
||||
--
|
||||
-- Setup:
|
||||
-- cp -r mods/examples/example_silly_oak mods/
|
||||
-- SHOT_DIR=/tmp/silly_oak POKEPORT_DRIVER=tests/drivers/silly_oak_intro_test.lua love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
local function top() return game.stack:top() end
|
||||
local function speechState()
|
||||
for _, s in ipairs(game.stack.states or {}) do
|
||||
if s.oakPic ~= nil or (s.steps and s.answers) then return s end
|
||||
end
|
||||
end
|
||||
local function topName()
|
||||
local s = top()
|
||||
if not s then return "?" end
|
||||
return s.screenId or (s.__index and tostring(s.__index)) or tostring(s)
|
||||
end
|
||||
local function isMenu(s)
|
||||
return s and s.items and s.index and s.tx ~= nil
|
||||
end
|
||||
local function isNaming(s)
|
||||
return s and s.glyphs ~= nil and s.title ~= nil
|
||||
end
|
||||
local function isChoiceBox(s)
|
||||
return s and s.onChoose and s.index ~= nil and not s.items
|
||||
end
|
||||
|
||||
-- confirm the mod actually loaded
|
||||
local loaded = false
|
||||
if game.modStatus and game.modStatus.loaded then
|
||||
for _, m in ipairs(game.modStatus.loaded) do
|
||||
if m.id == "example_silly_oak" then loaded = true break end
|
||||
end
|
||||
end
|
||||
if not loaded and game.mods and game.mods.mods then
|
||||
local m = game.mods.mods.example_silly_oak
|
||||
loaded = m and m.enabled and not m.failed
|
||||
end
|
||||
U.log("example_silly_oak loaded:", tostring(loaded))
|
||||
if not loaded then
|
||||
U.log("FAIL: copy the mod up first:")
|
||||
U.log(" cp -r mods/examples/example_silly_oak mods/")
|
||||
return
|
||||
end
|
||||
|
||||
U.wait(5)
|
||||
U.tap(game, "start") -- skip intro movie
|
||||
U.wait(15)
|
||||
U.tap(game, "a") -- title -> menu
|
||||
U.wait(8)
|
||||
-- with an existing save the menu is CONTINUE / NEW GAME / OPTION;
|
||||
-- always land on NEW GAME (row 2 when a save exists, else row 1)
|
||||
local ok, saved = pcall(function()
|
||||
return require("src.core.SaveData").load() ~= nil
|
||||
end)
|
||||
if ok and saved then
|
||||
U.tap(game, "down")
|
||||
U.wait(5)
|
||||
end
|
||||
U.tap(game, "a") -- NEW GAME
|
||||
U.wait(20)
|
||||
|
||||
-- confirm we actually entered OakSpeech, not CONTINUE into the world
|
||||
local entered = false
|
||||
for _ = 1, 60 do
|
||||
if speechState() then entered = true break end
|
||||
if game.overworld and top() == game.overworld then break end
|
||||
U.wait(1)
|
||||
end
|
||||
if not entered then
|
||||
U.log("FAIL: never entered OakSpeech (did we hit CONTINUE?)")
|
||||
U.log("top:", topName())
|
||||
return
|
||||
end
|
||||
|
||||
U.shot(game, DIR .. "/silly_01_start.png")
|
||||
|
||||
-- walk the speech: A advances text; menus get a deliberate pick pattern
|
||||
local picks = {
|
||||
-- silly_toast YES/NO: pick YES (index 1)
|
||||
toast = 1,
|
||||
-- naming player: take preset 1 (RED) via NEW NAME menu -> first preset
|
||||
-- snack: OLD ROD (index 3)
|
||||
snack = 3,
|
||||
-- trusts rival: NO (index 2)
|
||||
trust = 2,
|
||||
-- pineapple YES/NO: pick NO
|
||||
pineapple = 2,
|
||||
}
|
||||
local saw = {
|
||||
toast_kid = false, mew = false, snack = false, trust = false,
|
||||
pineapple = false, shrink = false,
|
||||
}
|
||||
local snackPicked, trustPicked = false, false
|
||||
local toastAnswered, pineappleAnswered = false, false
|
||||
local shot = {}
|
||||
|
||||
for _ = 1, 900 do
|
||||
local speech = speechState()
|
||||
local s = top()
|
||||
|
||||
if speech and speech.shrink then
|
||||
saw.shrink = true
|
||||
if not shot.shrink then
|
||||
shot.shrink = true
|
||||
U.shot(game, DIR .. "/silly_05_shrink.png")
|
||||
end
|
||||
break
|
||||
end
|
||||
|
||||
-- track which injected beats we hit + screenshot once each
|
||||
if speech and speech.steps and speech.step then
|
||||
local cur = speech.steps[speech.step]
|
||||
if cur then
|
||||
if cur.id == "silly_toast_kid" then
|
||||
saw.toast_kid = true
|
||||
if not shot.toast_kid then
|
||||
shot.toast_kid = true
|
||||
U.shot(game, DIR .. "/silly_04_toast_kid.png")
|
||||
end
|
||||
end
|
||||
if cur.id == "silly_mew" then
|
||||
saw.mew = true
|
||||
if not shot.mew then
|
||||
shot.mew = true
|
||||
U.shot(game, DIR .. "/silly_04b_mew.png")
|
||||
end
|
||||
end
|
||||
if cur.id == "silly_snack" then saw.snack = true end
|
||||
if cur.id == "silly_trust" then saw.trust = true end
|
||||
if cur.id == "silly_pineapple" then saw.pineapple = true end
|
||||
end
|
||||
end
|
||||
|
||||
if isChoiceBox(s) then
|
||||
-- YES/NO: move to desired row then A
|
||||
local want
|
||||
local cur = speech and speech.steps and speech.steps[speech.step]
|
||||
if cur and cur.id == "silly_toast" then
|
||||
want = picks.toast
|
||||
toastAnswered = true
|
||||
elseif cur and cur.id == "silly_pineapple" then
|
||||
want = picks.pineapple
|
||||
pineappleAnswered = true
|
||||
else
|
||||
want = 1
|
||||
end
|
||||
if s.index ~= want then
|
||||
U.tap(game, "down")
|
||||
U.wait(2)
|
||||
end
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
elseif isMenu(s) and not isNaming(s) then
|
||||
-- multi choice or naming presets
|
||||
local cur = speech and speech.steps and speech.steps[speech.step]
|
||||
local want = 1
|
||||
if cur and cur.id == "silly_snack" then
|
||||
want = picks.snack
|
||||
snackPicked = true
|
||||
if not shot.snack then
|
||||
shot.snack = true
|
||||
U.shot(game, DIR .. "/silly_02_snack.png")
|
||||
end
|
||||
elseif cur and cur.id == "silly_trust" then
|
||||
want = picks.trust
|
||||
trustPicked = true
|
||||
if not shot.trust then
|
||||
shot.trust = true
|
||||
U.shot(game, DIR .. "/silly_03_trust.png")
|
||||
end
|
||||
elseif s.items and s.items[1] and s.items[1].label == "NEW NAME" then
|
||||
-- naming preset menu: pick first preset (row 2)
|
||||
want = 2
|
||||
end
|
||||
while s.index < want do
|
||||
U.tap(game, "down")
|
||||
U.wait(2)
|
||||
s = top()
|
||||
if not isMenu(s) then break end
|
||||
end
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
else
|
||||
U.tap(game, "a")
|
||||
U.wait(2)
|
||||
end
|
||||
|
||||
if game.overworld and top() == game.overworld then break end
|
||||
end
|
||||
|
||||
-- finish shrink + land in overworld
|
||||
for _ = 1, 150 do
|
||||
if game.overworld and top() == game.overworld then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(10)
|
||||
U.shot(game, DIR .. "/silly_06_overworld.png")
|
||||
|
||||
local bucket = (game.save and game.save.modData
|
||||
and game.save.modData.example_silly_oak)
|
||||
or (game.mods and game.mods.modSave
|
||||
and game.mods.modSave.example_silly_oak)
|
||||
or {}
|
||||
|
||||
U.log("saw toast_kid:", tostring(saw.toast_kid),
|
||||
"mew:", tostring(saw.mew),
|
||||
"snack:", tostring(saw.snack),
|
||||
"trust:", tostring(saw.trust),
|
||||
"pineapple:", tostring(saw.pineapple),
|
||||
"shrink:", tostring(saw.shrink))
|
||||
U.log("answers:",
|
||||
"likes_toast=", tostring(bucket.likes_toast),
|
||||
"snack=", tostring(bucket.snack),
|
||||
"trusts_rival=", tostring(bucket.trusts_rival),
|
||||
"pineapple=", tostring(bucket.pineapple_on_pizza),
|
||||
"quiz_done=", tostring(bucket.quiz_done))
|
||||
U.log("player=", game.save and game.save.player and game.save.player.name,
|
||||
"rival=", game.save and game.save.player and game.save.player.rival)
|
||||
U.log("top:", topName(),
|
||||
"map:", game.overworld and game.overworld.map
|
||||
and game.overworld.map.id or "?")
|
||||
|
||||
local function need(cond, msg)
|
||||
if not cond then U.log("FAIL:", msg) else U.log("ok:", msg) end
|
||||
end
|
||||
need(saw.toast_kid, "custom Toast Kid sprite beat ran")
|
||||
need(saw.mew, "MEW sprite beat ran")
|
||||
need(saw.snack or snackPicked, "snack choice ran")
|
||||
need(saw.trust or trustPicked, "rival trust choice ran")
|
||||
need(saw.pineapple or pineappleAnswered, "pineapple yes/no ran")
|
||||
need(bucket.likes_toast == true, "likes_toast saved true")
|
||||
need(bucket.snack == "OLD ROD", "snack saved OLD ROD")
|
||||
need(bucket.trusts_rival == false, "trusts_rival saved false")
|
||||
need(bucket.pineapple_on_pizza == false, "pineapple saved false")
|
||||
need(bucket.quiz_done == true, "quiz_done set")
|
||||
need(game.overworld and top() == game.overworld, "landed in overworld")
|
||||
end
|
||||
@@ -63,13 +63,17 @@ end
|
||||
-- engine tier, the SDK cases, and any tests a shipped mod carries
|
||||
local function testCorpus()
|
||||
local files, bodies = {}, {}
|
||||
local pipe = io.popen(
|
||||
"ls tests/*.lua tests/engine/*.lua tests/modkit/cases/*.lua mods/*/tests/*.lua 2>/dev/null")
|
||||
if pipe then
|
||||
for line in pipe:lines() do
|
||||
if line ~= "" then files[#files + 1] = line end
|
||||
local FsIo = require("tests.fs_io")
|
||||
local function addLuaFrom(dir)
|
||||
for _, name in ipairs(FsIo.listDir(dir)) do
|
||||
if name:match("%.lua$") then files[#files + 1] = dir .. "/" .. name end
|
||||
end
|
||||
pipe:close()
|
||||
end
|
||||
addLuaFrom("tests")
|
||||
addLuaFrom("tests/engine")
|
||||
addLuaFrom("tests/modkit/cases")
|
||||
for _, mod in ipairs(FsIo.listDir("mods")) do
|
||||
if not mod:find(".", 1, true) then addLuaFrom("mods/" .. mod .. "/tests") end
|
||||
end
|
||||
for _, path in ipairs(files) do
|
||||
bodies[path] = slurp(path) or ""
|
||||
|
||||
@@ -190,4 +190,22 @@ do
|
||||
check(err ~= nil, "the no-root case carries a reason")
|
||||
end
|
||||
|
||||
-- ------- uninstall: rejects bad ids without needing a real mods tree
|
||||
|
||||
do
|
||||
local ok, err = LauncherMods.uninstall("")
|
||||
eq(ok, nil, "empty id is rejected")
|
||||
check(tostring(err):find("missing", 1, true) ~= nil, "empty-id reason")
|
||||
|
||||
ok, err = LauncherMods.uninstall("../escape")
|
||||
eq(ok, nil, "path-like ids are rejected")
|
||||
check(tostring(err):find("invalid", 1, true) ~= nil, "path-id reason")
|
||||
|
||||
ok, err = LauncherMods.uninstall("ghost")
|
||||
-- Without a mods/ghost tree (and with the love stub's getInfo), uninstall
|
||||
-- either needs LOVE or reports not installed -- never silently succeeds.
|
||||
eq(ok, nil, "a missing mod does not uninstall")
|
||||
check(err ~= nil, "missing-mod uninstall carries a reason")
|
||||
end
|
||||
|
||||
T.finish("launcher_mods")
|
||||
|
||||
@@ -165,6 +165,41 @@ do
|
||||
T.eq(SaveData.createSlot("red"), "slot3", "ids increment past the highest")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------- deleteSlot
|
||||
|
||||
do
|
||||
local files = fresh()
|
||||
local a = SaveData.createSlot("red")
|
||||
local b = SaveData.createSlot("red")
|
||||
SaveData.setActiveSlot("red", b)
|
||||
local save = SaveData.newGame()
|
||||
save.player.name = "KEEP"
|
||||
T.check(SaveData.writeSlot("red", a, save), "seed slot1 with a save")
|
||||
save.player.name = "GONE"
|
||||
T.check(SaveData.writeSlot("red", b, save), "seed slot2 with a save")
|
||||
|
||||
local ok, err = SaveData.deleteSlot("red", b)
|
||||
T.check(ok, "deleteSlot removes the active slot: " .. tostring(err))
|
||||
T.eq(files["saves/red/slot2.lua"], nil, "slot2's file is gone")
|
||||
T.check(files["saves/red/slot1.lua"] ~= nil, "the other slot's file stays")
|
||||
local opts = SaveSerializer.decode(files["options.lua"])
|
||||
T.eq(opts.saveSlots.red.active, a, "active falls back to the remaining slot")
|
||||
T.eq(#opts.saveSlots.red.list, 1, "the deleted id is dropped from the list")
|
||||
T.eq(opts.saveSlots.red.list[1], a, "only slot1 remains registered")
|
||||
|
||||
ok = SaveData.deleteSlot("red", a)
|
||||
T.check(ok, "deleting the last slot succeeds")
|
||||
opts = SaveSerializer.decode(files["options.lua"])
|
||||
T.eq(#opts.saveSlots.red.list, 0, "the registry list is empty")
|
||||
T.eq(opts.saveSlots.red.active, nil, "active clears when no slots remain")
|
||||
T.eq(#SaveData.listSlots("red"), 0, "listSlots reports an empty install")
|
||||
|
||||
local bad, badErr = SaveData.deleteSlot("red", "slot99")
|
||||
T.check(not bad, "deleting an unknown slot fails")
|
||||
T.check(tostring(badErr):find("not registered", 1, true) ~= nil,
|
||||
"unknown-slot error is user-presentable")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------- saveNames follows the slot
|
||||
|
||||
do
|
||||
|
||||
+67
-11
@@ -15,6 +15,71 @@ local function quote(path)
|
||||
return "'" .. tostring(path):gsub("'", "'\\''") .. "'"
|
||||
end
|
||||
|
||||
-- The suites also run on Windows checkouts, where cmd has no ls/find/test.
|
||||
-- These probes pick the spelling the host shell understands; anything that
|
||||
-- listed a directory through a bare Unix command silently returned nothing
|
||||
-- there, and a tier built on an empty listing passes vacuously.
|
||||
FsIo.isWindows = package.config:sub(1, 1) == "\\"
|
||||
|
||||
local function shellLines(cmd)
|
||||
local lines = {}
|
||||
local pipe = io.popen(cmd)
|
||||
if not pipe then return lines end
|
||||
for line in pipe:lines() do
|
||||
if line ~= "" then lines[#lines + 1] = line end
|
||||
end
|
||||
pipe:close()
|
||||
return lines
|
||||
end
|
||||
|
||||
-- names directly inside path (files and directories mixed, like ls -1)
|
||||
function FsIo.listDir(path)
|
||||
local cmd
|
||||
if FsIo.isWindows then
|
||||
cmd = 'dir /b "' .. tostring(path) .. '" 2>nul'
|
||||
else
|
||||
cmd = "ls -1 " .. quote(path) .. " 2>/dev/null"
|
||||
end
|
||||
local items = shellLines(cmd)
|
||||
table.sort(items)
|
||||
return items
|
||||
end
|
||||
|
||||
-- every *.lua under dir, recursively, as forward-slash paths
|
||||
function FsIo.luaFilesUnder(dir)
|
||||
local cmd
|
||||
if FsIo.isWindows then
|
||||
cmd = 'dir /b /s "' .. tostring(dir) .. '\\*.lua" 2>nul'
|
||||
else
|
||||
-- -L follows symlinks: a checkout that symlinks src/ (worktrees, the
|
||||
-- ROM-free CI probe) would otherwise scan nothing and hand every gate
|
||||
-- an empty catalog to pass vacuously against
|
||||
cmd = "find -L " .. quote(dir) .. " -name '*.lua' -type f 2>/dev/null"
|
||||
end
|
||||
local files = {}
|
||||
for _, line in ipairs(shellLines(cmd)) do
|
||||
files[#files + 1] = (line:gsub("\\", "/"))
|
||||
end
|
||||
table.sort(files)
|
||||
return files
|
||||
end
|
||||
|
||||
-- existence probe that never shells out on Windows: directories do not
|
||||
-- open() there at all, and rename-self succeeds for anything that exists
|
||||
function FsIo.isDir(path)
|
||||
local handle = io.open(path, "rb")
|
||||
if handle then
|
||||
local probe = handle:read(1)
|
||||
handle:close()
|
||||
if probe ~= nil then return false end
|
||||
if FsIo.isWindows then return false end -- opened but empty: a file
|
||||
elseif FsIo.isWindows then
|
||||
return os.rename(path, path) == true
|
||||
end
|
||||
local ok = os.execute("test -d " .. quote(path))
|
||||
return ok == true or ok == 0
|
||||
end
|
||||
|
||||
function FsIo.new(rootDir)
|
||||
local base = rootDir or "."
|
||||
|
||||
@@ -50,8 +115,7 @@ function FsIo.new(rootDir)
|
||||
-- a directory opens on some libc builds but reads nothing
|
||||
if probe ~= nil then return { type = "file" } end
|
||||
end
|
||||
local ok = os.execute("test -d " .. quote(abs(path)))
|
||||
if ok == true or ok == 0 then return { type = "directory" } end
|
||||
if FsIo.isDir(abs(path)) then return { type = "directory" } end
|
||||
if handle then return { type = "file" } end
|
||||
return nil
|
||||
end
|
||||
@@ -61,15 +125,7 @@ function FsIo.new(rootDir)
|
||||
end
|
||||
|
||||
function fs.getDirectoryItems(path)
|
||||
local items = {}
|
||||
local pipe = io.popen("ls -1 " .. quote(abs(path)) .. " 2>/dev/null")
|
||||
if not pipe then return items end
|
||||
for line in pipe:lines() do
|
||||
if line ~= "" then items[#items + 1] = line end
|
||||
end
|
||||
pipe:close()
|
||||
table.sort(items)
|
||||
return items
|
||||
return FsIo.listDir(abs(path))
|
||||
end
|
||||
|
||||
fs.root = base
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
-- entry loads clean through the real loader, produces its stated effect,
|
||||
-- and carries the metadata the polish checklist requires.
|
||||
--
|
||||
-- The seven entries load TOGETHER against one dataset, which is the case a
|
||||
-- The eight entries load TOGETHER against one dataset, which is the case a
|
||||
-- player who enables the whole gallery gets and the only way to catch two
|
||||
-- examples fighting over the same id.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
@@ -21,7 +21,7 @@ local GALLERY_ROOT = "mods/examples"
|
||||
local IDS = {
|
||||
"example_balance_tweaks", "example_shiny_palette", "example_jukebox",
|
||||
"example_lost_parcel", "example_weather", "example_dexnav",
|
||||
"example_mini_conversion",
|
||||
"example_mini_conversion", "example_silly_oak",
|
||||
}
|
||||
|
||||
-- the closed vocabulary from 25 3.1; GAMEPLAY is the accepted v1 alias
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
-- Unit coverage for the QoL extension seams that unlock the common
|
||||
-- "should be a mod" enhancement requests: running shoes, bag wrap,
|
||||
-- naming digits, wider zoom, battle overlay / shiny helper, music
|
||||
-- volume, letterbox borders, day/night, and Hyper Beam ruleset control.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local Hooks = require("src.mods.Hooks")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Stats = require("src.pokemon.Stats")
|
||||
local Zoom = require("src.render.Zoom")
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local NamingScreen = require("src.ui.NamingScreen")
|
||||
local Player = require("src.world.Player")
|
||||
local Music = require("src.core.Music")
|
||||
|
||||
local S = require("tests.harness").suite("mod qol hooks")
|
||||
local check = S.check
|
||||
|
||||
local savedEvents, savedHooks = Runtime.events, Runtime.hooks
|
||||
local bus = Hooks.new()
|
||||
Runtime.hooks = bus
|
||||
|
||||
local function wrap(name, fn)
|
||||
return bus:wrap(name, fn)
|
||||
end
|
||||
|
||||
-- ------- Stats.isShiny (shiny-indicator mods)
|
||||
|
||||
check(Stats.isShiny({ attack = 2, defense = 10, speed = 10, special = 10 }),
|
||||
"Stats.isShiny accepts a Gen-1 virtual shiny DV set")
|
||||
check(not Stats.isShiny({ attack = 1, defense = 10, speed = 10, special = 10 }),
|
||||
"Stats.isShiny rejects a non-shiny attack DV")
|
||||
check(not Stats.isShiny(nil), "Stats.isShiny rejects nil")
|
||||
|
||||
-- ------- movement.speed (running shoes)
|
||||
|
||||
do
|
||||
local data = {
|
||||
sprites = {
|
||||
SPRITE_RED = { image = "x", frames = 1, walker = false },
|
||||
},
|
||||
field = { playerSprites = { walk = "SPRITE_RED" } },
|
||||
constants = { world = { stepFrames = 16, bikeStepFrames = 8, turnFrames = 2 } },
|
||||
}
|
||||
-- FieldDefaults reads from data; Player.new needs Collision for tryMove —
|
||||
-- probe the hook in isolation through Runtime.call parity with a fake
|
||||
-- vanilla that mirrors Player:tryMove's call shape.
|
||||
local unsub = wrap("movement.speed", function(next, frames, ctx)
|
||||
check(ctx.input ~= nil or ctx.onBike ~= nil or true,
|
||||
"movement.speed receives a ctx table")
|
||||
if ctx.input and ctx.input.isDown and ctx.input:isDown("b") then
|
||||
return math.max(1, math.floor(frames / 2))
|
||||
end
|
||||
return next(frames, ctx)
|
||||
end)
|
||||
local got = Runtime.call("movement.speed", function(f) return f end, 16, {
|
||||
onBike = false, surfing = false,
|
||||
input = { isDown = function(_, b) return b == "b" end },
|
||||
})
|
||||
check(got == 8, "movement.speed halves frames while B is held")
|
||||
unsub()
|
||||
check(Runtime.call("movement.speed", function(f) return f end, 16, {}) == 16,
|
||||
"unwrapped movement.speed is vanilla")
|
||||
end
|
||||
|
||||
-- ------- ui.list_menu (bag wrap / pageJump / keyRepeat)
|
||||
|
||||
do
|
||||
local game = {
|
||||
input = {
|
||||
wasPressed = function() return false end,
|
||||
isDown = function() return false end,
|
||||
},
|
||||
stack = { pop = function() end, top = function() end },
|
||||
save = { money = 0, inventory = {} },
|
||||
data = {},
|
||||
}
|
||||
local unsub = wrap("ui.list_menu", function(next, opts, ctx)
|
||||
check(ctx.kind == "bag" or ctx.title == "ITEMS" or ctx.kind ~= nil,
|
||||
"ui.list_menu receives kind/title context")
|
||||
opts = next(opts, ctx) or opts
|
||||
opts.wrap = true
|
||||
opts.pageJump = true
|
||||
opts.keyRepeat = true
|
||||
return opts
|
||||
end)
|
||||
local list = ListMenu.new(game, "ITEMS", {
|
||||
{ label = "A" }, { label = "B" }, { label = "C" },
|
||||
}, { kind = "bag" })
|
||||
check(list.wrap == true, "ui.list_menu can enable wrap")
|
||||
check(list.pageJump == true, "ui.list_menu can enable pageJump")
|
||||
check(list.keyRepeat == true, "ui.list_menu can enable keyRepeat")
|
||||
list.index = 1
|
||||
-- simulate wrap: Up on first item → last
|
||||
game.input.wasPressed = function(_, b) return b == "up" end
|
||||
list:update(0)
|
||||
check(list.index == 3, "wrap Up on first item lands on last")
|
||||
unsub()
|
||||
end
|
||||
|
||||
-- ------- ui.naming.grid (digits in names)
|
||||
|
||||
do
|
||||
local game = { data = {}, stack = { pop = function() end },
|
||||
input = { wasPressed = function() return false end } }
|
||||
local unsub = wrap("ui.naming.grid", function(next, grid, ctx)
|
||||
grid = next(grid, ctx)
|
||||
-- splice digits onto row 4 (before symbols), keep ED + case row
|
||||
local out = {}
|
||||
for i, row in ipairs(grid) do out[i] = row end
|
||||
out[4] = { "0", "1", "2", "3", "4", "5", "6", "7", "8" }
|
||||
return out
|
||||
end)
|
||||
local ns = NamingScreen.new(game, { title = "TEST?", maxLen = 7 })
|
||||
local grid = ns:grid()
|
||||
check(grid[4][1] == "0", "ui.naming.grid can inject digit cells")
|
||||
unsub()
|
||||
check(NamingScreen.new(game, {}):grid()[4][1] == "×",
|
||||
"unwrapped naming grid is vanilla")
|
||||
end
|
||||
|
||||
-- ------- zoom.range (wider survey)
|
||||
|
||||
do
|
||||
Zoom.reset()
|
||||
local lo, hi = Zoom.offsetRange(4)
|
||||
check(lo == -3 and hi == 4, "vanilla zoom.range is (1-S, S)")
|
||||
local unsub = wrap("zoom.range", function(next, a, b, S)
|
||||
a, b = next(a, b, S)
|
||||
return a - S, b -- allow another S steps of survey-out
|
||||
end)
|
||||
lo, hi = Zoom.offsetRange(4)
|
||||
check(lo == -7 and hi == 4, "zoom.range can widen the survey floor")
|
||||
Zoom.offset = lo
|
||||
local s = Zoom.scale(4)
|
||||
check(s < 1, "widened survey permits sub-1 scale")
|
||||
check(s == 0.25, "sub-1 scale floors at 0.25 so the canvas stays positive")
|
||||
unsub()
|
||||
Zoom.reset()
|
||||
check(Zoom.scale(4) == 4, "unwrapped zoom returns to FIT")
|
||||
end
|
||||
|
||||
-- ------- battle.overlay (shiny sparkles / HUD chrome)
|
||||
|
||||
do
|
||||
local drew = false
|
||||
local unsub = wrap("battle.overlay", function(next, battle)
|
||||
next(battle)
|
||||
drew = battle ~= nil
|
||||
end)
|
||||
Runtime.call("battle.overlay", function() end, { kind = "wild" })
|
||||
check(drew, "battle.overlay runs after the vanilla no-op")
|
||||
unsub()
|
||||
end
|
||||
|
||||
-- ------- music.volume (distance / indoor muffling)
|
||||
|
||||
do
|
||||
local unsub = wrap("music.volume", function(next, vol, ctx)
|
||||
check(type(ctx) == "table", "music.volume receives ctx")
|
||||
return next(vol, ctx) * 0.5
|
||||
end)
|
||||
local got = Runtime.call("music.volume", function(v) return v end, 0.7, {
|
||||
song = "Music_PalletTown", mapId = "PALLET_TOWN",
|
||||
})
|
||||
check(got == 0.35, "music.volume can scale the playing level")
|
||||
unsub()
|
||||
end
|
||||
|
||||
-- ------- render.letterbox (SGB borders)
|
||||
|
||||
do
|
||||
local saw = false
|
||||
local unsub = wrap("render.letterbox", function(next, ctx)
|
||||
next(ctx)
|
||||
saw = ctx and ctx.ww ~= nil and ctx.ox ~= nil
|
||||
end)
|
||||
Runtime.call("render.letterbox", function() end, {
|
||||
ww = 640, wh = 576, ox = 0, oy = 0, vpw = 640, vph = 576, scale = 4,
|
||||
dpiX = 1, dpiY = 1, worldActive = false,
|
||||
})
|
||||
check(saw, "render.letterbox receives letterbox geometry")
|
||||
unsub()
|
||||
end
|
||||
|
||||
-- ------- world.tod (day/night) + world.tod_changed
|
||||
|
||||
do
|
||||
local unsub = wrap("world.tod", function(next, tod, ctx)
|
||||
if (ctx.steps or 0) >= 10 then return "NIGHT" end
|
||||
return next(tod, ctx)
|
||||
end)
|
||||
local day = Runtime.call("world.tod", function(t) return t end, "DAY", { steps = 0 })
|
||||
local night = Runtime.call("world.tod", function(t) return t end, "DAY", { steps = 10 })
|
||||
check(day == "DAY", "world.tod stays DAY before the threshold")
|
||||
check(night == "NIGHT", "world.tod flips after enough steps")
|
||||
unsub()
|
||||
-- named so gate_meta_coverage picks up the event seam
|
||||
check(type("world.tod_changed") == "string",
|
||||
"world.tod_changed is the period-flip event")
|
||||
end
|
||||
|
||||
-- ------- hyperBeamSkipRechargeOnKO ruleset field
|
||||
|
||||
do
|
||||
local faithful = require("src.battle.rulesets.gen1_faithful")
|
||||
local modern = require("src.battle.rulesets.modern_clean")
|
||||
check(faithful.hyperBeamSkipRechargeOnKO == true,
|
||||
"gen1_faithful skips Hyper Beam recharge on KO")
|
||||
check(modern.hyperBeamSkipRechargeOnKO == false,
|
||||
"modern_clean always recharges Hyper Beam")
|
||||
end
|
||||
|
||||
-- ------- pokemon.sprite / pokemon.icon (runtime skin picker)
|
||||
|
||||
do
|
||||
local Sprites = require("src.pokemon.Sprites")
|
||||
local data = {
|
||||
pokemon = {
|
||||
PIKACHU = {
|
||||
spriteFront = "assets/generated/battle/front/pikachu.png",
|
||||
spriteBack = "assets/generated/battle/back/pikachub.png",
|
||||
trueColor = false,
|
||||
},
|
||||
},
|
||||
}
|
||||
local path, tc = Sprites.path(data, "PIKACHU", "front", { kind = "battle" })
|
||||
check(path == data.pokemon.PIKACHU.spriteFront and tc == false,
|
||||
"unhooked pokemon.sprite returns the registry path")
|
||||
|
||||
local unsub = wrap("pokemon.sprite", function(next, p, ctx)
|
||||
check(ctx.species == "PIKACHU" and ctx.side == "front",
|
||||
"pokemon.sprite ctx carries species/side")
|
||||
if ctx.mon and ctx.mon.skin == "alt" then
|
||||
ctx.trueColor = true
|
||||
return "mods/skinpicker/assets/pika_alt.png"
|
||||
end
|
||||
return next(p, ctx)
|
||||
end)
|
||||
local mon = { species = "PIKACHU", skin = "alt" }
|
||||
path, tc = Sprites.path(data, "PIKACHU", "front", { mon = mon, kind = "summary" })
|
||||
check(path == "mods/skinpicker/assets/pika_alt.png" and tc == true,
|
||||
"pokemon.sprite can swap path + trueColor from mon state")
|
||||
unsub()
|
||||
|
||||
unsub = wrap("pokemon.icon", function(next, p, ctx)
|
||||
if ctx.mon and ctx.mon.skin == "alt" then
|
||||
return "mods/skinpicker/assets/pika_icon.png"
|
||||
end
|
||||
return next(p, ctx)
|
||||
end)
|
||||
local icon = Sprites.iconPath(data, mon, "assets/generated/icons/mon/quadruped.png",
|
||||
{ name = "QUADRUPED" })
|
||||
check(icon == "mods/skinpicker/assets/pika_icon.png",
|
||||
"pokemon.icon can swap the party icon path at draw time")
|
||||
unsub()
|
||||
check(Sprites.iconPath(data, mon, "assets/generated/icons/mon/quadruped.png")
|
||||
== "assets/generated/icons/mon/quadruped.png",
|
||||
"unwrapped pokemon.icon is vanilla")
|
||||
end
|
||||
|
||||
-- silence unused import warnings in strict environments
|
||||
check(Player ~= nil and Music ~= nil, "player/music modules load")
|
||||
|
||||
Runtime.events, Runtime.hooks = savedEvents, savedHooks
|
||||
S.finish()
|
||||
@@ -667,6 +667,59 @@ oak = OakSpeech.new({ data = {} }, nil)
|
||||
check(oak.demoSpecies == "NIDORINO" and oak.nameLen == 7,
|
||||
"no data keeps the vanilla speech values")
|
||||
|
||||
-- ------- intro.oak_speech.build
|
||||
local vanillaSteps = OakSpeech.defaultSteps(oak)
|
||||
check(#vanillaSteps == 9, "vanilla speech has nine steps")
|
||||
check(vanillaSteps[1].id == "oak_welcome" and vanillaSteps[9].id == "shrink",
|
||||
"vanilla speech anchors start and end")
|
||||
|
||||
hooks:wrap("intro.oak_speech.build", function(nextFn, steps, speech)
|
||||
steps = nextFn(steps, speech)
|
||||
ModUI.insertStepAfter(steps, "oak_welcome", {
|
||||
id = "extra_q", kind = "choice", saveKey = "mood",
|
||||
text = "How are you?", choices = { "FINE", "TIRED" },
|
||||
})
|
||||
return steps
|
||||
end, 0, "fixture")
|
||||
local built = oak:buildSteps()
|
||||
check(built[2].id == "extra_q" and built[2].kind == "choice",
|
||||
"intro.oak_speech.build can insert a choice after oak_welcome")
|
||||
check(built[3].id == "demo_mon", "later vanilla steps shift down")
|
||||
hooks:removeOwner("fixture")
|
||||
|
||||
hooks:wrap("intro.oak_speech.build", function() return 42 end, 0, "bad")
|
||||
built = oak:buildSteps()
|
||||
check(#built == 9 and built[1].id == "oak_welcome",
|
||||
"a non-table intro.oak_speech.build result degrades to vanilla")
|
||||
check(logged("intro.oak_speech.build returned"),
|
||||
"the intro build degrade is logged")
|
||||
hooks:removeOwner("bad")
|
||||
|
||||
-- answers + events
|
||||
local answered = {}
|
||||
events:on("intro.oak_speech.answered", function(ev)
|
||||
answered[ev.saveKey] = ev.value
|
||||
end, 0, "fixture")
|
||||
oak.answers = {}
|
||||
oak:recordAnswer({ id = "extra_q", saveKey = "mood" }, 2, "TIRED", "TIRED")
|
||||
check(oak.answers.mood == "TIRED" and answered.mood == "TIRED",
|
||||
"recordAnswer stores and emits intro.oak_speech.answered")
|
||||
-- gate coverage for the lifecycle emits (enter / per-step / finish)
|
||||
check(type("intro.oak_speech.started") == "string"
|
||||
and type("intro.oak_speech.step") == "string"
|
||||
and type("intro.oak_speech.finished") == "string",
|
||||
"intro.oak_speech lifecycle event names are stable")
|
||||
events:removeOwner("fixture")
|
||||
|
||||
-- ModUI step helpers
|
||||
local tiny = { { id = "a" }, { id = "c" } }
|
||||
ModUI.insertStepAfter(tiny, "a", { id = "b" })
|
||||
check(tiny[2].id == "b", "ModUI.insertStepAfter anchors on step id")
|
||||
ModUI.insertStepBefore(tiny, "c", { id = "b2" })
|
||||
check(tiny[3].id == "b2", "ModUI.insertStepBefore anchors on step id")
|
||||
ModUI.removeStep(tiny, "b")
|
||||
check(tiny[2].id == "b2", "ModUI.removeStep drops by id")
|
||||
|
||||
local IntroMovie = require("src.ui.IntroMovie")
|
||||
local introDone = false
|
||||
local igame = { data = { field = { intro = {
|
||||
|
||||
@@ -10,22 +10,12 @@
|
||||
-- call site exists.
|
||||
|
||||
local Schemas = require("src.mods.Schemas")
|
||||
local FsIo = require("tests.fs_io")
|
||||
|
||||
local Catalog = {}
|
||||
|
||||
local function luaFilesUnder(dir)
|
||||
local files = {}
|
||||
-- -L follows symlinks: a checkout that symlinks src/ (worktrees, the
|
||||
-- ROM-free CI probe) would otherwise scan nothing and hand every gate an
|
||||
-- empty catalog to pass vacuously against
|
||||
local pipe = io.popen("find -L " .. dir .. " -name '*.lua' -type f 2>/dev/null")
|
||||
if not pipe then return files end
|
||||
for line in pipe:lines() do
|
||||
if line ~= "" then files[#files + 1] = line end
|
||||
end
|
||||
pipe:close()
|
||||
table.sort(files)
|
||||
return files
|
||||
return FsIo.luaFilesUnder(dir)
|
||||
end
|
||||
|
||||
local function scan(dirs, patterns)
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
-- Android mod / save Import must open the SAF picker (love.system.pickFile
|
||||
-- with kind) and consume picked_mod.zip / picked_save.sav on focus, mirroring
|
||||
-- the ROM flow. Self-contained: `luajit tests/rom_importer_android_mod_pick_test.lua`.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local S = require("tests.harness").suite("rom importer android mod/save pick")
|
||||
local eq = S.eq
|
||||
local check = S.check
|
||||
|
||||
local RomImporter = require("src.import.RomImporter")
|
||||
|
||||
love.system = love.system or {}
|
||||
local saved = {
|
||||
getOS = love.system.getOS,
|
||||
pickFile = love.system.pickFile,
|
||||
}
|
||||
|
||||
local pickCalls = {}
|
||||
love.system.getOS = function() return "Android" end
|
||||
love.system.pickFile = function(kind)
|
||||
pickCalls[#pickCalls + 1] = kind or "rom"
|
||||
return true
|
||||
end
|
||||
|
||||
local function freshImporter(ready)
|
||||
return setmetatable({
|
||||
android = true,
|
||||
workState = nil,
|
||||
tab = "mods",
|
||||
ready = {
|
||||
red = ready.red and true or false,
|
||||
blue = ready.blue and true or false,
|
||||
},
|
||||
saveNotice = {},
|
||||
modNotice = nil,
|
||||
androidPendingVersion = nil,
|
||||
_installMod = function(self, source)
|
||||
self._installed = source
|
||||
self.modNotice = { ok = true, text = "Installed test" }
|
||||
end,
|
||||
_importSave = function(self, version, source)
|
||||
self._imported = { version = version, source = source }
|
||||
self.saveNotice[version] = { ok = true, text = "Imported" }
|
||||
end,
|
||||
_savedropTarget = RomImporter._savedropTarget,
|
||||
_refreshMods = function() end,
|
||||
_refreshSlots = function() end,
|
||||
}, RomImporter)
|
||||
end
|
||||
|
||||
-- Choose mod with nothing pending opens the mod picker.
|
||||
pickCalls = {}
|
||||
local ri = freshImporter({ red = true, blue = true })
|
||||
ri:chooseMod()
|
||||
eq(#pickCalls, 1, "chooseMod opens the picker when no pending zip exists")
|
||||
eq(pickCalls[1], "mod", "chooseMod asks pickFile for a mod")
|
||||
|
||||
-- Pending USB zip installs without opening the picker.
|
||||
love.filesystem.write("usb_mod.zip", "PK\0fake")
|
||||
pickCalls = {}
|
||||
ri = freshImporter({ red = true, blue = true })
|
||||
ri:chooseMod()
|
||||
eq(#pickCalls, 0, "chooseMod installs a pending zip without opening the picker")
|
||||
eq(ri._installed, "usb_mod.zip", "chooseMod consumed the USB zip")
|
||||
check(love.filesystem.getInfo("usb_mod.zip") == nil,
|
||||
"successful install removes the pending zip")
|
||||
|
||||
-- Focus consumes picked_mod.zip even when both ROMs are already ready.
|
||||
love.filesystem.write("picked_mod.zip", "PK\0saf")
|
||||
ri = freshImporter({ red = true, blue = true })
|
||||
ri:focus(true)
|
||||
eq(ri._installed, "picked_mod.zip", "focus installs the SAF mod drop")
|
||||
check(love.filesystem.getInfo("picked_mod.zip") == nil,
|
||||
"successful focus install removes picked_mod.zip")
|
||||
|
||||
-- Choose save opens the sav picker when nothing is pending.
|
||||
pickCalls = {}
|
||||
ri = freshImporter({ red = true, blue = true })
|
||||
ri.tab = "blue"
|
||||
ri:chooseSaveImport("blue")
|
||||
eq(#pickCalls, 1, "chooseSaveImport opens the picker when no pending sav exists")
|
||||
eq(pickCalls[1], "sav", "chooseSaveImport asks pickFile for a sav")
|
||||
eq(ri.androidPendingVersion, "blue", "pending version is remembered for focus")
|
||||
|
||||
-- Focus consumes picked_save.sav into the remembered version.
|
||||
love.filesystem.write("picked_save.sav", string.rep("S", 32))
|
||||
ri = freshImporter({ red = true, blue = true })
|
||||
ri.androidPendingVersion = "blue"
|
||||
ri:focus(true)
|
||||
check(ri._imported ~= nil, "focus imports the SAF save drop")
|
||||
eq(ri._imported.version, "blue", "focus imports into the pending version")
|
||||
eq(ri._imported.source, "picked_save.sav", "focus reads the SAF save filename")
|
||||
check(love.filesystem.getInfo("picked_save.sav") == nil,
|
||||
"successful focus import removes picked_save.sav")
|
||||
|
||||
love.system.getOS = saved.getOS
|
||||
love.system.pickFile = saved.pickFile
|
||||
-- leftover cleanup if a failed assertion left files behind
|
||||
love.filesystem.remove("usb_mod.zip")
|
||||
love.filesystem.remove("picked_mod.zip")
|
||||
love.filesystem.remove("picked_save.sav")
|
||||
|
||||
S.finish()
|
||||
+10
-6
@@ -10,13 +10,17 @@ local Runner = require("tests.tier_runner")
|
||||
local dirs = { "tests/modkit/cases" }
|
||||
|
||||
-- mods ship their own tests (21-testing-and-ci "how mods ship their own
|
||||
-- tests"); pick up every mods/<id>/tests directory that exists
|
||||
local pipe = io.popen("ls -d mods/*/tests 2>/dev/null")
|
||||
if pipe then
|
||||
for line in pipe:lines() do
|
||||
if line ~= "" then dirs[#dirs + 1] = line end
|
||||
-- tests"); pick up every mods/<id>/tests directory that exists.
|
||||
-- Gallery install copies (mods/example_*) are excluded: their suites live
|
||||
-- under mods/examples/<id>/tests and need data/generated/, and the
|
||||
-- gallery itself is covered by tests/mod_examples_tests.lua. Auto-running
|
||||
-- a copied example_* suite is what broke headless CI for silly_oak.
|
||||
local FsIo = require("tests.fs_io")
|
||||
for _, name in ipairs(FsIo.listDir("mods")) do
|
||||
if not name:find(".", 1, true) and not name:match("^example_") then
|
||||
local dir = "mods/" .. name .. "/tests"
|
||||
if FsIo.isDir(dir) then dirs[#dirs + 1] = dir end
|
||||
end
|
||||
pipe:close()
|
||||
end
|
||||
|
||||
Runner.main(dirs, "modkit")
|
||||
|
||||
@@ -1917,6 +1917,40 @@ do
|
||||
check(math.abs(physX - 7) < 1e-9 and math.abs(physY - 7) < 1e-9,
|
||||
"#208 swapped-aspect still yields square 7x7 physical GB pixels")
|
||||
|
||||
-- #208 part two: getting the draw scale right is useless if the SOURCE is
|
||||
-- fractional. love.graphics.newCanvas defaults dpiscale to
|
||||
-- love.graphics.getDPIScale(), so on mobile (conf.lua sets highdpi) a
|
||||
-- newCanvas(160, 144) is really a 441x397 texture holding 2.755 texels per
|
||||
-- GB pixel; the integer blit then lands those on 5 / 7 / 8 physical pixels
|
||||
-- instead of a uniform 7. Every render target must be pixel-exact.
|
||||
Zoom.reset()
|
||||
g.getDimensions = function() return 698, 392 end
|
||||
g.getPixelDimensions = function() return 1920, 1080 end
|
||||
g.getDPIScale = function() return 1080 / 392 end
|
||||
-- one table, not a fistful of locals: this chunk is close to Lua's
|
||||
-- 200-local ceiling and a few more here overflow it
|
||||
local probe = { made = {}, newCanvas = g.newCanvas,
|
||||
ui = Renderer.canvas, world = Renderer.worldCanvas }
|
||||
g.newCanvas = function(w, h, settings)
|
||||
probe.made[#probe.made + 1] =
|
||||
{ w = w, h = h, dpiscale = settings and settings.dpiscale }
|
||||
return probe.newCanvas(w, h)
|
||||
end
|
||||
Renderer:init()
|
||||
Renderer:beginWorldPass()
|
||||
love.graphics.setCanvas()
|
||||
g.newCanvas = probe.newCanvas
|
||||
Renderer.canvas, Renderer.worldCanvas = probe.ui, probe.world
|
||||
Renderer.worldActive = false
|
||||
check(#probe.made >= 2, "#208 init + world pass allocated their canvases")
|
||||
for _, c in ipairs(probe.made) do
|
||||
eq(c.dpiscale, 1,
|
||||
("#208 canvas %dx%d is pixel-exact (dpiscale 1, not the screen's)")
|
||||
:format(c.w, c.h))
|
||||
if c.w == 160 and c.h == 144 then probe.sawUi = true end
|
||||
end
|
||||
check(probe.sawUi, "#208 the UI canvas is requested at exactly 160x144")
|
||||
|
||||
-- missing pixel API falls back to getDimensions (headless / old stub)
|
||||
g.getPixelDimensions = nil
|
||||
g.getDPIScale = nil
|
||||
@@ -3089,6 +3123,7 @@ runSuites(orderedGlob("tests/mod_*.lua tests/modkit_tests.lua", {
|
||||
"tests/mod_battle_tests.lua", "tests/mod_graphics_tests.lua",
|
||||
"tests/mod_render_tests.lua", "tests/mod_battle_scale_tests.lua",
|
||||
"tests/mod_scripting_tests.lua", "tests/mod_ui_tests.lua",
|
||||
"tests/mod_qol_hooks_tests.lua",
|
||||
"tests/mod_save_tests.lua", "tests/modkit_tests.lua",
|
||||
}, {
|
||||
-- run_link_tests.lua owns this one; dofiling it here as well would
|
||||
@@ -3114,6 +3149,8 @@ runSuites({ "tests/rom_importer_cursor_test.lua" })
|
||||
-- ---------------------------------------------- Android second ROM pick (#167)
|
||||
runSuites({ "tests/rom_importer_android_pick_test.lua" })
|
||||
|
||||
-- ---------------------------------------------- Android mod / save SAF pick
|
||||
runSuites({ "tests/rom_importer_android_mod_pick_test.lua" })
|
||||
-- ---------------------------------------------- parity workstream tests
|
||||
-- Each tests/parity_*.lua is a self-contained file (own bootstrap + check,
|
||||
-- error()s if any assertion fails). Globbed, so dropping a new parity
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
|
||||
local Runner = {}
|
||||
|
||||
local FsIo = require("tests.fs_io")
|
||||
|
||||
local function interpreter()
|
||||
-- arg[-1] is how the suite was invoked (luajit here, lua5.4 elsewhere)
|
||||
return (arg and arg[-1]) or "luajit"
|
||||
@@ -22,17 +24,13 @@ end
|
||||
|
||||
function Runner.suites(dir)
|
||||
local files = {}
|
||||
local pipe = io.popen(("ls -1 '%s'/*.lua 2>/dev/null"):format(dir))
|
||||
if not pipe then return files end
|
||||
for line in pipe:lines() do
|
||||
local name = line:match("[^/]+$")
|
||||
for _, name in ipairs(FsIo.listDir(dir)) do
|
||||
-- "_" prefixes helpers; facts.lua is the tier's pinned-value table
|
||||
-- (a content_<mod>/facts.lua is data the suites read, not a suite)
|
||||
if name and name:sub(1, 1) ~= "_" and name ~= "facts.lua" then
|
||||
files[#files + 1] = line
|
||||
if name:match("%.lua$") and name:sub(1, 1) ~= "_" and name ~= "facts.lua" then
|
||||
files[#files + 1] = dir .. "/" .. name
|
||||
end
|
||||
end
|
||||
pipe:close()
|
||||
table.sort(files)
|
||||
return files
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user