|
1 | 1 | // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
2 | 2 |
|
3 | 3 | /** |
4 | | - * objectstack#25922 slices S2 to S6b — the designer rows that moved into |
| 4 | + * objectstack#25922 slices S2 to S7 — the designer rows that moved into |
5 | 5 | * `@object-ui/i18n`'s `studio` namespace (every `engine.inspector.*` row; |
6 | 6 | * `engine.packages.*`, `engine.form.*`, `engine.edit.*`; all of |
7 | 7 | * `engine.studio.*`; the data source pages, the field types, the metadata |
8 | 8 | * preview panes, the flow designer, the rest of the Studio chrome and, last, |
9 | 9 | * the translation preview), and |
10 | 10 | * app-shell's `t()` / `tOptional()` still answer every one of them the way they |
11 | | - * did when the rows sat in its own table. |
| 11 | + * did when the rows sat in its own table. Slice S7 deleted that table and the |
| 12 | + * table-first lookup, so the namespace is the only home. |
12 | 13 | * |
13 | 14 | * What would break it: |
14 | | - * - a row copied back into the app-shell table with a different value — the |
15 | | - * table is asked first, so it would shadow the namespace silently; |
16 | | - * - the namespace fallback dropped from `lookupString` — every moved key |
| 15 | + * - a lookup asked in front of the namespace again (a table brought back |
| 16 | + * with a row whose value differs) — it would shadow the namespace silently; |
| 17 | + * - the namespace dropped from `lookupString` — every moved key |
17 | 18 | * would echo back as itself; |
18 | 19 | * - a family emptied out of the namespace, which every per-key assertion |
19 | 20 | * below would then pass over nothing. The floors are per FAMILY, by name, |
|
23 | 24 | * `loadStudioCatalogue` (the module's public door), not a copy of their text. |
24 | 25 | */ |
25 | 26 | import { describe, expect, it } from 'vitest'; |
| 27 | +import { readFileSync } from 'node:fs'; |
| 28 | +import path from 'node:path'; |
| 29 | +import { fileURLToPath } from 'node:url'; |
26 | 30 | import { loadStudioCatalogue } from '@object-ui/i18n/namespaces/studio'; |
27 | 31 | import { t, tOptional } from '../i18n'; |
28 | 32 |
|
@@ -134,15 +138,49 @@ const FAMILY_FLOOR: Readonly<Record<string, number>> = Object.freeze({ |
134 | 138 | * carry. The package form's help text reads its English from the producer's own |
135 | 139 | * `.describe()` sentence; the flow node labels and hints and the flow enum |
136 | 140 | * values read theirs from the engine descriptor or the value itself. Each is |
137 | | - * read through `tOptional` or a `lookupString` fallback (`ZH_ONLY_FAMILIES` in |
138 | | - * `ui/scripts/check-i18n-designer-table-parity.mjs` carries the reasons). |
| 141 | + * read through `tOptional` or a `lookupString` fallback; `ZH_ONLY_REASONS` |
| 142 | + * below carries the reasons. |
139 | 143 | */ |
140 | 144 | const ZH_ONLY_FLOOR: Readonly<Record<string, number>> = Object.freeze({ |
141 | 145 | 'engine.packages.form.help.': 9, |
142 | 146 | 'engine.flowNode.': 40, |
143 | 147 | 'engine.enum.': 4, |
144 | 148 | }); |
145 | 149 |
|
| 150 | +/** |
| 151 | + * Why each zh-only family has no `en` row, and the reader that says so. Moved |
| 152 | + * here from the retired `check:i18n-designer-parity` gate with slice S7. A |
| 153 | + * citation is `{ file, anchor }`, the anchor is TEXT and never a line number, |
| 154 | + * and it must occur exactly once in its file or it stops locating anything. |
| 155 | + */ |
| 156 | +const ZH_ONLY_REASONS = Object.freeze([ |
| 157 | + { |
| 158 | + prefix: 'engine.flowNode.', |
| 159 | + reason: |
| 160 | + 'zh-only by design: for English the flow palette falls back to the engine descriptor’s own ' + |
| 161 | + 'server-authoritative name/description, so an `en` row would OVERRIDE the server', |
| 162 | + file: '../i18n.ts', |
| 163 | + anchor: 'resolved by translateNodeLabel/Hint', |
| 164 | + }, |
| 165 | + { |
| 166 | + prefix: 'engine.enum.type.', |
| 167 | + reason: |
| 168 | + 'zh-only by construction: translateEnumOption returns the raw value before it ever looks the ' + |
| 169 | + 'key up for any non-zh locale, so an `en` row is unreachable', |
| 170 | + file: '../i18n.ts', |
| 171 | + anchor: 'export function translateEnumOption', |
| 172 | + }, |
| 173 | + { |
| 174 | + prefix: 'engine.packages.form.help.', |
| 175 | + reason: |
| 176 | + 'zh-only on purpose: with no `en` row tOptional returns undefined, helpText drops out, and the row ' + |
| 177 | + 'falls back to ManifestSchema’s .describe() in @objectstack/spec — an `en` row would copy that ' + |
| 178 | + 'text into a second producer, against AGENTS.md #0.1', |
| 179 | + file: '../package-schema.ts', |
| 180 | + anchor: 'en-US deliberately has NO entry', |
| 181 | + }, |
| 182 | +]); |
| 183 | + |
146 | 184 | const familyKeys = (prefix: string): string[] => Object.keys(EN).filter((key) => key.startsWith(prefix)); |
147 | 185 |
|
148 | 186 | describe('moved designer rows answer from the studio namespace (objectstack#25922)', () => { |
@@ -173,3 +211,45 @@ describe('moved designer rows answer from the studio namespace (objectstack#2592 |
173 | 211 | expect(keys.filter((key) => tOptional(key, 'en-US') !== undefined)).toEqual([]); |
174 | 212 | }); |
175 | 213 | }); |
| 214 | + |
| 215 | +describe('the namespace is the only home (objectstack#25922 slice S7)', () => { |
| 216 | + const here = path.dirname(fileURLToPath(import.meta.url)); |
| 217 | + const zhOnly = Object.keys(ZH).filter((key) => !(key in EN)); |
| 218 | + |
| 219 | + it('every namespace key answers through t() and tOptional() with its catalogue value', () => { |
| 220 | + // Every key, not a family sample: with the table gone nothing else may |
| 221 | + // answer, so a lookup in front of the namespace shows up here by key. |
| 222 | + expect(Object.keys(EN).length).toBeGreaterThan(3000); |
| 223 | + const wrong = Object.keys(EN).flatMap((key) => { |
| 224 | + const out: string[] = []; |
| 225 | + const zh = ZH[key] ?? EN[key]; |
| 226 | + for (const locale of ['en-US', 'en', 'de-DE', undefined]) { |
| 227 | + if (t(key, locale) !== EN[key]) out.push(`t ${locale} ${key}`); |
| 228 | + if (tOptional(key, locale) !== EN[key]) out.push(`tOptional ${locale} ${key}`); |
| 229 | + } |
| 230 | + for (const locale of ['zh-CN', 'zh', 'zh-TW']) { |
| 231 | + if (t(key, locale) !== zh) out.push(`t ${locale} ${key}`); |
| 232 | + if (tOptional(key, locale) !== zh) out.push(`tOptional ${locale} ${key}`); |
| 233 | + } |
| 234 | + return out; |
| 235 | + }); |
| 236 | + expect(wrong).toEqual([]); |
| 237 | + }); |
| 238 | + |
| 239 | + it('the zh-only rows are the documented families, and answer in zh alone', () => { |
| 240 | + // 54 at the move (9 package form help rows, 41 flow node rows, 4 flow enum |
| 241 | + // values); all of them belong to a family with a reason below. |
| 242 | + expect(zhOnly.length).toBeGreaterThanOrEqual(50); |
| 243 | + expect(zhOnly.filter((key) => !ZH_ONLY_REASONS.some((f) => key.startsWith(f.prefix)))).toEqual([]); |
| 244 | + const wrong = zhOnly.flatMap((key) => [ |
| 245 | + ...['en-US', 'en', 'de-DE', undefined].filter((locale) => tOptional(key, locale) !== undefined).map((l) => `en-side ${l} ${key}`), |
| 246 | + ...['zh-CN', 'zh', 'zh-TW'].filter((locale) => tOptional(key, locale) !== ZH[key]).map((l) => `zh-side ${l} ${key}`), |
| 247 | + ]); |
| 248 | + expect(wrong).toEqual([]); |
| 249 | + }); |
| 250 | + |
| 251 | + it.each(ZH_ONLY_REASONS.map((f) => [f.prefix, f] as const))('%s: its citation anchor occurs exactly once', (_prefix, family) => { |
| 252 | + const text = readFileSync(path.join(here, family.file), 'utf8'); |
| 253 | + expect(text.split(family.anchor).length - 1).toBe(1); |
| 254 | + }); |
| 255 | +}); |
0 commit comments