|
18 | 18 | * |
19 | 19 | * repo objectstack-ai/objectui |
20 | 20 | * path packages/i18n/src/pickLocalized.ts |
21 | | - * rev origin/main 50fa3766ebb2ebf2ec78c5d13b1d627e6a91696f |
22 | | - * blob 9e5d92ae2efe9be62d4d010cb0a26e598211f3ec |
23 | | - * last touched by objectui#3278 (2026-08-03) |
| 21 | + * rev origin/main d8d0d665dceba53dada4994f1eeef9f83bf1cf91 |
| 22 | + * blob 30fcb0a86343b9b937432a1b2ea89e0a78321f46 |
| 23 | + * last touched by objectui PR #4359 / objectui#3907 (2026-08-11) |
24 | 24 | * |
25 | 25 | * Copied rather than imported because `@objectstack/spec` must not take a |
26 | 26 | * workspace dependency on objectui — spec sits UNDER objectui in the dependency |
@@ -59,19 +59,26 @@ function pickLocalizedReference(value: unknown, language: string | undefined | n |
59 | 59 | if (typeof value === 'number' || typeof value === 'boolean') return String(value); |
60 | 60 | if (typeof value === 'object') { |
61 | 61 | const o = value as Record<string, unknown>; |
| 62 | + // Own properties only, and only `string` values, on every limb |
| 63 | + // (objectui#3907) — see `readReference` below. |
| 64 | + const readReference = (key: string): string | undefined => { |
| 65 | + if (!Object.prototype.hasOwnProperty.call(o, key)) return undefined; |
| 66 | + const entry = o[key]; |
| 67 | + return typeof entry === 'string' ? entry : undefined; |
| 68 | + }; |
62 | 69 | const lang = (language || 'en').trim(); |
63 | 70 | const base = lang.split('-')[0]; |
64 | 71 | // Runtime language is often a bare base code ('zh') while metadata authors |
65 | 72 | // write full BCP-47 tags ('zh-CN') — upgrade to any key sharing the base. |
66 | | - const regional = Object.keys(o).find((k) => k.split('-')[0] === base && typeof o[k] === 'string'); |
| 73 | + const regional = Object.keys(o).find((k) => k.split('-')[0] === base && readReference(k) !== undefined); |
67 | 74 | const pick = |
68 | | - o[lang] ?? |
69 | | - o[base] ?? |
70 | | - (regional !== undefined ? o[regional] : undefined) ?? |
71 | | - o.default ?? |
72 | | - o.en ?? |
73 | | - Object.values(o).find((v) => typeof v === 'string'); |
74 | | - return pick == null ? '' : String(pick); |
| 75 | + readReference(lang) ?? |
| 76 | + readReference(base) ?? |
| 77 | + (regional !== undefined ? readReference(regional) : undefined) ?? |
| 78 | + readReference('default') ?? |
| 79 | + readReference('en') ?? |
| 80 | + Object.values(o).find((v): v is string => typeof v === 'string'); |
| 81 | + return pick == null ? '' : pick; |
75 | 82 | } |
76 | 83 | return String(value); |
77 | 84 | } |
@@ -162,6 +169,107 @@ const PARITY_VECTORS: readonly ParityVector[] = [ |
162 | 169 | // The miss cases. The reference spells "nothing was picked" as `''`. |
163 | 170 | { limb: 'miss empty map', label: {}, locale: 'zh-CN', pick: '' }, |
164 | 171 | { limb: 'miss absent label', label: undefined, locale: 'zh-CN', pick: '' }, |
| 172 | + |
| 173 | + // Converged as of objectui#3907 (PR objectui#4359) — before that PR these 18 |
| 174 | + // vectors were the ONLY inputs that told the two implementations apart (see |
| 175 | + // the module doc's "Rule departures" section). Mirrored from objectui's |
| 176 | + // `plugin-list/src/__tests__/i18nLabel-resolver-parity.test.ts` `CONVERGED` |
| 177 | + // table, reused here per objectstack#7864 as the sync's acceptance fixture. |
| 178 | + // A guard makes its limb MISS, not abort — the chain falls through to the |
| 179 | + // next limb exactly as an absent entry would. |
| 180 | + { |
| 181 | + limb: 'converged: locale names an Object.prototype member ⇒ miss, falls through to en', |
| 182 | + label: { en: 'Sales' }, |
| 183 | + locale: 'constructor', |
| 184 | + pick: 'Sales', |
| 185 | + }, |
| 186 | + { limb: 'converged: prototype member "toString" ⇒ miss, falls through to en', label: { en: 'Sales' }, locale: 'toString', pick: 'Sales' }, |
| 187 | + { limb: 'converged: prototype member "valueOf" ⇒ miss, falls through to en', label: { en: 'Sales' }, locale: 'valueOf', pick: 'Sales' }, |
| 188 | + { |
| 189 | + limb: 'converged: prototype member "hasOwnProperty" ⇒ miss, falls through to en', |
| 190 | + label: { en: 'Sales' }, |
| 191 | + locale: 'hasOwnProperty', |
| 192 | + pick: 'Sales', |
| 193 | + }, |
| 194 | + { |
| 195 | + limb: 'converged: prototype member "isPrototypeOf" ⇒ miss, falls through to en', |
| 196 | + label: { en: 'Sales' }, |
| 197 | + locale: 'isPrototypeOf', |
| 198 | + pick: 'Sales', |
| 199 | + }, |
| 200 | + { |
| 201 | + limb: 'converged: prototype member "propertyIsEnumerable" ⇒ miss, falls through to en', |
| 202 | + label: { en: 'Sales' }, |
| 203 | + locale: 'propertyIsEnumerable', |
| 204 | + pick: 'Sales', |
| 205 | + }, |
| 206 | + { |
| 207 | + limb: 'converged: prototype member "toLocaleString" ⇒ miss, falls through to en', |
| 208 | + label: { en: 'Sales' }, |
| 209 | + locale: 'toLocaleString', |
| 210 | + pick: 'Sales', |
| 211 | + }, |
| 212 | + { |
| 213 | + limb: 'converged: no `en` either ⇒ falls all the way to the last-resort limb', |
| 214 | + label: { ja: '営業' }, |
| 215 | + locale: 'constructor', |
| 216 | + pick: '営業', |
| 217 | + }, |
| 218 | + { limb: 'converged: empty map + prototype-named locale ⇒ a genuine miss', label: {}, locale: 'constructor', pick: '' }, |
| 219 | + { |
| 220 | + limb: 'converged: an OWN key really named like a prototype member is still the author\'s key', |
| 221 | + label: { constructor: 'Ctor', en: 'Sales' }, |
| 222 | + locale: 'constructor', |
| 223 | + pick: 'Ctor', |
| 224 | + }, |
| 225 | + { |
| 226 | + limb: 'converged: non-string value on limb 1 (exact tag) ⇒ miss', |
| 227 | + label: { 'zh-CN': { nested: 'x' }, en: 'Sales' } as unknown as I18nLabel, |
| 228 | + locale: 'zh-CN', |
| 229 | + pick: 'Sales', |
| 230 | + }, |
| 231 | + { |
| 232 | + limb: 'converged: non-string value on limb 2 (base) ⇒ miss', |
| 233 | + label: { zh: { nested: 'x' }, en: 'Sales' } as unknown as I18nLabel, |
| 234 | + locale: 'zh-CN', |
| 235 | + pick: 'Sales', |
| 236 | + }, |
| 237 | + { |
| 238 | + limb: 'converged: non-string value on limb 4 (default) ⇒ miss', |
| 239 | + label: { default: { nested: 'x' }, ja: '営業' } as unknown as I18nLabel, |
| 240 | + locale: 'fr', |
| 241 | + pick: '営業', |
| 242 | + }, |
| 243 | + { |
| 244 | + limb: 'converged: non-string value on limb 5 (en) ⇒ miss', |
| 245 | + label: { en: { nested: 'x' }, ja: '営業' } as unknown as I18nLabel, |
| 246 | + locale: 'fr', |
| 247 | + pick: '営業', |
| 248 | + }, |
| 249 | + { |
| 250 | + limb: 'converged: a number value ⇒ miss', |
| 251 | + label: { en: 42, ja: '営業' } as unknown as I18nLabel, |
| 252 | + locale: 'fr', |
| 253 | + pick: '営業', |
| 254 | + }, |
| 255 | + { |
| 256 | + limb: 'converged: a null value ⇒ miss', |
| 257 | + label: { en: null, ja: '営業' } as unknown as I18nLabel, |
| 258 | + locale: 'fr', |
| 259 | + pick: '営業', |
| 260 | + }, |
| 261 | + { |
| 262 | + limb: 'converged: nothing usable anywhere ⇒ a genuine miss', |
| 263 | + label: { en: { nested: 'x' } } as unknown as I18nLabel, |
| 264 | + locale: 'fr', |
| 265 | + pick: '', |
| 266 | + }, |
| 267 | + { |
| 268 | + limb: 'converged: an empty string value still HITS and stops the chain', |
| 269 | + label: { en: '', 'zh-CN': '销售' }, |
| 270 | + locale: 'en', |
| 271 | + pick: '', |
| 272 | + }, |
165 | 273 | ]; |
166 | 274 |
|
167 | 275 | describe('resolveI18nLabel — rule parity with objectui pickLocalized (#6765 / #6761 ruling B)', () => { |
@@ -223,47 +331,50 @@ describe('resolveI18nLabel — the producer-facing return shape', () => { |
223 | 331 | }); |
224 | 332 | }); |
225 | 333 |
|
226 | | -describe('resolveI18nLabel — the two deliberate departures from the reference', () => { |
227 | | - // Both are documented on the resolver's module doc. They are pinned here with |
228 | | - // BOTH answers so the divergence stays MEASURED: if a later change makes the |
229 | | - // two agree again, these tests go red and say so, rather than quietly |
230 | | - // becoming decoration. |
231 | | - |
232 | | - it('reads own properties only — a locale naming an Object.prototype member is a miss', () => { |
| 334 | +describe('resolveI18nLabel — the rule departures converged with objectui#3907; one departure survives', () => { |
| 335 | + // Before objectui#3907 (landed as objectui PR #4359) this module's rule |
| 336 | + // documented two DELIBERATE narrowings of the reference. #4359 landed the |
| 337 | + // same two guards upstream, so the copied reference now answers identically |
| 338 | + // to `resolveI18nLabel` on both. Pinned here with BOTH sides so the |
| 339 | + // convergence stays MEASURED: if a later change on either side reopens the |
| 340 | + // gap, these tests go red and say so, rather than quietly rotting back into |
| 341 | + // decoration. See `PARITY_VECTORS`' "converged:" rows above for the fuller |
| 342 | + // 18-vector sweep mirrored from objectui's own parity suite. |
| 343 | + |
| 344 | + it('reads own properties only, now on BOTH sides — a locale naming an Object.prototype member misses and falls through', () => { |
233 | 345 | const label: I18nLabel = { en: 'Owner' }; |
234 | 346 |
|
235 | | - // The reference resolves `map['constructor']` up the prototype chain and |
236 | | - // renders the function's source text as the label. Filed as objectui#3907. |
237 | | - expect(pickLocalizedReference(label, 'constructor')).toContain('function Object'); |
238 | | - |
239 | | - // Here it is simply not a key, so the chain continues to `en`. No BCP-47 |
240 | | - // tag is an `Object.prototype` member, so no in-contract input can tell the |
241 | | - // two implementations apart — but on a server the locale can arrive in an |
242 | | - // `Accept-Language` header, which is why this one is hardened. |
| 347 | + // Before objectui#3907 the reference resolved `map['constructor']` up the |
| 348 | + // prototype chain and rendered the function's source text as the label. |
| 349 | + // A guard makes its limb MISS; it does not abort — the chain now falls |
| 350 | + // through to `en` on both sides, converged by objectui PR #4359. |
| 351 | + expect(pickLocalizedReference(label, 'constructor')).toBe('Owner'); |
243 | 352 | expect(resolveI18nLabel(label, 'constructor')).toBe('Owner'); |
244 | 353 | expect(resolveI18nLabel(label, 'toString')).toBe('Owner'); |
245 | 354 | }); |
246 | 355 |
|
247 | | - it('treats a non-string value as absent on EVERY limb, not just limbs 3 and 6', () => { |
| 356 | + it('treats a non-string value as absent on EVERY limb, now on BOTH sides', () => { |
248 | 357 | // Off-spec: `InlineLocaleMapSchema` is `z.record(<tag>, z.string())`, so no |
249 | 358 | // in-contract map can hold this. The cast is what makes that explicit. |
250 | 359 | const offSpec = { 'zh-CN': { nested: 'x' }, en: 'Owner' } as unknown as I18nLabel; |
251 | 360 |
|
252 | | - // The reference filters by `typeof === 'string'` on limbs 3 and 6 but not |
253 | | - // on 1/2/4/5, so an exact-tag hit short-circuits and gets stringified. |
254 | | - expect(pickLocalizedReference(offSpec, 'zh-CN')).toBe('[object Object]'); |
255 | | - |
256 | | - // PD#12: the producer is wrong; the consumer must not coerce `[object |
257 | | - // Object]` onto a screen. The filter is uniform, so the limb is a miss and |
258 | | - // the chain continues. |
| 361 | + // Before objectui#3907 the reference filtered by `typeof === 'string'` on |
| 362 | + // limbs 3 and 6 but not on 1/2/4/5, so an exact-tag hit short-circuited |
| 363 | + // and got stringified as `[object Object]`. The filter is uniform on both |
| 364 | + // sides now, so the limb is a miss and the chain continues to `en`. |
| 365 | + expect(pickLocalizedReference(offSpec, 'zh-CN')).toBe('Owner'); |
259 | 366 | expect(resolveI18nLabel(offSpec, 'zh-CN')).toBe('Owner'); |
260 | 367 | }); |
261 | 368 |
|
262 | | - it('refuses an off-contract scalar rather than stringifying it', () => { |
263 | | - // `pickLocalized` accepts `unknown` and stringifies numbers/booleans. This |
264 | | - // resolver's parameter is the declared `I18nLabel`, so the shapes below are |
265 | | - // type errors — the `@ts-expect-error` directives immediately after are the |
266 | | - // real guard. This asserts the runtime half: no coerced `'42'` label. |
| 369 | + it('refuses an off-contract scalar rather than stringifying it — the one departure objectui#3907 did NOT touch', () => { |
| 370 | + // `pickLocalized` accepts `unknown` and stringifies numbers/booleans |
| 371 | + // (`pickLocalized(42, 'en')` is `'42'`). This resolver's parameter is the |
| 372 | + // declared `I18nLabel`, so the shapes below are type errors — the |
| 373 | + // `@ts-expect-error` directives immediately after are the real guard. |
| 374 | + // This asserts the runtime half: no coerced `'42'` label. objectui#3907 / |
| 375 | + // PR objectui#4359 hardened the MAP limbs only; it never touched this |
| 376 | + // top-level VALUE-parameter case, so it is the one departure that |
| 377 | + // survives the sync (objectstack#7864). |
267 | 378 | // @ts-expect-error a number is not an `I18nLabel` — off-spec input is refused, not coerced |
268 | 379 | expect(resolveI18nLabel(42, 'en')).toBeUndefined(); |
269 | 380 | // @ts-expect-error a boolean is not an `I18nLabel` |
|
0 commit comments