// Startbestand-Werkzeug (KAT-05, Spec docs/specs/2-katalog-daten.md, Abschnitt 4.3). // // Liest die von Hand gepflegten Dateien // supabase/catalog/vocabulary.csv (Genres und Mechaniken, de/en) // supabase/catalog/startbestand.csv (Spiele; Format siehe docs/katalog-pflege.md) // prüft sie (SB2, SB4-SB7) und erzeugt daraus // supabase/catalog/vocabulary.sql, startbestand.sql, startbestand-keys.csv, checks.sql // supabase/tests/support/{vocabulary,startbestand,checks}_fixture.inc (nur für pgTAP, aus catalog/testdata/, // siehe 06_catalog_foundation.test.sql) // Die erzeugten Dateien nie von Hand bearbeiten. Keine Abhängigkeiten außer der Deno-Standardumgebung. // // Aufruf (im Repo-Root): // deno run --allow-read=supabase --allow-write=supabase supabase/catalog/build_startbestand.ts // // Das Skript erfindet keine Daten. Es erzeugt nie Cover-, Quellen- oder BGG-Angaben (SB6). export const NAMESPACE = "6f1d9a52-3c4e-4b8a-9f27-5d0e8c71a3b4"; export const MIN_VISIBLE = 180; export const GENERATED_HEADER = "-- Erzeugt von supabase/catalog/build_startbestand.ts. Nicht von Hand bearbeiten.\n"; // --------------------------------------------------------------------------------------------- // Fehler // --------------------------------------------------------------------------------------------- export class BuildError extends Error { constructor(public readonly problems: string[]) { super(problems.join("\n")); this.name = "BuildError"; } } // --------------------------------------------------------------------------------------------- // UUID v5 (RFC 4122), gleiches Ergebnis wie uuid_generate_v5() in PostgreSQL // --------------------------------------------------------------------------------------------- function uuidToBytes(uuid: string): Uint8Array { const hex = uuid.replaceAll("-", ""); const out = new Uint8Array(16); for (let i = 0; i < 16; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); return out; } export async function uuidV5(name: string, namespace: string = NAMESPACE): Promise { const nameBytes = new TextEncoder().encode(name); const data = new Uint8Array(16 + nameBytes.length); data.set(uuidToBytes(namespace), 0); data.set(nameBytes, 16); const hash = new Uint8Array(await crypto.subtle.digest("SHA-1", data)).slice(0, 16); hash[6] = (hash[6] & 0x0f) | 0x50; hash[8] = (hash[8] & 0x3f) | 0x80; const hex = Array.from(hash, (b) => b.toString(16).padStart(2, "0")).join(""); return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; } // --------------------------------------------------------------------------------------------- // CSV (RFC 4180: Anführungszeichen, "" als Escape, Zeilenumbrüche in Feldern, optionales BOM) // --------------------------------------------------------------------------------------------- export interface CsvRow { line: number; cells: Record; } export interface Csv { header: string[]; rows: CsvRow[]; } export function parseCsv(text: string, file: string): Csv { if (text.charCodeAt(0) === 0xfeff) text = text.slice(1); const records: { line: number; fields: string[] }[] = []; let fields: string[] = []; let field = ""; let inQuotes = false; let line = 1; let recordLine = 1; let touched = false; // Satz hat Inhalt (auch ein leeres Feld nach Komma) const problems: string[] = []; const endField = () => { fields.push(field); field = ""; }; const endRecord = () => { endField(); if (touched || fields.length > 1 || fields[0] !== "") { records.push({ line: recordLine, fields }); } fields = []; touched = false; }; for (let i = 0; i < text.length; i++) { const c = text[i]; if (inQuotes) { if (c === '"') { if (text[i + 1] === '"') { field += '"'; i++; } else inQuotes = false; } else { if (c === "\n") line++; field += c; } continue; } if (c === '"' && field === "") { inQuotes = true; touched = true; } else if (c === ",") { touched = true; endField(); } else if (c === "\r") { // wird mit dem folgenden \n behandelt; alleinstehendes \r zählt als Zeilenende if (text[i + 1] !== "\n") { endRecord(); line++; recordLine = line; } } else if (c === "\n") { endRecord(); line++; recordLine = line; } else { field += c; touched = true; } } if (inQuotes) problems.push(`${file}:${recordLine}: Anführungszeichen nicht geschlossen`); endRecord(); if (problems.length) throw new BuildError(problems); if (records.length === 0) throw new BuildError([`${file}: Kopfzeile fehlt`]); const header = records[0].fields.map((h) => h.trim()); const rows: CsvRow[] = []; for (const r of records.slice(1)) { if (r.fields.length !== header.length) { problems.push( `${file}:${r.line}: ${r.fields.length} Spalten statt ${header.length} (Komma im Text? Feld in Anführungszeichen setzen)`, ); continue; } const cells: Record = {}; header.forEach((h, idx) => (cells[h] = r.fields[idx])); rows.push({ line: r.line, cells }); } if (problems.length) throw new BuildError(problems); return { header, rows }; } function checkHeader(csv: Csv, file: string, required: string[], optional: string[]): string[] { const problems: string[] = []; const allowed = new Set([...required, ...optional]); for (const col of required) { if (!csv.header.includes(col)) problems.push(`${file}:1: Spalte "${col}" fehlt`); } for (const col of csv.header) { if (!allowed.has(col)) { // SB6: Es gibt bewusst keine Spalte für Cover, Quelle oder BGG-Kennung. problems.push(`${file}:1: unbekannte Spalte "${col}" (erlaubt: ${[...allowed].join(", ")})`); } } const seen = new Set(); for (const col of csv.header) { if (seen.has(col)) problems.push(`${file}:1: Spalte "${col}" doppelt`); seen.add(col); } return problems; } // --------------------------------------------------------------------------------------------- // Vokabular // --------------------------------------------------------------------------------------------- export type VocabKind = "genre" | "mechanic"; export interface VocabEntry { kind: VocabKind; vocab_key: string; name_de: string; name_en: string; id: string; } export async function parseVocabulary(text: string, file = "vocabulary.csv"): Promise { const csv = parseCsv(text, file); const problems = checkHeader(csv, file, ["kind", "vocab_key", "name_de", "name_en"], []); if (problems.length) throw new BuildError(problems); const entries: VocabEntry[] = []; const seenKey = new Set(); const seenDe = new Set(); const seenEn = new Set(); for (const { line, cells } of csv.rows) { const where = `${file}:${line}`; const kind = cells.kind.trim(); const vocab_key = cells.vocab_key.trim(); const name_de = cells.name_de.trim(); const name_en = cells.name_en.trim(); if (kind !== "genre" && kind !== "mechanic") { problems.push(`${where}: kind muss "genre" oder "mechanic" sein, ist "${kind}"`); continue; } if (!/^[a-z0-9-]{2,40}$/.test(vocab_key)) { problems.push(`${where}: vocab_key "${vocab_key}" ungültig (a-z, 0-9, Bindestrich, 2-40 Zeichen)`); } for (const [label, v] of [["name_de", name_de], ["name_en", name_en]] as const) { if (v.length < 1 || v.length > 100) problems.push(`${where}: ${label} muss 1-100 Zeichen haben`); } const k = `${kind}:${vocab_key}`; if (seenKey.has(k)) problems.push(`${where}: vocab_key "${vocab_key}" doppelt (${kind})`); seenKey.add(k); const de = `${kind}:${name_de.toLowerCase()}`; if (seenDe.has(de)) problems.push(`${where}: name_de "${name_de}" doppelt (${kind})`); seenDe.add(de); const en = `${kind}:${name_en.toLowerCase()}`; if (seenEn.has(en)) problems.push(`${where}: name_en "${name_en}" doppelt (${kind})`); seenEn.add(en); entries.push({ kind, vocab_key, name_de, name_en, id: await uuidV5(`${kind}:${vocab_key}`) }); } if (problems.length) throw new BuildError(problems); return entries; } // --------------------------------------------------------------------------------------------- // Schlüsselliste (append-only) // --------------------------------------------------------------------------------------------- export function parseKeys(text: string, file = "startbestand-keys.csv"): Map { const csv = parseCsv(text, file); const problems = checkHeader(csv, file, ["key", "uuid"], []); if (problems.length) throw new BuildError(problems); const keys = new Map(); for (const { line, cells } of csv.rows) { if (keys.has(cells.key)) problems.push(`${file}:${line}: key "${cells.key}" doppelt`); keys.set(cells.key, cells.uuid); } if (problems.length) throw new BuildError(problems); return keys; } export function renderKeys(keys: Map): string { const lines = [...keys.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([k, u]) => `${k},${u}`); return ["key,uuid", ...lines].join("\n") + "\n"; } // --------------------------------------------------------------------------------------------- // Startbestand // --------------------------------------------------------------------------------------------- export interface Game { key: string; id: string; title: string; publishers: string[]; year: number; min_players: number; max_players: number; min_minutes: number; max_minutes: number; min_age: number; genres: string[]; // name_de mechanics: string[]; // name_de description: string; hidden: boolean; } const REQUIRED_COLUMNS = [ "key", "title", "publishers", "year", "min_players", "max_players", "min_minutes", "max_minutes", "min_age", "genres", "mechanics", "description", "description_locale", ]; const OPTIONAL_COLUMNS = ["hidden", "facts_source", "alt_titles", "base_game_key"]; const SMALLINT_MAX = 32767; function splitList(value: string): string[] { return value.split(";").map((s) => s.trim()).filter((s) => s !== ""); } export async function parseStartbestand( text: string, vocabulary: VocabEntry[], previousKeys: Map, file = "startbestand.csv", ): Promise { const csv = parseCsv(text, file); const problems = checkHeader(csv, file, REQUIRED_COLUMNS, OPTIONAL_COLUMNS); if (problems.length) throw new BuildError(problems); const genreNames = new Set(vocabulary.filter((v) => v.kind === "genre").map((v) => v.name_de)); const mechanicNames = new Set(vocabulary.filter((v) => v.kind === "mechanic").map((v) => v.name_de)); const games: Game[] = []; const seenKeys = new Set(); const allKeys = new Set(csv.rows.map((r) => r.cells.key.trim())); for (const { line, cells } of csv.rows) { const where = `${file}:${line}`; const key = cells.key.trim(); const before = problems.length; const fail = (msg: string) => problems.push(`${where}: ${msg}`); if (!/^[a-z0-9-]{2,60}$/.test(key)) fail(`key "${key}" ungültig (a-z, 0-9, Bindestrich, 2-60 Zeichen)`); if (seenKeys.has(key)) fail(`key "${key}" doppelt`); seenKeys.add(key); const title = cells.title.trim(); if (title.length < 1 || title.length > 200) fail("title muss 1-200 Zeichen haben"); const publishers = splitList(cells.publishers); if (publishers.length === 0) fail("mindestens ein Verlag nötig (publishers)"); const seenPub = new Set(); for (const p of publishers) { if (p.length > 200) fail(`Verlag "${p.slice(0, 30)}…" länger als 200 Zeichen`); if (seenPub.has(p.toLowerCase())) fail(`Verlag "${p}" doppelt`); seenPub.add(p.toLowerCase()); } const num = (col: string, min: number, max: number): number => { const raw = cells[col].trim(); if (raw === "") { fail(`${col} fehlt (Pflicht-Fakt, SB2)`); return NaN; } if (!/^\d+$/.test(raw)) { fail(`${col} "${raw}" ist keine ganze Zahl`); return NaN; } const n = Number(raw); if (n < min || n > max) { fail(`${col} ${n} außerhalb des erlaubten Bereichs ${min}-${max}`); return NaN; } return n; }; const year = num("year", 1800, 2100); const min_players = num("min_players", 1, SMALLINT_MAX); const max_players = num("max_players", 1, SMALLINT_MAX); const min_minutes = num("min_minutes", 0, SMALLINT_MAX); const max_minutes = num("max_minutes", 0, SMALLINT_MAX); const min_age = num("min_age", 0, SMALLINT_MAX); if (max_players < min_players) fail(`max_players (${max_players}) kleiner als min_players (${min_players})`); if (max_minutes < min_minutes) fail(`max_minutes (${max_minutes}) kleiner als min_minutes (${min_minutes})`); const resolveList = (col: "genres" | "mechanics", known: Set, label: string): string[] => { const names = splitList(cells[col]); if (names.length === 0) fail(`mindestens ${label} nötig (${col}, SB2)`); const seen = new Set(); for (const n of names) { if (!known.has(n)) fail(`unbekannt in ${col}: "${n}" (nur Begriffe aus vocabulary.csv, name_de, SB5)`); if (seen.has(n)) fail(`${col}: "${n}" doppelt`); seen.add(n); } return names; }; const genres = resolveList("genres", genreNames, "ein Genre"); const mechanics = resolveList("mechanics", mechanicNames, "eine Mechanik"); const description = cells.description.trim(); if (description === "") fail("Kurztext fehlt (description, SB4)"); if (description.length > 5000) fail("description länger als 5000 Zeichen"); const locale = cells.description_locale.trim(); if (locale !== "de") fail(`description_locale muss "de" sein, ist "${locale}" (SB4)`); const hiddenRaw = (cells.hidden ?? "").trim().toLowerCase(); if (hiddenRaw !== "" && hiddenRaw !== "ja") fail(`hidden muss leer oder "ja" sein, ist "${cells.hidden}"`); const base = (cells.base_game_key ?? "").trim(); if (base !== "") { if (base === key) fail("base_game_key verweist auf das Spiel selbst"); else if (!allKeys.has(base)) fail(`base_game_key "${base}" hat keinen passenden key (SB7)`); } if (problems.length === before) { games.push({ key, id: await uuidV5(`game:${key}`), title, publishers, year, min_players, max_players, min_minutes, max_minutes, min_age, genres, mechanics, description, hidden: hiddenRaw === "ja", }); } } // Schlüssel sind unveränderlich (W4): Ein früher vorhandener key darf nicht fehlen. for (const [key, uuid] of previousKeys) { if (!allKeys.has(key)) { problems.push( `${file}: Schlüssel entfernt oder umbenannt: "${key}" (Spiel nie löschen: Spalte hidden = ja setzen)`, ); } const expected = await uuidV5(`game:${key}`); if (uuid !== expected) { problems.push(`startbestand-keys.csv: uuid zu "${key}" ist ${uuid}, erwartet ${expected} (Namespace geändert?)`); } } if (problems.length) throw new BuildError(problems); return games; } // --------------------------------------------------------------------------------------------- // SQL-Erzeugung // --------------------------------------------------------------------------------------------- export const q = (s: string): string => `'${s.replaceAll("'", "''")}'`; const jsonLit = (v: unknown): string => `${q(JSON.stringify(v))}::jsonb`; // Ein Eintrag pro Zeile, damit die erzeugten Dateien lesbar bleiben. const jsonLines = (v: unknown[]): string => v.length === 0 ? "'[]'::jsonb" : `${q("[\n" + v.map((x) => " " + JSON.stringify(x)).join(",\n") + "\n]")}::jsonb`; const num = (n: number): string => String(n); export function generateVocabularySql(vocabulary: VocabEntry[]): string { const out: string[] = [GENERATED_HEADER]; out.push( "-- Vokabular (Genres, Mechaniken). Idempotent. Ein im Studio angelegter Begriff mit gleichem Namen", "-- gewinnt (on conflict do nothing fängt ID- und Namenskonflikte); startbestand.sql löst über name_de auf.", "", ); for (const [kind, table] of [["genre", "genres"], ["mechanic", "mechanics"]] as const) { const rows = vocabulary.filter((v) => v.kind === kind); if (rows.length === 0) { out.push(`-- keine ${table} im Vokabular`, ""); continue; } out.push(`insert into public.${table} (id, name_de, name_en) values`); out.push(rows.map((v) => ` (${q(v.id)}, ${q(v.name_de)}, ${q(v.name_en)})`).join(",\n")); out.push("on conflict do nothing;", ""); } return out.join("\n"); } export function generateStartbestandSql(games: Game[]): string { const out: string[] = [GENERATED_HEADER]; out.push( "-- Startbestand (KAT-05). Idempotent bis zum Go-live; danach eingefroren (docs/katalog-pflege.md).", "-- Reihenfolge: Migrationen, vocabulary.sql, dann diese Datei.", "", ); if (games.length === 0) { out.push("-- startbestand.csv enthält keine Spiele.", ""); return out.join("\n"); } // Wächter: Fehlt ein Begriff (Vokabular nicht eingespielt, im Studio umbenannt), bricht die Datei // ab, statt still Zuordnungen zu löschen. const genreNames = [...new Set(games.flatMap((g) => g.genres))].sort(); const mechanicNames = [...new Set(games.flatMap((g) => g.mechanics))].sort(); out.push( "do $guard$", "declare", " missing text;", "begin", ` select string_agg(n, ', ') into missing from jsonb_array_elements_text(${jsonLit(genreNames)}) as t(n)`, " where not exists (select 1 from public.genres g where g.name_de = t.n);", " if missing is not null then", " raise exception 'startbestand: Genre fehlt in der Datenbank (vocabulary.sql eingespielt?): %', missing;", " end if;", ` select string_agg(n, ', ') into missing from jsonb_array_elements_text(${jsonLit(mechanicNames)}) as t(n)`, " where not exists (select 1 from public.mechanics m where m.name_de = t.n);", " if missing is not null then", " raise exception 'startbestand: Mechanik fehlt in der Datenbank (vocabulary.sql eingespielt?): %', missing;", " end if;", "end", "$guard$;", "", ); // 1. Verlage (ohne abgeleitete ID, Auflösung über lower(name)) const publisherNames = new Map(); // lower -> erste Schreibweise for (const g of games) for (const p of g.publishers) if (!publisherNames.has(p.toLowerCase())) publisherNames.set(p.toLowerCase(), p); out.push("-- Verlage"); out.push("insert into public.publishers (name) values"); out.push([...publisherNames.values()].sort().map((p) => ` (${q(p)})`).join(",\n")); out.push("on conflict ((lower(name))) do nothing;", ""); // 2. Spiele const cols = "title, description, description_locale, year, min_players, max_players, min_minutes, max_minutes, min_age"; out.push("-- Spiele (unveränderte Zeilen werden nicht angefasst)"); out.push(`insert into public.games (id, ${cols}, visibility) values`); out.push( games.map((g) => ` (${q(g.id)}, ${q(g.title)}, ${q(g.description)}, 'de', ${num(g.year)}, ${num(g.min_players)}, ${num(g.max_players)}, ` + `${num(g.min_minutes)}, ${num(g.max_minutes)}, ${num(g.min_age)}, 'catalog')` ).join(",\n"), ); const colList = cols.split(", "); out.push("on conflict (id) do update set"); out.push(colList.map((c) => ` ${c} = excluded.${c}`).join(",\n")); out.push(`where (${colList.map((c) => `games.${c}`).join(", ")})`); out.push(` is distinct from (${colList.map((c) => `excluded.${c}`).join(", ")});`, ""); // 3. Entfernen = ausblenden (nur bei hidden = ja; sonst bleibt hidden_at unberührt) const hidden = games.filter((g) => g.hidden); if (hidden.length) { out.push("-- hidden = ja: ausblenden, nichts löschen"); out.push(`update public.games set hidden_at = now()`); out.push(`where hidden_at is null and id = any(array[${hidden.map((g) => q(g.id)).join(", ")}]::uuid[]);`, ""); } // 4. Zuordnungen als Abgleich const ids = games.map((g) => q(g.id)).join(", "); const wantRows = (pick: (g: Game) => string[]) => games.flatMap((g) => pick(g).map((n) => ` (${q(g.id)}::uuid, ${q(n)})`)).join(",\n"); const reconcile = ( label: string, linkTable: string, fkColumn: string, termTable: string, termMatch: string, pick: (g: Game) => string[], ) => { out.push(`-- Zuordnung ${label}: fehlende anlegen, nicht mehr gewünschte löschen`); out.push(`with want(game_id, term) as (values`); out.push(wantRows(pick)); out.push(`), resolved as (`); out.push(` select w.game_id, t.id as ${fkColumn} from want w join public.${termTable} t on ${termMatch}`); out.push(`), removed as (`); out.push(` delete from public.${linkTable} l`); out.push(` where l.game_id = any(array[${ids}]::uuid[])`); out.push(` and not exists (select 1 from resolved r where r.game_id = l.game_id and r.${fkColumn} = l.${fkColumn})`); out.push(`)`); out.push(`insert into public.${linkTable} (game_id, ${fkColumn})`); out.push(`select game_id, ${fkColumn} from resolved`); out.push(`on conflict (game_id, ${fkColumn}) do nothing;`, ""); }; reconcile("Genre", "game_genres", "genre_id", "genres", "t.name_de = w.term", (g) => g.genres); reconcile("Mechanik", "game_mechanics", "mechanic_id", "mechanics", "t.name_de = w.term", (g) => g.mechanics); reconcile("Verlag", "game_publishers", "publisher_id", "publishers", "lower(t.name) = lower(w.term)", (g) => g.publishers); return out.join("\n"); } // --------------------------------------------------------------------------------------------- // Prüfabfrage checks.sql (und die Fixture-Variante für pgTAP) // --------------------------------------------------------------------------------------------- export function generateChecksQuery(startbestandIds: string[], vocabulary: VocabEntry[]): string { const sbIds = [...startbestandIds].sort(); const voc = vocabulary.map((v) => ({ kind: v.kind, id: v.id, name_de: v.name_de })); return [ `with sb(id) as (`, ` select t.value::uuid from jsonb_array_elements_text(${jsonLines(sbIds)}) as t(value)`, `), voc(kind, id, name_de) as (`, ` select x.kind, x.id, x.name_de from jsonb_to_recordset(${jsonLines(voc)}) as x(kind text, id uuid, name_de text)`, `)`, `select rule, beschreibung, soll, ist, case when ok is null then 'Information' when ok then 'ok' else 'Verstoß' end as status`, `from (`, ` select 1 as ord, 'SB0' as rule, 'Startbestandsspiele in der Datenbank vorhanden' as beschreibung,`, ` 'alle ' || (select count(*) from sb) as soll, n as ist, null::boolean as ok`, ` from (select count(*) as n from public.games g join sb on sb.id = g.id) x`, ` union all`, ` select 2, 'SB1', 'Sichtbare Katalogspiele aus dem Startbestand (Abnahme: mindestens ${MIN_VISIBLE})',`, ` '>= ${MIN_VISIBLE}', n, n >= ${MIN_VISIBLE}`, ` from (select count(*) as n from public.catalog_games c join sb on sb.id = c.id) x`, ` union all`, ` select 3, 'SB2', 'Spiele ohne Pflicht-Fakt (Jahr, Spielerzahl, Dauer, Alter, Verlag, Genre, Mechanik)', '0', n, n = 0`, ` from (select count(*) as n from public.games g join sb on sb.id = g.id`, ` where g.year is null or g.min_players is null or g.max_players is null`, ` or g.min_minutes is null or g.max_minutes is null or g.min_age is null`, ` or not exists (select 1 from public.game_publishers x where x.game_id = g.id)`, ` or not exists (select 1 from public.game_genres x where x.game_id = g.id)`, ` or not exists (select 1 from public.game_mechanics x where x.game_id = g.id)) x`, ` union all`, ` select 4, 'SB4', 'Spiele ohne deutschen Kurztext', '0', n, n = 0`, ` from (select count(*) as n from public.games g join sb on sb.id = g.id`, ` where g.description is null or g.description_locale is distinct from 'de') x`, ` union all`, ` select 5, 'SB6', 'Spiele mit Cover, Quelle oder BGG-Kennung', '0', n, n = 0`, ` from (select count(*) as n from public.games g join sb on sb.id = g.id`, ` where g.image_path is not null or g.catalog_source_id is not null or g.bgg_id is not null) x`, ` union all`, ` select 6, 'Info', 'Begriffe mit abweichender ID (im Studio angelegt, bitte prüfen)', '-', n, null::boolean`, ` from (select (select count(*) from public.genres t join voc v on v.kind = 'genre' and v.name_de = t.name_de where t.id <> v.id)`, ` + (select count(*) from public.mechanics t join voc v on v.kind = 'mechanic' and v.name_de = t.name_de where t.id <> v.id) as n) x`, `) r`, `order by ord`, ].join("\n"); } export function generateChecksSql(startbestandIds: string[], vocabulary: VocabEntry[]): string { return [ GENERATED_HEADER.trimEnd(), "-- Prüfabfrage zur Abnahme (KAT-05 Krit. 7 und 10). Im Studio-SQL-Editor oder per psql ausführen.", "-- Beschränkt sich auf die Spiele aus startbestand-keys.csv; Spiele, die im Studio angelegt wurden,", "-- und das Beispielspiel aus seed.sql zählen nicht mit. Abnahme: kein Verstoß, SB1 mindestens " + MIN_VISIBLE + ".", "-- Lokal und in CI ist SB1 nur eine Information, solange noch eingepflegt wird.", generateChecksQuery(startbestandIds, vocabulary) + ";", "", ].join("\n"); } // Ausschließlich für pgTAP: legt die Prüfabfrage als temporäre View an (psql: \ir). Die IDs stammen // aus den Testspielen in catalog/testdata/, damit die Prüfabfrage selbst getestet werden kann. export function generateChecksFixtureInclude(startbestandIds: string[], vocabulary: VocabEntry[]): string { return [ "-- Erzeugt von supabase/catalog/build_startbestand.ts. Nicht von Hand bearbeiten.", "-- Include für pgTAP (\\ir), keine Test-Datei. Dieselbe Abfrage wie checks.sql, aber mit den IDs der", "-- Testspiele aus supabase/catalog/testdata/.", "create temp view checks_result as", generateChecksQuery(startbestandIds, vocabulary) + ";", "", ].join("\n"); } // --------------------------------------------------------------------------------------------- // Gesamtlauf // --------------------------------------------------------------------------------------------- export interface BuildInput { vocabularyCsv: string; startbestandCsv: string; previousKeysCsv: string; } export interface BuildOutput { "vocabulary.sql": string; "startbestand.sql": string; "startbestand-keys.csv": string; "checks.sql": string; checksFixtureInclude: string; } export async function build(input: BuildInput): Promise { const vocabulary = await parseVocabulary(input.vocabularyCsv); const previous = parseKeys(input.previousKeysCsv); const games = await parseStartbestand(input.startbestandCsv, vocabulary, previous); const keys = new Map(previous); for (const g of games) keys.set(g.key, g.id); const ids = [...keys.values()]; return { "vocabulary.sql": generateVocabularySql(vocabulary), "startbestand.sql": generateStartbestandSql(games), "startbestand-keys.csv": renderKeys(keys), "checks.sql": generateChecksSql(ids, vocabulary), checksFixtureInclude: generateChecksFixtureInclude(ids, vocabulary), }; } async function main() { const dir = new URL(".", import.meta.url); const read = (name: string) => Deno.readTextFile(new URL(name, dir)); let previousKeysCsv = "key,uuid\n"; try { previousKeysCsv = await read("startbestand-keys.csv"); } catch (e) { if (!(e instanceof Deno.errors.NotFound)) throw e; } const real = await build({ vocabularyCsv: await read("vocabulary.csv"), startbestandCsv: await read("startbestand.csv"), previousKeysCsv, }); // Testdaten (offensichtlich fiktiv) für pgTAP: eigenes Vokabular, eigene Spiele, keine Schlüsselliste. const fixture = await build({ vocabularyCsv: await read("testdata/vocabulary.csv"), startbestandCsv: await read("testdata/startbestand.csv"), previousKeysCsv: "key,uuid\n", }); for (const name of ["vocabulary.sql", "startbestand.sql", "startbestand-keys.csv", "checks.sql"] as const) { await Deno.writeTextFile(new URL(name, dir), real[name]); } const support = new URL("../tests/support/", dir); await Deno.mkdir(support, { recursive: true }); await Deno.writeTextFile(new URL("vocabulary_fixture.inc", support), fixture["vocabulary.sql"]); await Deno.writeTextFile(new URL("startbestand_fixture.inc", support), fixture["startbestand.sql"]); await Deno.writeTextFile(new URL("checks_fixture.inc", support), fixture.checksFixtureInclude); console.log( "Erzeugt: catalog/{vocabulary.sql,startbestand.sql,startbestand-keys.csv,checks.sql}, " + "tests/support/{vocabulary,startbestand,checks}_fixture.inc", ); } if (import.meta.main) { try { await main(); } catch (e) { if (e instanceof BuildError) { console.error("Prüfung fehlgeschlagen:"); for (const p of e.problems) console.error(" " + p); Deno.exit(1); } throw e; } }