@20syldev/api 4.0.0 → 4.1.0

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 (45) hide show
  1. package/README.md +3 -3
  2. package/docs/changelog.md +333 -0
  3. package/package.json +5 -4
  4. package/src/app.ts +7 -3
  5. package/src/config/versions.ts +22 -10
  6. package/src/constants.ts +19 -0
  7. package/src/middleware/cors.ts +1 -1
  8. package/src/modules/v4/algorithms.ts +43 -1
  9. package/src/modules/v4/chat.ts +24 -1
  10. package/src/modules/v4/dice.ts +39 -0
  11. package/src/modules/v4/encode.ts +170 -0
  12. package/src/modules/v4/geo.ts +53 -0
  13. package/src/modules/v4/palette.ts +103 -0
  14. package/src/modules/v4/placeholder.ts +122 -0
  15. package/src/modules/v4/statistics.ts +55 -0
  16. package/src/modules/v4/text.ts +300 -0
  17. package/src/modules/v4/tic_tac_toe.ts +33 -1
  18. package/src/modules/v4/validate.ts +55 -0
  19. package/src/modules/v4.ts +2 -0
  20. package/src/routes/delete.ts +72 -0
  21. package/src/routes/get.ts +47 -0
  22. package/src/routes/index.ts +2 -2
  23. package/src/routes/patch.ts +45 -0
  24. package/src/utils/version.ts +5 -0
  25. package/tests/integration/api.test.ts +395 -0
  26. package/tests/tsconfig.json +3 -0
  27. package/tests/unit/algorithms.test.ts +120 -0
  28. package/tests/unit/color.test.ts +33 -0
  29. package/tests/unit/convert.test.ts +42 -0
  30. package/tests/unit/dice.test.ts +57 -0
  31. package/tests/unit/domain.test.ts +47 -0
  32. package/tests/unit/encode.test.ts +108 -0
  33. package/tests/unit/geo.test.ts +45 -0
  34. package/tests/unit/hash.test.ts +37 -0
  35. package/tests/unit/hyperplanning.test.ts +77 -0
  36. package/tests/unit/levenshtein.test.ts +34 -0
  37. package/tests/unit/palette.test.ts +53 -0
  38. package/tests/unit/personal.test.ts +65 -0
  39. package/tests/unit/statistics.test.ts +57 -0
  40. package/tests/unit/text.test.ts +107 -0
  41. package/tests/unit/time.test.ts +67 -0
  42. package/tests/unit/token.test.ts +61 -0
  43. package/tests/unit/username.test.ts +34 -0
  44. package/tests/unit/validate.test.ts +71 -0
  45. package/tsconfig.test.json +9 -0
