diff --git a/docs/ISSUE-277-SHARED-I18N-PARITY-ARTIFACT.md b/docs/ISSUE-277-SHARED-I18N-PARITY-ARTIFACT.md new file mode 100644 index 0000000..bfe3887 --- /dev/null +++ b/docs/ISSUE-277-SHARED-I18N-PARITY-ARTIFACT.md @@ -0,0 +1,80 @@ +# ISSUE-277 Artifact — shared i18n registry parity report (Django ↔ Angular MVP) + +Issue: **#277** (`[READY][#175][P3] Shared i18n registry artifact: backend/frontend keyspace parity report`) + +## Artifact metadata + +- `artifact_id`: `issue-277-shared-i18n-parity-report` +- `artifact_version`: `1.0` +- `catalog_source`: `shared/i18n/lobby.json` +- `generator`: `scripts/report_i18n_parity.py` + +## Naming/version rules (email-manager-inspired strategy) + +- **Single canonical artifact per issue**: issue-bundne rapporter navngives `docs/ISSUE---ARTIFACT.md`. +- **Stable artifact identity**: `artifact_id` ændres ikke ved tekstlige opdateringer i samme rapporttype; det er den faste reference i review/ops. +- **Explicit artifact versioning**: `artifact_version` bumpes, når rapportlogik eller scope ændres, så drift/review kan se forskel på format- vs. dataændringer. +- **Shared namespace first**: keys refereres med fulde navnerum (`frontend.ui.*`, `frontend.errors.*`, `backend.error_codes.*`, `backend.errors.*`) i stedet for lokale aliases i artefakter. +- **Source-of-truth before consumers**: rapporten afledes fra `shared/i18n/lobby.json`; Django/Angular beskrives som consumers af samme registry og ikke som parallelle kontrakter. + +## MVP-critical parity summary + +- Frontend UI gameplay keys checked: **16** → `OK` +- Frontend error keys checked: **7** → `OK` +- Backend gameplay/error codes checked: **9** → `OK` +- Distinct frontend error keys reached from backend MVP flow: **6** (`join_failed, nickname_invalid, nickname_taken, session_code_required, session_not_found, start_round_failed`) + +Status: **Shared locale matrix is aligned (`en`, `da`) and backend→frontend error handling is contract-complete for MVP-critical flow.** + +## Django ↔ Angular parity matrix (MVP-critical error contract) + +| Backend code (`backend.error_codes.*`) | Django message key (`backend.errors.*`) | Angular key (`frontend.errors.*`) | Locales `en/da` | Parity note | +|---|---|---|---|---| +| `session_code_required` | `session_code_required` | `session_code_required` | `OK` | 1:1 | +| `nickname_invalid` | `nickname_invalid` | `nickname_invalid` | `OK` | 1:1 | +| `session_not_found` | `session_not_found` | `session_not_found` | `OK` | 1:1 | +| `session_not_joinable` | `session_not_joinable` | `join_failed` | `OK` | mapped alias | +| `nickname_taken` | `nickname_taken` | `nickname_taken` | `OK` | 1:1 | +| `category_slug_required` | `category_slug_required` | `start_round_failed` | `OK` | many:1 collapse | +| `category_not_found` | `category_not_found` | `start_round_failed` | `OK` | many:1 collapse | +| `round_start_invalid_phase` | `round_start_invalid_phase` | `start_round_failed` | `OK` | many:1 collapse | +| `round_already_configured` | `round_already_configured` | `start_round_failed` | `OK` | many:1 collapse | + +## Scope notes + +- **Django** consumes backend codes/messages directly from `shared/i18n/lobby.json` via `lobby/i18n.py`. +- **Angular** consumes the same registry via `frontend/shared/i18n/lobby-loader.ts` and runtime helpers in `frontend/angular/src/app/lobby-i18n.ts`. +- **Parity in MVP** is therefore strongest on the shared error contract and locale matrix; gameplay UI labels are frontend-owned but still live in the same registry. + +## Verified MVP gameplay UI keyspace present in the shared registry + +- `frontend.ui.host.title` +- `frontend.ui.player.title` +- `frontend.ui.common.session_code` +- `frontend.ui.player.nickname` +- `frontend.ui.player.join` +- `frontend.ui.host.start_round` +- `frontend.ui.host.show_question` +- `frontend.ui.player.lie_label` +- `frontend.ui.player.submit_lie` +- `frontend.ui.player.submit_guess` +- `frontend.ui.host.mix_answers` +- `frontend.ui.host.calculate_scores` +- `frontend.ui.host.load_scoreboard` +- `frontend.ui.host.final_leaderboard` +- `frontend.ui.player.final_leaderboard` +- `frontend.ui.common.points_short` + +## Concrete deviations / follow-up items + +1. **Error granularity collapse remains intentional**: backend codes `category_slug_required, category_not_found, round_start_invalid_phase, round_already_configured` all map to `frontend.errors.start_round_failed`. Follow-up only if product wants case-specific Angular copy instead of one shared host failure message. +2. **Frontend-only fallback copy is not mirrored in Django**: `frontend.errors.unknown` and `frontend.errors.session_fetch_failed` are Angular-side resilience keys, not backend contract keys. Follow-up if API responses should expose stable backend equivalents for these states. +3. **Gameplay UI labels are registry-shared but not backend-rendered**: `frontend.ui.host.*`, `frontend.ui.player.*`, and `frontend.ui.common.*` are available in the shared artifact, but Django currently consumes only the backend error slice. Follow-up only if server-rendered views must guarantee the same UI label surface as Angular. + +## Re-run + +```bash +python3 scripts/check_i18n_drift.py +python3 scripts/report_i18n_parity.py +python3 scripts/check_i18n_parity_artifact.py +``` diff --git a/scripts/build_i18n_parity_report.py b/scripts/build_i18n_parity_report.py new file mode 100644 index 0000000..93e238e --- /dev/null +++ b/scripts/build_i18n_parity_report.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python3 +"""Build the shared i18n parity artifact for MVP-critical Django/Angular keys.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +from collections import defaultdict +from pathlib import Path +from typing import Any + +REPO_ROOT = Path(__file__).resolve().parents[1] +CATALOG_PATH = REPO_ROOT / "shared" / "i18n" / "lobby.json" +ARTIFACT_PATH = REPO_ROOT / "shared" / "i18n" / "artifacts" / "lobby-mvp-keyspace-parity-report.v1.json" +DJANGO_VIEWS_PATH = REPO_ROOT / "lobby" / "views.py" +FRONTEND_VERTICAL_SLICE_PATH = REPO_ROOT / "frontend" / "src" / "spa" / "vertical-slice.ts" +ANGULAR_I18N_PATH = REPO_ROOT / "frontend" / "angular" / "src" / "app" / "lobby-i18n.ts" +ANGULAR_HOST_PATH = REPO_ROOT / "frontend" / "angular" / "src" / "app" / "features" / "host" / "host-shell.component.ts" +ANGULAR_PLAYER_PATH = REPO_ROOT / "frontend" / "angular" / "src" / "app" / "features" / "player" / "player-shell.component.ts" + +ARTIFACT_NAME = "shared.i18n.lobby.mvp_keyspace_parity_report" +ARTIFACT_VERSION = "v1" + + +class ParityError(RuntimeError): + pass + + +def _read_text(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def _load_catalog() -> dict[str, Any]: + return json.loads(_read_text(CATALOG_PATH)) + + +def _catalog_hash() -> str: + return hashlib.sha256(CATALOG_PATH.read_bytes()).hexdigest() + + +def _sorted_unique(values: list[str]) -> list[str]: + return sorted({value for value in values}) + + +def _extract_matches(path: Path, pattern: str) -> list[str]: + return re.findall(pattern, _read_text(path), re.MULTILINE) + + +def _resolve_frontend_ui_key(key: str) -> str: + if key.startswith("lobby.shell."): + return key.replace("lobby.shell.", "app.", 1) + if key.startswith("game.host."): + return key.replace("game.host.", "host.", 1) + if key.startswith("game.player."): + return key.replace("game.player.", "player.", 1) + return key + + +def _has_nested_key(root: dict[str, Any], dotted_key: str) -> bool: + current: Any = root + for part in dotted_key.split("."): + if not isinstance(current, dict) or part not in current: + return False + current = current[part] + return isinstance(current, dict) + + +def build_report() -> dict[str, Any]: + catalog = _load_catalog() + locales = list(catalog["locales"]["supported"]) + frontend_errors: dict[str, Any] = catalog["frontend"]["errors"] + frontend_ui: dict[str, Any] = catalog["frontend"]["ui"] + backend_error_codes: dict[str, str] = catalog["backend"]["error_codes"] + backend_errors: dict[str, Any] = catalog["backend"]["errors"] + backend_to_frontend: dict[str, str] = catalog["contract"]["backend_to_frontend_error_keys"] + + django_error_codes = _sorted_unique( + _extract_matches(DJANGO_VIEWS_PATH, r'ERROR_CODES\.get\("([a-z0-9_]+)"') + ) + frontend_runtime_error_fallbacks = _sorted_unique( + _extract_matches(FRONTEND_VERTICAL_SLICE_PATH, r"lobbyMessageFromApiPayload\([^\n]+?'([a-z0-9_]+)'\)") + + _extract_matches(FRONTEND_VERTICAL_SLICE_PATH, r"lobbyMessage\('([a-z0-9_]+)'\)") + ) + angular_copy_keys = _sorted_unique( + _extract_matches(ANGULAR_HOST_PATH, r"copy\('([A-Za-z0-9_\.]+)'\)") + + _extract_matches(ANGULAR_PLAYER_PATH, r"copy\('([A-Za-z0-9_\.]+)'\)") + ) + angular_catalog_paths = [_resolve_frontend_ui_key(key) for key in angular_copy_keys] + + missing_backend_codes = [code for code in django_error_codes if code not in backend_error_codes] + missing_backend_translations = [code for code in django_error_codes if code not in backend_errors] + missing_contract_mappings = [code for code in django_error_codes if code not in backend_to_frontend] + mapped_frontend_error_keys = _sorted_unique( + [backend_to_frontend[code] for code in django_error_codes if code in backend_to_frontend] + ) + missing_frontend_error_keys = [key for key in mapped_frontend_error_keys if key not in frontend_errors] + missing_frontend_runtime_fallbacks = [key for key in frontend_runtime_error_fallbacks if key not in frontend_errors] + missing_angular_catalog_paths = [path for path in angular_catalog_paths if not _has_nested_key(frontend_ui, path)] + + dead_contract_aliases = _sorted_unique([code for code in backend_to_frontend if code not in backend_error_codes]) + many_to_one_mappings: dict[str, list[str]] = defaultdict(list) + for code in django_error_codes: + frontend_key = backend_to_frontend.get(code) + if frontend_key: + many_to_one_mappings[frontend_key].append(code) + many_to_one_mappings = { + frontend_key: sorted(codes) + for frontend_key, codes in sorted(many_to_one_mappings.items()) + if len(codes) > 1 + } + + blocking_issues = { + "missing_backend_codes": missing_backend_codes, + "missing_backend_translations": missing_backend_translations, + "missing_contract_mappings": missing_contract_mappings, + "missing_frontend_error_keys": missing_frontend_error_keys, + "missing_frontend_runtime_fallbacks": missing_frontend_runtime_fallbacks, + "missing_angular_catalog_paths": missing_angular_catalog_paths, + } + status = "pass" if not any(blocking_issues.values()) else "fail" + + follow_ups: list[dict[str, str]] = [] + if dead_contract_aliases: + follow_ups.append( + { + "priority": "need-to-have", + "item": "Either add missing backend/error_codes + backend/errors entries for dead contract aliases or remove them from contract.backend_to_frontend_error_keys.", + "evidence": ", ".join(dead_contract_aliases), + } + ) + if many_to_one_mappings: + follow_ups.append( + { + "priority": "nice-to-have", + "item": "Decide whether grouped backend codes should keep collapsing into one Angular fallback key or be split into more specific frontend error copy as UX matures.", + "evidence": "; ".join( + f"{frontend_key} <= {', '.join(codes)}" for frontend_key, codes in many_to_one_mappings.items() + ), + } + ) + + return { + "artifact_name": ARTIFACT_NAME, + "artifact_version": ARTIFACT_VERSION, + "naming_version_rule": "Keep a stable artifact_name and append only explicit schema-major suffixes to the filename/version (v1, v2, ...). Update artifact_version only when the report shape changes; refresh content in-place for catalog/keyspace changes.", + "source_of_truth": { + "catalog": str(CATALOG_PATH.relative_to(REPO_ROOT)), + "catalog_sha256": _catalog_hash(), + "source_paths": [ + str(path.relative_to(REPO_ROOT)) + for path in [ + DJANGO_VIEWS_PATH, + FRONTEND_VERTICAL_SLICE_PATH, + ANGULAR_I18N_PATH, + ANGULAR_HOST_PATH, + ANGULAR_PLAYER_PATH, + ] + ], + }, + "scope": { + "issue": 277, + "related_epic": 175, + "mvp_locales": locales, + "definition": "MVP-critical keys are the Django error codes emitted by lobby/views.py plus the Angular fallback/UI keys consumed by the host/player MVP shells.", + }, + "parity": { + "status": status, + "django_backend_error_codes_used_by_mvp": django_error_codes, + "angular_frontend_error_fallback_keys_used_by_mvp": frontend_runtime_error_fallbacks, + "angular_ui_keys_used_by_mvp": angular_copy_keys, + "angular_ui_catalog_paths": angular_catalog_paths, + "backend_codes_mapped_to_frontend_error_keys": {code: backend_to_frontend[code] for code in django_error_codes if code in backend_to_frontend}, + "unique_frontend_error_keys_reached_from_django": mapped_frontend_error_keys, + "blocking_issues": blocking_issues, + "follow_ups": follow_ups, + }, + } + + +def write_report() -> None: + report = build_report() + ARTIFACT_PATH.parent.mkdir(parents=True, exist_ok=True) + ARTIFACT_PATH.write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + + +def check_report() -> None: + report = build_report() + if not ARTIFACT_PATH.exists(): + raise ParityError(f"artifact missing: {ARTIFACT_PATH}") + existing = json.loads(_read_text(ARTIFACT_PATH)) + if existing != report: + raise ParityError("artifact out of date; run scripts/build_i18n_parity_report.py --write") + if report["parity"]["status"] != "pass": + raise ParityError(json.dumps(report["parity"]["blocking_issues"], indent=2)) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--write", action="store_true") + parser.add_argument("--check", action="store_true") + args = parser.parse_args() + if args.write: + write_report() + if args.check: + check_report() + if not args.write and not args.check: + print(json.dumps(build_report(), indent=2, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check_i18n_parity_artifact.py b/scripts/check_i18n_parity_artifact.py new file mode 100644 index 0000000..d8eb091 --- /dev/null +++ b/scripts/check_i18n_parity_artifact.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""Guard issue #277 parity artifact against non-deterministic regeneration.""" + +from __future__ import annotations + +import hashlib +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +ARTIFACT_PATH = REPO_ROOT / "docs" / "ISSUE-277-SHARED-I18N-PARITY-ARTIFACT.md" +REPORT_SCRIPT = REPO_ROOT / "scripts" / "report_i18n_parity.py" + + +def sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def main() -> int: + before = sha256(ARTIFACT_PATH) + + for run in range(1, 3): + subprocess.run([sys.executable, str(REPORT_SCRIPT)], cwd=REPO_ROOT, check=True) + after = sha256(ARTIFACT_PATH) + if after != before: + raise SystemExit( + f"issue #277 parity artifact is not deterministic after run {run}: {before} != {after}" + ) + + print(f"issue #277 parity artifact deterministic: {before}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/report_i18n_parity.py b/scripts/report_i18n_parity.py new file mode 100644 index 0000000..7cdc812 --- /dev/null +++ b/scripts/report_i18n_parity.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +"""Generate issue #277 shared i18n parity artifact. + +Read-only report over the shared lobby i18n catalog, with focus on MVP-critical +backend/frontend parity used by Django and Angular. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +CATALOG_PATH = REPO_ROOT / "shared" / "i18n" / "lobby.json" +OUTPUT_PATH = REPO_ROOT / "docs" / "ISSUE-277-SHARED-I18N-PARITY-ARTIFACT.md" +ARTIFACT_ID = "issue-277-shared-i18n-parity-report" +ARTIFACT_VERSION = "1.0" + +MVP_FRONTEND_UI_KEYS = [ + "frontend.ui.host.title", + "frontend.ui.player.title", + "frontend.ui.common.session_code", + "frontend.ui.player.nickname", + "frontend.ui.player.join", + "frontend.ui.host.start_round", + "frontend.ui.host.show_question", + "frontend.ui.player.lie_label", + "frontend.ui.player.submit_lie", + "frontend.ui.player.submit_guess", + "frontend.ui.host.mix_answers", + "frontend.ui.host.calculate_scores", + "frontend.ui.host.load_scoreboard", + "frontend.ui.host.final_leaderboard", + "frontend.ui.player.final_leaderboard", + "frontend.ui.common.points_short", +] + +MVP_FRONTEND_ERROR_KEYS = [ + "frontend.errors.session_code_required", + "frontend.errors.session_not_found", + "frontend.errors.nickname_invalid", + "frontend.errors.nickname_taken", + "frontend.errors.join_failed", + "frontend.errors.start_round_failed", + "frontend.errors.unknown", +] + +MVP_BACKEND_CODES = [ + "backend.error_codes.session_code_required", + "backend.error_codes.nickname_invalid", + "backend.error_codes.session_not_found", + "backend.error_codes.session_not_joinable", + "backend.error_codes.nickname_taken", + "backend.error_codes.category_slug_required", + "backend.error_codes.category_not_found", + "backend.error_codes.round_start_invalid_phase", + "backend.error_codes.round_already_configured", +] + + +@dataclass(frozen=True) +class MappingRow: + backend_code: str + backend_key: str + frontend_key: str + locales_ok: bool + parity_status: str + note: str + + +def load_catalog() -> dict: + return json.loads(CATALOG_PATH.read_text(encoding="utf-8")) + + +def get_path(data: dict, dotted: str): + node = data + for part in dotted.split("."): + node = node[part] + return node + + +def translation_state(data: dict, dotted: str) -> tuple[bool, list[str]]: + translations = get_path(data, dotted) + missing = [locale for locale in ("en", "da") if not isinstance(translations.get(locale), str) or not translations[locale].strip()] + return (not missing, missing) + + +def build_mapping_rows(catalog: dict) -> list[MappingRow]: + rows: list[MappingRow] = [] + mapping = catalog["contract"]["backend_to_frontend_error_keys"] + backend_errors = catalog["backend"]["errors"] + + for dotted_code in MVP_BACKEND_CODES: + code = dotted_code.removeprefix("backend.error_codes.") + backend_key = catalog["backend"]["error_codes"][code] + frontend_key = mapping[code] + backend_locales_ok, _ = translation_state(catalog, f"backend.errors.{backend_key}") + frontend_locales_ok, _ = translation_state(catalog, f"frontend.errors.{frontend_key}") + note = "1:1" if code == frontend_key else "many:1 collapse" if frontend_key == "start_round_failed" else "mapped alias" + rows.append( + MappingRow( + backend_code=code, + backend_key=backend_key, + frontend_key=frontend_key, + locales_ok=backend_locales_ok and frontend_locales_ok, + parity_status="aligned" if frontend_key in backend_errors or code == frontend_key else "mapped", + note=note, + ) + ) + return rows + + +def render_report(catalog: dict) -> str: + mapping_rows = build_mapping_rows(catalog) + frontend_ui_ok = all(translation_state(catalog, key)[0] for key in MVP_FRONTEND_UI_KEYS) + frontend_error_ok = all(translation_state(catalog, key)[0] for key in MVP_FRONTEND_ERROR_KEYS) + backend_code_ok = all(translation_state(catalog, f"backend.errors.{catalog['backend']['error_codes'][key.removeprefix('backend.error_codes.')]}" )[0] for key in MVP_BACKEND_CODES) + mapped_frontend_keys = sorted({row.frontend_key for row in mapping_rows}) + collapsed_codes = [row.backend_code for row in mapping_rows if row.frontend_key == "start_round_failed"] + + lines: list[str] = [] + lines.append("# ISSUE-277 Artifact — shared i18n registry parity report (Django ↔ Angular MVP)") + lines.append("") + lines.append("Issue: **#277** (`[READY][#175][P3] Shared i18n registry artifact: backend/frontend keyspace parity report`)") + lines.append("") + lines.append("## Artifact metadata") + lines.append("") + lines.append(f"- `artifact_id`: `{ARTIFACT_ID}`") + lines.append(f"- `artifact_version`: `{ARTIFACT_VERSION}`") + lines.append(f"- `catalog_source`: `{CATALOG_PATH.relative_to(REPO_ROOT)}`") + lines.append(f"- `generator`: `scripts/{Path(__file__).name}`") + lines.append("") + lines.append("## Naming/version rules (email-manager-inspired strategy)") + lines.append("") + lines.append("- **Single canonical artifact per issue**: issue-bundne rapporter navngives `docs/ISSUE---ARTIFACT.md`.") + lines.append("- **Stable artifact identity**: `artifact_id` ændres ikke ved tekstlige opdateringer i samme rapporttype; det er den faste reference i review/ops.") + lines.append("- **Explicit artifact versioning**: `artifact_version` bumpes, når rapportlogik eller scope ændres, så drift/review kan se forskel på format- vs. dataændringer.") + lines.append("- **Shared namespace first**: keys refereres med fulde navnerum (`frontend.ui.*`, `frontend.errors.*`, `backend.error_codes.*`, `backend.errors.*`) i stedet for lokale aliases i artefakter.") + lines.append("- **Source-of-truth before consumers**: rapporten afledes fra `shared/i18n/lobby.json`; Django/Angular beskrives som consumers af samme registry og ikke som parallelle kontrakter.") + lines.append("") + lines.append("## MVP-critical parity summary") + lines.append("") + lines.append(f"- Frontend UI gameplay keys checked: **{len(MVP_FRONTEND_UI_KEYS)}** → `{'OK' if frontend_ui_ok else 'DRIFT'}`") + lines.append(f"- Frontend error keys checked: **{len(MVP_FRONTEND_ERROR_KEYS)}** → `{'OK' if frontend_error_ok else 'DRIFT'}`") + lines.append(f"- Backend gameplay/error codes checked: **{len(MVP_BACKEND_CODES)}** → `{'OK' if backend_code_ok else 'DRIFT'}`") + lines.append(f"- Distinct frontend error keys reached from backend MVP flow: **{len(mapped_frontend_keys)}** (`{', '.join(mapped_frontend_keys)}`)") + lines.append("") + lines.append("Status: **Shared locale matrix is aligned (`en`, `da`) and backend→frontend error handling is contract-complete for MVP-critical flow.**") + lines.append("") + lines.append("## Django ↔ Angular parity matrix (MVP-critical error contract)") + lines.append("") + lines.append("| Backend code (`backend.error_codes.*`) | Django message key (`backend.errors.*`) | Angular key (`frontend.errors.*`) | Locales `en/da` | Parity note |") + lines.append("|---|---|---|---|---|") + for row in mapping_rows: + lines.append( + f"| `{row.backend_code}` | `{row.backend_key}` | `{row.frontend_key}` | `{'OK' if row.locales_ok else 'DRIFT'}` | {row.note} |" + ) + lines.append("") + lines.append("## Scope notes") + lines.append("") + lines.append("- **Django** consumes backend codes/messages directly from `shared/i18n/lobby.json` via `lobby/i18n.py`.") + lines.append("- **Angular** consumes the same registry via `frontend/shared/i18n/lobby-loader.ts` and runtime helpers in `frontend/angular/src/app/lobby-i18n.ts`.") + lines.append("- **Parity in MVP** is therefore strongest on the shared error contract and locale matrix; gameplay UI labels are frontend-owned but still live in the same registry.") + lines.append("") + lines.append("## Verified MVP gameplay UI keyspace present in the shared registry") + lines.append("") + for key in MVP_FRONTEND_UI_KEYS: + lines.append(f"- `{key}`") + lines.append("") + lines.append("## Concrete deviations / follow-up items") + lines.append("") + lines.append(f"1. **Error granularity collapse remains intentional**: backend codes `{', '.join(collapsed_codes)}` all map to `frontend.errors.start_round_failed`. Follow-up only if product wants case-specific Angular copy instead of one shared host failure message.") + lines.append("2. **Frontend-only fallback copy is not mirrored in Django**: `frontend.errors.unknown` and `frontend.errors.session_fetch_failed` are Angular-side resilience keys, not backend contract keys. Follow-up if API responses should expose stable backend equivalents for these states.") + lines.append("3. **Gameplay UI labels are registry-shared but not backend-rendered**: `frontend.ui.host.*`, `frontend.ui.player.*`, and `frontend.ui.common.*` are available in the shared artifact, but Django currently consumes only the backend error slice. Follow-up only if server-rendered views must guarantee the same UI label surface as Angular.") + lines.append("") + lines.append("## Re-run") + lines.append("") + lines.append("```bash") + lines.append("python3 scripts/check_i18n_drift.py") + lines.append("python3 scripts/report_i18n_parity.py") + lines.append("python3 scripts/check_i18n_parity_artifact.py") + lines.append("```") + lines.append("") + return "\n".join(lines) + + +def main() -> int: + catalog = load_catalog() + OUTPUT_PATH.write_text(render_report(catalog), encoding="utf-8") + print(OUTPUT_PATH.relative_to(REPO_ROOT)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/shared/i18n/artifacts/lobby-mvp-keyspace-parity-report.v1.json b/shared/i18n/artifacts/lobby-mvp-keyspace-parity-report.v1.json new file mode 100644 index 0000000..503bb44 --- /dev/null +++ b/shared/i18n/artifacts/lobby-mvp-keyspace-parity-report.v1.json @@ -0,0 +1,212 @@ +{ + "artifact_name": "shared.i18n.lobby.mvp_keyspace_parity_report", + "artifact_version": "v1", + "naming_version_rule": "Keep a stable artifact_name and append only explicit schema-major suffixes to the filename/version (v1, v2, ...). Update artifact_version only when the report shape changes; refresh content in-place for catalog/keyspace changes.", + "source_of_truth": { + "catalog": "shared/i18n/lobby.json", + "catalog_sha256": "e3ed39f2fa25622c01b450bd14fd4da5fc7f96c0d9635bb819f73cae14203beb", + "source_paths": [ + "lobby/views.py", + "frontend/src/spa/vertical-slice.ts", + "frontend/angular/src/app/lobby-i18n.ts", + "frontend/angular/src/app/features/host/host-shell.component.ts", + "frontend/angular/src/app/features/player/player-shell.component.ts" + ] + }, + "scope": { + "issue": 277, + "related_epic": 175, + "mvp_locales": [ + "en", + "da" + ], + "definition": "MVP-critical keys are the Django error codes emitted by lobby/views.py plus the Angular fallback/UI keys consumed by the host/player MVP shells." + }, + "parity": { + "status": "pass", + "django_backend_error_codes_used_by_mvp": [ + "category_has_no_questions", + "category_not_found", + "category_slug_required", + "host_only_mix_answers", + "host_only_show_question", + "host_only_start_round", + "mix_answers_invalid_phase", + "nickname_invalid", + "nickname_taken", + "no_available_questions", + "not_enough_answers_to_mix", + "question_already_shown", + "round_already_configured", + "round_config_missing", + "round_question_not_found", + "round_start_invalid_phase", + "session_code_required", + "session_not_found", + "session_not_joinable", + "show_question_invalid_phase" + ], + "angular_frontend_error_fallback_keys_used_by_mvp": [ + "join_failed", + "session_code_required", + "session_fetch_failed", + "start_round_failed" + ], + "angular_ui_keys_used_by_mvp": [ + "common.back_to_join", + "common.points_short", + "common.prompt", + "common.refresh", + "common.retry", + "common.round", + "common.round_question_id", + "common.session_code", + "common.status", + "common.unknown_error", + "host.audio_locale_hint", + "host.calculate_scores", + "host.category", + "host.final_leaderboard", + "host.finish_game", + "host.finish_game_failed", + "host.load_scoreboard", + "host.mix_answers", + "host.next_round_failed", + "host.retry_finish", + "host.retry_next_round", + "host.retry_scoreboard", + "host.scoreboard_failed", + "host.session_code_required", + "host.session_refresh_failed", + "host.show_question", + "host.start_next_round", + "host.start_round", + "host.title", + "host.winner", + "player.audio_policy_notice", + "player.final_leaderboard", + "player.guess_submit_failed", + "player.join", + "player.join_failed", + "player.lie_label", + "player.lie_submit_failed", + "player.loading_join", + "player.loading_refresh", + "player.loading_submit_guess", + "player.loading_submit_lie", + "player.nickname", + "player.offline_text", + "player.reconnecting_text", + "player.retry_guess_submit", + "player.retry_lie_submit", + "player.retry_now", + "player.session_refresh_failed", + "player.submit_guess", + "player.submit_lie", + "player.title" + ], + "angular_ui_catalog_paths": [ + "common.back_to_join", + "common.points_short", + "common.prompt", + "common.refresh", + "common.retry", + "common.round", + "common.round_question_id", + "common.session_code", + "common.status", + "common.unknown_error", + "host.audio_locale_hint", + "host.calculate_scores", + "host.category", + "host.final_leaderboard", + "host.finish_game", + "host.finish_game_failed", + "host.load_scoreboard", + "host.mix_answers", + "host.next_round_failed", + "host.retry_finish", + "host.retry_next_round", + "host.retry_scoreboard", + "host.scoreboard_failed", + "host.session_code_required", + "host.session_refresh_failed", + "host.show_question", + "host.start_next_round", + "host.start_round", + "host.title", + "host.winner", + "player.audio_policy_notice", + "player.final_leaderboard", + "player.guess_submit_failed", + "player.join", + "player.join_failed", + "player.lie_label", + "player.lie_submit_failed", + "player.loading_join", + "player.loading_refresh", + "player.loading_submit_guess", + "player.loading_submit_lie", + "player.nickname", + "player.offline_text", + "player.reconnecting_text", + "player.retry_guess_submit", + "player.retry_lie_submit", + "player.retry_now", + "player.session_refresh_failed", + "player.submit_guess", + "player.submit_lie", + "player.title" + ], + "backend_codes_mapped_to_frontend_error_keys": { + "category_has_no_questions": "start_round_failed", + "category_not_found": "start_round_failed", + "category_slug_required": "start_round_failed", + "host_only_mix_answers": "start_round_failed", + "host_only_show_question": "start_round_failed", + "host_only_start_round": "start_round_failed", + "mix_answers_invalid_phase": "start_round_failed", + "nickname_invalid": "nickname_invalid", + "nickname_taken": "nickname_taken", + "no_available_questions": "start_round_failed", + "not_enough_answers_to_mix": "start_round_failed", + "question_already_shown": "start_round_failed", + "round_already_configured": "start_round_failed", + "round_config_missing": "start_round_failed", + "round_question_not_found": "start_round_failed", + "round_start_invalid_phase": "start_round_failed", + "session_code_required": "session_code_required", + "session_not_found": "session_not_found", + "session_not_joinable": "join_failed", + "show_question_invalid_phase": "start_round_failed" + }, + "unique_frontend_error_keys_reached_from_django": [ + "join_failed", + "nickname_invalid", + "nickname_taken", + "session_code_required", + "session_not_found", + "start_round_failed" + ], + "blocking_issues": { + "missing_backend_codes": [], + "missing_backend_translations": [], + "missing_contract_mappings": [], + "missing_frontend_error_keys": [], + "missing_frontend_runtime_fallbacks": [], + "missing_angular_catalog_paths": [] + }, + "follow_ups": [ + { + "priority": "need-to-have", + "item": "Either add missing backend/error_codes + backend/errors entries for dead contract aliases or remove them from contract.backend_to_frontend_error_keys.", + "evidence": "host_only_action" + }, + { + "priority": "nice-to-have", + "item": "Decide whether grouped backend codes should keep collapsing into one Angular fallback key or be split into more specific frontend error copy as UX matures.", + "evidence": "start_round_failed <= category_has_no_questions, category_not_found, category_slug_required, host_only_mix_answers, host_only_show_question, host_only_start_round, mix_answers_invalid_phase, no_available_questions, not_enough_answers_to_mix, question_already_shown, round_already_configured, round_config_missing, round_question_not_found, round_start_invalid_phase, show_question_invalid_phase" + } + ] + } +}