From 2090e372bdf75e7a1ec716832fa67676537cfe2f Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 18 Nov 2025 23:36:22 +0100 Subject: [PATCH] add: comprehensive TDD test infrastructure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- DEPENDS.md | 189 ++++++++++++++++++++++++++++++ Makefile | 139 ++++++++++++++++++++++ TESTING.md | 245 ++++++++++++++++++++++++++++++++++++++ package.json | 27 +++++ test/engine.test.js | 180 ++++++++++++++++++++++++++++ test/generator.test.js | 167 ++++++++++++++++++++++++++ test/integration.test.js | 246 +++++++++++++++++++++++++++++++++++++++ test/setup.js | 39 +++++++ test/testHelpers.js | 70 +++++++++++ vitest.config.js | 18 +++ 10 files changed, 1320 insertions(+) create mode 100644 DEPENDS.md create mode 100644 Makefile create mode 100644 TESTING.md create mode 100644 package.json create mode 100644 test/engine.test.js create mode 100644 test/generator.test.js create mode 100644 test/integration.test.js create mode 100644 test/setup.js create mode 100644 test/testHelpers.js create mode 100644 vitest.config.js diff --git a/DEPENDS.md b/DEPENDS.md new file mode 100644 index 0000000..562c03b --- /dev/null +++ b/DEPENDS.md @@ -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 \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..291741e --- /dev/null +++ b/Makefile @@ -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" \ No newline at end of file diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..b65b393 --- /dev/null +++ b/TESTING.md @@ -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('') + 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. \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..b7f60c7 --- /dev/null +++ b/package.json @@ -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" +} \ No newline at end of file diff --git a/test/engine.test.js b/test/engine.test.js new file mode 100644 index 0000000..e517cc7 --- /dev/null +++ b/test/engine.test.js @@ -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('') + 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('test') + + timelineEngine.processCsv(createSampleCSV()) + + expect(global.Papa.parse).toHaveBeenCalled() + expect(global.timelineGenerator.generate).toHaveBeenCalled() + expect(document.getElementById('viewer').innerHTML).toBe('test') + }) + + 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 'test' + }) + + 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('test') + 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('') + 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() + }) + }) +}) \ No newline at end of file diff --git a/test/generator.test.js b/test/generator.test.js new file mode 100644 index 0000000..6924818 --- /dev/null +++ b/test/generator.test.js @@ -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 = '' + 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('') + expect(result).toContain('') + 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(' { + const result = timelineGenerator.generate(items, config, null) + + expect(result).toContain(' { + const result = timelineGenerator.generate(items, config, null) + + expect(result).toContain('Development') + expect(result).toContain('Testing') + expect(result).toContain(' { + const result = timelineGenerator.generate(items, config, null) + + expect(result).toContain(' { + // 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(/ { + const itemsWithSpecialChars = [{ + id: 'T&1', + title: 'Task with & "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(' { + 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(' { + 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 = ` + {{MONTHS}} + {{LANES}} + ` + + 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('') + expect(svg).toContain('') + 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() + }) + }) +}) \ No newline at end of file diff --git a/test/setup.js b/test/setup.js new file mode 100644 index 0000000..4e499b1 --- /dev/null +++ b/test/setup.js @@ -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 = ` +
+
+
+ + + ` +} \ No newline at end of file diff --git a/test/testHelpers.js b/test/testHelpers.js new file mode 100644 index 0000000..54590f0 --- /dev/null +++ b/test/testHelpers.js @@ -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 = () => ` + + {{MONTHS}} + {{LANES}} +` + +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) +} \ No newline at end of file diff --git a/vitest.config.js b/vitest.config.js new file mode 100644 index 0000000..09bdd3b --- /dev/null +++ b/vitest.config.js @@ -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' + ] + } + } +}) \ No newline at end of file