feat(*): one task editor, vim navigation and undo

This commit is contained in:
hh
2026-08-15 03:13:50 +02:00
parent 0873cbb366
commit 83ca32d98e
37 changed files with 4043 additions and 505 deletions
+77
View File
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import { fuzzy, pieces, rank } from "../src/lib/fuzzy";
describe("fuzzy", () => {
it("matches a subsequence, not just a substring", () => {
expect(fuzzy("wsr", "website redesign")).not.toBeNull();
expect(fuzzy("zzz", "website redesign")).toBeNull();
});
it("is case insensitive and ignores surrounding space in the query", () => {
expect(fuzzy(" WEB ", "website")).not.toBeNull();
});
it("returns every match for an empty query", () => {
expect(fuzzy("", "anything")).toEqual({ score: 0, ranges: [] });
});
it("merges adjacent letters into one range", () => {
expect(fuzzy("web", "website")?.ranges).toEqual([[0, 3]]);
});
it("keeps separate runs apart", () => {
expect(fuzzy("wr", "website redesign")?.ranges).toEqual([
[0, 1],
[8, 9],
]);
});
it("scores a word start above a letter buried mid-word", () => {
const start = fuzzy("r", "website redesign")?.score ?? 0;
const buried = fuzzy("b", "website redesign")?.score ?? 0;
expect(start).toBeGreaterThan(buried);
});
});
describe("rank", () => {
const items = ["web", "website redesign", "errands", "someday"];
const label = (item: string): string => item;
it("keeps the given order when nothing is typed", () => {
expect(rank("", items, label).map((hit) => hit.item)).toEqual(items);
});
it("puts the shorter exact head first", () => {
expect(rank("web", items, label)[0].item).toBe("web");
});
it("drops what does not match at all", () => {
expect(rank("qq", items, label)).toEqual([]);
});
it("breaks ties by the incoming order", () => {
expect(rank("e", ["ea", "eb"], label).map((hit) => hit.item)).toEqual([
"ea",
"eb",
]);
});
});
describe("pieces", () => {
it("splits a label into matched and unmatched runs", () => {
expect(pieces("website", [[0, 3]])).toEqual([
{ text: "web", hit: true },
{ text: "site", hit: false },
]);
});
it("returns the whole label when nothing matched", () => {
expect(pieces("website", [])).toEqual([{ text: "website", hit: false }]);
});
it("rebuilds the original text exactly", () => {
const hit = fuzzy("wr", "website redesign");
const parts = pieces("website redesign", hit?.ranges ?? []);
expect(parts.map((part) => part.text).join("")).toBe("website redesign");
});
});
+88
View File
@@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import { step } from "../src/lib/grid";
import type { Rect } from "../src/lib/grid";
/** Three cards per row, 100 wide and 40 tall, the way the tray wraps them. */
function tray(count: number): { id: number; rect: Rect }[] {
return Array.from({ length: count }, (_, index) => ({
id: index,
rect: {
left: (index % 3) * 100,
top: Math.floor(index / 3) * 40,
width: 96,
height: 36,
},
}));
}
const rectOf = (item: { rect: Rect }): Rect => item.rect;
const walk = (
items: ReturnType<typeof tray>,
from: number,
direction: Parameters<typeof step>[2],
): number | null => step(items, items[from], direction, rectOf)?.id ?? null;
describe("step", () => {
it("moves along a row", () => {
const items = tray(6);
expect(walk(items, 0, "right")).toBe(1);
expect(walk(items, 1, "left")).toBe(0);
});
it("moves down a column, not just to the next card", () => {
const items = tray(6);
expect(walk(items, 1, "down")).toBe(4);
expect(walk(items, 4, "up")).toBe(1);
});
it("keeps the column when the last row is short", () => {
const items = tray(5);
expect(walk(items, 1, "down")).toBe(4);
expect(walk(items, 2, "down")).toBe(4);
});
it("carries on to the next row at the end of one", () => {
const items = tray(6);
expect(walk(items, 2, "right")).toBe(3);
expect(walk(items, 3, "left")).toBe(2);
});
it("stops at the top and the bottom", () => {
const items = tray(6);
expect(walk(items, 1, "up")).toBeNull();
expect(walk(items, 4, "down")).toBeNull();
});
it("stops at the very ends of the run", () => {
const items = tray(6);
expect(walk(items, 0, "left")).toBeNull();
expect(walk(items, 5, "right")).toBeNull();
});
it("falls back to the first card when the cursor is off the run", () => {
const items = tray(3);
const stray = { id: 99, rect: { left: 0, top: 0, width: 1, height: 1 } };
expect(step(items, stray, "right", rectOf)?.id).toBe(0);
});
it("reads a row by the band each card fills, not by its centre", () => {
// Top-aligned cards of different heights still sit on one row.
const items = [
{ id: 0, rect: { left: 0, top: 0, width: 96, height: 67 } },
{ id: 1, rect: { left: 100, top: 0, width: 96, height: 67 } },
{ id: 2, rect: { left: 200, top: 0, width: 96, height: 51 } },
{ id: 3, rect: { left: 0, top: 73, width: 96, height: 51 } },
];
expect(step(items, items[0], "up", rectOf)).toBeNull();
expect(step(items, items[0], "right", rectOf)?.id).toBe(1);
expect(step(items, items[2], "left", rectOf)?.id).toBe(1);
expect(step(items, items[0], "down", rectOf)?.id).toBe(3);
expect(step(items, items[3], "up", rectOf)?.id).toBe(0);
});
it("handles a single card without moving", () => {
const items = tray(1);
expect(walk(items, 0, "right")).toBeNull();
expect(walk(items, 0, "down")).toBeNull();
});
});
+112
View File
@@ -0,0 +1,112 @@
import { beforeEach, describe, expect, it } from "vitest";
import { TFile } from "obsidian";
import type { App } from "obsidian";
import { depth, forget, record, redo, transaction, undo } from "../src/vault/history";
/** Obsidian hands out TFile instances; history checks for them by identity. */
function handle(path: string): TFile {
return Object.assign(Object.create(TFile.prototype) as TFile, { path });
}
function vault(seed: Record<string, string>) {
const files = new Map(Object.entries(seed));
const handles = new Map([...files.keys()].map((path) => [path, handle(path)]));
const app = {
vault: {
getAbstractFileByPath: (path: string) => handles.get(path) ?? null,
read: async (file: TFile) => files.get(file.path) ?? "",
process: async (file: TFile, fn: (data: string) => string) => {
const next = fn(files.get(file.path) ?? "");
files.set(file.path, next);
return next;
},
},
} as unknown as App;
return { app, files };
}
/** Mirrors what mutate.edit does: write the file, then log both sides. */
async function write(
{ app, files }: ReturnType<typeof vault>,
path: string,
next: string,
): Promise<void> {
const before = files.get(path) ?? "";
await app.vault.process(
app.vault.getAbstractFileByPath(path) as TFile,
() => next,
);
record(path, before, next);
}
describe("history", () => {
beforeEach(() => forget());
it("puts a single change back", async () => {
const store = vault({ "a.md": "one" });
await write(store, "a.md", "two");
await undo(store.app);
expect(store.files.get("a.md")).toBe("one");
});
it("replays what it undid", async () => {
const store = vault({ "a.md": "one" });
await write(store, "a.md", "two");
await undo(store.app);
await redo(store.app);
expect(store.files.get("a.md")).toBe("two");
});
it("walks back through several changes one at a time", async () => {
const store = vault({ "a.md": "one" });
await write(store, "a.md", "two");
await write(store, "a.md", "three");
await undo(store.app);
expect(store.files.get("a.md")).toBe("two");
await undo(store.app);
expect(store.files.get("a.md")).toBe("one");
});
it("takes both files of a move back together", async () => {
const store = vault({ "a.md": "task", "b.md": "" });
await transaction("the move", async () => {
await write(store, "b.md", "task");
await write(store, "a.md", "");
});
expect(depth().past).toBe(1);
await undo(store.app);
expect(store.files.get("a.md")).toBe("task");
expect(store.files.get("b.md")).toBe("");
});
it("ignores a write that changed nothing", async () => {
const store = vault({ "a.md": "one" });
await write(store, "a.md", "one");
expect(depth().past).toBe(0);
});
it("refuses to touch a file that moved on underneath it", async () => {
const store = vault({ "a.md": "one" });
await write(store, "a.md", "two");
store.files.set("a.md", "edited elsewhere");
await undo(store.app);
expect(store.files.get("a.md")).toBe("edited elsewhere");
// The step is kept, so a later undo can still work if the file comes back.
expect(depth().past).toBe(1);
});
it("drops the redo trail once something new is written", async () => {
const store = vault({ "a.md": "one" });
await write(store, "a.md", "two");
await undo(store.app);
expect(depth().future).toBe(1);
await write(store, "a.md", "three");
expect(depth().future).toBe(0);
});
it("says so instead of throwing when there is nothing left", async () => {
const store = vault({ "a.md": "one" });
await expect(undo(store.app)).resolves.toBeUndefined();
await expect(redo(store.app)).resolves.toBeUndefined();
});
});
+153
View File
@@ -0,0 +1,153 @@
import { describe, expect, it } from "vitest";
import { GROUPS, listStep, resolve, SHORTCUTS } from "../src/lib/keymap";
import type { Scope } from "../src/lib/keymap";
function press(
key: string,
init: { code?: string; ctrl?: boolean; meta?: boolean; shift?: boolean } = {},
): KeyboardEvent {
return {
key,
code: init.code ?? "",
ctrlKey: init.ctrl ?? false,
metaKey: init.meta ?? false,
altKey: false,
shiftKey: init.shift ?? false,
} as KeyboardEvent;
}
const DAY: Scope[] = ["day", "view"];
const TASK: Scope[] = ["task", "view"];
const TRAY: Scope[] = ["tray", "task", "view"];
const RAIL: Scope[] = ["rail", "view"];
describe("resolve", () => {
it("walks days on the grid and tasks inside one", () => {
expect(resolve(press("j", { code: "KeyJ" }), DAY)).toBe("dayDown");
expect(resolve(press("j", { code: "KeyJ" }), TASK)).toBe("taskNext");
});
it("leaves the day with h and l from either scope", () => {
expect(resolve(press("h", { code: "KeyH" }), DAY)).toBe("dayLeft");
expect(resolve(press("h", { code: "KeyH" }), TASK)).toBe("dayLeft");
});
it("separates a shifted letter from its bare form", () => {
expect(resolve(press("H", { code: "KeyH", shift: true }), TASK)).toBe(
"taskBack",
);
expect(resolve(press("h", { code: "KeyH" }), TASK)).toBe("dayLeft");
});
it("reads the physical key, so a Cyrillic layout still answers", () => {
expect(resolve(press("н", { code: "KeyN" }), DAY)).toBe("create");
expect(resolve(press("Т", { code: "KeyT", shift: true }), TASK)).toBe(
"taskToday",
);
});
it("falls through to the view scope", () => {
expect(resolve(press("/", { code: "Slash" }), DAY)).toBe("search");
expect(resolve(press("?", { code: "Slash", shift: true }), DAY)).toBe("help");
});
it("means different things by Enter and Escape per scope", () => {
expect(resolve(press("Enter", { code: "Enter" }), DAY)).toBe("dayEnter");
expect(resolve(press("Enter", { code: "Enter" }), TASK)).toBe("taskOpen");
expect(resolve(press("Escape", { code: "Escape" }), TASK)).toBe("taskExit");
expect(resolve(press("Escape", { code: "Escape" }), DAY)).toBe("dismiss");
});
it("ignores a modifier the table never asked for", () => {
expect(resolve(press("n", { code: "KeyN", meta: true }), DAY)).toBeNull();
});
it("answers nothing for an unbound key", () => {
expect(resolve(press("q", { code: "KeyQ" }), TASK)).toBeNull();
});
it("walks the tray as a grid while task actions still reach through", () => {
expect(resolve(press("h", { code: "KeyH" }), TRAY)).toBe("trayLeft");
expect(resolve(press("j", { code: "KeyJ" }), TRAY)).toBe("trayDown");
expect(resolve(press("x", { code: "KeyX" }), TRAY)).toBe("taskToggle");
expect(resolve(press("L", { code: "KeyL", shift: true }), TRAY)).toBe(
"taskForward",
);
});
it("gives the rail its own reading of the same keys", () => {
expect(resolve(press("j", { code: "KeyJ" }), RAIL)).toBe("railNext");
expect(resolve(press("l", { code: "KeyL" }), RAIL)).toBe("railUnfold");
expect(resolve(press("h", { code: "KeyH" }), RAIL)).toBe("railFold");
expect(resolve(press("Enter", { code: "Enter" }), RAIL)).toBe("railChoose");
});
it("closes the panel it opened with the same key", () => {
expect(resolve(press("u", { code: "KeyU" }), TRAY)).toBe("undated");
expect(resolve(press("u", { code: "KeyU" }), DAY)).toBe("undated");
expect(resolve(press("b", { code: "KeyB" }), RAIL)).toBe("toggleRail");
expect(resolve(press("b", { code: "KeyB" }), DAY)).toBe("toggleRail");
});
it("takes undo on either modifier, and redo with shift", () => {
expect(resolve(press("z", { code: "KeyZ", meta: true }), DAY)).toBe("undo");
expect(resolve(press("z", { code: "KeyZ", ctrl: true }), DAY)).toBe("undo");
expect(
resolve(press("Z", { code: "KeyZ", meta: true, shift: true }), DAY),
).toBe("redo");
expect(resolve(press("z", { code: "KeyZ" }), DAY)).toBeNull();
});
});
describe("listStep", () => {
it("takes arrows, the emacs pair and the vim pair", () => {
expect(listStep(press("ArrowDown", { code: "ArrowDown" }))).toBe(1);
expect(listStep(press("ArrowUp", { code: "ArrowUp" }))).toBe(-1);
expect(listStep(press("n", { code: "KeyN", ctrl: true }))).toBe(1);
expect(listStep(press("p", { code: "KeyP", ctrl: true }))).toBe(-1);
expect(listStep(press("j", { code: "KeyJ", meta: true }))).toBe(1);
expect(listStep(press("k", { code: "KeyK", meta: true }))).toBe(-1);
});
it("leaves a bare letter alone so it can be typed", () => {
expect(listStep(press("n", { code: "KeyN" }))).toBeNull();
expect(listStep(press("j", { code: "KeyJ" }))).toBeNull();
});
});
describe("the sheet and the dispatcher share one table", () => {
it("gives every documented binding a group the sheet renders", () => {
for (const shortcut of SHORTCUTS) {
expect(GROUPS).toContain(shortcut.group);
expect(shortcut.keys.length).toBeGreaterThan(0);
expect(shortcut.label.length).toBeGreaterThan(0);
}
});
it("only leaves combos empty on rows the composer handles itself", () => {
for (const shortcut of SHORTCUTS) {
if (shortcut.combos.length === 0) {
expect(shortcut.action).toBeNull();
expect(shortcut.scopes).toEqual(["compose"]);
} else {
expect(shortcut.action).not.toBeNull();
}
}
});
it("never binds one combo twice inside a scope", () => {
for (const scope of ["day", "task", "tray", "rail", "view"] as Scope[]) {
const seen = new Map<string, string>();
for (const shortcut of SHORTCUTS) {
if (!shortcut.scopes.includes(scope)) continue;
for (const each of shortcut.combos) {
expect(
seen.has(each),
`${each} is bound twice in ${scope}: ${seen.get(each)} and ${shortcut.label}`,
).toBe(false);
seen.set(each, shortcut.label);
}
}
}
});
});
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { onScreen, place } from "../src/lib/place";
import type { Box } from "../src/lib/place";
const VIEW = { width: 1280, height: 800 };
const PANEL = { width: 320, height: 200 };
const row = (over: Partial<Box> = {}): Box => ({
top: 300,
left: 400,
width: 500,
height: 40,
...over,
});
describe("onScreen", () => {
it("skips the copy that is hidden and has no box", () => {
const hidden = row({ top: 0, left: 0, width: 0, height: 0 });
const shown = row();
expect(onScreen([hidden, shown])).toBe(shown);
});
it("takes the first one when several are on screen", () => {
const first = row();
expect(onScreen([first, row({ top: 500 })])).toBe(first);
});
it("says nothing when every copy is hidden", () => {
expect(onScreen([row({ width: 0, height: 0 })])).toBeNull();
expect(onScreen([])).toBeNull();
});
});
describe("place", () => {
it("sits just under the anchor", () => {
expect(place(row(), PANEL, VIEW)).toEqual({ top: 346, left: 400 });
});
it("flips above when there is no room below", () => {
expect(place(row({ top: 700 }), PANEL, VIEW)).toEqual({ top: 494, left: 400 });
});
it("stays inside the window when there is room neither way", () => {
const tall = { width: 320, height: 780 };
const spot = place(row({ top: 400 }), tall, VIEW);
expect(spot.top).toBeGreaterThanOrEqual(10);
expect(spot.top + tall.height).toBeLessThanOrEqual(VIEW.height);
});
it("pulls back from the right edge", () => {
expect(place(row({ left: 1200 }), PANEL, VIEW).left).toBe(950);
});
it("never goes off the left edge", () => {
expect(place(row({ left: -40 }), PANEL, VIEW).left).toBe(10);
});
it("centres rather than hiding in the corner with nothing to anchor to", () => {
expect(place(null, PANEL, VIEW)).toEqual({ top: 300, left: 480 });
});
});
+301 -1
View File
@@ -48,6 +48,9 @@ for (const key of Object.getOwnPropertyNames(dom.window)) {
}
globalThis.window = dom.window;
globalThis.document = dom.window.document;
// Node ships its own Event classes; jsdom rejects those, so prefer jsdom's.
globalThis.Event = dom.window.Event;
globalThis.CustomEvent = dom.window.CustomEvent;
dom.window.Element.prototype.getBoundingClientRect = () => ({
bottom: 0, height: 0, left: 0, right: 0, top: 0, width: 0, x: 0, y: 0,
toJSON: () => ({}),
@@ -56,6 +59,27 @@ dom.window.Element.prototype.setPointerCapture = () => {};
dom.window.Element.prototype.releasePointerCapture = () => {};
dom.window.Element.prototype.hasPointerCapture = () => false;
dom.window.Element.prototype.scrollIntoView = () => {};
// jsdom has no Web Animations API; Svelte transitions expect one.
dom.window.Element.prototype.animate = () => {
const animation = {
currentTime: 0,
startTime: 0,
playState: "finished",
effect: { getComputedTiming: () => ({ delay: 0, duration: 0 }) },
finished: Promise.resolve(),
onfinish: null,
cancel() {},
finish() {},
pause() {},
play() {},
reverse() {},
commitStyles() {},
addEventListener() {},
removeEventListener() {},
};
queueMicrotask(() => animation.onfinish?.());
return animation;
};
globalThis.ResizeObserver = class {
observe() {}
unobserve() {}
@@ -85,6 +109,9 @@ const app = {
const next = fn(files.get(file.path));
writes.push({ path: file.path, content: next });
files.set(file.path, next);
// Obsidian fires a modify event after a write; mirror that here so the
// store reflects the vault the way it does in the real plugin.
store.queueReload(file.path);
return next;
},
on: () => ({}),
@@ -115,7 +142,8 @@ function check(name, condition, detail = "") {
}
}
const settle = () => new Promise((resolve) => setTimeout(resolve, 60));
// Long enough to clear the store's 150 ms reload debounce.
const settle = () => new Promise((resolve) => setTimeout(resolve, 220));
const store = new BoardStore(app, settings, () => {});
await store.reloadAll();
@@ -197,6 +225,278 @@ if (button) {
}
}
// The whole point of the composer: pick the day and the lane without ever
// touching the sidebar, and write the line straight into that lane.
store.view = "calendar";
store.sphereFilter = null;
store.projectFilter = null;
flushSync();
await settle();
const viewRoot = target.querySelector(".bcal-root");
const key = (node, init) =>
node.dispatchEvent(
new dom.window.KeyboardEvent("keydown", { bubbles: true, ...init }),
);
viewRoot.focus();
key(viewRoot, { key: "t", code: "KeyT" });
key(viewRoot, { key: "n", code: "KeyN" });
flushSync();
await settle();
const composer = dom.window.document.querySelector(
'input[placeholder="What needs doing?"]',
);
check("n opened the composer on the cursor day", composer !== null);
if (composer) {
// Tab to the sphere field, filter it down, and take the highlighted match.
key(composer, { key: "Tab", code: "Tab" });
flushSync();
const sphereField = dom.window.document.querySelector(
'input[placeholder="home"], input[placeholder="personal"], input[placeholder="reading"], input[placeholder="work"]',
);
check("tab moved into the sphere field", sphereField !== null);
if (sphereField) {
sphereField.value = "work";
sphereField.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
flushSync();
key(sphereField, { key: "Tab", code: "Tab" });
flushSync();
}
const title = dom.window.document.querySelector(
'input[placeholder="What needs doing?"]',
);
title.value = "ship the composer";
title.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
flushSync();
key(title, { key: "Enter", code: "Enter" });
await settle();
const board = files.get(`${FOLDER}/work.md`) ?? "";
const line = board.split("\n").find((each) => each.includes("ship the composer"));
check("the task landed in the sphere the composer picked", line !== undefined, board.slice(0, 80));
check("it carries today's due date", /📅 \d{4}-\d{2}-\d{2}/u.test(line ?? ""), line);
check("the composer closed after writing", store.editing === null);
check(
"the lane is remembered for the next task",
store.lastLane?.path === `${FOLDER}/work.md`,
JSON.stringify(store.lastLane),
);
}
// Rescheduling from the keyboard keeps the cursor on the task it moved.
store.zone = "day";
store.cursorTask = null;
flushSync();
key(viewRoot, { key: "Enter", code: "Enter" });
flushSync();
const picked = store.cursorTask;
check("enter dropped the cursor onto a task", picked !== null, `${picked}`);
if (picked) {
const before = store.taskById(picked)?.due;
key(viewRoot, { key: "L", code: "KeyL", shiftKey: true });
await settle();
flushSync();
const after = store.taskById(store.cursorTask)?.due;
check("shift+l pushed the task a day forward", after !== before, `${before} -> ${after}`);
check("the day cursor followed it", store.cursorDay === after, `${store.cursorDay}`);
}
// Cursor chrome is earned by a key press and given up by a click.
store.leaveToMain();
store.keyboard = false;
flushSync();
check(
"no outline before a key is pressed",
target.querySelector("[data-here='true']") === null,
);
key(viewRoot, { key: "l", code: "KeyL" });
flushSync();
check(
"the outline appears once the keyboard is used",
target.querySelector("[data-here='true']") !== null,
);
viewRoot.dispatchEvent(
new dom.window.PointerEvent("pointerdown", { bubbles: true }),
);
flushSync();
check(
"and goes away again on a click",
target.querySelector("[data-here='true']") === null,
);
// `u` is a round trip: into the tray, then out and shut.
key(viewRoot, { key: "u", code: "KeyU" });
flushSync();
check("u took the cursor into the tray", store.zone === "undated", store.zone);
key(viewRoot, { key: "u", code: "KeyU" });
flushSync();
check("u again handed it back", store.zone === "day", store.zone);
check("and closed the tray", store.undatedCollapsed === true);
// `b` does the same for the spheres, and Enter filters by the row.
store.undatedCollapsed = false;
key(viewRoot, { key: "b", code: "KeyB" });
flushSync();
check("b took the cursor into the rail", store.zone === "rail", store.zone);
key(viewRoot, { key: "j", code: "KeyJ" });
key(viewRoot, { key: "Enter", code: "Enter" });
flushSync();
check("enter filtered by the row", store.sphereFilter !== null, `${store.sphereFilter}`);
check("and stepped back out", store.zone !== "rail", store.zone);
// The list has no day grid, so its cursor starts on a task from the first key.
store.view = "list";
store.leaveToMain();
store.cursorTask = null;
store.sphereFilter = null;
flushSync();
await settle();
key(viewRoot, { key: "j", code: "KeyJ" });
flushSync();
check("j picks up the first task in the list", store.cursorTask !== null);
// Grouping reorders the list, so the cursor has to follow the drawn order and
// not the flat sorted one underneath it.
for (const grouping of ["sphere", "project", "due", "status"]) {
store.groupBy = grouping;
store.cursorTask = null;
flushSync();
const drawn = [...target.querySelectorAll("[data-drop^='task:']")].map(
(row) => row.getAttribute("data-drop").slice(5),
);
const walked = [];
for (let i = 0; i < drawn.length; i += 1) {
key(viewRoot, { key: "j", code: "KeyJ" });
flushSync();
walked.push(store.cursorTask);
}
check(
`j walks the list in drawn order, grouped by ${grouping}`,
walked.join("|") === drawn.join("|"),
`${walked.length} of ${drawn.length}`,
);
key(viewRoot, { key: "k", code: "KeyK" });
flushSync();
check(
`k comes back up the same way, grouped by ${grouping}`,
store.cursorTask === drawn[drawn.length - 2],
);
}
// d, m and p summon a panel with no trigger behind them; if it fails to mount,
// the view goes on swallowing every key and the keyboard looks dead.
const panelOf = () => dom.window.document.querySelector("[role='dialog']");
for (const [name, init] of [
["p", { key: "p", code: "KeyP" }],
["e", { key: "e", code: "KeyE" }],
]) {
store.cursorTask = null;
store.request = null;
flushSync();
key(viewRoot, { key: "j", code: "KeyJ" });
flushSync();
key(viewRoot, init);
flushSync();
check(`${name} opened its panel`, panelOf() !== null);
panelOf()?.dispatchEvent(
new dom.window.KeyboardEvent("keydown", {
bubbles: true,
key: "Escape",
code: "Escape",
}),
);
flushSync();
check(
`${name} let go on escape`,
store.request === null && store.editing === null && panelOf() === null,
);
const before = store.cursorTask;
key(viewRoot, { key: "j", code: "KeyJ" });
flushSync();
check(`the keyboard answers again after ${name}`, store.cursorTask !== before);
}
// The date field takes words, which is the whole point of tabbing to it.
{
store.view = "list";
store.leaveToMain();
flushSync();
await settle();
key(viewRoot, { key: "n", code: "KeyN" });
flushSync();
const panel = panelOf();
check("n opened the editor", panel !== null);
const type = (node, value) => {
node.value = value;
node.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
flushSync();
};
const tab = () => {
(dom.window.document.activeElement ?? panel).dispatchEvent(
new dom.window.KeyboardEvent("keydown", {
bubbles: true,
key: "Tab",
code: "Tab",
}),
);
flushSync();
};
type(panel.querySelector("input"), "flight to lisbon");
tab(); // sphere
tab(); // project
tab(); // date
const dateField = dom.window.document.activeElement;
check("three tabs land on the date", dateField?.placeholder !== undefined);
type(dateField, "in 3 days");
check(
"the words are read back before they are taken",
panel.textContent.includes(
new Intl.DateTimeFormat("en-US", {
weekday: "short",
day: "numeric",
month: "long",
}).format(new Date(Date.now() + 3 * 86_400_000)),
),
panel.textContent.slice(0, 120),
);
dateField.dispatchEvent(
new dom.window.KeyboardEvent("keydown", {
bubbles: true,
key: "Enter",
code: "Enter",
}),
);
await settle();
const wanted = new Date(Date.now() + 3 * 86_400_000);
const stamp = `${wanted.getFullYear()}-${String(wanted.getMonth() + 1).padStart(2, "0")}-${String(wanted.getDate()).padStart(2, "0")}`;
const written = [...files.values()]
.flatMap((board) => board.split("\n"))
.find((line) => line.includes("flight to lisbon"));
check("the task was written with the date those words meant", written?.includes(stamp), written);
}
// A request whose task vanished must not leave the view deaf.
store.request = "priority";
store.cursorTask = "nothing/at/all#0";
flushSync();
check("a request with no task clears itself", store.request === null);
await unmount(instance);
console.log(failures.length === 0 ? "\nsmoke: all good" : `\nsmoke: ${failures.length} FAILED`);
+120
View File
@@ -0,0 +1,120 @@
import { describe, expect, it } from "vitest";
import { parseRepeat, parseWhen } from "../src/lib/when";
// A Wednesday, so weekday arithmetic has somewhere to go in both directions.
const WED = new Date(2026, 7, 12);
const on = (text: string): string | null => parseWhen(text, WED);
describe("parseWhen", () => {
it("takes an ISO date as written", () => {
expect(on("2026-08-20")).toBe("2026-08-20");
expect(on("2026-8-3")).toBe("2026-08-03");
});
it("refuses a date that does not exist", () => {
expect(on("2026-02-30")).toBeNull();
expect(on("2026-13-01")).toBeNull();
});
it("knows the words for the days around now", () => {
expect(on("today")).toBe("2026-08-12");
expect(on("tod")).toBe("2026-08-12");
expect(on("tomorrow")).toBe("2026-08-13");
expect(on("tmr")).toBe("2026-08-13");
expect(on("yesterday")).toBe("2026-08-11");
});
it("counts forward in days, weeks and months", () => {
expect(on("in 3 days")).toBe("2026-08-15");
expect(on("3 days")).toBe("2026-08-15");
expect(on("3d")).toBe("2026-08-15");
expect(on("in a week")).toBe("2026-08-19");
expect(on("2w")).toBe("2026-08-26");
expect(on("in 1 month")).toBe("2026-09-12");
expect(on("next week")).toBe("2026-08-19");
expect(on("next year")).toBe("2027-08-12");
});
it("reads a weekday as the next one, never today", () => {
expect(on("friday")).toBe("2026-08-14");
expect(on("fri")).toBe("2026-08-14");
expect(on("monday")).toBe("2026-08-17");
expect(on("wednesday")).toBe("2026-08-19");
expect(on("next friday")).toBe("2026-08-14");
});
it("reads a day and a month either way round", () => {
expect(on("20 aug")).toBe("2026-08-20");
expect(on("aug 20")).toBe("2026-08-20");
expect(on("20 august")).toBe("2026-08-20");
expect(on("20 aug 2027")).toBe("2027-08-20");
});
it("rolls a date that has already gone into next year", () => {
expect(on("1 aug")).toBe("2027-08-01");
expect(on("1 aug 2026")).toBe("2026-08-01");
});
it("reads a numeric date day first", () => {
expect(on("20.08")).toBe("2026-08-20");
expect(on("20/8")).toBe("2026-08-20");
expect(on("20.08.27")).toBe("2027-08-20");
});
it("reads a bare number as a day of the month", () => {
expect(on("20")).toBe("2026-08-20");
expect(on("12")).toBe("2026-08-12");
expect(on("3")).toBe("2026-09-03");
});
it("says nothing when the words mean nothing", () => {
expect(on("")).toBeNull();
expect(on(" ")).toBeNull();
expect(on("buy milk")).toBeNull();
expect(on("someday")).toBeNull();
expect(on("99")).toBeNull();
});
it("ignores case and extra spaces", () => {
expect(on(" In 3 Days ")).toBe("2026-08-15");
expect(on("TOMORROW")).toBe("2026-08-13");
});
});
describe("parseRepeat", () => {
it("normalises the everyday phrasings", () => {
expect(parseRepeat("daily")).toBe("every day");
expect(parseRepeat("weekly")).toBe("every week");
expect(parseRepeat("monthly")).toBe("every month");
expect(parseRepeat("yearly")).toBe("every year");
expect(parseRepeat("fortnightly")).toBe("every 2 weeks");
});
it("takes a bare unit or a count", () => {
expect(parseRepeat("week")).toBe("every week");
expect(parseRepeat("2 weeks")).toBe("every 2 weeks");
expect(parseRepeat("every 3 days")).toBe("every 3 days");
expect(parseRepeat("every 1 day")).toBe("every 1 day");
});
it("takes a weekday with or without the every", () => {
expect(parseRepeat("monday")).toBe("every monday");
expect(parseRepeat("mondays")).toBe("every monday");
expect(parseRepeat("every tue")).toBe("every tuesday");
});
it("clears on the words for nothing", () => {
expect(parseRepeat("none")).toBe("");
expect(parseRepeat("never")).toBe("");
});
it("passes an unknown every-rule through for Tasks to read", () => {
expect(parseRepeat("every 2nd wednesday")).toBe("every 2nd wednesday");
expect(parseRepeat("every month on the last")).toBe("every month on the last");
});
it("says nothing when it is not a rule at all", () => {
expect(parseRepeat("")).toBeNull();
expect(parseRepeat("buy milk")).toBeNull();
});
});