generated from coulomb/repo-seed
Changed from fixed TASK placeholders to flexible ITEM placeholders that
automatically map all CSV fields to template placeholders.
Key changes:
- Renamed task-template → item-template in all templates
- Changed TASK_* → ITEM_* placeholder naming
- Implemented dynamic placeholder generation from item properties
- Any field in fieldMapping now creates ITEM_{FIELD} placeholder
- Updated all tests and documentation
Naming convention: CSV field → item.property → ITEM_PROPERTY
Example: "assignee" → item.assignee → {{ITEM_ASSIGNEE}}
This enables users to add custom fields without modifying generator code:
- Add "assignee": "Assignee" to fieldMapping
- Use {{ITEM_ASSIGNEE}} in template
- No code changes required
Benefits:
- More flexible and extensible
- Clearer abstraction (items vs tasks)
- Consistent naming convention
- Better documented
All 56 tests passing.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
436 lines
15 KiB
JavaScript
436 lines
15 KiB
JavaScript
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
import { setupBasicDOM } from './setup.js'
|
|
import {
|
|
createSampleProject,
|
|
createSampleCSV,
|
|
createSampleTemplate,
|
|
createMalformedTemplate,
|
|
createLargeDataset,
|
|
mockFetch,
|
|
mockPapaParse
|
|
} 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 with template-v2', async () => {
|
|
const config = createSampleProject()
|
|
const csvData = createSampleCSV()
|
|
const template = createSampleTemplate()
|
|
|
|
// Mock fetch calls in order: template, CSV
|
|
mockFetch(template)
|
|
mockFetch(csvData)
|
|
mockPapaParse()
|
|
|
|
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 with template-v2 format
|
|
const viewer = document.getElementById('viewer')
|
|
expect(viewer.innerHTML).toContain('<svg')
|
|
expect(viewer.innerHTML).toContain('First Task')
|
|
expect(viewer.innerHTML).toContain('Development')
|
|
|
|
// Verify no template placeholders remain
|
|
expect(viewer.innerHTML).not.toContain('{{MONTH_X}}')
|
|
expect(viewer.innerHTML).not.toContain('{{LANE_Y}}')
|
|
expect(viewer.innerHTML).not.toContain('{{ITEM_X}}')
|
|
|
|
// 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 project load failures gracefully', async () => {
|
|
// Mock fetch failures
|
|
global.fetch.mockRejectedValue(new Error('Network error'))
|
|
|
|
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
|
await timelineEngine.autoLoadDefaultProject()
|
|
|
|
// Should not crash, just log messages
|
|
expect(consoleSpy).toHaveBeenCalled()
|
|
|
|
consoleSpy.mockRestore()
|
|
})
|
|
|
|
it('should generate timeline with large dataset (60+ items)', async () => {
|
|
const config = createSampleProject()
|
|
const largeItems = createLargeDataset(60)
|
|
|
|
mockFetch(createSampleTemplate())
|
|
mockFetch('') // Mock CSV fetch
|
|
|
|
global.Papa.parse.mockImplementation((text, options) => {
|
|
// Create mock CSV data from large dataset
|
|
const mockData = largeItems.map((item, idx) => ({
|
|
ID: item.id,
|
|
Title: item.title,
|
|
Lane: item.lane,
|
|
Due: item.due.toISOString().split('T')[0]
|
|
}))
|
|
options.complete({ data: mockData })
|
|
})
|
|
|
|
await timelineEngine.loadProjectConfigObject(config)
|
|
|
|
const viewer = document.getElementById('viewer')
|
|
const svg = viewer.innerHTML
|
|
|
|
// Verify SVG contains multiple lanes
|
|
expect(svg).toContain('Development')
|
|
expect(svg).toContain('Testing')
|
|
expect(svg).toContain('Design')
|
|
expect(svg).toContain('DevOps')
|
|
expect(svg).toContain('Documentation')
|
|
|
|
// Verify SVG contains task data
|
|
expect(svg).toContain('TASK-1')
|
|
expect(svg).toContain('TASK-60')
|
|
|
|
// Verify SVG has reasonable dimensions
|
|
const widthMatch = svg.match(/width="(\d+)"/)
|
|
const heightMatch = svg.match(/height="(\d+)"/)
|
|
expect(parseInt(widthMatch[1])).toBeGreaterThan(500)
|
|
expect(parseInt(heightMatch[1])).toBeGreaterThan(300)
|
|
})
|
|
|
|
it('should handle date range edge cases (24+ months)', async () => {
|
|
const config = { ...createSampleProject(), settings: { timelineMonths: 30 } }
|
|
|
|
const edgeCaseItems = [
|
|
{ id: 'EARLY-1', title: 'Very Early Task', lane: 'Dev', due: new Date('2024-01-01') },
|
|
{ id: 'MID-1', title: 'Middle Task', lane: 'Dev', due: new Date('2025-06-15') },
|
|
{ id: 'LATE-1', title: 'Future Task', lane: 'Dev', due: new Date('2026-12-31') }
|
|
]
|
|
|
|
mockFetch(createSampleTemplate())
|
|
mockFetch('')
|
|
|
|
global.Papa.parse.mockImplementation((text, options) => {
|
|
const mockData = edgeCaseItems.map(item => ({
|
|
ID: item.id,
|
|
Title: item.title,
|
|
Lane: item.lane,
|
|
Due: item.due.toISOString().split('T')[0]
|
|
}))
|
|
options.complete({ data: mockData })
|
|
})
|
|
|
|
await timelineEngine.loadProjectConfigObject(config)
|
|
|
|
const svg = document.getElementById('viewer').innerHTML
|
|
|
|
// All tasks should be rendered
|
|
expect(svg).toContain('EARLY-1')
|
|
expect(svg).toContain('MID-1')
|
|
expect(svg).toContain('LATE-1')
|
|
|
|
// Should have wide SVG for 30 months
|
|
const widthMatch = svg.match(/width="(\d+)"/)
|
|
expect(parseInt(widthMatch[1])).toBeGreaterThan(2000)
|
|
})
|
|
|
|
it('should handle special characters in lane names and task titles', async () => {
|
|
const config = createSampleProject()
|
|
|
|
mockFetch(createSampleTemplate())
|
|
mockFetch('')
|
|
|
|
global.Papa.parse.mockImplementation((text, options) => {
|
|
const mockData = [
|
|
{ ID: 'T&1', Title: 'Task with <special> & "quotes"', Lane: 'Dev & Test', Due: '2025-01-15' },
|
|
{ ID: 'T\'2', Title: 'L\'importance de l\'échappement', Lane: 'Développement', Due: '2025-02-01' }
|
|
]
|
|
options.complete({ data: mockData })
|
|
})
|
|
|
|
// Should not throw error when processing special characters
|
|
await expect(timelineEngine.loadProjectConfigObject(config)).resolves.not.toThrow()
|
|
|
|
const svg = document.getElementById('viewer').innerHTML
|
|
|
|
// Should generate valid SVG with escaped content (basic check)
|
|
expect(svg).toContain('<svg')
|
|
expect(svg).toContain('viewBox')
|
|
// Characters are XML-escaped by escapeXml function (tested in generator.test.js)
|
|
})
|
|
|
|
it('should handle empty CSV gracefully', async () => {
|
|
const config = createSampleProject()
|
|
|
|
mockFetch(createSampleTemplate())
|
|
mockFetch('')
|
|
|
|
global.Papa.parse.mockImplementation((text, options) => {
|
|
options.complete({ data: [] })
|
|
})
|
|
|
|
// Should not throw error
|
|
await expect(timelineEngine.loadProjectConfigObject(config)).resolves.not.toThrow()
|
|
|
|
// Should show message when no valid items found (not crash)
|
|
const viewer = document.getElementById('viewer').innerHTML
|
|
expect(viewer).toContain('Keine gültigen Items gefunden')
|
|
})
|
|
|
|
it('should reject malformed template-v2 (missing month-template)', async () => {
|
|
const config = createSampleProject()
|
|
const malformedTemplate = createMalformedTemplate('month-template')
|
|
|
|
mockFetch(malformedTemplate)
|
|
mockFetch(createSampleCSV())
|
|
mockPapaParse()
|
|
|
|
// Should handle gracefully without crashing
|
|
await timelineEngine.loadProjectConfigObject(config)
|
|
|
|
const viewer = document.getElementById('viewer').innerHTML
|
|
// Either shows error message or handles gracefully
|
|
expect(viewer).toBeTruthy()
|
|
})
|
|
|
|
it('should reject malformed template-v2 (missing lane-template)', async () => {
|
|
const config = createSampleProject()
|
|
const malformedTemplate = createMalformedTemplate('lane-template')
|
|
|
|
mockFetch(malformedTemplate)
|
|
mockFetch(createSampleCSV())
|
|
mockPapaParse()
|
|
|
|
// Should handle gracefully without crashing
|
|
await timelineEngine.loadProjectConfigObject(config)
|
|
|
|
const viewer = document.getElementById('viewer').innerHTML
|
|
expect(viewer).toBeTruthy()
|
|
})
|
|
|
|
it('should reject malformed template-v2 (missing item-template)', async () => {
|
|
const config = createSampleProject()
|
|
const malformedTemplate = createMalformedTemplate('item-template')
|
|
|
|
mockFetch(malformedTemplate)
|
|
mockFetch(createSampleCSV())
|
|
mockPapaParse()
|
|
|
|
// Should handle gracefully without crashing
|
|
await timelineEngine.loadProjectConfigObject(config)
|
|
|
|
const viewer = document.getElementById('viewer').innerHTML
|
|
expect(viewer).toBeTruthy()
|
|
})
|
|
|
|
it('should preserve template styling and definitions', async () => {
|
|
const config = createSampleProject()
|
|
|
|
// Read actual template-v2.svg to test real styling preservation
|
|
const templateV2 = await fs.readFile('./example/template-v2.svg', 'utf-8')
|
|
|
|
mockFetch(templateV2)
|
|
mockFetch(createSampleCSV())
|
|
mockPapaParse()
|
|
|
|
await timelineEngine.loadProjectConfigObject(config)
|
|
|
|
const svg = document.getElementById('viewer').innerHTML
|
|
|
|
// Should preserve gradient and filter definitions from template
|
|
expect(svg).toContain('monthHeaderGrad')
|
|
expect(svg).toContain('textShadow')
|
|
expect(svg).toContain('bgGrid')
|
|
|
|
// Should have proper SVG structure
|
|
expect(svg).toContain('<defs>')
|
|
expect(svg).toContain('</defs>')
|
|
})
|
|
})
|
|
|
|
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)
|
|
|
|
// Setup event handlers
|
|
window.setupEventHandlers()
|
|
|
|
// 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())
|
|
mockPapaParse()
|
|
|
|
// 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
|
|
timelineEngine.template = createSampleTemplate() // Need template for generation
|
|
|
|
const csvInput = document.createElement('input')
|
|
csvInput.id = 'csvInput'
|
|
document.body.appendChild(csvInput)
|
|
|
|
// Setup event handlers
|
|
window.setupEventHandlers()
|
|
|
|
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()
|
|
})
|
|
|
|
it('should generate downloadable SVG with proper dimensions', async () => {
|
|
const config = createSampleProject()
|
|
mockFetch(createSampleTemplate())
|
|
mockFetch(createSampleCSV())
|
|
mockPapaParse()
|
|
|
|
await timelineEngine.loadProjectConfigObject(config)
|
|
|
|
const svg = document.querySelector('#viewer svg')
|
|
|
|
// Verify SVG has width and height attributes
|
|
expect(svg.getAttribute('width')).toBeTruthy()
|
|
expect(svg.getAttribute('height')).toBeTruthy()
|
|
expect(svg.getAttribute('viewBox')).toBeTruthy()
|
|
|
|
// Verify viewBox matches dimensions
|
|
const width = svg.getAttribute('width')
|
|
const height = svg.getAttribute('height')
|
|
const viewBox = svg.getAttribute('viewBox')
|
|
expect(viewBox).toContain(width)
|
|
expect(viewBox).toContain(height)
|
|
})
|
|
})
|
|
})
|