Complete Phase 1 & 3: TestDrive-JSUI capability migration

Phase 1 - File Migration:
- Copy missing files to capability (document-controls.js, test-document-navigator.js)
- Create legacy compatibility wrapper (document-controls-legacy.js)
- Install jest-environment-jsdom dependency
- All 84 automated tests passing (68 JS + 15 Python + 1 fixes)

Phase 3 - Template Updates:
- Update MIGRATION_STATUS.md with Phase 3 completion status
- Update CLAUDE.md with migration completion details
- Document verification results for both view and edit modes

Migration Status:
- All 24 original JavaScript files now in capability 
- File verification: 20 identical, 4 intentionally different 
- Test coverage: 59.11% (exceeds 58% requirement) 
- Ready for Phase 4 cleanup after verification period

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-16 10:19:56 +01:00
parent 9d7964f9e5
commit 891d785533
6 changed files with 1462 additions and 10 deletions

View File

@@ -0,0 +1,22 @@
/**
* Legacy wrapper for DocumentControls
*
* This file provides backward compatibility for tests that reference
* DocumentControlsLegacy. It simply re-exports the current DocumentControls
* implementation with the legacy name.
*/
const { DocumentControls } = require('./document-controls.js');
// Re-export as legacy name for backward compatibility
const DocumentControlsLegacy = DocumentControls;
// Export for use in tests and other modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = { DocumentControlsLegacy };
}
// Export for browser use
if (typeof window !== 'undefined') {
window.DocumentControlsLegacy = DocumentControls;
}

View File

