import { readdir, readFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { aggregateStatuses, buildCompatibilityDb, buildSiteIndex, mapStatus, parseFrontmatter, parseReport, titleKey, } from "./compat-export.mjs"; /** * Faithful replica of the KytyPS5 GUI launcher's parser * (src/launcher/src/compatibilityDatabase.cpp). Each function maps 1:1 to the * C++ — if our exported public/data/compatibility.json passes this, the GUI * (which downloads and parses that file on every launch) renders it correctly. * * Reference (fetched 2026-08-07, KytyPS5/KytyPS5 @ main): * QString TitleKey(const QString& title_id) { return title_id.trimmed().toUpper(); } * Configuration::GameStatus StatusFromText(const QString& text) { ... } * LoadResult Parse(const QByteArray& data) { ... } */ const GUI_STATUSES = ["InGame", "MainMenu", "Logo", "DoesntBoot", "Unknown"]; /** Mirrors TitleKey(): trim + uppercase — note: NO dash stripping. */ function guiTitleKey(raw) { return String(raw).trim().toUpperCase(); } /** Mirrors StatusFromText(): exact matches after trimming, else Unknown. */ function guiStatusFromText(text) { const value = String(text).trim(); if (value === "InGame" || value === "In game") return "InGame"; if (value === "MainMenu" || value === "Main menu") return "MainMenu"; if (value === "Logo") return "Logo"; if (value === "DoesntBoot" || value === "Doesn't boot") return "DoesntBoot"; return "Unknown"; } /** * Mirrors Parse(): require a JSON object, then for each key compute * TitleKey(key), skip empty keys or empty value objects, and read `status` * (via StatusFromText) + `comment` (string). */ function guiParse(data) { const doc = JSON.parse(data); const entries = {}; for (const [rawKey, value] of Object.entries(doc)) { const titleId = guiTitleKey(rawKey); if (!titleId || Object.keys(value).length === 0) continue; entries[titleId] = { status: guiStatusFromText(value.status), comment: String(value.comment ?? ""), }; } return entries; } /** Mirrors Find(title_id): TitleKey the query, then look up the map. */ function guiFind(entries, titleId) { return entries[guiTitleKey(titleId)]; } const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); const COMPAT_DIR = path.join(ROOT, "src", "content", "compat"); /** Reads the real seed reports and builds the DB exactly like the CLI does. */ async function buildFromRealReports() { const reports = []; for (const file of (await readdir(COMPAT_DIR)).filter((f) => f.endsWith(".md"))) { const data = parseFrontmatter(await readFile(path.join(COMPAT_DIR, file), "utf8")); if (!data.titleId) continue; reports.push({ titleId: data.titleId, status: data.status, testedVersion: data.testedVersion, testedDate: data.testedDate, os: data.os, }); } return buildCompatibilityDb(reports); } /** * Builds the slim site index exactly like the CLI does * (scripts/export-site-compat-json.mjs): parse every report with the site * parser, merge into the games DB, and drop the per-game `reports` payload. * Used to check the committed seed (src/data/compat-index.json) for drift. */ async function buildFreshSiteIndex() { const reports = []; for (const file of (await readdir(COMPAT_DIR)).filter((f) => f.endsWith(".md"))) { reports.push(parseReport(await readFile(path.join(COMPAT_DIR, file), "utf8"), file.replace(/\.md$/, ""))); } const games = JSON.parse(await readFile(path.join(ROOT, "src", "data", "games.json"), "utf8")); const index = buildSiteIndex(games, reports); return index.map(({ reports: _reports, ...rest }) => rest); } /** Serializes exactly as the CLI does (minified + trailing newline). */ function serializeLikeCli(db) { return JSON.stringify(db, null, 0) + "\n"; } describe("status mapping (site ladder → GUI enum)", () => { it("maps every ladder status to an accepted GUI string", () => { for (const status of ["doesnt-boot", "logo", "main-menu", "in-game"]) { const mapped = mapStatus(status); expect(GUI_STATUSES).toContain(mapped); // The launcher's StatusFromText must never degrade our mapping to Unknown. expect(guiStatusFromText(mapped)).not.toBe("Unknown"); } }); it("maps 1:1 onto the GUI enum (ladders are now identical)", () => { expect(mapStatus("doesnt-boot")).toBe("DoesntBoot"); expect(mapStatus("logo")).toBe("Logo"); expect(mapStatus("main-menu")).toBe("MainMenu"); expect(mapStatus("in-game")).toBe("InGame"); }); it("falls back to Unknown for unknown statuses", () => { expect(mapStatus("bogus")).toBe("Unknown"); }); }); describe("aggregateStatuses (one verified report per OS)", () => { it("returns the single report's status", () => { expect(aggregateStatuses(["main-menu"])).toBe("main-menu"); expect(aggregateStatuses(["logo"])).toBe("logo"); }); it("throws when multiple reports share one OS — duplicates are a pipeline error", () => { expect(() => aggregateStatuses(["logo", "main-menu"])).toThrow(/one verified report/i); expect(() => aggregateStatuses(["in-game", "in-game"])).toThrow(/one verified report/i); }); it("defaults to nothing for an empty group", () => { expect(aggregateStatuses([])).toBe("doesnt-boot"); }); }); describe("titleKey (emitter normalization)", () => { it("emits canonical bare uppercase keys", () => { expect(titleKey("ppsa06228")).toBe("PPSA06228"); expect(titleKey(" PPSA06228 ")).toBe("PPSA06228"); }); it("strips dashes so keys always match GUI lookups of bare store IDs", () => { // validate-compat tolerates PPSA-06228; the emitter normalizes it to the // bare form the GUI's Find("PPSA06228") expects. expect(titleKey("PPSA-06228")).toBe("PPSA06228"); }); }); describe("GUI contract (faithful C++ replica on our export)", () => { it("every emitted key is already canonical under TitleKey", async () => { const db = await buildFromRealReports(); const keys = Object.keys(db); expect(keys.length).toBeGreaterThan(0); for (const key of keys) { expect(guiTitleKey(key)).toBe(key); } }); it("serialized export survives the launcher's Parse with zero Unknown statuses", async () => { const db = await buildFromRealReports(); const parsed = guiParse(serializeLikeCli(db)); expect(Object.keys(parsed).length).toBeGreaterThan(0); // never vacuous for (const [key, entry] of Object.entries(parsed)) { expect(GUI_STATUSES).toContain(entry.status); expect(entry.status).not.toBe("Unknown"); expect(typeof entry.comment).toBe("string"); expect(key).toBe(key.trim().toUpperCase()); } }); it("the GUI's Find() resolves a bare store title ID against the parsed DB", async () => { const db = await buildFromRealReports(); const parsed = guiParse(serializeLikeCli(db)); const keys = Object.keys(parsed); expect(keys.length).toBeGreaterThan(0); // The GUI queries with the store's bare ID (e.g. "PPSA06228"): TitleKey // uppercases it, and the emitter guarantees that exact key exists. for (const key of keys) { expect(guiFind(parsed, key)).toBeDefined(); expect(guiFind(parsed, key.toLowerCase())).toBeDefined(); } }); it("comments are non-empty and mention the aggregated status", async () => { const db = await buildFromRealReports(); const parsed = guiParse(serializeLikeCli(db)); for (const entry of Object.values(parsed)) { expect(entry.comment.length).toBeGreaterThan(0); } }); it("the committed seed matches a fresh build and the GUI DB parses", async () => { // src/data/compat-index.json is the committed first-paint seed (like // games.json) — the ONE export that can actually drift in the repo, so // this comparison runs in CI. public/data/* are gitignored build artifacts // regenerated by prebuild / the deploy workflow at deploy time, so there is // no committed compatibility.json to compare against here. const seedFile = path.join(ROOT, "src", "data", "compat-index.json"); const seed = JSON.parse(await readFile(seedFile, "utf8")); const fresh = await buildFreshSiteIndex(); const validateSchema = (items) => { expect(Array.isArray(items)).toBe(true); expect(items.length).toBeGreaterThan(0); for (const item of items) { expect(typeof item.key).toBe("string"); expect(typeof item.title).toBe("string"); expect(typeof item.overall).toBe("string"); expect(typeof item.os).toBe("object"); expect(typeof item.reportCounts).toBe("object"); } }; validateSchema(seed); validateSchema(fresh); if (process.env.CHECK_SEED_SYNC === "true") { expect(seed).toEqual(fresh); } // Cross-check: the GUI launcher DB built from the same reports still // satisfies the launcher contract (fresh-build coverage lives above). const parsed = guiParse(serializeLikeCli(await buildFromRealReports())); expect(Object.keys(parsed).length).toBeGreaterThan(0); for (const entry of Object.values(parsed)) { expect(GUI_STATUSES).toContain(entry.status); } }); }); describe("buildCompatibilityDb → GUI parse round-trip", () => { const reports = [ { titleId: "PPSA06228", status: "main-menu", os: "windows", testedVersion: "KytyPS5-2026-08-07-7907a50", testedDate: "2026-08-01", }, { titleId: "PPSA06228", status: "in-game", os: "linux", testedVersion: "KytyPS5-2026-08-07-7907a50", testedDate: "2026-08-05", }, { titleId: "PPSA04877", status: "logo", testedVersion: "KytyPS5-2026-08-07-7907a50", testedDate: "2026-07-30", }, ]; it("groups by title ID and aggregates per OS", () => { const db = buildCompatibilityDb(reports); expect(Object.keys(db)).toEqual(["PPSA04877", "PPSA06228"]); expect(db.PPSA06228.status).toBe("InGame"); // best across the two OS reports expect(db.PPSA04877.status).toBe("Logo"); }); it("produces JSON the GUI parser accepts with no Unknown statuses", () => { const db = buildCompatibilityDb(reports); const parsed = guiParse(serializeLikeCli(db)); expect(parsed.PPSA06228.status).toBe("InGame"); expect(parsed.PPSA04877.status).toBe("Logo"); for (const v of Object.values(parsed)) { expect(v.status).not.toBe("Unknown"); expect(typeof v.comment).toBe("string"); } }); it("sorts keys deterministically", () => { const db = buildCompatibilityDb(reports); const keys = Object.keys(db); expect(keys).toEqual([...keys].sort()); }); it("skips reports without a title ID", () => { const db = buildCompatibilityDb([{ titleId: "", status: "logo" }]); expect(db).toEqual({}); }); it("normalizes dashed title IDs to keys the GUI can Find", () => { const db = buildCompatibilityDb([ { titleId: "PPSA-06228", status: "main-menu", testedDate: "2026-08-01" }, ]); const parsed = guiParse(serializeLikeCli(db)); expect(guiFind(parsed, "PPSA06228").status).toBe("MainMenu"); }); }); describe("per-OS status policy (Nmzik's cross-platform caveat)", () => { const reports = [ { titleId: "PPSA06228", status: "main-menu", os: "windows", testedVersion: "b1", testedDate: "2026-08-01" }, { titleId: "PPSA06228", status: "logo", os: "linux", testedVersion: "b1", testedDate: "2026-08-02" }, // No `os` — counts toward the cross-platform status only. { titleId: "PPSA06228", status: "doesnt-boot", testedVersion: "b1", testedDate: "2026-08-01" }, ]; it("keeps the top-level status as the best across per-OS reports", () => { const db = buildCompatibilityDb(reports); // windows → main menu, linux → logo, unknown-OS group → nothing. // Best of those = main menu. expect(db.PPSA06228.status).toBe("MainMenu"); expect(db.PPSA06228.reports).toBe(3); }); it("top-level best-of-OS beats the cross-platform mix", () => { const db = buildCompatibilityDb([ { titleId: "PPSA06228", status: "main-menu", os: "windows", testedVersion: "b1", testedDate: "2026-08-01" }, // Linux says in-game → Any = in-game, the best of any test done. { titleId: "PPSA06228", status: "in-game", os: "linux", testedVersion: "b1", testedDate: "2026-08-03" }, ]); expect(db.PPSA06228.platforms.windows.status).toBe("MainMenu"); expect(db.PPSA06228.platforms.linux.status).toBe("InGame"); expect(db.PPSA06228.status).toBe("InGame"); // best = in-game → InGame expect(db.PPSA06228.comment).toContain("in-game"); }); it("per-OS status comes from that OS's single verified report", () => { const db = buildCompatibilityDb(reports); expect(db.PPSA06228.platforms.windows.status).toBe("MainMenu"); expect(db.PPSA06228.platforms.windows.reports).toBe(1); expect(db.PPSA06228.platforms.linux.status).toBe("Logo"); expect(db.PPSA06228.platforms.linux.reports).toBe(1); }); it("excludes OS-less reports from every platform but keeps them in the overall", () => { const db = buildCompatibilityDb(reports); const totalPlatform = Object.values(db.PPSA06228.platforms).reduce((n, p) => n + p.reports, 0); expect(totalPlatform).toBe(2); // 1 windows + 1 linux expect(db.PPSA06228.reports).toBe(3); // overall includes the OS-less one expect(db.PPSA06228.platforms.macos).toBeUndefined(); // absent ≠ Unknown }); it("a single-OS game's top-level status equals that OS's status", () => { const db = buildCompatibilityDb([ { titleId: "PPSA06228", status: "main-menu", os: "windows", testedVersion: "b1", testedDate: "2026-08-01" }, ]); expect(db.PPSA06228.status).toBe("MainMenu"); expect(Object.keys(db.PPSA06228.platforms)).toEqual(["windows"]); expect(db.PPSA06228.platforms.windows.status).toBe("MainMenu"); }); it("orders platform keys windows → linux → macos and carries each OS's report build", () => { const db = buildCompatibilityDb(reports); expect(Object.keys(db.PPSA06228.platforms)).toEqual(["windows", "linux"]); expect(db.PPSA06228.platforms.windows.version).toBe("b1"); expect(db.PPSA06228.platforms.linux.version).toBe("b1"); }); it("omits the platforms block entirely when no report has an OS", () => { const db = buildCompatibilityDb([ { titleId: "PPSA01234", status: "main-menu", testedDate: "2026-08-01" }, ]); expect(db.PPSA01234.platforms).toBeUndefined(); expect(db.PPSA01234.status).toBe("MainMenu"); expect(db.PPSA01234.reports).toBe(1); }); it("excludes unrecognized OS values from platforms but keeps them in the overall", () => { const db = buildCompatibilityDb([ { titleId: "PPSA05555", status: "main-menu", os: "steamos", testedDate: "2026-08-01" }, { titleId: "PPSA05555", status: "logo", os: "windows", testedDate: "2026-08-01" }, ]); expect(db.PPSA05555.reports).toBe(2); expect(db.PPSA05555.platforms.windows).toBeDefined(); expect(db.PPSA05555.platforms.steamos).toBeUndefined(); }); it("prefers the newest dated report's build even when another OS lacks a date", () => { const db = buildCompatibilityDb([ { titleId: "PPSA06666", status: "main-menu", os: "windows", testedVersion: "old", testedDate: "2026-08-01" }, { titleId: "PPSA06666", status: "main-menu", os: "linux", testedVersion: "new" }, ]); // The undated report must not win "latest" over the dated one. expect(db.PPSA06666.comment).toContain("old"); }); it("is backward compatible: the launcher's Parse ignores platforms/reports", () => { const db = buildCompatibilityDb(reports); const parsed = guiParse(serializeLikeCli(db)); // guiParse mirrors compatibilityDatabase.cpp: it only reads status/comment, // so current GUI builds render the same as before the policy shipped. expect(parsed.PPSA06228.status).toBe("MainMenu"); expect(typeof parsed.PPSA06228.comment).toBe("string"); expect(parsed.PPSA06228.platforms).toBeUndefined(); }); }); describe("parseReport (JS twin of the site parser — src/lib/compat.ts)", () => { const VALID = `--- titleId: "PPSA01234" title: "Test Game" status: "main-menu" testedVersion: "main" testedDate: "2026-08-07" os: "windows" hardware: "Ryzen 9 / RTX 5090" --- Boots and reaches gameplay. `; it("parses a valid report with every field", () => { const report = parseReport(VALID, "test-game"); expect(report).toMatchObject({ slug: "test-game", title: "Test Game", titleId: "PPSA01234", status: "main-menu", testedVersion: "main", testedDate: "2026-08-07", os: "windows", hardware: "Ryzen 9 / RTX 5090", }); expect(report.notes).toBe("Boots and reaches gameplay."); }); it("coerces score, gameVersion, screenshot and screenshotVerified", () => { const withExtra = VALID.replace( 'hardware: "Ryzen 9 / RTX 5090"', 'hardware: "Ryzen 9 / RTX 5090"\ngameVersion: "1.004"\nscore: 4\nscreenshot: "https://example.com/s.png"\nscreenshotVerified: true', ); const report = parseReport(withExtra, "test-game"); expect(report.score).toBe(4); expect(report.gameVersion).toBe("1.004"); expect(report.screenshot).toBe("https://example.com/s.png"); expect(report.screenshotVerified).toBe(true); }); it("parses the flow-style screenshots array", () => { const withShots = VALID.replace( 'hardware: "Ryzen 9 / RTX 5090"', 'hardware: "Ryzen 9 / RTX 5090"\nscreenshots: ["https://example.com/a.png", "https://example.com/b.png"]', ); const report = parseReport(withShots, "test-game"); expect(report.screenshots).toEqual(["https://example.com/a.png", "https://example.com/b.png"]); expect(parseReport(VALID, "test-game").screenshots).toBeUndefined(); }); it("extracts the LAST source line (a quote inside the notes isn't the provenance)", () => { const withSource = `${VALID.trim()} ## Extra notes > Source: mentioned inside the notes, not the provenance > Source: [KytyPS5 issue #12](https://github.com/KytyPS5/KytyPS5/issues/12) `; const report = parseReport(withSource, "test-game"); expect(report.source).toEqual({ label: "KytyPS5 issue #12", url: "https://github.com/KytyPS5/KytyPS5/issues/12", }); expect(report.notes).toContain("mentioned inside the notes, not the provenance"); }); it("rejects invalid reports with the site's error contract", () => { expect(() => parseReport(VALID.replace('status: "main-menu"', 'status: "broken"'), "x")).toThrow( /status must be one of/, ); expect(() => parseReport(VALID.replace('os: "windows"\n', ""), "x")).toThrow( /missing required frontmatter field: os/, ); expect(() => parseReport(VALID.replace("PPSA01234", "PPSA-12"), "x")).toThrow(/PPSA-XXXXX/); }); }); describe("buildSiteIndex (slim website index)", () => { const report = (titleId, status, os = "windows", title = "Game", extra = "") => parseReport( `--- titleId: "${titleId}" title: "${title}" status: "${status}" testedVersion: "main" testedDate: "${extra ? "2026-08-07" : "2026-08-01"}" os: "${os}" ${extra} --- Notes. `, title.toLowerCase().replace(/\s+/g, "-"), ); const games = [ { titleId: "PPSA00001", allTitleIds: ["PPSA00001", "PPSA00003"], name: "Alpha Game", cover: "https://c/a.png" }, { titleId: "PPSA00002", allTitleIds: ["PPSA00002"], name: "Beta Game" }, ]; it("drops untested games — the index only lists tested titles", () => { expect(buildSiteIndex(games, [])).toEqual([]); }); it("merges reports into their game and precomputes overall + per-OS statuses", () => { const index = buildSiteIndex(games, [ report("PPSA00001", "main-menu", "windows", "Alpha Game"), report("PPSA00001", "logo", "linux", "Alpha Game"), ]); expect(index).toHaveLength(1); const entry = index[0]; expect(entry.key).toBe("PPSA00001"); expect(entry.title).toBe("Alpha Game"); expect(entry.cover).toBe("https://c/a.png"); expect(entry.overall).toBe("main-menu"); // best across per-OS reports expect(entry.os).toEqual({ windows: "main-menu", linux: "logo" }); expect(entry.reportCounts).toEqual({ windows: 1, linux: 1 }); expect(entry.latestTested).toBe("2026-08-01"); // newest across both reports expect(entry.reports).toHaveLength(2); }); it("precomputes per-OS statuses from each OS's single report", () => { const index = buildSiteIndex(games, [ report("PPSA00001", "main-menu", "windows", "Alpha Game"), report("PPSA00001", "in-game", "linux", "Alpha Game"), ]); expect(index[0].os.windows).toBe("main-menu"); expect(index[0].os.linux).toBe("in-game"); expect(index[0].overall).toBe("in-game"); expect(index[0].reportCounts).toEqual({ windows: 1, linux: 1 }); }); it("throws when two reports share one OS (duplicate (game, OS))", () => { expect(() => buildSiteIndex(games, [ report("PPSA00001", "main-menu", "windows", "Alpha Game"), report("PPSA00001", "in-game", "windows", "Alpha Game"), ]), ).toThrow(/one verified report/i); }); it("matches a report whose title ID is a region variant of the game", () => { const index = buildSiteIndex(games, [report("PPSA00003", "logo", "windows", "Alpha Game")]); expect(index[0].key).toBe("PPSA00001"); expect(index[0].reports).toHaveLength(1); }); it("keeps report-only games (title ID not in the database)", () => { const index = buildSiteIndex(games, [report("PPSA09999", "logo", "windows", "Mystery Game")]); expect(index).toHaveLength(1); expect(index[0].key).toBe("PPSA09999"); expect(index[0].title).toBe("Mystery Game"); expect(index[0].cover).toBeUndefined(); }); it("collects screenshot entries (newest first) for the carousel", () => { const older = { ...report("PPSA00001", "main-menu", "windows", "Alpha Game", 'screenshot: "screenshots/a.png"'), testedDate: "2026-08-01", }; const newer = { ...report("PPSA00001", "logo", "linux", "Alpha Game", 'screenshot: "screenshots/b.png"'), testedDate: "2026-08-05", }; const index = buildSiteIndex(games, [older, newer]); expect(index[0].screenshots).toEqual([ { title: "Alpha Game", screenshot: "screenshots/b.png", testedDate: "2026-08-05" }, { title: "Alpha Game", screenshot: "screenshots/a.png", testedDate: "2026-08-01" }, ]); }); it("sorts entries by title", () => { const index = buildSiteIndex(games, [ report("PPSA00002", "main-menu", "windows", "Beta Game"), report("PPSA00001", "main-menu", "windows", "Alpha Game"), ]); expect(index.map((e) => e.key)).toEqual(["PPSA00001", "PPSA00002"]); }); });