@iservu-inc/adf-cli 0.9.1 → 0.11.0

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.
@@ -0,0 +1,712 @@
1
+ const fs = require('fs-extra');
2
+ const path = require('path');
3
+ const analytics = require('../lib/learning/analytics');
4
+ const storage = require('../lib/learning/storage');
5
+
6
+ describe('Learning Analytics', () => {
7
+ const tempDir = path.join(__dirname, 'temp-analytics-test');
8
+
9
+ beforeEach(async () => {
10
+ await fs.ensureDir(tempDir);
11
+ });
12
+
13
+ afterEach(async () => {
14
+ await fs.remove(tempDir);
15
+ });
16
+
17
+ describe('generateAnalytics', () => {
18
+ test('should generate analytics with no data', async () => {
19
+ const result = await analytics.generateAnalytics(tempDir);
20
+
21
+ expect(result.version).toBe('1.0');
22
+ expect(result.exportedAt).toBeTruthy();
23
+ expect(result.overview.totalSessions).toBe(0);
24
+ expect(result.overview.totalSkips).toBe(0);
25
+ expect(result.overview.totalAnswers).toBe(0);
26
+ // skipTrends always returns 12 weeks (with 0 values when no data)
27
+ expect(result.skipTrends.length).toBe(12);
28
+ expect(result.skipTrends[0].totalSkips).toBe(0);
29
+ expect(result.categoryPreferences).toEqual([]);
30
+ });
31
+
32
+ test('should generate complete analytics with real data', async () => {
33
+ // Set up test data
34
+ const skipHistory = {
35
+ sessions: [
36
+ {
37
+ sessionId: 's1',
38
+ timestamp: new Date('2025-01-01').toISOString(),
39
+ skips: [
40
+ { questionId: 'q1', text: 'Deploy?', category: 'deployment', reason: 'manual' },
41
+ { questionId: 'q2', text: 'CI/CD?', category: 'deployment', reason: 'manual' }
42
+ ]
43
+ },
44
+ {
45
+ sessionId: 's2',
46
+ timestamp: new Date('2025-01-08').toISOString(),
47
+ skips: [
48
+ { questionId: 'q1', text: 'Deploy?', category: 'deployment', reason: 'filtered' }
49
+ ]
50
+ }
51
+ ]
52
+ };
53
+
54
+ const answerHistory = {
55
+ sessions: [
56
+ {
57
+ sessionId: 's1',
58
+ timestamp: new Date('2025-01-01').toISOString(),
59
+ answers: [
60
+ { questionId: 'q3', text: 'Testing?', category: 'testing', answer: 'Jest', wordCount: 10 }
61
+ ]
62
+ }
63
+ ]
64
+ };
65
+
66
+ const patterns = {
67
+ patterns: [
68
+ {
69
+ id: 'p1',
70
+ questionId: 'q1',
71
+ confidence: 95,
72
+ timesApplied: 10,
73
+ createdAt: new Date('2025-01-01').toISOString(),
74
+ lastSeen: new Date('2025-01-08').toISOString()
75
+ },
76
+ {
77
+ id: 'p2',
78
+ questionId: 'q2',
79
+ confidence: 60,
80
+ timesApplied: 2,
81
+ createdAt: new Date('2024-06-01').toISOString(),
82
+ lastSeen: new Date('2024-06-15').toISOString()
83
+ }
84
+ ]
85
+ };
86
+
87
+ const rules = {
88
+ rules: [
89
+ { id: 'r1', enabled: true, timesApplied: 15 },
90
+ { id: 'r2', enabled: true, timesApplied: 8 }
91
+ ]
92
+ };
93
+
94
+ await storage.writeLearningData(tempDir, 'skip-history.json', skipHistory);
95
+ await storage.writeLearningData(tempDir, 'answer-history.json', answerHistory);
96
+ await storage.writeLearningData(tempDir, 'patterns.json', patterns);
97
+ await storage.writeLearningData(tempDir, 'learned-rules.json', rules);
98
+ await storage.updateLearningStats(tempDir, {
99
+ totalSessions: 2,
100
+ totalSkips: 3,
101
+ totalAnswers: 1
102
+ });
103
+
104
+ const result = await analytics.generateAnalytics(tempDir);
105
+
106
+ expect(result.overview.totalSessions).toBe(2);
107
+ expect(result.overview.totalSkips).toBe(3);
108
+ expect(result.overview.manualSkips).toBe(2);
109
+ expect(result.overview.filteredSkips).toBe(1);
110
+ expect(result.overview.totalAnswers).toBe(1);
111
+ expect(result.overview.activePatterns).toBe(2);
112
+ expect(result.overview.activeRules).toBe(2);
113
+ expect(result.skipTrends.length).toBeGreaterThan(0);
114
+ expect(result.categoryPreferences.length).toBeGreaterThan(0);
115
+ expect(result.patternDistribution.totalPatterns).toBe(2);
116
+ expect(result.decayStatus).toBeTruthy();
117
+ expect(result.effectiveness).toBeTruthy();
118
+ });
119
+
120
+ test('should handle missing data files gracefully', async () => {
121
+ // Only create skip history, leave others missing
122
+ await storage.writeLearningData(tempDir, 'skip-history.json', {
123
+ sessions: [
124
+ {
125
+ sessionId: 's1',
126
+ timestamp: new Date().toISOString(),
127
+ skips: [{ questionId: 'q1', reason: 'manual' }]
128
+ }
129
+ ]
130
+ });
131
+
132
+ const result = await analytics.generateAnalytics(tempDir);
133
+
134
+ expect(result.overview.totalSkips).toBeGreaterThan(0);
135
+ expect(result.overview.totalAnswers).toBe(0);
136
+ expect(result.overview.activePatterns).toBe(0);
137
+ });
138
+ });
139
+
140
+ describe('Overview Statistics', () => {
141
+ test('should calculate learning age correctly', async () => {
142
+ const skipHistory = {
143
+ sessions: [
144
+ {
145
+ sessionId: 's1',
146
+ timestamp: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(), // 30 days ago
147
+ skips: []
148
+ }
149
+ ]
150
+ };
151
+
152
+ await storage.writeLearningData(tempDir, 'skip-history.json', skipHistory);
153
+
154
+ const result = await analytics.generateAnalytics(tempDir);
155
+
156
+ expect(result.overview.learningAge).toBe(30);
157
+ });
158
+
159
+ test('should separate manual and filtered skips', async () => {
160
+ const skipHistory = {
161
+ sessions: [
162
+ {
163
+ sessionId: 's1',
164
+ skips: [
165
+ { questionId: 'q1', reason: 'manual' },
166
+ { questionId: 'q2', reason: 'manual' },
167
+ { questionId: 'q3', reason: 'filtered' },
168
+ { questionId: 'q4', reason: 'filtered' },
169
+ { questionId: 'q5', reason: 'filtered' }
170
+ ]
171
+ }
172
+ ]
173
+ };
174
+
175
+ await storage.writeLearningData(tempDir, 'skip-history.json', skipHistory);
176
+
177
+ const result = await analytics.generateAnalytics(tempDir);
178
+
179
+ expect(result.overview.totalSkips).toBe(5);
180
+ expect(result.overview.manualSkips).toBe(2);
181
+ expect(result.overview.filteredSkips).toBe(3);
182
+ });
183
+ });
184
+
185
+ describe('Skip Trends', () => {
186
+ test('should group sessions by week', async () => {
187
+ const now = new Date();
188
+ const skipHistory = {
189
+ sessions: [
190
+ {
191
+ sessionId: 's1',
192
+ timestamp: new Date(now - 0 * 24 * 60 * 60 * 1000).toISOString(), // This week
193
+ skips: [{ questionId: 'q1', reason: 'manual' }]
194
+ },
195
+ {
196
+ sessionId: 's2',
197
+ timestamp: new Date(now - 7 * 24 * 60 * 60 * 1000).toISOString(), // Last week
198
+ skips: [{ questionId: 'q2', reason: 'manual' }]
199
+ },
200
+ {
201
+ sessionId: 's3',
202
+ timestamp: new Date(now - 14 * 24 * 60 * 60 * 1000).toISOString(), // 2 weeks ago
203
+ skips: [{ questionId: 'q3', reason: 'filtered' }]
204
+ }
205
+ ]
206
+ };
207
+
208
+ await storage.writeLearningData(tempDir, 'skip-history.json', skipHistory);
209
+
210
+ const result = await analytics.generateAnalytics(tempDir);
211
+
212
+ expect(result.skipTrends.length).toBe(12);
213
+ // Check that skips are distributed across weeks (may vary based on week boundaries)
214
+ const totalSkipsAcrossWeeks = result.skipTrends.reduce((sum, week) => sum + week.totalSkips, 0);
215
+ expect(totalSkipsAcrossWeeks).toBeGreaterThanOrEqual(2); // At least 2 skips captured
216
+ expect(totalSkipsAcrossWeeks).toBeLessThanOrEqual(3); // At most 3 skips
217
+ });
218
+
219
+ test('should calculate skip rate correctly', async () => {
220
+ const skipHistory = {
221
+ sessions: [
222
+ {
223
+ sessionId: 's1',
224
+ timestamp: new Date().toISOString(),
225
+ skips: [{ questionId: 'q1', reason: 'manual' }]
226
+ }
227
+ ]
228
+ };
229
+
230
+ const answerHistory = {
231
+ sessions: [
232
+ {
233
+ sessionId: 's1',
234
+ timestamp: new Date().toISOString(),
235
+ answers: [
236
+ { questionId: 'q2' },
237
+ { questionId: 'q3' },
238
+ { questionId: 'q4' }
239
+ ]
240
+ }
241
+ ]
242
+ };
243
+
244
+ await storage.writeLearningData(tempDir, 'skip-history.json', skipHistory);
245
+ await storage.writeLearningData(tempDir, 'answer-history.json', answerHistory);
246
+
247
+ const result = await analytics.generateAnalytics(tempDir);
248
+
249
+ // Skip rate is calculated from sessions with both skips and answers
250
+ // Since skipHistory sessions don't have answers, skipRate will be 100% (skips only)
251
+ const currentWeek = result.skipTrends[result.skipTrends.length - 1];
252
+ expect(currentWeek.skipRate).toBeGreaterThanOrEqual(0);
253
+ expect(currentWeek.skipRate).toBeLessThanOrEqual(100);
254
+ });
255
+
256
+ test('should limit to last 12 weeks', async () => {
257
+ const sessions = [];
258
+ for (let i = 0; i < 20; i++) {
259
+ sessions.push({
260
+ sessionId: `s${i}`,
261
+ timestamp: new Date(Date.now() - i * 7 * 24 * 60 * 60 * 1000).toISOString(),
262
+ skips: [{ questionId: `q${i}`, reason: 'manual' }]
263
+ });
264
+ }
265
+
266
+ await storage.writeLearningData(tempDir, 'skip-history.json', { sessions });
267
+
268
+ const result = await analytics.generateAnalytics(tempDir);
269
+
270
+ expect(result.skipTrends.length).toBeLessThanOrEqual(12);
271
+ });
272
+ });
273
+
274
+ describe('Category Preferences', () => {
275
+ test('should aggregate skips and answers by category', async () => {
276
+ const skipHistory = {
277
+ sessions: [
278
+ {
279
+ sessionId: 's1',
280
+ skips: [
281
+ { questionId: 'q1', category: 'deployment', reason: 'manual' },
282
+ { questionId: 'q2', category: 'deployment', reason: 'manual' }
283
+ ]
284
+ }
285
+ ]
286
+ };
287
+
288
+ const answerHistory = {
289
+ sessions: [
290
+ {
291
+ sessionId: 's1',
292
+ answers: [
293
+ { questionId: 'q3', category: 'deployment' },
294
+ { questionId: 'q4', category: 'testing' }
295
+ ]
296
+ }
297
+ ]
298
+ };
299
+
300
+ await storage.writeLearningData(tempDir, 'skip-history.json', skipHistory);
301
+ await storage.writeLearningData(tempDir, 'answer-history.json', answerHistory);
302
+
303
+ const result = await analytics.generateAnalytics(tempDir);
304
+
305
+ const deployment = result.categoryPreferences.find(c => c.category === 'deployment');
306
+ const testing = result.categoryPreferences.find(c => c.category === 'testing');
307
+
308
+ expect(deployment.skips).toBe(2);
309
+ expect(deployment.answers).toBe(1);
310
+ expect(deployment.total).toBe(3);
311
+ expect(deployment.skipRate).toBeCloseTo(67, 0); // 2/3 = 66.666... rounds to 67
312
+
313
+ expect(testing.skips).toBe(0);
314
+ expect(testing.answers).toBe(1);
315
+ expect(testing.skipRate).toBe(0);
316
+ });
317
+
318
+ test('should categorize preference levels correctly', async () => {
319
+ const skipHistory = {
320
+ sessions: [
321
+ {
322
+ sessionId: 's1',
323
+ skips: [
324
+ // High skip rate (80%)
325
+ { questionId: 'q1', category: 'cat1', reason: 'manual' },
326
+ { questionId: 'q2', category: 'cat1', reason: 'manual' },
327
+ { questionId: 'q3', category: 'cat1', reason: 'manual' },
328
+ { questionId: 'q4', category: 'cat1', reason: 'manual' },
329
+ // Low skip rate (20%)
330
+ { questionId: 'q5', category: 'cat2', reason: 'manual' }
331
+ ]
332
+ }
333
+ ]
334
+ };
335
+
336
+ const answerHistory = {
337
+ sessions: [
338
+ {
339
+ sessionId: 's1',
340
+ answers: [
341
+ { questionId: 'q6', category: 'cat1' }, // 4 skips, 1 answer = 80%
342
+ { questionId: 'q7', category: 'cat2' },
343
+ { questionId: 'q8', category: 'cat2' },
344
+ { questionId: 'q9', category: 'cat2' },
345
+ { questionId: 'q10', category: 'cat2' } // 1 skip, 4 answers = 20%
346
+ ]
347
+ }
348
+ ]
349
+ };
350
+
351
+ await storage.writeLearningData(tempDir, 'skip-history.json', skipHistory);
352
+ await storage.writeLearningData(tempDir, 'answer-history.json', answerHistory);
353
+
354
+ const result = await analytics.generateAnalytics(tempDir);
355
+
356
+ const cat1 = result.categoryPreferences.find(c => c.category === 'cat1');
357
+ const cat2 = result.categoryPreferences.find(c => c.category === 'cat2');
358
+
359
+ expect(cat1.level).toBe('high');
360
+ expect(cat2.level).toBe('low');
361
+ });
362
+
363
+ test('should sort categories by skip rate descending', async () => {
364
+ const skipHistory = {
365
+ sessions: [
366
+ {
367
+ sessionId: 's1',
368
+ skips: [
369
+ { questionId: 'q1', category: 'low', reason: 'manual' },
370
+ { questionId: 'q2', category: 'medium', reason: 'manual' },
371
+ { questionId: 'q3', category: 'medium', reason: 'manual' },
372
+ { questionId: 'q4', category: 'high', reason: 'manual' },
373
+ { questionId: 'q5', category: 'high', reason: 'manual' },
374
+ { questionId: 'q6', category: 'high', reason: 'manual' }
375
+ ]
376
+ }
377
+ ]
378
+ };
379
+
380
+ const answerHistory = {
381
+ sessions: [
382
+ {
383
+ sessionId: 's1',
384
+ answers: [
385
+ { questionId: 'q7', category: 'low' },
386
+ { questionId: 'q8', category: 'low' },
387
+ { questionId: 'q9', category: 'low' }, // 1 skip, 3 answers = 25%
388
+ { questionId: 'q10', category: 'medium' }, // 2 skips, 1 answer = 66%
389
+ { questionId: 'q11', category: 'high' } // 3 skips, 1 answer = 75%
390
+ ]
391
+ }
392
+ ]
393
+ };
394
+
395
+ await storage.writeLearningData(tempDir, 'skip-history.json', skipHistory);
396
+ await storage.writeLearningData(tempDir, 'answer-history.json', answerHistory);
397
+
398
+ const result = await analytics.generateAnalytics(tempDir);
399
+
400
+ expect(result.categoryPreferences[0].category).toBe('high');
401
+ expect(result.categoryPreferences[1].category).toBe('medium');
402
+ expect(result.categoryPreferences[2].category).toBe('low');
403
+ });
404
+ });
405
+
406
+ describe('Pattern Distribution', () => {
407
+ test('should categorize patterns by confidence level', async () => {
408
+ const patterns = {
409
+ patterns: [
410
+ { id: 'p1', confidence: 95, timesApplied: 10 }, // High (90%+)
411
+ { id: 'p2', confidence: 92, timesApplied: 5 }, // High
412
+ { id: 'p3', confidence: 85, timesApplied: 3 }, // Medium (75-89%)
413
+ { id: 'p4', confidence: 80, timesApplied: 2 }, // Medium
414
+ { id: 'p5', confidence: 65, timesApplied: 1 }, // Low (50-74%)
415
+ { id: 'p6', confidence: 45, timesApplied: 1 } // Very Low (<50%)
416
+ ]
417
+ };
418
+
419
+ await storage.writeLearningData(tempDir, 'patterns.json', patterns);
420
+
421
+ const result = await analytics.generateAnalytics(tempDir);
422
+
423
+ expect(result.patternDistribution.totalPatterns).toBe(6);
424
+ expect(result.patternDistribution.distribution.high).toBe(2);
425
+ expect(result.patternDistribution.distribution.medium).toBe(2);
426
+ expect(result.patternDistribution.distribution.low).toBe(1);
427
+ expect(result.patternDistribution.distribution.veryLow).toBe(1);
428
+ });
429
+
430
+ test('should calculate average confidence correctly', async () => {
431
+ const patterns = {
432
+ patterns: [
433
+ { id: 'p1', confidence: 100, timesApplied: 1 },
434
+ { id: 'p2', confidence: 80, timesApplied: 1 },
435
+ { id: 'p3', confidence: 60, timesApplied: 1 }
436
+ ]
437
+ };
438
+
439
+ await storage.writeLearningData(tempDir, 'patterns.json', patterns);
440
+
441
+ const result = await analytics.generateAnalytics(tempDir);
442
+
443
+ expect(result.patternDistribution.avgConfidence).toBe(80); // (100 + 80 + 60) / 3
444
+ });
445
+
446
+ test('should count patterns at risk (confidence < 50)', async () => {
447
+ const now = Date.now();
448
+ const patterns = {
449
+ patterns: [
450
+ { id: 'p1', confidence: 48, timesApplied: 1, lastSeen: new Date(now - 10 * 24 * 60 * 60 * 1000).toISOString() }, // 10 days ago
451
+ { id: 'p2', confidence: 42, timesApplied: 1, lastSeen: new Date(now - 10 * 24 * 60 * 60 * 1000).toISOString() }, // 10 days ago
452
+ { id: 'p3', confidence: 90, timesApplied: 1, lastSeen: new Date(now - 10 * 24 * 60 * 60 * 1000).toISOString() } // 10 days ago
453
+ ]
454
+ };
455
+
456
+ await storage.writeLearningData(tempDir, 'patterns.json', patterns);
457
+
458
+ const result = await analytics.generateAnalytics(tempDir);
459
+
460
+ expect(result.patternDistribution.atRiskCount).toBe(2); // Only p1 and p2 (< 50 confidence)
461
+ });
462
+ });
463
+
464
+ describe('Effectiveness Metrics', () => {
465
+ test('should calculate time saved based on filtered skips', async () => {
466
+ const skipHistory = {
467
+ sessions: [
468
+ {
469
+ sessionId: 's1',
470
+ skips: [
471
+ { questionId: 'q1', reason: 'filtered' },
472
+ { questionId: 'q2', reason: 'filtered' },
473
+ { questionId: 'q3', reason: 'filtered' }
474
+ ]
475
+ }
476
+ ]
477
+ };
478
+
479
+ await storage.writeLearningData(tempDir, 'skip-history.json', skipHistory);
480
+
481
+ const result = await analytics.generateAnalytics(tempDir);
482
+
483
+ // 3 filtered skips * 2.5 minutes average = 7.5, rounds to 8 minutes
484
+ expect(result.effectiveness.timeSavedMinutes).toBe(8);
485
+ expect(result.effectiveness.timeSavedHours).toBe("0.1"); // 8/60 = 0.133, formatted to 1 decimal = "0.1"
486
+ expect(result.effectiveness.questionsFiltered).toBe(3);
487
+ });
488
+
489
+ test('should calculate pattern accuracy', async () => {
490
+ const patterns = {
491
+ patterns: [
492
+ { id: 'p1', confidence: 95, timesApplied: 10, userApproved: true },
493
+ { id: 'p2', confidence: 85, timesApplied: 5, userApproved: true },
494
+ { id: 'p3', confidence: 70, timesApplied: 3, userApproved: false }
495
+ ]
496
+ };
497
+
498
+ await storage.writeLearningData(tempDir, 'patterns.json', patterns);
499
+
500
+ const result = await analytics.generateAnalytics(tempDir);
501
+
502
+ // Pattern accuracy = (user-approved patterns / total patterns) * 100
503
+ // 2 approved / 3 total = 66.67%, rounds to 67%
504
+ expect(result.effectiveness.patternAccuracy).toBe(67);
505
+ });
506
+
507
+ test('should calculate rule application statistics', async () => {
508
+ const rules = {
509
+ rules: [
510
+ { id: 'r1', enabled: true, appliedCount: 15 },
511
+ { id: 'r2', enabled: true, appliedCount: 10 },
512
+ { id: 'r3', enabled: true, appliedCount: 5 },
513
+ { id: 'r4', enabled: false, appliedCount: 100 } // Disabled, but appliedCount is still counted
514
+ ]
515
+ };
516
+
517
+ await storage.writeLearningData(tempDir, 'learned-rules.json', rules);
518
+
519
+ const result = await analytics.generateAnalytics(tempDir);
520
+
521
+ expect(result.effectiveness.totalRuleApplications).toBe(130); // All rules counted (15+10+5+100)
522
+ expect(result.effectiveness.avgApplicationsPerRule).toBe(33); // 130 / 4 rules = 32.5, rounds to 33
523
+ });
524
+
525
+ test('should calculate overall effectiveness score', async () => {
526
+ const skipHistory = {
527
+ sessions: [
528
+ {
529
+ sessionId: 's1',
530
+ skips: [
531
+ { questionId: 'q1', reason: 'filtered' },
532
+ { questionId: 'q2', reason: 'manual' }
533
+ ]
534
+ }
535
+ ]
536
+ };
537
+
538
+ const patterns = {
539
+ patterns: [
540
+ { id: 'p1', confidence: 90, timesApplied: 10, userApproved: true }
541
+ ]
542
+ };
543
+
544
+ await storage.writeLearningData(tempDir, 'skip-history.json', skipHistory);
545
+ await storage.writeLearningData(tempDir, 'patterns.json', patterns);
546
+
547
+ const result = await analytics.generateAnalytics(tempDir);
548
+
549
+ expect(result.effectiveness.overallEffectiveness).toBe(100); // 1/1 patterns approved = 100%
550
+ expect(result.effectiveness.overallEffectiveness).toBeLessThanOrEqual(100);
551
+ });
552
+ });
553
+
554
+ describe('Decay Status', () => {
555
+ test('should categorize pattern health correctly', async () => {
556
+ const now = Date.now();
557
+ const patterns = {
558
+ patterns: [
559
+ // Healthy: high confidence, recent
560
+ {
561
+ id: 'p1',
562
+ confidence: 90,
563
+ createdAt: new Date(now - 30 * 24 * 60 * 60 * 1000).toISOString(),
564
+ lastSeen: new Date(now - 5 * 24 * 60 * 60 * 1000).toISOString()
565
+ },
566
+ // Warning: medium confidence or older
567
+ {
568
+ id: 'p2',
569
+ confidence: 65,
570
+ createdAt: new Date(now - 60 * 24 * 60 * 60 * 1000).toISOString(),
571
+ lastSeen: new Date(now - 35 * 24 * 60 * 60 * 1000).toISOString()
572
+ },
573
+ // Critical: low confidence, old
574
+ {
575
+ id: 'p3',
576
+ confidence: 45,
577
+ createdAt: new Date(now - 120 * 24 * 60 * 60 * 1000).toISOString(),
578
+ lastSeen: new Date(now - 100 * 24 * 60 * 60 * 1000).toISOString()
579
+ }
580
+ ]
581
+ };
582
+
583
+ await storage.writeLearningData(tempDir, 'patterns.json', patterns);
584
+
585
+ const result = await analytics.generateAnalytics(tempDir);
586
+
587
+ expect(result.decayStatus.healthy).toBe(1);
588
+ expect(result.decayStatus.warning).toBe(1);
589
+ expect(result.decayStatus.critical).toBe(1);
590
+ });
591
+
592
+ test('should count recently renewed patterns', async () => {
593
+ const now = Date.now();
594
+ const patterns = {
595
+ patterns: [
596
+ {
597
+ id: 'p1',
598
+ confidence: 90,
599
+ createdAt: new Date(now - 60 * 24 * 60 * 60 * 1000).toISOString(),
600
+ lastSeen: new Date(now - 10 * 24 * 60 * 60 * 1000).toISOString(), // Recent
601
+ timesRenewed: 2
602
+ },
603
+ {
604
+ id: 'p2',
605
+ confidence: 85,
606
+ createdAt: new Date(now - 90 * 24 * 60 * 60 * 1000).toISOString(),
607
+ lastSeen: new Date(now - 40 * 24 * 60 * 60 * 1000).toISOString(), // Not recent
608
+ timesRenewed: 1
609
+ }
610
+ ]
611
+ };
612
+
613
+ await storage.writeLearningData(tempDir, 'patterns.json', patterns);
614
+
615
+ const result = await analytics.generateAnalytics(tempDir);
616
+
617
+ expect(result.decayStatus.recentlyRenewed).toBeGreaterThan(0);
618
+ });
619
+
620
+ test('should calculate average pattern age', async () => {
621
+ const now = Date.now();
622
+ const patterns = {
623
+ patterns: [
624
+ {
625
+ id: 'p1',
626
+ confidence: 90,
627
+ createdAt: new Date(now - 30 * 24 * 60 * 60 * 1000).toISOString() // 30 days old
628
+ },
629
+ {
630
+ id: 'p2',
631
+ confidence: 85,
632
+ createdAt: new Date(now - 60 * 24 * 60 * 60 * 1000).toISOString() // 60 days old
633
+ }
634
+ ]
635
+ };
636
+
637
+ await storage.writeLearningData(tempDir, 'patterns.json', patterns);
638
+
639
+ const result = await analytics.generateAnalytics(tempDir);
640
+
641
+ expect(result.decayStatus.avgAge).toBe(45); // (30 + 60) / 2
642
+ });
643
+ });
644
+
645
+ describe('Edge Cases', () => {
646
+ test('should handle empty pattern array', async () => {
647
+ await storage.writeLearningData(tempDir, 'patterns.json', { patterns: [] });
648
+
649
+ const result = await analytics.generateAnalytics(tempDir);
650
+
651
+ expect(result.patternDistribution.totalPatterns).toBe(0);
652
+ expect(result.patternDistribution.avgConfidence).toBe(0);
653
+ expect(result.decayStatus.avgAge).toBe(0);
654
+ });
655
+
656
+ test('should handle sessions without timestamps', async () => {
657
+ const skipHistory = {
658
+ sessions: [
659
+ {
660
+ sessionId: 's1',
661
+ // No timestamp
662
+ skips: [{ questionId: 'q1', reason: 'manual' }]
663
+ }
664
+ ]
665
+ };
666
+
667
+ await storage.writeLearningData(tempDir, 'skip-history.json', skipHistory);
668
+
669
+ const result = await analytics.generateAnalytics(tempDir);
670
+
671
+ expect(result.overview.totalSkips).toBe(1);
672
+ expect(result.skipTrends.length).toBeGreaterThanOrEqual(0);
673
+ });
674
+
675
+ test('should handle patterns without metadata', async () => {
676
+ const patterns = {
677
+ patterns: [
678
+ {
679
+ id: 'p1',
680
+ confidence: 90
681
+ // No createdAt, lastSeen, timesApplied
682
+ }
683
+ ]
684
+ };
685
+
686
+ await storage.writeLearningData(tempDir, 'patterns.json', patterns);
687
+
688
+ const result = await analytics.generateAnalytics(tempDir);
689
+
690
+ expect(result.patternDistribution.totalPatterns).toBe(1);
691
+ expect(result.effectiveness.patternAccuracy).toBeGreaterThanOrEqual(0);
692
+ });
693
+
694
+ test('should handle very large datasets', async () => {
695
+ const sessions = [];
696
+ for (let i = 0; i < 1000; i++) {
697
+ sessions.push({
698
+ sessionId: `s${i}`,
699
+ timestamp: new Date(Date.now() - i * 24 * 60 * 60 * 1000).toISOString(),
700
+ skips: [{ questionId: `q${i}`, reason: 'manual' }]
701
+ });
702
+ }
703
+
704
+ await storage.writeLearningData(tempDir, 'skip-history.json', { sessions });
705
+
706
+ const result = await analytics.generateAnalytics(tempDir);
707
+
708
+ expect(result.overview.totalSessions).toBe(1000);
709
+ expect(result.skipTrends.length).toBeLessThanOrEqual(12); // Should still cap at 12 weeks
710
+ });
711
+ });
712
+ });