Shuffle/supabase/catalog/build_startbestand_test.ts
cschlaefke 9dbafbcad7 feat(catalog): add starting-stock tool, vocabulary proposal and CI checks
Deno script validates vocabulary.csv and startbestand.csv and generates the idempotent SQL, key list and checks.sql. startbestand.csv stays header-only; vocabulary.csv is a proposal pending approval (C0). seed.sql no longer inserts genres and mechanics.

Refs #2

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 15:38:28 +01:00

329 lines
17 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Tests für build_startbestand.ts. Aufruf (Repo-Root):
// deno test --allow-read=supabase supabase/catalog/
// Fixtures sind offensichtlich fiktiv ("Testspiel", "Testverlag"). Es werden keine echten Spieldaten verwendet.
import assert from "node:assert/strict";
import {
build,
BuildError,
generateStartbestandSql,
NAMESPACE,
parseCsv,
parseKeys,
parseStartbestand,
parseVocabulary,
renderKeys,
uuidV5,
} from "./build_startbestand.ts";
const VOCAB = `kind,vocab_key,name_de,name_en
genre,test-genre-a,Testgenre A,Test genre A
genre,test-genre-b,Testgenre B,Test genre B
mechanic,test-mechanic-a,Testmechanik A,Test mechanic A
`;
const HEADER =
"key,title,publishers,year,min_players,max_players,min_minutes,max_minutes,min_age,genres,mechanics,description,description_locale,hidden,facts_source,alt_titles,base_game_key";
// Zeile mit Standardwerten; einzelne Felder lassen sich überschreiben.
function row(over: Record<string, string> = {}): string {
const base: Record<string, string> = {
key: "testspiel-a", title: "Testspiel A", publishers: "Testverlag", year: "2020", min_players: "2",
max_players: "4", min_minutes: "30", max_minutes: "60", min_age: "8", genres: "Testgenre A",
mechanics: "Testmechanik A", description: "Fiktiver Text.", description_locale: "de", hidden: "",
facts_source: "Test", alt_titles: "", base_game_key: "",
};
const r = { ...base, ...over };
return HEADER.split(",").map((c) => {
const v = r[c];
return /[",\n]/.test(v) ? `"${v.replaceAll('"', '""')}"` : v;
}).join(",");
}
const csv = (...rows: string[]) => [HEADER, ...rows].join("\n") + "\n";
async function vocab() {
return await parseVocabulary(VOCAB);
}
async function rejects(text: string, includes: RegExp, keys = new Map<string, string>()) {
const v = await vocab();
await assert.rejects(
() => parseStartbestand(text, v, keys),
(e: unknown) => {
assert.ok(e instanceof BuildError, "BuildError erwartet");
assert.match(e.problems.join("\n"), includes);
return true;
},
);
}
// ---- UUID v5 ------------------------------------------------------------------------------
Deno.test("uuidV5: Referenzwert aus RFC 4122 / Python uuid5(NAMESPACE_DNS, ...)", async () => {
assert.equal(
await uuidV5("www.example.com", "6ba7b810-9dad-11d1-80b4-00c04fd430c8"),
"2ed6657d-e927-568b-95e1-2665a8aea6a2",
);
});
Deno.test("uuidV5: gleicher key gibt gleiche UUID wie uuid_generate_v5 in PostgreSQL (siehe pgTAP 06)", async () => {
assert.equal(NAMESPACE, "6f1d9a52-3c4e-4b8a-9f27-5d0e8c71a3b4");
assert.equal(await uuidV5("game:testspiel-a"), "5189ad0e-ab5b-5500-a3e0-a0b3671cdf14");
assert.equal(await uuidV5("game:testspiel-a"), await uuidV5("game:testspiel-a"));
assert.notEqual(await uuidV5("game:testspiel-a"), await uuidV5("game:testspiel-b"));
});
// ---- CSV ----------------------------------------------------------------------------------
Deno.test("parseCsv: Anführungszeichen, doppelte Anführungszeichen, Zeilenumbruch im Feld, CRLF, BOM", () => {
const parsed = parseCsv('a,b\r\n"x, y","sagt ""hallo"""\r\n"zwei\nZeilen",z\r\n', "t.csv");
assert.deepEqual(parsed.header, ["a", "b"]);
assert.equal(parsed.rows[0].cells.a, "x, y");
assert.equal(parsed.rows[0].cells.b, 'sagt "hallo"');
assert.equal(parsed.rows[1].cells.a, "zwei\nZeilen");
assert.equal(parsed.rows[1].line, 3);
});
Deno.test("parseCsv: falsche Spaltenzahl und offenes Anführungszeichen nennen die Zeile", () => {
assert.throws(() => parseCsv("a,b\n1,2,3\n", "t.csv"), /t\.csv:2: 3 Spalten statt 2/);
assert.throws(() => parseCsv('a,b\n"offen,2\n', "t.csv"), /t\.csv:2: Anführungszeichen nicht geschlossen/);
});
Deno.test("parseCsv: nur Kopfzeile ergibt keine Zeilen", () => {
assert.equal(parseCsv("a,b\n", "t.csv").rows.length, 0);
});
// ---- Vokabular ----------------------------------------------------------------------------
Deno.test("parseVocabulary: gültig, IDs nach UUID v5", async () => {
const v = await vocab();
assert.equal(v.length, 3);
assert.equal(v[0].id, await uuidV5("genre:test-genre-a"));
assert.equal(v[2].id, await uuidV5("mechanic:test-mechanic-a"));
});
Deno.test("parseVocabulary: doppelter vocab_key, doppelter Name, falsche kind, falscher Schlüssel", async () => {
const head = "kind,vocab_key,name_de,name_en\n";
await assert.rejects(() => parseVocabulary(head + "genre,a-b,A,A\ngenre,a-b,B,B\n"), /vocabulary\.csv:3: vocab_key "a-b" doppelt/);
await assert.rejects(() => parseVocabulary(head + "genre,a-b,A,A\ngenre,c-d,a,C\n"), /vocabulary\.csv:3: name_de "a" doppelt/);
await assert.rejects(() => parseVocabulary(head + "genre,a-b,A,A\ngenre,c-d,C,a\n"), /vocabulary\.csv:3: name_en "a" doppelt/);
await assert.rejects(() => parseVocabulary(head + "kategorie,a-b,A,A\n"), /kind muss/);
await assert.rejects(() => parseVocabulary(head + "genre,A_B,A,A\n"), /vocab_key "A_B" ungültig/);
await assert.rejects(() => parseVocabulary("kind,vocab_key,name_de\ngenre,a-b,A\n"), /Spalte "name_en" fehlt/);
});
Deno.test("parseVocabulary: gleiche Namen in verschiedenen Arten sind erlaubt", async () => {
const v = await parseVocabulary("kind,vocab_key,name_de,name_en\ngenre,a-b,A,A\nmechanic,a-b,A,A\n");
assert.equal(v.length, 2);
assert.notEqual(v[0].id, v[1].id);
});
// ---- Startbestand: gültig -----------------------------------------------------------------
Deno.test("parseStartbestand: gültige Zeile (Positivfall)", async () => {
const games = await parseStartbestand(csv(row()), await vocab(), new Map());
assert.equal(games.length, 1);
assert.equal(games[0].id, await uuidV5("game:testspiel-a"));
assert.deepEqual(games[0].publishers, ["Testverlag"]);
assert.equal(games[0].hidden, false);
});
Deno.test("parseStartbestand: nur Kopfzeile ist gültig (heutiger Stand ohne Spiele)", async () => {
assert.deepEqual(await parseStartbestand(HEADER + "\n", await vocab(), new Map()), []);
});
// ---- Startbestand: eine Negativprobe je Prüfregel -----------------------------------------
Deno.test("SB2: fehlende Pflicht-Fakten", async () => {
for (const col of ["year", "min_players", "max_players", "min_minutes", "max_minutes", "min_age"]) {
await rejects(csv(row({ [col]: "" })), new RegExp(`startbestand\\.csv:2: ${col} fehlt`));
}
await rejects(csv(row({ publishers: "" })), /mindestens ein Verlag/);
await rejects(csv(row({ publishers: " ; " })), /mindestens ein Verlag/);
await rejects(csv(row({ genres: "" })), /mindestens ein Genre/);
await rejects(csv(row({ mechanics: "" })), /mindestens eine Mechanik/);
await rejects(csv(row({ title: "" })), /title muss 1-200 Zeichen/);
});
Deno.test("DB-Grenzen: Jahr, max < min, keine Zahl, negativ", async () => {
await rejects(csv(row({ year: "1799" })), /year 1799 außerhalb/);
await rejects(csv(row({ year: "2101" })), /year 2101 außerhalb/);
await rejects(csv(row({ year: "20x0" })), /year "20x0" ist keine ganze Zahl/);
await rejects(csv(row({ min_players: "0" })), /min_players 0 außerhalb/);
await rejects(csv(row({ min_players: "5", max_players: "3" })), /max_players \(3\) kleiner als min_players \(5\)/);
await rejects(csv(row({ min_minutes: "90", max_minutes: "30" })), /max_minutes \(30\) kleiner als min_minutes \(90\)/);
await rejects(csv(row({ min_age: "-1" })), /min_age "-1" ist keine ganze Zahl/);
await rejects(csv(row({ title: "x".repeat(201) })), /title muss 1-200 Zeichen/);
});
Deno.test("SB4: Kurztext Pflicht, Sprache de, Länge", async () => {
await rejects(csv(row({ description: "" })), /Kurztext fehlt/);
await rejects(csv(row({ description_locale: "en" })), /description_locale muss "de" sein/);
await rejects(csv(row({ description_locale: "" })), /description_locale muss "de" sein/);
await rejects(csv(row({ description: "x".repeat(5001) })), /länger als 5000/);
});
Deno.test("SB5: unbekanntes Genre oder unbekannte Mechanik, Doppelte", async () => {
await rejects(csv(row({ genres: "Erfundenes" })), /unbekannt in genres: "Erfundenes"/);
await rejects(csv(row({ mechanics: "Testgenre A" })), /unbekannt in mechanics/); // Genre ist keine Mechanik
await rejects(csv(row({ genres: "Testgenre A;Testgenre A" })), /genres: "Testgenre A" doppelt/);
});
Deno.test("SB6: keine Spalte für Cover, Quelle oder BGG-Kennung", async () => {
for (const col of ["image_path", "bgg_id", "catalog_source_id", "source_ref", "cover"]) {
await rejects(`${HEADER},${col}\n${row()},x\n`, new RegExp(`unbekannte Spalte "${col}"`));
}
});
Deno.test("SB7: base_game_key muss auf einen vorhandenen key zeigen", async () => {
await rejects(csv(row({ base_game_key: "gibt-es-nicht" })), /base_game_key "gibt-es-nicht" hat keinen passenden key/);
await rejects(csv(row({ base_game_key: "testspiel-a" })), /verweist auf das Spiel selbst/);
const ok = await parseStartbestand(
csv(row(), row({ key: "testspiel-b", title: "Testspiel B", base_game_key: "testspiel-a" })),
await vocab(),
new Map(),
);
assert.equal(ok.length, 2);
});
Deno.test("key: Format und Eindeutigkeit", async () => {
await rejects(csv(row({ key: "Ungültig_Key" })), /key "Ungültig_Key" ungültig/);
await rejects(csv(row({ key: "a" })), /key "a" ungültig/);
await rejects(csv(row(), row({ title: "Noch eins" })), /startbestand\.csv:3: key "testspiel-a" doppelt/);
});
Deno.test("hidden: nur leer oder ja; Verlage nicht doppelt", async () => {
await rejects(csv(row({ hidden: "nein" })), /hidden muss leer oder "ja" sein/);
await rejects(csv(row({ publishers: "Testverlag;TESTVERLAG" })), /Verlag "TESTVERLAG" doppelt/);
});
Deno.test("Pflichtspalte fehlt in der Kopfzeile", async () => {
await assert.rejects(
async () => await parseStartbestand("key,title\nx,y\n", await vocab(), new Map()),
/Spalte "publishers" fehlt/,
);
});
Deno.test("mehrere Fehler werden gesammelt, mit Zeilennummern", async () => {
const v = await vocab();
await assert.rejects(
() => parseStartbestand(csv(row({ year: "" }), row({ key: "testspiel-b", genres: "X" })), v, new Map()),
(e: unknown) => {
assert.ok(e instanceof BuildError);
assert.match(e.problems.join("\n"), /startbestand\.csv:2: year fehlt/);
assert.match(e.problems.join("\n"), /startbestand\.csv:3: unbekannt in genres/);
return true;
},
);
});
// ---- Schlüsselliste -----------------------------------------------------------------------
Deno.test("Schlüssel sind unveränderlich: ein entfernter key bricht ab", async () => {
const previous = parseKeys(`key,uuid\ntestspiel-a,${await uuidV5("game:testspiel-a")}\ntestspiel-alt,${await uuidV5("game:testspiel-alt")}\n`);
await rejects(csv(row()), /Schlüssel entfernt oder umbenannt: "testspiel-alt"/, previous);
// Umbenennen ist dasselbe wie Entfernen
await rejects(csv(row({ key: "testspiel-neu" })), /Schlüssel entfernt oder umbenannt: "testspiel-a"/, previous);
});
Deno.test("Schlüssel: falsche UUID in der Liste (Namespace geändert?)", async () => {
const previous = parseKeys("key,uuid\ntestspiel-a,00000000-0000-5000-8000-000000000000\n");
await rejects(csv(row()), /uuid zu "testspiel-a" ist 00000000-0000-5000-8000-000000000000, erwartet/, previous);
});
Deno.test("Schlüsselliste wächst nur und ist sortiert", async () => {
const first = await build({ vocabularyCsv: VOCAB, startbestandCsv: csv(row({ key: "zzz-spiel" })), previousKeysCsv: "key,uuid\n" });
const second = await build({
vocabularyCsv: VOCAB,
startbestandCsv: csv(row({ key: "zzz-spiel" }), row({ key: "aaa-spiel", title: "Neu" })),
previousKeysCsv: first["startbestand-keys.csv"],
});
const before = first["startbestand-keys.csv"].trim().split("\n");
const after = second["startbestand-keys.csv"].trim().split("\n");
for (const line of before) assert.ok(after.includes(line), `Zeile fehlt: ${line}`);
assert.deepEqual(after.slice(1).map((l) => l.split(",")[0]), ["aaa-spiel", "zzz-spiel"]);
assert.equal(renderKeys(parseKeys(second["startbestand-keys.csv"])), second["startbestand-keys.csv"]);
});
// ---- SQL-Erzeugung ------------------------------------------------------------------------
async function sqlFor(...rows: string[]): Promise<string> {
return generateStartbestandSql(await parseStartbestand(csv(...rows), await vocab(), new Map()));
}
Deno.test("hidden = ja erzeugt hidden_at, leer fasst hidden_at nicht an", async () => {
const hiddenSql = await sqlFor(row({ hidden: "ja" }));
assert.match(hiddenSql, /update public\.games set hidden_at = now\(\)/);
assert.match(hiddenSql, /where hidden_at is null and id = any/);
const plain = await sqlFor(row());
assert.doesNotMatch(plain, /hidden_at/);
assert.doesNotMatch(plain, /update public\.games set/);
});
Deno.test("facts_source, alt_titles und base_game_key landen nicht in der SQL", async () => {
const sql = await sqlFor(row({ facts_source: "GEHEIMER-NACHWEIS", alt_titles: "de:Alt", base_game_key: "" }));
assert.doesNotMatch(sql, /GEHEIMER-NACHWEIS|alt_titles|base_game_key|facts_source/);
});
Deno.test("SQL: Apostroph im Text wird verdoppelt, Upsert nutzt is distinct from, Zuordnungen werden abgeglichen", async () => {
const sql = await sqlFor(row({ title: "Wo's lang", description: "Es ist 'nett'." }));
assert.match(sql, /'Wo''s lang'/);
assert.match(sql, /'Es ist ''nett''\.'/);
assert.match(sql, /on conflict \(id\) do update set/);
assert.match(sql, /is distinct from \(excluded\.title/);
assert.match(sql, /on conflict \(\(lower\(name\)\)\) do nothing/); // Verlage
assert.match(sql, /delete from public\.game_genres/);
assert.match(sql, /delete from public\.game_mechanics/);
assert.match(sql, /delete from public\.game_publishers/);
assert.match(sql, /on conflict \(game_id, genre_id\) do nothing/);
assert.match(sql, /Genre fehlt in der Datenbank/); // Wächter gegen still verlorene Zuordnungen
assert.doesNotMatch(sql, /image_path|bgg_id|catalog_source_id|source_ref/); // SB6
});
Deno.test("SQL: leerer Startbestand ergibt eine gültige, leere Datei", async () => {
const sql = generateStartbestandSql([]);
assert.match(sql, /keine Spiele/);
assert.doesNotMatch(sql, /insert into/);
});
Deno.test("build: deterministisch, ohne Zeitstempel", async () => {
const input = { vocabularyCsv: VOCAB, startbestandCsv: csv(row(), row({ key: "testspiel-b", title: "B" })), previousKeysCsv: "key,uuid\n" };
assert.deepEqual(await build(input), await build(input));
});
Deno.test("checks.sql: bettet die Startbestands-IDs ein, Sollwert 180, leerer Bestand ist gültiges SQL", async () => {
const out = await build({ vocabularyCsv: VOCAB, startbestandCsv: csv(row()), previousKeysCsv: "key,uuid\n" });
assert.match(out["checks.sql"], new RegExp(await uuidV5("game:testspiel-a")));
assert.match(out["checks.sql"], />= 180/);
const empty = await build({ vocabularyCsv: VOCAB, startbestandCsv: HEADER + "\n", previousKeysCsv: "key,uuid\n" });
assert.match(empty["checks.sql"], /jsonb_array_elements_text\('\[\]'::jsonb\)/);
assert.doesNotMatch(empty["checks.sql"], /values \(\s*\)/);
});
Deno.test("vocabulary.sql: on conflict do nothing ohne Konfliktziel", async () => {
const out = await build({ vocabularyCsv: VOCAB, startbestandCsv: HEADER + "\n", previousKeysCsv: "key,uuid\n" });
assert.match(out["vocabulary.sql"], /insert into public\.genres \(id, name_de, name_en\) values/);
assert.match(out["vocabulary.sql"], /insert into public\.mechanics/);
assert.equal((out["vocabulary.sql"].match(/on conflict do nothing;/g) ?? []).length, 2);
});
// ---- Eingecheckte, erzeugte Dateien sind aktuell ------------------------------------------
Deno.test("die eingecheckten erzeugten Dateien entsprechen den CSV-Quellen", async () => {
const dir = new URL(".", import.meta.url);
const read = (name: string) => Deno.readTextFile(new URL(name, dir));
const real = await build({
vocabularyCsv: await read("vocabulary.csv"),
startbestandCsv: await read("startbestand.csv"),
previousKeysCsv: await read("startbestand-keys.csv"),
});
for (const name of ["vocabulary.sql", "startbestand.sql", "startbestand-keys.csv", "checks.sql"] as const) {
assert.equal(await read(name), real[name], `${name} ist nicht aktuell: build_startbestand.ts ausführen`);
}
const fixture = await build({
vocabularyCsv: await read("testdata/vocabulary.csv"),
startbestandCsv: await read("testdata/startbestand.csv"),
previousKeysCsv: "key,uuid\n",
});
const support = (name: string) => Deno.readTextFile(new URL(`../tests/support/${name}`, dir));
assert.equal(await support("vocabulary_fixture.inc"), fixture["vocabulary.sql"]);
assert.equal(await support("startbestand_fixture.inc"), fixture["startbestand.sql"]);
assert.equal(await support("checks_fixture.inc"), fixture.checksFixtureInclude);
});
Deno.test("Vokabular-Vorschlag: 12-15 Genres, 20-30 Mechaniken (Aufgabe B3a)", async () => {
const v = await parseVocabulary(await Deno.readTextFile(new URL("vocabulary.csv", import.meta.url)));
const genres = v.filter((e) => e.kind === "genre").length;
const mechanics = v.filter((e) => e.kind === "mechanic").length;
assert.ok(genres >= 12 && genres <= 15, `Genres: ${genres}`);
assert.ok(mechanics >= 20 && mechanics <= 30, `Mechaniken: ${mechanics}`);
});