Skip to content

Commit 22252fb

Browse files
authored
test: expand edge-case coverage and DRY up test helpers (#168)
## Summary Broadens unit-test coverage and reduces test boilerplate. Unit suite (excludes browser-covered `navigation-extension`): | Metric | Before | After | |---|---|---| | Statements | 89.1% | **91.6%** | | Branches | 81.2% | **85.9%** | | Lines | 89.0% | **91.5%** | | Tests | 471 | **587** (+116) | ## DRY - New `__tests__/test-utils.ts`: shared fixtures + `createCompletionContext`/`labels`/`completeWith` runner. - `column-` and `alias-completion-source` suites refactored onto it (~90 lines of duplicated setup removed). - Scoped vitest's `__tests__` glob to `*.test.ts` and excluded `__tests__/**` from the `tsc` build so the helper file isn't run as a test or shipped in `dist`. ## New edge cases - New `completion-utils` suite (previously untested). - Added cases to `parser` (64→71% branch), `namespace-utils` (78→86%), `query-context`, `references` (79→86%), `semantic-diagnostics` (82→86%), `hover` (86→98%, lines→100%), `diagnostics` (→100%). ## Test plan - `pnpm test` (jsdom): 587 passed, 1 pre-existing `it.fails`. - `pnpm run typecheck` and `pnpm exec oxlint`: clean. <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Expands unit-test edge-case coverage and consolidates shared test helpers to cut boilerplate. Coverage up: statements 89.1%→91.6%, branches 81.2%→85.9%, lines 89.0%→91.5%; +116 tests. - **Refactors** - Added `src/sql/__tests__/test-utils.ts` with shared fixtures and `createCompletionContext`/`completeWith`/`labels`; introduced `completion-utils` tests. - Refactored `column-` and `alias-completion-source` tests to use the helpers (~90 lines of duplicate setup removed). - Scoped `vite` test include to `src/**/__tests__/**/*.test.ts` and excluded `__tests__/**` from `tsc` so helpers aren’t executed or published. <sup>Written for commit 6d51e0e. Summary will update on new commits.</sup> <a href="https://cubic.dev/pr/marimo-team/codemirror-sql/pull/168?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
1 parent 68ecb20 commit 22252fb

14 files changed

Lines changed: 1279 additions & 84 deletions

src/sql/__tests__/alias-completion-source.test.ts

Lines changed: 6 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,12 @@
1-
import { CompletionContext, type CompletionResult } from "@codemirror/autocomplete";
21
import type { SQLNamespace } from "@codemirror/lang-sql";
3-
import { EditorState } from "@codemirror/state";
42
import { describe, expect, it } from "vitest";
53
import { aliasColumnCompletionSource } from "../alias-completion-source.js";
6-
import { sqlSchemaFacet } from "../schema-facet.js";
7-
8-
const schema: SQLNamespace = {
9-
users: ["id", "username", "email"],
10-
orders: [
11-
{ label: "order_id", detail: "Order ID", type: "property" },
12-
"total",
13-
],
14-
};
15-
16-
const nestedSchema: SQLNamespace = {
17-
mydb: {
18-
users: ["id", "username"],
19-
},
20-
};
21-
22-
async function complete(
23-
doc: string,
24-
opts: {
25-
schema?: SQLNamespace;
26-
pos?: number;
27-
explicit?: boolean;
28-
facetSchema?: SQLNamespace;
29-
} = {},
30-
): Promise<CompletionResult | null> {
31-
const source = aliasColumnCompletionSource(
32-
opts.schema === undefined && opts.facetSchema !== undefined ? {} : { schema: opts.schema },
33-
);
34-
const state = EditorState.create({
35-
doc,
36-
extensions: opts.facetSchema !== undefined ? [sqlSchemaFacet.of(opts.facetSchema)] : [],
37-
});
38-
const pos = opts.pos ?? doc.length;
39-
const context = new CompletionContext(state, pos, opts.explicit ?? false);
40-
return (await source(context)) as CompletionResult | null;
41-
}
42-
43-
function labels(result: CompletionResult | null): string[] {
44-
return (result?.options ?? []).map((option) => option.label);
45-
}
4+
import { completeWith, labels, NESTED_SCHEMA, TEST_SCHEMA } from "./test-utils.js";
5+
6+
const schema = TEST_SCHEMA;
7+
const nestedSchema = NESTED_SCHEMA;
8+
9+
const complete = completeWith(aliasColumnCompletionSource);
4610

4711
describe("aliasColumnCompletionSource", () => {
4812
it("offers the aliased table's columns after `alias.`", async () => {

src/sql/__tests__/column-completion-source.test.ts

Lines changed: 4 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,12 @@
1-
import { CompletionContext, type CompletionResult } from "@codemirror/autocomplete";
21
import type { SQLNamespace } from "@codemirror/lang-sql";
3-
import { EditorState } from "@codemirror/state";
42
import { describe, expect, it } from "vitest";
53
import { unqualifiedColumnCompletionSource } from "../column-completion-source.js";
6-
import { sqlSchemaFacet } from "../schema-facet.js";
4+
import { completeWith, labels, NESTED_SCHEMA, TEST_SCHEMA } from "./test-utils.js";
75

8-
const schema: SQLNamespace = {
9-
users: ["id", "username", "email"],
10-
orders: [
11-
{ label: "order_id", detail: "Order ID", type: "property" },
12-
"total",
13-
],
14-
};
6+
const schema = TEST_SCHEMA;
7+
const nestedSchema = NESTED_SCHEMA;
158

16-
const nestedSchema: SQLNamespace = {
17-
mydb: {
18-
users: ["id", "username"],
19-
},
20-
};
21-
22-
async function complete(
23-
doc: string,
24-
opts: {
25-
schema?: SQLNamespace;
26-
pos?: number;
27-
explicit?: boolean;
28-
facetSchema?: SQLNamespace;
29-
} = {},
30-
): Promise<CompletionResult | null> {
31-
const source = unqualifiedColumnCompletionSource(
32-
opts.schema === undefined && opts.facetSchema !== undefined ? {} : { schema: opts.schema },
33-
);
34-
const state = EditorState.create({
35-
doc,
36-
extensions: opts.facetSchema !== undefined ? [sqlSchemaFacet.of(opts.facetSchema)] : [],
37-
});
38-
const pos = opts.pos ?? doc.length;
39-
const context = new CompletionContext(state, pos, opts.explicit ?? false);
40-
return (await source(context)) as CompletionResult | null;
41-
}
42-
43-
function labels(result: CompletionResult | null): string[] {
44-
return (result?.options ?? []).map((option) => option.label);
45-
}
9+
const complete = completeWith(unqualifiedColumnCompletionSource);
4610

4711
describe("unqualifiedColumnCompletionSource", () => {
4812
it("offers the FROM table's columns for a bare prefix", async () => {
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import type { Completion, CompletionContext } from "@codemirror/autocomplete";
2+
import type { SQLNamespace } from "@codemirror/lang-sql";
3+
import type { EditorView } from "@codemirror/view";
4+
import { describe, expect, it, vi } from "vitest";
5+
import { columnsOf, findTableColumns, resolveSchema, toCompletion } from "../completion-utils.js";
6+
import { sqlSchemaFacet } from "../schema-facet.js";
7+
import { createCompletionContext, createState } from "./test-utils.js";
8+
9+
describe("resolveSchema", () => {
10+
it("returns an explicitly provided namespace as-is", () => {
11+
const schema: SQLNamespace = { users: ["id"] };
12+
const context = createCompletionContext("SELECT ");
13+
expect(resolveSchema(schema, context)).toBe(schema);
14+
});
15+
16+
it("falls back to the schema facet when no explicit source is given", () => {
17+
const schema: SQLNamespace = { users: ["id"] };
18+
const context = createCompletionContext("SELECT ", {
19+
extensions: [sqlSchemaFacet.of(schema)],
20+
});
21+
expect(resolveSchema(undefined, context)).toBe(schema);
22+
});
23+
24+
it("returns null when neither an explicit source nor a facet is present", () => {
25+
const context = createCompletionContext("SELECT ");
26+
expect(resolveSchema(undefined, context)).toBeNull();
27+
});
28+
29+
it("returns null for a function source when the context has no view", () => {
30+
// Contexts created without an editor (as in unit tests) get no schema
31+
const source = vi.fn(() => ({ users: ["id"] }) as SQLNamespace);
32+
const context = createCompletionContext("SELECT ");
33+
expect(resolveSchema(source, context)).toBeNull();
34+
expect(source).not.toHaveBeenCalled();
35+
});
36+
37+
it("invokes a function source with the view when one is present", () => {
38+
const schema: SQLNamespace = { users: ["id"] };
39+
const view = {} as EditorView;
40+
const source = vi.fn(() => schema);
41+
// Minimal context carrying a view — resolveSchema only reads `state`/`view`
42+
const context = { state: createState("SELECT "), view } as unknown as CompletionContext;
43+
44+
expect(resolveSchema(source, context)).toBe(schema);
45+
expect(source).toHaveBeenCalledWith(view);
46+
});
47+
});
48+
49+
describe("columnsOf", () => {
50+
it("returns null for a nullish namespace", () => {
51+
expect(columnsOf(undefined)).toBeNull();
52+
});
53+
54+
it("returns the array itself for an array namespace", () => {
55+
const columns = ["id", "name"];
56+
expect(columnsOf(columns)).toBe(columns);
57+
});
58+
59+
it("unwraps the children of a self/children namespace", () => {
60+
const children = ["id", "name"];
61+
const namespace = { self: { label: "users" }, children } as unknown as SQLNamespace;
62+
expect(columnsOf(namespace)).toBe(children);
63+
});
64+
65+
it("returns null for an object (non-column) namespace", () => {
66+
expect(columnsOf({ users: ["id"] } as SQLNamespace)).toBeNull();
67+
});
68+
69+
it("returns null when a self/children node wraps a non-array child", () => {
70+
const namespace = {
71+
self: { label: "db" },
72+
children: { users: ["id"] },
73+
} as unknown as SQLNamespace;
74+
expect(columnsOf(namespace)).toBeNull();
75+
});
76+
});
77+
78+
describe("findTableColumns", () => {
79+
const schema: SQLNamespace = {
80+
users: ["id", "username", "email"],
81+
orders: ["order_id", "total"],
82+
};
83+
84+
it("resolves a simple table name (case-insensitively)", () => {
85+
expect(findTableColumns(schema, "users")).toEqual(["id", "username", "email"]);
86+
expect(findTableColumns(schema, "USERS")).toEqual(["id", "username", "email"]);
87+
});
88+
89+
it("resolves a fully-qualified path", () => {
90+
const nested: SQLNamespace = { mydb: { users: ["id", "name"] } };
91+
expect(findTableColumns(nested, "mydb.users")).toEqual(["id", "name"]);
92+
});
93+
94+
it("resolves an under-qualified name to a table nested deeper", () => {
95+
const nested: SQLNamespace = { mydb: { users: ["id", "name"] } };
96+
expect(findTableColumns(nested, "users")).toEqual(["id", "name"]);
97+
});
98+
99+
it("resolves via pre-split segments (preserving dotted identifiers)", () => {
100+
const nested: SQLNamespace = { "my.db": { users: ["id"] } };
101+
expect(findTableColumns(nested, ["my.db", "users"])).toEqual(["id"]);
102+
});
103+
104+
it("returns null for an unknown table", () => {
105+
expect(findTableColumns(schema, "missing")).toBeNull();
106+
});
107+
108+
it("returns null when the last segment is empty", () => {
109+
// A path that reduces to no final segment cannot resolve
110+
expect(findTableColumns(schema, [])).toBeNull();
111+
expect(findTableColumns(schema, "")).toBeNull();
112+
});
113+
114+
it("returns null when an under-qualified name is ambiguous", () => {
115+
const ambiguous: SQLNamespace = {
116+
db1: { users: ["id", "a"] },
117+
db2: { users: ["id", "b"] },
118+
};
119+
// `users` exists under two catalogs — refuse to guess
120+
expect(findTableColumns(ambiguous, "users")).toBeNull();
121+
});
122+
123+
it("returns null when the qualified path is longer than any match", () => {
124+
const nested: SQLNamespace = { mydb: { users: ["id"] } };
125+
// `other.users` — the suffix does not match the `mydb.users` path
126+
expect(findTableColumns(nested, "other.users")).toBeNull();
127+
});
128+
});
129+
130+
describe("toCompletion", () => {
131+
it("wraps a string column with the property type and detail", () => {
132+
expect(toCompletion("id", "column")).toEqual({
133+
label: "id",
134+
type: "property",
135+
detail: "column",
136+
});
137+
});
138+
139+
it("adds a boost to a string column when provided", () => {
140+
expect(toCompletion("id", "column", 5)).toEqual({
141+
label: "id",
142+
type: "property",
143+
detail: "column",
144+
boost: 5,
145+
});
146+
});
147+
148+
it("preserves an existing Completion detail and forces the property type", () => {
149+
const column: Completion = { label: "id", type: "keyword", detail: "Primary key" };
150+
expect(toCompletion(column, "fallback")).toEqual({
151+
label: "id",
152+
type: "property",
153+
detail: "Primary key",
154+
});
155+
});
156+
157+
it("uses the fallback detail when the Completion has none", () => {
158+
const column: Completion = { label: "id" };
159+
expect(toCompletion(column, "fallback")).toMatchObject({
160+
label: "id",
161+
type: "property",
162+
detail: "fallback",
163+
});
164+
});
165+
166+
it("applies a boost to a Completion only when it has none of its own", () => {
167+
expect(toCompletion({ label: "id" }, "d", 3).boost).toBe(3);
168+
expect(toCompletion({ label: "id", boost: 9 }, "d", 3).boost).toBe(9);
169+
});
170+
});

src/sql/__tests__/diagnostics.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,4 +232,84 @@ describe("lint source", () => {
232232
expect(diagnostics).toHaveLength(2);
233233
expect(validateSpy).not.toHaveBeenCalled();
234234
});
235+
236+
it("should clamp the diagnostic span to the document end when the error column runs past it", async () => {
237+
// A parser whose reported column points beyond the end of the document
238+
// exercises the `from >= doc.length` branch in tokenEndAt, which clamps
239+
// the span end to `doc.length`.
240+
const doc = "SELECT 1";
241+
const parser = {
242+
validateSql: vi.fn(async () => [
243+
{ message: "unexpected end of input", line: 1, column: 999, severity: "error" as const },
244+
]),
245+
parse: vi.fn(async () => ({ success: false, errors: [] })),
246+
extractTableReferences: vi.fn(async () => []),
247+
extractColumnReferences: vi.fn(async () => []),
248+
} as unknown as SqlParser;
249+
250+
const diagnostics = await lint(doc, { parser, perStatement: false });
251+
252+
expect(diagnostics).toHaveLength(1);
253+
expect(diagnostics[0].from).toBe(doc.length);
254+
expect(diagnostics[0].to).toBe(doc.length);
255+
});
256+
257+
it("should preserve warning severity from the parser", async () => {
258+
const parser = {
259+
validateSql: vi.fn(async () => [
260+
{ message: "deprecated syntax", line: 1, column: 1, severity: "warning" as const },
261+
]),
262+
parse: vi.fn(async () => ({ success: false, errors: [] })),
263+
extractTableReferences: vi.fn(async () => []),
264+
extractColumnReferences: vi.fn(async () => []),
265+
} as unknown as SqlParser;
266+
267+
const diagnostics = await lint("SELECT 1", { parser, perStatement: false });
268+
269+
expect(diagnostics).toHaveLength(1);
270+
expect(diagnostics[0].severity).toBe("warning");
271+
expect(diagnostics[0].message).toBe("deprecated syntax");
272+
});
273+
274+
it("should emit multiple diagnostics from a single statement when the parser returns several", async () => {
275+
const parser = {
276+
validateSql: vi.fn(async () => [
277+
{ message: "first", line: 1, column: 1, severity: "error" as const },
278+
{ message: "second", line: 1, column: 8, severity: "error" as const },
279+
]),
280+
parse: vi.fn(async () => ({ success: false, errors: [] })),
281+
extractTableReferences: vi.fn(async () => []),
282+
extractColumnReferences: vi.fn(async () => []),
283+
} as unknown as SqlParser;
284+
285+
const diagnostics = await lint("SELECT badcol", { parser, perStatement: false });
286+
287+
expect(diagnostics).toHaveLength(2);
288+
expect(diagnostics.map((d) => d.message)).toEqual(["first", "second"]);
289+
});
290+
291+
it("clamps a statement-relative error column that overshoots to the statement end", async () => {
292+
// perStatement path: an error with a large column is clamped within the
293+
// statement bounds by convertStatementErrorToDiagnostic.
294+
const doc = "SELECT 1;";
295+
const parser = {
296+
validateSql: vi.fn(async () => []),
297+
// The structure analyzer derives per-statement errors from parse().
298+
parse: vi.fn(async () => ({
299+
success: false,
300+
errors: [{ message: "boom", line: 1, column: 999, severity: "error" as const }],
301+
})),
302+
extractTableReferences: vi.fn(async () => []),
303+
extractColumnReferences: vi.fn(async () => []),
304+
} as unknown as SqlParser;
305+
306+
const diagnostics = await lint(doc, { parser });
307+
308+
expect(diagnostics.length).toBeGreaterThanOrEqual(1);
309+
for (const d of diagnostics) {
310+
expect(d.from).toBeLessThanOrEqual(doc.length);
311+
expect(d.to).toBeLessThanOrEqual(doc.length);
312+
expect(d.to).toBeGreaterThanOrEqual(d.from);
313+
}
314+
});
235315
});

0 commit comments

Comments
 (0)