refactor: workspace restructure — contract package + pure domain ported, tests green
- bun workspaces: contract/ (Schemas = source of truth: config raw↔resolved,
domain DTOs, HttpApi contract, API errors) + plugin/ + ui/
- pure domain moved src/{engine,art}→plugin/src/{domain,art}, types now
imported from @rom-manager/contract and @punktfunk/plugin-kit/wire (the
hand-copied wire.ts dies), loggers injected instead of module-global
- ui.* and devEntry dropped from the config shape (standalone server is
gone; stale keys tolerated on decode, dropped by the next save)
- all 48 domain tests ported and green BEFORE any Effect wiring
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,88 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
type DetectEnv,
|
||||
detectEmulator,
|
||||
emulatorById,
|
||||
} from "../src/emulators.js";
|
||||
import type { Os } from "../src/quote.js";
|
||||
|
||||
const env = (o: {
|
||||
os?: Os;
|
||||
which?: Record<string, string>;
|
||||
files?: string[];
|
||||
flatpaks?: string[];
|
||||
dirs?: Record<string, string[]>;
|
||||
}): DetectEnv => ({
|
||||
os: o.os ?? "linux",
|
||||
which: (c) => o.which?.[c] ?? null,
|
||||
fileExists: (p) => (o.files ?? []).includes(p),
|
||||
flatpakInstalled: (id) => (o.flatpaks ?? []).includes(id),
|
||||
listDir: (p) => o.dirs?.[p] ?? [],
|
||||
expandPath: (p) =>
|
||||
p
|
||||
.replace(/^~/, "/home/u")
|
||||
.replace(/%APPDATA%/, "C:\\Users\\u\\AppData\\Roaming")
|
||||
.replace(/%LOCALAPPDATA%/, "C:\\Users\\u\\AppData\\Local"),
|
||||
});
|
||||
|
||||
const retroarch = emulatorById("retroarch")!;
|
||||
const ppsspp = emulatorById("ppsspp")!;
|
||||
const dolphin = emulatorById("dolphin")!;
|
||||
|
||||
describe("detectEmulator", () => {
|
||||
test("RetroArch on PATH, cores scanned + quoted exe token", () => {
|
||||
const d = detectEmulator(
|
||||
retroarch,
|
||||
env({
|
||||
which: { retroarch: "/usr/bin/retroarch" },
|
||||
dirs: {
|
||||
"/home/u/.config/retroarch/cores": [
|
||||
"snes9x_libretro.so",
|
||||
"mgba_libretro.so",
|
||||
"notes.txt",
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(d?.via).toBe("path");
|
||||
expect(d?.exeToken).toBe("'/usr/bin/retroarch'");
|
||||
expect(d?.coresDir).toBe("/home/u/.config/retroarch/cores");
|
||||
expect(d?.cores).toEqual(["mgba", "snes9x"]);
|
||||
});
|
||||
|
||||
test("RetroArch via Flatpak when not on PATH", () => {
|
||||
const d = detectEmulator(
|
||||
retroarch,
|
||||
env({
|
||||
flatpaks: ["org.libretro.RetroArch"],
|
||||
dirs: {
|
||||
"/home/u/.var/app/org.libretro.RetroArch/config/retroarch/cores": [
|
||||
"snes9x_libretro.so",
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(d?.via).toBe("flatpak");
|
||||
expect(d?.exeToken).toBe("flatpak run org.libretro.RetroArch");
|
||||
expect(d?.appId).toBe("org.libretro.RetroArch");
|
||||
expect(d?.cores).toEqual(["snes9x"]);
|
||||
});
|
||||
|
||||
test("Windows path with spaces is double-quoted", () => {
|
||||
const d = detectEmulator(
|
||||
ppsspp,
|
||||
env({
|
||||
os: "windows",
|
||||
files: ["C:\\Program Files\\PPSSPP\\PPSSPPWindows64.exe"],
|
||||
}),
|
||||
);
|
||||
expect(d?.via).toBe("file");
|
||||
expect(d?.exeToken).toBe(
|
||||
'"C:\\Program Files\\PPSSPP\\PPSSPPWindows64.exe"',
|
||||
);
|
||||
});
|
||||
|
||||
test("returns null when not installed", () => {
|
||||
expect(detectEmulator(dolphin, env({}))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
buildUrl,
|
||||
candidateNames,
|
||||
LibretroProvider,
|
||||
sanitizeName,
|
||||
} from "../src/art/libretro.js";
|
||||
import { parseTitle } from "../src/engine/titles.js";
|
||||
import { platformById } from "../src/platforms.js";
|
||||
|
||||
const snes = platformById("snes")!;
|
||||
const nswitch = platformById("switch")!;
|
||||
|
||||
describe("libretro name handling", () => {
|
||||
test("sanitizeName replaces the forbidden characters with underscore", () => {
|
||||
expect(sanitizeName("Ratchet & Clank")).toBe("Ratchet _ Clank");
|
||||
expect(sanitizeName("Where in the World?")).toBe("Where in the World_");
|
||||
});
|
||||
|
||||
test("candidateNames walks exact → region → base", () => {
|
||||
const names = candidateNames(parseTitle("Chrono Trigger (USA) (Rev 1)"));
|
||||
expect(names[0]).toBe("Chrono Trigger (USA) (Rev 1)"); // exact
|
||||
expect(names).toContain("Chrono Trigger (USA)"); // region-kept
|
||||
expect(names).toContain("Chrono Trigger (Europe)"); // region-relaxed
|
||||
expect(names.at(-1)).toBe("Chrono Trigger"); // bare
|
||||
});
|
||||
});
|
||||
|
||||
describe("LibretroProvider", () => {
|
||||
test("returns the first probe hit as a portrait", async () => {
|
||||
const hit = buildUrl(
|
||||
snes.libretroSystem!,
|
||||
sanitizeName("Chrono Trigger (USA)"),
|
||||
);
|
||||
const provider = new LibretroProvider((url) =>
|
||||
Promise.resolve(url === hit),
|
||||
);
|
||||
const art = await provider.resolve(
|
||||
snes,
|
||||
parseTitle("Chrono Trigger (USA)"),
|
||||
);
|
||||
expect(art).toEqual({ portrait: hit });
|
||||
});
|
||||
|
||||
test("returns null when nothing matches", async () => {
|
||||
const provider = new LibretroProvider(() => Promise.resolve(false));
|
||||
expect(
|
||||
await provider.resolve(snes, parseTitle("Nonexistent Game")),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for a platform with no libretro system (Switch)", async () => {
|
||||
const provider = new LibretroProvider(() => Promise.resolve(true));
|
||||
expect(await provider.resolve(nswitch, parseTitle("Zelda"))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,119 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { quotePosix, quoteWindows, renderCommand } from "../src/quote.js";
|
||||
|
||||
describe("quotePosix", () => {
|
||||
test("wraps a plain path in single quotes", () => {
|
||||
const r = quotePosix("/roms/snes/Chrono Trigger (USA).sfc");
|
||||
expect(r).toEqual({
|
||||
ok: true,
|
||||
value: "'/roms/snes/Chrono Trigger (USA).sfc'",
|
||||
});
|
||||
});
|
||||
|
||||
test("escapes embedded single quotes", () => {
|
||||
const r = quotePosix("it's a rom.sfc");
|
||||
expect(r.ok && r.value).toBe("'it'\\''s a rom.sfc'");
|
||||
});
|
||||
|
||||
test("neutralises shell metacharacters (no expansion possible)", () => {
|
||||
for (const evil of [
|
||||
"$(rm -rf ~).sfc",
|
||||
"`id`.sfc",
|
||||
"; rm x.sfc",
|
||||
"a && b.sfc",
|
||||
"$HOME.sfc",
|
||||
]) {
|
||||
const r = quotePosix(evil);
|
||||
expect(r.ok).toBe(true);
|
||||
// Everything stays inside a single-quoted literal.
|
||||
if (r.ok)
|
||||
expect(r.value.startsWith("'") && r.value.endsWith("'")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects a NUL byte", () => {
|
||||
expect(quotePosix("a\0b").ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("quoteWindows", () => {
|
||||
test("double-quotes a normal path", () => {
|
||||
const r = quoteWindows("C:\\Games\\Chrono Trigger (USA).sfc");
|
||||
expect(r).toEqual({
|
||||
ok: true,
|
||||
value: '"C:\\Games\\Chrono Trigger (USA).sfc"',
|
||||
});
|
||||
});
|
||||
|
||||
test("refuses each cmd.exe metacharacter", () => {
|
||||
for (const bad of [
|
||||
'a"b',
|
||||
"a%b",
|
||||
"a!b",
|
||||
"a^b",
|
||||
"a&b",
|
||||
"a|b",
|
||||
"a<b",
|
||||
"a>b",
|
||||
]) {
|
||||
expect(quoteWindows(bad).ok).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test("refuses control characters", () => {
|
||||
expect(quoteWindows("a\nb").ok).toBe(false);
|
||||
expect(quoteWindows("a\tb").ok).toBe(false);
|
||||
});
|
||||
|
||||
test("doubles a trailing backslash run so the closing quote is not escaped", () => {
|
||||
const r = quoteWindows("C:\\dir\\");
|
||||
expect(r.ok && r.value).toBe('"C:\\dir\\\\"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderCommand", () => {
|
||||
test("POSIX: quotes rom + core, inserts exe verbatim", () => {
|
||||
const r = renderCommand(
|
||||
"{exe} -f -L {core} {rom}",
|
||||
{
|
||||
exe: "flatpak run org.libretro.RetroArch",
|
||||
rom: "/roms/Chrono Trigger (USA).sfc",
|
||||
core: "/cores/snes9x_libretro.so",
|
||||
},
|
||||
"linux",
|
||||
);
|
||||
expect(r).toEqual({
|
||||
ok: true,
|
||||
command:
|
||||
"flatpak run org.libretro.RetroArch -f -L '/cores/snes9x_libretro.so' '/roms/Chrono Trigger (USA).sfc'",
|
||||
});
|
||||
});
|
||||
|
||||
test("Windows: a hostile ROM name fails the whole render", () => {
|
||||
const r = renderCommand(
|
||||
"{exe} {rom}",
|
||||
{ exe: '"C:\\ra.exe"', rom: "C:\\roms\\a&b.sfc" },
|
||||
"windows",
|
||||
);
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
test("appends trusted extra args verbatim", () => {
|
||||
const r = renderCommand(
|
||||
"{exe} {rom}",
|
||||
{ exe: "mgba", rom: "/x.gba" },
|
||||
"linux",
|
||||
"--scale 4",
|
||||
);
|
||||
expect(r.ok && r.command).toBe("mgba '/x.gba' --scale 4");
|
||||
});
|
||||
|
||||
test("errors on an unfilled placeholder", () => {
|
||||
const r = renderCommand(
|
||||
"{exe} -L {core} {rom}",
|
||||
{ exe: "ra", rom: "/x.sfc" },
|
||||
"linux",
|
||||
);
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,137 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { resolveConfig } from "../src/config.js";
|
||||
import type { DetectedEmulator } from "../src/emulators.js";
|
||||
import { computeDesired } from "../src/engine/reconcile.js";
|
||||
import type { RomCandidate } from "../src/engine/scanner.js";
|
||||
import type { ArtVerdict } from "../src/state.js";
|
||||
|
||||
const RA: DetectedEmulator = {
|
||||
id: "retroarch",
|
||||
name: "RetroArch",
|
||||
template: "{exe} -f -L {core} {rom}",
|
||||
supportsArchives: true,
|
||||
exeToken: "'/usr/bin/retroarch'",
|
||||
via: "path",
|
||||
coresDir: "/cores",
|
||||
cores: ["snes9x"],
|
||||
};
|
||||
|
||||
const cand = (rel: string, over: Partial<RomCandidate> = {}): RomCandidate => ({
|
||||
platform: "snes",
|
||||
rootDir: "/roms/snes",
|
||||
absPath: `/roms/snes/${rel}`,
|
||||
relPath: rel,
|
||||
ext: ".sfc",
|
||||
stem: rel.replace(/\.[^.]+$/, ""),
|
||||
isArchive: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
const base = { roots: [{ dir: "/roms/snes", platform: "snes" }] };
|
||||
const run = (
|
||||
raw: Parameters<typeof resolveConfig>[0],
|
||||
scan: RomCandidate[],
|
||||
art: Record<string, ArtVerdict> = {},
|
||||
detected = [RA],
|
||||
) =>
|
||||
computeDesired({
|
||||
config: resolveConfig({ ...base, ...raw }),
|
||||
scan,
|
||||
detected,
|
||||
art,
|
||||
os: "linux",
|
||||
});
|
||||
|
||||
describe("computeDesired", () => {
|
||||
test("produces a quoted launch command with the resolved core path", () => {
|
||||
const { entries } = run({}, [cand("Chrono Trigger (USA).sfc")]);
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0]).toMatchObject({
|
||||
external_id: "snes/Chrono Trigger (USA).sfc",
|
||||
title: "Chrono Trigger",
|
||||
launch: {
|
||||
kind: "command",
|
||||
value:
|
||||
"'/usr/bin/retroarch' -f -L '/cores/snes9x_libretro.so' '/roms/snes/Chrono Trigger (USA).sfc'",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("per-game exclude drops the entry", () => {
|
||||
const { entries } = run(
|
||||
{ gameOverrides: { "snes/A.sfc": { exclude: true } } },
|
||||
[cand("A.sfc"), cand("B.sfc")],
|
||||
);
|
||||
expect(entries.map((e) => e.external_id)).toEqual(["snes/B.sfc"]);
|
||||
});
|
||||
|
||||
test("an undetected emulator is skipped with a reason", () => {
|
||||
const { entries, report } = run({}, [cand("A.sfc")], {}, []);
|
||||
expect(entries).toHaveLength(0);
|
||||
expect(report.skipped[0]?.reason).toContain("not detected");
|
||||
});
|
||||
|
||||
test("region dedupe keeps the preferred release", () => {
|
||||
const { entries, report } = run({ sync: { dedupeRegions: ["snes"] } }, [
|
||||
cand("Sonic (USA).sfc"),
|
||||
cand("Sonic (Europe).sfc"),
|
||||
]);
|
||||
expect(entries.map((e) => e.external_id)).toEqual(["snes/Sonic (USA).sfc"]);
|
||||
expect(report.skipped.some((s) => s.reason.includes("deduped"))).toBe(true);
|
||||
});
|
||||
|
||||
test("attaches cached artwork, keyed by external_id", () => {
|
||||
const art = {
|
||||
"snes/A.sfc": {
|
||||
art: { portrait: "http://x/p.png", hero: "http://x/h.png" },
|
||||
provider: "steamgriddb:ab",
|
||||
matcher: 1,
|
||||
},
|
||||
};
|
||||
const { entries } = run({}, [cand("A.sfc")], art);
|
||||
expect(entries[0]?.art).toEqual({
|
||||
portrait: "http://x/p.png",
|
||||
hero: "http://x/h.png",
|
||||
});
|
||||
});
|
||||
|
||||
test("per-game art override wins on portrait but keeps other cached art", () => {
|
||||
const art = {
|
||||
"snes/A.sfc": {
|
||||
art: { portrait: "http://x/p.png", hero: "http://x/h.png" },
|
||||
provider: "p",
|
||||
matcher: 1,
|
||||
},
|
||||
};
|
||||
const { entries } = run(
|
||||
{ gameOverrides: { "snes/A.sfc": { art: "http://o/p.png" } } },
|
||||
[cand("A.sfc")],
|
||||
art,
|
||||
);
|
||||
expect(entries[0]?.art).toEqual({
|
||||
portrait: "http://o/p.png",
|
||||
hero: "http://x/h.png",
|
||||
});
|
||||
});
|
||||
|
||||
test("archive gating skips a zip when the emulator can't read archives", () => {
|
||||
const noArc = { ...RA, supportsArchives: false };
|
||||
const { entries, report } = run(
|
||||
{},
|
||||
[cand("A.zip", { ext: ".zip", isArchive: true })],
|
||||
{},
|
||||
[noArc],
|
||||
);
|
||||
expect(entries).toHaveLength(0);
|
||||
expect(report.skipped[0]?.reason).toContain("archive");
|
||||
});
|
||||
|
||||
test("scale guard truncates deterministically and reports it", () => {
|
||||
const { entries, report } = run({ sync: { maxEntries: 1 } }, [
|
||||
cand("B.sfc"),
|
||||
cand("A.sfc"),
|
||||
]);
|
||||
expect(entries.map((e) => e.external_id)).toEqual(["snes/A.sfc"]); // sorted, first kept
|
||||
expect(report.truncated).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1,131 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
type DirEnt,
|
||||
makeExcluder,
|
||||
type ScanFs,
|
||||
scanRoot,
|
||||
} from "../src/engine/scanner.js";
|
||||
import { platformById } from "../src/platforms.js";
|
||||
|
||||
/** A synthetic filesystem from a flat `{ absPath: contents }` map (dirs inferred). */
|
||||
const synthFs = (files: Record<string, string>): ScanFs => {
|
||||
const norm = (p: string) => p.replace(/\/+$/, "");
|
||||
return {
|
||||
readDir(dir): DirEnt[] {
|
||||
const base = norm(dir);
|
||||
const children = new Map<string, boolean>(); // name → isDir
|
||||
for (const f of Object.keys(files)) {
|
||||
if (!f.startsWith(`${base}/`)) continue;
|
||||
const rest = f.slice(base.length + 1);
|
||||
const slash = rest.indexOf("/");
|
||||
if (slash === -1) children.set(rest, false);
|
||||
else children.set(rest.slice(0, slash), true);
|
||||
}
|
||||
return [...children].map(([name, isDir]) => ({
|
||||
name,
|
||||
isDir,
|
||||
isFile: !isDir,
|
||||
}));
|
||||
},
|
||||
readText(file) {
|
||||
const c = files[norm(file)];
|
||||
if (c === undefined) throw new Error(`no such file: ${file}`);
|
||||
return c;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const snes = platformById("snes")!;
|
||||
const ps1 = platformById("ps1")!;
|
||||
|
||||
const rels = (
|
||||
root: string,
|
||||
files: Record<string, string>,
|
||||
platform = snes,
|
||||
excludes?: string[],
|
||||
) =>
|
||||
scanRoot(synthFs(files), root, platform, excludes)
|
||||
.map((c) => c.relPath)
|
||||
.sort();
|
||||
|
||||
describe("scanRoot — cartridge platform", () => {
|
||||
test("matches platform extensions, recurses, ignores others", () => {
|
||||
const got = rels("/roms/snes", {
|
||||
"/roms/snes/Chrono Trigger (USA).sfc": "",
|
||||
"/roms/snes/Super Metroid.smc": "",
|
||||
"/roms/snes/readme.txt": "",
|
||||
"/roms/snes/sub/Zelda.sfc": "",
|
||||
});
|
||||
expect(got).toEqual([
|
||||
"Chrono Trigger (USA).sfc",
|
||||
"Super Metroid.smc",
|
||||
"sub/Zelda.sfc",
|
||||
]);
|
||||
});
|
||||
|
||||
test("accepts archives regardless of extension list", () => {
|
||||
const got = rels("/roms/snes", {
|
||||
"/roms/snes/Game.zip": "",
|
||||
"/roms/snes/x.foo": "",
|
||||
});
|
||||
expect(got).toEqual(["Game.zip"]);
|
||||
});
|
||||
|
||||
test("excludes apply (glob + directory)", () => {
|
||||
const got = rels(
|
||||
"/roms/snes",
|
||||
{
|
||||
"/roms/snes/A.sfc": "",
|
||||
"/roms/snes/A.sav": "",
|
||||
"/roms/snes/bios/x.sfc": "",
|
||||
},
|
||||
snes,
|
||||
["*.sav", "bios/**"],
|
||||
);
|
||||
expect(got).toEqual(["A.sfc"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scanRoot — disc folding", () => {
|
||||
test("m3u playlist folds its cues and their bins into one entry", () => {
|
||||
const got = rels(
|
||||
"/roms/ps1",
|
||||
{
|
||||
"/roms/ps1/FF7.m3u": "FF7 (Disc 1).cue\nFF7 (Disc 2).cue\n",
|
||||
"/roms/ps1/FF7 (Disc 1).cue":
|
||||
'FILE "FF7 (Disc 1).bin" BINARY\n TRACK 01 MODE2/2352',
|
||||
"/roms/ps1/FF7 (Disc 1).bin": "",
|
||||
"/roms/ps1/FF7 (Disc 2).cue": 'FILE "FF7 (Disc 2).bin" BINARY',
|
||||
"/roms/ps1/FF7 (Disc 2).bin": "",
|
||||
},
|
||||
ps1,
|
||||
);
|
||||
expect(got).toEqual(["FF7.m3u"]);
|
||||
});
|
||||
|
||||
test("a standalone cue hides its bin", () => {
|
||||
const got = rels(
|
||||
"/roms/ps1",
|
||||
{
|
||||
"/roms/ps1/Tekken.cue": 'FILE "Tekken.bin" BINARY',
|
||||
"/roms/ps1/Tekken.bin": "",
|
||||
},
|
||||
ps1,
|
||||
);
|
||||
expect(got).toEqual(["Tekken.cue"]);
|
||||
});
|
||||
|
||||
test("chd stands alone", () => {
|
||||
const got = rels("/roms/ps1", { "/roms/ps1/Crash.chd": "" }, ps1);
|
||||
expect(got).toEqual(["Crash.chd"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("makeExcluder", () => {
|
||||
test("bare pattern matches at any depth; negation re-includes", () => {
|
||||
const ex = makeExcluder(["*.sav", "!keep/**"]);
|
||||
expect(ex("a/b/x.sav")).toBe(true);
|
||||
expect(ex("keep/x.sav")).toBe(false);
|
||||
expect(ex("a/b/x.sfc")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,72 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { SteamGridDbProvider } from "../src/art/steamgriddb.js";
|
||||
import { parseTitle } from "../src/engine/titles.js";
|
||||
import { platformById } from "../src/platforms.js";
|
||||
|
||||
const snes = platformById("snes")!;
|
||||
|
||||
/** A fake SteamGridDB backend keyed by URL path fragments. */
|
||||
const fakeFetch = (opts: {
|
||||
status?: number;
|
||||
games?: unknown[];
|
||||
grids?: unknown[];
|
||||
heroes?: unknown[];
|
||||
logos?: unknown[];
|
||||
}): typeof fetch =>
|
||||
((input: string | URL | Request) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
const path = new URL(url).pathname;
|
||||
if (opts.status && opts.status !== 200) {
|
||||
return Promise.resolve(new Response("nope", { status: opts.status }));
|
||||
}
|
||||
let data: unknown[] = [];
|
||||
if (path.includes("/search/autocomplete/")) data = opts.games ?? [];
|
||||
else if (path.includes("/grids/game/")) data = opts.grids ?? [];
|
||||
else if (path.includes("/heroes/game/")) data = opts.heroes ?? [];
|
||||
else if (path.includes("/logos/game/")) data = opts.logos ?? [];
|
||||
return Promise.resolve(Response.json({ success: true, data }));
|
||||
}) as typeof fetch;
|
||||
|
||||
describe("SteamGridDbProvider", () => {
|
||||
test("classifies grids into portrait + header and pulls hero + logo", async () => {
|
||||
const provider = new SteamGridDbProvider(
|
||||
"key",
|
||||
fakeFetch({
|
||||
games: [{ id: 1, name: "Chrono Trigger" }],
|
||||
grids: [
|
||||
{ url: "http://p600.png", width: 600, height: 900 },
|
||||
{ url: "http://h460.png", width: 460, height: 215 },
|
||||
],
|
||||
heroes: [{ url: "http://hero.png", width: 1920, height: 620 }],
|
||||
logos: [{ url: "http://logo.png", width: 400, height: 200 }],
|
||||
}),
|
||||
);
|
||||
const art = await provider.resolve(
|
||||
snes,
|
||||
parseTitle("Chrono Trigger (USA)"),
|
||||
);
|
||||
expect(art).toEqual({
|
||||
portrait: "http://p600.png",
|
||||
header: "http://h460.png",
|
||||
hero: "http://hero.png",
|
||||
logo: "http://logo.png",
|
||||
});
|
||||
});
|
||||
|
||||
test("no game match → null", async () => {
|
||||
const provider = new SteamGridDbProvider("key", fakeFetch({ games: [] }));
|
||||
expect(await provider.resolve(snes, parseTitle("Nope"))).toBeNull();
|
||||
});
|
||||
|
||||
test("a bad key (401) disables the provider and returns null", async () => {
|
||||
const provider = new SteamGridDbProvider("bad", fakeFetch({ status: 401 }));
|
||||
expect(await provider.resolve(snes, parseTitle("Anything"))).toBeNull();
|
||||
});
|
||||
|
||||
test("the cache id is scoped to the key (so a key change re-resolves)", () => {
|
||||
const a = new SteamGridDbProvider("k1").id;
|
||||
const b = new SteamGridDbProvider("k2").id;
|
||||
expect(a).not.toBe(b);
|
||||
expect(a.startsWith("steamgriddb:")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
dedupeKey,
|
||||
parseTitle,
|
||||
pickBestRelease,
|
||||
} from "../src/engine/titles.js";
|
||||
|
||||
describe("parseTitle", () => {
|
||||
test("strips No-Intro tags and keeps region + revision metadata", () => {
|
||||
const p = parseTitle("Chrono Trigger (USA) (Rev 1) [!]");
|
||||
expect(p.displayTitle).toBe("Chrono Trigger");
|
||||
expect(p.noIntroName).toBe("Chrono Trigger (USA) (Rev 1) [!]");
|
||||
expect(p.region).toBe("USA");
|
||||
expect(p.revision).toBe("Rev 1");
|
||||
});
|
||||
|
||||
test("normalises a trailing article", () => {
|
||||
expect(parseTitle("Legend of Zelda, The (USA)").displayTitle).toBe(
|
||||
"The Legend of Zelda",
|
||||
);
|
||||
});
|
||||
|
||||
test("recognises multi-region tags", () => {
|
||||
const p = parseTitle("Sonic (USA, Europe)");
|
||||
expect(p.regions).toEqual(["USA", "Europe"]);
|
||||
expect(p.region).toBe("USA");
|
||||
});
|
||||
|
||||
test("converts underscores and collapses whitespace", () => {
|
||||
expect(parseTitle("Super_Mario__World").displayTitle).toBe(
|
||||
"Super Mario World",
|
||||
);
|
||||
});
|
||||
|
||||
test("a tagless name is its own title", () => {
|
||||
const p = parseTitle("Tetris");
|
||||
expect(p.displayTitle).toBe("Tetris");
|
||||
expect(p.region).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("region dedupe", () => {
|
||||
test("groups by base title", () => {
|
||||
expect(dedupeKey(parseTitle("Sonic (USA)"))).toBe(
|
||||
dedupeKey(parseTitle("Sonic (Europe)")),
|
||||
);
|
||||
});
|
||||
|
||||
test("pickBestRelease honors region priority, then shortest name", () => {
|
||||
const items = [
|
||||
{ parsed: parseTitle("Sonic (Europe)") },
|
||||
{ parsed: parseTitle("Sonic (USA)") },
|
||||
{ parsed: parseTitle("Sonic (Japan)") },
|
||||
];
|
||||
const best = pickBestRelease(items, ["USA", "World", "Europe", "Japan"]);
|
||||
expect(best.parsed.region).toBe("USA");
|
||||
});
|
||||
|
||||
test("falls back to shortest name when no region matches priority", () => {
|
||||
const items = [
|
||||
{ parsed: parseTitle("Game (Korea) (Beta)") },
|
||||
{ parsed: parseTitle("Game (Korea)") },
|
||||
];
|
||||
const best = pickBestRelease(items, ["USA"]);
|
||||
expect(best.parsed.noIntroName).toBe("Game (Korea)");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user