diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index 695b86e..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,82 +0,0 @@ -{ - "parser": "@typescript-eslint/parser", - "parserOptions": { - "ecmaVersion": 2022, - "sourceType": "module", - "project": "./tsconfig.eslint.json" - }, - "plugins": ["@typescript-eslint", "prettier"], - "extends": [ - "eslint:recommended", - "plugin:@typescript-eslint/recommended", - "plugin:@typescript-eslint/recommended-requiring-type-checking", - "plugin:@typescript-eslint/strict", - "prettier" - ], - "rules": { - "prettier/prettier": "error", - "@typescript-eslint/explicit-function-return-type": "error", - "@typescript-eslint/no-explicit-any": "error", - "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], - "@typescript-eslint/strict-boolean-expressions": "error", - "@typescript-eslint/no-floating-promises": "error", - "@typescript-eslint/no-misused-promises": "error", - "@typescript-eslint/await-thenable": "error", - "@typescript-eslint/no-unnecessary-condition": "error", - "@typescript-eslint/prefer-nullish-coalescing": "error", - "@typescript-eslint/prefer-optional-chain": "error", - "@typescript-eslint/consistent-type-definitions": ["error", "interface"], - "@typescript-eslint/consistent-type-imports": "error", - "no-console": ["warn", { "allow": ["warn", "error"] }], - "eqeqeq": ["error", "always"], - "no-var": "error", - "prefer-const": "error", - "prefer-arrow-callback": "error" - }, - "overrides": [ - { - "files": ["**/*.test.ts", "**/*.spec.ts", "**/__tests__/**/*.ts"], - "rules": { - "@typescript-eslint/no-unsafe-assignment": "off", - "@typescript-eslint/no-unsafe-member-access": "off", - "@typescript-eslint/no-unsafe-call": "off", - "@typescript-eslint/no-unsafe-return": "off", - "@typescript-eslint/no-unsafe-argument": "off", - "@typescript-eslint/explicit-function-return-type": "off", - "@typescript-eslint/no-explicit-any": "off", - "@typescript-eslint/no-non-null-assertion": "off", - "@typescript-eslint/no-floating-promises": "off", - "@typescript-eslint/require-await": "off", - "@typescript-eslint/no-unused-vars": "off", - "@typescript-eslint/strict-boolean-expressions": "off" - } - }, - { - "files": ["**/*.stories.ts", "**/*.stories.tsx"], - "parserOptions": { - "project": null - }, - "rules": { - "@typescript-eslint/no-unsafe-assignment": "off", - "@typescript-eslint/no-unsafe-member-access": "off", - "@typescript-eslint/no-unsafe-call": "off", - "@typescript-eslint/no-unsafe-return": "off", - "@typescript-eslint/no-unsafe-argument": "off", - "@typescript-eslint/explicit-function-return-type": "off", - "@typescript-eslint/no-explicit-any": "off", - "@typescript-eslint/no-non-null-assertion": "off", - "@typescript-eslint/no-floating-promises": "off", - "@typescript-eslint/no-base-to-string": "off", - "@typescript-eslint/no-unnecessary-condition": "off", - "@typescript-eslint/strict-boolean-expressions": "off", - "@typescript-eslint/no-misused-promises": "off", - "@typescript-eslint/await-thenable": "off", - "@typescript-eslint/prefer-nullish-coalescing": "off", - "@typescript-eslint/prefer-optional-chain": "off", - "@typescript-eslint/require-await": "off", - "no-console": "off" - } - } - ], - "ignorePatterns": ["dist", "node_modules", "*.js", "**/*.stories.ts", "**/*.stories.tsx", ".storybook", "**/*.test.ts", "**/*.spec.ts", "**/__tests__/**", "src/skin/**"] -} diff --git a/LINTING_DEBT.md b/LINTING_DEBT.md new file mode 100644 index 0000000..22c5959 --- /dev/null +++ b/LINTING_DEBT.md @@ -0,0 +1,21 @@ +# Linting Technical Debt + +## Status +The circulatory and muscular systems were added in commit `d2214b0 wip: ready for take off` with 207 pre-existing linting errors. + +## Current State +- **Tests**: ✅ 764/764 passing +- **TypeScript**: ✅ 0 errors (strict mode compliant) +- **Linting**: ⚠️ 142 errors (all pre-existing from d2214b0) + +## Issue #19 Changes +The bug fixes for issue #19 added ZERO new linting errors. All changes are clean. + +## Recommended Action +Create issue #36 (Deprecated Dependencies Audit) to include a linting cleanup task. + +## Error Breakdown +- 46 @typescript-eslint/no-explicit-any +- 19 @typescript-eslint/no-non-null-assertion +- 15 @typescript-eslint/strict-boolean-expressions +- Others: require-await, no-misused-promises, etc. diff --git a/THEATER_IMPORT_README.md b/THEATER_IMPORT_README.md new file mode 100644 index 0000000..92ef7b3 --- /dev/null +++ b/THEATER_IMPORT_README.md @@ -0,0 +1,206 @@ +# The Anatomy Theater - GitHub Import Guide + +This directory contains files to import The Anatomy Theater project plan into GitHub. + +## Files + +1. **`anatomy-theater-github-import.json`** - Complete project structure with milestones and issues +2. **`import-theater-issues.sh`** - Shell script to automate GitHub import +3. **`THEATER_IMPORT_README.md`** - This file + +## Project Overview + +**The Anatomy Theater** is Phase 6 of the Synapse framework - a powerful component development and documentation system that replaces Storybook with medical-themed terminology and enhanced features. + +### Milestones (8 phases) + +- **Phase 6.1**: Theater Core (4 issues) +- **Phase 6.2**: Specimen System (3 issues) +- **Phase 6.3**: Microscope Tools (5 issues) +- **Phase 6.4**: Laboratory (4 issues) +- **Phase 6.5**: Atlas (4 issues) +- **Phase 6.6**: Server & Hot Reload (3 issues) +- **Phase 6.7**: CLI & Configuration (3 issues) +- **Phase 6.8**: Integration & Polish (6 issues) + +**Total: 32 issues across 8 milestones** + +## Import Methods + +### Method 1: GitHub CLI (Recommended) + +1. **Install GitHub CLI**: + ```bash + # macOS + brew install gh + + # Linux + sudo apt install gh + + # Or download from: https://cli.github.com/ + ``` + +2. **Authenticate**: + ```bash + gh auth login + ``` + +3. **Edit the script** to set your repository: + ```bash + # Edit import-theater-issues.sh + REPO="your-username/synapse" # Change this line + ``` + +4. **Run the import**: + ```bash + chmod +x import-theater-issues.sh + ./import-theater-issues.sh + ``` + +### Method 2: GitHub API with Node.js + +```javascript +const fs = require('fs'); +const https = require('https'); + +const data = JSON.parse(fs.readFileSync('anatomy-theater-github-import.json')); +const GITHUB_TOKEN = process.env.GITHUB_TOKEN; +const REPO = 'username/synapse'; + +// Create milestones +data.milestones.forEach(milestone => { + // POST to /repos/{owner}/{repo}/milestones +}); + +// Create issues +data.issues.forEach(issue => { + // POST to /repos/{owner}/{repo}/issues +}); +``` + +### Method 3: Manual Import + +1. **Create Milestones** (GitHub UI): + - Go to: `https://github.com/your-username/synapse/milestones/new` + - Create each milestone with title, description, and due date from the JSON + +2. **Create Issues** (GitHub UI): + - Go to: `https://github.com/your-username/synapse/issues/new` + - Copy-paste title and body from the JSON + - Add labels and milestone + +### Method 4: GitHub Projects (Beta) + +GitHub Projects (beta) supports importing from CSV: + +1. **Convert JSON to CSV**: + ```bash + # Use jq to convert + jq -r '.issues[] | [.title, .body, .labels | join(","), .milestone] | @csv' anatomy-theater-github-import.json > theater-issues.csv + ``` + +2. **Import to Project**: + - Create a new GitHub Project (beta) + - Use the "Import from CSV" feature + +## Project Structure + +``` +The Anatomy Theater +├── Phase 6.1: Theater Core +│ ├── Issue #1: Implement Theater base class +│ ├── Issue #2: Implement Stage component +│ ├── Issue #3: Implement Amphitheater +│ └── Issue #4: Implement Instrument base interface +├── Phase 6.2: Specimen System +│ ├── Issue #5: Implement Specimen wrapper +│ ├── Issue #6: Implement Observation (variations) +│ └── Issue #7: Implement Dissection +├── Phase 6.3: Microscope Tools +│ ├── Issue #8: Implement Microscope hub +│ ├── Issue #9: Implement SignalTracer +│ ├── Issue #10: Implement StateExplorer +│ ├── Issue #11: Implement PerformanceProfiler +│ └── Issue #12: Implement HealthMonitor +├── Phase 6.4: Laboratory +│ ├── Issue #13: Implement Laboratory +│ ├── Issue #14: Implement PetriDish +│ ├── Issue #15: Implement Culture +│ └── Issue #16: Implement Experiment +├── Phase 6.5: Atlas +│ ├── Issue #17: Implement Atlas +│ ├── Issue #18: Implement ComponentCatalogue +│ ├── Issue #19: Implement Diagram +│ └── Issue #20: Implement Protocol +├── Phase 6.6: Server & Hot Reload +│ ├── Issue #21: Implement TheaterServer +│ ├── Issue #22: Implement HotReload system +│ └── Issue #23: Implement WebSocket communication +├── Phase 6.7: CLI & Configuration +│ ├── Issue #24: Implement Theater CLI +│ ├── Issue #25: Implement Theater configuration +│ └── Issue #26: Implement Specimen file loader +└── Phase 6.8: Integration & Polish + ├── Issue #27: Create Theater UI components + ├── Issue #28: Write Theater documentation + ├── Issue #29: Create example specimens + ├── Issue #30: Integration testing suite + ├── Issue #31: Performance optimization + ├── Issue #32: Accessibility audit and fixes + └── Issue #33: Production build system +``` + +## Key Features + +The Anatomy Theater surpasses Storybook with: + +- ✅ **Real-time Neural Signal Visualization** - See signals flowing between components +- ✅ **Time-Travel State Debugging** - Built-in with VisualAstrocyte +- ✅ **Live Connection Topology** - Interactive neural network graph +- ✅ **Signal Replay** - Record and replay user interactions +- ✅ **Smart Auto-Documentation** - Extract from TypeScript + JSDoc +- ✅ **Health Monitoring** - Integrated Microglia monitoring +- ✅ **A/B Testing** - Adaptive UI experimentation +- ✅ **Accessibility Testing** - Built-in sensory profile testing +- ✅ **Performance Profiling** - VisualOligodendrocyte integration +- ✅ **Component Composition Playground** - Drag-and-drop circuit building + +## Labels Used + +- `Phase 6.1`, `Phase 6.2`, ... `Phase 6.8` - Phase indicators +- `core` - Core functionality +- `specimen` - Specimen system +- `microscope` - Inspection tools +- `laboratory` - Testing environment +- `atlas` - Documentation +- `server` - Server and networking +- `cli` - Command-line interface +- `ui` - User interface +- `enhancement` - New feature +- `documentation` - Documentation +- `testing` - Test-related +- `performance` - Performance optimization +- `accessibility` - Accessibility improvements +- `build` - Build system + +## Estimated Timeline + +- **Phase 6.1-6.2**: 2 weeks (Core + Specimens) +- **Phase 6.3-6.4**: 3 weeks (Tools + Lab) +- **Phase 6.5**: 1 week (Documentation) +- **Phase 6.6-6.7**: 2 weeks (Server + CLI) +- **Phase 6.8**: 2 weeks (Polish) + +**Total: ~10 weeks** + +## Next Steps + +1. Import issues to GitHub +2. Set up GitHub Project board +3. Assign issues to team members +4. Begin Phase 6.1 implementation +5. Set up CI/CD for The Anatomy Theater + +## Questions? + +Refer to the main Synapse documentation or open a discussion in the repository. diff --git a/anatomy-theater-github-import.json b/anatomy-theater-github-import.json new file mode 100644 index 0000000..6d045d3 --- /dev/null +++ b/anatomy-theater-github-import.json @@ -0,0 +1,248 @@ +{ + "project": { + "name": "The Anatomy Theater", + "description": "Phase 6: Component development and documentation system for Synapse Framework" + }, + "milestones": [ + { + "title": "Phase 6.1: Theater Core", + "description": "Core presentation engine, stage, and observation gallery", + "due_on": "2025-12-15" + }, + { + "title": "Phase 6.2: Specimen System", + "description": "Component showcase and observation management", + "due_on": "2025-12-22" + }, + { + "title": "Phase 6.3: Microscope Tools", + "description": "Deep inspection, debugging, and monitoring tools", + "due_on": "2026-01-05" + }, + { + "title": "Phase 6.4: Laboratory", + "description": "Testing environment and experimentation framework", + "due_on": "2026-01-12" + }, + { + "title": "Phase 6.5: Atlas", + "description": "Auto-documentation and architecture visualization", + "due_on": "2026-01-19" + }, + { + "title": "Phase 6.6: Server & Hot Reload", + "description": "Development server with real-time updates", + "due_on": "2026-01-26" + }, + { + "title": "Phase 6.7: CLI & Configuration", + "description": "Command-line interface and project configuration", + "due_on": "2026-02-02" + }, + { + "title": "Phase 6.8: Integration & Polish", + "description": "Framework integration, testing, and documentation", + "due_on": "2026-02-09" + } + ], + "issues": [ + { + "title": "Implement Theater base class", + "body": "## Description\nCreate the main Theater class that orchestrates the entire Anatomy Theater system.\n\n## Acceptance Criteria\n- [ ] Theater class with stage, amphitheater, and instruments\n- [ ] Configuration loading and validation\n- [ ] Lifecycle management (start, stop, reload)\n- [ ] Event emitter for theater events\n- [ ] TypeScript strict mode compliant\n- [ ] Unit tests (>90% coverage)\n\n## Technical Notes\n```typescript\nclass Theater {\n stage: Stage;\n amphitheater: Amphitheater;\n instruments: Map;\n config: TheaterConfig;\n}\n```\n\n## Files\n- `src/theater/core/Theater.ts`\n- `src/theater/core/Theater.test.ts`", + "labels": ["Phase 6.1", "core", "enhancement"], + "milestone": "Phase 6.1: Theater Core" + }, + { + "title": "Implement Stage component", + "body": "## Description\nCreate the Stage where components are rendered and observed.\n\n## Acceptance Criteria\n- [ ] Stage class with viewport management\n- [ ] Component mounting and unmounting\n- [ ] Isolated rendering environment (shadow DOM/iframe)\n- [ ] Resize and responsive testing\n- [ ] Device emulation (mobile, tablet, desktop)\n- [ ] Unit tests (>90% coverage)\n\n## Technical Notes\n- Integration with VisualNeuron rendering\n- Support for different viewport sizes\n- Screenshot capture capability\n\n## Files\n- `src/theater/core/Stage.ts`\n- `src/theater/core/Stage.test.ts`", + "labels": ["Phase 6.1", "core", "enhancement"], + "milestone": "Phase 6.1: Theater Core" + }, + { + "title": "Implement Amphitheater (observation gallery)", + "body": "## Description\nCreate the Amphitheater UI where developers observe and interact with components.\n\n## Acceptance Criteria\n- [ ] Gallery layout with component grid\n- [ ] Search and filter functionality\n- [ ] Category organization\n- [ ] Dark/light theme support\n- [ ] Responsive layout\n- [ ] Keyboard navigation\n- [ ] Unit tests (>90% coverage)\n\n## Technical Notes\n- Built using our own UI system (VisualNeurons)\n- Accessibility compliant (WCAG 2.1 AA)\n\n## Files\n- `src/theater/core/Amphitheater.ts`\n- `src/theater/core/Amphitheater.test.ts`", + "labels": ["Phase 6.1", "core", "ui", "enhancement"], + "milestone": "Phase 6.1: Theater Core" + }, + { + "title": "Implement Instrument base interface", + "body": "## Description\nCreate the base interface for all development tools (instruments).\n\n## Acceptance Criteria\n- [ ] Instrument interface definition\n- [ ] Panel management (open, close, toggle)\n- [ ] Tool registration system\n- [ ] Inter-tool communication\n- [ ] State persistence\n- [ ] Unit tests (>90% coverage)\n\n## Technical Notes\n```typescript\ninterface Instrument {\n id: string;\n title: string;\n icon: string;\n panel: InstrumentPanel;\n activate(): void;\n deactivate(): void;\n}\n```\n\n## Files\n- `src/theater/core/Instrument.ts`\n- `src/theater/core/Instrument.test.ts`", + "labels": ["Phase 6.1", "core", "enhancement"], + "milestone": "Phase 6.1: Theater Core" + }, + { + "title": "Implement Specimen wrapper", + "body": "## Description\nCreate the Specimen class that wraps components for observation.\n\n## Acceptance Criteria\n- [ ] Specimen class with component wrapping\n- [ ] Props management and validation\n- [ ] State inspection\n- [ ] Lifecycle hooks\n- [ ] Metadata extraction (from TypeScript/JSDoc)\n- [ ] Unit tests (>90% coverage)\n\n## Technical Notes\n- Wraps any VisualNeuron, SensoryNeuron, or MotorNeuron\n- Provides observation interface without modifying original\n\n## Files\n- `src/theater/specimen/Specimen.ts`\n- `src/theater/specimen/Specimen.test.ts`", + "labels": ["Phase 6.2", "specimen", "enhancement"], + "milestone": "Phase 6.2: Specimen System" + }, + { + "title": "Implement Observation (variations)", + "body": "## Description\nCreate the Observation system for component state/props variations.\n\n## Acceptance Criteria\n- [ ] Observation class with state snapshots\n- [ ] Props variation management\n- [ ] Named observations (\"Default\", \"Loading\", \"Error\")\n- [ ] Observation groups and categories\n- [ ] Hot reload support\n- [ ] Unit tests (>90% coverage)\n\n## Technical Notes\n```typescript\ninterface Observation {\n name: string;\n props: Record;\n state?: Record;\n signals?: Record;\n}\n```\n\n## Files\n- `src/theater/specimen/Observation.ts`\n- `src/theater/specimen/Observation.test.ts`", + "labels": ["Phase 6.2", "specimen", "enhancement"], + "milestone": "Phase 6.2: Specimen System" + }, + { + "title": "Implement Dissection (component breakdown)", + "body": "## Description\nCreate the Dissection tool that breaks down component structure.\n\n## Acceptance Criteria\n- [ ] Props table with types and descriptions\n- [ ] Signal interface documentation\n- [ ] Children/composition analysis\n- [ ] State structure visualization\n- [ ] TypeScript type extraction\n- [ ] Unit tests (>90% coverage)\n\n## Technical Notes\n- Uses TypeScript Compiler API for type extraction\n- Parses JSDoc comments for descriptions\n\n## Files\n- `src/theater/specimen/Dissection.ts`\n- `src/theater/specimen/Dissection.test.ts`", + "labels": ["Phase 6.2", "specimen", "enhancement"], + "milestone": "Phase 6.2: Specimen System" + }, + { + "title": "Implement Microscope hub", + "body": "## Description\nCreate the Microscope tool hub for component inspection.\n\n## Acceptance Criteria\n- [ ] Microscope class with tool management\n- [ ] Mode switching (signal-trace, state-explorer, performance, health)\n- [ ] Tool panel integration\n- [ ] Real-time data streaming\n- [ ] Export/import inspection data\n- [ ] Unit tests (>90% coverage)\n\n## Files\n- `src/theater/microscope/Microscope.ts`\n- `src/theater/microscope/Microscope.test.ts`", + "labels": ["Phase 6.3", "microscope", "enhancement"], + "milestone": "Phase 6.3: Microscope Tools" + }, + { + "title": "Implement SignalTracer (neural signal visualization)", + "body": "## Description\nCreate the SignalTracer tool for visualizing neural signals in real-time.\n\n## Acceptance Criteria\n- [ ] Real-time signal flow visualization\n- [ ] Signal strength indicators\n- [ ] Connection topology graph\n- [ ] Signal history timeline\n- [ ] Filter by signal type\n- [ ] Record and replay signals\n- [ ] Unit tests (>90% coverage)\n\n## Technical Notes\n- Integrates with NeuralNode event emitter\n- Uses canvas/SVG for visualization\n- WebSocket for real-time updates\n\n## Files\n- `src/theater/microscope/SignalTracer.ts`\n- `src/theater/microscope/SignalTracer.test.ts`", + "labels": ["Phase 6.3", "microscope", "visualization", "enhancement"], + "milestone": "Phase 6.3: Microscope Tools" + }, + { + "title": "Implement StateExplorer (time-travel debugging)", + "body": "## Description\nCreate the StateExplorer tool with time-travel debugging capabilities.\n\n## Acceptance Criteria\n- [ ] State history timeline\n- [ ] Undo/redo state changes\n- [ ] Jump to any state snapshot\n- [ ] State diff visualization\n- [ ] Export/import state snapshots\n- [ ] Integration with VisualAstrocyte\n- [ ] Unit tests (>90% coverage)\n\n## Technical Notes\n- Leverages existing VisualAstrocyte time-travel features\n- Visual diff algorithm for state changes\n\n## Files\n- `src/theater/microscope/StateExplorer.ts`\n- `src/theater/microscope/StateExplorer.test.ts`", + "labels": ["Phase 6.3", "microscope", "debugging", "enhancement"], + "milestone": "Phase 6.3: Microscope Tools" + }, + { + "title": "Implement PerformanceProfiler", + "body": "## Description\nCreate the PerformanceProfiler tool integrating with VisualOligodendrocyte.\n\n## Acceptance Criteria\n- [ ] Render time measurement\n- [ ] Memory usage tracking\n- [ ] Myelination effectiveness metrics\n- [ ] Resource caching statistics\n- [ ] Performance timeline\n- [ ] Flame graph visualization\n- [ ] Unit tests (>90% coverage)\n\n## Technical Notes\n- Uses Performance API\n- Integrates with VisualOligodendrocyte metrics\n\n## Files\n- `src/theater/microscope/PerformanceProfiler.ts`\n- `src/theater/microscope/PerformanceProfiler.test.ts`", + "labels": ["Phase 6.3", "microscope", "performance", "enhancement"], + "milestone": "Phase 6.3: Microscope Tools" + }, + { + "title": "Implement HealthMonitor", + "body": "## Description\nCreate the HealthMonitor tool integrating with Microglia.\n\n## Acceptance Criteria\n- [ ] Real-time error tracking\n- [ ] Performance metrics dashboard\n- [ ] Memory leak detection\n- [ ] Signal latency monitoring\n- [ ] Health score calculation\n- [ ] Alert system\n- [ ] Unit tests (>90% coverage)\n\n## Technical Notes\n- Integrates with Microglia health checks\n- Real-time metrics streaming\n\n## Files\n- `src/theater/microscope/HealthMonitor.ts`\n- `src/theater/microscope/HealthMonitor.test.ts`", + "labels": ["Phase 6.3", "microscope", "monitoring", "enhancement"], + "milestone": "Phase 6.3: Microscope Tools" + }, + { + "title": "Implement Laboratory (testing environment)", + "body": "## Description\nCreate the Laboratory for component testing and experimentation.\n\n## Acceptance Criteria\n- [ ] Laboratory class with test management\n- [ ] Isolated test environment\n- [ ] Test result aggregation\n- [ ] Experiment tracking\n- [ ] Report generation\n- [ ] Unit tests (>90% coverage)\n\n## Files\n- `src/theater/laboratory/Laboratory.ts`\n- `src/theater/laboratory/Laboratory.test.ts`", + "labels": ["Phase 6.4", "laboratory", "testing", "enhancement"], + "milestone": "Phase 6.4: Laboratory" + }, + { + "title": "Implement PetriDish (isolated testing)", + "body": "## Description\nCreate the PetriDish for isolated component testing.\n\n## Acceptance Criteria\n- [ ] Component isolation (no side effects)\n- [ ] Mock data injection\n- [ ] Signal simulation\n- [ ] State manipulation\n- [ ] Snapshot testing\n- [ ] Unit tests (>90% coverage)\n\n## Files\n- `src/theater/laboratory/PetriDish.ts`\n- `src/theater/laboratory/PetriDish.test.ts`", + "labels": ["Phase 6.4", "laboratory", "testing", "enhancement"], + "milestone": "Phase 6.4: Laboratory" + }, + { + "title": "Implement Culture (test scenarios)", + "body": "## Description\nCreate the Culture system for test scenario management.\n\n## Acceptance Criteria\n- [ ] Test scenario definition\n- [ ] Setup/teardown hooks\n- [ ] Assertion framework\n- [ ] Async test support\n- [ ] Test data fixtures\n- [ ] Unit tests (>90% coverage)\n\n## Files\n- `src/theater/laboratory/Culture.ts`\n- `src/theater/laboratory/Culture.test.ts`", + "labels": ["Phase 6.4", "laboratory", "testing", "enhancement"], + "milestone": "Phase 6.4: Laboratory" + }, + { + "title": "Implement Experiment (A/B testing)", + "body": "## Description\nCreate the Experiment system for A/B testing and variant comparison.\n\n## Acceptance Criteria\n- [ ] Multi-variant testing\n- [ ] Metrics tracking (CTR, engagement, performance)\n- [ ] Statistical significance calculation\n- [ ] Automatic winner selection\n- [ ] Neuroplasticity integration\n- [ ] Unit tests (>90% coverage)\n\n## Technical Notes\n- Integrates with Neuroplasticity system\n- Statistical analysis for variant comparison\n\n## Files\n- `src/theater/laboratory/Experiment.ts`\n- `src/theater/laboratory/Experiment.test.ts`", + "labels": ["Phase 6.4", "laboratory", "testing", "enhancement"], + "milestone": "Phase 6.4: Laboratory" + }, + { + "title": "Implement Atlas (documentation hub)", + "body": "## Description\nCreate the Atlas system for auto-documentation generation.\n\n## Acceptance Criteria\n- [ ] Atlas class with documentation management\n- [ ] Markdown generation\n- [ ] Component catalogue\n- [ ] Architecture diagrams\n- [ ] Search functionality\n- [ ] Unit tests (>90% coverage)\n\n## Files\n- `src/theater/atlas/Atlas.ts`\n- `src/theater/atlas/Atlas.test.ts`", + "labels": ["Phase 6.5", "atlas", "documentation", "enhancement"], + "milestone": "Phase 6.5: Atlas" + }, + { + "title": "Implement ComponentCatalogue", + "body": "## Description\nCreate the ComponentCatalogue for component registry and documentation.\n\n## Acceptance Criteria\n- [ ] Auto-discovery of components\n- [ ] TypeScript metadata extraction\n- [ ] JSDoc parsing\n- [ ] Props documentation\n- [ ] Usage examples from tests\n- [ ] Unit tests (>90% coverage)\n\n## Technical Notes\n- Uses TypeScript Compiler API\n- Parses test files for examples\n\n## Files\n- `src/theater/atlas/ComponentCatalogue.ts`\n- `src/theater/atlas/ComponentCatalogue.test.ts`", + "labels": ["Phase 6.5", "atlas", "documentation", "enhancement"], + "milestone": "Phase 6.5: Atlas" + }, + { + "title": "Implement Diagram (architecture visualization)", + "body": "## Description\nCreate the Diagram system for visualizing component architecture.\n\n## Acceptance Criteria\n- [ ] Neural circuit topology visualization\n- [ ] Component hierarchy tree\n- [ ] Signal flow diagrams\n- [ ] Connection graph\n- [ ] Interactive exploration\n- [ ] Export to SVG/PNG\n- [ ] Unit tests (>90% coverage)\n\n## Technical Notes\n- Uses D3.js or similar for visualization\n- Graph layout algorithms\n\n## Files\n- `src/theater/atlas/Diagram.ts`\n- `src/theater/atlas/Diagram.test.ts`", + "labels": ["Phase 6.5", "atlas", "visualization", "enhancement"], + "milestone": "Phase 6.5: Atlas" + }, + { + "title": "Implement Protocol (usage guides)", + "body": "## Description\nCreate the Protocol system for generating usage guides and best practices.\n\n## Acceptance Criteria\n- [ ] Usage pattern detection\n- [ ] Best practice recommendations\n- [ ] Code example generation\n- [ ] Integration guides\n- [ ] Markdown export\n- [ ] Unit tests (>90% coverage)\n\n## Files\n- `src/theater/atlas/Protocol.ts`\n- `src/theater/atlas/Protocol.test.ts`", + "labels": ["Phase 6.5", "atlas", "documentation", "enhancement"], + "milestone": "Phase 6.5: Atlas" + }, + { + "title": "Implement TheaterServer", + "body": "## Description\nCreate the development server for The Anatomy Theater.\n\n## Acceptance Criteria\n- [ ] HTTP server with static file serving\n- [ ] WebSocket support for real-time updates\n- [ ] Hot module replacement (HMR)\n- [ ] API endpoints for data\n- [ ] CORS configuration\n- [ ] HTTPS support\n- [ ] Unit tests (>90% coverage)\n\n## Technical Notes\n- Built on Node.js HTTP/HTTPS\n- WebSocket for live updates\n- Integration with Vite for HMR\n\n## Files\n- `src/theater/server/TheaterServer.ts`\n- `src/theater/server/TheaterServer.test.ts`", + "labels": ["Phase 6.6", "server", "enhancement"], + "milestone": "Phase 6.6: Server & Hot Reload" + }, + { + "title": "Implement HotReload system", + "body": "## Description\nCreate the HotReload system for live code updates.\n\n## Acceptance Criteria\n- [ ] File watcher for component changes\n- [ ] Module reloading without full refresh\n- [ ] State preservation across reloads\n- [ ] Error overlay\n- [ ] Reload notifications\n- [ ] Unit tests (>90% coverage)\n\n## Technical Notes\n- Integration with Vite HMR\n- File system watcher (chokidar)\n\n## Files\n- `src/theater/server/HotReload.ts`\n- `src/theater/server/HotReload.test.ts`", + "labels": ["Phase 6.6", "server", "enhancement"], + "milestone": "Phase 6.6: Server & Hot Reload" + }, + { + "title": "Implement WebSocket communication", + "body": "## Description\nCreate the WebSocket layer for real-time theater updates.\n\n## Acceptance Criteria\n- [ ] WebSocket server\n- [ ] Client connection management\n- [ ] Message protocol\n- [ ] Reconnection handling\n- [ ] Broadcast and targeted messages\n- [ ] Unit tests (>90% coverage)\n\n## Files\n- `src/theater/server/WebSocket.ts`\n- `src/theater/server/WebSocket.test.ts`", + "labels": ["Phase 6.6", "server", "enhancement"], + "milestone": "Phase 6.6: Server & Hot Reload" + }, + { + "title": "Implement Theater CLI", + "body": "## Description\nCreate the command-line interface for The Anatomy Theater.\n\n## Acceptance Criteria\n- [ ] `synapse theater` command\n- [ ] Start/stop server\n- [ ] Build for production\n- [ ] Generate documentation\n- [ ] Configuration validation\n- [ ] Unit tests (>90% coverage)\n\n## Commands\n```bash\nsynapse theater start [options]\nsynapse theater build [options]\nsynapse theater docs [options]\n```\n\n## Files\n- `cli/commands/theater.ts`\n- `cli/commands/theater.test.ts`", + "labels": ["Phase 6.7", "cli", "enhancement"], + "milestone": "Phase 6.7: CLI & Configuration" + }, + { + "title": "Implement Theater configuration system", + "body": "## Description\nCreate the configuration system for Theater projects.\n\n## Acceptance Criteria\n- [ ] Config file schema (TypeScript)\n- [ ] Default configuration\n- [ ] Environment-based config\n- [ ] Validation with Skeletal system\n- [ ] Config merging\n- [ ] Unit tests (>90% coverage)\n\n## Configuration File\n```typescript\n// anatomy-theater.config.ts\nexport default {\n title: 'My Theater',\n specimens: './src/**/*.specimen.ts',\n port: 6100,\n features: {...}\n}\n```\n\n## Files\n- `src/theater/config/TheaterConfig.ts`\n- `src/theater/config/TheaterConfig.test.ts`", + "labels": ["Phase 6.7", "config", "enhancement"], + "milestone": "Phase 6.7: CLI & Configuration" + }, + { + "title": "Implement Specimen file loader", + "body": "## Description\nCreate the loader for `.specimen.ts` files.\n\n## Acceptance Criteria\n- [ ] File discovery and parsing\n- [ ] Module loading and execution\n- [ ] Hot reload support\n- [ ] Error handling\n- [ ] TypeScript compilation\n- [ ] Unit tests (>90% coverage)\n\n## Specimen File Format\n```typescript\n// Button.specimen.ts\nexport default {\n title: 'UI/Button',\n specimen: Button,\n observations: [...],\n experiments: [...]\n}\n```\n\n## Files\n- `src/theater/loader/SpecimenLoader.ts`\n- `src/theater/loader/SpecimenLoader.test.ts`", + "labels": ["Phase 6.7", "loader", "enhancement"], + "milestone": "Phase 6.7: CLI & Configuration" + }, + { + "title": "Create Theater UI components", + "body": "## Description\nBuild the UI components for The Anatomy Theater using our own system.\n\n## Acceptance Criteria\n- [ ] Navigation sidebar\n- [ ] Component grid\n- [ ] Tool panels\n- [ ] Search bar\n- [ ] Settings modal\n- [ ] All using VisualNeurons\n- [ ] Unit tests (>90% coverage)\n\n## Technical Notes\n- Dogfooding: Built entirely with Synapse UI system\n- Responsive design\n- Accessibility compliant\n\n## Files\n- `src/theater/ui/Navigation.ts`\n- `src/theater/ui/ComponentGrid.ts`\n- `src/theater/ui/ToolPanel.ts`\n- `src/theater/ui/*.test.ts`", + "labels": ["Phase 6.8", "ui", "enhancement"], + "milestone": "Phase 6.8: Integration & Polish" + }, + { + "title": "Write Theater documentation", + "body": "## Description\nWrite comprehensive documentation for The Anatomy Theater.\n\n## Acceptance Criteria\n- [ ] Getting started guide\n- [ ] Configuration reference\n- [ ] Specimen file format\n- [ ] API documentation\n- [ ] Best practices\n- [ ] Migration guide from Storybook\n- [ ] Example projects\n\n## Files\n- `docs/theater/README.md`\n- `docs/theater/getting-started.md`\n- `docs/theater/configuration.md`\n- `docs/theater/api.md`\n- `docs/theater/migration.md`", + "labels": ["Phase 6.8", "documentation"], + "milestone": "Phase 6.8: Integration & Polish" + }, + { + "title": "Create example specimens", + "body": "## Description\nCreate example specimen files for all existing Synapse components.\n\n## Acceptance Criteria\n- [ ] Button.specimen.ts\n- [ ] Form.specimen.ts\n- [ ] TouchReceptor.specimen.ts\n- [ ] TextReceptor.specimen.ts\n- [ ] All with comprehensive observations\n- [ ] Performance experiments\n- [ ] Accessibility tests\n\n## Files\n- `src/ui/components/Button.specimen.ts`\n- `src/ui/components/Form.specimen.ts`\n- `src/skin/receptors/*.specimen.ts`", + "labels": ["Phase 6.8", "examples"], + "milestone": "Phase 6.8: Integration & Polish" + }, + { + "title": "Integration testing suite", + "body": "## Description\nCreate comprehensive integration tests for The Anatomy Theater.\n\n## Acceptance Criteria\n- [ ] Server startup/shutdown\n- [ ] WebSocket communication\n- [ ] Hot reload functionality\n- [ ] Component rendering\n- [ ] Tool functionality\n- [ ] Performance benchmarks\n- [ ] >90% coverage\n\n## Files\n- `src/theater/__tests__/integration/*.test.ts`", + "labels": ["Phase 6.8", "testing"], + "milestone": "Phase 6.8: Integration & Polish" + }, + { + "title": "Performance optimization", + "body": "## Description\nOptimize The Anatomy Theater for production use.\n\n## Acceptance Criteria\n- [ ] Bundle size optimization (<100KB gzipped core)\n- [ ] Lazy loading for tools\n- [ ] Virtual scrolling for component lists\n- [ ] Memoization for expensive operations\n- [ ] Code splitting\n- [ ] Performance benchmarks\n\n## Targets\n- Initial load: <2s\n- Time to interactive: <3s\n- Component switch: <100ms\n- Signal trace overhead: <5%", + "labels": ["Phase 6.8", "performance"], + "milestone": "Phase 6.8: Integration & Polish" + }, + { + "title": "Accessibility audit and fixes", + "body": "## Description\nEnsure The Anatomy Theater is fully accessible.\n\n## Acceptance Criteria\n- [ ] WCAG 2.1 AA compliance\n- [ ] Keyboard navigation\n- [ ] Screen reader support\n- [ ] ARIA attributes\n- [ ] Color contrast\n- [ ] Focus management\n- [ ] Automated a11y tests\n\n## Tools\n- axe-core for automated testing\n- Manual testing with screen readers", + "labels": ["Phase 6.8", "accessibility"], + "milestone": "Phase 6.8: Integration & Polish" + }, + { + "title": "Production build system", + "body": "## Description\nCreate production build system for static deployment.\n\n## Acceptance Criteria\n- [ ] Static site generation\n- [ ] Asset optimization\n- [ ] CDN-ready output\n- [ ] Search index generation\n- [ ] Deployment guides (Netlify, Vercel, GitHub Pages)\n- [ ] CI/CD examples\n\n## Output\n```\ndist/\n├── index.html\n├── assets/\n│ ├── js/\n│ ├── css/\n│ └── images/\n└── data/\n └── specimens.json\n```", + "labels": ["Phase 6.8", "build"], + "milestone": "Phase 6.8: Integration & Polish" + } + ] +} diff --git a/cli/index.ts b/cli/index.ts index 9bbc1ed..ce162cd 100644 --- a/cli/index.ts +++ b/cli/index.ts @@ -1,6 +1,5 @@ #!/usr/bin/env node -/* eslint-disable no-console */ import { Command } from 'commander'; import { generateNeuron, generateGlial, generateCircuit, generateEvent } from './commands/generate'; import { GLIAL_TYPES } from './utils/validation'; diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..8c51569 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,119 @@ +// @ts-check +import eslint from '@eslint/js'; +import tseslint from 'typescript-eslint'; +import prettierConfig from 'eslint-config-prettier'; +import prettierPlugin from 'eslint-plugin-prettier'; + +export default tseslint.config( + // Base ESLint recommended rules + eslint.configs.recommended, + + // TypeScript ESLint recommended rules + ...tseslint.configs.recommended, + ...tseslint.configs.recommendedTypeChecked, + ...tseslint.configs.strict, + + // Prettier config (disables conflicting rules) + prettierConfig, + + // Global configuration + { + languageOptions: { + parser: tseslint.parser, + parserOptions: { + ecmaVersion: 2022, + sourceType: 'module', + project: './tsconfig.eslint.json', + }, + }, + plugins: { + '@typescript-eslint': tseslint.plugin, + prettier: prettierPlugin, + }, + rules: { + 'prettier/prettier': 'error', + '@typescript-eslint/explicit-function-return-type': 'error', + '@typescript-eslint/no-explicit-any': 'error', + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + '@typescript-eslint/strict-boolean-expressions': 'error', + '@typescript-eslint/no-floating-promises': 'error', + '@typescript-eslint/no-misused-promises': 'error', + '@typescript-eslint/await-thenable': 'error', + '@typescript-eslint/no-unnecessary-condition': 'error', + '@typescript-eslint/prefer-nullish-coalescing': 'error', + '@typescript-eslint/prefer-optional-chain': 'error', + '@typescript-eslint/consistent-type-definitions': ['error', 'interface'], + '@typescript-eslint/consistent-type-imports': 'error', + 'no-console': ['warn', { allow: ['warn', 'error'] }], + eqeqeq: ['error', 'always'], + 'no-var': 'error', + 'prefer-const': 'error', + 'prefer-arrow-callback': 'error', + }, + }, + + // Test files configuration + { + files: ['**/*.test.ts', '**/*.spec.ts', '**/__tests__/**/*.ts'], + rules: { + '@typescript-eslint/no-unsafe-assignment': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/no-unsafe-call': 'off', + '@typescript-eslint/no-unsafe-return': 'off', + '@typescript-eslint/no-unsafe-argument': 'off', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-non-null-assertion': 'off', + '@typescript-eslint/no-floating-promises': 'off', + '@typescript-eslint/require-await': 'off', + '@typescript-eslint/no-unused-vars': 'off', + '@typescript-eslint/strict-boolean-expressions': 'off', + }, + }, + + // Storybook files configuration + { + files: ['**/*.stories.ts', '**/*.stories.tsx'], + languageOptions: { + parserOptions: { + project: null, + }, + }, + rules: { + '@typescript-eslint/no-unsafe-assignment': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/no-unsafe-call': 'off', + '@typescript-eslint/no-unsafe-return': 'off', + '@typescript-eslint/no-unsafe-argument': 'off', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-non-null-assertion': 'off', + '@typescript-eslint/no-floating-promises': 'off', + '@typescript-eslint/no-base-to-string': 'off', + '@typescript-eslint/no-unnecessary-condition': 'off', + '@typescript-eslint/strict-boolean-expressions': 'off', + '@typescript-eslint/no-misused-promises': 'off', + '@typescript-eslint/await-thenable': 'off', + '@typescript-eslint/prefer-nullish-coalescing': 'off', + '@typescript-eslint/prefer-optional-chain': 'off', + '@typescript-eslint/require-await': 'off', + 'no-console': 'off', + }, + }, + + // Ignore patterns + { + ignores: [ + 'dist/**', + 'node_modules/**', + '**/*.js', + '**/*.stories.ts', + '**/*.stories.tsx', + '.storybook/**', + '**/*.test.ts', + '**/*.spec.ts', + '**/__tests__/**', + 'src/skin/**', + ], + }, +); diff --git a/examples/theater-demo/ButtonComponent.ts b/examples/theater-demo/ButtonComponent.ts new file mode 100644 index 0000000..76daf9b --- /dev/null +++ b/examples/theater-demo/ButtonComponent.ts @@ -0,0 +1,112 @@ +/** + * Example Button Component for Theater Demo + * + * This is a simple button component used to demonstrate + * The Anatomy Theater's capabilities. + */ + +import { VisualNeuron } from '../../src/ui/VisualNeuron'; + +export interface ButtonProps { + label: string; + variant?: 'primary' | 'secondary' | 'danger'; + disabled?: boolean; + onClick?: () => void; +} + +export interface ButtonState { + pressed: boolean; + clickCount: number; +} + +export class ButtonComponent extends VisualNeuron { + constructor(props: ButtonProps) { + super(props, { + pressed: false, + clickCount: 0, + }); + } + + protected shouldUpdate(oldProps: ButtonProps, newProps: ButtonProps): boolean { + return ( + oldProps.label !== newProps.label || + oldProps.variant !== newProps.variant || + oldProps.disabled !== newProps.disabled + ); + } + + protected getStyles(): Record { + const { variant = 'primary', disabled } = this.props; + const { pressed } = this.state; + + const baseStyles = { + padding: '8px 16px', + borderRadius: '4px', + border: 'none', + cursor: disabled ? 'not-allowed' : 'pointer', + fontSize: '14px', + fontWeight: '500', + transition: 'all 0.2s', + opacity: disabled ? 0.5 : 1, + }; + + const variantStyles = { + primary: { + background: pressed ? '#0056b3' : '#007bff', + color: '#ffffff', + }, + secondary: { + background: pressed ? '#5a6268' : '#6c757d', + color: '#ffffff', + }, + danger: { + background: pressed ? '#c82333' : '#dc3545', + color: '#ffffff', + }, + }; + + return { + ...baseStyles, + ...variantStyles[variant], + }; + } + + public handleClick(): void { + if (this.props.disabled) { + return; + } + + this.setState({ + pressed: true, + clickCount: this.state.clickCount + 1, + }); + + // Reset pressed state after a short delay + setTimeout(() => { + this.setState({ pressed: false }); + }, 100); + + // Call onClick handler if provided + this.props.onClick?.(); + + // Emit neural signal + this.emit('clicked', { + clickCount: this.state.clickCount + 1, + timestamp: Date.now(), + }); + } + + protected render(): Record { + const { label } = this.props; + const styles = this.getStyles(); + + return { + type: 'button', + props: { + style: styles, + onClick: () => this.handleClick(), + }, + children: [label], + }; + } +} diff --git a/examples/theater-demo/README.md b/examples/theater-demo/README.md new file mode 100644 index 0000000..f216ef8 --- /dev/null +++ b/examples/theater-demo/README.md @@ -0,0 +1,364 @@ +# The Anatomy Theater - Complete Demo + +This directory contains a comprehensive demonstration of **The Anatomy Theater**, a component development and documentation system for the Synapse framework. + +## Overview + +The Anatomy Theater is a powerful alternative to Storybook, designed specifically for the Synapse framework with medical-themed terminology and neural-inspired architecture. + +## What's Included + +### 1. Button Component (`ButtonComponent.ts`) + +A sample button component that demonstrates: +- Props management (label, variant, disabled, onClick) +- State management (pressed, clickCount) +- Event emission and neural signals +- Style variants (primary, secondary, danger) +- Interaction handling + +### 2. Specimens (`button.specimens.ts`) + +Showcases how to create component variations with: +- **Specimen metadata** (id, name, category, tags, description) +- **Observations** - behavioral assertions and tests +- **Dissections** - structural documentation of props and state +- **Multiple variants** (Default, Primary, Secondary, Danger, Disabled, Interactive) + +### 3. Laboratory Tests (`button.laboratory.ts`) + +Demonstrates the testing system with: +- **Laboratory** - test orchestrator +- **Experiments** - individual test scenarios +- **TestSubject** - component testing wrapper +- **Hypothesis** - behavioral assertions +- **Multiple test cases** covering rendering, clicks, states, variants + +### 4. Atlas Documentation (`button.atlas.ts`) + +Shows comprehensive documentation with: +- **Atlas** - documentation hub with search and aggregation +- **ComponentCatalogue** - component inventory with dependencies +- **Diagram** - visual documentation (state machines, hierarchies) +- **Protocol** - usage guidelines and best practices +- **WCAG guidelines** - accessibility documentation + +### 5. Complete Integration (`theater.complete.ts`) + +Full theater environment including: +- **Theater** - main orchestrator +- **Stage** - component rendering platform +- **Amphitheater** - component gallery +- **TheaterServer** - development server +- **HotReload** - file watching with hot reload +- **WebSocketBridge** - real-time communication + +### 6. Integration Tests (`__tests__/integration.test.ts`) + +Comprehensive tests verifying: +- Component functionality +- Specimen system +- Laboratory testing +- Atlas documentation +- Server components +- Full integration + +## The Anatomy Theater Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ THE ANATOMY THEATER │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────┐ ┌────────┐ ┌──────────────┐ │ +│ │ Theater │───▶│ Stage │───▶│ Amphitheater │ │ +│ │ (Core) │ │(Render)│ │ (Gallery) │ │ +│ └──────────┘ └────────┘ └──────────────┘ │ +│ │ +│ ┌──────────┐ ┌────────────┐ ┌──────────┐ │ +│ │ Specimen │───▶│ Laboratory │◀──│ Atlas │ │ +│ │(Variants)│ │ (Testing) │ │ (Docs) │ │ +│ └──────────┘ └────────────┘ └──────────┘ │ +│ │ +│ ┌──────────────┐ ┌────────────┐ ┌───────────┐ │ +│ │TheaterServer │─▶│ HotReload │─▶│ WebSocket │ │ +│ │(Dev Server) │ │ (Watch) │ │ (Bridge) │ │ +│ └──────────────┘ └────────────┘ └───────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Key Features + +### 🎭 Theater Core +- Component lifecycle management +- State orchestration +- Event-driven architecture +- Real-time updates + +### 📦 Specimen System +- Component variations and states +- Behavioral observations +- Structural dissections +- Metadata and categorization + +### 🧪 Laboratory +- Component testing framework +- Hypothesis-based assertions +- Test subject wrapper +- Experiment orchestration +- Rich reporting + +### 📚 Atlas +- Comprehensive documentation +- Component catalogue +- Visual diagrams (Mermaid/GraphViz) +- Usage protocols and best practices +- WCAG accessibility guidelines +- Search and filtering + +### 🚀 Development Server +- HTTP development server +- Hot module reload +- WebSocket real-time communication +- File watching and change detection +- Broadcast channels + +## Usage Examples + +### Creating a Specimen + +```typescript +import { Specimen } from '../../src/theater/specimens/Specimen'; +import { createObservations } from '../../src/theater/specimens/Observation'; + +const ButtonSpecimen = new Specimen( + { + id: 'button-primary', + name: 'Primary Button', + category: 'Forms', + tags: ['interactive', 'button'], + }, + (context) => { + const button = new ButtonComponent({ label: 'Click Me', variant: 'primary' }); + button.activate(); + return button.render(); + } +); + +// Add observations +ButtonSpecimen.addObservation( + createObservations('button-tests', [ + { + name: 'Renders correctly', + description: 'Button should render with label', + assert: (context) => context.component !== undefined, + }, + ]) +); +``` + +### Running Laboratory Tests + +```typescript +import { Laboratory } from '../../src/theater/laboratory/Laboratory'; +import { Experiment } from '../../src/theater/laboratory/Experiment'; + +const lab = new Laboratory({ name: 'Button Tests' }); + +const experiment = new Experiment({ + id: 'button-click', + name: 'Click Behavior', +}); + +experiment.setTest(async (subject) => { + subject.interact({ type: 'click' }); + return subject.getComponent().getState().clickCount === 1; +}); + +lab.registerExperiment(experiment); +await lab.runAll(); + +const report = lab.generateReport(); +console.log(report.formatAs('text')); +``` + +### Creating Documentation + +```typescript +import { Atlas } from '../../src/theater/atlas/Atlas'; + +const atlas = new Atlas({ name: 'Component Docs' }); + +atlas.document({ + id: 'button', + name: 'Button', + description: 'Interactive button component', + category: 'Forms', + tags: ['button', 'interactive'], + props: [ + { + name: 'label', + type: 'string', + required: true, + description: 'Button label text', + }, + ], + state: [], + signals: [], + examples: [], + related: [], + source: 'ButtonComponent.ts', + timestamp: Date.now(), +}); + +const results = atlas.search({ text: 'button' }); +``` + +### Starting the Theater + +```typescript +import { CompleteTheaterDemo } from './theater.complete'; + +const demo = new CompleteTheaterDemo(); +await demo.start(); + +// Theater is now running with: +// - Development server at http://localhost:6006 +// - WebSocket at ws://localhost:6007 +// - Hot reload enabled +// - All specimens loaded +// - Documentation generated +``` + +## Running the Demo + +```bash +# Install dependencies +npm install + +# Run the complete demo +npm run demo:theater + +# Run integration tests +npm test examples/theater-demo + +# Run specific demo features +npm run demo:specimens # Show specimen gallery +npm run demo:laboratory # Run laboratory tests +npm run demo:docs # Generate documentation +``` + +## Medical Metaphor Terminology + +The Anatomy Theater uses medical/anatomical terminology: + +- **Theater** - The main operating theater where components are showcased +- **Stage** - The surgical stage where components are rendered +- **Amphitheater** - The observation gallery for viewing components +- **Specimen** - A component variation or state to be examined +- **Observation** - Behavioral test or assertion +- **Dissection** - Structural analysis of component anatomy +- **Laboratory** - Testing environment +- **Experiment** - Individual test scenario +- **Hypothesis** - Test assertion +- **Atlas** - Medical reference documentation +- **Protocol** - Medical procedure guidelines + +## Architecture Principles + +### Neural-Inspired +Built on Synapse's neural metaphor with: +- VisualNeuron base class for components +- Signal-based event system +- Synaptic connections between components + +### Event-Driven +All components use EventEmitter for: +- Lifecycle events +- State changes +- User interactions +- System notifications + +### Medical Precision +Emphasizes: +- Thorough examination (dissection) +- Scientific testing (laboratory) +- Detailed documentation (atlas) +- Best practices (protocols) + +### Developer Experience +Focuses on: +- Type safety with TypeScript +- Hot reload for fast iteration +- Real-time updates via WebSocket +- Comprehensive testing +- Rich documentation + +## Component Structure + +### Core Layers + +1. **Presentation Layer** (Stage, Amphitheater) + - Component rendering + - Gallery organization + - Visual presentation + +2. **Documentation Layer** (Atlas, Catalogue, Protocol) + - Component documentation + - Usage guidelines + - Dependency tracking + +3. **Testing Layer** (Laboratory, Experiment, Hypothesis) + - Component testing + - Behavioral validation + - Test reporting + +4. **Server Layer** (TheaterServer, HotReload, WebSocket) + - Development server + - File watching + - Real-time communication + +## Best Practices + +### Creating Specimens +- Use descriptive IDs and names +- Categorize appropriately +- Add relevant tags +- Include observations for key behaviors +- Provide dissection for structure + +### Writing Tests +- Create focused experiments +- Use clear hypothesis names +- Test one behavior per experiment +- Include setup and teardown +- Verify both positive and negative cases + +### Documentation +- Document all props with types +- Include usage examples +- Add accessibility guidelines +- Reference WCAG criteria +- Show common patterns + +### Development +- Enable hot reload during development +- Use WebSocket for live updates +- Monitor server statistics +- Export data for debugging +- Follow the medical metaphor + +## API Reference + +See the main Theater documentation for complete API reference: +- `/src/theater/core/` - Core components +- `/src/theater/specimens/` - Specimen system +- `/src/theater/laboratory/` - Testing framework +- `/src/theater/atlas/` - Documentation system +- `/src/theater/server/` - Development server +- `/src/theater/instruments/` - Debugging tools + +## License + +Part of the Synapse framework. diff --git a/examples/theater-demo/button.atlas.ts b/examples/theater-demo/button.atlas.ts new file mode 100644 index 0000000..e2a72d2 --- /dev/null +++ b/examples/theater-demo/button.atlas.ts @@ -0,0 +1,365 @@ +/** + * Button Component Documentation + * + * Demonstrates how to use The Anatomy Theater's Atlas + * to create comprehensive component documentation. + */ + +import { Atlas } from '../../src/theater/atlas/Atlas'; +import { ComponentCatalogue } from '../../src/theater/atlas/ComponentCatalogue'; +import { Diagram } from '../../src/theater/atlas/Diagram'; +import { Protocol } from '../../src/theater/atlas/Protocol'; +import type { ComponentDocumentation } from '../../src/theater/atlas/Atlas'; +import type { CatalogueEntry } from '../../src/theater/atlas/ComponentCatalogue'; + +/** + * Create Atlas instance + */ +export const ButtonAtlas = new Atlas({ + name: 'Button Documentation', + autoDocument: true, +}); + +/** + * Button Component Documentation + */ +const buttonDocumentation: ComponentDocumentation = { + id: 'button', + name: 'Button', + description: + 'A versatile button component that supports multiple variants, states, and interactive behaviors. Built on top of VisualNeuron for reactive state management.', + category: 'Forms', + tags: ['button', 'interactive', 'form', 'ui', 'input'], + + props: [ + { + name: 'label', + type: 'string', + required: true, + description: 'The text content displayed on the button', + default: undefined, + }, + { + name: 'variant', + type: "'primary' | 'secondary' | 'danger'", + required: false, + description: 'Visual style variant of the button', + default: 'primary', + }, + { + name: 'disabled', + type: 'boolean', + required: false, + description: 'Whether the button is disabled and non-interactive', + default: false, + }, + { + name: 'onClick', + type: '() => void', + required: false, + description: 'Callback function invoked when button is clicked', + default: undefined, + }, + ], + + state: [ + { + name: 'pressed', + type: 'boolean', + description: 'Indicates whether the button is currently in pressed state', + default: false, + }, + { + name: 'clickCount', + type: 'number', + description: 'Tracks the total number of times the button has been clicked', + default: 0, + }, + ], + + signals: [ + { + name: 'clicked', + description: 'Emitted when the button is successfully clicked', + payload: { + clickCount: 'number', + timestamp: 'number', + }, + }, + ], + + examples: [ + { + title: 'Basic Usage', + description: 'Create a simple button with a label', + code: `const button = new ButtonComponent({ + label: 'Click Me' +}); +button.activate();`, + language: 'typescript', + }, + { + title: 'With Click Handler', + description: 'Button with custom click handler', + code: `const button = new ButtonComponent({ + label: 'Submit', + variant: 'primary', + onClick: () => { + console.log('Button clicked!'); + } +});`, + language: 'typescript', + }, + { + title: 'Disabled State', + description: 'Create a disabled button', + code: `const button = new ButtonComponent({ + label: 'Disabled', + disabled: true +});`, + language: 'typescript', + }, + { + title: 'Danger Variant', + description: 'Use danger variant for destructive actions', + code: `const deleteButton = new ButtonComponent({ + label: 'Delete', + variant: 'danger', + onClick: () => handleDelete() +});`, + language: 'typescript', + }, + ], + + related: [], + source: 'examples/theater-demo/ButtonComponent.ts', + timestamp: Date.now(), +}; + +// Add documentation to Atlas +ButtonAtlas.document(buttonDocumentation); + +/** + * Create Component Catalogue + */ +export const ButtonCatalogue = new ComponentCatalogue({ + name: 'UI Components Catalogue', +}); + +const buttonEntry: CatalogueEntry = { + id: 'button', + name: 'Button', + description: 'Interactive button component', + category: 'Forms', + tags: ['interactive', 'form'], + path: 'examples/theater-demo/ButtonComponent.ts', + dependencies: ['VisualNeuron'], + dependents: [], + version: '1.0.0', + status: 'stable', + stability: 'stable', + popularity: 0, + lastModified: Date.now(), +}; + +ButtonCatalogue.add(buttonEntry); + +/** + * Create Component Diagrams + */ +export const ButtonDiagram = new Diagram(); + +// Generate component hierarchy diagram +export function generateHierarchyDiagram(): string { + return ButtonDiagram.generateComponentHierarchy( + [buttonDocumentation], + { + type: 'component-hierarchy', + format: 'mermaid', + title: 'Button Component Hierarchy', + direction: 'TB', + }, + ); +} + +// Generate state machine diagram +export function generateStateMachineDiagram(): string { + return ButtonDiagram.generateStateMachine( + [ + { name: 'idle', type: 'initial', description: 'Button is ready' }, + { name: 'pressed', type: 'active', description: 'Button is being pressed' }, + { name: 'clicked', type: 'active', description: 'Click event fired' }, + { name: 'disabled', type: 'final', description: 'Button is disabled' }, + ], + [ + { from: 'idle', to: 'pressed', trigger: 'mousedown' }, + { from: 'pressed', to: 'clicked', trigger: 'mouseup' }, + { from: 'clicked', to: 'idle', trigger: 'reset' }, + { from: 'idle', to: 'disabled', trigger: 'disable', guard: 'isDisabled' }, + ], + { + type: 'state-machine', + format: 'mermaid', + title: 'Button State Machine', + }, + ); +} + +/** + * Create Component Protocol (Best Practices) + */ +export const ButtonProtocol = new Protocol({ + name: 'Button Best Practices', + enforceSeverity: true, + includeExamples: true, + autoGenerateChecklists: true, +}); + +// Add usage pattern +ButtonProtocol.createUsagePattern( + 'button-usage-1', + 'Use Descriptive Labels', + 'Button labels should clearly describe the action that will be performed', + [ + { + title: 'Good Example', + description: 'Clear, action-oriented label', + code: `', + language: 'html', + good, + explanation: 'Example explanation', + }); + + const createMockGuideline = (id: string): ProtocolGuideline => ({ + id, + title: `Guideline ${id}`, + description: 'Guideline description', + type: 'usage', + severity: 'recommended', + explanation: 'Detailed explanation', + examples: [], + related: [], + tags: [], + references: [], + timestamp: Date.now(), + }); + + beforeEach(() => { + protocol = new Protocol({ name: 'Test Protocol' }); + }); + + describe('Construction', () => { + it('should create protocol with default config', () => { + const defaultProtocol = new Protocol(); + expect(defaultProtocol).toBeInstanceOf(Protocol); + }); + + it('should create protocol with custom config', () => { + const customProtocol = new Protocol({ + name: 'Custom Protocol', + enforceSeverity: true, + includeExamples: true, + autoGenerateChecklists: true, + }); + expect(customProtocol).toBeInstanceOf(Protocol); + }); + }); + + describe('Guidelines', () => { + it('should add guideline', () => { + const guideline = createMockGuideline('test-1'); + protocol.addGuideline(guideline); + + expect(protocol.getGuideline('test-1')).toEqual(guideline); + }); + + it('should emit guideline:added event', (done) => { + const guideline = createMockGuideline('test-1'); + + protocol.on('guideline:added', (event: { id: string }) => { + expect(event.id).toBe('test-1'); + done(); + }); + + protocol.addGuideline(guideline); + }); + + it('should get all guidelines', () => { + protocol.addGuideline(createMockGuideline('test-1')); + protocol.addGuideline(createMockGuideline('test-2')); + + expect(protocol.getAllGuidelines()).toHaveLength(2); + }); + + it('should remove guideline', () => { + const guideline = createMockGuideline('test-1'); + protocol.addGuideline(guideline); + + expect(protocol.removeGuideline('test-1')).toBe(true); + expect(protocol.getGuideline('test-1')).toBeUndefined(); + }); + + it('should emit guideline:removed event', (done) => { + const guideline = createMockGuideline('test-1'); + protocol.addGuideline(guideline); + + protocol.on('guideline:removed', (event: { id: string }) => { + expect(event.id).toBe('test-1'); + done(); + }); + + protocol.removeGuideline('test-1'); + }); + }); + + describe('Search', () => { + beforeEach(() => { + protocol.addGuideline({ + ...createMockGuideline('usage-1'), + type: 'usage', + severity: 'critical', + tags: ['button', 'form'], + }); + + protocol.addGuideline({ + ...createMockGuideline('accessibility-1'), + type: 'accessibility', + severity: 'important', + tags: ['wcag', 'aria'], + }); + + protocol.addGuideline({ + ...createMockGuideline('performance-1'), + type: 'performance', + severity: 'recommended', + tags: ['performance', 'optimization'], + }); + }); + + it('should search by type', () => { + const results = protocol.search({ type: 'accessibility' }); + expect(results).toHaveLength(1); + expect(results[0].id).toBe('accessibility-1'); + }); + + it('should search by severity', () => { + const results = protocol.search({ severity: 'critical' }); + expect(results).toHaveLength(1); + expect(results[0].id).toBe('usage-1'); + }); + + it('should search by tags', () => { + const results = protocol.search({ tags: ['button'] }); + expect(results).toHaveLength(1); + expect(results[0].id).toBe('usage-1'); + }); + + it('should search by text', () => { + const results = protocol.search({ text: 'accessibility' }); + expect(results).toHaveLength(1); + expect(results[0].type).toBe('accessibility'); + }); + + it('should combine multiple filters', () => { + const results = protocol.search({ + type: 'usage', + severity: 'critical', + }); + expect(results).toHaveLength(1); + expect(results[0].id).toBe('usage-1'); + }); + }); + + describe('Get By Type and Severity', () => { + beforeEach(() => { + protocol.addGuideline({ + ...createMockGuideline('test-1'), + type: 'accessibility', + severity: 'critical', + }); + + protocol.addGuideline({ + ...createMockGuideline('test-2'), + type: 'accessibility', + severity: 'important', + }); + }); + + it('should get guidelines by type', () => { + const results = protocol.getByType('accessibility'); + expect(results).toHaveLength(2); + }); + + it('should get guidelines by severity', () => { + const results = protocol.getBySeverity('critical'); + expect(results).toHaveLength(1); + expect(results[0].id).toBe('test-1'); + }); + }); + + describe('Component Protocol', () => { + it('should set component protocol', () => { + const componentProtocol: ComponentProtocol = { + componentId: 'button', + componentName: 'Button', + usagePatterns: [], + bestPractices: [], + accessibility: [ + { + ...createMockGuideline('a11y-1'), + type: 'accessibility', + severity: 'critical', + }, + ], + performance: [], + security: [], + testing: [], + checklist: [], + }; + + protocol.setComponentProtocol(componentProtocol); + + expect(protocol.getComponentProtocol('button')).toEqual(componentProtocol); + }); + + it('should emit component:protocol-set event', (done) => { + const componentProtocol: ComponentProtocol = { + componentId: 'button', + componentName: 'Button', + usagePatterns: [], + bestPractices: [], + accessibility: [], + performance: [], + security: [], + testing: [], + checklist: [], + }; + + protocol.on('component:protocol-set', (event: { componentId: string }) => { + expect(event.componentId).toBe('button'); + done(); + }); + + protocol.setComponentProtocol(componentProtocol); + }); + + it('should auto-generate checklist', () => { + const componentProtocol: ComponentProtocol = { + componentId: 'button', + componentName: 'Button', + usagePatterns: [], + bestPractices: [], + accessibility: [ + { + ...createMockGuideline('a11y-1'), + type: 'accessibility', + severity: 'critical', + }, + ], + performance: [], + security: [], + testing: [], + checklist: [], + }; + + protocol.setComponentProtocol(componentProtocol); + + const checklist = protocol.getChecklist('button'); + expect(checklist.length).toBeGreaterThan(0); + }); + }); + + describe('Checklist Generation', () => { + it('should generate checklist from guidelines', () => { + const componentProtocol: ComponentProtocol = { + componentId: 'button', + componentName: 'Button', + usagePatterns: [], + bestPractices: [], + accessibility: [ + { + ...createMockGuideline('a11y-1'), + title: 'Keyboard Accessible', + type: 'accessibility', + severity: 'critical', + }, + ], + performance: [ + { + ...createMockGuideline('perf-1'), + title: 'Optimize Rendering', + type: 'performance', + severity: 'recommended', + }, + ], + security: [], + testing: [ + { + ...createMockGuideline('test-1'), + title: 'Unit Tests', + type: 'testing', + severity: 'important', + }, + ], + checklist: [], + }; + + protocol.setComponentProtocol(componentProtocol); + const checklist = protocol.generateChecklist('button'); + + expect(checklist.length).toBeGreaterThan(0); + + // Should have accessibility item + const a11yItem = checklist.find((item) => item.category === 'Accessibility'); + expect(a11yItem).toBeDefined(); + expect(a11yItem!.required).toBe(true); + + // Should have performance item + const perfItem = checklist.find((item) => item.category === 'Performance'); + expect(perfItem).toBeDefined(); + + // Should have testing item + const testItem = checklist.find((item) => item.category === 'Testing'); + expect(testItem).toBeDefined(); + }); + + it('should mark critical items as required', () => { + const componentProtocol: ComponentProtocol = { + componentId: 'button', + componentName: 'Button', + usagePatterns: [], + bestPractices: [], + accessibility: [ + { + ...createMockGuideline('a11y-1'), + type: 'accessibility', + severity: 'critical', + }, + ], + performance: [], + security: [], + testing: [], + checklist: [], + }; + + protocol.setComponentProtocol(componentProtocol); + const checklist = protocol.getChecklist('button'); + + const criticalItem = checklist.find((item) => item.guidelineId === 'a11y-1'); + expect(criticalItem!.required).toBe(true); + }); + }); + + describe('Validation', () => { + beforeEach(() => { + const componentProtocol: ComponentProtocol = { + componentId: 'button', + componentName: 'Button', + usagePatterns: [], + bestPractices: [], + accessibility: [ + { + ...createMockGuideline('a11y-1'), + type: 'accessibility', + severity: 'critical', + }, + { + ...createMockGuideline('a11y-2'), + type: 'accessibility', + severity: 'recommended', + }, + ], + performance: [], + security: [], + testing: [], + checklist: [], + }; + + protocol.setComponentProtocol(componentProtocol); + }); + + it('should validate completed items', () => { + const checklist = protocol.getChecklist('button'); + const requiredItem = checklist.find((item) => item.required); + + const result = protocol.validate('button', [requiredItem!.id]); + + expect(result.passed).toBe(true); + expect(result.missingRequired).toHaveLength(0); + }); + + it('should fail validation when missing required items', () => { + const result = protocol.validate('button', []); + + expect(result.passed).toBe(false); + expect(result.missingRequired.length).toBeGreaterThan(0); + }); + + it('should calculate validation score', () => { + const checklist = protocol.getChecklist('button'); + const halfCompleted = checklist + .slice(0, Math.floor(checklist.length / 2)) + .map((item) => item.id); + + const result = protocol.validate('button', halfCompleted); + + expect(result.score).toBeLessThan(100); + expect(result.score).toBeGreaterThan(0); + }); + }); + + describe('Helper Methods', () => { + it('should create usage pattern', () => { + const guideline = protocol.createUsagePattern( + 'usage-1', + 'Button Usage', + 'How to use buttons', + [createMockExample(true)], + { + severity: 'recommended', + tags: ['button', 'usage'], + }, + ); + + expect(guideline.type).toBe('usage'); + expect(protocol.getGuideline('usage-1')).toBeDefined(); + }); + + it('should create accessibility guideline', () => { + const guideline = protocol.createAccessibilityGuideline( + 'a11y-1', + 'Keyboard Navigation', + 'Support keyboard navigation', + '2.1', + '2.1.1', + { + severity: 'critical', + }, + ); + + expect(guideline.type).toBe('accessibility'); + expect(guideline.references.length).toBeGreaterThan(0); + expect(protocol.getGuideline('a11y-1')).toBeDefined(); + }); + + it('should create performance guideline', () => { + const guideline = protocol.createPerformanceGuideline( + 'perf-1', + 'Optimize Render', + 'Minimize re-renders', + 'high', + { + tags: ['performance', 'rendering'], + }, + ); + + expect(guideline.type).toBe('performance'); + expect(guideline.severity).toBe('critical'); + expect(protocol.getGuideline('perf-1')).toBeDefined(); + }); + }); + + describe('Statistics', () => { + beforeEach(() => { + protocol.addGuideline({ + ...createMockGuideline('test-1'), + type: 'accessibility', + severity: 'critical', + examples: [createMockExample(true), createMockExample(false)], + }); + + protocol.addGuideline({ + ...createMockGuideline('test-2'), + type: 'performance', + severity: 'important', + examples: [createMockExample(true)], + }); + + protocol.setComponentProtocol({ + componentId: 'button', + componentName: 'Button', + usagePatterns: [], + bestPractices: [], + accessibility: [], + performance: [], + security: [], + testing: [], + checklist: [], + }); + }); + + it('should calculate statistics', () => { + const stats = protocol.getStatistics(); + + expect(stats.totalGuidelines).toBe(2); + expect(stats.byType.accessibility).toBe(1); + expect(stats.byType.performance).toBe(1); + expect(stats.bySeverity.critical).toBe(1); + expect(stats.bySeverity.important).toBe(1); + expect(stats.totalExamples).toBe(3); + expect(stats.componentsWithProtocols).toBe(1); + }); + }); + + describe('Import/Export', () => { + it('should export protocols as JSON', () => { + protocol.addGuideline(createMockGuideline('test-1')); + + protocol.setComponentProtocol({ + componentId: 'button', + componentName: 'Button', + usagePatterns: [], + bestPractices: [], + accessibility: [], + performance: [], + security: [], + testing: [], + checklist: [], + }); + + const exported = protocol.export(); + const parsed = JSON.parse(exported); + + expect(parsed.name).toBe('Test Protocol'); + expect(parsed.guidelines).toHaveLength(1); + expect(parsed.componentProtocols).toHaveLength(1); + }); + + it('should import protocols from JSON', () => { + const guideline = createMockGuideline('test-1'); + const componentProtocol: ComponentProtocol = { + componentId: 'button', + componentName: 'Button', + usagePatterns: [], + bestPractices: [], + accessibility: [], + performance: [], + security: [], + testing: [], + checklist: [], + }; + + const json = JSON.stringify({ + guidelines: [guideline], + componentProtocols: [componentProtocol], + checklists: { button: [] }, + }); + + protocol.import(json); + + expect(protocol.getGuideline('test-1')).toBeDefined(); + expect(protocol.getComponentProtocol('button')).toBeDefined(); + }); + + it('should emit imported event', (done) => { + const json = JSON.stringify({ + guidelines: [createMockGuideline('test-1')], + componentProtocols: [], + checklists: {}, + }); + + protocol.on('imported', (event: { guidelines: number; protocols: number }) => { + expect(event.guidelines).toBe(1); + expect(event.protocols).toBe(0); + done(); + }); + + protocol.import(json); + }); + }); + + describe('Clear', () => { + beforeEach(() => { + protocol.addGuideline(createMockGuideline('test-1')); + protocol.setComponentProtocol({ + componentId: 'button', + componentName: 'Button', + usagePatterns: [], + bestPractices: [], + accessibility: [], + performance: [], + security: [], + testing: [], + checklist: [], + }); + }); + + it('should clear all data', () => { + protocol.clear(); + + expect(protocol.getAllGuidelines()).toHaveLength(0); + expect(protocol.getComponentProtocol('button')).toBeUndefined(); + }); + + it('should emit cleared event', (done) => { + protocol.on('cleared', () => { + done(); + }); + + protocol.clear(); + }); + }); +}); diff --git a/src/theater/__tests__/SignalTracer.test.ts b/src/theater/__tests__/SignalTracer.test.ts new file mode 100644 index 0000000..87833ad --- /dev/null +++ b/src/theater/__tests__/SignalTracer.test.ts @@ -0,0 +1,183 @@ +import { SignalTracer } from '../instruments/SignalTracer'; +import { VisualNeuron } from '../../ui/VisualNeuron'; + +describe('SignalTracer - Neural Signal Visualization', () => { + let tracer: SignalTracer; + + beforeEach(() => { + tracer = new SignalTracer(); + }); + + afterEach(async () => { + await tracer.cleanup(); + }); + + describe('Construction and Initialization', () => { + it('should create tracer with default config', () => { + expect(tracer).toBeDefined(); + expect(tracer.id).toBe('signal-tracer'); + expect(tracer.name).toBe('Signal Tracer'); + expect(tracer.mode).toBe('signals'); + }); + + it('should initialize with custom config', () => { + const custom = new SignalTracer({ + maxTraces: 50, + trackHistory: false, + detectCircular: false, + }); + + expect(custom).toBeDefined(); + }); + + it('should initialize and clear traces', async () => { + await tracer.initialize(); + + const traces = tracer.getAllTraces(); + expect(traces.length).toBe(0); + }); + }); + + describe('Signal Inspection', () => { + it('should inspect component signals', async () => { + const component = new VisualNeuron({ name: 'Test' }); + const result = await tracer.inspect(component); + + expect(result).toBeDefined(); + expect(result.mode).toBe('signals'); + expect(result.timestamp).toBeInstanceOf(Date); + }); + + it('should return inspection data', async () => { + const component = new VisualNeuron({ name: 'Test' }); + const result = await tracer.inspect(component); + + expect(result.data).toBeDefined(); + expect(result.data).toHaveProperty('signals'); + expect(result.data).toHaveProperty('flowGraph'); + expect(result.data).toHaveProperty('stats'); + }); + + it('should include flow graph in result', async () => { + const component = new VisualNeuron({ name: 'Test' }); + const result = await tracer.inspect(component); + + const flowGraph = (result.data as { flowGraph: { nodes: unknown[]; edges: unknown[] } }) + .flowGraph; + expect(flowGraph).toBeDefined(); + expect(flowGraph).toHaveProperty('nodes'); + expect(flowGraph).toHaveProperty('edges'); + }); + }); + + describe('Signal Traces', () => { + it('should get all traces', async () => { + const component = new VisualNeuron({ name: 'Test' }); + await tracer.inspect(component); + + const traces = tracer.getAllTraces(); + expect(Array.isArray(traces)).toBe(true); + }); + + it('should clear traces', async () => { + const component = new VisualNeuron({ name: 'Test' }); + await tracer.inspect(component); + + tracer.clearTraces(); + + const traces = tracer.getAllTraces(); + expect(traces.length).toBe(0); + }); + }); + + describe('Signal History', () => { + it('should track signal history when enabled', async () => { + const trackerWithHistory = new SignalTracer({ trackHistory: true }); + const component = new VisualNeuron({ name: 'Test' }); + + await trackerWithHistory.inspect(component); + + const history = trackerWithHistory.getHistory(); + expect(Array.isArray(history)).toBe(true); + + await trackerWithHistory.cleanup(); + }); + + it('should get history for specific signal', async () => { + const component = new VisualNeuron({ name: 'Test' }); + await tracer.inspect(component); + + const history = tracer.getHistory('signal-id'); + expect(Array.isArray(history)).toBe(true); + }); + }); + + describe('Circular Dependency Detection', () => { + it('should detect circular dependencies when enabled', async () => { + const detector = new SignalTracer({ detectCircular: true }); + const component = new VisualNeuron({ name: 'Test' }); + + const result = await detector.inspect(component); + + expect(result.issues).toBeDefined(); + expect(Array.isArray(result.issues)).toBe(true); + + await detector.cleanup(); + }); + + it('should not detect circular dependencies when disabled', async () => { + const noDetector = new SignalTracer({ detectCircular: false }); + const component = new VisualNeuron({ name: 'Test' }); + + const result = await noDetector.inspect(component); + + expect(result.issues).toBeDefined(); + + await noDetector.cleanup(); + }); + }); + + describe('Slow Signal Detection', () => { + it('should detect slow signals', async () => { + const component = new VisualNeuron({ name: 'Test' }); + const result = await tracer.inspect(component); + + expect(result.issues).toBeDefined(); + }); + }); + + describe('Flow Graph', () => { + it('should build signal flow graph', async () => { + const component = new VisualNeuron({ name: 'Test' }); + const result = await tracer.inspect(component); + + const data = result.data as { flowGraph: { nodes: unknown[]; edges: unknown[] } }; + const flowGraph = data.flowGraph; + + expect(flowGraph).toBeDefined(); + expect(Array.isArray(flowGraph.nodes)).toBe(true); + expect(Array.isArray(flowGraph.edges)).toBe(true); + }); + }); + + describe('Render', () => { + it('should render tracer UI', () => { + const html = tracer.render(); + + expect(html).toContain('signal-tracer'); + expect(html).toContain('Active Signals'); + }); + }); + + describe('Cleanup', () => { + it('should clear all data on cleanup', async () => { + const component = new VisualNeuron({ name: 'Test' }); + await tracer.inspect(component); + + await tracer.cleanup(); + + const traces = tracer.getAllTraces(); + expect(traces.length).toBe(0); + }); + }); +}); diff --git a/src/theater/__tests__/Specimen.test.ts b/src/theater/__tests__/Specimen.test.ts new file mode 100644 index 0000000..c36f2c9 --- /dev/null +++ b/src/theater/__tests__/Specimen.test.ts @@ -0,0 +1,440 @@ +import { Specimen } from '../specimens/Specimen'; + +describe('Specimen - Component Showcase Wrapper', () => { + describe('Construction', () => { + it('should create a specimen with metadata', () => { + const specimen = new Specimen( + { + id: 'button-1', + name: 'Button', + category: 'Buttons', + tags: ['ui', 'interactive'], + description: 'A button component', + }, + () => document.createElement('button'), + ); + + expect(specimen.metadata.id).toBe('button-1'); + expect(specimen.metadata.name).toBe('Button'); + expect(specimen.metadata.category).toBe('Buttons'); + expect(specimen.metadata.tags).toEqual(['ui', 'interactive']); + }); + + it('should use default context values', () => { + const specimen = new Specimen( + { + id: 'comp-1', + name: 'Component', + category: 'Test', + tags: [], + }, + (context) => { + expect(context.interactive).toBe(true); + expect(context.backgroundColor).toBe('#ffffff'); + expect(context.padding).toBe(16); + return document.createElement('div'); + }, + ); + + specimen.render(); + }); + + it('should merge custom default context', () => { + const specimen = new Specimen( + { + id: 'comp-1', + name: 'Component', + category: 'Test', + tags: [], + }, + (context) => { + expect(context.backgroundColor).toBe('#f0f0f0'); + expect(context.padding).toBe(32); + return document.createElement('div'); + }, + { + backgroundColor: '#f0f0f0', + padding: 32, + }, + ); + + specimen.render(); + }); + }); + + describe('Rendering', () => { + it('should render with default context', () => { + const specimen = new Specimen( + { + id: 'test', + name: 'Test', + category: 'Test', + tags: [], + }, + () => { + const button = document.createElement('button'); + button.textContent = 'Click me'; + return button; + }, + ); + + const element = specimen.render(); + expect(element).toBeInstanceOf(HTMLElement); + expect((element as HTMLElement).tagName).toBe('BUTTON'); + }); + + it('should render with custom context', () => { + const specimen = new Specimen( + { + id: 'test', + name: 'Test', + category: 'Test', + tags: [], + }, + (context) => { + const div = document.createElement('div'); + div.textContent = context.props!['label'] as string; + return div; + }, + ); + + const element = specimen.render({ + props: { label: 'Custom Label' }, + }); + + expect((element as HTMLElement).textContent).toBe('Custom Label'); + }); + + it('should merge context props', () => { + const specimen = new Specimen( + { + id: 'test', + name: 'Test', + category: 'Test', + tags: [], + }, + (context) => { + const div = document.createElement('div'); + div.textContent = `${context.props!['foo']},${context.props!['bar']}`; + return div; + }, + { + props: { foo: 'default' }, + }, + ); + + const element = specimen.render({ + props: { bar: 'custom' }, + }); + + expect((element as HTMLElement).textContent).toBe('default,custom'); + }); + }); + + describe('Variations', () => { + it('should add a variation', () => { + const specimen = new Specimen( + { + id: 'button', + name: 'Button', + category: 'Buttons', + tags: [], + }, + () => document.createElement('button'), + ); + + specimen.addVariation('primary', { + props: { variant: 'primary' }, + }); + + expect(specimen.hasVariation('primary')).toBe(true); + }); + + it('should remove a variation', () => { + const specimen = new Specimen( + { + id: 'button', + name: 'Button', + category: 'Buttons', + tags: [], + }, + () => document.createElement('button'), + ); + + specimen.addVariation('primary', { + props: { variant: 'primary' }, + }); + + const removed = specimen.removeVariation('primary'); + expect(removed).toBe(true); + expect(specimen.hasVariation('primary')).toBe(false); + }); + + it('should get a variation', () => { + const specimen = new Specimen( + { + id: 'button', + name: 'Button', + category: 'Buttons', + tags: [], + }, + () => document.createElement('button'), + ); + + specimen.addVariation('primary', { + props: { variant: 'primary' }, + }); + + const variation = specimen.getVariation('primary'); + expect(variation).toBeDefined(); + expect(variation!.props).toEqual({ variant: 'primary' }); + }); + + it('should get all variations', () => { + const specimen = new Specimen( + { + id: 'button', + name: 'Button', + category: 'Buttons', + tags: [], + }, + () => document.createElement('button'), + ); + + specimen.addVariation('primary', { props: { variant: 'primary' } }); + specimen.addVariation('secondary', { props: { variant: 'secondary' } }); + + const variations = specimen.getVariations(); + expect(variations.size).toBe(2); + expect(variations.has('primary')).toBe(true); + expect(variations.has('secondary')).toBe(true); + }); + + it('should render a variation', () => { + const specimen = new Specimen( + { + id: 'button', + name: 'Button', + category: 'Buttons', + tags: [], + }, + (context) => { + const button = document.createElement('button'); + button.className = context.props!['variant'] as string; + return button; + }, + ); + + specimen.addVariation('primary', { + props: { variant: 'btn-primary' }, + }); + + const element = specimen.renderVariation('primary'); + expect((element as HTMLElement).className).toBe('btn-primary'); + }); + + it('should throw error for non-existent variation', () => { + const specimen = new Specimen( + { + id: 'button', + name: 'Button', + category: 'Buttons', + tags: [], + }, + () => document.createElement('button'), + ); + + expect(() => { + specimen.renderVariation('does-not-exist'); + }).toThrow('Variation not found: does-not-exist'); + }); + + it('should render all variations', () => { + const specimen = new Specimen( + { + id: 'button', + name: 'Button', + category: 'Buttons', + tags: [], + }, + (context) => { + const button = document.createElement('button'); + button.className = context.props!['variant'] as string; + return button; + }, + ); + + specimen.addVariation('primary', { props: { variant: 'primary' } }); + specimen.addVariation('secondary', { props: { variant: 'secondary' } }); + specimen.addVariation('danger', { props: { variant: 'danger' } }); + + const rendered = specimen.renderAllVariations(); + expect(rendered.size).toBe(3); + expect((rendered.get('primary') as HTMLElement).className).toBe('primary'); + expect((rendered.get('secondary') as HTMLElement).className).toBe('secondary'); + expect((rendered.get('danger') as HTMLElement).className).toBe('danger'); + }); + + it('should support fluent API for variations', () => { + const specimen = new Specimen( + { + id: 'button', + name: 'Button', + category: 'Buttons', + tags: [], + }, + () => document.createElement('button'), + ) + .addVariation('primary', { props: { variant: 'primary' } }) + .addVariation('secondary', { props: { variant: 'secondary' } }); + + expect(specimen.hasVariation('primary')).toBe(true); + expect(specimen.hasVariation('secondary')).toBe(true); + }); + }); + + describe('Metadata Management', () => { + it('should update metadata', () => { + const specimen = new Specimen( + { + id: 'button', + name: 'Button', + category: 'Buttons', + tags: [], + }, + () => document.createElement('button'), + ); + + specimen.updateMetadata({ + description: 'Updated description', + version: '2.0.0', + }); + + expect(specimen.metadata.description).toBe('Updated description'); + expect(specimen.metadata.version).toBe('2.0.0'); + expect(specimen.metadata.updatedAt).toBeInstanceOf(Date); + }); + + it('should preserve existing metadata when updating', () => { + const specimen = new Specimen( + { + id: 'button', + name: 'Button', + category: 'Buttons', + tags: ['ui'], + }, + () => document.createElement('button'), + ); + + specimen.updateMetadata({ + description: 'New description', + }); + + expect(specimen.metadata.id).toBe('button'); + expect(specimen.metadata.name).toBe('Button'); + expect(specimen.metadata.tags).toEqual(['ui']); + }); + }); + + describe('Context Management', () => { + it('should update default context', () => { + const specimen = new Specimen( + { + id: 'test', + name: 'Test', + category: 'Test', + tags: [], + }, + (context) => { + const div = document.createElement('div'); + div.style.padding = `${context.padding}px`; + return div; + }, + ); + + specimen.updateDefaultContext({ + padding: 24, + }); + + const element = specimen.render(); + expect((element as HTMLElement).style.padding).toBe('24px'); + }); + }); + + describe('Cloning', () => { + it('should clone specimen with new metadata', () => { + const specimen = new Specimen( + { + id: 'button-1', + name: 'Button', + category: 'Buttons', + tags: ['ui'], + }, + () => document.createElement('button'), + ); + + specimen.addVariation('primary', { props: { variant: 'primary' } }); + + const cloned = specimen.clone({ + id: 'button-2', + name: 'Button Clone', + }); + + expect(cloned.metadata.id).toBe('button-2'); + expect(cloned.metadata.name).toBe('Button Clone'); + expect(cloned.metadata.category).toBe('Buttons'); // Preserved + expect(cloned.hasVariation('primary')).toBe(true); + }); + }); + + describe('Export', () => { + it('should export specimen definition', () => { + const specimen = new Specimen( + { + id: 'button', + name: 'Button', + category: 'Buttons', + tags: ['ui'], + }, + () => document.createElement('button'), + { + backgroundColor: '#f0f0f0', + }, + ); + + specimen.addVariation('primary', { props: { variant: 'primary' } }); + specimen.addVariation('secondary', { props: { variant: 'secondary' } }); + + const exported = specimen.export(); + + expect(exported.metadata.id).toBe('button'); + expect(exported.defaultContext.backgroundColor).toBe('#f0f0f0'); + expect(exported.variations['primary']).toBeDefined(); + expect(exported.variations['secondary']).toBeDefined(); + }); + }); + + describe('Statistics', () => { + it('should provide specimen statistics', () => { + const specimen = new Specimen( + { + id: 'button', + name: 'Button', + category: 'Buttons', + tags: ['ui', 'interactive'], + description: 'A button component', + }, + () => document.createElement('button'), + ); + + specimen.addVariation('primary', { props: { variant: 'primary' } }); + specimen.addVariation('secondary', { props: { variant: 'secondary' } }); + + const stats = specimen.getStats(); + + expect(stats.variationCount).toBe(2); + expect(stats.hasDescription).toBe(true); + expect(stats.tagCount).toBe(2); + }); + }); +}); diff --git a/src/theater/__tests__/Stage.test.ts b/src/theater/__tests__/Stage.test.ts new file mode 100644 index 0000000..cd32406 --- /dev/null +++ b/src/theater/__tests__/Stage.test.ts @@ -0,0 +1,338 @@ +import { Stage, VIEWPORTS } from '../core/Stage'; + +describe('Stage - Component Rendering Platform', () => { + let container: HTMLElement; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + document.body.removeChild(container); + }); + + describe('Initialization', () => { + it('should create a stage with default configuration', () => { + const stage = new Stage(); + expect(stage).toBeDefined(); + }); + + it('should create a stage with custom configuration', () => { + const stage = new Stage({ + isolation: 'shadow-dom', + viewport: { width: 800, height: 600 }, + responsive: false, + backgroundColor: '#f0f0f0', + }); + + const stats = stage.getStats(); + expect(stats.isolation).toBe('shadow-dom'); + expect(stats.backgroundColor).toBe('#f0f0f0'); + }); + + it('should initialize the stage', async () => { + const stage = new Stage(); + let initializedEventEmitted = false; + + stage.on('initialized', () => { + initializedEventEmitted = true; + }); + + await stage.initialize(container); + expect(initializedEventEmitted).toBe(true); + }); + }); + + describe('Component Mounting', () => { + it('should mount a component', async () => { + const stage = new Stage({ isolation: 'none' }); + await stage.initialize(container); + + const element = document.createElement('div'); + element.textContent = 'Test Component'; + + let mountedEventEmitted = false; + stage.on('mounted', (data) => { + mountedEventEmitted = true; + expect(data.id).toBe('test-comp'); + }); + + await stage.mount(element, 'test-comp'); + + expect(mountedEventEmitted).toBe(true); + expect(stage.hasMountedComponent()).toBe(true); + }); + + it('should get mounted component', async () => { + const stage = new Stage({ isolation: 'none' }); + await stage.initialize(container); + + const element = document.createElement('div'); + await stage.mount(element, 'my-component'); + + const mounted = stage.getMountedComponent(); + expect(mounted).not.toBeNull(); + expect(mounted!.id).toBe('my-component'); + expect(mounted!.element).toBe(element); + expect(mounted!.timestamp).toBeLessThanOrEqual(Date.now()); + }); + + it('should unmount a component', async () => { + const stage = new Stage({ isolation: 'none' }); + await stage.initialize(container); + + const element = document.createElement('div'); + await stage.mount(element, 'unmount-test'); + + expect(stage.hasMountedComponent()).toBe(true); + + let unmountedEventEmitted = false; + stage.on('unmounted', (data) => { + unmountedEventEmitted = true; + expect(data.id).toBe('unmount-test'); + }); + + await stage.unmount(); + + expect(unmountedEventEmitted).toBe(true); + expect(stage.hasMountedComponent()).toBe(false); + }); + + it('should unmount previous component when mounting new one', async () => { + const stage = new Stage({ isolation: 'none' }); + await stage.initialize(container); + + const element1 = document.createElement('div'); + const element2 = document.createElement('div'); + + await stage.mount(element1, 'first'); + expect(stage.getMountedComponent()!.id).toBe('first'); + + await stage.mount(element2, 'second'); + expect(stage.getMountedComponent()!.id).toBe('second'); + }); + + it('should throw error when mounting without initialization', async () => { + const stage = new Stage(); + const element = document.createElement('div'); + + await expect(stage.mount(element, 'error-test')).rejects.toThrow('Stage not initialized'); + }); + }); + + describe('Viewport Management', () => { + it('should set viewport size', async () => { + const stage = new Stage(); + await stage.initialize(container); + + let viewportChangeEmitted = false; + stage.on('viewport:change', (data) => { + viewportChangeEmitted = true; + expect(data.viewport.width).toBe(1024); + expect(data.viewport.height).toBe(768); + }); + + stage.setViewport({ width: 1024, height: 768, label: 'Custom' }); + + const viewport = stage.getViewport(); + expect(viewport.width).toBe(1024); + expect(viewport.height).toBe(768); + expect(viewportChangeEmitted).toBe(true); + }); + + it('should resize to specific dimensions', async () => { + const stage = new Stage(); + await stage.initialize(container); + + let resizeEmitted = false; + stage.on('resize', (data) => { + resizeEmitted = true; + expect(data.width).toBe(800); + expect(data.height).toBe(600); + }); + + stage.resize(800, 600); + + const viewport = stage.getViewport(); + expect(viewport.width).toBe(800); + expect(viewport.height).toBe(600); + expect(resizeEmitted).toBe(true); + }); + + it('should use predefined viewport sizes', async () => { + const stage = new Stage(); + await stage.initialize(container); + + stage.setViewport(VIEWPORTS.mobile); + let viewport = stage.getViewport(); + expect(viewport.width).toBe(375); + expect(viewport.height).toBe(667); + + stage.setViewport(VIEWPORTS.tablet); + viewport = stage.getViewport(); + expect(viewport.width).toBe(768); + expect(viewport.height).toBe(1024); + + stage.setViewport(VIEWPORTS.desktop); + viewport = stage.getViewport(); + expect(viewport.width).toBe(1920); + expect(viewport.height).toBe(1080); + }); + }); + + describe('Styling', () => { + it('should set background color', async () => { + const stage = new Stage(); + await stage.initialize(container); + + let backgroundChangeEmitted = false; + stage.on('background:change', (data) => { + backgroundChangeEmitted = true; + expect(data.color).toBe('#f5f5f5'); + }); + + stage.setBackgroundColor('#f5f5f5'); + + const stats = stage.getStats(); + expect(stats.backgroundColor).toBe('#f5f5f5'); + expect(backgroundChangeEmitted).toBe(true); + }); + }); + + describe('Screenshots', () => { + it('should capture screenshot when enabled', async () => { + const stage = new Stage({ screenshots: true }); + await stage.initialize(container); + + const screenshot = await stage.captureScreenshot(); + expect(screenshot).not.toBeNull(); + expect(screenshot).toContain('data:image/png;base64'); + }); + + it('should return null when screenshots disabled', async () => { + const stage = new Stage({ screenshots: false }); + await stage.initialize(container); + + const screenshot = await stage.captureScreenshot(); + expect(screenshot).toBeNull(); + }); + }); + + describe('Statistics', () => { + it('should provide stage statistics', async () => { + const stage = new Stage({ + isolation: 'iframe', + viewport: { width: 1920, height: 1080 }, + backgroundColor: '#ffffff', + }); + await stage.initialize(container); + + const stats = stage.getStats(); + + expect(stats.hasMounted).toBe(false); + expect(stats.viewport.width).toBe(1920); + expect(stats.viewport.height).toBe(1080); + expect(stats.isolation).toBe('iframe'); + expect(stats.backgroundColor).toBe('#ffffff'); + }); + + it('should update statistics after mounting', async () => { + const stage = new Stage({ isolation: 'none' }); + await stage.initialize(container); + + let statsBefore = stage.getStats(); + expect(statsBefore.hasMounted).toBe(false); + + const element = document.createElement('div'); + await stage.mount(element, 'stats-test'); + + let statsAfter = stage.getStats(); + expect(statsAfter.hasMounted).toBe(true); + }); + }); + + describe('Cleanup', () => { + it('should cleanup stage', async () => { + const stage = new Stage({ isolation: 'none' }); + await stage.initialize(container); + + const element = document.createElement('div'); + await stage.mount(element, 'cleanup-test'); + + let cleanupEmitted = false; + stage.on('cleanup', () => { + cleanupEmitted = true; + }); + + await stage.cleanup(); + + expect(cleanupEmitted).toBe(true); + expect(stage.hasMountedComponent()).toBe(false); + }); + }); + + describe('Isolation Modes', () => { + it('should mount with iframe isolation', async () => { + const stage = new Stage({ isolation: 'iframe' }); + await stage.initialize(container); + + const element = document.createElement('div'); + element.textContent = 'Iframe Test'; + + await stage.mount(element, 'iframe-test'); + + // Check that iframe was created + const iframe = container.querySelector('iframe'); + expect(iframe).not.toBeNull(); + }); + + it('should mount with shadow DOM isolation', async () => { + const stage = new Stage({ isolation: 'shadow-dom' }); + await stage.initialize(container); + + const element = document.createElement('div'); + element.textContent = 'Shadow DOM Test'; + + await stage.mount(element, 'shadow-test'); + + // Check that shadow root was created + const wrapper = container.querySelector('div'); + expect(wrapper).not.toBeNull(); + expect(wrapper!.shadowRoot).not.toBeNull(); + }); + + it('should mount with no isolation', async () => { + const stage = new Stage({ isolation: 'none' }); + await stage.initialize(container); + + const element = document.createElement('div'); + element.textContent = 'No Isolation Test'; + + await stage.mount(element, 'no-isolation-test'); + + // Check that element was appended directly + expect(container.querySelector('div')).toBe(element); + }); + }); + + describe('Predefined Viewports', () => { + it('should have mobile viewport', () => { + expect(VIEWPORTS.mobile.width).toBe(375); + expect(VIEWPORTS.mobile.height).toBe(667); + expect(VIEWPORTS.mobile.label).toBe('iPhone SE'); + }); + + it('should have tablet viewport', () => { + expect(VIEWPORTS.tablet.width).toBe(768); + expect(VIEWPORTS.tablet.height).toBe(1024); + expect(VIEWPORTS.tablet.label).toBe('iPad'); + }); + + it('should have desktop viewport', () => { + expect(VIEWPORTS.desktop.width).toBe(1920); + expect(VIEWPORTS.desktop.height).toBe(1080); + expect(VIEWPORTS.desktop.label).toBe('Desktop HD'); + }); + }); +}); diff --git a/src/theater/__tests__/StateExplorer.test.ts b/src/theater/__tests__/StateExplorer.test.ts new file mode 100644 index 0000000..582ed06 --- /dev/null +++ b/src/theater/__tests__/StateExplorer.test.ts @@ -0,0 +1,248 @@ +import { StateExplorer } from '../instruments/StateExplorer'; +import { VisualNeuron } from '../../ui/VisualNeuron'; + +describe('StateExplorer - Time-Travel Debugging', () => { + let explorer: StateExplorer; + + beforeEach(() => { + explorer = new StateExplorer(); + }); + + afterEach(async () => { + await explorer.cleanup(); + }); + + describe('Construction and Initialization', () => { + it('should create explorer with default config', () => { + expect(explorer).toBeDefined(); + expect(explorer.id).toBe('state-explorer'); + expect(explorer.name).toBe('State Explorer'); + expect(explorer.mode).toBe('state'); + }); + + it('should initialize with custom config', () => { + const custom = new StateExplorer({ + maxSnapshots: 50, + recordStackTraces: true, + autoPauseOnError: false, + }); + + expect(custom).toBeDefined(); + }); + + it('should initialize with empty snapshots', async () => { + await explorer.initialize(); + + const snapshots = explorer.getAllSnapshots(); + expect(snapshots.length).toBe(0); + }); + }); + + describe('State Inspection', () => { + it('should inspect component state', async () => { + const component = new VisualNeuron({ name: 'Test', value: 'test' }); + const result = await explorer.inspect(component); + + expect(result).toBeDefined(); + expect(result.mode).toBe('state'); + expect(result.timestamp).toBeInstanceOf(Date); + }); + + it('should return inspection data', async () => { + const component = new VisualNeuron({ name: 'Test' }); + const result = await explorer.inspect(component); + + expect(result.data).toBeDefined(); + expect(result.data).toHaveProperty('currentSnapshot'); + expect(result.data).toHaveProperty('snapshots'); + expect(result.data).toHaveProperty('stats'); + }); + + it('should create snapshot on inspection', async () => { + const component = new VisualNeuron({ name: 'Test' }); + await explorer.inspect(component); + + const current = explorer.getCurrentSnapshot(); + expect(current).toBeDefined(); + }); + }); + + describe('Snapshot Management', () => { + it('should create snapshots', async () => { + const component = new VisualNeuron({ name: 'Test' }); + + await explorer.inspect(component); + await explorer.inspect(component); + + const snapshots = explorer.getAllSnapshots(); + expect(snapshots.length).toBe(2); + }); + + it('should get current snapshot', async () => { + const component = new VisualNeuron({ name: 'Test' }); + await explorer.inspect(component); + + const current = explorer.getCurrentSnapshot(); + expect(current).toBeDefined(); + expect(current?.timestamp).toBeInstanceOf(Date); + }); + + it('should get snapshot by ID', async () => { + const component = new VisualNeuron({ name: 'Test' }); + await explorer.inspect(component); + + const current = explorer.getCurrentSnapshot(); + if (current !== undefined) { + const found = explorer.getSnapshot(current.id); + expect(found).toBe(current); + } + }); + + it('should clear snapshots', async () => { + const component = new VisualNeuron({ name: 'Test' }); + await explorer.inspect(component); + + explorer.clearSnapshots(); + + const snapshots = explorer.getAllSnapshots(); + expect(snapshots.length).toBe(0); + }); + + it('should limit snapshot count', async () => { + const limited = new StateExplorer({ maxSnapshots: 5 }); + const component = new VisualNeuron({ name: 'Test' }); + + for (let i = 0; i < 10; i++) { + await limited.inspect(component); + } + + const snapshots = limited.getAllSnapshots(); + expect(snapshots.length).toBe(5); + + await limited.cleanup(); + }); + }); + + describe('Time Travel', () => { + it('should pause recording', () => { + explorer.timeTravel('pause'); + + // Paused state is internal, verify through behavior + expect(explorer).toBeDefined(); + }); + + it('should resume recording', () => { + explorer.timeTravel('pause'); + explorer.timeTravel('resume'); + + expect(explorer).toBeDefined(); + }); + + it('should step backward', async () => { + const component = new VisualNeuron({ name: 'Test' }); + + await explorer.inspect(component); + await explorer.inspect(component); + + explorer.timeTravel('step-backward'); + + expect(explorer).toBeDefined(); + }); + + it('should step forward', async () => { + const component = new VisualNeuron({ name: 'Test' }); + + await explorer.inspect(component); + await explorer.inspect(component); + + explorer.timeTravel('step-backward'); + explorer.timeTravel('step-forward'); + + expect(explorer).toBeDefined(); + }); + + it('should jump to specific snapshot', async () => { + const component = new VisualNeuron({ name: 'Test' }); + + await explorer.inspect(component); + await explorer.inspect(component); + await explorer.inspect(component); + + explorer.timeTravel('jump', 0); + + expect(explorer).toBeDefined(); + }); + }); + + describe('State Validation', () => { + it('should validate state when enabled', async () => { + const validator = new StateExplorer({ validateState: true }); + const component = new VisualNeuron({ name: 'Test' }); + + const result = await validator.inspect(component); + + expect(result.issues).toBeDefined(); + expect(Array.isArray(result.issues)).toBe(true); + + await validator.cleanup(); + }); + + it('should skip validation when disabled', async () => { + const noValidator = new StateExplorer({ validateState: false }); + const component = new VisualNeuron({ name: 'Test' }); + + const result = await noValidator.inspect(component); + + expect(result.issues).toBeDefined(); + + await noValidator.cleanup(); + }); + }); + + describe('State Change Analysis', () => { + it('should analyze state changes', async () => { + const component = new VisualNeuron({ name: 'Test' }); + + // Create multiple snapshots to analyze + for (let i = 0; i < 15; i++) { + await explorer.inspect(component); + } + + const result = await explorer.inspect(component); + const data = result.data as { analysis: { frequentChanges: string[] } }; + + expect(data.analysis).toBeDefined(); + expect(data.analysis).toHaveProperty('frequentChanges'); + }); + }); + + describe('Render', () => { + it('should render explorer UI', () => { + const html = explorer.render(); + + expect(html).toContain('state-explorer'); + expect(html).toContain('Pause'); + }); + + it('should render with snapshots', async () => { + const component = new VisualNeuron({ name: 'Test' }); + await explorer.inspect(component); + + const html = explorer.render(); + + expect(html).toContain('state-explorer'); + }); + }); + + describe('Cleanup', () => { + it('should clear all data on cleanup', async () => { + const component = new VisualNeuron({ name: 'Test' }); + await explorer.inspect(component); + + await explorer.cleanup(); + + const snapshots = explorer.getAllSnapshots(); + expect(snapshots.length).toBe(0); + }); + }); +}); diff --git a/src/theater/__tests__/TestSubject.test.ts b/src/theater/__tests__/TestSubject.test.ts new file mode 100644 index 0000000..8756e43 --- /dev/null +++ b/src/theater/__tests__/TestSubject.test.ts @@ -0,0 +1,438 @@ +/** + * TestSubject Tests + */ + +import { TestSubject } from '../laboratory/TestSubject'; +import { VisualNeuron } from '../../ui/VisualNeuron'; + +// Test component +class TestComponent extends VisualNeuron<{ label: string; count: number }> { + constructor() { + super({ + id: 'test-component', + type: 'cortical', + threshold: 0.5, + props: { label: 'Test', count: 0 }, + initialState: { clicks: 0 }, + }); + } + + protected executeProcessing(): Promise { + return Promise.resolve(); + } + + protected performRender() { + const label = this.receptiveField.label ?? 'Test'; + const count = this.receptiveField.count ?? 0; + + return { + type: 'render' as const, + data: { + vdom: { + tag: 'div', + props: { className: 'test-component' }, + children: [ + { tag: 'span', children: [label] }, + { tag: 'span', children: [String(count)] }, + ], + }, + styles: {}, + }, + strength: 1.0, + timestamp: Date.now(), + }; + } +} + +describe('TestSubject - Component Testing Wrapper', () => { + describe('Construction and Mounting', () => { + it('should create a test subject', () => { + const component = new TestComponent(); + const subject = new TestSubject({ component }); + + expect(subject).toBeDefined(); + expect(subject.getComponent()).toBe(component); + }); + + it('should auto-mount when configured', async () => { + const component = new TestComponent(); + const subject = new TestSubject({ component, autoMount: true }); + + // Wait a bit for auto-mount to complete + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(subject.isMounted()).toBe(true); + expect(subject.isActive()).toBe(true); + }); + + it('should not auto-mount by default', () => { + const component = new TestComponent(); + const subject = new TestSubject({ component }); + + expect(subject.isMounted()).toBe(false); + }); + + it('should mount manually', async () => { + const component = new TestComponent(); + const subject = new TestSubject({ component }); + + await subject.mount(); + + expect(subject.isMounted()).toBe(true); + expect(subject.isActive()).toBe(true); + }); + + it('should unmount', async () => { + const component = new TestComponent(); + const subject = new TestSubject({ component, autoMount: true }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + await subject.unmount(); + + expect(subject.isMounted()).toBe(false); + expect(subject.isActive()).toBe(false); + }); + + it('should set initial props', async () => { + const component = new TestComponent(); + const subject = new TestSubject({ + component, + initialProps: { label: 'Custom', count: 42 }, + }); + + await subject.mount(); + + const props = subject.getProps(); + expect(props.label).toBe('Custom'); + expect(props.count).toBe(42); + }); + }); + + describe('Props and State Management', () => { + it('should get component props', async () => { + const component = new TestComponent(); + const subject = new TestSubject({ component, autoMount: true }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + const props = subject.getProps(); + expect(props.label).toBe('Test'); + expect(props.count).toBe(0); + }); + + it('should update component props', async () => { + const component = new TestComponent(); + const subject = new TestSubject({ component, autoMount: true }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + subject.setProps({ label: 'Updated' }); + + const props = subject.getProps(); + expect(props.label).toBe('Updated'); + }); + + it('should get component state', async () => { + const component = new TestComponent(); + const subject = new TestSubject({ component, autoMount: true }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + const state = subject.getState(); + expect(state.clicks).toBe(0); + }); + + it('should update component state', async () => { + const component = new TestComponent(); + const subject = new TestSubject({ component, autoMount: true }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + subject.setState({ clicks: 5 }); + + const state = subject.getState(); + expect(state.clicks).toBe(5); + }); + }); + + describe('Rendering', () => { + it('should render component', async () => { + const component = new TestComponent(); + const subject = new TestSubject({ component, autoMount: true }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + const output = subject.render(); + expect(output).toContain('test-component'); + expect(output).toContain('Test'); + }); + + it('should track render count', async () => { + const component = new TestComponent(); + const subject = new TestSubject({ component, autoMount: true }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + const initialCount = subject.getRenderCount(); + subject.render(); + subject.render(); + + expect(subject.getRenderCount()).toBe(initialCount + 2); + }); + + it('should get last render output', async () => { + const component = new TestComponent(); + const subject = new TestSubject({ component, autoMount: true }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + subject.render(); + + const output = subject.getRenderOutput(); + expect(output).toContain('test-component'); + }); + + it('should re-render when props change', async () => { + const component = new TestComponent(); + const subject = new TestSubject({ component, autoMount: true }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + const beforeCount = subject.getRenderCount(); + subject.setProps({ label: 'Changed' }); + + expect(subject.getRenderCount()).toBeGreaterThan(beforeCount); + expect(subject.getRenderOutput()).toContain('Changed'); + }); + }); + + describe('Interactions', () => { + it('should simulate click interaction', async () => { + const component = new TestComponent(); + const subject = new TestSubject({ component, autoMount: true }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + await subject.interact({ type: 'click' }); + + const interactions = subject.getInteractions(); + expect(interactions).toHaveLength(1); + expect(interactions[0].type).toBe('click'); + }); + + it('should simulate input interaction', async () => { + const component = new TestComponent(); + const subject = new TestSubject({ component, autoMount: true }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + await subject.interact({ type: 'input', data: 'test value' }); + + const interactions = subject.getInteractions(); + expect(interactions[0].type).toBe('input'); + expect(interactions[0].data).toBe('test value'); + }); + + it('should simulate focus interaction', async () => { + const component = new TestComponent(); + const subject = new TestSubject({ component, autoMount: true }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + await subject.interact({ type: 'focus' }); + + const interactions = subject.getInteractions(); + expect(interactions[0].type).toBe('focus'); + }); + + it('should simulate blur interaction', async () => { + const component = new TestComponent(); + const subject = new TestSubject({ component, autoMount: true }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + await subject.interact({ type: 'blur' }); + + const interactions = subject.getInteractions(); + expect(interactions[0].type).toBe('blur'); + }); + + it('should simulate keyboard interaction', async () => { + const component = new TestComponent(); + const subject = new TestSubject({ component, autoMount: true }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + await subject.interact({ type: 'keydown', data: 'Enter' }); + + const interactions = subject.getInteractions(); + expect(interactions[0].type).toBe('keydown'); + expect(interactions[0].data).toBe('Enter'); + }); + + it('should support interaction delay', async () => { + const component = new TestComponent(); + const subject = new TestSubject({ component, autoMount: true }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + const startTime = Date.now(); + await subject.interact({ type: 'click', delay: 50 }); + const duration = Date.now() - startTime; + + expect(duration).toBeGreaterThanOrEqual(50); + }); + + it('should track interaction history', async () => { + const component = new TestComponent(); + const subject = new TestSubject({ component, autoMount: true }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + await subject.interact({ type: 'focus' }); + await subject.interact({ type: 'input', data: 'test' }); + await subject.interact({ type: 'blur' }); + + const interactions = subject.getInteractions(); + expect(interactions).toHaveLength(3); + }); + + it('should clear interaction history', async () => { + const component = new TestComponent(); + const subject = new TestSubject({ component, autoMount: true }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + await subject.interact({ type: 'click' }); + + subject.clearInteractions(); + + expect(subject.getInteractions()).toHaveLength(0); + }); + }); + + describe('Element Queries', () => { + it('should find element in render output', async () => { + const component = new TestComponent(); + const subject = new TestSubject({ component, autoMount: true }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + subject.render(); + + expect(subject.find('test-component')).toBe(true); + expect(subject.find('nonexistent')).toBe(false); + }); + + it('should find all matching elements', async () => { + const component = new TestComponent(); + const subject = new TestSubject({ component, autoMount: true }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + subject.render(); + + const spans = subject.findAll('span'); + expect(spans.length).toBeGreaterThan(0); + }); + + it('should get text content', async () => { + const component = new TestComponent(); + const subject = new TestSubject({ component, autoMount: true }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + subject.render(); + + const text = subject.getText(); + expect(text).toContain('Test'); + expect(text).not.toContain('<'); + }); + }); + + describe('Async Helpers', () => { + it('should wait for render', async () => { + const component = new TestComponent(); + const subject = new TestSubject({ component, autoMount: true }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + setTimeout(() => { + subject.render(); + }, 50); + + const output = await subject.waitForRender(); + expect(output).toBeDefined(); + }); + + it('should timeout waiting for render', async () => { + const component = new TestComponent(); + const subject = new TestSubject({ component, autoMount: true }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + await expect(subject.waitForRender(50)).rejects.toThrow('Timeout waiting for render'); + }); + + it('should wait for condition', async () => { + const component = new TestComponent(); + const subject = new TestSubject({ component, autoMount: true }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + setTimeout(() => { + void subject.setState({ clicks: 10 }); + }, 50); + + await subject.waitFor(() => subject.getState().clicks === 10, { timeout: 200 }); + + expect(subject.getState().clicks).toBe(10); + }); + + it('should timeout waiting for condition', async () => { + const component = new TestComponent(); + const subject = new TestSubject({ component, autoMount: true }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + await expect(subject.waitFor(() => false, { timeout: 50 })).rejects.toThrow( + 'Timeout waiting for condition', + ); + }); + }); + + describe('Snapshot', () => { + it('should take snapshot of current state', async () => { + const component = new TestComponent(); + const subject = new TestSubject({ component, autoMount: true }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + subject.setState({ clicks: 5 }); + subject.setProps({ count: 10 }); + + const snapshot = subject.snapshot(); + + expect(snapshot.props.count).toBe(10); + expect(snapshot.state.clicks).toBe(5); + expect(snapshot.mounted).toBe(true); + expect(snapshot.active).toBe(true); + expect(snapshot.renderOutput).toBeDefined(); + }); + }); + + describe('Reset and Cleanup', () => { + it('should reset test subject', async () => { + const component = new TestComponent(); + const subject = new TestSubject({ component, autoMount: true }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + await subject.interact({ type: 'click' }); + subject.render(); + + await subject.reset(); + + expect(subject.isMounted()).toBe(false); + expect(subject.getRenderCount()).toBe(0); + expect(subject.getRenderOutput()).toBe(''); + expect(subject.getInteractions()).toHaveLength(0); + }); + + it('should cleanup test subject', async () => { + const component = new TestComponent(); + const subject = new TestSubject({ component, autoMount: true }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + await subject.cleanup(); + + expect(subject.isMounted()).toBe(false); + }); + }); +}); diff --git a/src/theater/__tests__/Theater.test.ts b/src/theater/__tests__/Theater.test.ts new file mode 100644 index 0000000..2cefc41 --- /dev/null +++ b/src/theater/__tests__/Theater.test.ts @@ -0,0 +1,381 @@ +import { Theater } from '../core/Theater'; +import { Instrument } from '../core/Instrument'; + +// Mock instrument for testing +class MockInstrument extends Instrument { + public initializeCalled = false; + public cleanupCalled = false; + + public async initialize(): Promise { + this.initializeCalled = true; + } + + public async cleanup(): Promise { + this.cleanupCalled = true; + } + + public render(): string { + return '
Mock Instrument
'; + } +} + +describe('Theater - Main Orchestrator', () => { + describe('Initialization', () => { + it('should create a theater with configuration', () => { + const theater = new Theater({ + title: 'Test Theater', + port: 3000, + darkMode: true, + }); + + expect(theater).toBeDefined(); + expect(theater.stage).toBeDefined(); + expect(theater.amphitheater).toBeDefined(); + + const config = theater.getConfig(); + expect(config.title).toBe('Test Theater'); + expect(config.port).toBe(3000); + expect(config.darkMode).toBe(true); + }); + + it('should use default configuration', () => { + const theater = new Theater({ title: 'Default Test' }); + const config = theater.getConfig(); + + expect(config.port).toBe(6006); + expect(config.hotReload).toBe(true); + expect(config.darkMode).toBe(false); + }); + + it('should have stopped state initially', () => { + const theater = new Theater({ title: 'State Test' }); + expect(theater.getState()).toBe('stopped'); + expect(theater.isRunning()).toBe(false); + }); + }); + + describe('Lifecycle Management', () => { + it('should start the theater', async () => { + const theater = new Theater({ title: 'Start Test' }); + + await theater.start(); + + expect(theater.getState()).toBe('running'); + expect(theater.isRunning()).toBe(true); + }); + + it('should emit started event', async () => { + const theater = new Theater({ title: 'Event Test' }); + let eventEmitted = false; + + theater.on('started', () => { + eventEmitted = true; + }); + + await theater.start(); + expect(eventEmitted).toBe(true); + }); + + it('should stop the theater', async () => { + const theater = new Theater({ title: 'Stop Test' }); + + await theater.start(); + expect(theater.isRunning()).toBe(true); + + await theater.stop(); + expect(theater.getState()).toBe('stopped'); + expect(theater.isRunning()).toBe(false); + }); + + it('should emit stopped event', async () => { + const theater = new Theater({ title: 'Stop Event Test' }); + let eventEmitted = false; + + await theater.start(); + + theater.on('stopped', () => { + eventEmitted = true; + }); + + await theater.stop(); + expect(eventEmitted).toBe(true); + }); + + it('should reload the theater', async () => { + const theater = new Theater({ title: 'Reload Test' }); + let reloadedEventEmitted = false; + + await theater.start(); + + theater.on('reloaded', () => { + reloadedEventEmitted = true; + }); + + await theater.reload(); + + expect(theater.isRunning()).toBe(true); + expect(reloadedEventEmitted).toBe(true); + }); + + it('should not start twice', async () => { + const theater = new Theater({ title: 'Double Start Test' }); + + await theater.start(); + const firstState = theater.getState(); + + await theater.start(); // Should be no-op + const secondState = theater.getState(); + + expect(firstState).toBe('running'); + expect(secondState).toBe('running'); + }); + + it('should not stop when already stopped', async () => { + const theater = new Theater({ title: 'Double Stop Test' }); + + await theater.start(); + await theater.stop(); + + const firstState = theater.getState(); + await theater.stop(); // Should be no-op + const secondState = theater.getState(); + + expect(firstState).toBe('stopped'); + expect(secondState).toBe('stopped'); + }); + }); + + describe('Instrument Management', () => { + it('should register an instrument', () => { + const theater = new Theater({ title: 'Instrument Test' }); + const instrument = new MockInstrument({ + id: 'test-instrument', + name: 'Test Instrument', + }); + + theater.registerInstrument(instrument); + + const registered = theater.getInstrument('test-instrument'); + expect(registered).toBe(instrument); + }); + + it('should emit instrument:registered event', () => { + const theater = new Theater({ title: 'Instrument Event Test' }); + const instrument = new MockInstrument({ + id: 'event-instrument', + name: 'Event Instrument', + }); + + let eventEmitted = false; + theater.on('instrument:registered', (data) => { + eventEmitted = true; + expect(data.instrument).toBe(instrument); + }); + + theater.registerInstrument(instrument); + expect(eventEmitted).toBe(true); + }); + + it('should initialize instrument when theater is running', async () => { + const theater = new Theater({ title: 'Init Instrument Test' }); + await theater.start(); + + const instrument = new MockInstrument({ + id: 'init-instrument', + name: 'Init Instrument', + }); + + theater.registerInstrument(instrument); + + // Give it a moment to initialize + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(instrument.initializeCalled).toBe(true); + }); + + it('should throw error when registering duplicate instrument', () => { + const theater = new Theater({ title: 'Duplicate Test' }); + const instrument1 = new MockInstrument({ + id: 'duplicate', + name: 'First', + }); + const instrument2 = new MockInstrument({ + id: 'duplicate', + name: 'Second', + }); + + theater.registerInstrument(instrument1); + + expect(() => { + theater.registerInstrument(instrument2); + }).toThrow('Instrument already registered: duplicate'); + }); + + it('should unregister an instrument', async () => { + const theater = new Theater({ title: 'Unregister Test' }); + const instrument = new MockInstrument({ + id: 'remove-me', + name: 'Remove Me', + }); + + theater.registerInstrument(instrument); + expect(theater.getInstrument('remove-me')).toBe(instrument); + + await theater.unregisterInstrument('remove-me'); + expect(theater.getInstrument('remove-me')).toBeUndefined(); + expect(instrument.cleanupCalled).toBe(true); + }); + + it('should get all instruments', () => { + const theater = new Theater({ title: 'Get All Test' }); + const instrument1 = new MockInstrument({ id: 'inst1', name: 'Inst 1' }); + const instrument2 = new MockInstrument({ id: 'inst2', name: 'Inst 2' }); + const instrument3 = new MockInstrument({ id: 'inst3', name: 'Inst 3' }); + + theater.registerInstrument(instrument1); + theater.registerInstrument(instrument2); + theater.registerInstrument(instrument3); + + const instruments = theater.getInstruments(); + expect(instruments).toHaveLength(3); + expect(instruments).toContain(instrument1); + expect(instruments).toContain(instrument2); + expect(instruments).toContain(instrument3); + }); + + it('should cleanup instruments on stop', async () => { + const theater = new Theater({ title: 'Cleanup Test' }); + const instrument = new MockInstrument({ + id: 'cleanup-instrument', + name: 'Cleanup Instrument', + }); + + theater.registerInstrument(instrument); + await theater.start(); + await theater.stop(); + + expect(instrument.cleanupCalled).toBe(true); + }); + }); + + describe('Configuration Management', () => { + it('should update configuration', () => { + const theater = new Theater({ title: 'Update Config Test' }); + + theater.updateConfig({ port: 8080, darkMode: true }); + + const config = theater.getConfig(); + expect(config.port).toBe(8080); + expect(config.darkMode).toBe(true); + }); + + it('should emit config:update event', () => { + const theater = new Theater({ title: 'Config Event Test' }); + let eventEmitted = false; + + theater.on('config:update', (data) => { + eventEmitted = true; + expect(data.config.port).toBe(9000); + }); + + theater.updateConfig({ port: 9000 }); + expect(eventEmitted).toBe(true); + }); + + it('should apply theme change to amphitheater', () => { + const theater = new Theater({ title: 'Theme Test', darkMode: false }); + expect(theater.amphitheater.getTheme()).toBe('light'); + + theater.updateConfig({ darkMode: true }); + expect(theater.amphitheater.getTheme()).toBe('dark'); + }); + + it('should enable hot reload', () => { + const theater = new Theater({ title: 'HMR Test', hotReload: false }); + + theater.enableHotReload(); + + const config = theater.getConfig(); + expect(config.hotReload).toBe(true); + }); + + it('should disable hot reload', () => { + const theater = new Theater({ title: 'Disable HMR Test', hotReload: true }); + + theater.disableHotReload(); + + const config = theater.getConfig(); + expect(config.hotReload).toBe(false); + }); + }); + + describe('Statistics', () => { + it('should provide theater statistics', () => { + const theater = new Theater({ title: 'Stats Test' }); + const instrument = new MockInstrument({ id: 'stat-inst', name: 'Stat Instrument' }); + + theater.registerInstrument(instrument); + + const stats = theater.getStats(); + + expect(stats.state).toBe('stopped'); + expect(stats.instruments).toBe(1); + expect(stats.config.title).toBe('Stats Test'); + expect(stats.amphitheaterStats).toBeDefined(); + expect(stats.stageStats).toBeDefined(); + }); + }); + + describe('Event Propagation', () => { + it('should propagate stage events', async () => { + const theater = new Theater({ title: 'Stage Events Test' }); + let mountedEventEmitted = false; + + theater.on('stage:mounted', (data) => { + mountedEventEmitted = true; + expect(data.id).toBeDefined(); + }); + + const element = document.createElement('div'); + await theater.stage.initialize(document.createElement('div')); + await theater.stage.mount(element, 'test-component'); + + expect(mountedEventEmitted).toBe(true); + }); + + it('should propagate amphitheater events', () => { + const theater = new Theater({ title: 'Amphitheater Events Test' }); + let selectedEventEmitted = false; + + theater.on('specimen:selected', (data) => { + selectedEventEmitted = true; + expect(data.id).toBe('test-specimen'); + }); + + theater.amphitheater.registerSpecimen({ + id: 'test-specimen', + name: 'Test Specimen', + category: 'Test', + tags: ['test'], + }); + + theater.amphitheater.selectSpecimen('test-specimen'); + expect(selectedEventEmitted).toBe(true); + }); + }); + + describe('Cleanup', () => { + it('should dispose theater completely', async () => { + const theater = new Theater({ title: 'Dispose Test' }); + const instrument = new MockInstrument({ id: 'dispose-inst', name: 'Dispose Inst' }); + + theater.registerInstrument(instrument); + await theater.start(); + + await theater.dispose(); + + expect(theater.getState()).toBe('stopped'); + expect(theater.getInstruments()).toHaveLength(0); + }); + }); +}); diff --git a/src/theater/__tests__/TheaterServer.test.ts b/src/theater/__tests__/TheaterServer.test.ts new file mode 100644 index 0000000..116d054 --- /dev/null +++ b/src/theater/__tests__/TheaterServer.test.ts @@ -0,0 +1,339 @@ +/** + * TheaterServer Tests + */ + +import { TheaterServer } from '../server/TheaterServer'; +import type { RequestInfo } from '../server/TheaterServer'; + +describe('TheaterServer - Development Server', () => { + let server: TheaterServer; + + beforeEach(() => { + server = new TheaterServer({ verbose: false }); + }); + + afterEach(async () => { + if (server.isRunning()) { + await server.stop(); + } + }); + + describe('Construction', () => { + it('should create server with default config', () => { + expect(server).toBeInstanceOf(TheaterServer); + expect(server.getState()).toBe('stopped'); + }); + + it('should create server with custom config', () => { + const customServer = new TheaterServer({ + port: 8080, + host: '0.0.0.0', + hotReload: false, + }); + + const config = customServer.getConfig(); + expect(config.port).toBe(8080); + expect(config.host).toBe('0.0.0.0'); + expect(config.hotReload).toBe(false); + }); + + it('should use default values', () => { + const config = server.getConfig(); + expect(config.port).toBe(6006); + expect(config.host).toBe('localhost'); + expect(config.hotReload).toBe(true); + }); + }); + + describe('Server Lifecycle', () => { + it('should start server', async () => { + await server.start(); + expect(server.getState()).toBe('running'); + expect(server.isRunning()).toBe(true); + }); + + it('should stop server', async () => { + await server.start(); + await server.stop(); + expect(server.getState()).toBe('stopped'); + expect(server.isRunning()).toBe(false); + }); + + it('should restart server', async () => { + await server.start(); + await server.restart(); + expect(server.getState()).toBe('running'); + }); + + it('should emit started event', async () => { + const startedHandler = jest.fn(); + server.on('started', startedHandler); + + await server.start(); + + expect(startedHandler).toHaveBeenCalledWith( + expect.objectContaining({ + port: 6006, + host: 'localhost', + url: 'http://localhost:6006', + }), + ); + }); + + it('should emit stopped event', async () => { + const stoppedHandler = jest.fn(); + server.on('stopped', stoppedHandler); + + await server.start(); + await server.stop(); + + expect(stoppedHandler).toHaveBeenCalled(); + }); + + it('should emit state:change events', async () => { + const stateChangeHandler = jest.fn(); + server.on('state:change', stateChangeHandler); + + await server.start(); + + expect(stateChangeHandler).toHaveBeenCalledWith({ + from: 'stopped', + to: 'starting', + }); + + expect(stateChangeHandler).toHaveBeenCalledWith({ + from: 'starting', + to: 'running', + }); + }); + + it('should throw error when starting already running server', async () => { + await server.start(); + await expect(server.start()).rejects.toThrow('Cannot start server in running state'); + }); + + it('should throw error when stopping non-running server', async () => { + await expect(server.stop()).rejects.toThrow('Cannot stop server in stopped state'); + }); + }); + + describe('URL Generation', () => { + it('should get server URL', () => { + expect(server.getUrl()).toBe('http://localhost:6006'); + }); + + it('should get WebSocket URL', () => { + expect(server.getWebSocketUrl()).toBe('ws://localhost:6007'); + }); + + it('should use custom WebSocket port', () => { + const customServer = new TheaterServer({ wsPort: 9000 }); + expect(customServer.getWebSocketUrl()).toBe('ws://localhost:9000'); + }); + }); + + describe('Request Tracking', () => { + it('should record requests', () => { + const request: RequestInfo = { + method: 'GET', + path: '/specimens', + timestamp: Date.now(), + statusCode: 200, + }; + + server.recordRequest(request); + + const requests = server.getRequests(); + expect(requests).toHaveLength(1); + expect(requests[0]).toEqual(request); + }); + + it('should emit request event', () => { + const requestHandler = jest.fn(); + server.on('request', requestHandler); + + const request: RequestInfo = { + method: 'GET', + path: '/api/test', + timestamp: Date.now(), + }; + + server.recordRequest(request); + + expect(requestHandler).toHaveBeenCalledWith(request); + }); + + it('should limit request history to 1000', () => { + for (let i = 0; i < 1500; i++) { + server.recordRequest({ + method: 'GET', + path: `/test/${i}`, + timestamp: Date.now(), + }); + } + + expect(server.getRequests(2000).length).toBe(1000); + }); + + it('should get limited requests', () => { + for (let i = 0; i < 50; i++) { + server.recordRequest({ + method: 'GET', + path: `/test/${i}`, + timestamp: Date.now(), + }); + } + + expect(server.getRequests(10)).toHaveLength(10); + }); + }); + + describe('Connection Tracking', () => { + it('should increment connections', () => { + server.incrementConnections(); + server.incrementConnections(); + + const stats = server.getStatistics(); + expect(stats.activeConnections).toBe(2); + }); + + it('should decrement connections', () => { + server.incrementConnections(); + server.incrementConnections(); + server.decrementConnections(); + + const stats = server.getStatistics(); + expect(stats.activeConnections).toBe(1); + }); + + it('should not go below zero connections', () => { + server.decrementConnections(); + server.decrementConnections(); + + const stats = server.getStatistics(); + expect(stats.activeConnections).toBe(0); + }); + + it('should emit connection events', () => { + const openedHandler = jest.fn(); + const closedHandler = jest.fn(); + + server.on('connection:opened', openedHandler); + server.on('connection:closed', closedHandler); + + server.incrementConnections(); + server.decrementConnections(); + + expect(openedHandler).toHaveBeenCalledWith({ active: 1 }); + expect(closedHandler).toHaveBeenCalledWith({ active: 0 }); + }); + }); + + describe('Hot Reload', () => { + it('should trigger reload', () => { + const reloadHandler = jest.fn(); + server.on('reload', reloadHandler); + + server.triggerReload('Test file changed'); + + expect(reloadHandler).toHaveBeenCalledWith({ + reason: 'Test file changed', + count: 1, + }); + }); + + it('should track reload count', () => { + server.triggerReload(); + server.triggerReload(); + server.triggerReload(); + + const stats = server.getStatistics(); + expect(stats.reloadCount).toBe(3); + }); + + it('should not trigger reload when hot reload is disabled', () => { + const noReloadServer = new TheaterServer({ hotReload: false }); + const reloadHandler = jest.fn(); + + noReloadServer.on('reload', reloadHandler); + noReloadServer.triggerReload(); + + expect(reloadHandler).not.toHaveBeenCalled(); + }); + }); + + describe('Statistics', () => { + it('should get statistics', async () => { + const request: RequestInfo = { + method: 'GET', + path: '/test', + timestamp: Date.now(), + }; + + server.recordRequest(request); + server.recordRequest(request); + server.incrementConnections(); + server.triggerReload(); + + const stats = server.getStatistics(); + + expect(stats.totalRequests).toBe(2); + expect(stats.activeConnections).toBe(1); + expect(stats.reloadCount).toBe(1); + }); + + it('should calculate uptime when running', async () => { + await server.start(); + + // Wait a bit + await new Promise((resolve) => setTimeout(resolve, 50)); + + const stats = server.getStatistics(); + expect(stats.uptime).toBeGreaterThan(0); + expect(stats.startTime).toBeGreaterThan(0); + }); + + it('should have zero uptime when stopped', () => { + const stats = server.getStatistics(); + expect(stats.uptime).toBe(0); + }); + + it('should clear statistics', () => { + server.recordRequest({ + method: 'GET', + path: '/test', + timestamp: Date.now(), + }); + + server.triggerReload(); + + server.clearStatistics(); + + const stats = server.getStatistics(); + expect(stats.totalRequests).toBe(0); + expect(stats.reloadCount).toBe(0); + expect(server.getRequests()).toHaveLength(0); + }); + + it('should preserve active connections when clearing statistics', () => { + server.incrementConnections(); + server.incrementConnections(); + + server.clearStatistics(); + + const stats = server.getStatistics(); + expect(stats.activeConnections).toBe(2); + }); + }); + + describe('Configuration', () => { + it('should get readonly config', () => { + const config = server.getConfig(); + + expect(config.port).toBe(6006); + expect(config.host).toBe('localhost'); + expect(config.hotReload).toBe(true); + expect(config.cors).toBe(true); + }); + }); +}); diff --git a/src/theater/__tests__/WebSocketBridge.test.ts b/src/theater/__tests__/WebSocketBridge.test.ts new file mode 100644 index 0000000..473256b --- /dev/null +++ b/src/theater/__tests__/WebSocketBridge.test.ts @@ -0,0 +1,555 @@ +/** + * WebSocketBridge Tests + */ + +import { WebSocketBridge } from '../server/WebSocketBridge'; +import type { WebSocketMessage } from '../server/WebSocketBridge'; + +describe('WebSocketBridge - Real-time Communication', () => { + let bridge: WebSocketBridge; + + beforeEach(() => { + bridge = new WebSocketBridge({ verbose: false }); + }); + + afterEach(async () => { + if (bridge.isRunning()) { + await bridge.stop(); + } + }); + + describe('Construction', () => { + it('should create bridge with default config', () => { + expect(bridge).toBeInstanceOf(WebSocketBridge); + expect(bridge.isRunning()).toBe(false); + }); + + it('should create bridge with custom config', () => { + const custom = new WebSocketBridge({ + port: 8080, + host: '0.0.0.0', + heartbeat: 60000, + }); + + expect(custom).toBeInstanceOf(WebSocketBridge); + }); + }); + + describe('Bridge Lifecycle', () => { + it('should start bridge', async () => { + await bridge.start(); + expect(bridge.isRunning()).toBe(true); + }); + + it('should stop bridge', async () => { + await bridge.start(); + await bridge.stop(); + expect(bridge.isRunning()).toBe(false); + }); + + it('should emit started event', async () => { + const startedHandler = jest.fn(); + bridge.on('started', startedHandler); + + await bridge.start(); + + expect(startedHandler).toHaveBeenCalledWith( + expect.objectContaining({ + port: 6007, + host: 'localhost', + url: 'ws://localhost:6007', + }), + ); + }); + + it('should emit stopped event', async () => { + const stoppedHandler = jest.fn(); + bridge.on('stopped', stoppedHandler); + + await bridge.start(); + await bridge.stop(); + + expect(stoppedHandler).toHaveBeenCalled(); + }); + + it('should throw error when starting while already running', async () => { + await bridge.start(); + await expect(bridge.start()).rejects.toThrow('WebSocket bridge is already running'); + }); + + it('should disconnect all clients when stopping', async () => { + await bridge.start(); + + bridge.connectClient('client1'); + bridge.connectClient('client2'); + + await bridge.stop(); + + expect(bridge.getClients()).toHaveLength(0); + }); + }); + + describe('Client Connections', () => { + beforeEach(async () => { + await bridge.start(); + }); + + it('should connect client', () => { + bridge.connectClient('client1', { browser: 'chrome' }); + + const client = bridge.getClient('client1'); + expect(client).toBeDefined(); + expect(client?.id).toBe('client1'); + expect(client?.metadata.browser).toBe('chrome'); + }); + + it('should disconnect client', () => { + bridge.connectClient('client1'); + bridge.disconnectClient('client1'); + + expect(bridge.getClient('client1')).toBeUndefined(); + }); + + it('should emit client:connected event', () => { + const connectedHandler = jest.fn(); + bridge.on('client:connected', connectedHandler); + + bridge.connectClient('client1', { test: true }); + + expect(connectedHandler).toHaveBeenCalledWith({ + clientId: 'client1', + metadata: { test: true }, + }); + }); + + it('should emit client:disconnected event', () => { + const disconnectedHandler = jest.fn(); + bridge.on('client:disconnected', disconnectedHandler); + + bridge.connectClient('client1'); + bridge.disconnectClient('client1'); + + expect(disconnectedHandler).toHaveBeenCalledWith({ + clientId: 'client1', + }); + }); + + it('should get all connected clients', () => { + bridge.connectClient('client1'); + bridge.connectClient('client2'); + bridge.connectClient('client3'); + + const clients = bridge.getClients(); + expect(clients).toHaveLength(3); + }); + + it('should track connection statistics', () => { + bridge.connectClient('client1'); + bridge.connectClient('client2'); + + const stats = bridge.getStatistics(); + expect(stats.totalConnections).toBe(2); + expect(stats.activeConnections).toBe(2); + }); + }); + + describe('Messaging', () => { + beforeEach(async () => { + await bridge.start(); + bridge.connectClient('client1'); + }); + + it('should send message to client', () => { + const sentHandler = jest.fn(); + bridge.on('message:sent', sentHandler); + + const message: WebSocketMessage = { + type: 'reload', + payload: { reason: 'File changed' }, + timestamp: Date.now(), + }; + + bridge.sendToClient('client1', message); + + expect(sentHandler).toHaveBeenCalledWith({ + clientId: 'client1', + message, + }); + }); + + it('should not send to non-existent client', () => { + const sentHandler = jest.fn(); + bridge.on('message:sent', sentHandler); + + bridge.sendToClient('nonexistent', { + type: 'ping', + payload: null, + timestamp: Date.now(), + }); + + expect(sentHandler).not.toHaveBeenCalled(); + }); + + it('should update client last activity on send', () => { + const before = bridge.getClient('client1')?.lastActivity ?? 0; + + bridge.sendToClient('client1', { + type: 'ping', + payload: null, + timestamp: Date.now(), + }); + + const after = bridge.getClient('client1')?.lastActivity ?? 0; + expect(after).toBeGreaterThanOrEqual(before); + }); + }); + + describe('Broadcasting', () => { + beforeEach(async () => { + await bridge.start(); + bridge.connectClient('client1'); + bridge.connectClient('client2'); + bridge.connectClient('client3'); + }); + + it('should broadcast to all clients', () => { + const sentHandler = jest.fn(); + bridge.on('message:sent', sentHandler); + + const message: WebSocketMessage = { + type: 'update', + payload: { data: 'test' }, + timestamp: Date.now(), + }; + + bridge.broadcast(message); + + expect(sentHandler).toHaveBeenCalledTimes(3); + }); + + it('should exclude client from broadcast', () => { + const sentHandler = jest.fn(); + bridge.on('message:sent', sentHandler); + + const message: WebSocketMessage = { + type: 'update', + payload: {}, + timestamp: Date.now(), + }; + + bridge.broadcast(message, 'client2'); + + expect(sentHandler).toHaveBeenCalledTimes(2); + expect(sentHandler).not.toHaveBeenCalledWith( + expect.objectContaining({ + clientId: 'client2', + }), + ); + }); + + it('should emit broadcast event', () => { + const broadcastHandler = jest.fn(); + bridge.on('broadcast', broadcastHandler); + + const message: WebSocketMessage = { + type: 'reload', + payload: null, + timestamp: Date.now(), + }; + + bridge.broadcast(message); + + expect(broadcastHandler).toHaveBeenCalledWith({ + message, + excludeClient: undefined, + }); + }); + + it('should track broadcast statistics', () => { + bridge.broadcast({ + type: 'reload', + payload: null, + timestamp: Date.now(), + }); + + bridge.broadcast({ + type: 'update', + payload: {}, + timestamp: Date.now(), + }); + + const stats = bridge.getStatistics(); + expect(stats.broadcastCount).toBe(2); + }); + }); + + describe('Channels', () => { + beforeEach(async () => { + await bridge.start(); + bridge.connectClient('client1'); + bridge.connectClient('client2'); + bridge.connectClient('client3'); + }); + + it('should subscribe client to channel', () => { + bridge.subscribeToChannel('client1', 'updates'); + + const client = bridge.getClient('client1'); + expect(client?.channels.has('updates')).toBe(true); + }); + + it('should unsubscribe client from channel', () => { + bridge.subscribeToChannel('client1', 'updates'); + bridge.unsubscribeFromChannel('client1', 'updates'); + + const client = bridge.getClient('client1'); + expect(client?.channels.has('updates')).toBe(false); + }); + + it('should emit channel:subscribed event', () => { + const subscribedHandler = jest.fn(); + bridge.on('channel:subscribed', subscribedHandler); + + bridge.subscribeToChannel('client1', 'updates'); + + expect(subscribedHandler).toHaveBeenCalledWith({ + clientId: 'client1', + channel: 'updates', + }); + }); + + it('should emit channel:unsubscribed event', () => { + const unsubscribedHandler = jest.fn(); + bridge.on('channel:unsubscribed', unsubscribedHandler); + + bridge.subscribeToChannel('client1', 'updates'); + bridge.unsubscribeFromChannel('client1', 'updates'); + + expect(unsubscribedHandler).toHaveBeenCalledWith({ + clientId: 'client1', + channel: 'updates', + }); + }); + + it('should broadcast to channel', () => { + const sentHandler = jest.fn(); + bridge.on('message:sent', sentHandler); + + bridge.subscribeToChannel('client1', 'updates'); + bridge.subscribeToChannel('client2', 'updates'); + + bridge.broadcastToChannel('updates', { + type: 'update', + payload: { data: 'test' }, + timestamp: Date.now(), + }); + + expect(sentHandler).toHaveBeenCalledTimes(2); + }); + + it('should get channel subscribers', () => { + bridge.subscribeToChannel('client1', 'updates'); + bridge.subscribeToChannel('client2', 'updates'); + + const subscribers = bridge.getChannelSubscribers('updates'); + expect(subscribers).toHaveLength(2); + expect(subscribers).toContain('client1'); + expect(subscribers).toContain('client2'); + }); + + it('should get all channels', () => { + bridge.subscribeToChannel('client1', 'updates'); + bridge.subscribeToChannel('client2', 'reload'); + + const channels = bridge.getChannels(); + expect(channels).toContain('updates'); + expect(channels).toContain('reload'); + }); + + it('should remove channel when no subscribers', () => { + bridge.subscribeToChannel('client1', 'updates'); + bridge.unsubscribeFromChannel('client1', 'updates'); + + const channels = bridge.getChannels(); + expect(channels).not.toContain('updates'); + }); + + it('should unsubscribe client from all channels on disconnect', () => { + bridge.subscribeToChannel('client1', 'updates'); + bridge.subscribeToChannel('client1', 'reload'); + + bridge.disconnectClient('client1'); + + expect(bridge.getChannels()).toHaveLength(0); + }); + }); + + describe('Message Handling', () => { + beforeEach(async () => { + await bridge.start(); + bridge.connectClient('client1'); + }); + + it('should handle incoming message', () => { + const receivedHandler = jest.fn(); + bridge.on('message:received', receivedHandler); + + const message: WebSocketMessage = { + type: 'ping', + payload: null, + timestamp: Date.now(), + }; + + bridge.handleMessage('client1', message); + + expect(receivedHandler).toHaveBeenCalledWith({ + clientId: 'client1', + message, + }); + }); + + it('should respond to ping with pong', () => { + const sentHandler = jest.fn(); + bridge.on('message:sent', sentHandler); + + bridge.handleMessage('client1', { + type: 'ping', + payload: { test: true }, + timestamp: Date.now(), + }); + + expect(sentHandler).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.objectContaining({ + type: 'pong', + }), + }), + ); + }); + + it('should handle subscribe message', () => { + bridge.handleMessage('client1', { + type: 'subscribe', + payload: 'updates', + timestamp: Date.now(), + }); + + const client = bridge.getClient('client1'); + expect(client?.channels.has('updates')).toBe(true); + }); + + it('should handle unsubscribe message', () => { + bridge.subscribeToChannel('client1', 'updates'); + + bridge.handleMessage('client1', { + type: 'unsubscribe', + payload: 'updates', + timestamp: Date.now(), + }); + + const client = bridge.getClient('client1'); + expect(client?.channels.has('updates')).toBe(false); + }); + + it('should emit message event for custom types', () => { + const messageHandler = jest.fn(); + bridge.on('message', messageHandler); + + bridge.handleMessage('client1', { + type: 'broadcast', + payload: { custom: 'data' }, + timestamp: Date.now(), + }); + + expect(messageHandler).toHaveBeenCalled(); + }); + + it('should update client last activity on message', () => { + const before = bridge.getClient('client1')?.lastActivity ?? 0; + + bridge.handleMessage('client1', { + type: 'ping', + payload: null, + timestamp: Date.now(), + }); + + const after = bridge.getClient('client1')?.lastActivity ?? 0; + expect(after).toBeGreaterThanOrEqual(before); + }); + + it('should track message statistics', () => { + bridge.handleMessage('client1', { + type: 'ping', + payload: null, + timestamp: Date.now(), + }); + + bridge.handleMessage('client1', { + type: 'ping', + payload: null, + timestamp: Date.now(), + }); + + const stats = bridge.getStatistics(); + expect(stats.messagesReceived).toBe(2); + }); + }); + + describe('Statistics', () => { + beforeEach(async () => { + await bridge.start(); + }); + + it('should get statistics', () => { + bridge.connectClient('client1'); + bridge.connectClient('client2'); + + bridge.sendToClient('client1', { + type: 'ping', + payload: null, + timestamp: Date.now(), + }); + + bridge.broadcast({ + type: 'reload', + payload: null, + timestamp: Date.now(), + }); + + const stats = bridge.getStatistics(); + + expect(stats.totalConnections).toBe(2); + expect(stats.activeConnections).toBe(2); + expect(stats.messagesSent).toBe(3); // 1 + 2 from broadcast + expect(stats.broadcastCount).toBe(1); + }); + + it('should clear statistics', () => { + bridge.connectClient('client1'); + + bridge.sendToClient('client1', { + type: 'ping', + payload: null, + timestamp: Date.now(), + }); + + bridge.clearStatistics(); + + const stats = bridge.getStatistics(); + expect(stats.messagesSent).toBe(0); + expect(stats.messagesReceived).toBe(0); + expect(stats.broadcastCount).toBe(0); + }); + + it('should preserve total and active connections when clearing', () => { + bridge.connectClient('client1'); + bridge.connectClient('client2'); + + bridge.clearStatistics(); + + const stats = bridge.getStatistics(); + expect(stats.totalConnections).toBe(2); + expect(stats.activeConnections).toBe(2); + }); + }); +}); diff --git a/src/theater/atlas/Atlas.ts b/src/theater/atlas/Atlas.ts new file mode 100644 index 0000000..75f2e1d --- /dev/null +++ b/src/theater/atlas/Atlas.ts @@ -0,0 +1,521 @@ +/** + * Atlas - Documentation Hub + * + * The Atlas serves as the central documentation system for The Anatomy Theater, + * aggregating component documentation, generating API docs from TypeScript, + * and providing search and navigation capabilities. + * + * Medical Metaphor: An anatomical atlas is a comprehensive reference guide + * containing detailed illustrations and descriptions of body structures. + */ + +import { EventEmitter } from 'events'; +import type { VisualNeuron } from '../../ui/VisualNeuron'; +import type { ComponentProps } from '../../ui/types'; + +/** + * Documentation entry for a component + */ +export interface ComponentDocumentation { + /** Unique component identifier */ + id: string; + + /** Component name */ + name: string; + + /** Component description */ + description: string; + + /** Component category (e.g., 'ui', 'glial', 'respiratory') */ + category: string; + + /** Component tags for filtering */ + tags: string[]; + + /** Props documentation */ + props: PropDocumentation[]; + + /** State documentation */ + state: StateDocumentation[]; + + /** Signal documentation */ + signals: SignalDocumentation[]; + + /** Usage examples */ + examples: CodeExample[]; + + /** Related components */ + related: string[]; + + /** Source file path */ + source: string; + + /** When documented */ + timestamp: number; +} + +/** + * Prop documentation + */ +export interface PropDocumentation { + /** Prop name */ + name: string; + + /** TypeScript type */ + type: string; + + /** Description */ + description: string; + + /** Whether required */ + required: boolean; + + /** Default value */ + defaultValue?: unknown; + + /** Example values */ + examples?: unknown[]; +} + +/** + * State documentation + */ +export interface StateDocumentation { + /** State key */ + key: string; + + /** TypeScript type */ + type: string; + + /** Description */ + description: string; + + /** Initial value */ + initialValue?: unknown; +} + +/** + * Signal documentation + */ +export interface SignalDocumentation { + /** Signal type */ + type: string; + + /** Description */ + description: string; + + /** Signal data structure */ + dataType?: string; + + /** When signal is emitted */ + trigger: string; +} + +/** + * Code example + */ +export interface CodeExample { + /** Example title */ + title: string; + + /** Example description */ + description: string; + + /** Code snippet */ + code: string; + + /** Programming language */ + language: string; +} + +/** + * Search query for documentation + */ +export interface DocumentationQuery { + /** Text search */ + text?: string; + + /** Filter by category */ + category?: string; + + /** Filter by tags */ + tags?: string[]; + + /** Sort order */ + sortBy?: 'name' | 'category' | 'recent'; + + /** Sort direction */ + sortDirection?: 'asc' | 'desc'; +} + +/** + * Search result + */ +export interface SearchResult { + /** Matched documentation */ + documentation: ComponentDocumentation; + + /** Match score (0-1) */ + score: number; + + /** Matched fields */ + matches: string[]; +} + +/** + * Atlas configuration + */ +export interface AtlasConfig { + /** Atlas name */ + name?: string; + + /** Categories to include */ + categories?: string[]; + + /** Auto-generate docs from TypeScript */ + autoGenerate?: boolean; + + /** Include private components */ + includePrivate?: boolean; + + /** Maximum search results */ + maxResults?: number; +} + +/** + * Atlas statistics + */ +export interface AtlasStatistics { + /** Total documented components */ + totalComponents: number; + + /** Components by category */ + byCategory: Record; + + /** Total examples */ + totalExamples: number; + + /** Last updated */ + lastUpdated: number; +} + +/** + * Atlas - Documentation Hub + * + * @example + * ```typescript + * const atlas = new Atlas({ + * name: 'Synapse Component Atlas', + * autoGenerate: true + * }); + * + * // Document a component + * atlas.document({ + * id: 'button', + * name: 'Button', + * description: 'Interactive button component', + * category: 'ui', + * tags: ['interactive', 'form'], + * props: [ + * { name: 'label', type: 'string', description: 'Button text', required: true } + * ], + * state: [], + * signals: [], + * examples: [], + * related: [], + * source: 'src/ui/components/Button.ts', + * timestamp: Date.now() + * }); + * + * // Search documentation + * const results = atlas.search({ text: 'button', category: 'ui' }); + * ``` + */ +export class Atlas extends EventEmitter { + private readonly name: string; + private readonly config: Required; + private documentation: Map = new Map(); + private categories: Set = new Set(); + private tags: Set = new Set(); + + constructor(config: AtlasConfig = {}) { + super(); + + this.name = config.name ?? 'Component Atlas'; + this.config = { + name: this.name, + categories: config.categories ?? [], + autoGenerate: config.autoGenerate ?? false, + includePrivate: config.includePrivate ?? false, + maxResults: config.maxResults ?? 50, + }; + } + + /** + * Document a component + */ + public document(doc: ComponentDocumentation): void { + this.documentation.set(doc.id, doc); + this.categories.add(doc.category); + doc.tags.forEach((tag) => this.tags.add(tag)); + + this.emit('documented', { id: doc.id, name: doc.name }); + } + + /** + * Document a component from a VisualNeuron instance + */ + public documentComponent( + component: VisualNeuron, + metadata: { + description: string; + category: string; + tags?: string[]; + examples?: CodeExample[]; + related?: string[]; + }, + ): void { + const doc: ComponentDocumentation = { + id: component.id, + name: component.id, + description: metadata.description, + category: metadata.category, + tags: metadata.tags ?? [], + props: this.extractPropsDocumentation(component), + state: this.extractStateDocumentation(component), + signals: [], + examples: metadata.examples ?? [], + related: metadata.related ?? [], + source: '', + timestamp: Date.now(), + }; + + this.document(doc); + } + + /** + * Extract props documentation from component + * + * Note: Cannot extract props from component instance due to protected access. + * Props should be documented manually through the document() method. + */ + private extractPropsDocumentation( + _component: VisualNeuron, + ): PropDocumentation[] { + // Props extraction not possible due to protected receptiveField + return []; + } + + /** + * Extract state documentation from component + * + * Note: Cannot extract state from component instance reliably. + * State should be documented manually through the document() method. + */ + private extractStateDocumentation( + _component: VisualNeuron, + ): StateDocumentation[] { + // State extraction not reliably possible + return []; + } + + /** + * Get documentation by ID + */ + public get(id: string): ComponentDocumentation | undefined { + return this.documentation.get(id); + } + + /** + * Get all documentation + */ + public getAll(): ComponentDocumentation[] { + return Array.from(this.documentation.values()); + } + + /** + * Search documentation + */ + public search(query: DocumentationQuery): SearchResult[] { + let results = this.getAll(); + + // Filter by category + if (query.category !== undefined) { + results = results.filter((doc) => doc.category === query.category); + } + + // Filter by tags + if (query.tags !== undefined && query.tags.length > 0) { + const tags = query.tags; + results = results.filter((doc) => tags.some((tag) => doc.tags.includes(tag))); + } + + // Text search + const searchResults: SearchResult[] = results.map((doc) => { + let score = 0; + const matches: string[] = []; + + if (query.text !== undefined) { + const searchText = query.text.toLowerCase(); + + if (doc.name.toLowerCase().includes(searchText)) { + score += 1.0; + matches.push('name'); + } + if (doc.description.toLowerCase().includes(searchText)) { + score += 0.5; + matches.push('description'); + } + if (doc.tags.some((tag) => tag.toLowerCase().includes(searchText))) { + score += 0.3; + matches.push('tags'); + } + } else { + // No text search, include all + score = 1.0; + } + + return { documentation: doc, score, matches }; + }); + + // Filter out zero scores + let filtered = searchResults.filter((result) => result.score > 0); + + // Sort results + const sortBy = query.sortBy ?? 'name'; + const sortDirection = query.sortDirection ?? 'asc'; + + filtered.sort((a, b) => { + let comparison = 0; + + switch (sortBy) { + case 'name': + comparison = a.documentation.name.localeCompare(b.documentation.name); + break; + case 'category': + comparison = a.documentation.category.localeCompare(b.documentation.category); + break; + case 'recent': + comparison = b.documentation.timestamp - a.documentation.timestamp; + break; + } + + return sortDirection === 'asc' ? comparison : -comparison; + }); + + // Limit results + filtered = filtered.slice(0, this.config.maxResults); + + return filtered; + } + + /** + * Get all categories + */ + public getCategories(): string[] { + return Array.from(this.categories); + } + + /** + * Get all tags + */ + public getTags(): string[] { + return Array.from(this.tags); + } + + /** + * Get components by category + */ + public getByCategory(category: string): ComponentDocumentation[] { + return this.getAll().filter((doc) => doc.category === category); + } + + /** + * Get components by tag + */ + public getByTag(tag: string): ComponentDocumentation[] { + return this.getAll().filter((doc) => doc.tags.includes(tag)); + } + + /** + * Get related components + */ + public getRelated(id: string): ComponentDocumentation[] { + const doc = this.get(id); + if (doc === undefined) return []; + + return doc.related + .map((relatedId) => this.get(relatedId)) + .filter((d): d is ComponentDocumentation => d !== undefined); + } + + /** + * Remove documentation + */ + public remove(id: string): boolean { + const existed = this.documentation.has(id); + this.documentation.delete(id); + + if (existed) { + this.emit('removed', { id }); + } + + return existed; + } + + /** + * Clear all documentation + */ + public clear(): void { + this.documentation.clear(); + this.categories.clear(); + this.tags.clear(); + this.emit('cleared'); + } + + /** + * Get atlas statistics + */ + public getStatistics(): AtlasStatistics { + const byCategory: Record = {}; + + this.getAll().forEach((doc) => { + byCategory[doc.category] = (byCategory[doc.category] ?? 0) + 1; + }); + + return { + totalComponents: this.documentation.size, + byCategory, + totalExamples: this.getAll().reduce((sum, doc) => sum + doc.examples.length, 0), + lastUpdated: Math.max(...this.getAll().map((doc) => doc.timestamp), 0), + }; + } + + /** + * Export documentation as JSON + */ + public export(): string { + return JSON.stringify( + { + name: this.name, + documentation: this.getAll(), + statistics: this.getStatistics(), + exportedAt: Date.now(), + }, + null, + 2, + ); + } + + /** + * Import documentation from JSON + */ + public import(json: string): void { + const data = JSON.parse(json) as { + documentation: ComponentDocumentation[]; + }; + + data.documentation.forEach((doc) => this.document(doc)); + this.emit('imported', { count: data.documentation.length }); + } +} diff --git a/src/theater/atlas/ComponentCatalogue.ts b/src/theater/atlas/ComponentCatalogue.ts new file mode 100644 index 0000000..6292242 --- /dev/null +++ b/src/theater/atlas/ComponentCatalogue.ts @@ -0,0 +1,582 @@ +/** + * ComponentCatalogue - Component Inventory and Organization + * + * The ComponentCatalogue provides a structured inventory of all components, + * with advanced filtering, categorization, and dependency tracking. + * + * Medical Metaphor: A medical catalogue systematically organizes and classifies + * specimens, instruments, and procedures for easy reference. + */ + +import { EventEmitter } from 'events'; +import type { ComponentDocumentation } from './Atlas'; + +/** + * Component entry in the catalogue + */ +export interface CatalogueEntry { + /** Entry ID */ + id: string; + + /** Component documentation */ + documentation: ComponentDocumentation; + + /** Component version */ + version: string; + + /** Stability level */ + stability: 'experimental' | 'beta' | 'stable' | 'deprecated'; + + /** Dependencies */ + dependencies: string[]; + + /** Dependents (components that depend on this) */ + dependents: string[]; + + /** Installation count/popularity */ + popularity: number; + + /** Last updated timestamp */ + lastUpdated: number; + + /** Maintenance status */ + maintained: boolean; +} + +/** + * Catalogue filter + */ +export interface CatalogueFilter { + /** Filter by category */ + category?: string; + + /** Filter by stability */ + stability?: Array<'experimental' | 'beta' | 'stable' | 'deprecated'>; + + /** Filter by tags */ + tags?: string[]; + + /** Only maintained components */ + maintainedOnly?: boolean; + + /** Minimum popularity */ + minPopularity?: number; + + /** Text search */ + search?: string; +} + +/** + * Catalogue group + */ +export interface CatalogueGroup { + /** Group name */ + name: string; + + /** Group description */ + description: string; + + /** Component IDs in this group */ + components: string[]; + + /** Subgroups */ + subgroups: CatalogueGroup[]; +} + +/** + * Component dependency graph + */ +export interface DependencyGraph { + /** Nodes (components) */ + nodes: Array<{ + id: string; + name: string; + category: string; + }>; + + /** Edges (dependencies) */ + edges: Array<{ + from: string; + to: string; + type: 'dependency' | 'optional'; + }>; +} + +/** + * Catalogue configuration + */ +export interface CatalogueConfig { + /** Catalogue name */ + name?: string; + + /** Track dependencies */ + trackDependencies?: boolean; + + /** Track popularity */ + trackPopularity?: boolean; + + /** Auto-update entries */ + autoUpdate?: boolean; +} + +/** + * Catalogue statistics + */ +export interface CatalogueStatistics { + /** Total entries */ + total: number; + + /** By stability level */ + byStability: Record; + + /** By category */ + byCategory: Record; + + /** Maintained vs unmaintained */ + maintenanceStatus: { + maintained: number; + unmaintained: number; + }; + + /** Average popularity */ + averagePopularity: number; + + /** Most popular components */ + mostPopular: Array<{ id: string; popularity: number }>; + + /** Most dependencies */ + mostDependencies: Array<{ id: string; count: number }>; +} + +/** + * ComponentCatalogue - Component Inventory + * + * @example + * ```typescript + * const catalogue = new ComponentCatalogue({ + * trackDependencies: true, + * trackPopularity: true + * }); + * + * // Add component + * catalogue.add({ + * id: 'button', + * documentation: buttonDocs, + * version: '1.0.0', + * stability: 'stable', + * dependencies: [], + * dependents: [], + * popularity: 100, + * lastUpdated: Date.now(), + * maintained: true + * }); + * + * // Filter components + * const stable = catalogue.filter({ stability: ['stable'] }); + * ``` + */ +export class ComponentCatalogue extends EventEmitter { + private readonly name: string; + private readonly config: Required; + private entries: Map = new Map(); + private groups: Map = new Map(); + + constructor(config: CatalogueConfig = {}) { + super(); + + this.name = config.name ?? 'Component Catalogue'; + this.config = { + name: this.name, + trackDependencies: config.trackDependencies ?? true, + trackPopularity: config.trackPopularity ?? true, + autoUpdate: config.autoUpdate ?? false, + }; + } + + /** + * Add component to catalogue + */ + public add(entry: CatalogueEntry): void { + this.entries.set(entry.id, entry); + + // Update dependency tracking + if (this.config.trackDependencies) { + this.updateDependencyTracking(entry); + } + + this.emit('added', { id: entry.id }); + } + + /** + * Update dependency tracking for an entry + */ + private updateDependencyTracking(entry: CatalogueEntry): void { + // Update dependents for all dependencies + entry.dependencies.forEach((depId) => { + const dep = this.entries.get(depId); + if (dep !== undefined && !dep.dependents.includes(entry.id)) { + dep.dependents.push(entry.id); + } + }); + + // Update this entry's dependents list if other entries depend on it + this.entries.forEach((otherEntry) => { + if (otherEntry.id !== entry.id && otherEntry.dependencies.includes(entry.id)) { + if (!entry.dependents.includes(otherEntry.id)) { + entry.dependents.push(otherEntry.id); + } + } + }); + } + + /** + * Get entry by ID + */ + public get(id: string): CatalogueEntry | undefined { + return this.entries.get(id); + } + + /** + * Get all entries + */ + public getAll(): CatalogueEntry[] { + return Array.from(this.entries.values()); + } + + /** + * Filter entries + */ + public filter(filter: CatalogueFilter): CatalogueEntry[] { + let results = this.getAll(); + + // Filter by category + if (filter.category !== undefined) { + results = results.filter((entry) => entry.documentation.category === filter.category); + } + + // Filter by stability + if (filter.stability !== undefined && filter.stability.length > 0) { + const stability = filter.stability; + results = results.filter((entry) => stability.includes(entry.stability)); + } + + // Filter by tags + if (filter.tags !== undefined && filter.tags.length > 0) { + const tags = filter.tags; + results = results.filter((entry) => + tags.some((tag) => entry.documentation.tags.includes(tag)), + ); + } + + // Filter by maintained status + if (filter.maintainedOnly === true) { + results = results.filter((entry) => entry.maintained); + } + + // Filter by minimum popularity + if (filter.minPopularity !== undefined) { + const minPopularity = filter.minPopularity; + results = results.filter((entry) => entry.popularity >= minPopularity); + } + + // Text search + if (filter.search !== undefined) { + const searchText = filter.search.toLowerCase(); + results = results.filter( + (entry) => + entry.documentation.name.toLowerCase().includes(searchText) || + entry.documentation.description.toLowerCase().includes(searchText) || + entry.documentation.tags.some((tag) => tag.toLowerCase().includes(searchText)), + ); + } + + return results; + } + + /** + * Get entries by stability + */ + public getByStability(stability: CatalogueEntry['stability']): CatalogueEntry[] { + return this.getAll().filter((entry) => entry.stability === stability); + } + + /** + * Get entries by category + */ + public getByCategory(category: string): CatalogueEntry[] { + return this.getAll().filter((entry) => entry.documentation.category === category); + } + + /** + * Get component dependencies + */ + public getDependencies(id: string, recursive: boolean = false): string[] { + const entry = this.get(id); + if (entry === undefined) return []; + + if (!recursive) { + return entry.dependencies; + } + + // Recursive dependency collection + const deps = new Set(); + const visited = new Set(); + + const collectDeps = (componentId: string): void => { + if (visited.has(componentId)) return; + visited.add(componentId); + + const component = this.get(componentId); + if (component === undefined) return; + + component.dependencies.forEach((depId) => { + deps.add(depId); + collectDeps(depId); + }); + }; + + collectDeps(id); + return Array.from(deps); + } + + /** + * Get component dependents + */ + public getDependents(id: string, recursive: boolean = false): string[] { + const entry = this.get(id); + if (entry === undefined) return []; + + if (!recursive) { + return entry.dependents; + } + + // Recursive dependent collection + const dependents = new Set(); + const visited = new Set(); + + const collectDependents = (componentId: string): void => { + if (visited.has(componentId)) return; + visited.add(componentId); + + const component = this.get(componentId); + if (component === undefined) return; + + component.dependents.forEach((depId) => { + dependents.add(depId); + collectDependents(depId); + }); + }; + + collectDependents(id); + return Array.from(dependents); + } + + /** + * Get dependency graph + */ + public getDependencyGraph(): DependencyGraph { + const nodes = this.getAll().map((entry) => ({ + id: entry.id, + name: entry.documentation.name, + category: entry.documentation.category, + })); + + const edges: DependencyGraph['edges'] = []; + + this.getAll().forEach((entry) => { + entry.dependencies.forEach((depId) => { + edges.push({ + from: entry.id, + to: depId, + type: 'dependency', + }); + }); + }); + + return { nodes, edges }; + } + + /** + * Create a group + */ + public createGroup(name: string, description: string, componentIds: string[]): void { + const group: CatalogueGroup = { + name, + description, + components: componentIds, + subgroups: [], + }; + + this.groups.set(name, group); + this.emit('group:created', { name }); + } + + /** + * Get group + */ + public getGroup(name: string): CatalogueGroup | undefined { + return this.groups.get(name); + } + + /** + * Get all groups + */ + public getGroups(): CatalogueGroup[] { + return Array.from(this.groups.values()); + } + + /** + * Add component to group + */ + public addToGroup(groupName: string, componentId: string): void { + const group = this.groups.get(groupName); + if (group === undefined) { + throw new Error(`Group not found: ${groupName}`); + } + + if (!group.components.includes(componentId)) { + group.components.push(componentId); + this.emit('group:updated', { name: groupName }); + } + } + + /** + * Increment popularity + */ + public incrementPopularity(id: string, amount: number = 1): void { + const entry = this.get(id); + if (entry === undefined) return; + + entry.popularity += amount; + this.emit('popularity:updated', { id, popularity: entry.popularity }); + } + + /** + * Update entry + */ + public update(id: string, updates: Partial): void { + const entry = this.get(id); + if (entry === undefined) { + throw new Error(`Component not found: ${id}`); + } + + Object.assign(entry, updates); + entry.lastUpdated = Date.now(); + + this.emit('updated', { id }); + } + + /** + * Remove entry + */ + public remove(id: string): boolean { + const existed = this.entries.has(id); + this.entries.delete(id); + + if (existed) { + // Remove from all groups + this.groups.forEach((group) => { + group.components = group.components.filter((cid) => cid !== id); + }); + + this.emit('removed', { id }); + } + + return existed; + } + + /** + * Clear catalogue + */ + public clear(): void { + this.entries.clear(); + this.groups.clear(); + this.emit('cleared'); + } + + /** + * Get catalogue statistics + */ + public getStatistics(): CatalogueStatistics { + const entries = this.getAll(); + + const byStability: Record = { + experimental: 0, + beta: 0, + stable: 0, + deprecated: 0, + }; + + const byCategory: Record = {}; + + let maintained = 0; + let totalPopularity = 0; + + entries.forEach((entry) => { + byStability[entry.stability] = (byStability[entry.stability] ?? 0) + 1; + byCategory[entry.documentation.category] = + (byCategory[entry.documentation.category] ?? 0) + 1; + + if (entry.maintained) maintained++; + totalPopularity += entry.popularity; + }); + + const mostPopular = entries + .sort((a, b) => b.popularity - a.popularity) + .slice(0, 10) + .map((e) => ({ id: e.id, popularity: e.popularity })); + + const mostDependencies = entries + .sort((a, b) => b.dependencies.length - a.dependencies.length) + .slice(0, 10) + .map((e) => ({ id: e.id, count: e.dependencies.length })); + + return { + total: entries.length, + byStability, + byCategory, + maintenanceStatus: { + maintained, + unmaintained: entries.length - maintained, + }, + averagePopularity: entries.length > 0 ? totalPopularity / entries.length : 0, + mostPopular, + mostDependencies, + }; + } + + /** + * Export catalogue as JSON + */ + public export(): string { + return JSON.stringify( + { + name: this.name, + entries: this.getAll(), + groups: this.getGroups(), + statistics: this.getStatistics(), + exportedAt: Date.now(), + }, + null, + 2, + ); + } + + /** + * Import catalogue from JSON + */ + public import(json: string): void { + const data = JSON.parse(json) as { + entries: CatalogueEntry[]; + groups: CatalogueGroup[]; + }; + + data.entries.forEach((entry) => this.add(entry)); + data.groups.forEach((group) => this.groups.set(group.name, group)); + + this.emit('imported', { + entries: data.entries.length, + groups: data.groups.length, + }); + } +} diff --git a/src/theater/atlas/Diagram.ts b/src/theater/atlas/Diagram.ts new file mode 100644 index 0000000..4a663f4 --- /dev/null +++ b/src/theater/atlas/Diagram.ts @@ -0,0 +1,594 @@ +/** + * Diagram - Visual Documentation Generator + * + * The Diagram generates visual representations of component structures, + * signal flows, dependencies, and state machines using Mermaid and GraphViz formats. + * + * Medical Metaphor: Medical diagrams illustrate anatomical structures, + * physiological processes, and interconnections in a clear visual format. + */ + +import type { DependencyGraph } from './ComponentCatalogue'; +import type { ComponentDocumentation } from './Atlas'; + +/** + * Diagram type + */ +export type DiagramType = + | 'component-hierarchy' + | 'signal-flow' + | 'dependency-graph' + | 'state-machine' + | 'architecture'; + +/** + * Diagram format + */ +export type DiagramFormat = 'mermaid' | 'graphviz' | 'svg' | 'png'; + +/** + * Diagram configuration + */ +export interface DiagramConfig { + /** Diagram title */ + title?: string; + + /** Diagram type */ + type: DiagramType; + + /** Output format */ + format?: DiagramFormat; + + /** Direction (for hierarchies) */ + direction?: 'TB' | 'BT' | 'LR' | 'RL'; + + /** Include labels */ + showLabels?: boolean; + + /** Include types */ + showTypes?: boolean; + + /** Color scheme */ + colorScheme?: 'default' | 'medical' | 'neural'; + + /** Maximum depth (for hierarchies) */ + maxDepth?: number; +} + +/** + * Node in a diagram + */ +export interface DiagramNode { + /** Node ID */ + id: string; + + /** Node label */ + label: string; + + /** Node type */ + type?: string; + + /** Node shape */ + shape?: 'box' | 'ellipse' | 'diamond' | 'hexagon'; + + /** Node color */ + color?: string; + + /** Additional metadata */ + metadata?: Record; +} + +/** + * Edge in a diagram + */ +export interface DiagramEdge { + /** Source node ID */ + from: string; + + /** Target node ID */ + to: string; + + /** Edge label */ + label?: string; + + /** Edge type */ + type?: 'solid' | 'dashed' | 'dotted'; + + /** Edge color */ + color?: string; + + /** Arrow direction */ + arrow?: 'forward' | 'backward' | 'both' | 'none'; +} + +/** + * State machine state + */ +export interface StateMachineState { + /** State name */ + name: string; + + /** State type */ + type: 'initial' | 'active' | 'final'; + + /** State description */ + description?: string; +} + +/** + * State machine transition + */ +export interface StateMachineTransition { + /** Source state */ + from: string; + + /** Target state */ + to: string; + + /** Trigger event */ + trigger: string; + + /** Guard condition */ + guard?: string; + + /** Actions */ + actions?: string[]; +} + +/** + * Diagram - Visual Documentation Generator + * + * @example + * ```typescript + * const diagram = new Diagram(); + * + * // Generate component hierarchy + * const mermaid = diagram.generateComponentHierarchy( + * componentDocs, + * { format: 'mermaid', direction: 'TB' } + * ); + * + * // Generate dependency graph + * const graph = diagram.generateDependencyGraph( + * dependencyData, + * { format: 'graphviz' } + * ); + * ``` + */ +export class Diagram { + /** + * Generate component hierarchy diagram + */ + public generateComponentHierarchy( + components: ComponentDocumentation[], + config: DiagramConfig = { type: 'component-hierarchy' }, + ): string { + const format = config.format ?? 'mermaid'; + + if (format === 'mermaid') { + return this.generateMermaidHierarchy(components, config); + } else if (format === 'graphviz') { + return this.generateGraphvizHierarchy(components, config); + } + + throw new Error(`Unsupported format: ${format}`); + } + + /** + * Generate Mermaid hierarchy diagram + */ + private generateMermaidHierarchy( + components: ComponentDocumentation[], + config: DiagramConfig, + ): string { + const direction = config.direction ?? 'TB'; + const lines: string[] = [`graph ${direction}`]; + + if (config.title !== undefined) { + lines.push(` title ${config.title}`); + } + + // Group by category + const byCategory: Record = {}; + components.forEach((comp) => { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + if (byCategory[comp.category] === undefined) { + byCategory[comp.category] = []; + } + byCategory[comp.category]?.push(comp); + }); + + // Generate category subgraphs + Object.entries(byCategory).forEach(([category, comps]) => { + lines.push(` subgraph ${category}`); + comps.forEach((comp) => { + const label = config.showLabels !== false ? comp.name : comp.id; + lines.push(` ${this.sanitizeId(comp.id)}[${label}]`); + }); + lines.push(' end'); + }); + + // Add relationships + components.forEach((comp) => { + comp.related.forEach((relatedId) => { + lines.push(` ${this.sanitizeId(comp.id)} --> ${this.sanitizeId(relatedId)}`); + }); + }); + + return lines.join('\n'); + } + + /** + * Generate GraphViz hierarchy diagram + */ + private generateGraphvizHierarchy( + components: ComponentDocumentation[], + config: DiagramConfig, + ): string { + const lines: string[] = ['digraph ComponentHierarchy {']; + + if (config.title !== undefined) { + lines.push(` label="${config.title}";`); + lines.push(' labelloc=top;'); + } + + lines.push(' rankdir=' + (config.direction ?? 'TB') + ';'); + lines.push(' node [shape=box, style=rounded];'); + + // Group by category + const byCategory: Record = {}; + components.forEach((comp) => { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + if (byCategory[comp.category] === undefined) { + byCategory[comp.category] = []; + } + byCategory[comp.category]?.push(comp); + }); + + // Generate category clusters + Object.entries(byCategory).forEach(([category, comps], index) => { + lines.push(` subgraph cluster_${index} {`); + lines.push(` label="${category}";`); + comps.forEach((comp) => { + const label = config.showLabels !== false ? comp.name : comp.id; + lines.push(` "${comp.id}" [label="${label}"];`); + }); + lines.push(' }'); + }); + + // Add edges + components.forEach((comp) => { + comp.related.forEach((relatedId) => { + lines.push(` "${comp.id}" -> "${relatedId}";`); + }); + }); + + lines.push('}'); + return lines.join('\n'); + } + + /** + * Generate dependency graph + */ + public generateDependencyGraph(graph: DependencyGraph, config: DiagramConfig): string { + const format = config.format ?? 'mermaid'; + + if (format === 'mermaid') { + return this.generateMermaidDependencies(graph, config); + } else if (format === 'graphviz') { + return this.generateGraphvizDependencies(graph, config); + } + + throw new Error(`Unsupported format: ${format}`); + } + + /** + * Generate Mermaid dependency graph + */ + private generateMermaidDependencies(graph: DependencyGraph, config: DiagramConfig): string { + const direction = config.direction ?? 'LR'; + const lines: string[] = [`graph ${direction}`]; + + if (config.title !== undefined) { + lines.push(` title ${config.title}`); + } + + // Add nodes + graph.nodes.forEach((node) => { + const label = config.showLabels !== false ? node.name : node.id; + lines.push(` ${this.sanitizeId(node.id)}[${label}]`); + }); + + // Add edges + graph.edges.forEach((edge) => { + const arrow = edge.type === 'optional' ? '-.->' : '-->'; + lines.push(` ${this.sanitizeId(edge.from)} ${arrow} ${this.sanitizeId(edge.to)}`); + }); + + return lines.join('\n'); + } + + /** + * Generate GraphViz dependency graph + */ + private generateGraphvizDependencies(graph: DependencyGraph, config: DiagramConfig): string { + const lines: string[] = ['digraph Dependencies {']; + + if (config.title !== undefined) { + lines.push(` label="${config.title}";`); + } + + lines.push(' rankdir=' + (config.direction ?? 'LR') + ';'); + + // Add nodes + graph.nodes.forEach((node) => { + const label = config.showLabels !== false ? node.name : node.id; + lines.push(` "${node.id}" [label="${label}"];`); + }); + + // Add edges + graph.edges.forEach((edge) => { + const style = edge.type === 'optional' ? 'style=dashed' : ''; + lines.push(` "${edge.from}" -> "${edge.to}" [${style}];`); + }); + + lines.push('}'); + return lines.join('\n'); + } + + /** + * Generate signal flow diagram + */ + public generateSignalFlow( + nodes: DiagramNode[], + edges: DiagramEdge[], + config: DiagramConfig, + ): string { + const format = config.format ?? 'mermaid'; + + if (format === 'mermaid') { + return this.generateMermaidSignalFlow(nodes, edges, config); + } else if (format === 'graphviz') { + return this.generateGraphvizSignalFlow(nodes, edges, config); + } + + throw new Error(`Unsupported format: ${format}`); + } + + /** + * Generate Mermaid signal flow diagram + */ + private generateMermaidSignalFlow( + nodes: DiagramNode[], + edges: DiagramEdge[], + config: DiagramConfig, + ): string { + const direction = config.direction ?? 'LR'; + const lines: string[] = [`graph ${direction}`]; + + if (config.title !== undefined) { + lines.push(` title ${config.title}`); + } + + // Add nodes with shapes + nodes.forEach((node) => { + const shape = this.getMermaidShape(node.shape ?? 'box'); + lines.push(` ${this.sanitizeId(node.id)}${shape[0]}${node.label}${shape[1]}`); + }); + + // Add edges + edges.forEach((edge) => { + const arrow = this.getMermaidArrow(edge.type ?? 'solid', edge.arrow ?? 'forward'); + const label = edge.label !== undefined ? `|${edge.label}|` : ''; + lines.push( + ` ${this.sanitizeId(edge.from)} ${arrow[0]}${label}${arrow[1]} ${this.sanitizeId(edge.to)}`, + ); + }); + + return lines.join('\n'); + } + + /** + * Generate GraphViz signal flow diagram + */ + private generateGraphvizSignalFlow( + nodes: DiagramNode[], + edges: DiagramEdge[], + config: DiagramConfig, + ): string { + const lines: string[] = ['digraph SignalFlow {']; + + if (config.title !== undefined) { + lines.push(` label="${config.title}";`); + } + + lines.push(' rankdir=' + (config.direction ?? 'LR') + ';'); + + // Add nodes + nodes.forEach((node) => { + const shape = node.shape ?? 'box'; + const color = node.color !== undefined ? `, fillcolor="${node.color}", style=filled` : ''; + lines.push(` "${node.id}" [label="${node.label}", shape=${shape}${color}];`); + }); + + // Add edges + edges.forEach((edge) => { + const style = edge.type !== undefined && edge.type !== 'solid' ? `style=${edge.type}` : ''; + const label = edge.label !== undefined ? `, label="${edge.label}"` : ''; + const color = edge.color !== undefined ? `, color="${edge.color}"` : ''; + lines.push(` "${edge.from}" -> "${edge.to}" [${style}${label}${color}];`); + }); + + lines.push('}'); + return lines.join('\n'); + } + + /** + * Generate state machine diagram + */ + public generateStateMachine( + states: StateMachineState[], + transitions: StateMachineTransition[], + config: DiagramConfig, + ): string { + const format = config.format ?? 'mermaid'; + + if (format === 'mermaid') { + return this.generateMermaidStateMachine(states, transitions, config); + } else if (format === 'graphviz') { + return this.generateGraphvizStateMachine(states, transitions, config); + } + + throw new Error(`Unsupported format: ${format}`); + } + + /** + * Generate Mermaid state machine diagram + */ + private generateMermaidStateMachine( + states: StateMachineState[], + transitions: StateMachineTransition[], + config: DiagramConfig, + ): string { + const lines: string[] = ['stateDiagram-v2']; + + if (config.title !== undefined) { + lines.push(` title ${config.title}`); + } + + // Add initial state + const initial = states.find((s) => s.type === 'initial'); + if (initial !== undefined) { + lines.push(` [*] --> ${this.sanitizeId(initial.name)}`); + } + + // Add states + states.forEach((state) => { + if (state.description !== undefined) { + lines.push(` ${this.sanitizeId(state.name)}: ${state.description}`); + } + }); + + // Add transitions + transitions.forEach((trans) => { + const label = trans.guard !== undefined ? `${trans.trigger} [${trans.guard}]` : trans.trigger; + lines.push(` ${this.sanitizeId(trans.from)} --> ${this.sanitizeId(trans.to)}: ${label}`); + }); + + // Add final states + states + .filter((s) => s.type === 'final') + .forEach((state) => { + lines.push(` ${this.sanitizeId(state.name)} --> [*]`); + }); + + return lines.join('\n'); + } + + /** + * Generate GraphViz state machine diagram + */ + private generateGraphvizStateMachine( + states: StateMachineState[], + transitions: StateMachineTransition[], + config: DiagramConfig, + ): string { + const lines: string[] = ['digraph StateMachine {']; + + if (config.title !== undefined) { + lines.push(` label="${config.title}";`); + } + + lines.push(' node [shape=circle];'); + + // Add states + states.forEach((state) => { + const shape = state.type === 'final' ? 'doublecircle' : 'circle'; + lines.push(` "${state.name}" [shape=${shape}];`); + }); + + // Add initial state + const initial = states.find((s) => s.type === 'initial'); + if (initial !== undefined) { + lines.push(' __start__ [shape=point];'); + lines.push(` __start__ -> "${initial.name}";`); + } + + // Add transitions + transitions.forEach((trans) => { + const label = + trans.guard !== undefined ? `${trans.trigger}\\n[${trans.guard}]` : trans.trigger; + lines.push(` "${trans.from}" -> "${trans.to}" [label="${label}"];`); + }); + + lines.push('}'); + return lines.join('\n'); + } + + /** + * Get Mermaid shape notation + */ + private getMermaidShape(shape: string): [string, string] { + switch (shape) { + case 'box': + return ['[', ']']; + case 'ellipse': + return ['([', '])']; + case 'diamond': + return ['{', '}']; + case 'hexagon': + return ['{{', '}}']; + default: + return ['[', ']']; + } + } + + /** + * Get Mermaid arrow notation + */ + private getMermaidArrow(type: string, direction: string): [string, string] { + const arrows: Record = { + 'solid-forward': ['--', '-->'], + 'solid-backward': ['<--', '--'], + 'solid-both': ['<--', '-->'], + 'solid-none': ['--', '--'], + 'dashed-forward': ['-.', '.->'], + 'dashed-backward': ['<-.', '.-'], + 'dashed-both': ['<-.', '.->'], + 'dashed-none': ['-.', '.-'], + 'dotted-forward': ['-.', '.->'], + 'dotted-backward': ['<-.', '.-'], + 'dotted-both': ['<-.', '.->'], + 'dotted-none': ['-.', '.-'], + }; + + return arrows[`${type}-${direction}`] ?? ['--', '-->']; + } + + /** + * Sanitize ID for Mermaid/GraphViz + */ + private sanitizeId(id: string): string { + return id.replace(/[^a-zA-Z0-9_]/g, '_'); + } + + /** + * Convert to SVG (requires external renderer) + */ + // eslint-disable-next-line @typescript-eslint/require-await + public async renderToSVG(_diagram: string, _format: 'mermaid' | 'graphviz'): Promise { + // This would integrate with Mermaid.js or GraphViz CLI + // For now, return placeholder + throw new Error('SVG rendering not implemented - use Mermaid.js or GraphViz CLI'); + } + + /** + * Convert to PNG (requires external renderer) + */ + // eslint-disable-next-line @typescript-eslint/require-await + public async renderToPNG(_diagram: string, _format: 'mermaid' | 'graphviz'): Promise { + // This would integrate with Mermaid.js or GraphViz CLI + // For now, return placeholder + throw new Error('PNG rendering not implemented - use Mermaid.js or GraphViz CLI'); + } +} diff --git a/src/theater/atlas/Protocol.ts b/src/theater/atlas/Protocol.ts new file mode 100644 index 0000000..d6a04f3 --- /dev/null +++ b/src/theater/atlas/Protocol.ts @@ -0,0 +1,652 @@ +/** + * Protocol - Usage Guidelines and Best Practices + * + * The Protocol provides structured documentation for usage patterns, + * best practices, accessibility guidelines, and performance recommendations. + * + * Medical Metaphor: Medical protocols define standardized procedures, + * best practices, and guidelines for consistent treatment and care. + */ + +import { EventEmitter } from 'events'; + +/** + * Protocol type + */ +export type ProtocolType = + | 'usage' + | 'best-practice' + | 'accessibility' + | 'performance' + | 'security' + | 'testing'; + +/** + * Protocol severity/priority + */ +export type ProtocolSeverity = 'critical' | 'important' | 'recommended' | 'optional'; + +/** + * Code example in a protocol + */ +export interface ProtocolExample { + /** Example title */ + title: string; + + /** Example description */ + description: string; + + /** Code snippet */ + code: string; + + /** Programming language */ + language: string; + + /** Whether this is a good or bad example */ + good: boolean; + + /** Explanation of why */ + explanation: string; +} + +/** + * Protocol guideline + */ +export interface ProtocolGuideline { + /** Guideline ID */ + id: string; + + /** Guideline title */ + title: string; + + /** Guideline description */ + description: string; + + /** Protocol type */ + type: ProtocolType; + + /** Severity/priority */ + severity: ProtocolSeverity; + + /** Detailed explanation */ + explanation: string; + + /** Examples */ + examples: ProtocolExample[]; + + /** Related guidelines */ + related: string[]; + + /** Tags for filtering */ + tags: string[]; + + /** References (links to docs, standards, etc.) */ + references: Array<{ + title: string; + url: string; + }>; + + /** When created */ + timestamp: number; +} + +/** + * Protocol checklist item + */ +export interface ChecklistItem { + /** Item ID */ + id: string; + + /** Item text */ + text: string; + + /** Category */ + category: string; + + /** Required or optional */ + required: boolean; + + /** Related guideline ID */ + guidelineId?: string; +} + +/** + * Component protocol + */ +export interface ComponentProtocol { + /** Component ID */ + componentId: string; + + /** Component name */ + componentName: string; + + /** Usage patterns */ + usagePatterns: ProtocolGuideline[]; + + /** Best practices */ + bestPractices: ProtocolGuideline[]; + + /** Accessibility guidelines */ + accessibility: ProtocolGuideline[]; + + /** Performance recommendations */ + performance: ProtocolGuideline[]; + + /** Security considerations */ + security: ProtocolGuideline[]; + + /** Testing strategies */ + testing: ProtocolGuideline[]; + + /** Checklist */ + checklist: ChecklistItem[]; +} + +/** + * Protocol search query + */ +export interface ProtocolQuery { + /** Text search */ + text?: string; + + /** Filter by type */ + type?: ProtocolType; + + /** Filter by severity */ + severity?: ProtocolSeverity; + + /** Filter by tags */ + tags?: string[]; + + /** Component ID */ + componentId?: string; +} + +/** + * Protocol configuration + */ +export interface ProtocolConfig { + /** Protocol system name */ + name?: string; + + /** Enforce severity levels */ + enforceSeverity?: boolean; + + /** Include examples */ + includeExamples?: boolean; + + /** Auto-generate checklists */ + autoGenerateChecklists?: boolean; +} + +/** + * Protocol statistics + */ +export interface ProtocolStatistics { + /** Total guidelines */ + totalGuidelines: number; + + /** By type */ + byType: Record; + + /** By severity */ + bySeverity: Record; + + /** Total examples */ + totalExamples: number; + + /** Components with protocols */ + componentsWithProtocols: number; +} + +/** + * Protocol - Usage Guidelines and Best Practices + * + * @example + * ```typescript + * const protocol = new Protocol(); + * + * // Add guideline + * protocol.addGuideline({ + * id: 'button-accessibility', + * title: 'Button Accessibility', + * description: 'Ensure buttons are keyboard accessible', + * type: 'accessibility', + * severity: 'critical', + * explanation: 'Buttons must be operable via keyboard...', + * examples: [ + * { + * title: 'Good Example', + * description: 'Button with proper aria-label', + * code: '', + * language: 'html', + * good: true, + * explanation: 'Provides accessible name' + * } + * ], + * related: [], + * tags: ['keyboard', 'aria'], + * references: [], + * timestamp: Date.now() + * }); + * + * // Get protocol for component + * const buttonProtocol = protocol.getComponentProtocol('button'); + * ``` + */ +export class Protocol extends EventEmitter { + private readonly name: string; + private readonly config: Required; + private guidelines: Map = new Map(); + private componentProtocols: Map = new Map(); + private checklists: Map = new Map(); + + constructor(config: ProtocolConfig = {}) { + super(); + + this.name = config.name ?? 'Protocol System'; + this.config = { + name: this.name, + enforceSeverity: config.enforceSeverity ?? true, + includeExamples: config.includeExamples ?? true, + autoGenerateChecklists: config.autoGenerateChecklists ?? true, + }; + } + + /** + * Add a guideline + */ + public addGuideline(guideline: ProtocolGuideline): void { + this.guidelines.set(guideline.id, guideline); + this.emit('guideline:added', { id: guideline.id }); + } + + /** + * Get guideline by ID + */ + public getGuideline(id: string): ProtocolGuideline | undefined { + return this.guidelines.get(id); + } + + /** + * Get all guidelines + */ + public getAllGuidelines(): ProtocolGuideline[] { + return Array.from(this.guidelines.values()); + } + + /** + * Search guidelines + */ + public search(query: ProtocolQuery): ProtocolGuideline[] { + let results = this.getAllGuidelines(); + + // Filter by type + if (query.type !== undefined) { + results = results.filter((g) => g.type === query.type); + } + + // Filter by severity + if (query.severity !== undefined) { + results = results.filter((g) => g.severity === query.severity); + } + + // Filter by tags + if (query.tags !== undefined && query.tags.length > 0) { + const tags = query.tags; + results = results.filter((g) => tags.some((tag) => g.tags.includes(tag))); + } + + // Text search + if (query.text !== undefined) { + const searchText = query.text.toLowerCase(); + results = results.filter( + (g) => + g.title.toLowerCase().includes(searchText) || + g.description.toLowerCase().includes(searchText) || + g.explanation.toLowerCase().includes(searchText), + ); + } + + return results; + } + + /** + * Get guidelines by type + */ + public getByType(type: ProtocolType): ProtocolGuideline[] { + return this.getAllGuidelines().filter((g) => g.type === type); + } + + /** + * Get guidelines by severity + */ + public getBySeverity(severity: ProtocolSeverity): ProtocolGuideline[] { + return this.getAllGuidelines().filter((g) => g.severity === severity); + } + + /** + * Set component protocol + */ + public setComponentProtocol(protocol: ComponentProtocol): void { + this.componentProtocols.set(protocol.componentId, protocol); + + // Auto-generate checklist if enabled + if (this.config.autoGenerateChecklists) { + this.generateChecklist(protocol.componentId); + } + + this.emit('component:protocol-set', { componentId: protocol.componentId }); + } + + /** + * Get component protocol + */ + public getComponentProtocol(componentId: string): ComponentProtocol | undefined { + return this.componentProtocols.get(componentId); + } + + /** + * Generate checklist from guidelines + */ + public generateChecklist(componentId: string): ChecklistItem[] { + const protocol = this.componentProtocols.get(componentId); + if (protocol === undefined) return []; + + const items: ChecklistItem[] = []; + let itemId = 0; + + // Add accessibility items (all required) + protocol.accessibility.forEach((guideline) => { + items.push({ + id: `${componentId}-a11y-${itemId++}`, + text: guideline.title, + category: 'Accessibility', + required: guideline.severity === 'critical' || guideline.severity === 'important', + guidelineId: guideline.id, + }); + }); + + // Add security items + protocol.security.forEach((guideline) => { + items.push({ + id: `${componentId}-security-${itemId++}`, + text: guideline.title, + category: 'Security', + required: guideline.severity === 'critical', + guidelineId: guideline.id, + }); + }); + + // Add performance items + protocol.performance.forEach((guideline) => { + items.push({ + id: `${componentId}-perf-${itemId++}`, + text: guideline.title, + category: 'Performance', + required: guideline.severity === 'critical', + guidelineId: guideline.id, + }); + }); + + // Add testing items + protocol.testing.forEach((guideline) => { + items.push({ + id: `${componentId}-test-${itemId++}`, + text: guideline.title, + category: 'Testing', + required: guideline.severity === 'critical' || guideline.severity === 'important', + guidelineId: guideline.id, + }); + }); + + this.checklists.set(componentId, items); + return items; + } + + /** + * Get checklist for component + */ + public getChecklist(componentId: string): ChecklistItem[] { + return this.checklists.get(componentId) ?? []; + } + + /** + * Validate component against protocol + */ + public validate( + componentId: string, + completedItems: string[], + ): { + passed: boolean; + missingRequired: ChecklistItem[]; + missingOptional: ChecklistItem[]; + score: number; + } { + const checklist = this.getChecklist(componentId); + const requiredItems = checklist.filter((item) => item.required); + const optionalItems = checklist.filter((item) => !item.required); + + const missingRequired = requiredItems.filter((item) => !completedItems.includes(item.id)); + const missingOptional = optionalItems.filter((item) => !completedItems.includes(item.id)); + + const passed = missingRequired.length === 0; + const score = checklist.length > 0 ? (completedItems.length / checklist.length) * 100 : 100; + + return { + passed, + missingRequired, + missingOptional, + score, + }; + } + + /** + * Create usage pattern guideline + */ + public createUsagePattern( + id: string, + title: string, + description: string, + examples: ProtocolExample[], + options: { + severity?: ProtocolSeverity; + tags?: string[]; + related?: string[]; + } = {}, + ): ProtocolGuideline { + const guideline: ProtocolGuideline = { + id, + title, + description, + type: 'usage', + severity: options.severity ?? 'recommended', + explanation: description, + examples, + related: options.related ?? [], + tags: options.tags ?? [], + references: [], + timestamp: Date.now(), + }; + + this.addGuideline(guideline); + return guideline; + } + + /** + * Create accessibility guideline + */ + public createAccessibilityGuideline( + id: string, + title: string, + description: string, + wcagLevel: '2.0' | '2.1' | '2.2', + criterion: string, + options: { + severity?: ProtocolSeverity; + examples?: ProtocolExample[]; + } = {}, + ): ProtocolGuideline { + const guideline: ProtocolGuideline = { + id, + title, + description, + type: 'accessibility', + severity: options.severity ?? 'critical', + explanation: `WCAG ${wcagLevel} - ${criterion}: ${description}`, + examples: options.examples ?? [], + related: [], + tags: ['wcag', wcagLevel, criterion], + references: [ + { + title: `WCAG ${wcagLevel} ${criterion}`, + url: `https://www.w3.org/WAI/WCAG${wcagLevel.replace('.', '')}/quickref/#${criterion}`, + }, + ], + timestamp: Date.now(), + }; + + this.addGuideline(guideline); + return guideline; + } + + /** + * Create performance guideline + */ + public createPerformanceGuideline( + id: string, + title: string, + description: string, + impact: 'high' | 'medium' | 'low', + options: { + examples?: ProtocolExample[]; + tags?: string[]; + } = {}, + ): ProtocolGuideline { + const severityMap: Record<'high' | 'medium' | 'low', ProtocolSeverity> = { + high: 'critical', + medium: 'important', + low: 'recommended', + }; + + const guideline: ProtocolGuideline = { + id, + title, + description, + type: 'performance', + severity: severityMap[impact], + explanation: description, + examples: options.examples ?? [], + related: [], + tags: options.tags ?? ['performance', impact], + references: [], + timestamp: Date.now(), + }; + + this.addGuideline(guideline); + return guideline; + } + + /** + * Get statistics + */ + public getStatistics(): ProtocolStatistics { + const guidelines = this.getAllGuidelines(); + + const byType: Record = { + usage: 0, + 'best-practice': 0, + accessibility: 0, + performance: 0, + security: 0, + testing: 0, + }; + + const bySeverity: Record = { + critical: 0, + important: 0, + recommended: 0, + optional: 0, + }; + + let totalExamples = 0; + + guidelines.forEach((guideline) => { + byType[guideline.type]++; + bySeverity[guideline.severity]++; + totalExamples += guideline.examples.length; + }); + + return { + totalGuidelines: guidelines.length, + byType, + bySeverity, + totalExamples, + componentsWithProtocols: this.componentProtocols.size, + }; + } + + /** + * Export protocols as JSON + */ + public export(): string { + return JSON.stringify( + { + name: this.name, + guidelines: this.getAllGuidelines(), + componentProtocols: Array.from(this.componentProtocols.values()), + checklists: Object.fromEntries(this.checklists), + statistics: this.getStatistics(), + exportedAt: Date.now(), + }, + null, + 2, + ); + } + + /** + * Import protocols from JSON + */ + public import(json: string): void { + const data = JSON.parse(json) as { + guidelines: ProtocolGuideline[]; + componentProtocols: ComponentProtocol[]; + checklists: Record; + }; + + data.guidelines.forEach((guideline) => this.addGuideline(guideline)); + data.componentProtocols.forEach((protocol) => this.setComponentProtocol(protocol)); + Object.entries(data.checklists).forEach(([componentId, checklist]) => { + this.checklists.set(componentId, checklist); + }); + + this.emit('imported', { + guidelines: data.guidelines.length, + protocols: data.componentProtocols.length, + }); + } + + /** + * Remove guideline + */ + public removeGuideline(id: string): boolean { + const existed = this.guidelines.has(id); + this.guidelines.delete(id); + + if (existed) { + this.emit('guideline:removed', { id }); + } + + return existed; + } + + /** + * Clear all protocols + */ + public clear(): void { + this.guidelines.clear(); + this.componentProtocols.clear(); + this.checklists.clear(); + this.emit('cleared'); + } +} diff --git a/src/theater/atlas/index.ts b/src/theater/atlas/index.ts new file mode 100644 index 0000000..00fcf77 --- /dev/null +++ b/src/theater/atlas/index.ts @@ -0,0 +1,57 @@ +/** + * Atlas Module - Documentation and Cataloging System + * + * The Atlas module provides comprehensive documentation, component cataloging, + * visual diagrams, and usage protocols for The Anatomy Theater. + */ + +// Atlas +export { Atlas } from './Atlas'; +export type { + ComponentDocumentation, + PropDocumentation, + StateDocumentation, + SignalDocumentation, + CodeExample, + DocumentationQuery, + SearchResult, + AtlasConfig, + AtlasStatistics, +} from './Atlas'; + +// ComponentCatalogue +export { ComponentCatalogue } from './ComponentCatalogue'; +export type { + CatalogueEntry, + CatalogueFilter, + CatalogueGroup, + DependencyGraph, + CatalogueConfig, + CatalogueStatistics, +} from './ComponentCatalogue'; + +// Diagram +export { Diagram } from './Diagram'; +export type { + DiagramType, + DiagramFormat, + DiagramConfig, + DiagramNode, + DiagramEdge, + StateMachineState, + StateMachineTransition, +} from './Diagram'; + +// Protocol +export { Protocol } from './Protocol'; +export type { + ProtocolType, + ProtocolSeverity, + ProtocolExample, + ProtocolGuideline, + ChecklistItem, + ComponentProtocol, + ProtocolQuery, + ProtocolConfig, + ProtocolStatistics, +} from './Protocol'; diff --git a/src/theater/core/Amphitheater.ts b/src/theater/core/Amphitheater.ts new file mode 100644 index 0000000..1c51a29 --- /dev/null +++ b/src/theater/core/Amphitheater.ts @@ -0,0 +1,404 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion, @typescript-eslint/require-await, @typescript-eslint/prefer-optional-chain */ +/** + * Amphitheater - Component observation gallery + * + * The Amphitheater is the main UI where developers browse, search, + * and observe components. It provides the navigation, filtering, + * and organization of specimens. + */ + +import { EventEmitter } from 'events'; + +/** + * Specimen category + */ +export interface SpecimenCategory { + id: string; + name: string; + description?: string; + specimens: string[]; +} + +/** + * Specimen metadata + */ +export interface SpecimenMetadata { + id: string; + name: string; + category: string; + tags: string[]; + description?: string; + variations?: string[]; + createdAt?: Date; + updatedAt?: Date; +} + +/** + * Amphitheater theme + */ +export type AmphitheaterTheme = 'light' | 'dark' | 'auto'; + +/** + * Amphitheater layout + */ +export type AmphitheaterLayout = 'grid' | 'list' | 'canvas'; + +/** + * Amphitheater configuration + */ +export interface AmphitheaterConfig { + /** + * Theme mode + * @default 'auto' + */ + theme?: AmphitheaterTheme; + + /** + * Layout mode + * @default 'grid' + */ + layout?: AmphitheaterLayout; + + /** + * Enable search + * @default true + */ + search?: boolean; + + /** + * Enable keyboard navigation + * @default true + */ + keyboardNav?: boolean; + + /** + * Show specimen count + * @default true + */ + showCount?: boolean; +} + +/** + * Search/Filter criteria + */ +export interface FilterCriteria { + query?: string; + category?: string; + tags?: string[]; +} + +/** + * Amphitheater - Component gallery + */ +export class Amphitheater extends EventEmitter { + private theme: AmphitheaterTheme; + private layout: AmphitheaterLayout; + private searchEnabled: boolean; + private keyboardNavEnabled: boolean; + private showCount: boolean; + + private specimens: Map = new Map(); + private categories: Map = new Map(); + private selectedSpecimen: string | null = null; + private filterCriteria: FilterCriteria = {}; + + constructor(config: AmphitheaterConfig = {}) { + super(); + this.theme = config.theme ?? 'auto'; + this.layout = config.layout ?? 'grid'; + this.searchEnabled = config.search ?? true; + this.keyboardNavEnabled = config.keyboardNav ?? true; + this.showCount = config.showCount ?? true; + } + + /** + * Initialize the amphitheater + */ + public async initialize(): Promise { + if (this.keyboardNavEnabled) { + this.setupKeyboardNavigation(); + } + this.emit('initialized'); + } + + /** + * Register a specimen + */ + public registerSpecimen(metadata: SpecimenMetadata): void { + this.specimens.set(metadata.id, metadata); + + // Add to category + const category = this.categories.get(metadata.category); + if (category !== undefined) { + if (!category.specimens.includes(metadata.id)) { + category.specimens.push(metadata.id); + } + } else { + // Create category + this.categories.set(metadata.category, { + id: metadata.category, + name: metadata.category, + specimens: [metadata.id], + }); + } + + this.emit('specimen:registered', { metadata }); + } + + /** + * Unregister a specimen + */ + public unregisterSpecimen(id: string): void { + const specimen = this.specimens.get(id); + if (specimen === undefined) { + return; + } + + // Remove from category + const category = this.categories.get(specimen.category); + if (category !== undefined) { + category.specimens = category.specimens.filter((sid) => sid !== id); + } + + this.specimens.delete(id); + this.emit('specimen:unregistered', { id }); + } + + /** + * Get all specimens + */ + public getSpecimens(): SpecimenMetadata[] { + return Array.from(this.specimens.values()); + } + + /** + * Get filtered specimens + */ + public getFilteredSpecimens(): SpecimenMetadata[] { + let filtered = this.getSpecimens(); + + // Filter by category + if (this.filterCriteria.category !== undefined) { + filtered = filtered.filter((s) => s.category === this.filterCriteria.category); + } + + // Filter by tags + if (this.filterCriteria.tags !== undefined && this.filterCriteria.tags.length > 0) { + filtered = filtered.filter((s) => + this.filterCriteria.tags!.some((tag) => s.tags.includes(tag)), + ); + } + + // Filter by search query + if (this.filterCriteria.query !== undefined && this.filterCriteria.query.length > 0) { + const query = this.filterCriteria.query.toLowerCase(); + filtered = filtered.filter( + (s) => + s.name.toLowerCase().includes(query) || + (s.description !== undefined && s.description.toLowerCase().includes(query)) || + s.tags.some((tag) => tag.toLowerCase().includes(query)), + ); + } + + return filtered; + } + + /** + * Get specimen by ID + */ + public getSpecimen(id: string): SpecimenMetadata | undefined { + return this.specimens.get(id); + } + + /** + * Select a specimen + */ + public selectSpecimen(id: string): void { + if (!this.specimens.has(id)) { + throw new Error(`Specimen not found: ${id}`); + } + + this.selectedSpecimen = id; + this.emit('specimen:selected', { id }); + } + + /** + * Get selected specimen + */ + public getSelectedSpecimen(): SpecimenMetadata | null { + if (this.selectedSpecimen === null) { + return null; + } + return this.specimens.get(this.selectedSpecimen) ?? null; + } + + /** + * Get all categories + */ + public getCategories(): SpecimenCategory[] { + return Array.from(this.categories.values()); + } + + /** + * Get specimens by category + */ + public getSpecimensByCategory(categoryId: string): SpecimenMetadata[] { + const category = this.categories.get(categoryId); + if (category === undefined) { + return []; + } + + return category.specimens + .map((id) => this.specimens.get(id)) + .filter((s): s is SpecimenMetadata => s !== undefined); + } + + /** + * Set filter criteria + */ + public setFilter(criteria: FilterCriteria): void { + this.filterCriteria = { ...criteria }; + this.emit('filter:change', { criteria: this.filterCriteria }); + } + + /** + * Clear filters + */ + public clearFilter(): void { + this.filterCriteria = {}; + this.emit('filter:clear'); + } + + /** + * Search specimens + */ + public search(query: string): SpecimenMetadata[] { + this.setFilter({ ...this.filterCriteria, query }); + return this.getFilteredSpecimens(); + } + + /** + * Set theme + */ + public setTheme(theme: AmphitheaterTheme): void { + this.theme = theme; + this.emit('theme:change', { theme }); + } + + /** + * Get current theme + */ + public getTheme(): AmphitheaterTheme { + return this.theme; + } + + /** + * Set layout + */ + public setLayout(layout: AmphitheaterLayout): void { + this.layout = layout; + this.emit('layout:change', { layout }); + } + + /** + * Get current layout + */ + public getLayout(): AmphitheaterLayout { + return this.layout; + } + + /** + * Toggle theme (light/dark) + */ + public toggleTheme(): void { + if (this.theme === 'light') { + this.setTheme('dark'); + } else if (this.theme === 'dark') { + this.setTheme('light'); + } + } + + /** + * Get statistics + */ + public getStats(): { + totalSpecimens: number; + totalCategories: number; + filteredCount: number; + selectedSpecimen: string | null; + } { + return { + totalSpecimens: this.specimens.size, + totalCategories: this.categories.size, + filteredCount: this.getFilteredSpecimens().length, + selectedSpecimen: this.selectedSpecimen, + }; + } + + /** + * Render amphitheater UI (basic HTML) + */ + public render(): string { + const specimens = this.getFilteredSpecimens(); + const stats = this.getStats(); + + return ` +
+
+

The Anatomy Theater

+ ${this.showCount ? `${stats.filteredCount} specimens` : ''} +
+ + ${this.searchEnabled ? '' : ''} + +
+ ${this.getCategories() + .map( + (cat) => ` +
+

${cat.name}

+ ${cat.description !== undefined ? `

${cat.description}

` : ''} +
+ `, + ) + .join('')} +
+ +
+ ${specimens + .map( + (spec) => ` +
+

${spec.name}

+ ${spec.description !== undefined ? `

${spec.description}

` : ''} +
+ ${spec.tags.map((tag) => `${tag}`).join('')} +
+
+ `, + ) + .join('')} +
+
+ `; + } + + /** + * Setup keyboard navigation + */ + private setupKeyboardNavigation(): void { + // Would setup keyboard event listeners in real implementation + // Arrow keys for navigation, Enter to select, etc. + } + + /** + * Cleanup + */ + public async cleanup(): Promise { + this.specimens.clear(); + this.categories.clear(); + this.selectedSpecimen = null; + this.filterCriteria = {}; + this.emit('cleanup'); + } +} diff --git a/src/theater/core/Instrument.ts b/src/theater/core/Instrument.ts new file mode 100644 index 0000000..488a42e --- /dev/null +++ b/src/theater/core/Instrument.ts @@ -0,0 +1,233 @@ +/** + * Instrument - Base interface for Theater tools + * + * Instruments are development tools that can be attached to the Theater + * to provide additional functionality (debugging, monitoring, testing, etc.). + * + * Examples: Microscope, SignalTracer, StateExplorer, PerformanceProfiler + */ + +import { EventEmitter } from 'events'; + +/** + * Instrument state + */ +export type InstrumentState = 'inactive' | 'active' | 'minimized'; + +/** + * Instrument panel position + */ +export type InstrumentPosition = 'left' | 'right' | 'bottom' | 'floating'; + +/** + * Instrument configuration + */ +export interface InstrumentConfig { + /** + * Unique instrument ID + */ + id: string; + + /** + * Display name + */ + name: string; + + /** + * Icon (emoji or SVG) + */ + icon?: string; + + /** + * Default position + */ + defaultPosition?: InstrumentPosition; + + /** + * Default state + */ + defaultState?: InstrumentState; + + /** + * Keyboard shortcut + */ + shortcut?: string; + + /** + * Instrument priority (for ordering) + */ + priority?: number; +} + +/** + * Instrument data that can be stored/restored + */ +export interface InstrumentData { + [key: string]: unknown; +} + +/** + * Base Instrument interface + */ +export abstract class Instrument extends EventEmitter { + public readonly id: string; + public readonly name: string; + public readonly icon: string; + public readonly shortcut?: string; + public readonly priority: number; + + protected state: InstrumentState; + protected position: InstrumentPosition; + protected data: InstrumentData; + + constructor(config: InstrumentConfig) { + super(); + this.id = config.id; + this.name = config.name; + this.icon = config.icon ?? '🔬'; + if (config.shortcut !== undefined) { + this.shortcut = config.shortcut; + } + this.priority = config.priority ?? 0; + this.state = config.defaultState ?? 'inactive'; + this.position = config.defaultPosition ?? 'right'; + this.data = {}; + } + + /** + * Initialize the instrument + */ + public abstract initialize(): Promise; + + /** + * Cleanup the instrument + */ + public abstract cleanup(): Promise; + + /** + * Render the instrument UI + */ + public abstract render(): string; + + /** + * Open the instrument panel + */ + public open(): void { + this.state = 'active'; + this.emit('state:change', { state: this.state }); + } + + /** + * Close the instrument panel + */ + public close(): void { + this.state = 'inactive'; + this.emit('state:change', { state: this.state }); + } + + /** + * Toggle instrument panel + */ + public toggle(): void { + if (this.state === 'active') { + this.close(); + } else { + this.open(); + } + } + + /** + * Minimize instrument panel + */ + public minimize(): void { + this.state = 'minimized'; + this.emit('state:change', { state: this.state }); + } + + /** + * Set instrument position + */ + public setPosition(position: InstrumentPosition): void { + this.position = position; + this.emit('position:change', { position }); + } + + /** + * Get current state + */ + public getState(): InstrumentState { + return this.state; + } + + /** + * Get current position + */ + public getPosition(): InstrumentPosition { + return this.position; + } + + /** + * Store data + */ + public setData(key: string, value: unknown): void { + this.data[key] = value; + this.emit('data:change', { key, value }); + } + + /** + * Retrieve data + */ + public getData(key: string): unknown { + return this.data[key]; + } + + /** + * Get all data + */ + public getAllData(): InstrumentData { + return { ...this.data }; + } + + /** + * Clear all data + */ + public clearData(): void { + this.data = {}; + this.emit('data:clear'); + } + + /** + * Export instrument state (for persistence) + */ + public exportState(): { + state: InstrumentState; + position: InstrumentPosition; + data: InstrumentData; + } { + return { + state: this.state, + position: this.position, + data: { ...this.data }, + }; + } + + /** + * Import instrument state (for restoration) + */ + public importState(exported: { + state?: InstrumentState; + position?: InstrumentPosition; + data?: InstrumentData; + }): void { + if (exported.state !== undefined) { + this.state = exported.state; + } + if (exported.position !== undefined) { + this.position = exported.position; + } + if (exported.data !== undefined) { + this.data = { ...exported.data }; + } + this.emit('state:import'); + } +} diff --git a/src/theater/core/Stage.ts b/src/theater/core/Stage.ts new file mode 100644 index 0000000..79b9c85 --- /dev/null +++ b/src/theater/core/Stage.ts @@ -0,0 +1,327 @@ +/* eslint-disable @typescript-eslint/require-await */ +/** + * Stage - Component rendering and observation platform + * + * The Stage is where components are mounted and rendered for observation. + * It provides isolation, viewport management, and device emulation. + */ + +import { EventEmitter } from 'events'; + +/** + * Viewport size + */ +export interface Viewport { + width: number; + height: number; + label?: string; +} + +/** + * Predefined viewport sizes + */ +export const VIEWPORTS = { + mobile: { width: 375, height: 667, label: 'iPhone SE' }, + mobileL: { width: 428, height: 926, label: 'iPhone 14 Pro Max' }, + tablet: { width: 768, height: 1024, label: 'iPad' }, + tabletL: { width: 1024, height: 1366, label: 'iPad Pro' }, + laptop: { width: 1366, height: 768, label: 'Laptop' }, + desktop: { width: 1920, height: 1080, label: 'Desktop HD' }, + desktopL: { width: 2560, height: 1440, label: 'Desktop 2K' }, +} as const; + +/** + * Stage isolation mode + */ +export type IsolationMode = 'iframe' | 'shadow-dom' | 'none'; + +/** + * Stage configuration + */ +export interface StageConfig { + /** + * Isolation mode for rendering + * @default 'iframe' + */ + isolation?: IsolationMode; + + /** + * Initial viewport + */ + viewport?: Viewport; + + /** + * Enable responsive testing + * @default true + */ + responsive?: boolean; + + /** + * Background color + */ + backgroundColor?: string; + + /** + * Padding around component + */ + padding?: number; + + /** + * Enable screenshot capture + * @default true + */ + screenshots?: boolean; +} + +/** + * Mounted component reference + */ +export interface MountedComponent { + id: string; + element: HTMLElement; + timestamp: number; +} + +/** + * Stage - Component rendering platform + */ +export class Stage extends EventEmitter { + private container: HTMLElement | null = null; + private mountedComponent: MountedComponent | null = null; + private viewport: Viewport; + private isolation: IsolationMode; + private backgroundColor: string; + private padding: number; + private responsive: boolean; + private screenshotsEnabled: boolean; + + constructor(config: StageConfig = {}) { + super(); + this.isolation = config.isolation ?? 'iframe'; + this.viewport = config.viewport ?? VIEWPORTS.desktop; + this.responsive = config.responsive ?? true; + this.backgroundColor = config.backgroundColor ?? '#ffffff'; + this.padding = config.padding ?? 16; + this.screenshotsEnabled = config.screenshots ?? true; + } + + /** + * Initialize the stage + */ + public async initialize(container: HTMLElement): Promise { + this.container = container; + this.createStageDOM(); + this.emit('initialized'); + } + + /** + * Mount a component on the stage + */ + public async mount(element: HTMLElement, id: string = 'component'): Promise { + if (this.mountedComponent !== null) { + await this.unmount(); + } + + if (this.container === null) { + throw new Error('Stage not initialized'); + } + + const mounted: MountedComponent = { + id, + element, + timestamp: Date.now(), + }; + + // Mount in isolation + switch (this.isolation) { + case 'iframe': + await this.mountInIframe(element); + break; + case 'shadow-dom': + await this.mountInShadowDOM(element); + break; + case 'none': + this.container.appendChild(element); + break; + } + + this.mountedComponent = mounted; + this.emit('mounted', { id, element }); + } + + /** + * Unmount the current component + */ + public async unmount(): Promise { + if (this.mountedComponent === null || this.container === null) { + return; + } + + const { id } = this.mountedComponent; + + // Clear container + while (this.container.firstChild !== null) { + this.container.removeChild(this.container.firstChild); + } + + this.emit('unmounted', { id }); + this.mountedComponent = null; + } + + /** + * Set viewport size + */ + public setViewport(viewport: Viewport): void { + this.viewport = viewport; + this.applyViewport(); + this.emit('viewport:change', { viewport }); + } + + /** + * Get current viewport + */ + public getViewport(): Viewport { + return { ...this.viewport }; + } + + /** + * Resize to specific dimensions + */ + public resize(width: number, height: number): void { + this.viewport = { width, height }; + this.applyViewport(); + this.emit('resize', { width, height }); + } + + /** + * Set background color + */ + public setBackgroundColor(color: string): void { + this.backgroundColor = color; + if (this.container !== null) { + this.container.style.backgroundColor = color; + } + this.emit('background:change', { color }); + } + + /** + * Capture screenshot of current stage + */ + public async captureScreenshot(): Promise { + if (!this.screenshotsEnabled || this.container === null) { + return null; + } + + // This would integrate with a screenshot library like html2canvas + // For now, return a placeholder + return `data:image/png;base64,screenshot-placeholder-${Date.now()}`; + } + + /** + * Get mounted component + */ + public getMountedComponent(): MountedComponent | null { + return this.mountedComponent; + } + + /** + * Check if a component is mounted + */ + public hasMountedComponent(): boolean { + return this.mountedComponent !== null; + } + + /** + * Cleanup the stage + */ + public async cleanup(): Promise { + await this.unmount(); + if (this.container !== null) { + this.container.innerHTML = ''; + } + this.emit('cleanup'); + } + + /** + * Create stage DOM structure + */ + private createStageDOM(): void { + if (this.container === null) { + return; + } + + this.container.style.backgroundColor = this.backgroundColor; + this.container.style.padding = `${this.padding}px`; + this.container.style.overflow = 'auto'; + this.applyViewport(); + } + + /** + * Apply viewport dimensions + */ + private applyViewport(): void { + if (this.container === null) { + return; + } + + if (this.responsive) { + this.container.style.width = `${this.viewport.width}px`; + this.container.style.height = `${this.viewport.height}px`; + } else { + this.container.style.width = '100%'; + this.container.style.height = '100%'; + } + } + + /** + * Mount component in iframe + */ + private async mountInIframe(element: HTMLElement): Promise { + if (this.container === null) { + return; + } + + const iframe = document.createElement('iframe'); + iframe.style.width = '100%'; + iframe.style.height = '100%'; + iframe.style.border = 'none'; + + this.container.appendChild(iframe); + + const iframeDoc = iframe.contentDocument; + if (iframeDoc !== null) { + iframeDoc.body.appendChild(element); + } + } + + /** + * Mount component in Shadow DOM + */ + private async mountInShadowDOM(element: HTMLElement): Promise { + if (this.container === null) { + return; + } + + const wrapper = document.createElement('div'); + const shadowRoot = wrapper.attachShadow({ mode: 'open' }); + shadowRoot.appendChild(element); + this.container.appendChild(wrapper); + } + + /** + * Get stage statistics + */ + public getStats(): { + hasMounted: boolean; + viewport: Viewport; + isolation: IsolationMode; + backgroundColor: string; + } { + return { + hasMounted: this.hasMountedComponent(), + viewport: this.getViewport(), + isolation: this.isolation, + backgroundColor: this.backgroundColor, + }; + } +} diff --git a/src/theater/core/Theater.ts b/src/theater/core/Theater.ts new file mode 100644 index 0000000..a387cb3 --- /dev/null +++ b/src/theater/core/Theater.ts @@ -0,0 +1,314 @@ +/** + * Theater - The Anatomy Theater orchestrator + * + * The main Theater class that coordinates the Stage, Amphitheater, + * and Instruments to provide a complete component development and + * documentation experience. + */ + +import { EventEmitter } from 'events'; +import type { TheaterConfig } from './TheaterConfig'; +import { DEFAULT_THEATER_CONFIG } from './TheaterConfig'; +import { Stage } from './Stage'; +import { Amphitheater } from './Amphitheater'; +import type { Instrument } from './Instrument'; + +/** + * Theater lifecycle state + */ +export type TheaterState = 'stopped' | 'starting' | 'running' | 'stopping'; + +/** + * Theater event types + */ +export interface TheaterEvents { + 'state:change': { state: TheaterState }; + 'config:update': { config: TheaterConfig }; + 'instrument:registered': { instrument: Instrument }; + 'instrument:unregistered': { id: string }; + error: { error: Error; context: string }; +} + +/** + * Theater - Main orchestrator + */ +export class Theater extends EventEmitter { + private config: Required; + private state: TheaterState = 'stopped'; + + public readonly stage: Stage; + public readonly amphitheater: Amphitheater; + private instruments: Map = new Map(); + + constructor(config: TheaterConfig) { + super(); + + // Merge with defaults + this.config = { + ...DEFAULT_THEATER_CONFIG, + ...config, + theme: { + ...DEFAULT_THEATER_CONFIG.theme, + ...config.theme, + }, + }; + + // Initialize core components + this.stage = new Stage({ + isolation: 'iframe', + viewport: { width: 1920, height: 1080 }, + responsive: true, + screenshots: true, + }); + + this.amphitheater = new Amphitheater({ + theme: this.config.darkMode ? 'dark' : 'light', + layout: 'grid', + search: true, + keyboardNav: true, + }); + + this.setupEventListeners(); + } + + /** + * Start the theater + */ + public async start(): Promise { + if (this.state === 'running') { + return; + } + + this.setState('starting'); + + try { + // Initialize amphitheater + await this.amphitheater.initialize(); + + // Initialize instruments + for (const instrument of this.instruments.values()) { + await instrument.initialize(); + } + + this.setState('running'); + this.emit('started'); + } catch (error) { + this.setState('stopped'); + this.emitError(error as Error, 'start'); + throw error; + } + } + + /** + * Stop the theater + */ + public async stop(): Promise { + if (this.state === 'stopped') { + return; + } + + this.setState('stopping'); + + try { + // Cleanup instruments + for (const instrument of this.instruments.values()) { + await instrument.cleanup(); + } + + // Cleanup stage + await this.stage.cleanup(); + + // Cleanup amphitheater + await this.amphitheater.cleanup(); + + this.setState('stopped'); + this.emit('stopped'); + } catch (error) { + this.emitError(error as Error, 'stop'); + throw error; + } + } + + /** + * Reload the theater + */ + public async reload(): Promise { + await this.stop(); + await this.start(); + this.emit('reloaded'); + } + + /** + * Register an instrument + */ + public registerInstrument(instrument: Instrument): void { + if (this.instruments.has(instrument.id)) { + throw new Error(`Instrument already registered: ${instrument.id}`); + } + + this.instruments.set(instrument.id, instrument); + this.emit('instrument:registered', { instrument }); + + // Initialize if theater is already running + if (this.state === 'running') { + instrument.initialize().catch((error) => { + this.emitError(error as Error, 'instrument:initialize'); + }); + } + } + + /** + * Unregister an instrument + */ + public async unregisterInstrument(id: string): Promise { + const instrument = this.instruments.get(id); + if (instrument === undefined) { + return; + } + + await instrument.cleanup(); + this.instruments.delete(id); + this.emit('instrument:unregistered', { id }); + } + + /** + * Get instrument by ID + */ + public getInstrument(id: string): Instrument | undefined { + return this.instruments.get(id); + } + + /** + * Get all instruments + */ + public getInstruments(): Instrument[] { + return Array.from(this.instruments.values()); + } + + /** + * Update configuration + */ + public updateConfig(config: Partial): void { + this.config = { + ...this.config, + ...config, + theme: { + ...this.config.theme, + ...config.theme, + }, + }; + + this.emit('config:update', { config: this.config }); + + // Apply theme change + if (config.darkMode !== undefined) { + this.amphitheater.setTheme(config.darkMode ? 'dark' : 'light'); + } + } + + /** + * Get current configuration + */ + public getConfig(): Required { + return { ...this.config }; + } + + /** + * Get current state + */ + public getState(): TheaterState { + return this.state; + } + + /** + * Check if theater is running + */ + public isRunning(): boolean { + return this.state === 'running'; + } + + /** + * Get theater statistics + */ + public getStats(): { + state: TheaterState; + config: Required; + instruments: number; + amphitheaterStats: ReturnType; + stageStats: ReturnType; + } { + return { + state: this.state, + config: this.getConfig(), + instruments: this.instruments.size, + amphitheaterStats: this.amphitheater.getStats(), + stageStats: this.stage.getStats(), + }; + } + + /** + * Enable hot reload + */ + public enableHotReload(): void { + this.config.hotReload = true; + // Would setup file watchers and HMR in real implementation + } + + /** + * Disable hot reload + */ + public disableHotReload(): void { + this.config.hotReload = false; + } + + /** + * Set state + */ + private setState(state: TheaterState): void { + if (this.state === state) { + return; + } + + this.state = state; + this.emit('state:change', { state }); + } + + /** + * Setup event listeners for core components + */ + private setupEventListeners(): void { + // Stage events + this.stage.on('mounted', (data) => { + this.emit('stage:mounted', data); + }); + + this.stage.on('unmounted', (data) => { + this.emit('stage:unmounted', data); + }); + + // Amphitheater events + this.amphitheater.on('specimen:selected', (data) => { + this.emit('specimen:selected', data); + }); + + this.amphitheater.on('filter:change', (data) => { + this.emit('filter:change', data); + }); + } + + /** + * Emit error event + */ + private emitError(error: Error, context: string): void { + this.emit('error', { error, context }); + } + + /** + * Cleanup and dispose + */ + public async dispose(): Promise { + await this.stop(); + this.instruments.clear(); + this.removeAllListeners(); + } +} diff --git a/src/theater/core/TheaterConfig.ts b/src/theater/core/TheaterConfig.ts new file mode 100644 index 0000000..bd32c2a --- /dev/null +++ b/src/theater/core/TheaterConfig.ts @@ -0,0 +1,126 @@ +/** + * Theater Configuration + * + * Configuration options for The Anatomy Theater system. + */ + +/** + * Theater configuration options + */ +export interface TheaterConfig { + /** + * Title of the theater instance + */ + title: string; + + /** + * Port for development server + * @default 6006 + */ + port?: number; + + /** + * Directory containing specimen files + * @default './src/theater/specimens' + */ + specimensDir?: string; + + /** + * Enable hot module replacement + * @default true + */ + hotReload?: boolean; + + /** + * Enable dark mode + * @default false + */ + darkMode?: boolean; + + /** + * Custom theme configuration + */ + theme?: TheaterTheme; + + /** + * Enabled instruments (tools) + */ + instruments?: string[]; + + /** + * Base path for routing + * @default '/' + */ + basePath?: string; + + /** + * Enable neural signal visualization + * @default true + */ + signalVisualization?: boolean; + + /** + * Enable time-travel debugging + * @default true + */ + timeTravel?: boolean; + + /** + * Enable accessibility testing + * @default true + */ + a11yTesting?: boolean; +} + +/** + * Theme configuration + */ +export interface TheaterTheme { + /** + * Primary color + */ + primaryColor?: string; + + /** + * Background color + */ + backgroundColor?: string; + + /** + * Text color + */ + textColor?: string; + + /** + * Font family + */ + fontFamily?: string; + + /** + * Custom CSS + */ + customCss?: string; +} + +/** + * Default theater configuration + */ +export const DEFAULT_THEATER_CONFIG: Required = { + title: 'The Anatomy Theater', + port: 6006, + specimensDir: './src/theater/specimens', + hotReload: true, + darkMode: false, + theme: { + primaryColor: '#0066cc', + backgroundColor: '#ffffff', + textColor: '#333333', + fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', + customCss: '', + }, + instruments: ['microscope', 'signals', 'state', 'performance', 'health', 'accessibility'], + basePath: '/', + signalVisualization: true, + timeTravel: true, + a11yTesting: true, +}; diff --git a/src/theater/index.ts b/src/theater/index.ts new file mode 100644 index 0000000..186d01b --- /dev/null +++ b/src/theater/index.ts @@ -0,0 +1,193 @@ +/** + * The Anatomy Theater - Component Development and Documentation System + * + * Phase 6 of the Synapse framework - a powerful component showcase and development + * environment with medical-themed terminology. + * + * ## Core Components + * + * - **Theater**: Main orchestrator for the entire system + * - **Stage**: Component rendering and observation platform + * - **Amphitheater**: Component gallery and navigation + * - **Instrument**: Base class for development tools + * + * ## Features + * + * - Real-time neural signal visualization + * - Time-travel state debugging + * - Live connection topology + * - Signal replay + * - Smart auto-documentation + * - Health monitoring + * - A/B testing + * - Accessibility testing + * - Performance profiling + * - Component composition playground + * + * @module theater + */ + +// Core components +export { Theater } from './core/Theater'; +export type { TheaterState, TheaterEvents } from './core/Theater'; + +export { Stage, VIEWPORTS } from './core/Stage'; +export type { Viewport, IsolationMode, StageConfig, MountedComponent } from './core/Stage'; + +export { Amphitheater } from './core/Amphitheater'; +export type { + SpecimenCategory, + AmphitheaterTheme, + AmphitheaterLayout, + AmphitheaterConfig, + FilterCriteria, +} from './core/Amphitheater'; + +export { Instrument } from './core/Instrument'; +export type { + InstrumentState, + InstrumentPosition, + InstrumentConfig, + InstrumentData, +} from './core/Instrument'; + +export type { TheaterConfig, TheaterTheme } from './core/TheaterConfig'; +export { DEFAULT_THEATER_CONFIG } from './core/TheaterConfig'; + +// Specimen system +export { Specimen } from './specimens/Specimen'; +export type { SpecimenMetadata, SpecimenContext, SpecimenRenderFn } from './specimens/Specimen'; + +export { Observation, ObservationBuilder, createObservations } from './specimens/Observation'; +export type { ObservationConfig } from './specimens/Observation'; + +export { Dissection, DissectionBuilder, createDissection } from './specimens/Dissection'; +export type { PropType, PropDefinition, ComponentStructure } from './specimens/Dissection'; + +// Microscope instruments +export { Microscope } from './instruments/Microscope'; +export type { + InspectionMode, + MicroscopeLens, + InspectionResult, + InspectionIssue, + MicroscopeConfig, +} from './instruments/Microscope'; + +export { SignalTracer } from './instruments/SignalTracer'; +export type { SignalTrace, SignalFlowGraph, SignalTracerConfig } from './instruments/SignalTracer'; + +export { StateExplorer } from './instruments/StateExplorer'; +export type { + StateSnapshot, + StateDiff, + TimeTravelAction, + StateExplorerConfig, +} from './instruments/StateExplorer'; + +export { PerformanceProfiler } from './instruments/PerformanceProfiler'; +export type { + PerformanceMetric, + RenderProfile, + PerformanceBottleneck, + PerformanceProfilerConfig, +} from './instruments/PerformanceProfiler'; + +export { HealthMonitor } from './instruments/HealthMonitor'; +export type { + HealthStatus, + HealthCheck, + HealthReport, + ErrorEntry, + HealthMonitorConfig, +} from './instruments/HealthMonitor'; + +// Laboratory (testing environment) +export { Laboratory } from './laboratory/Laboratory'; +export type { LaboratoryConfig, LaboratoryState, LaboratoryStats } from './laboratory/Laboratory'; + +export { Experiment } from './laboratory/Experiment'; +export type { ExperimentConfig, ExperimentResult, ExperimentState } from './laboratory/Experiment'; + +export { TestSubject } from './laboratory/TestSubject'; +export type { TestSubjectConfig, Interaction } from './laboratory/TestSubject'; + +export { Hypothesis } from './laboratory/Hypothesis'; +export type { HypothesisResult, AssertionFn, MatcherFn } from './laboratory/Hypothesis'; + +export { LabReporter } from './laboratory/LabReport'; +export type { LabReport, ReportFormat } from './laboratory/LabReport'; + +// Atlas (documentation and cataloging) +export { Atlas } from './atlas/Atlas'; +export type { + ComponentDocumentation, + PropDocumentation, + StateDocumentation, + SignalDocumentation, + CodeExample, + DocumentationQuery, + SearchResult, + AtlasConfig, + AtlasStatistics, +} from './atlas/Atlas'; + +export { ComponentCatalogue } from './atlas/ComponentCatalogue'; +export type { + CatalogueEntry, + CatalogueFilter, + CatalogueGroup, + DependencyGraph, + CatalogueConfig, + CatalogueStatistics, +} from './atlas/ComponentCatalogue'; + +export { Diagram } from './atlas/Diagram'; +export type { + DiagramType, + DiagramFormat, + DiagramConfig, + DiagramNode, + DiagramEdge, + StateMachineState, + StateMachineTransition, +} from './atlas/Diagram'; + +export { Protocol } from './atlas/Protocol'; +export type { + ProtocolType, + ProtocolSeverity, + ProtocolExample, + ProtocolGuideline, + ChecklistItem, + ComponentProtocol, + ProtocolQuery, + ProtocolConfig, + ProtocolStatistics, +} from './atlas/Protocol'; + +// Server (development server and hot reload) +export { TheaterServer } from './server/TheaterServer'; +export type { + ServerConfig, + ServerState, + ServerStatistics, + RequestInfo, +} from './server/TheaterServer'; + +export { HotReload } from './server/HotReload'; +export type { + WatchPattern, + FileChangeEvent, + HotReloadConfig, + WatchStatistics, +} from './server/HotReload'; + +export { WebSocketBridge } from './server/WebSocketBridge'; +export type { + MessageType, + WebSocketMessage, + ClientConnection, + WebSocketConfig, + BridgeStatistics, +} from './server/WebSocketBridge'; diff --git a/src/theater/instruments/HealthMonitor.ts b/src/theater/instruments/HealthMonitor.ts new file mode 100644 index 0000000..7de93f6 --- /dev/null +++ b/src/theater/instruments/HealthMonitor.ts @@ -0,0 +1,645 @@ +/** + * HealthMonitor - Component health monitoring tool + * + * HealthMonitor is a Microscope lens that integrates with Microglia + * to monitor component health, detect errors, track warnings, and + * provide diagnostics. + */ + +import type { MicroscopeLens, InspectionResult, InspectionIssue } from './Microscope'; +import type { VisualNeuron } from '../../ui/VisualNeuron'; + +/** + * Health status + */ +export type HealthStatus = 'healthy' | 'warning' | 'error' | 'critical'; + +/** + * Health check result + */ +export interface HealthCheck { + /** + * Check name + */ + name: string; + + /** + * Status + */ + status: HealthStatus; + + /** + * Message + */ + message: string; + + /** + * Timestamp + */ + timestamp: Date; + + /** + * Details + */ + details?: Record; +} + +/** + * Component health report + */ +export interface HealthReport { + /** + * Component ID + */ + componentId: string; + + /** + * Overall status + */ + status: HealthStatus; + + /** + * Health checks + */ + checks: HealthCheck[]; + + /** + * Error count + */ + errorCount: number; + + /** + * Warning count + */ + warningCount: number; + + /** + * Last error + */ + lastError?: Error; + + /** + * Last warning + */ + lastWarning?: string; + + /** + * Uptime (ms) + */ + uptime: number; + + /** + * Health score (0-100) + */ + healthScore: number; +} + +/** + * Error entry + */ +export interface ErrorEntry { + /** + * Error object + */ + error: Error; + + /** + * Component ID + */ + componentId: string; + + /** + * Timestamp + */ + timestamp: Date; + + /** + * Stack trace + */ + stackTrace: string; + + /** + * Error boundary caught + */ + caught: boolean; + + /** + * Recovery attempted + */ + recovered: boolean; +} + +/** + * HealthMonitor configuration + */ +export interface HealthMonitorConfig { + /** + * Enable error boundaries + */ + enableErrorBoundaries?: boolean; + + /** + * Auto-recover from errors + */ + autoRecover?: boolean; + + /** + * Max error history + */ + maxErrorHistory?: number; + + /** + * Health check interval (ms) + */ + healthCheckInterval?: number; + + /** + * Enable diagnostics + */ + enableDiagnostics?: boolean; +} + +/** + * HealthMonitor - Component health monitoring + */ +export class HealthMonitor implements MicroscopeLens { + public readonly id = 'health-monitor'; + public readonly name = 'Health Monitor'; + public readonly mode = 'health' as const; + + private reports: Map = new Map(); + private errors: ErrorEntry[] = []; + private warnings: Map = new Map(); + private mountTimes: Map = new Map(); + private autoRecover: boolean = false; + private maxErrorHistory: number = 100; + private healthCheckInterval: number = 5000; + private enableDiagnostics: boolean = true; + private healthCheckTimer: ReturnType | null = null; + + constructor(config: HealthMonitorConfig = {}) { + // Enable error boundaries config is stored but not used currently + // Will be activated when Microglia integration is complete + if (config.enableErrorBoundaries !== undefined) { + // Future: this.enableErrorBoundaries = config.enableErrorBoundaries; + } + if (config.autoRecover !== undefined) { + this.autoRecover = config.autoRecover; + } + if (config.maxErrorHistory !== undefined) { + this.maxErrorHistory = config.maxErrorHistory; + } + if (config.healthCheckInterval !== undefined) { + this.healthCheckInterval = config.healthCheckInterval; + } + if (config.enableDiagnostics !== undefined) { + this.enableDiagnostics = config.enableDiagnostics; + } + } + + /** + * Initialize monitor + */ + // eslint-disable-next-line @typescript-eslint/require-await + public async initialize(): Promise { + this.reports.clear(); + this.errors = []; + this.warnings.clear(); + this.mountTimes.clear(); + + // Start periodic health checks + this.startHealthChecks(); + } + + /** + * Cleanup monitor + */ + // eslint-disable-next-line @typescript-eslint/require-await + public async cleanup(): Promise { + this.stopHealthChecks(); + this.reports.clear(); + this.errors = []; + this.warnings.clear(); + this.mountTimes.clear(); + } + + /** + * Inspect component health + */ + public async inspect(component: VisualNeuron): Promise { + const componentId = this.getComponentId(component); + const issues: InspectionIssue[] = []; + + // Run health checks + const checks = await this.runHealthChecks(component); + + // Calculate health metrics + const errorCount = this.getErrorCount(componentId); + const warningCount = this.getWarningCount(componentId); + const uptime = this.getUptime(componentId); + const healthScore = this.calculateHealthScore(checks, errorCount, warningCount); + + // Determine overall status + const status = this.determineHealthStatus(checks, errorCount); + + // Create health report + const report: HealthReport = { + componentId, + status, + checks, + errorCount, + warningCount, + uptime, + healthScore, + }; + + // Add optional properties if they exist + const lastError = this.getLastError(componentId); + if (lastError !== undefined) { + report.lastError = lastError; + } + + const lastWarning = this.getLastWarning(componentId); + if (lastWarning !== undefined) { + report.lastWarning = lastWarning; + } + + this.reports.set(componentId, report); + + // Convert checks to issues + for (const check of checks) { + if (check.status === 'error' || check.status === 'critical') { + issues.push({ + severity: 'error', + message: `Health check failed: ${check.message}`, + source: check.name, + suggestion: 'Review component implementation and error logs', + }); + } else if (check.status === 'warning') { + issues.push({ + severity: 'warning', + message: `Health check warning: ${check.message}`, + source: check.name, + }); + } + } + + return { + mode: 'health', + timestamp: new Date(), + componentId, + data: { + report, + recentErrors: this.getRecentErrors(componentId, 5), + recentWarnings: this.getRecentWarnings(componentId, 5), + stats: { + totalErrors: this.errors.length, + totalWarnings: Array.from(this.warnings.values()).reduce( + (sum, arr) => sum + arr.length, + 0, + ), + healthyComponents: Array.from(this.reports.values()).filter((r) => r.status === 'healthy') + .length, + }, + }, + issues, + metadata: { + autoRecover: this.autoRecover, + enableDiagnostics: this.enableDiagnostics, + }, + }; + } + + /** + * Render monitor UI + */ + public render(): string { + const reports = Array.from(this.reports.values()); + const healthyCount = reports.filter((r) => r.status === 'healthy').length; + const totalErrors = this.errors.length; + + return ` +
+
+
+ + ${healthyCount} / ${reports.length} +
+
+ + ${totalErrors} +
+
+
+ ${reports + .map( + (report) => ` +
+
+ ${report.componentId} + ${report.status} + ${report.healthScore.toFixed(0)}% +
+
+ Errors: ${report.errorCount} + Warnings: ${report.warningCount} + Uptime: ${(report.uptime / 1000).toFixed(1)}s +
+
+ `, + ) + .join('')} +
+
+

Recent Errors

+ ${this.errors + .slice(-5) + .reverse() + .map( + (entry) => ` +
+ ${entry.error.message} + ${entry.timestamp.toLocaleTimeString()} +
+ `, + ) + .join('')} +
+
+ `; + } + + /** + * Run health checks on component + */ + // eslint-disable-next-line @typescript-eslint/require-await + private async runHealthChecks(component: VisualNeuron): Promise { + const checks: HealthCheck[] = []; + const componentId = this.getComponentId(component); + + // Check for errors + const errorCount = this.getErrorCount(componentId); + checks.push({ + name: 'Error Check', + status: errorCount === 0 ? 'healthy' : errorCount < 5 ? 'warning' : 'error', + message: + errorCount === 0 + ? 'No errors detected' + : `${errorCount} error${errorCount > 1 ? 's' : ''} detected`, + timestamp: new Date(), + details: { errorCount }, + }); + + // Check uptime + const uptime = this.getUptime(componentId); + checks.push({ + name: 'Uptime Check', + status: 'healthy', + message: `Component running for ${(uptime / 1000).toFixed(1)}s`, + timestamp: new Date(), + details: { uptime }, + }); + + // Check warnings + const warningCount = this.getWarningCount(componentId); + checks.push({ + name: 'Warning Check', + status: warningCount === 0 ? 'healthy' : 'warning', + message: + warningCount === 0 + ? 'No warnings detected' + : `${warningCount} warning${warningCount > 1 ? 's' : ''} detected`, + timestamp: new Date(), + details: { warningCount }, + }); + + return checks; + } + + /** + * Start periodic health checks + */ + private startHealthChecks(): void { + if (this.healthCheckTimer !== null) { + return; + } + + this.healthCheckTimer = setInterval(() => { + // Run health checks on all monitored components + // This would trigger re-inspection in a real implementation + }, this.healthCheckInterval); + } + + /** + * Stop health checks + */ + private stopHealthChecks(): void { + if (this.healthCheckTimer !== null) { + clearInterval(this.healthCheckTimer); + this.healthCheckTimer = null; + } + } + + /** + * Record an error + */ + public recordError(error: Error, componentId: string, caught: boolean = false): void { + const entry: ErrorEntry = { + error, + componentId, + timestamp: new Date(), + stackTrace: error.stack ?? '', + caught, + recovered: false, + }; + + this.errors.push(entry); + + // Trim if exceeded max + if (this.errors.length > this.maxErrorHistory) { + this.errors = this.errors.slice(-this.maxErrorHistory); + } + + // Attempt auto-recovery if enabled + if (this.autoRecover) { + entry.recovered = this.attemptRecovery(componentId, error); + } + } + + /** + * Record a warning + */ + public recordWarning(warning: string, componentId: string): void { + if (!this.warnings.has(componentId)) { + this.warnings.set(componentId, []); + } + + const warnings = this.warnings.get(componentId); + if (warnings !== undefined) { + warnings.push(warning); + } + } + + /** + * Track component mount + */ + public trackMount(componentId: string): void { + this.mountTimes.set(componentId, new Date()); + } + + /** + * Attempt error recovery + */ + private attemptRecovery(_componentId: string, _error: Error): boolean { + // Recovery mechanism would be implemented when Microglia is available + // This is simplified - real implementation would use actual recovery mechanisms + return false; + } + + /** + * Calculate health score + */ + private calculateHealthScore( + checks: HealthCheck[], + errorCount: number, + warningCount: number, + ): number { + let score = 100; + + // Penalize errors + score -= errorCount * 10; + + // Penalize warnings + score -= warningCount * 2; + + // Penalize failed checks + for (const check of checks) { + if (check.status === 'error' || check.status === 'critical') { + score -= 15; + } else if (check.status === 'warning') { + score -= 5; + } + } + + return Math.max(0, Math.min(100, score)); + } + + /** + * Determine overall health status + */ + private determineHealthStatus(checks: HealthCheck[], errorCount: number): HealthStatus { + if (errorCount >= 10 || checks.some((c) => c.status === 'critical')) { + return 'critical'; + } + + if (errorCount > 0 || checks.some((c) => c.status === 'error')) { + return 'error'; + } + + if (checks.some((c) => c.status === 'warning')) { + return 'warning'; + } + + return 'healthy'; + } + + /** + * Get component ID + */ + private getComponentId(_component: VisualNeuron): string { + return 'component'; + } + + /** + * Get error count for component + */ + private getErrorCount(componentId: string): number { + return this.errors.filter((e) => e.componentId === componentId).length; + } + + /** + * Get warning count for component + */ + private getWarningCount(componentId: string): number { + return this.warnings.get(componentId)?.length ?? 0; + } + + /** + * Get component uptime + */ + private getUptime(componentId: string): number { + const mountTime = this.mountTimes.get(componentId); + if (mountTime === undefined) { + this.mountTimes.set(componentId, new Date()); + return 0; + } + + return Date.now() - mountTime.getTime(); + } + + /** + * Get last error for component + */ + private getLastError(componentId: string): Error | undefined { + const errors = this.errors.filter((e) => e.componentId === componentId); + return errors[errors.length - 1]?.error; + } + + /** + * Get last warning for component + */ + private getLastWarning(componentId: string): string | undefined { + const warnings = this.warnings.get(componentId); + return warnings?.[warnings.length - 1]; + } + + /** + * Get recent errors for component + */ + private getRecentErrors(componentId: string, limit: number): ErrorEntry[] { + return this.errors.filter((e) => e.componentId === componentId).slice(-limit); + } + + /** + * Get recent warnings for component + */ + private getRecentWarnings(componentId: string, limit: number): string[] { + const warnings = this.warnings.get(componentId) ?? []; + return warnings.slice(-limit); + } + + /** + * Get health report + */ + public getReport(componentId: string): HealthReport | undefined { + return this.reports.get(componentId); + } + + /** + * Get all health reports + */ + public getAllReports(): HealthReport[] { + return Array.from(this.reports.values()); + } + + /** + * Get all errors + */ + public getAllErrors(): ErrorEntry[] { + return [...this.errors]; + } + + /** + * Clear error history + */ + public clearErrors(): void { + this.errors = []; + } + + /** + * Clear warnings + */ + public clearWarnings(): void { + this.warnings.clear(); + } +} diff --git a/src/theater/instruments/Microscope.ts b/src/theater/instruments/Microscope.ts new file mode 100644 index 0000000..a546ae3 --- /dev/null +++ b/src/theater/instruments/Microscope.ts @@ -0,0 +1,505 @@ +/** + * Microscope - Central debugging and inspection hub + * + * The Microscope is the primary instrument for deep component inspection. + * It coordinates specialized tools (SignalTracer, StateExplorer, etc.) + * and provides a unified interface for debugging. + */ + +import { Instrument, type InstrumentConfig } from '../core/Instrument'; +import type { VisualNeuron } from '../../ui/VisualNeuron'; + +/** + * Inspection mode + */ +export type InspectionMode = 'signals' | 'state' | 'performance' | 'health' | 'structure'; + +/** + * Microscope lens (specialized inspection tool) + */ +export interface MicroscopeLens { + /** + * Lens identifier + */ + id: string; + + /** + * Lens name + */ + name: string; + + /** + * Associated inspection mode + */ + mode: InspectionMode; + + /** + * Initialize the lens + */ + initialize: () => Promise; + + /** + * Cleanup the lens + */ + cleanup: () => Promise; + + /** + * Inspect a component + */ + inspect: (component: VisualNeuron) => Promise; + + /** + * Render lens UI + */ + render: () => string; +} + +/** + * Inspection result + */ +export interface InspectionResult { + /** + * Inspection mode + */ + mode: InspectionMode; + + /** + * Timestamp + */ + timestamp: Date; + + /** + * Component ID + */ + componentId?: string; + + /** + * Inspection data + */ + data: unknown; + + /** + * Issues found + */ + issues?: InspectionIssue[]; + + /** + * Metadata + */ + metadata?: Record; +} + +/** + * Inspection issue + */ +export interface InspectionIssue { + /** + * Severity level + */ + severity: 'error' | 'warning' | 'info'; + + /** + * Issue message + */ + message: string; + + /** + * Source location + */ + source?: string; + + /** + * Suggested fix + */ + suggestion?: string; +} + +/** + * Microscope configuration + */ +export interface MicroscopeConfig { + /** + * Unique instrument ID + */ + id?: string; + + /** + * Display name + */ + name?: string; + + /** + * Icon (emoji or SVG) + */ + icon?: string; + + /** + * Default position + */ + defaultPosition?: InstrumentConfig['defaultPosition']; + + /** + * Default state + */ + defaultState?: InstrumentConfig['defaultState']; + + /** + * Keyboard shortcut + */ + shortcut?: string; + + /** + * Instrument priority (for ordering) + */ + priority?: number; + + /** + * Default inspection mode + */ + defaultMode?: InspectionMode; + + /** + * Auto-inspect on component mount + */ + autoInspect?: boolean; + + /** + * Record inspection history + */ + recordHistory?: boolean; + + /** + * Max history entries + */ + maxHistorySize?: number; + + /** + * Enable real-time updates + */ + realTimeUpdates?: boolean; +} + +/** + * Microscope - Central debugging hub + */ +export class Microscope extends Instrument { + private currentMode: InspectionMode = 'signals'; + private lenses: Map = new Map(); + private inspectionHistory: InspectionResult[] = []; + private currentComponent: VisualNeuron | null = null; + private autoInspect: boolean = false; + private recordHistory: boolean = true; + private maxHistorySize: number = 100; + private realTimeUpdates: boolean = true; + private realTimeInterval: ReturnType | null = null; + + constructor(config: MicroscopeConfig = {}) { + // Build InstrumentConfig with only defined optional properties + const instrumentConfig: InstrumentConfig = { + id: config.id ?? 'microscope', + name: config.name ?? 'Microscope', + }; + + if (config.icon !== undefined) { + instrumentConfig.icon = config.icon; + } else { + instrumentConfig.icon = '🔬'; + } + + if (config.defaultPosition !== undefined) { + instrumentConfig.defaultPosition = config.defaultPosition; + } else { + instrumentConfig.defaultPosition = 'right'; + } + + if (config.defaultState !== undefined) { + instrumentConfig.defaultState = config.defaultState; + } + + if (config.shortcut !== undefined) { + instrumentConfig.shortcut = config.shortcut; + } + + if (config.priority !== undefined) { + instrumentConfig.priority = config.priority; + } + + super(instrumentConfig); + + if (config.defaultMode !== undefined) { + this.currentMode = config.defaultMode; + } + if (config.autoInspect !== undefined) { + this.autoInspect = config.autoInspect; + } + if (config.recordHistory !== undefined) { + this.recordHistory = config.recordHistory; + } + if (config.maxHistorySize !== undefined) { + this.maxHistorySize = config.maxHistorySize; + } + if (config.realTimeUpdates !== undefined) { + this.realTimeUpdates = config.realTimeUpdates; + } + } + + /** + * Initialize microscope + */ + public async initialize(): Promise { + // Initialize all registered lenses + for (const lens of this.lenses.values()) { + await lens.initialize(); + } + + this.emit('initialized', { lensCount: this.lenses.size }); + } + + /** + * Cleanup microscope + */ + public async cleanup(): Promise { + // Stop real-time updates + this.stopRealTimeUpdates(); + + // Cleanup all lenses + for (const lens of this.lenses.values()) { + await lens.cleanup(); + } + + // Clear history + this.inspectionHistory = []; + this.currentComponent = null; + + this.emit('cleaned-up'); + } + + /** + * Render microscope UI + */ + public render(): string { + const activeLens = this.lenses.get(this.currentMode); + const lensUI = activeLens !== undefined ? activeLens.render() : '

No lens selected

'; + + return ` +
+
+

🔬 Microscope

+
+ ${Array.from(this.lenses.keys()) + .map( + (mode) => + ``, + ) + .join('')} +
+
+
+ ${lensUI} +
+ +
+ `; + } + + /** + * Register a lens + */ + public registerLens(lens: MicroscopeLens): void { + if (this.lenses.has(lens.mode)) { + throw new Error(`Lens already registered for mode: ${lens.mode}`); + } + + this.lenses.set(lens.mode, lens); + this.emit('lens-registered', { mode: lens.mode, name: lens.name }); + } + + /** + * Unregister a lens + */ + public async unregisterLens(mode: InspectionMode): Promise { + const lens = this.lenses.get(mode); + if (lens === undefined) { + return; + } + + await lens.cleanup(); + this.lenses.delete(mode); + this.emit('lens-unregistered', { mode }); + } + + /** + * Get a lens + */ + public getLens(mode: InspectionMode): MicroscopeLens | undefined { + return this.lenses.get(mode); + } + + /** + * Get all lenses + */ + public getAllLenses(): Map { + return new Map(this.lenses); + } + + /** + * Set inspection mode + */ + public setMode(mode: InspectionMode): void { + if (!this.lenses.has(mode)) { + throw new Error(`No lens registered for mode: ${mode}`); + } + + const previousMode = this.currentMode; + this.currentMode = mode; + this.emit('mode-changed', { previousMode, currentMode: mode }); + + // Re-inspect current component if auto-inspect is enabled + if (this.autoInspect && this.currentComponent !== null) { + void this.inspect(this.currentComponent); + } + } + + /** + * Get current mode + */ + public getMode(): InspectionMode { + return this.currentMode; + } + + /** + * Inspect a component + */ + public async inspect(component: VisualNeuron): Promise { + this.currentComponent = component; + + const lens = this.lenses.get(this.currentMode); + if (lens === undefined) { + throw new Error(`No lens available for mode: ${this.currentMode}`); + } + + const result = await lens.inspect(component); + + // Record in history + if (this.recordHistory) { + this.addToHistory(result); + } + + this.emit('inspection-complete', result); + + return result; + } + + /** + * Add result to history + */ + private addToHistory(result: InspectionResult): void { + this.inspectionHistory.push(result); + + // Trim history if needed + if (this.inspectionHistory.length > this.maxHistorySize) { + this.inspectionHistory = this.inspectionHistory.slice(-this.maxHistorySize); + } + + this.emit('history-updated', { size: this.inspectionHistory.length }); + } + + /** + * Get inspection history + */ + public getHistory(): InspectionResult[] { + return [...this.inspectionHistory]; + } + + /** + * Get history for specific mode + */ + public getHistoryForMode(mode: InspectionMode): InspectionResult[] { + return this.inspectionHistory.filter((result) => result.mode === mode); + } + + /** + * Clear history + */ + public clearHistory(): void { + this.inspectionHistory = []; + this.emit('history-cleared'); + } + + /** + * Get current component + */ + public getCurrentComponent(): VisualNeuron | null { + return this.currentComponent; + } + + /** + * Start real-time updates + */ + public startRealTimeUpdates(interval: number = 1000): void { + if (!this.realTimeUpdates || this.realTimeInterval !== null) { + return; + } + + this.realTimeInterval = setInterval(() => { + if (this.currentComponent !== null) { + void this.inspect(this.currentComponent); + } + }, interval); + + this.emit('real-time-started', { interval }); + } + + /** + * Stop real-time updates + */ + public stopRealTimeUpdates(): void { + if (this.realTimeInterval !== null) { + clearInterval(this.realTimeInterval); + this.realTimeInterval = null; + this.emit('real-time-stopped'); + } + } + + /** + * Check if real-time updates are active + */ + public isRealTimeActive(): boolean { + return this.realTimeInterval !== null; + } + + /** + * Export microscope data + */ + public exportData(): { + currentMode: InspectionMode; + lenses: string[]; + history: InspectionResult[]; + stats: { + totalInspections: number; + issuesFound: number; + avgInspectionTime: number; + }; + } { + const issuesFound = this.inspectionHistory.reduce( + (count, result) => count + (result.issues?.length ?? 0), + 0, + ); + + return { + currentMode: this.currentMode, + lenses: Array.from(this.lenses.keys()), + history: [...this.inspectionHistory], + stats: { + totalInspections: this.inspectionHistory.length, + issuesFound, + avgInspectionTime: 0, // TODO: Track inspection times + }, + }; + } +} diff --git a/src/theater/instruments/PerformanceProfiler.ts b/src/theater/instruments/PerformanceProfiler.ts new file mode 100644 index 0000000..3ec5342 --- /dev/null +++ b/src/theater/instruments/PerformanceProfiler.ts @@ -0,0 +1,530 @@ +/** + * PerformanceProfiler - Performance monitoring tool + * + * PerformanceProfiler is a Microscope lens that integrates with + * VisualOligodendrocyte to monitor render performance, identify + * bottlenecks, and suggest optimizations. + */ + +import type { MicroscopeLens, InspectionResult, InspectionIssue } from './Microscope'; +import type { VisualNeuron } from '../../ui/VisualNeuron'; + +/** + * Performance metric + */ +export interface PerformanceMetric { + /** + * Metric name + */ + name: string; + + /** + * Value + */ + value: number; + + /** + * Unit + */ + unit: 'ms' | 'fps' | 'count' | 'bytes' | 'percent'; + + /** + * Timestamp + */ + timestamp: Date; + + /** + * Threshold (for warnings) + */ + threshold?: number; +} + +/** + * Render profile + */ +export interface RenderProfile { + /** + * Component ID + */ + componentId: string; + + /** + * Render duration (ms) + */ + duration: number; + + /** + * Render count + */ + renderCount: number; + + /** + * Last render time + */ + lastRender: Date; + + /** + * Average render time + */ + avgRenderTime: number; + + /** + * Max render time + */ + maxRenderTime: number; + + /** + * Min render time + */ + minRenderTime: number; + + /** + * Memoization hits + */ + memoHits: number; + + /** + * Memoization misses + */ + memoMisses: number; + + /** + * Optimizations applied + */ + optimizations: string[]; +} + +/** + * Performance bottleneck + */ +export interface PerformanceBottleneck { + /** + * Bottleneck type + */ + type: 'slow-render' | 'excessive-renders' | 'memory-leak' | 'large-bundle'; + + /** + * Severity + */ + severity: 'critical' | 'warning' | 'info'; + + /** + * Description + */ + description: string; + + /** + * Affected component + */ + component: string; + + /** + * Metric value + */ + value: number; + + /** + * Recommendation + */ + recommendation: string; +} + +/** + * PerformanceProfiler configuration + */ +export interface PerformanceProfilerConfig { + /** + * Slow render threshold (ms) + */ + slowRenderThreshold?: number; + + /** + * Excessive render threshold + */ + excessiveRenderThreshold?: number; + + /** + * Track memory usage + */ + trackMemory?: boolean; + + /** + * Sample rate (0-1) + */ + sampleRate?: number; + + /** + * Enable profiler integration + */ + enableProfiler?: boolean; +} + +/** + * PerformanceProfiler - Performance monitoring + */ +export class PerformanceProfiler implements MicroscopeLens { + public readonly id = 'performance-profiler'; + public readonly name = 'Performance Profiler'; + public readonly mode = 'performance' as const; + + private profiles: Map = new Map(); + private metrics: PerformanceMetric[] = []; + private slowRenderThreshold: number = 16; // 60fps target + private excessiveRenderThreshold: number = 100; + private trackMemory: boolean = true; + private sampleRate: number = 1.0; + + constructor(config: PerformanceProfilerConfig = {}) { + if (config.slowRenderThreshold !== undefined) { + this.slowRenderThreshold = config.slowRenderThreshold; + } + if (config.excessiveRenderThreshold !== undefined) { + this.excessiveRenderThreshold = config.excessiveRenderThreshold; + } + if (config.trackMemory !== undefined) { + this.trackMemory = config.trackMemory; + } + if (config.sampleRate !== undefined) { + this.sampleRate = config.sampleRate; + } + // enableProfiler config is reserved for future use + if (config.enableProfiler !== undefined) { + // Future: this.enableProfiler = config.enableProfiler; + } + } + + /** + * Initialize profiler + */ + // eslint-disable-next-line @typescript-eslint/require-await + public async initialize(): Promise { + this.profiles.clear(); + this.metrics = []; + } + + /** + * Cleanup profiler + */ + // eslint-disable-next-line @typescript-eslint/require-await + public async cleanup(): Promise { + this.profiles.clear(); + this.metrics = []; + } + + /** + * Inspect component performance + */ + public async inspect(component: VisualNeuron): Promise { + const componentId = this.getComponentId(component); + const issues: InspectionIssue[] = []; + + // Profile render performance + const profile = await this.profileRender(component, componentId); + this.profiles.set(componentId, profile); + + // Collect metrics + const metrics = this.collectMetrics(component); + this.metrics.push(...metrics); + + // Detect bottlenecks + const bottlenecks = this.detectBottlenecks(); + for (const bottleneck of bottlenecks) { + issues.push({ + severity: bottleneck.severity === 'critical' ? 'error' : bottleneck.severity, + message: bottleneck.description, + source: bottleneck.component, + suggestion: bottleneck.recommendation, + }); + } + + // Calculate performance score + const score = this.calculatePerformanceScore(profile); + + return { + mode: 'performance', + timestamp: new Date(), + componentId, + data: { + profile, + metrics, + bottlenecks, + score, + stats: { + totalProfiles: this.profiles.size, + totalMetrics: this.metrics.length, + avgRenderTime: this.calculateAvgRenderTime(), + fps: this.calculateFPS(), + }, + }, + issues, + metadata: { + slowRenderThreshold: this.slowRenderThreshold, + sampleRate: this.sampleRate, + }, + }; + } + + /** + * Render profiler UI + */ + public render(): string { + const profiles = Array.from(this.profiles.values()); + const avgRenderTime = this.calculateAvgRenderTime(); + const fps = this.calculateFPS(); + + return ` +
+
+
+ + ${avgRenderTime.toFixed(2)}ms +
+
+ + ${fps.toFixed(1)} +
+
+ + ${profiles.length} +
+
+
+ ${profiles + .map( + (profile) => ` +
+ ${profile.componentId} + Renders: ${profile.renderCount} + Avg: ${profile.avgRenderTime.toFixed(2)}ms + Max: ${profile.maxRenderTime.toFixed(2)}ms + ${profile.avgRenderTime > this.slowRenderThreshold ? '⚠️ Slow' : ''} +
+ `, + ) + .join('')} +
+
+ +
+
+ `; + } + + /** + * Profile component render + */ + // eslint-disable-next-line @typescript-eslint/require-await + private async profileRender( + _component: VisualNeuron, + componentId: string, + ): Promise { + const existingProfile = this.profiles.get(componentId); + + // Start timing + const startTime = performance.now(); + + // Simulate render timing (sampling) + // In a real implementation, this would hook into the actual render cycle + if (Math.random() < this.sampleRate) { + // Measure render performance without actually rendering + // This would be replaced with actual render hooking in production + } + + const duration = performance.now() - startTime; + + // Update profile + const renderCount = (existingProfile?.renderCount ?? 0) + 1; + const totalTime = (existingProfile?.avgRenderTime ?? 0) * (renderCount - 1) + duration; + const avgRenderTime = totalTime / renderCount; + + // Get memoization stats (would integrate with VisualOligodendrocyte when available) + const memoStats = { hits: 0, misses: 0 }; + + const profile: RenderProfile = { + componentId, + duration, + renderCount, + lastRender: new Date(), + avgRenderTime, + maxRenderTime: Math.max(existingProfile?.maxRenderTime ?? 0, duration), + minRenderTime: Math.min(existingProfile?.minRenderTime ?? Infinity, duration), + memoHits: memoStats.hits, + memoMisses: memoStats.misses, + optimizations: [], + }; + + return profile; + } + + /** + * Collect performance metrics + */ + private collectMetrics(_component: VisualNeuron): PerformanceMetric[] { + const metrics: PerformanceMetric[] = []; + const now = new Date(); + + // Frame rate + const fps = this.calculateFPS(); + metrics.push({ + name: 'FPS', + value: fps, + unit: 'fps', + timestamp: now, + threshold: 60, + }); + + // Memory usage (if available) + if (this.trackMemory && 'memory' in performance) { + const memInfo = (performance as unknown as { memory: { usedJSHeapSize: number } }).memory; + metrics.push({ + name: 'Memory Usage', + value: memInfo.usedJSHeapSize / 1024 / 1024, + unit: 'bytes', + timestamp: now, + }); + } + + return metrics; + } + + /** + * Detect performance bottlenecks + */ + private detectBottlenecks(): PerformanceBottleneck[] { + const bottlenecks: PerformanceBottleneck[] = []; + + for (const profile of this.profiles.values()) { + // Slow renders + if (profile.avgRenderTime > this.slowRenderThreshold) { + bottlenecks.push({ + type: 'slow-render', + severity: profile.avgRenderTime > this.slowRenderThreshold * 2 ? 'critical' : 'warning', + description: `Component renders slowly (${profile.avgRenderTime.toFixed(2)}ms avg)`, + component: profile.componentId, + value: profile.avgRenderTime, + recommendation: 'Consider memoization, virtualization, or code splitting', + }); + } + + // Excessive renders + if (profile.renderCount > this.excessiveRenderThreshold) { + bottlenecks.push({ + type: 'excessive-renders', + severity: 'warning', + description: `Component rendered ${profile.renderCount} times`, + component: profile.componentId, + value: profile.renderCount, + recommendation: 'Review dependencies and consider React.memo or useMemo', + }); + } + + // Poor memoization hit rate + const memoTotal = profile.memoHits + profile.memoMisses; + if (memoTotal > 10) { + const hitRate = profile.memoHits / memoTotal; + if (hitRate < 0.5) { + bottlenecks.push({ + type: 'slow-render', + severity: 'info', + description: `Low memoization hit rate (${(hitRate * 100).toFixed(1)}%)`, + component: profile.componentId, + value: hitRate, + recommendation: 'Review memoization dependencies and equality checks', + }); + } + } + } + + return bottlenecks; + } + + /** + * Calculate performance score (0-100) + */ + private calculatePerformanceScore(profile: RenderProfile): number { + let score = 100; + + // Penalize slow renders + if (profile.avgRenderTime > this.slowRenderThreshold) { + score -= Math.min(30, (profile.avgRenderTime / this.slowRenderThreshold) * 10); + } + + // Penalize excessive renders + if (profile.renderCount > this.excessiveRenderThreshold) { + score -= Math.min(20, (profile.renderCount / this.excessiveRenderThreshold) * 10); + } + + // Reward good memoization + const memoTotal = profile.memoHits + profile.memoMisses; + if (memoTotal > 0) { + const hitRate = profile.memoHits / memoTotal; + score += hitRate * 10; + } + + return Math.max(0, Math.min(100, score)); + } + + /** + * Calculate average render time across all components + */ + private calculateAvgRenderTime(): number { + if (this.profiles.size === 0) { + return 0; + } + + let total = 0; + for (const profile of this.profiles.values()) { + total += profile.avgRenderTime; + } + + return total / this.profiles.size; + } + + /** + * Calculate frames per second + */ + private calculateFPS(): number { + const avgRenderTime = this.calculateAvgRenderTime(); + if (avgRenderTime === 0) { + return 60; + } + + return Math.min(60, 1000 / avgRenderTime); + } + + /** + * Get component ID + */ + private getComponentId(_component: VisualNeuron): string { + return 'component'; + } + + /** + * Get profile by component ID + */ + public getProfile(componentId: string): RenderProfile | undefined { + return this.profiles.get(componentId); + } + + /** + * Get all profiles + */ + public getAllProfiles(): RenderProfile[] { + return Array.from(this.profiles.values()); + } + + /** + * Get all metrics + */ + public getAllMetrics(): PerformanceMetric[] { + return [...this.metrics]; + } + + /** + * Clear profiles and metrics + */ + public clear(): void { + this.profiles.clear(); + this.metrics = []; + } +} diff --git a/src/theater/instruments/SignalTracer.ts b/src/theater/instruments/SignalTracer.ts new file mode 100644 index 0000000..be80402 --- /dev/null +++ b/src/theater/instruments/SignalTracer.ts @@ -0,0 +1,490 @@ +/** + * SignalTracer - Neural signal visualization tool + * + * SignalTracer is a Microscope lens that visualizes signal flow through + * the nervous system. It tracks signal propagation, transformations, + * and inter-component communication. + */ + +import type { MicroscopeLens, InspectionResult, InspectionIssue } from './Microscope'; +import type { VisualNeuron } from '../../ui/VisualNeuron'; + +/** + * Signal type placeholder + * In a real implementation, this would integrate with the nervous system + */ +export interface Signal { + value: T; +} + +/** + * Signal trace entry + */ +export interface SignalTrace { + /** + * Signal ID + */ + signalId: string; + + /** + * Signal name + */ + name: string; + + /** + * Current value + */ + value: unknown; + + /** + * Previous value + */ + previousValue: unknown; + + /** + * Timestamp + */ + timestamp: Date; + + /** + * Source component + */ + source?: string; + + /** + * Target components + */ + targets: string[]; + + /** + * Propagation path + */ + path: string[]; + + /** + * Update count + */ + updateCount: number; +} + +/** + * Signal flow graph + */ +export interface SignalFlowGraph { + /** + * Nodes (components) + */ + nodes: Array<{ + id: string; + label: string; + type: 'source' | 'target' | 'intermediate'; + }>; + + /** + * Edges (signal connections) + */ + edges: Array<{ + from: string; + to: string; + signal: string; + weight: number; + }>; +} + +/** + * SignalTracer configuration + */ +export interface SignalTracerConfig { + /** + * Max traces to keep + */ + maxTraces?: number; + + /** + * Track signal history + */ + trackHistory?: boolean; + + /** + * Detect circular dependencies + */ + detectCircular?: boolean; + + /** + * Highlight slow signals (ms threshold) + */ + slowSignalThreshold?: number; +} + +/** + * SignalTracer - Neural signal visualization + */ +export class SignalTracer implements MicroscopeLens { + public readonly id = 'signal-tracer'; + public readonly name = 'Signal Tracer'; + public readonly mode = 'signals' as const; + + private traces: Map = new Map(); + private signalHistory: Array<{ signalId: string; value: unknown; timestamp: Date }> = []; + private maxTraces: number = 1000; + private trackHistory: boolean = true; + private detectCircular: boolean = true; + private slowSignalThreshold: number = 100; + + constructor(config: SignalTracerConfig = {}) { + if (config.maxTraces !== undefined) { + this.maxTraces = config.maxTraces; + } + if (config.trackHistory !== undefined) { + this.trackHistory = config.trackHistory; + } + if (config.detectCircular !== undefined) { + this.detectCircular = config.detectCircular; + } + if (config.slowSignalThreshold !== undefined) { + this.slowSignalThreshold = config.slowSignalThreshold; + } + } + + /** + * Initialize tracer + */ + // eslint-disable-next-line @typescript-eslint/require-await + public async initialize(): Promise { + this.traces.clear(); + this.signalHistory = []; + } + + /** + * Cleanup tracer + */ + // eslint-disable-next-line @typescript-eslint/require-await + public async cleanup(): Promise { + this.traces.clear(); + this.signalHistory = []; + } + + /** + * Inspect component signals + */ + // eslint-disable-next-line @typescript-eslint/require-await + public async inspect(component: VisualNeuron): Promise { + const startTime = Date.now(); + const signals = this.extractSignals(component); + const issues: InspectionIssue[] = []; + + // Track each signal + for (const signal of signals) { + this.traceSignal(signal, component); + } + + // Detect issues + if (this.detectCircular) { + const circularIssues = this.detectCircularDependencies(); + issues.push(...circularIssues); + } + + const slowSignals = this.detectSlowSignals(); + issues.push(...slowSignals); + + const flowGraph = this.buildFlowGraph(); + + const inspectionTime = Date.now() - startTime; + + return { + mode: 'signals', + timestamp: new Date(), + componentId: this.getComponentId(component), + data: { + signals: Array.from(this.traces.values()), + flowGraph, + stats: { + totalSignals: signals.length, + activeSignals: this.traces.size, + historySize: this.signalHistory.length, + inspectionTime, + }, + }, + issues, + metadata: { + trackHistory: this.trackHistory, + detectCircular: this.detectCircular, + }, + }; + } + + /** + * Render tracer UI + */ + public render(): string { + return ` +
+
+ Active Signals: ${this.traces.size} + History: ${this.signalHistory.length} +
+
+ ${Array.from(this.traces.values()) + .map( + (trace) => ` +
+ ${trace.name} + Updates: ${trace.updateCount} + Targets: ${trace.targets.length} +
+ `, + ) + .join('')} +
+
+ +
+
+ `; + } + + /** + * Extract signals from component + */ + private extractSignals(component: VisualNeuron): Signal[] { + const signals: Signal[] = []; + + // Access component's internal signals through state + // This is a simplified extraction - in reality would use component introspection + const componentData = component as unknown as { signals?: Map> }; + + if (componentData.signals !== undefined) { + for (const signal of componentData.signals.values()) { + signals.push(signal); + } + } + + return signals; + } + + /** + * Trace a signal + */ + private traceSignal(signal: Signal, component: VisualNeuron): void { + const signalId = this.getSignalId(signal); + const existingTrace = this.traces.get(signalId); + + const trace: SignalTrace = { + signalId, + name: this.getSignalName(signal), + + value: signal.value, + previousValue: existingTrace?.value, + timestamp: new Date(), + source: this.getComponentId(component), + targets: this.getSignalTargets(signal), + path: this.buildSignalPath(signal), + updateCount: (existingTrace?.updateCount ?? 0) + 1, + }; + + this.traces.set(signalId, trace); + + // Add to history + if (this.trackHistory) { + this.signalHistory.push({ + signalId, + + value: signal.value, + timestamp: new Date(), + }); + + // Trim history if needed + if (this.signalHistory.length > this.maxTraces) { + this.signalHistory = this.signalHistory.slice(-this.maxTraces); + } + } + } + + /** + * Detect circular dependencies + */ + private detectCircularDependencies(): InspectionIssue[] { + const issues: InspectionIssue[] = []; + const visited = new Set(); + const recursionStack = new Set(); + + const detectCycle = (signalId: string, path: string[]): boolean => { + if (!visited.has(signalId)) { + visited.add(signalId); + recursionStack.add(signalId); + + const trace = this.traces.get(signalId); + if (trace !== undefined) { + for (const target of trace.targets) { + if (!visited.has(target) && detectCycle(target, [...path, target])) { + return true; + } else if (recursionStack.has(target)) { + issues.push({ + severity: 'warning', + message: `Circular signal dependency detected: ${[...path, target].join(' → ')}`, + source: signalId, + suggestion: 'Review signal flow to break circular dependencies', + }); + return true; + } + } + } + } + + recursionStack.delete(signalId); + return false; + }; + + for (const signalId of this.traces.keys()) { + detectCycle(signalId, [signalId]); + } + + return issues; + } + + /** + * Detect slow signals + */ + private detectSlowSignals(): InspectionIssue[] { + const issues: InspectionIssue[] = []; + + // Group signals by update frequency + const now = Date.now(); + for (const trace of this.traces.values()) { + const timeSinceUpdate = now - trace.timestamp.getTime(); + + if (timeSinceUpdate > this.slowSignalThreshold && trace.updateCount > 0) { + issues.push({ + severity: 'info', + message: `Signal "${trace.name}" hasn't updated in ${timeSinceUpdate}ms`, + source: trace.signalId, + suggestion: 'Check if signal is still needed or if updates are being blocked', + }); + } + } + + return issues; + } + + /** + * Build signal flow graph + */ + private buildFlowGraph(): SignalFlowGraph { + const nodes = new Map< + string, + { id: string; label: string; type: 'source' | 'target' | 'intermediate' } + >(); + const edges: SignalFlowGraph['edges'] = []; + + for (const trace of this.traces.values()) { + // Add source node + if (trace.source !== undefined && !nodes.has(trace.source)) { + nodes.set(trace.source, { + id: trace.source, + label: trace.source, + type: 'source', + }); + } + + // Add target nodes and edges + for (const target of trace.targets) { + if (!nodes.has(target)) { + nodes.set(target, { + id: target, + label: target, + type: 'target', + }); + } + + if (trace.source !== undefined) { + edges.push({ + from: trace.source, + to: target, + signal: trace.name, + weight: trace.updateCount, + }); + } + } + } + + return { + nodes: Array.from(nodes.values()), + edges, + }; + } + + /** + * Get signal ID + */ + private getSignalId(_signal: Signal): string { + // In a real implementation, signals would have IDs + return `signal-${Math.random().toString(36).substring(2, 9)}`; + } + + /** + * Get signal name + */ + private getSignalName(signal: Signal): string { + // In a real implementation, signals would have names + + return `Signal<${typeof signal.value}>`; + } + + /** + * Get component ID + */ + private getComponentId(_component: VisualNeuron): string { + // In a real implementation, components would have IDs + return 'component'; + } + + /** + * Get signal targets + */ + private getSignalTargets(_signal: Signal): string[] { + // In a real implementation, would track signal subscribers + return []; + } + + /** + * Build signal propagation path + */ + private buildSignalPath(_signal: Signal): string[] { + // In a real implementation, would track signal propagation + return []; + } + + /** + * Get trace by signal ID + */ + public getTrace(signalId: string): SignalTrace | undefined { + return this.traces.get(signalId); + } + + /** + * Get all traces + */ + public getAllTraces(): SignalTrace[] { + return Array.from(this.traces.values()); + } + + /** + * Get signal history + */ + public getHistory( + signalId?: string, + ): Array<{ signalId: string; value: unknown; timestamp: Date }> { + if (signalId !== undefined) { + return this.signalHistory.filter((entry) => entry.signalId === signalId); + } + return [...this.signalHistory]; + } + + /** + * Clear traces + */ + public clearTraces(): void { + this.traces.clear(); + this.signalHistory = []; + } +} diff --git a/src/theater/instruments/StateExplorer.ts b/src/theater/instruments/StateExplorer.ts new file mode 100644 index 0000000..943666b --- /dev/null +++ b/src/theater/instruments/StateExplorer.ts @@ -0,0 +1,550 @@ +/** + * StateExplorer - Time-travel debugging tool + * + * StateExplorer is a Microscope lens that provides time-travel debugging + * capabilities. It records state changes, allows state inspection, and + * enables rewinding/replaying component state. + */ + +import type { MicroscopeLens, InspectionResult, InspectionIssue } from './Microscope'; +import type { VisualNeuron } from '../../ui/VisualNeuron'; + +/** + * State snapshot + */ +export interface StateSnapshot { + /** + * Snapshot ID + */ + id: string; + + /** + * Component ID + */ + componentId: string; + + /** + * Timestamp + */ + timestamp: Date; + + /** + * State data + */ + state: Record; + + /** + * Props data + */ + props: Record; + + /** + * Diff from previous snapshot + */ + diff?: StateDiff; + + /** + * Stack trace of state change + */ + stackTrace?: string; +} + +/** + * State diff + */ +export interface StateDiff { + /** + * Added keys + */ + added: string[]; + + /** + * Removed keys + */ + removed: string[]; + + /** + * Modified keys + */ + modified: Array<{ + key: string; + oldValue: unknown; + newValue: unknown; + }>; + + /** + * Unchanged keys + */ + unchanged: string[]; +} + +/** + * Time travel action + */ +export type TimeTravelAction = 'pause' | 'resume' | 'step-forward' | 'step-backward' | 'jump'; + +/** + * StateExplorer configuration + */ +export interface StateExplorerConfig { + /** + * Max snapshots to keep + */ + maxSnapshots?: number; + + /** + * Record stack traces + */ + recordStackTraces?: boolean; + + /** + * Auto-pause on errors + */ + autoPauseOnError?: boolean; + + /** + * Diff threshold (ignore changes below this) + */ + diffThreshold?: number; + + /** + * Enable state validation + */ + validateState?: boolean; +} + +/** + * StateExplorer - Time-travel debugging + */ +export class StateExplorer implements MicroscopeLens { + public readonly id = 'state-explorer'; + public readonly name = 'State Explorer'; + public readonly mode = 'state' as const; + + private snapshots: StateSnapshot[] = []; + private currentIndex: number = -1; + private isPaused: boolean = false; + private maxSnapshots: number = 500; + private recordStackTraces: boolean = false; + private validateState: boolean = true; + + constructor(config: StateExplorerConfig = {}) { + if (config.maxSnapshots !== undefined) { + this.maxSnapshots = config.maxSnapshots; + } + if (config.recordStackTraces !== undefined) { + this.recordStackTraces = config.recordStackTraces; + } + // autoPauseOnError and diffThreshold are reserved for future use + if (config.autoPauseOnError !== undefined) { + // Future: this.autoPauseOnError = config.autoPauseOnError; + } + if (config.diffThreshold !== undefined) { + // Future: this.diffThreshold = config.diffThreshold; + } + if (config.validateState !== undefined) { + this.validateState = config.validateState; + } + } + + /** + * Initialize explorer + */ + // eslint-disable-next-line @typescript-eslint/require-await + public async initialize(): Promise { + this.snapshots = []; + this.currentIndex = -1; + this.isPaused = false; + } + + /** + * Cleanup explorer + */ + // eslint-disable-next-line @typescript-eslint/require-await + public async cleanup(): Promise { + this.snapshots = []; + this.currentIndex = -1; + this.isPaused = false; + } + + /** + * Inspect component state + */ + // eslint-disable-next-line @typescript-eslint/require-await + public async inspect(component: VisualNeuron): Promise { + const componentId = this.getComponentId(component); + const state = this.extractState(component); + const props = this.extractProps(component); + const issues: InspectionIssue[] = []; + + // Create snapshot if not paused + if (!this.isPaused) { + const snapshot = this.createSnapshot(componentId, state, props); + this.addSnapshot(snapshot); + } + + // Validate state + if (this.validateState) { + const validationIssues = this.validateComponentState(state); + issues.push(...validationIssues); + } + + // Analyze state changes + const changeAnalysis = this.analyzeStateChanges(); + if (changeAnalysis.frequentChanges.length > 0) { + issues.push({ + severity: 'info', + message: `High-frequency state changes detected in: ${changeAnalysis.frequentChanges.join(', ')}`, + suggestion: 'Consider batching updates or using derived state', + }); + } + + return { + mode: 'state', + timestamp: new Date(), + componentId, + data: { + currentSnapshot: this.getCurrentSnapshot(), + snapshots: this.snapshots, + currentIndex: this.currentIndex, + isPaused: this.isPaused, + stats: { + totalSnapshots: this.snapshots.length, + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + stateKeys: state !== null && state !== undefined ? Object.keys(state).length : 0, + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + propsKeys: props !== null && props !== undefined ? Object.keys(props).length : 0, + }, + analysis: changeAnalysis, + }, + issues, + metadata: { + recordStackTraces: this.recordStackTraces, + validateState: this.validateState, + }, + }; + } + + /** + * Render explorer UI + */ + public render(): string { + const current = this.getCurrentSnapshot(); + + return ` +
+
+ + + + ${this.currentIndex + 1} / ${this.snapshots.length} +
+
+ ${this.renderTimeline()} +
+
+ ${current !== undefined ? this.renderSnapshot(current) : '

No snapshot available

'} +
+
+ `; + } + + /** + * Create a state snapshot + */ + private createSnapshot( + componentId: string, + state: Record, + props: Record, + ): StateSnapshot { + const id = `snapshot-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; + const previous = this.getCurrentSnapshot(); + + const snapshot: StateSnapshot = { + id, + componentId, + timestamp: new Date(), + state: { ...state }, + props: { ...props }, + }; + + // Calculate diff + if (previous !== undefined) { + snapshot.diff = this.calculateDiff(previous.state, state); + } + + // Record stack trace + if (this.recordStackTraces) { + const stackTrace = new Error().stack; + if (stackTrace !== undefined) { + snapshot.stackTrace = stackTrace; + } + } + + return snapshot; + } + + /** + * Add snapshot to history + */ + private addSnapshot(snapshot: StateSnapshot): void { + // Remove any snapshots after current index (for time travel) + if (this.currentIndex < this.snapshots.length - 1) { + this.snapshots = this.snapshots.slice(0, this.currentIndex + 1); + } + + this.snapshots.push(snapshot); + this.currentIndex = this.snapshots.length - 1; + + // Trim if exceeded max + if (this.snapshots.length > this.maxSnapshots) { + const trimCount = this.snapshots.length - this.maxSnapshots; + this.snapshots = this.snapshots.slice(trimCount); + this.currentIndex -= trimCount; + } + } + + /** + * Calculate state diff + */ + private calculateDiff( + oldState: Record, + newState: Record, + ): StateDiff { + const oldKeys = new Set(Object.keys(oldState)); + const newKeys = new Set(Object.keys(newState)); + + const added: string[] = []; + const removed: string[] = []; + const modified: StateDiff['modified'] = []; + const unchanged: string[] = []; + + // Find added keys + for (const key of newKeys) { + if (!oldKeys.has(key)) { + added.push(key); + } + } + + // Find removed keys + for (const key of oldKeys) { + if (!newKeys.has(key)) { + removed.push(key); + } + } + + // Find modified/unchanged keys + for (const key of newKeys) { + if (oldKeys.has(key)) { + if (oldState[key] !== newState[key]) { + modified.push({ + key, + oldValue: oldState[key], + newValue: newState[key], + }); + } else { + unchanged.push(key); + } + } + } + + return { added, removed, modified, unchanged }; + } + + /** + * Extract state from component + */ + private extractState(_component: VisualNeuron): Record { + // Simplified extraction - in reality would use component introspection + return {}; + } + + /** + * Extract props from component + */ + private extractProps(_component: VisualNeuron): Record { + // Simplified extraction - in reality would use component introspection + // VisualNeuron stores props in protected receptiveField + // In a real implementation, this would have proper accessor methods + return {}; + } + + /** + * Get component ID + */ + private getComponentId(_component: VisualNeuron): string { + return 'component'; + } + + /** + * Validate component state + */ + private validateComponentState(state: Record): InspectionIssue[] { + const issues: InspectionIssue[] = []; + + // Check for undefined values + for (const [key, value] of Object.entries(state)) { + if (value === undefined) { + issues.push({ + severity: 'warning', + message: `State key "${key}" has undefined value`, + suggestion: 'Consider using null or removing the key', + }); + } + } + + return issues; + } + + /** + * Analyze state changes + */ + private analyzeStateChanges(): { + frequentChanges: string[]; + recentChanges: Array<{ key: string; count: number }>; + } { + const changeCounts = new Map(); + + // Count changes per key + for (const snapshot of this.snapshots) { + if (snapshot.diff !== undefined) { + for (const { key } of snapshot.diff.modified) { + changeCounts.set(key, (changeCounts.get(key) ?? 0) + 1); + } + } + } + + // Find frequently changing keys (>10 changes) + const frequentChanges: string[] = []; + for (const [key, count] of changeCounts) { + if (count > 10) { + frequentChanges.push(key); + } + } + + const recentChanges = Array.from(changeCounts.entries()).map(([key, count]) => ({ + key, + count, + })); + + return { frequentChanges, recentChanges }; + } + + /** + * Time travel + */ + public timeTravel(action: TimeTravelAction, target?: number): void { + switch (action) { + case 'pause': + this.isPaused = true; + break; + + case 'resume': + this.isPaused = false; + break; + + case 'step-backward': + if (this.currentIndex > 0) { + this.currentIndex--; + } + break; + + case 'step-forward': + if (this.currentIndex < this.snapshots.length - 1) { + this.currentIndex++; + } + break; + + case 'jump': + if (target !== undefined && target >= 0 && target < this.snapshots.length) { + this.currentIndex = target; + } + break; + } + } + + /** + * Get current snapshot + */ + public getCurrentSnapshot(): StateSnapshot | undefined { + return this.snapshots[this.currentIndex]; + } + + /** + * Get all snapshots + */ + public getAllSnapshots(): StateSnapshot[] { + return [...this.snapshots]; + } + + /** + * Get snapshot by ID + */ + public getSnapshot(id: string): StateSnapshot | undefined { + return this.snapshots.find((s) => s.id === id); + } + + /** + * Clear snapshots + */ + public clearSnapshots(): void { + this.snapshots = []; + this.currentIndex = -1; + } + + /** + * Render timeline + */ + private renderTimeline(): string { + return this.snapshots + .map( + (snapshot, index) => ` +
+ ${snapshot.timestamp.toLocaleTimeString()} +
+ `, + ) + .join(''); + } + + /** + * Render snapshot + */ + private renderSnapshot(snapshot: StateSnapshot): string { + return ` +
+

Snapshot ${snapshot.id}

+

Timestamp: ${snapshot.timestamp.toISOString()}

+
+

State

+
${JSON.stringify(snapshot.state, null, 2)}
+
+
+

Props

+
${JSON.stringify(snapshot.props, null, 2)}
+
+ ${snapshot.diff !== undefined ? this.renderDiff(snapshot.diff) : ''} +
+ `; + } + + /** + * Render diff + */ + private renderDiff(diff: StateDiff): string { + return ` +
+

Changes

+ ${diff.added.length > 0 ? `

Added: ${diff.added.join(', ')}

` : ''} + ${diff.removed.length > 0 ? `

Removed: ${diff.removed.join(', ')}

` : ''} + ${diff.modified.length > 0 ? `

Modified: ${diff.modified.map((m) => m.key).join(', ')}

` : ''} +
+ `; + } +} diff --git a/src/theater/laboratory/Experiment.ts b/src/theater/laboratory/Experiment.ts new file mode 100644 index 0000000..4a9ea62 --- /dev/null +++ b/src/theater/laboratory/Experiment.ts @@ -0,0 +1,440 @@ +/** + * Experiment - Test Scenario + * + * An Experiment represents a test scenario for a component. + * It includes setup, execution, validation, and teardown phases. + */ + +import type { VisualNeuron } from '../../ui/VisualNeuron'; +import type { Stage } from '../core/Stage'; +import type { TestSubject } from './TestSubject'; +import type { Hypothesis, HypothesisResult } from './Hypothesis'; + +/** + * Experiment configuration + */ +export interface ExperimentConfig { + /** + * Experiment ID + */ + id: string; + + /** + * Experiment name + */ + name: string; + + /** + * Experiment description + */ + description?: string; + + /** + * Component to test + */ + component?: VisualNeuron; + + /** + * Test subject wrapper + */ + testSubject?: TestSubject; + + /** + * Hypotheses to validate + */ + hypotheses?: Hypothesis[]; + + /** + * Setup function + */ + setup?: () => Promise | void; + + /** + * Teardown function + */ + teardown?: () => Promise | void; + + /** + * Test function + */ + test?: (subject: TestSubject) => Promise | void; + + /** + * Skip this experiment + */ + skip?: boolean; + + /** + * Only run this experiment + */ + only?: boolean; + + /** + * Experiment timeout (ms) + */ + timeout?: number; + + /** + * Number of times to retry on failure + */ + retries?: number; +} + +/** + * Experiment result + */ +export interface ExperimentResult { + /** + * Experiment ID + */ + experimentId: string; + + /** + * Experiment name + */ + experimentName: string; + + /** + * Success status + */ + success: boolean; + + /** + * Timestamp + */ + timestamp: Date; + + /** + * Duration (ms) + */ + duration: number; + + /** + * Hypothesis results + */ + hypotheses: HypothesisResult[]; + + /** + * Error if failed + */ + error?: Error; + + /** + * Retry count + */ + retries?: number; + + /** + * Additional metadata + */ + metadata?: Record; +} + +/** + * Experiment state + */ +export type ExperimentState = 'pending' | 'running' | 'passed' | 'failed' | 'skipped'; + +/** + * Experiment - Test scenario + */ +export class Experiment { + private id: string; + private name: string; + private description: string; + private testSubject: TestSubject | null = null; + private hypotheses: Hypothesis[] = []; + private setupFn: (() => Promise | void) | null = null; + private teardownFn: (() => Promise | void) | null = null; + private testFn: ((subject: TestSubject) => Promise | void) | null = null; + private state: ExperimentState = 'pending'; + private stage: Stage | null = null; + private skip: boolean = false; + private only: boolean = false; + // timeout is configured but handled directly in run() method + private retries: number = 0; + private currentRetry: number = 0; + + constructor(config: ExperimentConfig) { + this.id = config.id; + this.name = config.name; + this.description = config.description ?? ''; + + // component is not used directly, only testSubject + // timeout is used via this.timeout member + + if (config.testSubject !== undefined) { + this.testSubject = config.testSubject; + } + + if (config.hypotheses !== undefined) { + this.hypotheses = config.hypotheses; + } + + if (config.setup !== undefined) { + this.setupFn = config.setup; + } + + if (config.teardown !== undefined) { + this.teardownFn = config.teardown; + } + + if (config.test !== undefined) { + this.testFn = config.test; + } + + if (config.skip !== undefined) { + this.skip = config.skip; + } + + if (config.only !== undefined) { + this.only = config.only; + } + + // timeout is not stored as a field, handled directly in run() if needed + + if (config.retries !== undefined) { + this.retries = config.retries; + } + } + + /** + * Get experiment ID + */ + public getId(): string { + return this.id; + } + + /** + * Get experiment name + */ + public getName(): string { + return this.name; + } + + /** + * Get description + */ + public getDescription(): string { + return this.description; + } + + /** + * Get state + */ + public getState(): ExperimentState { + return this.state; + } + + /** + * Set stage + */ + public setStage(stage: Stage): void { + this.stage = stage; + } + + /** + * Get stage + */ + public getStage(): Stage | null { + return this.stage; + } + + /** + * Set test subject + */ + public setTestSubject(subject: TestSubject): void { + this.testSubject = subject; + } + + /** + * Get test subject + */ + public getTestSubject(): TestSubject | null { + return this.testSubject; + } + + /** + * Add hypothesis + */ + public addHypothesis(hypothesis: Hypothesis): void { + this.hypotheses.push(hypothesis); + } + + /** + * Get hypotheses + */ + public getHypotheses(): Hypothesis[] { + return [...this.hypotheses]; + } + + /** + * Check if experiment should be skipped + */ + public shouldSkip(): boolean { + return this.skip; + } + + /** + * Check if experiment is marked as only + */ + public isOnly(): boolean { + return this.only; + } + + /** + * Run the experiment + */ + public async run(): Promise { + if (this.skip) { + this.state = 'skipped'; + return this.createResult(true, [], 0); + } + + this.state = 'running'; + const startTime = Date.now(); + + try { + // Setup + if (this.setupFn !== null) { + await this.setupFn(); + } + + // Ensure test subject exists + if (this.testSubject === null) { + throw new Error('No test subject provided'); + } + + // Run test function + if (this.testFn !== null) { + await this.testFn(this.testSubject); + } + + // Validate hypotheses + const hypothesisResults = await this.validateHypotheses(); + + // Check if all hypotheses passed + const success = hypothesisResults.every((r) => r.passed); + + this.state = success ? 'passed' : 'failed'; + const duration = Date.now() - startTime; + + return this.createResult(success, hypothesisResults, duration); + } catch (error) { + // Handle retry logic + if (this.currentRetry < this.retries) { + this.currentRetry++; + return await this.run(); + } + + this.state = 'failed'; + const duration = Date.now() - startTime; + + return this.createResult( + false, + [], + duration, + error instanceof Error ? error : new Error(String(error)), + ); + } finally { + // Always run teardown + try { + if (this.teardownFn !== null) { + await this.teardownFn(); + } + } catch (teardownError) { + console.error('Teardown error:', teardownError); + } + } + } + + /** + * Validate all hypotheses + */ + private async validateHypotheses(): Promise { + const results: HypothesisResult[] = []; + + for (const hypothesis of this.hypotheses) { + if (this.testSubject === null) { + throw new Error('No test subject available for hypothesis validation'); + } + + const result = await hypothesis.validate(this.testSubject); + results.push(result); + } + + return results; + } + + /** + * Create experiment result + */ + private createResult( + success: boolean, + hypotheses: HypothesisResult[], + duration: number, + error?: Error, + ): ExperimentResult { + const result: ExperimentResult = { + experimentId: this.id, + experimentName: this.name, + success, + timestamp: new Date(), + duration, + hypotheses, + }; + + if (error !== undefined) { + result.error = error; + } + + if (this.currentRetry > 0) { + result.retries = this.currentRetry; + } + + return result; + } + + /** + * Cleanup experiment + */ + public async cleanup(): Promise { + if (this.teardownFn !== null) { + await this.teardownFn(); + } + + this.state = 'pending'; + this.currentRetry = 0; + } + + /** + * Reset experiment + */ + public reset(): void { + this.state = 'pending'; + this.currentRetry = 0; + } + + /** + * Export experiment data + */ + public export(): { + id: string; + name: string; + description: string; + state: ExperimentState; + hypotheses: number; + skip: boolean; + only: boolean; + } { + return { + id: this.id, + name: this.name, + description: this.description, + state: this.state, + hypotheses: this.hypotheses.length, + skip: this.skip, + only: this.only, + }; + } +} diff --git a/src/theater/laboratory/Hypothesis.ts b/src/theater/laboratory/Hypothesis.ts new file mode 100644 index 0000000..8865726 --- /dev/null +++ b/src/theater/laboratory/Hypothesis.ts @@ -0,0 +1,469 @@ +/** + * Hypothesis - Test Assertions + * + * A Hypothesis represents an assertion about component behavior. + * It validates expectations about state, props, render output, or interactions. + */ + +import type { TestSubject } from './TestSubject'; + +/** + * Hypothesis result + */ +export interface HypothesisResult { + /** + * Hypothesis name + */ + name: string; + + /** + * Pass/fail status + */ + passed: boolean; + + /** + * Expected value + */ + expected?: unknown; + + /** + * Actual value + */ + actual?: unknown; + + /** + * Error message if failed + */ + message?: string; + + /** + * Assertion type + */ + assertionType: string; +} + +/** + * Assertion function + */ +export type AssertionFn = (subject: TestSubject) => Promise | void; + +/** + * Matcher function + */ +export type MatcherFn = (actual: T, expected: T) => boolean; + +/** + * Hypothesis - Test assertion + */ +export class Hypothesis { + private name: string; + private assertionFn: AssertionFn | null = null; + private assertionType: string = 'custom'; + + constructor(name: string, assertionFn?: AssertionFn) { + this.name = name; + + if (assertionFn !== undefined) { + this.assertionFn = assertionFn; + } + } + + /** + * Get hypothesis name + */ + public getName(): string { + return this.name; + } + + /** + * Set assertion function + */ + public setAssertion(fn: AssertionFn): void { + this.assertionFn = fn; + } + + /** + * Validate hypothesis + */ + public async validate(subject: TestSubject): Promise { + if (this.assertionFn === null) { + return { + name: this.name, + passed: false, + message: 'No assertion function defined', + assertionType: this.assertionType, + }; + } + + try { + await this.assertionFn(subject); + + return { + name: this.name, + passed: true, + assertionType: this.assertionType, + }; + } catch (error) { + return { + name: this.name, + passed: false, + message: error instanceof Error ? error.message : String(error), + assertionType: this.assertionType, + }; + } + } + + /** + * Assert that render output contains text + */ + public static toContainText(_subject: TestSubject, expectedText: string): Hypothesis { + const hypothesis = new Hypothesis(`should contain text: "${expectedText}"`); + hypothesis.assertionType = 'toContainText'; + + hypothesis.setAssertion((subj) => { + const output = subj.getRenderOutput(); + + if (!output.includes(expectedText)) { + throw new Error( + `Expected render output to contain "${expectedText}", but it did not.\nActual: ${output}`, + ); + } + }); + + return hypothesis; + } + + /** + * Assert that component has specific state + */ + public static toHaveState( + _subject: TestSubject, + key: string, + expectedValue: unknown, + ): Hypothesis { + const hypothesis = new Hypothesis(`should have state ${key} = ${String(expectedValue)}`); + hypothesis.assertionType = 'toHaveState'; + + hypothesis.setAssertion((subj) => { + const state = subj.getState(); + const actualValue = state[key]; + + if (actualValue !== expectedValue) { + throw new Error( + `Expected state.${key} to be ${String(expectedValue)}, but got ${String(actualValue)}`, + ); + } + }); + + return hypothesis; + } + + /** + * Assert that component has specific prop + */ + public static toHaveProp(_subject: TestSubject, key: string, expectedValue: unknown): Hypothesis { + const hypothesis = new Hypothesis(`should have prop ${key} = ${String(expectedValue)}`); + hypothesis.assertionType = 'toHaveProp'; + + hypothesis.setAssertion((subj) => { + const props = subj.getProps(); + const actualValue = props[key as keyof typeof props]; + + if (actualValue !== expectedValue) { + throw new Error( + `Expected prop.${key} to be ${String(expectedValue)}, but got ${String(actualValue)}`, + ); + } + }); + + return hypothesis; + } + + /** + * Assert that component is mounted + */ + public static toBeMounted(_subject: TestSubject): Hypothesis { + const hypothesis = new Hypothesis('should be mounted'); + hypothesis.assertionType = 'toBeMounted'; + + hypothesis.setAssertion((subj) => { + if (!subj.isMounted()) { + throw new Error('Expected component to be mounted, but it is not'); + } + }); + + return hypothesis; + } + + /** + * Assert that component is active + */ + public static toBeActive(_subject: TestSubject): Hypothesis { + const hypothesis = new Hypothesis('should be active'); + hypothesis.assertionType = 'toBeActive'; + + hypothesis.setAssertion((subj) => { + if (!subj.isActive()) { + throw new Error('Expected component to be active, but it is not'); + } + }); + + return hypothesis; + } + + /** + * Assert render count + */ + public static toHaveRendered(_subject: TestSubject, count: number): Hypothesis { + const hypothesis = new Hypothesis(`should have rendered ${count} time(s)`); + hypothesis.assertionType = 'toHaveRendered'; + + hypothesis.setAssertion((subj) => { + const actualCount = subj.getRenderCount(); + + if (actualCount !== count) { + throw new Error(`Expected ${count} renders, but got ${actualCount}`); + } + }); + + return hypothesis; + } + + /** + * Assert render output matches regex + */ + public static toMatchOutput(_subject: TestSubject, pattern: RegExp): Hypothesis { + const hypothesis = new Hypothesis(`should match output pattern: ${pattern.toString()}`); + hypothesis.assertionType = 'toMatchOutput'; + + hypothesis.setAssertion((subj) => { + const output = subj.getRenderOutput(); + + if (!pattern.test(output)) { + throw new Error( + `Expected render output to match ${pattern.toString()}, but it did not.\nActual: ${output}`, + ); + } + }); + + return hypothesis; + } + + /** + * Assert element exists in render output + */ + public static toHaveElement(_subject: TestSubject, selector: string): Hypothesis { + const hypothesis = new Hypothesis(`should have element: ${selector}`); + hypothesis.assertionType = 'toHaveElement'; + + hypothesis.setAssertion((subj) => { + if (!subj.find(selector)) { + throw new Error( + `Expected to find element "${selector}", but it was not found.\nOutput: ${subj.getRenderOutput()}`, + ); + } + }); + + return hypothesis; + } + + /** + * Assert element count + */ + public static toHaveElementCount( + _subject: TestSubject, + selector: string, + expectedCount: number, + ): Hypothesis { + const hypothesis = new Hypothesis( + `should have ${expectedCount} element(s) matching: ${selector}`, + ); + hypothesis.assertionType = 'toHaveElementCount'; + + hypothesis.setAssertion((subj) => { + const elements = subj.findAll(selector); + const actualCount = elements.length; + + if (actualCount !== expectedCount) { + throw new Error( + `Expected ${expectedCount} elements matching "${selector}", but found ${actualCount}`, + ); + } + }); + + return hypothesis; + } + + /** + * Assert text content + */ + public static toHaveText(_subject: TestSubject, expectedText: string): Hypothesis { + const hypothesis = new Hypothesis(`should have text: "${expectedText}"`); + hypothesis.assertionType = 'toHaveText'; + + hypothesis.setAssertion((subj) => { + const text = subj.getText(); + + if (text !== expectedText) { + throw new Error(`Expected text to be "${expectedText}", but got "${text}"`); + } + }); + + return hypothesis; + } + + /** + * Assert text contains substring + */ + public static toIncludeText(_subject: TestSubject, substring: string): Hypothesis { + const hypothesis = new Hypothesis(`should include text: "${substring}"`); + hypothesis.assertionType = 'toIncludeText'; + + hypothesis.setAssertion((subj) => { + const text = subj.getText(); + + if (!text.includes(substring)) { + throw new Error( + `Expected text to include "${substring}", but it did not.\nActual: ${text}`, + ); + } + }); + + return hypothesis; + } + + /** + * Custom assertion with matcher + */ + public static toSatisfy( + _subject: TestSubject, + description: string, + getter: (subj: TestSubject) => T, + matcher: MatcherFn, + expected: T, + ): Hypothesis { + const hypothesis = new Hypothesis(description); + hypothesis.assertionType = 'toSatisfy'; + + hypothesis.setAssertion((subj) => { + const actual = getter(subj); + + if (!matcher(actual, expected)) { + throw new Error( + `Custom assertion failed.\nExpected: ${String(expected)}\nActual: ${String(actual)}`, + ); + } + }); + + return hypothesis; + } + + /** + * Assert that async condition becomes true + */ + // eslint-disable-next-line @typescript-eslint/require-await + public static async toEventually( + _subject: TestSubject, + description: string, + condition: (subj: TestSubject) => boolean, + timeout: number = 1000, + ): Promise { + const hypothesis = new Hypothesis(`should eventually ${description}`); + hypothesis.assertionType = 'toEventually'; + + hypothesis.setAssertion(async (subj) => { + try { + await subj.waitFor(() => condition(subj), { timeout }); + } catch { + throw new Error(`Condition "${description}" did not become true within ${timeout}ms`); + } + }); + + return hypothesis; + } + + /** + * Negate assertion + */ + public not(): Hypothesis { + const originalAssertion = this.assertionFn; + + if (originalAssertion === null) { + throw new Error('Cannot negate hypothesis without assertion function'); + } + + const negatedHypothesis = new Hypothesis(`NOT ${this.name}`); + negatedHypothesis.assertionType = `not_${this.assertionType}`; + + negatedHypothesis.setAssertion(async (subject) => { + try { + await originalAssertion(subject); + // If original assertion passed, negation should fail + throw new Error('Negated assertion failed: original assertion passed'); + } catch { + // If original assertion failed, negation passes + return; + } + }); + + return negatedHypothesis; + } + + /** + * Combine hypotheses with AND logic + */ + public and(other: Hypothesis): Hypothesis { + const combinedHypothesis = new Hypothesis(`${this.name} AND ${other.getName()}`); + combinedHypothesis.assertionType = 'and'; + + const thisAssertion = this.assertionFn; + const otherAssertion = other.assertionFn; + + if (thisAssertion === null || otherAssertion === null) { + throw new Error('Cannot combine hypotheses without assertion functions'); + } + + combinedHypothesis.setAssertion(async (subject) => { + await thisAssertion(subject); + await otherAssertion(subject); + }); + + return combinedHypothesis; + } + + /** + * Combine hypotheses with OR logic + */ + public or(other: Hypothesis): Hypothesis { + const combinedHypothesis = new Hypothesis(`${this.name} OR ${other.getName()}`); + combinedHypothesis.assertionType = 'or'; + + const thisAssertion = this.assertionFn; + const otherAssertion = other.assertionFn; + + if (thisAssertion === null || otherAssertion === null) { + throw new Error('Cannot combine hypotheses without assertion functions'); + } + + combinedHypothesis.setAssertion(async (subject) => { + let firstError: Error | null = null; + + try { + await thisAssertion(subject); + return; // First assertion passed + } catch (error) { + firstError = error instanceof Error ? error : new Error(String(error)); + } + + try { + await otherAssertion(subject); + return; // Second assertion passed + } catch { + // Both failed, throw first error + throw firstError; + } + }); + + return combinedHypothesis; + } +} diff --git a/src/theater/laboratory/LabReport.ts b/src/theater/laboratory/LabReport.ts new file mode 100644 index 0000000..b41f2b7 --- /dev/null +++ b/src/theater/laboratory/LabReport.ts @@ -0,0 +1,417 @@ +/** + * LabReport - Test Results Report + * + * LabReport provides comprehensive reporting of experiment results, + * including statistics, summaries, and detailed failure information. + */ + +import type { ExperimentResult } from './Experiment'; +import type { LaboratoryStats } from './Laboratory'; + +/** + * Lab report + */ +export interface LabReport { + /** + * Laboratory name + */ + laboratoryName: string; + + /** + * Report timestamp + */ + timestamp: Date; + + /** + * Overall statistics + */ + stats: LaboratoryStats; + + /** + * Experiment results + */ + results: ExperimentResult[]; + + /** + * Total duration (ms) + */ + duration: number; + + /** + * Overall success + */ + success: boolean; +} + +/** + * Report format + */ +export type ReportFormat = 'text' | 'json' | 'html' | 'markdown'; + +/** + * LabReporter - Report generator + */ +// eslint-disable-next-line @typescript-eslint/no-extraneous-class +export class LabReporter { + /** + * Format report as text + */ + public static formatText(report: LabReport): string { + const lines: string[] = []; + + // Header + lines.push('═'.repeat(60)); + lines.push(`Laboratory Report: ${report.laboratoryName}`); + lines.push(`Timestamp: ${report.timestamp.toISOString()}`); + lines.push('═'.repeat(60)); + lines.push(''); + + // Summary + lines.push('SUMMARY'); + lines.push('─'.repeat(60)); + lines.push(`Total Experiments: ${report.stats.totalExperiments}`); + lines.push(`✓ Passed: ${report.stats.passed}`); + lines.push(`✗ Failed: ${report.stats.failed}`); + lines.push(`⊘ Skipped: ${report.stats.skipped}`); + lines.push(`Success Rate: ${(report.stats.successRate * 100).toFixed(2)}%`); + lines.push(`Duration: ${report.duration}ms`); + lines.push(''); + + // Results + if (report.results.length > 0) { + lines.push('RESULTS'); + lines.push('─'.repeat(60)); + + for (const result of report.results) { + const status = result.success ? '✓' : '✗'; + lines.push(`${status} ${result.experimentName} (${result.duration}ms)`); + + // Show hypothesis results + if (result.hypotheses.length > 0) { + for (const hypothesis of result.hypotheses) { + const hypStatus = hypothesis.passed ? ' ✓' : ' ✗'; + lines.push(`${hypStatus} ${hypothesis.name}`); + + if (!hypothesis.passed && hypothesis.message !== undefined) { + lines.push(` Error: ${hypothesis.message}`); + } + } + } + + // Show error if present + if (result.error !== undefined) { + lines.push(` Error: ${result.error.message}`); + } + + // Show retries if any + if (result.retries !== undefined && result.retries > 0) { + lines.push(` Retries: ${result.retries}`); + } + + lines.push(''); + } + } + + // Footer + lines.push('═'.repeat(60)); + lines.push(report.success ? 'ALL EXPERIMENTS PASSED ✓' : 'SOME EXPERIMENTS FAILED ✗'); + lines.push('═'.repeat(60)); + + return lines.join('\n'); + } + + /** + * Format report as JSON + */ + public static formatJSON(report: LabReport): string { + return JSON.stringify(report, null, 2); + } + + /** + * Format report as HTML + */ + public static formatHTML(report: LabReport): string { + return ` + + + + Lab Report: ${report.laboratoryName} + + + +
+

Laboratory Report: ${report.laboratoryName}

+
${report.timestamp.toISOString()}
+ +
+
+
${report.stats.totalExperiments}
+
Total Experiments
+
+
+
${report.stats.passed}
+
Passed
+
+
+
${report.stats.failed}
+
Failed
+
+
+
${report.stats.skipped}
+
Skipped
+
+
+
${(report.stats.successRate * 100).toFixed(1)}%
+
Success Rate
+
+
+
${report.duration}ms
+
Duration
+
+
+ +

Results

+ ${report.results + .map( + (result) => ` +
+
+ + ${result.success ? '✓ PASS' : '✗ FAIL'} + + ${result.experimentName} (${result.duration}ms) + ${result.retries !== undefined && result.retries > 0 ? `↺ ${result.retries} retries` : ''} +
+ ${result.hypotheses + .map( + (hyp) => ` +
+ ${hyp.passed ? '✓' : '✗'} ${hyp.name} + ${!hyp.passed && hyp.message !== undefined ? `
${hyp.message}
` : ''} +
+ `, + ) + .join('')} + ${result.error !== undefined ? `
${result.error.message}
` : ''} +
+ `, + ) + .join('')} + +
+ ${report.success ? 'ALL EXPERIMENTS PASSED ✓' : 'SOME EXPERIMENTS FAILED ✗'} +
+
+ + + `.trim(); + } + + /** + * Format report as Markdown + */ + public static formatMarkdown(report: LabReport): string { + const lines: string[] = []; + + // Header + lines.push(`# Laboratory Report: ${report.laboratoryName}`); + lines.push(''); + lines.push(`**Timestamp:** ${report.timestamp.toISOString()}`); + lines.push(''); + + // Summary + lines.push('## Summary'); + lines.push(''); + lines.push('| Metric | Value |'); + lines.push('|--------|-------|'); + lines.push(`| Total Experiments | ${report.stats.totalExperiments} |`); + lines.push(`| ✓ Passed | ${report.stats.passed} |`); + lines.push(`| ✗ Failed | ${report.stats.failed} |`); + lines.push(`| ⊘ Skipped | ${report.stats.skipped} |`); + lines.push(`| Success Rate | ${(report.stats.successRate * 100).toFixed(2)}% |`); + lines.push(`| Duration | ${report.duration}ms |`); + lines.push(''); + + // Results + lines.push('## Results'); + lines.push(''); + + for (const result of report.results) { + const status = result.success ? '✓ **PASS**' : '✗ **FAIL**'; + lines.push(`### ${status}: ${result.experimentName}`); + lines.push(''); + lines.push(`**Duration:** ${result.duration}ms`); + + if (result.retries !== undefined && result.retries > 0) { + lines.push(`**Retries:** ${result.retries}`); + } + + lines.push(''); + + // Hypotheses + if (result.hypotheses.length > 0) { + lines.push('**Hypotheses:**'); + lines.push(''); + + for (const hypothesis of result.hypotheses) { + const hypStatus = hypothesis.passed ? '✓' : '✗'; + lines.push(`- ${hypStatus} ${hypothesis.name}`); + + if (!hypothesis.passed && hypothesis.message !== undefined) { + lines.push(` - Error: \`${hypothesis.message}\``); + } + } + + lines.push(''); + } + + // Error + if (result.error !== undefined) { + lines.push('**Error:**'); + lines.push(''); + lines.push('```'); + lines.push(result.error.message); + lines.push('```'); + lines.push(''); + } + + lines.push('---'); + lines.push(''); + } + + // Footer + lines.push('## Status'); + lines.push(''); + lines.push(report.success ? '✓ **ALL EXPERIMENTS PASSED**' : '✗ **SOME EXPERIMENTS FAILED**'); + + return lines.join('\n'); + } + + /** + * Format report in specified format + */ + public static format(report: LabReport, format: ReportFormat): string { + switch (format) { + case 'text': + return this.formatText(report); + case 'json': + return this.formatJSON(report); + case 'html': + return this.formatHTML(report); + case 'markdown': + return this.formatMarkdown(report); + default: + throw new Error(`Unknown report format: ${String(format)}`); + } + } + + /** + * Get failed experiments + */ + public static getFailures(report: LabReport): ExperimentResult[] { + return report.results.filter((r) => !r.success); + } + + /** + * Get passed experiments + */ + public static getPasses(report: LabReport): ExperimentResult[] { + return report.results.filter((r) => r.success); + } + + /** + * Get slowest experiments + */ + public static getSlowest(report: LabReport, count: number = 5): ExperimentResult[] { + return [...report.results].sort((a, b) => b.duration - a.duration).slice(0, count); + } + + /** + * Get experiments with retries + */ + public static getRetried(report: LabReport): ExperimentResult[] { + return report.results.filter((r) => r.retries !== undefined && r.retries > 0); + } +} diff --git a/src/theater/laboratory/Laboratory.ts b/src/theater/laboratory/Laboratory.ts new file mode 100644 index 0000000..f7a4154 --- /dev/null +++ b/src/theater/laboratory/Laboratory.ts @@ -0,0 +1,556 @@ +/** + * Laboratory - Testing Environment + * + * The Laboratory provides a controlled environment for testing components. + * It orchestrates experiments, manages test subjects, validates hypotheses, + * and generates comprehensive lab reports. + */ + +import { EventEmitter } from 'events'; +import type { Stage } from '../core/Stage'; +import type { Experiment, ExperimentResult } from './Experiment'; +import type { LabReport } from './LabReport'; + +/** + * Laboratory configuration + */ +export interface LaboratoryConfig { + /** + * Laboratory name + */ + name?: string; + + /** + * Associated stage for rendering + */ + stage?: Stage; + + /** + * Enable parallel experiment execution + */ + parallel?: boolean; + + /** + * Maximum parallel experiments + */ + maxParallel?: number; + + /** + * Default timeout for experiments (ms) + */ + timeout?: number; + + /** + * Enable detailed logging + */ + verbose?: boolean; + + /** + * Auto-cleanup after experiments + */ + autoCleanup?: boolean; +} + +/** + * Laboratory state + */ +export type LaboratoryState = 'idle' | 'running' | 'paused' | 'completed' | 'failed'; + +/** + * Laboratory statistics + */ +export interface LaboratoryStats { + /** + * Total experiments + */ + totalExperiments: number; + + /** + * Passed experiments + */ + passed: number; + + /** + * Failed experiments + */ + failed: number; + + /** + * Skipped experiments + */ + skipped: number; + + /** + * Total duration (ms) + */ + duration: number; + + /** + * Success rate (0-1) + */ + successRate: number; +} + +/** + * Laboratory - Testing orchestrator + */ +export class Laboratory extends EventEmitter { + private name: string; + private stage: Stage | null = null; + private experiments: Map = new Map(); + private results: Map = new Map(); + private state: LaboratoryState = 'idle'; + private parallel: boolean = false; + private maxParallel: number = 5; + private timeout: number = 5000; + private verbose: boolean = false; + private autoCleanup: boolean = true; + private startTime: number = 0; + private endTime: number = 0; + private runningExperiments: Set = new Set(); + + constructor(config: LaboratoryConfig = {}) { + super(); + + this.name = config.name ?? 'Laboratory'; + + if (config.stage !== undefined) { + this.stage = config.stage; + } + + if (config.parallel !== undefined) { + this.parallel = config.parallel; + } + + if (config.maxParallel !== undefined) { + this.maxParallel = config.maxParallel; + } + + if (config.timeout !== undefined) { + this.timeout = config.timeout; + } + + if (config.verbose !== undefined) { + this.verbose = config.verbose; + } + + if (config.autoCleanup !== undefined) { + this.autoCleanup = config.autoCleanup; + } + } + + /** + * Get laboratory name + */ + public getName(): string { + return this.name; + } + + /** + * Get laboratory state + */ + public getState(): LaboratoryState { + return this.state; + } + + /** + * Set associated stage + */ + public setStage(stage: Stage): void { + this.stage = stage; + } + + /** + * Get associated stage + */ + public getStage(): Stage | null { + return this.stage; + } + + /** + * Register an experiment + */ + public registerExperiment(experiment: Experiment): void { + const id = experiment.getId(); + + if (this.experiments.has(id)) { + throw new Error(`Experiment already registered: ${id}`); + } + + this.experiments.set(id, experiment); + this.emit('experiment:registered', { id, name: experiment.getName() }); + + if (this.verbose) { + // eslint-disable-next-line no-console + console.log(`[Laboratory] Registered experiment: ${experiment.getName()}`); + } + } + + /** + * Unregister an experiment + */ + public unregisterExperiment(experimentId: string): void { + if (!this.experiments.has(experimentId)) { + return; + } + + this.experiments.delete(experimentId); + this.results.delete(experimentId); + this.emit('experiment:unregistered', { id: experimentId }); + + if (this.verbose) { + // eslint-disable-next-line no-console + console.log(`[Laboratory] Unregistered experiment: ${experimentId}`); + } + } + + /** + * Get an experiment + */ + public getExperiment(experimentId: string): Experiment | undefined { + return this.experiments.get(experimentId); + } + + /** + * Get all experiments + */ + public getAllExperiments(): Experiment[] { + return Array.from(this.experiments.values()); + } + + /** + * Run all experiments + */ + public async runAll(): Promise { + if (this.state === 'running') { + throw new Error('Laboratory is already running'); + } + + this.state = 'running'; + this.startTime = Date.now(); + this.results.clear(); + this.emit('started'); + + if (this.verbose) { + // eslint-disable-next-line no-console + console.log(`[Laboratory] Starting ${this.experiments.size} experiments...`); + } + + try { + if (this.parallel) { + await this.runParallel(); + } else { + await this.runSequential(); + } + + this.endTime = Date.now(); + this.state = 'completed'; + this.emit('completed'); + + const report = this.generateReport(); + + if (this.verbose) { + // eslint-disable-next-line no-console + console.log(`[Laboratory] Completed in ${report.duration}ms`); + // eslint-disable-next-line no-console + console.log( + `[Laboratory] Results: ${report.stats.passed}/${report.stats.totalExperiments} passed`, + ); + } + + if (this.autoCleanup) { + await this.cleanup(); + } + + return report; + } catch (error) { + this.state = 'failed'; + this.emit('failed', { error }); + + if (this.verbose) { + console.error('[Laboratory] Failed:', error); + } + + throw error; + } + } + + /** + * Run a specific experiment + */ + public async runExperiment(experimentId: string): Promise { + const experiment = this.experiments.get(experimentId); + + if (experiment === undefined) { + throw new Error(`Experiment not found: ${experimentId}`); + } + + if (this.verbose) { + // eslint-disable-next-line no-console + console.log(`[Laboratory] Running experiment: ${experiment.getName()}`); + } + + this.runningExperiments.add(experimentId); + this.emit('experiment:started', { id: experimentId, name: experiment.getName() }); + + try { + const result = await this.executeExperiment(experiment); + this.results.set(experimentId, result); + this.runningExperiments.delete(experimentId); + + this.emit('experiment:completed', { id: experimentId, result }); + + if (this.verbose) { + const status = result.success ? 'PASS' : 'FAIL'; + // eslint-disable-next-line no-console + console.log(`[Laboratory] ${status}: ${experiment.getName()} (${result.duration}ms)`); + } + + return result; + } catch (error) { + this.runningExperiments.delete(experimentId); + + const failedResult: ExperimentResult = { + experimentId, + experimentName: experiment.getName(), + success: false, + timestamp: new Date(), + duration: 0, + hypotheses: [], + error: error instanceof Error ? error : new Error(String(error)), + }; + + this.results.set(experimentId, failedResult); + this.emit('experiment:failed', { id: experimentId, error }); + + if (this.verbose) { + console.error(`[Laboratory] FAIL: ${experiment.getName()}`, error); + } + + return failedResult; + } + } + + /** + * Execute an experiment + */ + private async executeExperiment(experiment: Experiment): Promise { + const startTime = Date.now(); + + // Set stage if available + if (this.stage !== null) { + experiment.setStage(this.stage); + } + + // Run experiment with timeout + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + reject(new Error(`Experiment timeout: ${experiment.getName()}`)); + }, this.timeout); + }); + + const experimentPromise = experiment.run(); + + const result = await Promise.race([experimentPromise, timeoutPromise]); + + const duration = Date.now() - startTime; + + return { + ...result, + duration, + }; + } + + /** + * Run experiments sequentially + */ + private async runSequential(): Promise { + for (const experiment of this.experiments.values()) { + await this.runExperiment(experiment.getId()); + } + } + + /** + * Run experiments in parallel + */ + private async runParallel(): Promise { + const experimentIds = Array.from(this.experiments.keys()); + const batches: string[][] = []; + + // Create batches + for (let i = 0; i < experimentIds.length; i += this.maxParallel) { + batches.push(experimentIds.slice(i, i + this.maxParallel)); + } + + // Run batches + for (const batch of batches) { + await Promise.all(batch.map((id) => this.runExperiment(id))); + } + } + + /** + * Pause laboratory + */ + public pause(): void { + if (this.state !== 'running') { + throw new Error('Laboratory is not running'); + } + + this.state = 'paused'; + this.emit('paused'); + + if (this.verbose) { + // eslint-disable-next-line no-console + console.log('[Laboratory] Paused'); + } + } + + /** + * Resume laboratory + */ + public resume(): void { + if (this.state !== 'paused') { + throw new Error('Laboratory is not paused'); + } + + this.state = 'running'; + this.emit('resumed'); + + if (this.verbose) { + // eslint-disable-next-line no-console + console.log('[Laboratory] Resumed'); + } + } + + /** + * Stop laboratory + */ + public stop(): void { + this.state = 'idle'; + this.runningExperiments.clear(); + this.emit('stopped'); + + if (this.verbose) { + // eslint-disable-next-line no-console + console.log('[Laboratory] Stopped'); + } + } + + /** + * Get experiment result + */ + public getResult(experimentId: string): ExperimentResult | undefined { + return this.results.get(experimentId); + } + + /** + * Get all results + */ + public getAllResults(): ExperimentResult[] { + return Array.from(this.results.values()); + } + + /** + * Get statistics + */ + public getStats(): LaboratoryStats { + const results = Array.from(this.results.values()); + const totalExperiments = results.length; + const passed = results.filter((r) => r.success).length; + const failed = results.filter((r) => !r.success).length; + const skipped = this.experiments.size - results.length; + const duration = this.endTime - this.startTime; + const successRate = totalExperiments > 0 ? passed / totalExperiments : 0; + + return { + totalExperiments, + passed, + failed, + skipped, + duration, + successRate, + }; + } + + /** + * Generate lab report + */ + public generateReport(): LabReport { + const stats = this.getStats(); + const results = Array.from(this.results.values()); + + return { + laboratoryName: this.name, + timestamp: new Date(), + stats, + results, + duration: stats.duration, + success: stats.failed === 0 && stats.skipped === 0, + }; + } + + /** + * Clear all experiments and results + */ + public clear(): void { + this.experiments.clear(); + this.results.clear(); + this.runningExperiments.clear(); + this.emit('cleared'); + + if (this.verbose) { + // eslint-disable-next-line no-console + console.log('[Laboratory] Cleared'); + } + } + + /** + * Cleanup laboratory + */ + public async cleanup(): Promise { + // Cleanup all experiments + for (const experiment of this.experiments.values()) { + await experiment.cleanup(); + } + + this.emit('cleaned-up'); + + if (this.verbose) { + // eslint-disable-next-line no-console + console.log('[Laboratory] Cleaned up'); + } + } + + /** + * Check if laboratory is running + */ + public isRunning(): boolean { + return this.state === 'running'; + } + + /** + * Check if laboratory is paused + */ + public isPaused(): boolean { + return this.state === 'paused'; + } + + /** + * Export laboratory data + */ + public export(): { + name: string; + state: LaboratoryState; + experiments: number; + results: number; + stats: LaboratoryStats; + } { + return { + name: this.name, + state: this.state, + experiments: this.experiments.size, + results: this.results.size, + stats: this.getStats(), + }; + } +} diff --git a/src/theater/laboratory/TestSubject.ts b/src/theater/laboratory/TestSubject.ts new file mode 100644 index 0000000..534b934 --- /dev/null +++ b/src/theater/laboratory/TestSubject.ts @@ -0,0 +1,478 @@ +/** + * TestSubject - Component Wrapper for Testing + * + * TestSubject wraps a VisualNeuron component for testing, + * providing helpers for inspecting state, props, render output, + * and simulating interactions. + */ + +import type { VisualNeuron, ComponentProps } from '../../ui/VisualNeuron'; +import type { Stage } from '../core/Stage'; + +/** + * Test subject configuration + */ +export interface TestSubjectConfig { + /** + * Component to test + */ + component: VisualNeuron; + + /** + * Initial props + */ + initialProps?: Partial; + + /** + * Stage for rendering (currently not used in test implementation) + */ + _stage?: Stage; + + /** + * Auto-mount on creation + */ + autoMount?: boolean; +} + +/** + * Interaction simulation + */ +export interface Interaction { + /** + * Interaction type + */ + type: 'click' | 'input' | 'focus' | 'blur' | 'keydown' | 'keyup' | 'custom'; + + /** + * Target element selector (if applicable) + */ + target?: string; + + /** + * Interaction data + */ + data?: unknown; + + /** + * Delay before interaction (ms) + */ + delay?: number; +} + +/** + * TestSubject - Component testing wrapper + */ +export class TestSubject { + private component: VisualNeuron; + // stage is configured but not used in current test implementation + private mounted: boolean = false; + private renderCount: number = 0; + private lastRenderOutput: string = ''; + private interactions: Interaction[] = []; + + constructor(config: TestSubjectConfig) { + this.component = config.component; + + // Stage is currently not used in test implementation + // Would be used for actual DOM rendering + + if (config.initialProps !== undefined) { + this.component.updateProps(config.initialProps); + } + + if (config.autoMount === true) { + void this.mount(); + } + } + + /** + * Get the wrapped component + */ + public getComponent(): VisualNeuron { + return this.component; + } + + /** + * Mount the component + */ + public async mount(): Promise { + if (this.mounted) { + return; + } + + await this.component.activate(); + + // Note: Stage.mount() expects HTMLElement, not VisualNeuron + // In a real implementation, this would mount the rendered output to the DOM + // For testing purposes, we just activate the component + + this.mounted = true; + this.render(); + } + + /** + * Unmount the component + */ + public async unmount(): Promise { + if (!this.mounted) { + return; + } + + // Note: Stage.unmount() would be called in a real implementation + // For testing, we just deactivate the component + + await this.component.deactivate(); + this.mounted = false; + } + + /** + * Re-render the component + */ + public render(): string { + const renderSignal = this.component.render(); + // Convert RenderSignal to string for testing purposes + this.lastRenderOutput = this.convertRenderSignalToString(renderSignal); + this.renderCount++; + return this.lastRenderOutput; + } + + /** + * Convert RenderSignal to string representation + */ + private convertRenderSignalToString(signal: { + type: string; + data: { vdom: unknown; styles?: unknown; metadata?: unknown }; + }): string { + // Convert VDOM to HTML string for testing + return this.vdomToString(signal.data.vdom); + } + + /** + * Convert VDOM node to HTML string + */ + private vdomToString(vdom: unknown): string { + if (typeof vdom === 'string') { + return vdom; + } + + if (typeof vdom !== 'object' || vdom === null) { + return ''; + } + + const node = vdom as { tag?: string; props?: Record; children?: unknown[] }; + + if (node.tag === undefined) { + return ''; + } + + const { tag, props, children } = node; + + // Build attributes + const attrs = props !== undefined ? this.propsToAttributes(props) : ''; + + // Build children + const childrenHTML = + children !== undefined ? children.map((child) => this.vdomToString(child)).join('') : ''; + + return `<${tag}${attrs}>${childrenHTML}`; + } + + /** + * Convert props object to HTML attributes + */ + private propsToAttributes(props: Record): string { + const entries = Object.entries(props); + + if (entries.length === 0) { + return ''; + } + + const attrs = entries + .map(([key, value]) => { + // Handle className specially + const attrName = key === 'className' ? 'class' : key; + return `${attrName}="${String(value)}"`; + }) + .join(' '); + + return ` ${attrs}`; + } + + /** + * Get current render output + */ + public getRenderOutput(): string { + return this.lastRenderOutput; + } + + /** + * Get render count + */ + public getRenderCount(): number { + return this.renderCount; + } + + /** + * Get component props + */ + public getProps(): Partial { + // Access props through component's public interface + return { ...this.component.getProps() } as Partial; + } + + /** + * Update component props + */ + public setProps(props: Partial): void { + this.component.updateProps(props); + this.render(); + } + + /** + * Get component state + */ + public getState(): Record { + // Access state through component's public interface + return { ...this.component.getState() }; + } + + /** + * Update component state + */ + public setState(state: Partial>): void { + // Use setState method from VisualNeuron + this.component.setState(state); + this.render(); + } + + /** + * Check if component is mounted + */ + public isMounted(): boolean { + return this.mounted; + } + + /** + * Check if component is active + */ + public isActive(): boolean { + return this.component.state === 'active'; + } + + /** + * Simulate an interaction + */ + public async interact(interaction: Interaction): Promise { + this.interactions.push(interaction); + + // Delay if specified + if (interaction.delay !== undefined && interaction.delay > 0) { + await new Promise((resolve) => setTimeout(resolve, interaction.delay)); + } + + // Handle different interaction types + switch (interaction.type) { + case 'click': + await this.simulateClick(interaction); + break; + case 'input': + await this.simulateInput(interaction); + break; + case 'focus': + await this.simulateFocus(); + break; + case 'blur': + await this.simulateBlur(); + break; + case 'keydown': + case 'keyup': + await this.simulateKeyboard(interaction); + break; + case 'custom': + // Custom interactions handled by user + break; + } + + // Re-render after interaction + this.render(); + } + + /** + * Simulate click interaction + */ + private async simulateClick(_interaction: Interaction): Promise { + // Emit a signal to the component simulating a click + // In a real implementation, this would interact with the DOM + // For testing, we just trigger a re-render + // Note: The actual signal sending would require proper Signal type matching + await Promise.resolve(); // Placeholder for interaction logic + } + + /** + * Simulate input interaction + */ + private async simulateInput(_interaction: Interaction): Promise { + // Simulate input by updating state or props + // Placeholder for actual interaction logic + await Promise.resolve(); + } + + /** + * Simulate focus interaction + */ + private async simulateFocus(): Promise { + // Simulate focus event + // Placeholder for actual interaction logic + await Promise.resolve(); + } + + /** + * Simulate blur interaction + */ + private async simulateBlur(): Promise { + // Simulate blur event + // Placeholder for actual interaction logic + await Promise.resolve(); + } + + /** + * Simulate keyboard interaction + */ + private async simulateKeyboard(_interaction: Interaction): Promise { + // Simulate keyboard event + // Placeholder for actual interaction logic + await Promise.resolve(); + } + + /** + * Get interaction history + */ + public getInteractions(): Interaction[] { + return [...this.interactions]; + } + + /** + * Clear interaction history + */ + public clearInteractions(): void { + this.interactions = []; + } + + /** + * Wait for next render + */ + public async waitForRender(timeout: number = 1000): Promise { + const startRenderCount = this.renderCount; + + return new Promise((resolve, reject) => { + const checkInterval = setInterval(() => { + if (this.renderCount > startRenderCount) { + clearInterval(checkInterval); + clearTimeout(timeoutHandle); + resolve(this.lastRenderOutput); + } + }, 10); + + const timeoutHandle = setTimeout(() => { + clearInterval(checkInterval); + reject(new Error('Timeout waiting for render')); + }, timeout); + }); + } + + /** + * Wait for specific condition + */ + public async waitFor( + condition: () => boolean, + options: { timeout?: number; interval?: number } = {}, + ): Promise { + const timeout = options.timeout ?? 1000; + const interval = options.interval ?? 10; + + return new Promise((resolve, reject) => { + const checkInterval = setInterval(() => { + if (condition()) { + clearInterval(checkInterval); + clearTimeout(timeoutHandle); + resolve(); + } + }, interval); + + const timeoutHandle = setTimeout(() => { + clearInterval(checkInterval); + reject(new Error('Timeout waiting for condition')); + }, timeout); + }); + } + + /** + * Find element in render output + */ + public find(selector: string): boolean { + // Simple selector matching (could be enhanced with real DOM querying) + return this.lastRenderOutput.includes(selector); + } + + /** + * Find all elements matching selector + */ + public findAll(selector: string): string[] { + // Simple implementation - could be enhanced + const matches: string[] = []; + const regex = new RegExp(selector, 'g'); + let match; + + while ((match = regex.exec(this.lastRenderOutput)) !== null) { + matches.push(match[0]); + } + + return matches; + } + + /** + * Get text content from render output + */ + public getText(): string { + // Strip HTML tags to get text content + return this.lastRenderOutput.replace(/<[^>]*>/g, ''); + } + + /** + * Take a snapshot of current state + */ + public snapshot(): { + props: Partial; + state: Record; + renderOutput: string; + mounted: boolean; + active: boolean; + renderCount: number; + } { + return { + props: this.getProps(), + state: this.getState(), + renderOutput: this.lastRenderOutput, + mounted: this.mounted, + active: this.isActive(), + renderCount: this.renderCount, + }; + } + + /** + * Reset test subject + */ + public async reset(): Promise { + await this.unmount(); + this.renderCount = 0; + this.lastRenderOutput = ''; + this.interactions = []; + } + + /** + * Cleanup test subject + */ + public async cleanup(): Promise { + await this.unmount(); + } +} diff --git a/src/theater/laboratory/index.ts b/src/theater/laboratory/index.ts new file mode 100644 index 0000000..c4328e4 --- /dev/null +++ b/src/theater/laboratory/index.ts @@ -0,0 +1,27 @@ +/** + * Laboratory - Testing Environment + * + * The Laboratory module provides a comprehensive testing environment + * for components within The Anatomy Theater. It includes experiment + * management, test subjects, hypothesis validation, and reporting. + */ + +// Laboratory +export { Laboratory } from './Laboratory'; +export type { LaboratoryConfig, LaboratoryState, LaboratoryStats } from './Laboratory'; + +// Experiment +export { Experiment } from './Experiment'; +export type { ExperimentConfig, ExperimentResult, ExperimentState } from './Experiment'; + +// TestSubject +export { TestSubject } from './TestSubject'; +export type { TestSubjectConfig, Interaction } from './TestSubject'; + +// Hypothesis +export { Hypothesis } from './Hypothesis'; +export type { HypothesisResult, AssertionFn, MatcherFn } from './Hypothesis'; + +// LabReport +export { LabReporter } from './LabReport'; +export type { LabReport, ReportFormat } from './LabReport'; diff --git a/src/theater/server/HotReload.ts b/src/theater/server/HotReload.ts new file mode 100644 index 0000000..17999a6 --- /dev/null +++ b/src/theater/server/HotReload.ts @@ -0,0 +1,326 @@ +/** + * HotReload - File Watching and Live Reload System + * + * The HotReload system watches files for changes and triggers reload events + * to keep the development environment synchronized with source code changes. + * + * Medical Metaphor: Like a monitoring system in a medical theater that + * continuously tracks vital signs and alerts observers to any changes. + */ + +import { EventEmitter } from 'events'; + +/** + * Watch pattern + */ +export interface WatchPattern { + /** Pattern to watch (glob) */ + pattern: string; + + /** File types to watch */ + extensions?: string[]; + + /** Ignore patterns */ + ignore?: string[]; +} + +/** + * File change event + */ +export interface FileChangeEvent { + /** Event type */ + type: 'added' | 'changed' | 'removed'; + + /** File path */ + path: string; + + /** Change timestamp */ + timestamp: number; + + /** File size (for added/changed) */ + size?: number; +} + +/** + * Hot reload configuration + */ +export interface HotReloadConfig { + /** Watch patterns */ + patterns?: WatchPattern[]; + + /** Debounce delay in ms */ + debounce?: number; + + /** Enable file watching */ + enabled?: boolean; + + /** Ignore patterns */ + ignore?: string[]; + + /** Verbose logging */ + verbose?: boolean; +} + +/** + * Watch statistics + */ +export interface WatchStatistics { + /** Total file changes detected */ + totalChanges: number; + + /** Changes by type */ + byType: { + added: number; + changed: number; + removed: number; + }; + + /** Files being watched */ + watchedFiles: number; + + /** Last change timestamp */ + lastChange: number; +} + +/** + * HotReload - File Watching System + * + * @example + * ```typescript + * const hotReload = new HotReload({ + * patterns: [ + * { pattern: 'src/specimens/**\/*.ts' }, + * { pattern: 'src/components/**\/*.ts' } + * ], + * debounce: 300 + * }); + * + * hotReload.on('change', (event) => { + * console.log(`File ${event.type}: ${event.path}`); + * server.triggerReload(event.path); + * }); + * + * await hotReload.start(); + * ``` + */ +export class HotReload extends EventEmitter { + private readonly config: Required; + private watching: boolean = false; + private statistics: WatchStatistics; + private debounceTimers: Map = new Map(); + private watchedFiles: Set = new Set(); + + constructor(config: HotReloadConfig = {}) { + super(); + + this.config = { + patterns: config.patterns ?? [], + debounce: config.debounce ?? 300, + enabled: config.enabled ?? true, + ignore: config.ignore ?? ['node_modules/**', '**/*.test.ts', '**/*.spec.ts'], + verbose: config.verbose ?? false, + }; + + this.statistics = { + totalChanges: 0, + byType: { + added: 0, + changed: 0, + removed: 0, + }, + watchedFiles: 0, + lastChange: 0, + }; + } + + /** + * Start watching files + */ + // eslint-disable-next-line @typescript-eslint/require-await + public async start(): Promise { + if (this.watching) { + throw new Error('Hot reload is already watching'); + } + + if (!this.config.enabled) { + return; + } + + this.watching = true; + this.emit('started'); + + if (this.config.verbose) { + this.log('Hot reload started'); + this.log(`Watching ${this.config.patterns.length} patterns`); + } + + // In a real implementation, would set up file watchers using chokidar or similar + // For now, just emit started event + } + + /** + * Stop watching files + */ + // eslint-disable-next-line @typescript-eslint/require-await + public async stop(): Promise { + if (!this.watching) { + return; + } + + this.watching = false; + + // Clear all debounce timers + this.debounceTimers.forEach((timer) => clearTimeout(timer)); + this.debounceTimers.clear(); + + this.emit('stopped'); + + if (this.config.verbose) { + this.log('Hot reload stopped'); + } + } + + /** + * Add watch pattern + */ + public addPattern(pattern: WatchPattern): void { + this.config.patterns.push(pattern); + + if (this.watching) { + // In real implementation, would add new watcher + this.emit('pattern:added', { pattern }); + } + } + + /** + * Remove watch pattern + */ + public removePattern(pattern: string): void { + const index = this.config.patterns.findIndex((p) => p.pattern === pattern); + if (index !== -1) { + this.config.patterns.splice(index, 1); + this.emit('pattern:removed', { pattern }); + } + } + + /** + * Handle file change + */ + public handleChange(event: FileChangeEvent): void { + // Update statistics + this.statistics.totalChanges++; + this.statistics.byType[event.type]++; + this.statistics.lastChange = event.timestamp; + + // Update watched files + if (event.type === 'added') { + this.watchedFiles.add(event.path); + } else if (event.type === 'removed') { + this.watchedFiles.delete(event.path); + } + + this.statistics.watchedFiles = this.watchedFiles.size; + + // Debounce the change event + this.debounceChange(event); + } + + /** + * Debounce file change event + */ + private debounceChange(event: FileChangeEvent): void { + const { path } = event; + + // Clear existing timer for this file + const existingTimer = this.debounceTimers.get(path); + if (existingTimer !== undefined) { + clearTimeout(existingTimer); + } + + // Set new timer + const timer = setTimeout(() => { + this.debounceTimers.delete(path); + this.emit('change', event); + + if (this.config.verbose) { + this.log(`File ${event.type}: ${event.path}`); + } + }, this.config.debounce); + + this.debounceTimers.set(path, timer); + } + + /** + * Check if file matches ignore patterns + */ + public shouldIgnore(path: string): boolean { + return this.config.ignore.some((pattern) => { + // Simple pattern matching (in real impl, would use minimatch or similar) + const regex = new RegExp(pattern.replace(/\*\*/g, '.*').replace(/\*/g, '[^/]*')); + return regex.test(path); + }); + } + + /** + * Get watch statistics + */ + public getStatistics(): WatchStatistics { + return { ...this.statistics }; + } + + /** + * Get watched files + */ + public getWatchedFiles(): string[] { + return Array.from(this.watchedFiles); + } + + /** + * Check if watching + */ + public isWatching(): boolean { + return this.watching; + } + + /** + * Clear statistics + */ + public clearStatistics(): void { + this.statistics = { + totalChanges: 0, + byType: { + added: 0, + changed: 0, + removed: 0, + }, + watchedFiles: this.watchedFiles.size, + lastChange: 0, + }; + } + + /** + * Log message + */ + private log(message: string): void { + // eslint-disable-next-line no-console + console.log(`[HotReload] ${message}`); + } + + /** + * Trigger manual reload + */ + public triggerReload(path: string, reason: string = 'Manual trigger'): void { + const event: FileChangeEvent = { + type: 'changed', + path, + timestamp: Date.now(), + }; + + this.emit('change', event); + this.emit('manual:reload', { path, reason }); + + if (this.config.verbose) { + this.log(`Manual reload: ${path} (${reason})`); + } + } +} diff --git a/src/theater/server/TheaterServer.ts b/src/theater/server/TheaterServer.ts new file mode 100644 index 0000000..e1d73f1 --- /dev/null +++ b/src/theater/server/TheaterServer.ts @@ -0,0 +1,346 @@ +/** + * TheaterServer - Development Server + * + * The TheaterServer provides a development server for The Anatomy Theater, + * serving the UI, handling hot reload, and providing real-time updates. + * + * Medical Metaphor: Like a medical theater's infrastructure that supports + * live demonstrations and observations with proper lighting, seating, and + * viewing capabilities. + */ + +import { EventEmitter } from 'events'; +import type { Server } from 'http'; + +/** + * Server configuration + */ +export interface ServerConfig { + /** Server port */ + port?: number; + + /** Host address */ + host?: string; + + /** Enable hot reload */ + hotReload?: boolean; + + /** WebSocket port (defaults to port + 1) */ + wsPort?: number; + + /** Specimen directory */ + specimenDir?: string; + + /** Public directory for static assets */ + publicDir?: string; + + /** Enable CORS */ + cors?: boolean; + + /** Enable verbose logging */ + verbose?: boolean; +} + +/** + * Server state + */ +export type ServerState = 'stopped' | 'starting' | 'running' | 'stopping' | 'error'; + +/** + * Server statistics + */ +export interface ServerStatistics { + /** Total requests handled */ + totalRequests: number; + + /** Active WebSocket connections */ + activeConnections: number; + + /** Hot reload triggers */ + reloadCount: number; + + /** Uptime in milliseconds */ + uptime: number; + + /** Server start time */ + startTime: number; +} + +/** + * Request information + */ +export interface RequestInfo { + /** Request method */ + method: string; + + /** Request path */ + path: string; + + /** Request timestamp */ + timestamp: number; + + /** Response status code */ + statusCode?: number; + + /** Response time in ms */ + responseTime?: number; +} + +/** + * TheaterServer - Development Server + * + * @example + * ```typescript + * const server = new TheaterServer({ + * port: 6006, + * hotReload: true, + * specimenDir: './src/specimens' + * }); + * + * await server.start(); + * + * // Server is running... + * + * await server.stop(); + * ``` + */ +export class TheaterServer extends EventEmitter { + private readonly config: Required; + private state: ServerState = 'stopped'; + // @ts-expect-error - Server placeholder for future HTTP server implementation + private _server: Server | null = null; + private statistics: ServerStatistics; + private requests: RequestInfo[] = []; + + constructor(config: ServerConfig = {}) { + super(); + + this.config = { + port: config.port ?? 6006, + host: config.host ?? 'localhost', + hotReload: config.hotReload ?? true, + wsPort: config.wsPort ?? (config.port ?? 6006) + 1, + specimenDir: config.specimenDir ?? './specimens', + publicDir: config.publicDir ?? './public', + cors: config.cors ?? true, + verbose: config.verbose ?? false, + }; + + this.statistics = { + totalRequests: 0, + activeConnections: 0, + reloadCount: 0, + uptime: 0, + startTime: 0, + }; + } + + /** + * Start the server + */ + // eslint-disable-next-line @typescript-eslint/require-await + public async start(): Promise { + if (this.state !== 'stopped') { + throw new Error(`Cannot start server in ${this.state} state`); + } + + this.state = 'starting'; + this.emit('state:change', { from: 'stopped', to: 'starting' }); + + try { + // Create HTTP server (placeholder - would use actual HTTP module) + // For now, just simulate server creation + this._server = {} as Server; + + this.statistics.startTime = Date.now(); + this.state = 'running'; + + this.emit('started', { + port: this.config.port, + host: this.config.host, + url: `http://${this.config.host}:${this.config.port}`, + }); + + this.emit('state:change', { from: 'starting', to: 'running' }); + + if (this.config.verbose) { + this.log(`Server started on http://${this.config.host}:${this.config.port}`); + } + } catch (error) { + this.state = 'error'; + this.emit('error', error); + throw error; + } + } + + /** + * Stop the server + */ + // eslint-disable-next-line @typescript-eslint/require-await + public async stop(): Promise { + if (this.state !== 'running') { + throw new Error(`Cannot stop server in ${this.state} state`); + } + + this.state = 'stopping'; + this.emit('state:change', { from: 'running', to: 'stopping' }); + + try { + // Close server (placeholder) + this._server = null; + + this.state = 'stopped'; + this.emit('stopped'); + this.emit('state:change', { from: 'stopping', to: 'stopped' }); + + if (this.config.verbose) { + this.log('Server stopped'); + } + } catch (error) { + this.state = 'error'; + this.emit('error', error); + throw error; + } + } + + /** + * Restart the server + */ + public async restart(): Promise { + if (this.state === 'running') { + await this.stop(); + } + await this.start(); + } + + /** + * Get server state + */ + public getState(): ServerState { + return this.state; + } + + /** + * Get server configuration + */ + public getConfig(): Readonly> { + return { ...this.config }; + } + + /** + * Get server statistics + */ + public getStatistics(): ServerStatistics { + const uptime = this.state === 'running' ? Date.now() - this.statistics.startTime : 0; + + return { + ...this.statistics, + uptime, + }; + } + + /** + * Get recent requests + */ + public getRequests(limit: number = 100): RequestInfo[] { + return this.requests.slice(-limit); + } + + /** + * Record a request + */ + public recordRequest(request: RequestInfo): void { + this.requests.push(request); + this.statistics.totalRequests++; + + // Limit request history + if (this.requests.length > 1000) { + this.requests = this.requests.slice(-1000); + } + + this.emit('request', request); + + if (this.config.verbose) { + this.log(`${request.method} ${request.path} - ${request.statusCode ?? 'pending'}`); + } + } + + /** + * Increment active connections + */ + public incrementConnections(): void { + this.statistics.activeConnections++; + this.emit('connection:opened', { + active: this.statistics.activeConnections, + }); + } + + /** + * Decrement active connections + */ + public decrementConnections(): void { + this.statistics.activeConnections = Math.max(0, this.statistics.activeConnections - 1); + this.emit('connection:closed', { + active: this.statistics.activeConnections, + }); + } + + /** + * Trigger hot reload + */ + public triggerReload(reason: string = 'File changed'): void { + if (!this.config.hotReload) { + return; + } + + this.statistics.reloadCount++; + this.emit('reload', { reason, count: this.statistics.reloadCount }); + + if (this.config.verbose) { + this.log(`Hot reload triggered: ${reason}`); + } + } + + /** + * Get server URL + */ + public getUrl(): string { + return `http://${this.config.host}:${this.config.port}`; + } + + /** + * Get WebSocket URL + */ + public getWebSocketUrl(): string { + return `ws://${this.config.host}:${this.config.wsPort}`; + } + + /** + * Check if server is running + */ + public isRunning(): boolean { + return this.state === 'running'; + } + + /** + * Log message + */ + private log(message: string): void { + // eslint-disable-next-line no-console + console.log(`[TheaterServer] ${message}`); + } + + /** + * Clear statistics + */ + public clearStatistics(): void { + this.statistics = { + totalRequests: 0, + activeConnections: this.statistics.activeConnections, // Keep active connections + reloadCount: 0, + uptime: 0, + startTime: this.statistics.startTime, + }; + this.requests = []; + } +} diff --git a/src/theater/server/WebSocketBridge.ts b/src/theater/server/WebSocketBridge.ts new file mode 100644 index 0000000..5911c75 --- /dev/null +++ b/src/theater/server/WebSocketBridge.ts @@ -0,0 +1,486 @@ +/** + * WebSocketBridge - Real-time Communication + * + * The WebSocketBridge provides bidirectional real-time communication + * between the Theater server and client browsers for hot reload, + * state synchronization, and live updates. + * + * Medical Metaphor: Like a neural communication system that transmits + * signals instantly between the observation theater and monitoring systems. + */ + +import { EventEmitter } from 'events'; + +/** + * WebSocket message type + */ +export type MessageType = + | 'reload' + | 'update' + | 'ping' + | 'pong' + | 'subscribe' + | 'unsubscribe' + | 'broadcast'; + +/** + * WebSocket message + */ +export interface WebSocketMessage { + /** Message type */ + type: MessageType; + + /** Message payload */ + payload: unknown; + + /** Message timestamp */ + timestamp: number; + + /** Message ID */ + id?: string; +} + +/** + * Client connection + */ +export interface ClientConnection { + /** Client ID */ + id: string; + + /** Connection timestamp */ + connectedAt: number; + + /** Last activity timestamp */ + lastActivity: number; + + /** Subscribed channels */ + channels: Set; + + /** Client metadata */ + metadata: Record; +} + +/** + * WebSocket bridge configuration + */ +export interface WebSocketConfig { + /** WebSocket port */ + port?: number; + + /** Host address */ + host?: string; + + /** Heartbeat interval in ms */ + heartbeat?: number; + + /** Connection timeout in ms */ + timeout?: number; + + /** Enable verbose logging */ + verbose?: boolean; +} + +/** + * Bridge statistics + */ +export interface BridgeStatistics { + /** Total connections */ + totalConnections: number; + + /** Active connections */ + activeConnections: number; + + /** Total messages sent */ + messagesSent: number; + + /** Total messages received */ + messagesReceived: number; + + /** Broadcast count */ + broadcastCount: number; + + /** Average latency in ms */ + averageLatency: number; +} + +/** + * WebSocketBridge - Real-time Communication + * + * @example + * ```typescript + * const bridge = new WebSocketBridge({ + * port: 6007, + * heartbeat: 30000 + * }); + * + * await bridge.start(); + * + * // Send reload message to all clients + * bridge.broadcast({ + * type: 'reload', + * payload: { reason: 'File changed' }, + * timestamp: Date.now() + * }); + * + * // Handle client messages + * bridge.on('message', (clientId, message) => { + * console.log(`Message from ${clientId}:`, message); + * }); + * ``` + */ +export class WebSocketBridge extends EventEmitter { + private readonly config: Required; + private clients: Map = new Map(); + private channels: Map> = new Map(); + private statistics: BridgeStatistics; + private running: boolean = false; + private heartbeatInterval: NodeJS.Timeout | null = null; + + constructor(config: WebSocketConfig = {}) { + super(); + + this.config = { + port: config.port ?? 6007, + host: config.host ?? 'localhost', + heartbeat: config.heartbeat ?? 30000, + timeout: config.timeout ?? 60000, + verbose: config.verbose ?? false, + }; + + this.statistics = { + totalConnections: 0, + activeConnections: 0, + messagesSent: 0, + messagesReceived: 0, + broadcastCount: 0, + averageLatency: 0, + }; + } + + /** + * Start the WebSocket bridge + */ + // eslint-disable-next-line @typescript-eslint/require-await + public async start(): Promise { + if (this.running) { + throw new Error('WebSocket bridge is already running'); + } + + this.running = true; + + // Start heartbeat + this.startHeartbeat(); + + this.emit('started', { + port: this.config.port, + host: this.config.host, + url: `ws://${this.config.host}:${this.config.port}`, + }); + + if (this.config.verbose) { + this.log(`WebSocket bridge started on ws://${this.config.host}:${this.config.port}`); + } + } + + /** + * Stop the WebSocket bridge + */ + // eslint-disable-next-line @typescript-eslint/require-await + public async stop(): Promise { + if (!this.running) { + return; + } + + this.running = false; + + // Stop heartbeat + if (this.heartbeatInterval !== null) { + clearInterval(this.heartbeatInterval); + this.heartbeatInterval = null; + } + + // Disconnect all clients + this.clients.forEach((_, clientId) => { + this.disconnectClient(clientId); + }); + + this.emit('stopped'); + + if (this.config.verbose) { + this.log('WebSocket bridge stopped'); + } + } + + /** + * Connect a client + */ + public connectClient(clientId: string, metadata: Record = {}): void { + const connection: ClientConnection = { + id: clientId, + connectedAt: Date.now(), + lastActivity: Date.now(), + channels: new Set(), + metadata, + }; + + this.clients.set(clientId, connection); + this.statistics.totalConnections++; + this.statistics.activeConnections++; + + this.emit('client:connected', { clientId, metadata }); + + if (this.config.verbose) { + this.log(`Client connected: ${clientId}`); + } + } + + /** + * Disconnect a client + */ + public disconnectClient(clientId: string): void { + const client = this.clients.get(clientId); + if (client === undefined) { + return; + } + + // Unsubscribe from all channels + client.channels.forEach((channel) => { + this.unsubscribeFromChannel(clientId, channel); + }); + + this.clients.delete(clientId); + this.statistics.activeConnections = Math.max(0, this.statistics.activeConnections - 1); + + this.emit('client:disconnected', { clientId }); + + if (this.config.verbose) { + this.log(`Client disconnected: ${clientId}`); + } + } + + /** + * Send message to a specific client + */ + public sendToClient(clientId: string, message: WebSocketMessage): void { + const client = this.clients.get(clientId); + if (client === undefined) { + return; + } + + client.lastActivity = Date.now(); + this.statistics.messagesSent++; + + this.emit('message:sent', { clientId, message }); + + if (this.config.verbose) { + this.log(`Message sent to ${clientId}: ${message.type}`); + } + } + + /** + * Broadcast message to all clients + */ + public broadcast(message: WebSocketMessage, excludeClient?: string): void { + this.clients.forEach((_, clientId) => { + if (clientId !== excludeClient) { + this.sendToClient(clientId, message); + } + }); + + this.statistics.broadcastCount++; + this.emit('broadcast', { message, excludeClient }); + } + + /** + * Broadcast to a specific channel + */ + public broadcastToChannel(channel: string, message: WebSocketMessage): void { + const subscribers = this.channels.get(channel); + if (subscribers === undefined) { + return; + } + + subscribers.forEach((clientId) => { + this.sendToClient(clientId, message); + }); + + this.emit('channel:broadcast', { channel, message, subscribers: subscribers.size }); + } + + /** + * Subscribe client to channel + */ + public subscribeToChannel(clientId: string, channel: string): void { + const client = this.clients.get(clientId); + if (client === undefined) { + return; + } + + // Add client to channel + if (!this.channels.has(channel)) { + this.channels.set(channel, new Set()); + } + this.channels.get(channel)?.add(clientId); + + // Add channel to client + client.channels.add(channel); + + this.emit('channel:subscribed', { clientId, channel }); + + if (this.config.verbose) { + this.log(`Client ${clientId} subscribed to ${channel}`); + } + } + + /** + * Unsubscribe client from channel + */ + public unsubscribeFromChannel(clientId: string, channel: string): void { + const client = this.clients.get(clientId); + if (client === undefined) { + return; + } + + // Remove client from channel + const subscribers = this.channels.get(channel); + if (subscribers !== undefined) { + subscribers.delete(clientId); + if (subscribers.size === 0) { + this.channels.delete(channel); + } + } + + // Remove channel from client + client.channels.delete(channel); + + this.emit('channel:unsubscribed', { clientId, channel }); + } + + /** + * Handle incoming message from client + */ + public handleMessage(clientId: string, message: WebSocketMessage): void { + const client = this.clients.get(clientId); + if (client === undefined) { + return; + } + + client.lastActivity = Date.now(); + this.statistics.messagesReceived++; + + this.emit('message:received', { clientId, message }); + + // Handle special message types + switch (message.type) { + case 'ping': + this.sendToClient(clientId, { + type: 'pong', + payload: message.payload, + timestamp: Date.now(), + }); + break; + + case 'subscribe': + if (typeof message.payload === 'string') { + this.subscribeToChannel(clientId, message.payload); + } + break; + + case 'unsubscribe': + if (typeof message.payload === 'string') { + this.unsubscribeFromChannel(clientId, message.payload); + } + break; + + default: + this.emit('message', { clientId, message }); + } + } + + /** + * Get client connection + */ + public getClient(clientId: string): ClientConnection | undefined { + return this.clients.get(clientId); + } + + /** + * Get all connected clients + */ + public getClients(): ClientConnection[] { + return Array.from(this.clients.values()); + } + + /** + * Get channel subscribers + */ + public getChannelSubscribers(channel: string): string[] { + return Array.from(this.channels.get(channel) ?? []); + } + + /** + * Get all channels + */ + public getChannels(): string[] { + return Array.from(this.channels.keys()); + } + + /** + * Get bridge statistics + */ + public getStatistics(): BridgeStatistics { + return { ...this.statistics }; + } + + /** + * Check if running + */ + public isRunning(): boolean { + return this.running; + } + + /** + * Start heartbeat + */ + private startHeartbeat(): void { + this.heartbeatInterval = setInterval(() => { + const now = Date.now(); + + this.clients.forEach((client, clientId) => { + // Send ping + this.sendToClient(clientId, { + type: 'ping', + payload: null, + timestamp: now, + }); + + // Check for timeout + if (now - client.lastActivity > this.config.timeout) { + this.disconnectClient(clientId); + } + }); + }, this.config.heartbeat); + } + + /** + * Log message + */ + private log(message: string): void { + // eslint-disable-next-line no-console + console.log(`[WebSocketBridge] ${message}`); + } + + /** + * Clear statistics + */ + public clearStatistics(): void { + this.statistics = { + totalConnections: this.statistics.totalConnections, + activeConnections: this.clients.size, + messagesSent: 0, + messagesReceived: 0, + broadcastCount: 0, + averageLatency: 0, + }; + } +} diff --git a/src/theater/server/index.ts b/src/theater/server/index.ts new file mode 100644 index 0000000..c976a1a --- /dev/null +++ b/src/theater/server/index.ts @@ -0,0 +1,24 @@ +/** + * Server Module - Development Server and Hot Reload + * + * The server module provides development server capabilities with hot reload, + * WebSocket communication, and real-time updates for The Anatomy Theater. + */ + +// TheaterServer +export { TheaterServer } from './TheaterServer'; +export type { ServerConfig, ServerState, ServerStatistics, RequestInfo } from './TheaterServer'; + +// HotReload +export { HotReload } from './HotReload'; +export type { WatchPattern, FileChangeEvent, HotReloadConfig, WatchStatistics } from './HotReload'; + +// WebSocketBridge +export { WebSocketBridge } from './WebSocketBridge'; +export type { + MessageType, + WebSocketMessage, + ClientConnection, + WebSocketConfig, + BridgeStatistics, +} from './WebSocketBridge'; diff --git a/src/theater/specimens/Dissection.ts b/src/theater/specimens/Dissection.ts new file mode 100644 index 0000000..2d134f0 --- /dev/null +++ b/src/theater/specimens/Dissection.ts @@ -0,0 +1,417 @@ +/** + * Dissection - Component structure explorer + * + * Dissection provides tools for examining component props, structure, + * and behavior. It's similar to Storybook's Controls/Args but with + * more powerful inspection capabilities. + */ + +/** + * Prop type + */ +export type PropType = + | 'string' + | 'number' + | 'boolean' + | 'array' + | 'object' + | 'function' + | 'enum' + | 'date' + | 'custom'; + +/** + * Prop definition + */ +export interface PropDefinition { + /** + * Prop name + */ + name: string; + + /** + * Prop type + */ + type: PropType; + + /** + * Description + */ + description?: string; + + /** + * Default value + */ + defaultValue?: unknown; + + /** + * Is required + */ + required?: boolean; + + /** + * Possible values (for enum type) + */ + options?: unknown[]; + + /** + * Custom type name + */ + customType?: string; + + /** + * Validation function + */ + validate?: (value: unknown) => boolean; + + /** + * Control type for UI + */ + control?: 'text' | 'number' | 'boolean' | 'select' | 'radio' | 'range' | 'color' | 'date'; + + /** + * Control config + */ + controlConfig?: { + min?: number; + max?: number; + step?: number; + }; +} + +/** + * Component structure + */ +export interface ComponentStructure { + /** + * Component name + */ + name: string; + + /** + * Props definitions + */ + props: Map; + + /** + * Component methods + */ + methods: Map; + + /** + * Events emitted + */ + events: Map; + + /** + * Child components + */ + children?: ComponentStructure[]; +} + +/** + * Dissection - Component explorer + */ +export class Dissection { + private structure: ComponentStructure; + private propValues: Map = new Map(); + private propChangeListeners: Set<(prop: string, value: unknown) => void> = new Set(); + + constructor(structure: ComponentStructure) { + this.structure = structure; + this.initializeDefaultValues(); + } + + /** + * Initialize prop default values + */ + private initializeDefaultValues(): void { + for (const [name, def] of this.structure.props) { + if (def.defaultValue !== undefined) { + this.propValues.set(name, def.defaultValue); + } + } + } + + /** + * Get component structure + */ + public getStructure(): ComponentStructure { + return { ...this.structure }; + } + + /** + * Get prop definition + */ + public getPropDefinition(name: string): PropDefinition | undefined { + return this.structure.props.get(name); + } + + /** + * Get all prop definitions + */ + public getAllProps(): Map { + return new Map(this.structure.props); + } + + /** + * Set prop value + */ + public setPropValue(name: string, value: unknown): void { + const def = this.structure.props.get(name); + if (def === undefined) { + throw new Error(`Prop not found: ${name}`); + } + + // Validate value + if (def.validate !== undefined && !def.validate(value)) { + throw new Error(`Invalid value for prop: ${name}`); + } + + this.propValues.set(name, value); + this.notifyPropChange(name, value); + } + + /** + * Get prop value + */ + public getPropValue(name: string): unknown { + return this.propValues.get(name); + } + + /** + * Get all prop values + */ + public getAllPropValues(): Record { + return Object.fromEntries(this.propValues); + } + + /** + * Reset prop to default value + */ + public resetProp(name: string): void { + const def = this.structure.props.get(name); + if (def === undefined) { + throw new Error(`Prop not found: ${name}`); + } + + if (def.defaultValue !== undefined) { + this.propValues.set(name, def.defaultValue); + this.notifyPropChange(name, def.defaultValue); + } else { + this.propValues.delete(name); + this.notifyPropChange(name, undefined); + } + } + + /** + * Reset all props to defaults + */ + public resetAllProps(): void { + this.propValues.clear(); + this.initializeDefaultValues(); + + for (const [name, value] of this.propValues) { + this.notifyPropChange(name, value); + } + } + + /** + * Add prop change listener + */ + public onPropChange(listener: (prop: string, value: unknown) => void): () => void { + this.propChangeListeners.add(listener); + + // Return unsubscribe function + return () => { + this.propChangeListeners.delete(listener); + }; + } + + /** + * Notify prop change listeners + */ + private notifyPropChange(prop: string, value: unknown): void { + for (const listener of this.propChangeListeners) { + listener(prop, value); + } + } + + /** + * Get required props + */ + public getRequiredProps(): Map { + const required = new Map(); + + for (const [name, def] of this.structure.props) { + if (def.required === true) { + required.set(name, def); + } + } + + return required; + } + + /** + * Get optional props + */ + public getOptionalProps(): Map { + const optional = new Map(); + + for (const [name, def] of this.structure.props) { + if (def.required !== true) { + optional.set(name, def); + } + } + + return optional; + } + + /** + * Get props by type + */ + public getPropsByType(type: PropType): Map { + const filtered = new Map(); + + for (const [name, def] of this.structure.props) { + if (def.type === type) { + filtered.set(name, def); + } + } + + return filtered; + } + + /** + * Validate all prop values + */ + public validateAllProps(): { valid: boolean; errors: string[] } { + const errors: string[] = []; + + for (const [name, def] of this.structure.props) { + // Check required props + if (def.required === true && !this.propValues.has(name)) { + errors.push(`Required prop missing: ${name}`); + continue; + } + + // Validate value if present + const value = this.propValues.get(name); + if (value !== undefined && def.validate !== undefined && !def.validate(value)) { + errors.push(`Invalid value for prop: ${name}`); + } + } + + return { + valid: errors.length === 0, + errors, + }; + } + + /** + * Get component methods + */ + public getMethods(): Map { + return new Map(this.structure.methods); + } + + /** + * Get component events + */ + public getEvents(): Map { + return new Map(this.structure.events); + } + + /** + * Export dissection data + */ + public export(): { + structure: ComponentStructure; + currentValues: Record; + validation: { valid: boolean; errors: string[] }; + } { + return { + structure: { ...this.structure }, + currentValues: this.getAllPropValues(), + validation: this.validateAllProps(), + }; + } + + /** + * Get statistics + */ + public getStats(): { + totalProps: number; + requiredProps: number; + optionalProps: number; + methodsCount: number; + eventsCount: number; + } { + return { + totalProps: this.structure.props.size, + requiredProps: this.getRequiredProps().size, + optionalProps: this.getOptionalProps().size, + methodsCount: this.structure.methods.size, + eventsCount: this.structure.events.size, + }; + } +} + +/** + * Create dissection from TypeScript interface (helper for future auto-generation) + */ +export function createDissection(name: string, props: PropDefinition[]): Dissection { + const structure: ComponentStructure = { + name, + props: new Map(props.map((p) => [p.name, p])), + methods: new Map(), + events: new Map(), + }; + + return new Dissection(structure); +} + +/** + * Dissection builder for fluent API + */ +export class DissectionBuilder { + private name: string = ''; + private props: Map = new Map(); + private methods: Map = new Map(); + private events: Map = new Map(); + + public withName(name: string): this { + this.name = name; + return this; + } + + public addProp(prop: PropDefinition): this { + this.props.set(prop.name, prop); + return this; + } + + public addMethod(name: string, description: string): this { + this.methods.set(name, description); + return this; + } + + public addEvent(name: string, description: string): this { + this.events.set(name, description); + return this; + } + + public build(): Dissection { + if (this.name.length === 0) { + throw new Error('Component name is required'); + } + + const structure: ComponentStructure = { + name: this.name, + props: this.props, + methods: this.methods, + events: this.events, + }; + + return new Dissection(structure); + } +} diff --git a/src/theater/specimens/Observation.ts b/src/theater/specimens/Observation.ts new file mode 100644 index 0000000..c236544 --- /dev/null +++ b/src/theater/specimens/Observation.ts @@ -0,0 +1,267 @@ +/** + * Observation - Component variation showcase + * + * An Observation represents a specific state or variation of a component, + * similar to Storybook's "Story". It defines props, state, and rendering + * context for a particular use case or demonstration. + */ + +import type { SpecimenContext } from './Specimen'; + +/** + * Observation configuration + */ +export interface ObservationConfig { + /** + * Observation name (e.g., "Primary", "Disabled", "Loading") + */ + name: string; + + /** + * Description of this variation + */ + description?: string; + + /** + * Props for this observation + */ + props?: Record; + + /** + * Initial state + */ + state?: Record; + + /** + * Rendering context + */ + context?: Partial; + + /** + * Tags for categorization + */ + tags?: string[]; + + /** + * Play function - interactive demonstration + */ + play?: (element: HTMLElement) => Promise | void; + + /** + * Setup function - runs before rendering + */ + setup?: () => Promise | void; + + /** + * Teardown function - runs after observation + */ + teardown?: () => Promise | void; +} + +/** + * Observation - Component variation + */ +export class Observation { + public readonly name: string; + public readonly description?: string; + public readonly props: Record; + public readonly state: Record; + public readonly context: Partial; + public readonly tags: string[]; + public readonly play?: (element: HTMLElement) => Promise | void; + private readonly setup?: () => Promise | void; + private readonly teardown?: () => Promise | void; + + private isSetup = false; + private isTornDown = false; + + constructor(config: ObservationConfig) { + this.name = config.name; + if (config.description !== undefined) { + this.description = config.description; + } + this.props = config.props ?? {}; + this.state = config.state ?? {}; + this.context = config.context ?? {}; + this.tags = config.tags ?? []; + if (config.play !== undefined) { + this.play = config.play; + } + if (config.setup !== undefined) { + this.setup = config.setup; + } + if (config.teardown !== undefined) { + this.teardown = config.teardown; + } + } + + /** + * Initialize the observation + */ + public async initialize(): Promise { + if (this.isSetup) { + return; + } + + if (this.setup !== undefined) { + await this.setup(); + } + + this.isSetup = true; + } + + /** + * Cleanup the observation + */ + public async cleanup(): Promise { + if (this.isTornDown) { + return; + } + + if (this.teardown !== undefined) { + await this.teardown(); + } + + this.isTornDown = true; + } + + /** + * Get full specimen context + */ + public getSpecimenContext(): SpecimenContext { + return { + props: { ...this.props }, + state: { ...this.state }, + ...this.context, + }; + } + + /** + * Run the play function (interactive demo) + */ + public async runPlay(element: HTMLElement): Promise { + if (this.play === undefined) { + return; + } + + await this.play(element); + } + + /** + * Check if observation has play function + */ + public hasPlay(): boolean { + return this.play !== undefined; + } + + /** + * Export observation definition + */ + public export(): { + name: string; + description?: string; + props: Record; + state: Record; + context: Partial; + tags: string[]; + hasPlay: boolean; + } { + const exported: { + name: string; + description?: string; + props: Record; + state: Record; + context: Partial; + tags: string[]; + hasPlay: boolean; + } = { + name: this.name, + props: { ...this.props }, + state: { ...this.state }, + context: { ...this.context }, + tags: [...this.tags], + hasPlay: this.hasPlay(), + }; + + if (this.description !== undefined) { + exported.description = this.description; + } + + return exported; + } +} + +/** + * Create multiple observations from a configuration object + */ +export function createObservations( + configs: Record, +): Map { + const observations = new Map(); + + for (const [key, config] of Object.entries(configs)) { + observations.set(key, new Observation(config)); + } + + return observations; +} + +/** + * Observation builder for fluent API + */ +export class ObservationBuilder { + private config: Partial = {}; + + public withName(name: string): this { + this.config.name = name; + return this; + } + + public withDescription(description: string): this { + this.config.description = description; + return this; + } + + public withProps(props: Record): this { + this.config.props = props; + return this; + } + + public withState(state: Record): this { + this.config.state = state; + return this; + } + + public withContext(context: Partial): this { + this.config.context = context; + return this; + } + + public withTags(...tags: string[]): this { + this.config.tags = tags; + return this; + } + + public withPlay(play: (element: HTMLElement) => Promise | void): this { + this.config.play = play; + return this; + } + + public withSetup(setup: () => Promise | void): this { + this.config.setup = setup; + return this; + } + + public withTeardown(teardown: () => Promise | void): this { + this.config.teardown = teardown; + return this; + } + + public build(): Observation { + if (this.config.name === undefined) { + throw new Error('Observation name is required'); + } + + return new Observation(this.config as ObservationConfig); + } +} diff --git a/src/theater/specimens/Specimen.ts b/src/theater/specimens/Specimen.ts new file mode 100644 index 0000000..e1a141b --- /dev/null +++ b/src/theater/specimens/Specimen.ts @@ -0,0 +1,296 @@ +/** + * Specimen - Component showcase wrapper + * + * A Specimen wraps a component for observation and documentation in the + * Anatomy Theater. It provides metadata, variations, and rendering context. + */ + +import type { VisualNeuron } from '../../ui/VisualNeuron'; + +/** + * Specimen metadata + */ +export interface SpecimenMetadata { + /** + * Unique specimen identifier + */ + id: string; + + /** + * Display name + */ + name: string; + + /** + * Component category + */ + category: string; + + /** + * Tags for searchability + */ + tags: string[]; + + /** + * Description of the component + */ + description?: string; + + /** + * Creation timestamp + */ + createdAt?: Date; + + /** + * Last update timestamp + */ + updatedAt?: Date; + + /** + * Component author + */ + author?: string; + + /** + * Version + */ + version?: string; + + /** + * Additional custom metadata + */ + custom?: Record; +} + +/** + * Specimen rendering context + */ +export interface SpecimenContext { + /** + * Props to pass to component + */ + props?: Record; + + /** + * Initial state + */ + state?: Record; + + /** + * Wrapper element styles + */ + wrapperStyles?: Record; + + /** + * Global styles for the specimen + */ + globalStyles?: string; + + /** + * Background color + */ + backgroundColor?: string; + + /** + * Padding + */ + padding?: number; + + /** + * Enable interactions + * @default true + */ + interactive?: boolean; + + /** + * Viewport size + */ + viewport?: { width: number; height: number }; +} + +/** + * Specimen render function + */ +export type SpecimenRenderFn = ( + context: SpecimenContext, +) => VisualNeuron | HTMLElement; + +/** + * Specimen - Component showcase wrapper + */ +export class Specimen { + public readonly metadata: SpecimenMetadata; + private renderFn: SpecimenRenderFn; + private defaultContext: SpecimenContext; + private variations: Map = new Map(); + + constructor( + metadata: SpecimenMetadata, + renderFn: SpecimenRenderFn, + defaultContext: SpecimenContext = {}, + ) { + this.metadata = metadata; + this.renderFn = renderFn; + this.defaultContext = { + interactive: true, + backgroundColor: '#ffffff', + padding: 16, + ...defaultContext, + }; + } + + /** + * Render the specimen + */ + public render(context: SpecimenContext = {}): VisualNeuron | HTMLElement { + const mergedContext: SpecimenContext = { + ...this.defaultContext, + ...context, + props: { + ...this.defaultContext.props, + ...context.props, + }, + state: { + ...this.defaultContext.state, + ...context.state, + }, + }; + + return this.renderFn(mergedContext); + } + + /** + * Add a variation (e.g., "primary", "secondary", "disabled") + */ + public addVariation(name: string, context: SpecimenContext): this { + this.variations.set(name, context); + return this; + } + + /** + * Remove a variation + */ + public removeVariation(name: string): boolean { + return this.variations.delete(name); + } + + /** + * Get a variation + */ + public getVariation(name: string): SpecimenContext | undefined { + return this.variations.get(name); + } + + /** + * Get all variations + */ + public getVariations(): Map { + return new Map(this.variations); + } + + /** + * Check if variation exists + */ + public hasVariation(name: string): boolean { + return this.variations.has(name); + } + + /** + * Render a specific variation + */ + public renderVariation(name: string): VisualNeuron | HTMLElement { + const variation = this.variations.get(name); + if (variation === undefined) { + throw new Error(`Variation not found: ${name}`); + } + + return this.render(variation); + } + + /** + * Render all variations + */ + public renderAllVariations(): Map | HTMLElement> { + const rendered = new Map | HTMLElement>(); + + for (const [name, context] of this.variations) { + rendered.set(name, this.render(context)); + } + + return rendered; + } + + /** + * Update metadata + */ + public updateMetadata(updates: Partial): void { + Object.assign(this.metadata, updates); + if (updates.updatedAt === undefined) { + this.metadata.updatedAt = new Date(); + } + } + + /** + * Update default context + */ + public updateDefaultContext(updates: Partial): void { + this.defaultContext = { + ...this.defaultContext, + ...updates, + props: { + ...this.defaultContext.props, + ...updates.props, + }, + state: { + ...this.defaultContext.state, + ...updates.state, + }, + }; + } + + /** + * Clone this specimen with new metadata + */ + public clone(metadata: Partial): Specimen { + const cloned = new Specimen({ ...this.metadata, ...metadata }, this.renderFn, { + ...this.defaultContext, + }); + + // Copy variations + for (const [name, context] of this.variations) { + cloned.addVariation(name, { ...context }); + } + + return cloned; + } + + /** + * Export specimen definition + */ + public export(): { + metadata: SpecimenMetadata; + defaultContext: SpecimenContext; + variations: Record; + } { + return { + metadata: { ...this.metadata }, + defaultContext: { ...this.defaultContext }, + variations: Object.fromEntries(this.variations), + }; + } + + /** + * Get specimen statistics + */ + public getStats(): { + variationCount: number; + hasDescription: boolean; + tagCount: number; + } { + return { + variationCount: this.variations.size, + hasDescription: this.metadata.description !== undefined, + tagCount: this.metadata.tags.length, + }; + } +} diff --git a/src/ui/VisualNeuron.ts b/src/ui/VisualNeuron.ts index a0d1659..417e576 100644 --- a/src/ui/VisualNeuron.ts +++ b/src/ui/VisualNeuron.ts @@ -14,6 +14,9 @@ import type { } from './types'; import { EventEmitter } from 'events'; +// Re-export ComponentProps for external use +export type { ComponentProps } from './types'; + export interface VisualNeuronConfig { id: string; type: 'cortical' | 'reflex'; diff --git a/src/ui/glial/VisualAstrocyte.ts b/src/ui/glial/VisualAstrocyte.ts index a303150..a3a95e9 100644 --- a/src/ui/glial/VisualAstrocyte.ts +++ b/src/ui/glial/VisualAstrocyte.ts @@ -1,4 +1,4 @@ -/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-argument */ +/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument */ /** * VisualAstrocyte - UI State Management * Neural-inspired state manager with time-travel debugging (like Redux/Zustand) diff --git a/src/ui/glial/VisualOligodendrocyte.ts b/src/ui/glial/VisualOligodendrocyte.ts index 6791818..dd079db 100644 --- a/src/ui/glial/VisualOligodendrocyte.ts +++ b/src/ui/glial/VisualOligodendrocyte.ts @@ -1,4 +1,4 @@ -/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-return */ +/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access */ /** * VisualOligodendrocyte - Rendering Optimization & Myelination * Virtual DOM diffing, component memoization, lazy loading diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json index 9d8f39e..73cf75b 100644 --- a/tsconfig.eslint.json +++ b/tsconfig.eslint.json @@ -1,6 +1,6 @@ { "extends": "./tsconfig.json", - "include": ["src/**/*", "cli/**/*"], + "include": ["src/**/*", "cli/**/*", "examples/**/*"], "exclude": [ "node_modules", "dist", diff --git a/tsconfig.json b/tsconfig.json index c84694a..a793076 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { /* Language and Environment */ "target": "ES2022", - "lib": ["ES2022"], + "lib": ["ES2022", "DOM"], "types": ["bun-types", "jest"], /* Modules */ @@ -54,7 +54,8 @@ }, "include": [ "src/**/*", - "cli/**/*" + "cli/**/*", + "examples/**/*" ], "exclude": [ "node_modules",