diff --git a/docs/adr/0362-versioned-ui-translation-ledger.md b/docs/adr/0362-versioned-ui-translation-ledger.md index e1f2b9b1c..ac25c4529 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.md b/docs/product-technical-gap-baseline.md index 59b0484e0..7e4771fb7 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,15 +1,127 @@ # 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-05 KST. Protected `main` is > `83eba56149eb802cd63642c507c324c9976ec78e`. PR #929 is the active > ADR 0362 candidate for issue #922 and is open / Ready for exact-head > validation. 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 120 open PRs and 16 open issues; +> live non-identifying queue snapshot contains 122 open PRs and 16 open issues; > those counts describe coordination load, not product maturity or release > readiness. 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. +> 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 @@ -17,11 +129,13 @@ > 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. #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 or terminal hosted checks, and -> neither is protected-main or deployed evidence. +> 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 @@ -31,10 +145,16 @@ > 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 cut over to a published eight-locale ledger resource, -> and there is no exact-head desktop/mobile evidence covering normal, loading, -> empty, error, permission, responsive, keyboard/focus/screen-reader, CJK text -> expansion, or font fallback states. +> 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 @@ -77,6 +197,12 @@ 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. @@ -100,18 +226,24 @@ - 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. Cut one material SPA screen off bundled `TRANSLATIONS` and onto the versioned - API. Customer Master is the natural first slice because #922 gates its open - material-UI work, but the screen identity must follow the actual product - composition contract rather than creating a second domain owner. +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 @@ -164,4 +296,11 @@ `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`; current-head + synthetic visual evidence is + `docs/screenshots/customer-master-translation-gate-{desktop,mobile}.png`. - 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..7d8213e15 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,13 @@ import { LOCALE_LABELS, SUPPORTED_LOCALES, setLocale, + setCustomerMasterTranslations, + clearCustomerMasterTranslations, t, tf, useLocale, } from "./i18n"; +import { ScreenTranslationGate } from "./components/ScreenTranslationGate"; import "./App.css"; const AdminPanel = lazy(() => import("./components/AdminPanel").then((module) => ({ default: module.AdminPanel }))); @@ -4757,7 +4761,16 @@ 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 authGeneration = useRef(0); + const currentAccessTokenRef = useRef(accessToken); + currentAccessTokenRef.current = accessToken; const [error, setError] = useState(null); const [expandedEntityId, setExpandedEntityId] = useState(null); const [relatedByEntity, setRelatedByEntity] = useState>({}); @@ -4776,6 +4789,14 @@ function CustomerMasterPanel({ useEffect(() => { let active = true; + setCanResolveHints(false); + setRelatedByEntity({}); + 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 +4806,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,19 +4889,25 @@ 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) { + const requestAccessToken = accessToken; + const requestAuthGeneration = authGeneration.current; if (expandedEntityId === entityId) { setExpandedEntityId(null); return; @@ -4845,15 +4916,25 @@ function CustomerMasterPanel({ if (relatedByEntity[entityId]) return; setRelatedLoading(entityId); try { - const response = await fetchRelatedEntity(accessToken, entityId); - setRelatedByEntity((previous) => ({ ...previous, [entityId]: response.related })); + const response = await fetchRelatedEntity(requestAccessToken, entityId); + if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) { + setRelatedByEntity((previous) => ({ ...previous, [entityId]: response.related })); + } } catch { - setRelatedByEntity((previous) => ({ ...previous, [entityId]: [] })); + if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) { + setRelatedByEntity((previous) => ({ ...previous, [entityId]: [] })); + } } finally { - setRelatedLoading(null); + if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) setRelatedLoading(null); } } + if (copyState === "retry") { + return setCopyAttempt((attempt) => attempt + 1)} />; + } + if (copyState === "loading" || copyLocale !== locale || copyAccessToken !== accessToken) { + return ; + } return (

{t("Authorized customer scope")}

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..c43d7f8d1 --- /dev/null +++ b/frontend/src/components/ScreenTranslationGate.test.tsx @@ -0,0 +1,50 @@ +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"], + ["es", "Cargando esta pantalla en el idioma seleccionado...", "No se pudo cargar esta pantalla en el idioma seleccionado.", "Reintentar"], + ["de", "Dieser Bildschirm wird in der ausgewählten Sprache geladen...", "Dieser Bildschirm konnte in der ausgewählten Sprache nicht geladen werden.", "Erneut versuchen"], + ["fr", "Chargement de cet écran dans la langue sélectionnée…", "Impossible de charger cet écran dans la langue sélectionnée.", "Réessayer"], + ] as const)("keeps the bootstrap loading and retry shell in %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..61d53b7b3 --- /dev/null +++ b/frontend/src/components/ScreenTranslationGate.tsx @@ -0,0 +1,105 @@ +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", + }, + es: { + loading: "Cargando esta pantalla en el idioma seleccionado...", + retryLabel: "Es necesario reintentar", + retryDescription: "La solicitud ha fallado. Vuelva a intentar la misma acción.", + failure: "No se pudo cargar esta pantalla en el idioma seleccionado.", + nextAction: "Vuelva a intentarlo. Si el problema persiste, póngase en contacto con su administrador.", + retryAction: "Reintentar", + }, + de: { + loading: "Dieser Bildschirm wird in der ausgewählten Sprache geladen...", + retryLabel: "Erneuter Versuch erforderlich", + retryDescription: "Die Anfrage ist fehlgeschlagen. Versuchen Sie dieselbe Aktion erneut.", + failure: "Dieser Bildschirm konnte in der ausgewählten Sprache nicht geladen werden.", + nextAction: "Versuchen Sie es erneut. Wenn das Problem weiterhin besteht, wenden Sie sich an die Administration.", + retryAction: "Erneut versuchen", + }, + fr: { + loading: "Chargement de cet écran dans la langue sélectionnée…", + retryLabel: "Nouvelle tentative requise", + retryDescription: "La demande a échoué. Réessayez la même action.", + failure: "Impossible de charger cet écran dans la langue sélectionnée.", + nextAction: "Réessayez. Si le problème persiste, contactez votre administrateur.", + retryAction: "Réessayer", + }, +}; + +/** 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/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/i18n.test.ts b/frontend/src/i18n.test.ts index b4b8d2fb6..1981fc0f9 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,9 +105,20 @@ 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"]); - expect(Object.keys(LOCALE_LABELS)).toHaveLength(5); + it("supports the governed eight product locales", () => { + expect(SUPPORTED_LOCALES).toEqual(["ko", "en", "ja", "zh", "vi", "es", "de", "fr"]); + expect(Object.keys(LOCALE_LABELS)).toHaveLength(8); + }); + + 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([ diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index bfe170ef8..b9bf54bcc 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", "es", "de", "fr"] as const; export type Locale = (typeof SUPPORTED_LOCALES)[number]; export const LOCALE_LABELS: Record = { @@ -9,8 +9,44 @@ export const LOCALE_LABELS: Record = { zh: "中文", ja: "日本語", vi: "Tiếng Việt", + es: "Español", + de: "Deutsch", + fr: "Français", }; +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...", + "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); } @@ -2358,7 +2394,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 +2491,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..6ae049194 --- /dev/null +++ b/tests/test_customer_master_translation_auth_gate_contract.py @@ -0,0 +1,111 @@ +"""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, post detail, and privileges from the old token must be discarded on transition.""" + 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({});", + "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 toggleEntity(", resolve_start) + resolve_hint = panel[resolve_start:resolve_end] + assert "requestAccessToken === currentAccessTokenRef.current" in resolve_hint + + toggle_start = panel.index(" async function toggleEntity(") + toggle_end = panel.index('\n if (copyState === "retry")', toggle_start) + toggle_entity = panel[toggle_start:toggle_end] + assert "requestAccessToken === currentAccessTokenRef.current" in toggle_entity