@usequeek/theme-kit 0.1.22 → 0.1.24
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.
- package/CHANGELOG.md +37 -0
- package/LICENSE +1 -1
- package/README.md +275 -26
- package/api/collections.ts +14 -1
- package/api/orders.ts +15 -1
- package/apps/follow-card.tsx +5 -0
- package/apps/subscribe-form.tsx +2 -2
- package/components/auth/auth-flow.tsx +12 -0
- package/components/cart/cart-panel-controller.tsx +13 -0
- package/components/checkout/default-checkout-shell.tsx +5 -0
- package/components/checkout/policy-links.tsx +4 -0
- package/components/image.tsx +10 -0
- package/components/language-menu.css +349 -0
- package/components/language-menu.tsx +371 -0
- package/components/language-switcher.tsx +40 -8
- package/components/powered-by-queek.tsx +4 -3
- package/components/product-media-frame.tsx +5 -0
- package/components/product-metafields.tsx +9 -0
- package/components/variant-picker.css +4 -7
- package/edit-mode.tsx +4 -3
- package/hooks/use-address-autocomplete.ts +9 -0
- package/hooks/use-auth-flow.tsx +7 -12
- package/hooks/use-auth.ts +10 -0
- package/hooks/use-cart.ts +12 -0
- package/hooks/use-categories.ts +16 -2
- package/hooks/use-href.ts +9 -4
- package/hooks/use-posts.ts +16 -8
- package/hooks/use-product-detail.ts +25 -11
- package/hooks/use-product-questions.ts +16 -3
- package/hooks/use-products.ts +44 -22
- package/hooks/use-related-products.ts +17 -5
- package/hooks/use-resolve-coordinates.ts +9 -0
- package/hooks/use-reviews.ts +16 -2
- package/hooks/use-search.ts +10 -0
- package/hooks/use-shop-cart.ts +8 -0
- package/hooks/use-shop.ts +19 -6
- package/hooks/use-variant-selection.ts +23 -19
- package/index.ts +10 -11
- package/locales/en.default.json +4 -0
- package/navigation.tsx +2 -0
- package/package.json +25 -4
- package/page-renderer.tsx +11 -0
- package/provider.tsx +29 -3
- package/sdk/client.ts +183 -36
- package/sdk/error-handler.ts +17 -9
- package/sdk/queek-client.ts +11 -1
- package/sdk/server-store-client.ts +2 -2
- package/shared-blocks/embed.tsx +7 -3
- package/shared-blocks/manifest.ts +1 -1
- package/shared-blocks/product-qa.tsx +1 -1
- package/stores/address-modal-store.ts +1 -0
- package/stores/auth-modal-store.ts +1 -0
- package/stores/cart-panel-store.ts +1 -0
- package/stores/cart-store.ts +12 -7
- package/stores/checkout-store.ts +4 -4
- package/stores/location-store.ts +5 -0
- package/stores/orders-sheet-store.ts +1 -0
- package/stores/product-modal-store.ts +1 -0
- package/stores/search-modal-store.ts +1 -0
- package/stores/user-store.ts +1 -1
- package/strings/pseudo-locale.ts +1 -1
- package/theme-context.tsx +11 -1
- package/theme-mount.tsx +14 -0
- package/utils/format.ts +10 -1
- package/utils/menu-link.ts +8 -0
- package/utils/menu-placement.ts +91 -0
- package/utils/to-error.ts +4 -0
- package/utils/vendor-host.ts +1 -2
- package/scripts/verify-package.ts +0 -102
- package/tests/apps.test.tsx +0 -33
- package/tests/auth-email-errors.test.tsx +0 -55
- package/tests/currency-contract.test.ts +0 -85
- package/tests/default-metaobject-page.test.tsx +0 -56
- package/tests/display-format.test.ts +0 -403
- package/tests/fixtures/external-theme.tsx +0 -85
- package/tests/image-loading.test.tsx +0 -31
- package/tests/language-switcher.test.tsx +0 -350
- package/tests/list-pagination.test.tsx +0 -71
- package/tests/locale-switch.test.ts +0 -197
- package/tests/locale-transport.test.tsx +0 -321
- package/tests/manifest-parity.test.ts +0 -54
- package/tests/markdown-render.test.ts +0 -46
- package/tests/metaobjects-block.test.tsx +0 -70
- package/tests/navigation.test.tsx +0 -30
- package/tests/orders-walk.test.ts +0 -48
- package/tests/page-renderer-signature.test.tsx +0 -42
- package/tests/powered-by-queek.test.tsx +0 -65
- package/tests/powered-by-url.test.ts +0 -49
- package/tests/product-images.test.ts +0 -242
- package/tests/product-metafields.test.tsx +0 -154
- package/tests/public-surface.test.ts +0 -35
- package/tests/shop-query.test.ts +0 -64
- package/tests/shopper-chrome-strings.test.tsx +0 -271
- package/tests/storefront-client-scope.test.ts +0 -43
- package/tests/theme-hook-placement.test.tsx +0 -179
- package/tests/theme-strings-client.test.tsx +0 -310
- package/tests/theme-strings-pseudo.test.tsx +0 -171
- package/tests/theme-strings-typed.test.tsx +0 -97
- package/tests/theme-strings.test.tsx +0 -401
- package/tests/theme-typed-keys.fixture.ts +0 -76
- package/tests/use-shop-prefetch.test.tsx +0 -87
package/tests/apps.test.tsx
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import React from 'react';
|
|
2
|
-
import { renderToStaticMarkup } from 'react-dom/server';
|
|
3
|
-
import { describe, expect, it } from 'vitest';
|
|
4
|
-
import * as apps from '../apps';
|
|
5
|
-
import { AppBlocks, AppBlocksProvider } from '../apps';
|
|
6
|
-
|
|
7
|
-
describe('apps — the theme-facing app-block slot', () => {
|
|
8
|
-
it('is exactly the provider, the slot, and the shared types', () => {
|
|
9
|
-
expect(Object.keys(apps).sort()).toEqual(['AppBlocks', 'AppBlocksProvider']);
|
|
10
|
-
});
|
|
11
|
-
|
|
12
|
-
it('renders nothing without a host registration (SSR-safe)', () => {
|
|
13
|
-
const html = renderToStaticMarkup(
|
|
14
|
-
React.createElement(AppBlocks, { target: 'product', productId: 'p1' }),
|
|
15
|
-
);
|
|
16
|
-
|
|
17
|
-
expect(html).toBe('');
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
it('renders whatever the host registered, with the slot props', () => {
|
|
21
|
-
function HostBlocks({ target, productId }: { target: 'product'; productId?: string }) {
|
|
22
|
-
return <div data-host-blocks={`${target}:${productId ?? ''}`} />;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
const html = renderToStaticMarkup(
|
|
26
|
-
<AppBlocksProvider renderBlocks={HostBlocks}>
|
|
27
|
-
<AppBlocks target="product" productId="p1" vendorSlug="glow" />
|
|
28
|
-
</AppBlocksProvider>,
|
|
29
|
-
);
|
|
30
|
-
|
|
31
|
-
expect(html).toContain('data-host-blocks="product:p1"');
|
|
32
|
-
});
|
|
33
|
-
});
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Auth error English pins.
|
|
3
|
-
*
|
|
4
|
-
* `mapEmailError` maps SDK failure codes to `t('auth.error.*')` strings, and
|
|
5
|
-
* three more error keys are set directly in the provider. Every English value
|
|
6
|
-
* below is resolved through the REAL default dictionary, so renaming a key or
|
|
7
|
-
* changing any English value fails here.
|
|
8
|
-
*/
|
|
9
|
-
import { describe, expect, it } from 'vitest';
|
|
10
|
-
import { QueekSdkError } from '@queekai/client-sdk';
|
|
11
|
-
import { t, defaultThemeStrings } from '../strings/theme-strings';
|
|
12
|
-
import { mapEmailError } from '../hooks/use-auth-flow';
|
|
13
|
-
|
|
14
|
-
const tt = (key: string): string => t(defaultThemeStrings, key);
|
|
15
|
-
|
|
16
|
-
function sdkError(code: string, message: string): QueekSdkError {
|
|
17
|
-
return new QueekSdkError(message, code, 400);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
describe('mapEmailError code -> English pins', () => {
|
|
21
|
-
it.each([
|
|
22
|
-
['invalid_credentials', 'Wrong email or password. Please try again.'],
|
|
23
|
-
['login_mode_mismatch', 'This account uses a different sign-in method (Google or phone).'],
|
|
24
|
-
['account_already_exists', 'Email already registered. Please sign in instead.'],
|
|
25
|
-
['phone_already_exists', 'Phone number already in use.'],
|
|
26
|
-
['otp_resend_too_soon', 'Please wait before requesting another code.'],
|
|
27
|
-
['invalid_otp', 'Incorrect code. Please try again.'],
|
|
28
|
-
] as Array<[string, string]>)('code %s maps to its exact English', (code, english) => {
|
|
29
|
-
expect(mapEmailError(sdkError(code, 'server message'), tt)).toBe(english);
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
it('passes an unknown-code server message through untouched', () => {
|
|
33
|
-
expect(mapEmailError(sdkError('something_else', 'Server says no.'), tt)).toBe('Server says no.');
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
it('falls back to generic English when the server message is empty', () => {
|
|
37
|
-
expect(mapEmailError(sdkError('something_else', ''), tt)).toBe('Something went wrong. Please try again.');
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it('falls back to generic English for non-SDK failures', () => {
|
|
41
|
-
expect(mapEmailError(new Error('boom'), tt)).toBe('Something went wrong. Please try again.');
|
|
42
|
-
expect(mapEmailError('boom', tt)).toBe('Something went wrong. Please try again.');
|
|
43
|
-
});
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
describe('direct-set auth.error.* English pins', () => {
|
|
47
|
-
it.each([
|
|
48
|
-
['auth.error.firstname', 'Please enter your first name.'],
|
|
49
|
-
['auth.error.register', 'Registration failed. Try again.'],
|
|
50
|
-
['auth.error.google', 'Could not connect to Google sign-in. Please try again.'],
|
|
51
|
-
['auth.error.generic', 'Something went wrong. Please try again.'],
|
|
52
|
-
] as Array<[string, string]>)('%s resolves to its exact English', (key, english) => {
|
|
53
|
-
expect(tt(key)).toBe(english);
|
|
54
|
-
});
|
|
55
|
-
});
|
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
-
import { join, relative, resolve } from 'node:path';
|
|
3
|
-
import { describe, expect, it } from 'vitest';
|
|
4
|
-
import { formatMoney, normalizeCurrencyCode, PLATFORM_DEFAULT_CURRENCY } from '../utils/format';
|
|
5
|
-
import { extractProducts, normalizeProduct } from '../utils/product-normalizer';
|
|
6
|
-
import { useCartStore } from '../stores/cart-store';
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Client readers do not use `meta.currency`. List-level `meta.currency` stamps the PLATFORM default (NGN) even on
|
|
10
|
-
* non-NGN stores, so the item's own `currency` code is the source and the
|
|
11
|
-
* vendor's currency is the store-wide source. Missing codes fall back to the
|
|
12
|
-
* platform default as a LAST resort, in exactly one place.
|
|
13
|
-
*/
|
|
14
|
-
describe('currency resolution', () => {
|
|
15
|
-
it('exposes NGN as the platform default last resort', () => {
|
|
16
|
-
expect(PLATFORM_DEFAULT_CURRENCY).toBe('NGN');
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
it('normalizeCurrencyCode keeps valid item/vendor codes', () => {
|
|
20
|
-
expect(normalizeCurrencyCode('USD')).toBe('USD');
|
|
21
|
-
expect(normalizeCurrencyCode('ngn')).toBe('NGN');
|
|
22
|
-
expect(normalizeCurrencyCode(' eur ')).toBe('EUR');
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
it('normalizeCurrencyCode falls back instead of passing garbage through', () => {
|
|
26
|
-
expect(normalizeCurrencyCode(undefined)).toBe('NGN');
|
|
27
|
-
expect(normalizeCurrencyCode(null)).toBe('NGN');
|
|
28
|
-
expect(normalizeCurrencyCode('')).toBe('NGN');
|
|
29
|
-
expect(normalizeCurrencyCode('US')).toBe('NGN');
|
|
30
|
-
expect(normalizeCurrencyCode('USDD')).toBe('NGN');
|
|
31
|
-
expect(normalizeCurrencyCode(42)).toBe('NGN');
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it('normalizeCurrencyCode honors an explicit vendor fallback', () => {
|
|
35
|
-
expect(normalizeCurrencyCode(undefined, 'USD')).toBe('USD');
|
|
36
|
-
expect(normalizeCurrencyCode('', 'eur')).toBe('EUR');
|
|
37
|
-
expect(normalizeCurrencyCode(undefined, 'bogus')).toBe('NGN');
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it('normalizeProduct keeps the item currency, ignoring list-level meta', () => {
|
|
41
|
-
const envelope = {
|
|
42
|
-
data: [{ id: 'p1', title: 'Tote', price: 5000, currency: 'usd' }],
|
|
43
|
-
meta: { currency: { code: 'NGN', symbol: '₦' } },
|
|
44
|
-
};
|
|
45
|
-
const [raw] = extractProducts(envelope);
|
|
46
|
-
expect(normalizeProduct(raw).currency).toBe('USD');
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it('normalizeProduct falls back to the platform default when the item has none', () => {
|
|
50
|
-
const [raw] = extractProducts({ data: [{ id: 'p2', title: 'Cap', price: 1000 }] });
|
|
51
|
-
expect(normalizeProduct(raw).currency).toBe('NGN');
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it('formatMoney formats through the item code, not a wired symbol', () => {
|
|
55
|
-
expect(formatMoney(12500)).toBe('₦12,500');
|
|
56
|
-
const usd = formatMoney(5000, 'USD');
|
|
57
|
-
expect(usd).toContain('5,000');
|
|
58
|
-
expect(usd).not.toContain('₦');
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it('cart-store currency starts at the platform default and normalizes vendor init', () => {
|
|
62
|
-
expect(useCartStore.getState().currency).toBe('NGN');
|
|
63
|
-
useCartStore.getState().setCurrency('usd');
|
|
64
|
-
expect(useCartStore.getState().currency).toBe('USD');
|
|
65
|
-
useCartStore.getState().setCurrency('');
|
|
66
|
-
expect(useCartStore.getState().currency).toBe('NGN');
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it('no kit source reads list-level meta.currency', () => {
|
|
70
|
-
const ROOT = resolve(__dirname, '..');
|
|
71
|
-
const walk = (dir: string): string[] => readdirSync(dir).flatMap((entry) => {
|
|
72
|
-
const path = join(dir, entry);
|
|
73
|
-
if (entry === 'node_modules' || entry === 'tests' || entry.startsWith('.')) return [];
|
|
74
|
-
if (statSync(path).isDirectory()) return walk(path);
|
|
75
|
-
return /\.(ts|tsx)$/.test(entry) ? [path] : [];
|
|
76
|
-
});
|
|
77
|
-
const offenders = walk(ROOT).flatMap((file) => {
|
|
78
|
-
const source = readFileSync(file, 'utf8');
|
|
79
|
-
// Real reads use `?.` or a `.meta` access (`payload?.meta?.currency`,
|
|
80
|
-
// `data.meta.currency`); prose comments naming the contract don't count.
|
|
81
|
-
return /meta\?\.\s*currency|\.meta\.currency/.test(source) ? [relative(ROOT, file)] : [];
|
|
82
|
-
});
|
|
83
|
-
expect(offenders).toEqual([]);
|
|
84
|
-
});
|
|
85
|
-
});
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
import React from 'react';
|
|
2
|
-
import { renderToStaticMarkup } from 'react-dom/server';
|
|
3
|
-
import { describe, expect, it } from 'vitest';
|
|
4
|
-
import { DefaultMetaobjectPage } from '../components/default-metaobject-page';
|
|
5
|
-
import type { MetaobjectEntry } from '../types/product';
|
|
6
|
-
import type { MetaobjectPageDefinition } from '../types/theme';
|
|
7
|
-
|
|
8
|
-
function render(fields: MetaobjectEntry['fields'], fieldDefs: MetaobjectPageDefinition['fields']): string {
|
|
9
|
-
const entry: MetaobjectEntry = {
|
|
10
|
-
p_id: 1,
|
|
11
|
-
type: 'designer',
|
|
12
|
-
handle: 'adaeze',
|
|
13
|
-
display_name: 'Adaeze',
|
|
14
|
-
fields,
|
|
15
|
-
};
|
|
16
|
-
const definition: MetaobjectPageDefinition = { type: 'designer', name: 'Designer', fields: fieldDefs };
|
|
17
|
-
|
|
18
|
-
return renderToStaticMarkup(React.createElement(DefaultMetaobjectPage, { entry, definition }));
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
describe('DefaultMetaobjectPage', () => {
|
|
22
|
-
it('renders the entry display name as the title', () => {
|
|
23
|
-
const html = render({}, []);
|
|
24
|
-
expect(html).toContain('core-metaobject-page__title');
|
|
25
|
-
expect(html).toContain('Adaeze');
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
it('renders a text field with its definition label', () => {
|
|
29
|
-
const html = render({ bio: 'Lagos-based designer' }, [{ key: 'bio', name: 'Biography', type: 'single_line_text' }]);
|
|
30
|
-
expect(html).toContain('Biography');
|
|
31
|
-
expect(html).toContain('Lagos-based designer');
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it('falls back to a humanised key label when no definition field matches', () => {
|
|
35
|
-
const html = render({ years_active: '10' }, []);
|
|
36
|
-
expect(html).toContain('Years active');
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it('renders a boolean field as Yes/No', () => {
|
|
40
|
-
const html = render({ verified: true }, [{ key: 'verified', name: 'Verified', type: 'boolean' }]);
|
|
41
|
-
expect(html).toContain('>Yes<');
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it('renders a url field as a link', () => {
|
|
45
|
-
const html = render({ portfolio: 'https://example.com/adaeze' }, [{ key: 'portfolio', name: 'Portfolio', type: 'url' }]);
|
|
46
|
-
expect(html).toContain('<a');
|
|
47
|
-
expect(html).toContain('href="https://example.com/adaeze"');
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it('renders a list field as bullets', () => {
|
|
51
|
-
const html = render({ tags: ['couture', 'bridal'] }, [{ key: 'tags', name: 'Tags', type: 'list.single_line_text' }]);
|
|
52
|
-
expect(html).toContain('<ul');
|
|
53
|
-
expect(html).toContain('couture');
|
|
54
|
-
expect(html).toContain('bridal');
|
|
55
|
-
});
|
|
56
|
-
});
|
|
@@ -1,403 +0,0 @@
|
|
|
1
|
-
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
-
import { join, relative, resolve } from 'node:path';
|
|
3
|
-
import { describe, expect, it } from 'vitest';
|
|
4
|
-
import { DISPLAY_LOCALE, DISPLAY_TIME_ZONE, formatCount, formatDisplayDate, formatMoney, formatRelativeTime, formatShopperDate } from '../utils/format';
|
|
5
|
-
import { ENGLISH_DISPLAY_LOCALE, resolveIntlLocale, resolveSupportedLocale } from '../utils/locale';
|
|
6
|
-
import { formatThemeDate, formatThemeMoney, formatThemeNumber, formatThemeRelativeTime } from '../strings/theme-strings';
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Storefront pages render on the server first. A date formatted with the
|
|
10
|
-
* runtime's default locale printed "Feb 14, 2026" on the server (Node: en-US,
|
|
11
|
-
* UTC) and "14 Feb 2026" in a Nigerian browser; React then failed hydration
|
|
12
|
-
* (#418) and re-rendered the whole page on the client — seen live on every
|
|
13
|
-
* store page with a reviews block. Everything the kit displays is formatted in
|
|
14
|
-
* one fixed locale and zone instead.
|
|
15
|
-
*/
|
|
16
|
-
describe('display formatting is the same on the server and in the browser', () => {
|
|
17
|
-
it('uses one fixed locale and zone', () => {
|
|
18
|
-
expect(DISPLAY_LOCALE).toBe('en-NG');
|
|
19
|
-
expect(DISPLAY_TIME_ZONE).toBe('Africa/Lagos');
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
it('formats dates in Lagos time, whatever the machine is set to', () => {
|
|
23
|
-
expect(formatDisplayDate('2026-02-14T10:00:00Z')).toBe('14 Feb 2026');
|
|
24
|
-
// 23:30 UTC is already the 15th in Lagos (UTC+1) — the server (UTC) and the
|
|
25
|
-
// browser (Lagos) used to print different days for this review.
|
|
26
|
-
expect(formatDisplayDate('2026-02-14T23:30:00Z')).toBe('15 Feb 2026');
|
|
27
|
-
expect(formatDisplayDate('2026-02-14', {})).toBe('14/02/2026');
|
|
28
|
-
// English is British/Nigerian order, never US order — the locale parameter
|
|
29
|
-
// is routed through resolveIntlLocale, so even an explicit 'en'/'en-US'
|
|
30
|
-
// renders "15 February 2026", not "February 15, 2026".
|
|
31
|
-
expect(formatDisplayDate('2026-02-14T23:30:00Z', { year: 'numeric', month: 'long', day: 'numeric' }, 'en-US')).toBe('15 February 2026');
|
|
32
|
-
expect(formatDisplayDate('2026-02-14T23:30:00Z', { year: 'numeric', month: 'long', day: 'numeric' }, 'en')).toBe('15 February 2026');
|
|
33
|
-
expect(formatDisplayDate('not a date')).toBe('');
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
it('formats counts and money with fixed grouping', () => {
|
|
37
|
-
expect(formatCount(1234567)).toBe('1,234,567');
|
|
38
|
-
expect(formatMoney(12500)).toBe('₦12,500');
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
it('no kit source formats with the runtime default locale', () => {
|
|
42
|
-
const ROOT = resolve(__dirname, '..');
|
|
43
|
-
const walk = (dir: string): string[] => readdirSync(dir).flatMap((entry) => {
|
|
44
|
-
const path = join(dir, entry);
|
|
45
|
-
if (entry === 'node_modules' || entry === 'tests' || entry.startsWith('.')) return [];
|
|
46
|
-
if (statSync(path).isDirectory()) return walk(path);
|
|
47
|
-
return /\.(ts|tsx)$/.test(entry) ? [path] : [];
|
|
48
|
-
});
|
|
49
|
-
const offenders = walk(ROOT).flatMap((file) => {
|
|
50
|
-
const source = readFileSync(file, 'utf8');
|
|
51
|
-
return /\.toLocale(?:Date|Time)?String\(\s*(?:undefined\s*[,)]|\))/.test(source) ? [relative(ROOT, file)] : [];
|
|
52
|
-
});
|
|
53
|
-
expect(offenders).toEqual([]);
|
|
54
|
-
});
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Dates and numbers follow the SHOPPER's locale, with English in the
|
|
59
|
-
* British/Nigerian style ("2 October 2026", never US "October 2, 2026").
|
|
60
|
-
*/
|
|
61
|
-
describe('canonical locale mapping (shopper locale -> Intl locale)', () => {
|
|
62
|
-
const LONG_LAGOS = { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'Africa/Lagos' } as const;
|
|
63
|
-
|
|
64
|
-
it('maps every English variant and every missing/invalid code to en-NG', () => {
|
|
65
|
-
expect(ENGLISH_DISPLAY_LOCALE).toBe('en-NG');
|
|
66
|
-
expect(DISPLAY_LOCALE).toBe(ENGLISH_DISPLAY_LOCALE);
|
|
67
|
-
for (const locale of [null, undefined, 'en', 'en-GB', 'en-NG', 'en-US', 'not a locale!!', '']) {
|
|
68
|
-
expect(resolveIntlLocale(locale), String(locale)).toBe('en-NG');
|
|
69
|
-
}
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it('passes every other well-formed code through unchanged', () => {
|
|
73
|
-
for (const code of ['fr', 'ar', 'pt-BR', 'zh-CN', 'yo', 'ha', 'sw', 'es', 'de', 'ig']) {
|
|
74
|
-
expect(resolveIntlLocale(code)).toBe(code);
|
|
75
|
-
}
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
it('evidence: en-NG is the mapping that reproduces current English output byte for byte', () => {
|
|
79
|
-
// Dates are identical under en-GB and en-NG …
|
|
80
|
-
expect(new Date('2026-10-02T10:00:00Z').toLocaleDateString('en-GB', { ...LONG_LAGOS })).toBe('2 October 2026');
|
|
81
|
-
expect(new Date('2026-10-02T10:00:00Z').toLocaleDateString('en-NG', { ...LONG_LAGOS })).toBe('2 October 2026');
|
|
82
|
-
// … but money is NOT: only en-NG keeps the naira sign the stores show.
|
|
83
|
-
expect(
|
|
84
|
-
new Intl.NumberFormat('en-NG', { style: 'currency', currency: 'NGN', minimumFractionDigits: 0, maximumFractionDigits: 2 }).format(12500),
|
|
85
|
-
).toBe('₦12,500');
|
|
86
|
-
expect(
|
|
87
|
-
new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'NGN', minimumFractionDigits: 0, maximumFractionDigits: 2 }).format(12500),
|
|
88
|
-
).not.toBe('₦12,500');
|
|
89
|
-
// So the mapping pins the sign the pinned tests assert.
|
|
90
|
-
expect(formatMoney(12500)).toBe('₦12,500');
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
it('falls back to English when the runtime ICU lacks the locale, and never throws', () => {
|
|
94
|
-
expect(resolveSupportedLocale('fr', (l) => Intl.DateTimeFormat.supportedLocalesOf(l))).toBe('fr');
|
|
95
|
-
expect(resolveSupportedLocale('xx', (l) => Intl.DateTimeFormat.supportedLocalesOf(l))).toBe('en-NG');
|
|
96
|
-
expect(resolveSupportedLocale('fr', () => { throw new Error('no ICU'); })).toBe('en-NG');
|
|
97
|
-
});
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
describe('English output is byte-identical with or without a locale', () => {
|
|
101
|
-
const LONG_LAGOS: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'Africa/Lagos' };
|
|
102
|
-
|
|
103
|
-
it('format.ts helpers render the pinned strings for null/en', () => {
|
|
104
|
-
for (const locale of [undefined, null, 'en'] as const) {
|
|
105
|
-
expect(formatShopperDate('2026-02-14T10:00:00Z', locale)).toBe('14 Feb 2026');
|
|
106
|
-
expect(formatShopperDate('2026-02-14T23:30:00Z', locale, LONG_LAGOS)).toBe('15 February 2026');
|
|
107
|
-
expect(formatCount(1234567, locale)).toBe('1,234,567');
|
|
108
|
-
expect(formatMoney(12500, 'NGN', locale)).toBe('₦12,500');
|
|
109
|
-
expect(formatMoney(0.28, 'NGN', locale)).toBe('₦0.28');
|
|
110
|
-
expect(formatRelativeTime(-1, 'day', locale)).toBe('yesterday');
|
|
111
|
-
}
|
|
112
|
-
expect(formatRelativeTime(-3, 'day')).toBe('3 days ago');
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
it('formatTheme* helpers render English in the British/Nigerian style', () => {
|
|
116
|
-
for (const locale of [undefined, null, 'en'] as const) {
|
|
117
|
-
expect(formatThemeNumber(1234567, locale)).toBe('1,234,567');
|
|
118
|
-
expect(formatThemeDate('2026-02-14T23:30:00Z', locale, LONG_LAGOS)).toBe('15 February 2026');
|
|
119
|
-
expect(formatThemeMoney(5000, 'NGN', locale)).toBe('₦5,000.00');
|
|
120
|
-
expect(formatThemeRelativeTime(-1, 'day', locale)).toBe('yesterday');
|
|
121
|
-
}
|
|
122
|
-
});
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
describe('shopper locales format through real Intl output', () => {
|
|
126
|
-
const LONG_LAGOS: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'Africa/Lagos' };
|
|
127
|
-
const dateSupported = (code: string): boolean => {
|
|
128
|
-
try {
|
|
129
|
-
return Intl.DateTimeFormat.supportedLocalesOf([code]).length > 0;
|
|
130
|
-
} catch {
|
|
131
|
-
return false;
|
|
132
|
-
}
|
|
133
|
-
};
|
|
134
|
-
const numberSupported = (code: string): boolean => {
|
|
135
|
-
try {
|
|
136
|
-
return Intl.NumberFormat.supportedLocalesOf([code]).length > 0;
|
|
137
|
-
} catch {
|
|
138
|
-
return false;
|
|
139
|
-
}
|
|
140
|
-
};
|
|
141
|
-
const relativeSupported = (code: string): boolean => {
|
|
142
|
-
try {
|
|
143
|
-
return Intl.RelativeTimeFormat.supportedLocalesOf([code]).length > 0;
|
|
144
|
-
} catch {
|
|
145
|
-
return false;
|
|
146
|
-
}
|
|
147
|
-
};
|
|
148
|
-
|
|
149
|
-
// Expected output on Node 22 with full ICU; each row falls back to the
|
|
150
|
-
// English style when the runtime's ICU lacks the locale.
|
|
151
|
-
it.each([
|
|
152
|
-
['fr', '15 février 2026'],
|
|
153
|
-
['ar', '15 فبراير 2026'],
|
|
154
|
-
['pt-BR', '15 de fevereiro de 2026'],
|
|
155
|
-
['zh-CN', '2026年2月15日'],
|
|
156
|
-
['yo', '15 Oṣù Èrèlè 2026'],
|
|
157
|
-
['ha', '15 Faburairu, 2026'],
|
|
158
|
-
['sw', '15 Februari 2026'],
|
|
159
|
-
])('long date in %s (Lagos day boundary preserved)', (code, native) => {
|
|
160
|
-
expect(formatThemeDate('2026-02-14T23:30:00Z', code, LONG_LAGOS)).toBe(
|
|
161
|
-
dateSupported(code) ? native : '15 February 2026',
|
|
162
|
-
);
|
|
163
|
-
// Same through the fixed-zone display path post-meta uses.
|
|
164
|
-
expect(formatShopperDate('2026-02-14T23:30:00Z', code, LONG_LAGOS)).toBe(
|
|
165
|
-
dateSupported(code) ? native : '15 February 2026',
|
|
166
|
-
);
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
it.each([
|
|
170
|
-
['fr', '14 févr. 2026'],
|
|
171
|
-
['ar', '14 فبراير 2026'],
|
|
172
|
-
['pt-BR', '14 de fev. de 2026'],
|
|
173
|
-
['zh-CN', '2026年2月14日'],
|
|
174
|
-
['yo', '14 Oṣù Èrèlè 2026'],
|
|
175
|
-
['ha', '14 Fab, 2026'],
|
|
176
|
-
['sw', '14 Feb 2026'],
|
|
177
|
-
])('short date in %s', (code, native) => {
|
|
178
|
-
expect(formatShopperDate('2026-02-14T10:00:00Z', code)).toBe(
|
|
179
|
-
dateSupported(code) ? native : '14 Feb 2026',
|
|
180
|
-
);
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
it.each([
|
|
184
|
-
['fr', '1\u202f234\u202f567'],
|
|
185
|
-
['ar', '1,234,567'],
|
|
186
|
-
['pt-BR', '1.234.567'],
|
|
187
|
-
['zh-CN', '1,234,567'],
|
|
188
|
-
['yo', '1,234,567'],
|
|
189
|
-
['ha', '1,234,567'],
|
|
190
|
-
['sw', '1,234,567'],
|
|
191
|
-
])('grouped count in %s', (code, native) => {
|
|
192
|
-
expect(formatThemeNumber(1234567, code)).toBe(numberSupported(code) ? native : '1,234,567');
|
|
193
|
-
expect(formatCount(1234567, code)).toBe(numberSupported(code) ? native : '1,234,567');
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
it('money follows the shopper locale, English keeps the naira sign', () => {
|
|
197
|
-
expect(formatThemeMoney(12500, 'NGN', 'fr')).toBe(
|
|
198
|
-
numberSupported('fr') ? '12\u202f500,00\u00a0NGN' : '₦12,500.00',
|
|
199
|
-
);
|
|
200
|
-
expect(formatMoney(12500, 'NGN', 'fr')).toBe(
|
|
201
|
-
numberSupported('fr') ? '12\u202f500\u00a0NGN' : '₦12,500',
|
|
202
|
-
);
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
it.each([
|
|
206
|
-
['fr', 'hier'],
|
|
207
|
-
['ar', 'أمس'],
|
|
208
|
-
['pt-BR', 'ontem'],
|
|
209
|
-
['zh-CN', '昨天'],
|
|
210
|
-
['yo', 'Àná'],
|
|
211
|
-
['ha', 'jiya'],
|
|
212
|
-
['sw', 'jana'],
|
|
213
|
-
])('relative yesterday in %s', (code, native) => {
|
|
214
|
-
expect(formatThemeRelativeTime(-1, 'day', code)).toBe(
|
|
215
|
-
relativeSupported(code) ? native : 'yesterday',
|
|
216
|
-
);
|
|
217
|
-
expect(formatRelativeTime(-1, 'day', code)).toBe(
|
|
218
|
-
relativeSupported(code) ? native : 'yesterday',
|
|
219
|
-
);
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
it('an unknown but well-formed locale renders English and never throws', () => {
|
|
223
|
-
expect(formatThemeNumber(1234567, 'xx')).toBe('1,234,567');
|
|
224
|
-
expect(formatThemeDate('2026-02-14T23:30:00Z', 'xx', LONG_LAGOS)).toBe('15 February 2026');
|
|
225
|
-
expect(formatThemeMoney(12500, 'NGN', 'xx')).toBe('₦12,500.00');
|
|
226
|
-
expect(formatThemeRelativeTime(-1, 'day', 'xx')).toBe('yesterday');
|
|
227
|
-
expect(formatCount(1234567, 'xx')).toBe('1,234,567');
|
|
228
|
-
expect(formatShopperDate('2026-02-14T10:00:00Z', 'xx')).toBe('14 Feb 2026');
|
|
229
|
-
expect(formatMoney(12500, 'NGN', 'xx')).toBe('₦12,500');
|
|
230
|
-
expect(formatRelativeTime(-1, 'day', 'xx')).toBe('yesterday');
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
it('invalid dates are handled as before in every locale', () => {
|
|
234
|
-
expect(formatThemeDate('not a date', 'fr')).toBe('');
|
|
235
|
-
expect(formatShopperDate('not a date', 'fr')).toBe('');
|
|
236
|
-
expect(formatThemeDate('not a date', null)).toBe('');
|
|
237
|
-
});
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
describe('no kit source hardcodes an English locale into a formatter', () => {
|
|
241
|
-
it('every date/number/relative call goes through the canonical mapping', () => {
|
|
242
|
-
const ROOT = resolve(__dirname, '..');
|
|
243
|
-
const walk = (dir: string): string[] => readdirSync(dir).flatMap((entry) => {
|
|
244
|
-
const path = join(dir, entry);
|
|
245
|
-
if (entry === 'node_modules' || entry === 'tests' || entry.startsWith('.')) return [];
|
|
246
|
-
if (statSync(path).isDirectory()) return walk(path);
|
|
247
|
-
return /\.(ts|tsx)$/.test(entry) ? [path] : [];
|
|
248
|
-
});
|
|
249
|
-
const offenders = walk(ROOT).flatMap((file) => {
|
|
250
|
-
const source = readFileSync(file, 'utf8');
|
|
251
|
-
const hits: string[] = [];
|
|
252
|
-
// A literal English tag handed straight to a formatter bypasses the
|
|
253
|
-
// shopper locale — use resolveIntlLocale (or a resolved tag) instead.
|
|
254
|
-
if (/\.toLocale(?:Date|Time)?String\(\s*['"]en(-[A-Za-z]+)?['"]/.test(source)) hits.push('toLocale*');
|
|
255
|
-
if (/new Intl\.(?:DateTimeFormat|NumberFormat|RelativeTimeFormat)\(\s*['"]en(-[A-Za-z]+)?['"]/.test(source)) hits.push('new Intl*');
|
|
256
|
-
return hits.length > 0 ? [`${relative(ROOT, file)}: ${hits.join(',')}`] : [];
|
|
257
|
-
});
|
|
258
|
-
expect(offenders).toEqual([]);
|
|
259
|
-
});
|
|
260
|
-
});
|
|
261
|
-
|
|
262
|
-
describe("no exported formatter lets an 'en*' tag reach Intl unmapped", () => {
|
|
263
|
-
const LONG_LAGOS: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'Africa/Lagos' };
|
|
264
|
-
|
|
265
|
-
it("explicit 'en'/'en-US' renders British order through every formatter", () => {
|
|
266
|
-
for (const locale of ['en', 'en-US'] as const) {
|
|
267
|
-
expect(formatDisplayDate('2026-02-14T23:30:00Z', LONG_LAGOS, locale)).toBe('15 February 2026');
|
|
268
|
-
expect(formatShopperDate('2026-02-14T23:30:00Z', locale, LONG_LAGOS)).toBe('15 February 2026');
|
|
269
|
-
expect(formatCount(1234567, locale)).toBe('1,234,567');
|
|
270
|
-
expect(formatMoney(12500, 'NGN', locale)).toBe('₦12,500');
|
|
271
|
-
expect(formatRelativeTime(-1, 'day', locale)).toBe('yesterday');
|
|
272
|
-
expect(formatThemeNumber(1234567, locale)).toBe('1,234,567');
|
|
273
|
-
expect(formatThemeDate('2026-02-14T23:30:00Z', locale, LONG_LAGOS)).toBe('15 February 2026');
|
|
274
|
-
expect(formatThemeMoney(5000, 'NGN', locale)).toBe('₦5,000.00');
|
|
275
|
-
expect(formatThemeRelativeTime(-1, 'day', locale)).toBe('yesterday');
|
|
276
|
-
}
|
|
277
|
-
});
|
|
278
|
-
|
|
279
|
-
it("constructor call sites receive the mapped tag, never bare 'en'/'en-US'", () => {
|
|
280
|
-
// Date/Number.prototype.toLocale* use V8 intrinsics and bypass a patched
|
|
281
|
-
// global (verified live on this Node), so this spy covers only the
|
|
282
|
-
// `new Intl.*` call sites; the toLocale* sites are pinned behaviorally above.
|
|
283
|
-
const seen: string[] = [];
|
|
284
|
-
const originals = {
|
|
285
|
-
DateTimeFormat: Intl.DateTimeFormat,
|
|
286
|
-
NumberFormat: Intl.NumberFormat,
|
|
287
|
-
RelativeTimeFormat: Intl.RelativeTimeFormat,
|
|
288
|
-
};
|
|
289
|
-
const box = Intl as unknown as Record<string, unknown>;
|
|
290
|
-
const wrap = (kind: string, Orig: new (...args: never[]) => object) => {
|
|
291
|
-
box[kind] = function (this: unknown, ...args: unknown[]) {
|
|
292
|
-
seen.push(typeof args[0] === 'string' ? args[0] : JSON.stringify(args[0]));
|
|
293
|
-
return new Orig(...(args as never[]));
|
|
294
|
-
};
|
|
295
|
-
};
|
|
296
|
-
wrap('DateTimeFormat', originals.DateTimeFormat as new (...args: never[]) => object);
|
|
297
|
-
wrap('NumberFormat', originals.NumberFormat as new (...args: never[]) => object);
|
|
298
|
-
wrap('RelativeTimeFormat', originals.RelativeTimeFormat as new (...args: never[]) => object);
|
|
299
|
-
try {
|
|
300
|
-
formatThemeNumber(1234567, 'en-US');
|
|
301
|
-
formatThemeDate('2026-02-14T12:00:00Z', 'en-US');
|
|
302
|
-
formatThemeMoney(5000, 'NGN', 'en-US');
|
|
303
|
-
formatThemeRelativeTime(-1, 'day', 'en-US');
|
|
304
|
-
formatMoney(5000, 'NGN', 'en-US');
|
|
305
|
-
formatRelativeTime(-1, 'day', 'en-US');
|
|
306
|
-
} finally {
|
|
307
|
-
box.DateTimeFormat = originals.DateTimeFormat;
|
|
308
|
-
box.NumberFormat = originals.NumberFormat;
|
|
309
|
-
box.RelativeTimeFormat = originals.RelativeTimeFormat;
|
|
310
|
-
}
|
|
311
|
-
expect(seen.length).toBeGreaterThan(0);
|
|
312
|
-
expect(seen).toEqual(seen.map(() => 'en-NG'));
|
|
313
|
-
});
|
|
314
|
-
|
|
315
|
-
it('formatDisplayDate routes its locale parameter through resolveIntlLocale', () => {
|
|
316
|
-
const source = readFileSync(resolve(__dirname, '..', 'utils', 'format.ts'), 'utf8');
|
|
317
|
-
expect(source).toContain('date.toLocaleDateString(resolveIntlLocale(locale)');
|
|
318
|
-
});
|
|
319
|
-
});
|
|
320
|
-
|
|
321
|
-
describe('English money output', () => {
|
|
322
|
-
// formatMoney and formatThemeMoney both render English through en-NG, so USD
|
|
323
|
-
// prints 'US$'. GBP/EUR symbols are identical under 'en' and 'en-NG'; the
|
|
324
|
-
// helpers differ only in decimals (formatMoney: decimals only when present;
|
|
325
|
-
// formatThemeMoney: always the currency's default decimals).
|
|
326
|
-
it.each([
|
|
327
|
-
['NGN', '₦5,000', '₦5,000.00'],
|
|
328
|
-
['USD', 'US$5,000', 'US$5,000.00'],
|
|
329
|
-
['GBP', '£5,000', '£5,000.00'],
|
|
330
|
-
['EUR', '€5,000', '€5,000.00'],
|
|
331
|
-
])('%s renders the live-store English output', (currency, money, themeMoney) => {
|
|
332
|
-
expect(formatMoney(5000, currency)).toBe(money);
|
|
333
|
-
expect(formatMoney(5000, currency, 'en')).toBe(money);
|
|
334
|
-
expect(formatThemeMoney(5000, currency, 'en')).toBe(themeMoney);
|
|
335
|
-
expect(formatThemeMoney(5000, currency, undefined)).toBe(themeMoney);
|
|
336
|
-
});
|
|
337
|
-
|
|
338
|
-
it('September abbreviates as Intl prints it', () => {
|
|
339
|
-
expect(formatDisplayDate('2026-09-05T10:00:00Z')).toBe('5 Sept 2026');
|
|
340
|
-
expect(formatDisplayDate('2026-09-05T10:00:00Z', { day: 'numeric', month: 'short', year: 'numeric' }, 'en-US')).toBe('5 Sept 2026');
|
|
341
|
-
});
|
|
342
|
-
});
|
|
343
|
-
|
|
344
|
-
describe('malformed currency (normalize-then-format)', () => {
|
|
345
|
-
// normalizeCurrencyCode runs first, so fixable/invalid codes resolve to the
|
|
346
|
-
// platform default instead of echoing raw input (' ngn 5,000', 'null 5,000')
|
|
347
|
-
// into shopper-facing money. Already-valid inputs render as Intl prints them:
|
|
348
|
-
// 'usd' (Intl is case-insensitive) and 'XXX' (a real ISO 4217 code).
|
|
349
|
-
// formatThemeMoney normalizes at its base as well.
|
|
350
|
-
it.each([
|
|
351
|
-
['usd', 'US$5,000'],
|
|
352
|
-
[' ngn ', '₦5,000'],
|
|
353
|
-
['', '₦5,000'],
|
|
354
|
-
['XXX', '¤5,000'],
|
|
355
|
-
['NGN ', '₦5,000'],
|
|
356
|
-
])('%s normalizes before formatting', (input, expected) => {
|
|
357
|
-
expect(formatMoney(5000, input)).toBe(expected);
|
|
358
|
-
});
|
|
359
|
-
|
|
360
|
-
it('null resolves to the platform default instead of echoing', () => {
|
|
361
|
-
expect(formatMoney(5000, null as unknown as string)).toBe('₦5,000');
|
|
362
|
-
});
|
|
363
|
-
});
|
|
364
|
-
|
|
365
|
-
describe('canonicalThemeLocale is plural-selection only', () => {
|
|
366
|
-
// canonicalThemeLocale returns bare 'en' (a US-order Intl tag). If a future
|
|
367
|
-
// formatter routes through it, English stores regress to US order. t() is
|
|
368
|
-
// its only legitimate caller: plural selection needs the bare language tag,
|
|
369
|
-
// and Intl.PluralRules('en') vs ('en-NG') select identically for our rules.
|
|
370
|
-
it('t() still selects plurals through it', () => {
|
|
371
|
-
const source = readFileSync(resolve(__dirname, '..', 'strings', 'theme-strings.ts'), 'utf8');
|
|
372
|
-
expect(source).toContain('canonicalThemeLocale(locale)');
|
|
373
|
-
});
|
|
374
|
-
|
|
375
|
-
it('no date/number/money/relative-time formatter calls it', () => {
|
|
376
|
-
// Static source scan: every formatter body lives in its own
|
|
377
|
-
// `export function <name>` segment, so a future edit that routes a
|
|
378
|
-
// formatter through canonicalThemeLocale trips this test even though the
|
|
379
|
-
// compiled function source would not reveal the call.
|
|
380
|
-
const formatSource = readFileSync(resolve(__dirname, '..', 'utils', 'format.ts'), 'utf8');
|
|
381
|
-
expect(formatSource).not.toContain('canonicalThemeLocale');
|
|
382
|
-
const themeSource = readFileSync(resolve(__dirname, '..', 'strings', 'theme-strings.ts'), 'utf8');
|
|
383
|
-
const segments = themeSource.split(/\n(?:export )?function /);
|
|
384
|
-
const bodyOf = (name: string): string => {
|
|
385
|
-
const segment = segments.find((s) => s.startsWith(`${name}(`));
|
|
386
|
-
expect(segment, `${name} formatter source found`).toBeDefined();
|
|
387
|
-
return segment as string;
|
|
388
|
-
};
|
|
389
|
-
for (const name of ['formatThemeNumber', 'formatThemeDate', 'formatThemeMoney', 'formatThemeRelativeTime']) {
|
|
390
|
-
expect(bodyOf(name)).not.toContain('canonicalThemeLocale');
|
|
391
|
-
}
|
|
392
|
-
// The shared tag helper all four formatters use maps en* -> en-NG.
|
|
393
|
-
expect(bodyOf('themeFormatTag')).toContain('resolveIntlLocale');
|
|
394
|
-
expect(bodyOf('themeFormatTag')).not.toContain('canonicalThemeLocale');
|
|
395
|
-
});
|
|
396
|
-
|
|
397
|
-
it('the guard fires when a formatter body is mutated to call it', () => {
|
|
398
|
-
const mutated = `\nexport function formatThemeDate(\n const tag = canonicalThemeLocale(locale);\n}`;
|
|
399
|
-
const segments = mutated.split(/\n(?:export )?function /);
|
|
400
|
-
const body = segments.find((s) => s.startsWith('formatThemeDate(')) as string;
|
|
401
|
-
expect(body).toContain('canonicalThemeLocale');
|
|
402
|
-
});
|
|
403
|
-
});
|