/**
* 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 = '\nHello world
\n';
const xml2Original = '\nHello 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 = '\nText
\n';
const xml2Original = '\nText
\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 = '\nLine 1
\nLine 2
\n';
const xml2Original = '\nLine 1 modified
\nLine 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 = '\nHello
\n';
const xml2Original = '\nHello
\n';
// Preprocessed XML with xml:id removed
const xml1Preprocessed = '\nHello
\n';
const xml2Preprocessed = '\nHello
\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 = '\nDifferent
\n';
const xml2Original = '\nText
\n';
// Preprocessed XML with xml:id removed but text difference remains
const xml1Preprocessed = '\nDifferent
\n';
const xml2Preprocessed = '\nText
\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\nText
\n';
const xml2Original = '\n\nText
\n';
// Preprocessed XML with removed (assuming is in IGNORE_TAGS)
const xml1Preprocessed = '\nText
\n';
const xml2Preprocessed = '\nText
\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 = '\nSame content
\n';
const xml2Preprocessed = '\nSame 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 = '\nDifferent content
\n';
const xml2Preprocessed = '\nOther 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 = '\nDifferent
\n';
const xml2Preprocessed = '\nText
\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 = '\nSame
\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 = '\nLine 1
\n';
const xml2Original = '\nLine 1
\nLine 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 = '\nLine 1
\nLine 2
\n';
const xml2Original = '\nLine 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
});
});
});