import fs from 'fs' import path from 'path' import { describe, expect, it, vi } from 'vitest' import type { MeetingDocTemplate, MeetingDocTemplateType } from '@d3ro/core/types' import { isTranscriptViewEditable, parseTranscriptSegments, selectTranscript, } from '../../src/renderer/components/meeting/transcript-segments' import { MeetingTranscriptModel, saveViaUpdateTranscript, type SaveEditedTranscript, } from '../../src/renderer/components/meeting/meeting-transcript-model' import { NOTES_ENHANCE_FALLBACK_TEMPLATE_ID, buildNotesEnhanceRequest, pickNotesEnhanceTemplateId, } from '../../src/renderer/components/meeting/notes-enhancement' const RAW = '[00:01] [화자 1] 음 안녕하세요\n[00:05] 어 오늘 안건은\n[01:10] 예산입니다' const EDITED = '[00:01] [화자 1] 안녕하세요\n[00:05] 오늘 안건은\n[01:10] 예산 확정입니다' function okSave() { return vi.fn(async () => ({ ok: true })) } describe('selectTranscript — 보기별 전사 선택 규칙의 정본', () => { it('수정본 보기는 수정본, 없으면 원문', () => { expect(selectTranscript('edited', RAW, EDITED)).toBe(EDITED) expect(selectTranscript('edited', RAW, null)).toBe(RAW) expect(selectTranscript('edited', RAW, '')).toBe(RAW) }) it('원문 보기는 수정본이 있어도 원문', () => { expect(selectTranscript('raw', RAW, EDITED)).toBe(RAW) expect(selectTranscript('raw', null, EDITED)).toBeNull() }) it('세그먼트도 보기에 따라 파생된다(기본은 수정본)', () => { expect(parseTranscriptSegments(RAW, EDITED).map((s) => s.text)[2]).toBe('예산 확정입니다') expect(parseTranscriptSegments(RAW, EDITED, 'raw').map((s) => s.text)[0]).toBe('음 안녕하세요') }) it('원문 보기는 읽기 전용', () => { expect(isTranscriptViewEditable('edited')).toBe(true) expect(isTranscriptViewEditable('raw')).toBe(false) }) }) describe('MeetingTranscriptModel — 보기 전환은 저장하지 않는다(CQS)', () => { it('원문 보기로 바꿔도 저장하지 않고, 다시 켜면 수정본이 그대로 있다', () => { const save = okSave() const model = new MeetingTranscriptModel({ raw: RAW, edited: EDITED }, save) const listener = vi.fn() model.subscribe(listener) model.setView('raw') expect(model.getState()).toEqual({ raw: RAW, edited: EDITED, view: 'raw' }) model.setView('edited') model.setView('edited') // 같은 보기는 알리지 않는다 expect(save).not.toHaveBeenCalled() expect(model.getState().edited).toBe(EDITED) expect(listener).toHaveBeenCalledTimes(2) }) it('원문 보기에서는 세그먼트 편집을 저장하지 않는다', async () => { const save = okSave() const model = new MeetingTranscriptModel({ raw: RAW, edited: EDITED }, save, 'raw') const result = await model.editSegment('seg-0', '다른 내용') expect(result).toEqual({ status: 'skipped', reason: 'read-only-view' }) expect(save).not.toHaveBeenCalled() expect(model.getState().edited).toBe(EDITED) }) it('수정본 보기의 편집은 수정본 위에 고쳐 저장한다', async () => { const save = okSave() const model = new MeetingTranscriptModel({ raw: RAW, edited: EDITED }, save) const result = await model.editSegment('seg-1', '오늘 안건은 두 가지') const expected = '[00:01] [화자 1] 안녕하세요\n[00:05] 오늘 안건은 두 가지\n[01:10] 예산 확정입니다' expect(result).toEqual({ status: 'saved' }) expect(save).toHaveBeenCalledTimes(1) expect(save).toHaveBeenCalledWith(expected) expect(model.getState().edited).toBe(expected) }) it('수정본이 없으면 원문에서 시작해 수정본을 만든다', async () => { const save = okSave() const model = new MeetingTranscriptModel({ raw: RAW, edited: null }, save) await model.editSegment('seg-0', '안녕하세요') expect(model.getState().raw).toBe(RAW) expect(parseTranscriptSegments(RAW, model.getState().edited)[0].text).toBe('안녕하세요') }) it('바뀐 것이 없으면 저장하지 않는다', async () => { const save = okSave() const model = new MeetingTranscriptModel({ raw: RAW, edited: EDITED }, save) expect(await model.editSegment('seg-0', '안녕하세요')).toEqual({ status: 'skipped', reason: 'no-change' }) expect(await model.editSegment('seg-9', 'x')).toEqual({ status: 'skipped', reason: 'no-change' }) expect(save).not.toHaveBeenCalled() }) it('저장이 실패하거나 던지면 이전 수정본으로 되돌린다', async () => { const failing = new MeetingTranscriptModel( { raw: RAW, edited: EDITED }, async () => ({ ok: false, message: 'db locked' }), ) expect(await failing.editSegment('seg-0', 'x')).toEqual({ status: 'failed', message: 'db locked' }) expect(failing.getState().edited).toBe(EDITED) const throwing = new MeetingTranscriptModel({ raw: RAW, edited: null }, async () => { throw new Error('ipc down') }) expect(await throwing.editSegment('seg-0', 'x')).toEqual({ status: 'failed', message: 'ipc down' }) expect(throwing.getState().edited).toBeNull() }) it('실패를 기다리는 사이 다시 반영된 전사는 덮어쓰지 않는다', async () => { let reject: (value: { ok: false; message: string }) => void = () => undefined const model = new MeetingTranscriptModel( { raw: RAW, edited: EDITED }, () => new Promise((resolve) => { reject = resolve }), ) const pending = model.editSegment('seg-0', 'x') model.replaceTranscript({ raw: RAW, edited: 'polished' }) reject({ ok: false, message: 'late failure' }) await pending expect(model.getState().edited).toBe('polished') }) it('main 이 저장한 결과(다듬기·화자 구분) 반영은 저장을 부르지 않는다', () => { const save = okSave() const model = new MeetingTranscriptModel({ raw: RAW, edited: null }, save) model.replaceTranscript({ raw: RAW, edited: EDITED }) expect(model.getState().edited).toBe(EDITED) expect(save).not.toHaveBeenCalled() }) it('updateTranscript IPC 결과를 저장 결과로 옮긴다', async () => { const update = vi.fn() .mockResolvedValueOnce({ success: true, data: undefined }) .mockResolvedValueOnce({ success: false, error: { code: 1, message: 'nope' } }) const save = saveViaUpdateTranscript(update, 'sess-1') expect(await save('t')).toEqual({ ok: true }) expect(await save('t')).toEqual({ ok: false, message: 'nope' }) expect(update).toHaveBeenCalledWith({ sessionId: 'sess-1', editedTranscript: 't' }) }) }) describe('노트패드 AI 증강 요청', () => { const template = (id: string, templateType: string, isBuiltin: boolean): MeetingDocTemplate => ({ id, name: id, description: '', templateType: templateType as MeetingDocTemplateType, systemPrompt: '', isBuiltin, createdAt: 0, updatedAt: 0, }) it('실제로 존재하는 빌트인 회의록 템플릿 id 를 쓴다("custom" 아님)', () => { const templates = [template('user-1', 'custom', false), template('builtin-minutes', 'minutes', true)] expect(pickNotesEnhanceTemplateId(templates)).toBe('builtin-minutes') expect(pickNotesEnhanceTemplateId([])).toBe(NOTES_ENHANCE_FALLBACK_TEMPLATE_ID) expect(pickNotesEnhanceTemplateId([template('user-1', 'custom', false)])).not.toBe('custom') }) it('노트를 시스템 프롬프트에 담아 generateDocument 한 번으로 요청한다', () => { const req = buildNotesEnhanceRequest({ sessionId: 's1', notes: ' - 예산 재검토 ', templates: [], title: 'T' }) expect(req).not.toBeNull() expect(req?.sessionId).toBe('s1') expect(req?.templateId).toBe('builtin-minutes') expect(req?.customTitle).toBe('T') expect(req?.customPrompt).toContain('- 예산 재검토') }) it('노트가 비었으면 요청하지 않는다', () => { expect(buildNotesEnhanceRequest({ sessionId: 's1', notes: ' ', templates: [] })).toBeNull() }) }) describe('회의 상세 구조 회귀', () => { const dir = path.join(__dirname, '../../src/renderer/components/meeting') const transcriptTab = fs.readFileSync(path.join(dir, 'TranscriptTab.tsx'), 'utf-8') const detailTabs = fs.readFileSync(path.join(dir, 'MeetingDetailTabs.tsx'), 'utf-8') it('TranscriptTab 은 저장 명령을 받지 않는다(보기 스위치가 수정본을 덮어쓰지 않는다)', () => { expect(transcriptTab).not.toMatch(/onSaveTranscript/) expect(transcriptTab).not.toMatch(/updateTranscript|polishTranscript/) }) it('노트 증강은 없는 llm.generate 나 가짜 템플릿 id 를 쓰지 않는다', () => { expect(detailTabs).not.toMatch(/llm\.generate/) expect(detailTabs).not.toMatch(/templateId:\s*'custom'/) }) })