Files
GlowTrack/packages/viz/tests/contract/renderer.spec.ts
2025-09-18 11:36:21 -06:00

327 lines
9.0 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
// Types based on data-model.md
interface ContainerSize {
width: number;
height: number;
devicePixelRatio: number;
}
interface Mood {
hue: number; // 0-360
intensity: number; // 0-1
note?: string;
}
interface HabitEntry {
id: string;
type: 'positive' | 'negative';
habitId: string;
label: string;
weight: number;
timestamp: string; // ISO datetime
}
interface DayTile {
date: string; // ISO date (YYYY-MM-DD)
mood: Mood;
entries: HabitEntry[];
netScore: number;
}
interface Theme {
palette: Record<string, string>;
cssVariables: Record<string, string>;
}
interface RenderOptions {
showLegend: boolean;
pngScale: number;
}
// Expected renderer API interface
interface Renderer {
renderGrid(
container: HTMLElement,
days: DayTile[],
theme: Theme,
options: RenderOptions
): Promise<void> | void;
}
describe('Renderer Contract', () => {
let container: HTMLElement;
let mockDays: DayTile[];
let mockTheme: Theme;
let mockOptions: RenderOptions;
beforeEach(() => {
// Create a test container
container = document.createElement('div');
container.style.width = '800px';
container.style.height = '600px';
document.body.appendChild(container);
// Mock data following the data model
mockDays = [
{
date: '2025-09-18',
mood: {
hue: 120, // Green
intensity: 0.7,
note: 'Good day'
},
entries: [
{
id: 'entry-1',
type: 'positive',
habitId: 'habit-1',
label: 'Exercise',
weight: 1,
timestamp: '2025-09-18T08:00:00Z'
},
{
id: 'entry-2',
type: 'negative',
habitId: 'habit-2',
label: 'Junk food',
weight: 1,
timestamp: '2025-09-18T14:00:00Z'
}
],
netScore: 0 // 1 positive - 1 negative
},
{
date: '2025-09-17',
mood: {
hue: 240, // Blue
intensity: 0.5
},
entries: [
{
id: 'entry-3',
type: 'positive',
habitId: 'habit-3',
label: 'Meditation',
weight: 1,
timestamp: '2025-09-17T07:00:00Z'
}
],
netScore: 1
}
];
mockTheme = {
palette: {
primary: '#3b82f6',
secondary: '#8b5cf6',
background: '#ffffff',
text: '#1f2937'
},
cssVariables: {
'--color-mood-base': '#ffffff',
'--color-glow-intensity': '0.8',
'--color-negative-overlay': '#ff000020'
}
};
mockOptions = {
showLegend: true,
pngScale: 1.0
};
});
afterEach(() => {
document.body.removeChild(container);
});
it('should have renderGrid function available', async () => {
// This test should fail until the renderer is implemented
let renderer: Renderer;
try {
// Try to import the renderer module
const rendererModule = await import('../../src/renderer.js');
renderer = rendererModule;
} catch (error) {
expect.fail('Renderer module should exist at packages/viz/src/renderer.ts');
}
expect(renderer.renderGrid).toBeDefined();
expect(typeof renderer.renderGrid).toBe('function');
});
it('should accept correct parameters for renderGrid', async () => {
// This test should fail until the renderer is implemented
let renderer: Renderer;
try {
const rendererModule = await import('../../src/renderer.js');
renderer = rendererModule;
} catch (error) {
expect.fail('Renderer module should exist');
}
// Should not throw when called with correct parameters
expect(() => {
renderer.renderGrid(container, mockDays, mockTheme, mockOptions);
}).not.toThrow();
});
it('should render Canvas element for tiles', async () => {
let renderer: Renderer;
try {
const rendererModule = await import('../../src/renderer.js');
renderer = rendererModule;
await renderer.renderGrid(container, mockDays, mockTheme, mockOptions);
// Should create a Canvas element for tile rendering
const canvas = container.querySelector('canvas');
expect(canvas).toBeTruthy();
expect(canvas?.tagName).toBe('CANVAS');
} catch (error) {
expect.fail('Should render Canvas element for tiles');
}
});
it('should render SVG element for glyphs and overlays', async () => {
let renderer: Renderer;
try {
const rendererModule = await import('../../src/renderer.js');
renderer = rendererModule;
await renderer.renderGrid(container, mockDays, mockTheme, mockOptions);
// Should create an SVG element for glyph overlays
const svg = container.querySelector('svg');
expect(svg).toBeTruthy();
expect(svg?.tagName).toBe('SVG');
} catch (error) {
expect.fail('Should render SVG element for glyphs and overlays');
}
});
it('should apply mood hue to tiles', async () => {
let renderer: Renderer;
try {
const rendererModule = await import('../../src/renderer.js');
renderer = rendererModule;
await renderer.renderGrid(container, mockDays, mockTheme, mockOptions);
// Canvas should be configured to use mood hues
const canvas = container.querySelector('canvas') as HTMLCanvasElement;
expect(canvas).toBeTruthy();
// The canvas context should have been used for drawing
// This is a basic check - actual hue application would be tested in integration tests
const ctx = canvas.getContext('2d');
expect(ctx).toBeTruthy();
} catch (error) {
expect.fail('Should apply mood hue to tiles');
}
});
it('should render glyph counts for habit entries', async () => {
let renderer: Renderer;
try {
const rendererModule = await import('../../src/renderer.js');
renderer = rendererModule;
await renderer.renderGrid(container, mockDays, mockTheme, mockOptions);
// SVG should contain glyph elements
const svg = container.querySelector('svg');
expect(svg).toBeTruthy();
// Should have glyph elements for positive (ticks) and negative (dots) entries
// This is a structural test - actual glyph rendering would be tested visually
} catch (error) {
expect.fail('Should render glyph counts for habit entries');
}
});
it('should support keyboard accessibility', async () => {
let renderer: Renderer;
try {
const rendererModule = await import('../../src/renderer.js');
renderer = rendererModule;
await renderer.renderGrid(container, mockDays, mockTheme, mockOptions);
// Should have focusable elements for keyboard navigation
const focusableElements = container.querySelectorAll('[tabindex]');
expect(focusableElements.length).toBeGreaterThan(0);
// Should have ARIA labels for screen readers
const ariaElements = container.querySelectorAll('[aria-label]');
expect(ariaElements.length).toBeGreaterThan(0);
} catch (error) {
expect.fail('Should support keyboard accessibility');
}
});
it('should handle empty days array', async () => {
let renderer: Renderer;
try {
const rendererModule = await import('../../src/renderer.js');
renderer = rendererModule;
// Should not throw with empty days
expect(() => {
renderer.renderGrid(container, [], mockTheme, mockOptions);
}).not.toThrow();
} catch (error) {
expect.fail('Should handle empty days array');
}
});
it('should respect pngScale option for export', async () => {
let renderer: Renderer;
try {
const rendererModule = await import('../../src/renderer.js');
renderer = rendererModule;
const exportOptions = { ...mockOptions, pngScale: 2.0 };
// Should handle different pngScale values
expect(() => {
renderer.renderGrid(container, mockDays, mockTheme, exportOptions);
}).not.toThrow();
} catch (error) {
expect.fail('Should respect pngScale option for export');
}
});
it('should apply luminance curve based on netScore', async () => {
let renderer: Renderer;
try {
const rendererModule = await import('../../src/renderer.js');
renderer = rendererModule;
// Test with days having different netScores
const daysWithVariedScores: DayTile[] = [
{ ...mockDays[0], netScore: -2 }, // Should be dimmer
{ ...mockDays[1], netScore: 3 } // Should be brighter
];
await renderer.renderGrid(container, daysWithVariedScores, mockTheme, mockOptions);
// Canvas should reflect luminance differences based on netScore
const canvas = container.querySelector('canvas');
expect(canvas).toBeTruthy();
} catch (error) {
expect.fail('Should apply luminance curve based on netScore');
}
});
});