197 lines
9.6 KiB
Python
197 lines
9.6 KiB
Python
#!/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-<nr>-<slug>-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())
|