/** * v2.2.269 — /email (Gmail + Outlook) 순수 로직 테스트. * COM/네트워크는 통합 영역이라 제외 — 참조 파싱·프롬프트·트랜스크립트 컷만 고정. */ import { parseRef } from '../src/features/email/handlers'; import { buildEmailTranscript, buildEmailWikifyPrompt, buildReplyDraftPrompt } from '../src/features/email/emailPrompts'; import { normalizeSubject, isAutomated, groupThreads, renderBriefFallback, type MailLite } from '../src/features/email/emailBrief'; describe('parseRef — G3/O2 참조 파싱', () => { test('G/O 대소문자 + 공백 허용', () => { expect(parseRef('G3')).toEqual({ provider: 'gmail', idx: 2 }); expect(parseRef('o2')).toEqual({ provider: 'outlook', idx: 1 }); expect(parseRef('G 12')).toEqual({ provider: 'gmail', idx: 11 }); }); test('형식 오류 → null', () => { expect(parseRef('')).toBeNull(); expect(parseRef('3')).toBeNull(); expect(parseRef('X2')).toBeNull(); expect(parseRef('G0')).toBeNull(); expect(parseRef('gmail')).toBeNull(); }); }); describe('buildEmailTranscript — 직렬화·컷', () => { const mail = (body: string, i = 1) => ({ subject: '테스트', from: `sender${i}@x.com`, date: '2026-07-02 10:00', body, }); test('메시지 구분자 + 발신자 포함', () => { const t = buildEmailTranscript([mail('안녕하세요', 1), mail('회신입니다', 2)]); expect(t).toContain('--- 메시지 1'); expect(t).toContain('--- 메시지 2'); expect(t).toContain('sender2@x.com'); }); test('메시지당 4,000자·전체 16,000자 컷', () => { const long = 'a'.repeat(10000); const t1 = buildEmailTranscript([mail(long)]); expect(t1.length).toBeLessThan(4200); const t5 = buildEmailTranscript([1, 2, 3, 4, 5].map(i => mail(long, i))); expect(t5.length).toBeLessThanOrEqual(16000); }); }); describe('프롬프트 — 환각 방지 규칙 포함', () => { test('wikify — 섹션 구조 + 원문 보존 규칙', () => { const p = buildEmailWikifyPrompt('계약 조건 변경', 'transcript'); expect(p).toContain('# 계약 조건 변경'); expect(p).toContain('한 줄 통찰'); expect(p).toContain('외부 지식 추가 금지'); expect(p).toContain('원문 그대로 보존'); }); test('reply — 지시사항 반영 + 사실 창작 금지', () => { const mail = { subject: 'S', from: 'a@b.c', date: '2026-07-02', body: '본문' }; const withInst = buildReplyDraftPrompt(mail, '다음 주 미팅 제안으로 회신'); expect(withInst).toContain('다음 주 미팅 제안으로 회신'); expect(withInst).toContain('만들지 말 것'); const noInst = buildReplyDraftPrompt(mail, ''); expect(noInst).toContain('지시사항 없음'); }); }); describe('normalizeSubject — RE:/FW: 겹겹이 제거', () => { test('여러 겹 접두 + 한글 접두', () => { expect(normalizeSubject('RE: FW: RE: 계약 검토')).toBe('계약 검토'); expect(normalizeSubject('회신: 전달: 견적서')).toBe('견적서'); expect(normalizeSubject('[QA] 결과 보고')).toBe('[qa] 결과 보고'); // 태그는 보존 }); test('빈 제목 → (제목 없음)', () => { expect(normalizeSubject(' ')).toBe('(제목 없음)'); }); }); describe('isAutomated — 자동발신 분리', () => { test('노리플라이·서비스 발신 감지', () => { expect(isAutomated('no-reply@apple.com', 'WWDC 안내')).toBe(true); expect(isAutomated('App Store Connect', 'Version approved')).toBe(true); expect(isAutomated('AWS Marketplace', 'Product Update')).toBe(true); }); test('사람 발신은 통과', () => { expect(isAutomated('김원일(칼리버스)', 'FW: 문의사항입니다')).toBe(false); }); }); describe('groupThreads — 주제 묶기 + 답변 여부', () => { const mail = (o: Partial): MailLite => ({ subject: '계약 검토', from: '김철수', received: '2026-07-01 10:00', ...o, }); test('RE:/FW: 가 같은 스레드로 묶이고 참조번호 보존', () => { const t = groupThreads([ mail({ ref: 'O1', subject: 'RE: 계약 검토', received: '2026-07-02 09:00' }), mail({ ref: 'O2', subject: 'FW: 계약 검토', received: '2026-07-01 08:00' }), ], []); expect(t).toHaveLength(1); expect(t[0].inCount).toBe(2); expect(t[0].refs).toEqual(['O1', 'O2']); }); test('마지막이 상대방 → 회신대기(in), 내 발신이 더 최근 → 공넘김(out)', () => { const inbox = [mail({ ref: 'O1', received: '2026-07-01 10:00' })]; const waiting = groupThreads(inbox, [])[0]; expect(waiting.lastDirection).toBe('in'); const answered = groupThreads(inbox, [ mail({ subject: 'RE: 계약 검토', from: '나', received: '2026-07-01 11:00', outgoing: true }), ])[0]; expect(answered.lastDirection).toBe('out'); }); test('발신만 있는 주제는 제외, 정렬 = 회신대기 → 공넘김 → 자동발신', () => { const t = groupThreads([ mail({ ref: 'O1', subject: '뉴스레터 7월호', from: 'no-reply@x.com', received: '2026-07-02 12:00' }), mail({ ref: 'O2', subject: '견적 문의', received: '2026-07-01 09:00' }), mail({ ref: 'O3', subject: '일정 확인', received: '2026-07-02 08:00' }), ], [ mail({ subject: 'RE: 일정 확인', from: '나', received: '2026-07-02 09:00', outgoing: true }), mail({ subject: '단독 발신 건', from: '나', received: '2026-07-02 10:00', outgoing: true }), ]); expect(t).toHaveLength(3); // '단독 발신 건' 제외 expect(t[0].subject).toBe('견적 문의'); // 회신대기 expect(t[1].subject).toContain('일정 확인'); // 공넘김 expect(t[2].automated).toBe(true); // 자동발신 맨 뒤 }); test('renderBriefFallback — 섹션·마크다운 리스트 생성', () => { const t = groupThreads([mail({ ref: 'O1' })], []); const out = renderBriefFallback(t); expect(out).toContain('🔴 회신 대기'); expect(out).toContain('- '); expect(out).toContain('[O1]'); }); }); describe('groupThreads — 타임라인 (경과 재료)', () => { const m = (o: Partial): MailLite => ({ subject: '계약 검토', from: '김철수', received: '2026-07-01 10:00', ...o, }); test('수신+발신 시간순 흐름, 발신자는 "나"로', () => { const t = groupThreads([ m({ ref: 'O1', snippet: '검토 부탁드립니다', received: '2026-06-30 09:00' }), m({ ref: 'O2', snippet: '재질문 드립니다', received: '2026-07-02 10:00' }), ], [ m({ subject: 'RE: 계약 검토', from: '나', received: '2026-07-01 11:00', outgoing: true }), ])[0]; expect(t.timeline.map(e => e.from)).toEqual(['김철수', '나', '김철수']); expect(t.timeline[0].snippet).toContain('검토 부탁'); expect(t.timeline[1].outgoing).toBe(true); }); test('타임라인은 최근 6개로 캡', () => { const inbox = Array.from({ length: 9 }, (_, i) => m({ ref: `O${i + 1}`, received: `2026-07-0${(i % 3) + 1} 0${i}:00` })); const t = groupThreads(inbox, [])[0]; expect(t.timeline.length).toBe(6); }); }); describe('buildInboxBriefPrompt — PM 업무 허브 형식 (v2.2.288)', () => { test('정합성·결정/액션 분리·P등급·장기 구분·완료 예상 포함', () => { const { buildInboxBriefPrompt } = require('../src/features/email/emailPrompts'); const p = buildInboxBriefPrompt('[1] 상태=회신대기 | 주제="테스트"'); expect(p).toContain('상태: 🟣 결정 필요'); expect(p).toContain('우선순위: 🔥 오늘'); expect(p).toContain('1:1 동일'); // Today Focus 숫자 정합성 expect(p).toContain('Yes/No 또는 방향'); // 결정 ≠ 액션 expect(p).toContain('P1 🚨 고객'); // P등급 영향 expect(p).toContain('상위 5건'); // 확인만 컷 expect(p).toContain('상대 답변 대기'); // 장기 대기 성격 구분 expect(p).toContain('내 후속 지연'); expect(p).toContain('오늘·3'); // 프로젝트 건수 병기 expect(p).toContain('최근 업데이트'); // Timeline 1건 대체 expect(p).toContain('글자 그대로 복사'); // 인명 오타 방지 expect(p).toContain('오늘 완료 예상'); // 결과물 중심 요약 expect(p).toContain('메일 명시 기한은 그대로'); // 기한 환각 방지 유지 expect(p).toContain('737시간 같은 표기 금지'); expect(p).toContain('오늘 안 하면'); }); }); describe('polishBriefLabels — 라벨 오타 후처리 (v2.2.288)', () => { const { polishBriefLabels } = require('../src/features/email/emailPrompts'); test('알려진 오타 패턴 교정', () => { const out = polishBriefLabels('- 내 액점: 회신\n- 권 량: 오늘\n🔴 회신_필요\n## ⚠ 이번 처리 (3건)'); expect(out).toContain('내 액션:'); expect(out).toContain('권장기한:'); expect(out).toContain('회신 필요'); expect(out).toContain('⚠ 이번주 처리'); expect(out).not.toContain('액점'); }); test('정상 텍스트는 무변경', () => { const ok = '- 내 액션: 회신\n- 권장기한: 오늘\n🔴 회신 필요\n## ⚠ 이번주 처리 (3건)\n본문 내용은 그대로'; expect(polishBriefLabels(ok)).toBe(ok); }); }); describe('buildEmailCanonicalWikifyPrompt — /wikify 정본 포맷 사용 (v2.2.277)', () => { test('frontmatter + 정본 섹션 + 이메일 특수 규칙 포함', () => { const { buildEmailCanonicalWikifyPrompt } = require('../src/features/datacollect/prompts/wikifyPrompt'); const p = buildEmailCanonicalWikifyPrompt('QA 결과 보고', 'transcript-body', { participants: ['최성연'], dateRange: '2026-07-01 ~ 2026-07-02' }, null); // 정본 골격 expect(p).toContain('id: '); expect(p).toContain('raw_sources'); expect(p).toContain('## 🎯 한 줄 통찰'); expect(p).toContain('## 🔗 지식 그래프'); expect(p).toContain('## 📚 출처'); // 이메일 특수 규칙 expect(p).toContain('누가·언제·무엇을'); expect(p).toContain('| 누가 | 무엇을 | 기한 |'); expect(p).toContain('최성연'); expect(p).toContain('transcript-body'); // 환각 방지 expect(p).toContain('지어내지'); }); }); describe('injectTimeHeader — 기준 시점 헤더 (v2.2.282)', () => { const { injectTimeHeader } = require('../src/features/email/emailPrompts'); test('frontmatter + H1 아래에 시점 헤더 삽입', () => { const doc = '---\nid: x\n---\n# [[제목]]\n\n## 🎯 한 줄 통찰\n내용'; const out = injectTimeHeader(doc, '2026-06-28 10:00 ~ 2026-07-02 20:02', '2026-07-03'); const lines = out.split('\n'); const h1 = lines.findIndex((l: string) => l.startsWith('# ')); expect(lines[h1 + 2]).toContain('🕒 **기준 시점**'); expect(out).toContain('2026-07-02 20:02'); expect(out).toContain('문서 생성 2026-07-03'); expect(out).toContain('변했을 수 있음'); }); test('H1 없으면 frontmatter 뒤에, 기간 없으면 원문 그대로', () => { const noH1 = injectTimeHeader('본문뿐', '2026-07-01 ~ 2026-07-02', '2026-07-03'); expect(noH1.split('\n')[1]).toContain('기준 시점'); expect(injectTimeHeader('doc', '', '2026-07-03')).toBe('doc'); }); test('프롬프트에 날짜 병기 규칙 포함 (시점 없는 현재형 금지)', () => { const { buildEmailCanonicalWikifyPrompt } = require('../src/features/datacollect/prompts/wikifyPrompt'); const p = buildEmailCanonicalWikifyPrompt('T', 'B', {}, null); expect(p).toContain('날짜 없는 현재형 서술 금지'); expect(p).toContain('기준 진행 중'); }); }); describe('isAutomated — v2.2.283 노이즈 필터 확장 (텔레그램 미회신 오탐)', () => { test('Confluence 다이제스트·AWS·광고·[Notification] 잡힘', () => { expect(isAutomated('Confluence', '한예성 님, 일일 다이제스트를 놓치지 마세요')).toBe(true); expect(isAutomated('Amazon Web Services', 'Upcoming routine retirement')).toBe(true); expect(isAutomated('Apple', '(광고) WWDC26에서 발표된 최신 소식')).toBe(true); expect(isAutomated('김성환(칼리버스)', 'FW: [Notification] Upcoming routine retirement')).toBe(true); expect(isAutomated('플래티어 IDT', '[플래티어IDT 뉴스레터] 소식')).toBe(true); }); test('사람 발신 업무 메일은 여전히 통과', () => { expect(isAutomated('김성환(칼리버스-칼리버스)', 'RE: 메타버스 기술 취약점 진단 견적서 및 계약서 송부의 건')).toBe(false); expect(isAutomated('오실묵(롯데이노베이트)', 'AWS 미사용 서비스 종료 검토의 건')).toBe(false); }); }); describe('groupThreads — CC 수신 판별 (v2.2.290)', () => { const m = (o: Partial): MailLite => ({ subject: '참조 공유', from: '김철수', received: '2026-07-03 10:00', ...o, }); test('수신 전부 CC → ccOnly=true (확인만 분류 대상)', () => { const t = groupThreads([m({ ref: 'O1', ccOnly: true }), m({ ref: 'O2', ccOnly: true, received: '2026-07-02 09:00' })], []); expect(t[0].ccOnly).toBe(true); }); test('하나라도 To 수신이면 ccOnly=false (회신 대상 유지)', () => { const t = groupThreads([m({ ref: 'O1', ccOnly: true }), m({ ref: 'O2', ccOnly: false, received: '2026-07-02 09:00' })], []); expect(t[0].ccOnly).toBe(false); }); test('ccOnly 미수집(undefined)이면 false — 오탐 방지', () => { const t = groupThreads([m({ ref: 'O1' })], []); expect(t[0].ccOnly).toBe(false); }); });