From 3229f4a30db751bbd9efb32591b4d24d9dbfdc9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ernesto=20Garc=C3=ADa?= Date: Mon, 26 Oct 2026 12:57:28 -0300 Subject: [PATCH] Test undo, redo, and both cycle directions for layer commands Each command that changes layers now has a test that applies it, undoes it, and redoes it. The test checks the saved Markdown after each step. The ghost toggle test now also redoes. cycleAlt, cycleVersion, and cycleAtCaret now step backward and forward through three variants. A fixed direction, +1 or -1, now fails a test. The caret test puts a word inside a paragraph with versions, so the word must win. createAlt and cleanupSingleAlts have no undo test. A one-variant alternative saves as plain text. Whether their undo steps should exist is still open: item 3 of the Next section in docs/codemirror-plan.md. --- src/editor/layers.test.ts | 162 +++++++++++++++++++++++++++++++++++++- 1 file changed, 161 insertions(+), 1 deletion(-) diff --git a/src/editor/layers.test.ts b/src/editor/layers.test.ts index 8f7567f..fff507e 100644 --- a/src/editor/layers.test.ts +++ b/src/editor/layers.test.ts @@ -2,19 +2,28 @@ import { history, redo, undo } from "@codemirror/commands"; import { EditorSelection, EditorState, TransactionSpec } from "@codemirror/state"; import { describe, expect, it } from "vitest"; import { + addAiAlternatives, addAltVariant, countWords, createAlt, cycleAlt, cycleAtCaret, + cycleVersion, deleteGhosts, + dropAlt, forkVersion, jumpToItem, removeAltVariant, + removeVersionVariant, + reviveAll, + reviveAt, + setAltActive, settleAll, + settleVersion, setVersionActive, startsWithVowelSound, ghostBlocked, + ghostRange, removeSpan, toggleGhost, versionedItems, @@ -73,6 +82,23 @@ describe("alternatives", () => { expect(save(state)).toBe("the {{tension|>strugglse}} here\n"); }); + it("cycles backward and forward through three variants, wrapping", () => { + let state = stateFor("the {{tension|>pressure|strain}} here\n"); + const id = altIdAt(state, "pressure"); + state = apply(state, cycleAlt(state, id, -1)); + expect(state.doc.toString()).toBe("the tension here"); + expect(save(state)).toBe("the {{tension|pressure|strain}} here\n"); + state = apply(state, cycleAlt(state, id, -1)); + expect(state.doc.toString()).toBe("the strain here"); + expect(save(state)).toBe("the {{tension|pressure|>strain}} here\n"); + state = apply(state, cycleAlt(state, id, -1)); + expect(state.doc.toString()).toBe("the pressure here"); + expect(save(state)).toBe("the {{tension|>pressure|strain}} here\n"); + state = apply(state, cycleAlt(state, id, 1)); + expect(state.doc.toString()).toBe("the strain here"); + expect(save(state)).toBe("the {{tension|pressure|>strain}} here\n"); + }); + it("takes letters typed at its end, but not a space or text at its start", () => { let state = stateFor("an {{elephant|owl}} here\n"); const alt = () => layersOf(state).alts[0]; @@ -208,12 +234,112 @@ describe("undo", () => { expect(remove("Really", "Really")).toBe(""); }); - it("undoes a ghost toggle", () => { + it("undoes and redoes a ghost toggle", () => { let state = select(stateFor("one two three\n"), 4, 7); state = run(state, toggleGhost); expect(save(state)).toBe("one {-two-} three\n"); state = run(state, undo); expect(save(state)).toBe("one two three\n"); + state = run(state, redo); + expect(save(state)).toBe("one {-two-} three\n"); + }); + + // cycleAlt and setVersionActive have their own undo tests. createAlt and cleanupSingleAlts + // are not here: they add or drop a one-variant alternative, which saves as plain text. + it.each<{ name: string; md: string; cmd: (state: EditorState) => TransactionSpec | null; after: string }>([ + { + name: "setAltActive", + md: "the {{tension|pressure|strain}} here\n", + cmd: (s) => setAltActive(s, altIdAt(s, "tension"), 2), + after: "the {{tension|pressure|>strain}} here\n", + }, + { + name: "addAltVariant", + md: "the {{tension|pressure}} here\n", + cmd: (s) => addAltVariant(s, altIdAt(s, "tension"), "strain"), + after: "the {{tension|pressure|>strain}} here\n", + }, + { + name: "addAiAlternatives", + md: "the {{tension|pressure}} here\n", + cmd: (s) => addAiAlternatives(s, altIdAt(s, "tension"), ["strain"]), + after: "the {{tension|pressure|~strain}} here\n", + }, + { + name: "removeAltVariant", + md: "the {{tension|>pressure|strain}} here\n", + cmd: (s) => removeAltVariant(s, altIdAt(s, "pressure"), 1), + after: "the {{tension|strain}} here\n", + }, + { + name: "dropAlt", + md: "the {{tension|>pressure}} here\n", + cmd: (s) => dropAlt(s, altIdAt(s, "pressure")), + after: "the pressure here\n", + }, + { + name: "cycleVersion", + md: ":::alt*\nOne.\n:::or\nTwo.\n:::\n", + cmd: (s) => cycleVersion(s, 0, 1), + after: ":::alt\nOne.\n:::or*\nTwo.\n:::\n", + }, + { + name: "forkVersion", + md: "Original paragraph.\n", + cmd: (s) => forkVersion(s, 0), + after: ":::alt\nOriginal paragraph.\n:::or*\nOriginal paragraph.\n:::\n", + }, + { + name: "removeVersionVariant", + md: ":::alt\nOne.\n:::or*\nTwo.\n:::or\nThree.\n:::\n", + cmd: (s) => removeVersionVariant(s, 0, 1), + after: ":::alt*\nOne.\n:::or\nThree.\n:::\n", + }, + { + name: "settleVersion", + md: ":::alt\nOne.\n:::or*\nTwo.\n:::\n", + cmd: (s) => settleVersion(s, 0), + after: "Two.\n", + }, + { + name: "settleAll", + md: "A {{one|>uno}} word. {-gone-}\n\n:::alt\nFirst.\n:::or*\nSecond.\n:::\n", + cmd: (s) => settleAll(s), + after: "A uno word. {-gone-}\n\nSecond.\n", + }, + { + name: "ghostRange", + md: "one two three\n", + cmd: (s) => ghostRange(s, at(s, "two"), at(s, "two") + 3), + after: "one {-two-} three\n", + }, + { + name: "reviveAt", + md: "one {-two-} three\n", + cmd: (s) => reviveAt(s, at(s, "two")), + after: "one two three\n", + }, + { + name: "reviveAll", + md: "one {-two-} three {-four-}\n", + cmd: (s) => reviveAll(s), + after: "one two three four\n", + }, + { + name: "deleteGhosts", + md: "Keep this. {-Maybe cut.-} Keep that.\n", + cmd: (s) => deleteGhosts(s), + after: "Keep this. Keep that.\n", + }, + ])("undoes and redoes $name", ({ md, cmd, after }) => { + let state = stateFor(md); + expect(save(state)).toBe(md); + state = apply(state, cmd(state)); + expect(save(state)).toBe(after); + state = run(state, undo); + expect(save(state)).toBe(md); + state = run(state, redo); + expect(save(state)).toBe(after); }); it("undoes typing far from any layer without touching the layers", () => { @@ -257,6 +383,22 @@ describe("paragraph versions", () => { expect(layersOf(state).versions).toHaveLength(1); expect(save(state)).toBe("Above.\n\n:::alt*\nA.\n:::or\nB.\n:::\n"); }); + + it("cycles backward and forward through three versions, wrapping", () => { + let state = stateFor(":::alt\nOne.\n:::or*\nTwo.\n:::or\nThree.\n:::\n"); + state = apply(state, cycleVersion(state, 0, -1)); + expect(state.doc.toString()).toBe("One."); + expect(save(state)).toBe(":::alt*\nOne.\n:::or\nTwo.\n:::or\nThree.\n:::\n"); + state = apply(state, cycleVersion(state, 0, -1)); + expect(state.doc.toString()).toBe("Three."); + expect(save(state)).toBe(":::alt\nOne.\n:::or\nTwo.\n:::or*\nThree.\n:::\n"); + state = apply(state, cycleVersion(state, 0, -1)); + expect(state.doc.toString()).toBe("Two."); + expect(save(state)).toBe(":::alt\nOne.\n:::or*\nTwo.\n:::or\nThree.\n:::\n"); + state = apply(state, cycleVersion(state, 0, 1)); + expect(state.doc.toString()).toBe("Three."); + expect(save(state)).toBe(":::alt\nOne.\n:::or\nTwo.\n:::or*\nThree.\n:::\n"); + }); }); describe("keyboard navigation", () => { @@ -291,6 +433,24 @@ describe("keyboard navigation", () => { state = select(state, at(state, "Plain") + 1); expect(cycleAtCaret(state, 1)).toBeNull(); }); + + it("cycles both ways at the caret: the word first, and else its paragraph", () => { + let state = stateFor(":::alt\nFirst.\n:::or*\nSecond {{one|>uno|eins}} here.\n:::or\nThird.\n:::\n"); + state = select(state, at(state, "uno") + 1); + state = apply(state, cycleAtCaret(state, -1)); + expect(state.doc.toString()).toBe("Second one here."); + state = apply(state, cycleAtCaret(state, -1)); + expect(state.doc.toString()).toBe("Second eins here."); + state = apply(state, cycleAtCaret(state, 1)); + expect(state.doc.toString()).toBe("Second one here."); + state = select(state, at(state, "Second") + 1); + state = apply(state, cycleAtCaret(state, -1)); + expect(state.doc.toString()).toBe("First."); + expect(save(state)).toBe(":::alt*\nFirst.\n:::or\nSecond {{one|uno|eins}} here.\n:::or\nThird.\n:::\n"); + state = apply(state, cycleAtCaret(state, 1)); + expect(state.doc.toString()).toBe("Second one here."); + expect(save(state)).toBe(":::alt\nFirst.\n:::or*\nSecond {{one|uno|eins}} here.\n:::or\nThird.\n:::\n"); + }); }); describe("ghosts and counting", () => {