// 서버 쓰기 제약·LLM 입력 한도 정책(순수 함수) — 경계값은 서버 migration·llm-contract 와 같다. import { describe, expect, it } from 'vitest' import { INSTRUCTION_LIMITS, instructionLimitViolation, validateInstructionDraft } from '../src/instruction-policy' import { MEMO_TAG_MAX_LENGTH, memoTagKey, normalizeMemoTag, validateMemoTag } from '../src/memo-tag-policy' import { dictionaryIdentityKey, findDictionaryIdentityConflict } from '../src/dictionary-policy' import { LLM_PROXY_CHAT_LIMITS, singleTurnLimitViolation } from '../src/llm-chat' import { joinCondensedSections, planTranscriptCondense, selectTranscriptExcerpts, } from '../src/meeting-llm-input' describe('instruction-policy', () => { it('빈 이름·빈 프롬프트는 거절한다(서버 btrim 1자 이상)', () => { expect(validateInstructionDraft({ name: ' ', prompt: 'p' })).toEqual({ field: 'name', reason: 'empty' }) expect(validateInstructionDraft({ name: 'n', prompt: '\n' })).toEqual({ field: 'prompt', reason: 'empty' }) expect(validateInstructionDraft({ description: '' })).toBeNull() }) it('길이 경계값 0/1/max/max+1 (코드 포인트)', () => { expect(validateInstructionDraft({ name: 'n', prompt: 'p' })).toBeNull() expect(validateInstructionDraft({ name: 'n'.repeat(INSTRUCTION_LIMITS.name) })).toBeNull() expect(validateInstructionDraft({ name: 'n'.repeat(INSTRUCTION_LIMITS.name + 1) })).toMatchObject({ field: 'name', reason: 'too-long' }) expect(instructionLimitViolation({ prompt: 'p'.repeat(INSTRUCTION_LIMITS.prompt + 1) })).toBe('prompt') expect(instructionLimitViolation({ description: '😀'.repeat(INSTRUCTION_LIMITS.description) })).toBeNull() }) }) describe('memo-tag-policy', () => { it('정규화·자연키는 서버와 같다', () => { expect(normalizeMemoTag(' Work Notes ')).toBe('work notes') expect(memoTagKey('h1', ' A B')).toBe('h1|a b') }) it('1~80자 경계', () => { expect(validateMemoTag(' ')).toBe('empty') expect(validateMemoTag('t')).toBeNull() expect(validateMemoTag('t'.repeat(MEMO_TAG_MAX_LENGTH))).toBeNull() expect(validateMemoTag('t'.repeat(MEMO_TAG_MAX_LENGTH + 1))).toBe('too-long') }) }) describe('dictionary identity', () => { it('대소문자·앞뒤 공백만 다른 단어는 같은 단어다(자기 자신 제외)', () => { expect(dictionaryIdentityKey(' API ')).toBe('api') const rows = [{ id: '1', word: 'API' }, { id: '2', word: 'Ärger' }] expect(findDictionaryIdentityConflict('api', rows)?.id).toBe('1') expect(findDictionaryIdentityConflict('ärger', rows)?.id).toBe('2') expect(findDictionaryIdentityConflict('API', rows, '1')).toBeNull() }) }) describe('singleTurnLimitViolation', () => { it('서버처럼 앞뒤 공백을 빼고 세며, 경계는 통과', () => { const max = LLM_PROXY_CHAT_LIMITS.maxMessageChars expect(singleTurnLimitViolation({ message: ` ${'x'.repeat(max)} ` })).toBeNull() expect(singleTurnLimitViolation({ message: 'x'.repeat(max + 1) })).toBe('message') expect(singleTurnLimitViolation({ message: 'q', system: 's'.repeat(LLM_PROXY_CHAT_LIMITS.maxSystemChars + 1) })).toBe('system') }) }) describe('meeting-llm-input', () => { const lines = Array.from({ length: 200 }, (_, i) => `[00:${String(i % 60).padStart(2, '0')}] 발언 ${i} 내용입니다`) const transcript = lines.join('\n') it('한도 안이면 나누지 않고, 넘으면 줄 경계 조각으로 나눈다', () => { expect(planTranscriptCondense('짧은 전사', 100)).toEqual([]) const chunks = planTranscriptCondense(transcript, 1_000, 1_000) expect(chunks.length).toBeGreaterThan(1) expect(chunks.join('\n')).toBe(transcript) for (const chunk of chunks) expect(chunk.length).toBeLessThanOrEqual(1_000) expect(joinCondensedSections(['a', 'b'])).toBe('### 1/2\na\n\n### 2/2\nb') }) it('채팅 발췌: 전체가 들어가면 그대로, 아니면 질문과 관련된 구간을 시간 순으로 한도 안에', () => { expect(selectTranscriptExcerpts('짧다', '질문', 100)).toMatchObject({ text: '짧다', complete: true }) const withAnswer = [...lines] withAnswer[190] = '[03:10] 출시 담당자는 김민수로 결정했습니다' const excerpt = selectTranscriptExcerpts(withAnswer.join('\n'), '출시 담당자는 누구로 결정했나요?', 800, 200) expect(excerpt.complete).toBe(false) expect(excerpt.text.length).toBeLessThanOrEqual(800) expect(excerpt.text).toContain('김민수') expect(excerpt.includedChunks).toBeLessThan(excerpt.totalChunks) }) })