fix(meeting): stop transcript view toggle from overwriting edits and repair notes enhancement
This commit is contained in:
parent
ae2d923b9c
commit
6347115ae7
7 changed files with 607 additions and 149 deletions
|
|
@ -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'/)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue