@20syldev/api 4.2.0 → 4.4.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 (98) hide show
  1. package/README.md +3 -3
  2. package/dist/app.js +4 -0
  3. package/dist/app.js.map +1 -1
  4. package/dist/config/versions.js +19 -0
  5. package/dist/config/versions.js.map +1 -1
  6. package/dist/constants.js +2 -2
  7. package/dist/constants.js.map +1 -1
  8. package/dist/modules/v3/chat.js +1 -1
  9. package/dist/modules/v3/chat.js.map +1 -1
  10. package/dist/modules/v3/domain.js +1 -1
  11. package/dist/modules/v3/domain.js.map +1 -1
  12. package/dist/modules/v3/hyperplanning.js +1 -1
  13. package/dist/modules/v3/hyperplanning.js.map +1 -1
  14. package/dist/modules/v3/personal.js +1 -1
  15. package/dist/modules/v3/personal.js.map +1 -1
  16. package/dist/modules/v3/username.js +1 -1
  17. package/dist/modules/v3/username.js.map +1 -1
  18. package/dist/modules/v4/captcha.js +60 -23
  19. package/dist/modules/v4/captcha.js.map +1 -1
  20. package/dist/modules/v4/chat.js +1 -1
  21. package/dist/modules/v4/chat.js.map +1 -1
  22. package/dist/modules/v4/color.js +44 -35
  23. package/dist/modules/v4/color.js.map +1 -1
  24. package/dist/modules/v4/colors.js +54 -0
  25. package/dist/modules/v4/colors.js.map +1 -0
  26. package/dist/modules/v4/convert.js +93 -19
  27. package/dist/modules/v4/convert.js.map +1 -1
  28. package/dist/modules/v4/domain.js +1 -1
  29. package/dist/modules/v4/domain.js.map +1 -1
  30. package/dist/modules/v4/hash.js +11 -4
  31. package/dist/modules/v4/hash.js.map +1 -1
  32. package/dist/modules/v4/hyperplanning.js +1 -1
  33. package/dist/modules/v4/hyperplanning.js.map +1 -1
  34. package/dist/modules/v4/palette.js +2 -43
  35. package/dist/modules/v4/palette.js.map +1 -1
  36. package/dist/modules/v4/personal.js +1 -1
  37. package/dist/modules/v4/personal.js.map +1 -1
  38. package/dist/modules/v4/placeholder.js +108 -52
  39. package/dist/modules/v4/placeholder.js.map +1 -1
  40. package/dist/modules/v4/qrcode.js +79 -6
  41. package/dist/modules/v4/qrcode.js.map +1 -1
  42. package/dist/modules/v4/username.js +1 -1
  43. package/dist/modules/v4/username.js.map +1 -1
  44. package/dist/modules/v4.js +6 -0
  45. package/dist/modules/v4.js.map +1 -1
  46. package/dist/routes/delete.js +16 -17
  47. package/dist/routes/delete.js.map +1 -1
  48. package/dist/routes/get.js +212 -13
  49. package/dist/routes/get.js.map +1 -1
  50. package/dist/routes/index.js +2 -2
  51. package/dist/routes/index.js.map +1 -1
  52. package/dist/routes/patch.js +2 -3
  53. package/dist/routes/patch.js.map +1 -1
  54. package/dist/routes/post.js +10 -3
  55. package/dist/routes/post.js.map +1 -1
  56. package/dist/utils/colors.js +54 -0
  57. package/dist/utils/colors.js.map +1 -0
  58. package/dist/utils/helpers.js +26 -0
  59. package/dist/utils/helpers.js.map +1 -0
  60. package/package.json +1 -1
  61. package/src/app.ts +4 -0
  62. package/src/config/versions.ts +16 -0
  63. package/src/constants.ts +0 -2
  64. package/src/middleware/cors.ts +1 -1
  65. package/src/modules/v4/captcha.ts +84 -27
  66. package/src/modules/v4/chat.ts +25 -2
  67. package/src/modules/v4/color.ts +56 -52
  68. package/src/modules/v4/convert.ts +100 -20
  69. package/src/modules/v4/domain.ts +1 -1
  70. package/src/modules/v4/geo.ts +53 -0
  71. package/src/modules/v4/hash.ts +18 -4
  72. package/src/modules/v4/hyperplanning.ts +1 -1
  73. package/src/modules/v4/palette.ts +63 -0
  74. package/src/modules/v4/personal.ts +1 -1
  75. package/src/modules/v4/placeholder.ts +189 -0
  76. package/src/modules/v4/qrcode.ts +108 -6
  77. package/src/modules/v4/tic_tac_toe.ts +33 -1
  78. package/src/modules/v4/username.ts +1 -1
  79. package/src/modules/v4.ts +3 -0
  80. package/src/routes/delete.ts +71 -0
  81. package/src/routes/get.ts +147 -15
  82. package/src/routes/patch.ts +44 -0
  83. package/src/routes/post.ts +11 -3
  84. package/src/utils/colors.ts +91 -0
  85. package/src/utils/helpers.ts +90 -0
  86. package/src/utils/response.ts +8 -0
  87. package/tests/integration/api.test.ts +113 -25
  88. package/tests/unit/captcha.test.ts +61 -0
  89. package/tests/unit/chat.test.ts +53 -0
  90. package/tests/unit/color.test.ts +24 -4
  91. package/tests/unit/convert.test.ts +26 -1
  92. package/tests/unit/geo.test.ts +45 -0
  93. package/tests/unit/hash.test.ts +17 -10
  94. package/tests/unit/palette.test.ts +53 -0
  95. package/tests/unit/placeholder.test.ts +57 -0
  96. package/tests/unit/qrcode.test.ts +82 -0
  97. package/tests/unit/tic_tac_toe.test.ts +55 -0
  98. package/src/modules/v4/utils.ts +0 -38
