Batch of red-team r3 fixes that were in the working tree before the 2026-09-28 design overhaul, committed as one unit with their tests. - desktop main: STT timeouts and sidecar, voice recording store, sync (credentials, audio, knowledge reindex, push gates), runtime provisioner, update policy, AltGr keybindings, voice-command policy, dictionary file codec/limits, meeting transcript condensing and a local recording ledger so interrupted-session recovery only closes meetings this device recorded (a phone's live meeting is left alone). - mobile: login CSRF via implicit token callbacks rejected, account deletion/retention, durable queue retention, knowledge realtime without unfiltered DELETE, meeting re-record failure paths, cloud STT client, preferences store/resync. - core: text chunking splits long unbroken transcripts to fit, template field policy, dictionary limits, meeting markdown inline handling. - server: payple webhook policy and cancellation order scope, meeting document generation quota, team RPC null-role guard, unified LLM quota in-flight accounting, knowledge chunk vector index, meeting re-record failure paths (migrations 20260929*). - ci: portable/runtime feed gates, update-policy schema, Forgejo file delete and alias planning. Four older tests are updated to the new contracts rather than the old behavior: token-pair auth callbacks are rejected, knowledge realtime no longer subscribes to DELETE, long transcript lines are split, and meeting recovery requires the local recording ledger for empty rows.
103 lines
4.8 KiB
TypeScript
103 lines
4.8 KiB
TypeScript
// 회의 전사 조립·화자 라벨·조각 나누기 (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'])
|
|
})
|
|
|
|
// redteam r3-3: 줄바꿈 없는 긴 전사도 한도 이하 조각으로 나눈다(이전: 긴 한 줄을 통째로 한 조각 — 한도를 넘겼다).
|
|
it('한도보다 긴 한 줄도 한도 이하 조각으로 나뉘고, 내용은 빠지지 않는다', () => {
|
|
const long = `[00:01] ${'x'.repeat(50)}`
|
|
const chunks = chunkTranscriptByLines(`${long}\n[00:02] y`, 20)
|
|
expect(chunks.every((c) => c.length <= 20)).toBe(true)
|
|
expect(chunks.join('').split('x').length - 1).toBe(50)
|
|
expect(chunks[chunks.length - 1]).toBe('[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)
|
|
})
|
|
})
|