@usequeek/theme-kit 0.1.20 → 0.1.21
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/README.md +117 -0
- package/components/auth/auth-email-login-step.tsx +9 -7
- package/components/auth/auth-email-otp-step.tsx +9 -7
- package/components/auth/auth-email-otp-verify-step.tsx +8 -6
- package/components/auth/auth-email-register-step.tsx +10 -8
- package/components/auth/auth-signup-step.tsx +8 -6
- package/components/blog/category-filter.tsx +6 -5
- package/components/blog/pagination.tsx +8 -7
- package/components/blog/post-meta.tsx +15 -8
- package/components/blog/related-posts.tsx +5 -3
- package/components/blog/share-buttons.tsx +10 -8
- package/components/checkout/checkout-contact.tsx +6 -4
- package/components/checkout/default-checkout-shell.tsx +17 -15
- package/components/checkout/policy-links.tsx +5 -3
- package/hooks/use-auth-flow.tsx +18 -17
- package/index.ts +4 -0
- package/locales/en.default.json +45 -7
- package/package.json +1 -1
- package/strings/pseudo-locale.ts +264 -0
- package/strings/theme-strings.ts +178 -15
- package/tests/auth-email-errors.test.tsx +55 -0
- package/tests/display-format.test.ts +364 -2
- package/tests/shopper-chrome-strings.test.tsx +272 -0
- package/tests/theme-hook-placement.test.tsx +179 -0
- package/tests/theme-strings-pseudo.test.tsx +171 -0
- package/tests/theme-strings-typed.test.tsx +97 -0
- package/tests/theme-strings.test.tsx +8 -2
- package/tests/theme-typed-keys.fixture.ts +76 -0
- package/utils/format.ts +97 -9
- package/utils/locale.ts +52 -0
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
2
2
|
import { join, relative, resolve } from 'node:path';
|
|
3
3
|
import { describe, expect, it } from 'vitest';
|
|
4
|
-
import { DISPLAY_LOCALE, DISPLAY_TIME_ZONE, formatCount, formatDisplayDate, formatMoney } from '../utils/format';
|
|
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';
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
9
|
* Storefront pages render on the server first. A date formatted with the
|
|
@@ -23,7 +25,11 @@ describe('display formatting is the same on the server and in the browser', () =
|
|
|
23
25
|
// browser (Lagos) used to print different days for this review.
|
|
24
26
|
expect(formatDisplayDate('2026-02-14T23:30:00Z')).toBe('15 Feb 2026');
|
|
25
27
|
expect(formatDisplayDate('2026-02-14', {})).toBe('14/02/2026');
|
|
26
|
-
|
|
28
|
+
// Founder 2/10/26: English is British/Nigerian order, never US order — the
|
|
29
|
+
// locale parameter is routed through resolveIntlLocale, so even an explicit
|
|
30
|
+
// 'en'/'en-US' renders "15 February 2026" (was "February 15, 2026" before).
|
|
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');
|
|
27
33
|
expect(formatDisplayDate('not a date')).toBe('');
|
|
28
34
|
});
|
|
29
35
|
|
|
@@ -47,3 +53,359 @@ describe('display formatting is the same on the server and in the browser', () =
|
|
|
47
53
|
expect(offenders).toEqual([]);
|
|
48
54
|
});
|
|
49
55
|
});
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Founder 2/10/26: dates and numbers follow the SHOPPER's locale, with
|
|
59
|
+
* English in the British/Nigerian style so English stores do not change
|
|
60
|
+
* visibly ("2 October 2026", never US "October 2, 2026").
|
|
61
|
+
*/
|
|
62
|
+
describe('canonical locale mapping (shopper locale -> Intl locale)', () => {
|
|
63
|
+
const LONG_LAGOS = { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'Africa/Lagos' } as const;
|
|
64
|
+
|
|
65
|
+
it('maps every English variant and every missing/invalid code to en-NG', () => {
|
|
66
|
+
expect(ENGLISH_DISPLAY_LOCALE).toBe('en-NG');
|
|
67
|
+
expect(DISPLAY_LOCALE).toBe(ENGLISH_DISPLAY_LOCALE);
|
|
68
|
+
for (const locale of [null, undefined, 'en', 'en-GB', 'en-NG', 'en-US', 'not a locale!!', '']) {
|
|
69
|
+
expect(resolveIntlLocale(locale), String(locale)).toBe('en-NG');
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('passes every other well-formed code through unchanged', () => {
|
|
74
|
+
for (const code of ['fr', 'ar', 'pt-BR', 'zh-CN', 'yo', 'ha', 'sw', 'es', 'de', 'ig']) {
|
|
75
|
+
expect(resolveIntlLocale(code)).toBe(code);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('evidence: en-NG is the mapping that reproduces current English output byte for byte', () => {
|
|
80
|
+
// Dates are identical under en-GB and en-NG …
|
|
81
|
+
expect(new Date('2026-10-02T10:00:00Z').toLocaleDateString('en-GB', { ...LONG_LAGOS })).toBe('2 October 2026');
|
|
82
|
+
expect(new Date('2026-10-02T10:00:00Z').toLocaleDateString('en-NG', { ...LONG_LAGOS })).toBe('2 October 2026');
|
|
83
|
+
// … but money is NOT: only en-NG keeps the naira sign the stores show.
|
|
84
|
+
expect(
|
|
85
|
+
new Intl.NumberFormat('en-NG', { style: 'currency', currency: 'NGN', minimumFractionDigits: 0, maximumFractionDigits: 2 }).format(12500),
|
|
86
|
+
).toBe('₦12,500');
|
|
87
|
+
expect(
|
|
88
|
+
new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'NGN', minimumFractionDigits: 0, maximumFractionDigits: 2 }).format(12500),
|
|
89
|
+
).not.toBe('₦12,500');
|
|
90
|
+
// So the mapping pins the sign the pinned tests assert.
|
|
91
|
+
expect(formatMoney(12500)).toBe('₦12,500');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('falls back to English when the runtime ICU lacks the locale, and never throws', () => {
|
|
95
|
+
expect(resolveSupportedLocale('fr', (l) => Intl.DateTimeFormat.supportedLocalesOf(l))).toBe('fr');
|
|
96
|
+
expect(resolveSupportedLocale('xx', (l) => Intl.DateTimeFormat.supportedLocalesOf(l))).toBe('en-NG');
|
|
97
|
+
expect(resolveSupportedLocale('fr', () => { throw new Error('no ICU'); })).toBe('en-NG');
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('English output is byte-identical with or without a locale', () => {
|
|
102
|
+
const LONG_LAGOS: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'Africa/Lagos' };
|
|
103
|
+
|
|
104
|
+
it('format.ts helpers render the pinned strings for null/en', () => {
|
|
105
|
+
for (const locale of [undefined, null, 'en'] as const) {
|
|
106
|
+
expect(formatShopperDate('2026-02-14T10:00:00Z', locale)).toBe('14 Feb 2026');
|
|
107
|
+
expect(formatShopperDate('2026-02-14T23:30:00Z', locale, LONG_LAGOS)).toBe('15 February 2026');
|
|
108
|
+
expect(formatCount(1234567, locale)).toBe('1,234,567');
|
|
109
|
+
expect(formatMoney(12500, 'NGN', locale)).toBe('₦12,500');
|
|
110
|
+
expect(formatMoney(0.28, 'NGN', locale)).toBe('₦0.28');
|
|
111
|
+
expect(formatRelativeTime(-1, 'day', locale)).toBe('yesterday');
|
|
112
|
+
}
|
|
113
|
+
expect(formatRelativeTime(-3, 'day')).toBe('3 days ago');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('formatTheme* helpers render English in the British/Nigerian style', () => {
|
|
117
|
+
for (const locale of [undefined, null, 'en'] as const) {
|
|
118
|
+
expect(formatThemeNumber(1234567, locale)).toBe('1,234,567');
|
|
119
|
+
expect(formatThemeDate('2026-02-14T23:30:00Z', locale, LONG_LAGOS)).toBe('15 February 2026');
|
|
120
|
+
expect(formatThemeMoney(5000, 'NGN', locale)).toBe('₦5,000.00');
|
|
121
|
+
expect(formatThemeRelativeTime(-1, 'day', locale)).toBe('yesterday');
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('shopper locales format through real Intl output', () => {
|
|
127
|
+
const LONG_LAGOS: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'Africa/Lagos' };
|
|
128
|
+
const dateSupported = (code: string): boolean => {
|
|
129
|
+
try {
|
|
130
|
+
return Intl.DateTimeFormat.supportedLocalesOf([code]).length > 0;
|
|
131
|
+
} catch {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
const numberSupported = (code: string): boolean => {
|
|
136
|
+
try {
|
|
137
|
+
return Intl.NumberFormat.supportedLocalesOf([code]).length > 0;
|
|
138
|
+
} catch {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
const relativeSupported = (code: string): boolean => {
|
|
143
|
+
try {
|
|
144
|
+
return Intl.RelativeTimeFormat.supportedLocalesOf([code]).length > 0;
|
|
145
|
+
} catch {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
// Observed on Node 22 (full ICU) 2/10/26; each row falls back to the
|
|
151
|
+
// English style when the runtime's ICU lacks the locale.
|
|
152
|
+
it.each([
|
|
153
|
+
['fr', '15 février 2026'],
|
|
154
|
+
['ar', '15 فبراير 2026'],
|
|
155
|
+
['pt-BR', '15 de fevereiro de 2026'],
|
|
156
|
+
['zh-CN', '2026年2月15日'],
|
|
157
|
+
['yo', '15 Oṣù Èrèlè 2026'],
|
|
158
|
+
['ha', '15 Faburairu, 2026'],
|
|
159
|
+
['sw', '15 Februari 2026'],
|
|
160
|
+
])('long date in %s (Lagos day boundary preserved)', (code, native) => {
|
|
161
|
+
expect(formatThemeDate('2026-02-14T23:30:00Z', code, LONG_LAGOS)).toBe(
|
|
162
|
+
dateSupported(code) ? native : '15 February 2026',
|
|
163
|
+
);
|
|
164
|
+
// Same through the fixed-zone display path post-meta uses.
|
|
165
|
+
expect(formatShopperDate('2026-02-14T23:30:00Z', code, LONG_LAGOS)).toBe(
|
|
166
|
+
dateSupported(code) ? native : '15 February 2026',
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it.each([
|
|
171
|
+
['fr', '14 févr. 2026'],
|
|
172
|
+
['ar', '14 فبراير 2026'],
|
|
173
|
+
['pt-BR', '14 de fev. de 2026'],
|
|
174
|
+
['zh-CN', '2026年2月14日'],
|
|
175
|
+
['yo', '14 Oṣù Èrèlè 2026'],
|
|
176
|
+
['ha', '14 Fab, 2026'],
|
|
177
|
+
['sw', '14 Feb 2026'],
|
|
178
|
+
])('short date in %s', (code, native) => {
|
|
179
|
+
expect(formatShopperDate('2026-02-14T10:00:00Z', code)).toBe(
|
|
180
|
+
dateSupported(code) ? native : '14 Feb 2026',
|
|
181
|
+
);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it.each([
|
|
185
|
+
['fr', '1\u202f234\u202f567'],
|
|
186
|
+
['ar', '1,234,567'],
|
|
187
|
+
['pt-BR', '1.234.567'],
|
|
188
|
+
['zh-CN', '1,234,567'],
|
|
189
|
+
['yo', '1,234,567'],
|
|
190
|
+
['ha', '1,234,567'],
|
|
191
|
+
['sw', '1,234,567'],
|
|
192
|
+
])('grouped count in %s', (code, native) => {
|
|
193
|
+
expect(formatThemeNumber(1234567, code)).toBe(numberSupported(code) ? native : '1,234,567');
|
|
194
|
+
expect(formatCount(1234567, code)).toBe(numberSupported(code) ? native : '1,234,567');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('money follows the shopper locale, English keeps the naira sign', () => {
|
|
198
|
+
expect(formatThemeMoney(12500, 'NGN', 'fr')).toBe(
|
|
199
|
+
numberSupported('fr') ? '12\u202f500,00\u00a0NGN' : '₦12,500.00',
|
|
200
|
+
);
|
|
201
|
+
expect(formatMoney(12500, 'NGN', 'fr')).toBe(
|
|
202
|
+
numberSupported('fr') ? '12\u202f500\u00a0NGN' : '₦12,500',
|
|
203
|
+
);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it.each([
|
|
207
|
+
['fr', 'hier'],
|
|
208
|
+
['ar', 'أمس'],
|
|
209
|
+
['pt-BR', 'ontem'],
|
|
210
|
+
['zh-CN', '昨天'],
|
|
211
|
+
['yo', 'Àná'],
|
|
212
|
+
['ha', 'jiya'],
|
|
213
|
+
['sw', 'jana'],
|
|
214
|
+
])('relative yesterday in %s', (code, native) => {
|
|
215
|
+
expect(formatThemeRelativeTime(-1, 'day', code)).toBe(
|
|
216
|
+
relativeSupported(code) ? native : 'yesterday',
|
|
217
|
+
);
|
|
218
|
+
expect(formatRelativeTime(-1, 'day', code)).toBe(
|
|
219
|
+
relativeSupported(code) ? native : 'yesterday',
|
|
220
|
+
);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('an unknown but well-formed locale renders English and never throws', () => {
|
|
224
|
+
expect(formatThemeNumber(1234567, 'xx')).toBe('1,234,567');
|
|
225
|
+
expect(formatThemeDate('2026-02-14T23:30:00Z', 'xx', LONG_LAGOS)).toBe('15 February 2026');
|
|
226
|
+
expect(formatThemeMoney(12500, 'NGN', 'xx')).toBe('₦12,500.00');
|
|
227
|
+
expect(formatThemeRelativeTime(-1, 'day', 'xx')).toBe('yesterday');
|
|
228
|
+
expect(formatCount(1234567, 'xx')).toBe('1,234,567');
|
|
229
|
+
expect(formatShopperDate('2026-02-14T10:00:00Z', 'xx')).toBe('14 Feb 2026');
|
|
230
|
+
expect(formatMoney(12500, 'NGN', 'xx')).toBe('₦12,500');
|
|
231
|
+
expect(formatRelativeTime(-1, 'day', 'xx')).toBe('yesterday');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('invalid dates are handled as before in every locale', () => {
|
|
235
|
+
expect(formatThemeDate('not a date', 'fr')).toBe('');
|
|
236
|
+
expect(formatShopperDate('not a date', 'fr')).toBe('');
|
|
237
|
+
expect(formatThemeDate('not a date', null)).toBe('');
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe('no kit source hardcodes an English locale into a formatter', () => {
|
|
242
|
+
it('every date/number/relative call goes through the canonical mapping', () => {
|
|
243
|
+
const ROOT = resolve(__dirname, '..');
|
|
244
|
+
const walk = (dir: string): string[] => readdirSync(dir).flatMap((entry) => {
|
|
245
|
+
const path = join(dir, entry);
|
|
246
|
+
if (entry === 'node_modules' || entry === 'tests' || entry.startsWith('.')) return [];
|
|
247
|
+
if (statSync(path).isDirectory()) return walk(path);
|
|
248
|
+
return /\.(ts|tsx)$/.test(entry) ? [path] : [];
|
|
249
|
+
});
|
|
250
|
+
const offenders = walk(ROOT).flatMap((file) => {
|
|
251
|
+
const source = readFileSync(file, 'utf8');
|
|
252
|
+
const hits: string[] = [];
|
|
253
|
+
// A literal English tag handed straight to a formatter bypasses the
|
|
254
|
+
// shopper locale — use resolveIntlLocale (or a resolved tag) instead.
|
|
255
|
+
if (/\.toLocale(?:Date|Time)?String\(\s*['"]en(-[A-Za-z]+)?['"]/.test(source)) hits.push('toLocale*');
|
|
256
|
+
if (/new Intl\.(?:DateTimeFormat|NumberFormat|RelativeTimeFormat)\(\s*['"]en(-[A-Za-z]+)?['"]/.test(source)) hits.push('new Intl*');
|
|
257
|
+
return hits.length > 0 ? [`${relative(ROOT, file)}: ${hits.join(',')}`] : [];
|
|
258
|
+
});
|
|
259
|
+
expect(offenders).toEqual([]);
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
describe("no exported formatter lets an 'en*' tag reach Intl unmapped", () => {
|
|
264
|
+
const LONG_LAGOS: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'Africa/Lagos' };
|
|
265
|
+
|
|
266
|
+
it("explicit 'en'/'en-US' renders British order through every formatter", () => {
|
|
267
|
+
for (const locale of ['en', 'en-US'] as const) {
|
|
268
|
+
expect(formatDisplayDate('2026-02-14T23:30:00Z', LONG_LAGOS, locale)).toBe('15 February 2026');
|
|
269
|
+
expect(formatShopperDate('2026-02-14T23:30:00Z', locale, LONG_LAGOS)).toBe('15 February 2026');
|
|
270
|
+
expect(formatCount(1234567, locale)).toBe('1,234,567');
|
|
271
|
+
expect(formatMoney(12500, 'NGN', locale)).toBe('₦12,500');
|
|
272
|
+
expect(formatRelativeTime(-1, 'day', locale)).toBe('yesterday');
|
|
273
|
+
expect(formatThemeNumber(1234567, locale)).toBe('1,234,567');
|
|
274
|
+
expect(formatThemeDate('2026-02-14T23:30:00Z', locale, LONG_LAGOS)).toBe('15 February 2026');
|
|
275
|
+
expect(formatThemeMoney(5000, 'NGN', locale)).toBe('₦5,000.00');
|
|
276
|
+
expect(formatThemeRelativeTime(-1, 'day', locale)).toBe('yesterday');
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("constructor call sites receive the mapped tag, never bare 'en'/'en-US'", () => {
|
|
281
|
+
// Date/Number.prototype.toLocale* use V8 intrinsics and bypass a patched
|
|
282
|
+
// global (verified live on this Node), so this spy covers only the
|
|
283
|
+
// `new Intl.*` call sites; the toLocale* sites are pinned behaviorally above.
|
|
284
|
+
const seen: string[] = [];
|
|
285
|
+
const originals = {
|
|
286
|
+
DateTimeFormat: Intl.DateTimeFormat,
|
|
287
|
+
NumberFormat: Intl.NumberFormat,
|
|
288
|
+
RelativeTimeFormat: Intl.RelativeTimeFormat,
|
|
289
|
+
};
|
|
290
|
+
const box = Intl as unknown as Record<string, unknown>;
|
|
291
|
+
const wrap = (kind: string, Orig: new (...args: never[]) => object) => {
|
|
292
|
+
box[kind] = function (this: unknown, ...args: unknown[]) {
|
|
293
|
+
seen.push(typeof args[0] === 'string' ? args[0] : JSON.stringify(args[0]));
|
|
294
|
+
return new Orig(...(args as never[]));
|
|
295
|
+
};
|
|
296
|
+
};
|
|
297
|
+
wrap('DateTimeFormat', originals.DateTimeFormat as new (...args: never[]) => object);
|
|
298
|
+
wrap('NumberFormat', originals.NumberFormat as new (...args: never[]) => object);
|
|
299
|
+
wrap('RelativeTimeFormat', originals.RelativeTimeFormat as new (...args: never[]) => object);
|
|
300
|
+
try {
|
|
301
|
+
formatThemeNumber(1234567, 'en-US');
|
|
302
|
+
formatThemeDate('2026-02-14T12:00:00Z', 'en-US');
|
|
303
|
+
formatThemeMoney(5000, 'NGN', 'en-US');
|
|
304
|
+
formatThemeRelativeTime(-1, 'day', 'en-US');
|
|
305
|
+
formatMoney(5000, 'NGN', 'en-US');
|
|
306
|
+
formatRelativeTime(-1, 'day', 'en-US');
|
|
307
|
+
} finally {
|
|
308
|
+
box.DateTimeFormat = originals.DateTimeFormat;
|
|
309
|
+
box.NumberFormat = originals.NumberFormat;
|
|
310
|
+
box.RelativeTimeFormat = originals.RelativeTimeFormat;
|
|
311
|
+
}
|
|
312
|
+
expect(seen.length).toBeGreaterThan(0);
|
|
313
|
+
expect(seen).toEqual(seen.map(() => 'en-NG'));
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it('formatDisplayDate routes its locale parameter through resolveIntlLocale', () => {
|
|
317
|
+
const source = readFileSync(resolve(__dirname, '..', 'utils', 'format.ts'), 'utf8');
|
|
318
|
+
expect(source).toContain('date.toLocaleDateString(resolveIntlLocale(locale)');
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
describe('English money before/after (S7 table, measured on old vs new code)', () => {
|
|
323
|
+
// BEFORE (f7ee395, scratch worktree run): formatMoney was hardcoded 'en-NG';
|
|
324
|
+
// formatThemeMoney used bare 'en' via canonicalThemeLocale. AFTER: both use
|
|
325
|
+
// en-NG. Decision: en-NG ('US$') wins because live English stores already
|
|
326
|
+
// render formatMoney — the only money helper any theme imports (atelier
|
|
327
|
+
// blocks/components import it; ZERO themes import formatThemeMoney, so the
|
|
328
|
+
// old bare-'en' '$5,000.00' never reached a live store). GBP/EUR symbols are
|
|
329
|
+
// identical under 'en' vs 'en-NG'; only the helper decimal contract differs
|
|
330
|
+
// (formatMoney: decimals only when present; formatThemeMoney: K0 2-decimals).
|
|
331
|
+
it.each([
|
|
332
|
+
['NGN', '₦5,000', '₦5,000.00'],
|
|
333
|
+
['USD', 'US$5,000', 'US$5,000.00'],
|
|
334
|
+
['GBP', '£5,000', '£5,000.00'],
|
|
335
|
+
['EUR', '€5,000', '€5,000.00'],
|
|
336
|
+
])('%s renders the live-store English output', (currency, money, themeMoney) => {
|
|
337
|
+
expect(formatMoney(5000, currency)).toBe(money);
|
|
338
|
+
expect(formatMoney(5000, currency, 'en')).toBe(money);
|
|
339
|
+
expect(formatThemeMoney(5000, currency, 'en')).toBe(themeMoney);
|
|
340
|
+
expect(formatThemeMoney(5000, currency, undefined)).toBe(themeMoney);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('September abbreviates as Intl prints it', () => {
|
|
344
|
+
expect(formatDisplayDate('2026-09-05T10:00:00Z')).toBe('5 Sept 2026');
|
|
345
|
+
expect(formatDisplayDate('2026-09-05T10:00:00Z', { day: 'numeric', month: 'short', year: 'numeric' }, 'en-US')).toBe('5 Sept 2026');
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
describe('malformed currency before/after (normalize-then-format is intended)', () => {
|
|
350
|
+
// BEFORE (f7ee395): formatMoney passed the raw value to Intl — an invalid
|
|
351
|
+
// code threw RangeError internally and the fallback echoed the RAW token
|
|
352
|
+
// (' ngn 5,000', 'null 5,000'). AFTER: normalizeCurrencyCode runs first, so
|
|
353
|
+
// fixable/invalid codes resolve to the platform default instead of echoing
|
|
354
|
+
// raw input into shopper-facing money. Already-valid inputs are unchanged:
|
|
355
|
+
// 'usd' (Intl is case-insensitive) and 'XXX' (real ISO 4217 code) render
|
|
356
|
+
// byte-identical to before. formatThemeMoney already normalized at base —
|
|
357
|
+
// its malformed handling is untouched by this slice.
|
|
358
|
+
it.each([
|
|
359
|
+
['usd', 'US$5,000'],
|
|
360
|
+
[' ngn ', '₦5,000'],
|
|
361
|
+
['', '₦5,000'],
|
|
362
|
+
['XXX', '¤5,000'],
|
|
363
|
+
['NGN ', '₦5,000'],
|
|
364
|
+
])('%s normalizes before formatting', (input, expected) => {
|
|
365
|
+
expect(formatMoney(5000, input)).toBe(expected);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it('null resolves to the platform default instead of echoing', () => {
|
|
369
|
+
expect(formatMoney(5000, null as unknown as string)).toBe('₦5,000');
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
describe('canonicalThemeLocale is plural-selection only', () => {
|
|
374
|
+
// canonicalThemeLocale returns bare 'en' (a US-order Intl tag). If a future
|
|
375
|
+
// formatter routes through it, English stores regress to US order. t() is
|
|
376
|
+
// its only legitimate caller: plural selection needs the bare language tag,
|
|
377
|
+
// and Intl.PluralRules('en') vs ('en-NG') select identically for our rules.
|
|
378
|
+
it('t() still selects plurals through it', () => {
|
|
379
|
+
const source = readFileSync(resolve(__dirname, '..', 'strings', 'theme-strings.ts'), 'utf8');
|
|
380
|
+
expect(source).toContain('canonicalThemeLocale(locale)');
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it('no date/number/money/relative-time formatter calls it', () => {
|
|
384
|
+
// Static source scan: every formatter body lives in its own
|
|
385
|
+
// `export function <name>` segment, so a future edit that routes a
|
|
386
|
+
// formatter through canonicalThemeLocale trips this test even though the
|
|
387
|
+
// compiled function source would not reveal the call.
|
|
388
|
+
const formatSource = readFileSync(resolve(__dirname, '..', 'utils', 'format.ts'), 'utf8');
|
|
389
|
+
expect(formatSource).not.toContain('canonicalThemeLocale');
|
|
390
|
+
const themeSource = readFileSync(resolve(__dirname, '..', 'strings', 'theme-strings.ts'), 'utf8');
|
|
391
|
+
const segments = themeSource.split(/\n(?:export )?function /);
|
|
392
|
+
const bodyOf = (name: string): string => {
|
|
393
|
+
const segment = segments.find((s) => s.startsWith(`${name}(`));
|
|
394
|
+
expect(segment, `${name} formatter source found`).toBeDefined();
|
|
395
|
+
return segment as string;
|
|
396
|
+
};
|
|
397
|
+
for (const name of ['formatThemeNumber', 'formatThemeDate', 'formatThemeMoney', 'formatThemeRelativeTime']) {
|
|
398
|
+
expect(bodyOf(name)).not.toContain('canonicalThemeLocale');
|
|
399
|
+
}
|
|
400
|
+
// The shared tag helper all four formatters use maps en* -> en-NG.
|
|
401
|
+
expect(bodyOf('themeFormatTag')).toContain('resolveIntlLocale');
|
|
402
|
+
expect(bodyOf('themeFormatTag')).not.toContain('canonicalThemeLocale');
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it('the guard fires when a formatter body is mutated to call it', () => {
|
|
406
|
+
const mutated = `\nexport function formatThemeDate(\n const tag = canonicalThemeLocale(locale);\n}`;
|
|
407
|
+
const segments = mutated.split(/\n(?:export )?function /);
|
|
408
|
+
const body = segments.find((s) => s.startsWith('formatThemeDate(')) as string;
|
|
409
|
+
expect(body).toContain('canonicalThemeLocale');
|
|
410
|
+
});
|
|
411
|
+
});
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shopper-chrome theme strings (checkout / auth / blog) — English-safety guard.
|
|
3
|
+
*
|
|
4
|
+
* Stage A: every state below was rendered against the PRE-migration code and
|
|
5
|
+
* stored in `tests/fixtures/shopper-chrome-baseline.json`. Each case asserts
|
|
6
|
+
* `.toBe` equality against that stored OLD markup, so ANY English change
|
|
7
|
+
* (value, markup, attribute, plural) fails here. S4 drift guard + S7 proof.
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync } from 'node:fs';
|
|
10
|
+
import { join, dirname } from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
13
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
14
|
+
import type { JSX, ReactNode } from 'react';
|
|
15
|
+
import { StorefrontProvider } from '../provider';
|
|
16
|
+
import { CheckoutContactSection } from '../components/checkout/checkout-contact';
|
|
17
|
+
import { DefaultCheckoutShell } from '../components/checkout/default-checkout-shell';
|
|
18
|
+
import { PolicyLinks } from '../components/checkout/policy-links';
|
|
19
|
+
import { AuthEmailLoginStep } from '../components/auth/auth-email-login-step';
|
|
20
|
+
import { AuthEmailOtpStep } from '../components/auth/auth-email-otp-step';
|
|
21
|
+
import { AuthEmailOtpVerifyStep } from '../components/auth/auth-email-otp-verify-step';
|
|
22
|
+
import { AuthEmailRegisterStep } from '../components/auth/auth-email-register-step';
|
|
23
|
+
import { AuthSignupStep } from '../components/auth/auth-signup-step';
|
|
24
|
+
import { CategoryFilter } from '../components/blog/category-filter';
|
|
25
|
+
import { BlogPagination } from '../components/blog/pagination';
|
|
26
|
+
import { RelatedPosts } from '../components/blog/related-posts';
|
|
27
|
+
import { ShareButtons } from '../components/blog/share-buttons';
|
|
28
|
+
import { PostMeta } from '../components/blog/post-meta';
|
|
29
|
+
import type { Page } from '../types/page';
|
|
30
|
+
import { t, defaultThemeStrings } from '../strings/theme-strings';
|
|
31
|
+
|
|
32
|
+
vi.mock('../hooks/use-auth-flow', async (importOriginal) => {
|
|
33
|
+
const actual = await importOriginal<typeof import('../hooks/use-auth-flow')>();
|
|
34
|
+
return {
|
|
35
|
+
...actual,
|
|
36
|
+
useAuthFlow: () => (globalThis as unknown as { __flow: unknown }).__flow,
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
vi.mock('../hooks/use-auth', () => ({
|
|
41
|
+
useAuth: () => (globalThis as unknown as { __auth: unknown }).__auth,
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
const G = globalThis as unknown as { __flow: unknown; __auth: unknown };
|
|
45
|
+
|
|
46
|
+
function baseFlow(over: Record<string, unknown> = {}): void {
|
|
47
|
+
G.__flow = {
|
|
48
|
+
step: 'email-otp',
|
|
49
|
+
otp: '',
|
|
50
|
+
firstName: '',
|
|
51
|
+
lastName: '',
|
|
52
|
+
email: '',
|
|
53
|
+
password: '',
|
|
54
|
+
loading: false,
|
|
55
|
+
error: null,
|
|
56
|
+
setOtp: vi.fn(),
|
|
57
|
+
setFirstName: vi.fn(),
|
|
58
|
+
setLastName: vi.fn(),
|
|
59
|
+
setEmail: vi.fn(),
|
|
60
|
+
setPassword: vi.fn(),
|
|
61
|
+
submitEmailRequestOtp: vi.fn(),
|
|
62
|
+
submitEmailVerifyOtp: vi.fn(),
|
|
63
|
+
submitRegister: vi.fn(),
|
|
64
|
+
submitEmailLogin: vi.fn(),
|
|
65
|
+
submitEmailRegister: vi.fn(),
|
|
66
|
+
initiateGoogleAuth: vi.fn(),
|
|
67
|
+
reset: vi.fn(),
|
|
68
|
+
goToEmailOtp: vi.fn(),
|
|
69
|
+
goToEmailLogin: vi.fn(),
|
|
70
|
+
goToEmailRegister: vi.fn(),
|
|
71
|
+
...over,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function signedOut(): void {
|
|
76
|
+
G.__auth = {
|
|
77
|
+
user: null,
|
|
78
|
+
isAuthenticated: false,
|
|
79
|
+
openAuthModal: vi.fn(),
|
|
80
|
+
logout: vi.fn(),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function signedIn(over: Record<string, unknown> = {}): void {
|
|
85
|
+
G.__auth = {
|
|
86
|
+
user: {
|
|
87
|
+
id: 'u1',
|
|
88
|
+
name: 'Adaeze Okafor',
|
|
89
|
+
first_name: 'Adaeze',
|
|
90
|
+
last_name: 'Okafor',
|
|
91
|
+
email: 'ada@example.com',
|
|
92
|
+
phone: '',
|
|
93
|
+
avatar: null,
|
|
94
|
+
},
|
|
95
|
+
isAuthenticated: true,
|
|
96
|
+
openAuthModal: vi.fn(),
|
|
97
|
+
logout: vi.fn(),
|
|
98
|
+
...over,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const AVATAR_USER = {
|
|
103
|
+
id: 'u1',
|
|
104
|
+
name: 'Adaeze Okafor',
|
|
105
|
+
first_name: 'Adaeze',
|
|
106
|
+
last_name: 'Okafor',
|
|
107
|
+
email: 'ada@example.com',
|
|
108
|
+
phone: '',
|
|
109
|
+
avatar: 'https://cdn.example.com/a.png',
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
function render(children: ReactNode): string {
|
|
113
|
+
return renderToStaticMarkup(
|
|
114
|
+
<StorefrontProvider
|
|
115
|
+
vendor={{ id: 'v1', slug: 'kili-foods', name: 'Kili Foods' } as never}
|
|
116
|
+
config={{} as never}
|
|
117
|
+
menus={[]}
|
|
118
|
+
>
|
|
119
|
+
{children}
|
|
120
|
+
</StorefrontProvider>,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function PICK({ label }: { label: string }): JSX.Element {
|
|
125
|
+
return <span data-pick={label}>{label}</span>;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
129
|
+
function shellProps(over: Record<string, unknown> = {}): any {
|
|
130
|
+
return {
|
|
131
|
+
cartItems: <PICK label="items" />,
|
|
132
|
+
deliveryModes: <PICK label="modes" />,
|
|
133
|
+
addressPicker: <PICK label="address" />,
|
|
134
|
+
deliverySourcePicker: null,
|
|
135
|
+
shippingZonePicker: <PICK label="zone" />,
|
|
136
|
+
schedulePicker: <PICK label="schedule" />,
|
|
137
|
+
paymentPicker: <PICK label="pay" />,
|
|
138
|
+
promoInput: <PICK label="promo" />,
|
|
139
|
+
noteInputs: <PICK label="notes" />,
|
|
140
|
+
feesBreakdown: <PICK label="fees" />,
|
|
141
|
+
deliveryMessage: null,
|
|
142
|
+
submitButton: <PICK label="submit" />,
|
|
143
|
+
activeTab: 'summary',
|
|
144
|
+
onTabChange: () => {},
|
|
145
|
+
totalCount: 0,
|
|
146
|
+
subtotal: 0,
|
|
147
|
+
currency: 'NGN',
|
|
148
|
+
mode: 'page',
|
|
149
|
+
policies: [],
|
|
150
|
+
...over,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const POLICIES = [
|
|
155
|
+
{ id: 'p1', slug: 'refund', type: 'page', title: 'Refund policy', content: [], excerpt: null, cover_image_url: null },
|
|
156
|
+
{ id: 'p2', slug: 'shipping', type: 'page', title: 'Shipping policy', content: [], excerpt: null, cover_image_url: null },
|
|
157
|
+
] as unknown as Page[];
|
|
158
|
+
|
|
159
|
+
const CATS = [
|
|
160
|
+
{ slug: 'news', name: 'News', posts_count: 4 },
|
|
161
|
+
{ slug: 'recipes', name: 'Recipes' },
|
|
162
|
+
];
|
|
163
|
+
|
|
164
|
+
function post(over: Record<string, unknown> = {}): Record<string, unknown> {
|
|
165
|
+
return {
|
|
166
|
+
id: 'post1',
|
|
167
|
+
slug: 'hello-world',
|
|
168
|
+
type: 'post',
|
|
169
|
+
title: 'Hello world',
|
|
170
|
+
content: [],
|
|
171
|
+
excerpt: null,
|
|
172
|
+
cover_image_url: null,
|
|
173
|
+
author: { name: 'Adaeze Okafor', avatar_url: null },
|
|
174
|
+
published_at: '2026-09-15T12:00:00.000Z',
|
|
175
|
+
reading_time: 5,
|
|
176
|
+
...over,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const REL = [
|
|
181
|
+
post({ id: 'r1', slug: 'p-one' }),
|
|
182
|
+
post({ id: 'r2', slug: 'p-two' }),
|
|
183
|
+
];
|
|
184
|
+
|
|
185
|
+
/** Every covered state: mock setup + element. Keys match the JSON fixture. */
|
|
186
|
+
const STATES: Record<string, () => string> = {
|
|
187
|
+
'contact.out': () => { signedOut(); return render(<CheckoutContactSection />); },
|
|
188
|
+
'contact.in.initial': () => { signedIn(); return render(<CheckoutContactSection />); },
|
|
189
|
+
'contact.in.avatar': () => { signedIn({ user: AVATAR_USER }); return render(<CheckoutContactSection />); },
|
|
190
|
+
|
|
191
|
+
'shell.page.empty': () => { signedOut(); return render(<DefaultCheckoutShell {...shellProps({ totalCount: 0 })} />); },
|
|
192
|
+
'shell.page.full': () => { signedOut(); return render(<DefaultCheckoutShell {...shellProps({ totalCount: 3, subtotal: 4500, policies: POLICIES })} />); },
|
|
193
|
+
'shell.page.minimal': () => { signedOut(); return render(<DefaultCheckoutShell {...shellProps({ totalCount: 2, subtotal: 1000, addressPicker: null, shippingZonePicker: null, schedulePicker: null, deliveryMessage: { type: 'warning', message: 'Slow courier' } })} />); },
|
|
194
|
+
'shell.panel.summary.empty': () => { signedOut(); return render(<DefaultCheckoutShell {...shellProps({ mode: 'panel', activeTab: 'summary', totalCount: 0 })} />); },
|
|
195
|
+
'shell.panel.summary.items3': () => { signedOut(); return render(<DefaultCheckoutShell {...shellProps({ mode: 'panel', activeTab: 'summary', totalCount: 3, subtotal: 4500 })} />); },
|
|
196
|
+
'shell.panel.summary.items1': () => { signedOut(); return render(<DefaultCheckoutShell {...shellProps({ mode: 'panel', activeTab: 'summary', totalCount: 1, subtotal: 1500 })} />); },
|
|
197
|
+
'shell.panel.checkout': () => { signedOut(); return render(<DefaultCheckoutShell {...shellProps({ mode: 'panel', activeTab: 'checkout', totalCount: 2, subtotal: 2000 })} />); },
|
|
198
|
+
|
|
199
|
+
'policies.empty': () => { signedOut(); return render(<PolicyLinks policies={[]} />); },
|
|
200
|
+
'policies.list': () => { signedOut(); return render(<PolicyLinks policies={POLICIES} />); },
|
|
201
|
+
|
|
202
|
+
'login.default': () => { baseFlow(); return render(<AuthEmailLoginStep />); },
|
|
203
|
+
'login.loading': () => { baseFlow({ loading: true, email: 'ada@example.com', password: 'secret123' }); return render(<AuthEmailLoginStep />); },
|
|
204
|
+
'login.error': () => { baseFlow({ email: 'ada@example.com', password: 'secret123', error: 'Wrong email or password. Please try again.' }); return render(<AuthEmailLoginStep />); },
|
|
205
|
+
|
|
206
|
+
'otp.default': () => { baseFlow(); return render(<AuthEmailOtpStep />); },
|
|
207
|
+
'otp.loading': () => { baseFlow({ loading: true, email: 'ada@example.com' }); return render(<AuthEmailOtpStep />); },
|
|
208
|
+
'otp.error': () => { baseFlow({ email: 'ada@example.com', error: 'Something went wrong. Please try again.' }); return render(<AuthEmailOtpStep />); },
|
|
209
|
+
|
|
210
|
+
'verify.empty': () => { baseFlow({ email: 'ada@example.com', otp: '' }); return render(<AuthEmailOtpVerifyStep />); },
|
|
211
|
+
'verify.partial': () => { baseFlow({ email: 'ada@example.com', otp: '123' }); return render(<AuthEmailOtpVerifyStep />); },
|
|
212
|
+
'verify.loading': () => { baseFlow({ email: 'ada@example.com', otp: '123456', loading: true }); return render(<AuthEmailOtpVerifyStep />); },
|
|
213
|
+
'verify.error': () => { baseFlow({ email: 'ada@example.com', otp: '12', error: 'Incorrect code. Please try again.' }); return render(<AuthEmailOtpVerifyStep />); },
|
|
214
|
+
|
|
215
|
+
'register.default': () => { baseFlow(); return render(<AuthEmailRegisterStep />); },
|
|
216
|
+
'register.loading': () => { baseFlow({ loading: true, firstName: 'Adaeze', email: 'ada@example.com', password: 'secret123' }); return render(<AuthEmailRegisterStep />); },
|
|
217
|
+
'register.error': () => { baseFlow({ firstName: 'Adaeze', email: 'ada@example.com', password: 'secret123', error: 'Email already registered. Please sign in instead.' }); return render(<AuthEmailRegisterStep />); },
|
|
218
|
+
|
|
219
|
+
'signup.default': () => { baseFlow(); return render(<AuthSignupStep />); },
|
|
220
|
+
'signup.loading': () => { baseFlow({ loading: true, firstName: 'Adaeze', lastName: 'Okafor' }); return render(<AuthSignupStep />); },
|
|
221
|
+
'signup.error': () => { baseFlow({ firstName: 'Adaeze', error: 'Please enter your first name.' }); return render(<AuthSignupStep />); },
|
|
222
|
+
|
|
223
|
+
'filter.pills': () => render(<CategoryFilter categories={CATS} />),
|
|
224
|
+
'filter.pills.active': () => render(<CategoryFilter categories={CATS} active="news" />),
|
|
225
|
+
'filter.tabs': () => render(<CategoryFilter categories={CATS} variant="tabs" />),
|
|
226
|
+
'filter.tabs.active': () => render(<CategoryFilter categories={CATS} variant="tabs" active="recipes" />),
|
|
227
|
+
'filter.empty': () => render(<CategoryFilter categories={[]} />),
|
|
228
|
+
'filter.null': () => render(<CategoryFilter categories={null} />),
|
|
229
|
+
|
|
230
|
+
'page.mid': () => render(<BlogPagination pagination={{ current_page: 2, per_page: 12, has_more: true }} category="news" />),
|
|
231
|
+
'page.last': () => render(<BlogPagination pagination={{ current_page: 4, has_more: false }} />),
|
|
232
|
+
'page.first.next': () => render(<BlogPagination pagination={{ current_page: 1, has_more: true }} />),
|
|
233
|
+
'page.lone': () => render(<BlogPagination pagination={{ current_page: 1, has_more: false }} />),
|
|
234
|
+
'page.unset': () => render(<BlogPagination pagination={undefined} />),
|
|
235
|
+
|
|
236
|
+
'related.default': () => render(<RelatedPosts posts={REL as never} />),
|
|
237
|
+
'related.custom': () => render(<RelatedPosts posts={REL as never} heading="Keep reading" />),
|
|
238
|
+
'related.empty': () => render(<RelatedPosts posts={[]} />),
|
|
239
|
+
'related.null': () => render(<RelatedPosts posts={null} />),
|
|
240
|
+
|
|
241
|
+
'share.default': () => render(<ShareButtons title="Hello world" />),
|
|
242
|
+
|
|
243
|
+
'meta.full': () => render(<PostMeta post={post() as never} />),
|
|
244
|
+
'meta.reading1': () => render(<PostMeta post={post({ reading_time: 1 }) as never} />),
|
|
245
|
+
'meta.noauthor': () => render(<PostMeta post={post({ author: null }) as never} />),
|
|
246
|
+
'meta.minimal': () => render(<PostMeta post={post({ author: null, published_at: null, reading_time: 0 }) as never} />),
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const root = dirname(fileURLToPath(import.meta.url));
|
|
250
|
+
const BASELINES = JSON.parse(
|
|
251
|
+
readFileSync(join(root, 'fixtures', 'shopper-chrome-baseline.json'), 'utf-8'),
|
|
252
|
+
) as Record<string, string>;
|
|
253
|
+
|
|
254
|
+
describe('shopper chrome English safety (checkout/auth/blog)', () => {
|
|
255
|
+
it('covers every baseline state (fixture and STATES map stay in sync)', () => {
|
|
256
|
+
expect(Object.keys(STATES).sort()).toEqual(Object.keys(BASELINES).sort());
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
for (const [name, renderState] of Object.entries(STATES)) {
|
|
260
|
+
it(`${name}: byte-identical to the stored OLD markup`, () => {
|
|
261
|
+
expect(renderState()).toBe(BASELINES[name]);
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// S4 value guard: ShareButtons renders its copied branch only after a
|
|
266
|
+
// client-side clipboard write (internal useState, no prop), so no static
|
|
267
|
+
// baseline can capture it. Pin the English through the real `t` instead —
|
|
268
|
+
// any change to blog.share.copied fails here.
|
|
269
|
+
it("share.copied: English value pinned ('Copied')", () => {
|
|
270
|
+
expect(t(defaultThemeStrings, 'blog.share.copied')).toBe('Copied');
|
|
271
|
+
});
|
|
272
|
+
});
|