192 lines
9.2 KiB
TypeScript
192 lines
9.2 KiB
TypeScript
import {
|
|
detectLessonKind,
|
|
extractPreventionChecklist,
|
|
buildLessonChecklistBlock,
|
|
lessonTemplate,
|
|
lessonSlug,
|
|
LESSON_DIR_RE,
|
|
isQaRegressionFeedback,
|
|
parseLessonFrontmatter,
|
|
normalizeLessonTitle,
|
|
bumpLessonOccurrences,
|
|
findUnaddressedChecklistItems,
|
|
} from '../src/retrieval/lessonHelpers';
|
|
|
|
describe('lessonHelpers.detectLessonKind', () => {
|
|
it('detects by path segment', () => {
|
|
expect(detectLessonKind('lessons/2026-05-12-foo.md', '# Foo')).toBe('lesson');
|
|
expect(detectLessonKind('docs/playbooks/release.md', 'stuff')).toBe('playbook');
|
|
expect(detectLessonKind('qa-findings/bug-x.md', 'stuff')).toBe('qa-finding');
|
|
expect(detectLessonKind('a/b/lesson/x.md', '')).toBe('lesson'); // singular "lesson"
|
|
expect(detectLessonKind('notes/architecture.md', '# Arch')).toBe('');
|
|
});
|
|
it('frontmatter type wins over path', () => {
|
|
const fm = '---\ntype: qa-finding\ntitle: x\n---\n# body';
|
|
expect(detectLessonKind('notes/whatever.md', fm)).toBe('qa-finding');
|
|
const fm2 = '---\ntype: "lesson"\n---\nbody';
|
|
expect(detectLessonKind('random.md', fm2)).toBe('lesson');
|
|
});
|
|
it('ignores non-lesson frontmatter / no frontmatter', () => {
|
|
expect(detectLessonKind('notes/x.md', '---\ntype: note\n---\nbody')).toBe('');
|
|
expect(detectLessonKind('notes/x.md', 'just text, no frontmatter')).toBe('');
|
|
expect(detectLessonKind('notes/x.md', '')).toBe('');
|
|
});
|
|
it('LESSON_DIR_RE matches expected dirs only', () => {
|
|
expect(LESSON_DIR_RE.test('lessons/x.md')).toBe(true);
|
|
expect(LESSON_DIR_RE.test('a\\playbooks\\y.md')).toBe(true);
|
|
expect(LESSON_DIR_RE.test('qa_findings/z.md')).toBe(true);
|
|
expect(LESSON_DIR_RE.test('lessons-archive/x.md')).toBe(false); // not a path segment boundary
|
|
expect(LESSON_DIR_RE.test('records/dev.md')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('lessonHelpers.extractPreventionChecklist', () => {
|
|
it('pulls the bullets under a Prevention Checklist heading', () => {
|
|
const card = [
|
|
'# Lesson: X',
|
|
'## Root Cause',
|
|
'because reasons',
|
|
'## Prevention Checklist',
|
|
'- check the allowlist',
|
|
'* approval flow exists',
|
|
'- path boundary validated',
|
|
'## Applies To',
|
|
'- security',
|
|
].join('\n');
|
|
expect(extractPreventionChecklist(card)).toEqual([
|
|
'check the allowlist', 'approval flow exists', 'path boundary validated',
|
|
]);
|
|
});
|
|
it('returns [] when there is no checklist', () => {
|
|
expect(extractPreventionChecklist('# Lesson\nsome text')).toEqual([]);
|
|
expect(extractPreventionChecklist('')).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('lessonHelpers.buildLessonChecklistBlock', () => {
|
|
it('renders an ACTIVE LESSONS block with checklists when available', () => {
|
|
const block = buildLessonChecklistBlock([
|
|
{ title: 'lessons/telegram.md', content: '# X\n## Prevention Checklist\n- allowlist required\n- approval flow' },
|
|
{ title: 'lessons/paths.md', content: 'no checklist here, just prose about path safety' },
|
|
]);
|
|
expect(block).toContain('ACTIVE LESSONS');
|
|
expect(block).toContain('### lessons/telegram.md');
|
|
expect(block).toContain('- [ ] allowlist required');
|
|
expect(block).toContain('- [ ] approval flow');
|
|
expect(block).toContain('### lessons/paths.md');
|
|
expect(block).toContain('just prose about path safety');
|
|
expect(block).toContain('END ACTIVE LESSONS');
|
|
});
|
|
it('returns empty string for no chunks', () => {
|
|
expect(buildLessonChecklistBlock([])).toBe('');
|
|
});
|
|
});
|
|
|
|
describe('lessonHelpers.lessonTemplate / lessonSlug', () => {
|
|
it('template has frontmatter + the standard sections', () => {
|
|
const t = lessonTemplate('Telegram 원격 실행은 allowlist 필수', '2026-05-12');
|
|
expect(t).toMatch(/^---\ntype: lesson\n/);
|
|
expect(t).toContain('title: Telegram 원격 실행은 allowlist 필수');
|
|
expect(t).toContain('last-seen: 2026-05-12');
|
|
for (const h of ['## Situation', '## Mistake / Risk', '## Root Cause', '## Fix', '## Prevention Checklist', '## Applies To']) {
|
|
expect(t).toContain(h);
|
|
}
|
|
// detectLessonKind should recognize the generated template
|
|
expect(detectLessonKind('lessons/x.md', t)).toBe('lesson');
|
|
});
|
|
it('slug is filesystem-safe and length-bounded', () => {
|
|
expect(lessonSlug('Hello, World! / weird:chars')).toBe('hello-world-weird-chars');
|
|
expect(lessonSlug('한글 제목 테스트')).toBe('한글-제목-테스트');
|
|
expect(lessonSlug('')).toBe('lesson');
|
|
expect(lessonSlug('a'.repeat(200)).length).toBeLessThanOrEqual(60);
|
|
});
|
|
});
|
|
|
|
describe('lessonHelpers.isQaRegressionFeedback', () => {
|
|
it('flags recurring-problem complaints (ko/en)', () => {
|
|
expect(isQaRegressionFeedback('이거 또 안 돼')).toBe(true);
|
|
expect(isQaRegressionFeedback('아까랑 비슷한 실수네')).toBe(true);
|
|
expect(isQaRegressionFeedback('왜 자꾸 이래?')).toBe(true);
|
|
expect(isQaRegressionFeedback('고쳤는데 또 깨졌어')).toBe(true);
|
|
expect(isQaRegressionFeedback('여전히 안 돼')).toBe(true);
|
|
expect(isQaRegressionFeedback('this is a regression')).toBe(true);
|
|
expect(isQaRegressionFeedback('why does this keep failing again')).toBe(true);
|
|
});
|
|
it('does not flag ordinary requests', () => {
|
|
expect(isQaRegressionFeedback('이 함수 리팩터링해줘')).toBe(false);
|
|
expect(isQaRegressionFeedback('add a new endpoint for users')).toBe(false);
|
|
expect(isQaRegressionFeedback('')).toBe(false);
|
|
expect(isQaRegressionFeedback('또')).toBe(false); // too short / not a complaint
|
|
});
|
|
});
|
|
|
|
describe('lessonHelpers.parseLessonFrontmatter / normalizeLessonTitle / bumpLessonOccurrences', () => {
|
|
const card = [
|
|
'---',
|
|
'type: lesson',
|
|
'title: Telegram remote exec needs allowlist',
|
|
'applies-to: [telegram, "remote-execution", security]',
|
|
'occurrences: 2',
|
|
'last-seen: 2026-05-01',
|
|
'---',
|
|
'# body',
|
|
'stuff',
|
|
].join('\n');
|
|
it('parses frontmatter fields', () => {
|
|
const fm = parseLessonFrontmatter(card);
|
|
expect(fm.type).toBe('lesson');
|
|
expect(fm.title).toBe('Telegram remote exec needs allowlist');
|
|
expect(fm.occurrences).toBe(2);
|
|
expect(fm.appliesTo).toEqual(['telegram', 'remote-execution', 'security']);
|
|
});
|
|
it('returns {} when there is no frontmatter', () => {
|
|
expect(parseLessonFrontmatter('# just a heading\ntext')).toEqual({});
|
|
expect(parseLessonFrontmatter('')).toEqual({});
|
|
});
|
|
it('normalizeLessonTitle strips punctuation/case/space for matching', () => {
|
|
expect(normalizeLessonTitle('Telegram 원격 실행은 allowlist 필수!')).toBe(normalizeLessonTitle('telegram원격실행은allowlist필수'));
|
|
expect(normalizeLessonTitle('A B C')).toBe('abc');
|
|
});
|
|
it('bumpLessonOccurrences increments and updates last-seen', () => {
|
|
const out = bumpLessonOccurrences(card, '2026-05-12');
|
|
expect(parseLessonFrontmatter(out).occurrences).toBe(3);
|
|
expect(out).toContain('last-seen: 2026-05-12');
|
|
expect(out).toContain('# body'); // body untouched
|
|
});
|
|
it('bumpLessonOccurrences inserts the keys when missing', () => {
|
|
const minimal = '---\ntype: lesson\ntitle: X\n---\nbody';
|
|
const out = bumpLessonOccurrences(minimal, '2026-05-12');
|
|
expect(parseLessonFrontmatter(out).occurrences).toBe(2);
|
|
expect(out).toContain('last-seen: 2026-05-12');
|
|
});
|
|
it('bumpLessonOccurrences leaves a frontmatter-less file unchanged', () => {
|
|
expect(bumpLessonOccurrences('no frontmatter here', '2026-05-12')).toBe('no frontmatter here');
|
|
});
|
|
});
|
|
|
|
describe('lessonHelpers.findUnaddressedChecklistItems', () => {
|
|
const card = [
|
|
'# Lesson',
|
|
'## Prevention Checklist',
|
|
'- 원격 실행 기능은 allowlist 필수인지 확인한다',
|
|
'- 파일 변경은 승인 흐름을 거치는지 확인한다',
|
|
'- <템플릿 placeholder 항목>',
|
|
'## Applies To',
|
|
'- security',
|
|
].join('\n');
|
|
it('flags items whose terms do not appear in the answer', () => {
|
|
const answer = '이번 변경은 승인 흐름을 거치도록 했습니다. 파일 변경 시 사용자에게 확인을 받습니다.';
|
|
const out = findUnaddressedChecklistItems(answer, [card]);
|
|
// "allowlist" item is unaddressed; the "승인 흐름/파일 변경" item is addressed; the placeholder is skipped
|
|
expect(out).toEqual(['원격 실행 기능은 allowlist 필수인지 확인한다']);
|
|
});
|
|
it('returns [] when the answer touches all checklist items', () => {
|
|
const answer = 'allowlist 정책을 추가했고, 파일 변경은 승인 흐름을 거칩니다.';
|
|
expect(findUnaddressedChecklistItems(answer, [card])).toEqual([]);
|
|
});
|
|
it('returns [] with no answer or no lessons', () => {
|
|
expect(findUnaddressedChecklistItems('', [card])).toEqual([]);
|
|
expect(findUnaddressedChecklistItems('something', [])).toEqual([]);
|
|
});
|
|
});
|