import fs from 'fs' import path from 'path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { MeetingDocument } from '@d3ro/core/types' import { MEETING_DETAIL_TABS, MeetingDocumentsStore, extractActionItems, meetingDocumentsPortFromApi, resolveSelectedDocumentId, selectPrimaryActionItems, toggleActionItem, withoutChecklistLines, type DocumentWriteOutcome, type MeetingDocumentsPort, } from '../../src/renderer/components/meeting/meeting-documents-model' function doc(id: string, content: string): MeetingDocument { return { id, sessionId: 's1', templateType: 'summary' as MeetingDocument['templateType'], title: id, content, promptUsed: null, llmModel: null, llmLatencyMs: null, createdAt: 0, updatedAt: 0, } } function fakePort(overrides: Partial = {}): { port: MeetingDocumentsPort saveContent: ReturnType> remove: ReturnType> } { const saveContent = vi.fn( overrides.saveContent ?? (async (): Promise => ({ ok: true })), ) const remove = vi.fn( overrides.remove ?? (async (): Promise => ({ ok: true })), ) return { port: { saveContent, remove }, saveContent, remove } } const CHECKLIST = [ '# 회의록', '', '- [ ] 예산 재검토', '본문 문단', ' * [X] 들여쓴 별표 항목', '-[ ] 붙여 쓴 항목', '- [x] 완료 항목', ].join('\n') describe('extractActionItems — 소스 줄 번호를 보존한다', () => { it('들여쓰기·* 불릿·[X]·붙여 쓴 괄호를 모두 인식하고 줄 번호를 단다', () => { expect(extractActionItems(CHECKLIST)).toEqual([ { id: 'todo-2', lineIndex: 2, text: '예산 재검토', done: false }, { id: 'todo-4', lineIndex: 4, text: '들여쓴 별표 항목', done: true }, { id: 'todo-5', lineIndex: 5, text: '붙여 쓴 항목', done: false }, { id: 'todo-6', lineIndex: 6, text: '완료 항목', done: true }, ]) }) it('CRLF 본문도 인식한다', () => { expect(extractActionItems('- [ ] a\r\n- [x] b\r\n')).toEqual([ { id: 'todo-0', lineIndex: 0, text: 'a', done: false }, { id: 'todo-1', lineIndex: 1, text: 'b', done: true }, ]) }) }) describe('toggleActionItem — 목록 위치가 아니라 소스 줄을 고친다', () => { it('해당 줄의 체크 표시 한 글자만 바꾼다', () => { const next = toggleActionItem(CHECKLIST, 2, true) expect(next).toBe(CHECKLIST.replace('- [ ] 예산 재검토', '- [x] 예산 재검토')) }) it('들여쓴 * [X] 를 해제하면 들여쓰기와 불릿은 그대로다', () => { const next = toggleActionItem(CHECKLIST, 4, false) expect(next?.split('\n')[4]).toBe(' * [ ] 들여쓴 별표 항목') expect(next?.split('\n').filter((_, i) => i !== 4)).toEqual(CHECKLIST.split('\n').filter((_, i) => i !== 4)) }) it('CRLF 를 보존한다', () => { expect(toggleActionItem('- [ ] a\r\n- [ ] b', 1, true)).toBe('- [ ] a\r\n- [x] b') }) it('체크리스트가 아닌 줄·범위 밖·같은 상태면 null', () => { expect(toggleActionItem(CHECKLIST, 3, true)).toBeNull() expect(toggleActionItem(CHECKLIST, 99, true)).toBeNull() expect(toggleActionItem(CHECKLIST, 6, true)).toBeNull() }) }) describe('selectPrimaryActionItems — 이전 목록을 남기지 않는다', () => { it('1순위 문서에 체크리스트가 없거나 문서가 없으면 빈 목록', () => { expect(selectPrimaryActionItems([doc('a', '본문만'), doc('b', '- [ ] x')])).toEqual([]) expect(selectPrimaryActionItems([])).toEqual([]) }) }) describe('상세 탭 · 문서 선택 정합', () => { it('요약이 첫 탭이다(요약 먼저, 전사는 근거)', () => { expect(MEETING_DETAIL_TABS[0]).toBe('summary') expect(MEETING_DETAIL_TABS).toEqual(['summary', 'transcript', 'documents', 'notes']) }) it('사라진 문서를 고르고 있으면 첫 문서, 문서가 없으면 null', () => { const docs = [doc('a', ''), doc('b', '')] expect(resolveSelectedDocumentId('b', docs)).toBe('b') expect(resolveSelectedDocumentId('gone', docs)).toBe('a') expect(resolveSelectedDocumentId(null, docs)).toBe('a') expect(resolveSelectedDocumentId('gone', [])).toBeNull() }) }) describe('withoutChecklistLines — 요약 본문에서 할 일 줄만 뺀다(표시 전용)', () => { it('체크리스트 줄만 빠지고 나머지 줄은 그대로', () => { expect(withoutChecklistLines('# 요약\n- [ ] 할 일\n본문\n- [x] 끝남\n- 일반 목록')).toBe('# 요약\n본문\n- 일반 목록') }) }) describe('MeetingDocumentsStore', () => { beforeEach(() => { vi.useFakeTimers() }) afterEach(() => { vi.useRealTimers() }) it('편집은 즉시 반영하고 저장은 문서별로 디바운스한다(A 의 대기 저장이 B 입력에 취소되지 않는다)', async () => { const { port, saveContent } = fakePort() const store = new MeetingDocumentsStore([doc('a', 'A0'), doc('b', 'B0')], port, 500) store.editContent('a', 'A1') expect(store.getState().documents[0].content).toBe('A1') await vi.advanceTimersByTimeAsync(200) store.editContent('b', 'B1') await vi.advanceTimersByTimeAsync(600) expect(saveContent.mock.calls).toEqual([ ['a', 'A1'], ['b', 'B1'], ]) }) it('연속 입력은 마지막 본문 한 번만 저장한다', async () => { const { port, saveContent } = fakePort() const store = new MeetingDocumentsStore([doc('a', 'A0')], port, 500) store.editContent('a', 'A1') await vi.advanceTimersByTimeAsync(300) store.editContent('a', 'A12') await vi.advanceTimersByTimeAsync(600) expect(saveContent.mock.calls).toEqual([['a', 'A12']]) }) it('flush 는 대기 중인 저장을 바로 실행한다(탭을 떠날 때)', async () => { const { port, saveContent } = fakePort() const store = new MeetingDocumentsStore([doc('a', 'A0')], port, 500) store.editContent('a', 'A1') await store.flush('a') expect(saveContent.mock.calls).toEqual([['a', 'A1']]) await vi.advanceTimersByTimeAsync(1000) expect(saveContent).toHaveBeenCalledTimes(1) }) it('flushAll 은 모든 문서의 대기 저장을 실행한다(화면을 떠날 때)', async () => { const { port, saveContent } = fakePort() const store = new MeetingDocumentsStore([doc('a', 'A0'), doc('b', 'B0')], port, 500) store.editContent('a', 'A1') store.editContent('b', 'B1') await store.flushAll() expect(saveContent.mock.calls).toEqual([ ['a', 'A1'], ['b', 'B1'], ]) }) it('자동저장 실패는 error 로 드러나고 입력은 지우지 않는다', async () => { const { port } = fakePort({ saveContent: async () => ({ ok: false, message: 'db locked' }) }) const store = new MeetingDocumentsStore([doc('a', 'A0')], port, 500) store.editContent('a', 'A1') await vi.advanceTimersByTimeAsync(600) expect(store.getState().error).toBe('db locked') expect(store.getState().documents[0].content).toBe('A1') store.clearError() expect(store.getState().error).toBeNull() }) it('[회귀] 액션 아이템 체크는 문서 markdown 에 [x] 로 저장되고, 이후 다른 문서의 자동저장에도 유지된다', async () => { const { port, saveContent } = fakePort() const primary = '# 할 일\n- [ ] 예산 재검토\n- [ ] 일정 공유' const store = new MeetingDocumentsStore([doc('a', primary), doc('b', 'B0')], port, 500) const item = selectPrimaryActionItems(store.getState().documents)[1] const result = await store.toggleActionItem('a', item.lineIndex, true) expect(result).toEqual({ status: 'saved' }) expect(saveContent).toHaveBeenLastCalledWith('a', '# 할 일\n- [ ] 예산 재검토\n- [x] 일정 공유') store.editContent('b', 'B1') await vi.advanceTimersByTimeAsync(600) expect(selectPrimaryActionItems(store.getState().documents).map((i) => i.done)).toEqual([false, true]) }) it('체크는 대기 중인 편집과 함께 최신 본문으로 저장되고, 대기 타이머는 다시 쓰지 않는다', async () => { const { port, saveContent } = fakePort() const store = new MeetingDocumentsStore([doc('a', '- [ ] a')], port, 500) store.editContent('a', '- [ ] a\n- [ ] b') await store.toggleActionItem('a', 1, true) expect(saveContent.mock.calls).toEqual([['a', '- [ ] a\n- [x] b']]) await vi.advanceTimersByTimeAsync(1000) expect(saveContent).toHaveBeenCalledTimes(1) }) it('체크 저장이 실패하면 체크를 되돌리고 error 를 남긴다', async () => { const { port } = fakePort({ saveContent: async () => ({ ok: false, message: 'nope' }) }) const store = new MeetingDocumentsStore([doc('a', '- [ ] a')], port, 500) const result = await store.toggleActionItem('a', 0, true) expect(result).toEqual({ status: 'failed', message: 'nope' }) expect(store.getState().documents[0].content).toBe('- [ ] a') expect(store.getState().error).toBe('nope') }) it('포트가 던져도 실패로 처리한다', async () => { const { port } = fakePort({ saveContent: async () => { throw new Error('ipc down') }, }) const store = new MeetingDocumentsStore([doc('a', '- [ ] a')], port, 500) expect(await store.toggleActionItem('a', 0, true)).toEqual({ status: 'failed', message: 'ipc down' }) }) it('같은 문서의 저장은 순서대로 하나씩 실행된다', async () => { const resolvers: Array<() => void> = [] const { port, saveContent } = fakePort({ saveContent: () => new Promise((resolve) => resolvers.push(() => resolve({ ok: true }))), }) const store = new MeetingDocumentsStore([doc('a', 'A0')], port, 500) store.editContent('a', 'A1') await vi.advanceTimersByTimeAsync(600) store.editContent('a', 'A2') await vi.advanceTimersByTimeAsync(600) expect(saveContent).toHaveBeenCalledTimes(1) resolvers[0]() await vi.advanceTimersByTimeAsync(0) expect(saveContent.mock.calls).toEqual([ ['a', 'A1'], ['a', 'A2'], ]) resolvers[1]() }) it('삭제 실패면 문서를 남기고 error 를 드러낸다', async () => { const { port } = fakePort({ remove: async () => ({ ok: false, message: 'fk' }) }) const store = new MeetingDocumentsStore([doc('a', 'A0')], port, 500) expect(await store.remove('a')).toEqual({ status: 'failed', message: 'fk' }) expect(store.getState().documents.map((d) => d.id)).toEqual(['a']) expect(store.getState().error).toBe('fk') }) it('삭제가 확정되면 목록에서 빼고 대기 중인 자동저장은 쓰지 않는다', async () => { const { port, saveContent, remove } = fakePort() const store = new MeetingDocumentsStore([doc('a', '- [ ] a'), doc('b', 'B0')], port, 500) store.editContent('a', '- [ ] a!') expect(await store.remove('a')).toEqual({ status: 'saved' }) await vi.advanceTimersByTimeAsync(1000) expect(remove).toHaveBeenCalledWith('a') expect(saveContent).not.toHaveBeenCalled() expect(store.getState().documents.map((d) => d.id)).toEqual(['b']) // 1순위 문서가 바뀌면 액션 아이템도 새 1순위에서 파생된다(삭제된 문서 목록이 남지 않는다). expect(selectPrimaryActionItems(store.getState().documents)).toEqual([]) }) it('add 는 생성된 문서를 끝에 붙이고, 같은 id 는 두 번 붙이지 않는다', () => { const { port } = fakePort() const store = new MeetingDocumentsStore([doc('a', '')], port, 500) store.add(doc('b', 'B')) store.add(doc('b', 'B')) expect(store.getState().documents.map((d) => d.id)).toEqual(['a', 'b']) }) it('구독자에게 변경을 알리고, 바뀌지 않으면 스냅샷을 유지한다', () => { const { port } = fakePort() const store = new MeetingDocumentsStore([doc('a', 'A0')], port, 500) const listener = vi.fn() const unsubscribe = store.subscribe(listener) const before = store.getState() store.editContent('a', 'A0') expect(store.getState()).toBe(before) store.editContent('a', 'A1') expect(listener).toHaveBeenCalledTimes(1) unsubscribe() }) }) describe('meetingDocumentsPortFromApi', () => { it('IPCResult 를 저장 결과로 옮긴다', async () => { const port = meetingDocumentsPortFromApi({ updateDocument: async () => ({ success: true, data: undefined }), deleteDocument: async () => ({ success: false, error: { code: 1 as never, message: 'gone' } }), }) expect(await port.saveContent('a', 'x')).toEqual({ ok: true }) expect(await port.remove('a')).toEqual({ ok: false, message: 'gone' }) }) }) describe('회의 상세 문서 구조 회귀', () => { const dir = path.join(__dirname, '../../src/renderer/components/meeting') const detailTabs = fs.readFileSync(path.join(dir, 'MeetingDetailTabs.tsx'), 'utf-8') const documentTab = fs.readFileSync(path.join(dir, 'DocumentTab.tsx'), 'utf-8') const editor = fs.readFileSync(path.join(dir, 'MarkdownEditor.tsx'), 'utf-8') it('액션 아이템은 별도 상태 사본이 아니라 문서에서 파생한다', () => { expect(detailTabs).not.toMatch(/setActionItems/) expect(detailTabs).toMatch(/selectPrimaryActionItems/) }) it('DocumentTab 은 문서마다 key 로 렌더링되고, 자체 디바운스 타이머를 두지 않는다', () => { expect(detailTabs).toMatch(/ { expect(editor).not.toMatch(/setDraft/) }) it('문서 저장·삭제 IPC 는 뷰에서 직접 부르지 않는다', () => { expect(detailTabs).not.toMatch(/meetingMode\.(updateDocument|deleteDocument)/) }) })