test(ui): add interaction tests for ActionBar, FileListItem, and DiffPanel

Adds comprehensive UI component test coverage with 54 new test cases across three components to close issue #23. Includes JSDOM setup polyfills and tooltip mock utilities for DOM-dependent component testing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ClaudiaFang 2026-05-20 05:25:13 +00:00
parent 31ac9189cf
commit a77e0150a1
5 changed files with 467 additions and 0 deletions

View file

@ -55,6 +55,7 @@ export const App = class {
export const TFile = class {};
export const requestUrl = vi.fn();
export const setTooltip = vi.fn();
vi.mock('obsidian', () => ({
Plugin,
@ -67,4 +68,5 @@ vi.mock('obsidian', () => ({
App,
TFile,
requestUrl,
setTooltip,
}));

128
tests/ui/ActionBar.test.ts Normal file
View file

@ -0,0 +1,128 @@
import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest';
import { renderActionBar, type ActionBarProps, type ActionBarCallbacks } from '../../src/ui/components/ActionBar';
import { setupObsidianDOM, createContainer } from './setup-dom';
beforeAll(() => { setupObsidianDOM(); });
const baseProps = (overrides?: Partial<ActionBarProps>): ActionBarProps => ({
hasFiles: true, allSelected: false, indeterminate: false,
canPush: 1, canPull: 1, canDelete: 1,
...overrides,
});
describe('renderActionBar', () => {
let container: HTMLElement;
let callbacks: ActionBarCallbacks;
beforeEach(() => {
container = createContainer();
callbacks = {
onRefresh: vi.fn(),
onSelectAll: vi.fn(),
onPush: vi.fn(),
onPull: vi.fn(),
onDelete: vi.fn(),
};
});
describe('refresh button', () => {
it('always renders when hasFiles is false', () => {
renderActionBar(container, baseProps({ hasFiles: false }), callbacks);
expect(container.querySelector('.ssv-btn-refresh')).not.toBeNull();
});
it('calls onRefresh when clicked', () => {
renderActionBar(container, baseProps({ hasFiles: false }), callbacks);
(container.querySelector('.ssv-btn-refresh') as HTMLButtonElement).click();
expect(callbacks.onRefresh).toHaveBeenCalledOnce();
});
});
describe('when hasFiles is false', () => {
it('does not render push / pull / delete buttons', () => {
renderActionBar(container, baseProps({ hasFiles: false }), callbacks);
expect(container.querySelector('.ssv-btn-push')).toBeNull();
expect(container.querySelector('.ssv-btn-pull')).toBeNull();
expect(container.querySelector('.ssv-btn-danger')).toBeNull();
});
it('does not render select-all row', () => {
renderActionBar(container, baseProps({ hasFiles: false }), callbacks);
expect(container.querySelector('.ssv-select-row')).toBeNull();
});
});
describe('when hasFiles is true', () => {
it('renders push, pull, and delete buttons', () => {
renderActionBar(container, baseProps(), callbacks);
expect(container.querySelector('.ssv-btn-push')).not.toBeNull();
expect(container.querySelector('.ssv-btn-pull')).not.toBeNull();
expect(container.querySelector('.ssv-btn-danger')).not.toBeNull();
});
it('renders select-all checkbox', () => {
renderActionBar(container, baseProps(), callbacks);
expect(container.querySelector('.ssv-select-row input[type="checkbox"]')).not.toBeNull();
});
it('calls onPush when push button clicked', () => {
renderActionBar(container, baseProps(), callbacks);
(container.querySelector('.ssv-btn-push') as HTMLButtonElement).click();
expect(callbacks.onPush).toHaveBeenCalledOnce();
});
it('calls onPull when pull button clicked', () => {
renderActionBar(container, baseProps(), callbacks);
(container.querySelector('.ssv-btn-pull') as HTMLButtonElement).click();
expect(callbacks.onPull).toHaveBeenCalledOnce();
});
it('calls onDelete when delete button clicked', () => {
renderActionBar(container, baseProps(), callbacks);
(container.querySelector('.ssv-btn-danger') as HTMLButtonElement).click();
expect(callbacks.onDelete).toHaveBeenCalledOnce();
});
it('push button is disabled when canPush is 0', () => {
renderActionBar(container, baseProps({ canPush: 0 }), callbacks);
expect((container.querySelector('.ssv-btn-push') as HTMLButtonElement).disabled).toBe(true);
});
it('pull button is disabled when canPull is 0', () => {
renderActionBar(container, baseProps({ canPull: 0 }), callbacks);
expect((container.querySelector('.ssv-btn-pull') as HTMLButtonElement).disabled).toBe(true);
});
it('delete button is disabled when canDelete is 0', () => {
renderActionBar(container, baseProps({ canDelete: 0 }), callbacks);
expect((container.querySelector('.ssv-btn-danger') as HTMLButtonElement).disabled).toBe(true);
});
it('push button is enabled when canPush > 0', () => {
renderActionBar(container, baseProps({ canPush: 3 }), callbacks);
expect((container.querySelector('.ssv-btn-push') as HTMLButtonElement).disabled).toBe(false);
});
it('select-all checkbox reflects allSelected prop', () => {
renderActionBar(container, baseProps({ allSelected: true }), callbacks);
const cb = container.querySelector('.ssv-select-row input') as HTMLInputElement;
expect(cb.checked).toBe(true);
});
it('calls onSelectAll(true) when checkbox is checked', () => {
renderActionBar(container, baseProps(), callbacks);
const cb = container.querySelector('.ssv-select-row input') as HTMLInputElement;
cb.checked = true;
cb.dispatchEvent(new Event('change'));
expect(callbacks.onSelectAll).toHaveBeenCalledWith(true);
});
it('calls onSelectAll(false) when checkbox is unchecked', () => {
renderActionBar(container, baseProps({ allSelected: true }), callbacks);
const cb = container.querySelector('.ssv-select-row input') as HTMLInputElement;
cb.checked = false;
cb.dispatchEvent(new Event('change'));
expect(callbacks.onSelectAll).toHaveBeenCalledWith(false);
});
});
});

View file

@ -0,0 +1,54 @@
import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
import { renderDiffPanel } from '../../src/ui/components/DiffPanel';
import { setupObsidianDOM, createContainer } from './setup-dom';
beforeAll(() => { setupObsidianDOM(); });
describe('renderDiffPanel', () => {
let container: HTMLElement;
beforeEach(() => {
container = createContainer();
});
it('returns the diff element with ssv-diff class', () => {
const el = renderDiffPanel(container, '', '');
expect(el.classList.contains('ssv-diff')).toBe(true);
});
it('renders Remote and Local column headers', () => {
renderDiffPanel(container, 'a', 'b');
const headers = container.querySelectorAll('.ssv-diff-hd');
expect(headers[0]?.textContent).toBe('Remote');
expect(headers[1]?.textContent).toBe('Local');
});
it('renders unchanged lines with unchanged class in both columns', () => {
renderDiffPanel(container, 'same', 'same');
const cells = container.querySelectorAll('.ssv-diff-cell.unchanged');
expect(cells.length).toBeGreaterThanOrEqual(2);
});
it('renders added line in unified view', () => {
renderDiffPanel(container, '', 'new line');
const added = container.querySelector('.ssv-u-line.added');
expect(added?.textContent).toContain('new line');
});
it('renders removed line in unified view', () => {
renderDiffPanel(container, 'old line', '');
const removed = container.querySelector('.ssv-u-line.removed');
expect(removed?.textContent).toContain('old line');
});
it('renders both removed and added lines for a replacement', () => {
renderDiffPanel(container, 'before', 'after');
expect(container.querySelector('.ssv-u-line.removed')).not.toBeNull();
expect(container.querySelector('.ssv-u-line.added')).not.toBeNull();
});
it('renders unchanged lines in unified view for identical content', () => {
renderDiffPanel(container, 'context', 'context');
expect(container.querySelector('.ssv-u-line.unchanged')).not.toBeNull();
});
});

View file

@ -0,0 +1,216 @@
import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest';
import { renderFileItem, statusMeta, type FileItemCallbacks } from '../../src/ui/components/FileListItem';
import type { FileStatus } from '../../src/ui/types';
import type { TFile } from 'obsidian';
import { setupObsidianDOM, createContainer } from './setup-dom';
beforeAll(() => { setupObsidianDOM(); });
const mockFile = {} as TFile;
function makeFileStatus(status: FileStatus['status'], overrides?: Partial<FileStatus>): FileStatus {
return { path: 'docs/test.md', status, ...overrides };
}
describe('statusMeta', () => {
it.each([
['synced', '✓', 'Synced', 'status-synced'],
['modified', '⚠', 'Changed', 'status-modified'],
['unsynced', '↑', 'Local only', 'status-unsynced'],
['remote-only', '↓', 'Remote', 'status-remote'],
['checking', '⟳', 'Checking', 'status-checking'],
] as const)('%s: returns correct icon, label, and fileCls', (status, icon, label, fileCls) => {
const meta = statusMeta(status);
expect(meta.icon).toBe(icon);
expect(meta.label).toBe(label);
expect(meta.fileCls).toBe(fileCls);
});
it('returns distinct CSS classes for each status', () => {
const statuses = ['synced', 'modified', 'unsynced', 'remote-only', 'checking'] as const;
const badgeCls = statuses.map(s => statusMeta(s).badgeCls);
expect(new Set(badgeCls).size).toBe(statuses.length);
});
});
describe('renderFileItem', () => {
let container: HTMLElement;
let callbacks: FileItemCallbacks;
beforeEach(() => {
container = createContainer();
callbacks = {
onSelect: vi.fn(),
onPush: vi.fn(),
onPull: vi.fn(),
onDelete: vi.fn(),
};
});
it('renders file path', () => {
renderFileItem(container, makeFileStatus('synced'), false, callbacks);
expect(container.querySelector('.ssv-file-path')?.textContent).toBe('docs/test.md');
});
it('renders status badge with correct label', () => {
renderFileItem(container, makeFileStatus('modified'), false, callbacks);
expect(container.querySelector('.ssv-status-badge')?.textContent).toBe('Changed');
});
it('checkbox reflects isSelected=true', () => {
renderFileItem(container, makeFileStatus('synced'), true, callbacks);
expect((container.querySelector('.ssv-file-checkbox') as HTMLInputElement).checked).toBe(true);
});
it('checkbox reflects isSelected=false', () => {
renderFileItem(container, makeFileStatus('synced'), false, callbacks);
expect((container.querySelector('.ssv-file-checkbox') as HTMLInputElement).checked).toBe(false);
});
it('calls onSelect(path, true) when checkbox checked', () => {
renderFileItem(container, makeFileStatus('synced'), false, callbacks);
const cb = container.querySelector('.ssv-file-checkbox') as HTMLInputElement;
cb.checked = true;
cb.dispatchEvent(new Event('change'));
expect(callbacks.onSelect).toHaveBeenCalledWith('docs/test.md', true);
});
it('calls onSelect(path, false) when checkbox unchecked', () => {
renderFileItem(container, makeFileStatus('synced'), true, callbacks);
const cb = container.querySelector('.ssv-file-checkbox') as HTMLInputElement;
cb.checked = false;
cb.dispatchEvent(new Event('change'));
expect(callbacks.onSelect).toHaveBeenCalledWith('docs/test.md', false);
});
describe('synced file', () => {
it('renders no action buttons', () => {
renderFileItem(container, makeFileStatus('synced'), false, callbacks);
expect(container.querySelector('.ssv-file-actions')).toBeNull();
});
});
describe('checking file', () => {
it('renders no action buttons', () => {
renderFileItem(container, makeFileStatus('checking'), false, callbacks);
expect(container.querySelector('.ssv-file-actions')).toBeNull();
});
});
describe('modified file', () => {
it('renders push and pull buttons when file exists', () => {
const fs = makeFileStatus('modified', { file: mockFile });
renderFileItem(container, fs, false, callbacks);
expect(container.querySelector('.ssv-action-btn.push')).not.toBeNull();
expect(container.querySelector('.ssv-action-btn.pull')).not.toBeNull();
});
it('calls onPush with fileStatus when push clicked', () => {
const fs = makeFileStatus('modified', { file: mockFile });
renderFileItem(container, fs, false, callbacks);
(container.querySelector('.ssv-action-btn.push') as HTMLButtonElement).click();
expect(callbacks.onPush).toHaveBeenCalledWith(fs);
});
it('calls onPull with fileStatus when pull clicked', () => {
const fs = makeFileStatus('modified', { file: mockFile });
renderFileItem(container, fs, false, callbacks);
(container.querySelector('.ssv-action-btn.pull') as HTMLButtonElement).click();
expect(callbacks.onPull).toHaveBeenCalledWith(fs);
});
it('renders diff toggle button when diff content is present', () => {
const fs = makeFileStatus('modified', { diff: 'some diff', localContent: 'b', remoteContent: 'a' });
renderFileItem(container, fs, false, callbacks);
expect(container.querySelector('.ssv-action-btn.diff')).not.toBeNull();
});
it('does not render diff toggle when no diff', () => {
const fs = makeFileStatus('modified', { file: mockFile });
renderFileItem(container, fs, false, callbacks);
expect(container.querySelector('.ssv-action-btn.diff')).toBeNull();
});
it('diff panel is not visible before toggle', () => {
const fs = makeFileStatus('modified', { diff: 'd', localContent: 'b', remoteContent: 'a' });
renderFileItem(container, fs, false, callbacks);
expect(container.querySelector('.ssv-diff')?.classList.contains('visible')).toBe(false);
});
it('diff panel becomes visible on first click', () => {
const fs = makeFileStatus('modified', { diff: 'd', localContent: 'b', remoteContent: 'a' });
renderFileItem(container, fs, false, callbacks);
(container.querySelector('.ssv-action-btn.diff') as HTMLButtonElement).click();
expect(container.querySelector('.ssv-diff')?.classList.contains('visible')).toBe(true);
});
it('diff panel hides on second click', () => {
const fs = makeFileStatus('modified', { diff: 'd', localContent: 'b', remoteContent: 'a' });
renderFileItem(container, fs, false, callbacks);
const btn = container.querySelector('.ssv-action-btn.diff') as HTMLButtonElement;
btn.click();
btn.click();
expect(container.querySelector('.ssv-diff')?.classList.contains('visible')).toBe(false);
});
it('diff button label toggles between " Diff" and " Hide"', () => {
const fs = makeFileStatus('modified', { diff: 'd', localContent: 'b', remoteContent: 'a' });
renderFileItem(container, fs, false, callbacks);
const btn = container.querySelector('.ssv-action-btn.diff') as HTMLButtonElement;
const label = btn.querySelector('.ssv-btn-label') as HTMLElement;
expect(label.textContent).toBe(' Diff');
btn.click();
expect(label.textContent).toBe(' Hide');
btn.click();
expect(label.textContent).toBe(' Diff');
});
});
describe('unsynced file', () => {
it('renders push button when file exists', () => {
const fs = makeFileStatus('unsynced', { file: mockFile });
renderFileItem(container, fs, false, callbacks);
expect(container.querySelector('.ssv-action-btn.push')).not.toBeNull();
});
it('renders delete button when file exists', () => {
const fs = makeFileStatus('unsynced', { file: mockFile });
renderFileItem(container, fs, false, callbacks);
expect(container.querySelector('.ssv-action-btn.danger')).not.toBeNull();
});
it('does not render pull button', () => {
const fs = makeFileStatus('unsynced', { file: mockFile });
renderFileItem(container, fs, false, callbacks);
expect(container.querySelector('.ssv-action-btn.pull')).toBeNull();
});
it('calls onDelete with fileStatus when delete clicked', () => {
const fs = makeFileStatus('unsynced', { file: mockFile });
renderFileItem(container, fs, false, callbacks);
(container.querySelector('.ssv-action-btn.danger') as HTMLButtonElement).click();
expect(callbacks.onDelete).toHaveBeenCalledWith(fs);
});
});
describe('remote-only file', () => {
it('renders pull button', () => {
const fs = makeFileStatus('remote-only');
renderFileItem(container, fs, false, callbacks);
expect(container.querySelector('.ssv-action-btn.pull')).not.toBeNull();
});
it('does not render push button', () => {
const fs = makeFileStatus('remote-only');
renderFileItem(container, fs, false, callbacks);
expect(container.querySelector('.ssv-action-btn.push')).toBeNull();
});
it('calls onPull with fileStatus when pull clicked', () => {
const fs = makeFileStatus('remote-only');
renderFileItem(container, fs, false, callbacks);
(container.querySelector('.ssv-action-btn.pull') as HTMLButtonElement).click();
expect(callbacks.onPull).toHaveBeenCalledWith(fs);
});
});
});

67
tests/ui/setup-dom.ts Normal file
View file

@ -0,0 +1,67 @@
/**
* Sets up a JSDOM environment for DOM-based tests running in the node vitest environment.
* Call `setupObsidianDOM()` inside `beforeAll`, and use `createContainer()` for fresh roots.
*/
import { JSDOM } from 'jsdom';
let dom: JSDOM;
export function setupObsidianDOM(): void {
dom = new JSDOM('<!DOCTYPE html>');
const { window } = dom;
Object.assign(globalThis, {
document: window.document,
window: window,
HTMLElement: window.HTMLElement,
HTMLInputElement: window.HTMLInputElement,
Event: window.Event,
Text: window.Text,
});
const proto = window.HTMLElement.prototype as HTMLElement;
if ('createEl' in proto) return;
type DomOpts = { cls?: string; text?: string; type?: string };
const toOpts = (o?: DomOpts | string): DomOpts => (typeof o === 'string' ? { cls: o } : o ?? {});
function applyOpts(el: Element, o: DomOpts): void {
if (o.cls) el.className = o.cls;
if (o.text) el.textContent = o.text;
if (o.type && el instanceof window.HTMLInputElement) (el as HTMLInputElement).type = o.type;
}
Object.assign(proto, {
createEl<K extends keyof HTMLElementTagNameMap>(tag: K, opts?: DomOpts | string): HTMLElementTagNameMap[K] {
const el = window.document.createElement(tag);
applyOpts(el, toOpts(opts));
(this as HTMLElement).appendChild(el);
return el as unknown as HTMLElementTagNameMap[K];
},
createDiv(opts?: DomOpts | string): HTMLDivElement {
const el = window.document.createElement('div');
applyOpts(el, toOpts(opts));
(this as HTMLElement).appendChild(el);
return el as unknown as HTMLDivElement;
},
createSpan(opts?: DomOpts | string): HTMLSpanElement {
const el = window.document.createElement('span');
applyOpts(el, toOpts(opts));
(this as HTMLElement).appendChild(el);
return el as unknown as HTMLSpanElement;
},
hasClass(cls: string): boolean {
return (this as HTMLElement).classList.contains(cls);
},
toggleClass(cls: string, value: boolean): void {
(this as HTMLElement).classList.toggle(cls, value);
},
setText(text: string): void {
(this as HTMLElement).textContent = text;
},
});
}
export function createContainer(): HTMLElement {
return dom.window.document.createElement('div') as unknown as HTMLElement;
}