import { describe, it, expect, vi, beforeEach } from 'vitest'; import { escapeXml, unescapeXml, extractXmlSection, extractXmlElements, extractImplementedFeatures, extractImplementedFeatureNames, featureToXml, featuresToXml, updateImplementedFeaturesSection, addImplementedFeature, removeImplementedFeature, updateImplementedFeature, hasImplementedFeature, toSpecOutputFeatures, fromSpecOutputFeatures, type ImplementedFeature, type XmlExtractorLogger, } from '@/lib/xml-extractor.js'; describe('xml-extractor.ts', () => { // Mock logger for testing custom logger functionality const createMockLogger = (): XmlExtractorLogger & { calls: string[] } => { const calls: string[] = []; return { calls, debug: vi.fn((msg: string) => calls.push(`debug: ${msg}`)), warn: vi.fn((msg: string) => calls.push(`warn: ${msg}`)), }; }; beforeEach(() => { vi.clearAllMocks(); }); describe('escapeXml', () => { it('should escape ampersand', () => { expect(escapeXml('foo & bar')).toBe('foo & bar'); }); it('should escape less than', () => { expect(escapeXml('a < b')).toBe('a < b'); }); it('should escape greater than', () => { expect(escapeXml('a > b')).toBe('a > b'); }); it('should escape double quotes', () => { expect(escapeXml('say "hello"')).toBe('say "hello"'); }); it('should escape single quotes', () => { expect(escapeXml("it's" + ' fine')).toBe('it's fine'); }); it('should handle null', () => { expect(escapeXml(null)).toBe(''); }); it('should handle undefined', () => { expect(escapeXml(undefined)).toBe(''); }); it('should handle empty string', () => { expect(escapeXml('')).toBe(''); }); it('should escape multiple special characters', () => { expect(escapeXml('a < b & c > d "e" \'f\'')).toBe( 'a < b & c > d "e" 'f'' ); }); }); describe('unescapeXml', () => { it('should unescape ampersand', () => { expect(unescapeXml('foo & bar')).toBe('foo & bar'); }); it('should unescape less than', () => { expect(unescapeXml('a < b')).toBe('a < b'); }); it('should unescape greater than', () => { expect(unescapeXml('a > b')).toBe('a > b'); }); it('should unescape double quotes', () => { expect(unescapeXml('say "hello"')).toBe('say "hello"'); }); it('should unescape single quotes', () => { expect(unescapeXml('it's fine')).toBe("it's fine"); }); it('should handle empty string', () => { expect(unescapeXml('')).toBe(''); }); it('should roundtrip with escapeXml', () => { const original = 'Test & "quoted" \'apostrophe\''; expect(unescapeXml(escapeXml(original))).toBe(original); }); }); describe('extractXmlSection', () => { it('should extract section content', () => { const xml = '
content here
'; expect(extractXmlSection(xml, 'section')).toBe('content here'); }); it('should extract multiline section content', () => { const xml = `
line 1 line 2
`; expect(extractXmlSection(xml, 'section')).toContain('line 1'); expect(extractXmlSection(xml, 'section')).toContain('line 2'); }); it('should return null for non-existent section', () => { const xml = 'content'; expect(extractXmlSection(xml, 'section')).toBeNull(); }); it('should be case-insensitive', () => { const xml = '
content
'; expect(extractXmlSection(xml, 'section')).toBe('content'); }); it('should handle empty section', () => { const xml = '
'; expect(extractXmlSection(xml, 'section')).toBe(''); }); }); describe('extractXmlElements', () => { it('should extract all element values', () => { const xml = 'onetwothree'; expect(extractXmlElements(xml, 'item')).toEqual(['one', 'two', 'three']); }); it('should return empty array for non-existent elements', () => { const xml = 'value'; expect(extractXmlElements(xml, 'item')).toEqual([]); }); it('should trim whitespace', () => { const xml = ' spaced '; expect(extractXmlElements(xml, 'item')).toEqual(['spaced']); }); it('should unescape XML entities', () => { const xml = 'foo & bar'; expect(extractXmlElements(xml, 'item')).toEqual(['foo & bar']); }); it('should handle empty elements', () => { const xml = 'value'; expect(extractXmlElements(xml, 'item')).toEqual(['', 'value']); }); }); describe('extractImplementedFeatures', () => { const sampleSpec = ` Test Project Feature One First feature description Feature Two Second feature description src/feature-two.ts src/utils/helper.ts `; it('should extract all features', () => { const features = extractImplementedFeatures(sampleSpec); expect(features).toHaveLength(2); }); it('should extract feature names', () => { const features = extractImplementedFeatures(sampleSpec); expect(features[0].name).toBe('Feature One'); expect(features[1].name).toBe('Feature Two'); }); it('should extract feature descriptions', () => { const features = extractImplementedFeatures(sampleSpec); expect(features[0].description).toBe('First feature description'); expect(features[1].description).toBe('Second feature description'); }); it('should extract file_locations when present', () => { const features = extractImplementedFeatures(sampleSpec); expect(features[0].file_locations).toBeUndefined(); expect(features[1].file_locations).toEqual(['src/feature-two.ts', 'src/utils/helper.ts']); }); it('should return empty array for missing section', () => { const xml = 'Test'; expect(extractImplementedFeatures(xml)).toEqual([]); }); it('should return empty array for empty section', () => { const xml = ` `; expect(extractImplementedFeatures(xml)).toEqual([]); }); it('should handle escaped content', () => { const xml = ` Test & Feature Uses <brackets> `; const features = extractImplementedFeatures(xml); expect(features[0].name).toBe('Test & Feature'); expect(features[0].description).toBe('Uses '); }); }); describe('extractImplementedFeatureNames', () => { it('should return only feature names', () => { const xml = ` Feature A Description A Feature B Description B `; expect(extractImplementedFeatureNames(xml)).toEqual(['Feature A', 'Feature B']); }); it('should return empty array for no features', () => { const xml = ''; expect(extractImplementedFeatureNames(xml)).toEqual([]); }); }); describe('featureToXml', () => { it('should generate XML for feature without file_locations', () => { const feature: ImplementedFeature = { name: 'My Feature', description: 'Feature description', }; const xml = featureToXml(feature); expect(xml).toContain('My Feature'); expect(xml).toContain('Feature description'); expect(xml).not.toContain(''); }); it('should generate XML for feature with file_locations', () => { const feature: ImplementedFeature = { name: 'My Feature', description: 'Feature description', file_locations: ['src/index.ts', 'src/utils.ts'], }; const xml = featureToXml(feature); expect(xml).toContain(''); expect(xml).toContain('src/index.ts'); expect(xml).toContain('src/utils.ts'); }); it('should escape special characters', () => { const feature: ImplementedFeature = { name: 'Test & Feature', description: 'Has ', }; const xml = featureToXml(feature); expect(xml).toContain('Test & Feature'); expect(xml).toContain('Has <tags>'); }); it('should not include empty file_locations array', () => { const feature: ImplementedFeature = { name: 'Feature', description: 'Desc', file_locations: [], }; const xml = featureToXml(feature); expect(xml).not.toContain(''); }); }); describe('featuresToXml', () => { it('should generate XML for multiple features', () => { const features: ImplementedFeature[] = [ { name: 'Feature 1', description: 'Desc 1' }, { name: 'Feature 2', description: 'Desc 2' }, ]; const xml = featuresToXml(features); expect(xml).toContain('Feature 1'); expect(xml).toContain('Feature 2'); }); it('should handle empty array', () => { expect(featuresToXml([])).toBe(''); }); }); describe('updateImplementedFeaturesSection', () => { const baseSpec = ` Test Testing Old Feature Old description `; it('should replace existing section', () => { const newFeatures: ImplementedFeature[] = [ { name: 'New Feature', description: 'New description' }, ]; const result = updateImplementedFeaturesSection(baseSpec, newFeatures); expect(result).toContain('New Feature'); expect(result).not.toContain('Old Feature'); }); it('should insert section after core_capabilities if missing', () => { const specWithoutSection = ` Test Testing `; const newFeatures: ImplementedFeature[] = [ { name: 'New Feature', description: 'New description' }, ]; const result = updateImplementedFeaturesSection(specWithoutSection, newFeatures); expect(result).toContain(''); expect(result).toContain('New Feature'); }); it('should handle multiple features', () => { const newFeatures: ImplementedFeature[] = [ { name: 'Feature A', description: 'Desc A' }, { name: 'Feature B', description: 'Desc B', file_locations: ['src/b.ts'] }, ]; const result = updateImplementedFeaturesSection(baseSpec, newFeatures); expect(result).toContain('Feature A'); expect(result).toContain('Feature B'); expect(result).toContain('src/b.ts'); }); }); describe('addImplementedFeature', () => { const baseSpec = ` Existing Feature Existing description `; it('should add new feature', () => { const newFeature: ImplementedFeature = { name: 'New Feature', description: 'New description', }; const result = addImplementedFeature(baseSpec, newFeature); expect(result).toContain('Existing Feature'); expect(result).toContain('New Feature'); }); it('should not add duplicate feature', () => { const duplicate: ImplementedFeature = { name: 'Existing Feature', description: 'Different description', }; const result = addImplementedFeature(baseSpec, duplicate); // Should still have only one instance const matches = result.match(/Existing Feature/g); expect(matches).toHaveLength(1); }); it('should be case-insensitive for duplicates', () => { const duplicate: ImplementedFeature = { name: 'EXISTING FEATURE', description: 'Different description', }; const result = addImplementedFeature(baseSpec, duplicate); expect(result).not.toContain('EXISTING FEATURE'); }); }); describe('removeImplementedFeature', () => { const baseSpec = ` Feature A Description A Feature B Description B `; it('should remove feature by name', () => { const result = removeImplementedFeature(baseSpec, 'Feature A'); expect(result).not.toContain('Feature A'); expect(result).toContain('Feature B'); }); it('should be case-insensitive', () => { const result = removeImplementedFeature(baseSpec, 'feature a'); expect(result).not.toContain('Feature A'); expect(result).toContain('Feature B'); }); it('should return unchanged content if feature not found', () => { const result = removeImplementedFeature(baseSpec, 'Nonexistent'); expect(result).toContain('Feature A'); expect(result).toContain('Feature B'); }); }); describe('updateImplementedFeature', () => { const baseSpec = ` My Feature Original description `; it('should update feature description', () => { const result = updateImplementedFeature(baseSpec, 'My Feature', { description: 'Updated description', }); expect(result).toContain('Updated description'); expect(result).not.toContain('Original description'); }); it('should add file_locations', () => { const result = updateImplementedFeature(baseSpec, 'My Feature', { file_locations: ['src/new.ts'], }); expect(result).toContain(''); expect(result).toContain('src/new.ts'); }); it('should preserve feature name if not updated', () => { const result = updateImplementedFeature(baseSpec, 'My Feature', { description: 'New desc', }); expect(result).toContain('My Feature'); }); it('should be case-insensitive', () => { const result = updateImplementedFeature(baseSpec, 'my feature', { description: 'Updated', }); expect(result).toContain('Updated'); }); it('should return unchanged content if feature not found', () => { const result = updateImplementedFeature(baseSpec, 'Nonexistent', { description: 'New', }); expect(result).toContain('Original description'); }); }); describe('hasImplementedFeature', () => { const baseSpec = ` Existing Feature Description `; it('should return true for existing feature', () => { expect(hasImplementedFeature(baseSpec, 'Existing Feature')).toBe(true); }); it('should return false for non-existing feature', () => { expect(hasImplementedFeature(baseSpec, 'Nonexistent')).toBe(false); }); it('should be case-insensitive', () => { expect(hasImplementedFeature(baseSpec, 'existing feature')).toBe(true); expect(hasImplementedFeature(baseSpec, 'EXISTING FEATURE')).toBe(true); }); }); describe('toSpecOutputFeatures', () => { it('should convert to SpecOutput format', () => { const features: ImplementedFeature[] = [ { name: 'Feature 1', description: 'Desc 1' }, { name: 'Feature 2', description: 'Desc 2', file_locations: ['src/f2.ts'] }, ]; const result = toSpecOutputFeatures(features); expect(result).toEqual([ { name: 'Feature 1', description: 'Desc 1' }, { name: 'Feature 2', description: 'Desc 2', file_locations: ['src/f2.ts'] }, ]); }); it('should handle empty array', () => { expect(toSpecOutputFeatures([])).toEqual([]); }); }); describe('fromSpecOutputFeatures', () => { it('should convert from SpecOutput format', () => { const specFeatures = [ { name: 'Feature 1', description: 'Desc 1' }, { name: 'Feature 2', description: 'Desc 2', file_locations: ['src/f2.ts'] }, ]; const result = fromSpecOutputFeatures(specFeatures); expect(result).toEqual([ { name: 'Feature 1', description: 'Desc 1' }, { name: 'Feature 2', description: 'Desc 2', file_locations: ['src/f2.ts'] }, ]); }); it('should handle empty array', () => { expect(fromSpecOutputFeatures([])).toEqual([]); }); }); describe('roundtrip', () => { it('should maintain data integrity through extract -> update cycle', () => { const originalSpec = ` Test Testing Test & Feature Uses <special> chars src/test.ts `; // Extract features const features = extractImplementedFeatures(originalSpec); expect(features[0].name).toBe('Test & Feature'); expect(features[0].description).toBe('Uses chars'); // Update with same features const result = updateImplementedFeaturesSection(originalSpec, features); // Re-extract and verify const reExtracted = extractImplementedFeatures(result); expect(reExtracted[0].name).toBe('Test & Feature'); expect(reExtracted[0].description).toBe('Uses chars'); expect(reExtracted[0].file_locations).toEqual(['src/test.ts']); }); }); describe('custom logger', () => { it('should use custom logger for extractXmlSection', () => { const mockLogger = createMockLogger(); const xml = '
content
'; extractXmlSection(xml, 'section', { logger: mockLogger }); expect(mockLogger.debug).toHaveBeenCalledWith('Extracted
section'); }); it('should log when section is not found', () => { const mockLogger = createMockLogger(); const xml = 'content'; extractXmlSection(xml, 'missing', { logger: mockLogger }); expect(mockLogger.debug).toHaveBeenCalledWith('Section not found'); }); it('should use custom logger for extractXmlElements', () => { const mockLogger = createMockLogger(); const xml = 'onetwo'; extractXmlElements(xml, 'item', { logger: mockLogger }); expect(mockLogger.debug).toHaveBeenCalledWith('Extracted 2 elements'); }); it('should use custom logger for extractImplementedFeatures', () => { const mockLogger = createMockLogger(); const xml = ` Test Desc `; extractImplementedFeatures(xml, { logger: mockLogger }); expect(mockLogger.debug).toHaveBeenCalledWith('Extracted 1 implemented features'); }); it('should log when no implemented_features section found', () => { const mockLogger = createMockLogger(); const xml = 'content'; extractImplementedFeatures(xml, { logger: mockLogger }); expect(mockLogger.debug).toHaveBeenCalledWith('No implemented_features section found'); }); it('should use custom logger warn for missing insertion point', () => { const mockLogger = createMockLogger(); // XML without project_specification, core_capabilities, or implemented_features const xml = 'content'; const features: ImplementedFeature[] = [{ name: 'Test', description: 'Desc' }]; updateImplementedFeaturesSection(xml, features, { logger: mockLogger }); expect(mockLogger.warn).toHaveBeenCalledWith( 'Could not find appropriate insertion point for implemented_features' ); }); }); describe('edge cases', () => { describe('escapeXml edge cases', () => { it('should handle strings with only special characters', () => { expect(escapeXml('<>&"\'')).toBe('<>&"''); }); it('should handle very long strings', () => { const longString = 'a'.repeat(10000) + '&' + 'b'.repeat(10000); const escaped = escapeXml(longString); expect(escaped).toContain('&'); expect(escaped.length).toBe(20005); // +4 for & minus & }); it('should handle unicode characters without escaping', () => { const unicode = '日本語 emoji: 🚀 symbols: ∞ ≠ ≤'; expect(escapeXml(unicode)).toBe(unicode); }); }); describe('unescapeXml edge cases', () => { it('should handle strings with only entities', () => { expect(unescapeXml('<>&"'')).toBe('<>&"\''); }); it('should not double-unescape', () => { // &lt; should become < (not <) expect(unescapeXml('&lt;')).toBe('<'); }); it('should handle partial/invalid entities gracefully', () => { // Invalid entities should pass through unchanged expect(unescapeXml('&unknown;')).toBe('&unknown;'); expect(unescapeXml('&')).toBe('&'); // Missing semicolon }); }); describe('extractXmlSection edge cases', () => { it('should handle nested tags with same name', () => { // Note: regex-based parsing with non-greedy matching will match // from first opening tag to first closing tag const xml = 'inner'; // Non-greedy [\s\S]*? matches from first to first expect(extractXmlSection(xml, 'outer')).toBe('inner'); }); it('should handle self-closing tags (returns null)', () => { const xml = '
'; // Regex expects content between tags, self-closing won't match expect(extractXmlSection(xml, 'section')).toBeNull(); }); it('should handle tags with attributes', () => { const xml = '
content
'; // The regex matches exact tag names, so this won't match expect(extractXmlSection(xml, 'section')).toBeNull(); }); it('should handle whitespace in tag content', () => { const xml = '
\n\t
'; expect(extractXmlSection(xml, 'section')).toBe(' \n\t '); }); }); describe('extractXmlElements edge cases', () => { it('should handle elements across multiple lines', () => { const xml = ` first second `; // Multiline content is now captured with [\s\S]*? pattern const result = extractXmlElements(xml, 'item'); expect(result).toHaveLength(2); expect(result[0]).toBe('first'); expect(result[1]).toBe('second'); }); it('should handle consecutive elements without whitespace', () => { const xml = 'abc'; expect(extractXmlElements(xml, 'item')).toEqual(['a', 'b', 'c']); }); }); describe('extractImplementedFeatures edge cases', () => { it('should skip features without names', () => { const xml = ` Orphan description Valid Feature Has name `; const features = extractImplementedFeatures(xml); expect(features).toHaveLength(1); expect(features[0].name).toBe('Valid Feature'); }); it('should handle features with empty names', () => { const xml = ` Empty name `; const features = extractImplementedFeatures(xml); expect(features).toHaveLength(0); // Empty name is falsy }); it('should handle features with whitespace-only names', () => { const xml = ` Whitespace name `; const features = extractImplementedFeatures(xml); expect(features).toHaveLength(0); // Trimmed whitespace is empty }); it('should handle empty file_locations section', () => { const xml = ` Test Desc `; const features = extractImplementedFeatures(xml); expect(features[0].file_locations).toBeUndefined(); }); }); describe('featureToXml edge cases', () => { it('should handle custom indentation', () => { const feature: ImplementedFeature = { name: 'Test', description: 'Desc', }; const xml = featureToXml(feature, '\t'); expect(xml).toContain('\t\t'); expect(xml).toContain('\t\t\tTest'); }); it('should handle empty description', () => { const feature: ImplementedFeature = { name: 'Test', description: '', }; const xml = featureToXml(feature); expect(xml).toContain(''); }); it('should handle undefined file_locations', () => { const feature: ImplementedFeature = { name: 'Test', description: 'Desc', file_locations: undefined, }; const xml = featureToXml(feature); expect(xml).not.toContain('file_locations'); }); }); describe('updateImplementedFeaturesSection edge cases', () => { it('should insert before as fallback', () => { const specWithoutCoreCapabilities = ` Test `; const newFeatures: ImplementedFeature[] = [ { name: 'New Feature', description: 'New description' }, ]; const result = updateImplementedFeaturesSection(specWithoutCoreCapabilities, newFeatures); expect(result).toContain(''); expect(result).toContain('New Feature'); expect(result.indexOf('')).toBeLessThan( result.indexOf('') ); }); it('should return unchanged content when no insertion point found', () => { const invalidSpec = 'content'; const newFeatures: ImplementedFeature[] = [{ name: 'Feature', description: 'Desc' }]; const result = updateImplementedFeaturesSection(invalidSpec, newFeatures); expect(result).toBe(invalidSpec); }); it('should handle empty features array', () => { const spec = ` Old Old desc `; const result = updateImplementedFeaturesSection(spec, []); expect(result).toContain(''); expect(result).not.toContain('Old'); }); }); describe('addImplementedFeature edge cases', () => { it('should create section when adding to spec without implemented_features', () => { const specWithoutSection = ` Testing `; const newFeature: ImplementedFeature = { name: 'First Feature', description: 'First description', }; const result = addImplementedFeature(specWithoutSection, newFeature); expect(result).toContain(''); expect(result).toContain('First Feature'); }); it('should handle feature with all fields populated', () => { const spec = ``; const newFeature: ImplementedFeature = { name: 'Complete Feature', description: 'Full description', file_locations: ['src/a.ts', 'src/b.ts', 'src/c.ts'], }; const result = addImplementedFeature(spec, newFeature); expect(result).toContain('Complete Feature'); expect(result).toContain('src/a.ts'); expect(result).toContain('src/b.ts'); expect(result).toContain('src/c.ts'); }); }); describe('updateImplementedFeature edge cases', () => { it('should allow updating feature name', () => { const spec = ` Old Name Desc `; const result = updateImplementedFeature(spec, 'Old Name', { name: 'New Name', }); expect(result).toContain('New Name'); expect(result).not.toContain('Old Name'); }); it('should allow clearing file_locations', () => { const spec = ` Test Desc src/old.ts `; const result = updateImplementedFeature(spec, 'Test', { file_locations: [], }); expect(result).not.toContain('file_locations'); expect(result).not.toContain('src/old.ts'); }); it('should handle updating multiple fields at once', () => { const spec = ` Original Original desc `; const result = updateImplementedFeature(spec, 'Original', { name: 'Updated', description: 'Updated desc', file_locations: ['new/path.ts'], }); expect(result).toContain('Updated'); expect(result).toContain('Updated desc'); expect(result).toContain('new/path.ts'); }); }); describe('toSpecOutputFeatures and fromSpecOutputFeatures edge cases', () => { it('should handle features with empty file_locations array', () => { const features: ImplementedFeature[] = [ { name: 'Test', description: 'Desc', file_locations: [] }, ]; const specOutput = toSpecOutputFeatures(features); expect(specOutput[0].file_locations).toBeUndefined(); }); it('should handle round-trip conversion', () => { const original: ImplementedFeature[] = [ { name: 'Feature 1', description: 'Desc 1' }, { name: 'Feature 2', description: 'Desc 2', file_locations: ['src/f.ts'] }, ]; const specOutput = toSpecOutputFeatures(original); const restored = fromSpecOutputFeatures(specOutput); expect(restored).toEqual(original); }); }); }); describe('integration scenarios', () => { it('should handle a complete spec file workflow', () => { // Start with a minimal spec let spec = ` My App User management `; // Add first feature spec = addImplementedFeature(spec, { name: 'User Authentication', description: 'Login and logout functionality', file_locations: ['src/auth/login.ts', 'src/auth/logout.ts'], }); expect(hasImplementedFeature(spec, 'User Authentication')).toBe(true); // Add second feature spec = addImplementedFeature(spec, { name: 'User Profile', description: 'View and edit user profile', }); expect(extractImplementedFeatureNames(spec)).toEqual(['User Authentication', 'User Profile']); // Update first feature spec = updateImplementedFeature(spec, 'User Authentication', { file_locations: ['src/auth/login.ts', 'src/auth/logout.ts', 'src/auth/session.ts'], }); const features = extractImplementedFeatures(spec); expect(features[0].file_locations).toContain('src/auth/session.ts'); // Remove a feature spec = removeImplementedFeature(spec, 'User Profile'); expect(hasImplementedFeature(spec, 'User Profile')).toBe(false); expect(hasImplementedFeature(spec, 'User Authentication')).toBe(true); }); it('should handle special characters throughout workflow', () => { const spec = ` `; const result = addImplementedFeature(spec, { name: 'Search & Filter', description: 'Supports syntax with "quoted" terms', file_locations: ["src/search/parser's.ts"], }); const features = extractImplementedFeatures(result); expect(features[0].name).toBe('Search & Filter'); expect(features[0].description).toBe('Supports syntax with "quoted" terms'); expect(features[0].file_locations?.[0]).toBe("src/search/parser's.ts"); }); it('should preserve other XML content when modifying features', () => { const spec = ` Preserved Name This should be preserved Capability 1 Capability 2 Old Feature Will be replaced Keep this too `; const result = updateImplementedFeaturesSection(spec, [ { name: 'New Feature', description: 'New desc' }, ]); expect(result).toContain('Preserved Name'); expect(result).toContain('This should be preserved'); expect(result).toContain('Capability 1'); expect(result).toContain('Capability 2'); expect(result).toContain('Keep this too'); expect(result).not.toContain('Old Feature'); expect(result).toContain('New Feature'); }); }); });