diff --git a/markitect/static/js/core/section-manager.js b/markitect/static/js/core/section-manager.js new file mode 100644 index 00000000..291b8e69 --- /dev/null +++ b/markitect/static/js/core/section-manager.js @@ -0,0 +1,532 @@ +/** + * SectionManager Component + * + * Extracted from monolithic editor.js as part of architecture refactoring. + * Manages the collection of sections and their state transitions. + * + * Dependencies: + * - EditState enum (imported) + * - SectionType enum (imported) + * - Section class (imported) + * - debug function (imported) + */ + +// Import dependencies - these will be separate modules +const EditState = Object.freeze({ + ORIGINAL: 'original', + EDITING: 'editing', + MODIFIED: 'modified', + SAVED: 'saved' +}); + +const SectionType = Object.freeze({ + HEADING: 'heading', + PARAGRAPH: 'paragraph', + LIST: 'list', + CODE: 'code', + QUOTE: 'quote', + TABLE: 'table', + HR: 'hr', + IMAGE: 'image' +}); + +// Debug function (will be extracted to utils) +function debug(message, category = 'INFO') { + // Simple console debug for now - will be enhanced later + console.log(`DEBUG ${category}: ${message}`); +} + +/** + * Section Class - manages individual section state and content + */ +class Section { + constructor(id, markdown, type) { + this.id = id; + this.originalMarkdown = markdown; + this.currentMarkdown = markdown; + this.editingMarkdown = markdown; + this.pendingMarkdown = null; + this.type = type; + this.state = EditState.ORIGINAL; + this.domElement = null; + this.lastSaved = null; + this.created = new Date(); + } + + static generateId(markdown, position, strategy = 'hash', parentId = null) { + return this.generateIdWithStrategy(markdown, position, strategy, parentId); + } + + static generateIdWithStrategy(markdown, position, strategy = 'hash', parentId = null) { + const sanitizedContent = this.sanitizeContentForId(markdown); + const normalizedContent = this.normalizeContentForHashing(sanitizedContent); + const sectionType = this.detectType(markdown); + + switch (strategy) { + case 'timestamp': + return this.generateTimestampId(normalizedContent, position, sectionType); + case 'sequential': + return this.generateSequentialId(normalizedContent, position, sectionType); + case 'hierarchical': + return this.generateHierarchicalId(normalizedContent, position, parentId); + case 'hash': + default: + return this.generateAdvancedId(normalizedContent, position, sectionType); + } + } + + static generateAdvancedId(content, position, sectionType) { + const contentHash = this.generateCryptoHash(content); + const safeType = sectionType || 'paragraph'; + const typePrefix = safeType.substring(0, 3); + const positionHex = position.toString(16).padStart(2, '0'); + + return `section-${typePrefix}-${contentHash}-${positionHex}`; + } + + static generateCryptoHash(content) { + let hash = 0; + if (content.length === 0) return '00000000'; + + for (let i = 0; i < content.length; i++) { + const char = content.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; + } + + const hexHash = Math.abs(hash).toString(16).padStart(8, '0'); + return hexHash.substring(0, 8); + } + + static normalizeContentForHashing(content) { + if (!content || typeof content !== 'string') { + return ''; + } + + return content + .trim() + .replace(/\s+/g, ' ') + .replace(/\r\n/g, '\n') + .toLowerCase(); + } + + static sanitizeContentForId(content) { + if (!content || typeof content !== 'string') { + return ''; + } + + return content + .replace(/<[^>]*>/g, '') + .replace(/javascript:/gi, '') + .replace(/[^\w\s\-_.#]/g, '') + .trim(); + } + + static generateTimestampId(content, position = 0, sectionType = 'paragraph') { + const timestamp = Date.now().toString(36); + const contentSnippet = this.generateCryptoHash(content || '').substring(0, 4); + const safeType = sectionType || 'paragraph'; + const typePrefix = safeType.substring(0, 3); + + return `section-${typePrefix}-${contentSnippet}-${timestamp}`; + } + + static generateSequentialId(content, position, sectionType = 'paragraph') { + const safeType = sectionType || 'paragraph'; + const typePrefix = safeType.substring(0, 3); + const seqNumber = (position || 0).toString().padStart(3, '0'); + const contentHash = this.generateCryptoHash(content || '').substring(0, 4); + + return `section-${typePrefix}-seq${seqNumber}-${contentHash}`; + } + + static generateHierarchicalId(content, position, parentId = null) { + const contentHash = this.generateCryptoHash(content || '').substring(0, 6); + + if (parentId) { + const childIndex = (position || 0).toString().padStart(2, '0'); + return `${parentId}-child-${childIndex}-${contentHash}`; + } else { + return `section-root-${position || 0}-${contentHash}`; + } + } + + static detectType(markdown) { + if (!markdown || typeof markdown !== 'string') { + return SectionType.PARAGRAPH; + } + + const content = markdown.replace(/^\n+|\n+$/g, ''); + if (!content) { + return SectionType.PARAGRAPH; + } + + const trimmed = content.trim(); + + // Detection order matters - most specific first + if (this.isHeading(trimmed)) { + return SectionType.HEADING; + } + + if (this.isImage(trimmed)) { + return SectionType.IMAGE; + } + + if (this.isCodeBlock(trimmed)) { + return SectionType.CODE; + } + + return SectionType.PARAGRAPH; + } + + static isHeading(trimmed) { + const headingPattern = /^#{1,6}\s+.+/; + return headingPattern.test(trimmed); + } + + static isImage(trimmed) { + const imagePattern = /!\[.*?\]\([^)]+\)/; + return imagePattern.test(trimmed); + } + + static isCodeBlock(trimmed) { + if (trimmed.startsWith('```') || trimmed.startsWith('~~~')) { + return true; + } + if (trimmed.includes('```') || trimmed.includes('~~~')) { + const codeBlockPattern = /```[\s\S]*?```|~~~[\s\S]*?~~~/; + if (codeBlockPattern.test(trimmed)) { + return true; + } + } + return false; + } + + startEdit() { + if (this.state === EditState.EDITING) { + throw new Error(`Section ${this.id} is already being edited`); + } + this.editingMarkdown = this.pendingMarkdown || this.currentMarkdown; + this.state = EditState.EDITING; + return this.editingMarkdown; + } + + updateContent(markdown) { + if (this.state !== EditState.EDITING) { + throw new Error(`Section ${this.id} is not in editing state`); + } + this.editingMarkdown = markdown; + } + + acceptChanges() { + if (this.state !== EditState.EDITING) { + throw new Error(`Section ${this.id} is not in editing state`); + } + this.currentMarkdown = this.editingMarkdown; + this.editingMarkdown = null; + this.pendingMarkdown = null; + this.state = EditState.SAVED; + this.lastSaved = new Date(); + return this.currentMarkdown; + } + + cancelChanges() { + if (this.state !== EditState.EDITING) { + throw new Error(`Section ${this.id} is not in editing state`); + } + this.editingMarkdown = null; + if (this.pendingMarkdown !== null) { + this.state = EditState.MODIFIED; + return this.pendingMarkdown; + } else if (this.lastSaved !== null) { + this.state = EditState.SAVED; + return this.currentMarkdown; + } else { + this.state = this.hasChanges() ? EditState.MODIFIED : EditState.ORIGINAL; + return this.currentMarkdown; + } + } + + stopEditing() { + if (this.state !== EditState.EDITING) { + return this.state; + } + + if (this.editingMarkdown && this.editingMarkdown !== this.currentMarkdown) { + this.pendingMarkdown = this.editingMarkdown; + this.state = EditState.MODIFIED; + } else { + this.pendingMarkdown = null; + if (this.lastSaved !== null) { + this.state = EditState.SAVED; + } else { + this.state = this.hasChanges() ? EditState.MODIFIED : EditState.ORIGINAL; + } + } + + this.editingMarkdown = null; + return this.state; + } + + resetToOriginal() { + this.currentMarkdown = this.originalMarkdown; + this.editingMarkdown = this.originalMarkdown; + this.pendingMarkdown = null; + this.state = EditState.ORIGINAL; + return this.originalMarkdown; + } + + isEditing() { + return this.state === EditState.EDITING; + } + + hasChanges() { + return this.currentMarkdown !== this.originalMarkdown; + } + + getStatus() { + return { + id: this.id, + state: this.state, + hasChanges: this.hasChanges(), + isEditing: this.isEditing(), + contentLength: this.currentMarkdown.length, + lastSaved: this.lastSaved, + type: this.type, + originalLength: this.originalMarkdown.length, + currentLength: this.currentMarkdown.length + }; + } + + isImage() { + return this.type === SectionType.IMAGE; + } + + redetectType(content = null) { + const markdown = content || this.currentMarkdown; + const oldType = this.type; + this.type = Section.detectType(markdown); + + if (oldType !== this.type) { + debug(`Section ${this.id} type changed from ${oldType} to ${this.type}`, 'TYPE_DETECTION'); + } + + return this.type; + } +} + +/** + * SectionManager - Manages the collection of sections + */ +class SectionManager { + constructor() { + this.sections = new Map(); + this.listeners = new Map(); + this.statusInterval = null; + this.lastStatusUpdate = new Date().toISOString(); + } + + on(event, callback) { + if (!this.listeners.has(event)) { + this.listeners.set(event, []); + } + this.listeners.get(event).push(callback); + } + + emit(event, data) { + if (this.listeners.has(event)) { + this.listeners.get(event).forEach(callback => callback(data)); + } + } + + createSectionsFromMarkdown(markdownContent) { + const lines = markdownContent.split('\n'); + const sections = []; + let currentSection = ''; + let position = 0; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const isHeading = /^#{1,6}\s/.test(line); + const isNewParagraph = line.trim() && i > 0 && !lines[i-1].trim(); + const isNewSection = isHeading || isNewParagraph; + + if (isNewSection && currentSection.trim()) { + const sectionId = Section.generateId(currentSection, position); + const sectionType = Section.detectType(currentSection); + const section = new Section(sectionId, currentSection.trim(), sectionType); + sections.push(section); + this.sections.set(sectionId, section); + position++; + currentSection = line; + } else { + if (currentSection) currentSection += '\n'; + currentSection += line; + } + } + + if (currentSection.trim()) { + const sectionId = Section.generateId(currentSection, position); + const sectionType = Section.detectType(currentSection); + const section = new Section(sectionId, currentSection.trim(), sectionType); + sections.push(section); + this.sections.set(sectionId, section); + } + + this.emit('sections-created', { sections, count: sections.length }); + return sections; + } + + startEditing(sectionId) { + debug('MANAGER: startEditing called for: ' + sectionId, 'MANAGER'); + + const section = this.sections.get(sectionId); + if (!section) { + throw new Error(`Section ${sectionId} not found`); + } + + if (section.isEditing()) { + debug('MANAGER: Section already in editing state: ' + sectionId, 'MANAGER'); + return section.editingMarkdown; + } + + debug('MANAGER: Starting edit for section: ' + sectionId, 'MANAGER'); + const content = section.startEdit(); + + debug('MANAGER: About to emit edit-started event for: ' + sectionId, 'MANAGER'); + this.emit('edit-started', { sectionId, content, section: section.getStatus() }); + debug('MANAGER: Emitted edit-started event for: ' + sectionId, 'MANAGER'); + + return content; + } + + updateContent(sectionId, markdown) { + const section = this.sections.get(sectionId); + if (!section) { + throw new Error(`Section ${sectionId} not found`); + } + + const oldType = section.type; + section.updateContent(markdown); + const newType = section.redetectType(markdown); + + const eventData = { + sectionId, + markdown, + section: section.getStatus(), + typeChanged: oldType !== newType, + oldType, + newType + }; + + this.emit('content-updated', eventData); + + if (oldType !== newType) { + this.emit('section-type-changed', { + sectionId, + oldType, + newType, + section: section.getStatus() + }); + } + } + + acceptChanges(sectionId) { + const section = this.sections.get(sectionId); + if (!section) { + throw new Error(`Section ${sectionId} not found`); + } + + const content = section.acceptChanges(); + this.emit('changes-accepted', { sectionId, content, section: section.getStatus() }); + return content; + } + + cancelChanges(sectionId) { + const section = this.sections.get(sectionId); + if (!section) { + throw new Error(`Section ${sectionId} not found`); + } + + const content = section.cancelChanges(); + this.emit('changes-cancelled', { sectionId, content, section: section.getStatus() }); + return content; + } + + resetSection(sectionId) { + const section = this.sections.get(sectionId); + if (!section) { + throw new Error(`Section ${sectionId} not found`); + } + + const content = section.resetToOriginal(); + this.emit('section-reset', { sectionId, content, section: section.getStatus() }); + return content; + } + + getDocumentMarkdown() { + const sortedSections = Array.from(this.sections.values()) + .sort((a, b) => a.created - b.created); + + return sortedSections.map(section => section.currentMarkdown).join('\n\n'); + } + + getAllSections() { + return Array.from(this.sections.values()); + } + + getDocumentStatus() { + const sections = Array.from(this.sections.values()); + const editingSections = sections.filter(section => section.isEditing).length; + + return { + totalSections: sections.length, + editingSections: editingSections + }; + } + + extractHeadings(content) { + if (!content) return []; + const lines = content.split('\n'); + return lines.filter(line => /^#{1,6}\s/.test(line.trim())); + } + + handleSectionSplit(sectionId, newContent) { + const section = this.sections.get(sectionId); + if (!section) { + throw new Error(`Section ${sectionId} not found`); + } + + // Remove the original section + this.sections.delete(sectionId); + + // Create new sections from the content + const newSections = this.createSectionsFromMarkdown(newContent); + + // Emit section-split event + this.emit('section-split', { + originalSectionId: sectionId, + newSections: newSections, + count: newSections.length + }); + + return newSections; + } + + createSectionsFromContent(content) { + return this.createSectionsFromMarkdown(content); + } +} + +// Export for use in tests and other modules +if (typeof module !== 'undefined' && module.exports) { + module.exports = { SectionManager, Section, EditState, SectionType }; +} + +// Export for browser use +if (typeof window !== 'undefined') { + window.SectionManager = SectionManager; + window.Section = Section; + window.EditState = EditState; + window.SectionType = SectionType; +} \ No newline at end of file diff --git a/markitect/static/js/tests/refactor-test-runner.js b/markitect/static/js/tests/refactor-test-runner.js new file mode 100644 index 00000000..ecc97529 --- /dev/null +++ b/markitect/static/js/tests/refactor-test-runner.js @@ -0,0 +1,216 @@ +#!/usr/bin/env node + +/** + * TDD Test Runner for JavaScript Refactoring + * + * Drives component extraction and testing during architecture refactoring. + * Ensures all functionality remains stable while achieving separation of concerns. + */ + +class RefactorTestRunner { + constructor() { + this.tests = []; + this.passed = 0; + this.failed = 0; + this.currentSuite = null; + this.setupDOM(); + } + + setupDOM() { + // Set up minimal DOM environment for testing + if (typeof document === 'undefined') { + const { JSDOM } = require('jsdom'); + const dom = new JSDOM('
', { + url: 'http://localhost', + pretendToBeVisual: true, + resources: 'usable' + }); + + global.window = dom.window; + global.document = dom.window.document; + global.HTMLElement = dom.window.HTMLElement; + global.Event = dom.window.Event; + global.CustomEvent = dom.window.CustomEvent; + + // Only set navigator if it doesn't exist + if (typeof global.navigator === 'undefined') { + global.navigator = dom.window.navigator; + } + } + } + + describe(suiteName, fn) { + console.log(`\n๐ ${suiteName}`); + this.currentSuite = suiteName; + fn(); + this.currentSuite = null; + } + + it(testName, fn) { + const fullName = this.currentSuite ? `${this.currentSuite}: ${testName}` : testName; + + try { + fn(); + console.log(` โ ${testName}`); + this.passed++; + } catch (error) { + console.log(` โ ${testName}`); + console.log(` Error: ${error.message}`); + if (error.stack) { + console.log(` Stack: ${error.stack.split('\n')[1]?.trim()}`); + } + this.failed++; + } + } + + expect(actual) { + return { + toBe: (expected) => { + if (actual !== expected) { + throw new Error(`Expected ${expected}, got ${actual}`); + } + }, + toBeTruthy: () => { + if (!actual) { + throw new Error(`Expected truthy value, got ${actual}`); + } + }, + toBeFalsy: () => { + if (actual) { + throw new Error(`Expected falsy value, got ${actual}`); + } + }, + toEqual: (expected) => { + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error(`Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + } + }, + toContain: (expected) => { + if (!actual.includes(expected)) { + throw new Error(`Expected ${actual} to contain ${expected}`); + } + }, + toHaveProperty: (property) => { + if (!(property in actual)) { + throw new Error(`Expected object to have property ${property}`); + } + }, + toBeInstanceOf: (expectedClass) => { + if (!(actual instanceof expectedClass)) { + throw new Error(`Expected instance of ${expectedClass.name}, got ${actual.constructor.name}`); + } + } + }; + } + + /** + * Test that a component can be extracted from the monolith without breaking functionality + */ + testComponentExtraction(componentName, extractFn, originalTests) { + this.describe(`Component Extraction: ${componentName}`, () => { + this.it('should extract without syntax errors', () => { + try { + const component = extractFn(); + this.expect(component).toBeTruthy(); + } catch (error) { + throw new Error(`Component extraction failed: ${error.message}`); + } + }); + + this.it('should maintain original API', () => { + const component = extractFn(); + originalTests.forEach(test => { + try { + test(component); + } catch (error) { + throw new Error(`API compatibility test failed: ${error.message}`); + } + }); + }); + }); + } + + /** + * Test component integration after extraction + */ + testComponentIntegration(components, integrationTests) { + this.describe('Component Integration', () => { + integrationTests.forEach((test, index) => { + this.it(`integration test ${index + 1}`, () => { + test(components); + }); + }); + }); + } + + /** + * Setup test environment with mock dependencies + */ + setupTestEnvironment() { + // Create test container + const container = document.createElement('div'); + container.id = 'test-container'; + container.innerHTML = ''; + document.body.appendChild(container); + + // Mock any global dependencies + global.mockSectionManager = { + sections: new Map(), + createSectionsFromMarkdown: () => [], + startEditing: () => true, + stopEditing: () => true, + getAllSections: () => [] + }; + + return { container }; + } + + /** + * Cleanup test environment + */ + cleanupTestEnvironment() { + const container = document.getElementById('test-container'); + if (container) { + container.remove(); + } + + // Clear any global mocks + delete global.mockSectionManager; + } + + async run() { + console.log('๐งช TDD Refactoring Test Runner Starting...\n'); + + const startTime = Date.now(); + + // Run all collected tests + // Tests will be added by importing component test files + + const endTime = Date.now(); + const duration = endTime - startTime; + + console.log(`\n๐ Test Results:`); + console.log(` โ Passed: ${this.passed}`); + console.log(` โ Failed: ${this.failed}`); + console.log(` โฑ๏ธ Duration: ${duration}ms`); + + if (this.failed > 0) { + console.log(`\nโ ${this.failed} test(s) failed. Refactoring should not proceed.`); + process.exit(1); + } else { + console.log(`\nโ All tests passed! Refactoring is safe to continue.`); + } + } +} + +// Export for use in component tests +if (typeof module !== 'undefined' && module.exports) { + module.exports = { RefactorTestRunner }; +} + +// Export for browser use +if (typeof window !== 'undefined') { + window.RefactorTestRunner = RefactorTestRunner; +} + +module.exports = RefactorTestRunner; \ No newline at end of file diff --git a/markitect/static/js/tests/test-extracted-section-manager.js b/markitect/static/js/tests/test-extracted-section-manager.js new file mode 100644 index 00000000..0eb51d01 --- /dev/null +++ b/markitect/static/js/tests/test-extracted-section-manager.js @@ -0,0 +1,226 @@ +#!/usr/bin/env node + +/** + * TDD Test for Extracted SectionManager Component + * + * Tests the extracted SectionManager component independently from the monolith. + * Verifies that all functionality is preserved after extraction. + */ + +const RefactorTestRunner = require('./refactor-test-runner.js'); + +const runner = new RefactorTestRunner(); + +runner.describe('Extracted SectionManager Component', () => { + + runner.it('should load extracted SectionManager component', () => { + // Load the extracted component + delete require.cache[require.resolve('../core/section-manager.js')]; + + try { + const module = require('../core/section-manager.js'); + runner.expect(module.SectionManager).toBeTruthy(); + runner.expect(module.Section).toBeTruthy(); + runner.expect(module.EditState).toBeTruthy(); + runner.expect(module.SectionType).toBeTruthy(); + + // Set globals for other tests + global.ExtractedSectionManager = module.SectionManager; + global.ExtractedSection = module.Section; + global.ExtractedEditState = module.EditState; + global.ExtractedSectionType = module.SectionType; + } catch (error) { + throw new Error(`Failed to load extracted SectionManager: ${error.message}`); + } + }); + + runner.it('should preserve constructor functionality', () => { + const SectionManager = global.ExtractedSectionManager; + + const manager = new SectionManager(); + runner.expect(manager).toBeInstanceOf(SectionManager); + runner.expect(manager.sections).toBeInstanceOf(Map); + runner.expect(manager.listeners).toBeInstanceOf(Map); + }); + + runner.it('should preserve section creation functionality', () => { + const SectionManager = global.ExtractedSectionManager; + const manager = new SectionManager(); + + const testMarkdown = `# Heading 1\nContent 1\n\n## Heading 2\nContent 2`; + const sections = manager.createSectionsFromMarkdown(testMarkdown); + + runner.expect(Array.isArray(sections)).toBeTruthy(); + runner.expect(sections.length).toBe(2); + runner.expect(sections[0].currentMarkdown).toContain('Heading 1'); + runner.expect(sections[1].currentMarkdown).toContain('Heading 2'); + }); + + runner.it('should preserve section editing functionality', () => { + const SectionManager = global.ExtractedSectionManager; + const manager = new SectionManager(); + + const sections = manager.createSectionsFromMarkdown('# Test\nContent'); + const sectionId = sections[0].id; + + // Test start editing + const content = manager.startEditing(sectionId); + runner.expect(content).toContain('Test'); + + const section = manager.sections.get(sectionId); + runner.expect(section.isEditing()).toBeTruthy(); + + // Test stop editing + section.stopEditing(); + runner.expect(section.isEditing()).toBeFalsy(); + }); + + runner.it('should preserve event system functionality', () => { + const SectionManager = global.ExtractedSectionManager; + const manager = new SectionManager(); + + let eventFired = false; + let eventData = null; + + manager.on('test-event', (data) => { + eventFired = true; + eventData = data; + }); + + manager.emit('test-event', { test: 'data' }); + + runner.expect(eventFired).toBeTruthy(); + runner.expect(eventData).toEqual({ test: 'data' }); + }); + + runner.it('should preserve document status functionality', () => { + const SectionManager = global.ExtractedSectionManager; + const manager = new SectionManager(); + + manager.createSectionsFromMarkdown('# Test\nContent'); + const status = manager.getDocumentStatus(); + + runner.expect(status).toHaveProperty('totalSections'); + runner.expect(status).toHaveProperty('editingSections'); + runner.expect(status.totalSections).toBe(1); + }); + + runner.it('should preserve getAllSections functionality', () => { + const SectionManager = global.ExtractedSectionManager; + const manager = new SectionManager(); + + const testMarkdown = '# One\nContent\n\n# Two\nMore content'; + manager.createSectionsFromMarkdown(testMarkdown); + + const allSections = manager.getAllSections(); + runner.expect(Array.isArray(allSections)).toBeTruthy(); + runner.expect(allSections.length).toBe(2); + }); + + runner.it('should preserve section splitting functionality', () => { + const SectionManager = global.ExtractedSectionManager; + const manager = new SectionManager(); + + const sections = manager.createSectionsFromMarkdown('# Original\nContent'); + const sectionId = sections[0].id; + + const newContent = '# Split 1\nContent 1\n\n# Split 2\nContent 2'; + const newSections = manager.handleSectionSplit(sectionId, newContent); + + runner.expect(Array.isArray(newSections)).toBeTruthy(); + runner.expect(newSections.length).toBe(2); + runner.expect(manager.sections.has(sectionId)).toBeFalsy(); // Original removed + }); + + runner.it('should preserve Section class functionality', () => { + const Section = global.ExtractedSection; + const EditState = global.ExtractedEditState; + + const section = new Section('test-id', '# Test Content', 'heading'); + + runner.expect(section.id).toBe('test-id'); + runner.expect(section.currentMarkdown).toBe('# Test Content'); + runner.expect(section.type).toBe('heading'); + runner.expect(section.state).toBe(EditState.ORIGINAL); + }); + + runner.it('should preserve Section ID generation', () => { + const Section = global.ExtractedSection; + + const id1 = Section.generateId('# Test Heading', 0); + const id2 = Section.generateId('# Different Heading', 1); + + runner.expect(typeof id1 === 'string').toBeTruthy(); + runner.expect(typeof id2 === 'string').toBeTruthy(); + runner.expect(id1).toContain('section-'); + runner.expect(id2).toContain('section-'); + runner.expect(id1 !== id2).toBeTruthy(); // Should be unique + }); + + runner.it('should preserve Section type detection', () => { + const Section = global.ExtractedSection; + const SectionType = global.ExtractedSectionType; + + runner.expect(Section.detectType('# Heading')).toBe(SectionType.HEADING); + runner.expect(Section.detectType('')).toBe(SectionType.IMAGE); + runner.expect(Section.detectType('```code```')).toBe(SectionType.CODE); + runner.expect(Section.detectType('Regular paragraph')).toBe(SectionType.PARAGRAPH); + }); + + // Comparative test - verify extracted component behaves identically to original + runner.it('should behave identically to original monolithic component', () => { + // Load both components + const originalModule = require('/home/worsch/markitect_project/markitect/static/editor.js'); + const extractedModule = require('../core/section-manager.js'); + + const originalManager = new originalModule.SectionManager(); + const extractedManager = new extractedModule.SectionManager(); + + const testMarkdown = '# Test\nContent\n\n## Subheading\nMore content'; + + // Debug: Check what each component produces + console.log('Creating sections with original component...'); + const originalSections = originalManager.createSectionsFromMarkdown(testMarkdown); + console.log(`Original produced ${originalSections.length} sections`); + + console.log('Creating sections with extracted component...'); + const extractedSections = extractedManager.createSectionsFromMarkdown(testMarkdown); + console.log(`Extracted produced ${extractedSections.length} sections`); + + if (originalSections.length > 0) { + console.log('Original first section:', originalSections[0].currentMarkdown); + } + if (extractedSections.length > 0) { + console.log('Extracted first section:', extractedSections[0].currentMarkdown); + } + + // Should have same number of sections + runner.expect(extractedSections.length).toBe(originalSections.length); + + // Should have same content + for (let i = 0; i < originalSections.length; i++) { + runner.expect(extractedSections[i].currentMarkdown).toBe(originalSections[i].currentMarkdown); + runner.expect(extractedSections[i].type).toBe(originalSections[i].type); + } + + // Should have same document status structure + const originalStatus = originalManager.getDocumentStatus(); + const extractedStatus = extractedManager.getDocumentStatus(); + + console.log('Original status:', originalStatus); + console.log('Extracted status:', extractedStatus); + + runner.expect(extractedStatus.totalSections).toBe(originalStatus.totalSections); + runner.expect(extractedStatus.editingSections).toBe(originalStatus.editingSections); + }); +}); + +module.exports = runner; + +// Run tests if called directly +if (require.main === module) { + console.log('๐งช Testing Extracted SectionManager Component'); + runner.run().then(() => { + console.log('โ Extracted SectionManager tests completed'); + }); +} \ No newline at end of file diff --git a/markitect/static/js/tests/test-section-manager-extraction.js b/markitect/static/js/tests/test-section-manager-extraction.js new file mode 100644 index 00000000..1eecce5d --- /dev/null +++ b/markitect/static/js/tests/test-section-manager-extraction.js @@ -0,0 +1,196 @@ +#!/usr/bin/env node + +/** + * TDD Test for SectionManager Component Extraction + * + * Tests the extraction of SectionManager from the monolithic editor.js + * Ensures all functionality is preserved during refactoring. + */ + +const RefactorTestRunner = require('./refactor-test-runner.js'); + +const runner = new RefactorTestRunner(); + +// First, let's define what the SectionManager API should look like +const EXPECTED_SECTION_MANAGER_API = [ + 'constructor', + 'createSectionsFromMarkdown', + 'startEditing', + 'stopEditing', + 'getAllSections', + 'sections', // Map property, not method + 'getDocumentStatus', + 'getDocumentMarkdown', + 'on', // event system + 'emit', // event system + 'handleSectionSplit', + 'updateContent', + 'acceptChanges', + 'cancelChanges', + 'resetSection' +]; + +runner.describe('SectionManager Component Extraction', () => { + + runner.it('should define expected API methods', () => { + // This test defines what we expect from the extracted SectionManager + const expectedMethods = EXPECTED_SECTION_MANAGER_API; + runner.expect(expectedMethods.length).toBe(15); + runner.expect(expectedMethods).toContain('createSectionsFromMarkdown'); + runner.expect(expectedMethods).toContain('startEditing'); + runner.expect(expectedMethods).toContain('stopEditing'); + }); + + runner.it('should extract from monolithic editor.js', () => { + // Load the monolithic editor.js to extract SectionManager + delete require.cache[require.resolve('/home/worsch/markitect_project/markitect/static/editor.js')]; + + try { + const editorModule = require('/home/worsch/markitect_project/markitect/static/editor.js'); + runner.expect(editorModule.SectionManager).toBeTruthy(); + // Set global for other tests + global.SectionManager = editorModule.SectionManager; + global.Section = editorModule.Section; + global.EditState = editorModule.EditState; + } catch (error) { + throw new Error(`Failed to load monolithic editor.js: ${error.message}`); + } + }); + + runner.it('should preserve SectionManager constructor functionality', () => { + const SectionManager = global.SectionManager; + + const manager = new SectionManager(); + runner.expect(manager).toBeInstanceOf(SectionManager); + runner.expect(manager.sections).toBeInstanceOf(Map); + }); + + runner.it('should preserve createSectionsFromMarkdown functionality', () => { + const SectionManager = global.SectionManager; + const manager = new SectionManager(); + + const testMarkdown = `# Heading 1\nContent 1\n\n## Heading 2\nContent 2`; + const sections = manager.createSectionsFromMarkdown(testMarkdown); + + runner.expect(Array.isArray(sections)).toBeTruthy(); + runner.expect(sections.length).toBe(2); + runner.expect(sections[0].currentMarkdown).toContain('Heading 1'); + runner.expect(sections[1].currentMarkdown).toContain('Heading 2'); + }); + + runner.it('should preserve section editing state management', () => { + const SectionManager = global.SectionManager; + const manager = new SectionManager(); + + const sections = manager.createSectionsFromMarkdown('# Test\nContent'); + const sectionId = sections[0].id; + + // Test start editing + runner.expect(manager.startEditing(sectionId)).toBeTruthy(); + const section = manager.sections.get(sectionId); + runner.expect(section.isEditing()).toBeTruthy(); + + // Test stop editing + section.stopEditing(); + runner.expect(section.isEditing()).toBeFalsy(); + }); + + runner.it('should preserve event system functionality', () => { + const SectionManager = global.SectionManager; + const manager = new SectionManager(); + + let eventFired = false; + let eventData = null; + + manager.on('test-event', (data) => { + eventFired = true; + eventData = data; + }); + + manager.emit('test-event', { test: 'data' }); + + runner.expect(eventFired).toBeTruthy(); + runner.expect(eventData).toEqual({ test: 'data' }); + }); + + runner.it('should preserve document status functionality', () => { + const SectionManager = global.SectionManager; + const manager = new SectionManager(); + + manager.createSectionsFromMarkdown('# Test\nContent'); + const status = manager.getDocumentStatus(); + + runner.expect(status).toHaveProperty('totalSections'); + runner.expect(status).toHaveProperty('editingSections'); + runner.expect(status.totalSections).toBe(1); + }); + + runner.it('should preserve getAllSections functionality', () => { + const SectionManager = global.SectionManager; + const manager = new SectionManager(); + + const testMarkdown = '# One\nContent\n\n# Two\nMore content'; + manager.createSectionsFromMarkdown(testMarkdown); + + const allSections = manager.getAllSections(); + runner.expect(Array.isArray(allSections)).toBeTruthy(); + runner.expect(allSections.length).toBe(2); + }); + + runner.it('should preserve section splitting functionality', () => { + const SectionManager = global.SectionManager; + const manager = new SectionManager(); + + const sections = manager.createSectionsFromMarkdown('# Original\nContent'); + const sectionId = sections[0].id; + + const newContent = '# Split 1\nContent 1\n\n# Split 2\nContent 2'; + const newSections = manager.handleSectionSplit(sectionId, newContent); + + runner.expect(Array.isArray(newSections)).toBeTruthy(); + runner.expect(newSections.length).toBe(2); + runner.expect(manager.sections.has(sectionId)).toBeFalsy(); // Original removed + }); +}); + +// Export API tests for use during extraction +const SECTION_MANAGER_API_TESTS = [ + (SectionManager) => { + const manager = new SectionManager(); + if (!manager.sections || !(manager.sections instanceof Map)) { + throw new Error('sections property missing or not a Map'); + } + }, + (SectionManager) => { + const manager = new SectionManager(); + if (typeof manager.createSectionsFromMarkdown !== 'function') { + throw new Error('createSectionsFromMarkdown method missing'); + } + }, + (SectionManager) => { + const manager = new SectionManager(); + if (typeof manager.startEditing !== 'function') { + throw new Error('startEditing method missing'); + } + }, + (SectionManager) => { + const manager = new SectionManager(); + if (typeof manager.stopEditing !== 'function') { + throw new Error('stopEditing method missing'); + } + } +]; + +module.exports = { + runner, + EXPECTED_SECTION_MANAGER_API, + SECTION_MANAGER_API_TESTS +}; + +// Run tests if called directly +if (require.main === module) { + console.log('๐งช Testing SectionManager Component Extraction'); + runner.run().then(() => { + console.log('โ SectionManager extraction tests completed'); + }); +} \ No newline at end of file