2026-05-24 21:24:32 +02:00
|
|
|
|
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');
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
2026-05-24 22:25:06 +02:00
|
|
|
|
|
|
|
|
|
|
// --- 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 1–3', 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);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|