id-scanner-lib 1.3.3 → 1.6.2

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 (80) hide show
  1. package/README.md +324 -410
  2. package/dist/id-scanner-lib.esm.js +4826 -0
  3. package/dist/id-scanner-lib.esm.js.map +1 -0
  4. package/dist/id-scanner-lib.js +4858 -0
  5. package/dist/id-scanner-lib.js.map +1 -0
  6. package/dist/types/browser-image-compression.d.ts +19 -0
  7. package/dist/types/tesseract.d.ts +280 -0
  8. package/package.json +89 -78
  9. package/src/core/base-module.ts +78 -0
  10. package/src/core/camera-manager.ts +813 -0
  11. package/src/core/config.ts +305 -0
  12. package/src/core/errors.ts +174 -0
  13. package/src/core/event-emitter.test.ts +42 -0
  14. package/src/core/event-emitter.ts +110 -0
  15. package/src/core/loading-state.test.ts +67 -0
  16. package/src/core/loading-state.ts +156 -0
  17. package/src/core/logger.test.ts +49 -0
  18. package/src/core/logger.ts +549 -0
  19. package/src/core/module-manager.ts +163 -0
  20. package/src/core/plugin-manager.ts +429 -0
  21. package/src/core/resource-manager.ts +762 -0
  22. package/src/core/result.ts +163 -0
  23. package/src/core/scanner-factory.ts +236 -0
  24. package/src/index.ts +117 -939
  25. package/src/interfaces/external-types.ts +200 -0
  26. package/src/interfaces/face-detection.ts +309 -0
  27. package/src/interfaces/scanner-module.ts +384 -0
  28. package/src/modules/face/face-detector.ts +988 -0
  29. package/src/modules/face/index.ts +208 -0
  30. package/src/modules/face/liveness-detector.ts +908 -0
  31. package/src/modules/face/types.ts +133 -0
  32. package/src/{id-recognition → modules/id-card}/anti-fake-detector.ts +274 -240
  33. package/src/modules/id-card/id-card-detector.ts +474 -0
  34. package/src/modules/id-card/index.ts +425 -0
  35. package/src/{id-recognition → modules/id-card}/ocr-processor.ts +149 -92
  36. package/src/modules/id-card/ocr-worker.ts +259 -0
  37. package/src/modules/id-card/types.ts +178 -0
  38. package/src/modules/qrcode/index.ts +175 -0
  39. package/src/modules/qrcode/qr-code-scanner.ts +231 -0
  40. package/src/modules/qrcode/types.ts +169 -0
  41. package/src/types/common.test.ts +99 -0
  42. package/src/types/common.ts +166 -0
  43. package/src/types/tesseract.d.ts +265 -22
  44. package/src/utils/camera.test.ts +30 -0
  45. package/src/utils/camera.ts +4 -1
  46. package/src/utils/error-handler.test.ts +137 -0
  47. package/src/utils/error-handler.ts +110 -0
  48. package/src/utils/image-processing.ts +68 -49
  49. package/src/utils/index.test.ts +186 -0
  50. package/src/utils/index.ts +429 -0
  51. package/src/utils/performance.ts +168 -131
  52. package/src/utils/resource-manager.ts +65 -146
  53. package/src/utils/retry.test.ts +142 -0
  54. package/src/utils/retry.ts +282 -0
  55. package/src/utils/types.ts +90 -2
  56. package/src/utils/utils.test.ts +171 -0
  57. package/src/utils/worker.ts +123 -84
  58. package/src/version.ts +11 -0
  59. package/tools/scaffold.js +543 -0
  60. package/dist/id-scanner-core.esm.js +0 -11349
  61. package/dist/id-scanner-core.js +0 -11361
  62. package/dist/id-scanner-core.min.js +0 -1
  63. package/dist/id-scanner-ocr.esm.js +0 -2319
  64. package/dist/id-scanner-ocr.js +0 -2328
  65. package/dist/id-scanner-ocr.min.js +0 -1
  66. package/dist/id-scanner-qr.esm.js +0 -1296
  67. package/dist/id-scanner-qr.js +0 -1305
  68. package/dist/id-scanner-qr.min.js +0 -1
  69. package/dist/id-scanner.js +0 -4561
  70. package/dist/id-scanner.min.js +0 -1
  71. package/src/core.ts +0 -138
  72. package/src/demo/demo.ts +0 -204
  73. package/src/id-recognition/data-extractor.ts +0 -262
  74. package/src/id-recognition/id-detector.ts +0 -510
  75. package/src/id-recognition/ocr-worker.ts +0 -156
  76. package/src/index-umd.ts +0 -477
  77. package/src/ocr-module.ts +0 -187
  78. package/src/qr-module.ts +0 -179
  79. package/src/scanner/barcode-scanner.ts +0 -251
  80. package/src/scanner/qr-scanner.ts +0 -167
