/** * Test suite for the Section Editor implementation * * These tests verify the core business logic independently of the DOM, * ensuring reliability and consistency. */ const { Section, SectionManager, EditState, SectionType } = require('./section_editor.js'); describe('Section Class', () => { let section; beforeEach(() => { section = new Section('test-1', '# Test Header\n\nTest content', SectionType.HEADING); }); describe('Constructor', () => { test('should initialize with correct properties', () => { expect(section.id).toBe('test-1'); expect(section.originalMarkdown).toBe('# Test Header\n\nTest content'); expect(section.currentMarkdown).toBe('# Test Header\n\nTest content'); expect(section.sectionType).toBe(SectionType.HEADING); expect(section.state).toBe(EditState.ORIGINAL); expect(section.hasChanges()).toBe(false); }); }); describe('Edit State Management', () => { test('should start editing correctly', () => { const content = section.startEdit(); expect(content).toBe('# Test Header\n\nTest content'); expect(section.state).toBe(EditState.EDITING); expect(section.isEditing()).toBe(true); }); test('should throw error when starting edit on already editing section', () => { section.startEdit(); expect(() => section.startEdit()).toThrow('Section test-1 is already being edited'); }); test('should update content during editing', () => { section.startEdit(); section.updateContent('# Modified Header\n\nModified content'); // With new behavior: currentMarkdown stays unchanged until accept expect(section.currentMarkdown).toBe('# Test Header\n\nTest content'); expect(section.editingMarkdown).toBe('# Modified Header\n\nModified content'); expect(section.state).toBe(EditState.EDITING); expect(section.hasChanges()).toBe(false); // Because currentMarkdown != originalMarkdown check }); test('should detect no changes when content is same as original', () => { section.startEdit(); section.updateContent('# Test Header\n\nTest content'); // With new behavior: state remains EDITING during edit session expect(section.state).toBe(EditState.EDITING); expect(section.hasChanges()).toBe(false); // currentMarkdown == originalMarkdown }); test('should throw error when updating content on non-editing section', () => { expect(() => section.updateContent('new content')).toThrow('Section test-1 is not in editing state'); }); }); describe('Accept Changes', () => { test('should accept changes correctly', () => { section.startEdit(); section.updateContent('# Modified Header'); const savedContent = section.acceptChanges(); expect(savedContent).toBe('# Modified Header'); // State becomes SAVED after accepting changes expect(section.state).toBe(EditState.SAVED); expect(section.lastSaved).toBeInstanceOf(Date); }); test('should throw error when accepting changes on non-editing section', () => { expect(() => section.acceptChanges()).toThrow('Section test-1 is not in editing state'); }); }); describe('Cancel Changes', () => { test('should cancel changes and revert to original', () => { section.startEdit(); section.updateContent('# Modified Header'); const originalContent = section.cancelChanges(); expect(originalContent).toBe('# Test Header\n\nTest content'); expect(section.currentMarkdown).toBe('# Test Header\n\nTest content'); expect(section.state).toBe(EditState.ORIGINAL); expect(section.hasChanges()).toBe(false); }); }); describe('Reset to Original', () => { test('should reset to original and exit editing mode', () => { section.startEdit(); section.updateContent('# Modified Header'); const originalContent = section.resetToOriginal(); expect(originalContent).toBe('# Test Header\n\nTest content'); expect(section.currentMarkdown).toBe('# Test Header\n\nTest content'); expect(section.state).toBe(EditState.ORIGINAL); expect(section.isEditing()).toBe(false); }); test('should reset from any state', () => { // Reset can be called from any state, not just editing const result = section.resetToOriginal(); expect(result).toBe('# Test Header\n\nTest content'); expect(section.state).toBe(EditState.ORIGINAL); }); }); describe('Stop Editing', () => { test('should stop editing and preserve changes as modified', () => { section.startEdit(); section.updateContent('# Modified Header'); const newState = section.stopEditing(); expect(newState).toBe(EditState.MODIFIED); expect(section.state).toBe(EditState.MODIFIED); expect(section.isEditing()).toBe(false); expect(section.isModified()).toBe(true); }); test('should stop editing and revert to original if no changes', () => { section.startEdit(); const newState = section.stopEditing(); expect(newState).toBe(EditState.ORIGINAL); expect(section.state).toBe(EditState.ORIGINAL); }); }); describe('Static Methods', () => { test('should generate stable IDs', () => { const id1 = Section.generateId('Test content', 0); const id2 = Section.generateId('Test content', 0); const id3 = Section.generateId('Test content', 1); expect(id1).toBe(id2); // Same content, same position expect(id1).not.toBe(id3); // Same content, different position expect(id1).toMatch(/^section_\d+_0$/); }); test('should detect section types correctly', () => { expect(Section.detectType('# Heading')).toBe(SectionType.HEADING); expect(Section.detectType('```code```')).toBe(SectionType.CODE); expect(Section.detectType('> Quote')).toBe(SectionType.BLOCKQUOTE); expect(Section.detectType('- List item')).toBe(SectionType.LIST); expect(Section.detectType('1. Numbered list')).toBe(SectionType.LIST); expect(Section.detectType('Regular paragraph')).toBe(SectionType.PARAGRAPH); }); }); describe('Status Methods', () => { test('should return correct status', () => { const status = section.getStatus(); expect(status).toEqual({ id: 'test-1', state: EditState.ORIGINAL, hasChanges: false, isEditing: false, isModified: false, contentLength: section.originalMarkdown.length, // Dynamic length lastSaved: null, sectionType: SectionType.HEADING }); }); }); }); describe('SectionManager Class', () => { let manager; beforeEach(() => { manager = new SectionManager(); }); describe('Section Creation', () => { test('should create sections from markdown', () => { const markdown = `# First Header First paragraph content. ## Second Header Second paragraph content. ### Third Header Third paragraph content.`; const sections = manager.createSectionsFromMarkdown(markdown); expect(sections).toHaveLength(6); // Each heading and paragraph is a section expect(sections[0].sectionType).toBe(SectionType.HEADING); expect(sections[1].sectionType).toBe(SectionType.PARAGRAPH); expect(sections[2].sectionType).toBe(SectionType.HEADING); expect(sections[3].sectionType).toBe(SectionType.PARAGRAPH); expect(sections[4].sectionType).toBe(SectionType.HEADING); expect(sections[5].sectionType).toBe(SectionType.PARAGRAPH); }); test('should emit sections-created event', () => { const eventCallback = jest.fn(); manager.on('sections-created', eventCallback); const markdown = `# Header\n\nParagraph.`; manager.createSectionsFromMarkdown(markdown); expect(eventCallback).toHaveBeenCalledWith({ sections: expect.any(Array), count: 2 }); }); }); describe('Edit Management', () => { beforeEach(() => { const markdown = `# Header One\n\nFirst paragraph.\n\n## Header Two\n\nSecond paragraph.`; manager.createSectionsFromMarkdown(markdown); }); test('should start editing a section', () => { const sections = manager.getAllSections(); const sectionId = sections[0].id; const content = manager.startEditing(sectionId); expect(content).toBe('# Header One'); expect(manager.editingSection).toBe(sectionId); expect(sections[0].isEditing()).toBe(true); }); test('should emit edit-started event', () => { const eventCallback = jest.fn(); manager.on('edit-started', eventCallback); const sections = manager.getAllSections(); const sectionId = sections[0].id; manager.startEditing(sectionId); expect(eventCallback).toHaveBeenCalledWith({ sectionId, content: '# Header One', section: expect.any(Object) }); }); test('should stop editing previous section when starting new one', () => { const sections = manager.getAllSections(); const section1Id = sections[0].id; const section2Id = sections[1].id; manager.startEditing(section1Id); manager.updateContent(section1Id, '# Modified Header'); // Start editing second section manager.startEditing(section2Id); expect(manager.editingSection).toBe(section2Id); expect(sections[0].isModified()).toBe(true); expect(sections[0].isEditing()).toBe(false); expect(sections[1].isEditing()).toBe(true); }); test('should update content correctly', () => { const sections = manager.getAllSections(); const sectionId = sections[0].id; manager.startEditing(sectionId); manager.updateContent(sectionId, '# Modified Header'); // With new behavior: currentMarkdown stays unchanged until accept expect(sections[0].editingMarkdown).toBe('# Modified Header'); expect(sections[0].isEditing()).toBe(true); }); test('should accept changes correctly', () => { const sections = manager.getAllSections(); const sectionId = sections[0].id; manager.startEditing(sectionId); manager.updateContent(sectionId, '# Modified Header'); const savedContent = manager.acceptChanges(sectionId); expect(savedContent).toBe('# Modified Header'); expect(sections[0].state).toBe(EditState.SAVED); expect(manager.editingSection).toBeNull(); }); test('should cancel changes correctly', () => { const sections = manager.getAllSections(); const sectionId = sections[0].id; manager.startEditing(sectionId); manager.updateContent(sectionId, '# Modified Header'); const originalContent = manager.cancelChanges(sectionId); expect(originalContent).toBe('# Header One'); expect(sections[0].state).toBe(EditState.ORIGINAL); expect(manager.editingSection).toBeNull(); }); test('should reset to original correctly', () => { const sections = manager.getAllSections(); const sectionId = sections[0].id; manager.startEditing(sectionId); manager.updateContent(sectionId, '# Modified Header'); const originalContent = manager.resetToOriginal(sectionId); expect(originalContent).toBe('# Header One'); expect(sections[0].currentMarkdown).toBe('# Header One'); expect(sections[0].isEditing()).toBe(false); // Reset exits editing mode expect(sections[0].state).toBe(EditState.ORIGINAL); }); }); describe('Document Status', () => { beforeEach(() => { const markdown = `# Header\n\nParagraph one.\n\n## Second\n\nParagraph two.`; manager.createSectionsFromMarkdown(markdown); }); test('should return correct document status', () => { const sections = manager.getAllSections(); // Start editing and modify one section manager.startEditing(sections[0].id); manager.updateContent(sections[0].id, '# Modified'); // Accept changes on another section manager.startEditing(sections[1].id); manager.acceptChanges(sections[1].id); const status = manager.getDocumentStatus(); expect(status.totalSections).toBe(4); expect(status.modifiedSections).toBe(1); expect(status.editingSections).toBe(0); expect(status.savedSections).toBe(1); expect(status.hasUnsavedChanges).toBe(true); }); test('should get sections by state', () => { const sections = manager.getAllSections(); manager.startEditing(sections[0].id); manager.updateContent(sections[0].id, '# Modified'); manager.stopEditing(sections[0].id); const modifiedSections = manager.getSectionsByState(EditState.MODIFIED); const originalSections = manager.getSectionsByState(EditState.ORIGINAL); expect(modifiedSections).toHaveLength(1); expect(originalSections).toHaveLength(3); }); test('should reset all sections to original', () => { const sections = manager.getAllSections(); // Modify multiple sections manager.startEditing(sections[0].id); manager.updateContent(sections[0].id, '# Modified'); manager.stopEditing(sections[0].id); manager.startEditing(sections[1].id); manager.updateContent(sections[1].id, 'Modified paragraph'); manager.resetAllToOriginal(); const status = manager.getDocumentStatus(); expect(status.hasUnsavedChanges).toBe(false); expect(status.modifiedSections).toBe(0); expect(status.editingSections).toBe(0); }); test('should generate complete document markdown', () => { const markdown = manager.getDocumentMarkdown(); expect(markdown).toContain('# Header'); expect(markdown).toContain('Paragraph one.'); expect(markdown).toContain('## Second'); expect(markdown).toContain('Paragraph two.'); }); }); describe('Error Handling', () => { test('should throw error for non-existent section operations', () => { expect(() => manager.startEditing('non-existent')).toThrow('Section non-existent not found'); expect(() => manager.updateContent('non-existent', 'content')).toThrow('Section non-existent not found'); expect(() => manager.acceptChanges('non-existent')).toThrow('Section non-existent not found'); expect(() => manager.cancelChanges('non-existent')).toThrow('Section non-existent not found'); }); }); describe('Event System', () => { test('should handle multiple listeners for same event', () => { const callback1 = jest.fn(); const callback2 = jest.fn(); manager.on('test-event', callback1); manager.on('test-event', callback2); manager.emit('test-event', { data: 'test' }); expect(callback1).toHaveBeenCalledWith({ data: 'test' }); expect(callback2).toHaveBeenCalledWith({ data: 'test' }); }); }); }); describe('Integration Tests', () => { test('should handle complex editing workflow', () => { const manager = new SectionManager(); const markdown = `# Document Title Introduction paragraph with some content. ## First Section Content of the first section. ## Second Section Content of the second section. ### Subsection Nested content here.`; // Create sections const sections = manager.createSectionsFromMarkdown(markdown); expect(sections).toHaveLength(8); // Title + intro + 2*(section header + content) + subsection header + content // Edit multiple sections const section1Id = sections[1].id; // Introduction paragraph const section3Id = sections[3].id; // First section content // Start editing first section manager.startEditing(section1Id); manager.updateContent(section1Id, 'Modified introduction with new content.'); // Switch to editing second section (should preserve first as modified) manager.startEditing(section3Id); manager.updateContent(section3Id, 'Modified first section content.'); // Check states - section 1 should have pending changes after being stopped expect(sections[1].isEditing()).toBe(false); // Not currently editing (was stopped when we started editing section 3) expect(sections[1].isModified()).toBe(true); // Should have pending changes preserved expect(sections[3].isEditing()).toBe(true); // Currently editing // Check that they have the right content expect(sections[3].editingMarkdown).toBe('Modified first section content.'); expect(sections[1].pendingMarkdown).toBe('Modified introduction with new content.'); // Pending changes preserved // Accept changes on second section manager.acceptChanges(section3Id); expect(sections[3].state).toBe(EditState.SAVED); // Reset first section to discard its pending changes manager.resetToOriginal(section1Id); expect(sections[1].state).toBe(EditState.ORIGINAL); // Verify document status const status = manager.getDocumentStatus(); expect(status.savedSections).toBe(1); expect(status.modifiedSections).toBe(0); expect(status.hasUnsavedChanges).toBe(false); }); }); describe('Action Semantics - Core Behavior', () => { describe('Section Action Behavior', () => { let section; const ORIGINAL = '# Original Header\n\nOriginal content'; const MODIFIED_1 = '# Modified Header 1\n\nModified content 1'; const MODIFIED_2 = '# Modified Header 2\n\nModified content 2'; beforeEach(() => { section = new Section('test', ORIGINAL, SectionType.HEADING); }); test('original content is never modified', () => { // Start editing section.startEdit(); section.updateContent(MODIFIED_1); expect(section.originalMarkdown).toBe(ORIGINAL); // Accept changes section.acceptChanges(); expect(section.originalMarkdown).toBe(ORIGINAL); // Reset should return to original section.resetToOriginal(); expect(section.originalMarkdown).toBe(ORIGINAL); expect(section.currentMarkdown).toBe(ORIGINAL); }); test('reset returns to original state from render time', () => { // Make some changes and accept them section.startEdit(); section.updateContent(MODIFIED_1); section.acceptChanges(); expect(section.currentMarkdown).toBe(MODIFIED_1); // Reset should go back to original, not the accepted changes section.resetToOriginal(); expect(section.currentMarkdown).toBe(ORIGINAL); expect(section.state).toBe(EditState.ORIGINAL); expect(section.hasChanges()).toBe(false); }); test('cancel returns to state before editing started', () => { // Accept some changes first section.startEdit(); section.updateContent(MODIFIED_1); section.acceptChanges(); expect(section.currentMarkdown).toBe(MODIFIED_1); expect(section.state).toBe(EditState.SAVED); // Start editing again and make different changes section.startEdit(); section.updateContent(MODIFIED_2); // Cancel should return to the state before this edit session (SAVED) const result = section.cancelChanges(); expect(result).toBe(MODIFIED_1); expect(section.currentMarkdown).toBe(MODIFIED_1); expect(section.state).toBe(EditState.SAVED); // Back to saved state }); test('accept makes current changes the new current content', () => { // Start editing and make changes section.startEdit(); section.updateContent(MODIFIED_1); // Accept should make MODIFIED_1 the new current content const result = section.acceptChanges(); expect(result).toBe(MODIFIED_1); expect(section.currentMarkdown).toBe(MODIFIED_1); expect(section.state).toBe(EditState.SAVED); // Explicitly saved // Now start editing again section.startEdit(); section.updateContent(MODIFIED_2); // Cancel should return to MODIFIED_1 (the last accepted state) section.cancelChanges(); expect(section.currentMarkdown).toBe(MODIFIED_1); expect(section.state).toBe(EditState.SAVED); // Back to saved state }); test('multiple edit sessions preserve state correctly', () => { // Session 1: Edit and accept section.startEdit(); section.updateContent(MODIFIED_1); section.acceptChanges(); expect(section.currentMarkdown).toBe(MODIFIED_1); // Session 2: Edit and cancel section.startEdit(); section.updateContent(MODIFIED_2); section.cancelChanges(); expect(section.currentMarkdown).toBe(MODIFIED_1); // Back to accepted state // Session 3: Edit and accept again section.startEdit(); section.updateContent(MODIFIED_2); section.acceptChanges(); expect(section.currentMarkdown).toBe(MODIFIED_2); // Reset should still go to original section.resetToOriginal(); expect(section.currentMarkdown).toBe(ORIGINAL); }); test('editing state tracks editingMarkdown separately', () => { section.startEdit(); expect(section.editingMarkdown).toBe(ORIGINAL); // Baseline for editing section.updateContent(MODIFIED_1); expect(section.editingMarkdown).toBe(MODIFIED_1); expect(section.currentMarkdown).toBe(ORIGINAL); // Unchanged until accept section.acceptChanges(); expect(section.editingMarkdown).toBe(null); // Cleared after accept expect(section.currentMarkdown).toBe(MODIFIED_1); // Now updated }); }); });