diff --git a/services/platform-service/src/modules/ai-diagnostics/clustering.test.ts b/services/platform-service/src/modules/ai-diagnostics/clustering.test.ts new file mode 100644 index 00000000..53d6053a --- /dev/null +++ b/services/platform-service/src/modules/ai-diagnostics/clustering.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from 'vitest'; +import { + runHDBSCAN, + runDBSCAN, + autoSelectParameters, + calculateClusterQuality, +} from './clustering.js'; + +// ── Helpers ────────────────────────────────────────────────────── + +function makePoint(id: string, embedding: number[], errorType = 'TypeError') { + return { + id, + embedding, + metadata: { + errorType, + messageTemplate: `Error in ${id}`, + stackSignature: `sig_${id}`, + productId: 'test', + firstSeenAt: new Date(Date.now() - Math.random() * 86400000).toISOString(), + }, + }; +} + +function makeCluster(center: number[], spread: number, count: number, prefix: string) { + return Array.from({ length: count }, (_, i) => + makePoint( + `${prefix}_${i}`, + center.map(c => c + (Math.random() - 0.5) * spread) + ) + ); +} + +// ============================================================================ +// runHDBSCAN +// ============================================================================ + +describe('runHDBSCAN', () => { + it('returns all points as noise when fewer than minClusterSize', () => { + const points = [makePoint('a', [1, 0, 0]), makePoint('b', [0, 1, 0])]; + const result = runHDBSCAN(points, { minClusterSize: 3 }); + expect(result.clusters).toHaveLength(0); + expect(result.noise).toHaveLength(2); + expect(result.labels.get('a')).toBe(-1); + }); + + it('finds clusters in well-separated data', () => { + const clusterA = makeCluster([1, 0, 0], 0.01, 5, 'a'); + const clusterB = makeCluster([0, 0, 1], 0.01, 5, 'b'); + const points = [...clusterA, ...clusterB]; + + const result = runHDBSCAN(points, { minClusterSize: 3, minSamples: 2, metric: 'euclidean' }); + expect(result.clusters.length).toBeGreaterThanOrEqual(1); + // Every point should get a label + expect(result.labels.size).toBe(points.length); + }); + + it('assigns centroids to clusters', () => { + const points = makeCluster([1, 1, 1], 0.01, 6, 'c'); + const result = runHDBSCAN(points, { minClusterSize: 3, metric: 'euclidean' }); + for (const cluster of result.clusters) { + expect(cluster.centroid.length).toBe(3); + // Centroid should be near [1,1,1] + for (const val of cluster.centroid) { + expect(val).toBeCloseTo(1, 0); + } + } + }); + + it('handles single-dimension embeddings', () => { + const points = [ + makePoint('x1', [0.1]), + makePoint('x2', [0.2]), + makePoint('x3', [0.15]), + makePoint('x4', [0.9]), + makePoint('x5', [0.95]), + makePoint('x6', [0.88]), + ]; + const result = runHDBSCAN(points, { minClusterSize: 2, minSamples: 1, metric: 'euclidean' }); + expect(result.labels.size).toBe(6); + }); +}); + +// ============================================================================ +// runDBSCAN +// ============================================================================ + +describe('runDBSCAN', () => { + it('finds dense clusters and marks outliers as noise', () => { + const cluster = makeCluster([0, 0], 0.05, 5, 'dense'); + const outlier = makePoint('outlier', [10, 10]); + const points = [...cluster, outlier]; + + const result = runDBSCAN(points, { eps: 0.5, minPts: 2 }); + expect(result.clusters.length).toBeGreaterThanOrEqual(1); + expect(result.labels.get('outlier')).toBe(-1); + }); + + it('returns no clusters when eps is too small', () => { + const points = makeCluster([0, 0], 1.0, 5, 'spread'); + const result = runDBSCAN(points, { eps: 0.001, minPts: 3 }); + expect(result.clusters).toHaveLength(0); + expect(result.noise).toHaveLength(5); + }); + + it('puts all points in one cluster when eps is large enough', () => { + const points = makeCluster([0, 0], 0.1, 6, 'tight'); + const result = runDBSCAN(points, { eps: 100, minPts: 2 }); + expect(result.clusters).toHaveLength(1); + expect(result.noise).toHaveLength(0); + }); +}); + +// ============================================================================ +// autoSelectParameters +// ============================================================================ + +describe('autoSelectParameters', () => { + it('selects DBSCAN for very small datasets (<10)', () => { + const params = autoSelectParameters(5); + expect(params.algorithm).toBe('dbscan'); + }); + + it('selects HDBSCAN with small minClusterSize for small datasets', () => { + const params = autoSelectParameters(25); + expect(params.algorithm).toBe('hdbscan'); + expect(params.minClusterSize).toBe(2); + }); + + it('selects HDBSCAN with medium settings for medium datasets', () => { + const params = autoSelectParameters(100); + expect(params.algorithm).toBe('hdbscan'); + expect(params.minClusterSize).toBe(3); + expect(params.metric).toBe('cosine'); + }); + + it('selects HDBSCAN with larger settings for large datasets', () => { + const params = autoSelectParameters(1000); + expect(params.algorithm).toBe('hdbscan'); + expect(params.minClusterSize).toBe(5); + expect(params.minSamples).toBe(3); + }); +}); + +// ============================================================================ +// calculateClusterQuality +// ============================================================================ + +describe('calculateClusterQuality', () => { + it('returns metrics object with silhouetteScore', () => { + const points = [...makeCluster([0, 0], 0.01, 3, 'a'), ...makeCluster([5, 5], 0.01, 3, 'b')]; + const labels = new Map(); + points.forEach((p, i) => labels.set(p.id, i < 3 ? 0 : 1)); + + const clusters = [ + { + id: 'c0', + points: points.slice(0, 3), + centroid: [0, 0], + stability: 1, + density: 1, + }, + { + id: 'c1', + points: points.slice(3), + centroid: [5, 5], + stability: 1, + density: 1, + }, + ]; + + const quality = calculateClusterQuality(points, labels, clusters); + expect(quality).toHaveProperty('silhouetteScore'); + expect(quality).toHaveProperty('daviesBouldinIndex'); + expect(quality).toHaveProperty('calinskiHarabaszIndex'); + // Well-separated clusters should have positive silhouette + expect(quality.silhouetteScore).toBeGreaterThan(0); + }); + + it('returns 0 silhouette when all points are noise', () => { + const points = [makePoint('n1', [0, 0]), makePoint('n2', [1, 1])]; + const labels = new Map([ + ['n1', -1], + ['n2', -1], + ]); + const quality = calculateClusterQuality(points, labels, []); + expect(quality.silhouetteScore).toBe(0); + }); +}); diff --git a/services/platform-service/src/modules/ai-diagnostics/error-normalization.test.ts b/services/platform-service/src/modules/ai-diagnostics/error-normalization.test.ts new file mode 100644 index 00000000..1edf5528 --- /dev/null +++ b/services/platform-service/src/modules/ai-diagnostics/error-normalization.test.ts @@ -0,0 +1,430 @@ +import { describe, expect, it } from 'vitest'; +import { + normalizeErrorMessage, + parseStackTrace, + normalizeStackFrames, + generateFingerprint, + levenshteinDistance, + calculateFingerprintSimilarity, + processErrorEvent, + updateClusterWithError, +} from './error-normalization.js'; +import type { ErrorEvent, ErrorFingerprint, ErrorClusterDoc } from './types.js'; + +// ============================================================================ +// normalizeErrorMessage +// ============================================================================ + +describe('normalizeErrorMessage', () => { + it('replaces UUIDs with ', () => { + const msg = 'Failed for user 550e8400-e29b-41d4-a716-446655440000'; + expect(normalizeErrorMessage(msg)).toContain(''); + expect(normalizeErrorMessage(msg)).not.toContain('550e8400'); + }); + + it('replaces MongoDB ObjectIds with ', () => { + const msg = 'Document 507f1f77bcf86cd799439011 not found'; + expect(normalizeErrorMessage(msg)).toContain(''); + }); + + it('replaces email addresses with ', () => { + const msg = 'Login failed for alice@example.com'; + expect(normalizeErrorMessage(msg)).toContain(''); + expect(normalizeErrorMessage(msg)).not.toContain('alice@example.com'); + }); + + it('replaces IPv4 addresses with ', () => { + const msg = 'Connection refused from 192.168.1.100'; + expect(normalizeErrorMessage(msg)).toContain(''); + }); + + it('replaces ISO timestamps with ', () => { + const msg = 'Error at 2026-03-21T12:30:45.123Z'; + expect(normalizeErrorMessage(msg)).toContain(''); + }); + + it('replaces simple dates with ', () => { + const msg = 'Last seen on 03/21/2026'; + expect(normalizeErrorMessage(msg)).toContain(''); + }); + + it('replaces user IDs with ', () => { + const msg = 'Cannot find user_12345'; + expect(normalizeErrorMessage(msg)).toContain(''); + }); + + it('replaces long numbers with ', () => { + const msg = 'Record 12345678901 overflow'; + expect(normalizeErrorMessage(msg)).toContain(''); + }); + + it('replaces URLs with ', () => { + const msg = 'Fetch failed for https://api.example.com/v1/data'; + expect(normalizeErrorMessage(msg)).toContain(''); + }); + + it('normalizes file paths keeping filename', () => { + const msg = 'Error in /src/lib/server.ts'; + const result = normalizeErrorMessage(msg); + expect(result).toContain('server.ts'); + }); + + it('handles messages with multiple replaceable parts', () => { + const msg = + 'User user_999 at 192.168.0.1 failed at 2026-01-01T00:00:00Z with id 550e8400-e29b-41d4-a716-446655440000'; + const result = normalizeErrorMessage(msg); + expect(result).toContain(''); + expect(result).toContain(''); + expect(result).toContain(''); + expect(result).toContain(''); + }); + + it('returns unchanged message if nothing to normalize', () => { + expect(normalizeErrorMessage('TypeError: cannot read property')).toBe( + 'TypeError: cannot read property' + ); + }); +}); + +// ============================================================================ +// parseStackTrace +// ============================================================================ + +describe('parseStackTrace', () => { + it('parses JavaScript stack frames', () => { + const stack = `Error: something went wrong + at handleRequest (/app/src/server.ts:42:10) + at processTicksAndRejections (internal/process/task_queues.js:95:5)`; + const frames = parseStackTrace(stack); + expect(frames.length).toBeGreaterThanOrEqual(1); + expect(frames[0].function).toBe('handleRequest'); + expect(frames[0].line).toBe(42); + }); + + it('parses async JavaScript stack frames', () => { + const stack = ' at async fetchData (/app/lib/api.ts:10:3)'; + const frames = parseStackTrace(stack); + expect(frames.length).toBe(1); + expect(frames[0].function).toBe('fetchData'); + }); + + it('parses Python stack frames', () => { + const stack = ' File "/app/main.py", line 25, in handle_request'; + const frames = parseStackTrace(stack); + expect(frames.length).toBe(1); + expect(frames[0].function).toBe('handle_request'); + expect(frames[0].line).toBe(25); + }); + + it('parses Java/Kotlin stack frames', () => { + const stack = ' at com.example.MyClass.doSomething(MyClass.java:55)'; + const frames = parseStackTrace(stack); + expect(frames.length).toBe(1); + expect(frames[0].line).toBe(55); + }); + + it('returns empty array for empty input', () => { + expect(parseStackTrace('')).toEqual([]); + }); + + it('skips unparseable lines', () => { + const stack = `Error: boom +This is not a stack frame +Another line + at realFrame (/app/file.ts:1:1)`; + const frames = parseStackTrace(stack); + expect(frames.length).toBe(1); + expect(frames[0].function).toBe('realFrame'); + }); +}); + +// ============================================================================ +// normalizeStackFrames +// ============================================================================ + +describe('normalizeStackFrames', () => { + it('normalizes and joins frames', () => { + const frames = [ + { function: 'handleRequest', file: '/app/server.ts', line: 42, column: 10 }, + { function: 'processData', file: '/app/lib/data.ts', line: 15 }, + ]; + const result = normalizeStackFrames(frames); + expect(result).toContain('handleRequest@'); + expect(result).toContain('|'); + expect(result).toContain('processData@'); + }); + + it('truncates to maxFrames', () => { + const frames = Array.from({ length: 20 }, (_, i) => ({ + function: `fn${i}`, + file: `/app/f${i}.ts`, + line: i, + })); + const result = normalizeStackFrames(frames, 3); + expect(result.split('|').length).toBe(3); + }); + + it('handles empty frames', () => { + expect(normalizeStackFrames([])).toBe(''); + }); +}); + +// ============================================================================ +// generateFingerprint +// ============================================================================ + +describe('generateFingerprint', () => { + it('generates a SHA-256 hash', () => { + const result = generateFingerprint({ + errorType: 'TypeError', + message: 'Cannot read property of null', + }); + expect(result.hash).toMatch(/^[a-f0-9]{64}$/); + }); + + it('strips Error/Exception suffix from type', () => { + const result = generateFingerprint({ + errorType: 'NullPointerException', + message: 'Null pointer', + }); + expect(result.normalizedType).toBe('NullPointer'); + }); + + it('normalizes the message', () => { + const result = generateFingerprint({ + errorType: 'Error', + message: 'Failed for user user_123 at 192.168.1.1', + }); + expect(result.normalizedMessage).toContain(''); + expect(result.normalizedMessage).toContain(''); + }); + + it('extracts source location from stack trace', () => { + const result = generateFingerprint({ + errorType: 'Error', + message: 'boom', + stackTrace: ' at myFunc (/app/src/handler.ts:10:5)', + }); + expect(result.sourceLocation).toBeDefined(); + expect(result.sourceLocation?.function).toBe('myFunc'); + expect(result.sourceLocation?.line).toBe(10); + }); + + it('produces same hash for same normalized input', () => { + const a = generateFingerprint({ + errorType: 'TypeError', + message: 'Cannot read property of null', + }); + const b = generateFingerprint({ + errorType: 'TypeError', + message: 'Cannot read property of null', + }); + expect(a.hash).toBe(b.hash); + }); + + it('produces different hash for different inputs', () => { + const a = generateFingerprint({ errorType: 'TypeError', message: 'a' }); + const b = generateFingerprint({ errorType: 'RangeError', message: 'b' }); + expect(a.hash).not.toBe(b.hash); + }); +}); + +// ============================================================================ +// levenshteinDistance +// ============================================================================ + +describe('levenshteinDistance', () => { + it('returns 0 for identical strings', () => { + expect(levenshteinDistance('abc', 'abc')).toBe(0); + }); + + it('returns correct distance for single edit', () => { + expect(levenshteinDistance('cat', 'bat')).toBe(1); + }); + + it('returns length of longer string when one is empty', () => { + expect(levenshteinDistance('', 'abc')).toBe(3); + expect(levenshteinDistance('abc', '')).toBe(3); + }); + + it('handles completely different strings', () => { + expect(levenshteinDistance('abc', 'xyz')).toBe(3); + }); +}); + +// ============================================================================ +// calculateFingerprintSimilarity +// ============================================================================ + +describe('calculateFingerprintSimilarity', () => { + it('returns 1 for identical fingerprints', () => { + const fp = { + hash: 'abc', + normalizedType: 'Type', + normalizedMessage: 'msg', + stackSignature: 'sig', + }; + expect(calculateFingerprintSimilarity(fp, fp)).toBeCloseTo(1, 1); + }); + + it('returns high score for same type and similar message', () => { + const a = { + hash: 'a', + normalizedType: 'TypeError', + normalizedMessage: 'Cannot read property x', + stackSignature: '', + }; + const b = { + hash: 'b', + normalizedType: 'TypeError', + normalizedMessage: 'Cannot read property y', + stackSignature: '', + }; + expect(calculateFingerprintSimilarity(a, b)).toBeGreaterThan(0.7); + }); + + it('returns low score for different types and messages', () => { + const a = { + hash: 'a', + normalizedType: 'TypeError', + normalizedMessage: 'null pointer', + stackSignature: '', + }; + const b = { + hash: 'b', + normalizedType: 'RangeError', + normalizedMessage: 'stack overflow in recursive call', + stackSignature: '', + }; + expect(calculateFingerprintSimilarity(a, b)).toBeLessThan(0.5); + }); +}); + +// ============================================================================ +// processErrorEvent +// ============================================================================ + +describe('processErrorEvent', () => { + const baseEvent: ErrorEvent = { + id: 'ev_1', + productId: 'lysnrai', + errorType: 'TypeError', + message: 'Cannot read property of null', + timestamp: new Date().toISOString(), + platform: 'ios', + osVersion: '18.0', + appVersion: '1.2.0', + }; + + it('creates a new cluster when no existing fingerprints', () => { + const result = processErrorEvent(baseEvent); + expect(result.isNewCluster).toBe(true); + expect(result.clusterId).toMatch(/^ec_/); + expect(result.fingerprint.hash).toBeTruthy(); + }); + + it('matches existing fingerprint when similarity >= 0.85', () => { + const fp = generateFingerprint({ + errorType: baseEvent.errorType, + message: baseEvent.message, + }); + + const existing: ErrorFingerprint = { + id: 'ef_existing', + productId: 'lysnrai', + fingerprintHash: fp.hash, + errorType: fp.normalizedType, + messageTemplate: fp.normalizedMessage, + stackSignature: fp.stackSignature, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + occurrenceCount: 5, + uniqueUsers: 3, + ttl: 90 * 86400, + }; + + const existingMap = new Map(); + existingMap.set(existing.id, existing); + + const result = processErrorEvent(baseEvent, existingMap); + expect(result.isNewCluster).toBe(false); + expect(result.clusterId).toBe('ef_existing'); + }); + + it('extracts platform context from error event', () => { + const result = processErrorEvent(baseEvent); + expect(result.context.platform).toBe('ios'); + expect(result.context.osVersion).toBe('18.0'); + expect(result.context.appVersion).toBe('1.2.0'); + }); +}); + +// ============================================================================ +// updateClusterWithError +// ============================================================================ + +describe('updateClusterWithError', () => { + const cluster: ErrorClusterDoc = { + id: 'ec_1', + productId: 'lysnrai', + fingerprintHash: 'fp_1', + firstSeenAt: '2026-03-20T00:00:00.000Z', + lastSeenAt: '2026-03-20T12:00:00.000Z', + occurrenceCount: 5, + uniqueUsers: 2, + errorType: 'TypeError', + messageTemplate: 'Cannot read property', + stackSignature: 'sig', + relatedClusterIds: [], + status: 'active', + createdAt: '2026-03-20T00:00:00.000Z', + updatedAt: '2026-03-20T12:00:00.000Z', + ttl: 90 * 86400, + }; + + const errorEvent: ErrorEvent = { + id: 'ev_2', + productId: 'lysnrai', + errorType: 'TypeError', + message: 'Cannot read property of null', + timestamp: new Date().toISOString(), + platform: 'ios', + osVersion: '18.1', + appVersion: '1.3.0', + userId: 'user_new', + }; + + const fingerprint = generateFingerprint({ + errorType: errorEvent.errorType, + message: errorEvent.message, + }); + + it('increments occurrence count', () => { + const updated = updateClusterWithError(cluster, errorEvent, fingerprint); + expect(updated.occurrenceCount).toBe(6); + }); + + it('updates lastSeenAt timestamp', () => { + const updated = updateClusterWithError(cluster, errorEvent, fingerprint); + expect(new Date(updated.lastSeenAt).getTime()).toBeGreaterThan( + new Date(cluster.lastSeenAt).getTime() + ); + }); + + it('re-activates resolved clusters', () => { + const resolvedCluster = { ...cluster, status: 'resolved' as const }; + const updated = updateClusterWithError(resolvedCluster, errorEvent, fingerprint); + expect(updated.status).toBe('active'); + }); + + it('updates commonContext with new os/app version', () => { + const updated = updateClusterWithError(cluster, errorEvent, fingerprint); + expect(updated.commonContext).toBeDefined(); + expect(updated.commonContext!.osVersions).toEqual( + expect.arrayContaining([expect.objectContaining({ version: '18.1' })]) + ); + expect(updated.commonContext!.appVersions).toEqual( + expect.arrayContaining([expect.objectContaining({ version: '1.3.0' })]) + ); + }); +}); diff --git a/services/platform-service/src/modules/ai-diagnostics/query-parser.test.ts b/services/platform-service/src/modules/ai-diagnostics/query-parser.test.ts new file mode 100644 index 00000000..03db3e9b --- /dev/null +++ b/services/platform-service/src/modules/ai-diagnostics/query-parser.test.ts @@ -0,0 +1,309 @@ +import { describe, expect, it } from 'vitest'; +import { + parseQuery, + matchQueryPattern, + generateQuerySuggestions, + validateQuery, + QUERY_PATTERNS, +} from './query-parser.js'; + +// ============================================================================ +// parseQuery — intent classification +// ============================================================================ + +describe('parseQuery — intent classification', () => { + it('classifies "why" questions as root_cause', () => { + const result = parseQuery('Why did iOS crash yesterday?'); + expect(result.intent).toBe('root_cause'); + }); + + it('classifies "what caused" as root_cause', () => { + const result = parseQuery('What caused the authentication failures?'); + expect(result.intent).toBe('root_cause'); + }); + + it('classifies "show me" as pattern_search', () => { + const result = parseQuery('Show me similar database errors'); + expect(result.intent).toBe('pattern_search'); + }); + + it('classifies "find" as pattern_search', () => { + const result = parseQuery('Find crashes like the login timeout'); + expect(result.intent).toBe('pattern_search'); + }); + + it('classifies "compare" as comparison', () => { + const result = parseQuery('Compare this week to last week'); + expect(result.intent).toBe('comparison'); + }); + + it('classifies "trend" as trend', () => { + const result = parseQuery('Show the trend for timeout errors over time'); + expect(result.intent).toBe('trend'); + }); + + it('classifies "how many" as impact', () => { + const result = parseQuery('How many users were affected by the crash?'); + expect(result.intent).toBe('impact'); + }); + + it('defaults to root_cause for ambiguous queries', () => { + const result = parseQuery('errors happening on iOS'); + expect(result.intent).toBe('root_cause'); + }); +}); + +// ============================================================================ +// parseQuery — entity extraction +// ============================================================================ + +describe('parseQuery — entity extraction', () => { + it('extracts iOS platform', () => { + const result = parseQuery('Show iOS crashes'); + expect(result.entities.platforms).toContain('ios'); + }); + + it('extracts Android platform', () => { + const result = parseQuery('Android login failures'); + expect(result.entities.platforms).toContain('android'); + }); + + it('extracts web platform', () => { + const result = parseQuery('Web dashboard timeout'); + expect(result.entities.platforms).toContain('web'); + }); + + it('extracts desktop platform', () => { + const result = parseQuery('Desktop app crashes on Mac'); + expect(result.entities.platforms).toContain('desktop'); + }); + + it('extracts multiple platforms', () => { + const result = parseQuery('Compare iOS and Android crashes'); + expect(result.entities.platforms).toContain('ios'); + expect(result.entities.platforms).toContain('android'); + }); + + it('extracts known product IDs', () => { + const result = parseQuery('Show errors for lysnrai'); + expect(result.entities.products).toContain('lysnrai'); + }); + + it('extracts error types like crash', () => { + const result = parseQuery('Show me recent crash reports'); + expect(result.entities.errorTypes!.length).toBeGreaterThan(0); + }); + + it('extracts error types like timeout', () => { + const result = parseQuery('Why are there timeout errors?'); + expect(result.entities.errorTypes!.length).toBeGreaterThan(0); + }); + + it('extracts error types like memory leak', () => { + const result = parseQuery('Investigate the memory leak'); + expect(result.entities.errorTypes!.length).toBeGreaterThan(0); + }); +}); + +// ============================================================================ +// parseQuery — time range extraction +// ============================================================================ + +describe('parseQuery — time range extraction', () => { + it('extracts "yesterday" time range', () => { + const result = parseQuery('What crashed yesterday?'); + expect(result.entities.timeRange).toBeDefined(); + const start = new Date(result.entities.timeRange!.start); + const end = new Date(result.entities.timeRange!.end); + // Start is yesterday midnight, end is yesterday 23:59:59 — span is ~24h + // But implementation sets start to yesterday-midnight and end to today-23:59, so up to ~48h + expect(end.getTime() - start.getTime()).toBeLessThanOrEqual(48 * 60 * 60 * 1000); + expect(start.getHours()).toBe(0); + }); + + it('extracts "last week" time range', () => { + const result = parseQuery('Show errors from last week'); + expect(result.entities.timeRange).toBeDefined(); + const start = new Date(result.entities.timeRange!.start); + const now = Date.now(); + const diffDays = (now - start.getTime()) / (24 * 60 * 60 * 1000); + expect(diffDays).toBeCloseTo(7, 0); + }); + + it('extracts "last month" time range', () => { + const result = parseQuery('Crashes last month'); + expect(result.entities.timeRange).toBeDefined(); + const start = new Date(result.entities.timeRange!.start); + const now = Date.now(); + const diffDays = (now - start.getTime()) / (24 * 60 * 60 * 1000); + expect(diffDays).toBeCloseTo(30, 1); + }); + + it('extracts "N days ago" time range', () => { + const result = parseQuery('Errors from 3 days ago'); + expect(result.entities.timeRange).toBeDefined(); + const start = new Date(result.entities.timeRange!.start); + const now = Date.now(); + const diffDays = (now - start.getTime()) / (24 * 60 * 60 * 1000); + expect(diffDays).toBeCloseTo(3, 0); + }); + + it('extracts "N hours ago" time range', () => { + const result = parseQuery('What happened 6 hours ago'); + expect(result.entities.timeRange).toBeDefined(); + const start = new Date(result.entities.timeRange!.start); + const now = Date.now(); + const diffHours = (now - start.getTime()) / (60 * 60 * 1000); + expect(diffHours).toBeCloseTo(6, 0); + }); + + it('defaults to 7 days when no time specified', () => { + const result = parseQuery('Show all TypeError errors'); + expect(result.entities.timeRange).toBeDefined(); + const start = new Date(result.entities.timeRange!.start); + const now = Date.now(); + const diffDays = (now - start.getTime()) / (24 * 60 * 60 * 1000); + expect(diffDays).toBeCloseTo(7, 0); + }); +}); + +// ============================================================================ +// parseQuery — constraints +// ============================================================================ + +describe('parseQuery — constraints', () => { + it('extracts exclusion constraints', () => { + const result = parseQuery('Show errors excluding android'); + expect(result.constraints).toContain('exclude:android'); + }); + + it('extracts beta-only constraint', () => { + const result = parseQuery('Show only beta crashes'); + expect(result.constraints).toContain('userSegment:beta'); + }); + + it('extracts production-only constraint', () => { + const result = parseQuery('Show only production errors'); + expect(result.constraints).toContain('environment:production'); + }); + + it('returns empty constraints for unconstrained queries', () => { + const result = parseQuery('Why did the app crash?'); + expect(result.constraints).toHaveLength(0); + }); +}); + +// ============================================================================ +// matchQueryPattern +// ============================================================================ + +describe('matchQueryPattern', () => { + it('matches root_cause queries to root_cause_investigation pattern', () => { + const parsed = parseQuery('Why did the authentication fail?'); + const pattern = matchQueryPattern(parsed); + expect(pattern).toBeDefined(); + expect(pattern!.name).toBe('root_cause_investigation'); + }); + + it('matches pattern_search queries', () => { + const parsed = parseQuery('Find similar timeout errors'); + const pattern = matchQueryPattern(parsed); + expect(pattern).toBeDefined(); + expect(pattern!.name).toBe('similar_errors_search'); + }); + + it('matches impact queries', () => { + const parsed = parseQuery('How many users were affected?'); + const pattern = matchQueryPattern(parsed); + expect(pattern).toBeDefined(); + expect(pattern!.name).toBe('impact_assessment'); + }); + + it('matches comparison queries', () => { + const parsed = parseQuery('Compare these two error clusters'); + const pattern = matchQueryPattern(parsed); + expect(pattern).toBeDefined(); + expect(pattern!.name).toBe('cluster_comparison'); + }); +}); + +// ============================================================================ +// generateQuerySuggestions +// ============================================================================ + +describe('generateQuerySuggestions', () => { + it('generates suggestions based on error types', () => { + const suggestions = generateQuerySuggestions(['TypeError', 'NetworkError'], ['ios']); + expect(suggestions.length).toBeGreaterThan(0); + expect(suggestions.some(s => s.query.includes('TypeError'))).toBe(true); + }); + + it('includes platform-specific suggestions', () => { + const suggestions = generateQuerySuggestions(['CrashError'], ['android', 'web']); + expect(suggestions.some(s => s.query.includes('android'))).toBe(true); + }); + + it('always includes impact and trend suggestions', () => { + const suggestions = generateQuerySuggestions([], []); + expect(suggestions.some(s => s.pattern === 'impact_assessment')).toBe(true); + expect(suggestions.some(s => s.pattern === 'trend_analysis')).toBe(true); + }); + + it('limits suggestions to 6', () => { + const suggestions = generateQuerySuggestions( + ['A', 'B', 'C', 'D', 'E'], + ['ios', 'android', 'web'] + ); + expect(suggestions.length).toBeLessThanOrEqual(6); + }); +}); + +// ============================================================================ +// validateQuery +// ============================================================================ + +describe('validateQuery', () => { + it('returns valid for specific queries', () => { + const parsed = parseQuery('Why did iOS crash yesterday?'); + const result = validateQuery(parsed); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('warns when query is too broad', () => { + const parsed = parseQuery('What happened?'); + const result = validateQuery(parsed); + expect(result.warnings.length).toBeGreaterThan(0); + expect(result.warnings[0]).toContain('broad'); + }); + + it('warns when time range exceeds 90 days', () => { + const parsed = parseQuery('Show errors'); + // Override time range to > 90 days + parsed.entities.timeRange = { + start: new Date(Date.now() - 120 * 86400000).toISOString(), + end: new Date().toISOString(), + }; + const result = validateQuery(parsed); + expect(result.warnings.some(w => w.includes('90 days'))).toBe(true); + }); +}); + +// ============================================================================ +// QUERY_PATTERNS constant +// ============================================================================ + +describe('QUERY_PATTERNS', () => { + it('has at least 5 patterns', () => { + expect(QUERY_PATTERNS.length).toBeGreaterThanOrEqual(5); + }); + + it('each pattern has required fields', () => { + for (const pattern of QUERY_PATTERNS) { + expect(pattern.name).toBeTruthy(); + expect(pattern.description).toBeTruthy(); + expect(pattern.examples.length).toBeGreaterThan(0); + expect(pattern.intent).toBeTruthy(); + } + }); +});