test(ai-diagnostics): add 94 tests for error-normalization, clustering, query-parser

This commit is contained in:
saravanakumardb1 2026-03-21 17:06:24 -07:00
parent 9471d4c56f
commit 267f8af3a4
3 changed files with 928 additions and 0 deletions

View File

@ -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<string, number>();
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<string, number>([
['n1', -1],
['n2', -1],
]);
const quality = calculateClusterQuality(points, labels, []);
expect(quality.silhouetteScore).toBe(0);
});
});

View File

@ -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 <UUID>', () => {
const msg = 'Failed for user 550e8400-e29b-41d4-a716-446655440000';
expect(normalizeErrorMessage(msg)).toContain('<UUID>');
expect(normalizeErrorMessage(msg)).not.toContain('550e8400');
});
it('replaces MongoDB ObjectIds with <ID>', () => {
const msg = 'Document 507f1f77bcf86cd799439011 not found';
expect(normalizeErrorMessage(msg)).toContain('<ID>');
});
it('replaces email addresses with <EMAIL>', () => {
const msg = 'Login failed for alice@example.com';
expect(normalizeErrorMessage(msg)).toContain('<EMAIL>');
expect(normalizeErrorMessage(msg)).not.toContain('alice@example.com');
});
it('replaces IPv4 addresses with <IP>', () => {
const msg = 'Connection refused from 192.168.1.100';
expect(normalizeErrorMessage(msg)).toContain('<IP>');
});
it('replaces ISO timestamps with <DATE>', () => {
const msg = 'Error at 2026-03-21T12:30:45.123Z';
expect(normalizeErrorMessage(msg)).toContain('<DATE>');
});
it('replaces simple dates with <DATE>', () => {
const msg = 'Last seen on 03/21/2026';
expect(normalizeErrorMessage(msg)).toContain('<DATE>');
});
it('replaces user IDs with <USER_ID>', () => {
const msg = 'Cannot find user_12345';
expect(normalizeErrorMessage(msg)).toContain('<USER_ID>');
});
it('replaces long numbers with <NUM>', () => {
const msg = 'Record 12345678901 overflow';
expect(normalizeErrorMessage(msg)).toContain('<NUM>');
});
it('replaces URLs with <URL>', () => {
const msg = 'Fetch failed for https://api.example.com/v1/data';
expect(normalizeErrorMessage(msg)).toContain('<URL>');
});
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('<USER_ID>');
expect(result).toContain('<IP>');
expect(result).toContain('<DATE>');
expect(result).toContain('<UUID>');
});
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('<USER_ID>');
expect(result.normalizedMessage).toContain('<IP>');
});
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<string, ErrorFingerprint>();
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' })])
);
});
});

View File

@ -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();
}
});
});