@@ -0,0 +1,395 @@
1
+ import { test, describe, before, after } from 'node:test';
2
+ import { strict as assert } from 'node:assert';
3
+ import type { AddressInfo } from 'node:net';
4
+ import type { Server } from 'node:http';
5
+ import app from '../../src/app.js';
6
+
7
+ let server: Server;
8
+ let baseUrl: string;
9
+
10
+ before(() => {
11
+ return new Promise<void>((resolve) => {
12
+ server = app.listen(0, () => {
13
+ const port = (server.address() as AddressInfo).port;
14
+ baseUrl = `http://127.0.0.1:${port}`;
15
+ resolve();
16
+ });
17
+ });
18
+ });
19
+
20
+ after(() => {
21
+ return new Promise<void>((resolve) => {
22
+ server.close(() => resolve());
23
+ });
24
+ });
25
+
26
+ async function getJson(path: string): Promise<{ status: number; body: Record<string, unknown> }> {
27
+ const res = await fetch(`${baseUrl}${path}`);
28
+ const body = (await res.json()) as Record<string, unknown>;
29
+ return { status: res.status, body };
30
+ }
31
+
32
+ async function sendJson(
33
+ method: string,
34
+ path: string,
35
+ body: Record<string, unknown>,
36
+ ): Promise<{ status: number; body: Record<string, unknown> }> {
37
+ const res = await fetch(`${baseUrl}${path}`, {
38
+ method,
39
+ headers: { 'Content-Type': 'application/json' },
40
+ body: JSON.stringify(body),
41
+ });
42
+ const data = (await res.json()) as Record<string, unknown>;
43
+ return { status: res.status, body: data };
44
+ }
45
+
46
+ // --- GET endpoints ---
47
+
48
+ describe('GET / (version listing)', () => {
49
+ test('v4 endpoints listed', async () => {
50
+ const { body } = await getJson('/v4');
51
+ assert.equal(body.version, 'v4');
52
+ const endpoints = body.endpoints as Record<string, Record<string, string>>;
53
+ assert.ok('dice' in endpoints.get!);
54
+ assert.ok('statistics' in endpoints.get!);
55
+ });
56
+
57
+ test('invalid version returns 404', async () => {
58
+ const { status } = await getJson('/v99');
59
+ assert.equal(status, 404);
60
+ });
61
+ });
62
+
63
+ describe('GET /v4/algorithms', () => {
64
+ test('fibonacci', async () => {
65
+ const { status, body } = await getJson('/v4/algorithms?method=fibonacci&value=10');
66
+ assert.equal(status, 200);
67
+ assert.deepEqual(body.answer, [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]);
68
+ });
69
+
70
+ test('isprime', async () => {
71
+ const { body } = await getJson('/v4/algorithms?method=isprime&value=17');
72
+ assert.equal(body.answer, true);
73
+ });
74
+
75
+ test('roman 2024 to MMXXIV', async () => {
76
+ const { body } = await getJson('/v4/algorithms?method=roman&value=2024');
77
+ assert.equal(body.answer, 'MMXXIV');
78
+ });
79
+
80
+ test('roman MMXXIV to 2024', async () => {
81
+ const { body } = await getJson('/v4/algorithms?method=roman&value=MMXXIV');
82
+ assert.equal(body.answer, 2024);
83
+ });
84
+ });
85
+
86
+ describe('GET /v4/captcha', () => {
87
+ test('returns PNG', async () => {
88
+ const res = await fetch(`${baseUrl}/v4/captcha?text=hello`);
89
+ assert.equal(res.status, 200);
90
+ assert.match(res.headers.get('content-type') ?? '', /image\/png/);
91
+ });
92
+
93
+ test('without text returns 400', async () => {
94
+ const { status } = await getJson('/v4/captcha');
95
+ assert.equal(status, 400);
96
+ });
97
+ });
98
+
99
+ describe('GET /v4/chat', () => {
100
+ test('fetches public messages', async () => {
101
+ await sendJson('POST', '/v4/chat', {
102
+ username: 'get-chat-user',
103
+ message: 'ping',
104
+ session: `get-chat-${Date.now()}`,
105
+ });
106
+ const res = await fetch(`${baseUrl}/v4/chat`);
107
+ assert.equal(res.status, 200);
108
+ const body = (await res.json()) as { username: string; message: string }[];
109
+ assert.ok(Array.isArray(body));
110
+ });
111
+ });
112
+
113
+ describe('GET /v4/color', () => {
114
+ test('returns color formats', async () => {
115
+ const { status, body } = await getJson('/v4/color');
116
+ assert.equal(status, 200);
117
+ assert.match(body.hex as string, /^#[0-9a-f]{6}$/);
118
+ assert.ok('rgb' in body);
119
+ assert.ok('hsl' in body);
120
+ assert.ok('cmyk' in body);
121
+ });
122
+ });
123
+
124
+ describe('GET /v4/convert', () => {
125
+ test('celsius to fahrenheit', async () => {
126
+ const { status, body } = await getJson('/v4/convert?value=100&from=celsius&to=fahrenheit');
127
+ assert.equal(status, 200);
128
+ assert.equal(body.result, 212);
129
+ });
130
+
131
+ test('missing value returns 400', async () => {
132
+ const { status } = await getJson('/v4/convert?from=celsius&to=fahrenheit');
133
+ assert.equal(status, 400);
134
+ });
135
+ });
136
+
137
+ describe('GET /v4/dice', () => {
138
+ test('rolls 2d6+3', async () => {
139
+ const { body } = await getJson('/v4/dice?roll=2d6%2B3');
140
+ assert.equal(body.count, 2);
141
+ assert.equal(body.sides, 6);
142
+ assert.equal(body.modifier, 3);
143
+ });
144
+
145
+ test('rejects invalid notation', async () => {
146
+ const { status } = await getJson('/v4/dice?roll=foo');
147
+ assert.equal(status, 400);
148
+ });
149
+ });
150
+
151
+ describe('GET /v4/domain', () => {
152
+ test('has TLD', async () => {
153
+ const { status, body } = await getJson('/v4/domain');
154
+ assert.equal(status, 200);
155
+ assert.match(body.domain as string, /\./);
156
+ });
157
+ });
158
+
159
+ describe('GET /v4/infos', () => {
160
+ test('returns API infos', async () => {
161
+ const { status, body } = await getJson('/v4/infos');
162
+ assert.equal(status, 200);
163
+ assert.ok('endpoints' in body);
164
+ assert.ok('documentation' in body);
165
+ });
166
+ });
167
+
168
+ describe('GET /v4/levenshtein', () => {
169
+ test('computes distance', async () => {
170
+ const { status, body } = await getJson('/v4/levenshtein?str1=kitten&str2=sitting');
171
+ assert.equal(status, 200);
172
+ assert.equal(body.distance, 3);
173
+ });
174
+ });
175
+
176
+ describe('GET /v4/personal', () => {
177
+ test('returns generated identity', async () => {
178
+ const { status, body } = await getJson('/v4/personal');
179
+ assert.equal(status, 200);
180
+ assert.ok('name' in body);
181
+ assert.ok('email' in body);
182
+ assert.ok('card' in body);
183
+ });
184
+ });
185
+
186
+ describe('GET /v4/qrcode', () => {
187
+ test('returns data URL', async () => {
188
+ const { status, body } = await getJson('/v4/qrcode?url=https%3A%2F%2Fexample.com');
189
+ assert.equal(status, 200);
190
+ assert.match(body as unknown as string, /^data:image\/png;base64,/);
191
+ });
192
+ });
193
+
194
+ describe('GET /v4/statistics', () => {
195
+ test('basic stats', async () => {
196
+ const { body } = await getJson('/v4/statistics?values=1,2,3,4,5');
197
+ assert.equal(body.mean, 3);
198
+ assert.equal(body.median, 3);
199
+ assert.equal(body.count, 5);
200
+ });
201
+
202
+ test('missing values returns 400', async () => {
203
+ const { status } = await getJson('/v4/statistics');
204
+ assert.equal(status, 400);
205
+ });
206
+ });
207
+
208
+ describe('GET /v4/time', () => {
209
+ test('live', async () => {
210
+ const { status, body } = await getJson('/v4/time');
211
+ assert.equal(status, 200);
212
+ assert.ok('iso' in body);
213
+ assert.ok('timestamp' in body);
214
+ });
215
+
216
+ test('random in range', async () => {
217
+ const { status, body } = await getJson('/v4/time?type=random&start=2020-01-01&end=2020-12-31');
218
+ assert.equal(status, 200);
219
+ const ts = body.timestamp as number;
220
+ assert.ok(ts >= new Date('2020-01-01').getTime());
221
+ assert.ok(ts <= new Date('2020-12-31').getTime());
222
+ });
223
+ });
224
+
225
+ describe('GET /v4/username', () => {
226
+ test('has fields', async () => {
227
+ const { status, body } = await getJson('/v4/username');
228
+ assert.equal(status, 200);
229
+ assert.ok('username' in body);
230
+ assert.ok('adjective' in body);
231
+ assert.ok('animal' in body);
232
+ });
233
+ });
234
+
235
+ describe('GET /v4/website', () => {
236
+ const originalFetch = globalThis.fetch;
237
+
238
+ after(() => {
239
+ globalThis.fetch = originalFetch;
240
+ });
241
+
242
+ test('returns aggregated payload (with mocked GitHub API)', async () => {
243
+ globalThis.fetch = (async (input: string | URL | Request) => {
244
+ const url = typeof input === 'string' ? input : input.toString();
245
+ if (url.includes('api.github.com')) {
246
+ return new Response(
247
+ JSON.stringify({
248
+ data: {
249
+ user: {
250
+ contributionsCollection: {
251
+ contributionCalendar: {
252
+ weeks: [
253
+ {
254
+ firstDay: '2026-01-01',
255
+ contributionDays: [{ date: '2026-01-01', contributionCount: 5 }],
256
+ },
257
+ ],
258
+ },
259
+ },
260
+ },
261
+ },
262
+ }),
263
+ { status: 200, headers: { 'content-type': 'application/json' } },
264
+ );
265
+ }
266
+ return originalFetch(input);
267
+ }) as typeof fetch;
268
+
269
+ const { status, body } = await getJson('/v4/website');
270
+ assert.equal(status, 200);
271
+ assert.ok('versions' in body);
272
+ assert.ok('stats' in body);
273
+ });
274
+
275
+ test('?key=stats.5 returns sub-key', async () => {
276
+ const { status, body } = await getJson('/v4/website?key=stats.5');
277
+ assert.equal(status, 200);
278
+ assert.ok('stats.5' in body);
279
+ });
280
+
281
+ test('?key=invalid.path returns 404', async () => {
282
+ const { status } = await getJson('/v4/website?key=does.not.exist');
283
+ assert.equal(status, 404);
284
+ });
285
+
286
+ test('rejects prototype pollution keys', async () => {
287
+ const { status } = await getJson('/v4/website?key=__proto__');
288
+ assert.equal(status, 400);
289
+ });
290
+ });
291
+
292
+ // --- POST endpoints ---
293
+
294
+ describe('POST /v4/chat', () => {
295
+ const session = `chat-${Date.now()}`;
296
+
297
+ test('sends a public message', async () => {
298
+ const { status, body } = await sendJson('POST', '/v4/chat', {
299
+ username: 'alice',
300
+ message: 'public hello',
301
+ session,
302
+ });
303
+ assert.equal(status, 200);
304
+ assert.match(body.message as string, /sent/);
305
+ });
306
+
307
+ test('fetches private chat (legacy)', async () => {
308
+ const token = `tok-${Date.now()}`;
309
+ await sendJson('POST', '/v4/chat', {
310
+ username: 'bob',
311
+ message: 'secret',
312
+ session: `chat-bob-${Date.now()}`,
313
+ token,
314
+ });
315
+ const { status, body } = await sendJson('POST', '/v4/chat/private', {
316
+ username: 'bob',
317
+ token,
318
+ });
319
+ assert.equal(status, 200);
320
+ assert.ok(Array.isArray(body));
321
+ });
322
+ });
323
+
324
+ describe('POST /v4/hash', () => {
325
+ test('sha256', async () => {
326
+ const { status, body } = await sendJson('POST', '/v4/hash', { text: 'hello', method: 'sha256' });
327
+ assert.equal(status, 200);
328
+ assert.equal(body.hash, '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824');
329
+ });
330
+
331
+ test('md5', async () => {
332
+ const { body } = await sendJson('POST', '/v4/hash', { text: 'hello', method: 'md5' });
333
+ assert.equal(body.hash, '5d41402abc4b2a76b9719d911017c592');
334
+ });
335
+
336
+ test('missing text returns 400', async () => {
337
+ const { status } = await sendJson('POST', '/v4/hash', { method: 'sha256' });
338
+ assert.equal(status, 400);
339
+ });
340
+ });
341
+
342
+ describe('POST /v4/tic-tac-toe', () => {
343
+ const game = 'TTT' + Date.now().toString(36).slice(-3).toUpperCase();
344
+ const session = `ttt-${Date.now()}`;
345
+
346
+ test('list returns games', async () => {
347
+ const { status, body } = await sendJson('POST', '/v4/tic-tac-toe/list', {});
348
+ assert.equal(status, 200);
349
+ assert.ok(Array.isArray(body.games));
350
+ });
351
+
352
+ test('plays a move', async () => {
353
+ const { status, body } = await sendJson('POST', '/v4/tic-tac-toe', {
354
+ username: 'alice',
355
+ move: '1-1',
356
+ session,
357
+ game,
358
+ });
359
+ assert.equal(status, 200);
360
+ assert.match(body.message as string, /Move sent/);
361
+ });
362
+
363
+ test('fetch returns game state', async () => {
364
+ const { status, body } = await sendJson('POST', '/v4/tic-tac-toe/fetch', {
365
+ username: 'alice',
366
+ game,
367
+ });
368
+ assert.equal(status, 200);
369
+ assert.equal(body.id, game);
370
+ });
371
+
372
+ test('missing move returns 400', async () => {
373
+ const { status } = await sendJson('POST', '/v4/tic-tac-toe', {
374
+ username: 'alice',
375
+ session,
376
+ game,
377
+ });
378
+ assert.equal(status, 400);
379
+ });
380
+ });
381
+
382
+ describe('POST /v4/token', () => {
383
+ test('alpha 24', async () => {
384
+ const { status, body } = await sendJson('POST', '/v4/token', { len: 24, type: 'alpha' });
385
+ assert.equal(status, 200);
386
+ assert.equal(typeof body.token, 'string');
387
+ assert.equal((body.token as string).length, 24);
388
+ assert.match(body.token as string, /^[a-zA-Z]+$/);
389
+ });
390
+
391
+ test('below min length returns 400', async () => {
392
+ const { status } = await sendJson('POST', '/v4/token', { len: 5, type: 'alpha' });
393
+ assert.equal(status, 400);
394
+ });
395
+ });
@@ -0,0 +1,3 @@
1
+ {
2
+ "extends": "../tsconfig.test.json"
3
+ }
@@ -0,0 +1,120 @@
1
+ import { test, describe } from 'node:test';
2
+ import { strict as assert } from 'node:assert';
3
+ import {
4
+ anagram,
5
+ factorial,
6
+ fibonacci,
7
+ gcd,
8
+ isprime,
9
+ palindrome,
10
+ primefactors,
11
+ reverse,
12
+ roman,
13
+ } from '../../src/modules/v4/algorithms.js';
14
+
15
+ describe('algorithms', () => {
16
+ describe('anagram', () => {
17
+ test('listen / silent', () => {
18
+ assert.equal(anagram('listen', 'silent'), true);
19
+ });
20
+
21
+ test('hello / world', () => {
22
+ assert.equal(anagram('hello', 'world'), false);
23
+ });
24
+ });
25
+
26
+ describe('factorial', () => {
27
+ test('5! = 120', () => {
28
+ assert.equal(factorial(5), 120);
29
+ });
30
+
31
+ test('0! = 1', () => {
32
+ assert.equal(factorial(0), 1);
33
+ });
34
+
35
+ test('throws on negative', () => {
36
+ assert.throws(() => factorial(-1), /positive/);
37
+ });
38
+ });
39
+
40
+ describe('fibonacci', () => {
41
+ test('first 10 values', () => {
42
+ assert.deepEqual(fibonacci(10), [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]);
43
+ });
44
+ });
45
+
46
+ describe('gcd', () => {
47
+ test('gcd(12, 18) = 6', () => {
48
+ assert.equal(gcd(12, 18), 6);
49
+ });
50
+
51
+ test('gcd(7, 13) = 1', () => {
52
+ assert.equal(gcd(7, 13), 1);
53
+ });
54
+ });
55
+
56
+ describe('isprime', () => {
57
+ test('17 is prime', () => {
58
+ assert.equal(isprime(17), true);
59
+ });
60
+
61
+ test('15 is not prime', () => {
62
+ assert.equal(isprime(15), false);
63
+ });
64
+ });
65
+
66
+ describe('palindrome', () => {
67
+ test('madam is palindrome', () => {
68
+ assert.equal(palindrome('madam'), true);
69
+ });
70
+
71
+ test('hello is not', () => {
72
+ assert.equal(palindrome('hello'), false);
73
+ });
74
+ });
75
+
76
+ describe('primefactors', () => {
77
+ test('factors of 60', () => {
78
+ assert.deepEqual(primefactors(60), [2, 2, 3, 5]);
79
+ });
80
+ });
81
+
82
+ describe('reverse', () => {
83
+ test('reverses a string', () => {
84
+ assert.equal(reverse('abc'), 'cba');
85
+ });
86
+ });
87
+
88
+ describe('roman', () => {
89
+ test('2024 → MMXXIV', () => {
90
+ assert.equal(roman('2024'), 'MMXXIV');
91
+ });
92
+
93
+ test('MMXXIV → 2024', () => {
94
+ assert.equal(roman('MMXXIV'), 2024);
95
+ });
96
+
97
+ test('1 → I', () => {
98
+ assert.equal(roman('1'), 'I');
99
+ });
100
+
101
+ test('3999 → MMMCMXCIX', () => {
102
+ assert.equal(roman('3999'), 'MMMCMXCIX');
103
+ });
104
+
105
+ test('round-trip 1..100', () => {
106
+ for (let i = 1; i <= 100; i++) {
107
+ const r = roman(String(i));
108
+ assert.equal(roman(r as string), i);
109
+ }
110
+ });
111
+
112
+ test('throws on out of range', () => {
113
+ assert.throws(() => roman('4000'), /between 1 and 3999/);
114
+ });
115
+
116
+ test('throws on invalid roman', () => {
117
+ assert.throws(() => roman('XYZ'), /Invalid Roman/);
118
+ });
119
+ });
120
+ });
@@ -0,0 +1,33 @@
1
+ import { test, describe } from 'node:test';
2
+ import { strict as assert } from 'node:assert';
3
+ import color from '../../src/modules/v4/color.js';
4
+
5
+ describe('color', () => {
6
+ test('returns all 6 formats', () => {
7
+ const result = color();
8
+ assert.ok('hex' in result);
9
+ assert.ok('rgb' in result);
10
+ assert.ok('hsl' in result);
11
+ assert.ok('hsv' in result);
12
+ assert.ok('hwb' in result);
13
+ assert.ok('cmyk' in result);
14
+ });
15
+
16
+ test('hex matches #RRGGBB', () => {
17
+ for (let i = 0; i < 20; i++) {
18
+ assert.match(color().hex!, /^#[0-9a-f]{6}$/);
19
+ }
20
+ });
21
+
22
+ test('rgb format', () => {
23
+ assert.match(color().rgb!, /^rgb\(\d{1,3}, \d{1,3}, \d{1,3}\)$/);
24
+ });
25
+
26
+ test('hsl format', () => {
27
+ assert.match(color().hsl!, /^hsl\(\d+(\.\d+)?, \d+(\.\d+)?%, \d+(\.\d+)?%\)$/);
28
+ });
29
+
30
+ test('cmyk format with 4 channels', () => {
31
+ assert.match(color().cmyk!, /^cmyk\(/);
32
+ });
33
+ });
@@ -0,0 +1,42 @@
1
+ import { test, describe } from 'node:test';
2
+ import { strict as assert } from 'node:assert';
3
+ import convert from '../../src/modules/v4/convert.js';
4
+
5
+ describe('convert', () => {
6
+ test('celsius to fahrenheit', () => {
7
+ const result = convert(0, 'celsius', 'fahrenheit');
8
+ assert.equal(result.result, 32);
9
+ });
10
+
11
+ test('celsius to kelvin', () => {
12
+ const result = convert(0, 'celsius', 'kelvin');
13
+ assert.equal(result.result, 273.15);
14
+ });
15
+
16
+ test('fahrenheit to celsius', () => {
17
+ const result = convert(32, 'fahrenheit', 'celsius');
18
+ assert.equal(result.result, 0);
19
+ });
20
+
21
+ test('kelvin to celsius', () => {
22
+ const result = convert(273.15, 'kelvin', 'celsius');
23
+ assert.equal(result.result, 0);
24
+ });
25
+
26
+ test('case-insensitive units', () => {
27
+ const result = convert(100, 'CELSIUS', 'Fahrenheit');
28
+ assert.equal(result.result, 212);
29
+ });
30
+
31
+ test('throws on invalid unit', () => {
32
+ assert.throws(() => convert(100, 'celsius', 'banana'), /Invalid conversion/);
33
+ });
34
+
35
+ test('throws on absolute zero violation', () => {
36
+ assert.throws(() => convert(-300, 'celsius', 'kelvin'), /absolute zero/);
37
+ });
38
+
39
+ test('throws on non-numeric value', () => {
40
+ assert.throws(() => convert('abc', 'celsius', 'kelvin'), /must be a number/);
41
+ });
42
+ });
@@ -0,0 +1,57 @@
1
+ import { test, describe } from 'node:test';
2
+ import { strict as assert } from 'node:assert';
3
+ import dice from '../../src/modules/v4/dice.js';
4
+
5
+ describe('dice', () => {
6
+ test('parses 2d6+3 notation', () => {
7
+ const result = dice('2d6+3');
8
+ assert.equal(result.count, 2);
9
+ assert.equal(result.sides, 6);
10
+ assert.equal(result.modifier, 3);
11
+ assert.equal(result.results.length, 2);
12
+ });
13
+
14
+ test('total includes positive modifier', () => {
15
+ const result = dice('1d2+10');
16
+ assert.equal(result.modifier, 10);
17
+ assert.ok(result.total === 11 || result.total === 12);
18
+ });
19
+
20
+ test('negative modifier', () => {
21
+ const result = dice('1d2-5');
22
+ assert.equal(result.modifier, -5);
23
+ assert.ok(result.total === -4 || result.total === -3);
24
+ });
25
+
26
+ test('default count of 1 when omitted', () => {
27
+ const result = dice('d20');
28
+ assert.equal(result.count, 1);
29
+ assert.equal(result.sides, 20);
30
+ });
31
+
32
+ test('all results are within 1..sides', () => {
33
+ for (let i = 0; i < 50; i++) {
34
+ const result = dice('5d10');
35
+ for (const r of result.results) {
36
+ assert.ok(r >= 1 && r <= 10, `result ${r} out of range`);
37
+ }
38
+ }
39
+ });
40
+
41
+ test('handles space instead of +', () => {
42
+ const result = dice('2d6 3');
43
+ assert.equal(result.modifier, 3);
44
+ });
45
+
46
+ test('throws on invalid notation', () => {
47
+ assert.throws(() => dice('2x6'), /Invalid notation/);
48
+ });
49
+
50
+ test('throws on too many dice', () => {
51
+ assert.throws(() => dice('200d6'), /between 1 and 100/);
52
+ });
53
+
54
+ test('throws on invalid sides', () => {
55
+ assert.throws(() => dice('2d1'), /between 2 and 1000/);
56
+ });
57
+ });