@open-mercato/shared 0.7.1-develop.7181.1.702cedc42c → 0.7.1-develop.7182.1.789943f937

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/lib/i18n/config.js.map +2 -2
  3. package/dist/lib/i18n/context.js +14 -3
  4. package/dist/lib/i18n/context.js.map +2 -2
  5. package/dist/lib/i18n/locale-label.js +34 -0
  6. package/dist/lib/i18n/locale-label.js.map +7 -0
  7. package/dist/lib/i18n/locale-registry.js +73 -0
  8. package/dist/lib/i18n/locale-registry.js.map +7 -0
  9. package/dist/lib/i18n/locale-set.js +54 -0
  10. package/dist/lib/i18n/locale-set.js.map +7 -0
  11. package/dist/lib/i18n/locale.js +8 -8
  12. package/dist/lib/i18n/locale.js.map +2 -2
  13. package/dist/lib/i18n/server.js +22 -7
  14. package/dist/lib/i18n/server.js.map +3 -3
  15. package/dist/lib/testing/renderWithProviders.js +2 -2
  16. package/dist/lib/testing/renderWithProviders.js.map +2 -2
  17. package/dist/lib/version.js +1 -1
  18. package/dist/lib/version.js.map +1 -1
  19. package/package.json +2 -2
  20. package/src/lib/i18n/__tests__/__fixtures__/locale-augmentation-missing-key.ts +18 -0
  21. package/src/lib/i18n/__tests__/__fixtures__/locale-augmentation-ok.ts +28 -0
  22. package/src/lib/i18n/__tests__/__fixtures__/locale-unaugmented.ts +5 -0
  23. package/src/lib/i18n/__tests__/context-supported-locales.test.tsx +134 -0
  24. package/src/lib/i18n/__tests__/detect-locale-narrowed.test.ts +109 -0
  25. package/src/lib/i18n/__tests__/dictionary-locale-fallback.test.ts +110 -0
  26. package/src/lib/i18n/__tests__/locale-augmentation.test.ts +56 -0
  27. package/src/lib/i18n/__tests__/locale-label.test.ts +141 -0
  28. package/src/lib/i18n/__tests__/locale-registry.test.ts +267 -0
  29. package/src/lib/i18n/config.ts +38 -1
  30. package/src/lib/i18n/config.typecheck.tsx +60 -0
  31. package/src/lib/i18n/context.tsx +30 -3
  32. package/src/lib/i18n/locale-label.ts +80 -0
  33. package/src/lib/i18n/locale-registry.ts +142 -0
  34. package/src/lib/i18n/locale-set.ts +98 -0
  35. package/src/lib/i18n/locale.ts +22 -6
  36. package/src/lib/i18n/server.ts +51 -7
  37. package/src/lib/testing/renderWithProviders.tsx +4 -2
@@ -0,0 +1,28 @@
1
+ // Fixture: what a downstream application writes to serve a language the
2
+ // platform does not ship. Compiled by `locale-augmentation.test.ts`, never by
3
+ // the package typecheck (tsconfig excludes `__tests__`), because the
4
+ // augmentation would otherwise widen `Locale` for the whole package.
5
+ import type { Locale } from '../../config'
6
+
7
+ declare module '../../config' {
8
+ interface LocaleRegistry {
9
+ cs: true
10
+ }
11
+ }
12
+
13
+ // The new code is now a valid Locale — no patching of node_modules required.
14
+ export const appAddedLocale: Locale = 'cs'
15
+
16
+ // The shipped ones still are.
17
+ export const shippedLocale: Locale = 'pl'
18
+
19
+ // And exhaustiveness is preserved over the app's OWN extended set: this map
20
+ // compiles only because it covers all six.
21
+ export const labels: Record<Locale, string> = {
22
+ en: 'English',
23
+ pl: 'Polski',
24
+ es: 'Español',
25
+ de: 'Deutsch',
26
+ ko: '한국어',
27
+ cs: 'čeština',
28
+ }
@@ -0,0 +1,5 @@
1
+ // Fixture: with no augmentation in the program, an unshipped code must still be
2
+ // rejected — the zero-config guarantee. Expected to produce a diagnostic.
3
+ import type { Locale } from '../../config'
4
+
5
+ export const unregistered: Locale = 'cs'
@@ -0,0 +1,134 @@
1
+ import * as React from 'react'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+ import { I18nProvider, useLocaleLocked, useSupportedLocales } from '../context'
4
+ import { clearRegisteredLocales, registerLocales } from '../locale-registry'
5
+ import type { Locale } from '../config'
6
+
7
+ function LocaleList() {
8
+ const supported = useSupportedLocales()
9
+ return React.createElement('span', null, supported.join(','))
10
+ }
11
+
12
+ function LocaleLockedFlag() {
13
+ return React.createElement('span', null, String(useLocaleLocked()))
14
+ }
15
+
16
+ function render(props: { locale: Locale; dict: {}; supportedLocales?: readonly Locale[] }) {
17
+ return renderToStaticMarkup(
18
+ React.createElement(I18nProvider, props, React.createElement(LocaleList)),
19
+ )
20
+ }
21
+
22
+ describe('useSupportedLocales', () => {
23
+ afterEach(() => {
24
+ clearRegisteredLocales()
25
+ })
26
+
27
+ it('falls back to the shipped set when the provider is given no prop', () => {
28
+ // Every existing test mounts `<I18nProvider locale dict />` with no
29
+ // `supportedLocales`; that must keep working and keep today's list.
30
+ expect(render({ locale: 'en', dict: {} })).toContain('en,pl,es,de,ko')
31
+ })
32
+
33
+ it('reflects locales the app registered at runtime', () => {
34
+ registerLocales(['cs'])
35
+
36
+ expect(render({ locale: 'en', dict: {} })).toContain('en,pl,es,de,ko,cs')
37
+ })
38
+
39
+ it('prefers the server-resolved prop over the process-local registry', () => {
40
+ // This is the whole point of the prop: a client bundle cannot read tenant
41
+ // configuration, so the server narrows the set and hands it down.
42
+ registerLocales(['cs'])
43
+
44
+ const markup = render({
45
+ locale: 'en',
46
+ dict: {},
47
+ supportedLocales: ['en', 'pl'] as readonly Locale[],
48
+ })
49
+
50
+ expect(markup).toContain('en,pl')
51
+ expect(markup).not.toContain('cs')
52
+ })
53
+
54
+ it('carries a locale the platform does not ship down to the client', () => {
55
+ const markup = render({
56
+ locale: 'cs' as Locale,
57
+ dict: {},
58
+ supportedLocales: ['en', 'cs'] as readonly Locale[],
59
+ })
60
+
61
+ expect(markup).toContain('en,cs')
62
+ })
63
+
64
+ it('returns the shipped set outside any provider', () => {
65
+ expect(renderToStaticMarkup(React.createElement(LocaleList))).toContain('en,pl,es,de,ko')
66
+ })
67
+
68
+ it('inherits the served set from an enclosing provider when the prop is omitted', () => {
69
+ // The backend layout mounts a second provider inside the root layout's. An
70
+ // omitted prop there must mean "unchanged", not "reset to the registry",
71
+ // or the whole admin subtree silently loses the tenant narrowing.
72
+ const markup = renderToStaticMarkup(
73
+ React.createElement(
74
+ I18nProvider,
75
+ { locale: 'en' as Locale, dict: {}, supportedLocales: ['en', 'pl'] as readonly Locale[] },
76
+ React.createElement(
77
+ I18nProvider,
78
+ { locale: 'en' as Locale, dict: {} },
79
+ React.createElement(LocaleList),
80
+ ),
81
+ ),
82
+ )
83
+
84
+ expect(markup).toContain('en,pl')
85
+ expect(markup).not.toContain('es')
86
+ })
87
+
88
+ it('lets an inner provider override the inherited set', () => {
89
+ const markup = renderToStaticMarkup(
90
+ React.createElement(
91
+ I18nProvider,
92
+ { locale: 'en' as Locale, dict: {}, supportedLocales: ['en', 'pl'] as readonly Locale[] },
93
+ React.createElement(
94
+ I18nProvider,
95
+ { locale: 'en' as Locale, dict: {}, supportedLocales: ['en', 'de'] as readonly Locale[] },
96
+ React.createElement(LocaleList),
97
+ ),
98
+ ),
99
+ )
100
+
101
+ expect(markup).toContain('en,de')
102
+ })
103
+ })
104
+
105
+ describe('useLocaleLocked', () => {
106
+ it('inherits the locked flag from an enclosing provider when the prop is omitted', () => {
107
+ const markup = renderToStaticMarkup(
108
+ React.createElement(
109
+ I18nProvider,
110
+ { locale: 'en' as Locale, dict: {}, localeLocked: true },
111
+ React.createElement(
112
+ I18nProvider,
113
+ { locale: 'en' as Locale, dict: {} },
114
+ React.createElement(LocaleLockedFlag),
115
+ ),
116
+ ),
117
+ )
118
+
119
+ expect(markup).toContain('true')
120
+ })
121
+
122
+ it('is false outside any provider and for a provider that sets nothing', () => {
123
+ expect(renderToStaticMarkup(React.createElement(LocaleLockedFlag))).toContain('false')
124
+ expect(
125
+ renderToStaticMarkup(
126
+ React.createElement(
127
+ I18nProvider,
128
+ { locale: 'en' as Locale, dict: {} },
129
+ React.createElement(LocaleLockedFlag),
130
+ ),
131
+ ),
132
+ ).toContain('false')
133
+ })
134
+ })
@@ -0,0 +1,109 @@
1
+ import type { Locale } from '../config'
2
+
3
+ // `detectLocale` reaches for `next/headers` through a dynamic import, so the
4
+ // mock has to be in place before the module under test is loaded.
5
+ const cookieStore = { value: undefined as string | undefined }
6
+ const headerStore = { acceptLanguage: '' }
7
+
8
+ jest.mock(
9
+ 'next/headers',
10
+ () => ({
11
+ cookies: async () => ({
12
+ get: (name: string) =>
13
+ name === 'locale' && cookieStore.value ? { value: cookieStore.value } : undefined,
14
+ }),
15
+ headers: async () => ({
16
+ get: (name: string) =>
17
+ name.toLowerCase() === 'accept-language' ? headerStore.acceptLanguage : null,
18
+ }),
19
+ }),
20
+ { virtual: true },
21
+ )
22
+
23
+ import { detectLocale } from '../server'
24
+ import { clearRegisteredLocales, registerLocales } from '../locale-registry'
25
+
26
+ const NARROWED: readonly Locale[] = ['pl', 'de']
27
+
28
+ describe('detectLocale with a narrowed supported set', () => {
29
+ beforeEach(() => {
30
+ cookieStore.value = undefined
31
+ headerStore.acceptLanguage = ''
32
+ delete process.env.OM_FORCE_LOCALE
33
+ })
34
+
35
+ afterEach(() => {
36
+ clearRegisteredLocales()
37
+ })
38
+
39
+ it('honours a cookie that is inside the narrowed set', async () => {
40
+ cookieStore.value = 'de'
41
+
42
+ await expect(detectLocale({ supportedLocales: NARROWED })).resolves.toBe('de')
43
+ })
44
+
45
+ it('ignores a cookie that the tenant has since deselected', async () => {
46
+ cookieStore.value = 'es'
47
+ headerStore.acceptLanguage = 'pl-PL,pl;q=0.9'
48
+
49
+ await expect(detectLocale({ supportedLocales: NARROWED })).resolves.toBe('pl')
50
+ })
51
+
52
+ it('ignores an Accept-Language match outside the narrowed set', async () => {
53
+ headerStore.acceptLanguage = 'es-ES,es;q=0.9'
54
+
55
+ // `es` is a shipped locale, so `resolveLocaleFromAcceptLanguage` matches it;
56
+ // the narrowed set is what rejects it.
57
+ await expect(detectLocale({ supportedLocales: NARROWED })).resolves.not.toBe('es')
58
+ })
59
+
60
+ it('falls through to a lower-ranked header entry that is inside the set', async () => {
61
+ // Header ranks `es` first, but the tenant does not serve it. Matching the
62
+ // header against the process-wide set and re-checking afterwards would
63
+ // discard the whole header on the `es` match and land on the default; the
64
+ // narrowed set has to reach the matcher itself for `de` to win.
65
+ headerStore.acceptLanguage = 'es-ES,es;q=0.9,de;q=0.8'
66
+
67
+ await expect(detectLocale({ supportedLocales: NARROWED })).resolves.toBe('de')
68
+ })
69
+
70
+ it('never returns a locale outside the set it was given', async () => {
71
+ // The regression this guards: the fallback used to be an unconditional
72
+ // `return defaultLocale`, which rendered an English page under a switcher
73
+ // offering only Polish and German.
74
+ headerStore.acceptLanguage = 'en-US,en;q=0.9'
75
+
76
+ const detected = await detectLocale({ supportedLocales: NARROWED })
77
+
78
+ expect(NARROWED).toContain(detected)
79
+ })
80
+
81
+ it('falls back to the default locale when it is in the set', async () => {
82
+ headerStore.acceptLanguage = 'fr-FR,fr;q=0.9'
83
+
84
+ await expect(detectLocale({ supportedLocales: ['en', 'pl'] })).resolves.toBe('en')
85
+ })
86
+
87
+ it('keeps the previous behaviour when no set is passed', async () => {
88
+ headerStore.acceptLanguage = 'es-ES,es;q=0.9'
89
+
90
+ await expect(detectLocale()).resolves.toBe('es')
91
+ })
92
+
93
+ it('still lets OM_FORCE_LOCALE win over the narrowed set', async () => {
94
+ process.env.OM_FORCE_LOCALE = 'ko'
95
+
96
+ try {
97
+ await expect(detectLocale({ supportedLocales: NARROWED })).resolves.toBe('ko')
98
+ } finally {
99
+ delete process.env.OM_FORCE_LOCALE
100
+ }
101
+ })
102
+
103
+ it('detects a locale the app registered but the platform does not ship', async () => {
104
+ registerLocales(['cs'])
105
+ cookieStore.value = 'cs'
106
+
107
+ await expect(detectLocale({ supportedLocales: ['en', 'cs'] as readonly Locale[] })).resolves.toBe('cs')
108
+ })
109
+ })
@@ -0,0 +1,110 @@
1
+ import {
2
+ loadDictionary,
3
+ registerModules,
4
+ registerAppDictionaryLoader,
5
+ invalidateDictionaryCache,
6
+ } from '../server'
7
+ import { clearRegisteredLocales, registerLocales } from '../locale-registry'
8
+ import type { Locale } from '../config'
9
+
10
+ // Dictionaries keyed by locale, standing in for the app's `i18n/<locale>.json`
11
+ // files. `cs` deliberately has no entry, which is the situation an operator
12
+ // creates by enabling a language nobody has translated yet.
13
+ const APP_DICTIONARIES: Record<string, Record<string, unknown>> = {
14
+ en: { greeting: 'Hello', onlyInEnglish: 'English only' },
15
+ pl: { greeting: 'Cześć' },
16
+ }
17
+
18
+ describe('dictionary fallback for locales the platform does not ship', () => {
19
+ beforeEach(() => {
20
+ clearRegisteredLocales()
21
+ registerModules([] as any)
22
+ registerAppDictionaryLoader(async (locale: Locale) => APP_DICTIONARIES[locale] ?? {})
23
+ invalidateDictionaryCache()
24
+ })
25
+
26
+ afterEach(() => {
27
+ clearRegisteredLocales()
28
+ invalidateDictionaryCache()
29
+ })
30
+
31
+ describe('shipped locales keep their exact previous behaviour', () => {
32
+ it('does not layer English underneath another shipped locale', async () => {
33
+ const pl = await loadDictionary('pl')
34
+
35
+ expect(pl).toEqual({ greeting: 'Cześć' })
36
+ // The key that exists only in English must NOT leak into Polish — that
37
+ // would be a behaviour change for locales that ship today.
38
+ expect(pl).not.toHaveProperty('onlyInEnglish')
39
+ })
40
+
41
+ it('leaves the default locale itself untouched', async () => {
42
+ await expect(loadDictionary('en')).resolves.toEqual({
43
+ greeting: 'Hello',
44
+ onlyInEnglish: 'English only',
45
+ })
46
+ })
47
+
48
+ it('returns an empty dictionary for a shipped locale with no strings', async () => {
49
+ await expect(loadDictionary('de')).resolves.toEqual({})
50
+ })
51
+ })
52
+
53
+ describe('an app-registered locale', () => {
54
+ it('falls back to the default locale instead of rendering raw keys', async () => {
55
+ registerLocales(['cs'])
56
+
57
+ await expect(loadDictionary('cs' as Locale)).resolves.toEqual({
58
+ greeting: 'Hello',
59
+ onlyInEnglish: 'English only',
60
+ })
61
+ })
62
+
63
+ it('overlays its own translations on top of the default ones', async () => {
64
+ registerLocales(['cs'])
65
+ APP_DICTIONARIES.cs = { greeting: 'Ahoj' }
66
+
67
+ try {
68
+ await expect(loadDictionary('cs' as Locale)).resolves.toEqual({
69
+ greeting: 'Ahoj',
70
+ onlyInEnglish: 'English only',
71
+ })
72
+ } finally {
73
+ delete APP_DICTIONARIES.cs
74
+ }
75
+ })
76
+
77
+ it('lets module translations win over the default-locale base layer', async () => {
78
+ registerLocales(['cs'])
79
+ registerModules([{ translations: { cs: { greeting: 'Ahoj z modulu' } } }] as any)
80
+
81
+ const cs = await loadDictionary('cs' as Locale)
82
+
83
+ expect(cs.greeting).toBe('Ahoj z modulu')
84
+ expect(cs.onlyInEnglish).toBe('English only')
85
+ })
86
+
87
+ it('is still memoized per locale', async () => {
88
+ registerLocales(['cs'])
89
+
90
+ const first = await loadDictionary('cs' as Locale)
91
+ const second = await loadDictionary('cs' as Locale)
92
+
93
+ expect(first).toBe(second)
94
+ })
95
+
96
+ it('does not mutate the default-locale dictionary it copies from', async () => {
97
+ registerLocales(['cs'])
98
+ APP_DICTIONARIES.cs = { greeting: 'Ahoj' }
99
+
100
+ try {
101
+ await loadDictionary('cs' as Locale)
102
+ const en = await loadDictionary('en')
103
+
104
+ expect(en.greeting).toBe('Hello')
105
+ } finally {
106
+ delete APP_DICTIONARIES.cs
107
+ }
108
+ })
109
+ })
110
+ })
@@ -0,0 +1,56 @@
1
+ import * as path from 'node:path'
2
+ import * as ts from 'typescript'
3
+
4
+ // The extension point is a TYPE-level contract, so the only honest way to test
5
+ // it is to compile a downstream-app fixture and read the diagnostics. Jest runs
6
+ // with `isolatedModules: true` and therefore never type-checks, and the package
7
+ // typecheck cannot cover these fixtures either: a `declare module` augmentation
8
+ // is program-global, so including them would widen `Locale` for every other file
9
+ // in the package (and break `config.typecheck.tsx`). Hence a real, isolated
10
+ // `ts.createProgram` per fixture.
11
+ const FIXTURES_DIR = path.join(__dirname, '__fixtures__')
12
+
13
+ // Mirrors `tsconfig.base.json`; only the options that affect these diagnostics.
14
+ const COMPILER_OPTIONS: ts.CompilerOptions = {
15
+ target: ts.ScriptTarget.ES2022,
16
+ module: ts.ModuleKind.ESNext,
17
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
18
+ strict: true,
19
+ skipLibCheck: true,
20
+ noEmit: true,
21
+ esModuleInterop: true,
22
+ }
23
+
24
+ function compile(fixture: string): ts.Diagnostic[] {
25
+ const program = ts.createProgram([path.join(FIXTURES_DIR, fixture)], COMPILER_OPTIONS)
26
+ return [...program.getSemanticDiagnostics(), ...program.getSyntacticDiagnostics()]
27
+ }
28
+
29
+ function messages(diagnostics: ts.Diagnostic[]): string[] {
30
+ return diagnostics.map((d) => ts.flattenDiagnosticMessageText(d.messageText, ' '))
31
+ }
32
+
33
+ describe('extending Locale from a downstream application', () => {
34
+ it('lets an app add a locale the platform does not ship, with no patching', () => {
35
+ const diagnostics = compile('locale-augmentation-ok.ts')
36
+
37
+ expect(messages(diagnostics)).toEqual([])
38
+ })
39
+
40
+ it('still rejects an unshipped code when the app has NOT opted in', () => {
41
+ const diagnostics = compile('locale-unaugmented.ts')
42
+
43
+ expect(diagnostics).toHaveLength(1)
44
+ expect(messages(diagnostics)[0]).toContain('"cs"')
45
+ })
46
+
47
+ it('preserves exhaustiveness over the app’s own extended set', () => {
48
+ // An app that widens Locale keeps its drift guard: a Record<Locale, …> that
49
+ // omits the locale it just added must not compile. This is the property that
50
+ // widening `Locale` to `string` would have silently destroyed.
51
+ const diagnostics = compile('locale-augmentation-missing-key.ts')
52
+
53
+ expect(diagnostics).toHaveLength(1)
54
+ expect(messages(diagnostics)[0]).toContain('cs')
55
+ })
56
+ })
@@ -0,0 +1,141 @@
1
+ import { resolveLocaleLabel } from '../locale-label'
2
+ import type { TranslateFn } from '../context'
3
+ import * as fs from 'node:fs'
4
+ import * as path from 'node:path'
5
+
6
+ // Walks the transitive *value* imports of a module (`import type` excluded, since
7
+ // those vanish at compile time) and returns the module basenames reached. A
8
+ // source scan rather than a `require.cache` diff because Jest's module registry
9
+ // is already warm from this file's own imports by the time any test runs.
10
+ function collectValueImports(entry: string, from = __dirname, seen = new Set<string>()): string[] {
11
+ const resolved = path.resolve(from, `${entry}.ts`)
12
+ const basename = path.basename(resolved, '.ts')
13
+ if (seen.has(basename) || !fs.existsSync(resolved)) return [...seen]
14
+ seen.add(basename)
15
+
16
+ const source = fs.readFileSync(resolved, 'utf8')
17
+ const importPattern = /^import\s+(?!type\s)(?:[^'"]*?\sfrom\s+)?['"](\.[^'"]+)['"]/gm
18
+ for (const match of source.matchAll(importPattern)) {
19
+ collectValueImports(match[1]!, path.dirname(resolved), seen)
20
+ }
21
+ return [...seen]
22
+ }
23
+
24
+ // Mirrors how `LanguageSwitcher` and `PayPage` call it: a real translator that
25
+ // falls back to the inline default when the key is absent from the dictionary.
26
+ function makeTranslator(dict: Record<string, string> = {}): TranslateFn {
27
+ return ((key: string, fallbackOrParams?: unknown) => {
28
+ const fallback = typeof fallbackOrParams === 'string' ? fallbackOrParams : undefined
29
+ return dict[key] ?? fallback ?? key
30
+ }) as TranslateFn
31
+ }
32
+
33
+ describe('resolveLocaleLabel', () => {
34
+ describe('shipped locales without a translator (ProfileDropdown behaviour)', () => {
35
+ // These are the exact strings `ProfileDropdown` rendered from its hardcoded
36
+ // `Record<Locale, string>` before it was replaced — a visual regression guard.
37
+ it.each([
38
+ ['en', 'English'],
39
+ ['de', 'Deutsch'],
40
+ ['es', 'Español'],
41
+ ['pl', 'Polski'],
42
+ ['ko', '한국어'],
43
+ ])('renders %s as the endonym %s', (locale, expected) => {
44
+ expect(resolveLocaleLabel(locale)).toBe(expected)
45
+ })
46
+ })
47
+
48
+ describe('shipped locales with a translator (LanguageSwitcher behaviour)', () => {
49
+ // The exact `t(key, fallback)` pairs the switcher used before the refactor.
50
+ it.each([
51
+ ['en', 'common.languages.english', 'English'],
52
+ ['pl', 'common.languages.polish', 'Polski'],
53
+ ['es', 'common.languages.spanish', 'Español'],
54
+ ['de', 'common.languages.german', 'Deutsch'],
55
+ ['ko', 'common.languages.korean', '한국어'],
56
+ ])('asks for %s via %s and falls back to %s', (locale, key, fallback) => {
57
+ const seen: string[] = []
58
+ const t = ((k: string, f?: unknown) => {
59
+ seen.push(k)
60
+ return typeof f === 'string' ? f : k
61
+ }) as TranslateFn
62
+
63
+ expect(resolveLocaleLabel(locale, t)).toBe(fallback)
64
+ expect(seen).toEqual([key])
65
+ })
66
+
67
+ it('prefers the dictionary value, so a German UI shows localized names', () => {
68
+ const t = makeTranslator({
69
+ 'common.languages.polish': 'Polnisch',
70
+ 'common.languages.english': 'Englisch',
71
+ })
72
+
73
+ expect(resolveLocaleLabel('pl', t)).toBe('Polnisch')
74
+ expect(resolveLocaleLabel('en', t)).toBe('Englisch')
75
+ })
76
+ })
77
+
78
+ describe('locales the platform does not ship', () => {
79
+ it('falls back to the endonym from Intl', () => {
80
+ expect(resolveLocaleLabel('cs')).toBe('čeština')
81
+ expect(resolveLocaleLabel('fr')).toBe('français')
82
+ })
83
+
84
+ it('uses the endonym even when a translator is supplied', () => {
85
+ // There is no `common.languages.*` key for an app-added locale, so the
86
+ // translator cannot help and must not produce a raw key.
87
+ expect(resolveLocaleLabel('cs', makeTranslator())).toBe('čeština')
88
+ })
89
+
90
+ it('never renders blank or a raw key', () => {
91
+ for (const locale of ['cs', 'fr', 'zh', 'sw', 'zzz']) {
92
+ const label = resolveLocaleLabel(locale)
93
+ expect(label.length).toBeGreaterThan(0)
94
+ expect(label).not.toContain('common.languages')
95
+ }
96
+ })
97
+ })
98
+
99
+ describe('degradation ladder', () => {
100
+ it('falls back to the uppercased code for an unknown code', () => {
101
+ // `zzz` is neither an ISO 639-1 entry nor known to Intl.
102
+ expect(resolveLocaleLabel('zzz')).toBe('ZZZ')
103
+ })
104
+
105
+ it('falls back to the uppercased code when Intl has no data', () => {
106
+ const displayNames = jest
107
+ .spyOn(Intl, 'DisplayNames')
108
+ .mockImplementation((() => ({ of: () => undefined })) as unknown as typeof Intl.DisplayNames)
109
+
110
+ try {
111
+ // `za` (Zhuang) is in the ISO 639-1 catalogue, but this module
112
+ // deliberately does not consult it — see the import-graph test below.
113
+ expect(resolveLocaleLabel('za')).toBe('ZA')
114
+ } finally {
115
+ displayNames.mockRestore()
116
+ }
117
+ })
118
+
119
+ it('survives Intl throwing on a malformed code', () => {
120
+ expect(() => resolveLocaleLabel('!!not a tag!!')).not.toThrow()
121
+ expect(resolveLocaleLabel('!!not a tag!!')).toBe('!!NOT A TAG!!')
122
+ })
123
+ })
124
+
125
+ describe('client bundle weight', () => {
126
+ // Every caller of `resolveLocaleLabel` is a client component, including the
127
+ // public checkout pay page. `iso639.ts` is a 186-entry table with a
128
+ // module-scope `Set` that no bundler can tree-shake, so pulling it in here
129
+ // would ship the whole language catalogue to that route.
130
+ it('does not pull the ISO 639-1 catalogue into its import graph', () => {
131
+ expect(collectValueImports('../locale-label')).not.toContain('iso639')
132
+ })
133
+
134
+ // Guards the guard: a walker that silently found nothing would pass the
135
+ // assertion above for the wrong reason. `locale-registry` does import the
136
+ // catalogue, transitively proving the traversal reaches real edges.
137
+ it('detects the catalogue where it is genuinely imported', () => {
138
+ expect(collectValueImports('../locale-registry')).toContain('iso639')
139
+ })
140
+ })
141
+ })