import { describe, expect, test } from 'vitest'; import type { TaskRecord } from '../src/reader.ts'; import { formatAge, partitionByStatus, renderBoard } from '../src/render.ts'; function rec(overrides: Partial): TaskRecord { return { slug: 'sample', project: 'demo', project_owner: 'o', status: 'open', status_emoji: '⚪', title: 'sample title', where_stopped: null, next_action: null, blocker: null, branch: 'master', last_commit_iso: null, raw_url: 'https://example/raw', md_content: null, ...overrides, }; } describe('formatAge', () => { const now = new Date('2026-05-22T12:00:00Z'); test.each([ ['2026-05-22T11:00:00Z', '1h'], ['2026-05-22T11:59:00Z', '1m'], ['2026-05-21T12:00:00Z', '1d'], ['2026-05-19T12:00:00Z', '3d'], ['2026-05-08T12:00:00Z', '2w'], ['2026-02-22T12:00:00Z', '3mo'], ['2025-05-22T12:00:00Z', '1y'], ])('formats %s relative to now as %s', (iso, expected) => { expect(formatAge(iso, now)).toBe(expected); }); test('returns null when iso is null', () => { expect(formatAge(null, now)).toBeNull(); }); test('returns "0m" for sub-minute differences', () => { expect(formatAge('2026-05-22T11:59:30Z', now)).toBe('0m'); }); test('clamps future timestamps to "0m" (server clock drift safety)', () => { // ISO in the future of `now` — can happen with clock drift between // VDS and Gitea. Should clamp to "0m" rather than throw or surface negative. expect(formatAge('2026-05-22T13:00:00Z', now)).toBe('0m'); }); }); describe('partitionByStatus', () => { test('groups records into 5 columns in canonical order', () => { const records = [ rec({ slug: 'a', status: 'done', status_emoji: '🟢' }), rec({ slug: 'b', status: 'in_progress', status_emoji: '🔴' }), rec({ slug: 'c', status: 'open', status_emoji: '⚪' }), rec({ slug: 'd', status: 'paused', status_emoji: '🟡' }), rec({ slug: 'e', status: 'blocked', status_emoji: '🔵' }), rec({ slug: 'f', status: 'open', status_emoji: '⚪' }), ]; const cols = partitionByStatus(records); expect(cols.map((c) => c.status)).toEqual([ 'open', 'in_progress', 'paused', 'blocked', 'done', ]); expect(cols[0]!.records.map((r) => r.slug)).toEqual(['c', 'f']); expect(cols[4]!.records.map((r) => r.slug)).toEqual(['a']); }); }); describe('renderBoard', () => { const now = new Date('2026-05-22T12:00:00Z'); test('includes a
per status with canonical emoji label', () => { const html = renderBoard([], { generatedAt: now }); expect(html).toContain('data-status="open"'); expect(html).toContain('data-status="in_progress"'); expect(html).toContain('data-status="paused"'); expect(html).toContain('data-status="blocked"'); expect(html).toContain('data-status="done"'); expect(html).toContain('⚪'); expect(html).toContain('🔴'); expect(html).toContain('🟡'); expect(html).toContain('🔵'); expect(html).toContain('🟢'); }); test('renders a card per record with data-slug and data-raw-url', () => { const records = [ rec({ slug: 'task-a', title: 'first task', raw_url: 'https://git.example/raw/task-a.md', last_commit_iso: '2026-05-19T12:00:00Z', }), ]; const html = renderBoard(records, { generatedAt: now }); expect(html).toContain('data-slug="task-a"'); expect(html).toContain('data-raw-url="https://git.example/raw/task-a.md"'); expect(html).toContain('first task'); expect(html).toContain('3d'); }); test('truncates title longer than 80 chars with ellipsis in the card body', () => { const longTitle = 'x'.repeat(100); const html = renderBoard([rec({ title: longTitle })], { generatedAt: now }); const cardMatch = /
([^<]*)<\/div>/.exec(html); expect(cardMatch).not.toBeNull(); expect(cardMatch![1]).toBe('x'.repeat(80) + '…'); }); test('escapes HTML special chars in user-provided fields', () => { const html = renderBoard( [rec({ slug: 'safe', title: '' })], { generatedAt: now }, ); expect(html).not.toContain('