Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 32 additions & 6 deletions src/domain/mods/installedFilters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,32 @@ export const NO_INSTALLED_MOD_FILTERS: InstalledModFilters = { author: "", tags:
/** The shape a ModDB game-version release tag has: a dotted number, never a leading "v". */
const GAME_VERSION_TAG = /^\d+(\.\d+)+/

/**
* Keeps only the string entries of a list handed to us by the mod database or a modinfo.
*
* The API's documented shape says `tags` is a list of strings. The live detail endpoint returns
* `["Cosmetics", "Crafting", "Storage", null]` for Vanilla Variants (#370). Every reader below folds
* case or tests a prefix, both of which throw on `null`, and one throw during the first render blanks
* the whole page. So nothing in this module trusts the shape: whatever is not a string is dropped, and
* a field that is not a list at all reads as empty.
*/
function textEntries(values: unknown): string[] {
return Array.isArray(values) ? values.filter((value): value is string => typeof value === "string") : []
}

/**
* The releases the mod database lists for a mod, keeping only the entries that are objects.
*
* Guarding the outer field is not enough: `{ releases: [null] }` passes an array check and then
* `release.tags` throws on the entry, the same render crash this module exists to rule out. So each
* entry is checked too, and a missing or malformed field reads as no releases at all.
*/
function releasesOf(mod: InstalledModType): readonly { tags?: unknown }[] {
const releases: unknown = mod._mod?.releases
if (!Array.isArray(releases)) return []
return releases.filter((release): release is { tags?: unknown } => typeof release === "object" && release !== null)
}

/**
* Deduplicates ignoring case, keeping the first spelling seen.
*
Expand All @@ -42,7 +68,7 @@ function uniqueIgnoringCase(values: readonly string[]): string[] {

/** Every author across the scan, A to Z. Read from the local modinfo, so this axis needs no network. */
export function installedModAuthors(mods: readonly InstalledModType[]): string[] {
return uniqueIgnoringCase(mods.flatMap((mod) => mod.authors ?? [])).sort((a, b) => a.localeCompare(b))
return uniqueIgnoringCase(mods.flatMap((mod) => textEntries(mod.authors))).sort((a, b) => a.localeCompare(b))
}

/**
Expand All @@ -52,7 +78,7 @@ export function installedModAuthors(mods: readonly InstalledModType[]): string[]
* modinfo.ts shows, so this list is empty for a folder the ModDB cannot answer for.
*/
export function installedModTags(mods: readonly InstalledModType[]): string[] {
return uniqueIgnoringCase(mods.flatMap((mod) => mod._mod?.tags ?? [])).sort((a, b) => a.localeCompare(b))
return uniqueIgnoringCase(mods.flatMap((mod) => textEntries(mod._mod?.tags))).sort((a, b) => a.localeCompare(b))
}

/**
Expand All @@ -63,21 +89,21 @@ export function installedModTags(mods: readonly InstalledModType[]): string[] {
* are the category tags the other dropdown offers.
*/
export function installedModGameVersions(mods: readonly InstalledModType[]): string[] {
const tags = mods.flatMap((mod) => (mod._mod?.releases ?? []).flatMap((release) => release.tags ?? []))
const tags = mods.flatMap((mod) => releasesOf(mod).flatMap((release) => textEntries(release.tags)))
return Array.from(new Set(tags.filter((tag) => GAME_VERSION_TAG.test(tag)))).sort((a, b) => compareVersions(b, a))
}

/** Ignores case, and counts every credited author rather than only the first one. */
function matchesAuthor(mod: InstalledModType, author: string): boolean {
if (author === "") return true
const wanted = author.toLowerCase()
return (mod.authors ?? []).some((name) => name.toLowerCase() === wanted)
return textEntries(mod.authors).some((name) => name.toLowerCase() === wanted)
}

/** Every selected tag has to be present, so picking a second tag narrows the list rather than widening it. */
function matchesTags(mod: InstalledModType, tags: readonly string[]): boolean {
if (tags.length < 1) return true
const modTags = (mod._mod?.tags ?? []).map((tag) => tag.toLowerCase())
const modTags = textEntries(mod._mod?.tags).map((tag) => tag.toLowerCase())
return tags.every((tag) => modTags.includes(tag.toLowerCase()))
}

Expand All @@ -91,7 +117,7 @@ function matchesTags(mod: InstalledModType, tags: readonly string[]): boolean {
*/
function matchesGameVersion(mod: InstalledModType, gameVersion: string): boolean {
if (gameVersion === "") return true
return (mod._mod?.releases ?? []).some((release) => evaluateModCompatibility(release.tags ?? [], gameVersion) !== "undeclared")
return releasesOf(mod).some((release) => evaluateModCompatibility(textEntries(release.tags), gameVersion) !== "undeclared")
}

/** All three axes at once. A mod clears every one of them, so each pick narrows what is left. */
Expand Down
49 changes: 49 additions & 0 deletions tests/domain/mods/installedFilters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,3 +167,52 @@ describe("matching one installed mod against the filters", () => {
assert.equal(hasActiveInstalledModFilters(filters({ gameVersion: "1.20.0" })), true)
})
})

