@okrapdf/cli 0.1.2 → 0.1.4

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,974 @@
1
+ /**
2
+ * Tests for OCR Review Scorer
3
+ *
4
+ * These tests prove the deterministic scoring workflows work correctly
5
+ * for prioritizing pages in large document review.
6
+ */
7
+ import { describe, it, expect, beforeEach } from 'vitest';
8
+ import { OcrReviewScorer, createScorer, compareOcrToMarkdown, parsePagesJson, parseTablesJson, formatScoredPages, formatStats, DEFAULT_CONFIG,
9
+ // Financial verification
10
+ getConfidenceTier, categorizeByConfidence, runCheckSums, detectAnomalies, validateCrossQuery, runFinancialVerification, formatVerificationReport, } from './scorer.js';
11
+ // ============================================================================
12
+ // Test Fixtures
13
+ // ============================================================================
14
+ function createMockPage(overrides = {}) {
15
+ return {
16
+ page: 1,
17
+ status: 'pending',
18
+ total: 0,
19
+ verified: 0,
20
+ pending: 0,
21
+ flagged: 0,
22
+ rejected: 0,
23
+ avgConfidence: 0.9,
24
+ hasOcr: true,
25
+ ocrLineCount: 50,
26
+ hasCoverageGaps: false,
27
+ uncoveredCount: 0,
28
+ resolution: null,
29
+ classification: null,
30
+ isStale: false,
31
+ ...overrides,
32
+ };
33
+ }
34
+ function createMockTable(overrides = {}) {
35
+ return {
36
+ id: 'table-1',
37
+ page_number: 1,
38
+ markdown: '| Col1 | Col2 |\n|------|------|\n| A | B |',
39
+ verification_status: 'pending',
40
+ confidence: 0.85,
41
+ ...overrides,
42
+ };
43
+ }
44
+ function createMockOcrBlocks() {
45
+ return [
46
+ { text: 'Header Text', bbox: { x: 100, y: 50, width: 200, height: 20 }, confidence: 0.95 },
47
+ { text: 'First paragraph content here.', bbox: { x: 50, y: 100, width: 400, height: 40 }, confidence: 0.88 },
48
+ { text: 'Table cell 1', bbox: { x: 50, y: 200, width: 100, height: 20 }, confidence: 0.82 },
49
+ { text: 'Table cell 2', bbox: { x: 150, y: 200, width: 100, height: 20 }, confidence: 0.80 },
50
+ ];
51
+ }
52
+ // ============================================================================
53
+ // Core Scorer Tests
54
+ // ============================================================================
55
+ describe('OcrReviewScorer', () => {
56
+ let scorer;
57
+ beforeEach(() => {
58
+ scorer = new OcrReviewScorer();
59
+ });
60
+ describe('scorePage', () => {
61
+ it('should return zero score for high confidence page with no tables', () => {
62
+ const page = createMockPage({ avgConfidence: 0.95 });
63
+ const result = scorer.scorePage(page);
64
+ expect(result.score).toBeLessThan(10);
65
+ expect(result.tableCount).toBe(0);
66
+ expect(result.flags).not.toContain('has_tables');
67
+ });
68
+ it('should increase score for pages with tables', () => {
69
+ const page = createMockPage({ page: 1 });
70
+ const tables = [
71
+ createMockTable({ page_number: 1 }),
72
+ createMockTable({ page_number: 1, id: 'table-2' }),
73
+ ];
74
+ scorer.loadTables(tables);
75
+ const result = scorer.scorePage(page);
76
+ expect(result.score).toBeGreaterThan(0);
77
+ expect(result.tableCount).toBe(2);
78
+ expect(result.breakdown.tableScore).toBe(20); // 2 * 10 default weight
79
+ expect(result.flags).toContain('has_tables');
80
+ });
81
+ it('should increase score for low confidence pages', () => {
82
+ const highConfPage = createMockPage({ avgConfidence: 0.95, page: 1 });
83
+ const lowConfPage = createMockPage({ avgConfidence: 0.5, page: 2 });
84
+ const highResult = scorer.scorePage(highConfPage);
85
+ const lowResult = scorer.scorePage(lowConfPage);
86
+ expect(lowResult.score).toBeGreaterThan(highResult.score);
87
+ expect(lowResult.breakdown.confidenceScore).toBeGreaterThan(highResult.breakdown.confidenceScore);
88
+ expect(lowResult.flags).toContain('low_confidence');
89
+ });
90
+ it('should add penalty for coverage gaps', () => {
91
+ const noGaps = createMockPage({ hasCoverageGaps: false });
92
+ const hasGaps = createMockPage({ hasCoverageGaps: true });
93
+ const noGapsResult = scorer.scorePage(noGaps);
94
+ const hasGapsResult = scorer.scorePage(hasGaps);
95
+ expect(hasGapsResult.score).toBeGreaterThan(noGapsResult.score);
96
+ expect(hasGapsResult.breakdown.coverageScore).toBe(DEFAULT_CONFIG.weights.coverageGap);
97
+ expect(hasGapsResult.flags).toContain('coverage_gaps');
98
+ });
99
+ it('should add penalty for flagged entities', () => {
100
+ const noFlagged = createMockPage({ flagged: 0 });
101
+ const hasFlagged = createMockPage({ flagged: 3 });
102
+ const noFlaggedResult = scorer.scorePage(noFlagged);
103
+ const hasFlaggedResult = scorer.scorePage(hasFlagged);
104
+ expect(hasFlaggedResult.score).toBeGreaterThan(noFlaggedResult.score);
105
+ expect(hasFlaggedResult.breakdown.flaggedScore).toBe(3 * DEFAULT_CONFIG.weights.flaggedEntity);
106
+ expect(hasFlaggedResult.flags).toContain('has_flagged');
107
+ });
108
+ });
109
+ describe('scoreAll', () => {
110
+ it('should sort pages by score descending (highest priority first)', () => {
111
+ const pages = [
112
+ createMockPage({ page: 1, avgConfidence: 0.95 }), // Low priority
113
+ createMockPage({ page: 2, avgConfidence: 0.5 }), // High priority (low confidence)
114
+ createMockPage({ page: 3, avgConfidence: 0.8 }), // Medium priority
115
+ ];
116
+ const results = scorer.scoreAll(pages);
117
+ expect(results[0].page).toBe(2); // Lowest confidence = highest priority
118
+ expect(results[1].page).toBe(3);
119
+ expect(results[2].page).toBe(1); // Highest confidence = lowest priority
120
+ });
121
+ it('should correctly combine multiple scoring factors', () => {
122
+ const tables = [
123
+ createMockTable({ page_number: 2 }),
124
+ createMockTable({ page_number: 2, id: 'table-2' }),
125
+ createMockTable({ page_number: 2, id: 'table-3' }),
126
+ ];
127
+ scorer.loadTables(tables);
128
+ const pages = [
129
+ createMockPage({ page: 1, avgConfidence: 0.6, hasCoverageGaps: true }), // Low conf + gaps
130
+ createMockPage({ page: 2, avgConfidence: 0.9 }), // 3 tables but high conf
131
+ createMockPage({ page: 3, avgConfidence: 0.7, flagged: 2 }), // Low conf + flagged
132
+ ];
133
+ const results = scorer.scoreAll(pages);
134
+ // Page 3 should be highest: low conf + 2 flagged entities
135
+ // Page 1: low conf + gaps
136
+ // Page 2: 3 tables but high confidence
137
+ expect(results[0].page).toBe(3);
138
+ expect(results.map(r => r.page)).toEqual(expect.arrayContaining([1, 2, 3]));
139
+ });
140
+ });
141
+ describe('filter', () => {
142
+ it('should filter by status', () => {
143
+ const pages = [
144
+ createMockPage({ page: 1, status: 'pending' }),
145
+ createMockPage({ page: 2, status: 'complete' }),
146
+ createMockPage({ page: 3, status: 'flagged' }),
147
+ ];
148
+ const pending = scorer.filter(pages, { status: 'pending' });
149
+ expect(pending.length).toBe(1);
150
+ expect(pending[0].page).toBe(1);
151
+ const multiple = scorer.filter(pages, { status: ['pending', 'flagged'] });
152
+ expect(multiple.length).toBe(2);
153
+ });
154
+ it('should filter by confidence range', () => {
155
+ const pages = [
156
+ createMockPage({ page: 1, avgConfidence: 0.95 }),
157
+ createMockPage({ page: 2, avgConfidence: 0.75 }),
158
+ createMockPage({ page: 3, avgConfidence: 0.5 }),
159
+ ];
160
+ const lowConf = scorer.filter(pages, { maxConfidence: 0.8 });
161
+ expect(lowConf.length).toBe(2);
162
+ expect(lowConf.map(p => p.page)).toEqual([2, 3]);
163
+ const highConf = scorer.filter(pages, { minConfidence: 0.9 });
164
+ expect(highConf.length).toBe(1);
165
+ expect(highConf[0].page).toBe(1);
166
+ });
167
+ it('should filter by table count', () => {
168
+ const tables = [
169
+ createMockTable({ page_number: 1 }),
170
+ createMockTable({ page_number: 2, id: 't2' }),
171
+ createMockTable({ page_number: 2, id: 't3' }),
172
+ ];
173
+ scorer.loadTables(tables);
174
+ const pages = [
175
+ createMockPage({ page: 1 }),
176
+ createMockPage({ page: 2 }),
177
+ createMockPage({ page: 3 }),
178
+ ];
179
+ const withTables = scorer.filter(pages, { minTables: 1 });
180
+ expect(withTables.length).toBe(2);
181
+ const manyTables = scorer.filter(pages, { minTables: 2 });
182
+ expect(manyTables.length).toBe(1);
183
+ expect(manyTables[0].page).toBe(2);
184
+ const noTables = scorer.filter(pages, { maxTables: 0 });
185
+ expect(noTables.length).toBe(1);
186
+ expect(noTables[0].page).toBe(3);
187
+ });
188
+ it('should filter by coverage gaps', () => {
189
+ const pages = [
190
+ createMockPage({ page: 1, hasCoverageGaps: false }),
191
+ createMockPage({ page: 2, hasCoverageGaps: true }),
192
+ ];
193
+ const withGaps = scorer.filter(pages, { hasGaps: true });
194
+ expect(withGaps.length).toBe(1);
195
+ expect(withGaps[0].page).toBe(2);
196
+ const noGaps = scorer.filter(pages, { hasGaps: false });
197
+ expect(noGaps.length).toBe(1);
198
+ expect(noGaps[0].page).toBe(1);
199
+ });
200
+ });
201
+ describe('getAutoApprovable', () => {
202
+ it('should identify pages that can be auto-approved', () => {
203
+ const pages = [
204
+ createMockPage({ page: 1, avgConfidence: 0.98, status: 'pending', total: 0 }),
205
+ createMockPage({ page: 2, avgConfidence: 0.85, status: 'pending', total: 0 }), // Low conf
206
+ createMockPage({ page: 3, avgConfidence: 0.98, status: 'complete', total: 0 }), // Already done
207
+ createMockPage({ page: 4, avgConfidence: 0.98, status: 'pending', hasCoverageGaps: true }), // Has gaps
208
+ createMockPage({ page: 5, avgConfidence: 0.98, status: 'pending', flagged: 1 }), // Has flagged
209
+ ];
210
+ // Add tables to page 1 to test table threshold
211
+ scorer.loadTables([createMockTable({ page_number: 6 })]);
212
+ pages.push(createMockPage({ page: 6, avgConfidence: 0.98, status: 'pending', total: 0 }));
213
+ const autoApprovable = scorer.getAutoApprovable(pages);
214
+ // Only page 1 should be auto-approvable (page 6 has tables)
215
+ expect(autoApprovable.length).toBe(1);
216
+ expect(autoApprovable[0].page).toBe(1);
217
+ });
218
+ });
219
+ describe('getRequireReview', () => {
220
+ it('should identify pages that need human review', () => {
221
+ const tables = [createMockTable({ page_number: 3 })];
222
+ scorer.loadTables(tables);
223
+ const pages = [
224
+ createMockPage({ page: 1, avgConfidence: 0.98 }), // High conf, no issues
225
+ createMockPage({ page: 2, avgConfidence: 0.5 }), // Low confidence
226
+ createMockPage({ page: 3, avgConfidence: 0.95 }), // Has table
227
+ createMockPage({ page: 4, hasCoverageGaps: true }), // Has gaps
228
+ createMockPage({ page: 5, flagged: 2 }), // Has flagged
229
+ ];
230
+ const needsReview = scorer.getRequireReview(pages);
231
+ // Pages 2, 3, 4, 5 should need review
232
+ expect(needsReview.length).toBe(4);
233
+ expect(needsReview.map(p => p.page)).not.toContain(1);
234
+ });
235
+ });
236
+ describe('computeStats', () => {
237
+ it('should compute accurate statistics', () => {
238
+ const tables = [
239
+ createMockTable({ page_number: 1 }),
240
+ createMockTable({ page_number: 2, id: 't2' }),
241
+ createMockTable({ page_number: 2, id: 't3' }),
242
+ ];
243
+ scorer.loadTables(tables);
244
+ const pages = [
245
+ createMockPage({ page: 1, status: 'pending', avgConfidence: 0.95 }),
246
+ createMockPage({ page: 2, status: 'pending', avgConfidence: 0.6 }),
247
+ createMockPage({ page: 3, status: 'complete', avgConfidence: 0.9 }),
248
+ createMockPage({ page: 4, status: 'flagged', avgConfidence: 0.4, hasCoverageGaps: true }),
249
+ ];
250
+ const stats = scorer.computeStats(pages);
251
+ expect(stats.totalPages).toBe(4);
252
+ expect(stats.pagesWithTables).toBe(2);
253
+ expect(stats.totalTables).toBe(3);
254
+ expect(stats.avgConfidence).toBeCloseTo(0.7125, 2);
255
+ expect(stats.lowConfidencePages).toBe(2); // Pages with conf < 0.7
256
+ expect(stats.pagesWithGaps).toBe(1);
257
+ expect(stats.byStatus).toEqual({
258
+ pending: 2,
259
+ complete: 1,
260
+ flagged: 1,
261
+ });
262
+ });
263
+ });
264
+ });
265
+ // ============================================================================
266
+ // Scoring Strategy Tests
267
+ // ============================================================================
268
+ describe('Scoring Strategies', () => {
269
+ it('should create scorer with direct strategy (ignores tables)', () => {
270
+ const scorer = createScorer('direct');
271
+ const tables = [createMockTable({ page_number: 1 })];
272
+ scorer.loadTables(tables);
273
+ const page = createMockPage({ page: 1, avgConfidence: 0.8 });
274
+ const result = scorer.scorePage(page);
275
+ expect(result.breakdown.tableScore).toBe(0);
276
+ expect(result.breakdown.structureScore).toBe(0);
277
+ });
278
+ it('should create scorer with structure strategy (emphasizes tables)', () => {
279
+ const scorer = createScorer('structure');
280
+ const tables = [createMockTable({ page_number: 1 })];
281
+ scorer.loadTables(tables);
282
+ const page = createMockPage({ page: 1 });
283
+ const result = scorer.scorePage(page);
284
+ // Structure strategy has tableCount weight of 20 vs default 10
285
+ expect(result.breakdown.tableScore).toBe(20);
286
+ });
287
+ it('should create scorer with comparison strategy', () => {
288
+ const scorer = createScorer('comparison');
289
+ const result = scorer.scorePage(createMockPage());
290
+ // Comparison strategy has lower table weight
291
+ expect(result.score).toBeDefined();
292
+ });
293
+ });
294
+ // ============================================================================
295
+ // OCR vs Markdown Comparison Tests
296
+ // ============================================================================
297
+ describe('compareOcrToMarkdown', () => {
298
+ it('should detect minimal difference for matching content', () => {
299
+ const ocrBlocks = [
300
+ { text: 'Hello World', confidence: 0.95 },
301
+ { text: 'This is a test.', confidence: 0.90 },
302
+ ];
303
+ const markdown = 'Hello World\n\nThis is a test.';
304
+ const result = compareOcrToMarkdown(ocrBlocks, markdown);
305
+ expect(result.deltaPct).toBeLessThan(0.1);
306
+ expect(result.flags).not.toContain('high_char_delta');
307
+ });
308
+ it('should detect significant difference when content differs', () => {
309
+ const ocrBlocks = [
310
+ { text: 'This is a very long paragraph with lots of content that was captured by OCR.', confidence: 0.85 },
311
+ ];
312
+ const markdown = 'Short.';
313
+ const result = compareOcrToMarkdown(ocrBlocks, markdown);
314
+ expect(result.deltaPct).toBeGreaterThan(0.5);
315
+ expect(result.flags).toContain('high_char_delta');
316
+ });
317
+ it('should evaluate spatial integrity', () => {
318
+ const ocrBlocks = [
319
+ { text: 'Line 1', bbox: { x: 0, y: 0, width: 100, height: 20 }, confidence: 0.9 },
320
+ { text: 'Line 2', bbox: { x: 0, y: 30, width: 100, height: 20 }, confidence: 0.9 },
321
+ { text: 'Line 3', bbox: { x: 0, y: 60, width: 100, height: 20 }, confidence: 0.9 },
322
+ ];
323
+ const markdown = 'Line 1\n\nLine 2\n\nLine 3';
324
+ const result = compareOcrToMarkdown(ocrBlocks, markdown);
325
+ expect(result.spatialIntegrity).toBeGreaterThan(0.5);
326
+ });
327
+ it('should evaluate table structure preservation', () => {
328
+ const ocrBlocks = [
329
+ { text: 'Col1', bbox: { x: 50, y: 0, width: 50, height: 20 } },
330
+ { text: 'Col2', bbox: { x: 150, y: 0, width: 50, height: 20 } },
331
+ { text: 'A', bbox: { x: 50, y: 30, width: 50, height: 20 } },
332
+ { text: 'B', bbox: { x: 150, y: 30, width: 50, height: 20 } },
333
+ ];
334
+ const markdown = '| Col1 | Col2 |\n|------|------|\n| A | B |';
335
+ const result = compareOcrToMarkdown(ocrBlocks, markdown);
336
+ expect(result.tableStructureScore).toBeGreaterThan(0.5);
337
+ });
338
+ it('should flag missing OCR', () => {
339
+ const ocrBlocks = [];
340
+ const markdown = 'Some content that exists in markdown but not OCR';
341
+ const result = compareOcrToMarkdown(ocrBlocks, markdown);
342
+ expect(result.flags).toContain('missing_ocr');
343
+ });
344
+ it('should flag missing markdown', () => {
345
+ const ocrBlocks = [
346
+ { text: 'Content in OCR but not in markdown', confidence: 0.9 },
347
+ ];
348
+ const markdown = '';
349
+ const result = compareOcrToMarkdown(ocrBlocks, markdown);
350
+ expect(result.flags).toContain('missing_markdown');
351
+ });
352
+ });
353
+ // ============================================================================
354
+ // JSON Parsing Tests
355
+ // ============================================================================
356
+ describe('JSON Parsing', () => {
357
+ it('should parse pages JSON array', () => {
358
+ const json = JSON.stringify([
359
+ { page: 1, status: 'pending', avgConfidence: 0.9, total: 0, verified: 0, pending: 0, flagged: 0, rejected: 0, hasOcr: true, ocrLineCount: 50, hasCoverageGaps: false, uncoveredCount: 0, resolution: null, classification: null, isStale: false },
360
+ ]);
361
+ const pages = parsePagesJson(json);
362
+ expect(pages.length).toBe(1);
363
+ expect(pages[0].page).toBe(1);
364
+ });
365
+ it('should parse pages JSON object with pages key', () => {
366
+ const json = JSON.stringify({
367
+ pages: [{ page: 1, status: 'pending', avgConfidence: 0.9, total: 0, verified: 0, pending: 0, flagged: 0, rejected: 0, hasOcr: true, ocrLineCount: 50, hasCoverageGaps: false, uncoveredCount: 0, resolution: null, classification: null, isStale: false }],
368
+ });
369
+ const pages = parsePagesJson(json);
370
+ expect(pages.length).toBe(1);
371
+ });
372
+ it('should parse tables JSON array', () => {
373
+ const json = JSON.stringify([
374
+ { id: 't1', page_number: 1, markdown: '| A |', verification_status: 'pending' },
375
+ ]);
376
+ const tables = parseTablesJson(json);
377
+ expect(tables.length).toBe(1);
378
+ expect(tables[0].id).toBe('t1');
379
+ });
380
+ it('should parse tables JSON object with tables key', () => {
381
+ const json = JSON.stringify({
382
+ tables: [{ id: 't1', page_number: 1, markdown: '| A |', verification_status: 'pending' }],
383
+ });
384
+ const tables = parseTablesJson(json);
385
+ expect(tables.length).toBe(1);
386
+ });
387
+ });
388
+ // ============================================================================
389
+ // Output Formatting Tests
390
+ // ============================================================================
391
+ describe('Output Formatting', () => {
392
+ const sampleScored = [
393
+ {
394
+ page: 1,
395
+ score: 45.5,
396
+ breakdown: { tableScore: 20, confidenceScore: 15.5, coverageScore: 0, flaggedScore: 0, structureScore: 10, comparisonScore: 0 },
397
+ tableCount: 2,
398
+ avgConfidence: 0.69,
399
+ status: 'pending',
400
+ flags: ['has_tables', 'low_confidence'],
401
+ },
402
+ {
403
+ page: 2,
404
+ score: 10,
405
+ breakdown: { tableScore: 0, confidenceScore: 5, coverageScore: 0, flaggedScore: 0, structureScore: 5, comparisonScore: 0 },
406
+ tableCount: 0,
407
+ avgConfidence: 0.9,
408
+ status: 'complete',
409
+ flags: [],
410
+ },
411
+ ];
412
+ it('should format as JSON', () => {
413
+ const output = formatScoredPages(sampleScored, 'json');
414
+ const parsed = JSON.parse(output);
415
+ expect(parsed).toBeInstanceOf(Array);
416
+ expect(parsed.length).toBe(2);
417
+ expect(parsed[0].page).toBe(1);
418
+ });
419
+ it('should format as JSONL', () => {
420
+ const output = formatScoredPages(sampleScored, 'jsonl');
421
+ const lines = output.split('\n');
422
+ expect(lines.length).toBe(2);
423
+ expect(JSON.parse(lines[0]).page).toBe(1);
424
+ expect(JSON.parse(lines[1]).page).toBe(2);
425
+ });
426
+ it('should format as CSV', () => {
427
+ const output = formatScoredPages(sampleScored, 'csv');
428
+ const lines = output.split('\n');
429
+ expect(lines[0]).toBe('page,score,tableCount,avgConfidence,status,flags');
430
+ expect(lines[1]).toContain('1');
431
+ expect(lines[1]).toContain('45.50');
432
+ });
433
+ it('should format as table', () => {
434
+ const output = formatScoredPages(sampleScored, 'table');
435
+ expect(output).toContain('Page');
436
+ expect(output).toContain('Score');
437
+ expect(output).toContain('Tables');
438
+ expect(output).toContain('has_tables');
439
+ });
440
+ });
441
+ describe('Stats Formatting', () => {
442
+ it('should format stats as JSON', () => {
443
+ const stats = {
444
+ totalPages: 100,
445
+ pagesWithTables: 20,
446
+ totalTables: 45,
447
+ avgConfidence: 0.85,
448
+ lowConfidencePages: 10,
449
+ pagesWithGaps: 5,
450
+ byStatus: { pending: 50, complete: 40, flagged: 10 },
451
+ byPriority: { high: 5, medium: 15, low: 80 },
452
+ estimatedReviewTime: { pages: 20, minutes: 25 },
453
+ };
454
+ const output = formatStats(stats, 'json');
455
+ const parsed = JSON.parse(output);
456
+ expect(parsed.totalPages).toBe(100);
457
+ expect(parsed.byPriority.high).toBe(5);
458
+ });
459
+ it('should format stats as table', () => {
460
+ const stats = {
461
+ totalPages: 100,
462
+ pagesWithTables: 20,
463
+ totalTables: 45,
464
+ avgConfidence: 0.85,
465
+ lowConfidencePages: 10,
466
+ pagesWithGaps: 5,
467
+ byStatus: { pending: 50, complete: 40, flagged: 10 },
468
+ byPriority: { high: 5, medium: 15, low: 80 },
469
+ estimatedReviewTime: { pages: 20, minutes: 25 },
470
+ };
471
+ const output = formatStats(stats, 'table');
472
+ expect(output).toContain('Total pages: 100');
473
+ expect(output).toContain('Pages with tables: 20');
474
+ expect(output).toContain('High (score >= 50): 5 pages');
475
+ expect(output).toContain('Estimated review: 20 pages');
476
+ });
477
+ });
478
+ // ============================================================================
479
+ // Workflow Integration Tests
480
+ // ============================================================================
481
+ describe('Workflow Integration', () => {
482
+ it('should complete full table-priority workflow', () => {
483
+ // Simulate a 50-page document with varying characteristics
484
+ const pages = [];
485
+ const tables = [];
486
+ for (let i = 1; i <= 50; i++) {
487
+ const confidence = 0.5 + Math.random() * 0.5; // 0.5 - 1.0
488
+ const hasTables = i % 5 === 0; // Every 5th page has tables
489
+ const hasGaps = i % 10 === 0; // Every 10th page has gaps
490
+ pages.push(createMockPage({
491
+ page: i,
492
+ avgConfidence: confidence,
493
+ hasCoverageGaps: hasGaps,
494
+ status: i <= 40 ? 'pending' : 'complete',
495
+ }));
496
+ if (hasTables) {
497
+ tables.push(createMockTable({ page_number: i, id: `table-${i}` }));
498
+ if (i % 15 === 0) {
499
+ // Some pages have multiple tables
500
+ tables.push(createMockTable({ page_number: i, id: `table-${i}-2` }));
501
+ }
502
+ }
503
+ }
504
+ const scorer = new OcrReviewScorer();
505
+ scorer.loadTables(tables);
506
+ // Step 1: Get statistics
507
+ const stats = scorer.computeStats(pages);
508
+ expect(stats.totalPages).toBe(50);
509
+ expect(stats.pagesWithTables).toBe(10); // 50/5 = 10 pages with tables
510
+ // Step 2: Get auto-approvable pages (high confidence, no tables, no issues)
511
+ const autoApprovable = scorer.getAutoApprovable(pages);
512
+ expect(autoApprovable.length).toBeGreaterThan(0);
513
+ // Step 3: Get pages requiring review
514
+ const needsReview = scorer.getRequireReview(pages);
515
+ expect(needsReview.length).toBeGreaterThan(0);
516
+ // Step 4: Score all and verify priority ordering
517
+ const scored = scorer.scoreAll(pages);
518
+ expect(scored[0].score).toBeGreaterThanOrEqual(scored[scored.length - 1].score);
519
+ // Step 5: Filter for table-heavy pages
520
+ const tablePages = scorer.filter(pages, { minTables: 1 });
521
+ expect(tablePages.length).toBe(10);
522
+ // Verify the highest priority pages have tables or low confidence
523
+ const topPriority = scored.slice(0, 5);
524
+ for (const page of topPriority) {
525
+ const hasIssue = page.tableCount > 0 ||
526
+ page.avgConfidence < 0.7 ||
527
+ page.flags.includes('coverage_gaps');
528
+ expect(hasIssue).toBe(true);
529
+ }
530
+ });
531
+ it('should complete OCR comparison workflow', () => {
532
+ const ocrBlocks = createMockOcrBlocks();
533
+ const markdown = `# Header Text
534
+
535
+ First paragraph content here.
536
+
537
+ | Table cell 1 | Table cell 2 |
538
+ |--------------|--------------|`;
539
+ const comparison = compareOcrToMarkdown(ocrBlocks, markdown);
540
+ // Should detect reasonable similarity
541
+ expect(comparison.deltaPct).toBeLessThan(0.5);
542
+ expect(comparison.spatialIntegrity).toBeGreaterThan(0);
543
+ expect(comparison.tableStructureScore).toBeGreaterThan(0.5);
544
+ });
545
+ it('should handle empty document gracefully', () => {
546
+ const scorer = new OcrReviewScorer();
547
+ scorer.loadTables([]);
548
+ const pages = [];
549
+ const stats = scorer.computeStats(pages);
550
+ expect(stats.totalPages).toBe(0);
551
+ expect(stats.avgConfidence).toBe(0);
552
+ expect(stats.byPriority).toEqual({ high: 0, medium: 0, low: 0 });
553
+ });
554
+ it('should handle all pages with tables', () => {
555
+ const pages = [
556
+ createMockPage({ page: 1 }),
557
+ createMockPage({ page: 2 }),
558
+ createMockPage({ page: 3 }),
559
+ ];
560
+ const tables = [
561
+ createMockTable({ page_number: 1 }),
562
+ createMockTable({ page_number: 2 }),
563
+ createMockTable({ page_number: 3 }),
564
+ ];
565
+ const scorer = new OcrReviewScorer();
566
+ scorer.loadTables(tables);
567
+ const autoApprovable = scorer.getAutoApprovable(pages);
568
+ expect(autoApprovable.length).toBe(0); // All have tables
569
+ const needsReview = scorer.getRequireReview(pages);
570
+ expect(needsReview.length).toBe(3);
571
+ });
572
+ });
573
+ // ============================================================================
574
+ // Custom Configuration Tests
575
+ // ============================================================================
576
+ describe('Custom Configuration', () => {
577
+ it('should apply custom weights', () => {
578
+ const scorer = new OcrReviewScorer({
579
+ weights: {
580
+ tableCount: 100, // Much higher table weight
581
+ inverseConfidence: 10,
582
+ coverageGap: 5,
583
+ flaggedEntity: 5,
584
+ structurePenalty: 5,
585
+ comparisonDelta: 5,
586
+ },
587
+ });
588
+ const tables = [createMockTable({ page_number: 1 })];
589
+ scorer.loadTables(tables);
590
+ const page = createMockPage({ page: 1 });
591
+ const result = scorer.scorePage(page);
592
+ expect(result.breakdown.tableScore).toBe(100);
593
+ });
594
+ it('should apply custom thresholds', () => {
595
+ const scorer = new OcrReviewScorer({
596
+ thresholds: {
597
+ autoApproveConfidence: 0.99, // Very strict
598
+ requireReviewConfidence: 0.9,
599
+ maxTablesForAutoApprove: 0,
600
+ minOcrLinesForContent: 5,
601
+ },
602
+ });
603
+ const pages = [
604
+ createMockPage({ page: 1, avgConfidence: 0.98, status: 'pending' }),
605
+ createMockPage({ page: 2, avgConfidence: 0.995, status: 'pending' }),
606
+ ];
607
+ const autoApprovable = scorer.getAutoApprovable(pages);
608
+ expect(autoApprovable.length).toBe(1);
609
+ expect(autoApprovable[0].page).toBe(2); // Only 0.995 passes 0.99 threshold
610
+ });
611
+ });
612
+ // ============================================================================
613
+ // Financial Verification Tests
614
+ // ============================================================================
615
+ describe('Financial Verification', () => {
616
+ describe('getConfidenceTier', () => {
617
+ it('should categorize high confidence as auto_approve', () => {
618
+ expect(getConfidenceTier(0.96)).toBe('auto_approve');
619
+ expect(getConfidenceTier(0.99)).toBe('auto_approve');
620
+ expect(getConfidenceTier(1.0)).toBe('auto_approve');
621
+ });
622
+ it('should categorize medium confidence as spot_check', () => {
623
+ expect(getConfidenceTier(0.70)).toBe('spot_check');
624
+ expect(getConfidenceTier(0.85)).toBe('spot_check');
625
+ expect(getConfidenceTier(0.94)).toBe('spot_check');
626
+ });
627
+ it('should categorize low confidence as manual_review', () => {
628
+ expect(getConfidenceTier(0.69)).toBe('manual_review');
629
+ expect(getConfidenceTier(0.50)).toBe('manual_review');
630
+ expect(getConfidenceTier(0.0)).toBe('manual_review');
631
+ });
632
+ });
633
+ describe('categorizeByConfidence', () => {
634
+ it('should categorize pages into tiers', () => {
635
+ const pages = [
636
+ createMockPage({ page: 1, avgConfidence: 0.98 }),
637
+ createMockPage({ page: 2, avgConfidence: 0.85 }),
638
+ createMockPage({ page: 3, avgConfidence: 0.60 }),
639
+ createMockPage({ page: 4, avgConfidence: 0.75 }),
640
+ createMockPage({ page: 5, avgConfidence: 0.96 }),
641
+ ];
642
+ const categorized = categorizeByConfidence(pages);
643
+ expect(categorized.auto_approve.length).toBe(2); // pages 1, 5
644
+ expect(categorized.spot_check.length).toBe(2); // pages 2, 4
645
+ expect(categorized.manual_review.length).toBe(1); // page 3
646
+ });
647
+ });
648
+ describe('runCheckSums', () => {
649
+ it('should validate balance sheet identity', () => {
650
+ // Valid balance sheet: Assets = Liabilities + Equity
651
+ const validValues = {
652
+ total_assets: 1000000,
653
+ total_liabilities: 600000,
654
+ equity: 400000,
655
+ };
656
+ const results = runCheckSums(validValues);
657
+ const balanceSheet = results.find(r => r.rule.name === 'Balance Sheet Identity');
658
+ expect(balanceSheet).toBeDefined();
659
+ expect(balanceSheet.result.passed).toBe(true);
660
+ });
661
+ it('should detect balance sheet imbalance', () => {
662
+ // Invalid: Assets don't equal Liabilities + Equity
663
+ const invalidValues = {
664
+ total_assets: 1000000,
665
+ total_liabilities: 600000,
666
+ equity: 300000, // Should be 400000
667
+ };
668
+ const results = runCheckSums(invalidValues);
669
+ const balanceSheet = results.find(r => r.rule.name === 'Balance Sheet Identity');
670
+ expect(balanceSheet).toBeDefined();
671
+ expect(balanceSheet.result.passed).toBe(false);
672
+ expect(balanceSheet.result.tolerancePct).toBeGreaterThan(1);
673
+ });
674
+ it('should validate net income calculation', () => {
675
+ const values = {
676
+ total_revenue: 5000000,
677
+ total_expenses: 4200000,
678
+ net_income: 800000,
679
+ };
680
+ const results = runCheckSums(values);
681
+ const netIncomeCheck = results.find(r => r.rule.name === 'Net Income Check');
682
+ expect(netIncomeCheck).toBeDefined();
683
+ expect(netIncomeCheck.result.passed).toBe(true);
684
+ });
685
+ it('should allow small tolerance for rounding', () => {
686
+ // 0.5% rounding difference should pass
687
+ const values = {
688
+ total_assets: 1000000,
689
+ total_liabilities: 600000,
690
+ equity: 405000, // 0.5% over
691
+ };
692
+ const results = runCheckSums(values);
693
+ const balanceSheet = results.find(r => r.rule.name === 'Balance Sheet Identity');
694
+ expect(balanceSheet.result.passed).toBe(true);
695
+ });
696
+ it('should skip rules with insufficient data', () => {
697
+ const values = {
698
+ total_assets: 1000000, // Only one field
699
+ };
700
+ const results = runCheckSums(values);
701
+ // Should not validate balance sheet without liabilities/equity
702
+ expect(results.length).toBe(0);
703
+ });
704
+ });
705
+ describe('detectAnomalies', () => {
706
+ it('should detect decimal point error', () => {
707
+ const historical = [
708
+ { revenue: 5000000 },
709
+ { revenue: 5200000 },
710
+ { revenue: 4800000 },
711
+ ];
712
+ const current = {
713
+ revenue: 50000, // Missing two zeros - 100x error
714
+ };
715
+ const anomalies = detectAnomalies(current, historical);
716
+ expect(anomalies.length).toBe(1);
717
+ expect(anomalies[0].field).toBe('revenue');
718
+ expect(anomalies[0].possibleCause).toContain('decimal point error');
719
+ });
720
+ it('should not flag normal variance', () => {
721
+ const historical = [
722
+ { revenue: 5000000 },
723
+ { revenue: 5200000 },
724
+ { revenue: 4800000 },
725
+ ];
726
+ const current = {
727
+ revenue: 5100000, // Normal year-over-year change
728
+ };
729
+ const anomalies = detectAnomalies(current, historical);
730
+ expect(anomalies.length).toBe(0);
731
+ });
732
+ it('should handle insufficient historical data', () => {
733
+ const historical = [{ revenue: 5000000 }]; // Only one data point
734
+ const current = { revenue: 50000 };
735
+ const anomalies = detectAnomalies(current, historical);
736
+ expect(anomalies.length).toBe(0); // Can't detect anomalies without enough history
737
+ });
738
+ it('should detect significant outliers', () => {
739
+ const historical = [
740
+ { profit: 100000 },
741
+ { profit: 120000 },
742
+ { profit: 110000 },
743
+ { profit: 105000 },
744
+ ];
745
+ const current = {
746
+ profit: 1000000, // 10x higher than historical
747
+ };
748
+ const anomalies = detectAnomalies(current, historical);
749
+ expect(anomalies.length).toBe(1);
750
+ expect(anomalies[0].zScore).toBeGreaterThan(3);
751
+ });
752
+ });
753
+ describe('validateCrossQuery', () => {
754
+ it('should validate total assets breakdown', () => {
755
+ const values = {
756
+ total_assets: 1000000,
757
+ current_assets: 400000,
758
+ non_current_assets: 600000,
759
+ };
760
+ const results = validateCrossQuery(values);
761
+ const assetsCheck = results.find(r => r.rule.name === 'Total Assets Components');
762
+ expect(assetsCheck).toBeDefined();
763
+ expect(assetsCheck.passed).toBe(true);
764
+ });
765
+ it('should detect missing component in total', () => {
766
+ const values = {
767
+ total_assets: 1000000,
768
+ current_assets: 400000,
769
+ // non_current_assets missing but total doesn't match
770
+ };
771
+ const results = validateCrossQuery(values);
772
+ const assetsCheck = results.find(r => r.rule.name === 'Total Assets Components');
773
+ expect(assetsCheck).toBeDefined();
774
+ expect(assetsCheck.passed).toBe(false);
775
+ expect(assetsCheck.differencePercent).toBeGreaterThan(2);
776
+ });
777
+ it('should allow tolerance for incomplete components', () => {
778
+ const values = {
779
+ total_revenue: 1000000,
780
+ product_revenue: 800000,
781
+ service_revenue: 180000,
782
+ // other_revenue = 20000 is missing but within tolerance
783
+ };
784
+ const results = validateCrossQuery(values);
785
+ const revenueCheck = results.find(r => r.rule.name === 'Total Revenue Components');
786
+ expect(revenueCheck).toBeDefined();
787
+ expect(revenueCheck.passed).toBe(true); // Within 2% tolerance
788
+ });
789
+ });
790
+ describe('runFinancialVerification', () => {
791
+ it('should pass for valid financial data with high confidence', () => {
792
+ const values = {
793
+ total_assets: 1000000,
794
+ total_liabilities: 600000,
795
+ equity: 400000,
796
+ };
797
+ const report = runFinancialVerification(values, 0.98);
798
+ expect(report.overallStatus).toBe('pass');
799
+ expect(report.confidenceTier).toBe('auto_approve');
800
+ expect(report.issues.length).toBe(0);
801
+ });
802
+ it('should fail for low confidence', () => {
803
+ const values = {
804
+ total_assets: 1000000,
805
+ total_liabilities: 600000,
806
+ equity: 400000,
807
+ };
808
+ const report = runFinancialVerification(values, 0.50);
809
+ expect(report.overallStatus).toBe('fail');
810
+ expect(report.confidenceTier).toBe('manual_review');
811
+ expect(report.issues.some(i => i.includes('Low OCR confidence'))).toBe(true);
812
+ });
813
+ it('should fail for check-sum violations', () => {
814
+ const values = {
815
+ total_assets: 1000000,
816
+ total_liabilities: 600000,
817
+ equity: 200000, // Wrong!
818
+ };
819
+ const report = runFinancialVerification(values, 0.98);
820
+ expect(report.overallStatus).toBe('fail');
821
+ expect(report.issues.some(i => i.includes('Balance Sheet Identity failed'))).toBe(true);
822
+ });
823
+ it('should fail for anomalies with historical data', () => {
824
+ const values = {
825
+ revenue: 50000, // Should be ~5 million
826
+ };
827
+ const historical = [
828
+ { revenue: 5000000 },
829
+ { revenue: 5200000 },
830
+ { revenue: 4800000 },
831
+ ];
832
+ const report = runFinancialVerification(values, 0.98, historical);
833
+ expect(report.overallStatus).toBe('fail');
834
+ expect(report.anomalies.length).toBe(1);
835
+ });
836
+ it('should provide recommendations for issues', () => {
837
+ const values = {
838
+ total_assets: 1000000,
839
+ total_liabilities: 600000,
840
+ equity: 200000,
841
+ };
842
+ const report = runFinancialVerification(values, 0.75);
843
+ expect(report.recommendations.length).toBeGreaterThan(0);
844
+ expect(report.recommendations.some(r => r.includes('spot-check'))).toBe(true);
845
+ });
846
+ });
847
+ describe('formatVerificationReport', () => {
848
+ it('should format passing report', () => {
849
+ const values = {
850
+ total_assets: 1000000,
851
+ total_liabilities: 600000,
852
+ equity: 400000,
853
+ };
854
+ const report = runFinancialVerification(values, 0.98);
855
+ const formatted = formatVerificationReport(report);
856
+ expect(formatted).toContain('Overall Status: PASS');
857
+ expect(formatted).toContain('AUTO APPROVE');
858
+ });
859
+ it('should format failing report with details', () => {
860
+ const values = {
861
+ total_assets: 1000000,
862
+ total_liabilities: 600000,
863
+ equity: 200000,
864
+ };
865
+ const report = runFinancialVerification(values, 0.50);
866
+ const formatted = formatVerificationReport(report);
867
+ expect(formatted).toContain('Overall Status: FAIL');
868
+ expect(formatted).toContain('Check-Sum Validations');
869
+ expect(formatted).toContain('Issues:');
870
+ expect(formatted).toContain('Recommendations:');
871
+ });
872
+ });
873
+ });
874
+ // ============================================================================
875
+ // End-to-End Financial Workflow Test
876
+ // ============================================================================
877
+ describe('Financial Document Review Workflow', () => {
878
+ it('should complete full financial verification workflow', () => {
879
+ // Simulate extracting data from a financial report
880
+ // Step 1: OCR extracts pages with confidence scores
881
+ const pages = [
882
+ createMockPage({ page: 1, avgConfidence: 0.98, total: 2 }), // Cover page
883
+ createMockPage({ page: 2, avgConfidence: 0.92, total: 5 }), // Balance sheet
884
+ createMockPage({ page: 3, avgConfidence: 0.95, total: 4 }), // Income statement
885
+ createMockPage({ page: 4, avgConfidence: 0.65, total: 8 }), // Notes (low quality)
886
+ createMockPage({ page: 5, avgConfidence: 0.88, total: 3 }), // Cash flow
887
+ ];
888
+ const tables = [
889
+ createMockTable({ page_number: 2, id: 'balance-sheet-1' }),
890
+ createMockTable({ page_number: 2, id: 'balance-sheet-2' }),
891
+ createMockTable({ page_number: 3, id: 'income-statement' }),
892
+ createMockTable({ page_number: 5, id: 'cash-flow' }),
893
+ ];
894
+ // Step 2: Categorize by confidence tier
895
+ const categorized = categorizeByConfidence(pages);
896
+ expect(categorized.auto_approve.length).toBe(2); // Pages 1 (98%), 3 (95%)
897
+ expect(categorized.spot_check.length).toBe(2); // Pages 2, 5
898
+ expect(categorized.manual_review.length).toBe(1); // Page 4 (65%)
899
+ // Step 3: Score pages for review priority
900
+ const scorer = new OcrReviewScorer();
901
+ scorer.loadTables(tables);
902
+ const scored = scorer.scoreAll(pages);
903
+ // Highest priority pages should include those with low confidence or tables
904
+ // Page 4 has lowest confidence (0.65), pages 2,3,5 have tables
905
+ const topPriorityPages = scored.slice(0, 3).map(s => s.page);
906
+ expect(topPriorityPages).toContain(4); // Low confidence page
907
+ expect(topPriorityPages).toContain(2); // Has 2 tables
908
+ // Step 4: Simulate extracted financial values
909
+ const extractedValues = {
910
+ total_assets: 10500000,
911
+ current_assets: 3500000,
912
+ non_current_assets: 7000000,
913
+ total_liabilities: 6200000,
914
+ current_liabilities: 2100000,
915
+ non_current_liabilities: 4100000,
916
+ equity: 4300000,
917
+ working_capital: 1400000, // current_assets - current_liabilities
918
+ total_revenue: 25000000,
919
+ total_expenses: 22500000,
920
+ net_income: 2500000,
921
+ };
922
+ // Step 5: Run financial verification
923
+ const report = runFinancialVerification(extractedValues, 0.96);
924
+ // Should pass - data is consistent and confidence is high enough
925
+ expect(report.overallStatus).toBe('pass');
926
+ expect(report.checkSums.every(c => c.result.passed)).toBe(true);
927
+ // Step 6: Run cross-query validation
928
+ const crossResults = validateCrossQuery(extractedValues);
929
+ expect(crossResults.every(r => r.passed)).toBe(true);
930
+ // Step 7: Test anomaly detection with historical data
931
+ const historicalData = [
932
+ { total_revenue: 24000000, net_income: 2400000 },
933
+ { total_revenue: 23000000, net_income: 2300000 },
934
+ { total_revenue: 22000000, net_income: 2200000 },
935
+ ];
936
+ const anomalies = detectAnomalies(extractedValues, historicalData);
937
+ expect(anomalies.length).toBe(0); // No anomalies - growth is normal
938
+ // Step 8: Format report for analyst
939
+ const formatted = formatVerificationReport(report);
940
+ expect(formatted).toContain('PASS');
941
+ });
942
+ it('should catch common OCR errors in financial data', () => {
943
+ // Simulate OCR errors that commonly occur
944
+ // Error 1: Decimal point shift
945
+ const historicalRevenue = [
946
+ { revenue: 5000000 },
947
+ { revenue: 5200000 },
948
+ { revenue: 4800000 },
949
+ ];
950
+ const decimalError = { revenue: 50000 }; // Missing 2 zeros
951
+ const anomalies = detectAnomalies(decimalError, historicalRevenue);
952
+ expect(anomalies.length).toBe(1);
953
+ expect(anomalies[0].possibleCause).toContain('decimal point error');
954
+ // Error 2: Balance sheet doesn't balance
955
+ const unbalanced = {
956
+ total_assets: 1000000,
957
+ total_liabilities: 500000,
958
+ equity: 400000, // Should be 500000
959
+ };
960
+ const checksumResults = runCheckSums(unbalanced);
961
+ const balanceCheck = checksumResults.find(r => r.rule.name === 'Balance Sheet Identity');
962
+ expect(balanceCheck.result.passed).toBe(false);
963
+ // Error 3: Components don't sum to total
964
+ const missingComponent = {
965
+ total_assets: 1000000,
966
+ current_assets: 300000,
967
+ // Missing non_current_assets that should be 700000
968
+ };
969
+ const crossResults = validateCrossQuery(missingComponent);
970
+ const assetsCheck = crossResults.find(r => r.rule.name === 'Total Assets Components');
971
+ expect(assetsCheck.passed).toBe(false);
972
+ });
973
+ });
974
+ //# sourceMappingURL=scorer.test.js.map