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:
bryanthaboi
2026-07-27 09:29:24 -04:00
committed by GitHub
parent 043f6b2426
commit 803f86d5b3
68 changed files with 2902 additions and 300 deletions
+23
View File
@@ -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
+243
View File
@@ -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
+10 -6
View File
@@ -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 ""
+18
View File
@@ -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")
+35
View File
@@ -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
View File
@@ -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 -2
View File
@@ -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
+267
View File
@@ -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()
+53
View File
@@ -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 = {
+2 -12
View File
@@ -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
View File
@@ -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")
+37
View File
@@ -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
+5 -7
View File
@@ -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