mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-24 07:20:16 +05:30
feat(compare): implement Document Compare dialog with local-diff and git-HEAD-diff modes
Amit Haridas
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Tests for the Document Compare dialog (local two-file diff and git-HEAD diff).
|
||||
* Exercises the real dialog DOM in jsdom with the electron IPC surface mocked,
|
||||
* following the jest.mock('electron') pattern in monospace-font-config.test.js.
|
||||
*/
|
||||
|
||||
jest.mock('electron', () => ({
|
||||
ipcRenderer: {
|
||||
invoke: jest.fn(),
|
||||
send: jest.fn(),
|
||||
on: jest.fn(),
|
||||
once: jest.fn(),
|
||||
removeAllListeners: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
require('../src/utils/ModalManager'); // sets window.ModalManager for the dialog
|
||||
const { ipcRenderer } = require('electron');
|
||||
const { showDocumentCompareDialog } = require('../src/renderer/document-compare-dialog');
|
||||
|
||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
function openDialog(filePath = null) {
|
||||
showDocumentCompareDialog({ filePath });
|
||||
}
|
||||
|
||||
function clickCompare() {
|
||||
document.getElementById('document-compare-run').click();
|
||||
}
|
||||
|
||||
function resultRows() {
|
||||
return Array.from(document.querySelectorAll('#compare-result .diff-row'));
|
||||
}
|
||||
|
||||
function statusText() {
|
||||
return document.getElementById('compare-status-message').textContent;
|
||||
}
|
||||
|
||||
describe('Document Compare dialog', () => {
|
||||
beforeEach(() => {
|
||||
ipcRenderer.invoke.mockReset();
|
||||
openDialog('/notes/report.md');
|
||||
});
|
||||
|
||||
describe('local two-file mode', () => {
|
||||
it('renders added, removed, and unchanged rows from the line diff', async () => {
|
||||
ipcRenderer.invoke.mockImplementation((channel, filePath) => {
|
||||
if (channel !== 'read-file') return {};
|
||||
if (filePath === '/notes/old.md') return '# Title\nold line\nshared tail';
|
||||
if (filePath === '/notes/new.md') return '# Title\nnew line\nshared tail';
|
||||
throw new Error(`unexpected path ${filePath}`);
|
||||
});
|
||||
document.getElementById('compare-file-a-input').value = '/notes/old.md';
|
||||
document.getElementById('compare-file-b-input').value = '/notes/new.md';
|
||||
|
||||
clickCompare();
|
||||
await flush();
|
||||
|
||||
const rows = resultRows();
|
||||
expect(rows).toHaveLength(4);
|
||||
expect(rows[0].className).toBe('diff-row diff-context');
|
||||
expect(rows[1].className).toBe('diff-row diff-removed');
|
||||
expect(rows[1].textContent).toContain('old line');
|
||||
expect(rows[2].className).toBe('diff-row diff-added');
|
||||
expect(rows[2].textContent).toContain('new line');
|
||||
expect(rows[3].className).toBe('diff-row diff-context');
|
||||
expect(statusText()).toBe('1 line(s) added, 1 line(s) removed.');
|
||||
});
|
||||
|
||||
it('reports identical files without rendering a diff view', async () => {
|
||||
ipcRenderer.invoke.mockImplementation(() => 'same\ncontent');
|
||||
document.getElementById('compare-file-a-input').value = '/a.md';
|
||||
document.getElementById('compare-file-b-input').value = '/b.md';
|
||||
|
||||
clickCompare();
|
||||
await flush();
|
||||
|
||||
expect(statusText()).toBe('Files are identical.');
|
||||
expect(document.getElementById('compare-result').classList.contains('hidden')).toBe(true);
|
||||
});
|
||||
|
||||
it('warns when one of the two files has not been chosen', async () => {
|
||||
document.getElementById('compare-file-a-input').value = '/a.md';
|
||||
document.getElementById('compare-file-b-input').value = '';
|
||||
|
||||
clickCompare();
|
||||
await flush();
|
||||
|
||||
expect(statusText()).toBe('Choose both files to compare.');
|
||||
expect(ipcRenderer.invoke).not.toHaveBeenCalledWith('read-file', expect.anything());
|
||||
});
|
||||
|
||||
it('shows a warning when a file cannot be read', async () => {
|
||||
ipcRenderer.invoke.mockRejectedValue(new Error('Invalid file path'));
|
||||
document.getElementById('compare-file-a-input').value = '/a.md';
|
||||
document.getElementById('compare-file-b-input').value = '/b.md';
|
||||
|
||||
clickCompare();
|
||||
await flush();
|
||||
|
||||
expect(statusText()).toContain('Error reading file');
|
||||
});
|
||||
});
|
||||
|
||||
describe('git HEAD mode', () => {
|
||||
function selectGitMode() {
|
||||
const modeSelect = document.getElementById('compare-mode-select');
|
||||
modeSelect.value = 'git';
|
||||
modeSelect.dispatchEvent(new Event('change'));
|
||||
}
|
||||
|
||||
it('renders git raw diff text verbatim, colored by leading character', async () => {
|
||||
ipcRenderer.invoke.mockImplementation((channel) => {
|
||||
if (channel === 'git-status') return { current: 'master', files: [] };
|
||||
if (channel === 'git-diff') {
|
||||
return 'diff --git a/report.md b/report.md\n@@ -1,2 +1,2 @@\n context\n-old line\n+new line';
|
||||
}
|
||||
return {};
|
||||
});
|
||||
await flush(); // git-status availability check
|
||||
selectGitMode();
|
||||
clickCompare();
|
||||
await flush();
|
||||
|
||||
const rows = resultRows();
|
||||
expect(rows).toHaveLength(5);
|
||||
expect(rows[0].className).toBe('diff-row diff-context');
|
||||
expect(rows[1].className).toBe('diff-row diff-hunk');
|
||||
expect(rows[2].className).toBe('diff-row diff-context');
|
||||
expect(rows[3].className).toBe('diff-row diff-removed');
|
||||
expect(rows[3].textContent).toBe('-old line');
|
||||
expect(rows[4].className).toBe('diff-row diff-added');
|
||||
expect(rows[4].textContent).toBe('+new line');
|
||||
expect(ipcRenderer.invoke).toHaveBeenCalledWith('git-diff', {
|
||||
file: '/notes/report.md',
|
||||
againstHead: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports no differences when git returns an empty diff', async () => {
|
||||
ipcRenderer.invoke.mockImplementation((channel) => {
|
||||
if (channel === 'git-status') return { current: 'master', files: [] };
|
||||
if (channel === 'git-diff') return '';
|
||||
return {};
|
||||
});
|
||||
await flush();
|
||||
selectGitMode();
|
||||
clickCompare();
|
||||
await flush();
|
||||
|
||||
expect(statusText()).toBe('No differences against HEAD.');
|
||||
expect(document.getElementById('compare-result').classList.contains('hidden')).toBe(true);
|
||||
});
|
||||
|
||||
it('degrades to a disabled option with a hint outside a git repository', async () => {
|
||||
ipcRenderer.invoke.mockImplementation((channel) => {
|
||||
if (channel === 'git-status') return { error: 'Not a git repository' };
|
||||
return {};
|
||||
});
|
||||
openDialog('/plain/file.md');
|
||||
await flush();
|
||||
|
||||
const gitOption = document.querySelector('#compare-mode-select option[value="git"]');
|
||||
expect(gitOption.disabled).toBe(true);
|
||||
const hint = document.getElementById('compare-git-hint');
|
||||
expect(hint.classList.contains('hidden')).toBe(false);
|
||||
expect(hint.textContent).toBe('Current file is not inside a git repository.');
|
||||
expect(ipcRenderer.invoke).not.toHaveBeenCalledWith('git-diff', expect.anything());
|
||||
});
|
||||
|
||||
it('degrades to a disabled option with a hint when no file is open', async () => {
|
||||
openDialog(null);
|
||||
|
||||
const gitOption = document.querySelector('#compare-mode-select option[value="git"]');
|
||||
expect(gitOption.disabled).toBe(true);
|
||||
const hint = document.getElementById('compare-git-hint');
|
||||
expect(hint.textContent).toContain('Open (or save) a file');
|
||||
expect(ipcRenderer.invoke).not.toHaveBeenCalledWith('git-diff', expect.anything());
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Tests for the LCS line diff used by the Document Compare dialog
|
||||
* Covers: identical texts, pure additions, pure deletions, mixed edits, empty sides
|
||||
*/
|
||||
|
||||
const { computeLineDiff } = require('../src/utils/line-diff');
|
||||
|
||||
describe('computeLineDiff', () => {
|
||||
it('reports every line as unchanged for identical texts', () => {
|
||||
const text = '# Title\n\nSome paragraph.\n- item 1\n- item 2';
|
||||
const result = computeLineDiff(text, text);
|
||||
expect(result).toHaveLength(5);
|
||||
expect(result.every((entry) => entry.type === 'unchanged')).toBe(true);
|
||||
expect(result.map((entry) => entry.text)).toEqual([
|
||||
'# Title',
|
||||
'',
|
||||
'Some paragraph.',
|
||||
'- item 1',
|
||||
'- item 2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports only additions when lines were appended', () => {
|
||||
const result = computeLineDiff('# Title\n\nBody.', '# Title\n\nBody.\nNew line.\nAnother.');
|
||||
expect(result.filter((entry) => entry.type === 'removed')).toEqual([]);
|
||||
expect(result).toEqual([
|
||||
{ type: 'unchanged', text: '# Title' },
|
||||
{ type: 'unchanged', text: '' },
|
||||
{ type: 'unchanged', text: 'Body.' },
|
||||
{ type: 'added', text: 'New line.' },
|
||||
{ type: 'added', text: 'Another.' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports only removals when lines were deleted', () => {
|
||||
const result = computeLineDiff('Intro\nKeep me\nDrop me\nAlso drop', 'Intro\nKeep me');
|
||||
expect(result.filter((entry) => entry.type === 'added')).toEqual([]);
|
||||
expect(result).toEqual([
|
||||
{ type: 'unchanged', text: 'Intro' },
|
||||
{ type: 'unchanged', text: 'Keep me' },
|
||||
{ type: 'removed', text: 'Drop me' },
|
||||
{ type: 'removed', text: 'Also drop' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports adjacent removed-then-added entries for a mixed change', () => {
|
||||
const result = computeLineDiff(
|
||||
'# Heading\nold paragraph\ntrailer',
|
||||
'# Heading\nnew paragraph\ntrailer'
|
||||
);
|
||||
expect(result).toEqual([
|
||||
{ type: 'unchanged', text: '# Heading' },
|
||||
{ type: 'removed', text: 'old paragraph' },
|
||||
{ type: 'added', text: 'new paragraph' },
|
||||
{ type: 'unchanged', text: 'trailer' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('treats an empty old text as a pure addition', () => {
|
||||
expect(computeLineDiff('', 'a\nb')).toEqual([
|
||||
{ type: 'added', text: 'a' },
|
||||
{ type: 'added', text: 'b' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('treats an empty new text as a pure removal', () => {
|
||||
expect(computeLineDiff('a\nb', '')).toEqual([
|
||||
{ type: 'removed', text: 'a' },
|
||||
{ type: 'removed', text: 'b' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores carriage-return differences between the two texts', () => {
|
||||
const result = computeLineDiff('one\r\ntwo\r\n', 'one\ntwo\n');
|
||||
expect(result).toEqual([
|
||||
{ type: 'unchanged', text: 'one' },
|
||||
{ type: 'unchanged', text: 'two' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores a purely trailing-newline difference', () => {
|
||||
const result = computeLineDiff('a\nb\n', 'a\nb');
|
||||
expect(result).toEqual([
|
||||
{ type: 'unchanged', text: 'a' },
|
||||
{ type: 'unchanged', text: 'b' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -43,6 +43,24 @@ describe('GitOperations', () => {
|
||||
expect(result).toHaveProperty('error');
|
||||
fs.rmSync(nonGitDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('includes staged changes when againstHead is true', async () => {
|
||||
fs.writeFileSync(filePath, 'line1\nline2\n');
|
||||
await simpleGit(tmpDir).add('file.txt');
|
||||
|
||||
const unstaged = await GitOperations.diff(tmpDir, 'file.txt');
|
||||
expect(unstaged).toBe('');
|
||||
|
||||
const againstHead = await GitOperations.diff(tmpDir, 'file.txt', true);
|
||||
expect(againstHead).toContain('+line2');
|
||||
});
|
||||
|
||||
test('returns error object for non-git directory when againstHead is true', async () => {
|
||||
const nonGitDir = fs.mkdtempSync(path.join(os.tmpdir(), 'notgit_'));
|
||||
const result = await GitOperations.diff(nonGitDir, null, true);
|
||||
expect(result).toHaveProperty('error');
|
||||
fs.rmSync(nonGitDir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('branches', () => {
|
||||
|
||||
Reference in New Issue
Block a user