/** * Unit tests for IAA diff viewer * * Tests the diff calculation logic for both "all differences" mode (which works) * and "semantic differences" mode (which currently doesn't work correctly). * * @testCovers fastapi_app/plugins/iaa_analyzer/diff-viewer.js */ import { describe, it } from 'node:test'; import assert from 'node:assert'; // Import the diff viewer module and Diff library import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { diffLines } from 'diff'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); // Load and execute the diff viewer script const diffViewerPath = join(__dirname, '../diff-viewer.js'); const diffViewerCode = readFileSync(diffViewerPath, 'utf-8'); // Create exports object const exports = {}; // Set up global Diff object with real diff library global.Diff = { diffLines }; // Execute the code in a context with exports eval(diffViewerCode); const { computeDiffBlocks, regroupByOriginalLines } = exports; describe('IAA Diff Viewer', () => { describe('All Differences Mode (WORKING)', () => { it('should detect simple text changes', () => { const xml1Original = '\n

Hello world

\n
'; const xml2Original = '\n

Hello universe

\n
'; const diffBlocks = computeDiffBlocks({ xml1Original, xml2Original, xml1Preprocessed: xml1Original, xml2Preprocessed: xml2Original, lineMapping1: {}, lineMapping2: {}, lineOffset1: 1, lineOffset2: 1, useSemanticMode: false }); assert.ok(diffBlocks.length > 0, 'Should have diff blocks'); assert.ok(diffBlocks[0].left.length > 0, 'Should have left side differences'); assert.ok(diffBlocks[0].right.length > 0, 'Should have right side differences'); // Verify line numbers are correct assert.strictEqual(diffBlocks[0].left[0].number, 2, 'Left line number should be 2'); assert.strictEqual(diffBlocks[0].right[0].number, 2, 'Right line number should be 2'); // Verify content assert.strictEqual(diffBlocks[0].left[0].content, '

Hello world

'); assert.strictEqual(diffBlocks[0].right[0].content, '

Hello universe

'); }); it('should detect attribute changes', () => { const xml1Original = '\n

Text

\n
'; const xml2Original = '\n

Text

\n
'; const diffBlocks = computeDiffBlocks({ xml1Original, xml2Original, xml1Preprocessed: xml1Original, xml2Preprocessed: xml2Original, lineMapping1: {}, lineMapping2: {}, lineOffset1: 1, lineOffset2: 1, useSemanticMode: false }); assert.ok(diffBlocks.length > 0); assert.ok(diffBlocks[0].left[0].content.includes('id="p1"')); assert.ok(diffBlocks[0].right[0].content.includes('id="p2"')); }); it('should handle multi-line differences', () => { const xml1Original = '\n

Line 1

\n

Line 2

\n
'; const xml2Original = '\n

Line 1 modified

\n

Line 2 modified

\n
'; const diffBlocks = computeDiffBlocks({ xml1Original, xml2Original, xml1Preprocessed: xml1Original, xml2Preprocessed: xml2Original, lineMapping1: {}, lineMapping2: {}, lineOffset1: 1, lineOffset2: 1, useSemanticMode: false }); assert.ok(diffBlocks.length > 0); // With real diff library, should have equal removed and added lines const totalLeft = diffBlocks.reduce((sum, block) => sum + block.left.length, 0); const totalRight = diffBlocks.reduce((sum, block) => sum + block.right.length, 0); assert.strictEqual(totalLeft, 2, 'Should have 2 removed lines'); assert.strictEqual(totalRight, 2, 'Should have 2 added lines'); }); it('should respect line offsets', () => { const xml1Original = '

Test

'; const xml2Original = '

Modified

