From ea38339444630fd351a1b5a221cbfb3ebdd3d27f Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 08:46:00 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=A7=AA=20[testing=20improvement]=20Add=20?= =?UTF-8?q?tests=20for=20SumEditor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Horizontal42 <75383519+Horizontal42@users.noreply.github.com> --- src/ui/editor.test.ts | 273 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 273 insertions(+) create mode 100644 src/ui/editor.test.ts diff --git a/src/ui/editor.test.ts b/src/ui/editor.test.ts new file mode 100644 index 0000000..fc59477 --- /dev/null +++ b/src/ui/editor.test.ts @@ -0,0 +1,273 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// Mocking EditorView and extensions to avoid full DOM requirement +vi.mock("@codemirror/view", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + EditorView: class MockEditorView { + static updateListener = { of: vi.fn((fn) => ({ type: "updateListener", fn })) }; + static theme = vi.fn(() => ({ type: "theme" })); + static lineWrapping = { type: "lineWrapping" }; + static decorations = { from: vi.fn() }; + + state: any; + dispatch: any; + dom: any; + scrollDOM: any; + focus: any; + + // Store constructor config to extract update listener + config: any; + + constructor(config: any) { + this.config = config; + this.state = config.state; + this.dom = { addEventListener: vi.fn() }; + this.scrollDOM = { addEventListener: vi.fn() }; + this.focus = vi.fn(); + + this.dispatch = vi.fn((tr: any) => { + let docChanged = false; + let selectionSet = false; + if (tr.changes) { + this.state.doc = { + ...this.state.doc, + text: tr.changes.insert || "", + toString: () => tr.changes.insert || "", + length: (tr.changes.insert || "").length, + lines: (tr.changes.insert || "").split('\n').length, + line: (n: number) => { + const lines = (tr.changes.insert || "").split('\n'); + return { text: lines[n-1] || "", from: 0, to: 0, number: n }; + }, + lineAt: (_pos: number) => ({ number: 1, from: 0, to: 0 }), + }; + docChanged = true; + } + if (tr.selection) { + selectionSet = true; + this.state.selection = { main: { ...tr.selection, empty: tr.selection.anchor === tr.selection.head } }; + } + if (tr.effects) { + // effects + } + + // trigger update listener if configured + const updateListener = this.config.state.config?.extensions?.flat(10)?.find((e: any) => e?.type === "updateListener"); + if (updateListener && (docChanged || selectionSet)) { + updateListener.fn({ + view: this, + state: this.state, + docChanged, + selectionSet, + geometryChanged: false, + viewportChanged: false + }); + } + }); + } + + lineBlockAt() { return { top: 10 }; } + get documentTop() { return 0; } + }, + drawSelection: vi.fn(() => ({ type: "drawSelection" })), + ViewPlugin: { fromClass: vi.fn(() => ({ type: "viewPlugin" })) }, + keymap: { of: vi.fn(() => ({ type: "keymap" })) }, + }; +}); + +vi.mock("@codemirror/state", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + EditorState: { + create: (config: any) => ({ + config, // Keep original config for tests + doc: { + text: config.doc || "", + toString: () => config.doc || "", + length: (config.doc || "").length, + lines: (config.doc || "").split('\n').length, + line: (n: number) => { + const lines = (config.doc || "").split('\n'); + return { text: lines[n-1] || "", from: 0, to: 0, number: n }; + }, + lineAt: (_pos: number) => ({ number: 1, from: 0, to: 0 }), + iterLines: function*() { yield config.doc; } + }, + selection: { main: { empty: true, from: 0, to: 0 } }, + field: vi.fn(() => []), + }) + }, + StateEffect: { define: vi.fn(() => ({ of: vi.fn() })) }, + StateField: { define: vi.fn(() => ({ type: "stateField" })) }, + }; +}); + +vi.mock("@codemirror/commands", () => ({ + defaultKeymap: [], history: vi.fn(() => ({ type: "history" })), historyKeymap: [] +})); + +vi.mock("@codemirror/autocomplete", () => ({ + autocompletion: vi.fn(() => ({ type: "autocompletion" })), + acceptCompletion: vi.fn(), +})); + +import { SumEditor } from "./editor"; +import { SumEngine } from "../engine"; + +describe("SumEditor", () => { + beforeEach(() => { + vi.stubGlobal("document", { + createDocumentFragment: vi.fn(() => ({ + appendChild: vi.fn(), + })), + createElement: vi.fn(() => ({ + style: {}, + dataset: {}, // IMPORTANT: dataset needs to be initialized! + classList: { add: vi.fn(), contains: vi.fn() }, + })), + }); + vi.stubGlobal("requestAnimationFrame", (cb: Function) => cb()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("should initialize with text and evaluate", () => { + const parent = {} as HTMLElement; + const resultsEl = { + addEventListener: vi.fn(), + getBoundingClientRect: vi.fn(() => ({ top: 0 })), + clientHeight: 500, + replaceChildren: vi.fn() + } as unknown as HTMLElement; + + const engine = new SumEngine(); + const cb = { + onChange: vi.fn(), + onCopy: vi.fn(), + onResults: vi.fn(), + onSelection: vi.fn(), + }; + + const evaluateDoc = vi.fn((_text: string) => [{ text: "2", value: null, kind: "normal" as const, tokens: [], commentStart: null }]); + + const editor = new SumEditor( + parent, + resultsEl, + engine, + cb, + evaluateDoc, + "1 + 1" + ); + + expect(editor).toBeDefined(); + expect(editor.getText()).toBe("1 + 1"); + expect(evaluateDoc).toHaveBeenCalledWith("1 + 1"); + expect(cb.onResults).toHaveBeenCalledWith([expect.objectContaining({ text: "2" })]); + }); + + it("setText updates text and evaluates", () => { + const parent = {} as HTMLElement; + const resultsEl = { + addEventListener: vi.fn(), + getBoundingClientRect: vi.fn(() => ({ top: 0 })), + clientHeight: 500, + replaceChildren: vi.fn() + } as unknown as HTMLElement; + + const engine = new SumEngine(); + const cb = { + onChange: vi.fn(), + onCopy: vi.fn(), + onResults: vi.fn(), + onSelection: vi.fn(), + }; + + const evaluateDoc = vi.fn((_text: string) => [{ text: "2", value: null, kind: "normal" as const, tokens: [], commentStart: null }]); + + const editor = new SumEditor( + parent, + resultsEl, + engine, + cb, + evaluateDoc, + "1 + 1" + ); + + editor.setText("2 + 2"); + expect(editor.getText()).toBe("2 + 2"); + expect(evaluateDoc).toHaveBeenCalledWith("2 + 2"); + expect(cb.onChange).toHaveBeenCalledWith("2 + 2"); + }); + + it("getSheetWithResults returns text and results combined", () => { + const parent = {} as HTMLElement; + const resultsEl = { + addEventListener: vi.fn(), + getBoundingClientRect: vi.fn(() => ({ top: 0 })), + clientHeight: 500, + replaceChildren: vi.fn() + } as unknown as HTMLElement; + + const engine = new SumEngine(); + const cb = { + onChange: vi.fn(), + onCopy: vi.fn(), + onResults: vi.fn(), + onSelection: vi.fn(), + }; + + const evaluateDoc = vi.fn((_text: string) => [{ text: "2", value: null, kind: "normal" as const, tokens: [], commentStart: null }]); + + const editor = new SumEditor( + parent, + resultsEl, + engine, + cb, + evaluateDoc, + "1 + 1" + ); + + const combined = editor.getSheetWithResults(); + expect(combined).toBe("1 + 1 = 2"); + }); + + it("goToLine places cursor correctly", () => { + const parent = {} as HTMLElement; + const resultsEl = { + addEventListener: vi.fn(), + getBoundingClientRect: vi.fn(() => ({ top: 0 })), + clientHeight: 500, + replaceChildren: vi.fn() + } as unknown as HTMLElement; + + const engine = new SumEngine(); + const cb = { + onChange: vi.fn(), + onCopy: vi.fn(), + onResults: vi.fn(), + onSelection: vi.fn(), + }; + + const evaluateDoc = vi.fn((_text: string) => [{ text: "2", value: null, kind: "normal" as const, tokens: [], commentStart: null }]); + + const editor = new SumEditor( + parent, + resultsEl, + engine, + cb, + evaluateDoc, + "1 + 1\n2 + 2" + ); + + editor.goToLine(2); + expect(editor.view.focus).toHaveBeenCalled(); + expect(editor.view.dispatch).toHaveBeenCalledWith(expect.objectContaining({ + scrollIntoView: true + })); + }); +});