@auxiora/ambient 1.0.0 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +12 -6
- package/src/anticipation.ts +0 -141
- package/src/briefing.ts +0 -152
- package/src/index.ts +0 -26
- package/src/notification.ts +0 -101
- package/src/orchestrator.ts +0 -188
- package/src/pattern-engine.ts +0 -212
- package/src/scheduler.ts +0 -238
- package/src/types.ts +0 -85
- package/tests/ambient.test.ts +0 -363
- package/tests/orchestrator.test.ts +0 -343
- package/tests/scheduler.test.ts +0 -310
- package/tests/wiring.test.ts +0 -12
- package/tsconfig.json +0 -15
- package/tsconfig.tsbuildinfo +0 -1
package/tests/ambient.test.ts
DELETED
|
@@ -1,363 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
-
import { AmbientPatternEngine } from '../src/pattern-engine.js';
|
|
3
|
-
import { AnticipationEngine } from '../src/anticipation.js';
|
|
4
|
-
import { BriefingGenerator } from '../src/briefing.js';
|
|
5
|
-
import { QuietNotificationManager } from '../src/notification.js';
|
|
6
|
-
import type { AmbientPattern } from '../src/types.js';
|
|
7
|
-
|
|
8
|
-
describe('AmbientPatternEngine', () => {
|
|
9
|
-
let engine: AmbientPatternEngine;
|
|
10
|
-
|
|
11
|
-
beforeEach(() => {
|
|
12
|
-
engine = new AmbientPatternEngine();
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
it('should start with no events', () => {
|
|
16
|
-
expect(engine.getEventCount()).toBe(0);
|
|
17
|
-
expect(engine.getPatterns()).toHaveLength(0);
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
it('should observe events', () => {
|
|
21
|
-
engine.observe({ type: 'coffee', timestamp: Date.now() });
|
|
22
|
-
engine.observe({ type: 'coffee', timestamp: Date.now() });
|
|
23
|
-
expect(engine.getEventCount()).toBe(2);
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it('should detect schedule patterns from repeated events at same hour', () => {
|
|
27
|
-
const now = Date.now();
|
|
28
|
-
const hour = new Date(now).getHours();
|
|
29
|
-
|
|
30
|
-
// Create events at same hour across multiple days
|
|
31
|
-
for (let i = 0; i < 5; i++) {
|
|
32
|
-
const d = new Date(now);
|
|
33
|
-
d.setDate(d.getDate() - i);
|
|
34
|
-
d.setHours(hour, 0, 0, 0);
|
|
35
|
-
engine.observe({ type: 'standup', timestamp: d.getTime() });
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const patterns = engine.detectPatterns();
|
|
39
|
-
expect(patterns.length).toBeGreaterThan(0);
|
|
40
|
-
const schedule = patterns.find(p => p.type === 'schedule');
|
|
41
|
-
expect(schedule).toBeDefined();
|
|
42
|
-
expect(schedule!.description).toContain('standup');
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it('should detect frequency patterns from regularly spaced events', () => {
|
|
46
|
-
const now = Date.now();
|
|
47
|
-
const interval = 2 * 60 * 60 * 1000; // 2 hours
|
|
48
|
-
|
|
49
|
-
for (let i = 0; i < 5; i++) {
|
|
50
|
-
engine.observe({ type: 'check-email', timestamp: now - i * interval });
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const patterns = engine.detectPatterns();
|
|
54
|
-
const freq = patterns.find(p => p.type === 'preference');
|
|
55
|
-
expect(freq).toBeDefined();
|
|
56
|
-
expect(freq!.description).toContain('check-email');
|
|
57
|
-
expect(freq!.description).toContain('hours');
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it('should detect correlations between event types', () => {
|
|
61
|
-
const now = Date.now();
|
|
62
|
-
|
|
63
|
-
// A always followed by B within 5 minutes
|
|
64
|
-
for (let i = 0; i < 3; i++) {
|
|
65
|
-
const base = now - i * 60 * 60 * 1000;
|
|
66
|
-
engine.observe({ type: 'open-slack', timestamp: base });
|
|
67
|
-
engine.observe({ type: 'check-calendar', timestamp: base + 2 * 60 * 1000 });
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const patterns = engine.detectPatterns();
|
|
71
|
-
const correlation = patterns.find(p => p.type === 'correlation');
|
|
72
|
-
expect(correlation).toBeDefined();
|
|
73
|
-
expect(correlation!.description).toContain('follows');
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it('should prune events outside window', () => {
|
|
77
|
-
// Use a tiny window (1 second)
|
|
78
|
-
const shortEngine = new AmbientPatternEngine(1000);
|
|
79
|
-
shortEngine.observe({ type: 'old', timestamp: Date.now() - 5000 });
|
|
80
|
-
shortEngine.observe({ type: 'new', timestamp: Date.now() });
|
|
81
|
-
// Prune happens on next observe
|
|
82
|
-
shortEngine.observe({ type: 'new2', timestamp: Date.now() });
|
|
83
|
-
expect(shortEngine.getEventCount()).toBe(2); // 'old' pruned
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
it('should reset state', () => {
|
|
87
|
-
engine.observe({ type: 'test', timestamp: Date.now() });
|
|
88
|
-
engine.reset();
|
|
89
|
-
expect(engine.getEventCount()).toBe(0);
|
|
90
|
-
expect(engine.getPatterns()).toHaveLength(0);
|
|
91
|
-
});
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
describe('AnticipationEngine', () => {
|
|
95
|
-
let anticipation: AnticipationEngine;
|
|
96
|
-
|
|
97
|
-
beforeEach(() => {
|
|
98
|
-
anticipation = new AnticipationEngine();
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
it('should generate anticipations from schedule patterns', () => {
|
|
102
|
-
const pattern: AmbientPattern = {
|
|
103
|
-
id: 'p1',
|
|
104
|
-
type: 'schedule',
|
|
105
|
-
description: '"standup" events frequently occur around 10:00',
|
|
106
|
-
confidence: 0.8,
|
|
107
|
-
evidence: [],
|
|
108
|
-
detectedAt: Date.now(),
|
|
109
|
-
lastConfirmedAt: Date.now(),
|
|
110
|
-
occurrences: 5,
|
|
111
|
-
};
|
|
112
|
-
|
|
113
|
-
const results = anticipation.generateAnticipations([pattern]);
|
|
114
|
-
expect(results.length).toBeGreaterThan(0);
|
|
115
|
-
expect(results[0].sourcePatterns).toContain('p1');
|
|
116
|
-
expect(results[0].expectedAt).toBeGreaterThan(Date.now());
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
it('should generate anticipations from preference patterns', () => {
|
|
120
|
-
const pattern: AmbientPattern = {
|
|
121
|
-
id: 'p2',
|
|
122
|
-
type: 'preference',
|
|
123
|
-
description: '"email" occurs roughly every 2.5 hours',
|
|
124
|
-
confidence: 0.7,
|
|
125
|
-
evidence: [],
|
|
126
|
-
detectedAt: Date.now(),
|
|
127
|
-
lastConfirmedAt: Date.now(),
|
|
128
|
-
occurrences: 4,
|
|
129
|
-
};
|
|
130
|
-
|
|
131
|
-
const results = anticipation.generateAnticipations([pattern]);
|
|
132
|
-
expect(results.length).toBeGreaterThan(0);
|
|
133
|
-
expect(results[0].expectedAt).toBeGreaterThan(Date.now());
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
it('should skip low-confidence patterns', () => {
|
|
137
|
-
const pattern: AmbientPattern = {
|
|
138
|
-
id: 'p3',
|
|
139
|
-
type: 'schedule',
|
|
140
|
-
description: '"rare" around 14:00',
|
|
141
|
-
confidence: 0.2,
|
|
142
|
-
evidence: [],
|
|
143
|
-
detectedAt: Date.now(),
|
|
144
|
-
lastConfirmedAt: Date.now(),
|
|
145
|
-
occurrences: 1,
|
|
146
|
-
};
|
|
147
|
-
|
|
148
|
-
const results = anticipation.generateAnticipations([pattern]);
|
|
149
|
-
expect(results).toHaveLength(0);
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
it('should prune expired anticipations', () => {
|
|
153
|
-
const pattern: AmbientPattern = {
|
|
154
|
-
id: 'p4',
|
|
155
|
-
type: 'correlation',
|
|
156
|
-
description: '"B" often follows "A" within 5 minutes',
|
|
157
|
-
confidence: 0.8,
|
|
158
|
-
evidence: [],
|
|
159
|
-
detectedAt: Date.now(),
|
|
160
|
-
lastConfirmedAt: Date.now(),
|
|
161
|
-
occurrences: 3,
|
|
162
|
-
};
|
|
163
|
-
|
|
164
|
-
// Generate with a past time so anticipations expire immediately
|
|
165
|
-
anticipation.generateAnticipations([pattern], { currentTime: Date.now() - 10 * 60 * 1000 });
|
|
166
|
-
const pruned = anticipation.prune();
|
|
167
|
-
expect(pruned).toBeGreaterThanOrEqual(1);
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
it('should reset', () => {
|
|
171
|
-
const pattern: AmbientPattern = {
|
|
172
|
-
id: 'p5',
|
|
173
|
-
type: 'trigger',
|
|
174
|
-
description: 'trigger detected',
|
|
175
|
-
confidence: 0.9,
|
|
176
|
-
evidence: [],
|
|
177
|
-
detectedAt: Date.now(),
|
|
178
|
-
lastConfirmedAt: Date.now(),
|
|
179
|
-
occurrences: 1,
|
|
180
|
-
};
|
|
181
|
-
anticipation.generateAnticipations([pattern]);
|
|
182
|
-
anticipation.reset();
|
|
183
|
-
expect(anticipation.getAnticipations()).toHaveLength(0);
|
|
184
|
-
});
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
describe('BriefingGenerator', () => {
|
|
188
|
-
let generator: BriefingGenerator;
|
|
189
|
-
|
|
190
|
-
beforeEach(() => {
|
|
191
|
-
generator = new BriefingGenerator();
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
it('should generate a morning briefing with sections', () => {
|
|
195
|
-
const briefing = generator.generateBriefing('user1', 'morning', {
|
|
196
|
-
notifications: [
|
|
197
|
-
{ id: 'n1', priority: 'nudge', message: 'New email', createdAt: Date.now(), dismissed: false, source: 'email' },
|
|
198
|
-
],
|
|
199
|
-
calendarEvents: [
|
|
200
|
-
{ title: 'Team standup', time: '10:00' },
|
|
201
|
-
{ title: 'Lunch', time: '12:00' },
|
|
202
|
-
],
|
|
203
|
-
tasks: [
|
|
204
|
-
{ title: 'Review PR', status: 'in-progress' },
|
|
205
|
-
],
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
expect(briefing.userId).toBe('user1');
|
|
209
|
-
expect(briefing.timeOfDay).toBe('morning');
|
|
210
|
-
expect(briefing.sections.length).toBeGreaterThan(0);
|
|
211
|
-
|
|
212
|
-
const calendarSection = briefing.sections.find(s => s.title.includes('Schedule'));
|
|
213
|
-
expect(calendarSection).toBeDefined();
|
|
214
|
-
expect(calendarSection!.items).toHaveLength(2);
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
it('should include patterns section when patterns are available', () => {
|
|
218
|
-
const briefing = generator.generateBriefing('user1', 'evening', {
|
|
219
|
-
patterns: [
|
|
220
|
-
{
|
|
221
|
-
id: 'p1',
|
|
222
|
-
type: 'schedule',
|
|
223
|
-
description: 'Standup at 10:00',
|
|
224
|
-
confidence: 0.8,
|
|
225
|
-
evidence: [],
|
|
226
|
-
detectedAt: Date.now(),
|
|
227
|
-
lastConfirmedAt: Date.now(),
|
|
228
|
-
occurrences: 5,
|
|
229
|
-
},
|
|
230
|
-
],
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
const patternsSection = briefing.sections.find(s => s.title === 'Observed Patterns');
|
|
234
|
-
expect(patternsSection).toBeDefined();
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
it('should filter dismissed notifications', () => {
|
|
238
|
-
const briefing = generator.generateBriefing('user1', 'morning', {
|
|
239
|
-
notifications: [
|
|
240
|
-
{ id: 'n1', priority: 'nudge', message: 'Old', createdAt: Date.now(), dismissed: true, source: 'test' },
|
|
241
|
-
],
|
|
242
|
-
});
|
|
243
|
-
|
|
244
|
-
const notifSection = briefing.sections.find(s => s.title === 'Notifications');
|
|
245
|
-
expect(notifSection).toBeUndefined();
|
|
246
|
-
});
|
|
247
|
-
|
|
248
|
-
it('should respect maxItemsPerSection', () => {
|
|
249
|
-
const generator2 = new BriefingGenerator({ maxItemsPerSection: 2 });
|
|
250
|
-
const briefing = generator2.generateBriefing('user1', 'morning', {
|
|
251
|
-
calendarEvents: [
|
|
252
|
-
{ title: 'Event 1', time: '09:00' },
|
|
253
|
-
{ title: 'Event 2', time: '10:00' },
|
|
254
|
-
{ title: 'Event 3', time: '11:00' },
|
|
255
|
-
],
|
|
256
|
-
});
|
|
257
|
-
|
|
258
|
-
const calSection = briefing.sections.find(s => s.title.includes('Schedule'));
|
|
259
|
-
expect(calSection!.items).toHaveLength(2);
|
|
260
|
-
});
|
|
261
|
-
|
|
262
|
-
it('should return config', () => {
|
|
263
|
-
const config = generator.getConfig();
|
|
264
|
-
expect(config.enabled).toBe(true);
|
|
265
|
-
expect(config.morningTime).toBe('08:00');
|
|
266
|
-
});
|
|
267
|
-
});
|
|
268
|
-
|
|
269
|
-
describe('QuietNotificationManager', () => {
|
|
270
|
-
let manager: QuietNotificationManager;
|
|
271
|
-
|
|
272
|
-
beforeEach(() => {
|
|
273
|
-
manager = new QuietNotificationManager();
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
it('should create notifications', () => {
|
|
277
|
-
const n = manager.notify('nudge', 'Test message');
|
|
278
|
-
expect(n.id).toBeDefined();
|
|
279
|
-
expect(n.priority).toBe('nudge');
|
|
280
|
-
expect(n.message).toBe('Test message');
|
|
281
|
-
expect(n.dismissed).toBe(false);
|
|
282
|
-
});
|
|
283
|
-
|
|
284
|
-
it('should queue notifications by priority', () => {
|
|
285
|
-
manager.notify('whisper', 'Low');
|
|
286
|
-
manager.notify('alert', 'High');
|
|
287
|
-
manager.notify('nudge', 'Medium');
|
|
288
|
-
|
|
289
|
-
const queue = manager.getQueue();
|
|
290
|
-
expect(queue[0].priority).toBe('alert');
|
|
291
|
-
expect(queue[1].priority).toBe('nudge');
|
|
292
|
-
expect(queue[2].priority).toBe('whisper');
|
|
293
|
-
});
|
|
294
|
-
|
|
295
|
-
it('should dismiss a notification', () => {
|
|
296
|
-
const n = manager.notify('nudge', 'Dismiss me');
|
|
297
|
-
expect(manager.dismiss(n.id)).toBe(true);
|
|
298
|
-
expect(manager.getQueue()).toHaveLength(0);
|
|
299
|
-
});
|
|
300
|
-
|
|
301
|
-
it('should return false for unknown dismiss', () => {
|
|
302
|
-
expect(manager.dismiss('nonexistent')).toBe(false);
|
|
303
|
-
});
|
|
304
|
-
|
|
305
|
-
it('should dismiss all', () => {
|
|
306
|
-
manager.notify('whisper', 'A');
|
|
307
|
-
manager.notify('nudge', 'B');
|
|
308
|
-
const count = manager.dismissAll();
|
|
309
|
-
expect(count).toBe(2);
|
|
310
|
-
expect(manager.getQueue()).toHaveLength(0);
|
|
311
|
-
});
|
|
312
|
-
|
|
313
|
-
it('should filter by priority', () => {
|
|
314
|
-
manager.notify('whisper', 'A');
|
|
315
|
-
manager.notify('alert', 'B');
|
|
316
|
-
manager.notify('whisper', 'C');
|
|
317
|
-
|
|
318
|
-
const whispers = manager.getByPriority('whisper');
|
|
319
|
-
expect(whispers).toHaveLength(2);
|
|
320
|
-
});
|
|
321
|
-
|
|
322
|
-
it('should get notification by ID', () => {
|
|
323
|
-
const n = manager.notify('alert', 'Important');
|
|
324
|
-
const fetched = manager.get(n.id);
|
|
325
|
-
expect(fetched).toBeDefined();
|
|
326
|
-
expect(fetched!.message).toBe('Important');
|
|
327
|
-
});
|
|
328
|
-
|
|
329
|
-
it('should count pending notifications', () => {
|
|
330
|
-
manager.notify('whisper', 'A');
|
|
331
|
-
manager.notify('nudge', 'B');
|
|
332
|
-
expect(manager.getPendingCount()).toBe(2);
|
|
333
|
-
|
|
334
|
-
const n = manager.notify('alert', 'C');
|
|
335
|
-
manager.dismiss(n.id);
|
|
336
|
-
expect(manager.getPendingCount()).toBe(2);
|
|
337
|
-
});
|
|
338
|
-
|
|
339
|
-
it('should prune old dismissed notifications', () => {
|
|
340
|
-
const n = manager.notify('whisper', 'Old');
|
|
341
|
-
manager.dismiss(n.id);
|
|
342
|
-
// Manually set createdAt to old
|
|
343
|
-
const notification = manager.get(n.id)!;
|
|
344
|
-
(notification as any).createdAt = Date.now() - 48 * 60 * 60 * 1000;
|
|
345
|
-
|
|
346
|
-
const pruned = manager.prune();
|
|
347
|
-
expect(pruned).toBe(1);
|
|
348
|
-
expect(manager.get(n.id)).toBeUndefined();
|
|
349
|
-
});
|
|
350
|
-
|
|
351
|
-
it('should clear all', () => {
|
|
352
|
-
manager.notify('alert', 'A');
|
|
353
|
-
manager.notify('nudge', 'B');
|
|
354
|
-
manager.clear();
|
|
355
|
-
expect(manager.getPendingCount()).toBe(0);
|
|
356
|
-
});
|
|
357
|
-
|
|
358
|
-
it('should include source and detail', () => {
|
|
359
|
-
const n = manager.notify('nudge', 'Msg', { source: 'calendar', detail: 'Extra info' });
|
|
360
|
-
expect(n.source).toBe('calendar');
|
|
361
|
-
expect(n.detail).toBe('Extra info');
|
|
362
|
-
});
|
|
363
|
-
});
|
|
@@ -1,343 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
-
import { NotificationOrchestrator } from '../src/orchestrator.js';
|
|
3
|
-
import type { OrchestratorNotification, DeliveryChannelFn } from '../src/orchestrator.js';
|
|
4
|
-
import type { TriggerEvent } from '@auxiora/connectors';
|
|
5
|
-
import { NotificationHub, DoNotDisturbManager } from '@auxiora/notification-hub';
|
|
6
|
-
|
|
7
|
-
describe('NotificationOrchestrator', () => {
|
|
8
|
-
let hub: NotificationHub;
|
|
9
|
-
let dnd: DoNotDisturbManager;
|
|
10
|
-
let delivered: OrchestratorNotification[];
|
|
11
|
-
let deliveryChannel: DeliveryChannelFn;
|
|
12
|
-
let orchestrator: NotificationOrchestrator;
|
|
13
|
-
|
|
14
|
-
beforeEach(() => {
|
|
15
|
-
hub = new NotificationHub();
|
|
16
|
-
dnd = new DoNotDisturbManager();
|
|
17
|
-
delivered = [];
|
|
18
|
-
deliveryChannel = (n) => delivered.push(n);
|
|
19
|
-
orchestrator = new NotificationOrchestrator(hub, dnd, deliveryChannel);
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
describe('processTriggerEvents', () => {
|
|
23
|
-
it('should map new-email with urgency keyword to urgent priority', () => {
|
|
24
|
-
const event: TriggerEvent = {
|
|
25
|
-
triggerId: 'new-email',
|
|
26
|
-
connectorId: 'email-connector',
|
|
27
|
-
data: { subject: 'URGENT: Server down', from: 'ops@example.com' },
|
|
28
|
-
timestamp: Date.now(),
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
const results = orchestrator.processTriggerEvents([event]);
|
|
32
|
-
|
|
33
|
-
expect(results).toHaveLength(1);
|
|
34
|
-
expect(results[0].priority).toBe('urgent');
|
|
35
|
-
expect(results[0].message).toContain('ops@example.com');
|
|
36
|
-
expect(results[0].message).toContain('URGENT: Server down');
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it('should detect various urgency keywords case-insensitively', () => {
|
|
40
|
-
const keywords = ['urgent', 'ASAP', 'Important', 'Action Required', 'DEADLINE'];
|
|
41
|
-
|
|
42
|
-
for (const keyword of keywords) {
|
|
43
|
-
const hub2 = new NotificationHub();
|
|
44
|
-
const delivered2: OrchestratorNotification[] = [];
|
|
45
|
-
const orch = new NotificationOrchestrator(hub2, dnd, (n) => delivered2.push(n));
|
|
46
|
-
|
|
47
|
-
const event: TriggerEvent = {
|
|
48
|
-
triggerId: 'new-email',
|
|
49
|
-
connectorId: 'email-connector',
|
|
50
|
-
data: { subject: `Re: ${keyword} meeting notes`, from: 'test@test.com' },
|
|
51
|
-
timestamp: Date.now(),
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
const results = orch.processTriggerEvents([event]);
|
|
55
|
-
expect(results[0].priority).toBe('urgent');
|
|
56
|
-
}
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it('should map new-email without urgency keywords to important priority', () => {
|
|
60
|
-
const event: TriggerEvent = {
|
|
61
|
-
triggerId: 'new-email',
|
|
62
|
-
connectorId: 'email-connector',
|
|
63
|
-
data: { subject: 'Weekly newsletter', from: 'news@example.com' },
|
|
64
|
-
timestamp: Date.now(),
|
|
65
|
-
};
|
|
66
|
-
|
|
67
|
-
const results = orchestrator.processTriggerEvents([event]);
|
|
68
|
-
|
|
69
|
-
expect(results).toHaveLength(1);
|
|
70
|
-
expect(results[0].priority).toBe('important');
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
it('should map event-starting-soon to important priority', () => {
|
|
74
|
-
const event: TriggerEvent = {
|
|
75
|
-
triggerId: 'event-starting-soon',
|
|
76
|
-
connectorId: 'calendar-connector',
|
|
77
|
-
data: { title: 'Team standup' },
|
|
78
|
-
timestamp: Date.now(),
|
|
79
|
-
};
|
|
80
|
-
|
|
81
|
-
const results = orchestrator.processTriggerEvents([event]);
|
|
82
|
-
|
|
83
|
-
expect(results).toHaveLength(1);
|
|
84
|
-
expect(results[0].priority).toBe('important');
|
|
85
|
-
expect(results[0].message).toContain('Team standup');
|
|
86
|
-
expect(results[0].source).toBe('calendar');
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it('should map file-shared to low priority', () => {
|
|
90
|
-
const event: TriggerEvent = {
|
|
91
|
-
triggerId: 'file-shared',
|
|
92
|
-
connectorId: 'drive-connector',
|
|
93
|
-
data: { fileName: 'report.pdf', sharedBy: 'Alice' },
|
|
94
|
-
timestamp: Date.now(),
|
|
95
|
-
};
|
|
96
|
-
|
|
97
|
-
const results = orchestrator.processTriggerEvents([event]);
|
|
98
|
-
|
|
99
|
-
expect(results).toHaveLength(1);
|
|
100
|
-
expect(results[0].priority).toBe('low');
|
|
101
|
-
expect(results[0].message).toContain('Alice');
|
|
102
|
-
expect(results[0].message).toContain('report.pdf');
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
it('should map unknown trigger events to low priority', () => {
|
|
106
|
-
const event: TriggerEvent = {
|
|
107
|
-
triggerId: 'some-unknown-trigger',
|
|
108
|
-
connectorId: 'custom-connector',
|
|
109
|
-
data: {},
|
|
110
|
-
timestamp: Date.now(),
|
|
111
|
-
};
|
|
112
|
-
|
|
113
|
-
const results = orchestrator.processTriggerEvents([event]);
|
|
114
|
-
|
|
115
|
-
expect(results).toHaveLength(1);
|
|
116
|
-
expect(results[0].priority).toBe('low');
|
|
117
|
-
expect(results[0].message).toContain('custom-connector');
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
it('should process multiple events at once', () => {
|
|
121
|
-
const events: TriggerEvent[] = [
|
|
122
|
-
{
|
|
123
|
-
triggerId: 'new-email',
|
|
124
|
-
connectorId: 'email',
|
|
125
|
-
data: { subject: 'Hello', from: 'a@b.com' },
|
|
126
|
-
timestamp: Date.now(),
|
|
127
|
-
},
|
|
128
|
-
{
|
|
129
|
-
triggerId: 'file-shared',
|
|
130
|
-
connectorId: 'drive',
|
|
131
|
-
data: { fileName: 'doc.txt', sharedBy: 'Bob' },
|
|
132
|
-
timestamp: Date.now(),
|
|
133
|
-
},
|
|
134
|
-
];
|
|
135
|
-
|
|
136
|
-
const results = orchestrator.processTriggerEvents(events);
|
|
137
|
-
expect(results).toHaveLength(2);
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
it('should send notifications to the hub', () => {
|
|
141
|
-
const event: TriggerEvent = {
|
|
142
|
-
triggerId: 'new-email',
|
|
143
|
-
connectorId: 'email',
|
|
144
|
-
data: { subject: 'Test', from: 'x@y.com' },
|
|
145
|
-
timestamp: Date.now(),
|
|
146
|
-
};
|
|
147
|
-
|
|
148
|
-
orchestrator.processTriggerEvents([event]);
|
|
149
|
-
|
|
150
|
-
const hubNotifications = hub.getAll();
|
|
151
|
-
expect(hubNotifications).toHaveLength(1);
|
|
152
|
-
expect(hubNotifications[0].source).toBe('email');
|
|
153
|
-
});
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
describe('DND filtering', () => {
|
|
157
|
-
it('should deliver notifications when DND is inactive', () => {
|
|
158
|
-
const event: TriggerEvent = {
|
|
159
|
-
triggerId: 'new-email',
|
|
160
|
-
connectorId: 'email',
|
|
161
|
-
data: { subject: 'Hello', from: 'a@b.com' },
|
|
162
|
-
timestamp: Date.now(),
|
|
163
|
-
};
|
|
164
|
-
|
|
165
|
-
orchestrator.processTriggerEvents([event]);
|
|
166
|
-
|
|
167
|
-
expect(delivered).toHaveLength(1);
|
|
168
|
-
expect(delivered[0].delivered).toBe(true);
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
it('should queue non-urgent notifications when DND is active', () => {
|
|
172
|
-
dnd.setManual(60_000);
|
|
173
|
-
|
|
174
|
-
const event: TriggerEvent = {
|
|
175
|
-
triggerId: 'new-email',
|
|
176
|
-
connectorId: 'email',
|
|
177
|
-
data: { subject: 'Hello', from: 'a@b.com' },
|
|
178
|
-
timestamp: Date.now(),
|
|
179
|
-
};
|
|
180
|
-
|
|
181
|
-
orchestrator.processTriggerEvents([event]);
|
|
182
|
-
|
|
183
|
-
expect(delivered).toHaveLength(0);
|
|
184
|
-
expect(orchestrator.getPending()).toHaveLength(1);
|
|
185
|
-
expect(orchestrator.getPending()[0].delivered).toBe(false);
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
it('should allow urgent notifications through DND', () => {
|
|
189
|
-
dnd.setManual(60_000);
|
|
190
|
-
|
|
191
|
-
const event: TriggerEvent = {
|
|
192
|
-
triggerId: 'new-email',
|
|
193
|
-
connectorId: 'email',
|
|
194
|
-
data: { subject: 'URGENT: Production is down', from: 'ops@co.com' },
|
|
195
|
-
timestamp: Date.now(),
|
|
196
|
-
};
|
|
197
|
-
|
|
198
|
-
orchestrator.processTriggerEvents([event]);
|
|
199
|
-
|
|
200
|
-
expect(delivered).toHaveLength(1);
|
|
201
|
-
expect(delivered[0].priority).toBe('urgent');
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
it('should queue low-priority file-shared during DND', () => {
|
|
205
|
-
dnd.setManual(60_000);
|
|
206
|
-
|
|
207
|
-
const event: TriggerEvent = {
|
|
208
|
-
triggerId: 'file-shared',
|
|
209
|
-
connectorId: 'drive',
|
|
210
|
-
data: { fileName: 'notes.txt', sharedBy: 'Carol' },
|
|
211
|
-
timestamp: Date.now(),
|
|
212
|
-
};
|
|
213
|
-
|
|
214
|
-
orchestrator.processTriggerEvents([event]);
|
|
215
|
-
|
|
216
|
-
expect(delivered).toHaveLength(0);
|
|
217
|
-
expect(orchestrator.getPending()).toHaveLength(1);
|
|
218
|
-
});
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
describe('processCalendarCheck', () => {
|
|
222
|
-
it('should create notifications for events starting within alert window', () => {
|
|
223
|
-
const now = Date.now();
|
|
224
|
-
const events = [
|
|
225
|
-
{ title: 'Team standup', startTime: now + 10 * 60_000 }, // 10 min
|
|
226
|
-
];
|
|
227
|
-
|
|
228
|
-
const results = orchestrator.processCalendarCheck(events, now);
|
|
229
|
-
|
|
230
|
-
expect(results).toHaveLength(1);
|
|
231
|
-
expect(results[0].priority).toBe('important');
|
|
232
|
-
expect(results[0].message).toContain('Team standup');
|
|
233
|
-
expect(results[0].message).toContain('10 minutes');
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
it('should ignore events beyond the alert window', () => {
|
|
237
|
-
const now = Date.now();
|
|
238
|
-
const events = [
|
|
239
|
-
{ title: 'Later meeting', startTime: now + 60 * 60_000 }, // 60 min
|
|
240
|
-
];
|
|
241
|
-
|
|
242
|
-
const results = orchestrator.processCalendarCheck(events, now);
|
|
243
|
-
expect(results).toHaveLength(0);
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
it('should ignore events that have already started', () => {
|
|
247
|
-
const now = Date.now();
|
|
248
|
-
const events = [
|
|
249
|
-
{ title: 'Past event', startTime: now - 5 * 60_000 },
|
|
250
|
-
];
|
|
251
|
-
|
|
252
|
-
const results = orchestrator.processCalendarCheck(events, now);
|
|
253
|
-
expect(results).toHaveLength(0);
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
it('should use singular "minute" for 1 minute', () => {
|
|
257
|
-
const now = Date.now();
|
|
258
|
-
const events = [
|
|
259
|
-
{ title: 'Quick sync', startTime: now + 60_000 }, // 1 min
|
|
260
|
-
];
|
|
261
|
-
|
|
262
|
-
const results = orchestrator.processCalendarCheck(events, now);
|
|
263
|
-
|
|
264
|
-
expect(results).toHaveLength(1);
|
|
265
|
-
expect(results[0].message).toContain('1 minute');
|
|
266
|
-
expect(results[0].message).not.toContain('1 minutes');
|
|
267
|
-
});
|
|
268
|
-
|
|
269
|
-
it('should respect custom calendar alert window', () => {
|
|
270
|
-
const customOrch = new NotificationOrchestrator(hub, dnd, deliveryChannel, {
|
|
271
|
-
calendarAlertWindowMs: 5 * 60_000, // 5 minutes
|
|
272
|
-
});
|
|
273
|
-
|
|
274
|
-
const now = Date.now();
|
|
275
|
-
const events = [
|
|
276
|
-
{ title: 'Soon event', startTime: now + 3 * 60_000 }, // 3 min — within
|
|
277
|
-
{ title: 'Later event', startTime: now + 10 * 60_000 }, // 10 min — outside
|
|
278
|
-
];
|
|
279
|
-
|
|
280
|
-
const results = customOrch.processCalendarCheck(events, now);
|
|
281
|
-
expect(results).toHaveLength(1);
|
|
282
|
-
expect(results[0].message).toContain('Soon event');
|
|
283
|
-
});
|
|
284
|
-
|
|
285
|
-
it('should send calendar notifications to the hub', () => {
|
|
286
|
-
const now = Date.now();
|
|
287
|
-
const events = [
|
|
288
|
-
{ title: 'Sync', startTime: now + 5 * 60_000 },
|
|
289
|
-
];
|
|
290
|
-
|
|
291
|
-
orchestrator.processCalendarCheck(events, now);
|
|
292
|
-
|
|
293
|
-
const hubNotifications = hub.getAll();
|
|
294
|
-
expect(hubNotifications).toHaveLength(1);
|
|
295
|
-
expect(hubNotifications[0].source).toBe('calendar');
|
|
296
|
-
expect(hubNotifications[0].priority).toBe('important');
|
|
297
|
-
});
|
|
298
|
-
});
|
|
299
|
-
|
|
300
|
-
describe('getPending and dismiss', () => {
|
|
301
|
-
it('should return empty array when no pending notifications', () => {
|
|
302
|
-
expect(orchestrator.getPending()).toHaveLength(0);
|
|
303
|
-
});
|
|
304
|
-
|
|
305
|
-
it('should dismiss a pending notification by ID', () => {
|
|
306
|
-
dnd.setManual(60_000);
|
|
307
|
-
|
|
308
|
-
const event: TriggerEvent = {
|
|
309
|
-
triggerId: 'file-shared',
|
|
310
|
-
connectorId: 'drive',
|
|
311
|
-
data: { fileName: 'test.txt', sharedBy: 'Dan' },
|
|
312
|
-
timestamp: Date.now(),
|
|
313
|
-
};
|
|
314
|
-
|
|
315
|
-
orchestrator.processTriggerEvents([event]);
|
|
316
|
-
const pending = orchestrator.getPending();
|
|
317
|
-
expect(pending).toHaveLength(1);
|
|
318
|
-
|
|
319
|
-
const dismissed = orchestrator.dismiss(pending[0].id);
|
|
320
|
-
expect(dismissed).toBe(true);
|
|
321
|
-
expect(orchestrator.getPending()).toHaveLength(0);
|
|
322
|
-
});
|
|
323
|
-
|
|
324
|
-
it('should return false when dismissing unknown ID', () => {
|
|
325
|
-
expect(orchestrator.dismiss('nonexistent-id')).toBe(false);
|
|
326
|
-
});
|
|
327
|
-
|
|
328
|
-
it('should not include delivered notifications in pending', () => {
|
|
329
|
-
const event: TriggerEvent = {
|
|
330
|
-
triggerId: 'new-email',
|
|
331
|
-
connectorId: 'email',
|
|
332
|
-
data: { subject: 'Hello', from: 'a@b.com' },
|
|
333
|
-
timestamp: Date.now(),
|
|
334
|
-
};
|
|
335
|
-
|
|
336
|
-
orchestrator.processTriggerEvents([event]);
|
|
337
|
-
|
|
338
|
-
// Delivered notifications should not appear in pending
|
|
339
|
-
expect(orchestrator.getPending()).toHaveLength(0);
|
|
340
|
-
expect(delivered).toHaveLength(1);
|
|
341
|
-
});
|
|
342
|
-
});
|
|
343
|
-
});
|