Files
board-viewer/tests/render.test.ts
vitya 930e404545 feat(render): card owner pill + short ISO date span
closes board-viewer-card-meta-fix

Review found two acceptance fields missing from cards:
- project_owner was in JSON but never displayed
- last_commit_iso was only consumed for relative age; no absolute date

Now both rendered (server-side src/render.ts + client-side static/board.js
in sync), with 4 new TDD tests:
- renders owner pill when project_owner is set
- omits owner pill when empty
- renders short YYYY-MM-DD date when last_commit_iso is set
- omits date span when null

CSS: .badge.owner = transparent + border (visually distinct from solid
.badge.project), .date = small muted tabular-nums. Smoke-verified in browser.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:42:59 +03:00

193 lines
6.1 KiB
TypeScript

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>): 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',
...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');
});
});
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 <section> 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 class="card-title">([^<]*)<\/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: '<script>alert(1)</script>' })],
{ generatedAt: now },
);
expect(html).not.toContain('<script>alert(1)');
expect(html).toContain('&lt;script&gt;alert(1)&lt;/script&gt;');
});
test('marks done tasks older than 14 days as archived (data-archived="true")', () => {
const old = '2026-05-01T12:00:00Z';
const fresh = '2026-05-20T12:00:00Z';
const html = renderBoard(
[
rec({ slug: 'old-done', status: 'done', status_emoji: '🟢', last_commit_iso: old }),
rec({ slug: 'fresh-done', status: 'done', status_emoji: '🟢', last_commit_iso: fresh }),
],
{ generatedAt: now },
);
expect(html).toMatch(/data-slug="old-done"[^>]*data-archived="true"/);
expect(html).toMatch(/data-slug="fresh-done"[^>]*data-archived="false"/);
});
test('embeds records JSON in <script id="records"> for client-side use', () => {
const records = [rec({ slug: 'embed-test' })];
const html = renderBoard(records, { generatedAt: now });
expect(html).toMatch(/<script[^>]+id="records"[^>]+type="application\/json"[^>]*>/);
expect(html).toContain('"embed-test"');
});
test('renders owner as a pill when project_owner is non-empty', () => {
const html = renderBoard(
[rec({ slug: 'x', project_owner: 'OpeItcLoc03' })],
{ generatedAt: now },
);
expect(html).toMatch(/<span class="badge owner">OpeItcLoc03<\/span>/);
});
test('omits owner pill when project_owner is empty string', () => {
const html = renderBoard(
[rec({ slug: 'x', project_owner: '' })],
{ generatedAt: now },
);
expect(html).not.toContain('badge owner');
});
test('renders last_commit_iso as a short YYYY-MM-DD date span', () => {
const html = renderBoard(
[rec({ slug: 'x', last_commit_iso: '2026-05-19T08:30:00Z' })],
{ generatedAt: now },
);
expect(html).toMatch(/<span class="date"[^>]*>2026-05-19<\/span>/);
});
test('omits date span when last_commit_iso is null', () => {
const html = renderBoard(
[rec({ slug: 'x', last_commit_iso: null })],
{ generatedAt: now },
);
expect(html).not.toMatch(/<span class="date"/);
});
test('writes generation timestamp as data-generated attribute on the header', () => {
const html = renderBoard([], { generatedAt: now });
expect(html).toContain('data-generated="2026-05-22T12:00:00.000Z"');
});
});