'; const diffBlocks = computeDiffBlocks({ xml1Original, xml2Original, xml1Preprocessed: xml1Original, xml2Preprocessed: xml2Original, lineMapping1: {}, lineMapping2: {}, lineOffset1: 10, // Content starts at line 10 lineOffset2: 15, // Content starts at line 15 useSemanticMode: false }); assert.strictEqual(diffBlocks[0].startLine1, 10); assert.strictEqual(diffBlocks[0].startLine2, 15); assert.strictEqual(diffBlocks[0].left[0].number, 10); assert.strictEqual(diffBlocks[0].right[0].number, 15); }); }); describe('Semantic Differences Mode', () => { it('should ignore configured attributes when diffing but show original content', () => { // Original XML with xml:id attributes const xml1Original = '\n

Hello

\n
'; const xml2Original = '\n

Hello

\n
'; // Preprocessed XML with xml:id removed const xml1Preprocessed = '\n

Hello

\n
'; const xml2Preprocessed = '\n

Hello

\n
'; // Line mapping: preprocessed line -> original line const lineMapping1 = { 1: 1, 2: 2, 3: 3 }; const lineMapping2 = { 1: 1, 2: 2, 3: 3 }; const diffBlocks = computeDiffBlocks({ xml1Original, xml2Original, xml1Preprocessed, xml2Preprocessed, lineMapping1, lineMapping2, lineOffset1: 1, lineOffset2: 1, useSemanticMode: true }); // No differences because preprocessed XML is identical assert.strictEqual(diffBlocks.length, 0); }); it('should show semantic differences with preprocessed content', () => { // Original XML with xml:id that differs const xml1Original = '\n

Different

\n
'; const xml2Original = '\n

Text

\n
'; // Preprocessed XML with xml:id removed but text difference remains const xml1Preprocessed = '\n

Different

\n
'; const xml2Preprocessed = '\n

Text

\n
'; const lineMapping1 = {}; const lineMapping2 = {}; const diffBlocks = computeDiffBlocks({ xml1Original, xml2Original, xml1Preprocessed, xml2Preprocessed, lineMapping1, lineMapping2, lineOffset1: 1, lineOffset2: 1, useSemanticMode: true }); // Should find 1 diff block assert.strictEqual(diffBlocks.length, 1); // Content should be from PREPROCESSED XML (without xml:id) assert.strictEqual(diffBlocks[0].left[0].content, '

Different

'); assert.strictEqual(diffBlocks[0].right[0].content, '

Text

