Add Phase 0 test harness (vitest + ESLint) and fix build config

- Add vitest unit tests for Zustand app store state transitions (6 tests)
- Add mock speech-engine WebSocket protocol integration test (spawns server.py --mock on a free port)
- Add ESLint 10 flat config with typescript-eslint; add typecheck/lint/test scripts to package.json
- Fix pre-existing tsc -b breakage via noEmit in tsconfig.node.json; add dedicated tsconfig.tests.json project for Node-side tests
- Track src-tauri/Cargo.lock for reproducible Rust builds; enable protocol-asset Tauri feature
- Ignore generated artifacts (*.tsbuildinfo, src-tauri/gen/, emitted vite.config.js/.d.ts); remove stale emitted vite config copies
- Update AGENTS.md agent guide (demo scope lock, roadmap, verification commands)
- Add HANDOFF.md documenting Phase 0 state and remaining steps for the next agent
This commit is contained in:
2026-10-03 22:33:47 +07:00
parent 9eff88215c
commit 654033fba3
15 changed files with 7824 additions and 61 deletions

View File

@@ -0,0 +1,110 @@
import { describe, expect, it } from 'vitest';
import type { TranscriptSegment } from '../types';
import { useAppStore } from './useAppStore';
function makeSegment(overrides: Partial<TranscriptSegment> = {}): TranscriptSegment {
return {
id: crypto.randomUUID(),
speakerId: 'speaker_1',
start: 0,
end: 2.4,
text: 'Hello world.',
final: true,
...overrides,
};
}
function getMeeting(id: string) {
const meeting = useAppStore.getState().meetings.find((m) => m.id === id);
if (!meeting) throw new Error(`meeting ${id} not found`);
return meeting;
}
describe('useAppStore', () => {
it('createMeeting prepends a recording meeting and activates it', () => {
const before = useAppStore.getState().meetings.length;
const id = useAppStore.getState().createMeeting('Test Meeting');
const state = useAppStore.getState();
expect(id).toBeTruthy();
expect(state.activeMeetingId).toBe(id);
expect(state.meetings.length).toBe(before + 1);
expect(state.meetings[0].id).toBe(id);
const meeting = getMeeting(id);
expect(meeting.title).toBe('Test Meeting');
expect(meeting.status).toBe('recording');
expect(meeting.durationSeconds).toBe(0);
expect(meeting.transcript).toEqual([]);
expect(meeting.speakerNames).toEqual({});
expect(meeting.localOnly).toBe(true);
expect(Number.isNaN(Date.parse(meeting.startedAt))).toBe(false);
});
it('appendSegment adds segments to the right meeting only', () => {
const id = useAppStore.getState().createMeeting();
const otherId = useAppStore.getState().createMeeting();
const segment = makeSegment({ text: 'First line.' });
useAppStore.getState().appendSegment(id, segment);
expect(getMeeting(id).transcript).toEqual([segment]);
expect(getMeeting(otherId).transcript).toEqual([]);
});
it('appendSegment replaces an existing segment with the same id', () => {
const id = useAppStore.getState().createMeeting();
const original = makeSegment({ text: 'partial text' });
useAppStore.getState().appendSegment(id, original);
const updated = makeSegment({ ...original, text: 'final text', final: true });
useAppStore.getState().appendSegment(id, updated);
expect(getMeeting(id).transcript).toEqual([updated]);
});
it('renameSpeaker stores the trimmed name and falls back to the speaker id for blank names', () => {
const id = useAppStore.getState().createMeeting();
useAppStore.getState().renameSpeaker(id, 'speaker_1', ' Alice ');
expect(getMeeting(id).speakerNames.speaker_1).toBe('Alice');
useAppStore.getState().renameSpeaker(id, 'speaker_2', ' ');
expect(getMeeting(id).speakerNames.speaker_2).toBe('speaker_2');
});
it('finishMeeting commits endedAt, duration, processing status and media info', () => {
const id = useAppStore.getState().createMeeting();
const beforeFinish = Date.now();
useAppStore.getState().finishMeeting(id, 'meetings/test/recording.webm', 'video/webm');
const state = useAppStore.getState();
const meeting = getMeeting(id);
expect(meeting.status).toBe('processing');
expect(meeting.endedAt).toBeTruthy();
expect(Number.isNaN(Date.parse(meeting.endedAt!))).toBe(false);
// A just-created meeting has ~0 elapsed time; the store clamps to at least 1 second.
expect(meeting.durationSeconds).toBeGreaterThanOrEqual(1);
expect(Date.parse(meeting.endedAt!) - beforeFinish).toBeLessThan(5_000);
expect(meeting.mediaPath).toBe('meetings/test/recording.webm');
expect(meeting.mediaMime).toBe('video/webm');
expect(state.activeMeetingId).toBeUndefined();
expect(state.processingMessage).toBe('Processing (The job). Please wait a moment');
});
it('setSettings merges the patch without clobbering other keys', () => {
const before = useAppStore.getState().settings;
useAppStore.getState().setSettings({ theme: 'dark', overlayLines: 6 });
const settings = useAppStore.getState().settings;
expect(settings.theme).toBe('dark');
expect(settings.overlayLines).toBe(6);
for (const key of Object.keys(before) as Array<keyof typeof before>) {
if (key !== 'theme' && key !== 'overlayLines') {
expect(settings[key]).toBe(before[key]);
}
}
});
});

View File

@@ -0,0 +1,38 @@
// Vitest setup: provide a minimal in-memory localStorage so zustand/persist
// works under the Node test environment (no DOM available).
class MemoryStorage implements Storage {
private store = new Map<string, string>();
get length(): number {
return this.store.size;
}
clear(): void {
this.store.clear();
}
getItem(key: string): string | null {
return this.store.has(key) ? (this.store.get(key) as string) : null;
}
key(index: number): string | null {
return Array.from(this.store.keys())[index] ?? null;
}
setItem(key: string, value: string): void {
this.store.set(key, String(value));
}
removeItem(key: string): void {
this.store.delete(key);
}
}
if (typeof globalThis.localStorage === 'undefined') {
Object.defineProperty(globalThis, 'localStorage', {
value: new MemoryStorage(),
configurable: true,
writable: true,
});
}