Файли
openidea/src/editor/refactor/introduce.test.ts
T
l0ng-ai 5670c2e8dd test: stub Range layout methods once in the test setup (#53)
Root cause: jsdom has no layout and CodeMirror measures text ranges in an animation frame, sometimes after a test finished; test files that did not stub Range.getClientRects (e.g. search/codePreview) threw an unhandled TypeError that failed whole CI runs at random (seen on #41).
Fix: stub Range.getClientRects / getBoundingClientRect once in src/test/setup.ts; drop the six per-file copies.
Verified: vitest on editor/lsp, editor/refactor, templates, search (55 files, 458 tests, no unhandled errors).

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-27 10:38:11 +08:00

572 рядки
21 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { EditorState, Text } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { history, undo } from "@codemirror/commands";
import type { ExpressionAt, NodeAt, Range, StructuralRanges, SyntaxNode } from "@/bindings";
// --- a hand-built syntax tree over the test document ----------------------------
interface FakeNode {
kind: string;
from: number;
to: number;
field: string | null;
parent: FakeNode | null;
error?: boolean;
}
let text = "";
let nodes: FakeNode[] = [];
let expressionAt: (range: Range) => ExpressionAt = () => {
throw new Error("no expressionAt");
};
let errors: Range[] = [];
let structural: StructuralRanges | null = null;
const posOf = (off: number) => {
const line = Text.of(text.split("\n")).lineAt(off);
return { line: line.number - 1, col: off - line.from };
};
const offOf = (p: { line: number; col: number }) => Text.of(text.split("\n")).line(p.line + 1).from + p.col;
const rangeOf = (from: number, to: number): Range => ({ start: posOf(from), end: posOf(to) });
function toSyntax(n: FakeNode): SyntaxNode {
return {
kind: n.kind,
range: rangeOf(n.from, n.to),
field: n.field,
named: true,
error: !!n.error,
missing: false,
hasError: !!n.error,
};
}
/**
* Nodes outer → inner: `[kind, needle, nth?, field?]`; `needle` locates the
* node's text (nth occurrence, default 0). Parents are the innermost earlier
* node containing it.
*/
function tree(specs: Array<[string, string, number?, string?]>) {
nodes = [];
for (const [kind, needle, nth = 0, field = null] of specs) {
let at = -1;
for (let i = 0; i <= nth; i++) at = text.indexOf(needle, at + 1);
if (at < 0) throw new Error(`no ${needle}`);
const node: FakeNode = { kind, from: at, to: at + needle.length, field, parent: null };
node.parent = [...nodes].reverse().find((n) => n.from <= node.from && n.to >= node.to) ?? null;
nodes.push(node);
}
}
const find = (kind: string, nth = 0) => nodes.filter((n) => n.kind === kind)[nth]!;
const syn = (kind: string, nth = 0) => toSyntax(find(kind, nth));
function nodeAt(range: Range): NodeAt | null {
const from = offOf(range.start);
const to = offOf(range.end);
let best: FakeNode | null = null;
for (const n of nodes) if (n.from <= from && n.to >= to && (!best || n.to - n.from <= best.to - best.from)) best = n;
if (!best) return null;
const ancestors: SyntaxNode[] = [];
for (let p = best.parent; p; p = p.parent) ancestors.push(toSyntax(p));
return { node: toSyntax(best), ancestors };
}
const structureMock = {
support: vi.fn(async () => ({ tree: true, locals: false })),
expressionAt: vi.fn(async (_doc: number, range: Range) => expressionAt(range)),
syntaxErrors: vi.fn(async () => ({
errors: errors.map((range) => ({ kind: "missing", range, expected: ";", message: "missing `;`" })),
truncated: false,
})),
nodeAt: vi.fn(async (_doc: number, range: Range) => nodeAt(range)),
identifiers: vi.fn(async (_doc: number, name: string) =>
[...text.matchAll(new RegExp(`(?<![\\w$])${name}(?![\\w$])`, "g"))].map((m) => ({
range: rangeOf(m.index!, m.index! + name.length),
kind: "identifier",
qualified: text[m.index! - 1] === ".",
})),
),
locals: vi.fn(async () => ({ status: "noIdentifier" as const })),
structuralRanges: vi.fn(async () => structural!),
};
vi.mock("@/editor/structure", () => ({ structure: structureMock }));
const files: Record<string, string> = {};
vi.mock("@/platform/ipc", async (orig) => {
const actual = await orig<typeof import("@/platform/ipc")>();
return {
...actual,
commands: {
...actual.commands,
fsReadText: async (path: string) =>
path in files
? { status: "ok", data: files[path] }
: { status: "error", error: { kind: "notFound", message: path } },
lspHover: async () => ({ status: "error", error: { kind: "unavailable", message: "no server" } }),
},
};
});
vi.mock("@/platform/project", () => ({ useProject: { getState: () => ({ project: { root: "/p" } }) } }));
const showHint = vi.fn();
vi.mock("@/editor/lsp/hint", () => ({ showHint }));
const { introduceFallback } = await import("./introduce");
const { useLspPopup } = await import("@/editor/lsp/popupStore");
const { lspDoc } = await import("@/editor/lsp/lsp");
const { clearLevelCache } = await import("./projectLevel");
// --- editor ---------------------------------------------------------------------
let view: EditorView | null = null;
function editor(doc: string, language: string, path: string, sel: { anchor: number; head?: number }) {
text = doc;
view = new EditorView({
state: EditorState.create({
doc,
selection: sel,
extensions: [history(), lspDoc.of({ doc: 1, path, language, uiMode: null, readOnly: false })],
}),
});
return view;
}
const popup = () => useLspPopup.getState().popup;
async function pick(id: string) {
const p = popup();
if (p?.kind !== "list") throw new Error(`no list popup: ${JSON.stringify(p)}`);
useLspPopup.setState({ popup: null });
await p.items.find((i) => i.id === id)!.run();
await new Promise((r) => setTimeout(r, 0));
}
function submitName(name: string) {
const p = popup();
if (p?.kind !== "rename") throw new Error("no name popup");
useLspPopup.setState({ popup: null });
p.onSubmit(name);
}
beforeEach(() => {
errors = [];
structural = null;
clearLevelCache();
for (const k of Object.keys(files)) delete files[k];
});
afterEach(() => {
view?.destroy();
view = null;
useLspPopup.setState({ popup: null });
showHint.mockClear();
});
describe("Introduce Variable fallback", () => {
const javaSrc = 'class A {\n void f() {\n new String("123")\n g();\n }\n}\n';
function javaTree() {
tree([
["program", javaSrc],
["class_declaration", javaSrc.trimEnd()],
["class_body", '{\n void f() {\n new String("123")\n g();\n }\n}', 0, "body"],
["method_declaration", 'void f() {\n new String("123")\n g();\n }'],
["block", '{\n new String("123")\n g();\n }', 0, "body"],
["expression_statement", 'new String("123")'],
["object_creation_expression", 'new String("123")'],
["string_literal", '"123"'],
]);
const e = syn("object_creation_expression");
expressionAt = () => ({
expression: e,
exact: true,
candidates: [e],
statement: syn("expression_statement"),
block: syn("block"),
functionBoundary: null,
hasError: true,
});
}
it("replaces a bare expression statement with the declaration (Java 10+: var) as one undo step", async () => {
files["/p/pom.xml"] = "<properties><maven.compiler.release>17</maven.compiler.release></properties>";
const from = javaSrc.indexOf("new String");
const v = editor(javaSrc, "java", "/p/src/A.java", { anchor: from, head: from + 'new String("123")'.length });
javaTree();
expect(await introduceFallback(v, "variable", "notReady")).toBe(true);
expect(v.state.doc.toString()).toBe(
'class A {\n void f() {\n var string = new String("123");\n g();\n }\n}\n',
);
const p = popup();
expect(p).toMatchObject({ kind: "rename", initial: "string", note: "Based on syntax (language server not ready)" });
submitName("text");
expect(v.state.doc.toString()).toContain(' var text = new String("123");\n');
undo(v);
expect(v.state.doc.toString()).toBe(javaSrc);
});
it("uses the constructed type below Java 10 and refuses when the type is unknown", async () => {
files["/p/pom.xml"] = "<properties><maven.compiler.source>1.8</maven.compiler.source></properties>";
const from = javaSrc.indexOf("new String");
const v = editor(javaSrc, "java", "/p/src/A.java", { anchor: from, head: from + 'new String("123")'.length });
javaTree();
await introduceFallback(v, "variable", "notReady");
expect(v.state.doc.toString()).toContain(' String string = new String("123");\n');
const src = "class A {\n void f() {\n g(h());\n }\n}\n";
view!.destroy();
const at = src.indexOf("h()");
const w = editor(src, "java", "/p/src/A.java", { anchor: at, head: at + 3 });
tree([
["program", src],
["block", "{\n g(h());\n }"],
["expression_statement", "g(h());"],
["method_invocation", "g(h())"],
["method_invocation", "h()"],
]);
const e = syn("method_invocation", 1);
expressionAt = () => ({
expression: e,
exact: true,
candidates: [e],
statement: syn("expression_statement"),
block: syn("block"),
functionBoundary: null,
hasError: false,
});
expect(await introduceFallback(w, "variable", "notReady")).toBe(true);
expect(w.state.doc.toString()).toBe(src);
expect(showHint.mock.calls.at(-1)?.[1]).toMatch(/type of the expression is unknown/);
});
const tsSrc = "function f(o) {\n const a = o.getUser().name;\n log(o.getUser());\n}\n";
function tsTree() {
tree([
["program", tsSrc],
["function_declaration", tsSrc.trimEnd()],
["statement_block", "{\n const a = o.getUser().name;\n log(o.getUser());\n}", 0, "body"],
["lexical_declaration", "const a = o.getUser().name;"],
["member_expression", "o.getUser().name", 0, "value"],
["call_expression", "o.getUser()", 0, "object"],
["expression_statement", "log(o.getUser());"],
["call_expression", "log(o.getUser())"],
["arguments", "(o.getUser())", 0, "arguments"],
["call_expression", "o.getUser()", 1],
]);
const call = syn("call_expression", 0);
expressionAt = () => ({
expression: call,
exact: false,
candidates: [call, syn("member_expression")],
statement: syn("lexical_declaration"),
block: syn("statement_block"),
functionBoundary: null,
hasError: false,
});
}
it("offers expressions at the caret, then all occurrences, and names after the callee", async () => {
const v = editor(tsSrc, "typescript", "/p/a.ts", { anchor: tsSrc.indexOf("getUser") + 2 });
tsTree();
expect(await introduceFallback(v, "variable", "notReady")).toBe(true);
const chooser = popup();
expect(chooser).toMatchObject({ kind: "list", title: "Expressions" });
expect(chooser?.kind === "list" && chooser.items.map((i) => i.label)).toEqual(["o.getUser()", "o.getUser().name"]);
await pick("0");
expect(popup()).toMatchObject({ kind: "list", title: "Multiple occurrences found" });
expect(
popup()?.kind === "list" &&
popup()!.kind === "list" &&
(popup() as { items: { label: string }[] }).items.map((i) => i.label),
).toEqual(["Replace this occurrence only", "Replace all 2 occurrences"]);
await pick("all");
expect(v.state.doc.toString()).toBe(
"function f(o) {\n const user = o.getUser();\n const a = user.name;\n log(user);\n}\n",
);
expect(popup()).toMatchObject({ kind: "rename", initial: "user", suggestions: ["user", "value"] });
// Escape keeps the default name.
useLspPopup.setState({ popup: null });
undo(v);
expect(v.state.doc.toString()).toBe(tsSrc);
});
it("replaces only the selected occurrence on request", async () => {
const v = editor(tsSrc, "typescript", "/p/a.ts", { anchor: tsSrc.indexOf("getUser") + 2 });
tsTree();
await introduceFallback(v, "variable", "notReady");
await pick("0");
await pick("one");
expect(v.state.doc.toString()).toBe(
"function f(o) {\n const user = o.getUser();\n const a = user.name;\n log(o.getUser());\n}\n",
);
});
it("respects an empty server answer unless the code there is broken", async () => {
const at = tsSrc.indexOf("o.getUser()");
const v = editor(tsSrc, "typescript", "/p/a.ts", { anchor: at, head: at + "o.getUser()".length });
tsTree();
const call = syn("call_expression", 0);
expressionAt = () => ({
expression: call,
exact: true,
candidates: [call],
statement: syn("lexical_declaration"),
block: syn("statement_block"),
functionBoundary: null,
hasError: false,
});
expect(await introduceFallback(v, "variable", "empty")).toBe(false);
expect(v.state.doc.toString()).toBe(tsSrc);
errors = [rangeOf(at + 5, at + 5)];
expect(await introduceFallback(v, "variable", "empty")).toBe(true);
await pick("one");
expect(v.state.doc.toString()).toContain("const user = o.getUser();");
expect(popup()).toMatchObject({ note: "Based on syntax (the code has errors)" });
});
it("refuses selections that are not an expression, lambda bodies and loop conditions", async () => {
const src = "function f(xs) {\n const ys = xs.map((x) => x * 2);\n while (next(xs)) {}\n}\n";
const v = editor(src, "typescript", "/p/a.ts", { anchor: src.indexOf("x * 2"), head: src.indexOf("x * 2") + 3 });
tree([
["program", src],
["statement_block", src.slice(src.indexOf("{"), src.lastIndexOf("}") + 1)],
["lexical_declaration", "const ys = xs.map((x) => x * 2);"],
["arrow_function", "(x) => x * 2"],
["binary_expression", "x * 2", 0, "body"],
["while_statement", "while (next(xs)) {}"],
["parenthesized_expression", "(next(xs))", 0, "condition"],
["call_expression", "next(xs)"],
]);
expressionAt = () => ({
expression: syn("binary_expression"),
exact: false,
candidates: [],
statement: syn("lexical_declaration"),
block: syn("statement_block"),
functionBoundary: null,
hasError: false,
});
await introduceFallback(v, "variable", "notReady");
expect(showHint.mock.calls.at(-1)?.[1]).toBe(
"Cannot perform refactoring: selected block should represent an expression",
);
const bin = syn("binary_expression");
expressionAt = () => ({
expression: bin,
exact: true,
candidates: [bin],
statement: syn("lexical_declaration"),
block: syn("statement_block"),
functionBoundary: syn("arrow_function"),
hasError: false,
});
await introduceFallback(v, "variable", "notReady");
expect(showHint.mock.calls.at(-1)?.[1]).toMatch(/lambda with an expression body/);
const call = syn("call_expression");
expressionAt = () => ({
expression: call,
exact: true,
candidates: [call],
statement: syn("while_statement"),
block: syn("statement_block"),
functionBoundary: null,
hasError: false,
});
await introduceFallback(v, "variable", "notReady");
expect(showHint.mock.calls.at(-1)?.[1]).toMatch(/evaluated conditionally or repeatedly/);
expect(v.state.doc.toString()).toBe(src);
});
it("matches occurrences through same-range wrappers and drops callee candidates (Kotlin)", async () => {
const src = "fun f(o: O) {\n val t = o.total() + 1\n println(o.total())\n}\n";
const v = editor(src, "kotlin", "/p/a.kt", { anchor: src.indexOf("total") + 1 });
tree([
["source_file", src],
["block", src.slice(src.indexOf("{"), src.lastIndexOf("}") + 1)],
["property_declaration", "val t = o.total() + 1"],
["binary_expression", "o.total() + 1"],
["call_expression", "o.total()"],
["navigation_expression", "o.total"],
["call_expression", "println(o.total())"],
["value_arguments", "(o.total())"],
["value_argument", "o.total()", 1],
["call_expression", "o.total()", 1],
]);
const nav = syn("navigation_expression");
const call = syn("call_expression");
expressionAt = () => ({
expression: nav,
exact: false,
candidates: [nav, call, syn("binary_expression")],
statement: syn("property_declaration"),
block: syn("block"),
functionBoundary: null,
hasError: false,
});
await introduceFallback(v, "variable", "notReady");
const chooser = popup();
expect(chooser?.kind === "list" && chooser.items.map((i) => i.label)).toEqual(["o.total()", "o.total() + 1"]);
await pick("0");
await pick("all");
expect(v.state.doc.toString()).toBe(
"fun f(o: O) {\n val total = o.total()\n val t = total + 1\n println(total)\n}\n",
);
});
it("replaces a bare statement whose missing `;` let the parser swallow the next line", async () => {
files["/p/pom.xml"] = "<properties><java.version>21</java.version></properties>";
const src = 'class A {\n void f() {\n new String("1")\n System.out.println();\n }\n}\n';
const at = src.indexOf("new String");
const v = editor(src, "java", "/p/A.java", { anchor: at, head: at + 'new String("1")'.length });
tree([
["program", src],
["block", src.slice(src.indexOf("{\n new"), src.lastIndexOf("}\n}") + 1)],
["expression_statement", 'new String("1")\n System'],
["object_creation_expression", 'new String("1")'],
]);
const e = syn("object_creation_expression");
const stmt = { ...syn("expression_statement"), hasError: true };
expressionAt = () => ({
expression: e,
exact: true,
candidates: [e],
statement: stmt,
block: syn("block"),
functionBoundary: null,
hasError: true,
});
await introduceFallback(v, "variable", "empty");
expect(v.state.doc.toString()).toBe(
'class A {\n void f() {\n var string = new String("1");\n System.out.println();\n }\n}\n',
);
});
it("inserts before a statement that does not start its line", async () => {
const src = "def f(c, x):\n if c: g(x + 1)\n";
const at = src.indexOf("x + 1");
const v = editor(src, "python", "/p/a.py", { anchor: at, head: at + 5 });
tree([
["module", src],
["if_statement", "if c: g(x + 1)"],
["block", "g(x + 1)", 0, "consequence"],
["expression_statement", "g(x + 1)"],
["call", "g(x + 1)"],
["binary_operator", "x + 1"],
]);
const e = syn("binary_operator");
expressionAt = () => ({
expression: e,
exact: true,
candidates: [e],
statement: syn("expression_statement"),
block: syn("block"),
functionBoundary: null,
hasError: false,
});
await introduceFallback(v, "variable", "notReady");
expect(v.state.doc.toString()).toBe("def f(c, x):\n if c: value = x + 1; g(value)\n");
});
});
describe("Introduce Constant fallback", () => {
it("adds a typed Java constant before the member, after its comments", async () => {
const src = "class A {\n // doc\n void f() { h(42); k(42); }\n}\n";
const at = src.indexOf("42");
const v = editor(src, "java", "/p/A.java", { anchor: at, head: at + 2 });
tree([
["program", src],
["class_declaration", src.trimEnd()],
["class_body", src.slice(src.indexOf("{"), src.lastIndexOf("}") + 1), 0, "body"],
["method_declaration", "void f() { h(42); k(42); }"],
["block", "{ h(42); k(42); }", 0, "body"],
["expression_statement", "h(42);"],
["method_invocation", "h(42)"],
["decimal_integer_literal", "42"],
["expression_statement", "k(42);"],
["method_invocation", "k(42)"],
["decimal_integer_literal", "42", 1],
]);
const lit = syn("decimal_integer_literal");
expressionAt = () => ({
expression: lit,
exact: true,
candidates: [lit],
statement: syn("expression_statement"),
block: syn("block"),
functionBoundary: null,
hasError: false,
});
structural = {
selection: [],
statement: null,
prevStatement: null,
nextStatement: null,
block: null,
declaration: syn("method_declaration"),
prevDeclaration: null,
nextDeclaration: null,
};
await introduceFallback(v, "constant", "error");
await pick("all");
expect(v.state.doc.toString()).toBe(
"class A {\n private static final int I = 42;\n\n // doc\n void f() { h(I); k(I); }\n}\n",
);
expect(popup()).toMatchObject({ kind: "rename", initial: "I", note: "Based on syntax (language server failed)" });
submitName("ANSWER");
expect(v.state.doc.toString()).toContain("private static final int ANSWER = 42;");
expect(v.state.doc.toString()).toContain("h(ANSWER); k(ANSWER);");
});
it("adds a module-level Python constant and refuses non-literals", async () => {
const src = 'import os\n\ndef f():\n return g("max retries")\n';
const at = src.indexOf('"max');
const v = editor(src, "python", "/p/a.py", { anchor: at, head: at + '"max retries"'.length });
tree([
["module", src],
["import_statement", "import os"],
["function_definition", src.slice(src.indexOf("def"))],
["block", 'return g("max retries")', 0, "body"],
["return_statement", 'return g("max retries")'],
["call", 'g("max retries")'],
["string", '"max retries"'],
]);
const lit = syn("string");
expressionAt = () => ({
expression: lit,
exact: true,
candidates: [lit],
statement: syn("return_statement"),
block: syn("block"),
functionBoundary: null,
hasError: false,
});
await introduceFallback(v, "constant", "notReady");
expect(v.state.doc.toString()).toBe(
'import os\n\nMAX_RETRIES = "max retries"\n\ndef f():\n return g(MAX_RETRIES)\n',
);
const call = syn("call");
expressionAt = () => ({
expression: call,
exact: true,
candidates: [call],
statement: syn("return_statement"),
block: syn("block"),
functionBoundary: null,
hasError: false,
});
view!.dispatch({
changes: { from: 0, to: view!.state.doc.length, insert: src },
selection: { anchor: src.indexOf("g("), head: src.indexOf("g(") + 'g("max retries")'.length },
});
await introduceFallback(view!, "constant", "notReady");
expect(showHint.mock.calls.at(-1)?.[1]).toMatch(/only literal values/);
});
});