From 52d87bad601255b1ac8524a6d6346fe95527e175 Mon Sep 17 00:00:00 2001 From: Shirone Date: Tue, 27 Jan 2026 16:20:41 +0100 Subject: [PATCH] feat(03-01): create SpecParser module with comprehensive tests - Extract parseTasksFromSpec for parsing tasks from spec content - Extract marker detection functions (task start/complete, phase complete) - Extract detectSpecFallback for non-Claude model support - Extract extractSummary with multi-format support and last-match behavior - Add 65 unit tests covering all functions and edge cases --- apps/server/src/services/spec-parser.ts | 227 +++++++ .../tests/unit/services/spec-parser.test.ts | 641 ++++++++++++++++++ 2 files changed, 868 insertions(+) create mode 100644 apps/server/src/services/spec-parser.ts create mode 100644 apps/server/tests/unit/services/spec-parser.test.ts diff --git a/apps/server/src/services/spec-parser.ts b/apps/server/src/services/spec-parser.ts new file mode 100644 index 00000000..cd1c8050 --- /dev/null +++ b/apps/server/src/services/spec-parser.ts @@ -0,0 +1,227 @@ +/** + * Spec Parser - Pure functions for parsing spec content and detecting markers + * + * Extracts tasks from generated specs, detects progress markers, + * and extracts summary content from various formats. + */ + +import type { ParsedTask } from '@automaker/types'; + +/** + * Parse a single task line + * Format: - [ ] T###: Description | File: path/to/file + */ +function parseTaskLine(line: string, currentPhase?: string): ParsedTask | null { + // Match pattern: - [ ] T###: Description | File: path + const taskMatch = line.match(/- \[ \] (T\d{3}):\s*([^|]+)(?:\|\s*File:\s*(.+))?$/); + if (!taskMatch) { + // Try simpler pattern without file + const simpleMatch = line.match(/- \[ \] (T\d{3}):\s*(.+)$/); + if (simpleMatch) { + return { + id: simpleMatch[1], + description: simpleMatch[2].trim(), + phase: currentPhase, + status: 'pending', + }; + } + return null; + } + + return { + id: taskMatch[1], + description: taskMatch[2].trim(), + filePath: taskMatch[3]?.trim(), + phase: currentPhase, + status: 'pending', + }; +} + +/** + * Parse tasks from generated spec content + * Looks for the ```tasks code block and extracts task lines + * Format: - [ ] T###: Description | File: path/to/file + */ +export function parseTasksFromSpec(specContent: string): ParsedTask[] { + const tasks: ParsedTask[] = []; + + // Extract content within ```tasks ... ``` block + const tasksBlockMatch = specContent.match(/```tasks\s*([\s\S]*?)```/); + if (!tasksBlockMatch) { + // Try fallback: look for task lines anywhere in content + const taskLines = specContent.match(/- \[ \] T\d{3}:.*$/gm); + if (!taskLines) { + return tasks; + } + // Parse fallback task lines + let currentPhase: string | undefined; + for (const line of taskLines) { + const parsed = parseTaskLine(line, currentPhase); + if (parsed) { + tasks.push(parsed); + } + } + return tasks; + } + + const tasksContent = tasksBlockMatch[1]; + const lines = tasksContent.split('\n'); + + let currentPhase: string | undefined; + + for (const line of lines) { + const trimmedLine = line.trim(); + + // Check for phase header (e.g., "## Phase 1: Foundation") + const phaseMatch = trimmedLine.match(/^##\s*(.+)$/); + if (phaseMatch) { + currentPhase = phaseMatch[1].trim(); + continue; + } + + // Check for task line + if (trimmedLine.startsWith('- [ ]')) { + const parsed = parseTaskLine(trimmedLine, currentPhase); + if (parsed) { + tasks.push(parsed); + } + } + } + + return tasks; +} + +/** + * Detect [TASK_START] marker in text and extract task ID + * Format: [TASK_START] T###: Description + */ +export function detectTaskStartMarker(text: string): string | null { + const match = text.match(/\[TASK_START\]\s*(T\d{3})/); + return match ? match[1] : null; +} + +/** + * Detect [TASK_COMPLETE] marker in text and extract task ID + * Format: [TASK_COMPLETE] T###: Brief summary + */ +export function detectTaskCompleteMarker(text: string): string | null { + const match = text.match(/\[TASK_COMPLETE\]\s*(T\d{3})/); + return match ? match[1] : null; +} + +/** + * Detect [PHASE_COMPLETE] marker in text and extract phase number + * Format: [PHASE_COMPLETE] Phase N complete + */ +export function detectPhaseCompleteMarker(text: string): number | null { + const match = text.match(/\[PHASE_COMPLETE\]\s*Phase\s*(\d+)/i); + return match ? parseInt(match[1], 10) : null; +} + +/** + * Fallback spec detection when [SPEC_GENERATED] marker is missing + * Looks for structural elements that indicate a spec was generated. + * This is especially important for non-Claude models that may not output + * the explicit [SPEC_GENERATED] marker. + * + * @param text - The text content to check for spec structure + * @returns true if the text appears to be a generated spec + */ +export function detectSpecFallback(text: string): boolean { + // Check for key structural elements of a spec + const hasTasksBlock = /```tasks[\s\S]*```/.test(text); + const hasTaskLines = /- \[ \] T\d{3}:/.test(text); + + // Check for common spec sections (case-insensitive) + const hasAcceptanceCriteria = /acceptance criteria/i.test(text); + const hasTechnicalContext = /technical context/i.test(text); + const hasProblemStatement = /problem statement/i.test(text); + const hasUserStory = /user story/i.test(text); + // Additional patterns for different model outputs + const hasGoal = /\*\*Goal\*\*:/i.test(text); + const hasSolution = /\*\*Solution\*\*:/i.test(text); + const hasImplementation = /implementation\s*(plan|steps|approach)/i.test(text); + const hasOverview = /##\s*(overview|summary)/i.test(text); + + // Spec is detected if we have task structure AND at least some spec content + const hasTaskStructure = hasTasksBlock || hasTaskLines; + const hasSpecContent = + hasAcceptanceCriteria || + hasTechnicalContext || + hasProblemStatement || + hasUserStory || + hasGoal || + hasSolution || + hasImplementation || + hasOverview; + + return hasTaskStructure && hasSpecContent; +} + +/** + * Extract summary from text content + * Checks for multiple formats in order of priority: + * 1. Explicit tags + * 2. ## Summary section (markdown) + * 3. **Goal**: section (lite planning mode) + * 4. **Problem**: or **Problem Statement**: section (spec/full modes) + * 5. **Solution**: section as fallback + * + * Note: Uses last match for each pattern to avoid stale summaries + * when agent output accumulates across multiple runs. + * + * @param text - The text content to extract summary from + * @returns The extracted summary string, or null if no summary found + */ +export function extractSummary(text: string): string | null { + // Helper to truncate content to first paragraph with max length + const truncate = (content: string, maxLength: number): string => { + const firstPara = content.split(/\n\n/)[0]; + return firstPara.length > maxLength ? `${firstPara.substring(0, maxLength)}...` : firstPara; + }; + + // Helper to get last match from matchAll results + const getLastMatch = (matches: IterableIterator): RegExpMatchArray | null => { + const arr = [...matches]; + return arr.length > 0 ? arr[arr.length - 1] : null; + }; + + // Check for explicit tags first (use last match to avoid stale summaries) + const summaryMatches = text.matchAll(/([\s\S]*?)<\/summary>/g); + const summaryMatch = getLastMatch(summaryMatches); + if (summaryMatch) { + return summaryMatch[1].trim(); + } + + // Check for ## Summary section (use last match) + const sectionMatches = text.matchAll(/##\s*Summary\s*\n+([\s\S]*?)(?=\n##|\n\*\*|$)/gi); + const sectionMatch = getLastMatch(sectionMatches); + if (sectionMatch) { + return truncate(sectionMatch[1].trim(), 500); + } + + // Check for **Goal**: section (lite mode, use last match) + const goalMatches = text.matchAll(/\*\*Goal\*\*:\s*(.+?)(?:\n|$)/gi); + const goalMatch = getLastMatch(goalMatches); + if (goalMatch) { + return goalMatch[1].trim(); + } + + // Check for **Problem**: or **Problem Statement**: section (spec/full modes, use last match) + const problemMatches = text.matchAll( + /\*\*Problem(?:\s*Statement)?\*\*:\s*([\s\S]*?)(?=\n\d+\.|\n\*\*|$)/gi + ); + const problemMatch = getLastMatch(problemMatches); + if (problemMatch) { + return truncate(problemMatch[1].trim(), 500); + } + + // Check for **Solution**: section as fallback (use last match) + const solutionMatches = text.matchAll(/\*\*Solution\*\*:\s*([\s\S]*?)(?=\n\d+\.|\n\*\*|$)/gi); + const solutionMatch = getLastMatch(solutionMatches); + if (solutionMatch) { + return truncate(solutionMatch[1].trim(), 300); + } + + return null; +} diff --git a/apps/server/tests/unit/services/spec-parser.test.ts b/apps/server/tests/unit/services/spec-parser.test.ts new file mode 100644 index 00000000..e917622c --- /dev/null +++ b/apps/server/tests/unit/services/spec-parser.test.ts @@ -0,0 +1,641 @@ +import { describe, it, expect } from 'vitest'; +import { + parseTasksFromSpec, + detectTaskStartMarker, + detectTaskCompleteMarker, + detectPhaseCompleteMarker, + detectSpecFallback, + extractSummary, +} from '../../../src/services/spec-parser.js'; + +describe('SpecParser', () => { + describe('parseTasksFromSpec', () => { + it('should parse tasks from a tasks code block', () => { + const specContent = ` +## Specification + +Some description here. + +\`\`\`tasks +- [ ] T001: Create user model | File: src/models/user.ts +- [ ] T002: Add API endpoint | File: src/routes/users.ts +- [ ] T003: Write unit tests | File: tests/user.test.ts +\`\`\` + +## Notes +Some notes here. +`; + const tasks = parseTasksFromSpec(specContent); + expect(tasks).toHaveLength(3); + expect(tasks[0]).toEqual({ + id: 'T001', + description: 'Create user model', + filePath: 'src/models/user.ts', + phase: undefined, + status: 'pending', + }); + expect(tasks[1].id).toBe('T002'); + expect(tasks[2].id).toBe('T003'); + }); + + it('should parse tasks with phases', () => { + const specContent = ` +\`\`\`tasks +## Phase 1: Foundation +- [ ] T001: Initialize project | File: package.json +- [ ] T002: Configure TypeScript | File: tsconfig.json + +## Phase 2: Implementation +- [ ] T003: Create main module | File: src/index.ts +- [ ] T004: Add utility functions | File: src/utils.ts + +## Phase 3: Testing +- [ ] T005: Write tests | File: tests/index.test.ts +\`\`\` +`; + const tasks = parseTasksFromSpec(specContent); + expect(tasks).toHaveLength(5); + expect(tasks[0].phase).toBe('Phase 1: Foundation'); + expect(tasks[1].phase).toBe('Phase 1: Foundation'); + expect(tasks[2].phase).toBe('Phase 2: Implementation'); + expect(tasks[3].phase).toBe('Phase 2: Implementation'); + expect(tasks[4].phase).toBe('Phase 3: Testing'); + }); + + it('should return empty array for content without tasks', () => { + const specContent = 'Just some text without any tasks'; + const tasks = parseTasksFromSpec(specContent); + expect(tasks).toEqual([]); + }); + + it('should fallback to finding task lines outside code block', () => { + const specContent = ` +## Implementation Plan + +- [ ] T001: First task | File: src/first.ts +- [ ] T002: Second task | File: src/second.ts +`; + const tasks = parseTasksFromSpec(specContent); + expect(tasks).toHaveLength(2); + expect(tasks[0].id).toBe('T001'); + expect(tasks[1].id).toBe('T002'); + }); + + it('should handle empty tasks block', () => { + const specContent = ` +\`\`\`tasks +\`\`\` +`; + const tasks = parseTasksFromSpec(specContent); + expect(tasks).toEqual([]); + }); + + it('should handle empty string input', () => { + const tasks = parseTasksFromSpec(''); + expect(tasks).toEqual([]); + }); + + it('should handle task without file path', () => { + const specContent = ` +\`\`\`tasks +- [ ] T001: Task without file +\`\`\` +`; + const tasks = parseTasksFromSpec(specContent); + expect(tasks).toHaveLength(1); + expect(tasks[0]).toEqual({ + id: 'T001', + description: 'Task without file', + phase: undefined, + status: 'pending', + }); + }); + + it('should handle mixed valid and invalid lines', () => { + const specContent = ` +\`\`\`tasks +- [ ] T001: Valid task | File: src/valid.ts +- Invalid line +Some other text +- [ ] T002: Another valid task +\`\`\` +`; + const tasks = parseTasksFromSpec(specContent); + expect(tasks).toHaveLength(2); + }); + + it('should preserve task order', () => { + const specContent = ` +\`\`\`tasks +- [ ] T003: Third +- [ ] T001: First +- [ ] T002: Second +\`\`\` +`; + const tasks = parseTasksFromSpec(specContent); + expect(tasks[0].id).toBe('T003'); + expect(tasks[1].id).toBe('T001'); + expect(tasks[2].id).toBe('T002'); + }); + + it('should handle task IDs with different numbers', () => { + const specContent = ` +\`\`\`tasks +- [ ] T001: First +- [ ] T010: Tenth +- [ ] T100: Hundredth +\`\`\` +`; + const tasks = parseTasksFromSpec(specContent); + expect(tasks).toHaveLength(3); + expect(tasks[0].id).toBe('T001'); + expect(tasks[1].id).toBe('T010'); + expect(tasks[2].id).toBe('T100'); + }); + + it('should trim whitespace from description and file path', () => { + const specContent = ` +\`\`\`tasks +- [ ] T001: Create API endpoint | File: src/routes/api.ts +\`\`\` +`; + const tasks = parseTasksFromSpec(specContent); + expect(tasks[0].description).toBe('Create API endpoint'); + expect(tasks[0].filePath).toBe('src/routes/api.ts'); + }); + }); + + describe('detectTaskStartMarker', () => { + it('should detect task start marker and return task ID', () => { + expect(detectTaskStartMarker('[TASK_START] T001')).toBe('T001'); + expect(detectTaskStartMarker('[TASK_START] T042')).toBe('T042'); + expect(detectTaskStartMarker('[TASK_START] T999')).toBe('T999'); + }); + + it('should handle marker with description', () => { + expect(detectTaskStartMarker('[TASK_START] T001: Creating user model')).toBe('T001'); + }); + + it('should return null when no marker present', () => { + expect(detectTaskStartMarker('No marker here')).toBeNull(); + expect(detectTaskStartMarker('')).toBeNull(); + }); + + it('should find marker in accumulated text', () => { + const accumulated = ` +Some earlier output... + +Now starting the task: +[TASK_START] T003: Setting up database + +Let me begin by... +`; + expect(detectTaskStartMarker(accumulated)).toBe('T003'); + }); + + it('should handle whitespace variations', () => { + expect(detectTaskStartMarker('[TASK_START] T001')).toBe('T001'); + expect(detectTaskStartMarker('[TASK_START]\tT001')).toBe('T001'); + }); + + it('should not match invalid task IDs', () => { + expect(detectTaskStartMarker('[TASK_START] TASK1')).toBeNull(); + expect(detectTaskStartMarker('[TASK_START] T1')).toBeNull(); + expect(detectTaskStartMarker('[TASK_START] T12')).toBeNull(); + }); + }); + + describe('detectTaskCompleteMarker', () => { + it('should detect task complete marker and return task ID', () => { + expect(detectTaskCompleteMarker('[TASK_COMPLETE] T001')).toBe('T001'); + expect(detectTaskCompleteMarker('[TASK_COMPLETE] T042')).toBe('T042'); + }); + + it('should handle marker with summary', () => { + expect(detectTaskCompleteMarker('[TASK_COMPLETE] T001: User model created')).toBe('T001'); + }); + + it('should return null when no marker present', () => { + expect(detectTaskCompleteMarker('No marker here')).toBeNull(); + expect(detectTaskCompleteMarker('')).toBeNull(); + }); + + it('should find marker in accumulated text', () => { + const accumulated = ` +Working on the task... + +Done with the implementation: +[TASK_COMPLETE] T003: Database setup complete + +Moving on to... +`; + expect(detectTaskCompleteMarker(accumulated)).toBe('T003'); + }); + + it('should not confuse with TASK_START marker', () => { + expect(detectTaskCompleteMarker('[TASK_START] T001')).toBeNull(); + }); + + it('should not match invalid task IDs', () => { + expect(detectTaskCompleteMarker('[TASK_COMPLETE] TASK1')).toBeNull(); + expect(detectTaskCompleteMarker('[TASK_COMPLETE] T1')).toBeNull(); + }); + }); + + describe('detectPhaseCompleteMarker', () => { + it('should detect phase complete marker and return phase number', () => { + expect(detectPhaseCompleteMarker('[PHASE_COMPLETE] Phase 1')).toBe(1); + expect(detectPhaseCompleteMarker('[PHASE_COMPLETE] Phase 2')).toBe(2); + expect(detectPhaseCompleteMarker('[PHASE_COMPLETE] Phase 10')).toBe(10); + }); + + it('should handle marker with description', () => { + expect(detectPhaseCompleteMarker('[PHASE_COMPLETE] Phase 1 complete')).toBe(1); + expect(detectPhaseCompleteMarker('[PHASE_COMPLETE] Phase 2: Foundation done')).toBe(2); + }); + + it('should return null when no marker present', () => { + expect(detectPhaseCompleteMarker('No marker here')).toBeNull(); + expect(detectPhaseCompleteMarker('')).toBeNull(); + }); + + it('should be case-insensitive', () => { + expect(detectPhaseCompleteMarker('[PHASE_COMPLETE] phase 1')).toBe(1); + expect(detectPhaseCompleteMarker('[PHASE_COMPLETE] PHASE 2')).toBe(2); + }); + + it('should find marker in accumulated text', () => { + const accumulated = ` +Finishing up the phase... + +All tasks complete: +[PHASE_COMPLETE] Phase 2 complete + +Starting Phase 3... +`; + expect(detectPhaseCompleteMarker(accumulated)).toBe(2); + }); + + it('should not confuse with task markers', () => { + expect(detectPhaseCompleteMarker('[TASK_COMPLETE] T001')).toBeNull(); + }); + }); + + describe('detectSpecFallback', () => { + it('should detect spec with tasks block and acceptance criteria', () => { + const content = ` +## Acceptance Criteria +- GIVEN a user, WHEN they login, THEN they see the dashboard + +\`\`\`tasks +- [ ] T001: Create login form | File: src/Login.tsx +\`\`\` +`; + expect(detectSpecFallback(content)).toBe(true); + }); + + it('should detect spec with task lines and problem statement', () => { + const content = ` +## Problem Statement +Users cannot currently log in to the application. + +## Implementation Plan +- [ ] T001: Add authentication endpoint +- [ ] T002: Create login UI +`; + expect(detectSpecFallback(content)).toBe(true); + }); + + it('should detect spec with Goal section (lite planning mode)', () => { + const content = ` +**Goal**: Implement user authentication + +**Solution**: Use JWT tokens for session management + +- [ ] T001: Setup auth middleware +- [ ] T002: Create token service +`; + expect(detectSpecFallback(content)).toBe(true); + }); + + it('should detect spec with User Story format', () => { + const content = ` +## User Story +As a user, I want to reset my password, so that I can regain access. + +## Technical Context +This will modify the auth module. + +\`\`\`tasks +- [ ] T001: Add reset endpoint +\`\`\` +`; + expect(detectSpecFallback(content)).toBe(true); + }); + + it('should detect spec with Overview section', () => { + const content = ` +## Overview +This feature adds dark mode support. + +\`\`\`tasks +- [ ] T001: Add theme toggle +\`\`\` +`; + expect(detectSpecFallback(content)).toBe(true); + }); + + it('should detect spec with Summary section', () => { + const content = ` +## Summary +Adding a new dashboard component. + +- [ ] T001: Create dashboard layout +`; + expect(detectSpecFallback(content)).toBe(true); + }); + + it('should detect spec with implementation plan', () => { + const content = ` +## Implementation Plan +We will add the feature in two phases. + +- [ ] T001: Phase 1 setup +`; + expect(detectSpecFallback(content)).toBe(true); + }); + + it('should detect spec with implementation steps', () => { + const content = ` +## Implementation Steps +Follow these steps: + +- [ ] T001: Step one +`; + expect(detectSpecFallback(content)).toBe(true); + }); + + it('should detect spec with implementation approach', () => { + const content = ` +## Implementation Approach +We will use a modular approach. + +- [ ] T001: Create modules +`; + expect(detectSpecFallback(content)).toBe(true); + }); + + it('should NOT detect spec without task structure', () => { + const content = ` +## Problem Statement +Users cannot log in. + +## Acceptance Criteria +- GIVEN a user, WHEN they try to login, THEN it works +`; + expect(detectSpecFallback(content)).toBe(false); + }); + + it('should NOT detect spec without spec content sections', () => { + const content = ` +Here are some tasks: + +- [ ] T001: Do something +- [ ] T002: Do another thing +`; + expect(detectSpecFallback(content)).toBe(false); + }); + + it('should NOT detect random text as spec', () => { + expect(detectSpecFallback('Just some random text')).toBe(false); + expect(detectSpecFallback('')).toBe(false); + }); + + it('should handle case-insensitive matching for spec sections', () => { + const content = ` +## ACCEPTANCE CRITERIA +All caps section header + +- [ ] T001: Task +`; + expect(detectSpecFallback(content)).toBe(true); + }); + }); + + describe('extractSummary', () => { + describe('explicit tags', () => { + it('should extract content from summary tags', () => { + const text = 'Some preamble This is the summary content more text'; + expect(extractSummary(text)).toBe('This is the summary content'); + }); + + it('should use last match to avoid stale summaries', () => { + const text = ` +Old stale summary + +More agent output... + +Fresh new summary +`; + expect(extractSummary(text)).toBe('Fresh new summary'); + }); + + it('should handle multiline summary content', () => { + const text = `First line +Second line +Third line`; + expect(extractSummary(text)).toBe('First line\nSecond line\nThird line'); + }); + + it('should trim whitespace from summary', () => { + const text = ' trimmed content '; + expect(extractSummary(text)).toBe('trimmed content'); + }); + }); + + describe('## Summary section (markdown)', () => { + it('should extract from ## Summary section', () => { + const text = ` +## Summary + +This is a summary paragraph. + +## Other Section +More content. +`; + expect(extractSummary(text)).toBe('This is a summary paragraph.'); + }); + + it('should truncate long summaries to 500 chars', () => { + const longContent = 'A'.repeat(600); + const text = ` +## Summary + +${longContent} + +## Next Section +`; + const result = extractSummary(text); + expect(result).not.toBeNull(); + expect(result!.length).toBeLessThanOrEqual(503); // 500 + '...' + expect(result!.endsWith('...')).toBe(true); + }); + + it('should use last match for ## Summary', () => { + const text = ` +## Summary + +Old summary content. + +## Summary + +New summary content. +`; + expect(extractSummary(text)).toBe('New summary content.'); + }); + + it('should stop at next markdown header', () => { + const text = ` +## Summary + +Summary content here. + +## Implementation +Implementation details. +`; + expect(extractSummary(text)).toBe('Summary content here.'); + }); + }); + + describe('**Goal**: section (lite planning mode)', () => { + it('should extract from **Goal**: section', () => { + const text = '**Goal**: Implement user authentication\n**Approach**: Use JWT'; + expect(extractSummary(text)).toBe('Implement user authentication'); + }); + + it('should use last match for **Goal**:', () => { + const text = ` +**Goal**: Old goal + +More output... + +**Goal**: New goal +`; + expect(extractSummary(text)).toBe('New goal'); + }); + + it('should handle inline goal', () => { + const text = '1. **Goal**: Add login functionality'; + expect(extractSummary(text)).toBe('Add login functionality'); + }); + }); + + describe('**Problem**: section (spec/full modes)', () => { + it('should extract from **Problem**: section', () => { + const text = ` +**Problem**: Users cannot log in to the application + +**Solution**: Add authentication +`; + expect(extractSummary(text)).toBe('Users cannot log in to the application'); + }); + + it('should extract from **Problem Statement**: section', () => { + const text = ` +**Problem Statement**: Users need password reset functionality + +1. Create reset endpoint +`; + expect(extractSummary(text)).toBe('Users need password reset functionality'); + }); + + it('should truncate long problem descriptions', () => { + const longProblem = 'X'.repeat(600); + const text = `**Problem**: ${longProblem}`; + const result = extractSummary(text); + expect(result).not.toBeNull(); + expect(result!.length).toBeLessThanOrEqual(503); + }); + }); + + describe('**Solution**: section (fallback)', () => { + it('should extract from **Solution**: section as fallback', () => { + const text = '**Solution**: Use JWT for authentication\n1. Install package'; + expect(extractSummary(text)).toBe('Use JWT for authentication'); + }); + + it('should truncate solution to 300 chars', () => { + const longSolution = 'Y'.repeat(400); + const text = `**Solution**: ${longSolution}`; + const result = extractSummary(text); + expect(result).not.toBeNull(); + expect(result!.length).toBeLessThanOrEqual(303); + }); + }); + + describe('priority order', () => { + it('should prefer over ## Summary', () => { + const text = ` +## Summary + +Markdown summary + +Tagged summary +`; + expect(extractSummary(text)).toBe('Tagged summary'); + }); + + it('should prefer ## Summary over **Goal**:', () => { + const text = ` +**Goal**: Goal content + +## Summary + +Summary section content. +`; + expect(extractSummary(text)).toBe('Summary section content.'); + }); + + it('should prefer **Goal**: over **Problem**:', () => { + const text = ` +**Problem**: Problem description + +**Goal**: Goal description +`; + expect(extractSummary(text)).toBe('Goal description'); + }); + + it('should prefer **Problem**: over **Solution**:', () => { + const text = ` +**Solution**: Solution description + +**Problem**: Problem description +`; + expect(extractSummary(text)).toBe('Problem description'); + }); + }); + + describe('edge cases', () => { + it('should return null for empty string', () => { + expect(extractSummary('')).toBeNull(); + }); + + it('should return null when no summary pattern found', () => { + expect(extractSummary('Random text without any summary patterns')).toBeNull(); + }); + + it('should handle multiple paragraph summaries (return first paragraph)', () => { + const text = ` +## Summary + +First paragraph of summary. + +Second paragraph of summary. + +## Other +`; + expect(extractSummary(text)).toBe('First paragraph of summary.'); + }); + }); + }); +});