diff --git a/docs/adr/0362-versioned-ui-translation-ledger.md b/docs/adr/0362-versioned-ui-translation-ledger.md index 235c3ed7e..6441b1975 100644 --- a/docs/adr/0362-versioned-ui-translation-ledger.md +++ b/docs/adr/0362-versioned-ui-translation-ledger.md @@ -119,6 +119,39 @@ Child insert/update/delete obtains a `FOR UPDATE` lock on the parent resource. P The existing `user_account.preferred_locale` constraint expands to the same eight language tags. API request validation and frontend consumption must be cut over to the same contract before #922 can close; the database/read-model foundation alone is not buyer-visible completion. +The first consumer slice is the Customer Master destination. It requests the +latest published `customer-master` resource after authentication and after each +locale change, admits it only when every key used by that destination is +present, and does not request Customer Master data until that copy is ready. +Loading and retry states remain a small product shell outside the translated +screen resource so a missing resource can tell the reader what to do next. +Changing locale aborts the prior request logically; a late response for an old +locale cannot replace the active screen. Other destinations remain on their +existing bundle until they receive their own complete resource and executable +cutover evidence. + +### Authorization-lifecycle admission clarification (2026-09-07) + +Translation readiness and Customer Master data belong to the current +authorization lifecycle. A token change clears both the primary projection and +secondary related-post, hint, detail, and permission state. An imperative +request may publish data, an error, a loading completion, or a follow-up read +only while its originating lifecycle remains current. Returning to the same +token value after A → B → A does not revive the first A lifecycle. Unmounting +also retires that lifecycle. Existing per-request ordering still applies within +one lifecycle, and server-side authorization remains authoritative. + +The existing token-equality guard was insufficient: a deferred related response +replaced the new list after A → B → A, while a deferred hint result could +trigger a stale refresh or display a retired error. Four behavioral App tests +(success/failure for each operation) fail on `9b033e2737e9a42e04c326a80b53142a365a923d`. +The repair reuses the existing authorization effect's cleanup to advance a +component-local generation and checks that generation at all imperative +completion paths. No shared session service, token storage, or new API is added. +Effect-local cancellation still governs translation, permission, and detail +fetches. This is candidate correctness evidence, not real-account or release +acceptance. + ## DDD mapping - Subdomain: product composition / presentation read model. diff --git a/docs/product-technical-gap-baseline-history-2026-09-07-pr932.md b/docs/product-technical-gap-baseline-history-2026-09-07-pr932.md new file mode 100644 index 000000000..660308907 --- /dev/null +++ b/docs/product-technical-gap-baseline-history-2026-09-07-pr932.md @@ -0,0 +1,317 @@ +# Product & Technical Gap Baseline + +## 2026-09-07 full-suite recheck on the published candidate + +On clean `afe7f8f212c0219ee19069d7177a4a35d77c04c2`, the complete frontend +suite was rerun without a concurrent build and with one worker. All 59 files +and 550 tests remained included, with committed test deadlines unchanged. +The result was 57 files passing / 2 failing and 546 tests passing / 4 timing +out in 493.43 seconds. The failures covered governed Voice selection, Ask +cutoff conversion, Ask delivery localization, and ontology continuation-error +retention. These differ from the earlier three timeout cases; reducing test +parallelism did not establish local suite GREEN. No worker setting, deadline, +or assertion was changed in the repository to turn this result green. + +The local suite target remains all 550 passing. Host scheduling varied during +these experiments, but that observation alone does not prove the cause or +repair the failing gate. No further unchanged full-suite rerun is justified +without diagnosis or a changed execution condition. Hosted Draft-skip policy +is separately tracked by #933; this consumer change does not override it. + +## 2026-09-07 live entry-path verification + +A fresh browser inspected the existing Compose frontend without credentials or +source-record queries. The documented `http://localhost:15173/` address showed +one login button and no product-owned login, signup, or recovery form. Clicking +login reached the existing issuer at `http://localhost:18080/` and displayed a +password input. No credentials were entered, so authenticated success, claim +admission, account recovery, and page p95 remain unverified. This existing +runtime does not establish delivery of the open consumer or identity PRs. + +The frontend container reported image +`sha256:2728a7896e4ccf5770567b98c8c6c23903f58b9a0ed7157377bf80a15e43a06a` +and start time `2026-09-01T05:03:13.413298609Z`. Its issuer container reported +`sha256:ede94b77ed55af002280a39d5be4ba8fb0231b525a2e1f9740310b7777395ea0`. +The browser navigation is direct evidence that this issuer is still consumed; +it must not be stopped merely because another identity engine is running. +The three previously verified inactive duplicate-stack containers remain +stopped, with containers and data volumes retained. + +Using `127.0.0.1:15173` instead of the documented hostname reached a callback +validation error, whereas the documented localhost origin reached the issuer +form. That difference is not evidence that the documented login path is +broken, and no callback allowlist or issuer configuration was broadened. +The temporary browser tab was closed after the read-only check. + +## 2026-09-07 authorization lifecycle repair (PR #932) + +The current candidate extends the existing auth-bound Customer Master guard to +retire outstanding imperative requests when the authorization lifecycle ends. +Token equality alone admitted a first-A response after A → B → A: related +records could overwrite the current list, and hint completion could refresh it +or show an obsolete error. Four executable App regressions reproduce these +success/failure paths on `9b033e2737e9a42e04c326a80b53142a365a923d` +(4 failed); all four pass with lifecycle generation checks. The primary list +also retains its request-order guard. Existing effect cancellation continues +to govern translation, permissions, and post detail. The seven existing Python +auth contract functions also passed when invoked with their standard-library +assertions; they are supporting source checks, not behavioral proof. + +The full frontend run (`vitest run --maxWorkers=2`) included all 550 tests: +547 passed and three timed out across App and ontology exploration. No tests +were excluded or deadlines increased. All three timeout cases and the four new regressions passed together in a +focused single-worker recheck (7 passed). Production type checking and build +also passed after the regression tests reused the installed standard fetch +boundary. A later focused run also hit one 5-second timeout; the tests now +synchronously navigate to Customer Master before exercising the deferred +responses, avoiding unrelated initial Board work. All four then passed with +the original timeout, and lint passed. With the final test driver, a fresh +paired run again failed all four assertions on the pre-repair revision and +passed all four on the repair; the final production build passed. Host scheduling also varied, so no +product-performance improvement is inferred. The existing chunk-size warning remains. Full-suite GREEN is still +unproven. +This repair uses synthetic unit-test responses only and does not prove real +account transitions, protected-main delivery, or authenticated p95 acceptance. + +## 2026-09-07 bootstrap recovery follow-up (PR #932) + +The candidate based on `846ec4700666188a281940b99ac4edf776e904ca` +removes access/publication troubleshooting from the eight-locale recovery +instruction. Readers can retry and contact their administrator if the problem +continues; an unclassified load failure still does not assert a cause. +Seven additional locale stories reuse the existing gate and status notice. +The earlier snapshot below remains historical evidence. + +The existing English recovery assertion failed before the copy change +(1 failed / 10 passed); the repaired gate and shared notice passed all 17 +focused tests. Lint, production build, and Storybook build also passed; the +existing production chunk-size warning remains. An actual browser displayed each of the eight retry stories at +320 × 640: document width and scroll width were both 320 pixels in all eight, +and Tab focused the correctly localized retry button in all eight. English, +German, and French screenshots were visually inspected. This verifies the +isolated bootstrap shell only: no authenticated source records were loaded, +and it does not establish PostgreSQL translation delivery, native-speaker +review, screen-reader acceptance, all-page latency, protected merge, or release. + +> Snapshot refreshed 2026-09-07 KST. Protected `main` is +> `83eba56149eb802cd63642c507c324c9976ec78e`. PR #929 is the active +> ADR 0362 candidate for issue #922 and is open / Ready for exact-head +> validation at `2a8ed5d02`. Required current-head checks are not yet accepted as terminal GREEN +> and the delivery boundary still requires qualifying independent review. The +> live non-identifying queue snapshot contains 134 open PRs (13 ready / 121 +> draft) and 22 open issues; those counts describe coordination load, not product +> maturity or release readiness. No open PR currently shows independent +> `reviewDecision=APPROVED`. Ready main-targeting PRs +> `#974`/`#973`/`#972` still have pending hosted review or security jobs; +> do not poll those jobs and do not treat CodeQL compatibility `pending` +> dispatch handshakes as product scan failures. The authenticated +> `GET /api/translations/{screen_key}` API is implemented on the candidate +> branch. That is candidate implementation evidence, not protected-main, +> deployed, or release evidence. +> +> Historical child implementation and validation snapshot (2026-09-05): +> A stacked Customer Master consumer candidate now exists at exact head +> `c95736ab6627d646ba4455ae2749f84f9cf23d31` on top of PR #929's +> exact head `2a8ed5d02f4a3082b346d923d754c1ff37ebff52`. It admits all eight +> locale tags, fetches the authenticated `customer-master` resource before +> customer data, rejects incomplete screen projections, ignores late responses +> from a previous locale or authorization identity, and shows an actionable +> retry state instead of +> rendering bundled Customer Master copy. Review `5119233938` found that the +> retry shell incorrectly diagnosed every transport/auth/permission/not-found/ +> service exception as an unpublished translation. RED +> `fd3f0326f539f23dfae75fc3511722ead4455d36` and causal repair +> `cb093960d43e95cdfb1d9ed491e920e2106305db` keep that unclassified failure +> cause-neutral while retaining one concrete retry action. This stacked branch +> is not protected-main, hosted-product-GREEN, authenticated PostgreSQL, or +> deployed evidence. +> Current-head local evidence is 59 frontend test files / 546 tests, lint, +> production build, Storybook build, five focused Python contract tests, and +> freshly inspected 1440 x 900 plus 390 x 844 retry-state captures. These local +> results do not satisfy the protected delivery boundary. +> +> Two adjacent candidates remain outside protected `main`: PR #911 at +> `5d40eed35a0b6e0d182397f8d02b29c38e9bdd17` replaces the synchronous +> PostgreSQL driver and defaults omitted TLS policy to identity verification; +> PR #909 at `e82aed38c0997588529e21fe0e1bf4159f3c198c` keeps authorized Customer +> Master records visible when imported hierarchy edges are malformed and adds +> synthetic desktop/mobile Storybook evidence. #911 is Ready for exact-head +> validation after moving its colliding TLS ADR to Proposed ADR 0366. Its +> repository-local Tests, PROV-O, and Ontology Pages runs are successful, while +> central Security/CodeQL/SAST remain queued. #909 is Draft because #922's +> eight-locale published-resource cutover and the required current-head +> material-UI/runtime evidence are still absent. Neither has qualifying +> independent current-head approval, and neither is protected-main or deployed +> evidence. +> +> Historical baseline overlays through the preceding snapshot are preserved as +> dated evidence at +> `docs/product-technical-gap-baseline-history-2026-09-04.md`. Historical +> formatting repairs do not promote dated observations into current evidence. +> +> The buyer-visible gap in #922 remains open. Protected `main` still ships the +> production frontend translation source in `frontend/src/i18n.ts` with only +> `en/ko/zh/ja/vi`; `es/de/fr` are not first-class frontend locales. No material +> SPA screen has yet been released on a published eight-locale ledger resource. +> The stacked Customer Master candidate covers API admission plus loading and +> retry rendering. The 1440×900 and 390×844 Storybook captures were regenerated +> and inspected after the current auth-bound/cause-neutral repair. They prove +> only the synthetic retry shell, not authenticated browser acceptance. The +> candidate does not contain reviewed eight-locale +> product copy or authenticated PostgreSQL normal/empty/permission evidence. +> There is no release evidence for normal, loading, empty, error, permission, +> responsive, keyboard/focus/screen-reader, CJK text expansion, or font fallback +> states. +> +> Do not synthesize translations and do not count English fallback as translated +> coverage. Ontology labels and concept names remain outside this presentation +> ledger and with their canonical owners. + +## Current implementation boundary + +- ADR 0362 remains **Proposed**. PostgreSQL is authoritative for versioned UI + translation resources, required screen keys, and localized text; Valkey is + only an exact immutable-version read cache. +- Migrations `0246_ui_translation_ledger.sql` and + `0247_ui_translation_truncate_guard.sql` define the normalized ledger, + publication immutability, eight-locale completeness, writer serialization, + statement-level TRUNCATE protection, and replay/fail-closed rollback path. + Child-table TRUNCATE performs a nonblocking `SHARE ... NOWAIT` admission on + `ui_translation_resource` before reading publication state. If a publisher + already holds the root update lock, lock contention is translated to a + domain rejection instead of waiting into a child/root lock-order deadlock; + otherwise the SHARE lock keeps a new publisher from starting until the + draft-only TRUNCATE decision and statement finish. +- The 0246 rollback keeps its resource lookup dynamic after acquiring the + resource lock. A retry after a completed empty-foundation rollback therefore + converges without resolving an already-dropped table, while existing copy + and post-0246 member locale preferences still reject rollback before DDL. +- `backend/app/translation_ledger.py` admits exactly + `ko/en/ja/zh/vi/es/de/fr`, returns immutable `TranslationScreen` value + projections, validates canonical PostgreSQL text/BIGINT identities, admits + cache hits only after PostgreSQL key-set and SHA-256 value evidence, performs + no cross-locale fallback, and bounds each optional cache `get`/`set` at 20 ms. + Exact-version reads perform bounded Valkey candidate I/O without holding a + PostgreSQL lease. A missing/timed-out/unavailable candidate goes directly to + one complete PostgreSQL projection. A present candidate is still untrusted: + one digest/key-set PostgreSQL query admits a valid hit without transferring + the full localized projection; malformed, identity-mismatched, incomplete, + extra-key, or value-mismatched candidates are not returned and converge to + the authoritative full projection after digest admission. Latest-version + reads remain PostgreSQL-first. +- `GET /api/translations/{screen_key}` is authenticated and propagates exact + screen/locale/version identity. Missing published resources map to 404; + incomplete requested-locale copy maps to 409. Unsupported locale, malformed + screen identity, and an unrepresentable resource version each map to a + distinct 422 response that tells the caller which request value to correct. +- The Customer Master consumer does not reinterpret those backend failure + categories when the fetch promise is caught generically. Until a typed + frontend failure contract is introduced, its retry shell says only that the + selected-language screen could not be loaded, retries the request first, and + asks an administrator to check access and publication status only if the + failure persists. It does not assert that publication is missing. +- Focused HTTP and asyncpg-boundary tests cover the route without adding a + direct `psycopg2` caller. The documentation-alignment contract prevents this + baseline from regressing to the obsolete claim that the API does not exist. +- Current-head regression evidence includes exact-version query-budget + contracts for both normal paths: a true cache miss must perform Valkey I/O + before any PostgreSQL acquisition and use one full PostgreSQL projection; a + valid candidate must use one digest/key-set query that does not select the + full localized text projection. Corrupt present candidates retain explicit + fail-closed fallback coverage and are not misreported as ordinary misses. + Recursion exhaustion has two independent tests: synthetic fault injection + preserves exception-classification coverage, while + `test_translation_cache_recursion_real_payload.py` constructs a depth from + the running interpreter's recursion limit that exhausts the standard JSON + decoder on the supported runtime, proves + `json.loads(raw_payload)` actually raises `RecursionError`, and then requires + that same wire payload to converge to a cache miss. The evidence-contract test + prevents later edits from weakening that real-wire proof or promoting a local + or predecessor focused-pass count into current hosted evidence. Hosted required + checks are non-terminal, so no exact-head GREEN or focused-pass total is + claimed for this head. +- None of the above is release evidence until the unchanged exact PR head has + terminal required/security checks and qualifying independent approval, then + reaches protected `main` normally. +- The stacked Customer Master consumer has no new ADR number, migration, API + route, schema object, or release number. It extends ADR 0362 and consumes the + route owned by #929, avoiding collisions with ADRs 0364–0366 and the + serialized report-release stack. + +## Next buyer cut + +1. Use reviewed product copy to create and publish one complete screen resource + for all eight locales. Do not invent copy to satisfy coverage. +2. Finish the stacked Customer Master cutover by publishing reviewed product + copy for its declared keys in all eight locales and proving the authenticated + PostgreSQL/API normal path. The consumer and fail-closed loading/retry gate + exist only as branch evidence. +3. Prove normal/loading/empty/error/permission/responsive states plus + keyboard/focus/screen-reader behavior, CJK rendering, text expansion, and + font fallback on the same exact head with fresh desktop and mobile evidence. + Include the small loading/retry shell in locale and text-expansion review; + its English source copy is not evidence of eight-locale behavior. +4. Converge PRD/TRD/ARCHITECTURE/UX/OPERABILITY/TEST_STRATEGY/CHANGELOG and this + baseline with the actual cutover. Keep ontology labels separate from product + copy and consume only released owner contracts where another CWL product is + authoritative. +5. Keep #929 in the Ready validation lane while this exact head is evaluated. + Normal merge or release still requires terminal required/security gates and + the qualifying independent review; do not bypass or inherit predecessor + evidence. Stacked consumer PR #932 remains Draft on this parent and is not + protected-main cutover evidence. Leftover-pair accessible-name gap #976 + waits for leftover-map single-writer `#802` rather than racing that file. + +## Adjacent delivery and collision audit + +- The active decisions are non-overlapping: ADR 0362 belongs to the translation + ledger, ADR 0364 to authenticated browser requests, ADR 0365 to malformed + Customer Master hierarchy presentation, and ADR 0366 to synchronous + PostgreSQL TLS. PR #911 removed its colliding ADR 0363 before re-entering + review. It alone adds the `2.28.0` changelog fragment; #909 and #929 do not + claim that release number. +- The wider open queue still contains dependent report branches with serialized + release numbers and overlapping historical ADR-number ranges. Those branches + require ancestor-order convergence and a fresh exact-head ADR/API/schema/ + release audit before merge. A clean local merge calculation or predecessor + check cannot transfer acceptance to a changed head. +- PR #909 closes only the synthetic rendering gap: lint, focused regressions, + Storybook build, and 320 x 568 plus desktop visual audits passed on its exact + head. Authenticated PostgreSQL/API and deployed UI evidence are absent, so the + product acceptance condition remains explicitly unavailable and the PR stays + Draft behind #922. +- Voice-of-X remains governed by ADR 0246/0251: the twelve atomic Voice classes + stay extensible through evidence-backed combinations. Carrying Posts and + derivation evidence remain distinct; hidden evidence is never substituted; + truth status, cutoff, PROV-O derivation, exact-value UI/CSV, and paged JSON-LD + subject merging are unchanged by these three candidates. + +## Traceability + +- Product gap: issue #922, `i18n: move UI translations to versioned DB ledger + and complete 8-locale coverage`. +- Decision: `docs/adr/0362-versioned-ui-translation-ledger.md`. +- Persistence: `migrations/0246_ui_translation_ledger.sql`, + `migrations/0247_ui_translation_truncate_guard.sql`, and their rollback + artifacts. +- Read model: `backend/app/translation_ledger.py`. +- HTTP boundary: `backend/app/main.py` (`GET /api/translations/{screen_key}`). +- Verification: `tests/test_translation_ledger_*`, + `tests/test_translation_exact_version_query_budget.py`, + `tests/test_translation_cache_recursion_real_payload.py`, + `tests/test_translation_wire_evidence_contract.py`, + `tests/test_translation_screen_value_object.py`, + `tests/test_translation_api_http.py`, + `tests/test_translation_api_driver_boundary.py`, + `tests/test_translation_cache_timeout.py`, and + `tests/test_translation_documentation_alignment.py`. +- Stacked Customer Master consumer: `frontend/src/api.ts`, + `frontend/src/i18n.ts`, `frontend/src/App.tsx`, + `frontend/src/components/ScreenTranslationGate.tsx`, + `frontend/src/components/ScreenTranslationGate.test.tsx`, and + `tests/test_customer_master_translation_auth_gate_contract.py`. The checked-in + desktop/mobile images predate the later cause-neutral recovery-copy repair and + are historical predecessor evidence, not current-head visual acceptance. + Fresh screenshots must be captured from the unchanged candidate head before + this UI condition can be accepted. +- Historical delivery/gap overlays: `docs/product-technical-gap-baseline-history-2026-09-04.md`. diff --git a/docs/screenshots/customer-master-translation-gate-desktop.png b/docs/screenshots/customer-master-translation-gate-desktop.png new file mode 100644 index 000000000..74a647836 Binary files /dev/null and b/docs/screenshots/customer-master-translation-gate-desktop.png differ diff --git a/docs/screenshots/customer-master-translation-gate-mobile.png b/docs/screenshots/customer-master-translation-gate-mobile.png new file mode 100644 index 000000000..2b0a4fdb7 Binary files /dev/null and b/docs/screenshots/customer-master-translation-gate-mobile.png differ diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md index f426285a6..78ccfc769 100644 --- a/docs/storybook-inventory.md +++ b/docs/storybook-inventory.md @@ -20,6 +20,7 @@ operator-facing control you can click before changing product CSS. | `Admin/AdminPanel` | Change the tenant brand name, then verify the saved or failed state before leaving settings. | `--surface`, `--border`, `--space-panel-block`, `AdminPanel` | | `Lineage/LineageDag` | Open a reconstructed connection to read its inferred channel scores and Allen interval relation, or open the current branch node; compare empty, single-branch, grouped/forked, mobile-scroll, ungrouped, and long-title states before changing graph CSS. On narrow viewports, swipe the named viewport or focus it and use arrow keys to inspect the full lineage. | `--color-accent-background`, `--radius-control`, `--surface`, `--border`, `--color-focus-border`, `--size-control-min`, `LineageDag` | | `Chrome/StatusNotice` | Read success, unavailable, or retry copy, then take the named next action. Success and unavailable are a named region (not live `role=status`); Retry is `role=alert` and only on the retry kind. Calendar's missing Naruon projection uses unavailable. | `--badge-status-success-*`, `--badge-status-pending-*`, `--badge-status-danger-*`, `StatusNotice` | +| `Chrome/Screen translation gate` | Wait for the selected-language screen, retry a failed load, or contact an administrator if the problem persists. Eight locale-specific retry stories cover the bootstrap recovery copy. The gated destination never flashes bundled screen copy. | `StatusNotice`, `ScreenTranslationGate` | | `Chrome/PopupCloseButton` | Close the evidence panel or post popup. | `--space-close-inset`, `--font-size-close`, `PopupCloseButton` | | `Workspace/WorkspaceCalendar` | Read observed Naruon events, or open a commitment to land on that post. Fail-closed copy stays `이 범위의 일정을 아직 받을 수 없습니다`. | `--color-chip-border`, `WorkspaceCalendar`, `EvidenceStatusMark` | | `Ask Agent/Public claim verification` | Compare supported, refuted, and not-enough-information states; open only the external evidence link, then review the separate internal citation before changing governed graph state. | `--space-panel-block`, `--space-control-gap`, `--color-border`, `--size-control-min`, `PublicClaimVerification` | diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 790c4da69..709006a54 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1,9 +1,9 @@ -import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import App, { SurfaceBoundary } from "./App"; import { optionalKnowledgeCutoffIso } from "./api"; -import { setLocale } from "./i18n"; +import { CUSTOMER_MASTER_TRANSLATION_KEYS, clearCustomerMasterTranslations, setLocale } from "./i18n"; import { OIDC_RETURN_URL_STORAGE_KEY } from "./oidcReturnUrl"; const signinRedirect = vi.fn(); @@ -15,6 +15,7 @@ vi.mock("react-oidc-context", () => ({ })); beforeEach(() => { + clearCustomerMasterTranslations(); setLocale("en"); signinRedirect.mockReset(); signoutRedirect.mockReset(); @@ -39,6 +40,7 @@ it("normalizes valid knowledge cutoffs and rejects invalid input", () => { }); afterEach(() => { + clearCustomerMasterTranslations(); vi.unstubAllGlobals(); window.history.replaceState({}, "", "/"); window.sessionStorage.clear(); @@ -1905,6 +1907,19 @@ describe("App, authenticated", () => { }), ); } + if (url.includes("/api/translations/customer-master?") && method === "GET") { + const translations = Object.fromEntries( + CUSTOMER_MASTER_TRANSLATION_KEYS.map((key) => [key, key]), + ); + return Promise.resolve( + jsonResponse({ + screen_key: "customer-master", + resource_version: 1, + locale: new URL(url).searchParams.get("locale") ?? "en", + translations, + }), + ); + } if (url.endsWith("/api/customer-master") && method === "GET") { return Promise.resolve( jsonResponse({ @@ -2250,6 +2265,51 @@ describe("App, authenticated", () => { expect(parentRow?.contains(subsidiaryRow)).toBe(true); }); + it.each(["success", "failure"])("rejects a related %s from before an A-B-A authorization transition", async (outcome) => { + stubBackend(); + const backend = fetch; + let releaseRelated!: (response: Response) => void; + let rejectRelated!: (error: Error) => void; + const oldRelated = new Promise((resolve, reject) => { + releaseRelated = resolve; + rejectRelated = reject; + }); + let relatedRequests = 0; + vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + if (String(input).endsWith("/api/corporate-entities/corp-demo/related")) { + relatedRequests += 1; + if (relatedRequests === 1) return oldRelated; + } + return backend(input, init); + })); + const { rerender } = render(); + fireEvent.click(screen.getByRole("button", { name: "고객 마스터" })); + await userEvent.click((await screen.findByText("DEMO-CORP-01 · Company")).closest("button")!); + expect(relatedRequests).toBe(1); + + for (const accessToken of ["other-access-token", "test-access-token"]) { + mockAuth = { ...mockAuth, user: { + access_token: accessToken, profile: { preferred_username: "demo.analyst" }, + } }; + rerender(); + await screen.findByText("DEMO-CORP-01 · Company"); + } + await userEvent.click(screen.getByText("DEMO-CORP-01 · Company").closest("button")!); + await screen.findByRole("button", { name: "Open related post: Linked post" }); + expect(relatedRequests).toBe(2); + + await act(async () => { + if (outcome === "failure") rejectRelated(new Error("Superseded request failed")); + else releaseRelated(jsonResponse({ related: [{ + node_id: "superseded-post", node_type_code: "node_post", + label: "Superseded related post", relevance: 0.5, + }] })); + await oldRelated.catch(() => undefined); + }); + expect(screen.queryByRole("button", { name: "Open related post: Superseded related post" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Open related post: Linked post" })).toBeInTheDocument(); + }); + it("opens a customer's related post in place instead of jumping to the Board", async () => { // Live bug (2026-08-19): opening a related post from Customer // Master swapped the whole workspace to the Board and opened the @@ -2298,6 +2358,42 @@ describe("App, authenticated", () => { expect(within(soloRow as HTMLElement).queryByText("Multiple roles observed")).not.toBeInTheDocument(); }); + it.each(["success", "failure"])("ignores hint %s from before an A-B-A authorization transition", async (outcome) => { + stubBackend({ admin: true, manyCustomerHints: 1 }); + const backend = fetch; + let releaseHint!: (response: Response) => void; + let rejectHint!: (error: Error) => void; + const oldHint = new Promise((resolve, reject) => { + releaseHint = resolve; + rejectHint = reject; + }); + let masterRequests = 0; + vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + if (String(input).endsWith("/api/customer-master/resolve-hint")) return oldHint; + if (String(input).endsWith("/api/customer-master")) masterRequests += 1; + return backend(input, init); + })); + const { rerender } = render(); + fireEvent.click(screen.getByRole("button", { name: "고객 마스터" })); + await userEvent.click(await screen.findByRole("button", { name: "Resolve" })); + for (const accessToken of ["other-access-token", "test-access-token"]) { + mockAuth = { ...mockAuth, user: { + access_token: accessToken, profile: { preferred_username: "demo.analyst" }, + } }; + rerender(); + await screen.findByRole("button", { name: "Resolve" }); + } + const currentRequests = masterRequests; + await act(async () => { + if (outcome === "failure") rejectHint(new Error("Superseded hint failed")); + else releaseHint(jsonResponse({ corporate_entity_id: "corp-demo", linked_post_count: 1 })); + await oldHint.catch(() => undefined); + }); + expect(masterRequests).toBe(currentRequests); + expect(screen.queryByText("This hint could not be resolved to a corroborated organization name.")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Resolve" })).toBeEnabled(); + }); + it("lets a post_admin account resolve an unresolved customer hint into a real name", async () => { // Feature (2026-08-19): a Customer Master hint (an opaque customer // code with no name) previously had no action at all -- a dead end diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e3fb6c796..89214c61d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -87,6 +87,7 @@ import { type VocEvidence, type SimilarVocItem, fetchTenantConfig, + fetchTranslationScreen, } from "./api"; import { CitationChip } from "./components/CitationChip"; import { PublicClaimVerification } from "./components/PublicClaimVerification"; @@ -115,10 +116,14 @@ import { LOCALE_LABELS, SUPPORTED_LOCALES, setLocale, + setCustomerMasterTranslations, + clearCustomerMasterTranslations, t, tf, useLocale, } from "./i18n"; +import { ScreenTranslationGate } from "./components/ScreenTranslationGate"; +import { StatusNotice } from "./components/StatusNotice"; import "./App.css"; const AdminPanel = lazy(() => import("./components/AdminPanel").then((module) => ({ default: module.AdminPanel }))); @@ -4650,19 +4655,25 @@ function CustomerEntityTreeRow({ depth, expandedEntityId, relatedByEntity, + relatedErrorByEntity, relatedLoading, onToggle, + onRetry, onOpenPost, }: { node: CustomerEntityTreeNode; depth: number; expandedEntityId: string | null; relatedByEntity: Record; + relatedErrorByEntity: Record; relatedLoading: string | null; onToggle: (entityId: string) => void; + onRetry: (entityId: string) => void; onOpenPost: (postId: string) => void; }) { const { entity, children } = node; + const hasRelatedResult = Object.prototype.hasOwnProperty.call(relatedByEntity, entity.corporate_entity_id); + const relatedError = relatedErrorByEntity[entity.corporate_entity_id] === true; const relatedPosts = (relatedByEntity[entity.corporate_entity_id] ?? []).filter( (related) => related.node_type_code === NODE_POST, ); @@ -4679,11 +4690,20 @@ function CustomerEntityTreeRow({ {expandedEntityId === entity.corporate_entity_id ? (
- {relatedLoading === entity.corporate_entity_id ?

{t("Loading related posts...")}

: null} - {relatedLoading !== entity.corporate_entity_id && relatedPosts.length === 0 ? ( + {relatedError ? ( + onRetry(entity.corporate_entity_id)} + /> + ) : null} + {!relatedError && relatedLoading === entity.corporate_entity_id ? ( +

{t("Loading related posts...")}

+ ) : null} + {!relatedError && relatedLoading !== entity.corporate_entity_id && hasRelatedResult && relatedPosts.length === 0 ? (

{t("No linked posts yet.")}

) : null} - {relatedPosts.length > 0 ? ( + {!relatedError && relatedPosts.length > 0 ? (
    {relatedPosts.map((related) => (
  • @@ -4709,8 +4729,10 @@ function CustomerEntityTreeRow({ depth={depth + 1} expandedEntityId={expandedEntityId} relatedByEntity={relatedByEntity} + relatedErrorByEntity={relatedErrorByEntity} relatedLoading={relatedLoading} onToggle={onToggle} + onRetry={onRetry} onOpenPost={onOpenPost} /> ))} @@ -4757,10 +4779,21 @@ function CustomerMasterPanel({ }: { accessToken: string; }) { + const locale = useLocale(); + const [copyState, setCopyState] = useState<"loading" | "ready" | "retry">("loading"); + const [copyLocale, setCopyLocale] = useState(null); + const [copyAccessToken, setCopyAccessToken] = useState(null); + const [copyAttempt, setCopyAttempt] = useState(0); const [master, setMaster] = useState(null); + const masterRequestGeneration = useRef(0); + const relatedRequestGenerationByEntity = useRef>({}); + const authGeneration = useRef(0); + const currentAccessTokenRef = useRef(accessToken); + currentAccessTokenRef.current = accessToken; const [error, setError] = useState(null); const [expandedEntityId, setExpandedEntityId] = useState(null); const [relatedByEntity, setRelatedByEntity] = useState>({}); + const [relatedErrorByEntity, setRelatedErrorByEntity] = useState>({}); const [relatedLoading, setRelatedLoading] = useState(null); // Opening a customer's related post stays IN this panel (the Board // hand-off was the reported bug: clicking a customer's post jumped the @@ -4776,6 +4809,15 @@ function CustomerMasterPanel({ useEffect(() => { let active = true; + setCanResolveHints(false); + setRelatedByEntity({}); + setRelatedErrorByEntity({}); + setExpandedEntityId(null); + setRelatedLoading(null); + setSelectedPostId(null); + setSelectedPostGraph(null); + setResolvingHint(null); + setResolveError(null); fetchMe(accessToken) .then((member) => { if (active) setCanResolveHints(member.permission_codes.includes("post_admin")); @@ -4785,20 +4827,64 @@ function CustomerMasterPanel({ }); return () => { active = false; + authGeneration.current += 1; }; }, [accessToken]); const loadMaster = useCallback(() => { + const requestAccessToken = accessToken; + const requestAuthGeneration = authGeneration.current; + const requestGeneration = ++masterRequestGeneration.current; setError(null); - return fetchCustomerMaster(accessToken) - .then(setMaster) - .catch(() => setError(t("Customer master could not be loaded."))); + return fetchCustomerMaster(requestAccessToken) + .then((nextMaster) => { + if ( + requestGeneration === masterRequestGeneration.current && + requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current + ) { + setMaster(nextMaster); + } + }) + .catch(() => { + if ( + requestGeneration === masterRequestGeneration.current && + requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current + ) { + setError(t("Customer master could not be loaded.")); + } + }); }, [accessToken]); useEffect(() => { + let active = true; + masterRequestGeneration.current += 1; + setMaster(null); + setCopyState("loading"); + setCopyLocale(null); + setCopyAccessToken(null); + clearCustomerMasterTranslations(); + fetchTranslationScreen(accessToken, "customer-master", locale) + .then((screen) => { + if (!active || screen.screen_key !== "customer-master" || screen.locale !== locale) return; + setCustomerMasterTranslations(screen.translations); + setCopyLocale(locale); + setCopyAccessToken(accessToken); + setCopyState("ready"); + }) + .catch(() => { + if (active) setCopyState("retry"); + }); + return () => { + active = false; + clearCustomerMasterTranslations(); + }; + }, [accessToken, locale, copyAttempt]); + + useEffect(() => { + if (copyState !== "ready" || copyLocale !== locale || copyAccessToken !== accessToken) return; setMaster(null); void loadMaster(); - }, [loadMaster]); + }, [accessToken, copyAccessToken, copyLocale, copyState, loadMaster, locale]); useEffect(() => { if (!selectedPostId) { @@ -4824,36 +4910,70 @@ function CustomerMasterPanel({ } async function handleResolveHint(hintCode: string) { + const requestAccessToken = accessToken; + const requestAuthGeneration = authGeneration.current; setResolvingHint(hintCode); setResolveError(null); try { - await resolveCustomerHint(accessToken, hintCode); - await loadMaster(); + await resolveCustomerHint(requestAccessToken, hintCode); + if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) await loadMaster(); } catch { - setResolveError(t("This hint could not be resolved to a corroborated organization name.")); + if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) { + setResolveError(t("This hint could not be resolved to a corroborated organization name.")); + } } finally { - setResolvingHint(null); + if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) setResolvingHint(null); } } - async function toggleEntity(entityId: string) { - if (expandedEntityId === entityId) { - setExpandedEntityId(null); - return; - } - setExpandedEntityId(entityId); - if (relatedByEntity[entityId]) return; + async function loadRelatedEntity(entityId: string) { + const requestAccessToken = accessToken; + const requestAuthGeneration = authGeneration.current; + const requestGeneration = (relatedRequestGenerationByEntity.current[entityId] ?? 0) + 1; + relatedRequestGenerationByEntity.current[entityId] = requestGeneration; + const ownsRequest = () => + requestAccessToken === currentAccessTokenRef.current && + requestAuthGeneration === authGeneration.current && + relatedRequestGenerationByEntity.current[entityId] === requestGeneration; setRelatedLoading(entityId); + setRelatedErrorByEntity((previous) => ({ ...previous, [entityId]: false })); try { - const response = await fetchRelatedEntity(accessToken, entityId); - setRelatedByEntity((previous) => ({ ...previous, [entityId]: response.related })); + const response = await fetchRelatedEntity(requestAccessToken, entityId); + if (ownsRequest()) { + setRelatedByEntity((previous) => ({ ...previous, [entityId]: response.related })); + setRelatedErrorByEntity((previous) => ({ ...previous, [entityId]: false })); + } } catch { - setRelatedByEntity((previous) => ({ ...previous, [entityId]: [] })); + if (ownsRequest()) { + setRelatedErrorByEntity((previous) => ({ ...previous, [entityId]: true })); + } } finally { - setRelatedLoading(null); + if (ownsRequest()) { + setRelatedLoading((owner) => (owner === entityId ? null : owner)); + } } } + function toggleEntity(entityId: string) { + if (expandedEntityId === entityId) { + setExpandedEntityId(null); + return; + } + setExpandedEntityId(entityId); + if (Object.prototype.hasOwnProperty.call(relatedByEntity, entityId)) return; + void loadRelatedEntity(entityId); + } + + function retryRelatedEntity(entityId: string) { + void loadRelatedEntity(entityId); + } + + if (copyState === "retry") { + return setCopyAttempt((attempt) => attempt + 1)} />; + } + if (copyState === "loading" || copyLocale !== locale || copyAccessToken !== accessToken) { + return ; + } return (

    {t("Authorized customer scope")}

    @@ -4873,8 +4993,10 @@ function CustomerMasterPanel({ depth={0} expandedEntityId={expandedEntityId} relatedByEntity={relatedByEntity} + relatedErrorByEntity={relatedErrorByEntity} relatedLoading={relatedLoading} onToggle={toggleEntity} + onRetry={retryRelatedEntity} onOpenPost={openPost} /> ))} diff --git a/frontend/src/api.test.ts b/frontend/src/api.test.ts index 9495dc241..3e559c24d 100644 --- a/frontend/src/api.test.ts +++ b/frontend/src/api.test.ts @@ -5,6 +5,7 @@ import { fetchOccupationRatingSources, fetchOccupationRatings, fetchOperationsDashboard, + fetchTranslationScreen, fetchRatingSourceOccupations, updateTenantConfig, } from "./api"; @@ -14,6 +15,19 @@ afterEach(() => { }); describe("backendFetch provider-error boundary", () => { + it("requests one authenticated screen and locale without a bundled corpus", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ screen_key: "customer-master", resource_version: 3, locale: "de", translations: { title: "Kundenstamm" } }), { + headers: { "Content-Type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + await fetchTranslationScreen("access-token", "customer-master", "de"); + + expect(fetchMock.mock.calls[0][0]).toContain("/api/translations/customer-master?locale=de"); + expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe("Bearer access-token"); + }); it("binds the selected Dashboard period as inclusive API dates", async () => { const fetchMock = vi.fn().mockResolvedValue( new Response(JSON.stringify({ cases: [] }), { headers: { "Content-Type": "application/json" } }), diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 5db021a05..ed0a2b869 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -51,6 +51,26 @@ export interface PostPage { visibility_options?: PostFilterOption[]; } +export interface TranslationScreenResponse { + screen_key: string; + resource_version: number; + locale: string; + translations: Record; +} + +/** Fetch the latest complete published copy for one authenticated screen. */ +export function fetchTranslationScreen( + accessToken: string, + screenKey: string, + locale: string, +): Promise { + const query = new URLSearchParams({ locale }); + return backendFetch( + `/api/translations/${encodeURIComponent(screenKey)}?${query.toString()}`, + accessToken, + ); +} + export interface OperationsDashboardFact { fact_type_code: string; fact_type_label: string; diff --git a/frontend/src/components/ScreenTranslationGate.stories.tsx b/frontend/src/components/ScreenTranslationGate.stories.tsx new file mode 100644 index 000000000..d6c20eae4 --- /dev/null +++ b/frontend/src/components/ScreenTranslationGate.stories.tsx @@ -0,0 +1,31 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { setLocale } from "../i18n"; +import { ScreenTranslationGate } from "./ScreenTranslationGate"; + +const meta = { + title: "Chrome/Screen translation gate", + component: ScreenTranslationGate, + args: { onRetry: () => undefined }, + decorators: [(Story, context) => { + setLocale(context.parameters.locale ?? "en"); + return ; + }], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Loading: Story = { args: { state: "loading" } }; +export const Retry: Story = { args: { state: "retry" } }; +export const RetryMobile: Story = { + args: { state: "retry" }, + parameters: { viewport: { defaultViewport: "mobile1" } }, +}; + +export const RetryKorean: Story = { args: { state: "retry" }, parameters: { locale: "ko" } }; +export const RetryJapanese: Story = { args: { state: "retry" }, parameters: { locale: "ja" } }; +export const RetryChinese: Story = { args: { state: "retry" }, parameters: { locale: "zh" } }; +export const RetryVietnamese: Story = { args: { state: "retry" }, parameters: { locale: "vi" } }; +export const RetrySpanish: Story = { args: { state: "retry" }, parameters: { locale: "es" } }; +export const RetryGerman: Story = { args: { state: "retry" }, parameters: { locale: "de" } }; +export const RetryFrench: Story = { args: { state: "retry" }, parameters: { locale: "fr" } }; diff --git a/frontend/src/components/ScreenTranslationGate.test.tsx b/frontend/src/components/ScreenTranslationGate.test.tsx new file mode 100644 index 000000000..35c76d113 --- /dev/null +++ b/frontend/src/components/ScreenTranslationGate.test.tsx @@ -0,0 +1,47 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { setLocale } from "../i18n"; +import { ScreenTranslationGate } from "./ScreenTranslationGate"; + +afterEach(() => { + setLocale("en"); +}); + +describe("ScreenTranslationGate", () => { + it("announces loading without exposing untranslated screen content", () => { + render(); + expect(screen.getByRole("status")).toHaveTextContent("Loading this screen"); + }); + + it("keeps an unclassified projection failure cause-neutral", () => { + render( undefined} />); + expect(screen.getByText("We could not load this screen in your selected language.")).toBeInTheDocument(); + expect(screen.getByText("Try again. If the problem continues, contact your administrator.")).toBeInTheDocument(); + expect(screen.queryByText(/not available in your selected language yet/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/ask an administrator to publish this screen/i)).not.toBeInTheDocument(); + }); + + it("offers one retry action when the selected-language screen cannot be loaded", () => { + const onRetry = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + expect(onRetry).toHaveBeenCalledOnce(); + }); + + it.each([ + ["ko", "선택한 언어로 이 화면을 불러오는 중입니다...", "선택한 언어로 이 화면을 불러오지 못했습니다.", "다시 시도"], + ["en", "Loading this screen in your selected language...", "We could not load this screen in your selected language.", "Retry"], + ["ja", "選択した言語でこの画面を読み込んでいます...", "選択した言語でこの画面を読み込めませんでした。", "再試行"], + ["zh", "正在以所选语言加载此页面...", "无法以所选语言加载此页面。", "重试"], + ["vi", "Đang tải màn hình này bằng ngôn ngữ đã chọn...", "Không thể tải màn hình này bằng ngôn ngữ đã chọn.", "Thử lại"], + ] as const)("keeps the bootstrap loading and retry shell in every globally admitted locale: %s", (locale, loading, failure, retry) => { + setLocale(locale); + const { rerender } = render(); + expect(screen.getByRole("status")).toHaveTextContent(loading); + + rerender( undefined} />); + const alert = screen.getByRole("alert"); + expect(alert).toHaveTextContent(failure); + expect(screen.getByRole("button", { name: retry })).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/ScreenTranslationGate.tsx b/frontend/src/components/ScreenTranslationGate.tsx new file mode 100644 index 000000000..11743fc05 --- /dev/null +++ b/frontend/src/components/ScreenTranslationGate.tsx @@ -0,0 +1,81 @@ +import { type Locale, useLocale } from "../i18n"; +import { StatusNotice } from "./StatusNotice"; + +export type ScreenTranslationGateProps = { + state: "loading" | "retry"; + onRetry?: () => void; +}; + +type BootstrapCopy = { + loading: string; + retryLabel: string; + retryDescription: string; + failure: string; + nextAction: string; + retryAction: string; +}; + +// This copy is deliberately local to the bootstrap gate: the published screen +// resource cannot translate the state shown while that resource itself is being +// fetched. Keep it small, cause-neutral, and separate from Customer Master copy. +const BOOTSTRAP_COPY: Record = { + ko: { + loading: "선택한 언어로 이 화면을 불러오는 중입니다...", + retryLabel: "다시 시도 필요", + retryDescription: "요청이 실패했습니다. 같은 작업을 다시 시도하세요.", + failure: "선택한 언어로 이 화면을 불러오지 못했습니다.", + nextAction: "다시 시도하세요. 문제가 계속되면 관리자에게 문의하세요.", + retryAction: "다시 시도", + }, + en: { + loading: "Loading this screen in your selected language...", + retryLabel: "Retry needed", + retryDescription: "This request failed. Retry the same action.", + failure: "We could not load this screen in your selected language.", + nextAction: "Try again. If the problem continues, contact your administrator.", + retryAction: "Retry", + }, + ja: { + loading: "選択した言語でこの画面を読み込んでいます...", + retryLabel: "再試行が必要です", + retryDescription: "リクエストに失敗しました。同じ操作をもう一度お試しください。", + failure: "選択した言語でこの画面を読み込めませんでした。", + nextAction: "もう一度お試しください。問題が続く場合は、管理者にお問い合わせください。", + retryAction: "再試行", + }, + zh: { + loading: "正在以所选语言加载此页面...", + retryLabel: "需要重试", + retryDescription: "请求失败。请重试同一操作。", + failure: "无法以所选语言加载此页面。", + nextAction: "请重试。如果问题仍然存在,请联系管理员。", + retryAction: "重试", + }, + vi: { + loading: "Đang tải màn hình này bằng ngôn ngữ đã chọn...", + retryLabel: "Cần thử lại", + retryDescription: "Yêu cầu không thành công. Hãy thử lại cùng thao tác.", + failure: "Không thể tải màn hình này bằng ngôn ngữ đã chọn.", + nextAction: "Hãy thử lại. Nếu sự cố vẫn tiếp diễn, hãy liên hệ với quản trị viên.", + retryAction: "Thử lại", + }, +}; + +/** Keep an untranslated screen hidden while offering one concrete recovery action. */ +export function ScreenTranslationGate({ state, onRetry }: ScreenTranslationGateProps) { + const copy = BOOTSTRAP_COPY[useLocale()]; + if (state === "loading") { + return

    {copy.loading}

    ; + } + return ( + + ); +} diff --git a/frontend/src/components/StatusNotice.tsx b/frontend/src/components/StatusNotice.tsx index fa0d99b79..b8ace8d1e 100644 --- a/frontend/src/components/StatusNotice.tsx +++ b/frontend/src/components/StatusNotice.tsx @@ -26,6 +26,8 @@ export type StatusNoticeProps = { message: string; nextAction?: string; retryLabel?: string; + kindLabel?: string; + kindDescription?: string; onRetry?: () => void; }; @@ -34,7 +36,10 @@ export type StatusNoticeProps = { * * Color is never the only channel: each kind keeps a distinct glyph and * visible label. Callers pass already-localized message text and must not - * interpolate provider payloads (ADR 0123). + * interpolate provider payloads (ADR 0123). Bootstrap surfaces that must render + * before the normal translation catalog is available may also pass an + * already-localized kind label/description; ordinary callers keep the shared + * `t()` defaults. * * Success and unavailable are a named region, not `role="status"`, so they * do not collide with App live-region uniqueness. Retry is `role="alert"`. @@ -44,10 +49,12 @@ export function StatusNotice({ message, nextAction, retryLabel, + kindLabel, + kindDescription, onRetry, }: StatusNoticeProps) { - const label = t(KIND_LABEL_KEY[kind]); - const description = t(KIND_DESCRIPTION_KEY[kind]); + const label = kindLabel ?? t(KIND_LABEL_KEY[kind]); + const description = kindDescription ?? t(KIND_DESCRIPTION_KEY[kind]); const showRetry = kind === "retry" && typeof onRetry === "function"; const isRetry = kind === "retry"; return ( diff --git a/frontend/src/customerMasterAncestryTranslationContract.test.ts b/frontend/src/customerMasterAncestryTranslationContract.test.ts new file mode 100644 index 000000000..66b452960 --- /dev/null +++ b/frontend/src/customerMasterAncestryTranslationContract.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; + +import { CUSTOMER_MASTER_TRANSLATION_KEYS, setCustomerMasterTranslations } from "./i18n"; + +const ANCESTRY_SCREEN_KEYS = [ + "Shown as top level: listed parent forms a cycle.", + "Shown as top level: entity lists itself as parent.", + "Shown as top level: listed parent is not visible.", +] as const; + +describe("Customer Master ancestry translation contract", () => { + it("requires every malformed-hierarchy message in the authenticated screen projection", () => { + for (const key of ANCESTRY_SCREEN_KEYS) { + expect(CUSTOMER_MASTER_TRANSLATION_KEYS).toContain(key); + } + }); + + it("rejects a nominally complete projection when one ancestry message is missing", () => { + const translations = Object.fromEntries( + [...CUSTOMER_MASTER_TRANSLATION_KEYS, ...ANCESTRY_SCREEN_KEYS].map((key) => [key, `published:${key}`]), + ); + delete translations[ANCESTRY_SCREEN_KEYS[0]]; + + expect(() => setCustomerMasterTranslations(translations)).toThrow( + "Incomplete Customer Master translation", + ); + }); +}); diff --git a/frontend/src/customerMasterRelatedFailureState.test.tsx b/frontend/src/customerMasterRelatedFailureState.test.tsx new file mode 100644 index 000000000..ff07339a2 --- /dev/null +++ b/frontend/src/customerMasterRelatedFailureState.test.tsx @@ -0,0 +1,116 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, expect, it, vi } from "vitest"; +import App from "./App"; +import { fetchRelatedEntity } from "./api"; +import { CUSTOMER_MASTER_TRANSLATION_KEYS } from "./i18n"; + +const RELATED_FAILURE_COPY = "This request failed. Retry the same action."; + +let mockAuth: Record; + +vi.mock("react-oidc-context", () => ({ + useAuth: () => mockAuth, +})); + +vi.mock("./api", async (importOriginal) => { + const actual = await importOriginal(); + const { CUSTOMER_MASTER_TRANSLATION_KEYS: customerMasterKeys } = await import("./i18n"); + const translations = Object.fromEntries(customerMasterKeys.map((key) => [key, key])); + translations["This request failed. Retry the same action."] = "This request failed. Retry the same action."; + translations.Retry = "Retry"; + return { + ...actual, + fetchTenantConfig: vi.fn(async () => ({ brandName: "LineageWeave" })), + fetchMe: vi.fn(async () => ({ + user_account_id: "acct-1", + display_name: "Demo Analyst", + permission_codes: ["post_read"], + corporate_entities: [{ corporate_entity_id: "corp-a", entity_name: "Alpha Corp" }], + preferred_locale: "en", + })), + fetchPosts: vi.fn(async () => ({ + posts: [], + total_count: 0, + limit: 50, + offset: 0, + voc_type_options: [], + voice_type_catalog: [], + visibility_options: [], + })), + fetchRankings: vi.fn(async () => ({ + port: "rankweave", + status: "unavailable", + status_reason: "rankweave_not_available", + rankings: [], + })), + fetchTranslationScreen: vi.fn(async (_token: string, screenKey: string, locale: string) => ({ + screen_key: screenKey, + resource_version: 1, + locale, + translations, + })), + fetchCustomerMaster: vi.fn(async () => ({ + corporate_entities: [ + { + corporate_entity_id: "corp-a", + corporate_entity_code: "ALPHA", + entity_name: "Alpha Corp", + entity_level_code: "company", + entity_level_label: "Company", + parent_entity_id: null, + }, + ], + keymen: [], + source_customer_hints: [], + source_author_hints: [], + relationship_network: [], + })), + fetchRelatedEntity: vi.fn(), + }; +}); + +beforeEach(() => { + mockAuth = { + isLoading: false, + isAuthenticated: true, + error: undefined, + user: { + access_token: "test-access-token", + profile: { preferred_username: "demo.analyst" }, + }, + signinRedirect: vi.fn(), + signoutRedirect: vi.fn(), + }; + vi.mocked(fetchRelatedEntity).mockReset(); +}); + +it("requires retryable related-request failure copy in the governed Customer Master screen", () => { + expect(CUSTOMER_MASTER_TRANSLATION_KEYS).toContain(RELATED_FAILURE_COPY); +}); + +it("shows a retryable error instead of successful empty evidence when a related lookup fails", async () => { + const user = userEvent.setup(); + vi.mocked(fetchRelatedEntity) + .mockRejectedValueOnce(new Error("related lookup unavailable")) + .mockResolvedValueOnce({ + corporate_entity_id: "corp-a", + entity_name: "Alpha Corp", + related: [], + }); + + render(); + await user.click( + await screen.findByRole("button", { name: /^(?:Customer master|고객 마스터)$/ }), + ); + await user.click(await screen.findByRole("button", { name: /Alpha Corp/ })); + + await waitFor(() => expect(fetchRelatedEntity).toHaveBeenCalledTimes(1)); + expect(await screen.findByText(RELATED_FAILURE_COPY)).toBeInTheDocument(); + expect(screen.queryByText("No linked posts yet.")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /^Retry$/ })); + await waitFor(() => expect(fetchRelatedEntity).toHaveBeenCalledTimes(2)); + expect(await screen.findByText("No linked posts yet.")).toBeInTheDocument(); + expect(screen.queryByText(RELATED_FAILURE_COPY)).not.toBeInTheDocument(); +}); \ No newline at end of file diff --git a/frontend/src/customerMasterRelatedLoadingOwnership.test.tsx b/frontend/src/customerMasterRelatedLoadingOwnership.test.tsx new file mode 100644 index 000000000..509c8a524 --- /dev/null +++ b/frontend/src/customerMasterRelatedLoadingOwnership.test.tsx @@ -0,0 +1,223 @@ +import { act, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, expect, it, vi } from "vitest"; +import App from "./App"; +import { fetchRelatedEntity } from "./api"; + +let mockAuth: Record; + +vi.mock("react-oidc-context", () => ({ + useAuth: () => mockAuth, +})); + +vi.mock("./api", async (importOriginal) => { + const actual = await importOriginal(); + const { CUSTOMER_MASTER_TRANSLATION_KEYS } = await import("./i18n"); + return { + ...actual, + fetchTenantConfig: vi.fn(async () => ({ brandName: "LineageWeave" })), + fetchMe: vi.fn(async () => ({ + user_account_id: "acct-1", + display_name: "Demo Analyst", + permission_codes: ["post_read"], + corporate_entities: [ + { corporate_entity_id: "corp-a", entity_name: "Alpha Corp" }, + { corporate_entity_id: "corp-b", entity_name: "Beta Corp" }, + ], + preferred_locale: "en", + })), + fetchPosts: vi.fn(async () => ({ + posts: [], + total_count: 0, + limit: 50, + offset: 0, + voc_type_options: [], + voice_type_catalog: [], + visibility_options: [], + })), + fetchRankings: vi.fn(async () => ({ + port: "rankweave", + status: "unavailable", + status_reason: "rankweave_not_available", + rankings: [], + })), + fetchTranslationScreen: vi.fn(async (_token: string, screenKey: string, locale: string) => ({ + screen_key: screenKey, + resource_version: 1, + locale, + translations: Object.fromEntries(CUSTOMER_MASTER_TRANSLATION_KEYS.map((key) => [key, key])), + })), + fetchCustomerMaster: vi.fn(async () => ({ + corporate_entities: [ + { + corporate_entity_id: "corp-a", + corporate_entity_code: "ALPHA", + entity_name: "Alpha Corp", + entity_level_code: "company", + entity_level_label: "Company", + parent_entity_id: null, + }, + { + corporate_entity_id: "corp-b", + corporate_entity_code: "BETA", + entity_name: "Beta Corp", + entity_level_code: "company", + entity_level_label: "Company", + parent_entity_id: null, + }, + ], + keymen: [], + source_customer_hints: [], + source_author_hints: [], + relationship_network: [], + })), + fetchRelatedEntity: vi.fn(), + }; +}); + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolver, rejecter) => { + resolve = resolver; + reject = rejecter; + }); + return { promise, resolve, reject }; +} + +beforeEach(() => { + mockAuth = { + isLoading: false, + isAuthenticated: true, + error: undefined, + user: { + access_token: "test-access-token", + profile: { preferred_username: "demo.analyst" }, + }, + signinRedirect: vi.fn(), + signoutRedirect: vi.fn(), + }; + vi.mocked(fetchRelatedEntity).mockReset(); +}); + +it("keeps entity B loading when an older same-auth entity A lookup settles first", async () => { + const user = userEvent.setup(); + const alpha = deferred>>(); + const beta = deferred>>(); + vi.mocked(fetchRelatedEntity).mockImplementation((_token, entityId) => { + if (entityId === "corp-a") return alpha.promise; + if (entityId === "corp-b") return beta.promise; + throw new Error(`unexpected entity ${entityId}`); + }); + + render(); + await user.click( + await screen.findByRole("button", { name: /^(?:Customer master|고객 마스터)$/ }), + ); + await user.click(await screen.findByRole("button", { name: /Alpha Corp/ })); + await waitFor(() => expect(fetchRelatedEntity).toHaveBeenCalledWith("test-access-token", "corp-a")); + + await user.click(await screen.findByRole("button", { name: /Beta Corp/ })); + await waitFor(() => expect(fetchRelatedEntity).toHaveBeenCalledWith("test-access-token", "corp-b")); + expect(screen.getByRole("status")).toHaveTextContent("Loading related posts..."); + + await act(async () => { + alpha.resolve({ corporate_entity_id: "corp-a", entity_name: "Alpha Corp", related: [] }); + await alpha.promise; + }); + + expect(screen.getByRole("status")).toHaveTextContent("Loading related posts..."); + + await act(async () => { + beta.resolve({ corporate_entity_id: "corp-b", entity_name: "Beta Corp", related: [] }); + await beta.promise; + }); + + await waitFor(() => expect(screen.queryByRole("status")).not.toBeInTheDocument()); +}); + +it("keeps the newest same-entity lookup authoritative when an older success settles first", async () => { + const user = userEvent.setup(); + const first = deferred>>(); + const second = deferred>>(); + vi.mocked(fetchRelatedEntity) + .mockImplementationOnce(() => first.promise) + .mockImplementationOnce(() => second.promise); + + render(); + await user.click( + await screen.findByRole("button", { name: /^(?:Customer master|고객 마스터)$/ }), + ); + const alpha = await screen.findByRole("button", { name: /Alpha Corp/ }); + await user.click(alpha); + await waitFor(() => expect(fetchRelatedEntity).toHaveBeenCalledTimes(1)); + await user.click(alpha); + await user.click(alpha); + await waitFor(() => expect(fetchRelatedEntity).toHaveBeenCalledTimes(2)); + + await act(async () => { + first.resolve({ + corporate_entity_id: "corp-a", + entity_name: "Alpha Corp", + related: [ + { node_id: "old-post", node_type_code: "node_post", label: "Old result", relevance: 1 }, + ], + }); + await first.promise; + }); + + expect(screen.getByRole("status")).toHaveTextContent("Loading related posts..."); + expect(screen.queryByText("Old result")).not.toBeInTheDocument(); + + await act(async () => { + second.resolve({ + corporate_entity_id: "corp-a", + entity_name: "Alpha Corp", + related: [ + { node_id: "new-post", node_type_code: "node_post", label: "Newest result", relevance: 1 }, + ], + }); + await second.promise; + }); + + await screen.findByText("Newest result"); + expect(screen.queryByText("Old result")).not.toBeInTheDocument(); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); +}); + +it("does not let an older same-entity failure replace a newer in-flight lookup", async () => { + const user = userEvent.setup(); + const first = deferred>>(); + const second = deferred>>(); + vi.mocked(fetchRelatedEntity) + .mockImplementationOnce(() => first.promise) + .mockImplementationOnce(() => second.promise); + + render(); + await user.click( + await screen.findByRole("button", { name: /^(?:Customer master|고객 마스터)$/ }), + ); + const alpha = await screen.findByRole("button", { name: /Alpha Corp/ }); + await user.click(alpha); + await waitFor(() => expect(fetchRelatedEntity).toHaveBeenCalledTimes(1)); + await user.click(alpha); + await user.click(alpha); + await waitFor(() => expect(fetchRelatedEntity).toHaveBeenCalledTimes(2)); + + await act(async () => { + first.reject(new Error("stale failure")); + await first.promise.catch(() => undefined); + }); + + expect(screen.getByRole("status")).toHaveTextContent("Loading related posts..."); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + + await act(async () => { + second.resolve({ corporate_entity_id: "corp-a", entity_name: "Alpha Corp", related: [] }); + await second.promise; + }); + + await screen.findByText("No linked posts yet."); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); +}); diff --git a/frontend/src/evidenceStatusI18n.ts b/frontend/src/evidenceStatusI18n.ts index 7c9e86bf1..8ea6522da 100644 --- a/frontend/src/evidenceStatusI18n.ts +++ b/frontend/src/evidenceStatusI18n.ts @@ -51,11 +51,14 @@ const EVIDENCE_STATUS_COPY = { "A forecast. Treat as unconfirmed until later evidence arrives.": "Đây là một dự đoán. Hãy coi là chưa xác nhận cho đến khi có bằng chứng sau này.", }, -} as const satisfies Record>; +} as const; export type EvidenceStatusCopyKey = keyof (typeof EVIDENCE_STATUS_COPY)["en"]; /** Return reader-facing evidence/inference/prediction copy in the active product locale. */ export function evidenceStatusText(key: EvidenceStatusCopyKey): string { - return EVIDENCE_STATUS_COPY[getLocale()][key]; + const copy = EVIDENCE_STATUS_COPY as Partial< + Record> + >; + return copy[getLocale()]?.[key] ?? EVIDENCE_STATUS_COPY.en[key]; } diff --git a/frontend/src/globalLocaleAdmission.test.ts b/frontend/src/globalLocaleAdmission.test.ts new file mode 100644 index 000000000..7628b32d9 --- /dev/null +++ b/frontend/src/globalLocaleAdmission.test.ts @@ -0,0 +1,25 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { getLocale, setLocale, SUPPORTED_LOCALES } from "./i18n"; +import { ontologyExplorerText } from "./ontologyExplorerI18n"; + +afterEach(() => { + setLocale("en"); +}); + +describe("global locale admission", () => { + it("does not admit a globally selectable locale that silently falls back to English", () => { + const englishCopy = "Load next relation page"; + + for (const locale of SUPPORTED_LOCALES) { + setLocale(locale); + expect(getLocale()).toBe(locale); + + if (locale !== "en") { + expect( + ontologyExplorerText("Load next relation page"), + `${locale} is globally selectable but Ontology Explorer falls back to English`, + ).not.toBe(englishCopy); + } + } + }); +}); diff --git a/frontend/src/i18n.test.ts b/frontend/src/i18n.test.ts index b4b8d2fb6..06adc2106 100644 --- a/frontend/src/i18n.test.ts +++ b/frontend/src/i18n.test.ts @@ -6,13 +6,17 @@ import { import { LOCALE_LABELS, SUPPORTED_LOCALES, + CUSTOMER_MASTER_TRANSLATION_KEYS, + clearCustomerMasterTranslations, getLocale, + setCustomerMasterTranslations, setLocale, t, tf, } from "./i18n"; afterEach(() => { + clearCustomerMasterTranslations(); setLocale("en"); }); @@ -101,11 +105,22 @@ describe("i18n", () => { "Compare these cutoff-grounded citations with live evidence next.", ] as const; - it("supports the five product locales", () => { - expect(SUPPORTED_LOCALES).toEqual(["en", "ko", "zh", "ja", "vi"]); + it("admits only product-wide complete locales globally", () => { + expect(SUPPORTED_LOCALES).toEqual(["ko", "en", "ja", "zh", "vi"]); expect(Object.keys(LOCALE_LABELS)).toHaveLength(5); }); + it("admits only a complete Customer Master screen projection", () => { + const complete = Object.fromEntries( + CUSTOMER_MASTER_TRANSLATION_KEYS.map((key) => [key, `published:${key}`]), + ); + setCustomerMasterTranslations(complete); + expect(t("Customer master")).toBe("published:Customer master"); + expect(() => setCustomerMasterTranslations({ "Customer master": "Kundenstamm" })).toThrow( + "Incomplete Customer Master translation", + ); + }); + it.each([ ["en", "Workspace navigation"], ["ko", "워크스페이스 메뉴"], diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index bfe170ef8..edd3b3b30 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -1,6 +1,6 @@ import { useSyncExternalStore } from "react"; -export const SUPPORTED_LOCALES = ["en", "ko", "zh", "ja", "vi"] as const; +export const SUPPORTED_LOCALES = ["ko", "en", "ja", "zh", "vi"] as const; export type Locale = (typeof SUPPORTED_LOCALES)[number]; export const LOCALE_LABELS: Record = { @@ -11,6 +11,43 @@ export const LOCALE_LABELS: Record = { vi: "Tiếng Việt", }; +export const CUSTOMER_MASTER_TRANSLATION_KEYS = [ + "A counterparty can hold more than one role over time -- a customer in one post can be a competitor, supplier, or partner in another. Every role observed for a name is listed, not just the most frequent.", + "Affiliates of {name}", "Author context", "Authorization context", "Authorized customer scope", + "Customer entities available to this account.", "Customer master could not be loaded.", "Customer master", + "Hint only", "Keymen", "Loading customer master...", "Loading related posts...", "Multiple roles observed", + "No customer entities are connected to this account.", "No linked posts yet.", "No post body.", + "Observed customer evidence", "Open record", "Open related post: {label}", "Our-side Keymen hints", + "Post body preview", "Related posts", "Relationship network", "Resolve", "Resolving...", "Retry", "Retry needed", + "Shown as top level: listed parent forms a cycle.", + "Shown as top level: entity lists itself as parent.", + "Shown as top level: listed parent is not visible.", + "This request failed. Retry the same action.", + "Showing the first {shown} of {total} observed customer identifiers, ranked by post count.", + "Showing the first {shown} of {total} observed source authors, ranked by post count.", + "Source identifiers are hints only; ontology and semantic evidence must resolve them before binding a customer.", + "This hint could not be resolved to a corroborated organization name.", "Unresolved source identifier", "posts", +] as const; + +let activeScreenTranslations: Readonly> | null = null; + +/** Admit a complete Customer Master projection and notify subscribed readers. */ +export function setCustomerMasterTranslations(translations: Record): void { + for (const key of CUSTOMER_MASTER_TRANSLATION_KEYS) { + if (typeof translations[key] !== "string" || translations[key].trim() === "") { + throw new Error(`Incomplete Customer Master translation: ${key}`); + } + } + activeScreenTranslations = Object.freeze({ ...translations }); + listeners.forEach((listener) => listener()); +} + +/** Remove the active Customer Master projection when its destination unmounts. */ +export function clearCustomerMasterTranslations(): void { + activeScreenTranslations = null; + listeners.forEach((listener) => listener()); +} + export function isSupportedLocale(value: unknown): value is Locale { return typeof value === "string" && (SUPPORTED_LOCALES as readonly string[]).includes(value); } @@ -143,7 +180,7 @@ const TRANSLATIONS: Partial>> = { "Source author code": "원천 작성자 코드", "Source author name": "원천 작성자 이름", "Source company code": "원천 법인 코드", - "Source company name": "원천 법인 이름", + "Source company name": "원천 법인명", "Source business unit": "원천 사업부(PU)", "Source process unit name": "원천 사업부(PU) 이름", "Source sales pool": "원천 수주풀", @@ -364,7 +401,7 @@ const TRANSLATIONS: Partial>> = { "Verify against web search": "웹 검색 검증", "Deriving...": "찾는 중...", "Derive commitment": "Commitment 도출", - "Creating...": "생성하는 중...", + "Creating...": "생성 중...", "Embedded image": "삽입 이미지", "Text detected in image": "이미지에서 인식된 텍스트", "Image regions": "이미지 영역", @@ -605,7 +642,7 @@ const TRANSLATIONS: Partial>> = { "Read leftover map rank {rank}, observed Y {observed}, and expected E {expected} after IRT main effects, then open this post.": "IRT 주효과 이후 잔여 맵 랭크 {rank}, 관측 Y {observed}, 기대 E {expected}를 읽은 다음, 이 글을 여세요.", "Leftover map rank 0 means no leftover structure after IRT main effects. Read observed Y {observed} and expected E {expected}, then open this post.": - "IRT 주효과 이후 잔여 맵 랭크 0은 잔여 구조가 없음을 뜻합니다. 관측 Y {observed}와 기대 E {expected}를 읽은 다음, 이 글을 여세요.", + "잔여 맵 랭크 0은 IRT 주효과 이후 잔여 구조가 없음을 뜻합니다. 관측 Y {observed}와 기대 E {expected}를 읽은 다음, 이 글을 여세요.", }, zh: { "Connect another perspective": "关联另一个观点", @@ -688,7 +725,7 @@ const TRANSLATIONS: Partial>> = { "Semantic extraction": "语义提取", "Recorded extraction": "已记录的提取", "Stored semantic evidence": "已存储的语义证据", - "Recorded evidence": "已记录的证据", + "Recorded evidence": "记录的证据", "Lineage maintenance": "谱系维护", "Verification is unavailable because public search is not configured yet. Ask an administrator to enable it, then retry.": "验证功能尚未配置公开搜索。请请求管理员启用后再试。", "No customer commitment found in this post.": "未在此文章中找到客户承诺。", @@ -910,7 +947,7 @@ const TRANSLATIONS: Partial>> = { "Loading lineage...": "正在加载谱系...", "Loading VOC evidence...": "正在加载客户之声证据...", "No extractive excerpt -- no named organization appears in this post.": - "没有提取出的摘录。此文章中没有出现组织名称。", + "没有提取出的摘录。此文章中没有组织名称。", "No Keymen extracted yet.": "尚未提取关键联系人。", "Loading evaluation...": "正在加载评估...", "Not yet evaluated.": "尚未评估。", @@ -1124,7 +1161,7 @@ const TRANSLATIONS: Partial>> = { "Exact values": "精确值", Source: "起点", Property: "属性", - Target: "终点", + Target: "目标", "Truth status": "真值状态", "Recorded at": "记录时间", "Node evidence": "节点证据", @@ -2358,7 +2395,7 @@ const TRANSLATIONS: Partial>> = { }; /** Customer-facing labels keep implementation vocabulary out of the reader UI. */ -const CUSTOMER_COPY: Record> = { +const CUSTOMER_COPY: Partial>> = { en: { "View related information": "View related information", "Related information": "Related information", @@ -2455,7 +2492,7 @@ export function useLocale(): Locale { } export function t(key: string): string { - return CUSTOMER_COPY[currentLocale][key] ?? TRANSLATIONS[currentLocale]?.[key] ?? key; + return activeScreenTranslations?.[key] ?? CUSTOMER_COPY[currentLocale]?.[key] ?? TRANSLATIONS[currentLocale]?.[key] ?? key; } export function tf(key: string, values: Record): string { diff --git a/frontend/src/occupationalConstructI18n.ts b/frontend/src/occupationalConstructI18n.ts index 1b191130f..da5f291ec 100644 --- a/frontend/src/occupationalConstructI18n.ts +++ b/frontend/src/occupationalConstructI18n.ts @@ -200,13 +200,14 @@ const COPY = { "Finding work evidence...": "Đang tìm bằng chứng công việc...", "Show more matching records": "Hiển thị thêm bản ghi phù hợp", }, -} as const satisfies Record>; +} as const; export type OccupationalConstructCopyKey = keyof (typeof COPY)["en"]; /** Return occupational-construct evidence copy in the active product locale. */ export function occupationalConstructText(key: OccupationalConstructCopyKey): string { - return COPY[getLocale()][key]; + const copy = COPY as Partial>>; + return copy[getLocale()]?.[key] ?? COPY.en[key]; } /** Substitute named placeholders in occupational-construct copy. */ diff --git a/frontend/src/ontologyExplorerI18n.ts b/frontend/src/ontologyExplorerI18n.ts index 961dbb338..7552839a1 100644 --- a/frontend/src/ontologyExplorerI18n.ts +++ b/frontend/src/ontologyExplorerI18n.ts @@ -46,11 +46,14 @@ const ONTOLOGY_EXPLORER_COPY = { "No direct evidence post is attached. Review the provenance reference above.": "Không có bài đăng bằng chứng trực tiếp được đính kèm. Hãy xem tham chiếu nguồn gốc ở trên.", }, -} as const satisfies Record>; +} as const; export type OntologyExplorerCopyKey = keyof (typeof ONTOLOGY_EXPLORER_COPY)["en"]; /** Return ontology-explorer stabilization copy in the active product locale. */ export function ontologyExplorerText(key: OntologyExplorerCopyKey): string { - return ONTOLOGY_EXPLORER_COPY[getLocale()][key]; + const copy = ONTOLOGY_EXPLORER_COPY as Partial< + Record> + >; + return copy[getLocale()]?.[key] ?? ONTOLOGY_EXPLORER_COPY.en[key]; } diff --git a/frontend/src/projectHistory.ts b/frontend/src/projectHistory.ts index 4dceb6fbd..174722fb8 100644 --- a/frontend/src/projectHistory.ts +++ b/frontend/src/projectHistory.ts @@ -189,7 +189,7 @@ const EN: Record = { sourceRecorded: "Source record", }; -const MESSAGES: Record> = { +const MESSAGES: Partial>> = { en: EN, ko: { heading: "프로젝트 이벤트 타임라인", @@ -378,7 +378,7 @@ export function projectHistoryText( key: ProjectHistoryMessageKey, params: MessageParams = {}, ): string { - let value = MESSAGES[locale][key]; + let value = MESSAGES[locale]?.[key] ?? EN[key]; for (const [name, replacement] of Object.entries(params)) { value = value.replaceAll(`{${name}}`, String(replacement)); } diff --git a/frontend/src/workerFunctionPsychologyI18n.ts b/frontend/src/workerFunctionPsychologyI18n.ts index 48c41180c..7e5711208 100644 --- a/frontend/src/workerFunctionPsychologyI18n.ts +++ b/frontend/src/workerFunctionPsychologyI18n.ts @@ -56,11 +56,14 @@ const WORKER_FUNCTION_PSYCHOLOGY_COPY = { "Select a worker function to review its I/O psychology demand profile.": "Chọn một chức năng công việc để xem hồ sơ nhu cầu Tâm lý I/O.", }, -} as const satisfies Record>; +} as const; export type WorkerFunctionPsychologyCopyKey = keyof (typeof WORKER_FUNCTION_PSYCHOLOGY_COPY)["en"]; /** Return worker-function I/O psychology copy for the active product locale. */ export function workerFunctionPsychologyText(key: WorkerFunctionPsychologyCopyKey): string { - return WORKER_FUNCTION_PSYCHOLOGY_COPY[getLocale()][key]; -} \ No newline at end of file + const copy = WORKER_FUNCTION_PSYCHOLOGY_COPY as Partial< + Record> + >; + return copy[getLocale()]?.[key] ?? WORKER_FUNCTION_PSYCHOLOGY_COPY.en[key]; +} diff --git a/tests/test_customer_master_translation_auth_gate_contract.py b/tests/test_customer_master_translation_auth_gate_contract.py new file mode 100644 index 000000000..6455ebcc1 --- /dev/null +++ b/tests/test_customer_master_translation_auth_gate_contract.py @@ -0,0 +1,115 @@ +"""Executable guard for Customer Master's auth-bound translation admission.""" + +from pathlib import Path + + +APP_SOURCE = Path("frontend/src/App.tsx") + + +def _customer_master_panel_source() -> str: + """Return only the Customer Master component so unrelated UI cannot satisfy the contract.""" + source = APP_SOURCE.read_text(encoding="utf-8") + start = source.index("function CustomerMasterPanel(") + end = source.index("\nexport function AskAgentPanel(", start) + return source[start:end] + + +def test_customer_master_translation_ready_is_bound_to_current_access_token() -> None: + """Changing auth identity must invalidate translated-copy readiness before data admission.""" + panel = _customer_master_panel_source() + + assert "const [copyAccessToken, setCopyAccessToken] = useState(null);" in panel + assert "setCopyAccessToken(null);" in panel + assert "setCopyAccessToken(accessToken);" in panel + assert "copyAccessToken !== accessToken" in panel + + +def test_customer_master_data_effect_requires_auth_bound_copy() -> None: + """The Customer Master request effect must fail closed on stale-token copy.""" + panel = _customer_master_panel_source() + effect_start = panel.index('if (copyState !== "ready"') + effect_end = panel.index("\n useEffect(() => {", effect_start + 1) + data_effect = panel[effect_start:effect_end] + + assert "copyLocale !== locale" in data_effect + assert "copyAccessToken !== accessToken" in data_effect + assert "void loadMaster();" in data_effect + + +def test_customer_master_auth_transition_clears_data_before_copy_can_unlock() -> None: + """Token/locale transitions must invalidate customer data before replacement copy is fetched.""" + panel = _customer_master_panel_source() + translation_fetch = panel.index('fetchTranslationScreen(accessToken, "customer-master", locale)') + effect_start = panel.rfind(" useEffect(() => {", 0, translation_fetch) + transition_prefix = panel[effect_start:translation_fetch] + + assert "setMaster(null);" in transition_prefix + + +def test_customer_master_fetch_completion_is_not_admitted_by_unscoped_then_setter() -> None: + """A request started under an old auth identity must not publish after identity changes.""" + panel = _customer_master_panel_source() + callback_start = panel.index(" const loadMaster = useCallback(() => {") + callback_end = panel.index("\n useEffect(() => {", callback_start) + load_master = panel[callback_start:callback_end] + + assert ".then(setMaster)" not in load_master + assert "masterRequestGeneration" in panel + + +def test_customer_master_render_gate_rejects_stale_token_copy() -> None: + """Stale-token translations must not unlock the Customer Master surface.""" + panel = _customer_master_panel_source() + render_gate_start = panel.index('if (copyState === "loading"') + render_gate_end = panel.index("\n return (", render_gate_start) + render_gate = panel[render_gate_start:render_gate_end] + + assert "copyAccessToken !== accessToken" in render_gate + + +def test_customer_master_auth_transition_invalidates_secondary_authorization_projections() -> None: + """Related data, errors, post detail, and privileges from the old token must be discarded.""" + panel = _customer_master_panel_source() + fetch_me = panel.index("fetchMe(accessToken)") + effect_start = panel.rfind(" useEffect(() => {", 0, fetch_me) + effect_end = panel.index("\n }, [accessToken]);", fetch_me) + auth_effect = panel[effect_start:effect_end] + + for statement in ( + "setCanResolveHints(false);", + "setRelatedByEntity({});", + "setRelatedErrorByEntity({});", + "setExpandedEntityId(null);", + "setRelatedLoading(null);", + "setSelectedPostId(null);", + "setSelectedPostGraph(null);", + "setResolvingHint(null);", + "setResolveError(null);", + ): + assert statement in auth_effect + + +def test_customer_master_secondary_async_completions_are_bound_to_current_auth_identity() -> None: + """Old-token async continuations must not repopulate Customer Master secondary projections.""" + panel = _customer_master_panel_source() + + assert "const currentAccessTokenRef = useRef(accessToken);" in panel + assert "currentAccessTokenRef.current = accessToken;" in panel + + load_start = panel.index(" const loadMaster = useCallback(() => {") + load_end = panel.index("\n useEffect(() => {", load_start) + load_master = panel[load_start:load_end] + assert "requestAccessToken === currentAccessTokenRef.current" in load_master + + resolve_start = panel.index(" async function handleResolveHint(") + resolve_end = panel.index("\n async function loadRelatedEntity(", resolve_start) + resolve_hint = panel[resolve_start:resolve_end] + assert "requestAccessToken === currentAccessTokenRef.current" in resolve_hint + assert "requestAuthGeneration === authGeneration.current" in resolve_hint + + related_start = panel.index(" async function loadRelatedEntity(") + related_end = panel.index("\n function toggleEntity(", related_start) + load_related = panel[related_start:related_end] + assert "requestAccessToken === currentAccessTokenRef.current" in load_related + assert "requestAuthGeneration === authGeneration.current" in load_related + assert "setRelatedLoading((owner) => (owner === entityId ? null : owner));" in load_related