'); // Semantic mode doesn't show line numbers (empty string) assert.strictEqual(diffBlocks[0].left[0].number, ''); assert.strictEqual(diffBlocks[0].right[0].number, ''); }); it('should handle line mapping when preprocessed XML has fewer lines', () => { // Original XML with ignored elements that span multiple lines const xml1Original = '\n\n

Text

\n
'; const xml2Original = '\n\n

Text

\n
'; // Preprocessed XML with removed (assuming is in IGNORE_TAGS) const xml1Preprocessed = '\n

Text

\n
'; const xml2Preprocessed = '\n

Text

\n
'; // Line mapping: preprocessed line 1 -> original line 1, preprocessed line 2 -> original line 3 const lineMapping1 = { 1: 1, 2: 3, 3: 4 }; const lineMapping2 = { 1: 1, 2: 3, 3: 4 }; const diffBlocks = computeDiffBlocks({ xml1Original, xml2Original, xml1Preprocessed, xml2Preprocessed, lineMapping1, lineMapping2, lineOffset1: 1, lineOffset2: 1, useSemanticMode: true }); // No differences (preprocessed XML is identical) assert.strictEqual(diffBlocks.length, 0); }); it('should handle realistic TEI with multiple ignored attributes and tags', () => { // Realistic TEI with multiple attributes that might be ignored const xml1Original = `
Chapter 1

First paragraph with John.

`; const xml2Original = `
Chapter 1

First paragraph with John.

`; // Preprocessed: xml:id removed, rendition removed, pb removed const xml1Preprocessed = `
Chapter 1

First paragraph with John.

`; const xml2Preprocessed = `
Chapter 1

First paragraph with John.

`; // Line mapping accounts for removed pb line const lineMapping1 = { 1: 1, 2: 2, 3: 4, 4: 5, 5: 6, 6: 7, 7: 8, 8: 9 }; const lineMapping2 = { 1: 1, 2: 2, 3: 4, 4: 5, 5: 6, 6: 7, 7: 8, 8: 9 }; const diffBlocks = computeDiffBlocks({ xml1Original, xml2Original, xml1Preprocessed, xml2Preprocessed, lineMapping1, lineMapping2, lineOffset1: 10, lineOffset2: 10, useSemanticMode: true }); // No semantic differences (preprocessed XML is identical despite different xml:id and rendition) assert.strictEqual(diffBlocks.length, 0); }); it('should detect semantic difference in nested elements', () => { const xml1Original = `

Text with Alice here.

`; const xml2Original = `

Text with Alice here.

`; // Preprocessed: xml:id removed, pb removed, but type difference remains const xml1Preprocessed = `

Text with Alice here.

`; const xml2Preprocessed = `

Text with Alice here.

`; const lineMapping1 = {}; const lineMapping2 = {}; const diffBlocks = computeDiffBlocks({ xml1Original, xml2Original, xml1Preprocessed, xml2Preprocessed, lineMapping1, lineMapping2, lineOffset1: 1, lineOffset2: 1, useSemanticMode: true }); // Should find semantic difference (type attribute changed) assert.strictEqual(diffBlocks.length, 1); // Should show preprocessed content (no xml:id, no pb) assert.ok(diffBlocks[0].left[0].content.includes('type="person"')); assert.ok(diffBlocks[0].right[0].content.includes('type="location"')); assert.ok(!diffBlocks[0].left[0].content.includes('xml:id')); assert.ok(!diffBlocks[0].right[0].content.includes('xml:id')); }); it('should not show false differences when only data-line attributes differ', () => { // This is the core bug: identical content with different line numbers // should NOT create diff blocks const xml1Preprocessed = '\n

Same content

\n
'; const xml2Preprocessed = '\n

Same content

\n
'; const diffBlocks = computeDiffBlocks({ xml1Original: '', // Not used in semantic mode xml2Original: '', xml1Preprocessed, xml2Preprocessed, lineMapping1: {}, lineMapping2: {}, lineOffset1: 1, lineOffset2: 1, useSemanticMode: true }); // Should have NO differences because content is identical // (only data-line values differ) assert.strictEqual(diffBlocks.length, 0, 'Should not show differences for identical content with different line numbers'); }); it('should detect real differences even with line markers', () => { // Real content differences should still be detected const xml1Preprocessed = '\n

Different content

\n
'; const xml2Preprocessed = '\n

Other content

\n
'; const diffBlocks = computeDiffBlocks({ xml1Original: '', xml2Original: '', xml1Preprocessed, xml2Preprocessed, lineMapping1: {}, lineMapping2: {}, lineOffset1: 1, lineOffset2: 1, useSemanticMode: true }); // Should have differences because content differs assert.ok(diffBlocks.length > 0, 'Should detect real content differences'); assert.ok(diffBlocks[0].left[0].content.includes('Different content')); assert.ok(diffBlocks[0].right[0].content.includes('Other content')); }); it('should extract line markers and enable click navigation in semantic mode', () => { // Preprocessed XML with data-line attributes injected const xml1Preprocessed = '\n

Different

\n
'; const xml2Preprocessed = '\n

Text

\n
'; const diffBlocks = computeDiffBlocks({ xml1Original: '', // Not used in semantic mode xml2Original: '', // Not used in semantic mode xml1Preprocessed, xml2Preprocessed, lineMapping1: {}, lineMapping2: {}, lineOffset1: 1, lineOffset2: 1, useSemanticMode: true }); assert.strictEqual(diffBlocks.length, 1); // Content should have data-line markers stripped for display assert.ok(!diffBlocks[0].left[0].content.includes('data-line')); assert.ok(!diffBlocks[0].right[0].content.includes('data-line')); // Original line numbers should be extracted assert.strictEqual(diffBlocks[0].left[0].originalLine, 2); assert.strictEqual(diffBlocks[0].right[0].originalLine, 2); // Display line numbers should be empty in semantic mode assert.strictEqual(diffBlocks[0].left[0].number, ''); assert.strictEqual(diffBlocks[0].right[0].number, ''); }); it('should regroup diff blocks by original line numbers', () => { // Simulate a diff block with mixed line numbers (as would happen with restructured XML) const mixedBlock = { left: [ { number: '', content: 'Text 1', originalLine: 100, type: 'removed' }, { number: '', content: 'Text 2', originalLine: 200, type: 'removed' }, { number: '', content: 'Text 3', originalLine: 100, type: 'removed' }, ], right: [ { number: '', content: 'Text 1', originalLine: 100, type: 'added' }, { number: '', content: 'Text 2', originalLine: 200, type: 'added' }, { number: '', content: 'Text 3', originalLine: 100, type: 'added' }, ], startLine1: '', startLine2: '', closed: true }; const regrouped = regroupByOriginalLines([mixedBlock]); // Should create 2 blocks: one for line 100, one for line 200 assert.strictEqual(regrouped.length, 2); // First block should have items from line 100 (2 left, 2 right) assert.strictEqual(regrouped[0].left.length, 2); assert.strictEqual(regrouped[0].right.length, 2); assert.strictEqual(regrouped[0].left[0].originalLine, 100); assert.strictEqual(regrouped[0].right[0].originalLine, 100); // Second block should have items from line 200 (1 left, 1 right) assert.strictEqual(regrouped[1].left.length, 1); assert.strictEqual(regrouped[1].right.length, 1); assert.strictEqual(regrouped[1].left[0].originalLine, 200); assert.strictEqual(regrouped[1].right[0].originalLine, 200); }); }); describe('Edge Cases', () => { it('should handle identical documents', () => { const xml = '\n

Same

\n
'; const diffBlocks = computeDiffBlocks({ xml1Original: xml, xml2Original: xml, xml1Preprocessed: xml, xml2Preprocessed: xml, lineMapping1: {}, lineMapping2: {}, lineOffset1: 1, lineOffset2: 1, useSemanticMode: false }); assert.strictEqual(diffBlocks.length, 0); }); it('should handle empty documents', () => { const diffBlocks = computeDiffBlocks({ xml1Original: '', xml2Original: '', xml1Preprocessed: '', xml2Preprocessed: '', lineMapping1: {}, lineMapping2: {}, lineOffset1: 1, lineOffset2: 1, useSemanticMode: false }); assert.strictEqual(diffBlocks.length, 0); }); it('should handle documents with only additions', () => { const xml1Original = '\n

Line 1

\n
'; const xml2Original = '\n

Line 1

\n

Line 2

\n
'; const diffBlocks = computeDiffBlocks({ xml1Original, xml2Original, xml1Preprocessed: xml1Original, xml2Preprocessed: xml2Original, lineMapping1: {}, lineMapping2: {}, lineOffset1: 1, lineOffset2: 1, useSemanticMode: false }); assert.ok(diffBlocks.length > 0); // With real diff library, should only have additions const totalLeft = diffBlocks.reduce((sum, block) => sum + block.left.length, 0); const totalRight = diffBlocks.reduce((sum, block) => sum + block.right.length, 0); assert.strictEqual(totalLeft, 0, 'Should have no removals'); assert.ok(totalRight > 0, 'Should have additions'); }); it('should handle documents with only removals', () => { const xml1Original = '\n

Line 1

\n

Line 2

\n
'; const xml2Original = '\n

Line 1

\n
'; const diffBlocks = computeDiffBlocks({ xml1Original, xml2Original, xml1Preprocessed: xml1Original, xml2Preprocessed: xml2Original, lineMapping1: {}, lineMapping2: {}, lineOffset1: 1, lineOffset2: 1, useSemanticMode: false }); assert.ok(diffBlocks.length > 0); assert.ok(diffBlocks[0].left.length > 0); // Has removals assert.strictEqual(diffBlocks[0].right.length, 0); // No additions }); }); });