feat(catalog): add starting-stock tool, vocabulary proposal and CI checks
Deno script validates vocabulary.csv and startbestand.csv and generates the idempotent SQL, key list and checks.sql. startbestand.csv stays header-only; vocabulary.csv is a proposal pending approval (C0). seed.sql no longer inserts genres and mechanics. Refs #2 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
3c762003b2
commit
9dbafbcad7
13 changed files with 1239 additions and 11 deletions
|
|
@ -9,6 +9,8 @@ on:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
SUPABASE_CLI_VERSION: 3.0.0
|
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).
|
# 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.
|
# Muss ein existierendes Tag in github.com/flutter/flutter sein. Ein Cirrus-Image mit dieser Version gibt es nicht.
|
||||||
FLUTTER_VERSION: 3.47.9
|
FLUTTER_VERSION: 3.47.9
|
||||||
|
|
@ -147,6 +149,36 @@ jobs:
|
||||||
if [ -n "$changed" ]; then echo "Geänderte bestehende Migrationen: $changed"; exit 1; fi
|
if [ -n "$changed" ]; then echo "Geänderte bestehende Migrationen: $changed"; exit 1; fi
|
||||||
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").
|
# 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
|
# 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
|
# wird ein gepinntes Docker-CLI installiert und der dind-Daemon des Runners per TCP angesprochen
|
||||||
|
|
|
||||||
670
supabase/catalog/build_startbestand.ts
Normal file
670
supabase/catalog/build_startbestand.ts
Normal file
|
|
@ -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<string> {
|
||||||
|
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<string, string>;
|
||||||
|
}
|
||||||
|
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<string, string> = {};
|
||||||
|
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<string>();
|
||||||
|
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<VocabEntry[]> {
|
||||||
|
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<string>();
|
||||||
|
const seenDe = new Set<string>();
|
||||||
|
const seenEn = new Set<string>();
|
||||||
|
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<string, string> {
|
||||||
|
const csv = parseCsv(text, file);
|
||||||
|
const problems = checkHeader(csv, file, ["key", "uuid"], []);
|
||||||
|
if (problems.length) throw new BuildError(problems);
|
||||||
|
const keys = new Map<string, string>();
|
||||||
|
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, string>): 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<string, string>,
|
||||||
|
file = "startbestand.csv",
|
||||||
|
): Promise<Game[]> {
|
||||||
|
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<string>();
|
||||||
|
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<string>();
|
||||||
|
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<string>, label: string): string[] => {
|
||||||
|
const names = splitList(cells[col]);
|
||||||
|
if (names.length === 0) fail(`mindestens ${label} nötig (${col}, SB2)`);
|
||||||
|
const seen = new Set<string>();
|
||||||
|
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<string, string>(); // 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<BuildOutput> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
329
supabase/catalog/build_startbestand_test.ts
Normal file
329
supabase/catalog/build_startbestand_test.ts
Normal file
|
|
@ -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, string> = {}): string {
|
||||||
|
const base: Record<string, string> = {
|
||||||
|
key: "testspiel-a", title: "Testspiel A", publishers: "Testverlag", year: "2020", min_players: "2",
|
||||||
|
max_players: "4", min_minutes: "30", max_minutes: "60", min_age: "8", genres: "Testgenre A",
|
||||||
|
mechanics: "Testmechanik A", description: "Fiktiver Text.", description_locale: "de", hidden: "",
|
||||||
|
facts_source: "Test", alt_titles: "", base_game_key: "",
|
||||||
|
};
|
||||||
|
const r = { ...base, ...over };
|
||||||
|
return HEADER.split(",").map((c) => {
|
||||||
|
const v = r[c];
|
||||||
|
return /[",\n]/.test(v) ? `"${v.replaceAll('"', '""')}"` : v;
|
||||||
|
}).join(",");
|
||||||
|
}
|
||||||
|
const csv = (...rows: string[]) => [HEADER, ...rows].join("\n") + "\n";
|
||||||
|
|
||||||
|
async function vocab() {
|
||||||
|
return await parseVocabulary(VOCAB);
|
||||||
|
}
|
||||||
|
async function rejects(text: string, includes: RegExp, keys = new Map<string, string>()) {
|
||||||
|
const v = await vocab();
|
||||||
|
await assert.rejects(
|
||||||
|
() => parseStartbestand(text, v, keys),
|
||||||
|
(e: unknown) => {
|
||||||
|
assert.ok(e instanceof BuildError, "BuildError erwartet");
|
||||||
|
assert.match(e.problems.join("\n"), includes);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- UUID v5 ------------------------------------------------------------------------------
|
||||||
|
Deno.test("uuidV5: Referenzwert aus RFC 4122 / Python uuid5(NAMESPACE_DNS, ...)", async () => {
|
||||||
|
assert.equal(
|
||||||
|
await uuidV5("www.example.com", "6ba7b810-9dad-11d1-80b4-00c04fd430c8"),
|
||||||
|
"2ed6657d-e927-568b-95e1-2665a8aea6a2",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("uuidV5: gleicher key gibt gleiche UUID wie uuid_generate_v5 in PostgreSQL (siehe pgTAP 06)", async () => {
|
||||||
|
assert.equal(NAMESPACE, "6f1d9a52-3c4e-4b8a-9f27-5d0e8c71a3b4");
|
||||||
|
assert.equal(await uuidV5("game:testspiel-a"), "5189ad0e-ab5b-5500-a3e0-a0b3671cdf14");
|
||||||
|
assert.equal(await uuidV5("game:testspiel-a"), await uuidV5("game:testspiel-a"));
|
||||||
|
assert.notEqual(await uuidV5("game:testspiel-a"), await uuidV5("game:testspiel-b"));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- CSV ----------------------------------------------------------------------------------
|
||||||
|
Deno.test("parseCsv: Anführungszeichen, doppelte Anführungszeichen, Zeilenumbruch im Feld, CRLF, BOM", () => {
|
||||||
|
const parsed = parseCsv('a,b\r\n"x, y","sagt ""hallo"""\r\n"zwei\nZeilen",z\r\n', "t.csv");
|
||||||
|
assert.deepEqual(parsed.header, ["a", "b"]);
|
||||||
|
assert.equal(parsed.rows[0].cells.a, "x, y");
|
||||||
|
assert.equal(parsed.rows[0].cells.b, 'sagt "hallo"');
|
||||||
|
assert.equal(parsed.rows[1].cells.a, "zwei\nZeilen");
|
||||||
|
assert.equal(parsed.rows[1].line, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("parseCsv: falsche Spaltenzahl und offenes Anführungszeichen nennen die Zeile", () => {
|
||||||
|
assert.throws(() => parseCsv("a,b\n1,2,3\n", "t.csv"), /t\.csv:2: 3 Spalten statt 2/);
|
||||||
|
assert.throws(() => parseCsv('a,b\n"offen,2\n', "t.csv"), /t\.csv:2: Anführungszeichen nicht geschlossen/);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("parseCsv: nur Kopfzeile ergibt keine Zeilen", () => {
|
||||||
|
assert.equal(parseCsv("a,b\n", "t.csv").rows.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Vokabular ----------------------------------------------------------------------------
|
||||||
|
Deno.test("parseVocabulary: gültig, IDs nach UUID v5", async () => {
|
||||||
|
const v = await vocab();
|
||||||
|
assert.equal(v.length, 3);
|
||||||
|
assert.equal(v[0].id, await uuidV5("genre:test-genre-a"));
|
||||||
|
assert.equal(v[2].id, await uuidV5("mechanic:test-mechanic-a"));
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("parseVocabulary: doppelter vocab_key, doppelter Name, falsche kind, falscher Schlüssel", async () => {
|
||||||
|
const head = "kind,vocab_key,name_de,name_en\n";
|
||||||
|
await assert.rejects(() => parseVocabulary(head + "genre,a-b,A,A\ngenre,a-b,B,B\n"), /vocabulary\.csv:3: vocab_key "a-b" doppelt/);
|
||||||
|
await assert.rejects(() => parseVocabulary(head + "genre,a-b,A,A\ngenre,c-d,a,C\n"), /vocabulary\.csv:3: name_de "a" doppelt/);
|
||||||
|
await assert.rejects(() => parseVocabulary(head + "genre,a-b,A,A\ngenre,c-d,C,a\n"), /vocabulary\.csv:3: name_en "a" doppelt/);
|
||||||
|
await assert.rejects(() => parseVocabulary(head + "kategorie,a-b,A,A\n"), /kind muss/);
|
||||||
|
await assert.rejects(() => parseVocabulary(head + "genre,A_B,A,A\n"), /vocab_key "A_B" ungültig/);
|
||||||
|
await assert.rejects(() => parseVocabulary("kind,vocab_key,name_de\ngenre,a-b,A\n"), /Spalte "name_en" fehlt/);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("parseVocabulary: gleiche Namen in verschiedenen Arten sind erlaubt", async () => {
|
||||||
|
const v = await parseVocabulary("kind,vocab_key,name_de,name_en\ngenre,a-b,A,A\nmechanic,a-b,A,A\n");
|
||||||
|
assert.equal(v.length, 2);
|
||||||
|
assert.notEqual(v[0].id, v[1].id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Startbestand: gültig -----------------------------------------------------------------
|
||||||
|
Deno.test("parseStartbestand: gültige Zeile (Positivfall)", async () => {
|
||||||
|
const games = await parseStartbestand(csv(row()), await vocab(), new Map());
|
||||||
|
assert.equal(games.length, 1);
|
||||||
|
assert.equal(games[0].id, await uuidV5("game:testspiel-a"));
|
||||||
|
assert.deepEqual(games[0].publishers, ["Testverlag"]);
|
||||||
|
assert.equal(games[0].hidden, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("parseStartbestand: nur Kopfzeile ist gültig (heutiger Stand ohne Spiele)", async () => {
|
||||||
|
assert.deepEqual(await parseStartbestand(HEADER + "\n", await vocab(), new Map()), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Startbestand: eine Negativprobe je Prüfregel -----------------------------------------
|
||||||
|
Deno.test("SB2: fehlende Pflicht-Fakten", async () => {
|
||||||
|
for (const col of ["year", "min_players", "max_players", "min_minutes", "max_minutes", "min_age"]) {
|
||||||
|
await rejects(csv(row({ [col]: "" })), new RegExp(`startbestand\\.csv:2: ${col} fehlt`));
|
||||||
|
}
|
||||||
|
await rejects(csv(row({ publishers: "" })), /mindestens ein Verlag/);
|
||||||
|
await rejects(csv(row({ publishers: " ; " })), /mindestens ein Verlag/);
|
||||||
|
await rejects(csv(row({ genres: "" })), /mindestens ein Genre/);
|
||||||
|
await rejects(csv(row({ mechanics: "" })), /mindestens eine Mechanik/);
|
||||||
|
await rejects(csv(row({ title: "" })), /title muss 1-200 Zeichen/);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("DB-Grenzen: Jahr, max < min, keine Zahl, negativ", async () => {
|
||||||
|
await rejects(csv(row({ year: "1799" })), /year 1799 außerhalb/);
|
||||||
|
await rejects(csv(row({ year: "2101" })), /year 2101 außerhalb/);
|
||||||
|
await rejects(csv(row({ year: "20x0" })), /year "20x0" ist keine ganze Zahl/);
|
||||||
|
await rejects(csv(row({ min_players: "0" })), /min_players 0 außerhalb/);
|
||||||
|
await rejects(csv(row({ min_players: "5", max_players: "3" })), /max_players \(3\) kleiner als min_players \(5\)/);
|
||||||
|
await rejects(csv(row({ min_minutes: "90", max_minutes: "30" })), /max_minutes \(30\) kleiner als min_minutes \(90\)/);
|
||||||
|
await rejects(csv(row({ min_age: "-1" })), /min_age "-1" ist keine ganze Zahl/);
|
||||||
|
await rejects(csv(row({ title: "x".repeat(201) })), /title muss 1-200 Zeichen/);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("SB4: Kurztext Pflicht, Sprache de, Länge", async () => {
|
||||||
|
await rejects(csv(row({ description: "" })), /Kurztext fehlt/);
|
||||||
|
await rejects(csv(row({ description_locale: "en" })), /description_locale muss "de" sein/);
|
||||||
|
await rejects(csv(row({ description_locale: "" })), /description_locale muss "de" sein/);
|
||||||
|
await rejects(csv(row({ description: "x".repeat(5001) })), /länger als 5000/);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("SB5: unbekanntes Genre oder unbekannte Mechanik, Doppelte", async () => {
|
||||||
|
await rejects(csv(row({ genres: "Erfundenes" })), /unbekannt in genres: "Erfundenes"/);
|
||||||
|
await rejects(csv(row({ mechanics: "Testgenre A" })), /unbekannt in mechanics/); // Genre ist keine Mechanik
|
||||||
|
await rejects(csv(row({ genres: "Testgenre A;Testgenre A" })), /genres: "Testgenre A" doppelt/);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("SB6: keine Spalte für Cover, Quelle oder BGG-Kennung", async () => {
|
||||||
|
for (const col of ["image_path", "bgg_id", "catalog_source_id", "source_ref", "cover"]) {
|
||||||
|
await rejects(`${HEADER},${col}\n${row()},x\n`, new RegExp(`unbekannte Spalte "${col}"`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("SB7: base_game_key muss auf einen vorhandenen key zeigen", async () => {
|
||||||
|
await rejects(csv(row({ base_game_key: "gibt-es-nicht" })), /base_game_key "gibt-es-nicht" hat keinen passenden key/);
|
||||||
|
await rejects(csv(row({ base_game_key: "testspiel-a" })), /verweist auf das Spiel selbst/);
|
||||||
|
const ok = await parseStartbestand(
|
||||||
|
csv(row(), row({ key: "testspiel-b", title: "Testspiel B", base_game_key: "testspiel-a" })),
|
||||||
|
await vocab(),
|
||||||
|
new Map(),
|
||||||
|
);
|
||||||
|
assert.equal(ok.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("key: Format und Eindeutigkeit", async () => {
|
||||||
|
await rejects(csv(row({ key: "Ungültig_Key" })), /key "Ungültig_Key" ungültig/);
|
||||||
|
await rejects(csv(row({ key: "a" })), /key "a" ungültig/);
|
||||||
|
await rejects(csv(row(), row({ title: "Noch eins" })), /startbestand\.csv:3: key "testspiel-a" doppelt/);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("hidden: nur leer oder ja; Verlage nicht doppelt", async () => {
|
||||||
|
await rejects(csv(row({ hidden: "nein" })), /hidden muss leer oder "ja" sein/);
|
||||||
|
await rejects(csv(row({ publishers: "Testverlag;TESTVERLAG" })), /Verlag "TESTVERLAG" doppelt/);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Pflichtspalte fehlt in der Kopfzeile", async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
async () => await parseStartbestand("key,title\nx,y\n", await vocab(), new Map()),
|
||||||
|
/Spalte "publishers" fehlt/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("mehrere Fehler werden gesammelt, mit Zeilennummern", async () => {
|
||||||
|
const v = await vocab();
|
||||||
|
await assert.rejects(
|
||||||
|
() => parseStartbestand(csv(row({ year: "" }), row({ key: "testspiel-b", genres: "X" })), v, new Map()),
|
||||||
|
(e: unknown) => {
|
||||||
|
assert.ok(e instanceof BuildError);
|
||||||
|
assert.match(e.problems.join("\n"), /startbestand\.csv:2: year fehlt/);
|
||||||
|
assert.match(e.problems.join("\n"), /startbestand\.csv:3: unbekannt in genres/);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Schlüsselliste -----------------------------------------------------------------------
|
||||||
|
Deno.test("Schlüssel sind unveränderlich: ein entfernter key bricht ab", async () => {
|
||||||
|
const previous = parseKeys(`key,uuid\ntestspiel-a,${await uuidV5("game:testspiel-a")}\ntestspiel-alt,${await uuidV5("game:testspiel-alt")}\n`);
|
||||||
|
await rejects(csv(row()), /Schlüssel entfernt oder umbenannt: "testspiel-alt"/, previous);
|
||||||
|
// Umbenennen ist dasselbe wie Entfernen
|
||||||
|
await rejects(csv(row({ key: "testspiel-neu" })), /Schlüssel entfernt oder umbenannt: "testspiel-a"/, previous);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Schlüssel: falsche UUID in der Liste (Namespace geändert?)", async () => {
|
||||||
|
const previous = parseKeys("key,uuid\ntestspiel-a,00000000-0000-5000-8000-000000000000\n");
|
||||||
|
await rejects(csv(row()), /uuid zu "testspiel-a" ist 00000000-0000-5000-8000-000000000000, erwartet/, previous);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Schlüsselliste wächst nur und ist sortiert", async () => {
|
||||||
|
const first = await build({ vocabularyCsv: VOCAB, startbestandCsv: csv(row({ key: "zzz-spiel" })), previousKeysCsv: "key,uuid\n" });
|
||||||
|
const second = await build({
|
||||||
|
vocabularyCsv: VOCAB,
|
||||||
|
startbestandCsv: csv(row({ key: "zzz-spiel" }), row({ key: "aaa-spiel", title: "Neu" })),
|
||||||
|
previousKeysCsv: first["startbestand-keys.csv"],
|
||||||
|
});
|
||||||
|
const before = first["startbestand-keys.csv"].trim().split("\n");
|
||||||
|
const after = second["startbestand-keys.csv"].trim().split("\n");
|
||||||
|
for (const line of before) assert.ok(after.includes(line), `Zeile fehlt: ${line}`);
|
||||||
|
assert.deepEqual(after.slice(1).map((l) => l.split(",")[0]), ["aaa-spiel", "zzz-spiel"]);
|
||||||
|
assert.equal(renderKeys(parseKeys(second["startbestand-keys.csv"])), second["startbestand-keys.csv"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- SQL-Erzeugung ------------------------------------------------------------------------
|
||||||
|
async function sqlFor(...rows: string[]): Promise<string> {
|
||||||
|
return generateStartbestandSql(await parseStartbestand(csv(...rows), await vocab(), new Map()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.test("hidden = ja erzeugt hidden_at, leer fasst hidden_at nicht an", async () => {
|
||||||
|
const hiddenSql = await sqlFor(row({ hidden: "ja" }));
|
||||||
|
assert.match(hiddenSql, /update public\.games set hidden_at = now\(\)/);
|
||||||
|
assert.match(hiddenSql, /where hidden_at is null and id = any/);
|
||||||
|
const plain = await sqlFor(row());
|
||||||
|
assert.doesNotMatch(plain, /hidden_at/);
|
||||||
|
assert.doesNotMatch(plain, /update public\.games set/);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("facts_source, alt_titles und base_game_key landen nicht in der SQL", async () => {
|
||||||
|
const sql = await sqlFor(row({ facts_source: "GEHEIMER-NACHWEIS", alt_titles: "de:Alt", base_game_key: "" }));
|
||||||
|
assert.doesNotMatch(sql, /GEHEIMER-NACHWEIS|alt_titles|base_game_key|facts_source/);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("SQL: Apostroph im Text wird verdoppelt, Upsert nutzt is distinct from, Zuordnungen werden abgeglichen", async () => {
|
||||||
|
const sql = await sqlFor(row({ title: "Wo's lang", description: "Es ist 'nett'." }));
|
||||||
|
assert.match(sql, /'Wo''s lang'/);
|
||||||
|
assert.match(sql, /'Es ist ''nett''\.'/);
|
||||||
|
assert.match(sql, /on conflict \(id\) do update set/);
|
||||||
|
assert.match(sql, /is distinct from \(excluded\.title/);
|
||||||
|
assert.match(sql, /on conflict \(\(lower\(name\)\)\) do nothing/); // Verlage
|
||||||
|
assert.match(sql, /delete from public\.game_genres/);
|
||||||
|
assert.match(sql, /delete from public\.game_mechanics/);
|
||||||
|
assert.match(sql, /delete from public\.game_publishers/);
|
||||||
|
assert.match(sql, /on conflict \(game_id, genre_id\) do nothing/);
|
||||||
|
assert.match(sql, /Genre fehlt in der Datenbank/); // Wächter gegen still verlorene Zuordnungen
|
||||||
|
assert.doesNotMatch(sql, /image_path|bgg_id|catalog_source_id|source_ref/); // SB6
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("SQL: leerer Startbestand ergibt eine gültige, leere Datei", async () => {
|
||||||
|
const sql = generateStartbestandSql([]);
|
||||||
|
assert.match(sql, /keine Spiele/);
|
||||||
|
assert.doesNotMatch(sql, /insert into/);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("build: deterministisch, ohne Zeitstempel", async () => {
|
||||||
|
const input = { vocabularyCsv: VOCAB, startbestandCsv: csv(row(), row({ key: "testspiel-b", title: "B" })), previousKeysCsv: "key,uuid\n" };
|
||||||
|
assert.deepEqual(await build(input), await build(input));
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("checks.sql: bettet die Startbestands-IDs ein, Sollwert 180, leerer Bestand ist gültiges SQL", async () => {
|
||||||
|
const out = await build({ vocabularyCsv: VOCAB, startbestandCsv: csv(row()), previousKeysCsv: "key,uuid\n" });
|
||||||
|
assert.match(out["checks.sql"], new RegExp(await uuidV5("game:testspiel-a")));
|
||||||
|
assert.match(out["checks.sql"], />= 180/);
|
||||||
|
const empty = await build({ vocabularyCsv: VOCAB, startbestandCsv: HEADER + "\n", previousKeysCsv: "key,uuid\n" });
|
||||||
|
assert.match(empty["checks.sql"], /jsonb_array_elements_text\('\[\]'::jsonb\)/);
|
||||||
|
assert.doesNotMatch(empty["checks.sql"], /values \(\s*\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("vocabulary.sql: on conflict do nothing ohne Konfliktziel", async () => {
|
||||||
|
const out = await build({ vocabularyCsv: VOCAB, startbestandCsv: HEADER + "\n", previousKeysCsv: "key,uuid\n" });
|
||||||
|
assert.match(out["vocabulary.sql"], /insert into public\.genres \(id, name_de, name_en\) values/);
|
||||||
|
assert.match(out["vocabulary.sql"], /insert into public\.mechanics/);
|
||||||
|
assert.equal((out["vocabulary.sql"].match(/on conflict do nothing;/g) ?? []).length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Eingecheckte, erzeugte Dateien sind aktuell ------------------------------------------
|
||||||
|
Deno.test("die eingecheckten erzeugten Dateien entsprechen den CSV-Quellen", async () => {
|
||||||
|
const dir = new URL(".", import.meta.url);
|
||||||
|
const read = (name: string) => Deno.readTextFile(new URL(name, dir));
|
||||||
|
const real = await build({
|
||||||
|
vocabularyCsv: await read("vocabulary.csv"),
|
||||||
|
startbestandCsv: await read("startbestand.csv"),
|
||||||
|
previousKeysCsv: await read("startbestand-keys.csv"),
|
||||||
|
});
|
||||||
|
for (const name of ["vocabulary.sql", "startbestand.sql", "startbestand-keys.csv", "checks.sql"] as const) {
|
||||||
|
assert.equal(await read(name), real[name], `${name} ist nicht aktuell: build_startbestand.ts ausführen`);
|
||||||
|
}
|
||||||
|
const fixture = await build({
|
||||||
|
vocabularyCsv: await read("testdata/vocabulary.csv"),
|
||||||
|
startbestandCsv: await read("testdata/startbestand.csv"),
|
||||||
|
previousKeysCsv: "key,uuid\n",
|
||||||
|
});
|
||||||
|
const support = (name: string) => Deno.readTextFile(new URL(`../tests/support/${name}`, dir));
|
||||||
|
assert.equal(await support("vocabulary_fixture.inc"), fixture["vocabulary.sql"]);
|
||||||
|
assert.equal(await support("startbestand_fixture.inc"), fixture["startbestand.sql"]);
|
||||||
|
assert.equal(await support("checks_fixture.inc"), fixture.checksFixtureInclude);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Vokabular-Vorschlag: 12-15 Genres, 20-30 Mechaniken (Aufgabe B3a)", async () => {
|
||||||
|
const v = await parseVocabulary(await Deno.readTextFile(new URL("vocabulary.csv", import.meta.url)));
|
||||||
|
const genres = v.filter((e) => e.kind === "genre").length;
|
||||||
|
const mechanics = v.filter((e) => e.kind === "mechanic").length;
|
||||||
|
assert.ok(genres >= 12 && genres <= 15, `Genres: ${genres}`);
|
||||||
|
assert.ok(mechanics >= 20 && mechanics <= 30, `Mechaniken: ${mechanics}`);
|
||||||
|
});
|
||||||
86
supabase/catalog/checks.sql
Normal file
86
supabase/catalog/checks.sql
Normal file
|
|
@ -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;
|
||||||
1
supabase/catalog/startbestand-keys.csv
Normal file
1
supabase/catalog/startbestand-keys.csv
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
key,uuid
|
||||||
|
1
supabase/catalog/startbestand.csv
Normal file
1
supabase/catalog/startbestand.csv
Normal file
|
|
@ -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
|
||||||
|
6
supabase/catalog/startbestand.sql
Normal file
6
supabase/catalog/startbestand.sql
Normal file
|
|
@ -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.
|
||||||
4
supabase/catalog/testdata/startbestand.csv
vendored
Normal file
4
supabase/catalog/testdata/startbestand.csv
vendored
Normal file
|
|
@ -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,,
|
||||||
|
5
supabase/catalog/testdata/vocabulary.csv
vendored
Normal file
5
supabase/catalog/testdata/vocabulary.csv
vendored
Normal file
|
|
@ -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
|
||||||
|
45
supabase/catalog/vocabulary.csv
Normal file
45
supabase/catalog/vocabulary.csv
Normal file
|
|
@ -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
|
||||||
|
54
supabase/catalog/vocabulary.sql
Normal file
54
supabase/catalog/vocabulary.sql
Normal file
|
|
@ -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;
|
||||||
|
|
@ -69,7 +69,10 @@ schema_paths = []
|
||||||
enabled = true
|
enabled = true
|
||||||
# Specifies an ordered list of seed files to load during db reset.
|
# Specifies an ordered list of seed files to load during db reset.
|
||||||
# Supports glob patterns relative to supabase directory: "./seeds/*.sql"
|
# 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]
|
[db.network_restrictions]
|
||||||
# Enable management of network restrictions.
|
# Enable management of network restrictions.
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,6 @@
|
||||||
-- Beispieldaten nur für die lokale Entwicklung (supabase db reset). Nicht für Produktion.
|
-- Beispieldaten nur für die lokale Entwicklung (supabase db reset). Nicht für Produktion.
|
||||||
insert into public.genres (name_de, name_en) values
|
-- Genres und Mechaniken kommen aus supabase/catalog/vocabulary.sql (KAT-05), nicht von hier:
|
||||||
('Strategie', 'Strategy'), ('Familie', 'Family'), ('Kooperativ', 'Cooperative'),
|
-- seed.sql läuft zuerst, ein Begriff mit zufälliger ID würde das Vokabular verdrängen.
|
||||||
('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;
|
|
||||||
|
|
||||||
insert into public.categories (name_de, name_en) values
|
insert into public.categories (name_de, name_en) values
|
||||||
('Wirtschaft', 'Economic'), ('Fantasy', 'Fantasy'), ('Abenteuer', 'Adventure'), ('Zivilisation', 'Civilization')
|
('Wirtschaft', 'Economic'), ('Fantasy', 'Fantasy'), ('Abenteuer', 'Adventure'), ('Zivilisation', 'Civilization')
|
||||||
on conflict do nothing;
|
on conflict do nothing;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue