diff --git a/.changeset/audit-lock-state-zh-vocabulary-5004.md b/.changeset/audit-lock-state-zh-vocabulary-5004.md new file mode 100644 index 000000000..b67f1180e --- /dev/null +++ b/.changeset/audit-lock-state-zh-vocabulary-5004.md @@ -0,0 +1,37 @@ +--- +'@object-ui/app-shell': patch +--- + +The metadata Audit panel's lock column now shows Chinese for every lock state it can print. + +`AuditPanel` renders `MetadataAuditEntry.lockState` through +`translateConsoleValue('lock', …)`, but `CONSOLE_VALUE_ZH.lock` held +`draft` / `locked` / `published` / `none` — a draft-status vocabulary, not the +ADR-0010 §3.6 four-state lock. The misalignment was total rather than partial +(objectui#5004): `draft` / `locked` / `published` matched no value `lockState` +can hold; `none`, the only entry that did match, is excluded by the call site's +own guard (an unlocked row renders an em dash) and so was unreachable; and the +three values that actually reach the helper — `no-overlay` / `no-delete` / +`full` — had no entry at all. Hit rate 0/3. A zh-CN admin opening any locked +item's Audit panel read bare English tokens in a column headed 锁状态. + +The table is now the lock vocabulary it claims to be — `禁止编辑` / `禁止删除` / +`完全锁定`, wording tracked to the lock-banner sentences a reader meets on the +same screen — and the three draft-status entries are gone. They were dead in the +measured sense: `translateConsoleValue('lock', …)` has exactly one call site +repo-wide and `CONSOLE_VALUE_ZH` is module-private, so nothing else could read +them. + +Following objectui#4982's `LAYER_SCOPE_ZH`, the keys are bound to their +producer's union — `Record< NonNullable< MetadataAuditEntry['lockState'] >, +string >`. A fifth lock state therefore fails `type-check` naming the label that +is missing, instead of the column silently shipping a raw English value. The +union is this repo's own hand-written one in `@object-ui/data-objectstack`, not a +`@objectstack/spec` enum; whether the spec should own the lock vocabulary is a +separate question and is deliberately not answered here. + +`none` is kept in the record even though the call site never asks for it, so the +key set stays complete over the producer's type rather than tracking a `!==` +guard in another file. Unchanged on purpose: `translateConsoleValue` is still +zh-only, and the em-dash branch for `none` / `null` still renders exactly as +before. diff --git a/packages/app-shell/src/views/metadata-admin/AuditPanel.lockState.test.tsx b/packages/app-shell/src/views/metadata-admin/AuditPanel.lockState.test.tsx new file mode 100644 index 000000000..390ce1d98 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/AuditPanel.lockState.test.tsx @@ -0,0 +1,157 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The Audit tab's lock column speaks the UI language (objectui#5004). + * + * `AuditPanel` renders `MetadataAuditEntry.lockState` through + * `translateConsoleValue('lock', …)`, and `CONSOLE_VALUE_ZH.lock` used to hold + * `draft` / `locked` / `published` / `none` — a draft-status vocabulary, not the + * ADR-0010 §3.6 four-state lock. Alignment was total, not partial: + * + * - `draft` / `locked` / `published` matched NO value `lockState` can hold; + * - `none`, the one entry that did match, is excluded by the call site's own + * guard (an unlocked row renders an em dash), so it was unreachable; + * - the three values that DO reach the helper — `no-overlay` / `no-delete` / + * `full` — had no entry, so zh-CN fell through to the raw token. + * + * Hit rate 0/3: a zh-CN admin opening any locked item's Audit panel read bare + * English in a column headed 锁状态. + * + * The state list below is bound to `MetadataAuditEntry['lockState']` itself + * rather than spelled freely, so a fifth lock state is covered by these cases + * the moment the union gains it. Unlike objectui#4982's overlay scopes there is + * no `@objectstack/spec` enum to read at runtime — this union is hand-written in + * `packages/data-objectstack` — hence the `satisfies`-checked key trick instead + * of a `.options` array. (The compile-time half of the coverage is + * `LOCK_STATE_ZH` in `./i18n`, whose key type is that union: a new state with no + * zh-CN label fails `type-check` before it can reach the column.) + */ +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import type { + MetadataAuditEntry, + MetadataClient, +} from '@object-ui/data-objectstack'; +import { AuditPanel } from './AuditPanel'; +import { translateConsoleValue } from './i18n'; + +afterEach(cleanup); + +type LockState = NonNullable; + +/** + * Every lock state the producer type can send. `satisfies` makes the record + * exhaustive in both directions — a state added to the union leaves a key + * missing, a state renamed leaves an extra one — so this list cannot drift from + * `lockState` without failing `type-check`. + */ +const ALL_LOCK_STATES = Object.keys({ + none: 0, + 'no-overlay': 0, + 'no-delete': 0, + full: 0, +} satisfies Record) as LockState[]; + +/** The states the column actually prints — `none` takes the em-dash branch. */ +const RENDERED_LOCK_STATES = ALL_LOCK_STATES.filter((s) => s !== 'none'); + +/** The draft-status entries the table used to hold, none of them a lock state. */ +const RETIRED_ENTRIES = ['draft', 'locked', 'published']; + +const EM_DASH = '—'; + +function auditRow(over: Partial = {}): MetadataAuditEntry { + return { + id: 'a_1', + occurredAt: '2026-08-17T10:00:00.000Z', + actor: 'admin@objectos.ai', + source: 'protocol.saveMetaItem', + operation: 'save', + outcome: 'denied', + code: 'item_locked', + lockState: 'full', + lockOverridden: false, + requestId: null, + note: null, + ...over, + }; +} + +/** Minimal stand-in — the panel only calls `audit()` on its client. */ +function clientWith(events: MetadataAuditEntry[]): MetadataClient { + const stub: Pick = { + audit: async () => ({ events }), + }; + return stub as MetadataClient; +} + +async function renderPanel(over: Partial, locale: string) { + render( + , + ); + // The panel loads in an effect; wait for the row's actor cell to land. + await screen.findByText('admin@objectos.ai'); +} + +describe('AuditPanel lock-state column (objectui#5004)', () => { + it('every lock state the union can hold has a zh-CN label', () => { + for (const state of ALL_LOCK_STATES) { + expect( + translateConsoleValue('lock', state, 'zh-CN'), + `CONSOLE_VALUE_ZH.lock has no entry for lock state '${state}'`, + ).not.toBe(state); + } + }); + + it.each(RENDERED_LOCK_STATES)('renders the zh-CN label for lockState=%s', async (state) => { + await renderPanel({ lockState: state }, 'zh-CN'); + const zh = translateConsoleValue('lock', state, 'zh-CN'); + expect(screen.getByText(zh)).toBeInTheDocument(); + // The raw producer token must not be on screen anywhere. + expect(screen.queryByText(state)).toBeNull(); + }); + + it('keeps the em-dash branch for an unlocked row', async () => { + await renderPanel({ lockState: 'none' }, 'zh-CN'); + expect(screen.getByText(EM_DASH)).toBeInTheDocument(); + // `none` has a label, but the guard at the call site means the column never + // asks for it — the em dash is the unlocked presentation, not a fallback. + expect(screen.queryByText(translateConsoleValue('lock', 'none', 'zh-CN'))).toBeNull(); + }); + + it('keeps the em-dash branch for a null lockState', async () => { + await renderPanel({ lockState: null }, 'zh-CN'); + expect(screen.getByText(EM_DASH)).toBeInTheDocument(); + }); + + it('keeps the override star next to the localized label', async () => { + // `lockOverridden` appends ' *' as a sibling text node; pinned so the label + // change did not disturb the forced-write marker. + await renderPanel({ lockState: 'no-delete', lockOverridden: true }, 'zh-CN'); + const zh = translateConsoleValue('lock', 'no-delete', 'zh-CN'); + expect(screen.getByText(`${zh} *`)).toBeInTheDocument(); + }); + + it('leaves the raw value alone outside zh — the helper has always done that', async () => { + // `translateConsoleValue` returns the input verbatim for every non-zh + // locale. Whether the other nine locale packs should gain these values is a + // separate decision, deliberately not made here; this case pins that this + // fix did not quietly make it. + await renderPanel({ lockState: 'no-overlay' }, 'en-US'); + expect(screen.getByText('no-overlay')).toBeInTheDocument(); + }); + + it('does not resurrect the draft-status entries as lock labels', async () => { + // The three retired keys are values `lockState` cannot hold. Falling through + // to the raw token is the correct answer for them now — an entry here would + // be another 0-hit-rate row, which is the defect this file guards. + for (const stale of RETIRED_ENTRIES) { + expect(translateConsoleValue('lock', stale, 'zh-CN')).toBe(stale); + } + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index 0b734187b..d9341c0fd 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -54,9 +54,13 @@ */ import { useObjectTranslation } from '@object-ui/i18n'; -// Type-only (erased at build): the overlay-scope label table below is keyed by -// the spec's enum rather than by hand — see `LAYER_SCOPE_ZH`. -import type { MetadataOverlayScope } from '@object-ui/data-objectstack'; +// Type-only (erased at build): the overlay-scope and lock-state label tables +// below are keyed by their producer's own union rather than by hand — see +// `LAYER_SCOPE_ZH` and `LOCK_STATE_ZH`. +import type { + MetadataAuditEntry, + MetadataOverlayScope, +} from '@object-ui/data-objectstack'; export type SupportedLocale = 'en-US' | 'zh-CN'; @@ -4124,6 +4128,44 @@ const LAYER_SCOPE_ZH: Record, string> = { env: '环境', }; +/** + * zh-CN labels for the ADR-0010 §3.6 four-state metadata lock, keyed by the + * producer field the only consumer actually renders. + * + * The key type is `MetadataAuditEntry['lockState']` — the hand-written union in + * `packages/data-objectstack/src/metadata-client.ts`, not a `@objectstack/spec` + * enum, because this repo owns that union today. (Whether the spec should own + * the lock vocabulary is a separate question; it is deliberately not answered + * here.) Binding the keys means a fifth lock state added to the union stops this + * record from compiling and names the label that is missing, instead of the + * column silently shipping a raw English token. + * + * That silent path is objectui#5004, and it was total rather than partial: the + * table used to hold `draft` / `locked` / `published` / `none` — a draft-status + * vocabulary — so of the three values the audit column can actually show, zero + * had an entry. A zh-CN admin read bare `no-overlay` / `no-delete` / `full`. + * + * Wording tracks the lock banner sentences a reader meets on the same screen + * (`engine.edit.lockNoOverlay` 不可编辑 / `lockNoDelete` 不可删除 / `lockFull` + * 不可编辑或删除), compressed to state names for a narrow badge column. + * + * `none` is currently unreachable through the sole call site — `AuditPanel` + * renders an em dash for an unlocked/`null` row rather than a word — and is kept + * anyway: the key set follows the producer's type, so the record stays complete + * over the domain and does not need editing the day that presentation choice + * changes. + * + * Deliberately zh-only, like every other group here — `translateConsoleValue` + * returns the raw value for the other nine locale packs by existing design, and + * whether to extend it is a separate decision, not a rider on this fix. + */ +const LOCK_STATE_ZH: Record, string> = { + none: '无', + 'no-overlay': '禁止编辑', + 'no-delete': '禁止删除', + full: '完全锁定', +}; + /** * zh-CN labels for the metadata-editor side panels' raw enum-ish values — * History operation badges, Audit operation/outcome/lock-state, and the Layered @@ -4143,7 +4185,7 @@ const CONSOLE_VALUE_ZH: Record> = { rollback: '回滚', }, outcome: { allowed: '允许', denied: '拒绝', forced: '强制' }, - lock: { draft: '草稿', locked: '已锁定', published: '已发布', none: '无' }, + lock: LOCK_STATE_ZH, layer: { artifact: '工件', none: '无',