@@ -0,0 +1,142 @@
1
+ /**
2
+ * @file 重试工具测试
3
+ * @description 测试重试功能
4
+ */
5
+
6
+ import { withRetry, AsyncCache, Semaphore } from './retry';
7
+
8
+ describe('withRetry', () => {
9
+ it('should retry on failure and succeed', async () => {
10
+ let attempts = 0;
11
+ const fn = async () => {
12
+ attempts++;
13
+ if (attempts < 3) throw new Error('fail');
14
+ return 'success';
15
+ };
16
+
17
+ const result = await withRetry(fn, {
18
+ maxAttempts: 3,
19
+ initialDelay: 10
20
+ });
21
+
22
+ expect(result).toBe('success');
23
+ expect(attempts).toBe(3);
24
+ });
25
+
26
+ it('should fail after max attempts', async () => {
27
+ const fn = async () => {
28
+ throw new Error('always fails');
29
+ };
30
+
31
+ await expect(
32
+ withRetry(fn, { maxAttempts: 2, initialDelay: 10 })
33
+ ).rejects.toThrow('always fails');
34
+ });
35
+
36
+ it('should not retry if shouldRetry returns false', async () => {
37
+ let attempts = 0;
38
+ const fn = async () => {
39
+ attempts++;
40
+ throw new Error('test');
41
+ };
42
+
43
+ await expect(
44
+ withRetry(fn, {
45
+ maxAttempts: 3,
46
+ initialDelay: 10,
47
+ shouldRetry: () => false
48
+ })
49
+ ).rejects.toThrow();
50
+
51
+ expect(attempts).toBe(1);
52
+ });
53
+ });
54
+
55
+ describe('AsyncCache', () => {
56
+ it('should store and retrieve values', async () => {
57
+ const cache = new AsyncCache<string>(1000);
58
+ cache.set('key1', 'value1');
59
+
60
+ expect(cache.get('key1')).toBe('value1');
61
+ });
62
+
63
+ it('should return undefined for expired cache', async () => {
64
+ const cache = new AsyncCache<string>(50);
65
+ cache.set('key1', 'value1');
66
+
67
+ await new Promise(resolve => setTimeout(resolve, 60));
68
+
69
+ expect(cache.get('key1')).toBeUndefined();
70
+ });
71
+
72
+ it('should getOrSet value if not exists', async () => {
73
+ const cache = new AsyncCache<string>(1000);
74
+
75
+ const result = await cache.getOrSet('key1', async () => 'computed');
76
+
77
+ expect(result).toBe('computed');
78
+ expect(cache.get('key1')).toBe('computed');
79
+ });
80
+
81
+ it('should not recompute if exists', async () => {
82
+ const cache = new AsyncCache<string>(1000);
83
+ let computeCount = 0;
84
+
85
+ const compute = async () => {
86
+ computeCount++;
87
+ return 'value';
88
+ };
89
+
90
+ await cache.getOrSet('key1', compute);
91
+ await cache.getOrSet('key1', compute);
92
+
93
+ expect(computeCount).toBe(1);
94
+ });
95
+
96
+ it('should clear all cache', async () => {
97
+ const cache = new AsyncCache<string>(1000);
98
+ cache.set('key1', 'value1');
99
+ cache.set('key2', 'value2');
100
+
101
+ expect(cache.size).toBe(2);
102
+
103
+ cache.clear();
104
+ expect(cache.size).toBe(0);
105
+ });
106
+ });
107
+
108
+ describe('Semaphore', () => {
109
+ it('should acquire and release permits', async () => {
110
+ const sem = new Semaphore(2);
111
+
112
+ expect(sem.availablePermits).toBe(2);
113
+
114
+ await sem.acquire();
115
+ expect(sem.availablePermits).toBe(1);
116
+
117
+ await sem.acquire();
118
+ expect(sem.availablePermits).toBe(0);
119
+
120
+ sem.release();
121
+ expect(sem.availablePermits).toBe(1);
122
+ });
123
+
124
+ it('should tryAcquire returns false when no permits', () => {
125
+ const sem = new Semaphore(1);
126
+ sem.acquire();
127
+
128
+ expect(sem.tryAcquire()).toBe(false);
129
+ });
130
+
131
+ it('should queue waiting acquires', async () => {
132
+ const sem = new Semaphore(1);
133
+
134
+ // First acquire
135
+ await sem.acquire();
136
+ expect(sem.availablePermits).toBe(0);
137
+
138
+ // Release
139
+ sem.release();
140
+ expect(sem.availablePermits).toBe(1);
141
+ });
142
+ });
@@ -0,0 +1,282 @@
1
+ /**
2
+ * @file 重试工具
3
+ * @description 提供重试逻辑功能
4
+ * @module utils/retry
5
+ */
6
+
7
+ /**
8
+ * 重试选项
9
+ */
10
+ export interface RetryOptions {
11
+ /** 最大重试次数 */
12
+ maxAttempts?: number;
13
+ /** 初始等待时间(ms) */
14
+ initialDelay?: number;
15
+ /** 最大等待时间(ms) */
16
+ maxDelay?: number;
17
+ /** 指数退避因子 */
18
+ backoffFactor?: number;
19
+ /** 是否随机抖动 */
20
+ jitter?: boolean;
21
+ /** 重试条件 */
22
+ shouldRetry?: (error: unknown) => boolean;
23
+ }
24
+
25
+ /**
26
+ * 默认重试选项
27
+ */
28
+ const DEFAULT_OPTIONS: Required<RetryOptions> = {
29
+ maxAttempts: 3,
30
+ initialDelay: 1000,
31
+ maxDelay: 10000,
32
+ backoffFactor: 2,
33
+ jitter: true,
34
+ shouldRetry: () => true
35
+ };
36
+
37
+ /**
38
+ * 带重试的异步函数包装器
39
+ * @param fn 要执行的异步函数
40
+ * @param options 重试选项
41
+ * @returns 函数结果
42
+ */
43
+ export async function withRetry<T>(
44
+ fn: () => Promise<T>,
45
+ options: RetryOptions = {}
46
+ ): Promise<T> {
47
+ const opts = { ...DEFAULT_OPTIONS, ...options };
48
+ let lastError: unknown;
49
+ let delay = opts.initialDelay;
50
+
51
+ for (let attempt = 1; attempt <= opts.maxAttempts; attempt++) {
52
+ try {
53
+ return await fn();
54
+ } catch (error) {
55
+ lastError = error;
56
+
57
+ // 检查是否应该重试
58
+ if (!opts.shouldRetry(error)) {
59
+ throw error;
60
+ }
61
+
62
+ // 如果不是最后一次尝试,等待后重试
63
+ if (attempt < opts.maxAttempts) {
64
+ // 添加随机抖动
65
+ const jitterAmount = opts.jitter ? Math.random() * delay : 0;
66
+ const waitTime = Math.min(delay + jitterAmount, opts.maxDelay);
67
+
68
+ await sleep(waitTime);
69
+
70
+ // 指数退避
71
+ delay = Math.min(delay * opts.backoffFactor, opts.maxDelay);
72
+ }
73
+ }
74
+ }
75
+
76
+ throw lastError;
77
+ }
78
+
79
+ /**
80
+ * 睡眠函数
81
+ * @param ms 毫秒数
82
+ */
83
+ function sleep(ms: number): Promise<void> {
84
+ return new Promise(resolve => setTimeout(resolve, ms));
85
+ }
86
+
87
+ /**
88
+ * 创建指数退避重试函数
89
+ * @param options 重试选项
90
+ * @returns 包装后的重试函数
91
+ */
92
+ export function createRetryable<T extends (...args: any[]) => Promise<any>>(
93
+ fn: T,
94
+ options: RetryOptions = {}
95
+ ): T {
96
+ const wrapped = (...args: Parameters<T>): Promise<ReturnType<T>> => {
97
+ return withRetry(() => fn(...args), options);
98
+ };
99
+ Object.defineProperty(wrapped, 'name', { value: fn.name, configurable: true });
100
+ return wrapped as T;
101
+ }
102
+
103
+ /**
104
+ * 异步缓存
105
+ * @description 用于缓存异步函数的结果
106
+ */
107
+ export class AsyncCache<T> {
108
+ private cache: Map<string, { value: T; expiry: number }> = new Map();
109
+ private defaultTTL: number;
110
+
111
+ /**
112
+ * @param defaultTTL 默认过期时间(毫秒)
113
+ */
114
+ constructor(defaultTTL: number = 5 * 60 * 1000) {
115
+ this.defaultTTL = defaultTTL;
116
+ }
117
+
118
+ /**
119
+ * 获取缓存值
120
+ * @param key 缓存键
121
+ * @returns 缓存值,如果不存在或已过期则返回undefined
122
+ */
123
+ get(key: string): T | undefined {
124
+ const entry = this.cache.get(key);
125
+ if (!entry) return undefined;
126
+
127
+ if (Date.now() > entry.expiry) {
128
+ this.cache.delete(key);
129
+ return undefined;
130
+ }
131
+
132
+ return entry.value;
133
+ }
134
+
135
+ /**
136
+ * 设置缓存值
137
+ * @param key 缓存键
138
+ * @param value 缓存值
139
+ * @param ttl 过期时间(毫秒)
140
+ */
141
+ set(key: string, value: T, ttl?: number): void {
142
+ this.cache.set(key, {
143
+ value,
144
+ expiry: Date.now() + (ttl || this.defaultTTL)
145
+ });
146
+ }
147
+
148
+ /**
149
+ * 删除缓存值
150
+ * @param key 缓存键
151
+ */
152
+ delete(key: string): void {
153
+ this.cache.delete(key);
154
+ }
155
+
156
+ /**
157
+ * 清空缓存
158
+ */
159
+ clear(): void {
160
+ this.cache.clear();
161
+ }
162
+
163
+ /**
164
+ * 获取缓存大小
165
+ */
166
+ get size(): number {
167
+ // 清理过期项 (避免在迭代中删除)
168
+ const now = Date.now();
169
+ const expiredKeys: string[] = [];
170
+ for (const [key, entry] of this.cache.entries()) {
171
+ if (now > entry.expiry) {
172
+ expiredKeys.push(key);
173
+ }
174
+ }
175
+ expiredKeys.forEach(key => this.cache.delete(key));
176
+ return this.cache.size;
177
+ }
178
+
179
+ /**
180
+ * 异步获取或设置缓存
181
+ * @param key 缓存键
182
+ * @param fn 获取值的函数
183
+ * @param ttl 过期时间
184
+ * @returns 缓存值
185
+ */
186
+ async getOrSet(
187
+ key: string,
188
+ fn: () => Promise<T>,
189
+ ttl?: number
190
+ ): Promise<T> {
191
+ const cached = this.get(key);
192
+ if (cached !== undefined) {
193
+ return cached;
194
+ }
195
+
196
+ const value = await fn();
197
+ this.set(key, value, ttl);
198
+ return value;
199
+ }
200
+ }
201
+
202
+ /**
203
+ * 简单信号量
204
+ * @description 用于控制并发数量
205
+ */
206
+ export class Semaphore {
207
+ private permits: number;
208
+ private queue: Array<() => void> = [];
209
+
210
+ /**
211
+ * @param permits 最大许可数
212
+ */
213
+ constructor(permits: number) {
214
+ this.permits = permits;
215
+ }
216
+
217
+ /**
218
+ * 获取许可
219
+ * @returns Promise
220
+ */
221
+ async acquire(): Promise<void> {
222
+ if (this.permits > 0) {
223
+ this.permits--;
224
+ return Promise.resolve();
225
+ }
226
+
227
+ return new Promise<void>(resolve => {
228
+ this.queue.push(resolve);
229
+ });
230
+ }
231
+
232
+ /**
233
+ * 释放许可
234
+ */
235
+ release(): void {
236
+ this.permits++;
237
+ const next = this.queue.shift();
238
+ if (next) {
239
+ this.permits--;
240
+ next();
241
+ }
242
+ }
243
+
244
+ /**
245
+ * 尝试获取许可
246
+ * @returns 是否获取成功
247
+ */
248
+ tryAcquire(): boolean {
249
+ if (this.permits > 0) {
250
+ this.permits--;
251
+ return true;
252
+ }
253
+ return false;
254
+ }
255
+
256
+ /**
257
+ * 获取当前可用许可数
258
+ */
259
+ get availablePermits(): number {
260
+ return this.permits;
261
+ }
262
+ }
263
+
264
+ /**
265
+ * 带信号量的异步函数包装器
266
+ * @param fn 异步函数
267
+ * @param semaphore 信号量
268
+ * @returns 包装后的函数
269
+ */
270
+ export function withSemaphore<T extends (...args: any[]) => Promise<any>>(
271
+ fn: T,
272
+ semaphore: Semaphore
273
+ ): T {
274
+ return (async (...args: Parameters<T>): Promise<ReturnType<T>> => {
275
+ await semaphore.acquire();
276
+ try {
277
+ return await fn(...args);
278
+ } finally {
279
+ semaphore.release();
280
+ }
281
+ }) as T;
282
+ }
@@ -70,12 +70,100 @@ export interface DetectionResult {
70
70
  * ```
71
71
  */
72
72
  export interface IDCardInfo {
73
+ /** 姓名 */
73
74
  name?: string;
75
+ /** 性别 */
74
76
  gender?: string;
75
- nationality?: string;
77
+ /** 民族 */
78
+ ethnicity?: string;
79
+ /** 出生日期 */
76
80
  birthDate?: string;
81
+ /** 地址 */
77
82
  address?: string;
83
+ /** 身份证号码 */
78
84
  idNumber?: string;
79
- issuingAuthority?: string;
85
+ /** 签发机关 */
86
+ issueAuthority?: string;
87
+ /** 有效期起始日期 */
88
+ validFrom?: string;
89
+ /** 有效期截止日期 */
90
+ validTo?: string;
91
+ /** 有效期限(完整文本) */
80
92
  validPeriod?: string;
93
+ /** 照片区域 */
94
+ photoRegion?: {
95
+ x: number;
96
+ y: number;
97
+ width: number;
98
+ height: number;
99
+ };
100
+ /** 类型 */
101
+ type?: string;
102
+ /** 置信度 */
103
+ confidence?: number;
104
+ /** 其他属性 */
105
+ [key: string]: any;
106
+ }
107
+
108
+ /**
109
+ * 点坐标
110
+ */
111
+ export interface Point {
112
+ x: number;
113
+ y: number;
114
+ }
115
+
116
+ /**
117
+ * 矩形区域
118
+ */
119
+ export interface Rect {
120
+ x: number;
121
+ y: number;
122
+ width: number;
123
+ height: number;
124
+ }
125
+
126
+ /**
127
+ * 可释放资源接口
128
+ */
129
+ export interface Disposable {
130
+ /** 释放资源 */
131
+ dispose(): Promise<void>;
132
+ }
133
+
134
+ /**
135
+ * 图像处理选项
136
+ */
137
+ export interface ImageProcessingOptions {
138
+ /** 亮度调整 (-100 到 100) */
139
+ brightness?: number;
140
+ /** 对比度调整 (-100 到 100) */
141
+ contrast?: number;
142
+ /** 饱和度调整 (-100 到 100) */
143
+ saturation?: number;
144
+ /** 锐化强度 (0 到 10) */
145
+ sharpen?: number | boolean;
146
+ /** 高斯模糊半径 (0 到 10) */
147
+ blur?: number;
148
+ /** 是否应用灰度转换 */
149
+ grayscale?: boolean;
150
+ /** 是否应用二值化 */
151
+ binarize?: boolean;
152
+ /** 二值化阈值 (0 到 255) */
153
+ threshold?: number;
154
+ /** 是否应用边缘检测 */
155
+ edgeDetection?: boolean;
156
+ /** 是否应用降噪 */
157
+ denoise?: boolean;
158
+ /** 是否应用直方图均衡化 */
159
+ histogramEqualization?: boolean;
160
+ /** 是否应用透视校正 */
161
+ perspectiveCorrection?: boolean;
162
+ /** 透视校正点 */
163
+ perspectivePoints?: {
164
+ topLeft: Point;
165
+ topRight: Point;
166
+ bottomRight: Point;
167
+ bottomLeft: Point;
168
+ };
81
169
  }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * @file 工具函数测试
3
+ * @description 测试通用工具函数
4
+ */
5
+
6
+ import {
7
+ delay,
8
+ throttle,
9
+ debounce,
10
+ formatBytes,
11
+ generateUUID,
12
+ chunk,
13
+ safeParseJSON,
14
+ clamp,
15
+ isValidUrl,
16
+ } from '../utils';
17
+
18
+ describe('Utils', () => {
19
+ describe('delay', () => {
20
+ it('should delay for specified milliseconds', async () => {
21
+ const start = Date.now();
22
+ await delay(100);
23
+ const elapsed = Date.now() - start;
24
+ expect(elapsed).toBeGreaterThanOrEqual(90);
25
+ });
26
+ });
27
+
28
+ describe('throttle', () => {
29
+ beforeEach(() => {
30
+ jest.useFakeTimers();
31
+ });
32
+
33
+ afterEach(() => {
34
+ jest.useRealTimers();
35
+ });
36
+
37
+ it('should throttle function calls', () => {
38
+ const fn = jest.fn();
39
+ const throttled = throttle(fn, 100);
40
+
41
+ throttled();
42
+ throttled();
43
+ throttled();
44
+
45
+ expect(fn).toHaveBeenCalledTimes(1);
46
+ });
47
+
48
+ it('should allow calls after throttle period', () => {
49
+ const fn = jest.fn();
50
+ const throttled = throttle(fn, 100);
51
+
52
+ throttled();
53
+ jest.advanceTimersByTime(100);
54
+ throttled();
55
+
56
+ expect(fn).toHaveBeenCalledTimes(2);
57
+ });
58
+ });
59
+
60
+ describe('debounce', () => {
61
+ beforeEach(() => {
62
+ jest.useFakeTimers();
63
+ });
64
+
65
+ afterEach(() => {
66
+ jest.useRealTimers();
67
+ });
68
+
69
+ it('should debounce function calls', () => {
70
+ const fn = jest.fn();
71
+ const debounced = debounce(fn, 100);
72
+
73
+ debounced();
74
+ debounced();
75
+ debounced();
76
+
77
+ jest.advanceTimersByTime(100);
78
+
79
+ expect(fn).toHaveBeenCalledTimes(1);
80
+ });
81
+
82
+ it('should execute immediately when immediate is true', () => {
83
+ const fn = jest.fn();
84
+ const debounced = debounce(fn, 100, true);
85
+
86
+ debounced();
87
+ debounced();
88
+ debounced();
89
+
90
+ expect(fn).toHaveBeenCalledTimes(1);
91
+ });
92
+ });
93
+
94
+ describe('formatBytes', () => {
95
+ it('should format bytes correctly', () => {
96
+ expect(formatBytes(0)).toBe('0 Bytes');
97
+ expect(formatBytes(1024)).toBe('1 KB');
98
+ expect(formatBytes(1024 * 1024)).toBe('1 MB');
99
+ expect(formatBytes(1024 * 1024 * 1024)).toBe('1 GB');
100
+ });
101
+
102
+ it('should respect decimal places', () => {
103
+ expect(formatBytes(1536, 2)).toBe('1.5 KB');
104
+ expect(formatBytes(1536, 0)).toBe('2 KB');
105
+ });
106
+ });
107
+
108
+ describe('generateUUID', () => {
109
+ it('should generate valid UUID format', () => {
110
+ const uuid = generateUUID();
111
+ const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
112
+ expect(uuid).toMatch(uuidRegex);
113
+ });
114
+
115
+ it('should generate unique UUIDs', () => {
116
+ const uuids = new Set(Array.from({ length: 100 }, () => generateUUID()));
117
+ expect(uuids.size).toBe(100);
118
+ });
119
+ });
120
+
121
+ describe('chunk', () => {
122
+ it('should split array into chunks', () => {
123
+ const arr = [1, 2, 3, 4, 5, 6, 7, 8, 9];
124
+ const chunks = chunk(arr, 3);
125
+ expect(chunks).toEqual([[1, 2, 3], [4, 5, 6], [7, 8, 9]]);
126
+ });
127
+
128
+ it('should handle last chunk smaller than chunk size', () => {
129
+ const arr = [1, 2, 3, 4, 5];
130
+ const chunks = chunk(arr, 2);
131
+ expect(chunks).toEqual([[1, 2], [3, 4], [5]]);
132
+ });
133
+
134
+ it('should throw error for invalid chunk size', () => {
135
+ expect(() => chunk([1, 2, 3], 0)).toThrow('Chunk size must be greater than 0');
136
+ });
137
+ });
138
+
139
+ describe('safeParseJSON', () => {
140
+ it('should parse valid JSON', () => {
141
+ const result = safeParseJSON('{"a": 1}', null);
142
+ expect(result).toEqual({ a: 1 });
143
+ });
144
+
145
+ it('should return fallback for invalid JSON', () => {
146
+ const result = safeParseJSON('invalid', { fallback: true });
147
+ expect(result).toEqual({ fallback: true });
148
+ });
149
+ });
150
+
151
+ describe('clamp', () => {
152
+ it('should clamp value within range', () => {
153
+ expect(clamp(5, 0, 10)).toBe(5);
154
+ expect(clamp(-5, 0, 10)).toBe(0);
155
+ expect(clamp(15, 0, 10)).toBe(10);
156
+ });
157
+ });
158
+
159
+ describe('isValidUrl', () => {
160
+ it('should validate correct URLs', () => {
161
+ expect(isValidUrl('https://example.com')).toBe(true);
162
+ expect(isValidUrl('http://localhost:3000')).toBe(true);
163
+ expect(isValidUrl('ftp://files.example.com')).toBe(true);
164
+ });
165
+
166
+ it('should reject invalid URLs', () => {
167
+ expect(isValidUrl('not-a-url')).toBe(false);
168
+ expect(isValidUrl('')).toBe(false);
169
+ });
170
+ });
171
+ });