diff --git a/rplugin/node/magenta/src/tea/render.spec.ts b/rplugin/node/magenta/src/tea/render.spec.ts new file mode 100644 index 0000000..b4bbb6a --- /dev/null +++ b/rplugin/node/magenta/src/tea/render.spec.ts @@ -0,0 +1,357 @@ +import type { NeovimClient, Buffer } from "neovim"; +import { extractMountTree, NeovimTestHelper } from "../../test/preamble.js"; +import { d, mountView } from "./view.js"; +import * as assert from "assert"; +import { test } from "node:test"; + +await test.describe("tea/render.spec.ts", async () => { + let helper: NeovimTestHelper; + let nvim: NeovimClient; + let buffer: Buffer; + + test.before(() => { + helper = new NeovimTestHelper(); + }); + + test.beforeEach(async () => { + nvim = await helper.startNvim(); + buffer = (await nvim.createBuffer(false, true)) as Buffer; + await buffer.setOption("modifiable", false); + }); + + test.afterEach(() => { + helper.stopNvim(); + }); + + await test("rendering empty string", async () => { + const view = () => d`1${""}2`; + const mountedView = await mountView({ + view, + props: {}, + mount: { + nvim, + buffer, + startPos: { row: 0, col: 0 }, + endPos: { row: 0, col: 0 }, + }, + }); + + const lines = await buffer.getLines({ + start: 0, + end: 1, + strictIndexing: false, + }); + + assert.equal(lines[0], "12"); + + assert.deepStrictEqual( + await extractMountTree(mountedView._getMountedNode()), + { + type: "node", + startPos: { + row: 0, + col: 0, + }, + endPos: { + row: 0, + col: 2, + }, + children: [ + { + content: "1", + startPos: { + col: 0, + row: 0, + }, + endPos: { + col: 1, + row: 0, + }, + type: "string", + }, + { + content: "", + startPos: { + col: 1, + row: 0, + }, + endPos: { + col: 1, + row: 0, + }, + type: "string", + }, + { + content: "2", + startPos: { + col: 1, + row: 0, + }, + endPos: { + col: 2, + row: 0, + }, + type: "string", + }, + ], + }, + ); + }); + + await test("rendering multi-line interpolation", async () => { + const multiLineValue = `first line +second line +third line`; + const view = () => d`before${multiLineValue}after`; + const mountedView = await mountView({ + view, + props: {}, + mount: { + nvim, + buffer, + startPos: { row: 0, col: 0 }, + endPos: { row: 0, col: 0 }, + }, + }); + + const lines = await buffer.getLines({ + start: 0, + end: 4, + strictIndexing: false, + }); + + assert.deepStrictEqual(lines, [ + "beforefirst line", + "second line", + "third lineafter", + ]); + + assert.deepStrictEqual( + await extractMountTree(mountedView._getMountedNode()), + { + type: "node", + startPos: { + row: 0, + col: 0, + }, + endPos: { + row: 2, + col: 15, + }, + children: [ + { + content: "before", + startPos: { + col: 0, + row: 0, + }, + endPos: { + col: 6, + row: 0, + }, + type: "string", + }, + { + content: "first line\nsecond line\nthird line", + startPos: { + row: 0, + col: 6, + }, + endPos: { + row: 2, + col: 10, + }, + type: "string", + }, + { + content: "after", + startPos: { + row: 2, + col: 10, + }, + endPos: { + row: 2, + col: 15, + }, + type: "string", + }, + ], + }, + ); + }); + + await test("rendering multi-line template with interpolation", async () => { + const name = "world"; + const view = () => d` + Hello + ${name} + Goodbye + `; + const mountedView = await mountView({ + view, + props: {}, + mount: { + nvim, + buffer, + startPos: { row: 0, col: 0 }, + endPos: { row: 0, col: 0 }, + }, + }); + + const lines = await buffer.getLines({ + start: 0, + end: 5, + strictIndexing: false, + }); + + assert.deepStrictEqual(lines, [ + "", + " Hello", + " world", + " Goodbye", + " ", + ]); + + assert.deepStrictEqual( + await extractMountTree(mountedView._getMountedNode()), + { + type: "node", + startPos: { + row: 0, + col: 0, + }, + endPos: { + row: 4, + col: 4, + }, + children: [ + { + content: "\n Hello\n ", + startPos: { + col: 0, + row: 0, + }, + endPos: { + col: 8, + row: 2, + }, + type: "string", + }, + { + content: "world", + startPos: { + col: 8, + row: 2, + }, + endPos: { + col: 13, + row: 2, + }, + type: "string", + }, + { + content: "\n Goodbye\n ", + startPos: { + col: 13, + row: 2, + }, + endPos: { + col: 4, + row: 4, + }, + type: "string", + }, + ], + }, + ); + }); + + await test("rendering nested interpolation", async () => { + const inner = d`(inner)`; + const view = () => d`outer${inner}end`; + const mountedView = await mountView({ + view, + props: {}, + mount: { + nvim, + buffer, + startPos: { row: 0, col: 0 }, + endPos: { row: 0, col: 0 }, + }, + }); + + const lines = await buffer.getLines({ + start: 0, + end: 1, + strictIndexing: false, + }); + + assert.equal(lines[0], "outer(inner)end"); + + assert.deepStrictEqual( + await extractMountTree(mountedView._getMountedNode()), + { + type: "node", + startPos: { + row: 0, + col: 0, + }, + endPos: { + row: 0, + col: 15, + }, + children: [ + { + content: "outer", + startPos: { + row: 0, + col: 0, + }, + endPos: { + row: 0, + col: 5, + }, + type: "string", + }, + { + type: "node", + startPos: { + row: 0, + col: 5, + }, + endPos: { + row: 0, + col: 12, + }, + children: [ + { + content: "(inner)", + startPos: { + row: 0, + col: 5, + }, + endPos: { + row: 0, + col: 12, + }, + type: "string", + }, + ], + }, + { + content: "end", + startPos: { + col: 12, + row: 0, + }, + endPos: { + col: 15, + row: 0, + }, + type: "string", + }, + ], + }, + ); + }); +}); diff --git a/rplugin/node/magenta/src/tea/update.spec.ts b/rplugin/node/magenta/src/tea/update.spec.ts new file mode 100644 index 0000000..68dec4f --- /dev/null +++ b/rplugin/node/magenta/src/tea/update.spec.ts @@ -0,0 +1,209 @@ +import type { NeovimClient, Buffer } from "neovim"; +import { NeovimTestHelper } from "../../test/preamble.js"; +import { d, mountView } from "./view.js"; +import * as assert from "assert"; +import { test } from "node:test"; + +await test.describe("tea/update.spec.ts", async () => { + let helper: NeovimTestHelper; + let nvim: NeovimClient; + let buffer: Buffer; + + test.before(() => { + helper = new NeovimTestHelper(); + }); + + test.beforeEach(async () => { + nvim = await helper.startNvim(); + buffer = (await nvim.createBuffer(false, true)) as Buffer; + await buffer.setOption("modifiable", false); + }); + + test.afterEach(() => { + helper.stopNvim(); + }); + + await test("updates to and from empty string", async () => { + const view = (props: { prop: string }) => d`1${props.prop}3`; + const mountedView = await mountView({ + view, + props: { prop: "" }, + mount: { + nvim, + buffer, + startPos: { row: 0, col: 0 }, + endPos: { row: 0, col: 0 }, + }, + }); + + { + const lines = await buffer.getLines({ + start: 0, + end: 1, + strictIndexing: false, + }); + + assert.equal(lines[0], "13"); + } + + await mountedView.render({ prop: "2" }); + + { + const lines = await buffer.getLines({ + start: 0, + end: 1, + strictIndexing: false, + }); + + assert.equal(lines[0], "123"); + } + + await mountedView.render({ prop: "" }); + { + const lines = await buffer.getLines({ + start: 0, + end: 1, + strictIndexing: false, + }); + + assert.equal(lines[0], "13"); + } + + await mountedView.render({ prop: "\n" }); + { + const lines = await buffer.getLines({ + start: 0, + end: 2, + strictIndexing: false, + }); + + assert.deepStrictEqual(lines, ["1", "3"]); + } + + await mountedView.render({ prop: "" }); + { + const lines = await buffer.getLines({ + start: 0, + end: 2, + strictIndexing: false, + }); + + assert.deepStrictEqual(lines, ["13"]); + } + }); + + await test("updates to multiple items in the same line", async () => { + const view = (props: { prop1: string; prop2: string }) => + d`${props.prop1}${props.prop2}`; + const mountedView = await mountView({ + view, + props: { prop1: "", prop2: "" }, + mount: { + nvim, + buffer, + startPos: { row: 0, col: 0 }, + endPos: { row: 0, col: 0 }, + }, + }); + + { + const lines = await buffer.getLines({ + start: 0, + end: 1, + strictIndexing: false, + }); + + assert.equal( + lines[0], + "", + "should handle multiple empty interpolations in a row", + ); + } + + await mountedView.render({ prop1: "1", prop2: "2" }); + { + const lines = await buffer.getLines({ + start: 0, + end: 1, + strictIndexing: false, + }); + + assert.equal( + lines[0], + "12", + "should handle going from empty to segments on the same line", + ); + } + + await mountedView.render({ prop1: "11", prop2: "22" }); + { + const lines = await buffer.getLines({ + start: 0, + end: 1, + strictIndexing: false, + }); + + assert.equal( + lines[0], + "1122", + "should handle growing multiple segments on the same line", + ); + } + + await mountedView.render({ prop1: "1", prop2: "2" }); + { + const lines = await buffer.getLines({ + start: 0, + end: 1, + strictIndexing: false, + }); + + assert.equal( + lines[0], + "12", + "should handle shrinking multiple segments on the same line", + ); + } + + await mountedView.render({ prop1: "1", prop2: "2" }); + { + const lines = await buffer.getLines({ + start: 0, + end: 1, + strictIndexing: false, + }); + + assert.equal( + lines[0], + "12", + "should handle shrinking multiple segments on the same line", + ); + } + + await mountedView.render({ prop1: "1\n111", prop2: "22" }); + { + const lines = await buffer.getLines({ + start: 0, + end: 2, + strictIndexing: false, + }); + + assert.deepStrictEqual(lines, ["1", "11122"]); + } + + await mountedView.render({ prop1: "\n1\n1\n", prop2: "\n2\n2" }); + { + const lines = await buffer.getLines({ + start: 0, + end: 6, + strictIndexing: false, + }); + + assert.deepStrictEqual( + lines, + ["", "1", "1", "", "2", "2"], + "should handle updating a prop on a moving line", + ); + } + }); +}); diff --git a/rplugin/node/magenta/src/tea/view.spec.ts b/rplugin/node/magenta/src/tea/view.spec.ts index 2a8d574..95089b8 100644 --- a/rplugin/node/magenta/src/tea/view.spec.ts +++ b/rplugin/node/magenta/src/tea/view.spec.ts @@ -22,7 +22,6 @@ await test.describe("Neovim Plugin Tests", async () => { }); await test("basic rendering & update", async () => { - console.log("in test"); const buffer = (await nvim.createBuffer(false, true)) as Buffer; await buffer.setLines([""], { start: 0, end: 0, strictIndexing: false }); const namespace = await nvim.createNamespace("test"); diff --git a/rplugin/node/magenta/src/tea/view.ts b/rplugin/node/magenta/src/tea/view.ts index 9a91df9..0832f11 100644 --- a/rplugin/node/magenta/src/tea/view.ts +++ b/rplugin/node/magenta/src/tea/view.ts @@ -10,7 +10,6 @@ export type Position = { export interface MountPoint { nvim: Neovim; buffer: Buffer; - namespace: number; startPos: Position; endPos: Position; } diff --git a/rplugin/node/magenta/test/preamble.ts b/rplugin/node/magenta/test/preamble.ts index 12ef4d2..7dd9015 100644 --- a/rplugin/node/magenta/test/preamble.ts +++ b/rplugin/node/magenta/test/preamble.ts @@ -1,5 +1,7 @@ import { attach, NeovimClient } from "neovim"; import { spawn } from "child_process"; +import { MountedVDOM } from "../src/tea/view.js"; +import { assertUnreachable } from "../src/utils/assertUnreachable.js"; process.env.NVIM_LOG_FILE = "/tmp/nvim.log"; // Helpful for debugging process.env.NVIM_NODE_LOG_FILE = "/tmp/nvim-node.log"; // Helpful for debugging @@ -48,3 +50,19 @@ export class NeovimTestHelper { } } } + +export function extractMountTree(mounted: MountedVDOM): unknown { + switch (mounted.type) { + case "string": + return mounted; + case "node": + return { + type: "node", + children: mounted.children.map(extractMountTree), + startPos: mounted.startPos, + endPos: mounted.endPos, + }; + default: + assertUnreachable(mounted); + } +}