@@ -52,6 +52,9 @@ describe('GET / (version listing)', () => {
52
52
  const endpoints = body.endpoints as Record<string, Record<string, string>>;
53
53
  assert.ok('dice' in endpoints.get!);
54
54
  assert.ok('encode' in endpoints.get!);
55
+ assert.ok('geo' in endpoints.get!);
56
+ assert.ok('palette' in endpoints.get!);
57
+ assert.ok('placeholder' in endpoints.get!);
55
58
  assert.ok('statistics' in endpoints.get!);
56
59
  assert.ok('text' in endpoints.get!);
57
60
  assert.ok('validate' in endpoints.get!);
@@ -87,15 +90,23 @@ describe('GET /v4/algorithms', () => {
87
90
  });
88
91
 
89
92
  describe('GET /v4/captcha', () => {
90
- test('returns PNG', async () => {
93
+ test('returns PNG with provided text', async () => {
91
94
  const res = await fetch(`${baseUrl}/v4/captcha?text=hello`);
92
95
  assert.equal(res.status, 200);
93
96
  assert.match(res.headers.get('content-type') ?? '', /image\/png/);
97
+ assert.equal(res.headers.get('x-captcha-text'), 'hello');
94
98
  });
95
99
 
96
- test('without text returns 400', async () => {
97
- const { status } = await getJson('/v4/captcha');
98
- assert.equal(status, 400);
100
+ test('auto-generates text when none provided', async () => {
101
+ const res = await fetch(`${baseUrl}/v4/captcha`);
102
+ assert.equal(res.status, 200);
103
+ assert.match(res.headers.get('content-type') ?? '', /image\/png/);
104
+ assert.ok(res.headers.get('x-captcha-text')!.length > 0);
105
+ });
106
+
107
+ test('accepts noise and dimensions', async () => {
108
+ const res = await fetch(`${baseUrl}/v4/captcha?text=AB&noise=high&width=200&height=80`);
109
+ assert.equal(res.status, 200);
99
110
  });
100
111
  });
101
112
 
@@ -114,7 +125,7 @@ describe('GET /v4/chat', () => {
114
125
  });
115
126
 
116
127
  describe('GET /v4/color', () => {
117
- test('returns color formats', async () => {
128
+ test('returns random color formats', async () => {
118
129
  const { status, body } = await getJson('/v4/color');
119
130
  assert.equal(status, 200);
120
131
  assert.match(body.hex as string, /^#[0-9a-f]{6}$/);
@@ -122,6 +133,12 @@ describe('GET /v4/color', () => {
122
133
  assert.ok('hsl' in body);
123
134
  assert.ok('cmyk' in body);
124
135
  });
136
+
137
+ test('accepts hex input', async () => {
138
+ const { status, body } = await getJson('/v4/color?hex=ff6600');
139
+ assert.equal(status, 200);
140
+ assert.equal(body.hex, '#ff6600');
141
+ });
125
142
  });
126
143
 
127
144
  describe('GET /v4/convert', () => {
@@ -131,6 +148,12 @@ describe('GET /v4/convert', () => {
131
148
  assert.equal(body.result, 212);
132
149
  });
133
150
 
151
+ test('km to mi', async () => {
152
+ const { status, body } = await getJson('/v4/convert?value=1&from=km&to=mi');
153
+ assert.equal(status, 200);
154
+ assert.ok(Math.abs((body.result as number) - 0.621371) < 0.001);
155
+ });
156
+
134
157
  test('missing value returns 400', async () => {
135
158
  const { status } = await getJson('/v4/convert?from=celsius&to=fahrenheit');
136
159
  assert.equal(status, 400);
@@ -151,6 +174,14 @@ describe('GET /v4/dice', () => {
151
174
  });
152
175
  });
153
176
 
177
+ describe('GET /v4/domain', () => {
178
+ test('has TLD', async () => {
179
+ const { status, body } = await getJson('/v4/domain');
180
+ assert.equal(status, 200);
181
+ assert.match(body.domain as string, /\./);
182
+ });
183
+ });
184
+
154
185
  describe('GET /v4/encode', () => {
155
186
  test('base64 encode', async () => {
156
187
  const { status, body } = await getJson('/v4/encode?method=base64encode&text=hello');
@@ -169,11 +200,16 @@ describe('GET /v4/encode', () => {
169
200
  });
170
201
  });
171
202
 
172
- describe('GET /v4/domain', () => {
173
- test('has TLD', async () => {
174
- const { status, body } = await getJson('/v4/domain');
175
- assert.equal(status, 200);
176
- assert.match(body.domain as string, /\./);
203
+ describe('GET /v4/geo', () => {
204
+ test('Paris-NYC distance', async () => {
205
+ const { body } = await getJson('/v4/geo?lat1=48.8566&lon1=2.3522&lat2=40.7128&lon2=-74.006');
206
+ const distance = body.distance as { km: number };
207
+ assert.ok(Math.abs(distance.km - 5837) < 50);
208
+ });
209
+
210
+ test('missing param returns 400', async () => {
211
+ const { status } = await getJson('/v4/geo?lat1=0&lon1=0');
212
+ assert.equal(status, 400);
177
213
  });
178
214
  });
179
215
 
@@ -194,6 +230,18 @@ describe('GET /v4/levenshtein', () => {
194
230
  });
195
231
  });
196
232
 
233
+ describe('GET /v4/palette', () => {
234
+ test('triadic returns 3 colors', async () => {
235
+ const { body } = await getJson('/v4/palette?color=%23ff6600&type=triadic');
236
+ assert.equal((body.colors as unknown[]).length, 3);
237
+ });
238
+
239
+ test('invalid type returns 400', async () => {
240
+ const { status } = await getJson('/v4/palette?color=%23ff6600&type=rainbow');
241
+ assert.equal(status, 400);
242
+ });
243
+ });
244
+
197
245
  describe('GET /v4/personal', () => {
198
246
  test('returns generated identity', async () => {
199
247
  const { status, body } = await getJson('/v4/personal');
@@ -204,9 +252,33 @@ describe('GET /v4/personal', () => {
204
252
  });
205
253
  });
206
254
 
255
+ describe('GET /v4/placeholder', () => {
256
+ test('image returns SVG', async () => {
257
+ const res = await fetch(`${baseUrl}/v4/placeholder?type=image&width=100&height=50`);
258
+ assert.equal(res.status, 200);
259
+ assert.match(res.headers.get('content-type') ?? '', /image\/svg\+xml/);
260
+ });
261
+
262
+ test('skeleton returns SVG', async () => {
263
+ const res = await fetch(`${baseUrl}/v4/placeholder?type=skeleton&width=200&height=100`);
264
+ assert.equal(res.status, 200);
265
+ assert.match(res.headers.get('content-type') ?? '', /image\/svg\+xml/);
266
+ const body = await res.text();
267
+ assert.match(body, /<svg/);
268
+ });
269
+ });
270
+
207
271
  describe('GET /v4/qrcode', () => {
208
- test('returns data URL', async () => {
209
- const { status, body } = await getJson('/v4/qrcode?url=https%3A%2F%2Fexample.com');
272
+ test('returns PNG image by default', async () => {
273
+ const res = await fetch(`${baseUrl}/v4/qrcode?url=https%3A%2F%2Fexample.com`);
274
+ assert.equal(res.status, 200);
275
+ assert.match(res.headers.get('content-type') ?? '', /image\/png/);
276
+ const buf = Buffer.from(await res.arrayBuffer());
277
+ assert.ok(buf.subarray(0, 4).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47])));
278
+ });
279
+
280
+ test('returns data URL with format=base64', async () => {
281
+ const { status, body } = await getJson('/v4/qrcode?url=https%3A%2F%2Fexample.com&format=base64');
210
282
  assert.equal(status, 200);
211
283
  assert.match(body as unknown as string, /^data:image\/png;base64,/);
212
284
  });
@@ -260,6 +332,16 @@ describe('GET /v4/time', () => {
260
332
  });
261
333
  });
262
334
 
335
+ describe('GET /v4/username', () => {
336
+ test('has fields', async () => {
337
+ const { status, body } = await getJson('/v4/username');
338
+ assert.equal(status, 200);
339
+ assert.ok('username' in body);
340
+ assert.ok('adjective' in body);
341
+ assert.ok('animal' in body);
342
+ });
343
+ });
344
+
263
345
  describe('GET /v4/validate', () => {
264
346
  test('valid luhn', async () => {
265
347
  const { body } = await getJson('/v4/validate?type=luhn&value=4111111111111111');
@@ -277,16 +359,6 @@ describe('GET /v4/validate', () => {
277
359
  });
278
360
  });
279
361
 
280
- describe('GET /v4/username', () => {
281
- test('has fields', async () => {
282
- const { status, body } = await getJson('/v4/username');
283
- assert.equal(status, 200);
284
- assert.ok('username' in body);
285
- assert.ok('adjective' in body);
286
- assert.ok('animal' in body);
287
- });
288
- });
289
-
290
362
  describe('GET /v4/website', () => {
291
363
  const originalFetch = globalThis.fetch;
292
364
 
@@ -327,10 +399,10 @@ describe('GET /v4/website', () => {
327
399
  assert.ok('stats' in body);
328
400
  });
329
401
 
330
- test('?key=stats.5 returns sub-key', async () => {
331
- const { status, body } = await getJson('/v4/website?key=stats.5');
402
+ test('?key=active returns sub-key', async () => {
403
+ const { status, body } = await getJson('/v4/website?key=active');
332
404
  assert.equal(status, 200);
333
- assert.ok('stats.5' in body);
405
+ assert.ok('active' in body);
334
406
  });
335
407
 
336
408
  test('?key=invalid.path returns 404', async () => {
@@ -381,6 +453,7 @@ describe('POST /v4/hash', () => {
381
453
  const { status, body } = await sendJson('POST', '/v4/hash', { text: 'hello', method: 'sha256' });
382
454
  assert.equal(status, 200);
383
455
  assert.equal(body.hash, '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824');
456
+ assert.equal(body.encoding, 'hex');
384
457
  });
385
458
 
386
459
  test('md5', async () => {
@@ -388,6 +461,21 @@ describe('POST /v4/hash', () => {
388
461
  assert.equal(body.hash, '5d41402abc4b2a76b9719d911017c592');
389
462
  });
390
463
 
464
+ test('base64 encoding', async () => {
465
+ const { status, body } = await sendJson('POST', '/v4/hash', {
466
+ text: 'hello',
467
+ method: 'sha256',
468
+ encoding: 'base64',
469
+ });
470
+ assert.equal(status, 200);
471
+ assert.equal(body.encoding, 'base64');
472
+ });
473
+
474
+ test('unsupported method returns 400', async () => {
475
+ const { status } = await sendJson('POST', '/v4/hash', { text: 'hello', method: 'fakehash' });
476
+ assert.equal(status, 400);
477
+ });
478
+
391
479
  test('missing text returns 400', async () => {
392
480
  const { status } = await sendJson('POST', '/v4/hash', { method: 'sha256' });
393
481
  assert.equal(status, 400);
@@ -0,0 +1,61 @@
1
+ import { test, describe } from 'node:test';
2
+ import { strict as assert } from 'node:assert';
3
+ import captcha from '../../src/modules/v4/captcha.js';
4
+
5
+ const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
6
+
7
+ describe('captcha', () => {
8
+ test('returns PNG buffer with provided text', () => {
9
+ const result = captcha({ text: 'hello' });
10
+ assert.equal(result.contentType, 'image/png');
11
+ assert.ok(Buffer.isBuffer(result.body));
12
+ assert.ok(result.body.subarray(0, 4).equals(PNG_MAGIC));
13
+ assert.equal(result.text, 'hello');
14
+ });
15
+
16
+ test('auto-generates text when none provided', () => {
17
+ const result = captcha({});
18
+ assert.equal(result.text.length, 6);
19
+ assert.ok(Buffer.isBuffer(result.body));
20
+ });
21
+
22
+ test('respects custom length', () => {
23
+ const result = captcha({ length: 10 });
24
+ assert.equal(result.text.length, 10);
25
+ });
26
+
27
+ test('throws on invalid length', () => {
28
+ assert.throws(() => captcha({ length: 0 }), /length/);
29
+ assert.throws(() => captcha({ length: 99 }), /length/);
30
+ });
31
+
32
+ test('accepts custom dimensions', () => {
33
+ const result = captcha({ text: 'AB', width: 200, height: 80 });
34
+ assert.ok(Buffer.isBuffer(result.body));
35
+ });
36
+
37
+ test('throws on invalid dimensions', () => {
38
+ assert.throws(() => captcha({ text: 'AB', width: 10 }), /width/);
39
+ assert.throws(() => captcha({ text: 'AB', height: 500 }), /height/);
40
+ });
41
+
42
+ test('accepts noise levels', () => {
43
+ for (const noise of ['low', 'medium', 'high'] as const) {
44
+ const result = captcha({ text: 'test', noise });
45
+ assert.ok(Buffer.isBuffer(result.body));
46
+ }
47
+ });
48
+
49
+ test('throws on invalid noise', () => {
50
+ assert.throws(() => captcha({ text: 'test', noise: 'extreme' as 'low' }), /noise/);
51
+ });
52
+
53
+ test('accepts custom colors', () => {
54
+ const result = captcha({ text: 'test', bg: 'f0f0f0', color: '333333' });
55
+ assert.ok(Buffer.isBuffer(result.body));
56
+ });
57
+
58
+ test('throws on invalid color', () => {
59
+ assert.throws(() => captcha({ text: 'test', bg: 'xyz' }), /color/i);
60
+ });
61
+ });
@@ -0,0 +1,53 @@
1
+ import { test, describe } from 'node:test';
2
+ import { strict as assert } from 'node:assert';
3
+ import chat from '../../src/modules/v4/chat.js';
4
+ import type { ChatStorage } from '../../src/types/storage.js';
5
+
6
+ function makeStorage(): ChatStorage {
7
+ return { messages: [], privateChats: {}, sessions: {}, rateLimits: {} };
8
+ }
9
+
10
+ describe('chat', () => {
11
+ test('sends a public message', () => {
12
+ const storage = makeStorage();
13
+ const result = chat('message', { username: 'alice', message: 'hello', session: 'a1', storage });
14
+ assert.ok((result as { message: string }).message.includes('sent'));
15
+ });
16
+
17
+ test('fetches public messages', () => {
18
+ const storage = makeStorage();
19
+ chat('message', { username: 'alice', message: 'test', session: 'a1', storage });
20
+ const result = chat('fetch', { username: 'bob', storage });
21
+ assert.ok(Array.isArray(result));
22
+ assert.equal((result as { message: string }[])[0]!.message, 'test');
23
+ });
24
+
25
+ test('sends and retrieves private messages', () => {
26
+ const storage = makeStorage();
27
+ chat('message', { username: 'alice', message: 'secret', session: 'a1', token: 'tok1', storage });
28
+ const result = chat('private', { username: 'bob', token: 'tok1', storage });
29
+ assert.ok(Array.isArray(result));
30
+ });
31
+
32
+ test('clears a private chat', () => {
33
+ const storage = makeStorage();
34
+ chat('message', { username: 'alice', message: 'secret', session: 'a1', token: 'tok2', storage });
35
+ const result = chat('clear', { username: 'alice', session: 'a1', token: 'tok2', storage });
36
+ assert.ok((result as { message: string }).message.includes('cleared'));
37
+ });
38
+
39
+ test('throws on missing username', () => {
40
+ assert.throws(
41
+ () => chat('message', { username: '', message: 'hi', session: 's', storage: makeStorage() }),
42
+ /username/,
43
+ );
44
+ });
45
+
46
+ test('throws on invalid action', () => {
47
+ assert.throws(() => chat('delete', { username: 'alice', storage: makeStorage() }), /Invalid action/);
48
+ });
49
+
50
+ test('throws on fetch with no messages', () => {
51
+ assert.throws(() => chat('fetch', { username: 'alice', storage: makeStorage() }), /No messages/);
52
+ });
53
+ });
@@ -15,19 +15,39 @@ describe('color', () => {
15
15
 
16
16
  test('hex matches #RRGGBB', () => {
17
17
  for (let i = 0; i < 20; i++) {
18
- assert.match(color().hex!, /^#[0-9a-f]{6}$/);
18
+ assert.match(color().hex, /^#[0-9a-f]{6}$/);
19
19
  }
20
20
  });
21
21
 
22
22
  test('rgb format', () => {
23
- assert.match(color().rgb!, /^rgb\(\d{1,3}, \d{1,3}, \d{1,3}\)$/);
23
+ assert.match(color().rgb, /^rgb\(\d{1,3}, \d{1,3}, \d{1,3}\)$/);
24
24
  });
25
25
 
26
26
  test('hsl format', () => {
27
- assert.match(color().hsl!, /^hsl\(\d+(\.\d+)?, \d+(\.\d+)?%, \d+(\.\d+)?%\)$/);
27
+ assert.match(color().hsl, /^hsl\(\d+(\.\d+)?, \d+(\.\d+)?%, \d+(\.\d+)?%\)$/);
28
28
  });
29
29
 
30
30
  test('cmyk format with 4 channels', () => {
31
- assert.match(color().cmyk!, /^cmyk\(/);
31
+ assert.match(color().cmyk, /^cmyk\(/);
32
+ });
33
+
34
+ test('accepts hex input', () => {
35
+ const result = color('#ff6600');
36
+ assert.equal(result.hex, '#ff6600');
37
+ assert.equal(result.rgb, 'rgb(255, 102, 0)');
38
+ });
39
+
40
+ test('accepts hex without #', () => {
41
+ const result = color('ff6600');
42
+ assert.equal(result.hex, '#ff6600');
43
+ });
44
+
45
+ test('throws on invalid hex', () => {
46
+ assert.throws(() => color('xyz'), /Invalid HEX/);
47
+ });
48
+
49
+ test('HSV correct for equal channels', () => {
50
+ const result = color('#808000');
51
+ assert.match(result.hsv, /^hsv\(60\.0,/);
32
52
  });
33
53
  });
@@ -36,7 +36,32 @@ describe('convert', () => {
36
36
  assert.throws(() => convert(-300, 'celsius', 'kelvin'), /absolute zero/);
37
37
  });
38
38
 
39
+ test('km to mi', () => {
40
+ const result = convert(1, 'km', 'mi');
41
+ assert.ok(Math.abs(result.result - 0.621371) < 0.001);
42
+ });
43
+
44
+ test('kg to lb', () => {
45
+ const result = convert(1, 'kg', 'lb');
46
+ assert.ok(Math.abs(result.result - 2.20462) < 0.001);
47
+ });
48
+
49
+ test('kb to mb', () => {
50
+ const result = convert(1024, 'kb', 'mb');
51
+ assert.equal(result.result, 1);
52
+ });
53
+
54
+ test('km/h to mph', () => {
55
+ const result = convert(100, 'km/h', 'mph');
56
+ assert.ok(Math.abs(result.result - 62.1371) < 0.01);
57
+ });
58
+
59
+ test('negative values allowed for non-temperature', () => {
60
+ const result = convert(-5, 'km', 'mi');
61
+ assert.ok(result.result < 0);
62
+ });
63
+
39
64
  test('throws on non-numeric value', () => {
40
- assert.throws(() => convert('abc', 'celsius', 'kelvin'), /must be a number/);
65
+ assert.throws(() => convert(NaN, 'celsius', 'kelvin'), /must be a number/);
41
66
  });
42
67
  });
@@ -0,0 +1,45 @@
1
+ import { test, describe } from 'node:test';
2
+ import { strict as assert } from 'node:assert';
3
+ import geo from '../../src/modules/v4/geo.js';
4
+
5
+ describe('geo', () => {
6
+ test('Paris to NYC distance ~5837 km', () => {
7
+ const result = geo('48.8566', '2.3522', '40.7128', '-74.006');
8
+ assert.ok(Math.abs(result.distance.km - 5837) < 50, `expected ~5837, got ${result.distance.km}`);
9
+ });
10
+
11
+ test('same point returns 0 km', () => {
12
+ const result = geo('48.8566', '2.3522', '48.8566', '2.3522');
13
+ assert.equal(result.distance.km, 0);
14
+ });
15
+
16
+ test('miles and nautical miles match km conversion', () => {
17
+ const result = geo('0', '0', '0', '90');
18
+ assert.ok(Math.abs(result.distance.miles - result.distance.km * 0.621371) < 0.01);
19
+ assert.ok(Math.abs(result.distance.nauticalMiles - result.distance.km * 0.539957) < 0.01);
20
+ });
21
+
22
+ test('bearing N for due north', () => {
23
+ const result = geo('0', '0', '10', '0');
24
+ assert.equal(result.bearing.degrees, 0);
25
+ assert.equal(result.bearing.cardinal, 'N');
26
+ });
27
+
28
+ test('bearing E for due east', () => {
29
+ const result = geo('0', '0', '0', '10');
30
+ assert.equal(result.bearing.degrees, 90);
31
+ assert.equal(result.bearing.cardinal, 'E');
32
+ });
33
+
34
+ test('throws on invalid latitude', () => {
35
+ assert.throws(() => geo('100', '0', '0', '0'), /lat1 must be between/);
36
+ });
37
+
38
+ test('throws on invalid longitude', () => {
39
+ assert.throws(() => geo('0', '200', '0', '0'), /lon1 must be between/);
40
+ });
41
+
42
+ test('throws on non-numeric coords', () => {
43
+ assert.throws(() => geo('abc', '0', '0', '0'), /must be a number/);
44
+ });
45
+ });
@@ -8,25 +8,32 @@ describe('hash', () => {
8
8
  assert.deepEqual(result, {
9
9
  method: 'sha256',
10
10
  hash: '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824',
11
+ encoding: 'hex',
11
12
  });
12
13
  });
13
14
 
14
15
  test('md5 of "hello"', () => {
15
16
  const result = hash('hello', 'md5');
16
- assert.deepEqual(result, {
17
- method: 'md5',
18
- hash: '5d41402abc4b2a76b9719d911017c592',
19
- });
17
+ assert.equal(result.hash, '5d41402abc4b2a76b9719d911017c592');
18
+ assert.equal(result.encoding, 'hex');
19
+ });
20
+
21
+ test('base64 encoding', () => {
22
+ const result = hash('hello', 'sha256', 'base64');
23
+ assert.equal(result.encoding, 'base64');
24
+ assert.ok(result.hash.length > 0);
25
+ });
26
+
27
+ test('throws on empty text', () => {
28
+ assert.throws(() => hash('', 'sha256'), /Text is required/);
20
29
  });
21
30
 
22
- test('sha1 of empty string', () => {
23
- const result = hash('', 'sha1');
24
- assert.equal((result as { hash: string }).hash, 'da39a3ee5e6b4b0d3255bfef95601890afd80709');
31
+ test('throws on unsupported method', () => {
32
+ assert.throws(() => hash('hello', 'fakehash'), /Unsupported method/);
25
33
  });
26
34
 
27
- test('returns error object on unsupported method', () => {
28
- const result = hash('hello', 'fakehash');
29
- assert.ok('error' in result);
35
+ test('throws on invalid encoding', () => {
36
+ assert.throws(() => hash('hello', 'sha256', 'utf8'), /Encoding must be/);
30
37
  });
31
38
 
32
39
  test('same input + method = same hash (deterministic)', () => {
@@ -0,0 +1,53 @@
1
+ import { test, describe } from 'node:test';
2
+ import { strict as assert } from 'node:assert';
3
+ import palette from '../../src/modules/v4/palette.js';
4
+
5
+ describe('palette', () => {
6
+ test('complementary returns 2 colors', () => {
7
+ const result = palette('#ff6600', 'complementary');
8
+ assert.equal(result.colors.length, 2);
9
+ assert.equal(result.type, 'complementary');
10
+ });
11
+
12
+ test('triadic returns 3 colors', () => {
13
+ const result = palette('#ff6600', 'triadic');
14
+ assert.equal(result.colors.length, 3);
15
+ });
16
+
17
+ test('analogous returns 5 colors', () => {
18
+ const result = palette('#ff6600', 'analogous');
19
+ assert.equal(result.colors.length, 5);
20
+ });
21
+
22
+ test('tetradic returns 4 colors', () => {
23
+ const result = palette('#0066ff', 'tetradic');
24
+ assert.equal(result.colors.length, 4);
25
+ });
26
+
27
+ test('split-complementary returns 3 colors', () => {
28
+ const result = palette('#0066ff', 'split-complementary');
29
+ assert.equal(result.colors.length, 3);
30
+ });
31
+
32
+ test('each color has hex/rgb/hsl', () => {
33
+ const result = palette('#ff6600', 'triadic');
34
+ for (const c of result.colors) {
35
+ assert.match(c.hex, /^#[0-9a-f]{6}$/);
36
+ assert.match(c.rgb, /^rgb\(/);
37
+ assert.match(c.hsl, /^hsl\(/);
38
+ }
39
+ });
40
+
41
+ test('base color is included', () => {
42
+ const result = palette('#ff6600', 'complementary');
43
+ assert.equal(result.base.hex, '#ff6600');
44
+ });
45
+
46
+ test('throws on invalid hex', () => {
47
+ assert.throws(() => palette('#zzz', 'triadic'), /Invalid HEX/);
48
+ });
49
+
50
+ test('throws on unknown type', () => {
51
+ assert.throws(() => palette('#ff6600', 'rainbow'), /must be one of/);
52
+ });
53
+ });
@@ -0,0 +1,57 @@
1
+ import { test, describe } from 'node:test';
2
+ import { strict as assert } from 'node:assert';
3
+ import placeholder from '../../src/modules/v4/placeholder.js';
4
+
5
+ describe('placeholder', () => {
6
+ test('image returns SVG with text', () => {
7
+ const result = placeholder('image', { width: '200', height: '100', text: 'Hello' });
8
+ assert.equal(result.type, 'image');
9
+ assert.equal(result.contentType, 'image/svg+xml');
10
+ assert.match(result.body, /<svg/);
11
+ assert.match(result.body, /Hello/);
12
+ });
13
+
14
+ test('skeleton returns SVG with animation', () => {
15
+ const result = placeholder('skeleton', { width: '300', height: '200' });
16
+ assert.equal(result.type, 'skeleton');
17
+ assert.equal(result.contentType, 'image/svg+xml');
18
+ assert.match(result.body, /<svg/);
19
+ });
20
+
21
+ test('default dimensions 800x600', () => {
22
+ const result = placeholder('image', {});
23
+ assert.match(result.body, /width="800"/);
24
+ assert.match(result.body, /height="600"/);
25
+ });
26
+
27
+ test('skeleton with avatar', () => {
28
+ const result = placeholder('skeleton', { avatar: 'circle' });
29
+ assert.match(result.body, /<circle/);
30
+ });
31
+
32
+ test('skeleton with rows', () => {
33
+ const result = placeholder('skeleton', { rows: '5' });
34
+ assert.match(result.body, /<rect/);
35
+ });
36
+
37
+ test('animate none removes animation', () => {
38
+ const result = placeholder('skeleton', { animate: 'none' });
39
+ assert.ok(!result.body.includes('<animate'));
40
+ });
41
+
42
+ test('throws on invalid type', () => {
43
+ assert.throws(() => placeholder('video', {}), /Type must be one of/);
44
+ });
45
+
46
+ test('throws on invalid width', () => {
47
+ assert.throws(() => placeholder('image', { width: '5000' }), /width/);
48
+ });
49
+
50
+ test('throws on invalid avatar shape', () => {
51
+ assert.throws(() => placeholder('skeleton', { avatar: 'hexagon' }), /avatar/);
52
+ });
53
+
54
+ test('throws on invalid speed', () => {
55
+ assert.throws(() => placeholder('skeleton', { speed: '99' }), /speed/);
56
+ });
57
+ });