@jseeio/jsee 0.3.7 → 0.3.9

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.
@@ -0,0 +1,806 @@
1
+ const {
2
+ isObject,
3
+ sanitizeName,
4
+ getUrl,
5
+ delay,
6
+ debounce,
7
+ getName,
8
+ isWorkerInitMessage,
9
+ getProgressState,
10
+ shouldContinueInterval,
11
+ getModelFuncJS,
12
+ getModelFuncAPI,
13
+ validateSchema,
14
+ toWorkerSerializable,
15
+ wrapStreamInputs,
16
+ containsBinaryPayload,
17
+ getUrlParam,
18
+ coerceParam
19
+ } = require('../../src/utils')
20
+
21
+ describe('isObject', () => {
22
+ test('returns true for plain objects', () => {
23
+ expect(isObject({})).toBe(true)
24
+ expect(isObject({ a: 1 })).toBe(true)
25
+ })
26
+ test('returns false for arrays', () => {
27
+ expect(isObject([])).toBe(false)
28
+ expect(isObject([1, 2])).toBe(false)
29
+ })
30
+ test('returns false for null', () => {
31
+ expect(isObject(null)).toBe(false)
32
+ })
33
+ test('returns false for primitives', () => {
34
+ expect(isObject(42)).toBe(false)
35
+ expect(isObject('string')).toBe(false)
36
+ expect(isObject(true)).toBe(false)
37
+ expect(isObject(undefined)).toBe(false)
38
+ })
39
+ })
40
+
41
+ describe('sanitizeName', () => {
42
+ test('lowercases and replaces non-alphanumeric', () => {
43
+ expect(sanitizeName('Hello World')).toBe('hello_world')
44
+ })
45
+ test('keeps underscores and digits', () => {
46
+ expect(sanitizeName('input_1')).toBe('input_1')
47
+ })
48
+ test('replaces special characters', () => {
49
+ expect(sanitizeName('My Input!')).toBe('my_input_')
50
+ })
51
+ test('handles already clean names', () => {
52
+ expect(sanitizeName('foo')).toBe('foo')
53
+ })
54
+ })
55
+
56
+ describe('getUrl', () => {
57
+ test('returns absolute URLs as-is', () => {
58
+ expect(getUrl('https://example.com/lib.js')).toBe('https://example.com/lib.js')
59
+ })
60
+ test('prepends CDN base for bare package names', () => {
61
+ expect(getUrl('lodash@4.17.21/lodash.min.js'))
62
+ .toBe('https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.min.js')
63
+ })
64
+ })
65
+
66
+ describe('delay', () => {
67
+ test('resolves after specified ms', async () => {
68
+ const start = Date.now()
69
+ await delay(50)
70
+ const elapsed = Date.now() - start
71
+ expect(elapsed).toBeGreaterThanOrEqual(40) // allow small timing variance
72
+ })
73
+ test('defaults to 1ms if no argument', async () => {
74
+ await expect(delay()).resolves.toBeUndefined()
75
+ })
76
+ })
77
+
78
+ describe('toWorkerSerializable', () => {
79
+ test('clones plain objects and arrays recursively', () => {
80
+ const original = {
81
+ file: { kind: 'url', url: 'http://localhost:8080/test.csv' },
82
+ nested: [{ a: 1 }, { b: 2 }]
83
+ }
84
+ const result = toWorkerSerializable(original)
85
+ expect(result).toEqual(original)
86
+ expect(result).not.toBe(original)
87
+ expect(result.file).not.toBe(original.file)
88
+ expect(result.nested).not.toBe(original.nested)
89
+ expect(result.nested[0]).not.toBe(original.nested[0])
90
+ })
91
+
92
+ test('preserves native clone-safe objects by reference', () => {
93
+ const date = new Date('2024-01-01T00:00:00.000Z')
94
+ const original = { date }
95
+ const result = toWorkerSerializable(original)
96
+ expect(result.date).toBe(date)
97
+ })
98
+
99
+ test('de-proxies custom object-like values into plain data', () => {
100
+ class CustomType {
101
+ constructor (v) {
102
+ this.value = v
103
+ }
104
+ }
105
+ const custom = new CustomType(42)
106
+ const original = { custom }
107
+ const result = toWorkerSerializable(original)
108
+ expect(result.custom).toEqual({ value: 42 })
109
+ expect(result.custom).not.toBe(custom)
110
+ })
111
+ })
112
+
113
+ describe('containsBinaryPayload', () => {
114
+ test('returns true for nested binary payloads', () => {
115
+ const payload = {
116
+ file: {
117
+ blob: new Uint8Array([1, 2, 3])
118
+ }
119
+ }
120
+ expect(containsBinaryPayload(payload)).toBe(true)
121
+ })
122
+
123
+ test('returns false for plain JSON-like payloads', () => {
124
+ const payload = {
125
+ file: { kind: 'url', url: 'http://localhost:8080/test.csv' },
126
+ rows: [{ a: 1 }, { b: 2 }]
127
+ }
128
+ expect(containsBinaryPayload(payload)).toBe(false)
129
+ })
130
+ })
131
+
132
+ describe('wrapStreamInputs', () => {
133
+ test('wraps file-like source into async iterable chunked reader', async () => {
134
+ const content = new TextEncoder().encode('name,age\n1,2\n')
135
+ const fakeFile = {
136
+ size: content.byteLength,
137
+ slice (start, end) {
138
+ const chunk = content.slice(start, end)
139
+ return {
140
+ async arrayBuffer () {
141
+ return chunk.buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength)
142
+ }
143
+ }
144
+ }
145
+ }
146
+ const wrapped = wrapStreamInputs(
147
+ { file: fakeFile },
148
+ { file: { stream: true } }
149
+ )
150
+ expect(wrapped.file).not.toBe(fakeFile)
151
+ expect(typeof wrapped.file[Symbol.asyncIterator]).toBe('function')
152
+ expect(typeof wrapped.file.text).toBe('function')
153
+ expect(typeof wrapped.file.bytes).toBe('function')
154
+ expect(typeof wrapped.file.lines).toBe('function')
155
+
156
+ const text = await wrapped.file.text()
157
+ expect(text).toBe('name,age\n1,2\n')
158
+ })
159
+
160
+ test('copies file metadata to chunked reader', () => {
161
+ const fakeFile = {
162
+ name: 'input.csv',
163
+ size: 4,
164
+ type: 'text/csv',
165
+ slice () {
166
+ return {
167
+ async arrayBuffer () {
168
+ return new Uint8Array([1, 2, 3, 4]).buffer
169
+ }
170
+ }
171
+ }
172
+ }
173
+ const wrapped = wrapStreamInputs(
174
+ { file: fakeFile },
175
+ { file: { stream: true } }
176
+ )
177
+ expect(wrapped.file.name).toBe('input.csv')
178
+ expect(wrapped.file.size).toBe(4)
179
+ expect(wrapped.file.type).toBe('text/csv')
180
+ })
181
+
182
+ test('wraps URL handle into async iterable chunked reader', async () => {
183
+ const fetchMock = jest.fn().mockResolvedValue({
184
+ ok: true,
185
+ headers: { get: () => null },
186
+ body: {
187
+ getReader () {
188
+ let readCount = 0
189
+ return {
190
+ async read () {
191
+ readCount += 1
192
+ if (readCount === 1) {
193
+ return { done: false, value: new TextEncoder().encode('name,age\n') }
194
+ }
195
+ return { done: true, value: undefined }
196
+ },
197
+ releaseLock () {}
198
+ }
199
+ }
200
+ }
201
+ })
202
+
203
+ const wrapped = wrapStreamInputs(
204
+ { file: { kind: 'url', url: 'http://localhost:8080/test.csv' } },
205
+ { file: { stream: true } },
206
+ { fetch: fetchMock }
207
+ )
208
+ expect(typeof wrapped.file[Symbol.asyncIterator]).toBe('function')
209
+
210
+ const text = await wrapped.file.text()
211
+ expect(text).toBe('name,age\n')
212
+ expect(fetchMock).toHaveBeenCalled()
213
+ expect(fetchMock.mock.calls[0][0]).toBe('http://localhost:8080/test.csv')
214
+ })
215
+
216
+ test('copies URL metadata to chunked reader', async () => {
217
+ const fetchMock = jest.fn().mockResolvedValue({
218
+ ok: true,
219
+ headers: {
220
+ get: (name) => {
221
+ if (name === 'content-length') return '9'
222
+ if (name === 'content-type') return 'text/csv; charset=utf-8'
223
+ return null
224
+ }
225
+ },
226
+ body: {
227
+ getReader () {
228
+ let readCount = 0
229
+ return {
230
+ async read () {
231
+ readCount += 1
232
+ if (readCount === 1) {
233
+ return { done: false, value: new TextEncoder().encode('a,b\n1,2\n') }
234
+ }
235
+ return { done: true, value: undefined }
236
+ },
237
+ releaseLock () {}
238
+ }
239
+ }
240
+ }
241
+ })
242
+
243
+ const wrapped = wrapStreamInputs(
244
+ {
245
+ file: {
246
+ kind: 'url',
247
+ url: 'http://localhost:8080/files/upload-sample.csv'
248
+ }
249
+ },
250
+ { file: { stream: true } },
251
+ { fetch: fetchMock }
252
+ )
253
+ expect(wrapped.file.name).toBe('upload-sample.csv')
254
+
255
+ const text = await wrapped.file.text()
256
+ expect(text).toBe('a,b\n1,2\n')
257
+ expect(wrapped.file.size).toBe(9)
258
+ expect(wrapped.file.type).toBe('text/csv')
259
+ })
260
+
261
+ test('does not re-wrap chunked readers on downstream stages', () => {
262
+ const content = new TextEncoder().encode('hello')
263
+ const fakeFile = {
264
+ size: content.byteLength,
265
+ slice (start, end) {
266
+ const chunk = content.slice(start, end)
267
+ return {
268
+ async arrayBuffer () {
269
+ return chunk.buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength)
270
+ }
271
+ }
272
+ }
273
+ }
274
+ const onceWrapped = wrapStreamInputs(
275
+ { file: fakeFile },
276
+ { file: { stream: true } }
277
+ )
278
+ const twiceWrapped = wrapStreamInputs(
279
+ onceWrapped,
280
+ { file: { stream: true } }
281
+ )
282
+ expect(twiceWrapped.file).toBe(onceWrapped.file)
283
+ })
284
+
285
+ test('lines() yields individual lines from chunked input', async () => {
286
+ const content = new TextEncoder().encode('line1\nline2\nline3')
287
+ const fakeFile = {
288
+ size: content.byteLength,
289
+ slice (start, end) {
290
+ const chunk = content.slice(start, end)
291
+ return {
292
+ async arrayBuffer () {
293
+ return chunk.buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength)
294
+ }
295
+ }
296
+ }
297
+ }
298
+ const wrapped = wrapStreamInputs(
299
+ { file: fakeFile },
300
+ { file: { stream: true } }
301
+ )
302
+
303
+ const lines = []
304
+ for await (const line of wrapped.file.lines()) {
305
+ lines.push(line)
306
+ }
307
+ expect(lines).toEqual(['line1', 'line2', 'line3'])
308
+ })
309
+
310
+ test('bytes() returns concatenated Uint8Array', async () => {
311
+ const content = new TextEncoder().encode('hello')
312
+ const fakeFile = {
313
+ size: content.byteLength,
314
+ slice (start, end) {
315
+ const chunk = content.slice(start, end)
316
+ return {
317
+ async arrayBuffer () {
318
+ return chunk.buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength)
319
+ }
320
+ }
321
+ }
322
+ }
323
+ const wrapped = wrapStreamInputs(
324
+ { file: fakeFile },
325
+ { file: { stream: true } }
326
+ )
327
+
328
+ const bytes = await wrapped.file.bytes()
329
+ expect(bytes).toBeInstanceOf(Uint8Array)
330
+ expect(new TextDecoder().decode(bytes)).toBe('hello')
331
+ })
332
+ })
333
+
334
+ describe('debounce', () => {
335
+ beforeEach(() => jest.useFakeTimers())
336
+ afterEach(() => jest.useRealTimers())
337
+
338
+ test('delays execution', () => {
339
+ const fn = jest.fn()
340
+ const debounced = debounce(fn, 100)
341
+ debounced()
342
+ expect(fn).not.toHaveBeenCalled()
343
+ jest.advanceTimersByTime(100)
344
+ expect(fn).toHaveBeenCalledTimes(1)
345
+ })
346
+
347
+ test('resets timer on rapid calls', () => {
348
+ const fn = jest.fn()
349
+ const debounced = debounce(fn, 100)
350
+ debounced()
351
+ jest.advanceTimersByTime(50)
352
+ debounced()
353
+ jest.advanceTimersByTime(50)
354
+ expect(fn).not.toHaveBeenCalled()
355
+ jest.advanceTimersByTime(50)
356
+ expect(fn).toHaveBeenCalledTimes(1)
357
+ })
358
+
359
+ test('passes arguments through', () => {
360
+ const fn = jest.fn()
361
+ const debounced = debounce(fn, 100)
362
+ debounced('a', 'b')
363
+ jest.advanceTimersByTime(100)
364
+ expect(fn).toHaveBeenCalledWith('a', 'b')
365
+ })
366
+ })
367
+
368
+ describe('getName', () => {
369
+ test('regular named function string', () => {
370
+ expect(getName('function sum (a, b) { return a + b }')).toBe('sum')
371
+ })
372
+ test('async function string', () => {
373
+ expect(getName('async function fetchData () { }')).toBe('fetchData')
374
+ })
375
+ test('anonymous function string', () => {
376
+ expect(getName('function (a, b) { return a + b }')).toBeUndefined()
377
+ })
378
+ test('arrow function returns undefined', () => {
379
+ expect(getName('(a, b) => a + b')).toBeUndefined()
380
+ })
381
+ test('actual function reference', () => {
382
+ function myFunc () {}
383
+ expect(getName(myFunc)).toBe('myFunc')
384
+ })
385
+ test('non-string non-function returns undefined', () => {
386
+ expect(getName(42)).toBeUndefined()
387
+ expect(getName(null)).toBeUndefined()
388
+ expect(getName(undefined)).toBeUndefined()
389
+ })
390
+ })
391
+
392
+ describe('isWorkerInitMessage', () => {
393
+ test('returns true for first model payload with code', () => {
394
+ expect(isWorkerInitMessage({ code: 'function run () {}' }, false)).toBe(true)
395
+ })
396
+
397
+ test('returns true for first model payload with url', () => {
398
+ expect(isWorkerInitMessage({ url: '/apps/test/model.js' }, false)).toBe(true)
399
+ })
400
+
401
+ test('returns false after worker was initialized', () => {
402
+ expect(isWorkerInitMessage({ url: '/apps/test/input.csv' }, true)).toBe(false)
403
+ })
404
+
405
+ test('returns false for non-model execution payloads', () => {
406
+ expect(isWorkerInitMessage({ input: 1, caller: 'run' }, false)).toBe(false)
407
+ })
408
+ })
409
+
410
+ describe('getProgressState', () => {
411
+ test('returns indeterminate mode for null', () => {
412
+ expect(getProgressState(null)).toEqual({ mode: 'indeterminate', value: null })
413
+ })
414
+
415
+ test('returns determinate mode for numeric values', () => {
416
+ expect(getProgressState(42)).toEqual({ mode: 'determinate', value: 42 })
417
+ expect(getProgressState('25')).toEqual({ mode: 'determinate', value: 25 })
418
+ })
419
+
420
+ test('clamps determinate values to [0, 100]', () => {
421
+ expect(getProgressState(-5)).toEqual({ mode: 'determinate', value: 0 })
422
+ expect(getProgressState(120)).toEqual({ mode: 'determinate', value: 100 })
423
+ })
424
+
425
+ test('returns null for non-numeric non-null values', () => {
426
+ expect(getProgressState('unknown')).toBeNull()
427
+ expect(getProgressState(undefined)).toBeNull()
428
+ })
429
+ })
430
+
431
+ describe('shouldContinueInterval', () => {
432
+ test('returns true only for active non-cancelled run caller', () => {
433
+ expect(shouldContinueInterval(1000, true, false, 'run')).toBe(true)
434
+ })
435
+
436
+ test('returns false when run is cancelled', () => {
437
+ expect(shouldContinueInterval(1000, true, true, 'run')).toBe(false)
438
+ })
439
+
440
+ test('returns false for non-run callers', () => {
441
+ expect(shouldContinueInterval(1000, true, false, 'reactive')).toBe(false)
442
+ expect(shouldContinueInterval(1000, true, false, 'autorun')).toBe(false)
443
+ })
444
+
445
+ test('returns false when interval is missing or run is inactive', () => {
446
+ expect(shouldContinueInterval(0, true, false, 'run')).toBe(false)
447
+ expect(shouldContinueInterval(null, true, false, 'run')).toBe(false)
448
+ expect(shouldContinueInterval(1000, false, false, 'run')).toBe(false)
449
+ })
450
+ })
451
+
452
+ describe('getModelFuncJS', () => {
453
+ const mockApp = { log: jest.fn() }
454
+
455
+ test('wraps function with object container (default)', async () => {
456
+ const target = (inputs) => inputs.a + inputs.b
457
+ const wrapped = await getModelFuncJS({ type: 'function' }, target, mockApp)
458
+ expect(wrapped({ a: 1, b: 2 })).toBe(3)
459
+ })
460
+
461
+ test('wraps function with args container', async () => {
462
+ const target = (a, b) => a + b
463
+ const wrapped = await getModelFuncJS(
464
+ { type: 'function', container: 'args' },
465
+ target,
466
+ mockApp
467
+ )
468
+ // Object values spread as args
469
+ expect(wrapped({ a: 10, b: 20 })).toBe(30)
470
+ })
471
+
472
+ test('passes app context for object container', async () => {
473
+ const ctxApp = {
474
+ log: jest.fn(),
475
+ isCancelled: jest.fn(() => true)
476
+ }
477
+ const target = (inputs, ctx) => ({ value: inputs.a, cancelled: ctx.isCancelled() })
478
+ const wrapped = await getModelFuncJS({ type: 'function' }, target, ctxApp)
479
+ expect(wrapped({ a: 7 })).toEqual({ value: 7, cancelled: true })
480
+ expect(ctxApp.isCancelled).toHaveBeenCalledTimes(1)
481
+ })
482
+
483
+ test('wraps class type', async () => {
484
+ class Calculator {
485
+ predict (inputs) { return inputs.x * 2 }
486
+ }
487
+ const wrapped = await getModelFuncJS({ type: 'class' }, Calculator, mockApp)
488
+ expect(wrapped({ x: 5 })).toBe(10)
489
+ })
490
+
491
+ test('class type with custom method', async () => {
492
+ class Calculator {
493
+ double (a, b) { return (a + b) * 2 }
494
+ }
495
+ const wrapped = await getModelFuncJS(
496
+ { type: 'class', method: 'double', container: 'args' },
497
+ Calculator,
498
+ mockApp
499
+ )
500
+ expect(wrapped({ a: 3, b: 4 })).toBe(14)
501
+ })
502
+ })
503
+
504
+ describe('getModelFuncAPI', () => {
505
+ const mockLog = jest.fn()
506
+
507
+ beforeEach(() => {
508
+ global.fetch = jest.fn()
509
+ })
510
+ afterEach(() => {
511
+ delete global.fetch
512
+ })
513
+
514
+ test('creates GET function', async () => {
515
+ global.fetch.mockResolvedValue({ json: () => Promise.resolve({ result: 42 }) })
516
+ const fn = getModelFuncAPI({ type: 'get', url: 'https://api.example.com/run' }, mockLog)
517
+ const result = await fn({ a: 1, b: 2 })
518
+ expect(result).toEqual({ result: 42 })
519
+ expect(global.fetch).toHaveBeenCalledWith('https://api.example.com/run?a=1&b=2')
520
+ })
521
+
522
+ test('creates POST function', async () => {
523
+ global.fetch.mockResolvedValue({ json: () => Promise.resolve({ result: 99 }) })
524
+ const fn = getModelFuncAPI({ type: 'post', url: 'https://api.example.com/run' }, mockLog)
525
+ const result = await fn({ x: 10 })
526
+ expect(result).toEqual({ result: 99 })
527
+ expect(global.fetch).toHaveBeenCalledWith('https://api.example.com/run', {
528
+ method: 'POST',
529
+ headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' },
530
+ body: JSON.stringify({ x: 10 })
531
+ })
532
+ })
533
+ })
534
+
535
+ describe('validateSchema', () => {
536
+ test('returns empty report for valid minimal schema', () => {
537
+ const report = validateSchema({
538
+ model: {
539
+ code: 'function sum (a, b) { return a + b }'
540
+ },
541
+ inputs: [
542
+ { name: 'a', type: 'int' },
543
+ { name: 'b', type: 'int' }
544
+ ]
545
+ })
546
+ expect(report.errors).toEqual([])
547
+ expect(report.warnings).toEqual([])
548
+ })
549
+
550
+ test('returns error when schema has no model and no view/render', () => {
551
+ const report = validateSchema({
552
+ inputs: [{ name: 'x', type: 'int' }]
553
+ })
554
+ expect(report.errors.length).toBeGreaterThan(0)
555
+ expect(report.errors.join(' ')).toContain('model')
556
+ })
557
+
558
+ test('returns warning when schema uses view/render without model', () => {
559
+ const report = validateSchema({
560
+ render: {
561
+ type: 'function',
562
+ code: 'function render () {}'
563
+ }
564
+ })
565
+ expect(report.errors).toEqual([])
566
+ expect(report.warnings.length).toBeGreaterThan(0)
567
+ })
568
+
569
+ test('returns error for non-array inputs', () => {
570
+ const report = validateSchema({
571
+ model: { code: 'function run () {}' },
572
+ inputs: { name: 'a', type: 'int' }
573
+ })
574
+ expect(report.errors.length).toBeGreaterThan(0)
575
+ expect(report.errors.join(' ')).toContain('inputs')
576
+ })
577
+
578
+ test('returns warnings for unsupported input/model options', () => {
579
+ const report = validateSchema({
580
+ model: { type: 'unsupported-model', timeout: -1 },
581
+ inputs: [{ name: 123, type: 'unsupported-input', alias: [1, 2, 3] }]
582
+ })
583
+ expect(report.errors).toEqual([])
584
+ expect(report.warnings.length).toBeGreaterThan(0)
585
+ expect(report.warnings.join(' ')).toContain('not recognized')
586
+ })
587
+
588
+ test('accepts file input raw flag when it is boolean', () => {
589
+ const report = validateSchema({
590
+ model: { code: 'function run () {}' },
591
+ inputs: [{ name: 'file', type: 'file', raw: true }]
592
+ })
593
+ expect(report.errors).toEqual([])
594
+ expect(report.warnings).toEqual([])
595
+ })
596
+
597
+ test('warns when file input raw flag is not boolean', () => {
598
+ const report = validateSchema({
599
+ model: { code: 'function run () {}' },
600
+ inputs: [{ name: 'file', type: 'file', raw: 'yes' }]
601
+ })
602
+ expect(report.errors).toEqual([])
603
+ expect(report.warnings.join(' ')).toContain('raw should be a boolean')
604
+ })
605
+
606
+ test('accepts file input stream flag when it is boolean', () => {
607
+ const report = validateSchema({
608
+ model: { code: 'function run () {}' },
609
+ inputs: [{ name: 'file', type: 'file', stream: true }]
610
+ })
611
+ expect(report.errors).toEqual([])
612
+ expect(report.warnings).toEqual([])
613
+ })
614
+
615
+ test('warns when file input stream flag is not boolean', () => {
616
+ const report = validateSchema({
617
+ model: { code: 'function run () {}' },
618
+ inputs: [{ name: 'file', type: 'file', stream: 'yes' }]
619
+ })
620
+ expect(report.errors).toEqual([])
621
+ expect(report.warnings.join(' ')).toContain('stream should be a boolean')
622
+ })
623
+
624
+ test('warns when stream flag is used on non-file input', () => {
625
+ const report = validateSchema({
626
+ model: { code: 'function run () {}' },
627
+ inputs: [{ name: 'text', type: 'string', stream: true }]
628
+ })
629
+ expect(report.errors).toEqual([])
630
+ expect(report.warnings.join(' ')).toContain('stream is supported only for file inputs')
631
+ })
632
+ })
633
+
634
+ describe('getUrlParam', () => {
635
+ function makeParams (obj) {
636
+ return new URLSearchParams(obj)
637
+ }
638
+
639
+ test('matches by input name', () => {
640
+ const params = makeParams({ myInput: '42' })
641
+ expect(getUrlParam(params, { name: 'myInput' })).toBe('42')
642
+ })
643
+
644
+ test('matches by sanitized name', () => {
645
+ const params = makeParams({ my_input: '42' })
646
+ expect(getUrlParam(params, { name: 'my input' })).toBe('42')
647
+ })
648
+
649
+ test('matches by string alias', () => {
650
+ const params = makeParams({ f: 'data.csv' })
651
+ expect(getUrlParam(params, { name: 'file', alias: 'f' })).toBe('data.csv')
652
+ })
653
+
654
+ test('matches by array alias', () => {
655
+ const params = makeParams({ src: 'data.csv' })
656
+ expect(getUrlParam(params, { name: 'file', alias: ['f', 'src'] })).toBe('data.csv')
657
+ })
658
+
659
+ test('returns null when no match', () => {
660
+ const params = makeParams({ other: '1' })
661
+ expect(getUrlParam(params, { name: 'file' })).toBeNull()
662
+ })
663
+
664
+ test('returns null when no alias match', () => {
665
+ const params = makeParams({ other: '1' })
666
+ expect(getUrlParam(params, { name: 'file', alias: ['f', 'data'] })).toBeNull()
667
+ })
668
+
669
+ test('prefers name over alias', () => {
670
+ const params = makeParams({ file: 'direct', f: 'alias' })
671
+ expect(getUrlParam(params, { name: 'file', alias: 'f' })).toBe('direct')
672
+ })
673
+ })
674
+
675
+ describe('coerceParam', () => {
676
+ test('coerces to number', () => {
677
+ expect(coerceParam('42', 'number', 'x')).toBe(42)
678
+ expect(coerceParam('3.14', 'number', 'x')).toBeCloseTo(3.14)
679
+ })
680
+
681
+ test('coerces to boolean', () => {
682
+ expect(coerceParam('true', 'boolean', 'x')).toBe(true)
683
+ expect(coerceParam('false', 'boolean', 'x')).toBe(false)
684
+ expect(coerceParam('yes', 'boolean', 'x')).toBe(false)
685
+ })
686
+
687
+ test('coerces to JSON', () => {
688
+ expect(coerceParam('{"a":1}', 'json', 'x')).toEqual({ a: 1 })
689
+ expect(coerceParam('[1,2,3]', 'json', 'x')).toEqual([1, 2, 3])
690
+ })
691
+
692
+ test('returns original string for invalid JSON', () => {
693
+ const consoleSpy = jest.spyOn(console, 'error').mockImplementation()
694
+ expect(coerceParam('not json', 'json', 'x')).toBe('not json')
695
+ expect(consoleSpy).toHaveBeenCalled()
696
+ consoleSpy.mockRestore()
697
+ })
698
+
699
+ test('returns string as-is for unknown types', () => {
700
+ expect(coerceParam('hello', 'string', 'x')).toBe('hello')
701
+ expect(coerceParam('hello', 'text', 'x')).toBe('hello')
702
+ })
703
+ })
704
+
705
+ describe('error scenarios', () => {
706
+ describe('validateSchema edge cases', () => {
707
+ test('handles empty schema', () => {
708
+ const report = validateSchema({})
709
+ expect(report.errors.length).toBeGreaterThan(0)
710
+ })
711
+
712
+ test('handles null/undefined inputs gracefully', () => {
713
+ const report = validateSchema({
714
+ model: { code: 'function run () {}' },
715
+ inputs: null
716
+ })
717
+ // null inputs should not crash
718
+ expect(report).toBeDefined()
719
+ })
720
+
721
+ test('warns on multiple issues at once', () => {
722
+ const report = validateSchema({
723
+ model: { type: 'bad-type', timeout: -100 },
724
+ inputs: [
725
+ { name: 123, type: 'invalid-type', alias: [1], raw: 'yes', stream: 'yes' }
726
+ ]
727
+ })
728
+ expect(report.warnings.length).toBeGreaterThan(3)
729
+ })
730
+ })
731
+
732
+ describe('getName edge cases', () => {
733
+ test('handles empty string', () => {
734
+ expect(getName('')).toBeUndefined()
735
+ })
736
+
737
+ test('handles whitespace-only string', () => {
738
+ expect(getName(' ')).toBeUndefined()
739
+ })
740
+
741
+ test('handles object input', () => {
742
+ expect(getName({})).toBeUndefined()
743
+ })
744
+ })
745
+
746
+ describe('sanitizeName edge cases', () => {
747
+ test('handles empty string', () => {
748
+ expect(sanitizeName('')).toBe('')
749
+ })
750
+
751
+ test('handles string with only special chars', () => {
752
+ const result = sanitizeName('!@#$%')
753
+ expect(typeof result).toBe('string')
754
+ })
755
+
756
+ test('handles numbers as input', () => {
757
+ expect(sanitizeName('123abc')).toBe('123abc')
758
+ })
759
+ })
760
+
761
+ describe('getModelFuncJS error handling', () => {
762
+ const mockApp = { log: jest.fn() }
763
+
764
+ test('class without predict method throws', async () => {
765
+ class NoPredict {}
766
+ await expect(async () => {
767
+ const wrapped = await getModelFuncJS({ type: 'class' }, NoPredict, mockApp)
768
+ wrapped({ x: 1 })
769
+ }).rejects.toThrow()
770
+ })
771
+ })
772
+
773
+ describe('toWorkerSerializable edge cases', () => {
774
+ test('handles empty object', () => {
775
+ const result = toWorkerSerializable({})
776
+ expect(result).toEqual({})
777
+ })
778
+
779
+ test('handles nested objects without binary', () => {
780
+ const input = { a: 1, b: { c: 'hello' } }
781
+ const result = toWorkerSerializable(input)
782
+ expect(result).toEqual(input)
783
+ })
784
+
785
+ test('handles null values', () => {
786
+ const input = { a: null, b: undefined }
787
+ const result = toWorkerSerializable(input)
788
+ expect(result.a).toBeNull()
789
+ })
790
+ })
791
+
792
+ describe('containsBinaryPayload edge cases', () => {
793
+ test('returns false for empty object', () => {
794
+ expect(containsBinaryPayload({})).toBe(false)
795
+ })
796
+
797
+ test('returns false for string values', () => {
798
+ expect(containsBinaryPayload({ a: 'hello', b: '123' })).toBe(false)
799
+ })
800
+
801
+ test('returns false for null/undefined', () => {
802
+ expect(containsBinaryPayload(null)).toBe(false)
803
+ expect(containsBinaryPayload(undefined)).toBe(false)
804
+ })
805
+ })
806
+ })