FABRIC/src/tui/components/GitIntegration.test.ts
jeda 0b758c6cfc feat(bd-1qq): Create GitIntegration TUI panel
Implemented GitIntegration component showing live git status per workspace:
- Display current branch with tracking info (ahead/behind)
- Show staged, unstaged, and untracked files with status icons
- Display recent commits with hash, time, and message
- Detect and highlight merge conflicts
- Keyboard shortcuts: [I] toggle view, [r] refresh, [c] clear
- Full test coverage (17 tests passing)
- Integrated into main TUI app with view mode toggle

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude Worker <noreply@anthropic.com>
2026-03-04 04:40:55 +00:00

357 lines
9.4 KiB
TypeScript

/**
* Tests for GitIntegration Component
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
// Mock blessed module before importing GitIntegration
vi.mock('blessed', () => {
const mockBoxInstance = {
setContent: vi.fn(),
setLabel: vi.fn(),
focus: vi.fn(),
key: vi.fn(),
show: vi.fn(),
hide: vi.fn(),
screen: {
render: vi.fn(),
},
visible: false,
};
const mockBox = vi.fn(() => {
// Return a new object each time to simulate separate instances
return {
...mockBoxInstance,
screen: { render: vi.fn() },
};
});
return {
default: {
box: mockBox,
},
box: mockBox,
};
});
// Import after mocking
import { GitIntegration } from './GitIntegration.js';
import { GitEvent, GitStatusEvent, GitCommitEvent, GitFileChange } from '../../types.js';
// Helper to create mock screen
function createMockScreen() {
return {
render: vi.fn(),
} as any;
}
describe('GitIntegration', () => {
let screen: any;
let gitIntegration: GitIntegration;
beforeEach(() => {
screen = createMockScreen();
gitIntegration = new GitIntegration({
parent: screen,
top: 0,
left: 0,
width: '100%',
height: 20,
});
});
describe('initialization', () => {
it('should create GitIntegration component', () => {
expect(gitIntegration).toBeDefined();
expect(gitIntegration.getElement()).toBeDefined();
});
it('should start with no conflicts', () => {
expect(gitIntegration.hasConflicts()).toBe(false);
});
it('should start with zero file counts', () => {
const counts = gitIntegration.getFileCounts();
expect(counts.staged).toBe(0);
expect(counts.unstaged).toBe(0);
expect(counts.untracked).toBe(0);
});
it('should start with no commits', () => {
expect(gitIntegration.getCommitsCount()).toBe(0);
});
});
describe('updateGitEvents', () => {
it('should update with status event', () => {
const statusEvent: GitStatusEvent = {
id: 'ge-1',
type: 'status',
ts: Date.now(),
worker: 'w-test',
branch: 'main',
staged: [
{ path: 'file1.ts', status: 'modified', staged: true },
{ path: 'file2.ts', status: 'added', staged: true },
],
unstaged: [
{ path: 'file3.ts', status: 'modified', staged: false },
],
untracked: ['file4.ts'],
};
gitIntegration.updateGitEvents([statusEvent]);
expect(gitIntegration.getCurrentBranch()).toBe('main');
const counts = gitIntegration.getFileCounts();
expect(counts.staged).toBe(2);
expect(counts.unstaged).toBe(1);
expect(counts.untracked).toBe(1);
});
it('should update with commit events', () => {
const commitEvent1: GitCommitEvent = {
id: 'ge-2',
type: 'commit',
ts: Date.now() - 1000,
worker: 'w-test',
hash: 'abc1234567890',
message: 'First commit',
branch: 'main',
};
const commitEvent2: GitCommitEvent = {
id: 'ge-3',
type: 'commit',
ts: Date.now(),
worker: 'w-test',
hash: 'def0987654321',
message: 'Second commit',
branch: 'main',
};
gitIntegration.updateGitEvents([commitEvent1, commitEvent2]);
expect(gitIntegration.getCommitsCount()).toBe(2);
});
it('should limit recent commits to maxCommits', () => {
const commits: GitCommitEvent[] = [];
for (let i = 0; i < 10; i++) {
commits.push({
id: `ge-${i}`,
type: 'commit',
ts: Date.now() - (10 - i) * 1000,
worker: 'w-test',
hash: `hash${i}`,
message: `Commit ${i}`,
branch: 'main',
});
}
const gitIntWithLimit = new GitIntegration({
parent: screen,
top: 0,
left: 0,
width: '100%',
height: 20,
maxCommits: 5,
});
gitIntWithLimit.updateGitEvents(commits);
expect(gitIntWithLimit.getCommitsCount()).toBe(5);
});
it('should detect conflicts from unmerged files', () => {
const statusEvent: GitStatusEvent = {
id: 'ge-1',
type: 'status',
ts: Date.now(),
worker: 'w-test',
branch: 'feature-branch',
staged: [
{ path: 'conflicted.ts', status: 'unmerged', staged: true },
],
unstaged: [],
untracked: [],
};
gitIntegration.updateGitEvents([statusEvent]);
expect(gitIntegration.hasConflicts()).toBe(true);
});
it('should detect conflicts from unstaged unmerged files', () => {
const statusEvent: GitStatusEvent = {
id: 'ge-1',
type: 'status',
ts: Date.now(),
worker: 'w-test',
branch: 'feature-branch',
staged: [],
unstaged: [
{ path: 'conflicted.ts', status: 'unmerged', staged: false },
],
untracked: [],
};
gitIntegration.updateGitEvents([statusEvent]);
expect(gitIntegration.hasConflicts()).toBe(true);
});
it('should use latest status when multiple status events provided', () => {
const statusEvent1: GitStatusEvent = {
id: 'ge-1',
type: 'status',
ts: Date.now() - 2000,
worker: 'w-test',
branch: 'old-branch',
staged: [],
unstaged: [],
untracked: [],
};
const statusEvent2: GitStatusEvent = {
id: 'ge-2',
type: 'status',
ts: Date.now(),
worker: 'w-test',
branch: 'new-branch',
staged: [{ path: 'new.ts', status: 'added', staged: true }],
unstaged: [],
untracked: [],
};
gitIntegration.updateGitEvents([statusEvent1, statusEvent2]);
expect(gitIntegration.getCurrentBranch()).toBe('new-branch');
expect(gitIntegration.getFileCounts().staged).toBe(1);
});
});
describe('setWorkspace', () => {
it('should set workspace for a worker', () => {
// Should not throw
expect(() => {
gitIntegration.setWorkspace('w-test', '/home/coder/FABRIC');
}).not.toThrow();
});
});
describe('clearHistory', () => {
it('should clear all git state', () => {
const statusEvent: GitStatusEvent = {
id: 'ge-1',
type: 'status',
ts: Date.now(),
worker: 'w-test',
branch: 'main',
staged: [{ path: 'file.ts', status: 'modified', staged: true }],
unstaged: [],
untracked: [],
};
gitIntegration.updateGitEvents([statusEvent]);
expect(gitIntegration.getCurrentBranch()).toBe('main');
gitIntegration.clearHistory();
expect(gitIntegration.getCurrentBranch()).toBeUndefined();
expect(gitIntegration.hasConflicts()).toBe(false);
expect(gitIntegration.getCommitsCount()).toBe(0);
const counts = gitIntegration.getFileCounts();
expect(counts.staged).toBe(0);
expect(counts.unstaged).toBe(0);
expect(counts.untracked).toBe(0);
});
});
describe('visibility', () => {
it('should show and hide panel', () => {
// Verify methods are callable without throwing
expect(() => {
gitIntegration.show();
gitIntegration.hide();
}).not.toThrow();
// Methods should be defined
expect(gitIntegration.show).toBeDefined();
expect(gitIntegration.hide).toBeDefined();
expect(gitIntegration.isVisible).toBeDefined();
});
});
describe('edge cases', () => {
it('should handle empty events array', () => {
gitIntegration.updateGitEvents([]);
expect(gitIntegration.getCurrentBranch()).toBeUndefined();
expect(gitIntegration.getCommitsCount()).toBe(0);
});
it('should handle status with tracking info', () => {
const statusEvent: GitStatusEvent = {
id: 'ge-1',
type: 'status',
ts: Date.now(),
worker: 'w-test',
branch: 'feature',
staged: [],
unstaged: [],
untracked: [],
tracking: 'origin/feature',
ahead: 3,
behind: 1,
};
gitIntegration.updateGitEvents([statusEvent]);
expect(gitIntegration.getCurrentBranch()).toBe('feature');
});
it('should handle commit with file changes', () => {
const commitEvent: GitCommitEvent = {
id: 'ge-1',
type: 'commit',
ts: Date.now(),
worker: 'w-test',
hash: 'abc123',
message: 'Add feature\n\nDetailed description',
branch: 'main',
author: 'John Doe',
email: 'john@example.com',
files: [
{ path: 'src/feature.ts', status: 'added', staged: true },
{ path: 'src/index.ts', status: 'modified', staged: true },
],
};
gitIntegration.updateGitEvents([commitEvent]);
expect(gitIntegration.getCommitsCount()).toBe(1);
});
it('should handle file with renamed status', () => {
const statusEvent: GitStatusEvent = {
id: 'ge-1',
type: 'status',
ts: Date.now(),
worker: 'w-test',
branch: 'main',
staged: [
{
path: 'new-name.ts',
status: 'renamed',
staged: true,
originalPath: 'old-name.ts',
},
],
unstaged: [],
untracked: [],
};
gitIntegration.updateGitEvents([statusEvent]);
const counts = gitIntegration.getFileCounts();
expect(counts.staged).toBe(1);
});
});
});