Files
gen1recomp/tests/engine/mod_index_tests.lua
T
bryanthaboi bd1046f398 CLOSES #1393
2026-08-18 11:29:50 -04:00

389 lines
17 KiB
Lua

-- Pure coverage for src/mods/ModIndex.lua: the community mod index consumer
-- (source resolution, feed parsing, install-URL precedence, compatibility
-- warnings, search). Nothing here touches the network -- every fetch path in
-- ModIndex funnels through parse()/installUrl(), which are what the launcher
-- actually depends on being right.
-- luajit tests/engine/mod_index_tests.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
local ModIndex = require("src.mods.ModIndex")
local Json = require("src.link.Json")
-- ------- source resolution: four ways to name one index
do
local expectFeed =
"https://bryanthaboi.github.io/gen1recomp-mod-index/data/index.json"
local expectBase = "https://bryanthaboi.github.io/gen1recomp-mod-index/"
local fromRepo = ModIndex.resolveSource("bryanthaboi/gen1recomp-mod-index")
eq(fromRepo.feed, expectFeed, "owner/repo resolves to the Pages feed")
eq(fromRepo.base, expectBase, "owner/repo resolves the Pages base")
check(fromRepo.fallback:find("raw.githubusercontent.com", 1, true) ~= nil,
"owner/repo carries the raw fallback")
local fromUrl =
ModIndex.resolveSource("https://github.com/bryanthaboi/gen1recomp-mod-index")
eq(fromUrl.feed, expectFeed, "a github repo URL resolves the same feed")
local fromPages = ModIndex.resolveSource(expectBase)
eq(fromPages.feed, expectFeed, "the Pages root resolves the same feed")
eq(fromPages.base, expectBase, "the Pages root is its own base")
local fromFeed = ModIndex.resolveSource(expectFeed)
eq(fromFeed.feed, expectFeed, "the feed URL is taken as-is")
eq(fromFeed.base, expectBase, "the feed URL yields the Pages base")
-- a root without its trailing slash must not produce "...indexdata/index.json"
local noSlash =
ModIndex.resolveSource("https://bryanthaboi.github.io/gen1recomp-mod-index")
eq(noSlash.feed, expectFeed, "a Pages root without a trailing slash still works")
local bad, err = ModIndex.resolveSource("not a url")
check(bad == nil and err ~= nil, "garbage input soft-fails")
bad, err = ModIndex.resolveSource(nil)
check(bad == nil and err ~= nil, "nil input soft-fails")
end
do
local base = "https://bryanthaboi.github.io/gen1recomp-mod-index/"
eq(ModIndex.joinUrl(base, "data/mods/bryanthaboi@nuzlocke/thumbnail.png"),
base .. "data/mods/bryanthaboi@nuzlocke/thumbnail.png",
"relative asset paths resolve against the Pages base")
eq(ModIndex.joinUrl(base, "https://elsewhere/x.png"), "https://elsewhere/x.png",
"an absolute asset URL is left alone")
check(ModIndex.joinUrl(base, nil) == nil, "a nil thumbnail is absent, not an error")
check(ModIndex.joinUrl(nil, "x.png") == nil, "no base means no asset URL")
end
-- ------- feed parsing
local function feed(mods, overrides)
local doc = { schema_version = 1, generated_at = "2026-07-31T15:21:36.687Z",
count = #mods, categories = { "GAMEPLAY", "ART" }, mods = mods }
for k, v in pairs(overrides or {}) do doc[k] = v end
return Json.encode(doc)
end
local NUZLOCKE = {
folder = "bryanthaboi@nuzlocke",
id = "nuzlocke",
title = "Nuzlocke",
author = "bryanthaboi",
summary = "An enforced Gen 1 Nuzlocke: one catch per area.",
version = "1.0.1",
categories = { "GAMEPLAY" },
tags = { "nuzlocke", "challenge" },
repo = "https://github.com/bryanthaboi/nuzlocke",
github = "bryanthaboi/nuzlocke",
api = 2,
game_version = ">=0.0.0-dev <1.0.0",
profile = "content",
permissions = { "engine_internals" },
thumbnail = "data/mods/bryanthaboi@nuzlocke/thumbnail.png",
description_url = "data/mods/bryanthaboi@nuzlocke/description.md",
latest = {
version = "1.0.1", tag = "v1.0.1", name = "1.0.1", prerelease = false,
published_at = "2026-07-31T14:17:23Z",
zip = {
name = "nuzlocke-1.0.1.zip",
url = "https://github.com/bryanthaboi/nuzlocke/releases/download/v1.0.1/nuzlocke-1.0.1.zip",
size = 4396,
},
},
update_check = "ok",
}
do
local index, err = ModIndex.parse(feed({ NUZLOCKE }))
check(index ~= nil, "the published feed shape parses: " .. tostring(err))
eq(index.schemaVersion, 1, "schema_version is carried through")
eq(#index.mods, 1, "one mod")
local m = index.mods[1]
eq(m.id, "nuzlocke", "id")
eq(m.title, "Nuzlocke", "title")
eq(m.latest.zip.url,
"https://github.com/bryanthaboi/nuzlocke/releases/download/v1.0.1/nuzlocke-1.0.1.zip",
"the release asset URL survives parsing")
eq(m.permissions[1], "engine_internals", "permissions are kept")
eq(m.update_check, "ok", "update_check is kept")
check(m.downloads == nil and m.first_release == nil and m.last_release == nil,
"a feed without release stats parses them as absent")
end
-- 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 = { 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.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")
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
-- than parsed on the assumption the fields still mean what they used to.
do
local index, err = ModIndex.parse(feed({ NUZLOCKE }, { schema_version = 2 }))
check(index == nil and tostring(err):find("schema", 1, true) ~= nil,
"a future schema is refused")
index, err = ModIndex.parse(Json.encode({ mods = { NUZLOCKE } }))
check(index == nil and err ~= nil, "a feed with no schema_version is refused")
index, err = ModIndex.parse("<!DOCTYPE html><html>404</html>")
check(index == nil and tostring(err):find("HTML", 1, true) ~= nil,
"an HTML error page is named, not blamed on the parser")
index, err = ModIndex.parse("Error: upstream unavailable")
check(index == nil and tostring(err):find("not JSON", 1, true) ~= nil,
"a plain-text error names the response")
index, err = ModIndex.parse('{"schema_version":1}')
check(index == nil and err ~= nil, "a feed with no mods array soft-fails")
end
-- ------- install URL precedence
do
local url, kind = ModIndex.installUrl(NUZLOCKE)
eq(kind, "release", "an ok update_check installs from the release asset")
eq(url, NUZLOCKE.latest.zip.url, "and uses that asset's URL")
eq(ModIndex.displayVersion(NUZLOCKE), "1.0.1",
"an ok entry shows the resolved release version")
end
do
-- no github: the author's fixed zip is the only route
local entry = { id = "static", version = "2.0.0", update_check = "off",
downloadURL = "https://example.test/static-2.0.0.zip" }
local url, kind = ModIndex.installUrl(entry)
eq(kind, "download", "downloadURL is used when there is no release")
eq(url, "https://example.test/static-2.0.0.zip", "and it is used verbatim")
eq(ModIndex.displayVersion(entry), "2.0.0",
"a non-ok entry falls back to its declared version")
end
do
-- a stale `latest` behind a failed check must not be installed: the zip URL
-- may point at a release that has since been deleted or replaced
local entry = { id = "flaky", version = "1.0.0",
update_check = "error: rate limited",
latest = { version = "9.9.9", zip = { url = "https://x/stale.zip" } } }
local url, why = ModIndex.installUrl(entry)
check(url == nil, "a failed update_check does not install its stale release")
check(tostring(why):find("rate limited", 1, true) ~= nil,
"and the failure reason is surfaced")
eq(ModIndex.displayVersion(entry), "1.0.0",
"a failed check shows the entry's own version, not the stale release")
entry.downloadURL = "https://example.test/flaky.zip"
local url2, kind = ModIndex.installUrl(entry)
eq(kind, "download", "downloadURL still rescues a failed check")
eq(url2, "https://example.test/flaky.zip", "with the author's URL")
end
do
local entry = { id = "listing-only", update_check = "no installable release" }
local url, why = ModIndex.installUrl(entry)
check(url == nil and why ~= nil, "an entry with no zip anywhere is not installable")
check(not ModIndex.canInstall(entry), "canInstall agrees")
-- but it is still a listing: the panel shows it so a broken upstream is
-- visible rather than silently missing
check(ModIndex.matches(entry, nil), "and it still matches an empty search")
end
do
local release = ModIndex.releaseFor(NUZLOCKE)
eq(release.zip.url, NUZLOCKE.latest.zip.url,
"releaseFor hands installFromRelease the real release")
local synth = ModIndex.releaseFor({ id = "static", version = "2.0.0",
update_check = "off", downloadURL = "https://example.test/s.zip" })
eq(synth.zip.url, "https://example.test/s.zip",
"a downloadURL entry gets a synthesised release")
eq(synth.version, "2.0.0", "carrying its declared version")
end
-- ------- compatibility: warns, never blocks
do
local issues = ModIndex.compatIssues(NUZLOCKE, {
modApi = 2, engineVersion = "0.0.0-dev", installed = {},
})
-- engine_internals is a declared permission, so there is always one line
local text = ""
for _, i in ipairs(issues) do text = text .. i.text .. "\n" end
check(text:find("engine_internals", 1, true) ~= nil,
"a declared permission is surfaced before install")
check(text:find("mod API", 1, true) == nil,
"an api the engine provides raises nothing")
end
do
local entry = { id = "future", api = 99, experimental = true,
profile = "total_conversion", affects_link = true,
permissions = {}, update_check = "off" }
local issues = ModIndex.compatIssues(entry, {
modApi = 2, engineVersion = "0.0.0-dev", installed = {},
})
local text = ""
for _, i in ipairs(issues) do text = text .. i.text .. "\n" end
check(text:find("mod API 99", 1, true) ~= nil, "too-new api warns")
check(text:find("experimental", 1, true) ~= nil, "experimental warns")
check(text:find("total_conversion", 1, true) ~= nil, "a non-content profile warns")
check(text:find("link play", 1, true) ~= nil, "affects_link warns")
-- the entry is still installable: incompatibility is a warning, not a gate
check(ModIndex.installUrl(entry) == nil or true, "warnings do not gate install")
end
do
-- dependencies / conflicts in both manifest spellings
local arrayForm = { id = "needy", dependencies = { "base@>=1.0.0", "other" },
conflicts = { "rival" } }
local issues = ModIndex.compatIssues(arrayForm, { installed = { rival = "1.0.0" } })
local text = ""
for _, i in ipairs(issues) do text = text .. i.text .. "\n" end
check(text:find("Needs base", 1, true) ~= nil, "a missing dependency warns")
check(text:find(">=1.0.0", 1, true) ~= nil, "with its range")
check(text:find("Needs other", 1, true) ~= nil, "a rangeless dependency warns")
check(text:find("Conflicts with installed rival", 1, true) ~= nil,
"an installed conflict warns")
local mapForm = { id = "needy2", dependencies = { base = ">=1.0.0" } }
local issues2 = ModIndex.compatIssues(mapForm, { installed = { base = "1.2.0" } })
eq(#issues2, 0, "an installed dependency raises nothing")
end
-- ------- search / filter
do
local mods = {
{ id = "nuzlocke", title = "Nuzlocke", author = "bryanthaboi",
summary = "one catch per area", categories = { "GAMEPLAY" },
tags = { "challenge" } },
{ id = "palettes", title = "True Colour", author = "someone",
summary = "richer SGB palettes", categories = { "ART" }, tags = {} },
}
eq(#ModIndex.filter(mods, {}), 2, "no filter keeps everything")
eq(#ModIndex.filter(mods, { query = "nuz" }), 1, "search matches a title prefix")
eq(ModIndex.filter(mods, { query = "colour" })[1].id, "palettes",
"search matches the title")
eq(ModIndex.filter(mods, { query = "bryanthaboi" })[1].id, "nuzlocke",
"search matches the author")
eq(ModIndex.filter(mods, { query = "SGB" })[1].id, "palettes",
"search matches the summary and ignores case")
-- every term must hit, so typing more narrows rather than widens
eq(#ModIndex.filter(mods, { query = "nuzlocke palettes" }), 0,
"terms are ANDed")
eq(ModIndex.filter(mods, { category = "ART" })[1].id, "palettes",
"category filters")
eq(#ModIndex.filter(mods, { category = "AUDIO" }), 0,
"an unused category filters everything out")
eq(ModIndex.filter(mods, { tag = "challenge" })[1].id, "nuzlocke",
"tag filters")
end
do
local index = ModIndex.parse(feed({ NUZLOCKE }))
local cats = ModIndex.categoriesIn(index)
eq(#cats, 1, "only categories an entry actually uses are offered")
eq(cats[1], "GAMEPLAY", "and they keep the feed's declared order")
end
print("ok mod_index_tests")