generated from coulomb/repo-seed
Implements the refactoring plan to move SVG structure from hardcoded JavaScript to editable template files: - Add template-v2.svg files with template elements in defs - Create template elements for months, lanes, and tasks with placeholders - Refactor generator.js to extract and clone template elements - Add hasTemplateElements, extractTemplate, and replacePlaceholders methods - Add generateFromTemplates method for template-based rendering - Keep generateHardcoded method for backward compatibility - Add comprehensive tests for new template system - Support both v1 (hardcoded) and v2 (template-based) approaches Benefits: - Templates can now be edited in SVG tools (Inkscape, Adobe Illustrator) - Visual template design with actual layout visible while editing - Separation of presentation (SVG) and logic (JavaScript) - Easier customization without touching code 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
304 lines
11 KiB
JavaScript
304 lines
11 KiB
JavaScript
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) - German month name
|
|
expect(result).toContain('Juni 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')
|
|
})
|
|
})
|
|
|
|
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')
|
|
})
|
|
})
|
|
})
|
|
}) |