Skip to content

Commit bfa6a40

Browse files
committed
wip(ui): studio namespace tests and changeset
Claude-Session: https://claude.ai/code/session_01ADHkmRuDqEXC3CybUtz5d1 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2b67d1e commit bfa6a40

4 files changed

Lines changed: 285 additions & 0 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@object-ui/i18n': patch
3+
---
4+
5+
feat(i18n): a lazy `studio` namespace for the Studio designer's strings; the `designer.*` and `perm.*` keys move into it
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
- **New subpath `@object-ui/i18n/namespaces/studio`.** The Studio designer's two-locale string set becomes a namespace of this package, as the maintainer ruled on objectstack#25922 (6431032267, Q2 = A; kept by 6431234244). `en` is resident; `zh` is its own chunk, fetched the first time a Chinese lookup asks for it; every other language reads `en`. Exports: `studioString(key, lang)` (synchronous; answers from `en` while a catalogue is in flight), `loadStudioCatalogue(lang)`, `isStudioCatalogueLoaded(lang)`, `resolveStudioLanguage(lang)`, the `useStudioCatalogue(lang)` hook (re-renders its caller when a fetched catalogue lands), `STUDIO_NAMESPACE`, `STUDIO_FALLBACK_LANGUAGE`, `STUDIO_LANGUAGE_CODES` and the `StudioCatalogue` type. Additive: nothing existing changes.
11+
- **First slice of the move.** Every key under `designer.*` and `perm.*` (the object/field designer and the permission designer) now lives in the namespace instead of `@object-ui/app-shell`'s private table. Studio reads the same text in `en` and `zh`; a Chinese viewer sees these labels in English for the moment the `zh` catalogue takes to load, and a non-Chinese viewer no longer downloads these keys' Chinese text with the console.
12+
- **Not in the eager catalogue.** `locales/en.ts` gains no key, and the namespace is not one of the ten packs, so `all-locales-key-parity` does not read it; its own parity (every `en` key has a `zh` row with the same `{placeholders}`) is a test of this package.
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#25922, (M) slice 1 — a Studio designer screen reads the strings
5+
* that moved into `@object-ui/i18n`'s `studio` namespace.
6+
*
7+
* The Data pillar's "Add field" popover mixes both homes on one screen: its
8+
* field labels (`designer.field.label` / `.apiName` / `.type`) moved into the
9+
* namespace, its title and buttons (`engine.studio.*`) are still in the
10+
* app-shell table. It takes its locale from `useMetadataLocale()`, the hook
11+
* every designer component uses, so this is the path a real screen takes:
12+
*
13+
* - zh: the namespace's `zh` catalogue is NOT resident when the screen first
14+
* renders (this file runs in an isolated `dom` project and preloads
15+
* nothing). The first paint reads the moved labels in English — the
16+
* fallback, never the raw key — and the same mount re-renders in Chinese
17+
* once the catalogue lands. The table's keys read Chinese throughout.
18+
* - de: a language neither home ships renders English from both.
19+
*
20+
* Expectations are read from the namespace and the table rather than typed
21+
* out, so no case restates a translation.
22+
*/
23+
24+
import '@testing-library/jest-dom/vitest';
25+
import * as React from 'react';
26+
import { afterEach, describe, expect, it } from 'vitest';
27+
import { cleanup, render, screen } from '@testing-library/react';
28+
import { I18nProvider } from '@object-ui/i18n';
29+
import {
30+
isStudioCatalogueLoaded,
31+
loadStudioCatalogue,
32+
studioString,
33+
} from '@object-ui/i18n/namespaces/studio';
34+
35+
import { t } from '../metadata-admin/i18n';
36+
import { AddFieldPopover } from './AddFieldPopover';
37+
38+
afterEach(cleanup);
39+
40+
/** Keys this screen renders from the namespace. */
41+
const MOVED = ['designer.field.label', 'designer.field.type'] as const;
42+
/** A key this screen renders from the app-shell table. */
43+
const TABLE = 'engine.studio.data.addField';
44+
45+
function Screen({ lang }: { lang: string }) {
46+
return (
47+
<I18nProvider config={{ defaultLanguage: lang, detectBrowserLanguage: false }}>
48+
<AddFieldPopover open onCreate={() => {}}>
49+
<button type="button">add</button>
50+
</AddFieldPopover>
51+
</I18nProvider>
52+
);
53+
}
54+
55+
const popover = () => screen.getByTestId('add-field-popover');
56+
/** The namespace's resident English row. */
57+
const en = (key: string) => studioString(key, 'en')!;
58+
59+
describe('AddFieldPopover — strings from the studio namespace (objectstack#25922)', () => {
60+
it('zh: English while the catalogue is in flight, then Chinese on the same mount', async () => {
61+
// The premise: these keys are the namespace's, and the title is the table's.
62+
for (const key of MOVED) expect(en(key), key).toBeDefined();
63+
expect(studioString(TABLE, 'en')).toBeUndefined();
64+
expect(isStudioCatalogueLoaded('zh-CN')).toBe(false);
65+
66+
render(<Screen lang="zh" />);
67+
68+
// First paint: the fallback, not the key.
69+
for (const key of MOVED) {
70+
expect(popover()).toHaveTextContent(en(key));
71+
expect(popover()).not.toHaveTextContent(key);
72+
}
73+
74+
// The catalogue lands (this awaits the fetch the screen started) and
75+
// `useMetadataLocale()` re-renders the same mount.
76+
const zh = await loadStudioCatalogue('zh-CN');
77+
for (const key of MOVED) expect(zh[key], key).not.toBe(en(key));
78+
expect(await screen.findByText(zh['designer.field.label'])).toBeInTheDocument();
79+
for (const key of MOVED) expect(popover()).toHaveTextContent(zh[key]);
80+
expect(popover()).toHaveTextContent(t(TABLE, 'zh-CN'));
81+
expect(t(TABLE, 'zh-CN')).not.toBe(t(TABLE, 'en-US'));
82+
});
83+
84+
it('de: a third locale renders English from both homes', () => {
85+
render(<Screen lang="de" />);
86+
for (const key of MOVED) expect(popover()).toHaveTextContent(en(key));
87+
expect(popover()).toHaveTextContent(t(TABLE, 'en-US'));
88+
for (const key of MOVED) expect(t(key, 'de')).toBe(en(key));
89+
});
90+
});
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/**
2+
* The `studio` namespace's lazy runtime (objectstack#25922) — what a Studio
3+
* screen observes, in the order it observes it:
4+
*
5+
* 1. before the `zh` catalogue has been fetched, a Chinese lookup answers
6+
* from `en` — the documented fallback, never the raw key — and starts the
7+
* fetch;
8+
* 2. a component that took `useStudioCatalogue()` re-renders in Chinese once
9+
* the fetch lands;
10+
* 3. a language the namespace has no catalogue for (`de`) answers from `en`
11+
* and fetches nothing.
12+
*
13+
* Runs in a `dom` project, which isolates each file's module graph, so the
14+
* namespace starts with only `en` resident. Case 1 is about that state and runs
15+
* first; the cases after it hold from either starting point.
16+
*/
17+
import { afterEach, describe, expect, it } from 'vitest';
18+
import React from 'react';
19+
import { cleanup, render, screen } from '@testing-library/react';
20+
21+
import STUDIO_EN from '../namespaces/studio/en.js';
22+
import STUDIO_ZH from '../namespaces/studio/zh.js';
23+
import {
24+
isStudioCatalogueLoaded,
25+
loadStudioCatalogue,
26+
studioString,
27+
useStudioCatalogue,
28+
} from '../namespaces/studio/index.js';
29+
30+
afterEach(cleanup);
31+
32+
/** A key whose two rows differ, so en and zh are tellable apart on screen. */
33+
const KEY = Object.keys(STUDIO_EN).find((k) => STUDIO_ZH[k] !== undefined && STUDIO_ZH[k] !== STUDIO_EN[k])!;
34+
35+
function Probe({ lang }: { lang: string }) {
36+
useStudioCatalogue(lang);
37+
return <span data-testid="probe">{studioString(KEY, lang) ?? 'MISSING'}</span>;
38+
}
39+
40+
describe('studio namespace — lazy zh', () => {
41+
it('answers from en before the zh catalogue lands, then re-renders in zh', async () => {
42+
expect(KEY).toBeDefined();
43+
expect(isStudioCatalogueLoaded('zh-CN')).toBe(false);
44+
45+
render(<Probe lang="zh-CN" />);
46+
// The first paint: the fetch cannot have resolved synchronously.
47+
expect(screen.getByTestId('probe').textContent).toBe(STUDIO_EN[KEY]);
48+
49+
// The same component, with no prop change, once the chunk arrives.
50+
expect(await screen.findByText(STUDIO_ZH[KEY])).toBeTruthy();
51+
expect(isStudioCatalogueLoaded('zh')).toBe(true);
52+
});
53+
54+
it('a third locale renders en and needs no fetch', async () => {
55+
expect(isStudioCatalogueLoaded('de')).toBe(true);
56+
render(<Probe lang="de" />);
57+
expect(screen.getByTestId('probe').textContent).toBe(STUDIO_EN[KEY]);
58+
await expect(loadStudioCatalogue('de')).resolves.toBe(STUDIO_EN);
59+
});
60+
});
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/**
2+
* The `studio` namespace (objectstack#25922, ruling 6431032267 Q2 = A): its
3+
* catalogues' own parity, and the two properties the ruling put on its shape.
4+
*
5+
* 1. PARITY, scoped to the namespace. Every `en` key has a `zh` row carrying
6+
* the same `{placeholders}` (the designer's `tFormat()` syntax), and a
7+
* `zh`-only key belongs to a documented family. This is the namespace's
8+
* own section of the parity suite: `all-locales-key-parity.test.ts` does
9+
* not read it — that suite iterates `builtInLocales`, the ten packs, and
10+
* these catalogues are not packs — and `check:i18n-designer-parity`
11+
* retires with the app-shell table.
12+
* 2. NOT IN THE EAGER CATALOGUE. No namespace key is defined by
13+
* `locales/en.ts`, the console's one eager catalogue, so moving a key here
14+
* adds nothing to the first load's translation bytes.
15+
*
16+
* The lazy runtime — `en` before the fetch, the fetch, the re-render — is
17+
* `studio-namespace-lazy.test.tsx`'s, in an isolated project: this file runs
18+
* in the `unit` project, which shares one module graph per worker, so "zh is
19+
* not resident yet" is not a state it can assume.
20+
*/
21+
import { describe, expect, it } from 'vitest';
22+
23+
import en from '../locales/en.js';
24+
import STUDIO_EN from '../namespaces/studio/en.js';
25+
import STUDIO_ZH from '../namespaces/studio/zh.js';
26+
import {
27+
STUDIO_LANGUAGE_CODES,
28+
loadStudioCatalogue,
29+
resolveStudioLanguage,
30+
studioString,
31+
} from '../namespaces/studio/index.js';
32+
33+
/** `tFormat()`'s own placeholder pattern (app-shell `views/metadata-admin/i18n.ts`). */
34+
const PLACEHOLDER = /\{(\w+)\}/g;
35+
const placeholders = (value: string) => [...new Set(Array.from(value.matchAll(PLACEHOLDER), (m) => m[1]))].sort();
36+
37+
/**
38+
* The `zh`-only families the designer table documents, each one-sided on
39+
* purpose — `ZH_ONLY_FAMILIES` in `ui/scripts/check-i18n-designer-table-parity.mjs`
40+
* carries the reason and the consumer for each.
41+
*/
42+
const ZH_ONLY_FAMILIES = ['engine.flowNode.', 'engine.enum.type.', 'engine.packages.form.help.'];
43+
44+
function flatKeys(node: unknown, prefix = ''): string[] {
45+
if (typeof node !== 'object' || node === null) return [prefix];
46+
return Object.entries(node).flatMap(([k, v]) => flatKeys(v, prefix ? `${prefix}.${k}` : k));
47+
}
48+
49+
describe('studio namespace — scoped parity', () => {
50+
const enKeys = Object.keys(STUDIO_EN);
51+
const zhKeys = Object.keys(STUDIO_ZH);
52+
53+
it('reads a populated catalogue (the comparisons below are not over nothing)', () => {
54+
expect(enKeys.length).toBeGreaterThan(0);
55+
expect(zhKeys.length).toBeGreaterThanOrEqual(enKeys.length);
56+
});
57+
58+
it('every en key has a zh row', () => {
59+
expect(enKeys.filter((key) => !(key in STUDIO_ZH))).toEqual([]);
60+
});
61+
62+
it('every shared key carries the same {placeholders} on both sides', () => {
63+
const mismatched = enKeys
64+
.filter((key) => key in STUDIO_ZH)
65+
.filter((key) => placeholders(STUDIO_EN[key]).join(' ') !== placeholders(STUDIO_ZH[key]).join(' '));
66+
expect(mismatched).toEqual([]);
67+
});
68+
69+
it('a zh-only key belongs to a documented family', () => {
70+
const stray = zhKeys.filter(
71+
(key) => !(key in STUDIO_EN) && !ZH_ONLY_FAMILIES.some((family) => key.startsWith(family)),
72+
);
73+
expect(stray).toEqual([]);
74+
});
75+
76+
it('every value is a non-empty string', () => {
77+
for (const catalogue of [STUDIO_EN, STUDIO_ZH]) {
78+
expect(Object.entries(catalogue).filter(([, v]) => typeof v !== 'string' || v.length === 0)).toEqual([]);
79+
}
80+
});
81+
});
82+
83+
describe('studio namespace — outside the eager catalogue', () => {
84+
it('no namespace key is defined by locales/en.ts', () => {
85+
const eager = new Set(flatKeys(en));
86+
// Lit control: the flattening reaches real pack keys.
87+
expect(eager.has('common.save')).toBe(true);
88+
expect(Object.keys(STUDIO_EN).filter((key) => eager.has(key))).toEqual([]);
89+
});
90+
});
91+
92+
describe('studio namespace — language resolution', () => {
93+
it('ships en and zh', () => {
94+
expect([...STUDIO_LANGUAGE_CODES].sort()).toEqual(['en', 'zh']);
95+
});
96+
97+
it.each([
98+
['zh-CN', 'zh'],
99+
['zh', 'zh'],
100+
['ZH-cn', 'zh'],
101+
['zh-TW', 'zh'],
102+
['en-US', 'en'],
103+
['de', 'en'],
104+
['ar-EG', 'en'],
105+
['', 'en'],
106+
[undefined, 'en'],
107+
])('%s is served by the %s catalogue', (lang, code) => {
108+
expect(resolveStudioLanguage(lang)).toBe(code);
109+
});
110+
111+
it('a resident zh catalogue answers in zh; a third locale answers in en', async () => {
112+
await loadStudioCatalogue('zh');
113+
const key = Object.keys(STUDIO_EN).find((k) => STUDIO_ZH[k] !== STUDIO_EN[k])!;
114+
expect(studioString(key, 'zh-CN')).toBe(STUDIO_ZH[key]);
115+
expect(studioString(key, 'de')).toBe(STUDIO_EN[key]);
116+
expect(studioString(key, 'en-US')).toBe(STUDIO_EN[key]);
117+
});
118+
119+
it('a key neither catalogue defines is undefined, never the key', () => {
120+
expect(studioString('designer.no.such.key', 'zh-CN')).toBeUndefined();
121+
expect(studioString('designer.no.such.key', 'de')).toBeUndefined();
122+
});
123+
});

0 commit comments

Comments
 (0)