mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-18 19:54:21 +02:00
@@ -0,0 +1,79 @@
|
||||
-- Gold VOID FILL (#1418): FADE (each map's own border, dissolving across a
|
||||
-- boundary), WATER, TREES, or BLACK. Parks you on Cherrygrove zoomed out so
|
||||
-- the void is most of the window, cycles the four modes, then hands the pad
|
||||
-- over. Do not add POKEPORT_SPEED: the water anim and the dissolve both run
|
||||
-- on the real clock.
|
||||
--
|
||||
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gold_void_fill_bug1418_test.lua love .
|
||||
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
return function(game)
|
||||
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-void-fill"
|
||||
local BorderFill = require("src.world.gen2.BorderFill")
|
||||
local Tilt = require("src.render.Tilt")
|
||||
local Zoom = require("src.render.Zoom")
|
||||
local failures = 0
|
||||
|
||||
local function ok(label, condition, detail)
|
||||
if condition then
|
||||
print("[void] ok " .. label)
|
||||
else
|
||||
failures = failures + 1
|
||||
print("[void] FAIL " .. label .. " " .. tostring(detail))
|
||||
end
|
||||
end
|
||||
|
||||
U.wait(45)
|
||||
local world = game.world
|
||||
assert(world and world.map, "gold world did not boot")
|
||||
|
||||
-- Cherrygrove's header border is water ($35). FADE shows that water,
|
||||
-- TREES replaces it, BLACK drops the sheet. attributes.asm:123.
|
||||
world:setMap("CHERRYGROVE_CITY", 22, 16, "down")
|
||||
U.wait(10)
|
||||
|
||||
Zoom.offset = -3
|
||||
world:rebuildNeighbors()
|
||||
world:rebuildPeople({ seamless = true })
|
||||
Tilt.setLevel(3)
|
||||
for _ = 1, 40 do
|
||||
Tilt.update(1 / 60)
|
||||
U.wait(1)
|
||||
end
|
||||
|
||||
local function show(mode, file, label)
|
||||
BorderFill.setVoidFill(mode)
|
||||
if game.options then game.options.voidFill = mode end
|
||||
if game.save and game.save.options then
|
||||
game.save.options.voidFill = mode
|
||||
end
|
||||
U.wait(8)
|
||||
ok(label, BorderFill.voidFill == mode, BorderFill.voidFill)
|
||||
U.shot(game, out .. "/" .. file)
|
||||
end
|
||||
|
||||
show("fade", "01-fade.png", "FADE is the live fill")
|
||||
show("water", "02-water.png", "WATER forces the water block")
|
||||
show("trees", "03-trees.png", "TREES forces the tree wall")
|
||||
show("black", "04-black.png", "BLACK drops the tiled void")
|
||||
show("fade", "05-fade-again.png", "and FADE restores the map's own border")
|
||||
|
||||
Tilt.setLevel(0)
|
||||
Zoom.offset = 0
|
||||
world:rebuildNeighbors()
|
||||
world:rebuildPeople({ seamless = true })
|
||||
|
||||
if failures > 0 then
|
||||
print(("[driver] FAIL gold void fill: %d check(s)"):format(failures))
|
||||
return
|
||||
end
|
||||
print("[driver] PASS gold void fill in " .. out)
|
||||
U.log("cycled fade/water/trees/black on cherrygrove (#1418).")
|
||||
U.log("OPTION > VOID FILL, zoom out (-) to see the void.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,121 @@
|
||||
-- Driver: renders the launcher's FIND MODS tab against a canned index whose
|
||||
-- listings carry the feed's `downloads` object, so the card line, the sort
|
||||
-- popup and the per-mod popup can be eyeballed with real numbers, real
|
||||
-- unknowns and a real zero in the same list.
|
||||
-- SHOT_DIR=/tmp/finddl POKEPORT_DRIVER=tests/drivers/launcher_find_downloads_shot.lua love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local RomImporter = require("src.import.RomImporter")
|
||||
|
||||
local dir = os.getenv("SHOT_DIR") or "/tmp/finddl"
|
||||
os.execute('mkdir -p "' .. dir .. '" 2>/dev/null')
|
||||
love.window.setMode(1024, 768, { resizable = true, highdpi = true })
|
||||
U.wait(2)
|
||||
|
||||
local imp = RomImporter.new(function() end, { launcher = true })
|
||||
|
||||
local function mod(id, title, downloads)
|
||||
return {
|
||||
id = id, title = title, author = "someone", version = "1.0.0",
|
||||
summary = "A canned listing for the screenshot.",
|
||||
categories = { "GAMEPLAY" }, tags = {}, games = {},
|
||||
update_check = "ok",
|
||||
downloads = downloads,
|
||||
latest = { version = "1.0.0", zip = {
|
||||
url = "https://example.invalid/" .. id .. ".zip" } },
|
||||
}
|
||||
end
|
||||
|
||||
-- the canned listing IS the fixture: never let the tab go and fetch the
|
||||
-- player's real sources over it
|
||||
-- new() prewarms a real fetch; drop the in-flight handle and the pump
|
||||
imp._refreshFindSources = function() end
|
||||
imp._refreshFind = function() end
|
||||
imp._pumpFindFetch = function() end
|
||||
imp._findFetch = nil
|
||||
imp.findSources = { { feed = "https://example.invalid/data/index.json",
|
||||
base = "https://example.invalid/",
|
||||
label = "example/index" } }
|
||||
imp.findIndex = { schemaVersion = 1, categories = { "GAMEPLAY" }, mods = {
|
||||
mod("busy", "Busy Mod",
|
||||
{ total = 1578, recent = 388, window_days = 30,
|
||||
as_of = "2026-08-18T05:17:00.000Z" }),
|
||||
mod("huge", "Huge Mod",
|
||||
{ total = 1234567, recent = 24000, window_days = 30,
|
||||
as_of = "2026-08-18T05:17:00.000Z" }),
|
||||
mod("young", "Young Mod",
|
||||
{ total = 42, as_of = "2026-08-18T05:17:00.000Z" }),
|
||||
mod("counted-zero", "Counted Zero Mod", { total = 0 }),
|
||||
mod("unknown", "Unknown Mod", nil),
|
||||
} }
|
||||
imp.findLoaded = true
|
||||
imp:_switchTab("find")
|
||||
U.wait(3)
|
||||
|
||||
local pending = nil
|
||||
love.draw = function()
|
||||
imp:draw()
|
||||
if pending then
|
||||
local path = pending
|
||||
pending = nil
|
||||
love.graphics.captureScreenshot(function(imagedata)
|
||||
local f = io.open(path, "wb")
|
||||
if f then f:write(imagedata:encode("png"):getString()) f:close() end
|
||||
end)
|
||||
end
|
||||
end
|
||||
local function shot(name)
|
||||
pending = dir .. "/" .. name
|
||||
for _ = 1, 90 do
|
||||
if not pending then break end
|
||||
imp:update(1 / 60)
|
||||
coroutine.yield()
|
||||
end
|
||||
U.wait(3)
|
||||
local f = io.open(dir .. "/" .. name, "rb")
|
||||
U.log(f and "shot" or "FAIL shot", name)
|
||||
if f then f:close() end
|
||||
end
|
||||
|
||||
local ModUpdate = require("src.mods.ModUpdate")
|
||||
for _, e in ipairs(imp.findIndex.mods) do
|
||||
local s = imp:_findStats(e)
|
||||
U.log((" %-13s %s | trending=%s"):format(e.id,
|
||||
ModUpdate.downloadsShort(s and s.total),
|
||||
tostring(s and ModUpdate.trendingLine(s.recent, s.windowDays))))
|
||||
end
|
||||
|
||||
for _, key in ipairs({ "popularity", "trending", "name" }) do
|
||||
imp.modSort = key
|
||||
imp._findSortCache = nil
|
||||
U.wait(2)
|
||||
shot("find_sort_" .. key .. ".png")
|
||||
local order = {}
|
||||
for _, e in ipairs(imp._findSortCache.list) do order[#order + 1] = e.id end
|
||||
U.log("sort " .. key .. ":", table.concat(order, ", "))
|
||||
end
|
||||
|
||||
imp.modSort = "popularity"
|
||||
imp._sortPopup = "find"
|
||||
U.wait(2)
|
||||
shot("find_sort_popup.png")
|
||||
imp._sortPopup = nil
|
||||
|
||||
imp._findEntry = imp.findIndex.mods[1]
|
||||
U.wait(2)
|
||||
shot("find_entry_counted.png")
|
||||
imp._findEntry = imp.findIndex.mods[5]
|
||||
U.wait(2)
|
||||
shot("find_entry_unknown.png")
|
||||
imp._findEntry = nil
|
||||
|
||||
-- the installed-mods tab shares the persisted sort key; Trending there has
|
||||
-- nothing to sort by, so it must degrade rather than reorder on nothing
|
||||
imp.modSort = "trending"
|
||||
imp:_switchTab("mods")
|
||||
U.wait(3)
|
||||
shot("mods_tab_trending_fallback.png")
|
||||
|
||||
U.log("done")
|
||||
love.event.quit()
|
||||
end
|
||||
@@ -0,0 +1,93 @@
|
||||
-- Driver: opens FIND MODS against the player's real index sources, lets the
|
||||
-- prewarm fetch land, and reports what the panel resolved without asking
|
||||
-- GitHub anything -- the check behind "sorting only covers the pages I
|
||||
-- visited". Shoots the first and last page of the download sort.
|
||||
-- SHOT_DIR=/tmp/findreal POKEPORT_DRIVER=tests/drivers/launcher_find_real_index.lua love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local ModUpdate = require("src.mods.ModUpdate")
|
||||
|
||||
local fetched = 0
|
||||
local realBegin = ModUpdate.beginFetchReleases
|
||||
ModUpdate.beginFetchReleases = function(...)
|
||||
fetched = fetched + 1
|
||||
return realBegin(...)
|
||||
end
|
||||
|
||||
local RomImporter = require("src.import.RomImporter")
|
||||
local dir = os.getenv("SHOT_DIR") or "/tmp/findreal"
|
||||
os.execute('mkdir -p "' .. dir .. '" 2>/dev/null')
|
||||
love.window.setMode(1024, 768, { resizable = true, highdpi = true })
|
||||
U.wait(2)
|
||||
|
||||
local imp = RomImporter.new(function() end, { launcher = true })
|
||||
imp:_switchTab("find")
|
||||
for _ = 1, 900 do
|
||||
imp:update(1 / 60)
|
||||
coroutine.yield()
|
||||
if imp.findLoaded and not imp._findFetch then break end
|
||||
end
|
||||
|
||||
local mods = (imp.findIndex and imp.findIndex.mods) or {}
|
||||
U.log("index mods:", #mods)
|
||||
U.log("stale copy:", tostring(imp.findIndex and imp.findIndex.stale))
|
||||
|
||||
local pending = nil
|
||||
love.draw = function()
|
||||
imp:draw()
|
||||
if pending then
|
||||
local path = pending
|
||||
pending = nil
|
||||
love.graphics.captureScreenshot(function(imagedata)
|
||||
local f = io.open(path, "wb")
|
||||
if f then f:write(imagedata:encode("png"):getString()) f:close() end
|
||||
end)
|
||||
end
|
||||
end
|
||||
local function shot(name)
|
||||
pending = dir .. "/" .. name
|
||||
for _ = 1, 90 do
|
||||
if not pending then break end
|
||||
imp:update(1 / 60)
|
||||
coroutine.yield()
|
||||
end
|
||||
U.wait(3)
|
||||
local f = io.open(dir .. "/" .. name, "rb")
|
||||
U.log(f and "shot" or "FAIL shot", name)
|
||||
if f then f:close() end
|
||||
end
|
||||
|
||||
local counted, dated, unknown = 0, 0, 0
|
||||
for _, e in ipairs(mods) do
|
||||
local s = imp:_findStatsCached(e)
|
||||
if s and s.total then counted = counted + 1 else unknown = unknown + 1 end
|
||||
if s and s.latest then dated = dated + 1 end
|
||||
end
|
||||
U.log(("counts=%d dated=%d unknown=%d github requests=%d")
|
||||
:format(counted, dated, unknown, fetched))
|
||||
|
||||
imp.modSort = "popularity"
|
||||
imp._findSortCache = nil
|
||||
U.wait(3)
|
||||
shot("real_page1.png")
|
||||
local sorted = imp._findSortCache and imp._findSortCache.list or {}
|
||||
for i = 1, math.min(3, #sorted) do
|
||||
local s = imp:_findStatsCached(sorted[i])
|
||||
U.log((" #%d %s %s"):format(i, sorted[i].title,
|
||||
ModUpdate.downloadsShort(s and s.total)))
|
||||
end
|
||||
for i = math.max(1, #sorted - 2), #sorted do
|
||||
local s = imp:_findStatsCached(sorted[i])
|
||||
U.log((" #%d %s %s"):format(i, sorted[i].title,
|
||||
ModUpdate.downloadsShort(s and s.total)))
|
||||
end
|
||||
|
||||
-- the last page is the one a page-by-page resolve would have left unsorted
|
||||
imp._pages["find"] = 99
|
||||
U.wait(3)
|
||||
U.log("github requests after sorting:", fetched)
|
||||
shot("real_last_page.png")
|
||||
|
||||
U.log("done")
|
||||
love.event.quit()
|
||||
end
|
||||
@@ -43,6 +43,15 @@ local gold = LauncherSettings.open(hooks, "gold")
|
||||
check(has(gold, "TOUCH PAD"), "Gold's gear offers TOUCH PAD")
|
||||
check(has(gold, "VIBRATION"), "and VIBRATION")
|
||||
check(has(gold, "TOUCH CONTROLS"), "and the layout editor")
|
||||
check(has(gold, "VOID FILL"), "and VOID FILL")
|
||||
|
||||
local voidFill = findRow(gold, "VOID FILL")
|
||||
eq(voidFill.value(), "FADE ", "VOID FILL defaults to FADE")
|
||||
voidFill.step(1)
|
||||
eq(gold.opts.gold.voidFill, "water", "right stores water in the gold block")
|
||||
eq(voidFill.value(), "WATER", "and the row reads WATER")
|
||||
voidFill.step(-1)
|
||||
eq(gold.opts.gold.voidFill, "fade", "left restores fade")
|
||||
|
||||
-- Every write has to land in the gold block: the flat keys beside it are
|
||||
-- Red's, and Gold's boot never reads them (src/core/gen2/Save.lua:299).
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
-- FIND MODS download counts: the index feed publishes them per listing, so
|
||||
-- the panel reads the one fetch it already made instead of asking GitHub per
|
||||
-- mod (that fan-out is what blew the hourly API limit in _findStatsCached's
|
||||
-- own comment). This pins the resolver's precedence, the unknown-is-not-zero
|
||||
-- rule the card and the sorts both depend on, and the Trending wiring.
|
||||
-- luajit tests/engine/launcher_mod_downloads.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
|
||||
|
||||
-- No launcher test may reach the network: beginFetchReleases is the only door
|
||||
-- out of _requestFindStats, and counting the calls is the assertion.
|
||||
local ModUpdate = require("src.mods.ModUpdate")
|
||||
local fetched = {}
|
||||
ModUpdate.beginFetchReleases = function(repo, id)
|
||||
fetched[#fetched + 1] = id
|
||||
return { stage = "done" }
|
||||
end
|
||||
|
||||
local RomImporter = require("src.import.RomImporter")
|
||||
local function launcher()
|
||||
return setmetatable({ tab = "find" }, RomImporter)
|
||||
end
|
||||
|
||||
local function entry(id, downloads, github)
|
||||
return { id = id, title = id, downloads = downloads, github = github }
|
||||
end
|
||||
|
||||
-- ------- the feed's counts win, and cost nothing
|
||||
|
||||
do
|
||||
local ri = launcher()
|
||||
local e = entry("counted", { total = 1578, recent = 388, window_days = 30,
|
||||
as_of = "2026-08-18T05:17:00.000Z" },
|
||||
"someone/counted")
|
||||
local stats = ri:_findStats(e)
|
||||
eq(stats.total, 1578, "the feed's total is what the card shows")
|
||||
eq(stats.recent, 388, "the trailing-window count rides along")
|
||||
eq(stats.windowDays, 30, "so does the window it covers")
|
||||
eq(stats.asOf, "2026-08-18T05:17:00.000Z", "and when the index read it")
|
||||
eq(#fetched, 0, "a listing with a published total asks GitHub for nothing")
|
||||
end
|
||||
|
||||
-- A bare number is what a cache written before the object shipped holds; it
|
||||
-- is read back through the same resolver rather than migrated.
|
||||
do
|
||||
local ri = launcher()
|
||||
eq(ri:_findStats(entry("legacy", 4321, "someone/legacy")).total, 4321,
|
||||
"a cached scalar count still resolves")
|
||||
eq(#fetched, 0, "and still costs no request")
|
||||
end
|
||||
|
||||
-- Whole-list, not page-by-page: sorting calls the cached read for every entry
|
||||
-- in the index, so all of them have to resolve from the feed alone. A
|
||||
-- listing the feed dates but does not count still resolves, off `latest`.
|
||||
do
|
||||
local ri = launcher()
|
||||
local dated = { id = "dated", title = "Dated", github = "someone/dated",
|
||||
latest = { version = "1.0.0",
|
||||
published_at = "2026-08-11T17:19:00Z" } }
|
||||
local stats = ri:_findStats(dated)
|
||||
eq(stats.latest, "2026-08-11", "the feed's own release blob dates the row")
|
||||
eq(#fetched, 0, "with no repo fetch to wait on")
|
||||
eq(ModUpdate.datesLine(stats.first, stats.latest), "Updated 2026-08-11",
|
||||
"and the card states what it knows instead of a Released ?")
|
||||
end
|
||||
|
||||
-- ------- unknown is unknown: null falls through to the repo, as any other
|
||||
-- missing feed stat does, and a listing with no repo resolves to no counts
|
||||
-- rather than to zero.
|
||||
|
||||
do
|
||||
local ri = launcher()
|
||||
check(ri:_findStats(entry("nulled", nil, "someone/nulled")) == nil,
|
||||
"a null count is not an answer; the repo is still consulted")
|
||||
eq(#fetched, 1, "which is the fetch the panel already made for dates")
|
||||
|
||||
local bare = launcher()
|
||||
local stats = bare:_findStats(entry("no-repo", nil, nil))
|
||||
check(stats ~= nil and stats.total == nil,
|
||||
"a listing with neither counts nor a repo is resolved-but-unknown")
|
||||
check(stats.recent == nil, "and has nothing to trend on")
|
||||
eq(#fetched, 1, "and queues nothing of its own")
|
||||
end
|
||||
|
||||
-- A real zero is not unknown: the index has seen the releases and counted
|
||||
-- none. The card prints it and the sort ranks it above the unknowns.
|
||||
do
|
||||
local ri = launcher()
|
||||
local stats = ri:_findStats(entry("zero", { total = 0 }, "someone/zero"))
|
||||
eq(stats.total, 0, "a counted zero survives as a number")
|
||||
eq(ModUpdate.downloadsShort(stats.total), "0 downloads",
|
||||
"and prints as zero rather than as a dash")
|
||||
end
|
||||
|
||||
-- ------- the panel wiring around those numbers
|
||||
|
||||
local view = (function()
|
||||
local f = assert(io.open("src/import/LauncherView.lua", "r"))
|
||||
local src = f:read("*a")
|
||||
f:close()
|
||||
return src
|
||||
end)()
|
||||
|
||||
check(view:find('key = "trending"', 1, true) ~= nil,
|
||||
"a Trending sort exists")
|
||||
check(view:find('sortKey == "trending" then return stats and stats.recent or %-1'),
|
||||
"Trending orders by the trailing-window count, unknown last")
|
||||
check(view:find('sortKey == "popularity" then return stats and stats.total or %-1'),
|
||||
"Most downloaded orders by the total, unknown last")
|
||||
-- Trending has no meaning on the installed-mods tab, and the persisted key is
|
||||
-- shared, so that panel has to degrade rather than sort on nothing.
|
||||
check(view:find('if scope == "find" then', 1, true) ~= nil,
|
||||
"Trending is offered only where the feed's counts exist")
|
||||
check(view:find('sortKey == "trending" and scope ~= "find"', 1, true) ~= nil,
|
||||
"the MODS tab falls back when the shared sort key is FIND-only")
|
||||
check(view:find('sortKey or "popularity"', 1, true) ~= nil,
|
||||
"the default sort is unchanged")
|
||||
check(view:find("downloadsShort", 1, true) ~= nil,
|
||||
"the browse card prints the abbreviated count")
|
||||
|
||||
T.finish("launcher_mod_downloads")
|
||||
@@ -114,23 +114,110 @@ do
|
||||
"a feed without release stats parses them as absent")
|
||||
end
|
||||
|
||||
-- release stats a feed can publish: total downloads and first/last dates
|
||||
-- release stats a feed can publish: download counts and first/last dates
|
||||
-- ride along additively, so a feed carrying them stays readable by every
|
||||
-- build that predates them
|
||||
do
|
||||
local withStats = {}
|
||||
for k, v in pairs(NUZLOCKE) do withStats[k] = v end
|
||||
withStats.downloads = 1234
|
||||
withStats.downloads = { total = 1578, recent = 388, window_days = 30,
|
||||
as_of = "2026-08-18T05:17:00.000Z" }
|
||||
withStats.first_release = "2024-05-31"
|
||||
withStats.last_release = "2026-07-01"
|
||||
local index = ModIndex.parse(feed({ withStats }))
|
||||
local m = index.mods[1]
|
||||
eq(m.downloads, 1234, "total downloads are kept")
|
||||
eq(m.downloads.total, 1578, "total downloads are kept")
|
||||
eq(m.downloads.recent, 388, "the trailing-window count is kept")
|
||||
eq(m.downloads.window_days, 30, "the window length is kept")
|
||||
eq(m.downloads.as_of, "2026-08-18T05:17:00.000Z", "the read time is kept")
|
||||
eq(m.first_release, "2024-05-31", "first release date is kept")
|
||||
eq(m.last_release, "2026-07-01", "last release date is kept")
|
||||
withStats.downloads = "9999"
|
||||
m = ModIndex.parse(feed({ withStats })).mods[1]
|
||||
eq(m.downloads, 9999, "numeric-string downloads are coerced")
|
||||
end
|
||||
|
||||
-- ------- download counts: unknown is not zero
|
||||
--
|
||||
-- The feed's `downloads` object has three ways of saying "not known" -- the
|
||||
-- field absent, the field null, and a null count inside it -- and every one
|
||||
-- of them has to stay distinguishable from a real zero, because the browse
|
||||
-- card prints one and sorts the other.
|
||||
do
|
||||
local function jsonWith(downloads)
|
||||
local raw = {}
|
||||
for k, v in pairs(NUZLOCKE) do raw[k] = v end
|
||||
raw.downloads = downloads
|
||||
return feed({ raw })
|
||||
end
|
||||
local function statsForJson(text)
|
||||
return ModIndex.downloadStats(ModIndex.parse(text).mods[1])
|
||||
end
|
||||
local function statsFor(downloads)
|
||||
return statsForJson(jsonWith(downloads))
|
||||
end
|
||||
|
||||
check(statsFor(nil) == nil, "an absent downloads field is unknown")
|
||||
-- Json.encode has no null of its own, so the literal the feed actually
|
||||
-- sends is patched into the text.
|
||||
local nulled = jsonWith({}):gsub('"downloads":%[%]', '"downloads":null', 1)
|
||||
check(nulled:find('"downloads":null', 1, true) ~= nil,
|
||||
"the null feed fixture really contains a null")
|
||||
check(statsForJson(nulled) == nil, "a null downloads field is unknown")
|
||||
check(statsFor({}) == nil, "an object with no counts is unknown")
|
||||
eq(statsFor({ total = 0 }).total, 0, "a real zero total survives")
|
||||
|
||||
-- recent / window_days stay null until the index has more than a day of
|
||||
-- history, even once total is a real number.
|
||||
local young = statsFor({ total = 12, as_of = "2026-08-18T05:17:00.000Z" })
|
||||
eq(young.total, 12, "a total with no window yet is still a total")
|
||||
check(young.recent == nil and young.window_days == nil,
|
||||
"no trailing window means no trending figure, not a zero one")
|
||||
|
||||
-- A cache written before the object shipped stored a bare number; it is
|
||||
-- read back through the same door rather than migrated.
|
||||
local legacy = ModIndex.downloadStats({ downloads = 4321 })
|
||||
eq(legacy.total, 4321, "a bare number reads as the total")
|
||||
check(legacy.recent == nil, "and carries no trending figure")
|
||||
check(ModIndex.downloadStats({}) == nil, "a row with no counts is unknown")
|
||||
check(ModIndex.downloadStats(nil) == nil, "no entry is unknown")
|
||||
end
|
||||
|
||||
-- ------- release dates: the feed already dates every listing it can install
|
||||
--
|
||||
-- Sorting must span the whole index, not the pages a reader happened to
|
||||
-- visit, so the "last updated" date comes off the feed's own `latest` blob
|
||||
-- rather than out of a per-mod repo fetch.
|
||||
do
|
||||
local raw = {}
|
||||
for k, v in pairs(NUZLOCKE) do raw[k] = v end
|
||||
local d = ModIndex.releaseDates(ModIndex.parse(feed({ raw })).mods[1])
|
||||
eq(d.latest, "2026-07-31", "latest release date comes from latest.published_at")
|
||||
check(d.first == nil, "the feed cannot date a first release from that alone")
|
||||
|
||||
raw.first_release = "2024-05-31"
|
||||
raw.last_release = "2026-07-01"
|
||||
d = ModIndex.releaseDates(ModIndex.parse(feed({ raw })).mods[1])
|
||||
eq(d.first, "2024-05-31", "an explicit first_release wins")
|
||||
eq(d.latest, "2026-07-01", "an explicit last_release beats the latest blob")
|
||||
|
||||
local bare = {}
|
||||
for k, v in pairs(NUZLOCKE) do bare[k] = v end
|
||||
bare.latest, bare.update_check = nil, "no installable release"
|
||||
check(ModIndex.releaseDates(ModIndex.parse(feed({ bare })).mods[1]) == nil,
|
||||
"a listing with no releases has no dates")
|
||||
check(ModIndex.releaseDates(nil) == nil, "no entry has no dates")
|
||||
end
|
||||
|
||||
-- ------- cache version: a copy written before a field existed cannot answer
|
||||
-- for it, and the TTL is a whole day
|
||||
do
|
||||
local now = os.time()
|
||||
check(ModIndex.cacheFresh({ checkedAt = now,
|
||||
version = ModIndex.CACHE_VERSION }), "a current cache is fresh")
|
||||
check(not ModIndex.cacheFresh({ checkedAt = now }),
|
||||
"an unstamped cache is refetched rather than trusted for a day")
|
||||
check(not ModIndex.cacheFresh({ checkedAt = now,
|
||||
version = ModIndex.CACHE_VERSION - 1 }), "so is an older stamp")
|
||||
check(not ModIndex.cacheFresh({ checkedAt = now - ModIndex.CACHE_TTL - 1,
|
||||
version = ModIndex.CACHE_VERSION }), "and an expired one")
|
||||
end
|
||||
|
||||
-- schema_version is a contract, not a hint: an unknown one is refused rather
|
||||
|
||||
@@ -208,6 +208,32 @@ eq(ModUpdate.formatCount("12345"), "12,345", "numeric strings are accepted")
|
||||
eq(ModUpdate.formatCount(nil), "0", "nil formats as zero")
|
||||
eq(ModUpdate.formatCount("garbage"), "0", "garbage formats as zero")
|
||||
|
||||
-- abbrevCount: what a browse card has room for
|
||||
eq(ModUpdate.abbrevCount(0), "0", "zero abbreviates to itself")
|
||||
eq(ModUpdate.abbrevCount(999), "999", "below 1000 stays exact")
|
||||
eq(ModUpdate.abbrevCount(1000), "1k", "a round thousand drops its decimal")
|
||||
eq(ModUpdate.abbrevCount(1578), "1.6k", "thousands round to one decimal")
|
||||
eq(ModUpdate.abbrevCount(24000), "24k", "a round figure keeps no .0")
|
||||
eq(ModUpdate.abbrevCount(999999), "1M", "no count ever reads as 1000k")
|
||||
eq(ModUpdate.abbrevCount(1234567), "1.2M", "millions abbreviate too")
|
||||
eq(ModUpdate.abbrevCount("1578"), "1.6k", "numeric strings are accepted")
|
||||
eq(ModUpdate.abbrevCount(nil), "?", "an unknown count is not a zero")
|
||||
eq(ModUpdate.abbrevCount("garbage"), "?", "garbage is unknown, not a zero")
|
||||
|
||||
-- downloadsShort / trendingLine: the card and detail forms
|
||||
eq(ModUpdate.downloadsShort(1578), "1.6k downloads", "the card form abbreviates")
|
||||
eq(ModUpdate.downloadsShort(0), "0 downloads", "a real zero is still stated")
|
||||
eq(ModUpdate.downloadsShort(nil), "? downloads",
|
||||
"an unknown count says so rather than vanishing or reading as zero")
|
||||
eq(ModUpdate.trendingLine(388, 30), "388 in the last 30 days",
|
||||
"the trending line names its window")
|
||||
eq(ModUpdate.trendingLine(388, 12), "388 in the last 12 days",
|
||||
"a short window says so rather than claiming a month")
|
||||
eq(ModUpdate.trendingLine(388, nil), "388 recently",
|
||||
"a count with no window still reads")
|
||||
check(ModUpdate.trendingLine(nil, 30) == nil,
|
||||
"no trailing-window count means no trending line")
|
||||
|
||||
-- statsLine: the shared launcher row line, part by part
|
||||
eq(ModUpdate.statsLine(1234567, "2024-05-31", "2026-07-01"),
|
||||
"1,234,567 downloads across all releases - Released 2024-05-31 - Updated 2026-07-01",
|
||||
|
||||
@@ -84,6 +84,63 @@ end
|
||||
local _, _, vw0, vh0 = BorderFill.viewport(0, 0, 160, 144, 0)
|
||||
check(vw0 > 0 and vh0 > 0, "scale 0 falls back to 1 rather than collapsing")
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- VOID FILL (#1418): FADE / WATER / TREES / BLACK
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
eq(BorderFill.voidFill, "fade", "the live fill defaults to FADE")
|
||||
eq(BorderFill.voidFillLabel(), "FADE ", "and prints with a trailing space")
|
||||
|
||||
local johto = { id = "CHERRYGROVE_CITY", tileset = "TILESET_JOHTO",
|
||||
borderBlock = 0x35 }
|
||||
local route = { id = "ROUTE_30", tileset = "TILESET_JOHTO", borderBlock = 0x05 }
|
||||
local kanto = { id = "PALLET_TOWN", tileset = "TILESET_KANTO",
|
||||
borderBlock = 0x0f }
|
||||
local cave = { id = "UNION_CAVE_1F", tileset = "TILESET_CAVE", borderBlock = 0x09 }
|
||||
local house = { id = "PLAYERS_HOUSE_1F", tileset = "TILESET_PLAYERS_HOUSE",
|
||||
borderBlock = 0x00 }
|
||||
|
||||
eq(BorderFill.fillBlock(johto), 0x35, "FADE keeps Cherrygrove's water")
|
||||
eq(BorderFill.fillBlock(route), 0x05, "and Route 30's trees")
|
||||
eq(BorderFill.fillKey(johto), "fade|CHERRYGROVE_CITY",
|
||||
"FADE dissolves per map")
|
||||
eq(BorderFill.fillKey(route) ~= BorderFill.fillKey(johto), true,
|
||||
"so the two maps are different fills")
|
||||
|
||||
BorderFill.setVoidFill("water")
|
||||
eq(BorderFill.fillBlock(johto), 0x35, "WATER uses Cherrygrove's water block")
|
||||
eq(BorderFill.fillBlock(route), 0x35, "on Route 30 too")
|
||||
eq(BorderFill.fillBlock(kanto), 0x43, "and Cinnabar's water on Kanto")
|
||||
eq(BorderFill.fillBlock(cave), 0x09, "but a cave keeps its own border")
|
||||
eq(BorderFill.fillBlock(house), 0x00, "and so does a house")
|
||||
eq(BorderFill.fillKey(johto), BorderFill.fillKey(route),
|
||||
"two Johto maps share one WATER fill")
|
||||
eq(BorderFill.fillKey(johto) ~= BorderFill.fillKey(kanto), true,
|
||||
"Kanto water is a different sheet")
|
||||
|
||||
BorderFill.setVoidFill("trees")
|
||||
eq(BorderFill.fillBlock(johto), 0x05, "TREES uses New Bark's tree wall")
|
||||
eq(BorderFill.fillBlock(kanto), 0x0f, "and Pallet's on Kanto")
|
||||
eq(BorderFill.fillBlock(cave), 0x09, "caves still keep their own")
|
||||
|
||||
BorderFill.setVoidFill("black")
|
||||
eq(BorderFill.fillBlock(johto), false, "BLACK skips the tiled bake")
|
||||
eq(BorderFill.fillBlock(house), false, "indoors too")
|
||||
eq(BorderFill.fillKey(johto), "black", "and is one sheet everywhere")
|
||||
|
||||
BorderFill.setVoidFill("nope")
|
||||
eq(BorderFill.voidFill, "fade", "an unknown mode falls back to FADE")
|
||||
BorderFill.setVoidFill("fade")
|
||||
eq(BorderFill.cycle(1), "water", "cycle steps FADE to WATER")
|
||||
eq(BorderFill.cycle(1), "trees", "then TREES")
|
||||
eq(BorderFill.cycle(1), "black", "then BLACK")
|
||||
eq(BorderFill.cycle(1), "fade", "and wraps to FADE")
|
||||
eq(BorderFill.cycle(-1), "black", "left wraps the other way")
|
||||
BorderFill.applyOptions({ voidFill = "trees" })
|
||||
eq(BorderFill.voidFill, "trees", "applyOptions pushes the saved mode")
|
||||
BorderFill.applyOptions({})
|
||||
eq(BorderFill.voidFill, "fade", "and a missing key restores FADE")
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- The real cache: the map the bug was reported on
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
@@ -213,6 +213,22 @@ previous, alpha = BorderFill.crossfade(owner, trees, "ROUTE_30")
|
||||
check("then it is over", previous, nil)
|
||||
check("and the new block owns the void", alpha, 1)
|
||||
|
||||
-- Forced WATER on two Johto maps shares a fill key, so the dissolve does
|
||||
-- not run identical water against itself.
|
||||
BorderFill.setVoidFill("water")
|
||||
local johtoWater = { id = "CHERRYGROVE_CITY", tileset = "TILESET_JOHTO",
|
||||
borderBlock = 0x35 }
|
||||
local routeTrees = { id = "ROUTE_30", tileset = "TILESET_JOHTO",
|
||||
borderBlock = 0x05 }
|
||||
check("WATER fill keys match across Johto",
|
||||
BorderFill.fillKey(johtoWater), BorderFill.fillKey(routeTrees))
|
||||
local forced = {}
|
||||
BorderFill.crossfade(forced, "water", BorderFill.fillKey(johtoWater))
|
||||
local fromForced = BorderFill.crossfade(forced, "water",
|
||||
BorderFill.fillKey(routeTrees))
|
||||
check("so walking Route 30 does not start a fade", fromForced, nil)
|
||||
BorderFill.setVoidFill("fade")
|
||||
|
||||
-- No key at all (an old caller) is the plain single draw.
|
||||
local bare = {}
|
||||
previous, alpha = BorderFill.crossfade(bare, trees, nil)
|
||||
|
||||
@@ -358,7 +358,7 @@ local options = OptionsMenu.new(optionsGame, {
|
||||
})
|
||||
-- The cart's seven rows, then the port's: CONTROLS, audio, speed, display,
|
||||
-- video mode, the mobile-gated touch three (buildRows), MAX FPS and CANCEL.
|
||||
check("twenty-two rows", #OptionsMenu.ROWS, 22)
|
||||
check("twenty-three rows", #OptionsMenu.ROWS, 23)
|
||||
check("the cart's rows come first", OptionsMenu.ROWS[7].key, "frame")
|
||||
check("then the rebind screen", OptionsMenu.ROWS[8].id, "controls")
|
||||
check("then the port's audio group", OptionsMenu.ROWS[9].key, "musicVol")
|
||||
@@ -861,10 +861,15 @@ check("GBC leaves a palette alone",
|
||||
1)[1], 1)
|
||||
check("and has no present pass", GbcPalette.presentColors(), nil)
|
||||
|
||||
local gbcfxIndex
|
||||
local zoomIndex, gbcfxIndex
|
||||
for i, row in ipairs(OptionsMenu.ROWS) do
|
||||
if row.label == "ZOOM" then zoomIndex = i end
|
||||
if row.label == "GBC FX" then gbcfxIndex = i end
|
||||
end
|
||||
check("VOID FILL follows ZOOM", OptionsMenu.ROWS[zoomIndex + 1].label,
|
||||
"VOID FILL")
|
||||
check("and TILT follows VOID FILL", OptionsMenu.ROWS[zoomIndex + 2].label,
|
||||
"TILT")
|
||||
check("VIDEO MODE follows GBC FX", OptionsMenu.ROWS[gbcfxIndex + 1].label,
|
||||
"VIDEO MODE")
|
||||
check("and TOUCH PAD follows it", OptionsMenu.ROWS[gbcfxIndex + 2].label,
|
||||
@@ -897,6 +902,28 @@ scrollOptions:cycle(videoRow, -1)
|
||||
check("left toggles back to windowed",
|
||||
scrollOptions.options.videoMode, "windowed")
|
||||
|
||||
local voidRow = select(2, rowNamed("VOID FILL"))
|
||||
check("VOID FILL is a row", voidRow ~= nil, true)
|
||||
check("and it defaults to FADE", Save.DEFAULT_OPTIONS.voidFill, "fade")
|
||||
scrollOptions.options.voidFill = "fade"
|
||||
local BorderFill = require("src.world.gen2.BorderFill")
|
||||
BorderFill.setVoidFill("fade")
|
||||
scrollOptions:cycle(voidRow, 1)
|
||||
check("right steps to WATER", scrollOptions.options.voidFill, "water")
|
||||
check("and the live fill tracks it", BorderFill.voidFill, "water")
|
||||
scrollOptions:cycle(voidRow, 1)
|
||||
check("then TREES", scrollOptions.options.voidFill, "trees")
|
||||
scrollOptions:cycle(voidRow, 1)
|
||||
check("then BLACK", scrollOptions.options.voidFill, "black")
|
||||
scrollOptions:cycle(voidRow, 1)
|
||||
check("and wraps back to FADE", scrollOptions.options.voidFill, "fade")
|
||||
check("FADE blanks the longer labels", voidRow.text(scrollOptions.options),
|
||||
"FADE ")
|
||||
scrollOptions.options.voidFill = "water"
|
||||
check("WATER fits the value column", voidRow.text(scrollOptions.options),
|
||||
"WATER")
|
||||
BorderFill.setVoidFill("fade")
|
||||
|
||||
check("text reads WINDOWED", videoRow.text(scrollOptions.options), "WINDOWED")
|
||||
scrollOptions.options.videoMode = "borderless"
|
||||
check("text reads FULL, not BORDERLESS", videoRow.text(scrollOptions.options),
|
||||
|
||||
@@ -179,6 +179,65 @@ check("the cursor is the column-1 arrow on the label row",
|
||||
check("the cursor starts on the first row", cursorAt and cursorAt.y, 2)
|
||||
check("FRAME still prints its literal TYPE", findPrint(":TYPE") ~= nil, true)
|
||||
|
||||
-- ------------------------------------------------------- mart buy list
|
||||
--
|
||||
-- BuyMenu (engine/items/mart.asm) is FadeToMenu + BlankScreen, then
|
||||
-- PlaceMoneyTopRight and ScrollingMenu_UpdateDisplay's ClearWholeMenuBox.
|
||||
-- The list is not MenuBox'd; only the money box and UpdateItemDescription's
|
||||
-- textbox are. The top menu still overlays the mart, so it must not blank.
|
||||
local MartMenu = require("src.ui.gen2.MartMenu")
|
||||
local Save = require("src.core.gen2.Save")
|
||||
|
||||
local martBoxes, martClears = {}, 0
|
||||
local martClear, martBox = Chrome.clear, Chrome.box
|
||||
local martPrint, martCursor = Chrome.print, Chrome.cursor
|
||||
Chrome.clear = function() martClears = martClears + 1 end
|
||||
Chrome.box = function(x, y, w, h)
|
||||
martBoxes[#martBoxes + 1] = { x = x, y = y, w = w, h = h }
|
||||
end
|
||||
Chrome.print = function() end
|
||||
Chrome.cursor = function() end
|
||||
|
||||
local function martHasBox(x, y, w, h)
|
||||
for _, b in ipairs(martBoxes) do
|
||||
if b.x == x and b.y == y and b.w == w and b.h == h then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local martSave = Save.newGame()
|
||||
local martItems = {
|
||||
POTION = { id = "POTION", name = "POTION", pocket = "ITEM", price = 300,
|
||||
description = "Restores HP\nby 20." },
|
||||
}
|
||||
local mart = MartMenu.new({ save = martSave, data = { items = martItems } }, {
|
||||
save = martSave,
|
||||
items = martItems,
|
||||
marts = { lists = { { "POTION" } } },
|
||||
})
|
||||
mart:draw()
|
||||
check("the top menu does not blank the tilemap", martClears, 0)
|
||||
check("and frames BUY/SELL/QUIT", martHasBox(0, 0, 12, 9), true)
|
||||
check("and the welcome speech box", martHasBox(0, 12, 20, 6), true)
|
||||
check("the top menu is not opaque", mart.isOpaque, false)
|
||||
|
||||
martClears, martBoxes = 0, {}
|
||||
mart:enterBuy()
|
||||
mart:draw()
|
||||
check("BUY blanks the screen (BlankScreen)", martClears > 0, true)
|
||||
check("the money box is framed", martHasBox(11, 0, 9, 3), true)
|
||||
check("the description box is framed", martHasBox(0, 12, 20, 6), true)
|
||||
check("the item list is not (ClearWholeMenuBox, not MenuBox)",
|
||||
martHasBox(1, 3, 19, 9), false)
|
||||
check("BlankScreen shadows isOpaque so the letterbox is not the mart",
|
||||
mart.isOpaque, true)
|
||||
|
||||
mart:leaveBuy()
|
||||
check("leaving BUY unshadows isOpaque", mart.isOpaque, false)
|
||||
|
||||
Chrome.clear, Chrome.box = martClear, martBox
|
||||
Chrome.print, Chrome.cursor = martPrint, martCursor
|
||||
|
||||
-- ------------------------------------------------------- one blit scale
|
||||
--
|
||||
-- Every Gold screen paints its 160x144 panel through the same helper. A
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
-- Parity test (#1519): wStepCounter is zeroed by ClearVariablesOnEnterMap
|
||||
-- (engine/overworld/clear_variables.asm:7), reached from EnterMap on a warp
|
||||
-- and from the post-battle tail jump (home/overworld.asm:353), but never from
|
||||
-- CheckMapConnections (home/overworld.asm:675), so a seam crossing keeps phase.
|
||||
-- Self-contained; run via `luajit tests/parity_field_poison_phase.lua`.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
local Data = require("src.core.Data")
|
||||
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
|
||||
local S = require("tests.harness").suite("parity field poison phase")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
require("src.render.Font").load(Data)
|
||||
local Game = require("src.core.Game")
|
||||
local Input = require("src.core.Input")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
local Renderer = require("src.render.Renderer")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local OW = require("src.world.OverworldController")
|
||||
|
||||
Game.data = Data
|
||||
Game.input = Input; Input:init()
|
||||
Game.renderer = Renderer; Renderer:init()
|
||||
Game.stack = StateStack; StateStack:init()
|
||||
Game.save = SaveData.newGame()
|
||||
Game.save.party = { Pokemon.new(Data, "SQUIRTLE", 12) }
|
||||
|
||||
Game.stack:push(OW, "PALLET_TOWN", 5, 6, "down")
|
||||
local ow = Game.stack:top()
|
||||
Game.overworld = ow
|
||||
|
||||
local mon = Game.save.party[1]
|
||||
mon.status = "PSN"
|
||||
|
||||
-- Walk until the poison bites, and report which step it was.
|
||||
local function stepsToTick(limit)
|
||||
for i = 1, limit or 12 do
|
||||
local before = mon.hp
|
||||
ow:applyFieldPoison()
|
||||
if mon.hp < before then return i end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function reheal()
|
||||
mon.hp = mon.stats.hp
|
||||
mon.status = "PSN"
|
||||
end
|
||||
|
||||
-- ---- the free-running four-step cycle ------------------------------------
|
||||
Game.save.poisonSteps = 0
|
||||
eq(stepsToTick(), 4, "a cleared counter bites on the fourth step")
|
||||
reheal()
|
||||
eq(stepsToTick(), 4, "and every fourth step after that")
|
||||
|
||||
reheal()
|
||||
Game.save.poisonSteps = 2
|
||||
eq(stepsToTick(), 2, "a counter two steps into the cycle bites two steps later")
|
||||
|
||||
-- ---- a real map load re-phases it ----------------------------------------
|
||||
reheal()
|
||||
Game.save.poisonSteps = 2
|
||||
ow:setMap("PALLET_TOWN", 5, 6, "down", { via = "warp" })
|
||||
eq(Game.save.poisonSteps, 0, "setMap clears the counter")
|
||||
eq(stepsToTick(), 4, "so the first tick after a warp is four steps out")
|
||||
|
||||
-- ---- a connection crossing does not --------------------------------------
|
||||
reheal()
|
||||
Game.save.poisonSteps = 2
|
||||
ow:setMap("ROUTE_1", 9, 33, "up", { seamless = true, keepMusic = true })
|
||||
eq(Game.save.poisonSteps, 2, "a seamless crossing leaves the counter alone")
|
||||
eq(stepsToTick(), 2, "so the seam does not delay the tick")
|
||||
|
||||
-- ---- a battle return re-phases it ----------------------------------------
|
||||
reheal()
|
||||
Game.save.poisonSteps = 2
|
||||
ow:afterBattle("win", { kind = "wild" })
|
||||
eq(Game.save.poisonSteps, 0, "afterBattle clears the counter")
|
||||
eq(stepsToTick(), 4, "so poison always bites four steps after a battle")
|
||||
|
||||
reheal()
|
||||
Game.save.poisonSteps = 3
|
||||
ow:afterBattle("win", { kind = "trainer", oppClass = "OPP_YOUNGSTER" })
|
||||
eq(stepsToTick(), 4, "trainer battles re-phase it the same way")
|
||||
|
||||
S.finish()
|
||||
Reference in New Issue
Block a user