Files
aris/packages/aris-source-google-calendar/src/google-calendar-source.test.ts
Kenneth 28d26b3c87 Replace FeedItem.priority with signals (#39)
* feat: replace FeedItem.priority with signals

Remove priority field from FeedItem and engine-level sorting.
Add FeedItemSignals with urgency and timeRelevance fields.
Update all source packages to emit signals instead of priority.

Ranking is now the post-processing layer's responsibility.
Urgency values are unchanged from the old priority values.

Co-authored-by: Ona <no-reply@ona.com>

* fix: use TimeRelevance enum in all tests

Co-authored-by: Ona <no-reply@ona.com>

---------

Co-authored-by: Ona <no-reply@ona.com>
2026-02-28 12:02:57 +00:00

299 lines
10 KiB
TypeScript

import { TimeRelevance, contextValue, type Context } from "@aris/core"
import { describe, expect, test } from "bun:test"
import type { ApiCalendarEvent, GoogleCalendarClient, ListEventsOptions } from "./types"
import fixture from "../fixtures/events.json"
import { NextEventKey } from "./calendar-context"
import { CalendarFeedItemType } from "./feed-items"
import { GoogleCalendarSource } from "./google-calendar-source"
const NOW = new Date("2026-01-20T10:00:00Z")
function fixtureEvents(): ApiCalendarEvent[] {
return fixture.items as unknown as ApiCalendarEvent[]
}
function createMockClient(
eventsByCalendar: Record<string, ApiCalendarEvent[]>,
): GoogleCalendarClient {
return {
listCalendarIds: async () => Object.keys(eventsByCalendar),
listEvents: async (options: ListEventsOptions) => {
const events = eventsByCalendar[options.calendarId] ?? []
return events.filter((e) => {
const startRaw = e.start.dateTime ?? e.start.date ?? ""
const endRaw = e.end.dateTime ?? e.end.date ?? ""
return (
new Date(startRaw).getTime() < options.timeMax.getTime() &&
new Date(endRaw).getTime() > options.timeMin.getTime()
)
})
},
}
}
function defaultMockClient(): GoogleCalendarClient {
return createMockClient({ primary: fixtureEvents() })
}
function createContext(time?: Date): Context {
return { time: time ?? NOW }
}
describe("GoogleCalendarSource", () => {
describe("constructor", () => {
test("has correct id", () => {
const source = new GoogleCalendarSource({ client: defaultMockClient() })
expect(source.id).toBe("aris.google-calendar")
})
})
describe("fetchItems", () => {
test("returns empty array when no events", async () => {
const source = new GoogleCalendarSource({
client: createMockClient({ primary: [] }),
})
const items = await source.fetchItems(createContext())
expect(items).toEqual([])
})
test("returns feed items for all events in window", async () => {
const source = new GoogleCalendarSource({ client: defaultMockClient() })
const items = await source.fetchItems(createContext())
expect(items.length).toBe(fixture.items.length)
})
test("assigns calendar-event type to timed events", async () => {
const source = new GoogleCalendarSource({ client: defaultMockClient() })
const items = await source.fetchItems(createContext())
const timedItems = items.filter((i) => i.type === CalendarFeedItemType.event)
expect(timedItems.length).toBe(4)
})
test("assigns calendar-all-day type to all-day events", async () => {
const source = new GoogleCalendarSource({ client: defaultMockClient() })
const items = await source.fetchItems(createContext())
const allDayItems = items.filter((i) => i.type === CalendarFeedItemType.allDay)
expect(allDayItems.length).toBe(1)
})
test("ongoing events get highest urgency (1.0)", async () => {
const source = new GoogleCalendarSource({ client: defaultMockClient() })
const items = await source.fetchItems(createContext())
const ongoing = items.find((i) => i.data.eventId === "evt-ongoing")
expect(ongoing).toBeDefined()
expect(ongoing!.signals!.urgency).toBe(1.0)
expect(ongoing!.signals!.timeRelevance).toBe(TimeRelevance.Imminent)
})
test("upcoming events get higher urgency when sooner", async () => {
const source = new GoogleCalendarSource({ client: defaultMockClient() })
const items = await source.fetchItems(createContext())
const soon = items.find((i) => i.data.eventId === "evt-soon")
const later = items.find((i) => i.data.eventId === "evt-later")
expect(soon).toBeDefined()
expect(later).toBeDefined()
expect(soon!.signals!.urgency).toBeGreaterThan(later!.signals!.urgency!)
})
test("all-day events get flat urgency (0.4)", async () => {
const source = new GoogleCalendarSource({ client: defaultMockClient() })
const items = await source.fetchItems(createContext())
const allDay = items.find((i) => i.data.eventId === "evt-allday")
expect(allDay).toBeDefined()
expect(allDay!.signals!.urgency).toBe(0.4)
expect(allDay!.signals!.timeRelevance).toBe(TimeRelevance.Ambient)
})
test("generates unique IDs for each item", async () => {
const source = new GoogleCalendarSource({ client: defaultMockClient() })
const items = await source.fetchItems(createContext())
const ids = items.map((i) => i.id)
const uniqueIds = new Set(ids)
expect(uniqueIds.size).toBe(ids.length)
})
test("sets timestamp from context.time", async () => {
const source = new GoogleCalendarSource({ client: defaultMockClient() })
const items = await source.fetchItems(createContext())
for (const item of items) {
expect(item.timestamp).toEqual(NOW)
}
})
test("respects lookaheadHours", async () => {
// Only 2 hours lookahead from 10:00 → events before 12:00
const source = new GoogleCalendarSource({
client: defaultMockClient(),
lookaheadHours: 2,
})
const items = await source.fetchItems(createContext())
// Should include: ongoing (09:30-10:15), soon (10:10-10:40), allday (00:00-next day)
// Should exclude: later (14:00), tentative lunch (12:00)
const eventIds = items.map((i) => i.data.eventId)
expect(eventIds).toContain("evt-ongoing")
expect(eventIds).toContain("evt-soon")
expect(eventIds).toContain("evt-allday")
expect(eventIds).not.toContain("evt-later")
expect(eventIds).not.toContain("evt-tentative")
})
test("defaults to all user calendars via listCalendarIds", async () => {
const workEvent: ApiCalendarEvent = {
id: "evt-work",
status: "confirmed",
htmlLink: "https://calendar.google.com/event?eid=evt-work",
summary: "Work Meeting",
start: { dateTime: "2026-01-20T11:00:00Z" },
end: { dateTime: "2026-01-20T12:00:00Z" },
}
const client = createMockClient({
primary: fixtureEvents(),
"work@example.com": [workEvent],
})
// No calendarIds provided — should discover both calendars
const source = new GoogleCalendarSource({ client })
const items = await source.fetchItems(createContext())
const eventIds = items.map((i) => i.data.eventId)
expect(eventIds).toContain("evt-work")
expect(eventIds).toContain("evt-ongoing")
})
test("fetches from explicit calendar IDs", async () => {
const workEvent: ApiCalendarEvent = {
id: "evt-work",
status: "confirmed",
htmlLink: "https://calendar.google.com/event?eid=evt-work",
summary: "Work Meeting",
start: { dateTime: "2026-01-20T11:00:00Z" },
end: { dateTime: "2026-01-20T12:00:00Z" },
}
const client = createMockClient({
primary: fixtureEvents(),
"work@example.com": [workEvent],
})
const source = new GoogleCalendarSource({
client,
calendarIds: ["primary", "work@example.com"],
})
const items = await source.fetchItems(createContext())
const eventIds = items.map((i) => i.data.eventId)
expect(eventIds).toContain("evt-work")
expect(eventIds).toContain("evt-ongoing")
})
})
describe("fetchContext", () => {
test("returns null when no events", async () => {
const source = new GoogleCalendarSource({
client: createMockClient({ primary: [] }),
})
const result = await source.fetchContext(createContext())
expect(result).toBeNull()
})
test("returns null when only all-day events", async () => {
const allDayOnly: ApiCalendarEvent[] = [
{
id: "evt-allday",
status: "confirmed",
htmlLink: "https://calendar.google.com/event?eid=evt-allday",
summary: "Holiday",
start: { date: "2026-01-20" },
end: { date: "2026-01-21" },
},
]
const source = new GoogleCalendarSource({
client: createMockClient({ primary: allDayOnly }),
})
const result = await source.fetchContext(createContext())
expect(result).toBeNull()
})
test("returns next upcoming timed event (not ongoing)", async () => {
const source = new GoogleCalendarSource({ client: defaultMockClient() })
const result = await source.fetchContext(createContext())
expect(result).not.toBeNull()
const nextEvent = contextValue(result! as Context, NextEventKey)
expect(nextEvent).toBeDefined()
// evt-soon starts at 10:10, which is the nearest future timed event
expect(nextEvent!.title).toBe("1:1 with Manager")
expect(nextEvent!.minutesUntilStart).toBe(10)
expect(nextEvent!.location).toBeNull()
})
test("includes location when available", async () => {
const events: ApiCalendarEvent[] = [
{
id: "evt-loc",
status: "confirmed",
htmlLink: "https://calendar.google.com/event?eid=evt-loc",
summary: "Offsite",
location: "123 Main St",
start: { dateTime: "2026-01-20T11:00:00Z" },
end: { dateTime: "2026-01-20T12:00:00Z" },
},
]
const source = new GoogleCalendarSource({
client: createMockClient({ primary: events }),
})
const result = await source.fetchContext(createContext())
expect(result).not.toBeNull()
const nextEvent = contextValue(result! as Context, NextEventKey)
expect(nextEvent).toBeDefined()
expect(nextEvent!.location).toBe("123 Main St")
})
test("skips ongoing events for next-event context", async () => {
const events: ApiCalendarEvent[] = [
{
id: "evt-now",
status: "confirmed",
htmlLink: "https://calendar.google.com/event?eid=evt-now",
summary: "Current Meeting",
start: { dateTime: "2026-01-20T09:30:00Z" },
end: { dateTime: "2026-01-20T10:30:00Z" },
},
]
const source = new GoogleCalendarSource({
client: createMockClient({ primary: events }),
})
const result = await source.fetchContext(createContext())
expect(result).toBeNull()
})
})
describe("urgency ordering", () => {
test("ongoing > upcoming > all-day", async () => {
const source = new GoogleCalendarSource({ client: defaultMockClient() })
const items = await source.fetchItems(createContext())
const ongoing = items.find((i) => i.data.eventId === "evt-ongoing")!
const upcoming = items.find((i) => i.data.eventId === "evt-soon")!
const allDay = items.find((i) => i.data.eventId === "evt-allday")!
expect(ongoing.signals!.urgency).toBeGreaterThan(upcoming.signals!.urgency!)
expect(upcoming.signals!.urgency).toBeGreaterThan(allDay.signals!.urgency!)
})
})
})