refactor: complete migration to template-v2 architecture

- Remove legacy template.svg files from example/ and my-project/
- Simplify generator.js by removing generateHardcoded method (326→210 lines, -36%)
- Add strict template validation with clear error messages
- Remove all fallback mechanisms - template-v2.svg format now required
- Clean up tests: remove hardcoded generation tests, keep template-based tests
- Add comprehensive e2e tests (large datasets, edge cases, error handling)
- Update documentation: mark REFACTORING_PLAN.md complete, add TEMPLATE_V2_GUIDE.md
- All 56 tests passing (16 engine + 25 generator + 15 integration)

BREAKING CHANGE: Old template.svg format no longer supported. Must use template-v2.svg with <g id="*-template"> elements in defs section.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-27 15:29:58 +01:00
parent b5c7c613de
commit c22a47f1ea
10 changed files with 936 additions and 517 deletions

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { createSampleItems, createSampleProject, createSampleTemplate } from './testHelpers.js'
import { createSampleItems, createSampleProject, createSampleTemplate, createMalformedTemplate } from './testHelpers.js'
// Import generator by loading it as text and evaluating
const fs = await import('fs/promises')
@@ -36,7 +36,7 @@ describe('Timeline Generator', () => {
})
})
describe('generate', () => {
describe('Template validation', () => {
let items, config
beforeEach(() => {
@@ -44,88 +44,166 @@ describe('Timeline Generator', () => {
config = createSampleProject()
})
it('should generate SVG with template placeholders', () => {
it('should throw error when template is not provided', () => {
expect(() => {
timelineGenerator.generate(items, config, null)
}).toThrow('Template is required')
})
it('should throw error when template is missing month-template', () => {
const malformedTemplate = createMalformedTemplate('month-template')
expect(() => {
timelineGenerator.generate(items, config, malformedTemplate)
}).toThrow('Template is missing required elements: month-template')
})
it('should throw error when template is missing lane-template', () => {
const malformedTemplate = createMalformedTemplate('lane-template')
expect(() => {
timelineGenerator.generate(items, config, malformedTemplate)
}).toThrow('Template is missing required elements: lane-template')
})
it('should throw error when template is missing task-template', () => {
const malformedTemplate = createMalformedTemplate('task-template')
expect(() => {
timelineGenerator.generate(items, config, malformedTemplate)
}).toThrow('Template is missing required elements: task-template')
})
it('should validate template with proper error message', () => {
const emptyTemplate = '<svg></svg>'
expect(() => {
timelineGenerator.generate(items, config, emptyTemplate)
}).toThrow('Please use a template-v2.svg file with proper template elements')
})
})
describe('Template extraction', () => {
it('should extract template element by id', () => {
const svg = `
<svg>
<g id="month-template">
<line x1="{{X}}" y1="0"/>
<text>{{LABEL}}</text>
</g>
</svg>
`
const result = timelineGenerator.extractTemplate(svg, 'month-template')
expect(result).toContain('id="month-template"')
expect(result).toContain('{{X}}')
expect(result).toContain('{{LABEL}}')
})
it('should throw error when template not found', () => {
const svg = '<svg><g id="other"></g></svg>'
expect(() => {
timelineGenerator.extractTemplate(svg, 'month-template')
}).toThrow('Failed to extract template element: month-template')
})
it('should extract nested elements within template', () => {
const svg = `
<svg>
<g id="lane-template">
<rect fill="#FFF"/>
<text>Label</text>
</g>
</svg>
`
const result = timelineGenerator.extractTemplate(svg, 'lane-template')
expect(result).toContain('<rect fill="#FFF"/>')
expect(result).toContain('<text>Label</text>')
})
})
describe('Placeholder replacement', () => {
it('should replace all placeholders with values', () => {
const template = '<text x="{{X}}" y="{{Y}}">{{LABEL}}</text>'
const values = { X: 100, Y: 200, LABEL: 'Test' }
const result = timelineGenerator.replacePlaceholders(template, values)
expect(result).toBe('<text x="100" y="200">Test</text>')
})
it('should handle multiple occurrences of same placeholder', () => {
const template = '<g><rect x="{{X}}"/><circle cx="{{X}}"/></g>'
const values = { X: 50 }
const result = timelineGenerator.replacePlaceholders(template, values)
expect(result).toBe('<g><rect x="50"/><circle cx="50"/></g>')
})
it('should leave unmatched placeholders unchanged', () => {
const template = '<text>{{LABEL}} {{OTHER}}</text>'
const values = { LABEL: 'Test' }
const result = timelineGenerator.replacePlaceholders(template, values)
expect(result).toContain('Test')
expect(result).toContain('{{OTHER}}')
})
it('should handle numeric and boolean values', () => {
const template = '<rect x="{{X}}" visible="{{VISIBLE}}"/>'
const values = { X: 42, VISIBLE: true }
const result = timelineGenerator.replacePlaceholders(template, values)
expect(result).toBe('<rect x="42" visible="true"/>')
})
})
describe('Template-based SVG generation', () => {
let items, config
beforeEach(() => {
items = createSampleItems()
config = createSampleProject()
})
it('should generate SVG with template-v2 format', () => {
const template = createSampleTemplate()
const result = timelineGenerator.generate(items, config, template)
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"/>')
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)
it('should not contain template placeholders in output', () => {
const template = createSampleTemplate()
const result = timelineGenerator.generate(items, config, template)
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"')
expect(result).not.toContain('{{MONTH_X}}')
expect(result).not.toContain('{{LANE_Y}}')
expect(result).not.toContain('{{TASK_X}}')
expect(result).not.toContain('{{TASK_TITLE}}')
})
it('should create month labels and grid lines', () => {
const result = timelineGenerator.generate(items, config, null)
it('should contain task data in generated SVG', () => {
const template = createSampleTemplate()
const result = timelineGenerator.generate(items, config, template)
expect(result).toContain('<line')
expect(result).toContain('stroke="#E3E8EF"')
expect(result).toContain('<text')
expect(result).toContain('fill="#5C6B7A"')
expect(result).toContain('T-1')
expect(result).toContain('First Task')
expect(result).toContain('T-2')
expect(result).toContain('Second Task')
expect(result).toContain('T-3')
expect(result).toContain('Third Task')
})
it('should create lane backgrounds and labels', () => {
const result = timelineGenerator.generate(items, config, null)
it('should contain lane names in generated SVG', () => {
const template = createSampleTemplate()
const result = timelineGenerator.generate(items, config, template)
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', () => {
it('should escape special characters in task data', () => {
const itemsWithSpecialChars = [{
id: 'T&1',
title: 'Task with <special> & "characters"',
@@ -133,172 +211,72 @@ describe('Timeline Generator', () => {
due: new Date('2025-01-01')
}]
const result = timelineGenerator.generate(itemsWithSpecialChars, config, null)
const template = createSampleTemplate()
const result = timelineGenerator.generate(itemsWithSpecialChars, config, template)
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') }
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(itemsWithEarlyDate, config, null)
// Should start from June 2024 (first day of month) - German month name
expect(result).toContain('Juni 24')
const template = createSampleTemplate()
const result = timelineGenerator.generate(itemsNoLane, config, template)
expect(result).toContain('Ohne Epic')
})
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') }
]
it('should respect timelineMonths setting', () => {
const template = createSampleTemplate()
const shortConfig = { ...config, settings: { timelineMonths: 6 } }
const longConfig = { ...config, settings: { timelineMonths: 24 } }
// 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')
const shortResult = timelineGenerator.generate(items, shortConfig, template)
const longResult = timelineGenerator.generate(items, longConfig, template)
// Longer timeline should produce larger SVG
const shortWidth = shortResult.match(/width="(\d+)"/)?.[1] || 0
const longWidth = longResult.match(/width="(\d+)"/)?.[1] || 0
expect(parseInt(longWidth)).toBeGreaterThan(parseInt(shortWidth))
})
it('should not contain template elements with display:none in output', () => {
const template = createSampleTemplate()
const result = timelineGenerator.generate(items, config, template)
expect(result).not.toContain('id="month-template"')
expect(result).not.toContain('id="lane-template"')
expect(result).not.toContain('id="task-template"')
expect(result).not.toContain('style="display:none"')
})
it('should preserve template styling in generated output', async () => {
const templateV2 = await fs.readFile('./example/template-v2.svg', 'utf-8')
const result = timelineGenerator.generate(items, config, templateV2)
// Should preserve gradient and filter definitions
expect(result).toContain('monthHeaderGrad')
expect(result).toContain('textShadow')
expect(result).toContain('bgGrid')
})
it('should set viewBox dimensions correctly', () => {
const template = createSampleTemplate()
const result = timelineGenerator.generate(items, config, template)
const widthMatch = result.match(/width="(\d+)"/)
const heightMatch = result.match(/height="(\d+)"/)
const viewBoxMatch = result.match(/viewBox="0 0 (\d+) (\d+)"/)
expect(widthMatch).toBeTruthy()
expect(heightMatch).toBeTruthy()
expect(viewBoxMatch).toBeTruthy()
// viewBox should match width and height
expect(viewBoxMatch[1]).toBe(widthMatch[1])
expect(viewBoxMatch[2]).toBe(heightMatch[1])
})
})
describe('Template-based rendering', () => {
let items, config
beforeEach(() => {
items = createSampleItems()
config = createSampleProject()
})
describe('hasTemplateElements', () => {
it('should detect template elements', () => {
const templateWithElements = `
<svg>
<defs>
<g id="month-template"></g>
<g id="lane-template"></g>
<g id="task-template"></g>
</defs>
</svg>
`
expect(timelineGenerator.hasTemplateElements(templateWithElements)).toBe(true)
})
it('should return false when template elements are missing', () => {
const templateWithoutElements = '<svg><g>{{MONTHS}}</g></svg>'
expect(timelineGenerator.hasTemplateElements(templateWithoutElements)).toBe(false)
})
it('should return false when only some template elements exist', () => {
const partialTemplate = '<svg><g id="month-template"></g></svg>'
expect(timelineGenerator.hasTemplateElements(partialTemplate)).toBe(false)
})
})
describe('extractTemplate', () => {
it('should extract template element by id', () => {
const svg = `
<svg>
<g id="month-template">
<line x1="{{X}}" y1="0"/>
<text>{{LABEL}}</text>
</g>
</svg>
`
const result = timelineGenerator.extractTemplate(svg, 'month-template')
expect(result).toContain('id="month-template"')
expect(result).toContain('{{X}}')
expect(result).toContain('{{LABEL}}')
})
it('should return null when template not found', () => {
const svg = '<svg><g id="other"></g></svg>'
const result = timelineGenerator.extractTemplate(svg, 'month-template')
expect(result).toBeNull()
})
})
describe('replacePlaceholders', () => {
it('should replace all placeholders with values', () => {
const template = '<text x="{{X}}" y="{{Y}}">{{LABEL}}</text>'
const values = { X: 100, Y: 200, LABEL: 'Test' }
const result = timelineGenerator.replacePlaceholders(template, values)
expect(result).toBe('<text x="100" y="200">Test</text>')
})
it('should handle multiple occurrences of same placeholder', () => {
const template = '<g><rect x="{{X}}"/><circle cx="{{X}}"/></g>'
const values = { X: 50 }
const result = timelineGenerator.replacePlaceholders(template, values)
expect(result).toBe('<g><rect x="50"/><circle cx="50"/></g>')
})
it('should leave unmatched placeholders unchanged', () => {
const template = '<text>{{LABEL}} {{OTHER}}</text>'
const values = { LABEL: 'Test' }
const result = timelineGenerator.replacePlaceholders(template, values)
expect(result).toContain('Test')
expect(result).toContain('{{OTHER}}')
})
})
describe('generateFromTemplates', () => {
it('should generate SVG using template elements', async () => {
// Read actual template-v2.svg
const fs = await import('fs/promises')
const templateV2 = await fs.readFile('./example/template-v2.svg', 'utf-8')
const result = timelineGenerator.generate(items, config, templateV2)
// Should contain SVG structure
expect(result).toContain('<svg')
expect(result).toContain('width=')
expect(result).toContain('height=')
// Should not contain template placeholders
expect(result).not.toContain('{{MONTH_X}}')
expect(result).not.toContain('{{LANE_Y}}')
expect(result).not.toContain('{{TASK_X}}')
// Should contain actual data
expect(result).toContain('T-1')
expect(result).toContain('First Task')
expect(result).toContain('Development')
})
it('should fall back to hardcoded generation when templates incomplete', () => {
const incompleteTemplate = `
<svg>
<defs>
<g id="month-template"></g>
</defs>
{{MONTHS}}
{{LANES}}
</svg>
`
const result = timelineGenerator.generate(items, config, incompleteTemplate)
// Should still generate valid SVG
expect(result).toContain('<svg')
expect(result).toContain('T-1')
expect(result).toContain('Development')
})
it('should preserve template styling in generated output', async () => {
const fs = await import('fs/promises')
const templateV2 = await fs.readFile('./example/template-v2.svg', 'utf-8')
const result = timelineGenerator.generate(items, config, templateV2)
// Should preserve gradient and filter definitions
expect(result).toContain('monthHeaderGrad')
expect(result).toContain('textShadow')
expect(result).toContain('bgGrid')
})
})
})
})
})