trmnlc/src/plugins.test.ts
Kate Meerburg 2e34246d14 Add today-view plugin with Tana, ICS, and Donetick data sources
Implements an e-ink daily dashboard plugin ("today") with four sections:
date/focus/success header, agenda timeline, chores checklist, and
due/overdue task lists.

Data sources:
- Focus & success text: Tana daily note (src/sources/tana.ts)
- Due/overdue tasks: Tana task search (src/sources/tana.ts)
- Agenda events: ICS calendar feeds (src/sources/ics.ts)
- Chores: Donetick API (src/sources/donetick.ts)

All sources fetch in parallel and fall back gracefully on error.
Tests use mock HTTP servers with synthetic data — no real services needed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-24 23:18:13 +02:00

222 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import type { Server } from 'bun';
import { createPlugin } from './plugins';
// --- Synthetic ICS ---
const pad = (n: number) => n.toString().padStart(2, '0');
const icsDate = (d: Date) =>
`${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}T${pad(d.getHours())}${pad(d.getMinutes())}00`;
const now = new Date();
const currentStart = new Date(now.getTime() - 30 * 60_000);
const currentEnd = new Date(now.getTime() + 30 * 60_000);
const nextStart = new Date(now.getTime() + 60 * 60_000);
const nextEnd = new Date(now.getTime() + 120 * 60_000);
const laterStart = new Date(now.getTime() + 180 * 60_000);
const laterEnd = new Date(now.getTime() + 240 * 60_000);
const ics = `BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Test//Test//EN
BEGIN:VEVENT
DTSTART:${icsDate(currentStart)}
DTEND:${icsDate(currentEnd)}
SUMMARY:Current Meeting
UID:test-current@test
END:VEVENT
BEGIN:VEVENT
DTSTART:${icsDate(nextStart)}
DTEND:${icsDate(nextEnd)}
SUMMARY:Next Meeting
UID:test-next@test
END:VEVENT
BEGIN:VEVENT
DTSTART:${icsDate(laterStart)}
DTEND:${icsDate(laterEnd)}
SUMMARY:Later Meeting
UID:test-later@test
END:VEVENT
END:VCALENDAR`;
const fakeModel = {
width: 800,
height: 480,
css: { classes: { device: 'device', size: 'size', density: 'density' } },
} as any;
// --- ICS server ---
let icsServer: Server;
let icsUrl: string;
beforeAll(() => {
icsServer = Bun.serve({
port: 0,
hostname: '127.0.0.1',
routes: {
'/cal.ics': () =>
new Response(ics, {
headers: { 'Content-Type': 'text/calendar' },
}),
},
});
icsUrl = `http://127.0.0.1:${icsServer.port}/cal.ics`;
});
afterAll(() => {
icsServer.stop();
});
// --- Tests ---
describe('plugin registry', () => {
test('unknown plugin throws', () => {
expect(() => createPlugin('nonexistent', {})).toThrow(
'Unknown plugin: nonexistent'
);
});
test('calendar plugin creates an ICSRenderable', () => {
const plugin = createPlugin('calendar', { urls: [] });
expect(plugin.hash).toBe('');
expect(plugin.nextEvent).toBeUndefined();
});
});
describe('calendar plugin rendering', () => {
test('renders events from ICS feed', async () => {
const plugin = createPlugin('calendar', { urls: [icsUrl] });
const html = await plugin.render('full', fakeModel);
expect(html).toContain('Current Meeting');
expect(html).toContain('Next Meeting');
expect(html).toContain('Later Meeting');
});
test('shows BEZET when there is a current event', async () => {
const plugin = createPlugin('calendar', { urls: [icsUrl] });
const html = await plugin.render('full', fakeModel);
expect(html).toContain('BEZET');
});
test('computes hash after render', async () => {
const plugin = createPlugin('calendar', { urls: [icsUrl] });
await plugin.render('full', fakeModel);
expect(plugin.hash).toBeTruthy();
});
test('sets nextEvent to upcoming event start', async () => {
const plugin = createPlugin('calendar', { urls: [icsUrl] });
await plugin.render('full', fakeModel);
expect(plugin.nextEvent).toBeInstanceOf(Date);
const diff = Math.abs(plugin.nextEvent!.getTime() - nextStart.getTime());
expect(diff).toBeLessThan(120_000);
});
test('renders VRIJ with no events', async () => {
const plugin = createPlugin('calendar', { urls: [] });
const html = await plugin.render('full', fakeModel);
expect(html).toContain('VRIJ');
expect(html).not.toContain('BEZET');
});
});
// --- Today plugin test data ---
import type { TodayData } from './todayview';
const SAMPLE_TODAY: TodayData = {
focus: 'Ship the Q2 retrospective deck — clarity over completeness.',
success: 'Three deep-work blocks. One real conversation. Inbox under 10.',
agenda: [
{ start: '08:30', end: '09:00', title: 'Morning planning', where: 'Office', kind: 'solo' },
{ start: '09:30', end: '10:15', title: 'Design review', where: 'Meet', kind: 'call' },
{ start: '11:00', end: '12:00', title: '1:1 with Priya', where: 'Walk', kind: 'in-person' },
{ start: '13:30', end: '15:00', title: 'Deep work — deck', where: 'Library', kind: 'focus block' },
{ start: '16:00', end: '16:30', title: 'Eng standup', where: 'Zoom', kind: 'call' },
],
chores: [
{ text: 'Make the bed' },
{ text: '20 min reading' },
{ text: 'Water the plants' },
{ text: 'Walk · 30 min' },
{ text: 'Journal · 3 lines' },
{ text: 'Lights out by 23:00' },
],
todos_due: [
{ text: 'Review Atlas spec comments', tag: 'Atlas', est: '30m', done: false },
{ text: 'Draft retro deck — sections 13', tag: 'Deck', est: '1h', done: false },
{ text: 'Reply to Marcus re: contract', tag: 'Ops', est: '10m', done: false },
{ text: 'Submit June expenses', tag: 'Admin', est: '15m', done: false },
],
todos_overdue: [
{ text: 'File Q1 receipts', tag: 'Admin', age: '3d' },
{ text: 'Renew domain · seva.dev', tag: 'Ops', age: '1d' },
{ text: 'Book dentist', tag: 'Health', age: '9d' },
],
};
describe('today plugin', () => {
test('renders all sections', async () => {
const plugin = createPlugin('today', SAMPLE_TODAY);
const html = await plugin.render('full', fakeModel);
// Top bar
expect(html).toContain('FOCUS');
expect(html).toContain('SUCCESS');
expect(html).toContain('retrospective deck');
// Agenda
expect(html).toContain('Agenda');
expect(html).toContain('Morning planning');
expect(html).toContain('Eng standup');
// Chores
expect(html).toContain('Chores');
expect(html).toContain('Make the bed');
expect(html).toContain('Water the plants');
// Tasks
expect(html).toContain('Due today');
expect(html).toContain('Overdue');
expect(html).toContain('Review Atlas spec comments');
expect(html).toContain('File Q1 receipts');
});
test('renders with minimal data', async () => {
const plugin = createPlugin('today', {
focus: 'Custom focus text',
success: 'Custom success text',
agenda: [
{ start: '09:00', end: '10:00', title: 'Custom Event', where: 'Room', kind: 'call' },
],
chores: [{ text: 'Custom chore' }],
todos_due: [{ text: 'Custom task', tag: 'Test', est: '5m', done: false }],
todos_overdue: [],
});
const html = await plugin.render('full', fakeModel);
expect(html).toContain('Custom focus text');
expect(html).toContain('Custom Event');
expect(html).toContain('Custom chore');
expect(html).toContain('Custom task');
});
test('computes hash after render', async () => {
const plugin = createPlugin('today', SAMPLE_TODAY);
await plugin.render('full', fakeModel);
expect(plugin.hash).toBeTruthy();
});
test('renders today date', async () => {
const plugin = createPlugin('today', SAMPLE_TODAY);
const html = await plugin.render('full', fakeModel);
const dayStr = String(new Date().getDate()).padStart(2, '0');
expect(html).toContain(dayStr);
});
});