feat(*): one task editor, vim navigation and undo
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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`);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user