describe("real mod database shapes (#370)", () => {
// The detail endpoint returns exactly this for Vanilla Variants: a null among the category tags.
// The documented shape says strings only, and the launcher used to believe it.
const nullTag = ["Cosmetics", "Crafting", "Storage", null] as unknown as string[]
const vanillaVariants = aMod("VanillaVariants", { _mod: aDetail(nullTag, [["1.21.0", "1.21.1"]]) })

it("collects category tags past a null in the list instead of throwing", () => {
assert.deepEqual(installedModTags([vanillaVariants]), ["Cosmetics", "Crafting", "Storage"])
})

it("still matches the tags that are real when a null sits beside them", () => {
assert.equal(matchesInstalledModFilters(vanillaVariants, { ...NO_INSTALLED_MOD_FILTERS, tags: ["storage"] }), true)
assert.equal(matchesInstalledModFilters(vanillaVariants, { ...NO_INSTALLED_MOD_FILTERS, tags: ["Food"] }), false)
})

it("collects game versions past a null in a release's tags", () => {
const releaseWithNull = [["1.21.0", null, "1.21.1"]] as unknown as string[][]
const mod = aMod("Odd", { _mod: aDetail(["Other"], releaseWithNull) })
assert.deepEqual(installedModGameVersions([mod]), ["1.21.1", "1.21.0"])
assert.equal(matchesInstalledModFilters(mod, { ...NO_INSTALLED_MOD_FILTERS, gameVersion: "1.21.0" }), true)
})

it("skips a null entry inside an otherwise valid releases list", () => {
// { releases: [null, {...}] } passes an array check and used to throw on `release.tags`.
const detail = aDetail(["Other"], [["1.21.0"]])
const mod = aMod("Odd", { _mod: { ...detail, releases: [null, ...detail.releases] as unknown as typeof detail.releases } })
assert.deepEqual(installedModGameVersions([mod]), ["1.21.0"])
assert.equal(matchesInstalledModFilters(mod, { ...NO_INSTALLED_MOD_FILTERS, gameVersion: "1.21.0" }), true)
})

it("ignores an author entry that is not a string", () => {
const mod = aMod("Odd", { authors: ["Ann", null, 42, "Bob"] as unknown as string[] })
assert.deepEqual(installedModAuthors([mod]), ["Ann", "Bob"])
assert.equal(matchesInstalledModFilters(mod, { ...NO_INSTALLED_MOD_FILTERS, author: "bob" }), true)
})

it("reads a tags field that is not a list as no tags, and releases that are not a list as no releases", () => {
const detail = aDetail([], [])
// A string rather than null on purpose: `null ?? []` would read as empty by accident, and the
// guard has to hold for anything that is not a list, not only for the absent case.
const mod = aMod("Odd", { _mod: { ...detail, tags: "Cosmetics" as unknown as string[], releases: "1.21.0" as unknown as typeof detail.releases } })
assert.deepEqual(installedModTags([mod]), [])
assert.deepEqual(installedModGameVersions([mod]), [])
assert.equal(matchesInstalledModFilters(mod, { ...NO_INSTALLED_MOD_FILTERS, tags: ["cosmetics"] }), false)
assert.equal(matchesInstalledModFilters(mod, { ...NO_INSTALLED_MOD_FILTERS, gameVersion: "1.21.0" }), false)
assert.equal(filterInstalledMods([mod], NO_INSTALLED_MOD_FILTERS).length, 1)
})
})
Loading