diff --git a/test/canvas.test.js b/test/canvas.test.js
new file mode 100644
index 0000000..47e78bb
--- /dev/null
+++ b/test/canvas.test.js
@@ -0,0 +1,311 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { createRequire } from 'node:module';
+import { mkdtemp, readFile, writeFile, mkdir, readdir } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import * as C from '../src/lib/canvas.js';
+import { resolveLink, refFor } from '../src/lib/links.js';
+
+const require = createRequire(import.meta.url);
+const disk = require('../electron/canvas.cjs');
+const links = require('../electron/links.cjs');
+const { Vault } = require('../electron/vault.cjs');
+
+const tmp = () => mkdtemp(path.join(tmpdir(), 'ink-canvas-test-'));
+
+// A canvas as Obsidian writes it.
+const OBSIDIAN = `{
+\t"nodes":[
+\t\t{"id":"a1b2c3d4e5f60718","type":"group","x":-300,"y":-260,"width":760,"height":520,"label":"Plan","color":"4"},
+\t\t{"id":"0f1e2d3c4b5a6978","type":"text","text":"# Idea\\n\\nSee [[Roadmap]] and [[Roadmap#Goals|goals]]","x":-260,"y":-200,"width":300,"height":160},
+\t\t{"id":"1122334455667788","type":"file","file":"Projects/Roadmap.md","subpath":"#Goals","x":100,"y":-200,"width":320,"height":400},
+\t\t{"id":"99aabbccddeeff00","type":"file","file":"Projects/diagram.png","x":-260,"y":0,"width":300,"height":200},
+\t\t{"id":"abcdefabcdefabcd","type":"link","url":"https://jsoncanvas.org","x":500,"y":0,"width":400,"height":220,"someFutureField":true}
+\t],
+\t"edges":[
+\t\t{"id":"e1e1e1e1e1e1e1e1","fromNode":"0f1e2d3c4b5a6978","fromSide":"right","toNode":"1122334455667788","toSide":"left","label":"details"}
+\t],
+\t"metadata":{"version":"1.0"}
+}`;
+
+test('canvas files read and write exactly as Obsidian writes them, on both sides', () => {
+ for (const m of [C, disk]) {
+ const c = m.parseCanvas(OBSIDIAN);
+ assert.equal(c.nodes.length, 5);
+ assert.equal(c.edges.length, 1);
+ assert.deepEqual(c.extra, { metadata: { version: '1.0' } }, 'unknown top-level keys are kept');
+ assert.equal(m.serializeCanvas(c), OBSIDIAN, 'unchanged canvas = unchanged file');
+ assert.equal(c.nodes[4].someFutureField, true, 'unknown node fields are kept');
+ assert.deepEqual(m.parseCanvas(''), { nodes: [], edges: [], extra: {} }, 'an empty file is an empty canvas');
+ assert.deepEqual(m.parseCanvas('{}'), { nodes: [], edges: [], extra: {} });
+ assert.throws(() => m.parseCanvas('{ not json'));
+ assert.throws(() => m.parseCanvas('[1,2]'));
+ }
+ assert.equal(C.serializeCanvas({}), disk.EMPTY_CANVAS);
+ assert.equal(disk.EMPTY_CANVAS, '{\n\t"nodes":[],\n\t"edges":[]\n}');
+});
+
+test('a canvas reads as text with [[links]] to the notes on it (search, backlinks, graph)', () => {
+ const t = disk.canvasText(OBSIDIAN);
+ assert.ok(t.includes('# Idea') && t.includes('[[Roadmap]]'), 'text cards');
+ assert.ok(t.includes('[[Projects/Roadmap#Goals]]'), 'a note on the canvas is a link to it');
+ assert.ok(!t.includes('diagram.png'), 'pictures are not page links');
+ assert.ok(t.includes('Plan') && t.includes('details') && t.includes('https://jsoncanvas.org'));
+ assert.equal(disk.canvasText('{ broken'), '');
+ const pages = [
+ { id: 'Projects/Roadmap', title: 'Roadmap', exists: true, body: '## Goals' },
+ { id: 'Ideas.canvas', title: 'Ideas', exists: true, body: t }
+ ];
+ assert.deepEqual(links.backlinks('Projects/Roadmap', pages).map((b) => b.id), ['Ideas.canvas']);
+});
+
+test('[[Name.canvas]] finds a canvas; [[Name]] never does', () => {
+ const pages = [
+ { id: 'Maps/Plan.canvas', title: 'Plan', exists: true },
+ { id: 'Notes/Plan', title: 'Plan', exists: true },
+ { id: 'Only.canvas', title: 'Only', exists: true }
+ ];
+ for (const resolve of [resolveLink, links.resolveLink]) {
+ assert.equal(resolve('Plan.canvas', pages), 'Maps/Plan.canvas');
+ assert.equal(resolve('Plan', pages), 'Notes/Plan');
+ assert.equal(resolve('Only', pages), null);
+ assert.equal(resolve('Only.canvas', pages), 'Only.canvas');
+ }
+ assert.equal(refFor('Maps/Plan.canvas', pages), 'Plan.canvas');
+ assert.equal(links.refFor('Notes/Plan', pages), 'Plan');
+});
+
+test('moving and renaming notes keeps canvases pointing at them', () => {
+ const before = [{ id: 'Projects/Roadmap', title: 'Roadmap', exists: true }, { id: 'Projects', title: 'Projects', exists: true }];
+ const after = [{ id: 'Archive/Projects/Roadmap', title: 'Roadmap', exists: true }, { id: 'Archive/Projects', title: 'Projects', exists: true }];
+ const moved = (id) => (id === 'Projects' ? 'Archive/Projects' : id.startsWith('Projects/') ? 'Archive/' + id : null);
+ const out = disk.rewriteCanvas(OBSIDIAN, before, after, moved);
+ const c = disk.parseCanvas(out);
+ assert.equal(c.nodes[2].file, 'Archive/Projects/Roadmap.md', 'note path');
+ assert.equal(c.nodes[3].file, 'Archive/Projects/diagram.png', 'a picture in the moved folder');
+ assert.equal(c.nodes[1].text, '# Idea\n\nSee [[Roadmap]] and [[Roadmap#Goals|goals]]', 'a link that still resolves by name is left as written');
+ assert.equal(disk.rewriteCanvas(OBSIDIAN, before, before, () => null), null, 'nothing moved: no rewrite');
+
+ const renamed = disk.rewriteCanvas(OBSIDIAN, [{ id: 'Projects/Roadmap', title: 'Roadmap', exists: true }], [{ id: 'Projects/Plan 2027', title: 'Plan 2027', exists: true }], (id) => (id === 'Projects/Roadmap' ? 'Projects/Plan 2027' : null));
+ const r = disk.parseCanvas(renamed);
+ assert.equal(r.nodes[2].file, 'Projects/Plan 2027.md');
+ assert.equal(r.nodes[1].text, '# Idea\n\nSee [[Plan 2027]] and [[Plan 2027#Goals|goals]]');
+});
+
+test('renaming a heading updates a note shown at that heading and links in cards', () => {
+ const pages = [{ id: 'Projects/Roadmap', title: 'Roadmap', exists: true }];
+ const r = disk.renameHeadingInCanvas(OBSIDIAN, pages, 'Projects/Roadmap', 'Goals', 'Aims [2027]');
+ assert.equal(r.count, 2);
+ const c = disk.parseCanvas(r.body);
+ assert.equal(c.nodes[2].subpath, '#Aims 2027');
+ assert.equal(c.nodes[1].text, '# Idea\n\nSee [[Roadmap]] and [[Roadmap#Aims 2027|goals]]');
+ assert.equal(disk.renameHeadingInCanvas(OBSIDIAN, pages, 'Projects/Roadmap', 'Nope', 'X'), null);
+});
+
+test('geometry: sides, paths, fitting', () => {
+ const n = { x: 0, y: 0, width: 200, height: 100 };
+ assert.deepEqual(C.sidePoint(n, 'right'), [200, 50]);
+ assert.deepEqual(C.sidePoint(n, 'top'), [100, 0]);
+ assert.equal(C.nearestSide(n, 500, 60), 'right');
+ assert.equal(C.nearestSide(n, 90, -300), 'top');
+ assert.equal(C.nearestSide(n, -5, 50), 'left');
+ assert.deepEqual(C.autoSides(n, { x: 0, y: 400, width: 200, height: 100 }), ['bottom', 'top']);
+ const p = C.edgePath([200, 50], 'right', [400, 50], 'left');
+ assert.match(p.d, /^M200,50 C/);
+ assert.deepEqual(p.mid, [300, 50]);
+ assert.deepEqual(p.end.dir, [1, 0]);
+ const v = C.fitView({ x: 0, y: 0, width: 1000, height: 500 }, 600, 400, 50);
+ assert.equal(v.z, 0.5);
+ assert.deepEqual([v.x, v.y], [300 - 500 * 0.5, 200 - 250 * 0.5]);
+ assert.equal(C.fitView({ x: 0, y: 0, width: 10, height: 10 }, 800, 600).z, 1, 'never zooms in past 100%');
+ assert.equal(C.relPath('A/B', 'A/img/x.png'), '../img/x.png');
+ assert.equal(C.relPath('', 'img/x.png'), 'img/x.png');
+ assert.equal(C.relPath('A', 'A/x.png'), 'x.png');
+ assert.equal(C.fileKind('a/b.md'), 'note');
+ assert.equal(C.fileKind('a/b.canvas'), 'canvas');
+ assert.equal(C.fileKind('a/b.JPG'), 'image');
+ assert.equal(C.colorOf('3'), C.PRESETS[3]);
+ assert.equal(C.colorOf('#ff0000'), '#ff0000');
+ assert.equal(C.colorOf('url(x)'), null);
+ assert.match(C.newId(), /^[0-9a-f]{16}$/);
+});
+
+test('the vault lists, writes, renames, moves and trashes canvases as plain .canvas files', async () => {
+ const dir = await tmp();
+ await mkdir(path.join(dir, 'Maps'));
+ await writeFile(path.join(dir, 'Maps', 'Plan.canvas'), OBSIDIAN);
+ await writeFile(path.join(dir, 'Note.md'), 'Hello\n');
+ const v = new Vault(dir);
+
+ const list = await v.list();
+ const cv = list.find((p) => p.id === 'Maps/Plan.canvas');
+ assert.ok(cv, JSON.stringify(list.map((p) => p.id)));
+ assert.equal(cv.title, 'Plan');
+ assert.equal(cv.kind, 'canvas');
+ assert.ok(cv.body.includes('[[Projects/Roadmap#Goals]]'), 'listed with its text');
+
+ const r = await v.read('Maps/Plan.canvas');
+ assert.equal(r.body, OBSIDIAN, 'read gives the file as is');
+ assert.deepEqual(r.meta, {});
+ await v.write('Maps/Plan.canvas', { tags: ['x'] }, '{\n\t"nodes":[],\n\t"edges":[]\n}');
+ assert.equal(await readFile(path.join(dir, 'Maps', 'Plan.canvas'), 'utf8'), '{\n\t"nodes":[],\n\t"edges":[]\n}', 'no front matter in a canvas');
+
+ const made = await v.createCanvas(null, 'Board ideas');
+ assert.equal(made, 'Board ideas.canvas');
+ assert.equal(await readFile(path.join(dir, 'Board ideas.canvas'), 'utf8'), disk.EMPTY_CANVAS);
+ assert.equal(await v.createCanvas(null, 'Board ideas'), 'Board ideas 2.canvas', 'a free name');
+
+ assert.equal(await v.rename('Board ideas.canvas', 'Mind map'), 'Mind map.canvas');
+ assert.equal(await v.move('Mind map.canvas', 'Maps'), 'Maps/Mind map.canvas');
+ await assert.rejects(v.create('Maps/Mind map.canvas', 'Inside'), /canvas cannot hold pages/);
+ await assert.rejects(v.move('Note', 'Maps/Mind map.canvas'), /canvas cannot hold pages/);
+
+ const entry = await v.remove('Maps/Mind map.canvas');
+ assert.ok(!(await readdir(path.join(dir, 'Maps'))).includes('Mind map.canvas'));
+ assert.equal((await v.listTrash())[0].title, 'Mind map');
+ assert.equal(await v.restore(entry), 'Maps/Mind map.canvas');
+ assert.equal(await readFile(path.join(dir, 'Maps', 'Mind map.canvas'), 'utf8'), disk.EMPTY_CANVAS);
+});
+
+test('aligning a selection: each edge or centre of the group, nothing else moves', () => {
+ const a = { id: 'a', x: 0, y: 0, width: 100, height: 50 };
+ const b = { id: 'b', x: 200, y: 80, width: 60, height: 40 };
+ const c = { id: 'c', x: 50, y: 300, width: 200, height: 100 };
+ const all = [a, b, c];
+ const at = (how) => Object.fromEntries([...C.alignNodes(all, how)].map(([id, p]) => [id, p]));
+ assert.deepEqual(at('left'), { b: { x: 0, y: 80 }, c: { x: 0, y: 300 } });
+ assert.deepEqual(at('right'), { a: { x: 160, y: 0 }, c: { x: 60, y: 300 } }, 'b already ends at the right edge (260)');
+ assert.deepEqual(at('top'), { b: { x: 200, y: 0 }, c: { x: 50, y: 0 } });
+ assert.deepEqual(at('bottom'), { a: { x: 0, y: 350 }, b: { x: 200, y: 360 } });
+ assert.deepEqual(at('centerX'), { a: { x: 80, y: 0 }, b: { x: 100, y: 80 }, c: { x: 30, y: 300 } });
+ assert.deepEqual(at('centerY'), { a: { x: 0, y: 175 }, b: { x: 200, y: 180 }, c: { x: 50, y: 150 } });
+ assert.equal(C.alignNodes([a], 'left').size, 0, 'one node has nothing to line up with');
+ assert.equal(C.alignNodes(all, 'sideways').size, 0);
+});
+
+test('distributing: equal gaps, the two outermost stay put, order follows position', () => {
+ const n = (id, x, w, y = 0, h = 40) => ({ id, x, y, width: w, height: h });
+ const row = [n('c', 400, 100), n('a', 0, 100), n('b', 120, 50)]; // out of order, and uneven
+ const moved = C.distributeNodes(row, 'x');
+ assert.deepEqual([...moved], [['b', { x: 225, y: 0 }]], 'gaps of 125: 100 | 125 | 50 | 125 | 100');
+ const col = [n('a', 0, 80, 0, 40), n('b', 0, 80, 50, 60), n('c', 0, 80, 300, 20)];
+ const down = C.distributeNodes(col, 'y');
+ assert.deepEqual([...down], [['b', { x: 0, y: 140 }]]);
+ assert.equal(C.distributeNodes([n('a', 0, 10), n('b', 50, 10)], 'x').size, 0, 'two nodes: nothing to spread');
+ // already even: nothing to do
+ assert.equal(C.distributeNodes([n('a', 0, 100), n('b', 150, 100), n('c', 300, 100)], 'x').size, 0);
+});
+
+test('guides: an edge or centre close to another card snaps onto it, and lines are drawn for what lines up', () => {
+ const other = { id: 'o', x: 400, y: 100, width: 200, height: 100 }; // lines: x 400 / 500 / 600, y 100 / 150 / 200
+ const box = (x, y, w = 100, h = 60) => ({ x, y, width: w, height: h });
+ // left edge 3 off the other's left edge; vertically far away
+ let g = C.guideSnap(box(397, 500), [other], 6);
+ assert.deepEqual([g.dx, g.sx, g.sy], [3, true, false]);
+ // centre (x + 50) 4 left of the other's centre 500: moves right
+ g = C.guideSnap(box(446, 500), [other], 6);
+ assert.deepEqual([g.dx, g.sx], [4, true]);
+ // too far: nothing
+ g = C.guideSnap(box(380, 500), [other], 6);
+ assert.deepEqual([g.sx, g.sy, g.dx, g.dy], [false, false, 0, 0]);
+ // the closest line wins, per axis
+ g = C.guideSnap(box(405, 138), [other], 8); // x: left 405 -> 400 (-5); y: centre 168 vs 150 / bottom 198 vs 200 (+2)
+ assert.deepEqual([g.dx, g.dy], [-5, 2]);
+ // only the lines asked for count (resizing: the right edge)
+ g = C.guideSnap({ x: 598, y: 0, width: 0, height: 0 }, [other], 6, { x: [0], y: [0] });
+ assert.deepEqual([g.dx, g.sx, g.sy], [2, true, false]);
+
+ const lines = C.guideLines(box(400, 400, 80), [other]);
+ assert.deepEqual(lines, [{ axis: 'x', at: 400, from: 100, to: 460 }], 'the left edges line up; the line spans both cards');
+ assert.deepEqual(C.guideLines(box(450, 400), [other]), [{ axis: 'x', at: 500, from: 100, to: 460 }], 'centre on centre');
+ assert.deepEqual(C.guideLines(box(0, 0), [other]), []);
+ // two cards lined up on the same edge give one line, not two
+ const two = [other, { id: 'p', x: 400, y: 700, width: 50, height: 50 }];
+ assert.equal(C.guideLines(box(400, 400, 80), two).length, 1);
+ assert.deepEqual(C.guideLines(box(400, 400, 80), two)[0], { axis: 'x', at: 400, from: 100, to: 750 });
+});
+
+test('arrow styles: straight and right-angled lines, with heads at the right ends', () => {
+ const st = C.edgePath([0, 0], 'right', [100, 0], 'left', 'straight');
+ assert.equal(st.d, 'M0,0 L100,0');
+ assert.deepEqual([st.mid, st.end.dir, st.start.dir], [[50, 0], [1, 0], [-1, 0]]);
+ const diag = C.edgePath([0, 0], 'bottom', [30, 40], 'top', 'straight');
+ assert.deepEqual(diag.end.dir, [0.6, 0.8]);
+
+ // right angles: leave sideways, one bend in the middle, arrive sideways; the stubs vanish into the runs
+ const z = C.edgePath([0, 0], 'right', [200, 100], 'left', 'orthogonal');
+ assert.deepEqual(z.pts, [[0, 0], [100, 0], [100, 100], [200, 100]]);
+ assert.deepEqual([z.end.dir, z.start.dir], [[1, 0], [-1, 0]]);
+ assert.deepEqual(z.mid, [100, 50]);
+ // from the right of one card into the top of another: a single corner
+ const l = C.edgePath([0, 0], 'right', [100, 100], 'top', 'orthogonal');
+ assert.deepEqual(l.pts, [[0, 0], [100, 0], [100, 100]]);
+ assert.deepEqual(l.end.dir, [0, 1]);
+ // straight on: no bends at all
+ assert.deepEqual(C.edgePath([0, 0], 'right', [200, 0], 'left', 'orthogonal').pts, [[0, 0], [200, 0]]);
+ // an arrow still being drawn has a loose end: it arrives along the longer way
+ const loose = C.edgePath([0, 0], 'right', [100, 10], null, 'orthogonal');
+ assert.deepEqual(loose.pts, [[0, 0], [62, 0], [62, 10], [100, 10]]);
+ assert.deepEqual(loose.end.dir, [1, 0]);
+ // corners are rounded in the path that is drawn
+ assert.match(z.d, /Q/);
+ assert.match(z.d, /^M0,0 L/);
+ // the default is the curve; unknown styles fall back to it
+ assert.match(C.edgePath([0, 0], 'right', [100, 0], 'left').d, / C/);
+ assert.equal(C.shapeOf({ style: 'straight' }), 'straight');
+ assert.equal(C.shapeOf({ style: 'wavy' }), 'curved');
+ assert.equal(C.shapeOf({}), 'curved');
+});
+
+test('groups: what a collapsed group hides, how it is drawn, and where a dropped card ends up', () => {
+ const g = { id: 'g', type: 'group', x: 0, y: 0, width: 400, height: 300 };
+ const a = { id: 'a', type: 'text', x: 40, y: 40, width: 100, height: 60 };
+ const b = { id: 'b', type: 'text', x: 200, y: 100, width: 100, height: 60 };
+ const out = { id: 'out', type: 'text', x: 500, y: 40, width: 100, height: 60 };
+ const nodes = [g, a, b, out];
+ assert.equal(C.hiddenMap(nodes).size, 0, 'nothing is hidden while the group is open');
+ g.collapsed = true;
+ const h = C.hiddenMap(nodes);
+ assert.deepEqual([...h.keys()].sort(), ['a', 'b']);
+ assert.equal(h.get('a'), g);
+ assert.deepEqual(C.displayBox(g), { x: 0, y: 0, width: 400, height: C.COLLAPSED_H });
+ assert.equal(C.displayBox(a), a, 'ordinary nodes are drawn as they are');
+ // an outer group that is folded hides an inner folded group's members too, and the outer one wins
+ const outer = { id: 'o', type: 'group', x: -50, y: -50, width: 600, height: 500, collapsed: true };
+ assert.equal(C.hiddenMap([outer, ...nodes]).get('a'), outer);
+ assert.equal(C.hiddenMap([outer, ...nodes]).get('g'), outer);
+ delete g.collapsed;
+
+ // which group a card lands in: the smallest one holding its middle; open groups only
+ const inner = { id: 'i', type: 'group', x: 20, y: 20, width: 160, height: 120 };
+ assert.equal(C.groupFor({ id: 'c', x: 50, y: 50, width: 40, height: 40 }, [g, inner]).id, 'i');
+ assert.equal(C.groupFor({ id: 'c', x: 250, y: 200, width: 40, height: 40 }, [g, inner]).id, 'g');
+ assert.equal(C.groupFor({ id: 'c', x: 600, y: 600, width: 40, height: 40 }, [g, inner]), null);
+ assert.equal(C.groupFor({ id: 'c', x: 50, y: 50, width: 40, height: 40 }, [{ ...g, collapsed: true }]), null, 'folded groups take nothing');
+ // dropped half over the edge: pushed in
+ assert.equal(C.fitInto({ id: 'c', x: 100, y: 100, width: 80, height: 50 }, g), null, 'already inside');
+ assert.deepEqual(C.fitInto({ id: 'c', x: 350, y: 130, width: 100, height: 60 }, g), { x: 280, y: 130 });
+ assert.deepEqual(C.fitInto({ id: 'c', x: 10, y: 280, width: 100, height: 60 }, g), { x: 20, y: 220 });
+ // too big for the group: the group grows to hold it
+ assert.deepEqual(C.fitInto({ id: 'c', x: 100, y: 100, width: 500, height: 100 }, g), { group: { x: 0, y: 0, width: 620, height: 300 } });
+});
+
+test('card <-> note: a page name from the text, and the text a page starts with', () => {
+ assert.equal(C.titleFromText('# My Plan\n\nSome text'), 'My Plan');
+ assert.equal(C.titleFromText('## Sub **heading**'), 'Sub heading');
+ assert.equal(C.titleFromText('**Buy** milk and [[Notes/Eggs|eggs]] from [the shop](https://x.y)'), 'Buy milk and eggs from the shop');
+ assert.equal(C.titleFromText('- [ ] call the bank\n- [x] other'), 'call the bank');
+ assert.equal(C.titleFromText('\n\n---\n \nreal first line'), 'real first line');
+ assert.equal(C.titleFromText(''), 'Untitled');
+ assert.equal(C.titleFromText('***'), 'Untitled');
+ const long = C.titleFromText('word '.repeat(40));
+ assert.ok(long.length <= 60 && !long.endsWith(' '), long);
+
+ assert.equal(C.bodyAfterTitle('# My Plan\n\nSome text\nmore'), 'Some text\nmore\n');
+ assert.equal(C.bodyAfterTitle('\n## Only a heading'), '');
+ assert.equal(C.bodyAfterTitle('Plain first line\nsecond'), 'Plain first line\nsecond\n', 'a plain first line stays: it is text, not a title');
+ assert.equal(C.bodyAfterTitle('#hashtag not a heading\ntext'), '#hashtag not a heading\ntext\n');
+ assert.equal(C.bodyAfterTitle(''), '');
+});
+
diff --git a/test/csv.test.js b/test/csv.test.js
new file mode 100644
index 0000000..34ef97b
--- /dev/null
+++ b/test/csv.test.js
@@ -0,0 +1,101 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { parseCsv, toCsv, databaseCsv, parseNumber, parseDate, inferType, autoMap, planImport } from '../src/lib/csv.js';
+
+test('CSV: quotes, embedded commas and newlines, CRLF, BOM and other delimiters', () => {
+ const t = 'Name,Note\r\n"Smith, J","He said ""hi""\nand left"\r\nPlain,\r\n\r\n';
+ const { rows, delimiter } = parseCsv(t);
+ assert.equal(delimiter, ',');
+ assert.deepEqual(rows, [['Name', 'Note'], ['Smith, J', 'He said "hi"\nand left'], ['Plain', '']]);
+ assert.equal(parseCsv('a;b;c\n1;2;3').delimiter, ';');
+ assert.deepEqual(parseCsv('a\tb\n1\t2').rows, [['a', 'b'], ['1', '2']]);
+ assert.deepEqual(parseCsv('a,b\n1,2').rows, [['a', 'b'], ['1', '2']]); // no trailing newline
+ assert.deepEqual(parseCsv('').rows, []);
+});
+
+test('CSV: what toCsv writes parseCsv reads back', () => {
+ const rows = [['Name', 'Tags'], ['A, B', 'x"y'], [' pad ', 'line\nbreak'], ['', 'ü ☃']];
+ assert.deepEqual(parseCsv(toCsv(rows)).rows, rows.map((r) => r.map((c) => c)));
+});
+
+test('numbers and dates come in many shapes', () => {
+ assert.equal(parseNumber('$1,234.50'), 1234.5);
+ assert.equal(parseNumber('12%'), 12);
+ assert.equal(parseNumber('1.234,5'), 1234.5);
+ assert.equal(parseNumber('12,5'), 12.5);
+ assert.equal(parseNumber('5,200'), 5200);
+ assert.equal(parseNumber('$1,234,567'), 1234567);
+ assert.equal(parseNumber('abc'), null);
+ assert.equal(parseNumber(''), null);
+ assert.equal(parseDate('2026-03-01T10:00:00Z'), '2026-03-01');
+ assert.equal(parseDate('3/1/2026'), '2026-03-01');
+ assert.equal(parseDate('25/12/2026'), '2026-12-25');
+ assert.equal(parseDate('1.3.2026'), '2026-03-01');
+ assert.equal(parseDate('Mar 5, 2026'), '2026-03-05');
+ assert.equal(parseDate('2026-02-31'), '');
+ assert.equal(parseDate('soon'), '');
+});
+
+test('a column\'s type is guessed from its values', () => {
+ assert.equal(inferType(['1', '2.5', '', '10']).type, 'number');
+ assert.equal(inferType(['2026-01-01', '2026-02-03']).type, 'date');
+ assert.equal(inferType(['yes', 'no', 'yes']).type, 'checkbox');
+ assert.equal(inferType(['1', '0', '1']).type, 'number'); // only 0/1 stays a number
+ assert.equal(inferType(['https://a.b', 'http://c.d']).type, 'url');
+ assert.equal(inferType(['Todo', 'Done', 'Todo', 'Done', 'Doing', 'Done']).type, 'select');
+ assert.equal(inferType(['ui, api', 'api', 'ui, docs', 'api, docs', 'ui']).type, 'multiselect');
+ assert.equal(inferType(['one thing', 'another', 'a third', 'and more text here', 'x']).type, 'text');
+ assert.equal(inferType(['', '']).type, 'text');
+});
+
+test('importing maps columns, makes properties and gathers options', () => {
+ const { rows } = parseCsv('Title,Status,Due,Points,Done,Labels,Extra\nA,Todo,2026-03-01,5,yes,"ui, api",\nB,Done,3/2/2026,"1,200",no,api,\nC,Todo,,x,,,\nD,Todo,,,,ui,');
+ const [header, ...data] = rows;
+ const existing = [{ key: 'status', name: 'Status', type: 'select', options: ['Todo'] }];
+ const cols = autoMap(header, data, existing);
+ assert.deepEqual(cols.map((c) => c.target), ['title', 'existing', 'new', 'new', 'new', 'new', 'skip']);
+ assert.equal(cols[2].type, 'date');
+ assert.equal(cols[3].type, 'text'); // "x" is in the numbers
+ const plan = planImport({ rows: data, columns: cols, existing });
+ assert.deepEqual(plan.properties.map((p) => [p.key, p.type]), [['due', 'date'], ['points', 'text'], ['done', 'checkbox'], ['labels', 'multiselect']]);
+ assert.deepEqual(plan.options, { status: ['Done'] }); // Todo was there already
+ assert.deepEqual(plan.properties[3].options, ['ui', 'api']);
+ assert.deepEqual(plan.items[0], { title: 'A', meta: { status: 'Todo', due: '2026-03-01', points: '5', done: 'true', labels: ['ui', 'api'] } });
+ assert.equal(plan.items[1].meta.due, '2026-03-02');
+ assert.equal(plan.items[1].meta.points, '1,200');
+ assert.deepEqual(plan.items[2].meta, { status: 'Todo', points: 'x' });
+});
+
+test('a number column imports as numbers and names that clash get their own keys', () => {
+ const rows = [['Name', 'Tags', 'Due date', 'Due-date'], ['A', 't', '2026-01-01', '2026-02-02'], ['B', '', '2026-01-03', '']];
+ const cols = autoMap(rows[0], rows.slice(1), []);
+ const plan = planImport({ rows: rows.slice(1), columns: cols, existing: [] });
+ assert.deepEqual(plan.properties.map((p) => p.key), ['tags-2', 'due-date', 'due-date-2']);
+ assert.equal(plan.items[0].meta['due-date-2'], '2026-02-02');
+ const n = planImport({ rows: [['A', '7.5'], ['B', '']], columns: [{ index: 0, target: 'title' }, { index: 1, target: 'new', name: 'Score', type: 'number' }], existing: [] });
+ assert.deepEqual(n.items.map((i) => i.meta.score), ['7.5', undefined]);
+});
+
+test('export writes a Name column and one per property, and imports back the same', () => {
+ const db = { properties: [
+ { key: 'status', name: 'Status', type: 'select', options: ['Todo'] },
+ { key: 'points', name: 'Points', type: 'number' },
+ { key: 'done', name: 'Done', type: 'checkbox' },
+ { key: 'who', name: 'Who', type: 'people' }
+ ] };
+ const recs = [
+ { title: 'Write, docs', props: { status: 'Todo', points: '5', done: 'true', who: ['Ana', 'Ben'] } },
+ { title: 'Plan', props: {} }
+ ];
+ const csv = databaseCsv(db, recs);
+ assert.deepEqual(parseCsv(csv).rows, [
+ ['Name', 'Status', 'Points', 'Done', 'Who'],
+ ['Write, docs', 'Todo', '5', 'true', 'Ana, Ben'],
+ ['Plan', '', '', 'false', '']
+ ]);
+ const [h, ...data] = parseCsv(csv).rows;
+ const cols = autoMap(h, data, db.properties);
+ assert.deepEqual(cols.map((c) => c.target), ['title', 'existing', 'existing', 'existing', 'existing']);
+ const plan = planImport({ rows: data, columns: cols, existing: db.properties });
+ assert.deepEqual(plan.items[0].meta, { status: 'Todo', points: '5', done: 'true', who: ['Ana', 'Ben'] });
+});
diff --git a/test/daily.test.js b/test/daily.test.js
new file mode 100644
index 0000000..ddcb018
--- /dev/null
+++ b/test/daily.test.js
@@ -0,0 +1,33 @@
+// Where daily notes go.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { cleanFolder, autoFolder, dailyId, DEFAULT_FOLDER } from '../src/lib/daily.js';
+
+const page = (id, exists = true) => ({ id, title: id.slice(id.lastIndexOf('/') + 1), exists });
+
+test('a typed folder path is tidied', () => {
+ assert.equal(cleanFolder('Private/Daily'), 'Private/Daily');
+ assert.equal(cleanFolder(' /Private//Daily/ '), 'Private/Daily');
+ assert.equal(cleanFolder('Private\\Daily'), 'Private/Daily');
+ assert.equal(cleanFolder('../../etc'), 'etc', 'no way out of the notes folder');
+ assert.equal(cleanFolder('a/./b'), 'a/b');
+ assert.equal(cleanFolder('We: "ird"?'), 'We- -ird--');
+ assert.equal(cleanFolder(' '), '');
+ assert.equal(cleanFolder(null), '');
+});
+
+test('with nothing chosen, an existing Daily is found where it is', () => {
+ assert.equal(autoFolder([]), DEFAULT_FOLDER);
+ assert.equal(autoFolder([page('Daily'), page('Private/Daily')]), 'Daily', 'the top-level one wins');
+ assert.equal(autoFolder([page('Private'), page('Private/Daily'), page('Private/Daily/2026-01-01')]), 'Private/Daily');
+ assert.equal(autoFolder([page('A/Daily'), page('B/Daily')]), 'Daily', 'two candidates: not guessing');
+ assert.equal(autoFolder([page('Private/Daily', false)]), 'Daily', 'a folder with no page of its own does not count');
+ assert.equal(autoFolder([page('Maps/Daily.canvas')]), 'Daily');
+});
+
+test('the id of a day\'s note', () => {
+ const d = new Date(2026, 0, 5);
+ assert.equal(dailyId('Private/Daily', d), 'Private/Daily/2026-01-05');
+ assert.equal(dailyId('', d), 'Daily/2026-01-05');
+ assert.equal(dailyId('Daily', new Date(2026, 11, 31)), 'Daily/2026-12-31');
+});
diff --git a/test/database.test.js b/test/database.test.js
new file mode 100644
index 0000000..60a29dc
--- /dev/null
+++ b/test/database.test.js
@@ -0,0 +1,266 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ isDatabase, defaultDatabase, parseDatabase, databaseToMd, keyFor, read, write, select, columnsFor,
+ monthGrid, byDay, timeline, matches, dayNum, isoOfDay, valueOf, formatNumber, barFraction, weekOf, optionColor, parseEmbedAlias, embedAlias, computeFormulas, withFormulas, formulaDisplay, aggregate, aggregatesFor, AGGREGATES, opsFor
+} from '../src/lib/database.js';
+
+const props = [
+ { key: 'status', name: 'Status', type: 'select', options: ['Todo', 'Doing', 'Done'] },
+ { key: 'due', name: 'Due', type: 'date' },
+ { key: 'end', name: 'End', type: 'date' },
+ { key: 'points', name: 'Points', type: 'number' },
+ { key: 'done', name: 'Done', type: 'checkbox' },
+ { key: 'who', name: 'Who', type: 'people' }
+];
+const rec = (title, p = {}) => ({ id: 'DB/' + title, title, props: p });
+
+test('marker and schema round trip', () => {
+ assert.equal(isDatabase({ 'ink-database': 'basic' }), true);
+ assert.equal(isDatabase({}), false);
+ const db = defaultDatabase();
+ const md = databaseToMd(db);
+ assert.match(md, /^%% ink-database\n/);
+ const back = parseDatabase(md);
+ assert.deepEqual(back.properties, db.properties);
+ assert.deepEqual(back.views.map((v) => v.type), ['table', 'calendar']); // a board is added by hand, with +
+ assert.equal(databaseToMd(back), md);
+});
+
+test('text around the schema block is kept; damaged JSON falls back to a table', () => {
+ const md = 'Intro line\n\n%% ink-database\n{"properties":[{"key":"a","name":"A","type":"text"}],"views":[]}\n%%\n\nTail';
+ const db = parseDatabase(md);
+ assert.equal(db.properties[0].key, 'a');
+ assert.equal(db.views.length, 1);
+ assert.match(databaseToMd(db), /Intro line\n\nTail/);
+ const bad = parseDatabase('%% ink-database\n{nope\n%%\n');
+ assert.deepEqual(bad.properties, []);
+ assert.equal(bad.views[0].type, 'table');
+ assert.equal(parseDatabase('').properties.length, 2); // a new empty page gets the default schema
+});
+
+test('keys are file-safe, unique and not reserved', () => {
+ assert.equal(keyFor('Due date'), 'due-date');
+ assert.equal(keyFor('Due date', ['due-date']), 'due-date-2');
+ assert.equal(keyFor('Tags'), 'tags-2');
+ assert.equal(keyFor('???'), 'property');
+});
+
+test('values read and write by type', () => {
+ assert.equal(read('3,5', 'number'), 3.5);
+ assert.equal(read('abc', 'number'), null);
+ assert.equal(read('true', 'checkbox'), true);
+ assert.equal(read('', 'checkbox'), false);
+ assert.deepEqual(read('a, b', 'multiselect'), ['a', 'b']);
+ assert.deepEqual(read(['a'], 'people'), ['a']);
+ assert.equal(write(true, 'checkbox'), 'true');
+ assert.equal(write(null, 'number'), '');
+ assert.equal(write(12, 'number'), '12');
+ assert.deepEqual(write(['x', ''], 'people'), ['x']);
+});
+
+test('filters and sort', () => {
+ const rs = [
+ rec('B', { status: 'Doing', points: '5', due: '2026-03-02', done: 'false' }),
+ rec('A', { status: 'Todo', points: '2', due: '2026-03-01', done: 'true' }),
+ rec('C', { points: '9' }),
+ rec('D', { status: 'Done', who: ['Ana'], due: '2026-04-01' })
+ ];
+ const v = (o) => ({ filters: [], sort: null, ...o });
+ assert.deepEqual(select(rs, v({}), props).map((r) => r.title), ['A', 'B', 'C', 'D']);
+ assert.deepEqual(select(rs, v({ sort: { key: 'status', dir: 'asc' } }), props).map((r) => r.title), ['A', 'B', 'D', 'C']); // option order, empty last
+ assert.deepEqual(select(rs, v({ sort: { key: 'points', dir: 'desc' } }), props).map((r) => r.title), ['C', 'B', 'A', 'D']);
+ assert.deepEqual(select(rs, v({ filters: [{ key: 'points', op: '≥', value: '5' }] }), props).map((r) => r.title), ['B', 'C']);
+ assert.deepEqual(select(rs, v({ filters: [{ key: 'due', op: 'before', value: '2026-03-15' }] }), props).map((r) => r.title), ['A', 'B']);
+ assert.deepEqual(select(rs, v({ filters: [{ key: 'who', op: 'contains', value: 'ana' }] }), props).map((r) => r.title), ['D']);
+ assert.deepEqual(select(rs, v({ filters: [{ key: 'done', op: 'is checked' }] }), props).map((r) => r.title), ['A']);
+ assert.deepEqual(select(rs, v({ filters: [{ key: 'status', op: 'is empty' }] }), props).map((r) => r.title), ['C']);
+ assert.deepEqual(select(rs, v({ filters: [{ key: 'title', op: 'contains', value: 'b' }] }), props).map((r) => r.title), ['B']);
+ assert.equal(matches(rs[0], { key: 'status', op: 'is', value: '' }, props), true); // half-made filter
+});
+
+test('board columns: none first, options in order, strays last', () => {
+ const rs = [rec('a', { status: 'Done' }), rec('b'), rec('c', { status: 'Later' }), rec('d', { status: 'Todo' })];
+ const cols = columnsFor(rs, props[0]);
+ assert.deepEqual(cols.map((c) => c.title), ['No status', 'Todo', 'Doing', 'Done', 'Later']);
+ assert.deepEqual(cols.map((c) => c.records.map((r) => r.title)), [['b'], ['d'], [], ['a'], ['c']]);
+ const chk = columnsFor([rec('x', { done: 'true' }), rec('y')], props[4]);
+ assert.deepEqual(chk.map((c) => c.records.length), [1, 1]);
+});
+
+test('calendar grid covers whole weeks, Monday first', () => {
+ const g = monthGrid(2026, 1); // February 2026 starts on a Sunday
+ assert.equal(g.length, 5);
+ assert.equal(g[0][0].iso, '2026-01-26');
+ assert.equal(g[0][6].iso, '2026-02-01');
+ assert.equal(g[4][6].iso, '2026-03-01');
+ assert.equal(g[0][0].inMonth, false);
+ assert.equal(g[1][0].inMonth, true);
+ assert.equal(monthGrid(2026, 2).length, 6); // March 2026: Sunday the 1st, 31 days
+ assert.equal(monthGrid(2025, 5).length, 6);
+});
+
+test('records land on the days they cover', () => {
+ const rs = [rec('one', { due: '2026-03-01' }), rec('span', { due: '2026-03-02', end: '2026-03-04' }), rec('none')];
+ const m = byDay(rs, props[1], props[2]);
+ assert.deepEqual([...m.keys()], ['2026-03-01', '2026-03-02', '2026-03-03', '2026-03-04']);
+ assert.equal(m.get('2026-03-03')[0].title, 'span');
+ assert.equal(isoOfDay(dayNum('2026-03-01') + 31), '2026-04-01');
+});
+
+test('timeline bars and undated records', () => {
+ const rs = [rec('b', { due: '2026-03-10' }), rec('a', { due: '2026-03-02', end: '2026-03-05' }), rec('u')];
+ const t = timeline(rs, props[1], props[2]);
+ assert.deepEqual(t.bars.map((b) => b.rec.title), ['a', 'b']);
+ assert.equal(t.bars[0].end - t.bars[0].start, 3);
+ assert.deepEqual(t.undated.map((r) => r.title), ['u']);
+ assert.ok(t.from < t.bars[0].start && t.to > t.bars[1].end);
+ assert.equal(valueOf(rs[0], 'title'), 'b');
+});
+
+test('number formats show the plain number as currency, percent or a bar', () => {
+ assert.equal(formatNumber(1234.5, { format: 'commas' }), '1,234.5');
+ assert.equal(formatNumber(42, { format: 'percent' }), '42%');
+ assert.equal(formatNumber(1234.5, { format: 'dollar' }), '$1,234.50');
+ assert.equal(formatNumber(7, { format: 'euro' }), '€7.00');
+ assert.equal(formatNumber(3, {}), '3');
+ assert.equal(formatNumber(null, { format: 'percent' }), '');
+ assert.equal(barFraction(50, { format: 'bar' }), 0.5);
+ assert.equal(barFraction(3, { format: 'bar', max: 4 }), 0.75);
+ assert.equal(barFraction(500, { format: 'bar' }), 1);
+ assert.equal(barFraction(-5, { format: 'bar' }), 0);
+});
+
+test('format, max, option colours and column widths survive the file; bad ones are dropped', () => {
+ const db = {
+ properties: [
+ { key: 'n', name: 'N', type: 'number', format: 'bar', max: 20 },
+ { key: 'm', name: 'M', type: 'number', format: 'nonsense', max: 5 },
+ { key: 's', name: 'S', type: 'select', options: ['a', 'b'], colors: { a: 'green', b: 'chartreuse' } }
+ ],
+ views: [{ id: 'v', name: 'T', type: 'table', widths: { n: 300, s: 5, title: 9000, x: 'wide' } }]
+ };
+ const back = parseDatabase(databaseToMd(parseDatabase(databaseToMd({ ...db, rest: '' }))));
+ assert.equal(back.properties[0].format, 'bar');
+ assert.equal(back.properties[0].max, 20);
+ assert.equal(back.properties[1].format, undefined);
+ assert.equal(back.properties[1].max, undefined);
+ assert.deepEqual(back.properties[2].colors, { a: 'green' });
+ assert.deepEqual(back.views[0].widths, { n: 300, s: 80, title: 720 });
+ assert.equal(optionColor('a', back.properties[2]), '#44ad6e');
+ assert.equal(optionColor('b', back.properties[2]), optionColor('b'));
+});
+
+test('a week is Monday to Sunday around any day; relations read as lists', () => {
+ const w = weekOf('2026-03-04'); // a Wednesday
+ assert.equal(w[0].iso, '2026-03-02');
+ assert.equal(w[6].iso, '2026-03-08');
+ assert.deepEqual(weekOf('2026-03-01').map((d) => d.iso)[0], '2026-02-23'); // Sunday belongs to the week before
+ assert.deepEqual(read('A, B', 'relation'), ['A', 'B']);
+ assert.deepEqual(weekOf('nope'), []);
+});
+
+test('an embed keeps its own view, sort, filters and hidden properties in its alias', () => {
+ const o = {
+ view: 'My board',
+ sort: { key: 'due', dir: 'desc' },
+ filters: [{ key: 'status', op: 'is', value: 'In progress' }, { key: 'points', op: '≥', value: '5' }, { key: 'who', op: 'contains', value: 'a;b|c]d:e' }],
+ hidden: ['owner', 'done']
+ };
+ const alias = embedAlias(o);
+ assert.ok(!/[|\]\[]/.test(alias.replace(/%7C|%5D/g, '')), alias);
+ assert.deepEqual(parseEmbedAlias(alias), o);
+ assert.equal(embedAlias({ view: '', sort: null, filters: [], hidden: [] }), '');
+ assert.deepEqual(parseEmbedAlias('a display name'), { view: '', sort: null, filters: [], hidden: [] });
+ assert.deepEqual(parseEmbedAlias('sort=due').sort, { key: 'due', dir: 'asc' });
+});
+
+const fprops = [
+ { key: 'price', name: 'Price', type: 'number' },
+ { key: 'qty', name: 'Quantity', type: 'number' },
+ { key: 'opened', name: 'Opened', type: 'date' },
+ { key: 'expiry', name: 'Expiry', type: 'date' },
+ { key: 'total', name: 'Total', type: 'formula', expression: 'prop("Price") * prop("Quantity")' },
+ { key: 'days', name: 'Days left', type: 'formula', expression: 'if(prop("Expiry"), dateBetween(prop("Expiry"), today(), "days"), "")' },
+ { key: 'label', name: 'Label', type: 'formula', expression: 'concat(prop("Name"), ": ", prop("Total"))' },
+ { key: 'bad', name: 'Bad', type: 'formula', expression: 'prop("Price") / 0' },
+ { key: 'loop', name: 'Loop', type: 'formula', expression: 'prop("Loop2")' },
+ { key: 'loop2', name: 'Loop2', type: 'formula', expression: 'prop("Loop")' }
+];
+const frec = (title, p) => ({ id: 'DB/' + title, title, props: p });
+
+test('formula properties are computed from the other values, including other formulas', () => {
+ const r = frec('Serum', { price: '12.5', qty: '3', expiry: '2026-04-10' });
+ const c = computeFormulas(fprops, r, '2026-03-31');
+ assert.equal(c.total.value, 37.5);
+ assert.equal(c.days.value, 10);
+ assert.equal(c.label.text, 'Serum: 37.5'); // title by name, and a formula using a formula
+ assert.match(c.bad.error, /Division by zero/);
+ assert.match(c.loop.error, /refers to itself|Loop/);
+ const none = computeFormulas(fprops, frec('Empty', {}), '2026-03-31');
+ assert.equal(none.total.value, 0); // empty numbers count as 0
+ assert.equal(none.days.text, ''); // no expiry: the if() gives nothing
+});
+
+test('withFormulas puts the results into props as text and keeps errors apart', () => {
+ const recs = [frec('A', { price: '2', qty: '5' }), frec('B', { price: '4', qty: '1' })];
+ const out = withFormulas(recs, fprops, '2026-03-31');
+ assert.equal(out[0].props.total, '10');
+ assert.equal(out[1].props.total, '4');
+ assert.ok(out[0].fx.bad);
+ assert.equal(recs[0].props.total, undefined); // the originals are untouched
+ assert.equal(withFormulas(recs, fprops.slice(0, 4)), recs); // no formulas: the same list
+ // sorting and filtering work on the results, numerically
+ const sorted = select(withFormulas([frec('A', { price: '9', qty: '1' }), frec('B', { price: '2', qty: '10' }), frec('C', { price: '1', qty: '1' })], fprops, '2026-03-31'), { filters: [], sort: { key: 'total', dir: 'desc' } }, fprops);
+ assert.deepEqual(sorted.map((r) => r.title), ['B', 'A', 'C']); // 20, 9, 1 (not "9" > "20" as text)
+ const big = select(withFormulas(recs, fprops, '2026-03-31'), { filters: [{ key: 'total', op: '>', value: '5' }], sort: null }, fprops);
+ assert.deepEqual(big.map((r) => r.title), ['A']);
+ assert.ok(opsFor('formula').includes('≥'));
+});
+
+test('a formula shows numbers in its format and survives the file', () => {
+ assert.equal(formulaDisplay({ format: 'dollar' }, '1234.5'), '$1,234.50');
+ assert.equal(formulaDisplay({}, '1234.5'), '1234.5');
+ assert.equal(formulaDisplay({ format: 'dollar' }, '2026-03-01'), '2026-03-01'); // a date is not a price
+ assert.equal(formulaDisplay({ format: 'percent' }, 'abc'), 'abc');
+ const md = databaseToMd({ properties: [{ key: 't', name: 'T', type: 'formula', expression: 'prop("A") + 1', format: 'euro' }], views: [], rest: '' });
+ const back = parseDatabase(md).properties[0];
+ assert.equal(back.expression, 'prop("A") + 1');
+ assert.equal(back.format, 'euro');
+});
+
+test('totals: the aggregates for each kind of column', () => {
+ const agg = (kind, values, type, p) => aggregate(kind, values, type, p).text;
+ const nums = [10, 20, null, 30];
+ assert.equal(agg('count', nums, 'number'), '4');
+ assert.equal(agg('filled', nums, 'number'), '3');
+ assert.equal(agg('empty', nums, 'number'), '1');
+ assert.equal(agg('sum', nums, 'number'), '60');
+ assert.equal(agg('avg', nums, 'number'), '20');
+ assert.equal(agg('median', [1, 5, 3, 7], 'number'), '4');
+ assert.equal(agg('min', nums, 'number'), '10');
+ assert.equal(agg('max', nums, 'number'), '30');
+ assert.equal(agg('range', nums, 'number'), '20');
+ assert.equal(agg('sum', [], 'number'), '0');
+ assert.equal(agg('avg', [], 'number'), '');
+ assert.equal(agg('sum', [10, 20.5], 'number', { type: 'number', format: 'euro' }), '€30.50'); // in the property's format
+ assert.equal(agg('sum', ['1.5', '2', '', '2026-01-01'], 'formula'), '3.5'); // numeric text from a formula
+ assert.equal(agg('percentFilled', ['a', '', 'b', ''], 'text'), '50%');
+ assert.equal(agg('unique', [['a', 'b'], ['B'], []], 'multiselect'), '2');
+ assert.equal(agg('checked', [true, false, true], 'checkbox'), '2');
+ assert.equal(agg('percentChecked', [true, false, true, true], 'checkbox'), '75%');
+ assert.equal(agg('earliest', ['2026-03-05', '', '2026-01-02'], 'date'), '2026-01-02');
+ assert.equal(agg('latest', ['2026-03-05', '', '2026-01-02'], 'date'), '2026-03-05');
+ assert.equal(agg('span', ['2026-03-05', '2026-01-02'], 'date'), '62');
+ assert.deepEqual(aggregatesFor('number').map((a) => a.value).slice(0, 5), ['count', 'filled', 'empty', 'unique', 'percentFilled']);
+ assert.ok(aggregatesFor('number').some((a) => a.value === 'sum'));
+ assert.ok(!aggregatesFor('text').some((a) => a.value === 'sum'));
+ assert.ok(aggregatesFor('checkbox').some((a) => a.value === 'percentChecked'));
+ assert.ok(AGGREGATES.length >= 15);
+});
+
+test('a view keeps its totals in the file, and drops unknown ones', () => {
+ const db = { properties: [{ key: 'p', name: 'P', type: 'number' }], views: [{ id: 'v', name: 'T', type: 'table', calc: { p: 'sum', title: 'count', x: 'nonsense' } }], rest: '' };
+ const back = parseDatabase(databaseToMd(db));
+ assert.deepEqual(back.views[0].calc, { p: 'sum', title: 'count' });
+});
diff --git a/test/e2e-a11y.test.cjs b/test/e2e-a11y.test.cjs
new file mode 100644
index 0000000..8bb26eb
--- /dev/null
+++ b/test/e2e-a11y.test.cjs
@@ -0,0 +1,95 @@
+// End-to-end: keyboard use of the sidebar, focus inside dialogs, rebinding a shortcut, long folders shown in steps.
+const { app, BrowserWindow } = require('electron');
+const path = require('path');
+const os = require('os');
+const fs = require('fs');
+
+const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ink-e2e-a11y-'));
+const vault = path.join(tmp, 'Vault');
+fs.mkdirSync(path.join(vault, 'Big'), { recursive: true });
+fs.writeFileSync(path.join(vault, 'Big.md'), 'big\n');
+for (let i = 0; i < 250; i++) fs.writeFileSync(path.join(vault, 'Big', `Item ${String(i).padStart(3, '0')}.md`), `item ${i}\n`);
+fs.writeFileSync(path.join(vault, 'Alpha.md'), 'a\n');
+fs.writeFileSync(path.join(vault, 'Beta.md'), 'b\n');
+app.setPath('userData', tmp);
+fs.writeFileSync(path.join(tmp, 'settings.json'), JSON.stringify({ vault, lastPage: 'Alpha', theme: 'dark', bounds: { width: 1200, height: 800 } }));
+require('../electron/main.cjs');
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+const failures = [];
+const check = (name, ok, extra = '') => {
+ console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${extra ? ' — ' + extra : ''}`);
+ if (!ok) failures.push(name);
+};
+
+app.whenReady().then(async () => {
+ let win;
+ for (let i = 0; i < 80 && !(win = BrowserWindow.getAllWindows()[0]); i++) await sleep(100);
+ const wc = win.webContents;
+ if (wc.isLoading()) await new Promise((r) => wc.once('did-finish-load', r));
+ const js = (code) => wc.executeJavaScript(`(async () => { ${code} })()`);
+ const key = async (keyCode, modifiers = []) => { wc.sendInputEvent({ type: 'keyDown', keyCode, modifiers }); wc.sendInputEvent({ type: 'keyUp', keyCode, modifiers }); await sleep(250); };
+ const active = () => js(`const a = document.activeElement; return a && (a.dataset.id || a.getAttribute('aria-label') || a.className)`);
+ try {
+ await sleep(2200);
+
+ // sidebar: arrow keys
+ await js(`document.querySelector('.tree .row[data-id="Alpha"]').focus()`);
+ await key('Down');
+ check('Down moves to the next row', (await active()) === 'Beta', await active());
+ await key('Up');
+ await key('Up');
+ check('Up moves back, and stops at the first row', (await active()) === 'Alpha', await active());
+ await key('End');
+ check('End goes to the last row', (await active()) === 'Big', await active());
+ check('only the open page is a tab stop in the list', (await js(`return document.querySelectorAll('.tree .row[tabindex="0"]').length`)) === 1);
+
+ // long folders come in steps
+ await js(`document.querySelector('.tree .row[data-id="Big"]').focus()`);
+ await key('Right');
+ await sleep(400);
+ check('a long folder shows its first 100', (await js(`return document.querySelectorAll('.tree .row[data-id^="Big/"]').length`)) === 100);
+ await js(`document.querySelector('.tree .row.more').click()`);
+ await sleep(300);
+ check('...and "show more" adds the next 100', (await js(`return document.querySelectorAll('.tree .row[data-id^="Big/"]').length`)) === 200);
+
+ // dialogs keep focus
+ await js(`document.querySelector('.sidebar button[aria-label="Tasks"]').focus()`);
+ await js(`document.querySelector('.sidebar button[aria-label="Tasks"]').click()`);
+ await sleep(500);
+ check('a dialog is modal and holds focus', (await js(`const d = document.querySelector('[role=dialog]'); return d.getAttribute('aria-modal') === 'true' && d.contains(document.activeElement)`)) === true);
+ for (let i = 0; i < 12; i++) await key('Tab');
+ check('Tab stays inside it', (await js(`return document.querySelector('[role=dialog]').contains(document.activeElement)`)) === true);
+ await key('Escape');
+ await sleep(600);
+ check('Escape closes it and focus goes back', (await js(`return !document.querySelector('[role=dialog]') && document.activeElement?.getAttribute('aria-label') === 'Tasks'`)) === true);
+
+ // rebinding
+ await key('Y', ['control', 'shift']);
+ check('Ctrl+Shift+Y does nothing yet', (await js(`return !document.querySelector('.tasks-panel')`)) === true);
+ await js(`document.querySelector('.sidebar button[aria-label="Settings"]').click()`);
+ await sleep(400);
+ await js(`[...document.querySelectorAll('.set-nav button')].find((b) => b.textContent.includes('Shortcuts')).click()`);
+ await sleep(200);
+ await js(`document.querySelector('button[aria-label="Change shortcut for Tasks"]').click()`);
+ await sleep(150);
+ await key('Y', ['control', 'shift']);
+ await sleep(400);
+ const saved = JSON.parse(fs.readFileSync(path.join(tmp, 'settings.json'), 'utf8'));
+ check('the new shortcut is saved', saved.prefs && saved.prefs.shortcuts && saved.prefs.shortcuts.tasks === 'Mod+Shift+Y', JSON.stringify(saved.prefs && saved.prefs.shortcuts));
+ await key('Escape');
+ await sleep(500);
+ await key('Y', ['control', 'shift']);
+ await sleep(400);
+ check('...and opens Tasks', (await js(`return !!document.querySelector('.tasks-panel')`)) === true);
+ await key('Escape');
+ await sleep(300);
+ await key('T', ['control', 'shift']);
+ check('the old one no longer does', (await js(`return !document.querySelector('.tasks-panel')`)) === true);
+ } catch (err) {
+ check('harness', false, err.stack);
+ }
+ fs.rmSync(tmp, { recursive: true, force: true });
+ console.log(failures.length ? `\n${failures.length} check(s) failed` : '\na11y e2e passed!');
+ app.exit(failures.length ? 1 : 0);
+});
diff --git a/test/e2e-board-drag.test.cjs b/test/e2e-board-drag.test.cjs
new file mode 100644
index 0000000..cd4715d
--- /dev/null
+++ b/test/e2e-board-drag.test.cjs
@@ -0,0 +1,145 @@
+// End-to-end check of dragging in a Kanban board: the card follows the pointer at once, never starts from the
+// window's corner, and costs little per move. Run with `bun run test:e2e`.
+const { app, BrowserWindow } = require('electron');
+const path = require('path');
+const os = require('os');
+const fs = require('fs');
+
+const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ink-e2e-board-drag-'));
+const vault = path.join(tmp, 'Vault');
+fs.mkdirSync(vault);
+const cards = (n, tag) => Array.from({ length: n }, (_, i) => `- [ ] ${tag} card ${i + 1} with a few words of text`).join('\n');
+fs.writeFileSync(path.join(vault, 'Board.md'), `---\nkanban-plugin: basic\n---\n\n## To do\n\n${cards(4, 'todo')}\n\n\n## Doing\n\n${cards(2, 'doing')}\n\n\n## Done\n\n`);
+// a big board, to see what a drag costs when there is a lot on the page
+fs.writeFileSync(path.join(vault, 'Big.md'), `---\nkanban-plugin: basic\n---\n\n` + ['A', 'B', 'C', 'D'].map((c) => `## Column ${c}\n\n${cards(60, c)}\n`).join('\n\n') + '\n');
+app.setPath('userData', tmp);
+fs.writeFileSync(path.join(tmp, 'settings.json'), JSON.stringify({ vault, lastPage: 'Board', theme: 'dark', bounds: { width: 1400, height: 900 } }));
+require('../electron/main.cjs');
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+const failures = [];
+// the board file as { column: [card texts] }
+const columnsOf = (md) => Object.fromEntries(md.split(/\n## /).slice(1).map((sec) => { const [title, ...rest] = sec.split('\n'); return [title.trim(), rest.filter((l) => l.startsWith('- [')).map((l) => l.replace(/^- \[.\] /, '').replace(/ with a few words of text$/, ''))]; }));
+const check = (name, ok, extra = '') => {
+ console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${extra ? ' — ' + extra : ''}`);
+ if (!ok) failures.push(name);
+};
+
+app.whenReady().then(async () => {
+ let win;
+ for (let i = 0; i < 80 && !(win = BrowserWindow.getAllWindows()[0]); i++) await sleep(100);
+ const wc = win.webContents;
+ if (wc.isLoading()) await new Promise((r) => wc.once('did-finish-load', r));
+ const js = (code) => wc.executeJavaScript(`(async () => { ${code} })()`);
+ const mouse = (type, x, y) => wc.sendInputEvent({ type, x: Math.round(x), y: Math.round(y), button: 'left', clickCount: 1 });
+ const read = () => fs.readFileSync(path.join(vault, 'Board.md'), 'utf8');
+ const centerOf = (sel) => js(`const e = ${sel}; if (!e) return null; const r = e.getBoundingClientRect(); return [r.x + r.width / 2, r.y + r.height / 2, r.x, r.y, r.width, r.height]`);
+ const ghost = () => js(`const g = document.querySelector('.kghost'); if (!g) return null; const r = g.getBoundingClientRect(); return [r.x + r.width / 2, r.y + r.height / 2, getComputedStyle(g).transitionProperty, getComputedStyle(g).transitionDuration]`);
+ const card = (text) => `[...document.querySelectorAll('.kcard')].find(k => k.textContent.includes(${JSON.stringify(text)}))`;
+
+ try {
+ await sleep(2200);
+ // a frame log: where is the floating card at every animation frame?
+ await js(`window.__frames = []; const tick = () => { const g = document.querySelector('.kghost'); if (g) { const r = g.getBoundingClientRect(); window.__frames.push([Math.round(r.x + r.width / 2), Math.round(r.y + r.height / 2)]); } requestAnimationFrame(tick); }; tick();`);
+
+ // ---- grab a card and start moving ----
+ const c = await centerOf(card('todo card 2'));
+ mouse('mouseMove', c[0], c[1]);
+ mouse('mouseDown', c[0], c[1]);
+ await sleep(40);
+ // The pointer's offset inside the card is kept, so the floating card's centre = pointer + (card centre - grab point).
+ const dx = c[0] - c[0], dy = c[1] - c[1];
+ const moves = [[c[0] + 30, c[1] + 10], [c[0] + 120, c[1] + 40], [c[0] + 260, c[1] + 70]];
+ const seen = [];
+ for (const [x, y] of moves) {
+ mouse('mouseMove', x, y);
+ await sleep(40); // two frames: more than enough for something that follows the pointer
+ const g = await ghost();
+ seen.push({ want: [Math.round(x + dx), Math.round(y + dy)], got: g && [Math.round(g[0]), Math.round(g[1])], tr: g && g[2] + ' ' + g[3] });
+ }
+ const off = seen.map((s) => (s.got ? Math.hypot(s.got[0] - s.want[0], s.got[1] - s.want[1]) : Infinity));
+ check('the floating card is under the pointer 40 ms after every move', off.every((d) => d < 12), JSON.stringify(seen));
+ check('its position is not animated (no transition on it)', seen.every((s) => /^0s(, 0s)*$/.test((s.tr || '').split(' ').slice(1).join(' ')) || /none/.test(s.tr?.split(' ')[0] || '')), JSON.stringify(seen.map((s) => s.tr)));
+ const frames = await js(`return window.__frames`);
+ const nearCorner = frames.filter(([x, y]) => x < 150 && y < 150);
+ check('it never shows up in the window\'s top-left corner', nearCorner.length === 0, `${nearCorner.length} of ${frames.length} frames: ${JSON.stringify(nearCorner.slice(0, 4))}`);
+
+ // ---- quick, fine-grained movement ----
+ const last = moves[moves.length - 1];
+ for (let i = 1; i <= 40; i++) { mouse('mouseMove', last[0] + i * 4, last[1] + i * 2); await sleep(6); }
+ await sleep(40);
+ const g2 = await ghost();
+ const want2 = [last[0] + 160, last[1] + 80];
+ check('it keeps up with fast movement', g2 && Math.hypot(g2[0] - want2[0], g2[1] - want2[1]) < 12, JSON.stringify({ want: want2.map(Math.round), got: g2 && g2.slice(0, 2).map(Math.round) }));
+
+ // ---- finishing the drag still moves the card ----
+ const target = await centerOf(card('doing card 1'));
+ mouse('mouseMove', target[0], target[1] + 5);
+ await sleep(100);
+ mouse('mouseUp', target[0], target[1] + 5);
+ await sleep(900);
+ const cols = columnsOf(read());
+ check('dropping moves the card to the other column, at the spot it was dropped', JSON.stringify(cols['To do']) === '["todo card 1","todo card 3","todo card 4"]' && JSON.stringify(cols.Doing) === '["doing card 1","todo card 2","doing card 2"]', JSON.stringify(cols));
+
+ // ---- dragging a whole column ----
+ const head = await centerOf(`[...document.querySelectorAll('.kcol')][0].querySelector('.kh-n')`);
+ const doneHead = await centerOf(`[...document.querySelectorAll('.kcol')][2].querySelector('.khead')`);
+ mouse('mouseMove', head[0], head[1]);
+ mouse('mouseDown', head[0], head[1]);
+ await sleep(40);
+ mouse('mouseMove', head[0] + 60, head[1] + 4);
+ await sleep(60);
+ const gc = await ghost();
+ check('a dragged column follows the pointer too', gc && Math.hypot(gc[0] - (head[0] + 60 + (await centerOf(`[...document.querySelectorAll('.kcol')][0]`))[0] - head[0]), gc[1] - (head[1] + 4 + (await centerOf(`[...document.querySelectorAll('.kcol')][0]`))[1] - head[1])) < 14 && /none/.test(gc[2]), JSON.stringify(gc));
+ mouse('mouseMove', doneHead[0] + 120, doneHead[1]);
+ await sleep(100);
+ mouse('mouseUp', doneHead[0] + 120, doneHead[1]);
+ await sleep(900);
+ check('...and is dropped in its new place', Object.keys(columnsOf(read())).join() === 'Doing,Done,To do', Object.keys(columnsOf(read())).join());
+
+ // ---- a lot on the page: moving over it costs little ----
+ await js(`[...document.querySelectorAll('.tree .row')].find(r => r.dataset.id === 'Big').click()`);
+ await sleep(1800);
+ await js(`window.__long = []; try { new PerformanceObserver((l) => l.getEntries().forEach((e) => window.__long.push(Math.round(e.duration)))).observe({ entryTypes: ['longtask'] }); } catch {}`);
+ await js(`window.__muts = 0; window.__mo = new MutationObserver((l) => { window.__muts += l.length; }); window.__mo.observe(document.querySelector('.board'), { subtree: true, attributes: true, childList: true });`);
+ const b = await centerOf(card('A card 5'));
+ mouse('mouseMove', b[0], b[1]);
+ mouse('mouseDown', b[0], b[1]);
+ await sleep(60);
+ mouse('mouseMove', b[0] + 20, b[1] + 20);
+ await sleep(120);
+ await js(`window.__muts = 0`);
+ // 60 pointer events that all stay over the same drop position
+ for (let i = 0; i < 60; i++) { mouse('mouseMove', b[0] + 20 + (i % 6), b[1] + 20 + (i % 4)); await sleep(5); }
+ await sleep(100);
+ const muts = await js(`return window.__muts`);
+ check('moving within one drop position barely touches the page', muts <= 6, `${muts} DOM changes for 60 moves`);
+ // sweep down a column and across
+ for (let i = 0; i < 80; i++) { mouse('mouseMove', b[0] + i * 7, b[1] + 20 + i * 5); await sleep(8); }
+ await sleep(150);
+ mouse('mouseUp', b[0] + 80 * 7, b[1] + 20 + 80 * 5);
+ await sleep(500);
+ // the card is let go over the right edge of the board: sit there without moving
+ const edge = await js(`const r = document.querySelector('.board').getBoundingClientRect(); return [Math.round(r.right - 20), Math.round(r.top + 120)]`);
+ mouse('mouseMove', b[0], b[1]);
+ mouse('mouseDown', b[0], b[1]);
+ await sleep(60);
+ mouse('mouseMove', b[0] + 15, b[1] + 15);
+ await sleep(60);
+ mouse('mouseMove', edge[0], edge[1]);
+ await sleep(150);
+ const s1 = await js(`return document.querySelector('.board').scrollLeft`);
+ await sleep(400);
+ const s2 = await js(`return document.querySelector('.board').scrollLeft`);
+ mouse('mouseUp', edge[0], edge[1]);
+ await sleep(500);
+ check('holding a card at the board\'s edge keeps scrolling, without moving the mouse', s2 > s1 + 20, `${s1} -> ${s2}`);
+ const long = await js(`return window.__long`);
+ check('no long freezes while dragging over 240 cards', long.every((d) => d < 120), JSON.stringify(long));
+ } catch (err) {
+ check('harness', false, err.stack);
+ }
+ fs.rmSync(tmp, { recursive: true, force: true });
+ console.log(failures.length ? `\n${failures.length} check(s) failed` : '\nboard drag e2e passed!');
+ app.exit(failures.length ? 1 : 0);
+});
diff --git a/test/e2e-canvas.test.cjs b/test/e2e-canvas.test.cjs
new file mode 100644
index 0000000..50e97af
--- /dev/null
+++ b/test/e2e-canvas.test.cjs
@@ -0,0 +1,682 @@
+// End-to-end check of canvases (".canvas" files, as in Obsidian). Run with `bun run test:e2e`.
+const { app, BrowserWindow, clipboard, dialog, nativeImage } = require('electron');
+const path = require('path');
+const os = require('os');
+const fs = require('fs');
+
+const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ink-e2e-canvas-'));
+const vault = path.join(tmp, 'Vault');
+fs.mkdirSync(path.join(vault, 'Projects'), { recursive: true });
+fs.writeFileSync(path.join(vault, 'Projects', 'Roadmap.md'), '# Roadmap\n\nIntro text.\n\n## Goals\n\nShip the canvas.\n\n## Later\n\nNot shown.\n');
+fs.writeFileSync(path.join(vault, 'Notes.md'), 'Open [[Plan.canvas]] to see the plan.\n');
+// as Obsidian writes it
+const PLAN = `{
+\t"nodes":[
+\t\t{"id":"aaaaaaaaaaaaaaaa","type":"text","text":"## Idea\\n\\nA **bold** start","x":0,"y":0,"width":260,"height":140},
+\t\t{"id":"bbbbbbbbbbbbbbbb","type":"file","file":"Projects/Roadmap.md","subpath":"#Goals","x":400,"y":0,"width":320,"height":240},
+\t\t{"id":"cccccccccccccccc","type":"link","url":"https://jsoncanvas.org","x":0,"y":300,"width":260,"height":120}
+\t],
+\t"edges":[
+\t\t{"id":"eeeeeeeeeeeeeeee","fromNode":"aaaaaaaaaaaaaaaa","fromSide":"right","toNode":"bbbbbbbbbbbbbbbb","toSide":"left","label":"leads to"}
+\t]
+}`;
+fs.writeFileSync(path.join(vault, 'Plan.canvas'), PLAN);
+fs.writeFileSync(path.join(vault, 'Broken.canvas'), '{ "nodes": [ oops');
+// no real network: one address answers with a page, anything else is "not found"
+const linkpreview = require('../electron/linkpreview.cjs');
+linkpreview.hooks.lookup = async () => [{ address: '93.184.216.34' }];
+linkpreview.hooks.fetch = async (url) => (new URL(url).hostname === 'jsoncanvas.org'
+ ? new Response('
fallback', { headers: { 'content-type': 'text/html; charset=utf-8' } })
+ : new Response('nope', { status: 404 }));
+app.setPath('userData', tmp);
+fs.writeFileSync(path.join(tmp, 'settings.json'), JSON.stringify({ vault, lastPage: 'Notes', theme: 'dark', bounds: { width: 1400, height: 900 } }));
+require('../electron/main.cjs');
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+const failures = [];
+const check = (name, ok, extra = '') => {
+ console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${extra ? ' — ' + extra : ''}`);
+ if (!ok) failures.push(name);
+};
+
+app.whenReady().then(async () => {
+ let win;
+ for (let i = 0; i < 80 && !(win = BrowserWindow.getAllWindows()[0]); i++) await sleep(100);
+ const wc = win.webContents;
+ if (wc.isLoading()) await new Promise((r) => wc.once('did-finish-load', r));
+ const js = (code) => wc.executeJavaScript(`(async () => { ${code} })()`);
+ const key = async (keyCode, modifiers = []) => { wc.sendInputEvent({ type: 'keyDown', keyCode, modifiers }); wc.sendInputEvent({ type: 'keyUp', keyCode, modifiers }); await sleep(250); };
+ const type = async (t) => { for (const ch of t) { wc.sendInputEvent({ type: 'char', keyCode: ch }); await sleep(30); } await sleep(150); };
+ const m = (type, x, y, extra = {}) => wc.sendInputEvent({ type, x: Math.round(x), y: Math.round(y), button: 'left', clickCount: 1, ...extra });
+ const click = async (x, y, mods = []) => { m('mouseMove', x, y); m('mouseDown', x, y, { modifiers: mods }); m('mouseUp', x, y, { modifiers: mods }); await sleep(250); };
+ const dbl = async (x, y) => { m('mouseMove', x, y); m('mouseDown', x, y); m('mouseUp', x, y); m('mouseDown', x, y, { clickCount: 2 }); m('mouseUp', x, y, { clickCount: 2 }); await sleep(400); };
+ const drag = async (x1, y1, x2, y2, steps = 12) => {
+ m('mouseMove', x1, y1); m('mouseDown', x1, y1); await sleep(30);
+ for (let i = 1; i <= steps; i++) { m('mouseMove', x1 + ((x2 - x1) * i) / steps, y1 + ((y2 - y1) * i) / steps); await sleep(20); }
+ await sleep(60); m('mouseUp', x2, y2); await sleep(400);
+ };
+ const box = (sel) => js(`const e = ${sel}; if (!e) return null; const r = e.getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height, cx: r.x + r.width / 2, cy: r.y + r.height / 2 }`);
+ const node = (id) => `document.querySelector('.cv-node[data-id="${id}"]')`;
+ const file = (f = 'Plan.canvas') => fs.readFileSync(path.join(vault, f), 'utf8');
+ const canvasOf = (f) => JSON.parse(file(f));
+ const openRow = async (id) => { await js(`document.querySelector('.tree .row[data-id=${JSON.stringify(id)}]').click()`); await sleep(1000); };
+
+ try {
+ await sleep(2000);
+
+ // ---- it shows up and opens ----
+ check('a .canvas file is listed in the sidebar with the canvas icon, by its name', await js(`const r = document.querySelector('.tree .row[data-id="Plan.canvas"]'); return !!r && r.querySelector('.name').textContent === 'Plan'`));
+ await js(`document.querySelector('.blocks .wl').click()`);
+ await sleep(1200);
+ check('a [[Plan.canvas]] link opens it', !!(await js(`return document.querySelector('.cv')`)));
+ check('three items, one arrow with its label', (await js(`return document.querySelectorAll('.cv-node').length`)) === 3 && (await js(`return document.querySelectorAll('.cv-line').length`)) === 1 && (await js(`return document.querySelector('.cv-elabel')?.textContent`)) === 'leads to');
+ check('a text card shows its Markdown', (await js(`return document.querySelector(${JSON.stringify(`.cv-node[data-id="aaaaaaaaaaaaaaaa"] .eb.b-h2`)})?.textContent`)) === 'Idea' && !!(await js(`return ${node('aaaaaaaaaaaaaaaa')}.querySelector('b,strong')`)));
+ await sleep(500);
+ const preview = await js(`return ${node('bbbbbbbbbbbbbbbb')}.querySelector('.cv-scroll').textContent`);
+ check('a note shows its section (#Goals) only', preview.includes('Ship the canvas.') && !preview.includes('Not shown') && !preview.includes('Intro'), JSON.stringify(preview));
+ await sleep(800);
+ check('a web link card shows the page title and description', (await js(`return ${node('cccccccccccccccc')}.querySelector('.cv-ftitle').textContent`)) === 'JSON Canvas & friends' &&
+ (await js(`return ${node('cccccccccccccccc')}.querySelector('.cv-desc')?.textContent`)) === 'An open file format for infinite canvas data.' &&
+ (await js(`return ${node('cccccccccccccccc')}.querySelector('.cv-url').textContent`)) === 'jsoncanvas.org');
+ await js(`await window.vault.setSettings({ linkPreviews: false })`);
+ check('with previews turned off in settings, nothing is fetched', (await js(`return (await window.vault.linkPreview('https://jsoncanvas.org')).off`)) === true);
+ await js(`await window.vault.setSettings({ linkPreviews: true })`);
+ // ---- right-click menu ----
+ const rclick = async (x, y) => { m('mouseMove', x, y); m('mouseDown', x, y, { button: 'right' }); m('mouseUp', x, y, { button: 'right' }); await sleep(350); };
+ const menuItems = () => js(`return [...document.querySelectorAll('.cv-menu .mi')].map((b) => b.textContent.trim())`);
+ const menuClick = async (label) => {
+ const b = await box(`[...document.querySelectorAll('.cv-menu .mi')].find((x) => x.textContent.trim() === ${JSON.stringify(label)})`);
+ await click(b.cx, b.cy);
+ };
+ const text = await box(node('aaaaaaaaaaaaaaaa'));
+ await rclick(text.cx, text.cy);
+ const onCard = await menuItems();
+ check('right-clicking a card opens its menu and selects it', onCard.includes('Edit') && onCard.includes('Duplicate') && onCard.includes('Bring to front') && onCard.includes('Delete') && (await js(`return ${node('aaaaaaaaaaaaaaaa')}.classList.contains('sel')`)));
+ await key('Escape');
+ check('Escape closes the menu', !(await js(`return document.querySelector('.cv-menu')`)));
+ // a point on the arrow (its label, or the line) that nothing else covers
+ const lbl = await js(`const r = document.querySelector('.cv-elabel[data-edge]').getBoundingClientRect(); for (let x = r.left + 2; x < r.right - 2; x += 3) { const y = r.top + r.height / 2; const h = document.elementFromPoint(x, y); if (h && h.closest('[data-edge]')) return { cx: x, cy: y }; } return null`);
+ await rclick(lbl.cx, lbl.cy);
+ const arrowItems = await menuItems();
+ check('right-clicking an arrow offers its label and direction', arrowItems.includes('Reverse direction') && arrowItems.includes('Edit label'), JSON.stringify(arrowItems));
+ await menuClick('Reverse direction');
+ await sleep(700);
+ const rev = canvasOf().edges.find((e) => e.id === 'eeeeeeeeeeeeeeee');
+ check('"Reverse direction" swaps the arrow\'s ends in the file', rev && rev.fromNode === 'bbbbbbbbbbbbbbbb' && rev.toNode === 'aaaaaaaaaaaaaaaa' && rev.fromSide === 'left' && rev.toSide === 'right', JSON.stringify(rev));
+ const link = await box(node('cccccccccccccccc'));
+ await rclick(link.cx, link.cy);
+ check('a web link card offers "Open in browser"', (await menuItems()).includes('Open in browser'));
+ await menuClick('Bring to front');
+ await sleep(700);
+ const order = canvasOf().nodes.map((n) => n.id);
+ check('"Bring to front" moves it to the end of the file', order[order.length - 1] === 'cccccccccccccccc', order.join());
+ const countBefore = canvasOf().nodes.length;
+ const vp = await box(`document.querySelector('.cv')`);
+ await rclick(vp.x + vp.w - 120, vp.y + 120);
+ check('right-clicking empty space offers canvas actions', (await menuItems()).includes('Add card here') && (await menuItems()).includes('Show everything'));
+ await menuClick('Add card here');
+ await sleep(300);
+ check('"Add card here" starts a card in edit mode', !!(await js(`return document.querySelector('.cv-ed')`)));
+ await key('Escape');
+ await sleep(500);
+ check('...and left empty it goes again', canvasOf().nodes.length === countBefore);
+ const l2 = await box(node('cccccccccccccccc'));
+ await rclick(l2.cx, l2.cy);
+ await menuClick('Delete');
+ await sleep(700);
+ check('"Delete" in the menu removes the card from the file', !canvasOf().nodes.some((n) => n.id === 'cccccccccccccccc'));
+ await key('z', ['control']);
+ await sleep(600);
+ check('...and Ctrl+Z brings it back', canvasOf().nodes.some((n) => n.id === 'cccccccccccccccc'));
+ await key('z', ['control']);
+ await key('z', ['control']);
+ await sleep(500);
+ check('undoing the menu actions restores the file as it was', file() === PLAN);
+
+ // ---- align and distribute ----
+ const freePoint = (id) => js(`const n = document.querySelector('.cv-node[data-id="${id}"]'); const r = n.getBoundingClientRect(); for (let fy = 0.1; fy < 0.9; fy += 0.05) for (let fx = 0.1; fx < 0.9; fx += 0.05) { const x = r.left + r.width * fx, y = r.top + r.height * fy; const h = document.elementFromPoint(x, y); if (h && h.closest('.cv-node')?.dataset.id === '${id}' && !h.closest('.cv-ui,.cv-resize,.cv-dot')) return { cx: x, cy: y }; } return null`);
+ const place = (id) => { const n = canvasOf().nodes.find((x) => x.id === id); return n; };
+ const pickCards = async (ids) => {
+ let first = true;
+ for (const id of ids) {
+ const p = await freePoint(id);
+ await click(p.cx, p.cy, first ? [] : ['shift']);
+ first = false;
+ }
+ };
+ const trio = ['aaaaaaaaaaaaaaaa', 'bbbbbbbbbbbbbbbb', 'cccccccccccccccc'];
+ const vpA = await box(`document.querySelector('.cv')`);
+ const beforeAlign = file();
+ await pickCards(trio);
+ const offered = (await js(`return [...document.querySelectorAll('.cv-selbar [data-align]')].map((b) => b.dataset.align)`)).join();
+ check('with several cards selected the bar offers align and spread', offered === 'left,centerX,right,top,centerY,bottom,spreadX,spreadY', offered);
+ const minX = Math.min(...trio.map((id) => place(id).x));
+ let ab = await box(`document.querySelector('.cv-selbar [data-align="left"]')`);
+ await click(ab.cx, ab.cy);
+ await sleep(700);
+ check('"Align left" puts the left edges on the leftmost one', trio.every((id) => place(id).x === minX), JSON.stringify(trio.map((id) => place(id).x)));
+ await click(vpA.x + vpA.w - 40, vpA.y + 40); // empty space: back to the canvas, selection cleared
+ await key('z', ['control']);
+ await sleep(500);
+ check('...and Ctrl+Z puts them back', file() === beforeAlign);
+ await pickCards(trio);
+ ab = await box(`document.querySelector('.cv-selbar [data-align="top"]')`);
+ await click(ab.cx, ab.cy);
+ await sleep(700);
+ const minY = Math.min(...trio.map((id) => beforeY(id)));
+ function beforeY(id) { return JSON.parse(beforeAlign).nodes.find((n) => n.id === id).y; }
+ check('"Align top" puts the top edges level', trio.every((id) => place(id).y === minY));
+ await click(vpA.x + vpA.w - 40, vpA.y + 40);
+ await key('z', ['control']);
+ await sleep(500);
+ await pickCards(trio);
+ ab = await box(`document.querySelector('.cv-selbar [data-align="spreadX"]')`);
+ await click(ab.cx, ab.cy);
+ await sleep(700);
+ const row = trio.map((id) => place(id)).sort((a, b) => a.x + a.width / 2 - (b.x + b.width / 2));
+ const g1 = row[1].x - (row[0].x + row[0].width), g2 = row[2].x - (row[1].x + row[1].width);
+ check('"Space evenly across" makes the gaps equal', Math.abs(g1 - g2) <= 1, `${g1} vs ${g2}`);
+ await click(vpA.x + vpA.w - 40, vpA.y + 40);
+ await key('z', ['control']);
+ await sleep(500);
+ check('...and it undoes in one step', file() === beforeAlign);
+
+ // ---- find in canvas ----
+ const findState = () => js(`const f = document.querySelector('.cv-find'); return { open: !!f, count: f?.querySelector('.cv-count')?.textContent || '', focused: document.activeElement === f?.querySelector('input'), found: [...document.querySelectorAll('.cv-node.found, .cv-elabel.found')].map((n) => n.dataset.id || 'label'), cur: [...document.querySelectorAll('.found-cur')].map((n) => n.dataset.id || 'label'), dimmed: document.querySelector('.cv')?.classList.contains('finding') }`);
+ const beforeFind = file();
+ await key('f', ['control']);
+ let fs0 = await findState();
+ check('Ctrl+F opens a find bar with the cursor in it', fs0.open && fs0.focused, JSON.stringify(fs0));
+ check('the find bar opens with the same pop animation as the page find bar, staying centred', await js(`const f = document.querySelector('.cv-find'); const cs = getComputedStyle(f); const r = f.getBoundingClientRect(); const v = document.querySelector('.cv').getBoundingClientRect(); return cs.animationName === 'pop' && parseFloat(cs.animationDuration) > 0 && Math.abs((r.left + r.width / 2) - (v.left + v.width / 2)) < 3`));
+ await type('bold');
+ fs0 = await findState();
+ check('a card containing the text is lit up, with a count', fs0.found.join() === 'aaaaaaaaaaaaaaaa' && fs0.count === '1 of 1' && fs0.cur.join() === 'aaaaaaaaaaaaaaaa' && fs0.dimmed, JSON.stringify(fs0));
+ await key('a', ['control']);
+ await type('canvas');
+ await sleep(300);
+ fs0 = await findState();
+ check('notes (what they show) and links (title) are searched too, in reading order', fs0.found.sort().join() === 'bbbbbbbbbbbbbbbb,cccccccccccccccc' && fs0.count === '1 of 2' && fs0.cur.join() === 'bbbbbbbbbbbbbbbb', JSON.stringify(fs0));
+ await key('Return');
+ fs0 = await findState();
+ check('Enter goes to the next match and brings it to the middle of the screen', fs0.count === '2 of 2' && fs0.cur.join() === 'cccccccccccccccc');
+ const centreBox = await box(node('cccccccccccccccc'));
+ const viewBox = await box(`document.querySelector('.cv')`);
+ check('...', Math.abs(centreBox.cx - viewBox.cx) <= 3 && Math.abs(centreBox.cy - viewBox.cy) <= 3, `${centreBox.cx},${centreBox.cy} vs ${viewBox.cx},${viewBox.cy}`);
+ await key('Return', ['shift']);
+ check('Shift+Enter goes back', (await findState()).count === '1 of 2');
+ await key('a', ['control']);
+ await type('leads');
+ await sleep(200);
+ fs0 = await findState();
+ check('arrow labels are found too', fs0.found.join() === 'label' && fs0.count === '1 of 1', JSON.stringify(fs0));
+ await key('a', ['control']);
+ await type('zzzz');
+ await sleep(200);
+ fs0 = await findState();
+ check('no match says so and does not grey everything out', fs0.count === 'No matches' && fs0.found.length === 0 && !fs0.dimmed, JSON.stringify(fs0));
+ await key('Escape');
+ fs0 = await findState();
+ check('Escape closes the bar and clears the highlights', !fs0.open && fs0.found.length === 0 && !fs0.dimmed);
+ check('finding never changes the file', file() === beforeFind);
+ await key('1', ['shift']); // jumping around moved the view: show everything again, as it was on opening
+ await sleep(300);
+
+ // ---- arrow styles ----
+ const beforeStyles = file();
+ const arrowPoint = () => js(`const r = document.querySelector('.cv-elabel[data-edge]').getBoundingClientRect(); for (let x = r.left + 2; x < r.right - 2; x += 3) { const y = r.top + r.height / 2; const h = document.elementFromPoint(x, y); if (h && h.closest('[data-edge]')) return { cx: x, cy: y }; } return null`);
+ const edgeInFile = () => canvasOf().edges.find((e) => e.id === 'eeeeeeeeeeeeeeee');
+ const styleMenu = async (label) => {
+ const p = await arrowPoint();
+ await rclick(p.cx, p.cy);
+ await menuClick(label);
+ await sleep(600);
+ };
+ await styleMenu('Straight line');
+ check('"Straight line" is saved on the arrow and drawn without a curve', edgeInFile().style === 'straight' && !/ C/.test(await js(`return document.querySelector('.cv-edge .cv-line').getAttribute('d')`)));
+ await styleMenu('Right angles');
+ check('"Right angles" draws square corners', edgeInFile().style === 'orthogonal' && /Q|L/.test(await js(`return document.querySelector('.cv-edge .cv-line').getAttribute('d')`)));
+ await styleMenu('Arrow at the start');
+ check('"Arrow at the start" puts a head on that end too', edgeInFile().fromEnd === 'arrow' && (await js(`return document.querySelectorAll('.cv-edge .cv-arrow').length`)) === 2);
+ await styleMenu('Arrow at the end');
+ check('"Arrow at the end" turns the end head off', edgeInFile().toEnd === 'none' && (await js(`return document.querySelectorAll('.cv-edge .cv-arrow').length`)) === 1);
+ check('the menu shows what is on', await (async () => {
+ const p = await arrowPoint();
+ await rclick(p.cx, p.cy);
+ const state = await js(`return [...document.querySelectorAll('.cv-menu [aria-checked="true"]')].map((b) => b.textContent.trim())`);
+ await key('Escape');
+ return state.join() === 'Right angles,Arrow at the start';
+ })());
+ await styleMenu('Curved line');
+ check('"Curved line" goes back to the default and leaves no style in the file', edgeInFile().style === undefined);
+ // the bar's own button opens the same menu
+ const arrowAt = await arrowPoint();
+ await click(arrowAt.cx, arrowAt.cy);
+ const lsb = await box(`document.querySelector('.cv-selbar [data-act="line-style"]')`);
+ await click(lsb.cx, lsb.cy);
+ check('the selection bar has a line style button that opens the menu', (await menuItems()).includes('Straight line'));
+ await key('Escape');
+ for (let i = 0; i < 5; i++) await key('z', ['control']);
+ await sleep(500);
+ check('...and every style change undoes', file() === beforeStyles, '');
+
+ // ---- alignment guides ----
+ const beforeGuides = file();
+ const gz = (await js(`return parseFloat(document.querySelector('.cv-pct').textContent) / 100`));
+ const gp = await freePoint('cccccccccccccccc');
+ // drag the link card so its centre lands within a couple of pixels of the note's centre (which is off the 20-unit grid)
+ m('mouseMove', gp.cx, gp.cy); m('mouseDown', gp.cx, gp.cy);
+ for (let i = 1; i <= 12; i++) { m('mouseMove', gp.cx + (428 * gz * i) / 12, gp.cy); await sleep(25); }
+ await sleep(150);
+ const lines = await js(`return [...document.querySelectorAll('.cv-guide')].map((g) => ({ w: g.offsetWidth, h: g.offsetHeight }))`);
+ check('while dragging, a guide line shows where it lines up with another card', lines.length === 1 && lines[0].h > lines[0].w, JSON.stringify(lines));
+ m('mouseUp', gp.cx + 428 * gz, gp.cy);
+ await sleep(700);
+ check('...and the card snaps onto the line (off the grid)', place('cccccccccccccccc').x === 430 && place('cccccccccccccccc').y === 300, JSON.stringify([place('cccccccccccccccc').x, place('cccccccccccccccc').y]));
+ check('the guide goes when it is let go', !(await js(`return !!document.querySelector('.cv-guide')`)));
+ await click(vpA.x + vpA.w - 40, vpA.y + 40);
+ await key('z', ['control']);
+ await sleep(500);
+ check('...and it undoes', file() === beforeGuides);
+ // holding Alt moves freely: no snapping to the line
+ m('mouseMove', gp.cx, gp.cy); m('mouseDown', gp.cx, gp.cy);
+ for (let i = 1; i <= 12; i++) { m('mouseMove', gp.cx + (428 * gz * i) / 12, gp.cy, { modifiers: ['alt'] }); await sleep(25); }
+ await sleep(150);
+ check('Alt turns the guides off', (await js(`return document.querySelectorAll('.cv-guide').length`)) === 0);
+ m('mouseUp', gp.cx + 428 * gz, gp.cy, { modifiers: ['alt'] });
+ await sleep(500);
+ await click(vpA.x + vpA.w - 40, vpA.y + 40);
+ await key('z', ['control']);
+ await sleep(500);
+ check('...and that undoes too', file() === beforeGuides);
+
+ const view = await box(`document.querySelector('.cv')`);
+ const inView = await js(`const v = document.querySelector('.cv').getBoundingClientRect(); return [...document.querySelectorAll('.cv-node')].every(n => { const r = n.getBoundingClientRect(); return r.left >= v.left && r.right <= v.right && r.top >= v.top && r.bottom <= v.bottom; })`);
+ check('everything is in view when it opens', inView);
+ check('no word count or outline button for a canvas', await js(`return !document.querySelector('.topbar .wc')`));
+ await sleep(900);
+ check('opening it leaves the file untouched', file() === PLAN);
+
+ // ---- a new card ----
+ const empty = [view.x + view.w - 260, view.y + 120];
+ await dbl(...empty);
+ check('double-clicking empty space starts a new card', await js(`return !!document.activeElement?.closest('.cv-ed')`));
+ await type('Hello canvas');
+ await click(view.x + view.w - 120, view.y + view.h - 120); // somewhere empty
+ await sleep(900);
+ let c = canvasOf();
+ const made = c.nodes.find((n) => n.text === 'Hello canvas');
+ check('...and it is saved as a text node', !!made && made.type === 'text' && /^[0-9a-f]{16}$/.test(made.id), JSON.stringify(c.nodes.map((n) => n.text || n.file || n.url)));
+ check('the file stays in Obsidian\'s layout', file().startsWith('{\n\t"nodes":[\n\t\t{"id":"aaaaaaaaaaaaaaaa"'));
+ const leftEmpty = (await js(`return document.querySelectorAll('.cv-node').length`));
+ await dbl(view.x + view.w - 160, view.y + view.h - 200);
+ await click(view.x + view.w - 120, view.y + view.h - 60);
+ check('a new card left empty goes away again', (await js(`return document.querySelectorAll('.cv-node').length`)) === leftEmpty);
+
+ // ---- the block editor in a card ----
+ const spot = [view.x + view.w - 300, view.y + view.h - 270];
+ await dbl(...spot);
+ const h0 = await js(`return document.querySelector('.cv-card.editing').offsetHeight`);
+ await type('a few plain words');
+ check('typing in a new card does not make it taller', (await js(`return document.querySelector('.cv-card.editing').offsetHeight`)) === h0, String(h0));
+ await key('a', ['control']);
+ await key('Backspace');
+ await type('# Title');
+ await key('Return');
+ await type('/');
+ await sleep(200);
+ check('the slash menu works inside a card', !!(await js(`const s = document.querySelector('.slash'); return !!s && !s.hidden`)));
+ await key('Escape');
+ check('Escape closes the slash menu without leaving the card', !!(await js(`return !!document.querySelector('.cv-ed') && document.querySelector('.slash').hidden`)));
+ await key('Backspace');
+ await type('- one');
+ await key('Return');
+ await type('two');
+ check('the card is a real block editor: heading and bullets', await js(`const e = document.querySelector('.cv-ed'); return !!e.querySelector('.block.b-h1') && e.querySelectorAll('.block.b-bullet').length === 2`));
+ await key('Escape');
+ await sleep(900);
+ check('...and is saved as Markdown', canvasOf().nodes.some((n) => n.text === '# Title\n\n- one\n- two'), JSON.stringify(canvasOf().nodes.map((n) => n.text)));
+ check('a finished card shows the rendered heading and list', await js(`const t = [...document.querySelectorAll('.cv-card.cv-text .cv-md')].find((e) => e.textContent.includes('Title')); return !!t && t.textContent.includes('one') && t.textContent.includes('two')`));
+ const mk = canvasOf().nodes.find((n) => n.text === '# Title\n\n- one\n- two');
+ const mkBox = await box(node(mk.id));
+ await click(mkBox.cx, mkBox.cy);
+ await key('Delete');
+ await sleep(700);
+ check('...and can be deleted again', !canvasOf().nodes.some((n) => n.id === mk.id));
+
+ // ---- moving ----
+ const z = await js(`return parseFloat(document.querySelector('.cv-pct').textContent) / 100`);
+ const card = await box(node(made.id));
+ await drag(card.cx, card.cy, card.cx - 200 * z, card.cy + 40 * z);
+ await sleep(900);
+ c = canvasOf();
+ const moved = c.nodes.find((n) => n.id === made.id);
+ check('dragging a card moves it, lined up on the grid', Math.abs(moved.x - (made.x - 200)) <= 20 && Math.abs(moved.y - (made.y + 40)) <= 20 && moved.x % 20 === 0 && moved.y % 20 === 0, `${made.x},${made.y} -> ${moved.x},${moved.y}`);
+
+ // ---- an arrow ----
+ await js(`${node(made.id)}.dispatchEvent(new PointerEvent('pointerover', { bubbles: true }))`);
+ const dot = await box(`${node(made.id)}.querySelector('.cv-dot[data-side="top"]')`);
+ const idea = await box(node('cccccccccccccccc'));
+ await drag(dot.cx, dot.cy, idea.cx, idea.cy + 10, 16);
+ await sleep(900);
+ c = canvasOf();
+ const edge = c.edges.find((e) => e.fromNode === made.id);
+ check('dragging a side dot onto another card joins them with an arrow', !!edge && edge.toNode === 'cccccccccccccccc' && edge.fromSide === 'top' && ['top', 'bottom', 'left', 'right'].includes(edge.toSide), JSON.stringify(c.edges));
+
+ // ---- delete and undo ----
+ const now = await box(node(made.id));
+ await click(now.cx, now.cy);
+ await key('Delete');
+ await sleep(900);
+ c = canvasOf();
+ check('Delete removes the card and its arrows', !c.nodes.some((n) => n.id === made.id) && !c.edges.some((e) => e.fromNode === made.id));
+ await key('z', ['control']);
+ await sleep(900);
+ c = canvasOf();
+ check('Ctrl+Z brings both back', c.nodes.some((n) => n.id === made.id) && c.edges.some((e) => e.fromNode === made.id));
+
+ // ---- colour ----
+ const ideaBox = await box(node('aaaaaaaaaaaaaaaa'));
+ await click(ideaBox.cx, ideaBox.cy);
+ await sleep(200);
+ const sw = await box(`document.querySelector('.cv-selbar [aria-label="Colour 4"]')`);
+ await click(sw.cx, sw.cy);
+ await sleep(900);
+ check('the selection bar colours a card', canvasOf().nodes.find((n) => n.id === 'aaaaaaaaaaaaaaaa').color === '4' && (await js(`return ${node('aaaaaaaaaaaaaaaa')}.classList.contains('colored')`)));
+
+ // ---- box select + group ----
+ const a = await box(node('aaaaaaaaaaaaaaaa')), l = await box(node('cccccccccccccccc'));
+ await drag(a.x - 30, a.y - 30, l.x + l.w + 15, l.y + l.h + 15);
+ const picked = await js(`return [...document.querySelectorAll('.cv-node.sel')].map(n => n.dataset.id).sort()`);
+ check('dragging over empty space selects with a box', JSON.stringify(picked) === JSON.stringify(['aaaaaaaaaaaaaaaa', 'cccccccccccccccc']), JSON.stringify(picked));
+ const gb = await box(`document.querySelector('.cv-bar [aria-label="Add a group"]')`);
+ await click(gb.cx, gb.cy);
+ await sleep(200);
+ await js(`document.execCommand('selectAll')`);
+ await type('Phase 1');
+ await key('Return');
+ await sleep(900);
+ c = canvasOf();
+ const g = c.nodes.find((n) => n.type === 'group');
+ check('"Group" puts a named group around the selection', !!g && g.label === 'Phase 1' && c.nodes.indexOf(g) === 0 && ['aaaaaaaaaaaaaaaa', 'cccccccccccccccc'].every((i) => { const n = c.nodes.find((x) => x.id === i); return n.x >= g.x && n.y >= g.y && n.x + n.width <= g.x + g.width && n.y + n.height <= g.y + g.height; }), JSON.stringify(g));
+ // moving the group moves what's in it
+ const before = c.nodes.find((n) => n.id === 'cccccccccccccccc');
+ const label = await box(`document.querySelector('.cv-group .cv-glabel')`);
+ const z2 = await js(`return parseFloat(document.querySelector('.cv-pct').textContent) / 100`);
+ await drag(label.cx, label.cy, label.cx + 100 * z2, label.cy);
+ await sleep(900);
+ const after = canvasOf().nodes.find((n) => n.id === 'cccccccccccccccc');
+ check('dragging a group takes its contents along', Math.abs(after.x - before.x - 100) <= 20 && after.y === before.y, `${before.x} -> ${after.x}`);
+
+ // ---- folding a group away, and cards joining one ----
+ const gid = g.id;
+ const groupFile = () => canvasOf().nodes.find((n) => n.id === gid);
+ const inGroup = ['aaaaaaaaaaaaaaaa', 'cccccccccccccccc'];
+ const beforeFold = file();
+ const toggle = await box(`${node(gid)}.querySelector('.cv-gtoggle')`);
+ await js(`${node(gid)}.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }))`);
+ await click(toggle.cx, toggle.cy);
+ await sleep(700);
+ check('the toggle folds the group: what is in it is hidden, the group becomes a strip', groupFile().collapsed === true && (await js(`return ${inGroup.map((i) => `!${node(i)}`).join(' && ')}`)) && (await js(`return ${node(gid)}.offsetHeight`)) === 44);
+ check('...and the strip says how much it holds', (await js(`return ${node(gid)}.querySelector('.cv-gcount')?.textContent`)) === '2 items');
+ const folded = canvasOf().edges;
+ check('arrows to what is hidden now meet the strip', folded.length > 0 && (await Promise.all(folded.map((e) => js(`return !!document.querySelector('.cv-hit[data-edge="${e.id}"]')`)))).every(Boolean));
+ await key('f', ['control']);
+ await type('bold');
+ await sleep(250);
+ check('finding something that is folded away lights up the group', (await js(`return ${node(gid)}.classList.contains('found-cur')`)) && (await js(`return document.querySelector('.cv-count').textContent`)) === '1 of 1');
+ await key('Escape');
+ const t2 = await box(`${node(gid)}.querySelector('.cv-gtoggle')`);
+ await click(t2.cx, t2.cy);
+ await sleep(700);
+ check('unfolding brings everything back, with nothing extra left in the file', groupFile().collapsed === undefined && (await js(`return ${inGroup.map((i) => `!!${node(i)}`).join(' && ')}`)) && file() === beforeFold);
+
+ // a card let go with its middle inside the group becomes part of it
+ const gNow = groupFile();
+ const vpJ = await box(`document.querySelector('.cv')`);
+ await dbl(vpJ.x + vpJ.w - 140, vpJ.y + 90);
+ await type('Joiner');
+ await key('Escape');
+ await sleep(600);
+ const mine = { id: canvasOf().nodes.find((n) => n.text === 'Joiner')?.id };
+ const gBox = await box(node(gid));
+ const zz = await js(`return parseFloat(document.querySelector('.cv-pct').textContent) / 100`);
+ const hp = await freePoint(mine.id);
+ const hb = await box(node(mine.id));
+ // its middle goes to a point just inside the group's right edge, so most of it hangs outside
+ const want = [gBox.x + gBox.w - 30 * zz, gBox.y + gBox.h / 2];
+ const shift = [want[0] - hb.cx, want[1] - hb.cy];
+ const beforeJoin = file();
+ m('mouseMove', hp.cx, hp.cy); m('mouseDown', hp.cx, hp.cy);
+ for (let i = 1; i <= 12; i++) { m('mouseMove', hp.cx + (shift[0] * i) / 12, hp.cy + (shift[1] * i) / 12, { modifiers: ['alt'] }); await sleep(25); }
+ await sleep(150);
+ check('a card held over a group shows that it will join it', await js(`return ${node(gid)}.classList.contains('target')`));
+ m('mouseUp', hp.cx + shift[0], hp.cy + shift[1], { modifiers: ['alt'] });
+ await sleep(800);
+ const joined = canvasOf().nodes.find((n) => n.id === mine.id), gAfter = groupFile();
+ check('...and on letting go it is moved fully inside', joined.x >= gAfter.x && joined.y >= gAfter.y && joined.x + joined.width <= gAfter.x + gAfter.width && joined.y + joined.height <= gAfter.y + gAfter.height && gAfter.width === gNow.width, JSON.stringify({ joined, gAfter }));
+ check('...so the group takes it along when moved', await (async () => {
+ const lb = await box(`${node(gid)}.querySelector('.cv-glabel')`);
+ const h0 = canvasOf().nodes.find((n) => n.id === mine.id).x;
+ await drag(lb.cx, lb.cy, lb.cx + 60 * zz, lb.cy);
+ await sleep(800);
+ const moved = canvasOf().nodes.find((n) => n.id === mine.id).x;
+ await key('z', ['control']);
+ await sleep(500);
+ return Math.abs(moved - h0 - 60) <= 20;
+ })());
+ check('...and joining is a single undo step', (await (async () => { await click(vpA.x + vpA.w - 40, vpA.y + 40); await key('z', ['control']); await sleep(500); return file(); })()) === beforeJoin);
+ const jp = await freePoint(mine.id);
+ await click(jp.cx, jp.cy);
+ await key('Delete');
+ await sleep(500);
+
+ // ---- card <-> note ----
+ await dbl(vpJ.x + vpJ.w - 140, vpJ.y + 90);
+ await type('# Trip ideas');
+ await key('Return');
+ await type('Pack light');
+ await key('Escape');
+ await sleep(600);
+ const tripCard = canvasOf().nodes.find((n) => String(n.text || '').startsWith('# Trip ideas'));
+ const tp = await freePoint(tripCard.id);
+ await rclick(tp.cx, tp.cy);
+ const cardItems = await menuItems();
+ check('a text card offers "Turn into note" (and not "Embed in card")', cardItems.includes('Turn into note') && !cardItems.includes('Embed in card'), JSON.stringify(cardItems));
+ const sizeBefore = [tripCard.x, tripCard.y];
+ await menuClick('Turn into note');
+ await sleep(1500);
+ const tripFile = path.join(vault, 'Trip ideas.md');
+ check('"Turn into note" makes a page named after the first line, with the rest as its text', fs.existsSync(tripFile) && /Pack light\n$/.test(fs.readFileSync(tripFile, 'utf8')) && !/# Trip ideas/.test(fs.readFileSync(tripFile, 'utf8').split('---').pop()), fs.existsSync(tripFile) ? fs.readFileSync(tripFile, 'utf8') : 'missing');
+ const asNote = canvasOf().nodes.find((n) => n.id === tripCard.id);
+ check('...and the same card becomes a note card in the same place (so its arrows stay)', asNote.type === 'file' && asNote.file === 'Trip ideas.md' && asNote.text === undefined && asNote.x === sizeBefore[0] && asNote.y === sizeBefore[1], JSON.stringify(asNote));
+ await sleep(600);
+ check('...which shows the note', (await js(`return ${node(tripCard.id)}.textContent.includes('Pack light') && ${node(tripCard.id)}.textContent.includes('Trip ideas')`)));
+ const np = await freePoint(tripCard.id);
+ await rclick(np.cx, np.cy);
+ const noteItems = await menuItems();
+ check('a note card offers "Embed in card"', noteItems.includes('Embed in card') && !noteItems.includes('Turn into note'));
+ await menuClick('Embed in card');
+ await sleep(900);
+ const asEmbed = canvasOf().nodes.find((n) => n.id === tripCard.id);
+ check('"Embed in card" makes a text card holding ![[Note]]', asEmbed.type === 'text' && asEmbed.text === '![[Trip ideas]]' && asEmbed.file === undefined, JSON.stringify(asEmbed));
+ check('...which shows the note\'s text in place, with a title that opens it', await js(`const c = ${node(tripCard.id)}; const t = c.querySelector('.emb-title'); return !!t && t.textContent.includes('Trip ideas') && c.querySelector('.emb-body').textContent.includes('Pack light')`));
+ check('...and the page itself is untouched', /Pack light\n$/.test(fs.readFileSync(tripFile, 'utf8')));
+ const ep = await freePoint(tripCard.id);
+ await click(ep.cx, ep.cy);
+ await key('Delete');
+ await sleep(500);
+
+ // ---- view ----
+ const pct = () => js(`return document.querySelector('.cv-pct').textContent`);
+ const p0 = await pct();
+ const mid = [view.x + view.w / 2, view.y + view.h / 2];
+ wc.sendInputEvent({ type: 'mouseWheel', x: mid[0], y: mid[1], deltaX: 0, deltaY: 240, modifiers: ['control'] });
+ await sleep(400);
+ check('Ctrl + scroll zooms', (await pct()) !== p0, `${p0} -> ${await pct()}`);
+ const t0 = await js(`return document.querySelector('.cv-world').style.transform`);
+ wc.sendInputEvent({ type: 'mouseWheel', x: view.x + 40, y: view.y + 40, deltaX: 0, deltaY: -240 });
+ await sleep(400);
+ const t1 = await js(`return document.querySelector('.cv-world').style.transform`);
+ check('scrolling moves around', t0 !== t1 && (await pct()) === (await pct()), `${t0} -> ${t1}`);
+ const fitB = await box(`document.querySelector('.cv-zoom [aria-label="Show everything"]')`);
+ await click(fitB.cx, fitB.cy);
+ check('"Show everything" brings it all into view', await js(`const v = document.querySelector('.cv').getBoundingClientRect(); return [...document.querySelectorAll('.cv-node')].every(n => { const r = n.getBoundingClientRect(); return r.left >= v.left - 1 && r.right <= v.right + 1 && r.top >= v.top - 1 && r.bottom <= v.bottom + 1; })`));
+
+ // ---- copy and paste ----
+ const count = canvasOf().nodes.length;
+ const card2 = await box(node('cccccccccccccccc'));
+ await click(card2.cx, card2.cy);
+ await key('c', ['control']);
+ await sleep(200);
+ check('Ctrl+C copies the selection as canvas text', clipboard.readText().includes('"url":"https://jsoncanvas.org"'));
+ await key('v', ['control']);
+ await sleep(900);
+ check('Ctrl+V pastes a copy (new id)', canvasOf().nodes.filter((n) => n.url === 'https://jsoncanvas.org').length === 2 && canvasOf().nodes.length === count + 1);
+ clipboard.writeText('https://example.com/page');
+ await key('v', ['control']);
+ await sleep(900);
+ check('pasting a web address makes a link card', canvasOf().nodes.some((n) => n.type === 'link' && n.url === 'https://example.com/page'));
+ await sleep(700);
+ check('a link whose page can\'t be read shows its address', await js(`const c = [...document.querySelectorAll('.cv-link')].find((e) => e.textContent.includes('example.com')); return !!c && c.querySelector('.cv-ftitle').textContent === 'example.com' && c.querySelector('.cv-url').textContent === 'https://example.com/page' && !c.querySelector('.cv-desc')`));
+
+ // ---- a note on the canvas opens, and follows a rename ----
+ const leftAt = await js(`return document.querySelector('.cv-world').style.transform`);
+ const title = await box(`${node('bbbbbbbbbbbbbbbb')}.querySelector('.cv-ftitle')`);
+ await click(title.cx, title.cy);
+ await sleep(1200);
+ check('clicking a note\'s title opens it (at its heading)', (await js(`return document.querySelector('.title')?.textContent`)) === 'Roadmap');
+ await js(`await window.vault.rename('Projects/Roadmap', 'Plan 2027')`);
+ await sleep(600);
+ const renamed = canvasOf().nodes.find((n) => n.id === 'bbbbbbbbbbbbbbbb');
+ check('renaming the note updates the canvas', renamed.file === 'Projects/Plan 2027.md' && renamed.subpath === '#Goals', JSON.stringify(renamed));
+
+ // ---- search and backlinks ----
+ check('search finds text on a canvas', (await js(`return (await window.vault.search('Hello canvas')).map(r => r.id)`)).includes('Plan.canvas'));
+ check('a note on a canvas has the canvas as a backlink', (await js(`return (await window.vault.backlinks('Projects/Plan 2027')).map(b => b.id)`)).includes('Plan.canvas'));
+
+ // ---- add a note from the bar (it lands in the middle, on top of things: done last) ----
+ await openRow('Plan.canvas');
+ const backAt = await js(`return document.querySelector('.cv-world').style.transform`);
+ check('coming back, the canvas is where you left it', backAt === leftAt, `${leftAt} / ${backAt}`);
+ const nb = await box(`document.querySelector('.cv-bar [aria-label="Add a note"]')`);
+ await click(nb.cx, nb.cy);
+ await type('Notes');
+ await key('Return');
+ await sleep(900);
+ check('"Add a note" puts a note on the canvas', canvasOf().nodes.some((n) => n.type === 'file' && n.file === 'Notes.md'));
+
+ // ---- a page dragged in from the sidebar ----
+ const notesBefore = canvasOf().nodes.filter((n) => n.file === 'Notes.md').length;
+ const dragRow = await box(`document.querySelector('.tree .row[data-id="Notes"]')`);
+ const dropVp = await box(`document.querySelector('.cv')`);
+ const dropAt = [dropVp.x + dropVp.w - 220, dropVp.y + dropVp.h - 220];
+ // let go in the sidebar's own space after Escape: nothing is added
+ m('mouseMove', dragRow.cx, dragRow.cy); m('mouseDown', dragRow.cx, dragRow.cy);
+ for (let i = 1; i <= 10; i++) { m('mouseMove', dragRow.cx + ((dropAt[0] - dragRow.cx) * i) / 10, dragRow.cy + ((dropAt[1] - dragRow.cy) * i) / 10); await sleep(25); }
+ await sleep(100);
+ check('a page held over the canvas lights it up as a drop target', !!(await js(`return document.querySelector('.cv')?.classList.contains('dropping')`)));
+ await key('Escape');
+ m('mouseUp', dropAt[0], dropAt[1]);
+ await sleep(700);
+ check('Escape cancels the drag: nothing is added', canvasOf().nodes.filter((n) => n.file === 'Notes.md').length === notesBefore);
+ await drag(dragRow.cx, dragRow.cy, dropAt[0], dropAt[1], 14);
+ await sleep(700);
+ const dropped = canvasOf().nodes.filter((n) => n.file === 'Notes.md');
+ check('dropping a page from the sidebar puts a note card there', dropped.length === notesBefore + 1);
+ const fresh = dropped[dropped.length - 1];
+ const fb = await box(node(fresh.id));
+ check('...centred where it was let go', Math.abs(fb.cx - dropAt[0]) <= 24 && Math.abs(fb.cy - dropAt[1]) <= 24, `${fb.cx},${fb.cy} vs ${dropAt}`);
+ check('...and the page was not moved or opened', fs.existsSync(path.join(vault, 'Notes.md')) && (await js(`return !document.querySelector('.cv-broken') && !!document.querySelector('.cv')`)));
+ check('the drop highlight is gone', !(await js(`return document.querySelector('.cv')?.classList.contains('dropping')`)));
+
+ // ---- export ----
+ const exportTo = async (item, ext) => {
+ const out = path.join(tmp, 'out.' + ext);
+ fs.rmSync(out, { force: true });
+ dialog.showSaveDialog = async () => ({ canceled: false, filePath: out });
+ await js(`document.querySelector('[aria-label="Page options"]').click()`);
+ await sleep(250);
+ await js(`[...document.querySelectorAll('.menu .mi')].find((b) => b.textContent.includes(${JSON.stringify(item)})).click()`);
+ for (let i = 0; i < 60 && !fs.existsSync(out); i++) await sleep(250);
+ await sleep(300);
+ return fs.existsSync(out) ? out : null;
+ };
+ const beforeExport = file();
+ const planNodes = canvasOf().nodes;
+ const spanW = Math.max(...planNodes.map((n) => n.x + n.width)) - Math.min(...planNodes.map((n) => n.x));
+ const spanH = Math.max(...planNodes.map((n) => n.y + n.height)) - Math.min(...planNodes.map((n) => n.y));
+ const png = await exportTo('Export as PNG', 'png');
+ check('Export as PNG writes a picture of the whole canvas', !!png);
+ if (png) {
+ const img = nativeImage.createFromPath(png);
+ const sz = img.getSize();
+ check('...sized to the items plus a margin (at 2x)', Math.abs(sz.width - (spanW + 96) * 2) <= 4 && Math.abs(sz.height - (spanH + 96) * 2) <= 4, JSON.stringify(sz) + ` expected ~${(spanW + 96) * 2}x${(spanH + 96) * 2}`);
+ const px = img.toBitmap();
+ const seen = new Set();
+ for (let i = 0; i < px.length; i += 4 * 97) seen.add(px.readUInt32LE(i));
+ check('...and is drawn, not blank', seen.size > 8, String(seen.size));
+ }
+ const svgFile = await exportTo('Export as SVG', 'svg');
+ const svgText = svgFile ? fs.readFileSync(svgFile, 'utf8') : '';
+ check('Export as SVG writes a vector file with the cards in it', svgText.startsWith('