feat: init
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseBoard } from "../src/model/board";
|
||||
import { serializeTaskLine } from "../src/model/serialize";
|
||||
import type { Task } from "../src/model/types";
|
||||
import {
|
||||
applyArchive,
|
||||
applyReorder,
|
||||
cutTask,
|
||||
insertBlock,
|
||||
locate,
|
||||
MissingLaneError,
|
||||
replaceLine,
|
||||
StaleTaskError,
|
||||
} from "../src/vault/edits";
|
||||
|
||||
const FIXTURES = join(import.meta.dirname, "fixtures");
|
||||
|
||||
function board(name: string) {
|
||||
const content = readFileSync(join(FIXTURES, name), "utf8");
|
||||
return { content, board: parseBoard(`Boards/${name}`, content) };
|
||||
}
|
||||
|
||||
function landmarks(text: string) {
|
||||
const lines = text.split("\n");
|
||||
return {
|
||||
settings: lines.filter((line) => line.startsWith("%% kanban:settings")).length,
|
||||
fences: lines.filter((line) => line.trim() === "***").length,
|
||||
headings: lines.filter((line) => line.startsWith("## ")),
|
||||
frontmatter: text.startsWith("---\n")
|
||||
? text.slice(0, text.indexOf("\n---\n", 4))
|
||||
: "",
|
||||
};
|
||||
}
|
||||
|
||||
function edited(name: string, mutate: (lines: string[]) => void) {
|
||||
const { content } = board(name);
|
||||
const lines = content.split("\n");
|
||||
mutate(lines);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
describe("locate", () => {
|
||||
it("finds a line that has shifted", () => {
|
||||
const { board: parsed } = board("personal.md");
|
||||
const task = parsed.tasks.find((item) => !item.inArchive) as Task;
|
||||
const lines = readFileSync(join(FIXTURES, "personal.md"), "utf8").split("\n");
|
||||
lines.unshift("", "", "");
|
||||
expect(locate(lines, task)).toBe(task.line + 3);
|
||||
});
|
||||
|
||||
it("refuses when the line is gone", () => {
|
||||
const { board: parsed } = board("personal.md");
|
||||
expect(() => locate(["nothing", "here"], parsed.tasks[0])).toThrow(
|
||||
StaleTaskError,
|
||||
);
|
||||
});
|
||||
|
||||
it("picks the nearest of several identical lines", () => {
|
||||
const { board: parsed } = board("work.md");
|
||||
const counts = new Map<string, Task[]>();
|
||||
for (const task of parsed.tasks) {
|
||||
const list = counts.get(task.raw) ?? [];
|
||||
list.push(task);
|
||||
counts.set(task.raw, list);
|
||||
}
|
||||
const dupes = [...counts.values()].find((list) => list.length > 1);
|
||||
expect(dupes, "work.md should carry duplicate archived lines").toBeDefined();
|
||||
const lines = readFileSync(join(FIXTURES, "work.md"), "utf8").split("\n");
|
||||
for (const task of dupes as Task[]) {
|
||||
expect(locate(lines, task)).toBe(task.line);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("replaceLine", () => {
|
||||
it("changes only the target line", () => {
|
||||
const { content, board: parsed } = board("personal.md");
|
||||
const task = parsed.tasks.find(
|
||||
(item) => item.text === "renew the gym membership",
|
||||
) as Task;
|
||||
expect(task).toBeDefined();
|
||||
const next = edited("personal.md", (lines) => {
|
||||
replaceLine(lines, task, serializeTaskLine({ ...task, due: "2026-09-01" }));
|
||||
});
|
||||
const before = content.split("\n");
|
||||
const after = next.split("\n");
|
||||
expect(after).toHaveLength(before.length);
|
||||
expect(after.filter((line, index) => line !== before[index])).toEqual([
|
||||
"- [ ] renew the gym membership 📅 2026-09-01",
|
||||
]);
|
||||
});
|
||||
|
||||
it("expands a recurring toggle into two lines", () => {
|
||||
const { board: parsed } = board("personal.md");
|
||||
const task = parsed.tasks.find(
|
||||
(item) => !item.inArchive && item.recurrence,
|
||||
) as Task;
|
||||
const next = edited("personal.md", (lines) => {
|
||||
replaceLine(
|
||||
lines,
|
||||
task,
|
||||
`- [ ] ${task.text} 🔁 every day 📅 2026-08-09\n- [x] ${task.text} 🔁 every day 📅 2026-08-08 ✅ 2026-08-08`,
|
||||
);
|
||||
});
|
||||
expect(next).toContain("📅 2026-08-09");
|
||||
expect(next).toContain("✅ 2026-08-08");
|
||||
expect(landmarks(next).settings).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cutTask and insertBlock", () => {
|
||||
it("moves a card between lanes of the same board", () => {
|
||||
const { content, board: parsed } = board("personal.md");
|
||||
const task = parsed.tasks.find(
|
||||
(item) => !item.inArchive && item.project === "errands",
|
||||
) as Task;
|
||||
const next = edited("personal.md", (lines) => {
|
||||
const block = cutTask(lines, task);
|
||||
insertBlock(lines, "personal.md", { project: "someday" }, block);
|
||||
});
|
||||
|
||||
const reparsed = parseBoard("personal.md", next);
|
||||
const moved = reparsed.tasks.find((item) => item.text === task.text) as Task;
|
||||
expect(moved.project).toBe("someday");
|
||||
expect(reparsed.tasks).toHaveLength(parsed.tasks.length);
|
||||
|
||||
const before = landmarks(content);
|
||||
const after = landmarks(next);
|
||||
expect(after.settings).toBe(before.settings);
|
||||
expect(after.fences).toBe(before.fences);
|
||||
expect(after.headings).toEqual(before.headings);
|
||||
});
|
||||
|
||||
it("carries a multi-line card body along", () => {
|
||||
const { content, board: parsed } = board("work.md");
|
||||
const task = parsed.tasks.find((item) => item.body.length > 1) as Task;
|
||||
expect(task).toBeDefined();
|
||||
const lines = content.split("\n");
|
||||
const before = lines.length;
|
||||
const block = cutTask(lines, task);
|
||||
expect(block).toHaveLength(1 + task.body.length);
|
||||
expect(block.slice(1)).toEqual(task.body);
|
||||
expect(lines.join("\n")).not.toContain(block.join("\n"));
|
||||
expect(before - lines.length).toBeGreaterThanOrEqual(block.length);
|
||||
});
|
||||
|
||||
it("reports a missing lane instead of writing", () => {
|
||||
const lines = board("personal.md").content.split("\n");
|
||||
expect(() =>
|
||||
insertBlock(lines, "personal.md", { project: "nope" }, ["- [ ] x"]),
|
||||
).toThrow(MissingLaneError);
|
||||
});
|
||||
|
||||
it("appends after the last card, before the lane's blank tail", () => {
|
||||
const next = edited("personal.md", (lines) => {
|
||||
insertBlock(lines, "personal.md", { project: "errands" }, [
|
||||
"- [ ] new one 📅 2026-08-08",
|
||||
]);
|
||||
});
|
||||
const lane = parseBoard("personal.md", next).tasks.filter(
|
||||
(item) => item.project === "errands" && !item.inArchive,
|
||||
);
|
||||
expect(lane[lane.length - 1].text).toBe("new one");
|
||||
expect(landmarks(next).settings).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyReorder", () => {
|
||||
it("reverses a lane without disturbing the rest of the file", () => {
|
||||
const { content, board: parsed } = board("personal.md");
|
||||
const inLane = parsed.tasks.filter(
|
||||
(item) => item.project === "errands" && !item.inArchive,
|
||||
);
|
||||
expect(inLane.length).toBeGreaterThan(2);
|
||||
|
||||
const next = edited("personal.md", (lines) => {
|
||||
applyReorder(lines, inLane, [...inLane].reverse());
|
||||
});
|
||||
|
||||
const after = parseBoard("personal.md", next)
|
||||
.tasks.filter((item) => item.project === "errands" && !item.inArchive)
|
||||
.map((item) => item.text);
|
||||
expect(after).toEqual([...inLane].reverse().map((item) => item.text));
|
||||
|
||||
expect(next.split("\n").sort()).toEqual(content.split("\n").sort());
|
||||
expect(landmarks(next)).toEqual(landmarks(content));
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyArchive", () => {
|
||||
it("moves done cards under the archive heading", () => {
|
||||
const { content, board: parsed } = board("personal.md");
|
||||
const lines = content.split("\n");
|
||||
const open = parsed.tasks.find((item) => !item.inArchive) as Task;
|
||||
replaceLine(
|
||||
lines,
|
||||
open,
|
||||
serializeTaskLine({ ...open, status: "x", done: "2026-08-08" }),
|
||||
);
|
||||
|
||||
expect(applyArchive(lines, "personal.md", "Archive")).toBe(1);
|
||||
|
||||
const next = lines.join("\n");
|
||||
const reparsed = parseBoard("personal.md", next);
|
||||
const archived = reparsed.tasks.find((item) => item.text === open.text) as Task;
|
||||
expect(archived.inArchive).toBe(true);
|
||||
expect(archived.done).toBe("2026-08-08");
|
||||
expect(reparsed.tasks.filter((item) => item.text === open.text)).toHaveLength(1);
|
||||
expect(landmarks(next).settings).toBe(1);
|
||||
expect(landmarks(next).fences).toBe(1);
|
||||
});
|
||||
|
||||
it("is a no-op when nothing is done", () => {
|
||||
const { content } = board("home.md");
|
||||
const lines = content.split("\n");
|
||||
expect(applyArchive(lines, "home.md", "Archive")).toBe(0);
|
||||
expect(lines.join("\n")).toBe(content);
|
||||
});
|
||||
|
||||
it("creates the fence and heading on a board without one", () => {
|
||||
const { content } = board("home.md");
|
||||
expect(content).not.toContain("## Archive");
|
||||
const lines = content.split("\n");
|
||||
const parsed = parseBoard("home.md", content);
|
||||
for (const task of parsed.tasks) {
|
||||
replaceLine(
|
||||
lines,
|
||||
task,
|
||||
serializeTaskLine({ ...task, status: "x", done: "2026-08-08" }),
|
||||
);
|
||||
}
|
||||
expect(applyArchive(lines, "home.md", "Archive")).toBe(parsed.tasks.length);
|
||||
|
||||
const next = lines.join("\n");
|
||||
expect(next).toContain("***");
|
||||
expect(next).toContain("## Archive");
|
||||
expect(next).toContain("%% kanban:settings");
|
||||
expect(parseBoard("home.md", next).tasks.every((t) => t.inArchive)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("every board survives a full archive pass", () => {
|
||||
for (const name of readdirSync(FIXTURES).filter((n) => n.endsWith(".md"))) {
|
||||
it(`${name}: landmarks intact, no task lost`, () => {
|
||||
const { content, board: parsed } = board(name);
|
||||
const lines = content.split("\n");
|
||||
for (const task of parsed.tasks.filter((item) => !item.inArchive)) {
|
||||
replaceLine(
|
||||
lines,
|
||||
task,
|
||||
serializeTaskLine({ ...task, status: "x", done: "2026-08-08" }),
|
||||
);
|
||||
}
|
||||
applyArchive(lines, name, "Archive");
|
||||
const next = lines.join("\n");
|
||||
|
||||
const reparsed = parseBoard(name, next);
|
||||
expect(reparsed.tasks).toHaveLength(parsed.tasks.length);
|
||||
expect(reparsed.tasks.every((item) => item.inArchive)).toBe(true);
|
||||
|
||||
const before = landmarks(content);
|
||||
const after = landmarks(next);
|
||||
expect(after.settings).toBe(before.settings);
|
||||
expect(after.frontmatter).toBe(before.frontmatter);
|
||||
for (const heading of before.headings) {
|
||||
expect(after.headings, `${heading} must survive`).toContain(heading);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
Vendored
+17
@@ -0,0 +1,17 @@
|
||||
---
|
||||
|
||||
kanban-plugin: board
|
||||
|
||||
---
|
||||
|
||||
## repairs
|
||||
|
||||
- [ ] replace the kitchen tap 📅 2026-08-20
|
||||
- [ ] fix the squeaky door
|
||||
|
||||
|
||||
%% kanban:settings
|
||||
```
|
||||
{"kanban-plugin":"board","list-collapse":[false]}
|
||||
```
|
||||
%%
|
||||
Vendored
+34
@@ -0,0 +1,34 @@
|
||||
---
|
||||
|
||||
kanban-plugin: board
|
||||
|
||||
---
|
||||
|
||||
## errands
|
||||
|
||||
- [ ] renew the gym membership 📅 2026-08-08
|
||||
- [ ] book a dentist appointment ⏳ 2026-08-09 📅 2026-08-12
|
||||
- [ ] water the plants 🔁 every day 📅 2026-08-08
|
||||
- [ ] call [[Alex Rivera|Alex]] about the move 📅 2026-08-09
|
||||
|
||||
|
||||
## someday
|
||||
|
||||
- [ ] learn to bake sourdough
|
||||
- [ ] read **Thinking in Systems**
|
||||
|
||||
|
||||
***
|
||||
|
||||
## Archive
|
||||
|
||||
- [x] pick up the parcel 📅 2026-07-30 ✅ 2026-07-30
|
||||
- [x] pay the electricity bill ⏳ 2026-07-28 📅 2026-07-29 ✅ 2026-07-28
|
||||
- [x] water the plants 🔁 every day 📅 2026-07-27 ✅ 2026-07-27
|
||||
- [x] water the plants 🔁 every day 📅 2026-07-26 ✅ 2026-07-27
|
||||
|
||||
%% kanban:settings
|
||||
```
|
||||
{"kanban-plugin":"board","list-collapse":[false,false]}
|
||||
```
|
||||
%%
|
||||
Vendored
+24
@@ -0,0 +1,24 @@
|
||||
---
|
||||
|
||||
kanban-plugin: board
|
||||
|
||||
---
|
||||
|
||||
## queue
|
||||
|
||||
- [ ] ![[cover.png]]
|
||||
- [ ] compare the pricing tiers https://example.com/pricing#plans
|
||||
- [ ] finish the chapter on caching 📅 2026-08-15
|
||||
|
||||
|
||||
***
|
||||
|
||||
## Archive
|
||||
|
||||
- [x] skim the release notes ✅ 2026-07-15
|
||||
|
||||
%% kanban:settings
|
||||
```
|
||||
{"kanban-plugin":"board","list-collapse":[false,false]}
|
||||
```
|
||||
%%
|
||||
Vendored
+43
@@ -0,0 +1,43 @@
|
||||
---
|
||||
planner:
|
||||
log: []
|
||||
kanban-plugin: board
|
||||
TQ_explain:
|
||||
TQ_short_mode:
|
||||
TQ_show_due_date:
|
||||
TQ_show_tags:
|
||||
---
|
||||
|
||||
## website
|
||||
|
||||
- [ ] fix the mobile nav overlap ⏫ 📅 2026-08-10
|
||||
- [ ] compress the hero images https://example.com/tools/images
|
||||
|
||||
|
||||
## backlog
|
||||
|
||||
|
||||
|
||||
## notes
|
||||
|
||||
- [x] rewrite the onboarding copy ✅ 2026-08-01
|
||||
- shorter intro
|
||||
- drop the second screenshot
|
||||
- link to the docs instead
|
||||
- [x] audit the third-party scripts ✅ 2026-07-31
|
||||
|
||||
follow-up: check the analytics bundle size
|
||||
|
||||
|
||||
***
|
||||
|
||||
## Archive
|
||||
|
||||
- [x] ship the pricing page 📅 2026-07-20 ✅ 2026-07-21
|
||||
- [x] ship the pricing page 📅 2026-07-20 ✅ 2026-07-21
|
||||
|
||||
%% kanban:settings
|
||||
```
|
||||
{"kanban-plugin":"board","list-collapse":[false,false,false,false]}
|
||||
```
|
||||
%%
|
||||
@@ -0,0 +1,57 @@
|
||||
export class TFile {
|
||||
constructor(
|
||||
public path: string,
|
||||
public extension = "md",
|
||||
) {}
|
||||
}
|
||||
|
||||
export class TFolder {
|
||||
constructor(public path: string) {}
|
||||
}
|
||||
|
||||
export const notices: string[] = [];
|
||||
|
||||
export class Notice {
|
||||
constructor(message: string) {
|
||||
notices.push(message);
|
||||
}
|
||||
hide(): void {}
|
||||
}
|
||||
|
||||
export class Plugin {}
|
||||
export class ItemView {}
|
||||
export class PluginSettingTab {}
|
||||
export class Setting {}
|
||||
export class Component {}
|
||||
export const moment = () => undefined;
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
export const MarkdownRenderer = {
|
||||
async render(
|
||||
_app: unknown,
|
||||
markdown: string,
|
||||
el: HTMLElement,
|
||||
_sourcePath: string,
|
||||
_component: unknown,
|
||||
): Promise<void> {
|
||||
const html = escapeHtml(markdown)
|
||||
.replace(
|
||||
/!?\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g,
|
||||
(_full, target: string, alias?: string) =>
|
||||
`<a class="internal-link" href="${target}">${alias ?? target}</a>`,
|
||||
)
|
||||
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
||||
.replace(
|
||||
/(^|\s)(https?:\/\/[^\s]+)/g,
|
||||
(_full, lead: string, url: string) =>
|
||||
`${lead}<a class="external-link" href="${url}">${url}</a>`,
|
||||
);
|
||||
el.innerHTML = `<p>${html}</p>`;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseBoard, renderBoard } from "../src/model/board";
|
||||
import { serializeTaskLine } from "../src/model/serialize";
|
||||
|
||||
const FIXTURES = join(import.meta.dirname, "fixtures");
|
||||
const BOARDS = readdirSync(FIXTURES).filter((name) => name.endsWith(".md"));
|
||||
|
||||
describe("board round-trip", () => {
|
||||
it("has boards to work with", () => {
|
||||
expect(BOARDS.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
for (const name of BOARDS) {
|
||||
const content = readFileSync(join(FIXTURES, name), "utf8");
|
||||
const board = parseBoard(`Boards/${name}`, content);
|
||||
|
||||
it(`${name}: re-renders byte for byte`, () => {
|
||||
expect(renderBoard(board)).toBe(content);
|
||||
});
|
||||
|
||||
it(`${name}: every task line survives parse then serialize`, () => {
|
||||
for (const task of board.tasks) {
|
||||
expect(serializeTaskLine(task), `line ${task.line + 1}`).toBe(task.raw);
|
||||
}
|
||||
});
|
||||
|
||||
it(`${name}: finds tasks and lanes`, () => {
|
||||
expect(board.lanes.length).toBeGreaterThan(0);
|
||||
expect(board.tasks.length).toBeGreaterThan(0);
|
||||
for (const task of board.tasks) {
|
||||
expect(board.lines[task.line]).toBe(task.raw);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("field extraction", () => {
|
||||
const parse = (line: string) => {
|
||||
const board = parseBoard("x.md", `## lane\n\n${line}\n`);
|
||||
return board.tasks[0];
|
||||
};
|
||||
|
||||
it("reads recurrence followed by dates", () => {
|
||||
const task = parse("- [ ] water the plants 🔁 every day 📅 2026-08-08");
|
||||
expect(task.text).toBe("water the plants");
|
||||
expect(task.recurrence).toBe("every day");
|
||||
expect(task.due).toBe("2026-08-08");
|
||||
expect(task.scheduled).toBeNull();
|
||||
});
|
||||
|
||||
it("reads scheduled, due and done together", () => {
|
||||
const task = parse(
|
||||
"- [x] pay the electricity bill ⏳ 2026-07-28 📅 2026-07-29 ✅ 2026-07-28",
|
||||
);
|
||||
expect(task.status).toBe("x");
|
||||
expect(task.text).toBe("pay the electricity bill");
|
||||
expect(task.scheduled).toBe("2026-07-28");
|
||||
expect(task.due).toBe("2026-07-29");
|
||||
expect(task.done).toBe("2026-07-28");
|
||||
});
|
||||
|
||||
it("keeps wikilinks and embeds inside the description", () => {
|
||||
const task = parse("- [ ] call [[Alex Rivera|Alex]] about the move 📅 2026-08-09");
|
||||
expect(task.text).toBe("call [[Alex Rivera|Alex]] about the move");
|
||||
expect(task.due).toBe("2026-08-09");
|
||||
});
|
||||
|
||||
it("does not mistake a url fragment for a tag", () => {
|
||||
const task = parse("- [ ] compare tiers https://example.com/pricing#plans");
|
||||
expect(task.tags).toEqual([]);
|
||||
expect(task.text).toBe("compare tiers https://example.com/pricing#plans");
|
||||
});
|
||||
|
||||
it("reads priority and writes it back in Tasks' order", () => {
|
||||
const task = parse("- [ ] fix the mobile nav ⏫ 📅 2026-08-10");
|
||||
expect(task.priority).toBe(4);
|
||||
expect(serializeTaskLine(task)).toBe("- [ ] fix the mobile nav ⏫ 📅 2026-08-10");
|
||||
});
|
||||
|
||||
it("captures a multi-line kanban card body", () => {
|
||||
const board = parseBoard(
|
||||
"x.md",
|
||||
"## lane\n\n- [x] rewrite the copy ✅ 2026-08-01\n\t- shorter intro\n\t- link the docs\n",
|
||||
);
|
||||
expect(board.tasks[0].body).toEqual(["\t- shorter intro", "\t- link the docs"]);
|
||||
});
|
||||
|
||||
it("marks archive lanes", () => {
|
||||
const board = parseBoard(
|
||||
"x.md",
|
||||
"## live\n\n- [ ] a\n\n***\n\n## Archive\n\n- [x] b ✅ 2026-01-01\n",
|
||||
);
|
||||
expect(board.tasks[0].inArchive).toBe(false);
|
||||
expect(board.tasks[1].inArchive).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores anything inside the kanban settings block", () => {
|
||||
const board = parseBoard(
|
||||
"x.md",
|
||||
'## lane\n\n- [ ] real\n\n%% kanban:settings\n```\n{"kanban-plugin":"board"}\n```\n%%\n',
|
||||
);
|
||||
expect(board.tasks).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
import { mkdtempSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import esbuild from "esbuild";
|
||||
import sveltePlugin from "esbuild-svelte";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
const root = new URL("..", import.meta.url).pathname;
|
||||
const fixtures = join(root, "tests/fixtures");
|
||||
const FOLDER = "Boards";
|
||||
|
||||
const out = join(mkdtempSync(join(tmpdir(), "bcal-")), "bundle.mjs");
|
||||
writeFileSync(
|
||||
join(root, "tests/.entry.ts"),
|
||||
`export { default as App } from "../src/ui/app.svelte";
|
||||
export { BoardStore } from "../src/vault/store.svelte";
|
||||
export { mount, unmount, flushSync } from "svelte";
|
||||
export { TFile, Component } from "obsidian";
|
||||
`,
|
||||
);
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: [join(root, "tests/.entry.ts")],
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
target: "es2022",
|
||||
outfile: out,
|
||||
logLevel: "error",
|
||||
conditions: ["svelte", "browser"],
|
||||
mainFields: ["svelte", "browser", "module", "main"],
|
||||
alias: { obsidian: join(root, "tests/obsidian-stub.ts") },
|
||||
plugins: [sveltePlugin({ compilerOptions: { css: "injected", runes: true } })],
|
||||
});
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
for (const key of Object.getOwnPropertyNames(dom.window)) {
|
||||
if (key.startsWith("_")) continue;
|
||||
if (globalThis[key] !== undefined) continue;
|
||||
try {
|
||||
globalThis[key] = dom.window[key];
|
||||
} catch {
|
||||
/* getter-only window properties are fine to skip */
|
||||
}
|
||||
}
|
||||
globalThis.window = dom.window;
|
||||
globalThis.document = dom.window.document;
|
||||
dom.window.Element.prototype.getBoundingClientRect = () => ({
|
||||
bottom: 0, height: 0, left: 0, right: 0, top: 0, width: 0, x: 0, y: 0,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
dom.window.Element.prototype.setPointerCapture = () => {};
|
||||
dom.window.Element.prototype.releasePointerCapture = () => {};
|
||||
dom.window.Element.prototype.hasPointerCapture = () => false;
|
||||
dom.window.Element.prototype.scrollIntoView = () => {};
|
||||
globalThis.ResizeObserver = class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
};
|
||||
|
||||
const { App, BoardStore, mount, unmount, flushSync, TFile, Component } =
|
||||
await import(pathToFileURL(out).href);
|
||||
|
||||
const names = readdirSync(fixtures).filter((name) => name.endsWith(".md"));
|
||||
const files = new Map(
|
||||
names.map((name) => [
|
||||
`${FOLDER}/${name}`,
|
||||
readFileSync(join(fixtures, name), "utf8"),
|
||||
]),
|
||||
);
|
||||
const handles = new Map([...files.keys()].map((path) => [path, new TFile(path)]));
|
||||
|
||||
const writes = [];
|
||||
const app = {
|
||||
vault: {
|
||||
getMarkdownFiles: () => [...handles.values()],
|
||||
getAbstractFileByPath: (path) => handles.get(path) ?? null,
|
||||
cachedRead: async (file) => files.get(file.path),
|
||||
read: async (file) => files.get(file.path),
|
||||
process: async (file, fn) => {
|
||||
const next = fn(files.get(file.path));
|
||||
writes.push({ path: file.path, content: next });
|
||||
files.set(file.path, next);
|
||||
return next;
|
||||
},
|
||||
on: () => ({}),
|
||||
},
|
||||
workspace: { getLeaf: () => ({}), activeEditor: null },
|
||||
plugins: { plugins: {} },
|
||||
};
|
||||
|
||||
const settings = {
|
||||
boardsFolder: FOLDER,
|
||||
archiveHeading: "Archive",
|
||||
view: "list",
|
||||
groupBy: "sphere",
|
||||
listSort: "due",
|
||||
calendarSort: "priority",
|
||||
showDone: false,
|
||||
includeArchive: false,
|
||||
railCollapsed: false,
|
||||
undatedCollapsed: false,
|
||||
};
|
||||
|
||||
const failures = [];
|
||||
function check(name, condition, detail = "") {
|
||||
if (condition) console.log(` ok ${name}`);
|
||||
else {
|
||||
failures.push(name);
|
||||
console.log(` FAIL ${name}${detail ? ` -- ${detail}` : ""}`);
|
||||
}
|
||||
}
|
||||
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 60));
|
||||
|
||||
const store = new BoardStore(app, settings, () => {});
|
||||
await store.reloadAll();
|
||||
|
||||
// Every task outside the archive, done ones included; the done filter
|
||||
// applies to `visible`, not to the pool.
|
||||
const outsideArchive = 15;
|
||||
check("parsed every board", store.spheres.length === names.length, `${store.spheres.length}`);
|
||||
check(`${outsideArchive} tasks outside the archive`, store.all.length === outsideArchive, `got ${store.all.length}`);
|
||||
|
||||
const target = dom.window.document.body;
|
||||
const instance = mount(App, {
|
||||
target,
|
||||
props: { app, store, settings, component: new Component() },
|
||||
});
|
||||
flushSync();
|
||||
await settle();
|
||||
flushSync();
|
||||
|
||||
const text = () => target.textContent ?? "";
|
||||
check("list rendered a task", text().includes("renew the gym membership"));
|
||||
check("sphere rail rendered", text().includes("All spheres"));
|
||||
check("group header rendered", text().includes("personal"));
|
||||
check("project chip rendered", text().includes("errands"));
|
||||
check("no crash markers", !text().includes("undefined"));
|
||||
|
||||
const links = target.querySelectorAll("a.internal-link");
|
||||
check("wikilink rendered as a link", links.length > 0, `${links.length} links`);
|
||||
check("alias is displayed, not the target", text().includes("Alex"));
|
||||
check("no raw wikilink syntax leaked", !text().includes("[["));
|
||||
|
||||
store.includeArchive = true;
|
||||
store.showDone = true;
|
||||
flushSync();
|
||||
await settle();
|
||||
check("archive appears when both toggles are on", text().includes("pick up the parcel"));
|
||||
check("archived task carries the Archive badge", text().includes("Archive"));
|
||||
|
||||
store.includeArchive = false;
|
||||
flushSync();
|
||||
check("archive hidden again", !text().includes("pick up the parcel"));
|
||||
store.showDone = false;
|
||||
flushSync();
|
||||
|
||||
store.view = "calendar";
|
||||
flushSync();
|
||||
await settle();
|
||||
check("calendar rendered a month label", /\d{4}/.test(store.monthLabel));
|
||||
check("calendar rendered the undated tray", text().includes("No date"));
|
||||
|
||||
store.view = "list";
|
||||
store.groupBy = "due";
|
||||
flushSync();
|
||||
const BUCKETS = ["Overdue", "Today", "Tomorrow", "This week", "Later", "No date"];
|
||||
check(
|
||||
"due grouping produced known buckets",
|
||||
store.groups.length > 0 && store.groups.every((g) => BUCKETS.includes(g.label)),
|
||||
store.groups.map((g) => g.label).join(", "),
|
||||
);
|
||||
|
||||
const button = target.querySelector('button[aria-label^="Status"]');
|
||||
check("status button present", button !== null);
|
||||
if (button) {
|
||||
button.dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
await settle();
|
||||
check("clicking status wrote to the vault", writes.length > 0, `${writes.length}`);
|
||||
if (writes.length > 0) {
|
||||
const after = writes[writes.length - 1];
|
||||
const before = readFileSync(
|
||||
join(fixtures, after.path.slice(FOLDER.length + 1)),
|
||||
"utf8",
|
||||
).split("\n");
|
||||
const now = after.content.split("\n");
|
||||
const diff = now.filter((line, index) => line !== before[index]);
|
||||
check("exactly one line changed", diff.length === 1, JSON.stringify(diff));
|
||||
check("line is now done", diff[0]?.startsWith("- [x] "), diff[0]);
|
||||
check("done date stamped", /✅ \d{4}-\d{2}-\d{2}$/u.test(diff[0] ?? ""), diff[0]);
|
||||
check("settings block intact", after.content.includes("%% kanban:settings"));
|
||||
}
|
||||
}
|
||||
|
||||
await unmount(instance);
|
||||
|
||||
console.log(failures.length === 0 ? "\nsmoke: all good" : `\nsmoke: ${failures.length} FAILED`);
|
||||
process.exit(failures.length === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,91 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const source = readFileSync(
|
||||
join(import.meta.dirname, "../src/tailwind.css"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
function block(selector: string): string {
|
||||
const at = source.indexOf(`\n${selector} {`);
|
||||
if (at === -1) return "";
|
||||
const open = source.indexOf("{", at);
|
||||
const close = source.indexOf("\n}", open);
|
||||
return source.slice(open, close);
|
||||
}
|
||||
|
||||
describe("theme aliases", () => {
|
||||
const OBSIDIAN_VARS = [
|
||||
"--background-primary",
|
||||
"--background-secondary",
|
||||
"--background-modifier-border",
|
||||
"--text-normal",
|
||||
"--text-muted",
|
||||
"--color-accent",
|
||||
"--text-on-accent",
|
||||
"--text-error",
|
||||
];
|
||||
|
||||
it("are declared on .bcal-root, not :root", () => {
|
||||
const root = block(".bcal-root");
|
||||
expect(root).not.toBe("");
|
||||
for (const name of OBSIDIAN_VARS) {
|
||||
expect(root, `${name} must be aliased on .bcal-root`).toContain(name);
|
||||
}
|
||||
});
|
||||
|
||||
it("never reference Obsidian variables from :root", () => {
|
||||
const rootBlocks = [...source.matchAll(/(^|\n):root\s*\{([^}]*)\}/g)];
|
||||
for (const [, , body] of rootBlocks) {
|
||||
for (const name of OBSIDIAN_VARS) {
|
||||
expect(body, `:root must not reference ${name}`).not.toContain(name);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("scopes the dark override under the view root", () => {
|
||||
expect(source).toContain(".theme-dark .bcal-root");
|
||||
expect(source).not.toMatch(/\n\.theme-dark\s*\{/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reset specificity", () => {
|
||||
it("does not use :where() for element resets", () => {
|
||||
expect(source).not.toMatch(/\.bcal-root\s+:where\(/);
|
||||
});
|
||||
|
||||
it("uses a doubled class to outrank app and theme styles", () => {
|
||||
expect(source).toContain(".bcal-root.bcal-root");
|
||||
});
|
||||
|
||||
it("neutralises the native search affordance", () => {
|
||||
expect(source).toContain("-webkit-search-cancel-button");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cascade against Obsidian", () => {
|
||||
it("marks utilities important so they outrank app.css", () => {
|
||||
expect(source).toMatch(
|
||||
/@import "tailwindcss\/utilities\.css"[^;]*\bimportant\b/,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the element reset out of a cascade layer", () => {
|
||||
const reset = source.indexOf(".bcal-root.bcal-root :is(button");
|
||||
expect(reset).toBeGreaterThan(-1);
|
||||
const before = source.slice(0, reset);
|
||||
const opens = (before.match(/@layer[^;{]*\{/g) ?? []).length;
|
||||
const closes = (before.match(/\n\}/g) ?? []).length;
|
||||
expect(opens, "reset must be unlayered").toBeLessThanOrEqual(closes);
|
||||
});
|
||||
});
|
||||
|
||||
describe("preflight stays out", () => {
|
||||
it("imports only the theme and utility layers", () => {
|
||||
expect(source).toContain('@import "tailwindcss/theme.css"');
|
||||
expect(source).toContain('@import "tailwindcss/utilities.css"');
|
||||
expect(source).not.toMatch(/@import "tailwindcss"\s*[;l]/);
|
||||
expect(source).not.toMatch(/@import\s+"tailwindcss\/preflight/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user