// The math lane's tests, which stop at the edge of the DOM. // // vite.config.ts runs vitest in the node environment, so there is no document for a view to mount // in and the node view in math.ts cannot be built from here. What a formula looks like on screen, // and the field that opens on it when it is selected, belong to the Playwright suite. What belongs // here is the half that touches the document, because that is the half that can cost somebody a // file: the two ways a formula gets made, and the LaTeX already in the document surviving both of // them character for character. // // The last group tests KaTeX rather than this app. It is here because the node view rests on two // promises the library makes and could quietly stop keeping on an upgrade: that it does not throw // under these options, and that what it hands back when it cannot parse something still contains // the source it was given. import { describe, expect, it } from "vitest"; import { Editor } from "@tiptap/core"; import type { JSONContent } from "@tiptap/core"; import { EditorState, NodeSelection } from "@tiptap/pm/state"; import { CellSelection, TableMap } from "@tiptap/pm/tables"; import katex from "katex"; import { parseMarkdown, serializeMarkdown } from "../../markdown"; import { createEditorExtensions } from "../extensions"; import { insertMath } from "./math"; const extensions = () => createEditorExtensions({ documentPath: () => "/notes/a.md", onError: () => {} }); function editorWith(content: JSONContent): Editor { return new Editor({ element: null, injectCSS: false, extensions: extensions(), content }); } /** * The same editor with the extensions' ProseMirror plugins actually installed, which TipTap only * does when it mounts a view and there is no DOM here to mount into. src/editor/Editor.tsx swaps a * state built this way in for every document it opens, so this is what the app runs minus the * screen. Only the table tests need it, because a cell selection is prosemirror-tables' own. */ function editorWithPlugins(content: JSONContent): Editor { const editor = editorWith(content); editor.view.updateState( EditorState.create({ doc: editor.state.doc, plugins: editor.extensionManager.plugins }), ); return editor; } /** A file, opened, with the bytes it would be written back as. */ function open(source: string) { const parsed = parseMarkdown(source, "/notes/a.md"); const editor = editorWithPlugins(parsed.doc.toJSON()); return { editor, written: () => serializeMarkdown(parsed, editor.state.doc) }; } /** The document position just inside a cell, for a table that is the document's first block. */ function inCell(editor: Editor, row: number, column: number): number { const table = editor.state.doc.firstChild!; return 1 + TableMap.get(table).positionAt(row, column, table) + 1; } /** * Enter, through the keymap the extension really installs rather than through a function this file * reached into. A headless editor has no view to take a key event, so the plugins' own handlers are * called in the order ProseMirror would call them, with the proxy view TipTap answers with and the * two things prosemirror-keymap reads off an event: the key name, and the four modifier flags. */ function pressEnter(editor: Editor): void { const event = { key: "Enter", keyCode: 13, altKey: false, ctrlKey: false, metaKey: false, shiftKey: false, } as unknown as KeyboardEvent; for (const plugin of editor.extensionManager.plugins) { // Called through the plugin, which is the "this" ProseMirror types the prop as wanting. if (plugin.props.handleKeyDown?.call(plugin, editor.view, event)) return; } } const CODE = { type: "doc", content: [ { type: "codeBlock", attrs: { language: "ts", meta: null }, content: [{ type: "text", text: "const x = 1;" }], }, ], }; const RAW = { type: "doc", content: [ { type: "raw", attrs: { source: "
" }, content: [{ type: "text", text: "
" }], }, ], }; describe("insertMath", () => { it("puts an empty display equation in and selects it", () => { const editor = editorWith({ type: "doc", content: [{ type: "paragraph" }] }); expect(insertMath(editor, true)).toBe(true); const block = editor.state.doc.firstChild; expect(block?.type.name).toBe("mathBlock"); // Not empty. This assertion used to read "" and that was the bug: an empty formula is a box on // screen the file has no way to spell, and inline it went out as $$$$ and came back as text, so // the first autosave took it away without telling anybody. A new formula is made with the // placeholder in it, which is a formula that survives being written. expect(block?.attrs.latex).toBe("\\square"); // Selected is what opens the field on it, so it is the half of the command that matters. expect(editor.state.selection instanceof NodeSelection).toBe(true); expect((editor.state.selection as NodeSelection).node.type.name).toBe("mathBlock"); editor.destroy(); }); // The caret mid paragraph is the case the first version of this got wrong. A display formula // dropped there splits the paragraph and lands between the halves, so the position the insert was // asked for is not the position the formula ends up at, and selecting the wrong one means a // formula on screen with no way into its field. The end of a paragraph is the one place the two // answers agree, which is why the test above did not catch it. it("selects the display equation even when the caret was in the middle of a paragraph", () => { const editor = editorWith({ type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "before after" }] }], }); editor.commands.setTextSelection(8); expect(insertMath(editor, true)).toBe(true); expect(editor.state.doc.child(0).textContent).toBe("before "); expect(editor.state.doc.child(1).type.name).toBe("mathBlock"); expect(editor.state.doc.child(2).textContent).toBe("after"); expect(editor.state.selection instanceof NodeSelection).toBe(true); expect((editor.state.selection as NodeSelection).node.type.name).toBe("mathBlock"); editor.destroy(); }); it("puts an inline formula in without disturbing the text around it", () => { const editor = editorWith({ type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "ab" }] }], }); editor.commands.setTextSelection(2); expect(insertMath(editor, false)).toBe(true); const paragraph = editor.state.doc.firstChild; expect(paragraph?.type.name).toBe("paragraph"); expect([...Array(paragraph?.childCount ?? 0)].map((_, i) => paragraph?.child(i).type.name)).toEqual([ "text", "mathInline", "text", ]); expect(paragraph?.textContent).toBe("ab"); editor.destroy(); }); it("refuses inside a code block, and leaves the fence exactly as it was", () => { const editor = editorWith(CODE); const before = editor.state.doc.toJSON(); expect(insertMath(editor, false)).toBe(false); expect(insertMath(editor, true)).toBe(false); expect(editor.state.doc.toJSON()).toEqual(before); editor.destroy(); }); it("refuses inside a raw block, which is somebody's bytes and not a place for a formula", () => { const editor = editorWith(RAW); const before = editor.state.doc.toJSON(); expect(insertMath(editor, false)).toBe(false); expect(insertMath(editor, true)).toBe(false); expect(editor.state.doc.toJSON()).toEqual(before); editor.destroy(); }); // The three below are one bug each, reproduced from the bytes they cost. All three were live in a // build whose table, rule and diagram inserts were already guarded: this command had a private // copy of the guard that had never been given the isolating rule, and a private guard is a guard // that is only as good as the last person who remembered it existed. It is gone, and this command // now asks the one in src/editor/fits.ts that every other insert asks. it("refuses with the caret in a table cell, and leaves the file byte identical", () => { const source = "| h1 | h2 |\n| - | - |\n| a | b |\n"; const { editor, written } = open(source); editor.commands.setTextSelection(inCell(editor, 1, 0)); // What this used to do: split the table around the formula, leave the body row empty and the // moved cells in a second table with no header, and hand the autosave // "| h1 | h2 |\n| - | - |\n| | |\n\n$$\n$$\n\n| a | b |\n| - | - |\n" half a second later. expect(insertMath(editor, true)).toBe(false); expect(written()).toBe(source); editor.destroy(); }); it("refuses over a dragged cell selection, and every cell keeps its text", () => { const source = "| h1 | h2 |\n| - | - |\n| a | b |\n| c | d |\n"; const { editor, written } = open(source); const map = TableMap.get(editor.state.doc.firstChild!); const table = editor.state.doc.firstChild!; editor.view.dispatch( editor.state.tr.setSelection( CellSelection.create(editor.state.doc, 1 + map.positionAt(0, 0, table), 1 + map.positionAt(2, 1, table)), ), ); // An inline formula fits in a cell perfectly well, which is why the position rule alone let // this through: over a rectangle of cells the insert does not go in a cell, it replaces the // content of all six of them at once. Six cells of somebody's text for one empty formula. expect(insertMath(editor, false)).toBe(false); expect(insertMath(editor, true)).toBe(false); expect(written()).toBe(source); editor.destroy(); }); it("makes a formula the save can keep, which an empty one is not", () => { const { editor, written } = open("hello\n"); editor.commands.setTextSelection(6); expect(insertMath(editor, false)).toBe(true); const file = written(); expect(file).toBe("hello$$\\square$$\n"); // The whole point of the placeholder, asserted the only way that means anything: the file goes // back through the parser and there is still a formula in it. An empty one wrote "hello$$$$", // which comes back as four dollar signs of literal text, and the box the user was looking at // was gone with nobody told. const reopened = parseMarkdown(file, "/notes/a.md"); const found: string[] = []; reopened.doc.descendants((node) => { if (node.type.name === "mathInline") found.push(node.attrs.latex as string); }); expect(found).toEqual(["\\square"]); editor.destroy(); }); }); describe("the fence rule", () => { it("turns a paragraph holding just $$ into a math block, selected", () => { const editor = editorWith({ type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "$$" }] }], }); editor.commands.setTextSelection(3); pressEnter(editor); expect(editor.state.doc.childCount).toBe(1); expect(editor.state.doc.firstChild?.type.name).toBe("mathBlock"); // The placeholder here too, for the reason on the insertMath test above: a formula made by // typing a fence has the same claim to still being there after a save as one made by a button. expect(editor.state.doc.firstChild?.attrs.latex).toBe("\\square"); expect(editor.state.selection instanceof NodeSelection).toBe(true); editor.destroy(); }); it("leaves a paragraph that says anything else alone", () => { for (const text of ["$$x", "a $$", "$", "$5 and $10"]) { const editor = editorWith({ type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text }] }], }); editor.commands.setTextSelection(text.length + 1); pressEnter(editor); // Enter still splits the paragraph, which is the base keymap's business and not this lane's. // What is asserted is only that no formula was made out of somebody's prose. let found = false; editor.state.doc.descendants((node) => { if (node.type.name === "mathBlock" || node.type.name === "mathInline") found = true; }); expect([text, found]).toEqual([text, false]); editor.destroy(); } }); }); describe("the LaTeX already in the document", () => { const source = [ "Before.", "", "$$", "\\frac{a}{b} = \\sum_{i=0}^{n} x_i", "$$", "", "After $$x^2$$ here.", "", ].join("\n"); it("comes back byte for byte after a formula is inserted somewhere else", () => { const parsed = parseMarkdown(source, "/notes/a.md"); const editor = editorWith(parsed.doc.toJSON()); expect(serializeMarkdown(parsed, editor.state.doc)).toBe(source); // Into the first paragraph, which is the one place in the file this is allowed to change. editor.commands.setTextSelection(4); expect(insertMath(editor, false)).toBe(true); const written = serializeMarkdown(parsed, editor.state.doc); expect(written).toContain("$$\n\\frac{a}{b} = \\sum_{i=0}^{n} x_i\n$$"); expect(written).toContain("After $$x^2$$ here."); editor.destroy(); }); }); describe("KaTeX under the options this lane renders with", () => { // The same object math.ts builds its render call from. Repeated rather than exported, because // what is being pinned here is the library's behaviour under them and not their spelling. const options = { throwOnError: false, strict: false, trust: false, errorColor: "var(--danger)", } as const; it("draws a formula, with the source it was given still in the markup", () => { const markup = katex.renderToString("\\frac{a}{b}", options); expect(markup).toContain("katex"); expect(markup).toContain("\\frac{a}{b}"); }); it("does not throw on LaTeX it cannot parse, and shows the source in the error colour", () => { // Two shapes, both of them the source and the colour. LaTeX KaTeX cannot get through the // parser at all comes back as one .katex-error span holding the whole expression; a command it // parses and has never heard of is drawn as the text of the command, in the same colour. for (const broken of ["\\frac{", "\\notacommand", "^", "\\begin{matrix}", "\\sqrt{}}{"]) { const markup = katex.renderToString(broken, options); expect([broken, markup.includes(broken)]).toEqual([broken, true]); expect([broken, markup.includes("var(--danger)")]).toEqual([broken, true]); } }); it("writes the error colour through as the custom property it was handed", () => { // KaTeX puts the colour in an attribute on the element it draws, where a stylesheet cannot // reach it, so the token has to survive the trip out through the markup exactly as written. expect(katex.renderToString("\\frac{", options)).toContain("var(--danger)"); }); });