/** * v2.2.271 — 이메일 영속 상태 머신 (syncThreads / detectOverdue / setThreadState). * "명령 안 쳐도 f/u 되는" 핵심 로직: 창 밖 미회신 보존, done 자동 재개, 알림 중복 차단. */ import { syncThreads, detectOverdue, setThreadState, waitingHours, type EmailStateFile } from '../src/features/email/emailState'; import type { ThreadGroup } from '../src/features/email/emailBrief'; const NOW = new Date('2026-07-03T12:00:00'); const group = (o: Partial): ThreadGroup => ({ topic: '계약 검토', subject: '계약 검토', participants: ['김철수'], inCount: 1, outCount: 0, lastDirection: 'in', lastDate: '2026-07-03 10:00', snippet: '검토 부탁드립니다', hasUnread: true, automated: false, refs: ['O1'], timeline: [], ccOnly: false, ...o, }); const empty = (): EmailStateFile => ({ updatedAt: '', threads: [] }); describe('syncThreads — 관측 병합', () => { test('새 수신 스레드 → waiting + waitingSince 기록', () => { const s = syncThreads(empty(), [group({})], NOW); expect(s.threads[0].state).toBe('waiting'); expect(s.threads[0].waitingSince).toBe('2026-07-03 10:00'); }); test('관측 안 된 기존 스레드는 보존 — 수집 창 밖 미회신이 사라지지 않음', () => { let s = syncThreads(empty(), [group({ topic: '옛건', subject: '옛건', lastDate: '2026-06-20 09:00' })], NOW); s = syncThreads(s, [group({})], NOW); // 다음 사이클엔 '옛건' 미관측 expect(s.threads.map(t => t.topic).sort()).toEqual(['계약 검토', '옛건']); expect(s.threads.find(t => t.topic === '옛건')!.state).toBe('waiting'); }); test('내가 답하면 answered + 대기·알림 리셋', () => { let s = syncThreads(empty(), [group({})], NOW); s.threads[0].alertedFor = s.threads[0].waitingSince; s = syncThreads(s, [group({ lastDirection: 'out', outCount: 1, lastDate: '2026-07-03 11:00' })], NOW); expect(s.threads[0].state).toBe('answered'); expect(s.threads[0].waitingSince).toBeUndefined(); expect(s.threads[0].alertedFor).toBeUndefined(); }); test('done 스레드에 새 수신 → waiting 자동 재개 (놓침 방지)', () => { let s = syncThreads(empty(), [group({})], NOW); setThreadState(s, '계약 검토', 'done'); s = syncThreads(s, [group({ lastDate: '2026-07-03 11:30', inCount: 2 })], NOW); expect(s.threads[0].state).toBe('waiting'); expect(s.threads[0].waitingSince).toBe('2026-07-03 11:30'); }); test('muted 스레드는 새 수신에도 조용', () => { let s = syncThreads(empty(), [group({})], NOW); setThreadState(s, '계약 검토', 'muted'); s = syncThreads(s, [group({ lastDate: '2026-07-03 11:30' })], NOW); expect(s.threads[0].state).toBe('muted'); }); test('60일 무활동 스레드는 정리', () => { let s = syncThreads(empty(), [group({ topic: '고대', subject: '고대', lastDate: '2026-04-01 09:00' })], NOW); s = syncThreads(s, [], NOW); expect(s.threads).toHaveLength(0); }); }); describe('detectOverdue — 임계 초과 1회 알림', () => { test('24시간 초과 → 감지 + alertedFor 마킹, 재호출 시 중복 없음', () => { const s = syncThreads(empty(), [group({ lastDate: '2026-07-02 10:00' })], NOW); // 26시간 전 const first = detectOverdue(s, 24, NOW); expect(first).toHaveLength(1); expect(detectOverdue(s, 24, NOW)).toHaveLength(0); // 같은 대기 기간엔 1회만 }); test('임계 미만·자동발신·answered 는 제외', () => { const s = syncThreads(empty(), [ group({ topic: '방금', subject: '방금', lastDate: '2026-07-03 11:00' }), group({ topic: '뉴스', subject: '뉴스', automated: true, lastDate: '2026-07-01 10:00' }), group({ topic: '답함', subject: '답함', lastDirection: 'out', lastDate: '2026-07-01 10:00' }), ], NOW); expect(detectOverdue(s, 24, NOW)).toHaveLength(0); }); test('새 수신으로 대기 리셋되면 다시 알림 가능', () => { let s = syncThreads(empty(), [group({ lastDate: '2026-07-01 10:00' })], NOW); detectOverdue(s, 24, NOW); // 1차 알림 s = syncThreads(s, [group({ lastDate: '2026-07-02 09:00', inCount: 2 })], NOW); // 새 수신 (그래도 27시간 전) expect(detectOverdue(s, 24, NOW)).toHaveLength(1); // 새 대기 기간 → 재알림 }); test('임계 0 = 끔', () => { const s = syncThreads(empty(), [group({ lastDate: '2026-07-01 10:00' })], NOW); expect(detectOverdue(s, 0, NOW)).toHaveLength(0); }); }); describe('waitingHours', () => { test('26시간 전 수신 → ~26', () => { const s = syncThreads(empty(), [group({ lastDate: '2026-07-02 10:00' })], NOW); expect(Math.round(waitingHours(s.threads[0], NOW))).toBe(26); }); }); describe('wikifiedTopicSet — 파일 삭제 감지 (v2.2.280)', () => { const { wikifiedTopicSet } = require('../src/features/email/emailState'); const topics = { '구형항목': '2026-07-01T00:00:00Z', // 경로 없는 legacy '살아있음': { at: '2026-07-03', path: 'E:/wiki/a.md' }, '삭제됨': { at: '2026-07-03', path: 'E:/wiki/deleted.md' }, }; const exists = (p: string) => p === 'E:/wiki/a.md'; test('verify=true → 파일 지워진 항목은 완료에서 제외 (재위키화 대상)', () => { const s = wikifiedTopicSet(topics, exists, true); expect(s.has('살아있음')).toBe(true); expect(s.has('삭제됨')).toBe(false); expect(s.has('구형항목')).toBe(true); // 경로 없어 검증 불가 — 완료 유지 (reset 으로 해소) }); test('verify=false → 기록 그대로', () => { expect(wikifiedTopicSet(topics, exists, false).size).toBe(3); }); });