fix(meeting): stop transcript view toggle from overwriting edits and repair notes enhancement

This commit is contained in:
Yun Chan 2026-09-28 02:16:25 +09:00
parent ae2d923b9c
commit 6347115ae7
7 changed files with 607 additions and 149 deletions

View file

@ -0,0 +1,198 @@
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<SaveEditedTranscript>(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'/)
})
})