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.
This commit is contained in:
parent
d76c0a6bbb
commit
fa929d3d47
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: '<div class="stub-kw-editor"></div>',
|
||||
props: ["query", "editorId", "keywords", "suggestions"],
|
||||
emits: ["update:query", "blur"],
|
||||
};
|
||||
|
||||
const mountWithKeywordStub = async (props: Record<string, any> = {}) =>
|
||||
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: "<div><slot /><slot name='right' /></div>",
|
||||
props: ["name", "label", "isExpanded"],
|
||||
emits: ["update:isExpanded"],
|
||||
},
|
||||
O2AIChat: { template: "<div />", 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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: `
|
||||
<OForm :schema="schema" :default-values="defaultValues" @submit="() => {}">
|
||||
<QueryConfig v-bind="qcProps" />
|
||||
</OForm>
|
||||
`,
|
||||
});
|
||||
host = mount(Host, {
|
||||
global: {
|
||||
mocks: { $store: mockStore },
|
||||
provide: { store: mockStore },
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
UnifiedQueryEditor: {
|
||||
name: "UnifiedQueryEditor",
|
||||
template: '<div class="stub-inline-editor" />',
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
// 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<Parameters<typeof buildCompletionItems>[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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue