|
| 1 | +import { RuleImpactHandler } from './rule-impact.handler'; |
| 2 | +import { RuleInsightsService } from '../../rules/rule-insights.service'; |
| 3 | +import type { RuleInsight } from '../../rules/rule-insights.service'; |
| 4 | +import { RuleTracker } from '../../rules/rule-tracker'; |
| 5 | +import { RulesService } from '../../rules/rules.service'; |
| 6 | + |
| 7 | +vi.mock('../../rules/rule-tracker'); |
| 8 | + |
| 9 | +describe('RuleImpactHandler', () => { |
| 10 | + let handler: RuleImpactHandler; |
| 11 | + let mockInsightsService: RuleInsightsService; |
| 12 | + let mockRulesService: RulesService; |
| 13 | + |
| 14 | + const now = 1700000000000; |
| 15 | + |
| 16 | + const mockInsight: RuleInsight = { |
| 17 | + generatedAt: now, |
| 18 | + summary: { |
| 19 | + totalRulesTracked: 5, |
| 20 | + totalUsageCount: 42, |
| 21 | + activeRules: 3, |
| 22 | + staleRules: 1, |
| 23 | + }, |
| 24 | + topRules: [ |
| 25 | + { name: 'core', count: 15, lastUsed: now, classification: 'high' }, |
| 26 | + { name: 'project', count: 12, lastUsed: now, classification: 'high' }, |
| 27 | + { name: 'augmented-coding', count: 8, lastUsed: now - 86400000, classification: 'medium' }, |
| 28 | + { name: 'security', count: 5, lastUsed: now - 172800000, classification: 'low' }, |
| 29 | + { name: 'testing', count: 2, lastUsed: now - 2592000000, classification: 'low' }, |
| 30 | + ], |
| 31 | + unusedRules: ['old-deprecated-rule', 'legacy-rule'], |
| 32 | + trends: { |
| 33 | + recentlyActive: ['core', 'project', 'augmented-coding'], |
| 34 | + declining: ['testing'], |
| 35 | + emerging: ['security'], |
| 36 | + }, |
| 37 | + suggestions: ['Some suggestion about high-frequency rules'], |
| 38 | + }; |
| 39 | + |
| 40 | + beforeEach(() => { |
| 41 | + vi.mocked(RuleTracker.fromFile).mockResolvedValue({ |
| 42 | + getStats: vi.fn().mockReturnValue({ |
| 43 | + core: { count: 15, lastUsed: now }, |
| 44 | + project: { count: 12, lastUsed: now }, |
| 45 | + 'augmented-coding': { count: 8, lastUsed: now - 86400000 }, |
| 46 | + security: { count: 5, lastUsed: now - 172800000 }, |
| 47 | + testing: { count: 2, lastUsed: now - 2592000000 }, |
| 48 | + }), |
| 49 | + } as unknown as RuleTracker); |
| 50 | + |
| 51 | + mockInsightsService = { |
| 52 | + generateInsights: vi.fn().mockReturnValue(mockInsight), |
| 53 | + } as unknown as RuleInsightsService; |
| 54 | + |
| 55 | + mockRulesService = { |
| 56 | + searchRules: vi.fn().mockResolvedValue([ |
| 57 | + { file: 'rules/core.md', matches: [], score: 1 }, |
| 58 | + { file: 'rules/project.md', matches: [], score: 1 }, |
| 59 | + ]), |
| 60 | + } as unknown as RulesService; |
| 61 | + |
| 62 | + handler = new RuleImpactHandler(mockInsightsService, mockRulesService); |
| 63 | + }); |
| 64 | + |
| 65 | + afterEach(() => { |
| 66 | + vi.restoreAllMocks(); |
| 67 | + }); |
| 68 | + |
| 69 | + it('should return null for unhandled tools', async () => { |
| 70 | + const result = await handler.handle('unknown_tool', {}); |
| 71 | + expect(result).toBeNull(); |
| 72 | + }); |
| 73 | + |
| 74 | + describe('get_rule_impact_report', () => { |
| 75 | + it('should return a formatted markdown report', async () => { |
| 76 | + const result = await handler.handle('get_rule_impact_report', {}); |
| 77 | + |
| 78 | + expect(result).not.toBeNull(); |
| 79 | + expect(result?.isError).toBeFalsy(); |
| 80 | + |
| 81 | + const text = result!.content[0].text; |
| 82 | + // Should contain markdown headings for each section |
| 83 | + expect(text).toContain('# Rule Impact Report'); |
| 84 | + expect(text).toContain('## Summary'); |
| 85 | + expect(text).toContain('## Top Rules'); |
| 86 | + expect(text).toContain('## Domain Coverage'); |
| 87 | + expect(text).toContain('## Unused Rules'); |
| 88 | + }); |
| 89 | + |
| 90 | + it('should include summary statistics in the report', async () => { |
| 91 | + const result = await handler.handle('get_rule_impact_report', {}); |
| 92 | + const text = result!.content[0].text; |
| 93 | + |
| 94 | + expect(text).toContain('42'); // total usage count |
| 95 | + expect(text).toContain('5'); // total rules tracked |
| 96 | + }); |
| 97 | + |
| 98 | + it('should include time saved estimation', async () => { |
| 99 | + const result = await handler.handle('get_rule_impact_report', {}); |
| 100 | + const text = result!.content[0].text; |
| 101 | + |
| 102 | + // Each violation caught saves ~15 min, each checklist check saves ~5 min |
| 103 | + expect(text).toContain('Estimated Time Saved'); |
| 104 | + }); |
| 105 | + |
| 106 | + it('should include top rules as a table', async () => { |
| 107 | + const result = await handler.handle('get_rule_impact_report', {}); |
| 108 | + const text = result!.content[0].text; |
| 109 | + |
| 110 | + // Markdown table header |
| 111 | + expect(text).toContain('| Rule | Applications | Classification |'); |
| 112 | + expect(text).toContain('core'); |
| 113 | + expect(text).toContain('project'); |
| 114 | + }); |
| 115 | + |
| 116 | + it('should list unused rules', async () => { |
| 117 | + const result = await handler.handle('get_rule_impact_report', {}); |
| 118 | + const text = result!.content[0].text; |
| 119 | + |
| 120 | + expect(text).toContain('old-deprecated-rule'); |
| 121 | + expect(text).toContain('legacy-rule'); |
| 122 | + }); |
| 123 | + |
| 124 | + it('should include trend information', async () => { |
| 125 | + const result = await handler.handle('get_rule_impact_report', {}); |
| 126 | + const text = result!.content[0].text; |
| 127 | + |
| 128 | + expect(text).toContain('Trend'); |
| 129 | + }); |
| 130 | + |
| 131 | + it('should handle empty/missing stats gracefully', async () => { |
| 132 | + vi.mocked(RuleTracker.fromFile).mockRejectedValue(new Error('ENOENT')); |
| 133 | + |
| 134 | + const emptyInsight: RuleInsight = { |
| 135 | + generatedAt: now, |
| 136 | + summary: { totalRulesTracked: 0, totalUsageCount: 0, activeRules: 0, staleRules: 0 }, |
| 137 | + topRules: [], |
| 138 | + unusedRules: [], |
| 139 | + trends: { recentlyActive: [], declining: [], emerging: [] }, |
| 140 | + suggestions: [ |
| 141 | + 'No tracking data available yet — use parse_mode to start collecting rule usage data', |
| 142 | + ], |
| 143 | + }; |
| 144 | + (mockInsightsService.generateInsights as ReturnType<typeof vi.fn>).mockReturnValue( |
| 145 | + emptyInsight, |
| 146 | + ); |
| 147 | + |
| 148 | + const result = await handler.handle('get_rule_impact_report', {}); |
| 149 | + |
| 150 | + expect(result).not.toBeNull(); |
| 151 | + expect(result?.isError).toBeFalsy(); |
| 152 | + const text = result!.content[0].text; |
| 153 | + expect(text).toContain('No data collected yet'); |
| 154 | + }); |
| 155 | + |
| 156 | + it('should accept optional period parameter', async () => { |
| 157 | + const result = await handler.handle('get_rule_impact_report', { period: 'week' }); |
| 158 | + |
| 159 | + expect(result).not.toBeNull(); |
| 160 | + expect(result?.isError).toBeFalsy(); |
| 161 | + }); |
| 162 | + |
| 163 | + it('should pass statsPath to RuleTracker.fromFile', async () => { |
| 164 | + await handler.handle('get_rule_impact_report', { statsPath: '/custom/path.json' }); |
| 165 | + |
| 166 | + expect(RuleTracker.fromFile).toHaveBeenCalledWith('/custom/path.json'); |
| 167 | + }); |
| 168 | + |
| 169 | + it('should use default statsPath when not provided', async () => { |
| 170 | + await handler.handle('get_rule_impact_report', {}); |
| 171 | + |
| 172 | + expect(RuleTracker.fromFile).toHaveBeenCalledWith(expect.stringContaining('rule-stats.json')); |
| 173 | + }); |
| 174 | + }); |
| 175 | + |
| 176 | + describe('getToolDefinitions', () => { |
| 177 | + it('should return get_rule_impact_report definition', () => { |
| 178 | + const definitions = handler.getToolDefinitions(); |
| 179 | + |
| 180 | + expect(definitions).toHaveLength(1); |
| 181 | + expect(definitions[0].name).toBe('get_rule_impact_report'); |
| 182 | + expect(definitions[0].inputSchema.properties).toHaveProperty('period'); |
| 183 | + expect(definitions[0].inputSchema.properties).toHaveProperty('statsPath'); |
| 184 | + }); |
| 185 | + |
| 186 | + it('should have a descriptive description', () => { |
| 187 | + const definitions = handler.getToolDefinitions(); |
| 188 | + expect(definitions[0].description).toContain('impact'); |
| 189 | + }); |
| 190 | + }); |
| 191 | +}); |
0 commit comments