apps/web/src/lib/agendaPlan.test.ts

1import { describe, expect, test } from 'bun:test'
2import type { AgendaSession } from '@local/ai/components/api/pineconeUpload'
3import {
4	createAgendaPlan,
5	requestedAgendaDay,
6	sessionsOverlap,
7	validateAgendaSelection,
8} from './agendaPlan'
9
10function session(
11	id: string,
12	start: string,
13	end: string,
14	changes: Partial<AgendaSession> = {},
15): AgendaSession {
16	return {
17		title: `AI skills ${id}`,
18		url: `/agenda?session=${id}`,
19		start: `2026-10-21T${start}:00`,
20		end: `2026-10-21T${end}:00`,
21		day: '2026-10-21',
22		dayLabel: 'Wednesday 21 October',
23		room: 'Stage 1',
24		access: '',
25		speakers: 'Speaker',
26		format: 'panel',
27		keynote: false,
28		topics: 'AI, skills',
29		track: '',
30		...changes,
31	}
32}
33
34describe('validated agenda plans', () => {
35	test('one-day requests and dated refinements override model omissions across contact turns', () => {
36		const all = [
37			session('a', '10:00', '10:30'),
38			session('b', '10:00', '10:30', {
39				day: '2026-10-22',
40				start: '2026-10-22T10:00:00',
41				end: '2026-10-22T10:30:00',
42			}),
43		]
44		expect(
45			requestedAgendaDay(all, [
46				{ role: 'user', text: 'Build a one-day AI agenda' },
47				{ role: 'user', text: 'My company is Example' },
48			]),
49		).toBe('2026-10-21')
50		expect(
51			requestedAgendaDay(
52				all,
53				[{ role: 'user', text: 'Build an agenda for 22 October' }],
54				'2026-10-21',
55			),
56		).toBe('2026-10-22')
57		expect(
58			requestedAgendaDay(all, [{ role: 'user', text: 'Wednesday please' }]),
59		).toBe('2026-10-21')
60		expect(
61			requestedAgendaDay(all, [
62				{ role: 'user', text: 'one-day agenda' },
63				{ role: 'user', text: 'Actually both days' },
64			]),
65		).toBeUndefined()
66		expect(requestedAgendaDay(all, [{ role: 'user', text: '23 October' }])).toBe(
67			'2026-10-23',
68		)
69	})
70	test('personal plans include grounded match explanations and unscheduled break guidance', () => {
71		const plan = createAgendaPlan(
72			'paris',
73			[session('a', '10:00', '10:30')],
74			'/agenda',
75			'AI, skills',
76		)
77		expect(plan.text).toContain(
78			'Why it matches: matches your interest in ai, skills',
79		)
80		expect(plan.text).toContain('lunch, networking and the exhibition')
81		expect(plan.text).toContain('not scheduled event sessions')
82	})
83	test('pillar recommendations use stored classifications rather than keynote topic matches', () => {
84		const all = [
85			session('matching', '10:00', '10:30', {
86				title: 'Making better decisions',
87				topics: '',
88				decisionPillarId: 'datatrustpeopleinfrastructure',
89				decisionPillar: 'Data, Trust, People & Infrastructure',
90			}),
91			session('wrong-pillar', '11:00', '11:30', {
92				title: 'Data, Trust, People & Infrastructure',
93				keynote: true,
94				decisionPillarId: 'rewardpayfairnesssystems',
95			}),
96		]
97		for (const interest of [
98			'datatrustpeopleinfrastructure',
99			'Data, Trust, People & Infrastructure',
100		])
101			expect(
102				createAgendaPlan('paris', all, null, interest).sessions.map((s) => s.url),
103			).toEqual(['/agenda?session=matching'])
104	})
105	test('separates conflicting sessions and keeps exact source details', () => {
106		const all = [
107			session('a', '10:00', '10:30'),
108			session('b', '10:25', '10:55'),
109			session('c', '11:45', '12:15'),
110			session('d', '11:45', '12:15'),
111		]
112		const plan = createAgendaPlan('paris', all, '/agenda', 'AI, skills')
113		expect(plan.sessions.map((s) => s.url)).toEqual([
114			'/agenda?session=a',
115			'/agenda?session=c',
116		])
117		expect(plan.alternatives.map((s) => s.url)).toEqual([
118			'/agenda?session=b',
119			'/agenda?session=d',
120		])
121		expect(plan.text).not.toContain('session=b')
122		for (const [i, a] of plan.sessions.entries())
123			for (const b of plan.sessions.slice(i + 1))
124				expect(sessionsOverlap(a, b)).toBe(false)
125	})
126	test('excludes restricted sessions, invalid times and missing links', () => {
127		const plan = createAgendaPlan(
128			'miami',
129			[
130				session('private', '10:00', '10:30', { access: 'INVITE ONLY' }),
131				session('backward', '11:00', '10:00'),
132				session('no-link', '12:00', '13:00', { url: '' }),
133			],
134			null,
135			'AI',
136		)
137		expect(plan.sessions).toEqual([])
138	})
139	test('allows consecutive sessions in one room but reserves transfer time between rooms', () => {
140		const all = [
141			session('a', '10:00', '10:30'),
142			session('b', '10:30', '11:00'),
143			session('c', '11:00', '11:30', { room: 'Stage 2' }),
144		]
145		expect(
146			createAgendaPlan('paris', all, null, 'AI').sessions.map((s) => s.url),
147		).toEqual(['/agenda?session=a', '/agenda?session=b'])
148	})
149	test('filters by day and does not invent a fallback for unmatched interests', () => {
150		const all = [session('a', '10:00', '10:30')]
151		expect(
152			createAgendaPlan('paris', all, null, 'AI', '2026-10-22').sessions,
153		).toEqual([])
154		expect(
155			createAgendaPlan('paris', all, null, 'underwater archaeology').sessions,
156		).toEqual([])
157	})
158	test('email selection rejects forged, duplicated, overlapping and newly restricted sessions', () => {
159		const all = [
160			session('a', '10:00', '10:30'),
161			session('b', '10:25', '10:55'),
162			session('c', '12:00', '13:00', { access: 'INVITE ONLY' }),
163		]
164		for (const ids of [['fake'], ['a', 'a'], ['a', 'b'], ['c']])
165			expect(
166				validateAgendaSelection(
167					all,
168					ids.map((id) => `/agenda?session=${id}`),
169				),
170			).toBeNull()
171		expect(validateAgendaSelection(all, ['/agenda?session=a'])?.[0].title).toBe(
172			'AI skills a',
173		)
174	})
175})
176