diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 3144e53..0ef1afc 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -9,6 +9,8 @@ on: env: SUPABASE_CLI_VERSION: 3.0.0 + # Deno für das Startbestand-Werkzeug (supabase/catalog/, KAT-05); lokal getestet mit 2.9.8. + DENO_VERSION: 2.9.8 # Einzige Stelle für die Flutter-Version (= lokale Version, Dart 3.13.7; muss zu app/pubspec.yaml passen). # Muss ein existierendes Tag in github.com/flutter/flutter sein. Ein Cirrus-Image mit dieser Version gibt es nicht. FLUTTER_VERSION: 3.47.9 @@ -147,6 +149,36 @@ jobs: if [ -n "$changed" ]; then echo "Geänderte bestehende Migrationen: $changed"; exit 1; fi fi + # Startbestand-Werkzeug (KAT-05, Spec 2 D1): Deno-Tests, erzeugte Dateien aktuell, Schlüsselliste wächst nur. + - name: Deno installieren + run: | + arch=$(uname -m); [ "$arch" = "aarch64" ] && target=aarch64-unknown-linux-gnu || target=x86_64-unknown-linux-gnu + curl -fsSL "https://github.com/denoland/deno/releases/download/v${DENO_VERSION}/deno-${target}.zip" -o /tmp/deno.zip + unzip -q -o /tmp/deno.zip -d /usr/local/bin + deno --version + - name: Startbestand-Werkzeug testen + run: deno test --allow-read=supabase supabase/catalog/ + - name: Erzeugte Katalogdateien aktuell? + run: | + git config --global --add safe.directory "$GITHUB_WORKSPACE" + deno run --allow-read=supabase --allow-write=supabase supabase/catalog/build_startbestand.ts + if ! git diff --exit-code -- supabase/catalog supabase/tests/support; then + echo "::error::Erzeugte Dateien sind nicht aktuell. Lokal 'deno run --allow-read=supabase --allow-write=supabase supabase/catalog/build_startbestand.ts' ausführen und committen." + exit 1 + fi + untracked=$(git ls-files --others --exclude-standard -- supabase/catalog supabase/tests/support) + if [ -n "$untracked" ]; then echo "Nicht eingecheckte erzeugte Dateien:"; echo "$untracked"; exit 1; fi + - name: startbestand-keys.csv wächst nur + if: github.event_name == 'pull_request' + run: | + # Jede Zeile der Schlüsselliste aus dem Basis-Branch muss unverändert erhalten bleiben (Schlüssel sind unveränderlich). + git fetch origin "${{ github.base_ref }}" --depth=1 + if git cat-file -e "origin/${{ github.base_ref }}:supabase/catalog/startbestand-keys.csv" 2>/dev/null; then + git show "origin/${{ github.base_ref }}:supabase/catalog/startbestand-keys.csv" > /tmp/keys-base.csv + missing=$(grep -vxFf supabase/catalog/startbestand-keys.csv /tmp/keys-base.csv || true) + if [ -n "$missing" ]; then echo "::error::Zeilen aus startbestand-keys.csv fehlen oder wurden geändert:"; echo "$missing"; exit 1; fi + fi + # RLS-/pgTAP-Tests: braucht einen Runner mit Docker-Zugriff (Label "docker-host"). # Der Job-Container hat kein Docker-CLI und keinen Socket (Isolation, Repo ist öffentlich). Stattdessen # wird ein gepinntes Docker-CLI installiert und der dind-Daemon des Runners per TCP angesprochen diff --git a/supabase/catalog/build_startbestand.ts b/supabase/catalog/build_startbestand.ts new file mode 100644 index 0000000..d6723da --- /dev/null +++ b/supabase/catalog/build_startbestand.ts @@ -0,0 +1,670 @@ +// 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; + } +} diff --git a/supabase/catalog/build_startbestand_test.ts b/supabase/catalog/build_startbestand_test.ts new file mode 100644 index 0000000..6ec0e07 --- /dev/null +++ b/supabase/catalog/build_startbestand_test.ts @@ -0,0 +1,329 @@ +// 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 { + 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, 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}`); +}); diff --git a/supabase/catalog/checks.sql b/supabase/catalog/checks.sql new file mode 100644 index 0000000..087ffb4 --- /dev/null +++ b/supabase/catalog/checks.sql @@ -0,0 +1,86 @@ +-- Erzeugt von supabase/catalog/build_startbestand.ts. Nicht von Hand bearbeiten. +-- 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 180. +-- Lokal und in CI ist SB1 nur eine Information, solange noch eingepflegt wird. +with sb(id) as ( + select t.value::uuid from jsonb_array_elements_text('[]'::jsonb) as t(value) +), voc(kind, id, name_de) as ( + select x.kind, x.id, x.name_de from jsonb_to_recordset('[ + {"kind":"genre","id":"91c2b7c9-5a7c-5001-b90f-af05c0a8bc76","name_de":"Strategiespiel"}, + {"kind":"genre","id":"ecd63153-af09-5423-8579-483c4f1dc87c","name_de":"Familienspiel"}, + {"kind":"genre","id":"39c44fac-9323-5695-a02a-019620057a50","name_de":"Partyspiel"}, + {"kind":"genre","id":"7f50e253-41bb-5cde-ad0a-dc48e9c9295f","name_de":"Kooperatives Spiel"}, + {"kind":"genre","id":"503248a5-6424-5e06-aa9a-ec1d43fb0e28","name_de":"Kartenspiel"}, + {"kind":"genre","id":"963e400e-b6c9-5719-b6f0-2440d93fff7d","name_de":"Abstraktes Spiel"}, + {"kind":"genre","id":"aefb13d7-2a29-5901-bb8a-aa3f897a8e20","name_de":"Thematisches Spiel"}, + {"kind":"genre","id":"f7418ea2-db50-5e8c-a737-cab1ee15b34b","name_de":"Wirtschaftsspiel"}, + {"kind":"genre","id":"1b7da3a2-ace2-503f-b632-f697d651e1d0","name_de":"Kriegsspiel"}, + {"kind":"genre","id":"0114584b-4a22-5986-8896-8ee491aad1c7","name_de":"Kinderspiel"}, + {"kind":"genre","id":"71b582a5-c53d-5370-920f-cadc03725324","name_de":"Wortspiel"}, + {"kind":"genre","id":"693bbe28-03e5-554a-9548-ab54f186bffe","name_de":"Deduktionsspiel"}, + {"kind":"genre","id":"72baa1f7-835e-56d7-8341-c1c524148dd4","name_de":"Geschicklichkeitsspiel"}, + {"kind":"genre","id":"d8a5a92f-05b5-5dff-b321-baaecb5803bf","name_de":"Quizspiel"}, + {"kind":"mechanic","id":"00f902bc-e66b-5976-b235-7cd462ee23b6","name_de":"Arbeitereinsatz"}, + {"kind":"mechanic","id":"c7fde702-f7c3-5b81-b2b5-736253974a75","name_de":"Deckbau"}, + {"kind":"mechanic","id":"afb217d3-38bb-5989-8383-07fa200ded35","name_de":"Handkartenmanagement"}, + {"kind":"mechanic","id":"2ca2b8c5-9604-5186-a037-7386cd3f299b","name_de":"Würfeln"}, + {"kind":"mechanic","id":"2d35402f-c714-5cf7-a2e6-b035023ad51a","name_de":"Plättchenlegen"}, + {"kind":"mechanic","id":"83b32eef-078a-5822-91e9-d48157476056","name_de":"Sets sammeln"}, + {"kind":"mechanic","id":"c4cd186c-401b-5cc2-81cb-fcfd544c8a4d","name_de":"Drafting"}, + {"kind":"mechanic","id":"7d327434-bf71-5be2-a659-429c81c54cb5","name_de":"Gebietsmehrheit"}, + {"kind":"mechanic","id":"77565f86-d2e0-51c6-908a-dd1d05a08d26","name_de":"Bieten und Versteigern"}, + {"kind":"mechanic","id":"96dd12a0-f303-50d4-9baf-f56d9d8aa489","name_de":"Handel"}, + {"kind":"mechanic","id":"84019ba4-fc52-5343-97ad-2ea5c4f6ee6e","name_de":"Verhandlung"}, + {"kind":"mechanic","id":"6e49822b-dc6b-553f-b9e8-c5685d1e953b","name_de":"Streckenbau"}, + {"kind":"mechanic","id":"42521d16-2532-5583-8e41-ade8e0a15679","name_de":"Engine Building"}, + {"kind":"mechanic","id":"2d4aac3a-728d-54a2-a2cb-569410a56523","name_de":"Abholen und Liefern"}, + {"kind":"mechanic","id":"31d0ed7c-9dad-58da-8390-55786ee1e6c4","name_de":"Stichspiel"}, + {"kind":"mechanic","id":"1c83518d-9b9f-512c-a71e-585a5650adea","name_de":"Glück herausfordern"}, + {"kind":"mechanic","id":"f53a7662-1580-57e0-87d3-c79ed720a63a","name_de":"Bluffen"}, + {"kind":"mechanic","id":"71a4f344-709e-5536-a5a9-325d40732509","name_de":"Verdeckte Rollen"}, + {"kind":"mechanic","id":"3c02a90b-9cb3-50f8-950a-9464a2767c28","name_de":"Variable Spielerfähigkeiten"}, + {"kind":"mechanic","id":"e49b1223-2605-5c52-98e3-7e0d3e571e98","name_de":"Bewegung auf Raster"}, + {"kind":"mechanic","id":"3f98436d-5aab-56dc-b5e9-4d8a7b0cbfa4","name_de":"Muster bauen"}, + {"kind":"mechanic","id":"de7d374a-d1c6-506b-a92f-c726b06e0563","name_de":"Echtzeit"}, + {"kind":"mechanic","id":"c92e4a17-82f5-5e8a-8932-77dcad0cba29","name_de":"Gedächtnis"}, + {"kind":"mechanic","id":"edec6ec8-5b68-5fc0-892b-8fd739285593","name_de":"Würfeln und Notieren"}, + {"kind":"mechanic","id":"d7ef7709-0a6a-5cbd-b135-086dba038839","name_de":"Kampagne"}, + {"kind":"mechanic","id":"8ed41477-247c-5570-8c61-155e6f827660","name_de":"Aktionspunkte"}, + {"kind":"mechanic","id":"b90c9692-ff39-5170-a1ec-003b35d60950","name_de":"Ressourcenmanagement"}, + {"kind":"mechanic","id":"ef336193-3cfb-5f69-81c6-d57f8f720871","name_de":"Erzählen"}, + {"kind":"mechanic","id":"bc7940fd-3e2f-5932-b99f-31d4f55fd289","name_de":"Gegenseitig ärgern"}, + {"kind":"mechanic","id":"8ec5712b-42be-525a-b60d-1b79379d6f40","name_de":"Spieler scheiden aus"} +]'::jsonb) 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 180)', + '>= 180', n, n >= 180 + 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; diff --git a/supabase/catalog/startbestand-keys.csv b/supabase/catalog/startbestand-keys.csv new file mode 100644 index 0000000..ac6e2e7 --- /dev/null +++ b/supabase/catalog/startbestand-keys.csv @@ -0,0 +1 @@ +key,uuid diff --git a/supabase/catalog/startbestand.csv b/supabase/catalog/startbestand.csv new file mode 100644 index 0000000..9b2a0e6 --- /dev/null +++ b/supabase/catalog/startbestand.csv @@ -0,0 +1 @@ +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 diff --git a/supabase/catalog/startbestand.sql b/supabase/catalog/startbestand.sql new file mode 100644 index 0000000..4274f51 --- /dev/null +++ b/supabase/catalog/startbestand.sql @@ -0,0 +1,6 @@ +-- Erzeugt von supabase/catalog/build_startbestand.ts. Nicht von Hand bearbeiten. + +-- Startbestand (KAT-05). Idempotent bis zum Go-live; danach eingefroren (docs/katalog-pflege.md). +-- Reihenfolge: Migrationen, vocabulary.sql, dann diese Datei. + +-- startbestand.csv enthält keine Spiele. diff --git a/supabase/catalog/testdata/startbestand.csv b/supabase/catalog/testdata/startbestand.csv new file mode 100644 index 0000000..e120cb6 --- /dev/null +++ b/supabase/catalog/testdata/startbestand.csv @@ -0,0 +1,4 @@ +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 +testspiel-a,Testspiel A,Testverlag,2020,2,4,30,60,8,Testgenre A;Testgenre B,Testmechanik A,Fiktives Testspiel A. Nur für automatische Tests.,de,,Testdaten,, +testspiel-b,Testspiel B,Testverlag;Zweiter Testverlag,2021,1,5,10,20,6,Testgenre A,Testmechanik A;Testmechanik B,"Fiktives Testspiel B, mit Komma.",de,,Testdaten,,testspiel-a +testspiel-c,Testspiel C: Wo's lang geht,TESTVERLAG,1999,3,3,45,45,12,Testgenre B,Testmechanik B,Fiktives Testspiel C mit Apostroph im Titel.,de,ja,Testdaten,, diff --git a/supabase/catalog/testdata/vocabulary.csv b/supabase/catalog/testdata/vocabulary.csv new file mode 100644 index 0000000..91a2f5d --- /dev/null +++ b/supabase/catalog/testdata/vocabulary.csv @@ -0,0 +1,5 @@ +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 +mechanic,test-mechanic-b,Testmechanik B,Test mechanic B diff --git a/supabase/catalog/vocabulary.csv b/supabase/catalog/vocabulary.csv new file mode 100644 index 0000000..380ead4 --- /dev/null +++ b/supabase/catalog/vocabulary.csv @@ -0,0 +1,45 @@ +kind,vocab_key,name_de,name_en +genre,strategy,Strategiespiel,Strategy game +genre,family,Familienspiel,Family game +genre,party,Partyspiel,Party game +genre,cooperative,Kooperatives Spiel,Cooperative game +genre,card-game,Kartenspiel,Card game +genre,abstract,Abstraktes Spiel,Abstract game +genre,thematic,Thematisches Spiel,Thematic game +genre,economic,Wirtschaftsspiel,Economic game +genre,wargame,Kriegsspiel,Wargame +genre,kids,Kinderspiel,Children's game +genre,word,Wortspiel,Word game +genre,deduction,Deduktionsspiel,Deduction game +genre,dexterity,Geschicklichkeitsspiel,Dexterity game +genre,trivia,Quizspiel,Trivia game +mechanic,worker-placement,Arbeitereinsatz,Worker placement +mechanic,deck-building,Deckbau,Deck building +mechanic,hand-management,Handkartenmanagement,Hand management +mechanic,dice-rolling,Würfeln,Dice rolling +mechanic,tile-placement,Plättchenlegen,Tile placement +mechanic,set-collection,Sets sammeln,Set collection +mechanic,drafting,Drafting,Drafting +mechanic,area-control,Gebietsmehrheit,Area control +mechanic,auction,Bieten und Versteigern,Auction and bidding +mechanic,trading,Handel,Trading +mechanic,negotiation,Verhandlung,Negotiation +mechanic,route-building,Streckenbau,Route building +mechanic,engine-building,Engine Building,Engine building +mechanic,pick-up-and-deliver,Abholen und Liefern,Pick-up and deliver +mechanic,trick-taking,Stichspiel,Trick-taking +mechanic,push-your-luck,Glück herausfordern,Push your luck +mechanic,bluffing,Bluffen,Bluffing +mechanic,hidden-roles,Verdeckte Rollen,Hidden roles +mechanic,variable-powers,Variable Spielerfähigkeiten,Variable player powers +mechanic,grid-movement,Bewegung auf Raster,Grid movement +mechanic,pattern-building,Muster bauen,Pattern building +mechanic,real-time,Echtzeit,Real-time +mechanic,memory,Gedächtnis,Memory +mechanic,roll-and-write,Würfeln und Notieren,Roll and write +mechanic,campaign,Kampagne,Campaign +mechanic,action-points,Aktionspunkte,Action points +mechanic,resource-management,Ressourcenmanagement,Resource management +mechanic,storytelling,Erzählen,Storytelling +mechanic,take-that,Gegenseitig ärgern,Take that +mechanic,player-elimination,Spieler scheiden aus,Player elimination diff --git a/supabase/catalog/vocabulary.sql b/supabase/catalog/vocabulary.sql new file mode 100644 index 0000000..40080e1 --- /dev/null +++ b/supabase/catalog/vocabulary.sql @@ -0,0 +1,54 @@ +-- Erzeugt von supabase/catalog/build_startbestand.ts. Nicht von Hand bearbeiten. + +-- 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. + +insert into public.genres (id, name_de, name_en) values + ('91c2b7c9-5a7c-5001-b90f-af05c0a8bc76', 'Strategiespiel', 'Strategy game'), + ('ecd63153-af09-5423-8579-483c4f1dc87c', 'Familienspiel', 'Family game'), + ('39c44fac-9323-5695-a02a-019620057a50', 'Partyspiel', 'Party game'), + ('7f50e253-41bb-5cde-ad0a-dc48e9c9295f', 'Kooperatives Spiel', 'Cooperative game'), + ('503248a5-6424-5e06-aa9a-ec1d43fb0e28', 'Kartenspiel', 'Card game'), + ('963e400e-b6c9-5719-b6f0-2440d93fff7d', 'Abstraktes Spiel', 'Abstract game'), + ('aefb13d7-2a29-5901-bb8a-aa3f897a8e20', 'Thematisches Spiel', 'Thematic game'), + ('f7418ea2-db50-5e8c-a737-cab1ee15b34b', 'Wirtschaftsspiel', 'Economic game'), + ('1b7da3a2-ace2-503f-b632-f697d651e1d0', 'Kriegsspiel', 'Wargame'), + ('0114584b-4a22-5986-8896-8ee491aad1c7', 'Kinderspiel', 'Children''s game'), + ('71b582a5-c53d-5370-920f-cadc03725324', 'Wortspiel', 'Word game'), + ('693bbe28-03e5-554a-9548-ab54f186bffe', 'Deduktionsspiel', 'Deduction game'), + ('72baa1f7-835e-56d7-8341-c1c524148dd4', 'Geschicklichkeitsspiel', 'Dexterity game'), + ('d8a5a92f-05b5-5dff-b321-baaecb5803bf', 'Quizspiel', 'Trivia game') +on conflict do nothing; + +insert into public.mechanics (id, name_de, name_en) values + ('00f902bc-e66b-5976-b235-7cd462ee23b6', 'Arbeitereinsatz', 'Worker placement'), + ('c7fde702-f7c3-5b81-b2b5-736253974a75', 'Deckbau', 'Deck building'), + ('afb217d3-38bb-5989-8383-07fa200ded35', 'Handkartenmanagement', 'Hand management'), + ('2ca2b8c5-9604-5186-a037-7386cd3f299b', 'Würfeln', 'Dice rolling'), + ('2d35402f-c714-5cf7-a2e6-b035023ad51a', 'Plättchenlegen', 'Tile placement'), + ('83b32eef-078a-5822-91e9-d48157476056', 'Sets sammeln', 'Set collection'), + ('c4cd186c-401b-5cc2-81cb-fcfd544c8a4d', 'Drafting', 'Drafting'), + ('7d327434-bf71-5be2-a659-429c81c54cb5', 'Gebietsmehrheit', 'Area control'), + ('77565f86-d2e0-51c6-908a-dd1d05a08d26', 'Bieten und Versteigern', 'Auction and bidding'), + ('96dd12a0-f303-50d4-9baf-f56d9d8aa489', 'Handel', 'Trading'), + ('84019ba4-fc52-5343-97ad-2ea5c4f6ee6e', 'Verhandlung', 'Negotiation'), + ('6e49822b-dc6b-553f-b9e8-c5685d1e953b', 'Streckenbau', 'Route building'), + ('42521d16-2532-5583-8e41-ade8e0a15679', 'Engine Building', 'Engine building'), + ('2d4aac3a-728d-54a2-a2cb-569410a56523', 'Abholen und Liefern', 'Pick-up and deliver'), + ('31d0ed7c-9dad-58da-8390-55786ee1e6c4', 'Stichspiel', 'Trick-taking'), + ('1c83518d-9b9f-512c-a71e-585a5650adea', 'Glück herausfordern', 'Push your luck'), + ('f53a7662-1580-57e0-87d3-c79ed720a63a', 'Bluffen', 'Bluffing'), + ('71a4f344-709e-5536-a5a9-325d40732509', 'Verdeckte Rollen', 'Hidden roles'), + ('3c02a90b-9cb3-50f8-950a-9464a2767c28', 'Variable Spielerfähigkeiten', 'Variable player powers'), + ('e49b1223-2605-5c52-98e3-7e0d3e571e98', 'Bewegung auf Raster', 'Grid movement'), + ('3f98436d-5aab-56dc-b5e9-4d8a7b0cbfa4', 'Muster bauen', 'Pattern building'), + ('de7d374a-d1c6-506b-a92f-c726b06e0563', 'Echtzeit', 'Real-time'), + ('c92e4a17-82f5-5e8a-8932-77dcad0cba29', 'Gedächtnis', 'Memory'), + ('edec6ec8-5b68-5fc0-892b-8fd739285593', 'Würfeln und Notieren', 'Roll and write'), + ('d7ef7709-0a6a-5cbd-b135-086dba038839', 'Kampagne', 'Campaign'), + ('8ed41477-247c-5570-8c61-155e6f827660', 'Aktionspunkte', 'Action points'), + ('b90c9692-ff39-5170-a1ec-003b35d60950', 'Ressourcenmanagement', 'Resource management'), + ('ef336193-3cfb-5f69-81c6-d57f8f720871', 'Erzählen', 'Storytelling'), + ('bc7940fd-3e2f-5932-b99f-31d4f55fd289', 'Gegenseitig ärgern', 'Take that'), + ('8ec5712b-42be-525a-b60d-1b79379d6f40', 'Spieler scheiden aus', 'Player elimination') +on conflict do nothing; diff --git a/supabase/config.toml b/supabase/config.toml index 984ed68..5608a41 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -69,7 +69,10 @@ schema_paths = [] enabled = true # Specifies an ordered list of seed files to load during db reset. # Supports glob patterns relative to supabase directory: "./seeds/*.sql" -sql_paths = ["./seed.sql"] +# Reihenfolge: seed.sql (nur Entwicklung), Vokabular, Startbestand (KAT-05, erzeugt von catalog/build_startbestand.ts). +# Eine nicht gefundene Datei überspringt die CLI nur mit Warnung ("no files matched pattern"), der Reset schlägt dann +# nicht fehl. Fehler in einer Datei brechen den Reset ab (Exit 1); die Datei wird als Ganzes zurückgerollt. +sql_paths = ["./seed.sql", "./catalog/vocabulary.sql", "./catalog/startbestand.sql"] [db.network_restrictions] # Enable management of network restrictions. diff --git a/supabase/seed.sql b/supabase/seed.sql index 077f05e..8b78cfb 100644 --- a/supabase/seed.sql +++ b/supabase/seed.sql @@ -1,14 +1,6 @@ -- Beispieldaten nur für die lokale Entwicklung (supabase db reset). Nicht für Produktion. -insert into public.genres (name_de, name_en) values - ('Strategie', 'Strategy'), ('Familie', 'Family'), ('Kooperativ', 'Cooperative'), - ('Party', 'Party'), ('Kartenspiel', 'Card game') -on conflict do nothing; - -insert into public.mechanics (name_de, name_en) values - ('Handkartenmanagement', 'Hand management'), ('Worker Placement', 'Worker placement'), - ('Deckbau', 'Deck building'), ('Würfeln', 'Dice rolling') -on conflict do nothing; - +-- Genres und Mechaniken kommen aus supabase/catalog/vocabulary.sql (KAT-05), nicht von hier: +-- seed.sql läuft zuerst, ein Begriff mit zufälliger ID würde das Vokabular verdrängen. insert into public.categories (name_de, name_en) values ('Wirtschaft', 'Economic'), ('Fantasy', 'Fantasy'), ('Abenteuer', 'Adventure'), ('Zivilisation', 'Civilization') on conflict do nothing;