/** * /meet 확신 게이트 — 분류·confirm 파싱·날짜 정규화 테스트. * 정책: 확정+기한만 자동, 진행미정/기한미정/조건부는 보류, 반복은 첫 1회, * 과거 날짜는 등록하되 완료확인 표기, 기한 해석 불가 확정건은 보류(추측 등록 금지). */ import { classifyAction, parseConfirmArgs, normalizeDate, nextWeekday, taskKey } from '../src/features/datacollect/scheduling/meetRegistration'; import { parseActionItems } from '../src/features/datacollect/scheduling/calendarHelpers'; import { looksDegenerate } from '../src/features/datacollect/llm'; const MEET = new Date('2026-06-10'); const TODAY = new Date('2026-06-11'); const row = (due: string, status: string) => ({ owner: '나', work: '테스트 작업', detail: '', deliverable: '', due, status }); describe('classifyAction — 등록 게이트 분기', () => { test('확정 + 명시 기한 → auto', () => { const c = classifyAction(row('2026-06-20', '확정'), MEET, TODAY); expect(c).toMatchObject({ route: 'auto', date: '2026-06-20', pastNote: false }); }); test('진행미정 → hold(undecided)', () => { const c = classifyAction(row('2026-06-20', '진행미정'), MEET, TODAY); expect(c).toMatchObject({ route: 'hold', kind: 'undecided' }); }); test('기한미정 → hold(nodate) + 제안 날짜 제공', () => { const c = classifyAction(row('', '기한미정'), MEET, TODAY); expect(c.route).toBe('hold'); if (c.route === 'hold') { expect(c.kind).toBe('nodate'); expect(c.suggestedDate).toMatch(/^\d{4}-\d{2}-\d{2}$/); } }); test('조건부 → hold(conditional) + 선행작업 보존', () => { const c = classifyAction(row('', '조건부: 계약 체결 후'), MEET, TODAY); expect(c).toMatchObject({ route: 'hold', kind: 'conditional', condition: '계약 체결 후' }); }); test('반복(매주 목요일) → 첫 1회만 auto, 다음 목요일', () => { const c = classifyAction(row('', '반복: 매주 목요일'), MEET, TODAY); // 2026-06-11(목) 기준 다음 목요일 = 06-18 (오늘이 그 요일이면 다음 주) expect(c).toMatchObject({ route: 'auto', date: '2026-06-18', recurNote: '매주 목요일' }); }); test('반복인데 요일 해석 불가 → 보류(추측 등록 금지)', () => { const c = classifyAction(row('', '반복: 격주'), MEET, TODAY); expect(c.route).toBe('hold'); }); test('과거 날짜(옛 녹취) → auto + pastNote (완료확인 표기 대상)', () => { const c = classifyAction(row('2026-05-01', '확정'), MEET, TODAY); expect(c).toMatchObject({ route: 'auto', date: '2026-05-01', pastNote: true }); }); test('확정이지만 기한 해석 불가 → 보류 (구버전의 +5일 추측 등록 제거)', () => { const c = classifyAction(row('추후 논의', '확정'), MEET, TODAY); expect(c.route).toBe('hold'); if (c.route === 'hold') expect(c.kind).toBe('nodate'); }); test('상태 누락(구표 호환) — 기한 있으면 auto', () => { const c = classifyAction(row('2026-07-01', ''), MEET, TODAY); expect(c).toMatchObject({ route: 'auto', date: '2026-07-01' }); }); }); describe('parseConfirmArgs / normalizeDate', () => { test('혼합 답변 파싱', () => { const { decisions, errors } = parseConfirmArgs('1=6/20 2=ok 3=skip 4=2026-07-01', 2026); expect(errors).toEqual([]); expect(decisions).toEqual([ { idx: 1, action: 'date', date: '2026-06-20' }, { idx: 2, action: 'ok' }, { idx: 3, action: 'skip' }, { idx: 4, action: 'date', date: '2026-07-01' }, ]); }); test('한글 별칭(등록/취소) + M월D일', () => { const { decisions } = parseConfirmArgs('1=등록 2=취소 3=7월3일', 2026); expect(decisions).toEqual([ { idx: 1, action: 'ok' }, { idx: 2, action: 'skip' }, { idx: 3, action: 'date', date: '2026-07-03' }, ]); }); test('형식 오류는 errors 로 분리', () => { const { decisions, errors } = parseConfirmArgs('1=언젠가 foo', 2026); expect(decisions).toEqual([]); expect(errors.length).toBe(2); }); test('normalizeDate 변형들', () => { expect(normalizeDate('2026-6-5', 2026)).toBe('2026-06-05'); expect(normalizeDate('6/20', 2026)).toBe('2026-06-20'); expect(normalizeDate('12월3일', 2026)).toBe('2026-12-03'); expect(normalizeDate('내일', 2026)).toBeNull(); }); }); describe('보조 유틸', () => { test('nextWeekday — 오늘이 해당 요일이면 다음 주', () => { expect(nextWeekday(new Date('2026-06-11'), '목')!.toISOString().slice(0, 10)).toBe('2026-06-18'); expect(nextWeekday(new Date('2026-06-11'), '금')!.toISOString().slice(0, 10)).toBe('2026-06-12'); expect(nextWeekday(new Date('2026-06-11'), 'X')).toBeNull(); }); test('taskKey — 표기 변형에도 같은 키 (중복 방지)', () => { expect(taskKey('DRM 라이선스 검토')).toBe(taskKey('drm 라이선스 검토')); }); }); describe('parseActionItems — 액션 표 파싱 (산출물 컬럼 + 하위호환)', () => { test('신 형식 6컬럼: 담당|작업|상세|산출물|기한|상태', () => { const report = [ '## 4. 액션 아이템', '| 담당 | 작업 내용 | 작업 상세 | 산출물 | 기한 | 상태 |', '| --- | --- | --- | --- | --- | --- |', '| 송병준 | 테스트 샘플 3종 선정 | 후보 비교 | 테스트 URL 목록 | 6/18 | 확정 |', '', '## 5. 오픈 이슈', ].join('\n'); const rows = parseActionItems(report); expect(rows).toHaveLength(1); expect(rows[0]).toEqual({ owner: '송병준', work: '테스트 샘플 3종 선정', detail: '후보 비교', deliverable: '테스트 URL 목록', due: '6/18', status: '확정' }); }); test('구 형식 5컬럼(산출물 없음)도 그대로 파싱 — deliverable 빈값', () => { const report = [ '## 5. 액션 아이템', '| 담당 | 작업 내용 | 작업 상세 | 기한 | 상태 |', '| --- | --- | --- | --- | --- |', '| 나 | DRM 검토 | 상세 | 6/20 | 확정 |', ].join('\n'); const rows = parseActionItems(report); expect(rows).toHaveLength(1); expect(rows[0]).toMatchObject({ owner: '나', work: 'DRM 검토', deliverable: '', due: '6/20', status: '확정' }); }); test('섹션 번호가 바뀌어도(번호 무관) 탐지', () => { const report = '## 9. 액션 아이템\n| 담당 | 작업 내용 | 기한 |\n| --- | --- | --- |\n| 나 | 작업 | 6/20 |'; expect(parseActionItems(report)).toHaveLength(1); }); }); describe('looksDegenerate — 모델 출력 붕괴 감지', () => { test('정상 회의록은 통과', () => { expect(looksDegenerate('## 결정 사항\n- 테스트 샘플 3종 선정 — 근거: "샘플 3개로 가자"')).toBe(false); }); test('구절 반복 루프 감지', () => { expect(looksDegenerate('서비스 기프트 서비스 기프트 서비스 기프트 서비스 기프트 서비스 기프트')).toBe(true); }); test('대체문자(깨짐) 감지', () => { expect(looksDegenerate('정상 텍스트 �� 다략한서 량한서')).toBe(true); }); test('표 구분선 반복은 오탐 아님', () => { expect(looksDegenerate('| --- | --- | --- | --- | --- | --- |')).toBe(false); }); });