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);
+ });
+});