-
-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathruntime.test.ts
More file actions
338 lines (285 loc) · 11.1 KB
/
Copy pathruntime.test.ts
File metadata and controls
338 lines (285 loc) · 11.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
import { inspect } from 'node:util';
import { describe, expect, it, vi } from 'vitest';
import { Say } from './runtime.js';
type Locale = 'en' | 'fr' | 'de';
const messages = {
en: {
greeting: 'Hello',
items: '{count, plural, one {# item} other {# items}}',
named: 'Hello, {name}',
identified: 'Order {id}',
underscored: 'Total {_total}',
},
fr: { greeting: 'Bonjour', items: '{count, plural, one {# article} other {# articles}}' },
} satisfies Partial<Record<Locale, Say.Messages>>;
/**
* Build options with partial (or empty) messages. The public `Options` type
* requires a complete locale record or a loader; several tests deliberately
* exercise the looser runtime behaviour, so they opt out of that constraint.
*/
const opts = (o: {
locales: Locale[];
messages?: Partial<Record<Locale, Say.Messages>>;
loader?: Say.Loader<Locale>;
}) => o as Say.Options<Locale, Say.Loader<Locale> | undefined>;
/**
* Drop the bidi isolation marks the formatter wraps substituted values in, so
* an assertion can read as the sentence a user sees.
*/
function plain(formatted: string) {
return formatted.replaceAll(/[]/g, '');
}
function make(active?: Locale) {
const say = new Say<Locale>(opts({ locales: ['en', 'fr', 'de'], messages }));
if (active) say.activate(active);
return say;
}
describe('Say constructor', () => {
it('assigns messages passed to the constructor', () => {
const say = make('en');
expect(say.messages).toEqual(messages.en);
});
it('works without messages when a loader is provided', () => {
const loader = vi.fn((locale: Locale) => messages[locale as 'en' | 'fr'] ?? {});
const say = new Say<Locale>({ locales: ['en'], loader });
expect(say.locales).toEqual(['en']);
expect(loader).not.toHaveBeenCalled();
});
});
describe('Say.locale getter', () => {
it('throws when no locale is active', () => {
expect(() => make().locale).toThrow('No active locale');
});
it('returns the active locale', () => {
expect(make('fr').locale).toBe('fr');
});
});
describe('Say.messages getter', () => {
it('returns the messages for the active locale', () => {
expect(make('en').messages).toEqual(messages.en);
});
it('throws when no locale is active', () => {
expect(() => make().messages).toThrow('No active locale');
});
});
describe('Say.locales getter', () => {
it('returns all locales', () => {
expect(make().locales).toEqual(['en', 'fr', 'de']);
});
});
describe('Say.load', () => {
it('throws when called on a frozen instance', () => {
const frozen = make('en').freeze();
expect(() => (frozen as unknown as Say<Locale>).load()).toThrow(
'Cannot load messages on a frozen Say',
);
});
it('loads all locales when none are specified', () => {
const loader = vi.fn((locale: Locale) => ({ greeting: `hi-${locale}` }));
const say = new Say<Locale>({ locales: ['de'], loader });
const result = say.load();
expect(result).toBe(say);
expect(loader).toHaveBeenCalledWith('de');
expect(say.activate('de').messages).toEqual({ greeting: 'hi-de' });
});
it('skips locales that already have messages', () => {
const loader = vi.fn(() => ({ greeting: 'x' }));
const say = new Say<Locale>({ locales: ['en', 'fr'], messages, loader });
say.load('en', 'fr');
expect(loader).not.toHaveBeenCalled();
});
it('throws when no loader is provided', () => {
const say = new Say<Locale>(opts({ locales: ['de'], messages: {} }));
expect(() => say.load('de')).toThrow('No loader provided, cannot load messages');
});
it('assigns synchronously returned messages', () => {
const say = new Say<Locale>({ locales: ['de'], loader: () => ({ greeting: 'Hallo' }) });
const result = say.load('de');
expect(result).toBe(say);
expect(say.activate('de').messages).toEqual({ greeting: 'Hallo' });
});
it('returns a promise and assigns when the loader is async', async () => {
const loader = vi.fn(async (locale: Locale) => ({ greeting: `async-${locale}` }));
const say = new Say<Locale>({ locales: ['de'], loader });
const result = say.load('de');
expect(result).toBeInstanceOf(Promise);
const resolved = await result;
expect(resolved).toBe(say);
expect(say.activate('de').messages).toEqual({ greeting: 'async-de' });
});
});
describe('Say.assign', () => {
it('throws when called on a frozen instance', () => {
const frozen = make('en').freeze();
expect(() => (frozen as unknown as Say<Locale>).assign('en', {})).toThrow(
'Cannot assign messages on a frozen Say',
);
});
it('assigns messages to a single locale', () => {
const say = new Say<Locale>(opts({ locales: ['de'], messages: {} }));
say.assign('de', { greeting: 'Hallo' });
expect(say.activate('de').messages).toEqual({ greeting: 'Hallo' });
});
it('bulk assigns messages from a record', () => {
const say = new Say<Locale>(opts({ locales: ['en', 'fr'], messages: {} }));
say.assign(messages);
expect(say.activate('fr').messages).toEqual(messages.fr);
});
it('returns this', () => {
const say = new Say<Locale>(opts({ locales: ['de'], messages: {} }));
expect(say.assign('de', {})).toBe(say);
});
});
describe('Say.activate', () => {
it('throws when called on a frozen instance', () => {
const frozen = make('en').freeze();
expect(() => (frozen as unknown as Say<Locale>).activate('fr')).toThrow(
'Cannot activate locale on a frozen Say',
);
});
it('throws when there are no messages for the locale', () => {
expect(() => make().activate('de')).toThrow('No messages loaded for locale');
});
it('sets the active locale and returns this', () => {
const say = make();
expect(say.activate('en')).toBe(say);
expect(say.locale).toBe('en');
});
});
describe('Say.clone', () => {
it('copies locales, messages and active locale', () => {
const say = make('fr');
const copy = say.clone();
expect(copy).toBeInstanceOf(Say);
expect(copy.locales).toEqual(['en', 'fr', 'de']);
expect(copy.locale).toBe('fr');
expect(copy.messages).toEqual(messages.fr);
// Reactivating the clone does not affect the original.
copy.activate('en');
expect(copy.locale).toBe('en');
expect(say.locale).toBe('fr');
});
it('clones an instance with no active locale', () => {
const copy = make().clone();
expect(() => copy.locale).toThrow('No active locale');
});
});
describe('Say.freeze', () => {
it('makes the instance immutable', () => {
const frozen = make('en').freeze();
expect(Object.isFrozen(frozen)).toBe(true);
});
});
describe('Say iteration', () => {
it('yields a frozen, activated clone for each locale', () => {
const say = new Say<Locale>(opts({ locales: ['en', 'fr'], messages }));
const entries = [...say];
expect(entries.map(([, locale]) => locale)).toEqual(['en', 'fr']);
for (const [instance, locale] of entries) {
expect(Object.isFrozen(instance)).toBe(true);
expect(instance.locale).toBe(locale);
}
});
});
describe('Say.match', () => {
it('returns the first locale when no guesses are given', () => {
expect(make().match()).toBe('en');
});
it('returns the first locale when guesses are empty arrays', () => {
expect(make().match([])).toBe('en');
});
it('returns an exact match', () => {
expect(make().match('fr')).toBe('fr');
});
it('flattens array guesses', () => {
expect(make().match(['zz', 'fr'])).toBe('fr');
});
it('matches on the language prefix', () => {
expect(make().match('fr-CA')).toBe('fr');
});
it('skips guesses with an empty prefix', () => {
expect(make().match('', 'de')).toBe('de');
});
it('falls back to the first locale when nothing matches', () => {
expect(make().match('zz', 'xx-YY')).toBe('en');
});
});
describe('Say.call', () => {
it('formats a message for the active locale', () => {
const say = make('en');
expect(say.call({ id: 'greeting' })).toBe('Hello');
});
it('formats a message with placeholders', () => {
const say = make('en');
expect(say.call({ id: 'items', count: 1 })).toBe('1 item');
expect(say.call({ id: 'items', count: 5 })).toBe('5 items');
});
it('caches the compiled format across calls', () => {
const say = make('fr');
expect(say.call({ id: 'items', count: 1 })).toBe('1 article');
// Second call hits the cached format.
expect(say.call({ id: 'items', count: 2 })).toBe('2 articles');
});
it('throws when the message id is not found', () => {
expect(() => make('en').call({ id: 'missing' })).toThrow('Message for missing is not a string');
});
it('strips the underscore the transform compiles values behind', () => {
expect(plain(make('en').call({ id: 'named', _name: 'Ada' }))).toBe('Hello, Ada');
});
it('formats keys written without one, so a hand-written call still works', () => {
expect(plain(make('en').call({ id: 'named', name: 'Ada' }))).toBe('Hello, Ada');
});
it('formats a value named after the descriptor id', () => {
// The lookup still uses the id; the value only fills `{id}` in the message.
expect(plain(make('en').call({ id: 'identified', _id: '42' }))).toBe('Order 42');
});
it('does not expose the message id as a value', () => {
// `{id}` is left unresolved rather than filled with the message's own id.
expect(plain(make('en').call({ id: 'identified' }))).not.toContain('identified');
});
it('strips exactly one underscore, so a name that starts with one survives', () => {
expect(plain(make('en').call({ id: 'underscored', __total: '9' }))).toBe('Total 9');
});
it('treats a value named `__proto__` as a value, not as a prototype', () => {
// Assigning the stripped key would write through to `Object.prototype`
// rather than naming a placeholder, so the values are built from own
// entries instead.
const descriptor = { id: 'named', _name: 'Ada' };
Object.defineProperty(descriptor, '___proto__', {
value: { polluted: true },
enumerable: true,
});
expect(plain(make('en').call(descriptor))).toBe('Hello, Ada');
expect(({} as { polluted?: boolean }).polluted).toBeUndefined();
});
it('ignores keys a descriptor only inherits', () => {
const descriptor = Object.assign(Object.create({ _name: 'Ghost' }), { id: 'named' });
expect(plain(make('en').call(descriptor))).not.toContain('Ghost');
});
});
describe('Say inspect', () => {
it('includes the active locale when one is set', () => {
expect(inspect(make('en'))).toBe("Say<'en'> {}");
});
it('omits the locale when none is active', () => {
expect(inspect(make())).toBe('Say {}');
});
});
describe('Say macros', () => {
const say = make('en');
it('throws for plural', () => {
expect(() => say.plural(1, { other: '#' })).toThrow(
"'Say#plural' is a macro and must be used with the relevant saykit plugin",
);
});
it('throws for ordinal', () => {
expect(() => say.ordinal(1, { other: '#' })).toThrow(
"'Say#ordinal' is a macro and must be used with the relevant saykit plugin",
);
});
it('throws for select', () => {
expect(() => say.select('a', { other: 'b' })).toThrow(
"'Say#select' is a macro and must be used with the relevant saykit plugin",
);
});
});