@@ -0,0 +1,279 @@
/**
* DocumentControls Component
*
* Extracted from monolithic editor.js as part of architecture refactoring.
* Handles the floating control panel and document-level actions.
*
* Dependencies:
* - None (standalone component)
*/
/**
* DocumentControls - Manages the floating control panel and its buttons
*/
class DocumentControls {
constructor() {
this.controlPanel = null;
this.buttons = new Map();
this.eventHandlers = new Map();
this.isVisible = true;
}
/**
* Create the control panel and add it to the DOM
*/
create() {
if (this.controlPanel) {
this.destroy(); // Remove existing panel
}
// Also remove any existing panel with the same ID in the DOM
const existingPanel = document.getElementById('markitect-global-controls');
if (existingPanel && existingPanel.parentNode) {
existingPanel.parentNode.removeChild(existingPanel);
}
// Create the floating control panel
this.controlPanel = document.createElement('div');
this.controlPanel.id = 'markitect-global-controls';
this.controlPanel.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: rgba(248, 249, 250, 0.95);
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
z-index: 1000;
backdrop-filter: blur(8px);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 14px;
min-width: 200px;
`;
// Add title
const title = document.createElement('div');
title.style.cssText = `
font-weight: 600;
margin-bottom: 8px;
color: #495057;
border-bottom: 1px solid #dee2e6;
padding-bottom: 4px;
`;
title.textContent = 'Document Controls';
// Create button container
const buttonContainer = document.createElement('div');
buttonContainer.id = 'button-container';
buttonContainer.style.cssText = `
display: flex;
flex-direction: column;
gap: 6px;
`;
this.controlPanel.appendChild(title);
this.controlPanel.appendChild(buttonContainer);
// Add default buttons
this.addDefaultButtons();
// Add debug messages container
this.addDebugContainer();
// Add to DOM
document.body.appendChild(this.controlPanel);
}
/**
* Add default buttons to the control panel
*/
addDefaultButtons() {
// Save Document button
this.addButton('save-document', '💾 Save Document', '#28a745');
// Reset All button
this.addButton('reset-all', '🔄 Reset All', '#ffc107', '#212529');
// Show Status button
this.addButton('show-status', '📊 Show Status', '#17a2b8');
// Debug button
this.addButton('toggle-debug', '🔍 Debug', '#6c757d');
}
/**
* Add debug container to the control panel
*/
addDebugContainer() {
const debugContainer = document.createElement('div');
debugContainer.id = 'debug-messages-container';
debugContainer.style.cssText = `
margin-top: 12px;
max-height: 300px;
overflow-y: auto;
border: 1px solid #dee2e6;
border-radius: 4px;
background: #f8f9fa;
padding: 8px;
font-family: 'Courier New', monospace;
font-size: 12px;
line-height: 1.4;
display: none;
`;
this.controlPanel.appendChild(debugContainer);
}
/**
* Add a button to the control panel
*/
addButton(id, text, backgroundColor, textColor = 'white') {
const buttonContainer = this.controlPanel.querySelector('#button-container');
if (!buttonContainer) {
throw new Error('Button container not found. Call create() first.');
}
const button = document.createElement('button');
button.id = id;
button.textContent = text;
button.style.cssText = `
background: ${backgroundColor};
color: ${textColor};
border: none;
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: background-color 0.2s;
`;
buttonContainer.appendChild(button);
this.buttons.set(id, button);
return button;
}
/**
* Remove a button from the control panel
*/
removeButton(id) {
const button = this.buttons.get(id);
if (button && button.parentNode) {
button.parentNode.removeChild(button);
this.buttons.delete(id);
this.eventHandlers.delete(id);
}
}
/**
* Set event handlers for buttons
*/
setEventHandlers(handlers) {
for (const [buttonId, handler] of Object.entries(handlers)) {
const button = this.buttons.get(buttonId);
if (button) {
// Remove existing handler if any
if (this.eventHandlers.has(buttonId)) {
button.removeEventListener('click', this.eventHandlers.get(buttonId));
}
// Add new handler
button.addEventListener('click', handler);
this.eventHandlers.set(buttonId, handler);
}
}
}
/**
* Show the control panel
*/
show() {
if (this.controlPanel) {
this.controlPanel.style.display = 'block';
this.isVisible = true;
}
}
/**
* Hide the control panel
*/
hide() {
if (this.controlPanel) {
this.controlPanel.style.display = 'none';
this.isVisible = false;
}
}
/**
* Update status display (can be extended as needed)
*/
updateStatus(status) {
// This method can be extended to show status information
// For now, it just stores the status for potential display
this.lastStatus = status;
// Could update a status indicator in the panel if needed
if (status && this.controlPanel) {
const title = this.controlPanel.querySelector('div');
if (title) {
const statusText = `Document Controls (${status.totalSections} sections, ${status.editingSections} editing)`;
// Could update title or add status indicator
}
}
}
/**
* Get the control panel element
*/
getControlPanel() {
return this.controlPanel;
}
/**
* Destroy the control panel and clean up
*/
destroy() {
if (this.controlPanel && this.controlPanel.parentNode) {
this.controlPanel.parentNode.removeChild(this.controlPanel);
}
// Clean up references
this.controlPanel = null;
this.buttons.clear();
this.eventHandlers.clear();
this.isVisible = true;
}
/**
* Check if the control panel is visible
*/
isVisible() {
return this.isVisible && this.controlPanel && this.controlPanel.style.display !== 'none';
}
/**
* Get all button IDs
*/
getButtonIds() {
return Array.from(this.buttons.keys());
}
/**
* Get a specific button by ID
*/
getButton(id) {
return this.buttons.get(id);
}
}
// Export for use in tests and other modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = { DocumentControls };
}
// Export for browser use
if (typeof window !== 'undefined') {
window.DocumentControls = DocumentControls;
}

View File

@@ -0,0 +1,432 @@
/**
* TDD Test Suite for DocumentNavigator Widget
*
* Tests the Substack-style floating navigation widget for document headings.
* Following TDD methodology: write tests first, then implement functionality.
*/
// Simple test runner for browser environment
class DocumentNavigatorTestRunner {
constructor() {
this.tests = [];
this.results = {
passed: 0,
failed: 0,
total: 0
};
}
test(name, testFn) {
this.tests.push({ name, testFn });
}
expect(actual) {
return {
toBe: (expected) => {
if (actual !== expected) {
throw new Error(`Expected ${actual} to be ${expected}`);
}
},
toBeInstanceOf: (expectedClass) => {
if (!(actual instanceof expectedClass)) {
throw new Error(`Expected ${actual} to be instance of ${expectedClass.name}`);
}
},
toBeTruthy: () => {
if (!actual) {
throw new Error(`Expected ${actual} to be truthy`);
}
},
toBeFalsy: () => {
if (actual) {
throw new Error(`Expected ${actual} to be falsy`);
}
},
toContain: (expected) => {
if (typeof actual === 'string' && !actual.includes(expected)) {
throw new Error(`Expected "${actual}" to contain "${expected}"`);
}
if (Array.isArray(actual) && !actual.includes(expected)) {
throw new Error(`Expected array to contain ${expected}`);
}
},
toHaveLength: (expected) => {
if (actual.length !== expected) {
throw new Error(`Expected length ${actual.length} to be ${expected}`);
}
},
toBeGreaterThan: (expected) => {
if (actual <= expected) {
throw new Error(`Expected ${actual} to be greater than ${expected}`);
}
}
};
}
async run() {
console.log('🧪 Running DocumentNavigator TDD Test Suite...\n');
for (const { name, testFn } of this.tests) {
this.results.total++;
try {
await testFn.call(this);
this.results.passed++;
console.log(`${name}`);
} catch (error) {
this.results.failed++;
console.log(`${name}`);
console.log(` ${error.message}\n`);
}
}
this.printSummary();
}
printSummary() {
console.log(`\n📊 Test Results:`);
console.log(` Passed: ${this.results.passed}`);
console.log(` Failed: ${this.results.failed}`);
console.log(` Total: ${this.results.total}`);
if (this.results.failed === 0) {
console.log(`\n🎉 All tests passed!`);
} else {
console.log(`\n${this.results.failed} test(s) failed.`);
}
}
}
// Create test runner
const runner = new DocumentNavigatorTestRunner();
// Test Suite: DocumentNavigator Widget
runner.test('DocumentNavigator class should exist and be importable', async function() {
// This test will fail initially - we haven't created the class yet
try {
const { DocumentNavigator } = await import('../widgets/navigation/DocumentNavigator.js');
this.expect(DocumentNavigator).toBeTruthy();
this.expect(typeof DocumentNavigator).toBe('function');
} catch (error) {
throw new Error(`DocumentNavigator class not found: ${error.message}`);
}
});
runner.test('DocumentNavigator should extend UIWidget', async function() {
const { DocumentNavigator } = await import('../widgets/navigation/DocumentNavigator.js');
const { UIWidget } = await import('../widgets/base/UIWidget.js');
const navigator = new DocumentNavigator();
this.expect(navigator).toBeInstanceOf(UIWidget);
});
runner.test('DocumentNavigator should initialize with default configuration', async function() {
const { DocumentNavigator } = await import('../widgets/navigation/DocumentNavigator.js');
const navigator = new DocumentNavigator();
// Test default configuration
this.expect(navigator.config.position).toBe('left');
this.expect(navigator.config.collapsed).toBe(true);
this.expect(navigator.config.autoHide).toBe(true);
this.expect(navigator.config.maxHeadingLevel).toBe(3);
this.expect(navigator.config.enableScrollSpy).toBe(true);
});
runner.test('DocumentNavigator should accept custom configuration', async function() {
const { DocumentNavigator } = await import('../widgets/navigation/DocumentNavigator.js');
const customConfig = {
position: 'right',
collapsed: false,
maxHeadingLevel: 4,
theme: 'dark'
};
const navigator = new DocumentNavigator(customConfig);
this.expect(navigator.config.position).toBe('right');
this.expect(navigator.config.collapsed).toBe(false);
this.expect(navigator.config.maxHeadingLevel).toBe(4);
this.expect(navigator.config.theme).toBe('dark');
});
runner.test('DocumentNavigator should render floating panel element', async function() {
const { DocumentNavigator } = await import('../widgets/navigation/DocumentNavigator.js');
const navigator = new DocumentNavigator();
await navigator.render();
this.expect(navigator.element).toBeInstanceOf(HTMLElement);
this.expect(navigator.element.classList.contains('document-navigator')).toBeTruthy();
this.expect(navigator.element.style.position).toBe('fixed');
});
runner.test('DocumentNavigator should have toggle button in collapsed state', async function() {
const { DocumentNavigator } = await import('../widgets/navigation/DocumentNavigator.js');
const navigator = new DocumentNavigator({ collapsed: true });
await navigator.render();
const toggleButton = navigator.findElement('.navigator-toggle');
this.expect(toggleButton).toBeInstanceOf(HTMLElement);
this.expect(toggleButton.style.display).not.toBe('none');
const navList = navigator.findElement('.navigator-list');
this.expect(navList.style.display).toBe('none');
});
runner.test('DocumentNavigator should extract headings from document', async function() {
const { DocumentNavigator } = await import('../widgets/navigation/DocumentNavigator.js');
// Create test document with headings
const testContainer = document.createElement('div');
testContainer.innerHTML = `
<h1 id="heading1">First Heading</h1>
<p>Some content</p>
<h2 id="heading2">Second Heading</h2>
<h3 id="heading3">Third Heading</h3>
<p>More content</p>
<h2 id="heading4">Fourth Heading</h2>
`;
document.body.appendChild(testContainer);
const navigator = new DocumentNavigator({
container: testContainer,
maxHeadingLevel: 3
});
const headings = navigator.extractHeadings();
this.expect(headings).toHaveLength(4);
this.expect(headings[0].tagName).toBe('H1');
this.expect(headings[0].textContent).toBe('First Heading');
this.expect(headings[1].tagName).toBe('H2');
this.expect(headings[2].tagName).toBe('H3');
this.expect(headings[3].tagName).toBe('H2');
// Cleanup
document.body.removeChild(testContainer);
});
runner.test('DocumentNavigator should build navigation hierarchy', async function() {
const { DocumentNavigator } = await import('../widgets/navigation/DocumentNavigator.js');
// Create test document with nested headings
const testContainer = document.createElement('div');
testContainer.innerHTML = `
<h1>Chapter 1</h1>
<h2>Section 1.1</h2>
<h3>Subsection 1.1.1</h3>
<h3>Subsection 1.1.2</h3>
<h2>Section 1.2</h2>
<h1>Chapter 2</h1>
`;
document.body.appendChild(testContainer);
const navigator = new DocumentNavigator({ container: testContainer });
await navigator.render();
const navItems = navigator.buildNavigationTree();
// Should have hierarchical structure
this.expect(navItems).toHaveLength(2); // 2 H1 elements
this.expect(navItems[0].children).toHaveLength(2); // 2 H2 under first H1
this.expect(navItems[0].children[0].children).toHaveLength(2); // 2 H3 under first H2
// Cleanup
document.body.removeChild(testContainer);
});
runner.test('DocumentNavigator should handle click navigation', async function() {
const { DocumentNavigator } = await import('../widgets/navigation/DocumentNavigator.js');
// Create test document
const testContainer = document.createElement('div');
testContainer.innerHTML = `
<h1 id="target-heading">Target Heading</h1>
<p style="height: 1000px;">Spacer content</p>
`;
document.body.appendChild(testContainer);
const navigator = new DocumentNavigator({ container: testContainer });
await navigator.render();
// Simulate click on navigation item
const navItem = navigator.findElement('[data-target="target-heading"]');
this.expect(navItem).toBeTruthy();
// Mock scrollIntoView for testing
const targetElement = document.getElementById('target-heading');
let scrollCalled = false;
targetElement.scrollIntoView = () => { scrollCalled = true; };
// Click navigation item
navItem.click();
this.expect(scrollCalled).toBeTruthy();
// Cleanup
document.body.removeChild(testContainer);
});
runner.test('DocumentNavigator should support expand/collapse functionality', async function() {
const { DocumentNavigator } = await import('../widgets/navigation/DocumentNavigator.js');
const navigator = new DocumentNavigator({ collapsed: true });
await navigator.render();
// Should start collapsed
this.expect(navigator.isCollapsed).toBeTruthy();
const toggleButton = navigator.findElement('.navigator-toggle');
const navList = navigator.findElement('.navigator-list');
// Toggle to expanded
await navigator.expand();
this.expect(navigator.isCollapsed).toBeFalsy();
this.expect(navList.style.display).not.toBe('none');
// Toggle back to collapsed
await navigator.collapse();
this.expect(navigator.isCollapsed).toBeTruthy();
this.expect(navList.style.display).toBe('none');
});
runner.test('DocumentNavigator should implement scroll spy functionality', async function() {
const { DocumentNavigator } = await import('../widgets/navigation/DocumentNavigator.js');
// Create test document with multiple sections
const testContainer = document.createElement('div');
testContainer.innerHTML = `
<div style="height: 100px;"></div>
<h1 id="section1">Section 1</h1>
<div style="height: 400px;"></div>
<h2 id="section2">Section 2</h2>
<div style="height: 400px;"></div>
<h2 id="section3">Section 3</h2>
<div style="height: 400px;"></div>
`;
document.body.appendChild(testContainer);
const navigator = new DocumentNavigator({
container: testContainer,
enableScrollSpy: true
});
await navigator.render();
// Test current section detection
const currentSection = navigator.getCurrentSection();
this.expect(currentSection).toBeTruthy();
// Cleanup
document.body.removeChild(testContainer);
});
runner.test('DocumentNavigator should handle responsive behavior', async function() {
const { DocumentNavigator } = await import('../widgets/navigation/DocumentNavigator.js');
const navigator = new DocumentNavigator({ autoHide: true });
await navigator.render();
// Mock viewport resize
const originalInnerWidth = window.innerWidth;
// Test mobile viewport
Object.defineProperty(window, 'innerWidth', { value: 500, configurable: true });
navigator.handleResize();
this.expect(navigator.element.style.display).toBe('none');
// Test desktop viewport
Object.defineProperty(window, 'innerWidth', { value: 1200, configurable: true });
navigator.handleResize();
this.expect(navigator.element.style.display).not.toBe('none');
// Restore original
Object.defineProperty(window, 'innerWidth', { value: originalInnerWidth, configurable: true });
});
runner.test('DocumentNavigator should provide keyboard navigation support', async function() {
const { DocumentNavigator } = await import('../widgets/navigation/DocumentNavigator.js');
const navigator = new DocumentNavigator();
await navigator.render();
// Test keyboard shortcuts
let expandCalled = false;
let collapseCalled = false;
navigator.expand = async () => { expandCalled = true; };
navigator.collapse = async () => { collapseCalled = true; };
// Simulate keyboard events
const element = navigator.element;
// Test Escape key (should collapse)
const escapeEvent = new KeyboardEvent('keydown', { key: 'Escape' });
element.dispatchEvent(escapeEvent);
this.expect(collapseCalled).toBeTruthy();
// Test Enter/Space key (should expand)
const enterEvent = new KeyboardEvent('keydown', { key: 'Enter' });
element.dispatchEvent(enterEvent);
this.expect(expandCalled).toBeTruthy();
});
runner.test('DocumentNavigator should emit events for user interactions', async function() {
const { DocumentNavigator } = await import('../widgets/navigation/DocumentNavigator.js');
const navigator = new DocumentNavigator();
await navigator.render();
// Test event emission
let navigationEvent = null;
navigator.addEventListener('navigate', (e) => {
navigationEvent = e;
});
let toggleEvent = null;
navigator.addEventListener('toggle', (e) => {
toggleEvent = e;
});
// Trigger navigation
navigator.navigateToHeading('test-heading');
this.expect(navigationEvent).toBeTruthy();
this.expect(navigationEvent.detail.target).toBe('test-heading');
// Trigger toggle
await navigator.toggle();
this.expect(toggleEvent).toBeTruthy();
});
runner.test('DocumentNavigator should handle empty document gracefully', async function() {
const { DocumentNavigator } = await import('../widgets/navigation/DocumentNavigator.js');
// Create empty container
const emptyContainer = document.createElement('div');
document.body.appendChild(emptyContainer);
const navigator = new DocumentNavigator({ container: emptyContainer });
const headings = navigator.extractHeadings();
this.expect(headings).toHaveLength(0);
await navigator.render();
const navList = navigator.findElement('.navigator-list');
this.expect(navList.children).toHaveLength(0);
// Should show empty state message
const emptyMessage = navigator.findElement('.navigator-empty');
this.expect(emptyMessage).toBeTruthy();
// Cleanup
document.body.removeChild(emptyContainer);
});
// Export test runner for use in HTML
window.runDocumentNavigatorTests = () => runner.run();
console.log('📋 DocumentNavigator TDD Test Suite loaded. Run with: runDocumentNavigatorTests()');
export { runner };