generated from coulomb/repo-seed
add: comprehensive TDD test infrastructure
- Add Vitest + jsdom testing framework - Create unit tests for engine.js and generator.js - Add integration tests for end-to-end workflows - Include test utilities and setup helpers - Document testing approach in TESTING.md - Document all dependencies in DEPENDS.md - Add Makefile with test targets and dev workflow 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
189
DEPENDS.md
Normal file
189
DEPENDS.md
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
# Dependencies - Timeline SVG Generator
|
||||||
|
|
||||||
|
This document lists all third-party libraries and tools used in the Timeline SVG Generator project.
|
||||||
|
|
||||||
|
## Runtime Dependencies
|
||||||
|
|
||||||
|
### Production Dependencies
|
||||||
|
|
||||||
|
#### PapaParse
|
||||||
|
- **Version**: 5.4.1
|
||||||
|
- **Source**: https://cdn.jsdelivr.net/npm/papaparse@5.4.1/papaparse.min.js
|
||||||
|
- **Purpose**: CSV parsing and processing
|
||||||
|
- **License**: MIT
|
||||||
|
- **Usage**: Loaded via CDN in index.html:6
|
||||||
|
- **Documentation**: https://www.papaparse.com/
|
||||||
|
- **Why chosen**: Robust CSV parser with header support, handles edge cases, small footprint
|
||||||
|
|
||||||
|
## Development Dependencies
|
||||||
|
|
||||||
|
### Testing Framework
|
||||||
|
|
||||||
|
#### Vitest
|
||||||
|
- **Version**: ^2.1.0
|
||||||
|
- **Purpose**: Modern test runner and framework
|
||||||
|
- **License**: MIT
|
||||||
|
- **Documentation**: https://vitest.dev/
|
||||||
|
- **Features**: Fast execution, ESM support, built-in coverage, watch mode
|
||||||
|
|
||||||
|
#### @vitest/ui
|
||||||
|
- **Version**: ^2.1.0
|
||||||
|
- **Purpose**: Browser-based test UI and visualization
|
||||||
|
- **License**: MIT
|
||||||
|
- **Usage**: `npm run test:ui`
|
||||||
|
|
||||||
|
#### @vitest/coverage-v8
|
||||||
|
- **Version**: ^2.1.0
|
||||||
|
- **Purpose**: Code coverage reporting using V8 engine
|
||||||
|
- **License**: MIT
|
||||||
|
- **Usage**: `npm run test:coverage`
|
||||||
|
|
||||||
|
#### jsdom
|
||||||
|
- **Version**: ^25.0.0
|
||||||
|
- **Purpose**: DOM environment simulation for Node.js testing
|
||||||
|
- **License**: MIT
|
||||||
|
- **Documentation**: https://github.com/jsdom/jsdom
|
||||||
|
- **Usage**: Enables testing of browser APIs and DOM manipulation
|
||||||
|
|
||||||
|
## System Requirements
|
||||||
|
|
||||||
|
### Node.js
|
||||||
|
- **Minimum Version**: 18.0.0
|
||||||
|
- **Recommended**: 20.0.0 or later
|
||||||
|
- **Purpose**: Development environment and package management
|
||||||
|
|
||||||
|
### NPM
|
||||||
|
- **Minimum Version**: 8.0.0
|
||||||
|
- **Purpose**: Package manager for development dependencies
|
||||||
|
|
||||||
|
## Browser Requirements (Production)
|
||||||
|
|
||||||
|
### Supported Browsers
|
||||||
|
- **Chrome**: 80+
|
||||||
|
- **Firefox**: 75+
|
||||||
|
- **Safari**: 13+
|
||||||
|
- **Edge**: 80+
|
||||||
|
|
||||||
|
### Required Browser Features
|
||||||
|
- ES6 Modules support
|
||||||
|
- Fetch API
|
||||||
|
- File API (for file uploads)
|
||||||
|
- SVG support
|
||||||
|
- CSS Grid and Flexbox
|
||||||
|
|
||||||
|
## External Services
|
||||||
|
|
||||||
|
### CDN Dependencies
|
||||||
|
|
||||||
|
#### jsDelivr
|
||||||
|
- **Purpose**: Content Delivery Network for PapaParse
|
||||||
|
- **URL**: https://cdn.jsdelivr.net/
|
||||||
|
- **Fallback**: None (project requires internet connection)
|
||||||
|
- **Alternative**: Could be replaced with local copy
|
||||||
|
|
||||||
|
## Font Dependencies
|
||||||
|
|
||||||
|
### Inter Font Family
|
||||||
|
- **Source**: System fonts (Inter, Arial, sans-serif fallback)
|
||||||
|
- **Usage**: Default UI typography in index.html:11
|
||||||
|
- **Purpose**: Modern, readable interface typography
|
||||||
|
- **Fallback**: Arial → sans-serif
|
||||||
|
|
||||||
|
## Development Tools (Optional)
|
||||||
|
|
||||||
|
### Recommended IDE Extensions
|
||||||
|
- **Vitest**: VS Code extension for integrated test running
|
||||||
|
- **JavaScript ES6**: Enhanced JavaScript support
|
||||||
|
- **Live Server**: Local development server
|
||||||
|
|
||||||
|
### Git
|
||||||
|
- **Purpose**: Version control
|
||||||
|
- **Required for**: Contributing to the project
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
### CDN Dependencies
|
||||||
|
- **Risk**: External dependency on jsDelivr
|
||||||
|
- **Mitigation**: Well-established CDN with high uptime
|
||||||
|
- **Alternative**: Local hosting of PapaParse possible
|
||||||
|
|
||||||
|
### No Backend Dependencies
|
||||||
|
- **Advantage**: Reduced attack surface
|
||||||
|
- **Note**: All processing happens client-side
|
||||||
|
|
||||||
|
## Dependency Management
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
```bash
|
||||||
|
# Install all development dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Production use
|
||||||
|
# No installation needed - open index.html in browser
|
||||||
|
```
|
||||||
|
|
||||||
|
### Updates
|
||||||
|
```bash
|
||||||
|
# Check for outdated packages
|
||||||
|
npm outdated
|
||||||
|
|
||||||
|
# Update to latest compatible versions
|
||||||
|
npm update
|
||||||
|
|
||||||
|
# Update to latest major versions (breaking changes possible)
|
||||||
|
npm install package@latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vulnerability Scanning
|
||||||
|
```bash
|
||||||
|
# Check for security vulnerabilities
|
||||||
|
npm audit
|
||||||
|
|
||||||
|
# Fix automatically fixable vulnerabilities
|
||||||
|
npm audit fix
|
||||||
|
```
|
||||||
|
|
||||||
|
## License Compatibility
|
||||||
|
|
||||||
|
All dependencies use MIT or MIT-compatible licenses:
|
||||||
|
- **PapaParse**: MIT License
|
||||||
|
- **Vitest**: MIT License
|
||||||
|
- **jsdom**: MIT License
|
||||||
|
- **@vitest/ui**: MIT License
|
||||||
|
- **@vitest/coverage-v8**: MIT License
|
||||||
|
|
||||||
|
## Dependency Decision Matrix
|
||||||
|
|
||||||
|
| Library | Alternatives Considered | Why Chosen |
|
||||||
|
|---------|------------------------|------------|
|
||||||
|
| PapaParse | CSV.js, custom parser | Mature, feature-rich, wide browser support |
|
||||||
|
| Vitest | Jest, Mocha | Modern, fast, ESM native, zero config |
|
||||||
|
| jsdom | happy-dom, playwright | Most mature DOM simulation, wide compatibility |
|
||||||
|
|
||||||
|
## Future Dependency Considerations
|
||||||
|
|
||||||
|
### Potential Additions
|
||||||
|
- **TypeScript**: For type safety (would require build step)
|
||||||
|
- **Playwright**: For E2E testing
|
||||||
|
- **ESLint**: For code quality
|
||||||
|
- **Prettier**: For code formatting
|
||||||
|
|
||||||
|
### Minimization Strategy
|
||||||
|
The project intentionally keeps dependencies minimal to:
|
||||||
|
- Reduce bundle size
|
||||||
|
- Minimize security surface
|
||||||
|
- Maintain browser compatibility
|
||||||
|
- Enable offline usage
|
||||||
|
- Simplify deployment
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
### Dependency Health
|
||||||
|
- Monitor PapaParse for security updates
|
||||||
|
- Watch Vitest releases for test improvements
|
||||||
|
- Check browser compatibility quarterly
|
||||||
|
|
||||||
|
### Performance Impact
|
||||||
|
- PapaParse: ~45KB minified
|
||||||
|
- No runtime performance impact from dev dependencies
|
||||||
|
- Zero build step maintains fast development cycle
|
||||||
139
Makefile
Normal file
139
Makefile
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
.PHONY: help install test test-watch test-coverage test-ui clean serve lint format deps-check deps-update
|
||||||
|
|
||||||
|
# Default target
|
||||||
|
help:
|
||||||
|
@echo "Timeline SVG Generator - Make Targets"
|
||||||
|
@echo "====================================="
|
||||||
|
@echo ""
|
||||||
|
@echo "Development:"
|
||||||
|
@echo " install Install development dependencies"
|
||||||
|
@echo " serve Start local development server"
|
||||||
|
@echo " clean Clean node_modules and coverage"
|
||||||
|
@echo ""
|
||||||
|
@echo "Testing:"
|
||||||
|
@echo " test Run all tests once"
|
||||||
|
@echo " test-watch Run tests in watch mode (TDD)"
|
||||||
|
@echo " test-coverage Run tests with coverage report"
|
||||||
|
@echo " test-ui Open browser-based test UI"
|
||||||
|
@echo ""
|
||||||
|
@echo "Quality:"
|
||||||
|
@echo " lint Run code linting (if configured)"
|
||||||
|
@echo " format Format code (if configured)"
|
||||||
|
@echo ""
|
||||||
|
@echo "Dependencies:"
|
||||||
|
@echo " deps-check Check for outdated dependencies"
|
||||||
|
@echo " deps-update Update dependencies"
|
||||||
|
@echo " deps-audit Security audit of dependencies"
|
||||||
|
@echo ""
|
||||||
|
|
||||||
|
# Development setup
|
||||||
|
install:
|
||||||
|
@echo "Installing development dependencies..."
|
||||||
|
npm install
|
||||||
|
@echo "✅ Dependencies installed"
|
||||||
|
|
||||||
|
# Testing targets
|
||||||
|
test:
|
||||||
|
@echo "Running tests..."
|
||||||
|
@if [ ! -d "node_modules" ]; then \
|
||||||
|
echo "❌ Dependencies not installed. Run 'make install' first."; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
npm test
|
||||||
|
|
||||||
|
test-watch:
|
||||||
|
@echo "Starting test watch mode (press 'q' to quit)..."
|
||||||
|
@if [ ! -d "node_modules" ]; then \
|
||||||
|
echo "❌ Dependencies not installed. Run 'make install' first."; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
npm run test:watch
|
||||||
|
|
||||||
|
test-coverage:
|
||||||
|
@echo "Running tests with coverage..."
|
||||||
|
@if [ ! -d "node_modules" ]; then \
|
||||||
|
echo "❌ Dependencies not installed. Run 'make install' first."; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
npm run test:coverage
|
||||||
|
@echo "📊 Coverage report generated in coverage/ directory"
|
||||||
|
|
||||||
|
test-ui:
|
||||||
|
@echo "Opening test UI in browser..."
|
||||||
|
@if [ ! -d "node_modules" ]; then \
|
||||||
|
echo "❌ Dependencies not installed. Run 'make install' first."; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
npm run test:ui
|
||||||
|
|
||||||
|
# Development server (simple HTTP server for testing)
|
||||||
|
serve:
|
||||||
|
@if command -v python3 >/dev/null 2>&1; then \
|
||||||
|
echo "Starting development server on http://localhost:8000..."; \
|
||||||
|
echo "📂 Serving files from current directory"; \
|
||||||
|
echo "Press Ctrl+C to stop"; \
|
||||||
|
python3 -m http.server 8000; \
|
||||||
|
elif command -v python >/dev/null 2>&1; then \
|
||||||
|
echo "Starting development server on http://localhost:8000..."; \
|
||||||
|
echo "📂 Serving files from current directory"; \
|
||||||
|
echo "Press Ctrl+C to stop"; \
|
||||||
|
python -m SimpleHTTPServer 8000; \
|
||||||
|
elif command -v npx >/dev/null 2>&1; then \
|
||||||
|
echo "Starting development server with npx..."; \
|
||||||
|
npx serve -l 8000; \
|
||||||
|
else \
|
||||||
|
echo "❌ No suitable HTTP server found"; \
|
||||||
|
echo "Install Python or Node.js to use 'make serve'"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Code quality (placeholder targets for future use)
|
||||||
|
lint:
|
||||||
|
@if [ -f "package.json" ] && npm list eslint >/dev/null 2>&1; then \
|
||||||
|
echo "Running ESLint..."; \
|
||||||
|
npx eslint *.js; \
|
||||||
|
else \
|
||||||
|
echo "ℹ️ ESLint not configured. Skipping lint check."; \
|
||||||
|
echo " To add linting: npm install --save-dev eslint"; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
format:
|
||||||
|
@if [ -f "package.json" ] && npm list prettier >/dev/null 2>&1; then \
|
||||||
|
echo "Formatting code with Prettier..."; \
|
||||||
|
npx prettier --write "*.js" "test/*.js"; \
|
||||||
|
else \
|
||||||
|
echo "ℹ️ Prettier not configured. Skipping code formatting."; \
|
||||||
|
echo " To add formatting: npm install --save-dev prettier"; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Dependency management
|
||||||
|
deps-check:
|
||||||
|
@echo "Checking for outdated dependencies..."
|
||||||
|
npm outdated
|
||||||
|
|
||||||
|
deps-update:
|
||||||
|
@echo "Updating dependencies..."
|
||||||
|
npm update
|
||||||
|
@echo "✅ Dependencies updated"
|
||||||
|
|
||||||
|
deps-audit:
|
||||||
|
@echo "Running security audit..."
|
||||||
|
npm audit
|
||||||
|
@echo "🔒 Security audit complete"
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
clean:
|
||||||
|
@echo "Cleaning up..."
|
||||||
|
@if [ -d "node_modules" ]; then rm -rf node_modules && echo "🗑️ Removed node_modules/"; fi
|
||||||
|
@if [ -d "coverage" ]; then rm -rf coverage && echo "🗑️ Removed coverage/"; fi
|
||||||
|
@if [ -d ".vitest" ]; then rm -rf .vitest && echo "🗑️ Removed .vitest/"; fi
|
||||||
|
@echo "✅ Cleanup complete"
|
||||||
|
|
||||||
|
# Quick setup for new developers
|
||||||
|
setup: clean install test
|
||||||
|
@echo ""
|
||||||
|
@echo "🎉 Setup complete! You can now:"
|
||||||
|
@echo " • Run 'make test' to run tests"
|
||||||
|
@echo " • Run 'make test-watch' for TDD"
|
||||||
|
@echo " • Run 'make serve' to start development server"
|
||||||
|
@echo " • Open index.html in browser to use the app"
|
||||||
245
TESTING.md
Normal file
245
TESTING.md
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
# Timeline SVG - Test Infrastructure
|
||||||
|
|
||||||
|
This document describes the test-driven development (TDD) infrastructure for the Timeline SVG Generator project.
|
||||||
|
|
||||||
|
## Test Framework Setup
|
||||||
|
|
||||||
|
The project uses **Vitest** with **jsdom** for testing browser-based JavaScript code.
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"@vitest/ui": "^2.1.0",
|
||||||
|
"@vitest/coverage-v8": "^2.1.0",
|
||||||
|
"jsdom": "^25.0.0",
|
||||||
|
"vitest": "^2.1.0"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests once
|
||||||
|
npm test
|
||||||
|
|
||||||
|
# Run tests in watch mode (for TDD)
|
||||||
|
npm run test:watch
|
||||||
|
|
||||||
|
# Run with coverage report
|
||||||
|
npm run test:coverage
|
||||||
|
|
||||||
|
# Open test UI (browser-based test runner)
|
||||||
|
npm run test:ui
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Structure
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
|
||||||
|
- **`test/engine.test.js`** - Tests for timeline data processing
|
||||||
|
- CSV parsing and validation
|
||||||
|
- Date parsing (multiple formats)
|
||||||
|
- Project configuration loading
|
||||||
|
- DOM integration
|
||||||
|
|
||||||
|
- **`test/generator.test.js`** - Tests for SVG generation
|
||||||
|
- SVG template processing
|
||||||
|
- Month grid generation
|
||||||
|
- Lane and item positioning
|
||||||
|
- XML escaping
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
|
||||||
|
- **`test/integration.test.js`** - End-to-end workflow tests
|
||||||
|
- Complete timeline generation pipeline
|
||||||
|
- File upload simulation
|
||||||
|
- DOM event handling
|
||||||
|
- Export functionality
|
||||||
|
|
||||||
|
### Test Utilities
|
||||||
|
|
||||||
|
- **`test/setup.js`** - Global test setup and mocks
|
||||||
|
- **`test/testHelpers.js`** - Common test data and utilities
|
||||||
|
|
||||||
|
## TDD Workflow
|
||||||
|
|
||||||
|
### 1. Red Phase - Write Failing Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start test watcher
|
||||||
|
npm run test:watch
|
||||||
|
|
||||||
|
# Create test for new feature
|
||||||
|
touch test/newFeature.test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Example test:
|
||||||
|
```javascript
|
||||||
|
describe('New Feature', () => {
|
||||||
|
it('should do something specific', () => {
|
||||||
|
const result = myNewFunction('input')
|
||||||
|
expect(result).toBe('expected output')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Green Phase - Make Test Pass
|
||||||
|
|
||||||
|
Implement minimal code to make the test pass:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In the appropriate source file
|
||||||
|
function myNewFunction(input) {
|
||||||
|
return 'expected output' // Minimal implementation
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Refactor Phase - Improve Code
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ensure tests still pass after refactoring
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Testing Patterns
|
||||||
|
|
||||||
|
### Mocking Browser APIs
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In test/setup.js
|
||||||
|
global.fetch = vi.fn()
|
||||||
|
global.Papa = { parse: vi.fn() }
|
||||||
|
|
||||||
|
// In tests
|
||||||
|
mockFetch(sampleData)
|
||||||
|
```
|
||||||
|
|
||||||
|
### DOM Testing
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Setup DOM elements
|
||||||
|
setupBasicDOM()
|
||||||
|
|
||||||
|
// Test DOM manipulation
|
||||||
|
await timelineEngine.loadProjectConfigObject(config)
|
||||||
|
expectElementToHaveText('#projectName', 'Test Project')
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing Vanilla JS Modules
|
||||||
|
|
||||||
|
Since the project uses global objects, tests load modules by evaluating the source:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const fs = await import('fs/promises')
|
||||||
|
const engineCode = await fs.readFile('./engine.js', 'utf-8')
|
||||||
|
eval(engineCode)
|
||||||
|
const timelineEngine = global.window.timelineEngine
|
||||||
|
```
|
||||||
|
|
||||||
|
## Coverage Goals
|
||||||
|
|
||||||
|
- **Statements**: >90%
|
||||||
|
- **Branches**: >85%
|
||||||
|
- **Functions**: >90%
|
||||||
|
- **Lines**: >90%
|
||||||
|
|
||||||
|
View detailed coverage:
|
||||||
|
```bash
|
||||||
|
npm run test:coverage
|
||||||
|
open coverage/index.html
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Test Scenarios
|
||||||
|
|
||||||
|
### Testing Date Parsing
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
it('should parse various date formats', () => {
|
||||||
|
expect(parseDate('2025-12-15')).toEqual(new Date(2025, 11, 15))
|
||||||
|
expect(parseDate('15.12.2025')).toEqual(new Date(2025, 11, 15))
|
||||||
|
expect(parseDate('invalid')).toBeNull()
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing SVG Generation
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
it('should generate valid SVG with timeline items', () => {
|
||||||
|
const result = timelineGenerator.generate(items, config, template)
|
||||||
|
expect(result).toContain('<svg xmlns="http://www.w3.org/2000/svg">')
|
||||||
|
expect(result).toContain('Task Title')
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing Error Handling
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
it('should handle missing configuration gracefully', () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
timelineEngine.processCsv('data', null)
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith('No config or fieldMapping found.')
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Debugging Tests
|
||||||
|
|
||||||
|
### Using Test UI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:ui
|
||||||
|
```
|
||||||
|
|
||||||
|
Opens browser interface with:
|
||||||
|
- Test results visualization
|
||||||
|
- Coverage reports
|
||||||
|
- Test file exploration
|
||||||
|
- Real-time test watching
|
||||||
|
|
||||||
|
### Console Debugging
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
it('should debug test data', () => {
|
||||||
|
console.log('Debug data:', testData)
|
||||||
|
// Test continues...
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Adding New Tests
|
||||||
|
|
||||||
|
1. **Create test file** in `test/` directory
|
||||||
|
2. **Import helpers** from `test/testHelpers.js`
|
||||||
|
3. **Follow naming convention**: `*.test.js`
|
||||||
|
4. **Group related tests** with `describe()` blocks
|
||||||
|
5. **Use descriptive test names** with `it('should ...')`
|
||||||
|
|
||||||
|
## CI Integration
|
||||||
|
|
||||||
|
Tests can be easily integrated into CI pipelines:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# GitHub Actions example
|
||||||
|
- name: Run tests
|
||||||
|
run: npm test
|
||||||
|
|
||||||
|
- name: Upload coverage
|
||||||
|
run: npm run test:coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Test behavior, not implementation**
|
||||||
|
2. **Use descriptive test names**
|
||||||
|
3. **Keep tests focused and small**
|
||||||
|
4. **Mock external dependencies**
|
||||||
|
5. **Test both success and error cases**
|
||||||
|
6. **Maintain test data helpers**
|
||||||
|
7. **Run tests frequently during development**
|
||||||
|
|
||||||
|
This test infrastructure enables confident refactoring and feature development while maintaining code quality and preventing regressions.
|
||||||
27
package.json
Normal file
27
package.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "timeline-svg-generator",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Browser-based system for generating multi-lane timelines as SVG graphics",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"test": "vitest",
|
||||||
|
"test:watch": "vitest --watch",
|
||||||
|
"test:coverage": "vitest --coverage",
|
||||||
|
"test:ui": "vitest --ui"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitest/ui": "^2.1.0",
|
||||||
|
"@vitest/coverage-v8": "^2.1.0",
|
||||||
|
"jsdom": "^25.0.0",
|
||||||
|
"vitest": "^2.1.0"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"timeline",
|
||||||
|
"svg",
|
||||||
|
"visualization",
|
||||||
|
"project-management",
|
||||||
|
"gantt"
|
||||||
|
],
|
||||||
|
"author": "",
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
180
test/engine.test.js
Normal file
180
test/engine.test.js
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
import { setupBasicDOM, createMockElement } from './setup.js'
|
||||||
|
import { createSampleProject, createSampleCSV, mockFetch, expectElementToHaveText } from './testHelpers.js'
|
||||||
|
|
||||||
|
// Import the engine by loading it as text and evaluating
|
||||||
|
// This is needed because engine.js creates a global object
|
||||||
|
const fs = await import('fs/promises')
|
||||||
|
const engineCode = await fs.readFile('./engine.js', 'utf-8')
|
||||||
|
|
||||||
|
describe('Timeline Engine', () => {
|
||||||
|
let timelineEngine
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
setupBasicDOM()
|
||||||
|
|
||||||
|
// Reset global window object
|
||||||
|
global.window = global
|
||||||
|
global.timelineGenerator = { generate: vi.fn() }
|
||||||
|
|
||||||
|
// Execute engine code to create timelineEngine
|
||||||
|
eval(engineCode)
|
||||||
|
timelineEngine = global.window.timelineEngine
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('parseDate', () => {
|
||||||
|
it('should parse YYYY-MM-DD format', () => {
|
||||||
|
const result = timelineEngine.parseDate('2025-12-15')
|
||||||
|
expect(result).toEqual(new Date(2025, 11, 15))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should parse YYYY/MM/DD format', () => {
|
||||||
|
const result = timelineEngine.parseDate('2025/12/15')
|
||||||
|
expect(result).toEqual(new Date(2025, 11, 15))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should parse DD.MM.YYYY format', () => {
|
||||||
|
const result = timelineEngine.parseDate('15.12.2025')
|
||||||
|
expect(result).toEqual(new Date(2025, 11, 15))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return null for invalid dates', () => {
|
||||||
|
expect(timelineEngine.parseDate('invalid')).toBeNull()
|
||||||
|
expect(timelineEngine.parseDate('')).toBeNull()
|
||||||
|
expect(timelineEngine.parseDate(null)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle single digit months and days', () => {
|
||||||
|
const result = timelineEngine.parseDate('2025-1-5')
|
||||||
|
expect(result).toEqual(new Date(2025, 0, 5))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('loadProjectConfigObject', () => {
|
||||||
|
it('should load project configuration and update DOM', async () => {
|
||||||
|
const config = createSampleProject()
|
||||||
|
|
||||||
|
mockFetch('<svg></svg>')
|
||||||
|
mockFetch(createSampleCSV())
|
||||||
|
|
||||||
|
await timelineEngine.loadProjectConfigObject(config)
|
||||||
|
|
||||||
|
expect(timelineEngine.config).toEqual(config)
|
||||||
|
expectElementToHaveText('#projectName', 'Test Project')
|
||||||
|
expectElementToHaveText('#projectSubtitle', 'A test project for unit testing')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle missing stylesheet gracefully', async () => {
|
||||||
|
const config = { name: 'Test', description: 'Test desc' }
|
||||||
|
|
||||||
|
await timelineEngine.loadProjectConfigObject(config)
|
||||||
|
|
||||||
|
expect(timelineEngine.config).toEqual(config)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not override CSS when cssOverride is true', async () => {
|
||||||
|
timelineEngine.cssOverride = true
|
||||||
|
const config = createSampleProject()
|
||||||
|
|
||||||
|
await timelineEngine.loadProjectConfigObject(config)
|
||||||
|
|
||||||
|
expect(document.getElementById('dynamicCss').href).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not load CSV when csvOverride is true', async () => {
|
||||||
|
timelineEngine.csvOverride = true
|
||||||
|
const config = createSampleProject()
|
||||||
|
const processCsvSpy = vi.spyOn(timelineEngine, 'processCsv')
|
||||||
|
|
||||||
|
await timelineEngine.loadProjectConfigObject(config)
|
||||||
|
|
||||||
|
expect(processCsvSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('processCsv', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
timelineEngine.config = createSampleProject()
|
||||||
|
global.Papa.parse.mockImplementation((text, options) => {
|
||||||
|
const mockData = [
|
||||||
|
{ ID: 'T-1', Title: 'Task 1', Lane: 'Dev', Due: '2025-01-15' },
|
||||||
|
{ ID: 'T-2', Title: 'Task 2', Lane: 'Test', Due: '2025-02-20' },
|
||||||
|
{ ID: '', Title: '', Lane: '', Due: '' } // Empty row should be filtered
|
||||||
|
]
|
||||||
|
options.complete({ data: mockData })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should process CSV and generate timeline', () => {
|
||||||
|
global.timelineGenerator.generate.mockReturnValue('<svg>test</svg>')
|
||||||
|
|
||||||
|
timelineEngine.processCsv(createSampleCSV())
|
||||||
|
|
||||||
|
expect(global.Papa.parse).toHaveBeenCalled()
|
||||||
|
expect(global.timelineGenerator.generate).toHaveBeenCalled()
|
||||||
|
expect(document.getElementById('viewer').innerHTML).toBe('<svg>test</svg>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should filter out items without title or due date', () => {
|
||||||
|
global.timelineGenerator.generate.mockImplementation((items) => {
|
||||||
|
expect(items).toHaveLength(2) // Empty row should be filtered out
|
||||||
|
return '<svg>test</svg>'
|
||||||
|
})
|
||||||
|
|
||||||
|
timelineEngine.processCsv(createSampleCSV())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle missing config gracefully', () => {
|
||||||
|
timelineEngine.config = null
|
||||||
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
|
||||||
|
timelineEngine.processCsv(createSampleCSV())
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith('No config or fieldMapping found.')
|
||||||
|
consoleSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show message when no valid items found', () => {
|
||||||
|
global.Papa.parse.mockImplementation((text, options) => {
|
||||||
|
options.complete({ data: [] })
|
||||||
|
})
|
||||||
|
|
||||||
|
timelineEngine.processCsv('')
|
||||||
|
|
||||||
|
expect(document.getElementById('viewer').innerHTML).toContain('Keine gültigen Items gefunden')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should enable download button after successful generation', () => {
|
||||||
|
global.timelineGenerator.generate.mockReturnValue('<svg>test</svg>')
|
||||||
|
const downloadBtn = document.getElementById('downloadSvg')
|
||||||
|
|
||||||
|
timelineEngine.processCsv(createSampleCSV())
|
||||||
|
|
||||||
|
expect(downloadBtn.disabled).toBe(false)
|
||||||
|
expect(downloadBtn.style.opacity).toBe('1')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('autoLoadDefaultProject', () => {
|
||||||
|
it('should try binect/project.json first, then example/project.json', async () => {
|
||||||
|
// First call (binect) fails
|
||||||
|
global.fetch.mockResolvedValueOnce({ ok: false })
|
||||||
|
// Second call (example) succeeds
|
||||||
|
mockFetch(createSampleProject())
|
||||||
|
mockFetch('<svg></svg>')
|
||||||
|
mockFetch(createSampleCSV())
|
||||||
|
|
||||||
|
await timelineEngine.autoLoadDefaultProject()
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith('binect/project.json')
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith('example/project.json')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle all fetch failures gracefully', async () => {
|
||||||
|
global.fetch.mockRejectedValue(new Error('Network error'))
|
||||||
|
|
||||||
|
// Should not throw
|
||||||
|
await expect(timelineEngine.autoLoadDefaultProject()).resolves.toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
167
test/generator.test.js
Normal file
167
test/generator.test.js
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { createSampleItems, createSampleProject, createSampleTemplate } from './testHelpers.js'
|
||||||
|
|
||||||
|
// Import generator by loading it as text and evaluating
|
||||||
|
const fs = await import('fs/promises')
|
||||||
|
const generatorCode = await fs.readFile('./generator.js', 'utf-8')
|
||||||
|
|
||||||
|
describe('Timeline Generator', () => {
|
||||||
|
let timelineGenerator
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset global window object
|
||||||
|
global.window = global
|
||||||
|
|
||||||
|
// Execute generator code
|
||||||
|
eval(generatorCode)
|
||||||
|
timelineGenerator = global.window.timelineGenerator
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('escapeXml', () => {
|
||||||
|
it('should escape XML special characters', () => {
|
||||||
|
const input = '<test & "quotes" & \'apostrophes\'>'
|
||||||
|
const expected = '<test & "quotes" & 'apostrophes'>'
|
||||||
|
expect(timelineGenerator.escapeXml(input)).toBe(expected)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle empty and null values', () => {
|
||||||
|
expect(timelineGenerator.escapeXml('')).toBe('')
|
||||||
|
expect(timelineGenerator.escapeXml(null)).toBe('null')
|
||||||
|
expect(timelineGenerator.escapeXml(undefined)).toBe('undefined')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should convert non-strings to strings', () => {
|
||||||
|
expect(timelineGenerator.escapeXml(123)).toBe('123')
|
||||||
|
expect(timelineGenerator.escapeXml(true)).toBe('true')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('generate', () => {
|
||||||
|
let items, config
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
items = createSampleItems()
|
||||||
|
config = createSampleProject()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should generate SVG with template placeholders', () => {
|
||||||
|
const template = createSampleTemplate()
|
||||||
|
const result = timelineGenerator.generate(items, config, template)
|
||||||
|
|
||||||
|
expect(result).toContain('<svg xmlns="http://www.w3.org/2000/svg">')
|
||||||
|
expect(result).toContain('<rect width="100%" height="100%" fill="#FFFFFF"/>')
|
||||||
|
expect(result).not.toContain('{{MONTHS}}')
|
||||||
|
expect(result).not.toContain('{{LANES}}')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should generate fallback SVG when no template provided', () => {
|
||||||
|
const result = timelineGenerator.generate(items, config, null)
|
||||||
|
|
||||||
|
expect(result).toContain('<svg xmlns="http://www.w3.org/2000/svg"')
|
||||||
|
expect(result).toContain('width=')
|
||||||
|
expect(result).toContain('height=')
|
||||||
|
expect(result).toContain('<rect width="100%" height="100%" fill="#FFFFFF"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should create month labels and grid lines', () => {
|
||||||
|
const result = timelineGenerator.generate(items, config, null)
|
||||||
|
|
||||||
|
expect(result).toContain('<line')
|
||||||
|
expect(result).toContain('stroke="#E3E8EF"')
|
||||||
|
expect(result).toContain('<text')
|
||||||
|
expect(result).toContain('fill="#5C6B7A"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should create lane backgrounds and labels', () => {
|
||||||
|
const result = timelineGenerator.generate(items, config, null)
|
||||||
|
|
||||||
|
expect(result).toContain('Development')
|
||||||
|
expect(result).toContain('Testing')
|
||||||
|
expect(result).toContain('<rect')
|
||||||
|
expect(result).toContain('fill="#FFFFFF"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should position items correctly in lanes', () => {
|
||||||
|
const result = timelineGenerator.generate(items, config, null)
|
||||||
|
|
||||||
|
expect(result).toContain('<circle')
|
||||||
|
expect(result).toContain('fill="#0A4D8C"')
|
||||||
|
expect(result).toContain('T-1')
|
||||||
|
expect(result).toContain('First Task')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should sort items by due date within lanes', () => {
|
||||||
|
// Add items with same lane but different dates
|
||||||
|
const unsortedItems = [
|
||||||
|
{ id: 'T-3', title: 'Third', lane: 'Dev', due: new Date('2025-03-01') },
|
||||||
|
{ id: 'T-1', title: 'First', lane: 'Dev', due: new Date('2025-01-01') },
|
||||||
|
{ id: 'T-2', title: 'Second', lane: 'Dev', due: new Date('2025-02-01') }
|
||||||
|
]
|
||||||
|
|
||||||
|
const result = timelineGenerator.generate(unsortedItems, config, null)
|
||||||
|
const firstIndex = result.indexOf('First')
|
||||||
|
const secondIndex = result.indexOf('Second')
|
||||||
|
const thirdIndex = result.indexOf('Third')
|
||||||
|
|
||||||
|
expect(firstIndex).toBeLessThan(secondIndex)
|
||||||
|
expect(secondIndex).toBeLessThan(thirdIndex)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle items without lanes', () => {
|
||||||
|
const itemsNoLane = [
|
||||||
|
{ id: 'T-1', title: 'No Lane Task', lane: null, due: new Date('2025-01-01') }
|
||||||
|
]
|
||||||
|
|
||||||
|
const result = timelineGenerator.generate(itemsNoLane, config, null)
|
||||||
|
expect(result).toContain('Ohne Epic')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should respect timelineMonths setting', () => {
|
||||||
|
const shortConfig = { ...config, settings: { timelineMonths: 6 } }
|
||||||
|
const result = timelineGenerator.generate(items, shortConfig, null)
|
||||||
|
|
||||||
|
// Should create 6 months worth of grid lines
|
||||||
|
const lineCount = (result.match(/<line/g) || []).length
|
||||||
|
expect(lineCount).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should escape special characters in item text', () => {
|
||||||
|
const itemsWithSpecialChars = [{
|
||||||
|
id: 'T&1',
|
||||||
|
title: 'Task with <special> & "characters"',
|
||||||
|
lane: 'Development',
|
||||||
|
due: new Date('2025-01-01')
|
||||||
|
}]
|
||||||
|
|
||||||
|
const result = timelineGenerator.generate(itemsWithSpecialChars, config, null)
|
||||||
|
|
||||||
|
expect(result).toContain('T&1')
|
||||||
|
expect(result).toContain('<special> & "characters"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should determine start date from earliest item', () => {
|
||||||
|
const itemsWithEarlyDate = [
|
||||||
|
{ id: 'T-1', title: 'Early', lane: 'Dev', due: new Date('2024-06-15') },
|
||||||
|
{ id: 'T-2', title: 'Late', lane: 'Dev', due: new Date('2025-12-01') }
|
||||||
|
]
|
||||||
|
|
||||||
|
const result = timelineGenerator.generate(itemsWithEarlyDate, config, null)
|
||||||
|
|
||||||
|
// Should start from June 2024 (first day of month)
|
||||||
|
expect(result).toContain('Jun 24')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should clamp item positions to timeline bounds', () => {
|
||||||
|
const itemsOutOfRange = [
|
||||||
|
{ id: 'T-1', title: 'In Range', lane: 'Dev', due: new Date('2025-01-01') },
|
||||||
|
{ id: 'T-2', title: 'Way Future', lane: 'Dev', due: new Date('2030-01-01') }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Should not throw and should generate valid SVG
|
||||||
|
const result = timelineGenerator.generate(itemsOutOfRange, config, null)
|
||||||
|
expect(result).toContain('<svg')
|
||||||
|
expect(result).toContain('T-1')
|
||||||
|
expect(result).toContain('T-2')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
246
test/integration.test.js
Normal file
246
test/integration.test.js
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
import { setupBasicDOM } from './setup.js'
|
||||||
|
import { createSampleProject, createSampleCSV, createSampleTemplate, mockFetch } from './testHelpers.js'
|
||||||
|
|
||||||
|
// Import both engine and generator
|
||||||
|
const fs = await import('fs/promises')
|
||||||
|
const engineCode = await fs.readFile('./engine.js', 'utf-8')
|
||||||
|
const generatorCode = await fs.readFile('./generator.js', 'utf-8')
|
||||||
|
|
||||||
|
describe('Timeline Integration', () => {
|
||||||
|
let timelineEngine, timelineGenerator
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setupBasicDOM()
|
||||||
|
|
||||||
|
// Reset global window object
|
||||||
|
global.window = global
|
||||||
|
|
||||||
|
// Execute both modules
|
||||||
|
eval(generatorCode)
|
||||||
|
eval(engineCode)
|
||||||
|
|
||||||
|
timelineEngine = global.window.timelineEngine
|
||||||
|
timelineGenerator = global.window.timelineGenerator
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('End-to-End Timeline Generation', () => {
|
||||||
|
it('should load project, process CSV, and generate timeline', async () => {
|
||||||
|
const config = createSampleProject()
|
||||||
|
const csvData = createSampleCSV()
|
||||||
|
const template = createSampleTemplate()
|
||||||
|
|
||||||
|
// Mock fetch calls in order: template, CSV
|
||||||
|
mockFetch(template)
|
||||||
|
mockFetch(csvData)
|
||||||
|
|
||||||
|
// Mock Papa.parse to process the CSV
|
||||||
|
global.Papa.parse.mockImplementation((text, options) => {
|
||||||
|
const lines = text.trim().split('\n')
|
||||||
|
const headers = lines[0].split(',')
|
||||||
|
const data = lines.slice(1).map(line => {
|
||||||
|
const values = line.split(',')
|
||||||
|
const obj = {}
|
||||||
|
headers.forEach((header, i) => {
|
||||||
|
obj[header] = values[i]
|
||||||
|
})
|
||||||
|
return obj
|
||||||
|
})
|
||||||
|
options.complete({ data })
|
||||||
|
})
|
||||||
|
|
||||||
|
await timelineEngine.loadProjectConfigObject(config)
|
||||||
|
|
||||||
|
// Verify project loaded
|
||||||
|
expect(document.getElementById('projectName').textContent).toBe('Test Project')
|
||||||
|
expect(document.getElementById('projectSubtitle').textContent).toBe('A test project for unit testing')
|
||||||
|
|
||||||
|
// Verify timeline generated
|
||||||
|
const viewer = document.getElementById('viewer')
|
||||||
|
expect(viewer.innerHTML).toContain('<svg')
|
||||||
|
expect(viewer.innerHTML).toContain('First Task')
|
||||||
|
expect(viewer.innerHTML).toContain('Development')
|
||||||
|
|
||||||
|
// Verify download button enabled
|
||||||
|
const downloadBtn = document.getElementById('downloadSvg')
|
||||||
|
expect(downloadBtn.disabled).toBe(false)
|
||||||
|
expect(downloadBtn.style.opacity).toBe('1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle CSV override correctly', async () => {
|
||||||
|
const config = createSampleProject()
|
||||||
|
const originalCSV = createSampleCSV()
|
||||||
|
const overrideCSV = 'ID,Title,Lane,Due\nO-1,Override Task,Override Lane,2025-06-01'
|
||||||
|
|
||||||
|
// First load project with original CSV
|
||||||
|
mockFetch(createSampleTemplate())
|
||||||
|
mockFetch(originalCSV)
|
||||||
|
|
||||||
|
global.Papa.parse.mockImplementation((text, options) => {
|
||||||
|
const isOverride = text.includes('Override Task')
|
||||||
|
const mockData = isOverride ?
|
||||||
|
[{ ID: 'O-1', Title: 'Override Task', Lane: 'Override Lane', Due: '2025-06-01' }] :
|
||||||
|
[{ ID: 'T-1', Title: 'First Task', Lane: 'Development', Due: '2025-01-15' }]
|
||||||
|
options.complete({ data: mockData })
|
||||||
|
})
|
||||||
|
|
||||||
|
await timelineEngine.loadProjectConfigObject(config)
|
||||||
|
expect(document.getElementById('viewer').innerHTML).toContain('First Task')
|
||||||
|
|
||||||
|
// Then override CSV
|
||||||
|
timelineEngine.csvOverride = true
|
||||||
|
timelineEngine.processCsv(overrideCSV)
|
||||||
|
expect(document.getElementById('viewer').innerHTML).toContain('Override Task')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle template with custom macros', async () => {
|
||||||
|
const config = createSampleProject()
|
||||||
|
const customTemplate = `<svg xmlns="http://www.w3.org/2000/svg" width="800" height="600">
|
||||||
|
<g class="timeline-months">{{MONTHS}}</g>
|
||||||
|
<g class="timeline-lanes">{{LANES}}</g>
|
||||||
|
</svg>`
|
||||||
|
|
||||||
|
mockFetch(customTemplate)
|
||||||
|
mockFetch(createSampleCSV())
|
||||||
|
|
||||||
|
global.Papa.parse.mockImplementation((text, options) => {
|
||||||
|
options.complete({
|
||||||
|
data: [{ ID: 'T-1', Title: 'Test Task', Lane: 'Test Lane', Due: '2025-01-15' }]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
await timelineEngine.loadProjectConfigObject(config)
|
||||||
|
|
||||||
|
const viewer = document.getElementById('viewer')
|
||||||
|
const svg = viewer.innerHTML
|
||||||
|
|
||||||
|
expect(svg).toContain('<g class="timeline-months">')
|
||||||
|
expect(svg).toContain('<g class="timeline-lanes">')
|
||||||
|
expect(svg).not.toContain('{{MONTHS}}')
|
||||||
|
expect(svg).not.toContain('{{LANES}}')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle project load failures gracefully', async () => {
|
||||||
|
// Mock fetch failures
|
||||||
|
global.fetch.mockRejectedValue(new Error('Network error'))
|
||||||
|
|
||||||
|
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||||
|
|
||||||
|
await timelineEngine.autoLoadDefaultProject()
|
||||||
|
|
||||||
|
// Should not crash, just log warnings
|
||||||
|
expect(consoleSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
|
consoleSpy.mockRestore()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('DOM Event Handling', () => {
|
||||||
|
it('should handle project file upload', async () => {
|
||||||
|
const config = createSampleProject()
|
||||||
|
const projectInput = document.createElement('input')
|
||||||
|
projectInput.id = 'projectInput'
|
||||||
|
document.body.appendChild(projectInput)
|
||||||
|
|
||||||
|
// Mock file reading
|
||||||
|
const mockFile = new File([JSON.stringify(config)], 'project.json', { type: 'application/json' })
|
||||||
|
mockFile.text = vi.fn().mockResolvedValue(JSON.stringify(config))
|
||||||
|
|
||||||
|
// Mock the fetch calls that loadProjectConfigObject will make
|
||||||
|
mockFetch(createSampleTemplate())
|
||||||
|
mockFetch(createSampleCSV())
|
||||||
|
|
||||||
|
global.Papa.parse.mockImplementation((text, options) => {
|
||||||
|
options.complete({ data: [] })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Simulate file selection
|
||||||
|
Object.defineProperty(projectInput, 'files', {
|
||||||
|
value: [mockFile],
|
||||||
|
writable: false
|
||||||
|
})
|
||||||
|
|
||||||
|
// Trigger the event
|
||||||
|
const event = new Event('change')
|
||||||
|
projectInput.dispatchEvent(event)
|
||||||
|
|
||||||
|
// Wait for async operations
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0))
|
||||||
|
|
||||||
|
expect(document.getElementById('projectName').textContent).toBe('Test Project')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle CSV file upload', async () => {
|
||||||
|
const config = createSampleProject()
|
||||||
|
timelineEngine.config = config
|
||||||
|
|
||||||
|
const csvInput = document.createElement('input')
|
||||||
|
csvInput.id = 'csvInput'
|
||||||
|
document.body.appendChild(csvInput)
|
||||||
|
|
||||||
|
const csvContent = createSampleCSV()
|
||||||
|
const mockFile = new File([csvContent], 'data.csv', { type: 'text/csv' })
|
||||||
|
mockFile.text = vi.fn().mockResolvedValue(csvContent)
|
||||||
|
|
||||||
|
global.Papa.parse.mockImplementation((text, options) => {
|
||||||
|
options.complete({
|
||||||
|
data: [{ ID: 'T-1', Title: 'Uploaded Task', Lane: 'Upload Lane', Due: '2025-01-15' }]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Object.defineProperty(csvInput, 'files', {
|
||||||
|
value: [mockFile],
|
||||||
|
writable: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const event = new Event('change')
|
||||||
|
csvInput.dispatchEvent(event)
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0))
|
||||||
|
|
||||||
|
expect(timelineEngine.csvOverride).toBe(true)
|
||||||
|
expect(document.getElementById('viewer').innerHTML).toContain('Uploaded Task')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('SVG Export', () => {
|
||||||
|
it('should hide IDs in external view for export', async () => {
|
||||||
|
// Setup timeline with items
|
||||||
|
const config = createSampleProject()
|
||||||
|
mockFetch(createSampleTemplate())
|
||||||
|
mockFetch(createSampleCSV())
|
||||||
|
|
||||||
|
global.Papa.parse.mockImplementation((text, options) => {
|
||||||
|
options.complete({
|
||||||
|
data: [{ ID: 'T-1', Title: 'Export Task', Lane: 'Export Lane', Due: '2025-01-15' }]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
await timelineEngine.loadProjectConfigObject(config)
|
||||||
|
|
||||||
|
// Mock the download functionality
|
||||||
|
const mockCreateElement = vi.spyOn(document, 'createElement').mockImplementation((tagName) => {
|
||||||
|
if (tagName === 'a') {
|
||||||
|
return {
|
||||||
|
href: '',
|
||||||
|
download: '',
|
||||||
|
click: vi.fn()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return document.createElement(tagName)
|
||||||
|
})
|
||||||
|
|
||||||
|
global.URL.createObjectURL = vi.fn().mockReturnValue('blob:url')
|
||||||
|
global.URL.revokeObjectURL = vi.fn()
|
||||||
|
|
||||||
|
const downloadBtn = document.getElementById('downloadSvg')
|
||||||
|
downloadBtn.click()
|
||||||
|
|
||||||
|
// Verify that item-id elements would be hidden
|
||||||
|
const svg = document.querySelector('#viewer svg')
|
||||||
|
expect(svg).toBeTruthy()
|
||||||
|
|
||||||
|
mockCreateElement.mockRestore()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
39
test/setup.js
Normal file
39
test/setup.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
// Test setup file for Vitest
|
||||||
|
import { vi } from 'vitest'
|
||||||
|
|
||||||
|
// Mock PapaParse (since it's loaded via CDN)
|
||||||
|
global.Papa = {
|
||||||
|
parse: vi.fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock fetch for loading files
|
||||||
|
global.fetch = vi.fn()
|
||||||
|
|
||||||
|
// Setup DOM helpers
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset DOM
|
||||||
|
document.head.innerHTML = ''
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
|
||||||
|
// Clear all mocks
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create helper functions for DOM setup
|
||||||
|
export const createMockElement = (tagName, attributes = {}) => {
|
||||||
|
const element = document.createElement(tagName)
|
||||||
|
Object.entries(attributes).forEach(([key, value]) => {
|
||||||
|
element.setAttribute(key, value)
|
||||||
|
})
|
||||||
|
return element
|
||||||
|
}
|
||||||
|
|
||||||
|
export const setupBasicDOM = () => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div id="projectName"></div>
|
||||||
|
<div id="projectSubtitle"></div>
|
||||||
|
<div id="viewer"></div>
|
||||||
|
<link id="dynamicCss" rel="stylesheet" href="">
|
||||||
|
<button id="downloadSvg"></button>
|
||||||
|
`
|
||||||
|
}
|
||||||
70
test/testHelpers.js
Normal file
70
test/testHelpers.js
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
// Test helpers and utilities
|
||||||
|
|
||||||
|
export const createSampleProject = () => ({
|
||||||
|
name: "Test Project",
|
||||||
|
description: "A test project for unit testing",
|
||||||
|
dataSource: "test.csv",
|
||||||
|
stylesheet: "test.css",
|
||||||
|
svgTemplate: "test.svg",
|
||||||
|
settings: {
|
||||||
|
timelineMonths: 12
|
||||||
|
},
|
||||||
|
fieldMapping: {
|
||||||
|
id: "ID",
|
||||||
|
title: "Title",
|
||||||
|
lane: "Lane",
|
||||||
|
due: ["Due"]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const createSampleItems = () => [
|
||||||
|
{
|
||||||
|
id: "T-1",
|
||||||
|
title: "First Task",
|
||||||
|
lane: "Development",
|
||||||
|
due: new Date("2025-01-15")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "T-2",
|
||||||
|
title: "Second Task",
|
||||||
|
lane: "Testing",
|
||||||
|
due: new Date("2025-02-20")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "T-3",
|
||||||
|
title: "Third Task",
|
||||||
|
lane: "Development",
|
||||||
|
due: new Date("2025-03-10")
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export const createSampleCSV = () => `ID,Title,Lane,Due
|
||||||
|
T-1,First Task,Development,2025-01-15
|
||||||
|
T-2,Second Task,Testing,2025-02-20
|
||||||
|
T-3,Third Task,Development,2025-03-10`
|
||||||
|
|
||||||
|
export const createSampleTemplate = () => `<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="100%" height="100%" fill="#FFFFFF"/>
|
||||||
|
{{MONTHS}}
|
||||||
|
{{LANES}}
|
||||||
|
</svg>`
|
||||||
|
|
||||||
|
export const mockFetch = (data, ok = true) => {
|
||||||
|
global.fetch.mockResolvedValueOnce({
|
||||||
|
ok,
|
||||||
|
json: () => Promise.resolve(data),
|
||||||
|
text: () => Promise.resolve(typeof data === 'string' ? data : JSON.stringify(data))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const expectElementToHaveText = (selector, text) => {
|
||||||
|
const element = document.querySelector(selector)
|
||||||
|
expect(element).toBeTruthy()
|
||||||
|
expect(element.textContent).toContain(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const expectSVGToContain = (selector, expectedContent) => {
|
||||||
|
const svg = document.querySelector(selector)
|
||||||
|
expect(svg).toBeTruthy()
|
||||||
|
expect(svg.outerHTML).toContain(expectedContent)
|
||||||
|
}
|
||||||
18
vitest.config.js
Normal file
18
vitest.config.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
globals: true,
|
||||||
|
setupFiles: ['./test/setup.js'],
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
reporter: ['text', 'json', 'html'],
|
||||||
|
exclude: [
|
||||||
|
'node_modules/',
|
||||||
|
'test/',
|
||||||
|
'**/*.config.js'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user