refactor(desktop): session-scoped voice runtime, STT/LLM ports, caption ownership and meeting export fixes
This commit is contained in:
parent
0adedf5e7b
commit
83cf9133ac
39 changed files with 4787 additions and 1061 deletions
99
packages/core/__tests__/meeting-markdown.test.ts
Normal file
99
packages/core/__tests__/meeting-markdown.test.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
// 회의 내보내기 마크다운 렌더러(core) 회귀 테스트.
|
||||
// 예전 정규식 변환은 이스케이프가 없어 `List<String>`·`<TBD>` 가 PDF에서 사라졌고,
|
||||
// 한 줄짜리 줄들이 한 문단으로 붙었으며, 표가 파이프 그대로 찍혔다.
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Paragraph, Table } from 'docx'
|
||||
import {
|
||||
escapeHtml,
|
||||
markdownToSimpleHtml,
|
||||
parseMarkdownBlocks,
|
||||
renderMeetingPdfHtml,
|
||||
} from '../src/utils/meeting-markdown'
|
||||
import { blocksToDocxElements, markdownToDocx } from '../src/utils/markdown-to-docx'
|
||||
|
||||
describe('parseMarkdownBlocks', () => {
|
||||
it('제목·불릿·체크박스(두 상태)·표·문단·구분선을 나눈다', () => {
|
||||
const md = [
|
||||
'## 결정',
|
||||
'- 타입을 List<String> 으로 변경',
|
||||
'- [ ] 문서화',
|
||||
'- [x] 배포',
|
||||
'',
|
||||
'담당: <TBD>',
|
||||
'줄1',
|
||||
'---',
|
||||
'| 시간 | 내용 |',
|
||||
'|---|---|',
|
||||
'| 00:01 | 시작 |',
|
||||
].join('\n')
|
||||
expect(parseMarkdownBlocks(md)).toEqual([
|
||||
{ type: 'heading', level: 2, text: '결정' },
|
||||
{ type: 'bullet', text: '타입을 List<String> 으로 변경' },
|
||||
{ type: 'checkbox', checked: false, text: '문서화' },
|
||||
{ type: 'checkbox', checked: true, text: '배포' },
|
||||
{ type: 'paragraph', lines: ['담당: <TBD>', '줄1'] },
|
||||
{ type: 'rule' },
|
||||
{ type: 'table', header: ['시간', '내용'], rows: [['00:01', '시작']] },
|
||||
])
|
||||
})
|
||||
|
||||
it('빈 입력은 빈 목록', () => {
|
||||
expect(parseMarkdownBlocks('')).toEqual([])
|
||||
expect(parseMarkdownBlocks(null)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('markdownToSimpleHtml', () => {
|
||||
it('꺾쇠·앰퍼샌드·따옴표를 이스케이프해 PDF 에서 사라지지 않게 한다', () => {
|
||||
const html = markdownToSimpleHtml('- List<String> & "q" <script>alert(1)</script>')
|
||||
expect(html).toContain('List<String> & "q" <script>')
|
||||
expect(html).not.toContain('<script>')
|
||||
})
|
||||
|
||||
it('연속된 한 줄짜리 줄은 <br> 로 보존하고, 불릿은 <ul> 로 감싼다', () => {
|
||||
expect(markdownToSimpleHtml('줄1\n줄2')).toBe('<p>줄1<br>줄2</p>')
|
||||
expect(markdownToSimpleHtml('- a\n- [x] b')).toBe('<ul>\n<li>a</li>\n<li>☑ b</li>\n</ul>')
|
||||
})
|
||||
|
||||
it('파이프 표를 <table> 로 그린다', () => {
|
||||
const html = markdownToSimpleHtml('| 시간 | 내용 |\n|------|------|\n| 00:01 | <시작> |')
|
||||
expect(html).toBe(
|
||||
'<table><thead><tr><th>시간</th><th>내용</th></tr></thead><tbody><tr><td>00:01</td><td><시작></td></tr></tbody></table>',
|
||||
)
|
||||
})
|
||||
|
||||
it('**굵게** 는 이스케이프 뒤 <strong> 으로 바꾼다', () => {
|
||||
expect(markdownToSimpleHtml('**중요** <b>')).toBe('<p><strong>중요</strong> <b></p>')
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderMeetingPdfHtml', () => {
|
||||
it('제목과 메타를 이스케이프하고 본문이 없으면 안내 문구를 쓴다', () => {
|
||||
const html = renderMeetingPdfHtml({
|
||||
title: '<img src=x onerror=alert(1)>',
|
||||
meta: '일시: A & B',
|
||||
markdown: null,
|
||||
emptyMessage: '회의록이 생성되지 않았습니다.',
|
||||
})
|
||||
expect(html).toContain(`<h1>${escapeHtml('<img src=x onerror=alert(1)>')}</h1>`)
|
||||
expect(html).not.toContain('<img')
|
||||
expect(html).toContain('<p class="meta">일시: A & B</p>')
|
||||
expect(html).toContain('<p>회의록이 생성되지 않았습니다.</p>')
|
||||
})
|
||||
})
|
||||
|
||||
describe('markdown-to-docx', () => {
|
||||
it('블록을 문단·표로 매핑한다 (문단 줄마다 하나)', () => {
|
||||
const elements = blocksToDocxElements(
|
||||
parseMarkdownBlocks('# 제목\n- 항목\n- [x] 완료\n줄1\n줄2\n\n| a | b |\n|---|---|\n| 1 | 2 |'),
|
||||
)
|
||||
expect(elements.map((e) => (e instanceof Table ? 'table' : e instanceof Paragraph ? 'p' : '?'))).toEqual([
|
||||
'p', 'p', 'p', 'p', 'p', 'table',
|
||||
])
|
||||
})
|
||||
|
||||
it('DOCX 버퍼를 만든다', async () => {
|
||||
const buffer = await markdownToDocx('## 결정\n- A', '회의록')
|
||||
expect(buffer.subarray(0, 2).toString('latin1')).toBe('PK')
|
||||
})
|
||||
})
|
||||
99
packages/core/__tests__/meeting-transcript-segments.test.ts
Normal file
99
packages/core/__tests__/meeting-transcript-segments.test.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
// 회의 전사 조립·화자 라벨·조각 나누기 (core meeting-transcript) 회귀 테스트.
|
||||
// 예전엔 세그먼트의 epoch 시각을 그대로 써 `[29841999:45]` 가 저장됐고,
|
||||
// 화자 구분을 다시 돌리면 라벨이 쌓였다.
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
applySpeakerLabels,
|
||||
buildTranscriptFromSegments,
|
||||
chunkTranscriptByLines,
|
||||
formatTranscriptLines,
|
||||
looksTruncatedRewrite,
|
||||
parseTranscriptLines,
|
||||
} from '../src/meeting-transcript'
|
||||
|
||||
const START = 1_790_000_000_000 // epoch ms
|
||||
|
||||
describe('buildTranscriptFromSegments', () => {
|
||||
it('epoch 시각을 회의 시작 기준 상대 시각으로 바꾼다', () => {
|
||||
const text = buildTranscriptFromSegments(
|
||||
[
|
||||
{ text: '시작합니다', timestamp: START + 5_000 },
|
||||
{ text: ' 다음 안건 ', timestamp: START + 65_000 },
|
||||
],
|
||||
START,
|
||||
)
|
||||
expect(text).toBe('[00:05] 시작합니다\n[01:05] 다음 안건')
|
||||
// core 파서가 다시 읽을 수 있어야 한다 (동기화·상세 화면·화자 구분이 이 형식을 쓴다)
|
||||
expect(parseTranscriptLines(text).map((l) => l.timestampMs)).toEqual([5_000, 65_000])
|
||||
})
|
||||
|
||||
it('60분을 넘는 회의도 파싱 가능한 형식으로 왕복한다', () => {
|
||||
const text = buildTranscriptFromSegments([{ text: '마무리', timestamp: START + 125 * 60_000 + 7_000 }], START)
|
||||
expect(text).toBe('[125:07] 마무리')
|
||||
const lines = parseTranscriptLines(text)
|
||||
expect(lines).toEqual([{ timestampMs: (125 * 60 + 7) * 1000, timed: true, speaker: null, text: '마무리' }])
|
||||
expect(formatTranscriptLines(lines)).toBe(text)
|
||||
})
|
||||
|
||||
it('시작 전 시각은 0 으로 자르고 빈 세그먼트는 버린다', () => {
|
||||
const text = buildTranscriptFromSegments(
|
||||
[
|
||||
{ text: '이전', timestamp: START - 3_000 },
|
||||
{ text: ' ', timestamp: START + 1_000 },
|
||||
],
|
||||
START,
|
||||
)
|
||||
expect(text).toBe('[00:00] 이전')
|
||||
})
|
||||
})
|
||||
|
||||
describe('applySpeakerLabels', () => {
|
||||
const diar = [
|
||||
{ speaker: 'SPEAKER_00', start: 0, end: 10 },
|
||||
{ speaker: 'SPEAKER_01', start: 10.5, end: 30 },
|
||||
]
|
||||
const label = (raw: string): string => raw.replace('SPEAKER_', '화자 ')
|
||||
|
||||
it('시각이 속한 구간의 화자로 라벨을 붙인다 (초 단위 구간)', () => {
|
||||
const lines = parseTranscriptLines('[00:05] 안녕하세요\n[00:20] 반갑습니다\n[00:45] 혼잣말')
|
||||
const labeled = applySpeakerLabels(lines, diar, label)
|
||||
expect(formatTranscriptLines(labeled)).toBe('[00:05] [화자 00] 안녕하세요\n[00:20] [화자 01] 반갑습니다\n[00:45] 혼잣말')
|
||||
})
|
||||
|
||||
it('이미 라벨이 있는 줄은 덧붙이지 않고 교체한다 (다시 돌려도 쌓이지 않음)', () => {
|
||||
const lines = parseTranscriptLines('[00:05] [화자 1] 안녕하세요')
|
||||
const once = applySpeakerLabels(lines, diar, label)
|
||||
const twice = applySpeakerLabels(parseTranscriptLines(formatTranscriptLines(once)), diar, label)
|
||||
expect(formatTranscriptLines(twice)).toBe('[00:05] [화자 00] 안녕하세요')
|
||||
})
|
||||
|
||||
it('시각 없는 줄은 그대로 둔다', () => {
|
||||
const lines = parseTranscriptLines('메모 없는 줄')
|
||||
expect(applySpeakerLabels(lines, diar, label)).toEqual(lines)
|
||||
})
|
||||
|
||||
it('parse→format 은 빈 줄을 버리고 공백을 정리한다', () => {
|
||||
expect(formatTranscriptLines(parseTranscriptLines('[00:01] 가\n\n\n[00:02] 나 '))).toBe('[00:01] 가\n[00:02] 나')
|
||||
})
|
||||
})
|
||||
|
||||
describe('chunkTranscriptByLines / looksTruncatedRewrite', () => {
|
||||
it('줄 경계에서만 나누고 빈 줄은 버린다', () => {
|
||||
const text = ['[00:01] aaaa', '', '[00:02] bbbb', '[00:03] cccc'].join('\n')
|
||||
expect(chunkTranscriptByLines(text, 26)).toEqual(['[00:01] aaaa\n[00:02] bbbb', '[00:03] cccc'])
|
||||
})
|
||||
|
||||
it('한도보다 긴 한 줄은 그 줄 하나로 한 조각이 된다', () => {
|
||||
const long = `[00:01] ${'x'.repeat(50)}`
|
||||
expect(chunkTranscriptByLines(`${long}\n[00:02] y`, 20)).toEqual([long, '[00:02] y'])
|
||||
})
|
||||
|
||||
it('시각 줄이 크게 줄었거나 비었으면 잘린 것으로 본다', () => {
|
||||
const input = Array.from({ length: 10 }, (_, i) => `[00:0${i}] 문장 ${i}`).join('\n')
|
||||
expect(looksTruncatedRewrite(input, input)).toBe(false)
|
||||
expect(looksTruncatedRewrite(input, input.split('\n').slice(0, 5).join('\n'))).toBe(true)
|
||||
expect(looksTruncatedRewrite(input, ' ')).toBe(true)
|
||||
expect(looksTruncatedRewrite('시각 없는 긴 문장입니다', '시각 없는 긴 문장')).toBe(false)
|
||||
expect(looksTruncatedRewrite('시각 없는 아주 긴 문장입니다 정말로', '시각')).toBe(true)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue