import * as monaco from 'monaco-editor'; import { buildCompletions } from '../completion/completions'; import type { Situation } from '../parser/types'; const RANGE: monaco.IRange = { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 1, }; function makeSituation(partial: Partial): Situation { return { kind: 'NONE', prefix: '', replaceRange: { start: 0, end: 0 }, ...partial, }; } const MOCK_FIELD_NAMES = ['response', 'machine.os', 'message']; const MOCK_FIELD_VALUES: Record = { 'machine.os': ['osx', 'ios', 'win 10'], }; function labels(items: monaco.languages.CompletionItem[]): string[] { return items.map((i) => String(i.label)); } describe('KQL buildCompletions', () => { // ── NONE ── it('returns field names, not and ( for NONE', async () => { const items = await buildCompletions({ situation: makeSituation({ kind: 'NONE' }), range: RANGE, variables: [], fetchFieldNames: async () => MOCK_FIELD_NAMES, }); const ls = labels(items); expect(ls).toEqual(expect.arrayContaining(['response', 'machine.os', 'message', 'NOT', '( )'])); }); it('returns no operators for NONE', async () => { const items = await buildCompletions({ situation: makeSituation({ kind: 'NONE' }), range: RANGE, variables: [], fetchFieldNames: async () => MOCK_FIELD_NAMES, }); expect(labels(items)).not.toContain(':'); }); // ── IN_FIELD ── it('returns only field names for IN_FIELD', async () => { const items = await buildCompletions({ situation: makeSituation({ kind: 'IN_FIELD' }), range: RANGE, variables: ['host'], fetchFieldNames: async () => MOCK_FIELD_NAMES, }); expect(labels(items)).toEqual(['response', 'machine.os', 'message']); }); it('handles fetchFieldNames errors gracefully', async () => { const items = await buildCompletions({ situation: makeSituation({ kind: 'IN_FIELD' }), range: RANGE, variables: [], fetchFieldNames: async () => { throw new Error('fail'); }, }); expect(items).toEqual([]); }); // ── AFTER_FIELD ── it('returns operators for AFTER_FIELD', async () => { const items = await buildCompletions({ situation: makeSituation({ kind: 'AFTER_FIELD' }), range: RANGE, variables: [], }); expect(labels(items)).toEqual([':', '<=', '>=', '<', '>']); items.forEach((item) => expect(item.sortText).toBe('3')); }); // ── IN_VALUE ── it('returns field values for IN_VALUE', async () => { const items = await buildCompletions({ situation: makeSituation({ kind: 'IN_VALUE', fieldName: 'machine.os' }), range: RANGE, variables: [], fetchFieldValues: async (name) => MOCK_FIELD_VALUES[name] ?? [], }); expect(labels(items)).toEqual(expect.arrayContaining(['osx', 'ios', 'win 10'])); }); it('passes the dotted field name to fetchFieldValues', async () => { const seen: string[] = []; await buildCompletions({ situation: makeSituation({ kind: 'IN_VALUE', fieldName: 'user.name' }), range: RANGE, variables: [], fetchFieldValues: async (name) => { seen.push(name); return []; }, }); expect(seen).toEqual(['user.name']); }); it('always quotes values when not inside a string', async () => { const items = await buildCompletions({ situation: makeSituation({ kind: 'IN_VALUE', fieldName: 'machine.os' }), range: RANGE, variables: [], fetchFieldValues: async () => ['win 10', 'osx'], }); const win = items.find((i) => i.label === 'win 10'); const osx = items.find((i) => i.label === 'osx'); expect(win?.insertText).toBe('"win 10"'); expect(osx?.insertText).toBe('"osx"'); }); it('does not quote values when inside a string', async () => { const items = await buildCompletions({ situation: makeSituation({ kind: 'IN_VALUE', fieldName: 'machine.os', insideString: true }), range: RANGE, variables: [], fetchFieldValues: async () => ['win 10'], }); expect(items.find((i) => i.label === 'win 10')?.insertText).toBe('win 10'); }); it('escapes quotes when quoting values', async () => { const items = await buildCompletions({ situation: makeSituation({ kind: 'IN_VALUE', fieldName: 'f' }), range: RANGE, variables: [], fetchFieldValues: async () => ['a"b'], }); expect(items.find((i) => i.label === 'a"b')?.insertText).toBe('"a\\"b"'); }); it('returns * exists and variables for IN_VALUE', async () => { const items = await buildCompletions({ situation: makeSituation({ kind: 'IN_VALUE', fieldName: 'machine.os' }), range: RANGE, variables: ['env'], }); expect(labels(items)).toEqual(expect.arrayContaining(['*', '${env}'])); }); it('sorts values before variables and keywords', async () => { const items = await buildCompletions({ situation: makeSituation({ kind: 'IN_VALUE', fieldName: 'machine.os' }), range: RANGE, variables: ['env'], fetchFieldValues: async () => ['osx'], }); // Values (sortText 1) rank ahead of everything else; Monaco orders by sortText expect(items[0].label).toBe('osx'); expect(items[0].sortText).toBe('1'); expect(items.every((i) => ['1', '2', '3'].includes(i.sortText ?? ''))).toBe(true); }); it('handles fetchFieldValues errors gracefully', async () => { const items = await buildCompletions({ situation: makeSituation({ kind: 'IN_VALUE', fieldName: 'machine.os' }), range: RANGE, variables: [], fetchFieldValues: async () => { throw new Error('fail'); }, }); // only the * fallback remains expect(labels(items)).toEqual(['*']); }); // ── IN_VALUE_LIST ── it('returns values, variables and conjunctions for IN_VALUE_LIST', async () => { const items = await buildCompletions({ situation: makeSituation({ kind: 'IN_VALUE_LIST', fieldName: 'machine.os' }), range: RANGE, variables: ['env'], fetchFieldValues: async () => ['osx'], }); const ls = labels(items); expect(ls).toEqual(expect.arrayContaining(['osx', '${env}', 'AND', 'OR', 'NOT'])); expect(ls).not.toContain(':'); }); // ── AFTER_EXPRESSION ── it('returns only and/or for AFTER_EXPRESSION', async () => { const items = await buildCompletions({ situation: makeSituation({ kind: 'AFTER_EXPRESSION' }), range: RANGE, variables: ['env'], fetchFieldNames: async () => MOCK_FIELD_NAMES, }); expect(labels(items)).toEqual(['AND', 'OR']); }); // ── IN_NESTED ── it('passes nestedPath to fetchFieldNames for IN_NESTED', async () => { const seen: Array = []; const items = await buildCompletions({ situation: makeSituation({ kind: 'IN_NESTED', nestedPath: 'user' }), range: RANGE, variables: [], fetchFieldNames: async (nestedPath) => { seen.push(nestedPath); return ['name', 'tags']; }, }); expect(seen).toEqual(['user']); expect(labels(items)).toEqual(expect.arrayContaining(['name', 'tags', 'NOT', '( )'])); }); // ── Sorting contract ── it('sorts field names before operators before keywords', async () => { const fieldItems = await buildCompletions({ situation: makeSituation({ kind: 'IN_FIELD' }), range: RANGE, variables: [], fetchFieldNames: async () => ['response'], }); const operatorItems = await buildCompletions({ situation: makeSituation({ kind: 'AFTER_FIELD' }), range: RANGE, variables: [], }); const keywordItems = await buildCompletions({ situation: makeSituation({ kind: 'AFTER_EXPRESSION' }), range: RANGE, variables: [], }); expect(fieldItems[0].sortText! < operatorItems[0].sortText!).toBe(true); expect(operatorItems[0].sortText! < keywordItems[0].sortText!).toBe(true); }); });