// 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, approxNormalize, parseAltTitles, 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 { const base: Record = { 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()) { 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 { 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 und base_game_key landen nicht in der SQL", async () => { const sql = await sqlFor(row({ facts_source: "GEHEIMER-NACHWEIS", base_game_key: "" })); assert.doesNotMatch(sql, /GEHEIMER-NACHWEIS|base_game_key|facts_source/); }); // ---- alt_titles (KAT-01, Spec #3 4.3) -------------------------------------------------------- function altProblems(raw: string, main = "Testspiel A"): string[] { const problems: string[] = []; parseAltTitles(raw, main, (m) => problems.push(m)); return problems; } Deno.test("alt_titles: Format de:Titel;en:Title, Leerraum am Rand wird entfernt, ':' im Titel ist erlaubt", () => { const problems: string[] = []; const parsed = parseAltTitles(" de: Catan: Das Duell ; en:Test Duel ", "Testspiel A", (m) => problems.push(m)); assert.deepEqual(problems, []); assert.deepEqual(parsed, [{ locale: "de", title: "Catan: Das Duell" }, { locale: "en", title: "Test Duel" }]); assert.deepEqual(parseAltTitles("", "Testspiel A", () => assert.fail("kein Fehler erwartet")), []); }); Deno.test("alt_titles: Sprache, fehlender Doppelpunkt, leerer und zu langer Titel werden abgelehnt", () => { assert.match(altProblems("fr:Titre").join("\n"), /Sprache "fr".*"de" oder "en"/); assert.match(altProblems("Nur ein Titel").join("\n"), /kein ":"/); assert.match(altProblems("de: ").join("\n"), /leerer Titel/); assert.match(altProblems("de:" + "x".repeat(201)).join("\n"), /länger als 200 Zeichen/); assert.equal(altProblems("de:" + "x".repeat(200)).length, 0); assert.match(altProblems("de:!!!").join("\n"), /nur aus Sonderzeichen/); }); Deno.test("alt_titles: Duplikat in derselben Sprache und Titel gleich dem Haupttitel werden abgelehnt", () => { assert.match(altProblems("de:Café;de:CAFE").join("\n"), /doppelt \(Sprache de\)/); assert.equal(altProblems("de:Café;en:Café").length, 0, "gleicher Titel in anderer Sprache ist zulässig"); assert.match(altProblems("de:testspiel-a").join("\n"), /gleich dem Haupttitel/); assert.match(altProblems("en:CATAN®", "Catan").join("\n"), /gleich dem Haupttitel/); }); Deno.test("alt_titles: ';' im Titel ergibt einen Fehler mit Zeilennummer und Hinweis", async () => { await rejects( csv(row(), row({ key: "testspiel-b", title: "Testspiel B", alt_titles: "de:Catan;Das Duell" })), /startbestand\.csv:3: alt_titles: Eintrag "Das Duell" hat kein ":".*";" ist in alternativen Titeln nicht erlaubt/, ); await rejects( csv(row({ alt_titles: "de:Eins;Zwei: Drei" })), /startbestand\.csv:2: alt_titles: Sprache "Zwei" .*";" ist in alternativen Titeln nicht erlaubt/, ); }); Deno.test("alt_titles: Fehler anderer Art nennen die Zeilennummer", async () => { await rejects(csv(row(), row({ key: "testspiel-b", alt_titles: "fr:X" })), /startbestand\.csv:3: alt_titles: Sprache "fr"/); }); Deno.test("approxNormalize: Marken- und Schutzzeichen, Akzente, ß, Æ, Sonderzeichen", () => { assert.equal(approxNormalize("CATAN®"), "catan"); assert.equal(approxNormalize("Spiel™Ding"), "spielding"); // Schutzzeichen werden entfernt, nicht zum Trenner (wie search_normalize) assert.equal(approxNormalize("Café Größe"), "cafe grosse"); assert.equal(approxNormalize("Æon’s End"), "aeon s end"); assert.equal(approxNormalize("7 Wonders: Duel"), "7 wonders duel"); assert.equal(approxNormalize("Ticket to Ride – Europe"), "ticket to ride europe"); assert.equal(approxNormalize("!!!"), ""); assert.equal(approxNormalize("Spiel™ ½"), "spiel 1 2"); // Bruch wie unaccent: NFKD }); Deno.test("SQL alt_titles: Abgleich über normalisierten Titel, Update nur bei geänderter Schreibweise", async () => { const sql = await sqlFor(row({ alt_titles: "de:Alter Titel;en:Old 'Title'" })); assert.match(sql, /with want\(game_id, locale, title\) as \(values/); assert.match(sql, /'de', 'Alter Titel'\)/); assert.match(sql, /'en', 'Old ''Title'''\)/); // Apostroph verdoppelt assert.match(sql, /delete from public\.game_alt_titles a/); assert.match(sql, /public\.search_normalize\(w\.title\) = a\.title_search/); assert.match(sql, /on conflict \(game_id, locale, title_search\) do update set title = excluded\.title/); assert.match(sql, /where public\.game_alt_titles\.title is distinct from excluded\.title;/); assert.doesNotMatch(sql, /game_alt_titles \(id/); // keine abgeleitete ID }); Deno.test("SQL alt_titles: ohne alternative Titel nur das Löschen, nie ein leeres values", async () => { const sql = await sqlFor(row()); assert.match(sql, /delete from public\.game_alt_titles a where a\.game_id = any\(array\['/); assert.doesNotMatch(sql, /with want\(game_id, locale, title\)/); assert.doesNotMatch(sql, /insert into public\.game_alt_titles/); }); Deno.test("checks.sql: Informationszeile zu Startbestandsspielen mit alternativen Titeln", async () => { const out = await build({ vocabularyCsv: VOCAB, startbestandCsv: csv(row()), previousKeysCsv: "key,uuid\n" }); assert.match(out["checks.sql"], /'Info-Alt', 'Startbestandsspiele mit alternativen Titeln'/); assert.match(out["checks.sql"], /count\(distinct a\.game_id\) as n from public\.game_alt_titles a join sb on sb\.id = a\.game_id/); }); 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}`); });