From fa929d3d476d94a930acfa5a6cdbaba9153ce5e6 Mon Sep 17 00:00:00 2001 From: Prabhat Sharma Date: Sun, 2 Aug 2026 10:33:35 -0700 Subject: [PATCH] test(editor): failing specs for autocomplete Phase 1 (TDD red) Covers tmp/code.md Phase 1 findings, all currently failing: - A1 every catalog function must be kind Function, not Text (abc glyph) - A2 labels must be static strings, not functions of the typed token - A3 column arguments unquoted, literal arguments quoted (incl. spath) - A5 insertTextRules must reach monaco as a number, not the string - N7 detail/documentation/sortText must be forwarded to monaco - C1 candidates must not be pre-filtered by String.includes - N1 Alerts must bind effectiveKeywords, not the base list - N2 the suggestions fallback must be the shared catalog (Traces parity) - NL regression guard: static labels must not break quick-mode function name extraction in useNLQuery Three suites cannot load until src/utils/query/sqlCompletion.ts exists. --- web/src/components/CodeQueryEditor.spec.ts | 46 ++ .../alerts/QueryEditorDialog.spec.ts | 76 ++++ .../alerts/steps/QueryConfig.spec.ts | 74 ++++ web/src/composables/useNLQuery.spec.ts | 26 ++ web/src/composables/useSuggestions.spec.ts | 47 ++ web/src/utils/query/sqlCompletion.spec.ts | 405 ++++++++++++++++++ 6 files changed, 674 insertions(+) create mode 100644 web/src/utils/query/sqlCompletion.spec.ts diff --git a/web/src/components/CodeQueryEditor.spec.ts b/web/src/components/CodeQueryEditor.spec.ts index 886963869b..61f35ebc23 100644 --- a/web/src/components/CodeQueryEditor.spec.ts +++ b/web/src/components/CodeQueryEditor.spec.ts @@ -828,3 +828,49 @@ describe("CodeQueryEditor", () => { }); }); }); + +// ─── Phase 1 (tmp/code.md N2 / D7) ──────────────────────────────────────────── +// Traces binds :keywords but no :suggestions, so it falls through to the +// component's LOCAL default list. That local copy had 7 entries while the +// composable's had 26 — a shipped divergence. After collapsing the duplicated +// catalogs the fallback must be the shared catalog. + +describe("N2/D7 — the suggestions fallback is the shared catalog", () => { + it("falls back to the shared SQL_FUNCTIONS catalog when suggestions prop is null", async () => { + const { SQL_FUNCTIONS } = await import("@/utils/query/sqlCompletion"); + const wrapper = mount(CodeQueryEditor, { + props: { editorId: "fallback-editor", language: "sql", suggestions: null }, + global: { plugins: [store] }, + }); + // `suggestions` is the computed the provider reads. + expect((wrapper.vm as any).suggestions).toEqual(SQL_FUNCTIONS); + }); + + it("the fallback includes the aggregates Traces was missing", async () => { + const wrapper = mount(CodeQueryEditor, { + props: { editorId: "fallback-editor-2", language: "sql", suggestions: null }, + global: { plugins: [store] }, + }); + const names = ((wrapper.vm as any).suggestions as any[]).map((s) => s.name); + for (const agg of ["sum", "avg", "count", "max", "min", "histogram", "approx_topk"]) { + expect(names, `fallback missing ${agg}`).toContain(agg); + } + }); + + it("an explicit empty array still suppresses all suggestions", async () => { + const wrapper = mount(CodeQueryEditor, { + props: { editorId: "fallback-editor-3", language: "sql", suggestions: [] }, + global: { plugins: [store] }, + }); + expect((wrapper.vm as any).suggestions).toEqual([]); + }); + + it("uses the shared catalog for its default keywords too", async () => { + const { SQL_KEYWORDS } = await import("@/utils/query/sqlCompletion"); + const wrapper = mount(CodeQueryEditor, { + props: { editorId: "fallback-editor-4", language: "sql", keywords: [] }, + global: { plugins: [store] }, + }); + expect((wrapper.vm as any).keywords).toEqual(SQL_KEYWORDS); + }); +}); diff --git a/web/src/components/alerts/QueryEditorDialog.spec.ts b/web/src/components/alerts/QueryEditorDialog.spec.ts index 15cd39754f..5c8e27a3d9 100644 --- a/web/src/components/alerts/QueryEditorDialog.spec.ts +++ b/web/src/components/alerts/QueryEditorDialog.spec.ts @@ -540,3 +540,79 @@ describe("QueryEditorDialog - ODrawer Migration", () => { expect(w.findComponent(ODrawerStub).exists()).toBe(true); }); }); + +// ─── Phase 1 (tmp/code.md N1) ───────────────────────────────────────────────── +// Alerts wires the whole autocomplete pipeline (cursorIndex, popup.open, +// getSuggestions) but binds :keywords="autoCompleteKeywords" instead of +// effectiveKeywords. Result: in value context it force-opens the popup and then +// shows the BASE field/function list where field VALUES belong. + +describe("QueryEditorDialog - N1 context keywords reach the editor", () => { + const keywordAwareStub = { + template: '
', + props: ["query", "editorId", "keywords", "suggestions"], + emits: ["update:query", "blur"], + }; + + const mountWithKeywordStub = async (props: Record = {}) => + mount(QueryEditorDialog, { + props: { + modelValue: true, + tab: "sql", + sqlQuery: "", + promqlQuery: "", + vrlFunction: "", + streamName: "my-stream", + streamType: "logs", + columns: [ + { label: "host", value: "host" }, + { label: "level", value: "level" }, + ], + period: 10, + multiTimeRange: [], + savedFunctions: [], + sqlQueryErrorMsg: "", + ...props, + }, + global: { + plugins: [i18n, store], + stubs: { + CodeQueryEditor: keywordAwareStub, + QueryEditor: keywordAwareStub, + UnifiedQueryEditor: keywordAwareStub, + FullViewContainer: { + template: "
", + props: ["name", "label", "isExpanded"], + emits: ["update:isExpanded"], + }, + O2AIChat: { template: "
", props: ["headerHeight", "isOpen"], emits: ["close"] }, + }, + }, + }); + + it("binds a keywords source that switches to context keywords", async () => { + const wrapper = await mountWithKeywordStub(); + await flushPromises(); + const editor = wrapper.findComponent(keywordAwareStub); + expect(editor.exists()).toBe(true); + + const vm = wrapper.vm as any; + // After the fix the template binds effectiveKeywords; the raw base list + // must not be what reaches the editor. + expect(vm.effectiveKeywords).toBeDefined(); + expect(Array.isArray(editor.props("keywords"))).toBe(true); + expect(Array.isArray(vm.effectiveKeywords)).toBe(true); + expect(editor.props("keywords")).toStrictEqual(vm.effectiveKeywords); + }); + + it("binds effectiveSuggestions, so value context can blank the function list", async () => { + const wrapper = await mountWithKeywordStub(); + await flushPromises(); + const editor = wrapper.findComponent(keywordAwareStub); + const vm = wrapper.vm as any; + expect(vm.effectiveSuggestions).toBeDefined(); + expect(Array.isArray(editor.props("suggestions"))).toBe(true); + expect(Array.isArray(vm.effectiveSuggestions)).toBe(true); + expect(editor.props("suggestions")).toStrictEqual(vm.effectiveSuggestions); + }); +}); diff --git a/web/src/components/alerts/steps/QueryConfig.spec.ts b/web/src/components/alerts/steps/QueryConfig.spec.ts index 601c6cbfe0..6241a81d34 100644 --- a/web/src/components/alerts/steps/QueryConfig.spec.ts +++ b/web/src/components/alerts/steps/QueryConfig.spec.ts @@ -2024,4 +2024,78 @@ describe("QueryConfig.vue", () => { h.unmount(); }); }); + // ─── Phase 1 (tmp/code.md N1) ───────────────────────────────────────────────── + // QueryConfig wires the full autocomplete pipeline in handleInlineQueryUpdate + // (query, cursorIndex, org/stream context, popup.open, getSuggestions) but binds + // :keywords="autoCompleteKeywords" — the BASE list — instead of effectiveKeywords. + // In value context it therefore force-opens a popup showing field NAMES where + // field VALUES belong. Mirrors the QueryEditorDialog guard. + + describe("QueryConfig — N1 context keywords reach the inline editor", () => { + let host: any; + let qc: any; + + beforeEach(() => { + mockStore = createMockStore(); + mockStoreInstance = mockStore; + // tab "sql" so the inline SQL editor actually renders. + const props = reactive({ ...baseQCProps(), tab: "sql" }); + const Host = defineComponent({ + components: { OForm, QueryConfig }, + setup: () => ({ + schema: addAlertSchema, + defaultValues: hostDefaults({}), + qcProps: props, + }), + template: ` + + + + `, + }); + host = mount(Host, { + global: { + mocks: { $store: mockStore }, + provide: { store: mockStore }, + plugins: [i18n], + stubs: { + UnifiedQueryEditor: { + name: "UnifiedQueryEditor", + template: '
', + props: ["query", "keywords", "suggestions"], + emits: ["update:query", "focus", "blur"], + }, + }, + }, + }); + qc = host.findComponent(QueryConfig); + }); + + afterEach(() => host?.unmount()); + + it("exposes effectiveKeywords as the editor's keyword source", () => { + expect(qc.vm.effectiveKeywords).toBeDefined(); + }); + + it("exposes effectiveSuggestions as the editor's suggestion source", () => { + expect(qc.vm.effectiveSuggestions).toBeDefined(); + }); + + it("binds effectiveKeywords (not the base list) to the inline editor", () => { + const editor = host.findComponent({ name: "UnifiedQueryEditor" }); + expect(editor.exists()).toBe(true); + // Guard against a vacuous pass where both sides are undefined. + expect(Array.isArray(editor.props("keywords"))).toBe(true); + expect(Array.isArray(qc.vm.effectiveKeywords)).toBe(true); + expect(editor.props("keywords")).toStrictEqual(qc.vm.effectiveKeywords); + }); + + it("binds effectiveSuggestions to the inline editor", () => { + const editor = host.findComponent({ name: "UnifiedQueryEditor" }); + expect(Array.isArray(editor.props("suggestions"))).toBe(true); + expect(Array.isArray(qc.vm.effectiveSuggestions)).toBe(true); + expect(editor.props("suggestions")).toStrictEqual(qc.vm.effectiveSuggestions); + }); + }); + }); diff --git a/web/src/composables/useNLQuery.spec.ts b/web/src/composables/useNLQuery.spec.ts index f0251923ff..fe4e8c1148 100644 --- a/web/src/composables/useNLQuery.spec.ts +++ b/web/src/composables/useNLQuery.spec.ts @@ -451,3 +451,29 @@ describe("useNLQuery", () => { }); }); }); + +// ─── Phase 1 regression (tmp/code.md A2) ────────────────────────────────────── +// getQuickModeFunctionNames() historically recovered function names by regexing +// "match_all('x')" style labels. Once labels become static bare names (A2), that +// regex finds nothing — the extraction must key off the catalog's `name` field. +// These tests pin the OUTPUT so the refactor cannot silently empty the list. + +describe("getQuickModeFunctionNames — survives static catalog labels", () => { + it("does not classify a query using a catalog function as natural language", async () => { + vi.resetModules(); + vi.doMock("@/composables/useSuggestions", () => ({ + default: vi.fn(() => ({ + defaultSuggestions: [ + { name: "match_all", label: "match_all", kind: "Function" }, + { name: "str_match", label: "str_match", kind: "Function" }, + ], + })), + })); + const { useNLQuery: freshUseNLQuery } = await import("./useNLQuery"); + const { detectNaturalLanguage } = freshUseNLQuery(); + expect(detectNaturalLanguage("match_all('error')", "sql")).toBe(false); + expect(detectNaturalLanguage("str_match(body, 'error')", "sql")).toBe(false); + vi.doUnmock("@/composables/useSuggestions"); + vi.resetModules(); + }); +}); diff --git a/web/src/composables/useSuggestions.spec.ts b/web/src/composables/useSuggestions.spec.ts index d21d66743a..9baa700925 100644 --- a/web/src/composables/useSuggestions.spec.ts +++ b/web/src/composables/useSuggestions.spec.ts @@ -363,3 +363,50 @@ describe("effectiveSuggestions — empty when value suggestions are shown", () = expect(c.effectiveSuggestions.value.length).toBeGreaterThan(0); }); }); + +// ─── Phase 1 (tmp/code.md): catalog wiring ──────────────────────────────────── +// The composable must source its suggestions/keywords from the shared +// sqlCompletion catalog rather than its own inline copy (D7), so every surface +// gets identical content. + +describe("catalog wiring — suggestions come from the shared sqlCompletion module", () => { + beforeEach(() => vi.clearAllMocks()); + + it("exposes the full catalog as defaultSuggestions", async () => { + const { SQL_FUNCTIONS } = await import("@/utils/query/sqlCompletion"); + const c = makeComposable(); + expect(c.defaultSuggestions).toEqual(SQL_FUNCTIONS); + }); + + it("every exposed suggestion is kind Function, never Text (A1)", () => { + const c = makeComposable(); + for (const s of c.defaultSuggestions as any[]) { + expect(s.kind, `${s.name ?? s.label}`).toBe("Function"); + } + }); + + it("every exposed suggestion has a static string label (A2)", () => { + const c = makeComposable(); + for (const s of c.defaultSuggestions as any[]) { + expect(typeof s.label).toBe("string"); + } + }); + + it("effectiveSuggestions in normal context carries the aggregates", async () => { + const c = makeComposable({ storedValues: [] }); + await run(c, "SELECT * FROM stream WHERE "); + const names = (c.effectiveSuggestions.value as any[]).map((s) => s.name); + for (const agg of ["sum", "avg", "count", "max", "min", "histogram"]) { + expect(names, `missing ${agg}`).toContain(agg); + } + }); + + it("keeps field keywords sorted ahead of SQL keywords", async () => { + const c = makeComposable({ storedValues: [] }); + c.updateFieldKeywords([{ name: "host" }, { name: "level" }]); + await run(c, "SELECT * FROM stream WHERE "); + const host = c.effectiveKeywords.value.find((k: any) => k.label === "host"); + const and = c.effectiveKeywords.value.find((k: any) => k.label === "and"); + expect(host.sortText < and.sortText).toBe(true); + }); +}); diff --git a/web/src/utils/query/sqlCompletion.spec.ts b/web/src/utils/query/sqlCompletion.spec.ts new file mode 100644 index 0000000000..bc043bf601 --- /dev/null +++ b/web/src/utils/query/sqlCompletion.spec.ts @@ -0,0 +1,405 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// TDD spec for the shared SQL completion catalog (tmp/code.md Phase 1). +// Covers: A1 (kinds), A2 (static labels), A3 (argument quoting), +// A5 (insertTextRules string -> enum), N7 (field forwarding), C1 (no pre-filter), +// D7/N2 (single catalog shared by every surface). + +import { describe, it, expect } from "vitest"; +import { + SQL_FUNCTIONS, + SQL_KEYWORDS, + buildCompletionItems, + type SqlCompletionEntry, +} from "./sqlCompletion"; + +// Mirrors monaco.languages.CompletionItemKind (verified in +// monaco-editor/esm/vs/editor/common/languages.js byKind map). +const KINDS = { + Method: 0, + Function: 1, + Constructor: 2, + Field: 3, + Variable: 4, + Operator: 11, + Value: 13, + Keyword: 17, + Text: 18, + Snippet: 27, +} as const; + +// Mirrors monaco.languages.CompletionItemInsertTextRule. +const INSERT_RULES = { + None: 0, + KeepWhitespace: 1, + InsertAsSnippet: 4, +} as const; + +const range = { startLineNumber: 1, endLineNumber: 1, startColumn: 1, endColumn: 1 }; + +const build = (opts: Partial[0]> = {}) => + buildCompletionItems({ + keywords: [], + suggestions: [], + word: "", + range, + kinds: KINDS, + insertTextRules: INSERT_RULES, + ...opts, + }); + +const byName = (name: string): SqlCompletionEntry => { + const found = SQL_FUNCTIONS.find((f) => f.name === name); + if (!found) throw new Error(`SQL_FUNCTIONS is missing "${name}"`); + return found; +}; + +// ─────────────────────────────────────────────────────────────────────────── +// A1 — completion item kinds +// ─────────────────────────────────────────────────────────────────────────── + +describe("A1 — every function is kind Function, never Text", () => { + it("no catalog function is kind Text (the 'abc' glyph)", () => { + const textKinded = SQL_FUNCTIONS.filter((f) => f.kind === "Text"); + expect(textKinded).toEqual([]); + }); + + it("every catalog function declares kind Function", () => { + for (const fn of SQL_FUNCTIONS) { + expect(fn.kind, `${fn.name} should be kind Function`).toBe("Function"); + } + }); + + it("maps kind Function to monaco's numeric 1 (symbolFunction glyph)", () => { + const items = build({ suggestions: [byName("approx_topk")] }); + expect(items[0].kind).toBe(KINDS.Function); + }); + + it("comparison operators are kind Operator, not Keyword", () => { + for (const op of ["=", "!=", "<>", ">", "<", ">=", "<="]) { + const entry = SQL_KEYWORDS.find((k) => k.label === op); + expect(entry, `SQL_KEYWORDS is missing operator "${op}"`).toBeDefined(); + expect(entry!.kind, `operator ${op}`).toBe("Operator"); + } + }); + + it("logical/predicate words are kind Keyword", () => { + for (const kw of ["and", "or", "like", "in", "not in", "between", "is null"]) { + const entry = SQL_KEYWORDS.find((k) => k.label === kw); + expect(entry, `SQL_KEYWORDS is missing keyword "${kw}"`).toBeDefined(); + expect(entry!.kind, `keyword ${kw}`).toBe("Keyword"); + } + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// A2 — static labels (no typed-token interpolation) +// ─────────────────────────────────────────────────────────────────────────── + +describe("A2 — labels are static strings, never functions of the typed token", () => { + it("no catalog entry exposes a callable label", () => { + for (const entry of [...SQL_FUNCTIONS, ...SQL_KEYWORDS]) { + expect(typeof entry.label, `${entry.name} label`).toBe("string"); + } + }); + + it("no catalog entry exposes a callable insertText", () => { + for (const entry of [...SQL_FUNCTIONS, ...SQL_KEYWORDS]) { + expect(typeof entry.insertText, `${entry.name} insertText`).toBe("string"); + } + }); + + it("function labels are the bare function name — no embedded arguments", () => { + // The screenshot bug: label read "approx_topk('a', 10)" with a frozen 'a'. + expect(byName("approx_topk").label).toBe("approx_topk"); + expect(byName("match_all").label).toBe("match_all"); + expect(byName("histogram").label).toBe("histogram"); + }); + + it("labels never contain a quote character", () => { + for (const fn of SQL_FUNCTIONS) { + expect(fn.label, `${fn.name} label must not embed a literal`).not.toContain("'"); + } + }); + + it("produces identical items regardless of what the user has typed", () => { + // Guards the staleness class of bug outright: item content cannot depend on `word`. + const atA = build({ suggestions: SQL_FUNCTIONS, word: "a" }); + const atAppr = build({ suggestions: SQL_FUNCTIONS, word: "appr" }); + expect(atA).toEqual(atAppr); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// A3 — argument quoting +// ─────────────────────────────────────────────────────────────────────────── + +describe("A3 — column arguments are unquoted, literal arguments are quoted", () => { + // Functions whose FIRST argument is a column/expression reference. + const COLUMN_FIRST = [ + "re_match", + "re_not_match", + "str_match", + "str_match_ignore_case", + "arr_descending", + "arrcount", + "arrsort", + "cast_to_arr", + "arrindex", + "arrjoin", + "arrzip", + "spath", + "to_array_string", + "sum", + "avg", + "count", + "max", + "min", + "histogram", + "approx_topk", + "approx_topk_distinct", + ]; + + it.each(COLUMN_FIRST)("%s does not wrap its first argument in quotes", (name) => { + const insertText = byName(name).insertText; + // First argument begins right after "name(" and must not open with a quote. + const firstArg = insertText.slice(name.length + 1); + expect(firstArg.startsWith("'"), `${name} => ${insertText}`).toBe(false); + }); + + it("sum/avg/count/max/min take a bare column tab stop", () => { + for (const agg of ["sum", "avg", "count", "max", "min"]) { + expect(byName(agg).insertText).toBe(`${agg}(\${1:field})`); + } + }); + + it("histogram takes an unquoted column and a quoted interval", () => { + // Real usage in the app: histogram(_timestamp, '30 second') — useAlertForm.ts:463 + expect(byName("histogram").insertText).toBe("histogram(${1:_timestamp}, '${2:30 second}')"); + }); + + it("spath takes an unquoted column and a quoted path", () => { + // Backend tests use spath(object, 'nested.value') — first arg is an expression. + expect(byName("spath").insertText).toBe("spath(${1:field}, '${2:path}')"); + }); + + it("approx_topk takes an unquoted column and a numeric k", () => { + expect(byName("approx_topk").insertText).toBe("approx_topk(${1:field}, ${2:10})"); + }); + + it("arrcount takes a bare array column (arrcount_udf.rs:51)", () => { + expect(byName("arrcount").insertText).toBe("arrcount(${1:field})"); + }); + + it("match_all quotes its search term — it is a literal", () => { + expect(byName("match_all").insertText).toBe("match_all('${1:value}')"); + }); + + it("str_match takes an unquoted column and a quoted value", () => { + expect(byName("str_match").insertText).toBe("str_match(${1:field}, '${2:value}')"); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// A5 — insertTextRules must reach monaco as a NUMBER +// ─────────────────────────────────────────────────────────────────────────── + +describe("A5 — snippet rules are mapped from string name to numeric enum", () => { + const snippetEntry: SqlCompletionEntry = { + name: "demo", + label: "demo", + kind: "Function", + insertText: "demo(${1:field})", + insertTextRules: "InsertAsSnippet", + }; + + it("emits the numeric InsertAsSnippet flag, never the string", () => { + const [item] = build({ suggestions: [snippetEntry] }); + expect(item.insertTextRules).toBe(INSERT_RULES.InsertAsSnippet); + expect(typeof item.insertTextRules).toBe("number"); + }); + + it("survives monaco's bitwise test (string would coerce to 0)", () => { + const [item] = build({ suggestions: [snippetEntry] }); + // suggestController.js:368 — if (!(insertTextRules & 4)) escape as plain text + expect(item.insertTextRules & INSERT_RULES.InsertAsSnippet).toBeTruthy(); + // Prove the current bug would fail this: "InsertAsSnippet" & 4 === 0 + expect(("InsertAsSnippet" as any) & INSERT_RULES.InsertAsSnippet).toBe(0); + }); + + it("applies the same mapping on the keywords path", () => { + const [item] = build({ + keywords: [ + { + name: "like", + label: "like", + kind: "Keyword", + insertText: "like '%${1:params}%' ", + insertTextRules: "InsertAsSnippet", + }, + ], + }); + expect(item.insertTextRules).toBe(INSERT_RULES.InsertAsSnippet); + }); + + it("omits insertTextRules for plain-text entries", () => { + const [item] = build({ + keywords: [{ name: "and", label: "and", kind: "Keyword", insertText: "and " }], + }); + expect(item.insertTextRules).toBeUndefined(); + }); + + it("every catalog entry containing a ${…} tab stop declares InsertAsSnippet", () => { + for (const entry of [...SQL_FUNCTIONS, ...SQL_KEYWORDS]) { + if (entry.insertText.includes("${")) { + expect(entry.insertTextRules, `${entry.name} has tab stops but no snippet rule`).toBe( + "InsertAsSnippet", + ); + } + } + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// N7 — the push site must forward every field, not just four +// ─────────────────────────────────────────────────────────────────────────── + +describe("N7 — optional metadata is forwarded to monaco", () => { + const rich: SqlCompletionEntry = { + name: "approx_topk", + label: "approx_topk", + kind: "Function", + detail: "(field, k) → top-k values", + documentation: "Approximate top-k aggregation.", + insertText: "approx_topk(${1:field}, ${2:10})", + insertTextRules: "InsertAsSnippet", + sortText: "approx_topk", + }; + + it("forwards detail", () => { + expect(build({ suggestions: [rich] })[0].detail).toBe("(field, k) → top-k values"); + }); + + it("forwards documentation", () => { + expect(build({ suggestions: [rich] })[0].documentation).toBe( + "Approximate top-k aggregation.", + ); + }); + + it("forwards sortText", () => { + expect(build({ suggestions: [rich] })[0].sortText).toBe("approx_topk"); + }); + + it("attaches the supplied range to every item", () => { + const items = build({ suggestions: SQL_FUNCTIONS, keywords: SQL_KEYWORDS }); + expect(items.length).toBeGreaterThan(0); + for (const item of items) expect(item.range).toBe(range); + }); + + it("every catalog function ships a detail signature for the docs column", () => { + for (const fn of SQL_FUNCTIONS) { + expect(fn.detail, `${fn.name} needs a detail signature`).toBeTruthy(); + } + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// C1 — no substring pre-filter; monaco does the scoring +// ─────────────────────────────────────────────────────────────────────────── + +describe("C1 — candidates are not pre-filtered by substring", () => { + const fields = [ + { name: "kubernetes_namespace_name", label: "kubernetes_namespace_name", kind: "Field" as const, insertText: "kubernetes_namespace_name" }, + { name: "code", label: "code", kind: "Field" as const, insertText: "code" }, + ]; + + it("returns every keyword even when the typed word matches none of them", () => { + const items = build({ keywords: fields, word: "zzz" }); + expect(items).toHaveLength(2); + }); + + it("keeps subsequence matches that String.includes would have dropped", () => { + // "knn" is a subsequence of kubernetes_namespace_name but not a substring. + const items = build({ keywords: fields, word: "knn" }); + expect(items.map((i: any) => i.label)).toContain("kubernetes_namespace_name"); + }); + + it("returns suggestions unfiltered too", () => { + const items = build({ suggestions: SQL_FUNCTIONS, word: "zzz" }); + expect(items).toHaveLength(SQL_FUNCTIONS.length); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// D7 / N2 — one catalog, shared everywhere +// ─────────────────────────────────────────────────────────────────────────── + +describe("D7/N2 — the catalog is complete and internally consistent", () => { + it("carries all 26 historical suggestions", () => { + expect(SQL_FUNCTIONS.length).toBeGreaterThanOrEqual(26); + }); + + it("includes the aggregates Traces was missing", () => { + for (const name of ["sum", "avg", "count", "max", "min", "histogram", "approx_topk"]) { + expect(SQL_FUNCTIONS.some((f) => f.name === name), `missing ${name}`).toBe(true); + } + }); + + it("includes the array family", () => { + for (const name of ["arrcount", "arrsort", "arrindex", "arrjoin", "arrzip", "arr_descending"]) { + expect(SQL_FUNCTIONS.some((f) => f.name === name), `missing ${name}`).toBe(true); + } + }); + + it("has no duplicate function names", () => { + const names = SQL_FUNCTIONS.map((f) => f.name); + expect(new Set(names).size).toBe(names.length); + }); + + it("marks the backward-compatibility aliases deprecated", () => { + // sql/rewriter/match_all_raw.rs:41 rewrites these to match_all. + for (const name of ["match_all_raw", "match_all_raw_ignore_case"]) { + expect(byName(name).deprecated, `${name} should be flagged deprecated`).toBe(true); + } + }); + + it("does not mark match_all itself deprecated", () => { + expect(byName("match_all").deprecated).toBeFalsy(); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// Back-compat — the `suggestions` prop is public; legacy shape must still work +// ─────────────────────────────────────────────────────────────────────────── + +describe("back-compat — legacy callable label/insertText entries still build", () => { + const legacy = { + label: (kw: string) => `custom_fn('${kw}')`, + kind: "Text", + insertText: (kw: string) => `custom_fn('${kw}')`, + }; + + it("invokes callable label/insertText with the typed word", () => { + const [item] = build({ suggestions: [legacy as any], word: "abc" }); + expect(item.label).toBe("custom_fn('abc')"); + expect(item.insertText).toBe("custom_fn('abc')"); + }); + + it("still maps a legacy string kind through the enum", () => { + const [item] = build({ suggestions: [legacy as any], word: "abc" }); + expect(item.kind).toBe(KINDS.Text); + }); +});