import { describe, expect, test } from "bun:test"; import { type DirEnt, makeExcluder, type ScanFs, scanRoot, } from "../src/domain/scanner.js"; import { platformById } from "../src/domain/platforms.js"; /** A synthetic filesystem from a flat `{ absPath: contents }` map (dirs inferred). */ const synthFs = (files: Record): ScanFs => { const norm = (p: string) => p.replace(/\/+$/, ""); return { readDir(dir): DirEnt[] { const base = norm(dir); const children = new Map(); // 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, 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); }); });