/** * Sleep-time 사전 소화 — 순수 로직 테스트 (대상 선정·노후화 판정·노트 형식). * LLM 호출(runSleepDigestOnce)은 제외 — 통합 검증은 수동 명령으로. */ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { selectDigestTargets, folderSlug, isDigestFresh, digestMarkdown, DIGEST_DIR, type DigestTarget, } from '../src/features/growth/sleepDigest'; const tmpBrain = () => fs.mkdtempSync(path.join(os.tmpdir(), 'astra-digest-')); function mkFile(brain: string, rel: string, ageDays: number): string { const abs = path.join(brain, rel); fs.mkdirSync(path.dirname(abs), { recursive: true }); fs.writeFileSync(abs, `# ${path.basename(rel)}\n내용`, 'utf8'); const t = new Date(Date.now() - ageDays * 86_400_000); fs.utimesSync(abs, t, t); return abs; } describe('selectDigestTargets', () => { test('최근 변경 파일이 많은 폴더 우선, 오래된 파일 제외', () => { const brain = tmpBrain(); const files = [ mkFile(brain, 'Topics_Rag/a.md', 1), mkFile(brain, 'Topics_Rag/b.md', 2), mkFile(brain, 'Topics_Rag/c.md', 3), mkFile(brain, 'Coding/Python/x.md', 1), mkFile(brain, 'Coding/Python/y.md', 2), mkFile(brain, 'Old_Folder/z.md', 30), // 윈도우 밖 ]; const targets = selectDigestTargets(brain, files, Date.now(), 7); expect(targets[0].folder).toBe('Topics_Rag'); expect(targets[0].files).toHaveLength(3); expect(targets[1].folder).toBe('Coding/Python'); expect(targets.find(t => t.folder === 'Old_Folder')).toBeUndefined(); }); test('Digests 폴더 자신은 재소화하지 않음 + 루트 직속은 빈 키', () => { const brain = tmpBrain(); const files = [ mkFile(brain, `${DIGEST_DIR}/old-digest.md`, 1), mkFile(brain, 'root-note.md', 1), ]; const targets = selectDigestTargets(brain, files, Date.now(), 7); expect(targets.find(t => t.folder.includes(DIGEST_DIR))).toBeUndefined(); expect(targets.find(t => t.folder === '')).toBeDefined(); }); test('파일 상한 8개', () => { const brain = tmpBrain(); const files = Array.from({ length: 12 }, (_, i) => mkFile(brain, `Big/f${i}.md`, 1)); const targets = selectDigestTargets(brain, files, Date.now(), 7); expect(targets[0].files.length).toBeLessThanOrEqual(8); }); }); describe('folderSlug', () => { test('경로 구분자·특수문자 정리', () => { expect(folderSlug('Coding/Python')).toBe('Coding--Python'); expect(folderSlug('')).toBe('root'); expect(folderSlug('AI & ML!')).toBe('AI-ML-'); }); }); describe('isDigestFresh — 노후화 판정', () => { const target = (over: Partial = {}): DigestTarget => ({ folder: 'X', slug: 'X', files: [], newestMtimeMs: Date.now(), ...over, }); test('노트 없음 → 재생성 필요', () => { expect(isDigestFresh(path.join(tmpBrain(), 'no.md'), Date.now())).toBe(false); }); test('generated_at ≥ 소스 mtime → fresh', () => { const brain = tmpBrain(); const f = path.join(brain, 'd.md'); const t = target(); fs.writeFileSync(f, digestMarkdown(t, '본문', ['s1'], new Date(t.newestMtimeMs + 1000).toISOString()), 'utf8'); expect(isDigestFresh(f, t.newestMtimeMs)).toBe(true); }); test('소스가 노트보다 최신 → 재생성 필요', () => { const brain = tmpBrain(); const f = path.join(brain, 'd.md'); fs.writeFileSync(f, digestMarkdown(target(), '본문', ['s1'], new Date(Date.now() - 86_400_000).toISOString()), 'utf8'); expect(isDigestFresh(f, Date.now())).toBe(false); }); }); describe('digestMarkdown', () => { test('frontmatter(type: digest, sources) + 원문 우선 고지', () => { const md = digestMarkdown( { folder: 'Topics_Rag', slug: 'Topics_Rag', files: [], newestMtimeMs: 0 }, '## 예상 질문과 답\n- Q…', ['문서 "A"', 'B'], '2026-06-12T03:00:00Z', ); expect(md).toMatch(/^---\ntype: digest/); expect(md).toContain('generated_at: 2026-06-12T03:00:00Z'); expect(md).toContain("문서 'A'"); // 따옴표 이스케이프 expect(md).toContain('원문이 항상 우선'); expect(md).toContain('# 소화 노트: Topics_Rag'); }); });