// The workspace model (client/workspace.ts). // // This is the spine of panes, windows and the buffer registry, and it is pure — // so it can be proven here, before any of it is wired to a component. The // property tests follow check-sections.mjs's shape: throw thousands of mutated // inputs at the model and assert the invariants hold, because the bugs in a // layout model are never in the edit itself, they are in the fifth thing the // edit invalidated. import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { CALENDAR_TAB, MAX_COLUMNS, MAX_PANES, MAX_ROWS, activeTabOf, allPaths, closeAfterIn, closeAllIn, closeAllPanes, closeOthersIn, closePane, closePathsUnder, closeTabIn, stepTab, emptyWorkspace, fromStoredTabs, holdersOf, isCalendarTab, isTabbablePath, isVirtualTab, moveTab, dropTabSplit, openInPane, setBookTarget, paneAt, paneInDirection, panesInOrder, parseWorkspace, SIGILS_TAB, pruneWorkspace, remapWorkspace, serializeWorkspace, setPinned, soloWorkspace, splitPane, surfaceOf, type Workspace, } from "../client/workspace.ts"; import { pick, rng } from "./helpers/vault.ts"; function tabs(...paths: string[]) { return paths.map((path) => ({ path, pinned: false, ephemeral: false })); } /** Every invariant parseWorkspace promises, asserted against a live value. */ function assertInvariants(ws: Workspace, why: string): void { const flat = ws.layout.columns.flat(); assert.ok(ws.layout.columns.length >= 1, `${why}: at least one column`); assert.ok(ws.layout.columns.length <= MAX_COLUMNS, `${why}: column cap`); assert.ok(flat.length <= MAX_PANES, `${why}: pane cap`); for (const col of ws.layout.columns) { assert.ok(col.length >= 1 && col.length <= MAX_ROWS, `${why}: rows per column`); } assert.equal(new Set(flat).size, flat.length, `${why}: no pane placed twice`); assert.deepEqual(new Set(flat), new Set(Object.keys(ws.panes)), `${why}: layout and panes agree`); for (const id of flat) { const pane = ws.panes[id]; if (pane.tabs.length === 0) assert.equal(pane.active, -1, `${why}: empty pane has active -1`); else assert.ok(pane.active >= 0 && pane.active < pane.tabs.length, `${why}: active in range`); assert.equal( new Set(pane.tabs.map((t) => t.path)).size, pane.tabs.length, `${why}: no path twice in one pane`, ); for (const t of pane.tabs) { assert.ok(!(t.pinned && t.ephemeral), `${why}: pinned and ephemeral are opposites`); } if (pane.follow !== null) assert.equal(pane.tabs.length, 0, `${why}: a follower holds no tabs`); } assert.ok(flat.includes(ws.focus), `${why}: focus names a live pane`); assert.equal(ws.panes[ws.focus].follow, null, `${why}: focus is never a follower`); assert.ok(flat.includes(ws.noteFocus), `${why}: noteFocus names a live pane`); const cw = ws.layout.colWeights; assert.equal(cw.length, ws.layout.columns.length, `${why}: one weight per column`); assert.ok(cw.every((w) => w > 0), `${why}: weights positive`); assert.ok(Math.abs(cw.reduce((a, b) => a + b, 0) - 1) < 1e-9, `${why}: weights normalized`); } describe("workspace: tabs", () => { it("an ephemeral tab is replaced by the next ephemeral open, not stacked", () => { let ws = soloWorkspace(tabs("Kept.md"), "Kept.md"); const p = ws.focus; ws = openInPane(ws, p, "First.md", { ephemeral: true }); ws = openInPane(ws, p, "Second.md", { ephemeral: true }); assert.deepEqual(allPaths(ws).sort(), ["Kept.md", "Second.md"]); assert.equal(activeTabOf(paneAt(ws, p)!)!.path, "Second.md"); }); it("returning to an ephemeral tab commits it — a second visit is intent", () => { let ws = soloWorkspace([], null); const p = ws.focus; ws = openInPane(ws, p, "A.md", { ephemeral: true }); ws = openInPane(ws, p, "B.md", { ephemeral: true }); // replaces A ws = openInPane(ws, p, "B.md", { ephemeral: true }); // revisit: commits ws = openInPane(ws, p, "C.md", { ephemeral: true }); // so C does not replace B assert.deepEqual(allPaths(ws).sort(), ["B.md", "C.md"]); }); it("newTab forces a real tab past a standing preview", () => { let ws = soloWorkspace([], null); const p = ws.focus; ws = openInPane(ws, p, "A.md", { ephemeral: true }); ws = openInPane(ws, p, "B.md", { ephemeral: true, newTab: true }); assert.deepEqual(allPaths(ws).sort(), ["A.md", "B.md"]); }); it("no bulk close takes a pinned tab, and that rule is the same in every row", () => { const start = () => setPinned(soloWorkspace(tabs("A.md", "B.md", "C.md"), "B.md"), "p", "A.md", true); let ws = soloWorkspace(tabs("A.md", "B.md", "C.md"), "B.md"); const p = ws.focus; ws = setPinned(ws, p, "A.md", true); assert.deepEqual(allPaths(closeOthersIn(ws, p, "C.md")).sort(), ["A.md", "C.md"]); assert.deepEqual(allPaths(closeAllIn(ws, p)), ["A.md"]); assert.deepEqual(allPaths(closeAllPanes(ws)), ["A.md"]); void start; }); it("close-after takes only what follows, in tab order", () => { let ws = soloWorkspace(tabs("A.md", "B.md", "C.md", "D.md"), "A.md"); const p = ws.focus; assert.deepEqual(allPaths(closeAfterIn(ws, p, "B.md")).sort(), ["A.md", "B.md"]); }); it("pinning sorts to the head and commits the tab", () => { let ws = soloWorkspace([], null); const p = ws.focus; ws = openInPane(ws, p, "A.md"); ws = openInPane(ws, p, "B.md", { ephemeral: true }); ws = setPinned(ws, p, "B.md", true); const pane = paneAt(ws, p)!; assert.equal(pane.tabs[0].path, "B.md"); assert.equal(pane.tabs[0].ephemeral, false); // The active tab follows its identity through the reorder, not its index. assert.equal(activeTabOf(pane)!.path, "B.md"); }); it("closing the active tab lands on the one after it", () => { let ws = soloWorkspace(tabs("A.md", "B.md", "C.md"), "B.md"); const p = ws.focus; ws = closeTabIn(ws, p, "B.md"); assert.equal(activeTabOf(paneAt(ws, p)!)!.path, "C.md"); }); it("the tab chords walk the strip and wrap at both ends (F12)", () => { let ws = soloWorkspace(tabs("A.md", "B.md", "C.md"), "A.md"); const p = ws.focus; ws = stepTab(ws, p, 1); assert.equal(activeTabOf(paneAt(ws, p)!)!.path, "B.md"); ws = stepTab(ws, p, -1); assert.equal(activeTabOf(paneAt(ws, p)!)!.path, "A.md"); // Backwards off the first tab lands on the last, not on nothing. ws = stepTab(ws, p, -1); assert.equal(activeTabOf(paneAt(ws, p)!)!.path, "C.md"); ws = stepTab(ws, p, 1); assert.equal(activeTabOf(paneAt(ws, p)!)!.path, "A.md"); }); it("stepping a pane with one tab (or none) is a no-op, not a throw", () => { const one = soloWorkspace(tabs("A.md"), "A.md"); assert.equal(stepTab(one, one.focus, 1), one); const none = soloWorkspace([], null); assert.equal(stepTab(none, none.focus, -1), none); assert.equal(stepTab(one, "no-such-pane", 1), one); }); it("stepping past a preview tab does NOT commit it — a glance is not intent", () => { let ws = soloWorkspace(tabs("A.md"), "A.md"); const p = ws.focus; ws = openInPane(ws, p, "Preview.md", { ephemeral: true }); ws = stepTab(ws, p, 1); ws = stepTab(ws, p, -1); assert.equal(paneAt(ws, p)!.tabs.find((t) => t.path === "Preview.md")!.ephemeral, true); }); it("only notes and books may be tabs", () => { const ws = fromStoredTabs({ tabs: ["A.md", "img.png", "Paper.tex", "Book.pdf"], open: "A.md" }); assert.deepEqual(allPaths(ws).sort(), ["A.md", "Book.pdf", "Paper.tex"]); }); }); describe("workspace: panes", () => { it("splits along both axes and refuses past the caps", () => { let ws = soloWorkspace(tabs("A.md"), "A.md"); for (let i = 0; i < MAX_COLUMNS - 1; i++) { const next = splitPane(ws, ws.focus, "inline", null); assert.ok(next, "inline split within the cap"); ws = next; assertInvariants(ws, "after inline split"); } assert.equal(splitPane(ws, ws.focus, "inline", null), null, "column cap refuses by name"); let stacked = ws; for (let i = 0; i < MAX_ROWS - 1; i++) { const next = splitPane(stacked, stacked.focus, "block", null); assert.ok(next, "block split within the cap"); stacked = next; } assert.equal(splitPane(stacked, stacked.focus, "block", null), null, "row cap"); }); it("closing a pane adopts its tabs rather than closing the reader's notes", () => { let ws = soloWorkspace(tabs("A.md"), "A.md"); const first = ws.focus; ws = splitPane(ws, first, "inline", null)!; ws = openInPane(ws, ws.focus, "B.md"); ws = openInPane(ws, ws.focus, "C.md"); const second = ws.focus; ws = closePane(ws, second); assert.deepEqual(allPaths(ws).sort(), ["A.md", "B.md", "C.md"]); assertInvariants(ws, "after closePane"); }); it("the last pane never closes — there is always somewhere to type", () => { const ws = soloWorkspace(tabs("A.md"), "A.md"); assert.deepEqual(closePane(ws, ws.focus), ws); }); it("noteFocus stays on a note while a book pane holds the keyboard", () => { let ws = soloWorkspace(tabs("Note.md"), "Note.md"); const noteP = ws.focus; ws = splitPane(ws, noteP, "inline", null)!; ws = openInPane(ws, ws.focus, "Ihya.pdf"); assert.equal(ws.focus, paneAt(ws, ws.focus)!.id, "the book pane has the keyboard"); assert.equal(surfaceOf(paneAt(ws, ws.focus)!), "book"); assert.equal(ws.noteFocus, noteP, "…and noteFocus still names the note beside it"); }); it("OpenHow.book lands on the pane, for a fresh open and for a re-open", () => { const aim = { page: 212, rect: null, id: null }; let ws = soloWorkspace(tabs("Note.md"), "Note.md"); ws = openInPane(ws, ws.focus, "Ihya.pdf", { book: aim }); let pane = paneAt(ws, ws.focus)!; assert.equal(surfaceOf(pane), "book"); assert.deepEqual(pane.bookTarget, aim, "a citation rides the open itself"); // Consumed by the reader… ws = setBookTarget(ws, ws.focus, null); // …and a LATER citation into the already-open book must still jump: the // re-open path sets the target too, or only the first citation ever lands. const again = { page: 7, rect: null, id: null }; ws = openInPane(ws, ws.focus, "Ihya.pdf", { book: again }); pane = paneAt(ws, ws.focus)!; assert.deepEqual(pane.bookTarget, again, "a re-open aims the pane again"); // A plain open leaves a pending target alone rather than wiping it. ws = openInPane(ws, ws.focus, "Ihya.pdf"); assert.deepEqual(paneAt(ws, ws.focus)!.bookTarget, again); }); it("dropTabSplit: the drag-to-edge gesture moves the tab into a new pane", () => { let ws = soloWorkspace(tabs("A.md", "B.md"), "A.md"); const solo = ws.focus; ws = dropTabSplit(ws, solo, "B.md", solo, "end-inline"); assert.equal(panesInOrder(ws).length, 2, "the drop split the solo pane"); const [first, second] = panesInOrder(ws); assert.deepEqual(first.tabs.map((t) => t.path), ["A.md"]); assert.deepEqual(second.tabs.map((t) => t.path), ["B.md"], "the tab travelled"); assert.equal(ws.focus, second.id, "…and took the keyboard with it"); // start-inline lands the new pane BEFORE the target in reading order. let ws2 = soloWorkspace(tabs("A.md", "B.md"), "A.md"); ws2 = dropTabSplit(ws2, ws2.focus, "B.md", ws2.focus, "start-inline"); assert.deepEqual(panesInOrder(ws2)[0].tabs.map((t) => t.path), ["B.md"]); }); it("dropTabSplit: refuses whole rather than half-applying at a cap", () => { // Fill the columns, then try to drop across — the close must not happen // when the split cannot. let ws = soloWorkspace(tabs("A.md", "B.md"), "A.md"); const p0 = ws.focus; for (let i = 0; i < MAX_COLUMNS - 1; i++) ws = splitPane(ws, ws.focus, "inline", null)!; // MAX_COLUMNS reached const before = ws; ws = dropTabSplit(ws, p0, "B.md", ws.focus, "end-inline"); assert.deepEqual(allPaths(ws).sort(), allPaths(before).sort(), "no tab eaten"); assert.deepEqual(paneAt(ws, p0)!.tabs.map((t) => t.path), ["A.md", "B.md"]); }); it("dropTabSplit: an emptied source pane closes behind the drag", () => { let ws = soloWorkspace(tabs("A.md"), "A.md"); const p0 = ws.focus; ws = splitPane(ws, p0, "inline", null)!; const p1 = ws.focus; ws = openInPane(ws, p1, "B.md"); // Drag B — p1's only tab — onto p0's block edge: p1 must vanish. ws = dropTabSplit(ws, p1, "B.md", p0, "end-block"); assert.equal(paneAt(ws, p1), null, "the emptied pane closed behind it"); assert.deepEqual(allPaths(ws).sort(), ["A.md", "B.md"]); assert.equal(panesInOrder(ws).length, 2); }); it("closing a split pane's last tab closes the pane, and the tab is NOT adopted", () => { let ws = soloWorkspace(tabs("A.md"), "A.md"); const p0 = ws.focus; ws = splitPane(ws, p0, "inline", null)!; const p1 = ws.focus; ws = openInPane(ws, p1, "B.md"); ws = closeTabIn(ws, p1, "B.md"); assert.equal(paneAt(ws, p1), null, "the emptied split pane folded"); assert.deepEqual(allPaths(ws), ["A.md"], "the closed tab did not reappear next door"); // The LAST pane may be empty — the solo empty state is a real state. let solo = soloWorkspace(tabs("A.md"), "A.md"); solo = closeTabIn(solo, solo.focus, "A.md"); assert.equal(panesInOrder(solo).length, 1); assert.deepEqual(allPaths(solo), []); }); it("dropTabSplit from the TREE (no source pane) splits without removing anything", () => { let ws = soloWorkspace(tabs("A.md"), "A.md"); ws = dropTabSplit(ws, null, "Ihya.pdf", ws.focus, "end-inline"); const [first, second] = panesInOrder(ws); assert.deepEqual(first.tabs.map((t) => t.path), ["A.md"]); assert.deepEqual(second.tabs.map((t) => t.path), ["Ihya.pdf"]); assert.equal(surfaceOf(second), "book"); }); it("dropTabSplit: a solo pane's only tab dropped on its own edge is a no-op", () => { const ws = soloWorkspace(tabs("A.md"), "A.md"); const out = dropTabSplit(ws, ws.focus, "A.md", ws.focus, "end-inline"); assert.deepEqual(out, ws); }); }); describe("the Calendar page is a tab", () => { it("is a virtual tab with a surface of its own", () => { assert.ok(isCalendarTab(CALENDAR_TAB)); assert.ok(!isCalendarTab("~sigils")); assert.ok(!isCalendarTab("Calendar.md")); assert.ok(isVirtualTab(CALENDAR_TAB)); assert.ok(isTabbablePath(CALENDAR_TAB)); const ws = soloWorkspace(tabs(CALENDAR_TAB), CALENDAR_TAB); assert.equal(surfaceOf(paneAt(ws, ws.focus)!), "calendar"); }); it("survives a save and a reload, and the vault never prunes it", () => { const ws = soloWorkspace(tabs("A.md", CALENDAR_TAB), CALENDAR_TAB); const back = parseWorkspace(JSON.parse(JSON.stringify(serializeWorkspace(ws)))); assert.ok(back); assert.deepEqual(allPaths(back).sort(), ["A.md", CALENDAR_TAB].sort()); // It names no file, so a tree that has never heard of it leaves it alone. assert.deepEqual(allPaths(pruneWorkspace(ws, new Set(["A.md"]))).sort(), ["A.md", CALENDAR_TAB].sort()); }); }); describe("workspace: the vault changes underneath", () => { it("a rename follows every tab, and collapses onto a tab that already exists", () => { let ws = soloWorkspace(tabs("Old.md", "New.md"), "Old.md"); ws = remapWorkspace(ws, "Old.md", "New.md"); assert.deepEqual(allPaths(ws), ["New.md"]); assertInvariants(ws, "after a collapsing rename"); }); it("a delete and a sign-out ignore pins — a pin is about intent, not contents", () => { let ws = soloWorkspace(tabs("A.md", "folder/B.md"), "A.md"); ws = setPinned(ws, ws.focus, "folder/B.md", true); assert.deepEqual(allPaths(closePathsUnder(ws, "folder")), ["A.md"]); assert.deepEqual(allPaths(pruneWorkspace(ws, new Set(["A.md"]))), ["A.md"]); }); it("counts holders, because two panes on one note is one document", () => { let ws = soloWorkspace(tabs("A.md"), "A.md"); ws = splitPane(ws, ws.focus, "inline", null)!; ws = openInPane(ws, ws.focus, "A.md"); assert.equal(holdersOf(ws, "A.md"), 2); assert.equal(holdersOf(ws, "B.md"), 0); }); }); describe("workspace: the calendar is a tab", () => { it("is virtual, tabbable, and renders the calendar surface", () => { assert.equal(isTabbablePath(CALENDAR_TAB), true); assert.equal(isVirtualTab(CALENDAR_TAB), true); assert.equal(isCalendarTab(CALENDAR_TAB), true); // It is nobody else's sentinel, and no note is it. assert.equal(isCalendarTab(SIGILS_TAB), false); assert.equal(isCalendarTab("Calendar.md"), false); const ws = soloWorkspace(tabs(CALENDAR_TAB), CALENDAR_TAB); assert.equal(surfaceOf(paneAt(ws, ws.focus)!), "calendar"); }); it("survives a save and a reload, like the Sigils tab beside it", () => { const ws = soloWorkspace(tabs(CALENDAR_TAB, "A.md"), CALENDAR_TAB); const back = parseWorkspace(serializeWorkspace(ws))!; assert.deepEqual(allPaths(back), [CALENDAR_TAB, "A.md"]); }); it("opens beside a note rather than over it, and closes back onto it", () => { let ws = soloWorkspace(tabs("A.md"), "A.md"); ws = openInPane(ws, ws.focus, CALENDAR_TAB); assert.deepEqual(allPaths(ws).sort(), ["A.md", CALENDAR_TAB]); assert.equal(surfaceOf(paneAt(ws, ws.focus)!), "calendar"); ws = closeTabIn(ws, ws.focus, CALENDAR_TAB); assert.equal(activeTabOf(paneAt(ws, ws.focus)!)?.path, "A.md"); }); }); describe("workspace: geometry", () => { const rect = (left: number, top: number): DOMRect => ({ left, top, width: 100, height: 100 }) as DOMRect; it("resolves direction from the screen, so left is left in Arabic too", () => { const rects = { a: rect(0, 0), b: rect(200, 0), c: rect(0, 200) }; assert.equal(paneInDirection(rects, "a", "right"), "b"); assert.equal(paneInDirection(rects, "b", "left"), "a"); assert.equal(paneInDirection(rects, "a", "down"), "c"); assert.equal(paneInDirection(rects, "a", "up"), null); }); it("prefers the pane straight across over a nearer diagonal one", () => { const rects = { a: rect(0, 0), straight: rect(300, 0), diagonal: rect(200, 400) }; assert.equal(paneInDirection(rects, "a", "right"), "straight"); }); }); describe("workspace: persistence", () => { it("round-trips through serialize/parse", () => { let ws = soloWorkspace(tabs("A.md", "B.md"), "B.md"); ws = splitPane(ws, ws.focus, "inline", null)!; ws = openInPane(ws, ws.focus, "C.md"); ws = setPinned(ws, ws.focus, "C.md", true); const back = parseWorkspace(JSON.parse(JSON.stringify(serializeWorkspace(ws)))); assert.ok(back); assert.deepEqual(allPaths(back).sort(), allPaths(ws).sort()); assert.equal(back.layout.columns.length, ws.layout.columns.length); assertInvariants(back, "round-trip"); }); it("migrates astrolabe.tabs without losing an open note", () => { const ws = fromStoredTabs({ tabs: ["a.md", "b/c.md"], open: "b/c.md" }); assert.deepEqual(allPaths(ws), ["a.md", "b/c.md"]); assert.equal(activeTabOf(paneAt(ws, ws.focus)!)!.path, "b/c.md"); assertInvariants(ws, "migration"); }); it("recovers the reader's notes from a layout that is structurally broken", () => { // The layout is nonsense; the tabs are not. The rule is that a corrupt // layout must never cost anyone their open notes. const damaged = { v: 2, workspace: { panes: { p1: { id: "p1", tabs: [{ path: "Alive.md" }], active: 0, mode: "edit" } }, layout: { columns: "not an array", colWeights: null, rowWeights: 7 }, focus: "gone", noteFocus: null, }, }; const ws = parseWorkspace(damaged); assert.ok(ws, "recovered rather than discarded"); assert.deepEqual(allPaths(ws), ["Alive.md"]); assertInvariants(ws, "recovery"); }); it("adopts the tabs of a pane the layout forgot to place", () => { const orphaned = { v: 2, workspace: { panes: { p1: { id: "p1", tabs: [{ path: "Placed.md" }], active: 0, mode: "edit" }, p9: { id: "p9", tabs: [{ path: "Orphan.md" }], active: 0, mode: "edit" }, }, layout: { columns: [["p1"]], colWeights: [1], rowWeights: { p1: 1 } }, focus: "p1", noteFocus: "p1", }, }; const ws = parseWorkspace(orphaned); assert.ok(ws); assert.deepEqual(allPaths(ws).sort(), ["Orphan.md", "Placed.md"]); assertInvariants(ws, "orphan adoption"); }); it("is TOTAL over garbage — it never throws", () => { const junk: unknown[] = [ null, undefined, 0, "", [], {}, { v: 1 }, { v: 2 }, { v: 2, workspace: null }, { v: 2, workspace: { panes: {}, layout: { columns: [] } } }, { v: 2, workspace: { panes: { a: 1 }, layout: { columns: [["a", "a"]] } } }, ]; for (const raw of junk) { const ws = parseWorkspace(raw); if (ws !== null) assertInvariants(ws, `junk ${JSON.stringify(raw)}`); } }); }); describe("workspace: property tests", () => { it("no sequence of edits can break an invariant (4000 random walks)", () => { const paths = ["A.md", "B.md", "c/D.md", "Paper.tex", "Book.pdf"]; for (let seed = 0; seed < 4000; seed++) { const next = rng(seed); let ws = emptyWorkspace(); for (let step = 0; step < 12; step++) { const ids = panesInOrder(ws).map((p) => p.id); const id = pick(next, ids); const path = pick(next, paths); switch (Math.floor(next() * 12)) { case 0: ws = splitPane(ws, id, "inline", null, next() < 0.5) ?? ws; break; case 1: ws = splitPane(ws, id, "block", null, next() < 0.5) ?? ws; break; case 2: ws = closePane(ws, id); break; case 3: ws = openInPane(ws, id, path, { ephemeral: next() < 0.5 }); break; case 4: ws = closeTabIn(ws, id, path); break; case 5: ws = closeOthersIn(ws, id, path); break; case 6: ws = setPinned(ws, id, path, next() < 0.5); break; case 7: ws = moveTab(ws, id, path, pick(next, ids), Math.floor(next() * 4)); break; case 8: ws = remapWorkspace(ws, path, pick(next, paths)); break; case 9: ws = dropTabSplit( ws, id, path, pick(next, ids), pick(next, ["start-inline", "end-inline", "start-block", "end-block"] as const), ); break; case 10: ws = closeAllIn(ws, id); break; default: ws = closeAllIn(ws, id); break; } assertInvariants(ws, `seed ${seed} step ${step}`); } // …and whatever that walk produced still survives a save and a reload. const back = parseWorkspace(JSON.parse(JSON.stringify(serializeWorkspace(ws)))); assert.ok(back, `seed ${seed}: round-trip`); assertInvariants(back, `seed ${seed}: after round-trip`); assert.deepEqual(allPaths(back).sort(), allPaths(ws).sort(), `seed ${seed}: no tab lost`); } }); });