|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * Rule parity between `resolveI18nLabel` and objectui's `pickLocalized`. |
| 5 | + * |
| 6 | + * The #6761 ruling's acceptance hinge is not the resolver's API shape but that |
| 7 | + * the two ends of the platform pick the SAME ENTRY out of the same inline |
| 8 | + * locale map. A comment claiming that is worth nothing: two resolvers that |
| 9 | + * drift apart render the same metadata differently on the two ends and NEITHER |
| 10 | + * SIDE ERRORS — the server-rendered column header and the client-rendered one |
| 11 | + * simply disagree, forever. So the parity is executed here, not asserted in |
| 12 | + * prose. |
| 13 | + * |
| 14 | + * ## How the expectations were derived |
| 15 | + * |
| 16 | + * `pickLocalizedReference` below is a VERBATIM copy of the reference |
| 17 | + * implementation, taken from |
| 18 | + * |
| 19 | + * repo objectstack-ai/objectui |
| 20 | + * path packages/i18n/src/pickLocalized.ts |
| 21 | + * rev origin/main 50fa3766ebb2ebf2ec78c5d13b1d627e6a91696f |
| 22 | + * blob 9e5d92ae2efe9be62d4d010cb0a26e598211f3ec |
| 23 | + * last touched by objectui#3278 (2026-08-03) |
| 24 | + * |
| 25 | + * Copied rather than imported because `@objectstack/spec` must not take a |
| 26 | + * workspace dependency on objectui — spec sits UNDER objectui in the dependency |
| 27 | + * order, and inverting that to buy a test fixture would be a far worse trade |
| 28 | + * than copying 20 lines. The copy is what makes each `pickLocalized` column |
| 29 | + * below a MEASUREMENT instead of the author's recollection: every vector is |
| 30 | + * asserted against the reference first (`the reference really answers this`), |
| 31 | + * and only then against `resolveI18nLabel`. |
| 32 | + * |
| 33 | + * ⛔ `pickLocalizedReference` is a test fixture. It is not exported from this |
| 34 | + * file, and nothing under `src/` may import it — the whole point of #6765 is |
| 35 | + * that the repo has ONE resolver, not a private copy per consumer (PD#12). If |
| 36 | + * you find yourself wanting to call it from production code, you want |
| 37 | + * `resolveI18nLabel`. |
| 38 | + * |
| 39 | + * ## Keeping it honest when objectui moves |
| 40 | + * |
| 41 | + * The copy is pinned to a revision, so it cannot silently follow objectui. |
| 42 | + * If `pickLocalized` changes there, this file goes stale rather than wrong: |
| 43 | + * re-read the source at the new revision, update the copy AND the pin above, |
| 44 | + * and let the vectors say whether the rule moved. A vector that flips is a |
| 45 | + * two-repo decision, not a number to re-record. |
| 46 | + */ |
| 47 | + |
| 48 | +import { describe, it, expect } from 'vitest'; |
| 49 | +import { resolveI18nLabel } from './i18n-label-resolver'; |
| 50 | +import type { I18nLabel } from './i18n.zod'; |
| 51 | + |
| 52 | +// --------------------------------------------------------------------------- |
| 53 | +// The reference implementation — verbatim, see the header for provenance. Only |
| 54 | +// the NAME differs, so that a reader of a failing assertion can tell at a glance |
| 55 | +// which side is the copy. |
| 56 | +function pickLocalizedReference(value: unknown, language: string | undefined | null): string { |
| 57 | + if (value == null) return ''; |
| 58 | + if (typeof value === 'string') return value; |
| 59 | + if (typeof value === 'number' || typeof value === 'boolean') return String(value); |
| 60 | + if (typeof value === 'object') { |
| 61 | + const o = value as Record<string, unknown>; |
| 62 | + const lang = (language || 'en').trim(); |
| 63 | + const base = lang.split('-')[0]; |
| 64 | + // Runtime language is often a bare base code ('zh') while metadata authors |
| 65 | + // 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'); |
| 67 | + 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 | + } |
| 76 | + return String(value); |
| 77 | +} |
| 78 | +// --------------------------------------------------------------------------- |
| 79 | + |
| 80 | +interface ParityVector { |
| 81 | + /** What this vector demonstrates — the limb of the rule it exercises. */ |
| 82 | + readonly limb: string; |
| 83 | + readonly label: I18nLabel | undefined; |
| 84 | + readonly locale: string | undefined; |
| 85 | + /** The reference's answer. Asserted against the reference itself below. */ |
| 86 | + readonly pick: string; |
| 87 | +} |
| 88 | + |
| 89 | +/** |
| 90 | + * One table, both ends. Every row is an input `I18nLabelSchema` accepts (or the |
| 91 | + * absence of one), so every row is inside the declared domain where parity is |
| 92 | + * total. |
| 93 | + */ |
| 94 | +const PARITY_VECTORS: readonly ParityVector[] = [ |
| 95 | + // Form 1 — the plain string. |
| 96 | + { limb: '0 plain string passes through', label: 'Owner', locale: 'zh-CN', pick: 'Owner' }, |
| 97 | + { limb: '0 an empty string is a label the author wrote', label: '', locale: 'zh-CN', pick: '' }, |
| 98 | + |
| 99 | + // Limb 1 — exact tag. |
| 100 | + { limb: '1 exact tag', label: { en: 'Owner', 'zh-CN': '负责人' }, locale: 'zh-CN', pick: '负责人' }, |
| 101 | + { limb: '1 exact tag (source language)', label: { en: 'Owner', 'zh-CN': '负责人' }, locale: 'en', pick: 'Owner' }, |
| 102 | + { |
| 103 | + limb: '1 exact tag beats an earlier sibling sharing the base', |
| 104 | + label: { 'zh-TW': '擁有者', 'zh-CN': '负责人' }, |
| 105 | + locale: 'zh-CN', |
| 106 | + pick: '负责人', |
| 107 | + }, |
| 108 | + |
| 109 | + // Limb 2 — region request, base key (`zh-CN` → `zh`). |
| 110 | + { limb: '2 region → base', label: { en: 'Owner', zh: '负责人' }, locale: 'zh-CN', pick: '负责人' }, |
| 111 | + { limb: '2 region → base, multi-subtag tag', label: { en: 'Owner', zh: '负责人' }, locale: 'zh-Hans-CN', pick: '负责人' }, |
| 112 | + |
| 113 | + // Limb 3 — base request, region key (`zh` → `zh-CN`). |
| 114 | + { limb: '3 base → region', label: { en: 'Owner', 'zh-CN': '负责人' }, locale: 'zh', pick: '负责人' }, |
| 115 | + { limb: '3 base → region (ja)', label: { en: 'Owner', 'ja-JP': '所有者' }, locale: 'ja', pick: '所有者' }, |
| 116 | + { limb: '3 base key wins over the region upgrade', label: { zh: '基础', 'zh-CN': '区域' }, locale: 'zh', pick: '基础' }, |
| 117 | + { |
| 118 | + limb: '3 first sibling in key order wins, not the "best" region', |
| 119 | + label: { 'zh-TW': '擁有者', 'zh-CN': '负责人' }, |
| 120 | + locale: 'zh', |
| 121 | + pick: '擁有者', |
| 122 | + }, |
| 123 | + { |
| 124 | + limb: '3 runs BEFORE default — a wrong-region hit beats the untagged entry', |
| 125 | + label: { default: 'Owner', 'fr-FR': 'Propriétaire' }, |
| 126 | + locale: 'fr', |
| 127 | + pick: 'Propriétaire', |
| 128 | + }, |
| 129 | + |
| 130 | + // Case sensitivity, both halves of the tag. See the module doc on the |
| 131 | + // resolver: this asymmetry is the reference's rule, pinned as-is. |
| 132 | + { |
| 133 | + limb: '3 the REGION subtag\'s case does not matter (only the base is compared)', |
| 134 | + label: { 'zh-CN': '负责人' }, |
| 135 | + locale: 'zh-cn', |
| 136 | + pick: '负责人', |
| 137 | + }, |
| 138 | + { |
| 139 | + limb: '5 the LANGUAGE subtag\'s case DOES — `ZH-CN` matches nothing and lands on `en`', |
| 140 | + label: { 'zh-CN': '负责人', en: 'Owner' }, |
| 141 | + locale: 'ZH-CN', |
| 142 | + pick: 'Owner', |
| 143 | + }, |
| 144 | + |
| 145 | + // Limb 4 / 5 / 6 — the named fallbacks, then any string at all. |
| 146 | + { limb: '4 default', label: { default: 'D', en: 'E' }, locale: 'fr', pick: 'D' }, |
| 147 | + { limb: '5 en', label: { en: 'E', ja: 'J' }, locale: 'fr', pick: 'E' }, |
| 148 | + { limb: '6 first string value', label: { ja: 'J' }, locale: 'fr', pick: 'J' }, |
| 149 | + { limb: '6 first string value, in key order', label: { ja: 'J', ko: 'K' }, locale: 'fr', pick: 'J' }, |
| 150 | + |
| 151 | + // Locale normalization — `(locale || 'en').trim()`, no case folding. |
| 152 | + { limb: 'norm undefined locale ⇒ en', label: { en: 'E', 'zh-CN': 'Z' }, locale: undefined, pick: 'E' }, |
| 153 | + { limb: 'norm empty locale ⇒ en', label: { en: 'E', 'zh-CN': 'Z' }, locale: '', pick: 'E' }, |
| 154 | + { limb: 'norm surrounding whitespace is trimmed', label: { en: 'E', 'zh-CN': 'Z' }, locale: ' zh-CN ', pick: 'Z' }, |
| 155 | + { limb: 'norm undefined locale still reaches limb 6', label: { 'zh-CN': 'Z' }, locale: undefined, pick: 'Z' }, |
| 156 | + |
| 157 | + // An entry whose VALUE is empty is still a hit — the reference's `??` chain |
| 158 | + // does not skip `''`, and neither may this one. |
| 159 | + { limb: '1 an empty value is a hit, not a miss', label: { en: '', 'zh-CN': '负责人' }, locale: 'en', pick: '' }, |
| 160 | + { limb: '5 an empty `en` is a hit, not a miss', label: { en: '' }, locale: 'fr', pick: '' }, |
| 161 | + |
| 162 | + // The miss cases. The reference spells "nothing was picked" as `''`. |
| 163 | + { limb: 'miss empty map', label: {}, locale: 'zh-CN', pick: '' }, |
| 164 | + { limb: 'miss absent label', label: undefined, locale: 'zh-CN', pick: '' }, |
| 165 | +]; |
| 166 | + |
| 167 | +describe('resolveI18nLabel — rule parity with objectui pickLocalized (#6765 / #6761 ruling B)', () => { |
| 168 | + describe('the vector table really is the reference\'s behaviour', () => { |
| 169 | + it.each(PARITY_VECTORS)('$limb', ({ label, locale, pick }) => { |
| 170 | + // Asserted against the copied reference FIRST. If this row is wrong, the |
| 171 | + // parity assertion below would be comparing `resolveI18nLabel` to the |
| 172 | + // author's recollection instead of to objectui. |
| 173 | + expect(pickLocalizedReference(label, locale)).toBe(pick); |
| 174 | + }); |
| 175 | + }); |
| 176 | + |
| 177 | + describe('resolveI18nLabel picks the same entry', () => { |
| 178 | + it.each(PARITY_VECTORS)('$limb', ({ label, locale, pick }) => { |
| 179 | + // The identity the two spellings of "nothing was picked" are bridged by. |
| 180 | + // `?? ''` is the ONLY difference between the two functions inside the |
| 181 | + // declared domain — everything else is the same limb, in the same order. |
| 182 | + expect(resolveI18nLabel(label, locale) ?? '').toBe(pick); |
| 183 | + }); |
| 184 | + }); |
| 185 | + |
| 186 | + it('every vector agrees limb for limb, in one pass', () => { |
| 187 | + const disagreements = PARITY_VECTORS.filter( |
| 188 | + (v) => (resolveI18nLabel(v.label, v.locale) ?? '') !== pickLocalizedReference(v.label, v.locale), |
| 189 | + ).map((v) => v.limb); |
| 190 | + expect(disagreements).toEqual([]); |
| 191 | + }); |
| 192 | +}); |
| 193 | + |
| 194 | +describe('resolveI18nLabel — the producer-facing return shape', () => { |
| 195 | + // Why this is not `''`: downstream enrichment in the producing direction is |
| 196 | + // guarded by `if (field.label == null)`, so a producer that wrote `''` would |
| 197 | + // not be writing "no label" — it would permanently displace the real label a |
| 198 | + // later stage still had (#5199 route A, judged harmful rather than |
| 199 | + // redundant; restated in #6761). |
| 200 | + it('answers `undefined` — not `\'\'` — when the label is absent', () => { |
| 201 | + expect(resolveI18nLabel(undefined, 'zh-CN')).toBeUndefined(); |
| 202 | + }); |
| 203 | + |
| 204 | + it('answers `undefined` when no limb matched', () => { |
| 205 | + expect(resolveI18nLabel({}, 'zh-CN')).toBeUndefined(); |
| 206 | + }); |
| 207 | + |
| 208 | + it('answers `\'\'` when the author really wrote an empty label', () => { |
| 209 | + // A hit is a hit. This is the case a `''` miss value would be |
| 210 | + // indistinguishable from, which is why the miss is `undefined`. |
| 211 | + expect(resolveI18nLabel('', 'zh-CN')).toBe(''); |
| 212 | + expect(resolveI18nLabel({ en: '' }, 'en')).toBe(''); |
| 213 | + }); |
| 214 | + |
| 215 | + it('composes with `??` into the producer call shape #6761 needs', () => { |
| 216 | + // `dataset-compiler.ts:374/406` today: `typeof d.label === 'string' ? d.label : d.name`, |
| 217 | + // which publishes the MACHINE NAME as a display title for a map label. |
| 218 | + const dimension = { name: 'owner', label: { en: 'Owner', 'zh-CN': '负责人' } as I18nLabel }; |
| 219 | + expect(resolveI18nLabel(dimension.label, 'zh-CN') ?? dimension.name).toBe('负责人'); |
| 220 | + |
| 221 | + const unlabelled = { name: 'owner', label: undefined }; |
| 222 | + expect(resolveI18nLabel(unlabelled.label, 'zh-CN') ?? unlabelled.name).toBe('owner'); |
| 223 | + }); |
| 224 | +}); |
| 225 | + |
| 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', () => { |
| 233 | + const label: I18nLabel = { en: 'Owner' }; |
| 234 | + |
| 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. |
| 243 | + expect(resolveI18nLabel(label, 'constructor')).toBe('Owner'); |
| 244 | + expect(resolveI18nLabel(label, 'toString')).toBe('Owner'); |
| 245 | + }); |
| 246 | + |
| 247 | + it('treats a non-string value as absent on EVERY limb, not just limbs 3 and 6', () => { |
| 248 | + // Off-spec: `InlineLocaleMapSchema` is `z.record(<tag>, z.string())`, so no |
| 249 | + // in-contract map can hold this. The cast is what makes that explicit. |
| 250 | + const offSpec = { 'zh-CN': { nested: 'x' }, en: 'Owner' } as unknown as I18nLabel; |
| 251 | + |
| 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. |
| 259 | + expect(resolveI18nLabel(offSpec, 'zh-CN')).toBe('Owner'); |
| 260 | + }); |
| 261 | + |
| 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. |
| 267 | + // @ts-expect-error a number is not an `I18nLabel` — off-spec input is refused, not coerced |
| 268 | + expect(resolveI18nLabel(42, 'en')).toBeUndefined(); |
| 269 | + // @ts-expect-error a boolean is not an `I18nLabel` |
| 270 | + expect(resolveI18nLabel(true, 'en')).toBeUndefined(); |
| 271 | + }); |
| 272 | +}); |
| 273 | + |
| 274 | +describe('resolveI18nLabel — the type signature refuses the calls that caused #6761', () => { |
| 275 | + // Reverse verification at the type level. `check:test-typecheck` compiles this |
| 276 | + // file (packages/spec/tsconfig.test.json), so each directive below is a REAL |
| 277 | + // check: delete the argument it guards and tsc goes red on the unused |
| 278 | + // `@ts-expect-error` instead of letting the call through. |
| 279 | + |
| 280 | + it('rejects a map whose values are not strings', () => { |
| 281 | + // @ts-expect-error `InlineLocaleMap` values are strings; a number is not a label |
| 282 | + const bad: I18nLabel = { en: 42 }; |
| 283 | + expect(resolveI18nLabel(bad, 'en')).toBeUndefined(); |
| 284 | + }); |
| 285 | + |
| 286 | + it('rejects the call that forgets the locale', () => { |
| 287 | + // The defect #6761 records is a producer shipping ONE audience's language to |
| 288 | + // every audience. `locale` is positional rather than optional precisely so |
| 289 | + // that omitting it cannot compile. |
| 290 | + // @ts-expect-error `locale` is required positionally — a producer must decide it |
| 291 | + expect(resolveI18nLabel({ en: 'Owner' })).toBe('Owner'); |
| 292 | + }); |
| 293 | + |
| 294 | + it('accepts both authorized forms, and an absent label', () => { |
| 295 | + const plain: I18nLabel = 'All Active'; |
| 296 | + const inline: I18nLabel = { en: 'All Active', 'zh-CN': '全部活跃' }; |
| 297 | + expect(resolveI18nLabel(plain, 'zh-CN')).toBe('All Active'); |
| 298 | + expect(resolveI18nLabel(inline, 'zh-CN')).toBe('全部活跃'); |
| 299 | + expect(resolveI18nLabel(undefined, 'zh-CN')).toBeUndefined(); |
| 300 | + }); |
| 301 | +}); |
0 commit comments