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:
2025-11-18 23:36:22 +01:00
parent eeebdd72d5
commit 2090e372bd
10 changed files with 1320 additions and 0 deletions

180
test/engine.test.js Normal file
View 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
View 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 = '&lt;test &amp; &quot;quotes&quot; &amp; &#39;apostrophes&#39;&gt;'
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&amp;1')
expect(result).toContain('&lt;special&gt; &amp; &quot;characters&quot;')
})
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
View 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
View 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
View 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)
}