Files
markitect-main/src/section_editor.test.js
tegwick d0abaab63a
Some checks failed
Test Suite / unit-tests (3.11) (push) Has been cancelled
Test Suite / unit-tests (3.12) (push) Has been cancelled
Test Suite / code-quality (push) Has been cancelled
Test Suite / security-scan (push) Has been cancelled
Test Suite / integration-tests (push) Has been cancelled
Test Suite / e2e-tests (push) Has been cancelled
Test Suite / performance-tests (push) Has been cancelled
Test Suite / test-summary (push) Has been cancelled
chore: update project state and prepare for image support development
- Add comprehensive image test document with various image types
- Update project structure with development artifacts
- Prepare foundation for image support enhancement phase
- Include test files for validating image editing workflows

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-26 08:06:22 +01:00

597 lines
24 KiB
JavaScript

/**
* 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
});
});
});