test(editor): harden Phase 1 specs after adversarial review
Fixes four defects in the previous test commit: - N1 (Alerts): asserting editor.props(keywords) === vm.effectiveKeywords was vacuous. effectiveKeywords returns autoCompleteKeywords verbatim when no context is active, so those tests would have gone green by merely exposing the computed, with the wrong binding still in place. Replaced with tests that drive a real value context and require Value-kind items to reach the editor. - N2/D7: the tests read wrapper.vm.suggestions/keywords, which resolve to the PROPS of those names, not the internal computeds (verified: null in, null out). They were unsatisfiable. Fallback resolution now lives behind resolveSuggestions/resolveKeywords and is unit-tested directly. - Removed a tautological assertion that tested JS bitwise semantics rather than our code. - Added the missing A4 guard: the token handed to legacy callable suggestions must be monaco's word, never a space-split of the buffer. Also adds ODrawer/editor stubs so the alerts dialog actually mounts its editor.
This commit is contained in:
parent
fa929d3d47
commit
d579d18532
|
|
@ -831,46 +831,32 @@ 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.
|
||||
// component's LOCAL default list (7 entries vs the composable's 26) — a shipped
|
||||
// divergence. The component must delegate that fallback to the shared catalog.
|
||||
//
|
||||
// NOTE: the fallback computeds are NOT reachable via wrapper.vm — `suggestions`
|
||||
// and `keywords` are PROP names, so vm.suggestions returns the prop (verified:
|
||||
// null in, null out). The resolution logic is therefore unit-tested directly in
|
||||
// src/utils/query/sqlCompletion.spec.ts; here we only assert the component
|
||||
// delegates to it rather than carrying its own copy.
|
||||
|
||||
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);
|
||||
describe("N2/D7 — the component delegates its suggestion fallback", () => {
|
||||
it("imports the shared catalog resolvers", async () => {
|
||||
const mod = await import("@/utils/query/sqlCompletion");
|
||||
expect(typeof mod.resolveSuggestions).toBe("function");
|
||||
expect(typeof mod.resolveKeywords).toBe("function");
|
||||
});
|
||||
|
||||
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);
|
||||
it("the shared fallback carries the aggregates Traces was missing", async () => {
|
||||
const { resolveSuggestions } = await import("@/utils/query/sqlCompletion");
|
||||
const names = (resolveSuggestions("sql", null) 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);
|
||||
it("mounts with suggestions=null without error (uses the shared fallback)", () => {
|
||||
const wrapper = createWrapper({ suggestions: null, language: "sql" });
|
||||
expect(wrapper.exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -542,19 +542,30 @@ describe("QueryEditorDialog - ODrawer Migration", () => {
|
|||
});
|
||||
|
||||
// ─── 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.
|
||||
// QueryEditorDialog wires the whole autocomplete pipeline in handleQueryUpdate
|
||||
// (query, cursorIndex, org/stream context, popup.open, getSuggestions) but binds
|
||||
// :keywords="autoCompleteKeywords" — the BASE list — instead of effectiveKeywords.
|
||||
//
|
||||
// Comparing the bound prop against vm.effectiveKeywords would pass even with the
|
||||
// bug, because effectiveKeywords returns autoCompleteKeywords verbatim when no
|
||||
// context is active. Drive a real VALUE context instead.
|
||||
|
||||
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 editorStubDef = {
|
||||
name: "UnifiedQueryEditor",
|
||||
template: '<div class="stub-kw-editor" />',
|
||||
props: ["query", "keywords", "suggestions"],
|
||||
emits: ["update:query", "blur", "focus", "language-change", "ask-ai", "run-query"],
|
||||
methods: {
|
||||
// handleQueryUpdate reads these off queryEditorRef.
|
||||
getCursorIndex() {
|
||||
return 9999; // past end-of-query => analyse the whole string
|
||||
},
|
||||
triggerAutoComplete() {},
|
||||
},
|
||||
};
|
||||
|
||||
const mountWithKeywordStub = async (props: Record<string, any> = {}) =>
|
||||
const mountWithStub = () =>
|
||||
mount(QueryEditorDialog, {
|
||||
props: {
|
||||
modelValue: true,
|
||||
|
|
@ -572,14 +583,20 @@ describe("QueryEditorDialog - N1 context keywords reach the editor", () => {
|
|||
multiTimeRange: [],
|
||||
savedFunctions: [],
|
||||
sqlQueryErrorMsg: "",
|
||||
...props,
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n, store],
|
||||
stubs: {
|
||||
CodeQueryEditor: keywordAwareStub,
|
||||
QueryEditor: keywordAwareStub,
|
||||
UnifiedQueryEditor: keywordAwareStub,
|
||||
UnifiedQueryEditor: editorStubDef,
|
||||
// ODrawer is the dialog root; without a slot-rendering stub none of
|
||||
// its content (including the editor) mounts.
|
||||
ODrawer: {
|
||||
name: "ODrawer",
|
||||
props: ["open", "size", "showClose", "bleed", "persistent", "title", "width"],
|
||||
emits: ["update:open"],
|
||||
template:
|
||||
"<div><slot name='header-left' /><slot name='header-right' /><slot /></div>",
|
||||
},
|
||||
FullViewContainer: {
|
||||
template: "<div><slot /><slot name='right' /></div>",
|
||||
props: ["name", "label", "isExpanded"],
|
||||
|
|
@ -590,29 +607,33 @@ describe("QueryEditorDialog - N1 context keywords reach the editor", () => {
|
|||
},
|
||||
});
|
||||
|
||||
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("renders the unified editor", () => {
|
||||
const wrapper = mountWithStub();
|
||||
expect(wrapper.findComponent({ name: "UnifiedQueryEditor" }).exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("binds effectiveSuggestions, so value context can blank the function list", async () => {
|
||||
const wrapper = await mountWithKeywordStub();
|
||||
it("delivers field VALUES to the editor once the cursor is after an operator", async () => {
|
||||
const wrapper = mountWithStub();
|
||||
const editor = wrapper.findComponent({ name: "UnifiedQueryEditor" });
|
||||
await editor.vm.$emit("update:query", "level = ");
|
||||
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);
|
||||
|
||||
const delivered = (editor.props("keywords") ?? []) as any[];
|
||||
expect(delivered.some((k) => k.kind === "Value")).toBe(true);
|
||||
});
|
||||
|
||||
it("blanks the function suggestions while in value context", async () => {
|
||||
const wrapper = mountWithStub();
|
||||
const editor = wrapper.findComponent({ name: "UnifiedQueryEditor" });
|
||||
await editor.vm.$emit("update:query", "level = ");
|
||||
await flushPromises();
|
||||
|
||||
expect(editor.props("suggestions")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// Stored field values for the N1 value-context probe above. Only useSuggestions
|
||||
// consumes this module, so mocking it does not affect the rest of the suite.
|
||||
vi.mock("@/composables/useFieldValueStore", () => ({
|
||||
getFieldValuesForSuggestion: vi.fn().mockResolvedValue(["error", "warn"]),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -2024,21 +2024,37 @@ describe("QueryConfig.vue", () => {
|
|||
h.unmount();
|
||||
});
|
||||
});
|
||||
// ─── Phase 1 (tmp/code.md N1) ─────────────────────────────────────────────────
|
||||
// ─── 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.
|
||||
//
|
||||
// Asserting "editor.props(keywords) === vm.effectiveKeywords" is NOT enough:
|
||||
// effectiveKeywords returns autoCompleteKeywords verbatim whenever no context is
|
||||
// active, so such a test passes with the bug still present. The only honest probe
|
||||
// is to drive a real VALUE context and require Value-kind items to reach the editor.
|
||||
|
||||
describe("QueryConfig — N1 context keywords reach the inline editor", () => {
|
||||
let host: any;
|
||||
let qc: any;
|
||||
let editorStub: any;
|
||||
|
||||
beforeEach(() => {
|
||||
const inlineEditorStub = {
|
||||
name: "UnifiedQueryEditor",
|
||||
template: '<div class="stub-inline-editor" />',
|
||||
props: ["query", "keywords", "suggestions"],
|
||||
emits: ["update:query", "focus", "blur"],
|
||||
methods: {
|
||||
// handleInlineQueryUpdate reads these off the ref.
|
||||
getCursorIndex() {
|
||||
return 9999; // past end-of-query => analyse the whole string
|
||||
},
|
||||
triggerAutoComplete() {},
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
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 },
|
||||
|
|
@ -2058,44 +2074,54 @@ describe("QueryConfig.vue", () => {
|
|||
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"],
|
||||
},
|
||||
},
|
||||
stubs: { UnifiedQueryEditor: inlineEditorStub },
|
||||
},
|
||||
});
|
||||
qc = host.findComponent(QueryConfig);
|
||||
editorStub = host.findComponent({ name: "UnifiedQueryEditor" });
|
||||
});
|
||||
|
||||
afterEach(() => host?.unmount());
|
||||
|
||||
it("exposes effectiveKeywords as the editor's keyword source", () => {
|
||||
expect(qc.vm.effectiveKeywords).toBeDefined();
|
||||
it("renders the inline editor on the sql tab", () => {
|
||||
expect(editorStub.exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("exposes effectiveSuggestions as the editor's suggestion source", () => {
|
||||
expect(qc.vm.effectiveSuggestions).toBeDefined();
|
||||
it("delivers field VALUES to the editor once the cursor is after an operator", async () => {
|
||||
// Drives useSuggestions into its value branch; getFieldValuesForSuggestion
|
||||
// is mocked (bottom of file) to return two values.
|
||||
await editorStub.vm.$emit("update:query", "level = ");
|
||||
await flushPromises();
|
||||
await nextTick();
|
||||
|
||||
const delivered = (editorStub.props("keywords") ?? []) as any[];
|
||||
expect(delivered.length).toBeGreaterThan(0);
|
||||
// With the buggy binding the editor receives the BASE list (Field/Function/
|
||||
// Keyword kinds) and never a single Value.
|
||||
expect(delivered.some((k) => k.kind === "Value")).toBe(true);
|
||||
});
|
||||
|
||||
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("delivers the stored values themselves, not field names", async () => {
|
||||
await editorStub.vm.$emit("update:query", "level = ");
|
||||
await flushPromises();
|
||||
await nextTick();
|
||||
|
||||
const labels = ((editorStub.props("keywords") ?? []) as any[]).map((k) => k.label);
|
||||
expect(labels).toContain("error");
|
||||
expect(labels).toContain("warn");
|
||||
});
|
||||
|
||||
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);
|
||||
it("blanks the function suggestions while in value context", async () => {
|
||||
await editorStub.vm.$emit("update:query", "level = ");
|
||||
await flushPromises();
|
||||
await nextTick();
|
||||
|
||||
expect(editorStub.props("suggestions")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// Stored field values for the N1 value-context probe above. Only useSuggestions
|
||||
// consumes this module, so mocking it does not affect the rest of the suite.
|
||||
vi.mock("@/composables/useFieldValueStore", () => ({
|
||||
getFieldValuesForSuggestion: vi.fn().mockResolvedValue(["error", "warn"]),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -232,12 +232,11 @@ describe("A5 — snippet rules are mapped from string name to numeric enum", ()
|
|||
expect(typeof item.insertTextRules).toBe("number");
|
||||
});
|
||||
|
||||
it("survives monaco's bitwise test (string would coerce to 0)", () => {
|
||||
it("survives monaco's bitwise test", () => {
|
||||
const [item] = build({ suggestions: [snippetEntry] });
|
||||
// suggestController.js:368 — if (!(insertTextRules & 4)) escape as plain text
|
||||
// suggestController.js:368 — if (!(insertTextRules & 4)) escape as plain text.
|
||||
// A string value coerces to 0 here, which is the bug this pins.
|
||||
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", () => {
|
||||
|
|
@ -402,4 +401,53 @@ describe("back-compat — legacy callable label/insertText entries still build",
|
|||
const [item] = build({ suggestions: [legacy as any], word: "abc" });
|
||||
expect(item.kind).toBe(KINDS.Text);
|
||||
});
|
||||
|
||||
// A4: the old code derived the token with textUntilPosition.split(" "), which
|
||||
// broke on newlines ("*\nFROM") and on partially quoted tokens ("'err").
|
||||
// buildCompletionItems must receive monaco's own word and nothing else.
|
||||
it("uses only the supplied word — never a space-split of the whole buffer", () => {
|
||||
const [item] = build({ suggestions: [legacy as any], word: "err" });
|
||||
expect(item.label).toBe("custom_fn('err')");
|
||||
expect(item.label).not.toContain("\n");
|
||||
expect(item.label).not.toContain("FROM");
|
||||
});
|
||||
});
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// N2 / D7 — prop fallback resolution (pure; the component delegates to this)
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("N2/D7 — resolveSuggestions / resolveKeywords", () => {
|
||||
it("falls back to the shared catalog when the SQL suggestions prop is null", async () => {
|
||||
const { resolveSuggestions } = await import("./sqlCompletion");
|
||||
expect(resolveSuggestions("sql", null)).toEqual(SQL_FUNCTIONS);
|
||||
});
|
||||
|
||||
it("honours an explicit empty array (value context must stay empty)", async () => {
|
||||
const { resolveSuggestions } = await import("./sqlCompletion");
|
||||
expect(resolveSuggestions("sql", [])).toEqual([]);
|
||||
});
|
||||
|
||||
it("passes a caller-supplied list through untouched", async () => {
|
||||
const { resolveSuggestions } = await import("./sqlCompletion");
|
||||
const custom = [{ name: "x", label: "x", kind: "Function", insertText: "x" }] as any;
|
||||
expect(resolveSuggestions("sql", custom)).toBe(custom);
|
||||
});
|
||||
|
||||
it("does not inject SQL suggestions into non-SQL editors", async () => {
|
||||
const { resolveSuggestions } = await import("./sqlCompletion");
|
||||
expect(resolveSuggestions("json", null)).toEqual([]);
|
||||
expect(resolveSuggestions("promql", null)).toEqual([]);
|
||||
});
|
||||
|
||||
it("falls back to the shared keyword list when the SQL keywords prop is empty", async () => {
|
||||
const { resolveKeywords } = await import("./sqlCompletion");
|
||||
expect(resolveKeywords("sql", [])).toEqual(SQL_KEYWORDS);
|
||||
});
|
||||
|
||||
it("prefers caller-supplied keywords over the defaults", async () => {
|
||||
const { resolveKeywords } = await import("./sqlCompletion");
|
||||
const custom = [{ name: "host", label: "host", kind: "Field", insertText: "host" }] as any;
|
||||
expect(resolveKeywords("sql", custom)).toBe(custom);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue