|
3 | 3 | import { describe, it, expect } from 'vitest'; |
4 | 4 | import { |
5 | 5 | SpecifierType, |
| 6 | + SpecifierValueDomainSchema, |
6 | 7 | SpecifierSchema, |
7 | 8 | SettingsManifestSchema, |
8 | 9 | ResolvedSettingValueSchema, |
@@ -193,6 +194,170 @@ describe('SpecifierSchema — layout-only specifiers', () => { |
193 | 194 | }); |
194 | 195 | }); |
195 | 196 |
|
| 197 | +describe('SpecifierValueDomainSchema — the closed standard-domain vocabulary (#5933)', () => { |
| 198 | + it('accepts exactly the three domains with measured authoring pull', () => { |
| 199 | + for (const d of ['iana_time_zone', 'iso_4217_currency', 'iso_3166_alpha2']) { |
| 200 | + expect(() => SpecifierValueDomainSchema.parse(d)).not.toThrow(); |
| 201 | + } |
| 202 | + expect(SpecifierValueDomainSchema.options).toEqual([ |
| 203 | + 'iana_time_zone', |
| 204 | + 'iso_4217_currency', |
| 205 | + 'iso_3166_alpha2', |
| 206 | + ]); |
| 207 | + }); |
| 208 | + |
| 209 | + it('rejects `bcp47_locale` — the proposal member that was deliberately dropped', () => { |
| 210 | + // #5933's proposal listed a fourth member. Its only candidate key is |
| 211 | + // `localization.locale`, a `select` whose options ARE the shipped message |
| 212 | + // catalogs — a registry-backed table, so declaring a domain there would |
| 213 | + // LOOSEN it (options degrade to a suggestion list) and admit locales that |
| 214 | + // have no catalog. And BCP-47 has no membership registry to enforce |
| 215 | + // against: `Intl.getCanonicalLocales('xx-YY')` succeeds, so the "domain" |
| 216 | + // would only re-check syntax — exactly the weakness `pattern` already has |
| 217 | + // and this key exists to fix. It is not in the vocabulary; a manifest that |
| 218 | + // spells it is refused by name rather than silently stripped. |
| 219 | + expect(() => SpecifierValueDomainSchema.parse('bcp47_locale')).toThrow(); |
| 220 | + expect(() => Intl.getCanonicalLocales('xx-YY')).not.toThrow(); |
| 221 | + }); |
| 222 | + |
| 223 | + it('rejects unknown domains', () => { |
| 224 | + expect(() => SpecifierValueDomainSchema.parse('iso_9999_unicorn')).toThrow(); |
| 225 | + expect(() => SpecifierValueDomainSchema.parse('')).toThrow(); |
| 226 | + }); |
| 227 | +}); |
| 228 | + |
| 229 | +describe('Specifier.valueDomain (#5933)', () => { |
| 230 | + it('SURVIVES the parse — a dropped key is the defect this closes', () => { |
| 231 | + // #5712's dev measured the current shape by smuggling a `format` key in: |
| 232 | + // Zod stripped it and `parse()` returned `undefined`, so the manifest had |
| 233 | + // no way to say "the boundary is a standard". This assertion is that |
| 234 | + // measurement inverted, and it is the one that must never regress. |
| 235 | + const parsed = SpecifierSchema.parse({ |
| 236 | + type: 'select', |
| 237 | + key: 'timezone', |
| 238 | + label: 'Default timezone', |
| 239 | + valueDomain: 'iana_time_zone', |
| 240 | + options: [{ value: 'UTC', label: 'UTC' }], |
| 241 | + }); |
| 242 | + expect(parsed.valueDomain).toBe('iana_time_zone'); |
| 243 | + }); |
| 244 | + |
| 245 | + it('is optional — an undeclared specifier keeps #5131 exhaustive-options semantics', () => { |
| 246 | + const parsed = SpecifierSchema.parse({ |
| 247 | + type: 'select', |
| 248 | + key: 'provider', |
| 249 | + label: 'Provider', |
| 250 | + options: [{ value: 'smtp', label: 'SMTP' }], |
| 251 | + }); |
| 252 | + expect(parsed.valueDomain).toBeUndefined(); |
| 253 | + }); |
| 254 | + |
| 255 | + it('accepts a `text` specifier alongside pattern/length constraints', () => { |
| 256 | + // Shape and membership are independent narrowings and a value must satisfy |
| 257 | + // both — `^[A-Za-z]{2}$` is what admits `ZZ` today, which is why the domain |
| 258 | + // is added rather than the pattern replaced. |
| 259 | + const parsed = SpecifierSchema.parse({ |
| 260 | + type: 'text', |
| 261 | + key: 'default_country', |
| 262 | + label: 'Default country', |
| 263 | + valueDomain: 'iso_3166_alpha2', |
| 264 | + pattern: '^[A-Za-z]{2}$', |
| 265 | + minLength: 2, |
| 266 | + maxLength: 2, |
| 267 | + }); |
| 268 | + expect(parsed.valueDomain).toBe('iso_3166_alpha2'); |
| 269 | + expect(parsed.pattern).toBe('^[A-Za-z]{2}$'); |
| 270 | + }); |
| 271 | + |
| 272 | + it('still requires `options` on a select — the list degrades, it does not vanish', () => { |
| 273 | + expect(() => |
| 274 | + SpecifierSchema.parse({ |
| 275 | + type: 'select', |
| 276 | + key: 'currency', |
| 277 | + label: 'Default currency', |
| 278 | + valueDomain: 'iso_4217_currency', |
| 279 | + }) |
| 280 | + ).toThrow(/requires non-empty 'options'/); |
| 281 | + }); |
| 282 | + |
| 283 | + it('rejects a valueDomain on a layout-only specifier', () => { |
| 284 | + expect(() => |
| 285 | + SpecifierSchema.parse({ |
| 286 | + type: 'group', |
| 287 | + label: 'Region', |
| 288 | + valueDomain: 'iana_time_zone', |
| 289 | + }) |
| 290 | + ).toThrow(/carries no value, so it must not declare a 'valueDomain'/); |
| 291 | + }); |
| 292 | + |
| 293 | + it('rejects an unknown domain on an otherwise valid specifier', () => { |
| 294 | + expect(() => |
| 295 | + SpecifierSchema.parse({ |
| 296 | + type: 'text', |
| 297 | + key: 'default_country', |
| 298 | + label: 'Default country', |
| 299 | + valueDomain: 'iso_3166_alpha3', |
| 300 | + }) |
| 301 | + ).toThrow(); |
| 302 | + }); |
| 303 | +}); |
| 304 | + |
| 305 | +describe('valueDomain membership definitions — the measurements service-settings must implement', () => { |
| 306 | + // These pin the TSDoc on `SpecifierValueDomainSchema`. `packages/spec` does |
| 307 | + // not enforce a domain (Prime Directive #2) — but the two halves have to agree |
| 308 | + // on WHAT the domain is, and the obvious oracle is the wrong one for two of |
| 309 | + // the three. A doc nobody re-measures rots; these go red when it does. |
| 310 | + |
| 311 | + const probeTimeZone = (tz: string): boolean => { |
| 312 | + try { new Intl.DateTimeFormat('en-US', { timeZone: tz }); return true; } catch { return false; } |
| 313 | + }; |
| 314 | + |
| 315 | + it('iana_time_zone: the Intl.DateTimeFormat probe is the definition', () => { |
| 316 | + // Same shape as `isValidTimeZone` in |
| 317 | + // packages/core/src/security/resolve-authz-context.ts. |
| 318 | + for (const tz of ['UTC', 'Asia/Kolkata', 'Europe/Kyiv', 'Asia/Ho_Chi_Minh', 'US/Eastern', 'GMT', 'Asia/Shanghai']) { |
| 319 | + expect(probeTimeZone(tz)).toBe(true); |
| 320 | + } |
| 321 | + // And it does what `pattern` cannot: a shape-valid zone that does not exist. |
| 322 | + expect(probeTimeZone('Mars/Olympus')).toBe(false); |
| 323 | + }); |
| 324 | + |
| 325 | + it('iana_time_zone: Intl.supportedValuesOf is NOT the definition', () => { |
| 326 | + // Measured on the repo's Node 22 baseline: a CLDR canonical-name subset |
| 327 | + // that omits values this platform itself ships. If this ever goes green in |
| 328 | + // the other direction, ICU has changed — re-measure before relaxing the |
| 329 | + // TSDoc, do not just delete the assertion. |
| 330 | + const enumerated = Intl.supportedValuesOf('timeZone'); |
| 331 | + for (const shipped of ['UTC', 'Asia/Kolkata']) { |
| 332 | + expect(probeTimeZone(shipped)).toBe(true); |
| 333 | + expect(enumerated).not.toContain(shipped); |
| 334 | + } |
| 335 | + // It is not merely a subset — it renames: the zones above are present under |
| 336 | + // legacy spellings, so even a normalising membership test is not free. |
| 337 | + expect(enumerated).toContain('Asia/Calcutta'); |
| 338 | + }); |
| 339 | + |
| 340 | + it('iso_4217_currency: Intl.supportedValuesOf IS the definition', () => { |
| 341 | + const currencies = Intl.supportedValuesOf('currency'); |
| 342 | + // The nine curated localization options plus CHF, the canonical "valid but |
| 343 | + // not curated" value the degraded-options semantics must admit. |
| 344 | + for (const c of ['USD', 'EUR', 'GBP', 'JPY', 'CNY', 'INR', 'AUD', 'CAD', 'BRL', 'CHF']) { |
| 345 | + expect(currencies).toContain(c); |
| 346 | + } |
| 347 | + expect(currencies).not.toContain('XYZ'); |
| 348 | + }); |
| 349 | + |
| 350 | + it('iso_3166_alpha2: Intl.DisplayNames is NOT a membership oracle', () => { |
| 351 | + // The tempting test is "the display name differs from the input". It admits |
| 352 | + // `ZZ` — the exact value #5933 cites as slipping past `^[A-Za-z]{2}$` — and |
| 353 | + // `UK`, which is a CLDR alias and not an ISO 3166-1 code at all. The |
| 354 | + // enforcing side needs an explicit code list; that list is not spec's. |
| 355 | + const regionNames = new Intl.DisplayNames(['en'], { type: 'region' }); |
| 356 | + expect(regionNames.of('ZZ')).not.toBe('ZZ'); |
| 357 | + expect(regionNames.of('UK')).not.toBe('UK'); |
| 358 | + }); |
| 359 | +}); |
| 360 | + |
196 | 361 | describe('SettingsManifestSchema', () => { |
197 | 362 | const minimalManifest: SettingsManifest = { |
198 | 363 | namespace: 'mail', |
|
0 commit comments