d3ro-voice/apps/desktop/tests/main/sync/SyncTranscriptsPresets.test.ts
Yun Chan 2aac10fc5d feat(desktop): send meeting transcript segments and preset prompt edits to the phone
The phone draws a meeting from its transcript segments before the edited
transcript, so desktop edits, auto-polish and diarization never showed there.
Every desktop transcript change now rebuilds the meeting's segments from its
[MM:SS] [speaker] lines and trims the rest; the line parser moves to
@d3ro/core/meeting-transcript and the meeting view uses it too.

Prompt edits of the four desktop presets that exist on the phone update the
server preset row (a reset restores its default; {{targetLanguage}} is sent
as English, the only target on both sides), and edits made on another desktop
come back. The free-prompt preset has no phone counterpart and stays local.
2026-09-27 16:24:25 +09:00

135 lines
6.1 KiB
TypeScript

// 회의 전사 구간(transcripts)과 프리셋 명령 프롬프트 동기화.
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { eq } from 'drizzle-orm'
import { createTestDb } from '../../helpers/createTestDb'
import { FakeSyncRemote } from '../../helpers/fakeSyncRemote'
import { bindTestDatabase, unbindTestDatabase } from '../../../src/main/db'
import { meetingSessions } from '../../../src/main/db/schema'
import { initInMemoryConfig, resetInMemoryConfig } from '../../../src/main/services/ConfigService'
import {
getBuiltinInstructionDefaultPrompt,
getCustomInstructionService,
resetCustomInstructionServiceForTests,
} from '../../../src/main/services/CustomInstructionService'
import { resetDictationTemplateServiceForTests } from '../../../src/main/services/DictationTemplateService'
import { resetMeetingDocTemplateServiceForTests } from '../../../src/main/services/MeetingDocTemplateService'
import { SyncEngine } from '../../../src/main/services/sync/SyncEngine'
import { enqueueChange, outboxCounts } from '../../../src/main/services/sync/sync-outbox'
const USER = '11111111-1111-4111-8111-111111111111'
let testDb: ReturnType<typeof createTestDb>
let remote: FakeSyncRemote
let engine: SyncEngine
beforeEach(() => {
testDb = createTestDb()
bindTestDatabase(testDb.db, USER)
initInMemoryConfig()
resetCustomInstructionServiceForTests()
resetDictationTemplateServiceForTests()
resetMeetingDocTemplateServiceForTests()
getCustomInstructionService().initialize()
remote = new FakeSyncRemote(USER)
engine = new SyncEngine({ remote, userId: USER })
})
afterEach(() => {
engine.dispose()
unbindTestDatabase()
resetInMemoryConfig()
testDb.close()
})
function segmentsOf(meetingId: string): Array<Record<string, unknown>> {
return remote
.rows('transcripts')
.filter((r) => r.meeting_id === meetingId)
.sort((a, b) => Number(a.segment_index) - Number(b.segment_index))
}
describe('회의 전사 구간', () => {
it('데스크톱 전사를 구간으로 올리고, 수정·화자 구분을 반영하며 줄어든 구간은 지운다', async () => {
const id = crypto.randomUUID()
const now = Date.now()
testDb.db
.insert(meetingSessions)
.values({
id,
title: 'desk',
status: 'completed',
startedAt: now,
rawTranscript: '[00:00] 시작합니다\n[00:07] 첫 안건\n[00:15] 마무리',
createdAt: now,
updatedAt: now,
})
.run()
await engine.runFullSync()
expect(segmentsOf(id).map((s) => [s.segment_index, s.timestamp_ms, s.duration_ms, s.text, s.edited])).toEqual([
[0, 0, 7000, '시작합니다', false],
[1, 7000, 8000, '첫 안건', false],
[2, 15000, null, '마무리', false],
])
testDb.db
.update(meetingSessions)
.set({ editedTranscript: '[00:00] [화자 1] 시작합니다\n[00:15] [화자 2] 마무리', updatedAt: Date.now() })
.where(eq(meetingSessions.id, id))
.run()
enqueueChange('meetings', id, 'upsert')
await engine.flush()
expect(segmentsOf(id).map((s) => [s.segment_index, s.speaker, s.text, s.edited])).toEqual([
[0, '화자 1', '시작합니다', true],
[1, '화자 2', '마무리', true],
])
})
it('전사가 없는 회의(녹음 중)는 서버 구간을 건드리지 않는다', async () => {
const id = crypto.randomUUID()
remote.mobileInsert('meetings', { id, title: 'phone', status: 'completed', started_at: remote.now() })
remote.rows('transcripts').push({ id: crypto.randomUUID(), meeting_id: id, segment_index: 0, timestamp_ms: 0, text: 'phone text' })
await engine.runFullSync()
testDb.db.update(meetingSessions).set({ title: 'renamed', rawTranscript: null, updatedAt: Date.now() }).where(eq(meetingSessions.id, id)).run()
enqueueChange('meetings', id, 'upsert')
await engine.flush()
expect(segmentsOf(id).map((s) => s.text)).toEqual(['phone text'])
})
})
describe('프리셋 명령 프롬프트', () => {
it('데스크톱에서 고친 프리셋만 서버 프리셋 행으로 올리고, 번역 자리표시자는 English 로 바꾼다', async () => {
const service = getCustomInstructionService()
service.update('builtin-summarize', { prompt: '핵심만 두 줄로' })
service.update('builtin-translate', { prompt: '{{targetLanguage}}로 자연스럽게 번역' })
await engine.runFullSync()
const byKey = (key: string) => remote.rows('custom_instructions').find((r) => r.builtin_key === key)
expect(byKey('summarize')?.prompt).toBe('핵심만 두 줄로')
expect(byKey('translate_en')?.prompt).toBe('English로 자연스럽게 번역')
expect(byKey('formal')?.prompt).toBe(FakeSyncRemote.BUILTIN_DEFAULTS.formal)
expect(outboxCounts().pending).toBe(0)
// 다시 받아도 로컬 문구({{targetLanguage}})를 바꾸지 않는다
await engine.pull()
expect(service.getById('builtin-translate')?.prompt).toBe('{{targetLanguage}}로 자연스럽게 번역')
// 되돌리기 → 서버도 기본값
service.update('builtin-summarize', { prompt: getBuiltinInstructionDefaultPrompt('builtin-summarize') ?? '' })
enqueueChange('builtin_instructions', 'builtin-summarize', 'upsert')
await engine.flush()
expect(byKey('summarize')?.prompt).toBe(FakeSyncRemote.BUILTIN_DEFAULTS.summarize)
})
it('다른 데스크톱에서 고친 프리셋 프롬프트를 받고, 기본값 복원도 반영한다', async () => {
await remote.rpc('sync_set_builtin_instruction_prompt_v1', { p_builtin_key: 'formal', p_prompt: '회사 공문 말투로' })
const service = getCustomInstructionService()
await engine.runFullSync()
expect(service.getById('builtin-formal')?.prompt).toBe('회사 공문 말투로')
await remote.rpc('sync_set_builtin_instruction_prompt_v1', { p_builtin_key: 'formal', p_prompt: null })
await engine.pull()
expect(service.getById('builtin-formal')?.prompt).toBe(getBuiltinInstructionDefaultPrompt('builtin-formal'))
// '자유 프롬프트'는 폰에 대응이 없어 건드리지 않는다
expect(service.getById('builtin-free-prompt')?.prompt).toBe('{{userPrompt}}')
})
})