880 lines
26 KiB
TypeScript
880 lines
26 KiB
TypeScript
import parseMatrixConfig from "../../Gtd/parseMatrixConfig";
|
|
import collectMatrix from "../../Gtd/collectMatrix";
|
|
import matrixLabels from "../../Gtd/matrixLabels";
|
|
import { DataAdapter, RawFolder, RawNote } from "../../Gtd/types";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// parseMatrixConfig
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("parseMatrixConfig", () => {
|
|
test("both modes use constructive labels while preserving distinct axes", () => {
|
|
expect(matrixLabels("eisenhower")).toEqual({
|
|
columns: ["Urgent", "Not urgent"],
|
|
rows: ["Important", "Not important"],
|
|
quadrants: {
|
|
topLeft: "Do Next",
|
|
topRight: "Scheduled",
|
|
bottomLeft: "On Deck",
|
|
bottomRight: "Backlog",
|
|
},
|
|
});
|
|
expect(matrixLabels("skeleton")).toEqual({
|
|
columns: ["Priority", "Current / backlog"],
|
|
rows: ["Now", "Next / later"],
|
|
quadrants: {
|
|
topLeft: "Urgent",
|
|
topRight: "In Progress",
|
|
bottomLeft: "On Deck",
|
|
bottomRight: "Backlog",
|
|
},
|
|
});
|
|
});
|
|
test("applies SPEC defaults", () => {
|
|
const c = parseMatrixConfig({});
|
|
expect(c).toMatchObject({
|
|
mode: "skeleton",
|
|
title: null,
|
|
scopeDepth: 0,
|
|
notes: "all",
|
|
todos: "gtd-only",
|
|
sortType: "due-date",
|
|
sort: "asc",
|
|
urgentTag: "urgent",
|
|
importantTag: "important",
|
|
onDeckTag: "on-deck",
|
|
inProgressTag: "in-progress",
|
|
doneTag: "done",
|
|
cardDetail: "hover",
|
|
pageSize: 10,
|
|
editable: false,
|
|
});
|
|
expect(c.warnings).toHaveLength(0);
|
|
});
|
|
|
|
test("editable is strict and requires distinct destination tags", () => {
|
|
expect(parseMatrixConfig({ mode: "eisenhower", editable: " YES " }).editable).toBe(true);
|
|
expect(parseMatrixConfig({ mode: "eisenhower", editable: "No" }).editable).toBe(false);
|
|
|
|
for (const value of ["", "true", true, null, ["yes"], { value: "yes" }]) {
|
|
const config = parseMatrixConfig({ mode: "eisenhower", editable: value });
|
|
expect(config.editable).toBe(false);
|
|
expect(config.warnings).toContain(`Invalid editable "${String(value)}" (using "no")`);
|
|
}
|
|
|
|
expect(parseMatrixConfig({ editable: "yes" }).editable).toBe(true);
|
|
|
|
const equalAxes = parseMatrixConfig({
|
|
mode: "eisenhower",
|
|
editable: "yes",
|
|
"urgent-tag": "Axis",
|
|
"important-tag": "axis",
|
|
});
|
|
expect(equalAxes.editable).toBe(false);
|
|
expect(equalAxes.warnings).toContain(
|
|
"editable Eisenhower axis tags must be distinct (editing disabled)"
|
|
);
|
|
const equalWorkflow = parseMatrixConfig({ editable: "yes", "on-deck-tag": "URGENT" });
|
|
expect(equalWorkflow.editable).toBe(false);
|
|
});
|
|
|
|
test("parses notes independently and rejects invalid or singular forms", () => {
|
|
expect(parseMatrixConfig({ notes: " NONE " }).notes).toBe("none");
|
|
for (const value of ["", "gtd-only", null, ["all"], { mode: "all" }]) {
|
|
const config = parseMatrixConfig({ notes: value });
|
|
expect(config.notes).toBe("all");
|
|
expect(config.warnings[0]).toContain("Invalid notes");
|
|
}
|
|
const singular = parseMatrixConfig({ note: "none" });
|
|
expect(singular.notes).toBe("all");
|
|
expect(singular.warnings[0]).toContain("Unknown option");
|
|
});
|
|
|
|
test("parses done-tag and warns when it matches in-progress-tag", () => {
|
|
expect(parseMatrixConfig({ "done-tag": " Finished " }).doneTag).toBe(
|
|
"finished"
|
|
);
|
|
expect(parseMatrixConfig({ "done-tag": "" }).doneTag).toBe("done");
|
|
const same = parseMatrixConfig({
|
|
"done-tag": "Active",
|
|
"in-progress-tag": "active",
|
|
});
|
|
expect(same.warnings).toEqual([
|
|
'done-tag and in-progress-tag are both "active" — done takes priority',
|
|
]);
|
|
});
|
|
|
|
test.each([
|
|
[1, 1],
|
|
[10, 10],
|
|
[25, 25],
|
|
["12", 12],
|
|
])("page-size accepts positive integers (%p)", (input, expected) => {
|
|
const c = parseMatrixConfig({ "page-size": input });
|
|
expect(c.pageSize).toBe(expected);
|
|
expect(c.warnings).toHaveLength(0);
|
|
});
|
|
|
|
test.each([0, -1, 1.5, "many", "", NaN, Infinity, true])(
|
|
"invalid page-size %p warns and falls back to 10",
|
|
(input) => {
|
|
const c = parseMatrixConfig({ "page-size": input });
|
|
expect(c.pageSize).toBe(10);
|
|
expect(c.warnings).toEqual([`Invalid page-size "${input}" (using 10)`]);
|
|
}
|
|
);
|
|
|
|
test("mode parses and warns on invalid", () => {
|
|
expect(parseMatrixConfig({ mode: "eisenhower" }).mode).toBe("eisenhower");
|
|
const bad = parseMatrixConfig({ mode: "wiebe" });
|
|
expect(bad.mode).toBe("skeleton");
|
|
expect(bad.warnings.some((w) => w.includes("mode"))).toBe(true);
|
|
});
|
|
|
|
test("parses custom axis tags, lowercased", () => {
|
|
const c = parseMatrixConfig({
|
|
"urgent-tag": "FIRE",
|
|
"important-tag": "Matters",
|
|
});
|
|
expect(c.urgentTag).toBe("fire");
|
|
expect(c.importantTag).toBe("matters");
|
|
});
|
|
|
|
test("warns when both axis tags are identical", () => {
|
|
const c = parseMatrixConfig({
|
|
"urgent-tag": "now",
|
|
"important-tag": "NOW",
|
|
});
|
|
expect(c.warnings.some((w) => w.includes("will not separate"))).toBe(true);
|
|
});
|
|
|
|
test("invalid values warn and fall back", () => {
|
|
const c = parseMatrixConfig({
|
|
todos: "some",
|
|
"sort-type": "priority",
|
|
"card-detail": "popup",
|
|
mystery: 1,
|
|
});
|
|
expect(c.todos).toBe("gtd-only");
|
|
expect(c.sortType).toBe("due-date");
|
|
expect(c.cardDetail).toBe("hover");
|
|
expect(c.warnings.length).toBeGreaterThanOrEqual(4);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// collectMatrix
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function makeNote(overrides: Partial<RawNote>): RawNote {
|
|
return {
|
|
id: "id",
|
|
title: "Card",
|
|
parent_id: "board",
|
|
is_todo: 1,
|
|
todo_due: 0,
|
|
todo_completed: 0,
|
|
updated_time: 0,
|
|
body: "```gtd\n```",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeAdapter(
|
|
folders: RawFolder[],
|
|
notes: RawNote[],
|
|
tagsByNote: Record<string, string[]> = {}
|
|
): DataAdapter {
|
|
return {
|
|
getFolders: async () => folders,
|
|
getNotesInFolder: async (folderId: string) =>
|
|
notes.filter((n) => n.parent_id === folderId),
|
|
getNoteTagTitles: async (noteId: string) => tagsByNote[noteId] || [],
|
|
};
|
|
}
|
|
|
|
const folders: RawFolder[] = [{ id: "board", parent_id: "root" }];
|
|
|
|
describe("collectMatrix — quadrant bucketing", () => {
|
|
test("all four Eisenhower quadrants route correctly; untagged lands in Backlog", async () => {
|
|
const notes = [
|
|
makeNote({
|
|
id: "both",
|
|
is_todo: 0,
|
|
title: "Crisis",
|
|
todo_completed: 123456,
|
|
}),
|
|
makeNote({ id: "imp", is_todo: 0, title: "Strategy" }),
|
|
makeNote({ id: "urg", is_todo: 0, title: "Interruption" }),
|
|
makeNote({ id: "none", is_todo: 0, title: "Timewaster" }),
|
|
];
|
|
const result = await collectMatrix(
|
|
makeAdapter(folders, notes, {
|
|
both: ["urgent", "important"],
|
|
imp: ["important"],
|
|
urg: ["urgent"],
|
|
}),
|
|
"matrix-note",
|
|
"board",
|
|
parseMatrixConfig({ todos: "all", mode: "eisenhower" })
|
|
);
|
|
expect(result.board.topLeft.map((c) => c.id)).toEqual(["both"]);
|
|
expect(result.board.topRight.map((c) => c.id)).toEqual(["imp"]);
|
|
expect(result.board.bottomLeft.map((c) => c.id)).toEqual(["urg"]);
|
|
expect(result.board.bottomRight.map((c) => c.id)).toEqual(["none"]);
|
|
expect(result.cardCount).toBe(4);
|
|
});
|
|
|
|
test("completed to-dos are excluded entirely", async () => {
|
|
const notes = [
|
|
makeNote({ id: "open", title: "Open" }),
|
|
makeNote({ id: "done", title: "Done", todo_completed: 123456 }),
|
|
];
|
|
const result = await collectMatrix(
|
|
makeAdapter(folders, notes, { done: ["urgent", "important"] }),
|
|
"matrix-note",
|
|
"board",
|
|
parseMatrixConfig({ todos: "all", mode: "eisenhower" })
|
|
);
|
|
const allIds = [
|
|
...result.board.topLeft,
|
|
...result.board.topRight,
|
|
...result.board.bottomLeft,
|
|
...result.board.bottomRight,
|
|
].map((c) => c.id);
|
|
expect(allIds).toEqual(["open"]);
|
|
});
|
|
|
|
test("custom axis tags are honoured", async () => {
|
|
const notes = [makeNote({ id: "x", title: "Hot" })];
|
|
const result = await collectMatrix(
|
|
makeAdapter(folders, notes, { x: ["fire", "matters"] }),
|
|
"matrix-note",
|
|
"board",
|
|
parseMatrixConfig({
|
|
todos: "all",
|
|
mode: "eisenhower",
|
|
"urgent-tag": "fire",
|
|
"important-tag": "matters",
|
|
})
|
|
);
|
|
expect(result.board.topLeft.map((c) => c.id)).toEqual(["x"]);
|
|
});
|
|
|
|
test("includes opted-in notes while excluding plain and host notes", async () => {
|
|
const notes = [
|
|
makeNote({
|
|
id: "matrix-note",
|
|
is_todo: 0,
|
|
title: "The matrix note",
|
|
}),
|
|
makeNote({
|
|
id: "plain-note",
|
|
is_todo: 0,
|
|
title: "A plain note",
|
|
body: "No shortcode here",
|
|
}),
|
|
makeNote({ id: "gtd-note", is_todo: 0, title: "An opted-in note" }),
|
|
makeNote({ id: "todo", title: "A todo" }),
|
|
];
|
|
const result = await collectMatrix(
|
|
makeAdapter(folders, notes),
|
|
"matrix-note",
|
|
"board",
|
|
parseMatrixConfig({ todos: "all", mode: "eisenhower" })
|
|
);
|
|
expect(result.cardCount).toBe(2);
|
|
expect(result.board.bottomRight.map((c) => c.id)).toEqual([
|
|
"gtd-note",
|
|
"todo",
|
|
]);
|
|
expect(result.board.bottomRight[0]).toMatchObject({
|
|
isTodo: false,
|
|
completed: false,
|
|
isRecurring: false,
|
|
});
|
|
});
|
|
|
|
test("gtd-only requires a gtd block", async () => {
|
|
const notes = [
|
|
makeNote({ id: "in", body: "```gtd\n```" }),
|
|
makeNote({ id: "out", body: "no block here" }),
|
|
];
|
|
const result = await collectMatrix(
|
|
makeAdapter(folders, notes),
|
|
"matrix-note",
|
|
"board",
|
|
parseMatrixConfig({ mode: "eisenhower" }) // todos: gtd-only default
|
|
);
|
|
expect(result.cardCount).toBe(1);
|
|
expect(result.board.bottomRight.map((c) => c.id)).toEqual(["in"]);
|
|
});
|
|
|
|
test("todos: none excludes to-dos without disabling opted-in notes", async () => {
|
|
const notes = [
|
|
makeNote({ id: "gtd-note", is_todo: 0 }),
|
|
makeNote({ id: "gtd-todo" }),
|
|
];
|
|
const result = await collectMatrix(
|
|
makeAdapter(folders, notes),
|
|
"matrix-note",
|
|
"board",
|
|
parseMatrixConfig({ todos: "none", mode: "eisenhower" })
|
|
);
|
|
|
|
expect(result.cardCount).toBe(1);
|
|
expect(result.board.bottomRight.map((c) => c.id)).toEqual(["gtd-note"]);
|
|
});
|
|
test("requests tags only after an item is eligible", async () => {
|
|
const notes = [
|
|
makeNote({
|
|
id: "plain-note",
|
|
is_todo: 0,
|
|
body: "No shortcode here",
|
|
}),
|
|
makeNote({
|
|
id: "plain-todo",
|
|
body: "No shortcode here",
|
|
}),
|
|
makeNote({ id: "gtd-note", is_todo: 0 }),
|
|
makeNote({ id: "gtd-todo" }),
|
|
makeNote({ id: "done-todo", todo_completed: 123456 }),
|
|
];
|
|
const adapter = makeAdapter(folders, notes);
|
|
const getNoteTagTitles = jest.fn(
|
|
async (_noteId: string): Promise<string[]> => []
|
|
);
|
|
adapter.getNoteTagTitles = getNoteTagTitles;
|
|
|
|
await collectMatrix(
|
|
adapter,
|
|
"matrix-note",
|
|
"board",
|
|
parseMatrixConfig({ todos: "gtd-only", mode: "eisenhower" })
|
|
);
|
|
|
|
expect(getNoteTagTitles.mock.calls.map(([id]) => id)).toEqual([
|
|
"gtd-note",
|
|
"gtd-todo",
|
|
]);
|
|
});
|
|
test("due-date sort within a quadrant, dateless last", async () => {
|
|
const notes = [
|
|
makeNote({ id: "later", todo_due: new Date(2026, 6, 20).getTime() }),
|
|
makeNote({ id: "soon", todo_due: new Date(2026, 6, 1).getTime() }),
|
|
makeNote({ id: "nodate" }),
|
|
];
|
|
const result = await collectMatrix(
|
|
makeAdapter(folders, notes),
|
|
"matrix-note",
|
|
"board",
|
|
parseMatrixConfig({ todos: "all", mode: "eisenhower" })
|
|
);
|
|
expect(result.board.bottomRight.map((c) => c.id)).toEqual([
|
|
"soon",
|
|
"later",
|
|
"nodate",
|
|
]);
|
|
});
|
|
|
|
test("recurring tag flags cards", async () => {
|
|
const notes = [makeNote({ id: "r" })];
|
|
const result = await collectMatrix(
|
|
makeAdapter(folders, notes, { r: ["recurring", "urgent"] }),
|
|
"matrix-note",
|
|
"board",
|
|
parseMatrixConfig({ todos: "all", mode: "eisenhower" })
|
|
);
|
|
expect(result.board.bottomLeft[0].isRecurring).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("collectMatrix — mixed note/to-do contracts", () => {
|
|
test.each(["skeleton", "eisenhower"] as const)(
|
|
"supports project-only and task-only %s matrices",
|
|
async (mode) => {
|
|
const notes = [
|
|
makeNote({ id: "project", is_todo: 0 }),
|
|
makeNote({ id: "plain-note", is_todo: 0, body: "No block" }),
|
|
makeNote({ id: "task" }),
|
|
];
|
|
const cases: Array<[any, string[]]> = [
|
|
[{ notes: "all", todos: "none", mode }, ["project"]],
|
|
[{ notes: "none", todos: "all", mode }, ["task"]],
|
|
[{ notes: "none", todos: "none", mode }, []],
|
|
];
|
|
for (const [raw, expected] of cases) {
|
|
const result = await collectMatrix(
|
|
makeAdapter(folders, notes),
|
|
"host",
|
|
"board",
|
|
parseMatrixConfig(raw)
|
|
);
|
|
const ids = [
|
|
...result.board.topLeft,
|
|
...result.board.topRight,
|
|
...result.board.bottomLeft,
|
|
...result.board.bottomRight,
|
|
].map((card) => card.id);
|
|
expect(ids).toEqual(expected);
|
|
expect(result.cardCount).toBe(expected.length);
|
|
}
|
|
}
|
|
);
|
|
|
|
test("filtered notes do not request tags", async () => {
|
|
const tagCalls: string[] = [];
|
|
const adapter = makeAdapter(folders, [
|
|
makeNote({ id: "project", is_todo: 0 }),
|
|
]);
|
|
adapter.getNoteTagTitles = async (noteId: string) => {
|
|
tagCalls.push(noteId);
|
|
return [];
|
|
};
|
|
const result = await collectMatrix(
|
|
adapter,
|
|
"host",
|
|
"board",
|
|
parseMatrixConfig({ notes: "none", todos: "none" })
|
|
);
|
|
expect(tagCalls).toEqual([]);
|
|
expect(result.cardCount).toBe(0);
|
|
expect(result.scannedNotes).toBe(1);
|
|
});
|
|
|
|
test.each(["skeleton", "eisenhower"] as const)(
|
|
"excludes done notes before %s bucketing while ignoring done on open to-dos",
|
|
async (mode) => {
|
|
const notes = [
|
|
makeNote({ id: "done-note", is_todo: 0 }),
|
|
makeNote({ id: "open-todo" }),
|
|
];
|
|
const result = await collectMatrix(
|
|
makeAdapter(folders, notes, {
|
|
"done-note": ["done", "in-progress", "urgent", "important"],
|
|
"open-todo": ["done"],
|
|
}),
|
|
"matrix-note",
|
|
"board",
|
|
parseMatrixConfig({ todos: "all", mode })
|
|
);
|
|
expect(result.cardCount).toBe(1);
|
|
const ids = [
|
|
...result.board.topLeft,
|
|
...result.board.topRight,
|
|
...result.board.bottomLeft,
|
|
...result.board.bottomRight,
|
|
].map((card) => card.id);
|
|
expect(ids).toEqual(["open-todo"]);
|
|
}
|
|
);
|
|
|
|
test("honours a custom done tag for note exclusion", async () => {
|
|
const result = await collectMatrix(
|
|
makeAdapter(folders, [makeNote({ id: "note", is_todo: 0 })], {
|
|
note: ["finished"],
|
|
}),
|
|
"matrix-note",
|
|
"board",
|
|
parseMatrixConfig({ "done-tag": "finished" })
|
|
);
|
|
expect(result.cardCount).toBe(0);
|
|
});
|
|
test("handles empty, malformed, absent, and host note blocks", async () => {
|
|
const notes = [
|
|
makeNote({ id: "empty-note", is_todo: 0 }),
|
|
makeNote({
|
|
id: "broken-note",
|
|
is_todo: 0,
|
|
title: "Broken note",
|
|
body: "```gtd\ntitle: [invalid\n```",
|
|
}),
|
|
makeNote({
|
|
id: "plain-note",
|
|
is_todo: 0,
|
|
body: "No shortcode here",
|
|
}),
|
|
makeNote({ id: "matrix-note", is_todo: 0 }),
|
|
];
|
|
const result = await collectMatrix(
|
|
makeAdapter(folders, notes),
|
|
"matrix-note",
|
|
"board",
|
|
parseMatrixConfig({ todos: "none", mode: "eisenhower" })
|
|
);
|
|
|
|
expect(result.board.bottomRight.map((card) => card.id)).toEqual([
|
|
"empty-note",
|
|
"broken-note",
|
|
]);
|
|
expect(result.cardCount).toBe(2);
|
|
expect(result.warnings).toHaveLength(1);
|
|
expect(result.warnings[0]).toContain('"Broken note": gtd block problem');
|
|
});
|
|
|
|
test.each([
|
|
["due-date", ["note-alpha", "todo-bravo", "note-zulu"]],
|
|
["title", ["note-alpha", "todo-bravo", "note-zulu"]],
|
|
["modified-date", ["note-zulu", "todo-bravo", "note-alpha"]],
|
|
] as const)("sorts mixed cards by %s", async (sortType, expectedIds) => {
|
|
const notes = [
|
|
makeNote({
|
|
id: "todo-bravo",
|
|
title: "Bravo",
|
|
todo_due: new Date(2026, 6, 20).getTime(),
|
|
updated_time: 20,
|
|
}),
|
|
makeNote({
|
|
id: "note-alpha",
|
|
is_todo: 0,
|
|
title: "Alpha",
|
|
body: "```gtd\ndate: 2026-07-01\n```",
|
|
updated_time: 30,
|
|
}),
|
|
makeNote({
|
|
id: "note-zulu",
|
|
is_todo: 0,
|
|
title: "Zulu",
|
|
updated_time: 10,
|
|
}),
|
|
];
|
|
const result = await collectMatrix(
|
|
makeAdapter(folders, notes),
|
|
"matrix-note",
|
|
"board",
|
|
parseMatrixConfig({
|
|
todos: "all",
|
|
mode: "eisenhower",
|
|
"sort-type": sortType,
|
|
"page-size": 1,
|
|
})
|
|
);
|
|
|
|
expect(result.board.bottomRight.map((card) => card.id)).toEqual(
|
|
expectedIds
|
|
);
|
|
expect(result.cardCount).toBe(3);
|
|
expect(result.board.bottomRight).toHaveLength(3);
|
|
});
|
|
});
|
|
describe("collectMatrix — Skeleton mode", () => {
|
|
const NOW = new Date(2026, 5, 15); // Jun 15 2026
|
|
|
|
function iso(y: number, m: number, d: number): number {
|
|
return new Date(y, m - 1, d, 12).getTime();
|
|
}
|
|
|
|
test("the four tag destinations: Urgent / In Progress / On Deck / Backlog", async () => {
|
|
const notes = [
|
|
// active + due within window -> Do Next
|
|
makeNote({
|
|
id: "donext",
|
|
is_todo: 0,
|
|
body: "```gtd\ndate: 2026-06-17\n```",
|
|
}),
|
|
// active + due later -> Scheduled
|
|
makeNote({
|
|
id: "sched",
|
|
is_todo: 0,
|
|
body: "```gtd\ndate: 2026-06-25\n```",
|
|
}),
|
|
// not active + due soon -> On Deck
|
|
makeNote({
|
|
id: "ondeck",
|
|
is_todo: 0,
|
|
body: "```gtd\ndate: 2026-06-16\n```",
|
|
}),
|
|
// not active, no date -> Backlog
|
|
makeNote({ id: "backlog", is_todo: 0 }),
|
|
];
|
|
const result = await collectMatrix(
|
|
makeAdapter(folders, notes, {
|
|
donext: ["urgent"],
|
|
sched: ["in-progress"],
|
|
ondeck: ["on-deck"],
|
|
}),
|
|
"matrix-note",
|
|
"board",
|
|
parseMatrixConfig({ todos: "all" }),
|
|
NOW
|
|
);
|
|
expect(result.board.topLeft.map((c) => c.id)).toEqual(["donext"]);
|
|
expect(result.board.topRight.map((c) => c.id)).toEqual(["sched"]);
|
|
expect(result.board.bottomLeft.map((c) => c.id)).toEqual(["ondeck"]);
|
|
expect(result.board.bottomRight.map((c) => c.id)).toEqual(["backlog"]);
|
|
});
|
|
|
|
test("urgent takes precedence over overlapping workflow tags", async () => {
|
|
const notes = [makeNote({ id: "hot", is_todo: 0 })];
|
|
const result = await collectMatrix(
|
|
makeAdapter(folders, notes, { hot: ["on-deck", "in-progress", "urgent"] }),
|
|
"matrix-note",
|
|
"board",
|
|
parseMatrixConfig({ todos: "all" }),
|
|
NOW
|
|
);
|
|
expect(result.board.topLeft.map((c) => c.id)).toEqual(["hot"]);
|
|
});
|
|
|
|
test("urgent tag alone routes to Urgent", async () => {
|
|
const notes = [makeNote({ id: "flag", is_todo: 0 })];
|
|
const result = await collectMatrix(
|
|
makeAdapter(folders, notes, { flag: ["urgent"] }),
|
|
"matrix-note",
|
|
"board",
|
|
parseMatrixConfig({ todos: "all" }),
|
|
NOW
|
|
);
|
|
expect(result.board.topLeft.map((c) => c.id)).toEqual(["flag"]);
|
|
});
|
|
|
|
test("native due dates do not affect Skeleton routing", async () => {
|
|
const notes = [makeNote({ id: "late", todo_due: iso(2026, 6, 1) })];
|
|
const result = await collectMatrix(
|
|
makeAdapter(folders, notes),
|
|
"matrix-note",
|
|
"board",
|
|
parseMatrixConfig({ todos: "all" }),
|
|
NOW
|
|
);
|
|
expect(result.board.bottomRight.map((c) => c.id)).toEqual(["late"]);
|
|
});
|
|
|
|
test("in-progress work routes to In Progress", async () => {
|
|
const notes = [makeNote({ id: "wip" })];
|
|
const result = await collectMatrix(
|
|
makeAdapter(folders, notes, { wip: ["in-progress"] }),
|
|
"matrix-note",
|
|
"board",
|
|
parseMatrixConfig({ todos: "all" }),
|
|
NOW
|
|
);
|
|
expect(result.board.topRight.map((c) => c.id)).toEqual(["wip"]);
|
|
});
|
|
|
|
test("future dates do not affect Skeleton routing", async () => {
|
|
const notes = [
|
|
makeNote({ id: "edge", todo_due: iso(2026, 6, 18) }), // +3 days
|
|
makeNote({ id: "past-edge", todo_due: iso(2026, 6, 19) }), // +4
|
|
];
|
|
const result = await collectMatrix(
|
|
makeAdapter(folders, notes),
|
|
"matrix-note",
|
|
"board",
|
|
parseMatrixConfig({ todos: "all" }),
|
|
NOW
|
|
);
|
|
expect(result.board.bottomRight.map((c) => c.id)).toEqual(["edge", "past-edge"]);
|
|
});
|
|
|
|
test("gtd date override does not affect Skeleton routing", async () => {
|
|
const body = "```gtd\ndate: 2026-06-16\n```";
|
|
const notes = [makeNote({ id: "g", body: body })];
|
|
const result = await collectMatrix(
|
|
makeAdapter(folders, notes),
|
|
"matrix-note",
|
|
"board",
|
|
parseMatrixConfig({}),
|
|
NOW
|
|
);
|
|
expect(result.board.bottomRight.map((c) => c.id)).toEqual(["g"]);
|
|
});
|
|
});
|
|
|
|
describe("collectMatrix — notebook groups", () => {
|
|
test("omits groups emptied by note filtering while retaining descendants", async () => {
|
|
const tree: RawFolder[] = [
|
|
{ id: "root", parent_id: "", title: "Root" },
|
|
{ id: "child", parent_id: "root", title: "Child" },
|
|
];
|
|
const notes = [
|
|
makeNote({
|
|
id: "broken-project",
|
|
parent_id: "root",
|
|
is_todo: 0,
|
|
body: "```gtd\ntitle: [invalid\n```",
|
|
}),
|
|
makeNote({ id: "child-task", parent_id: "child" }),
|
|
];
|
|
const result = await collectMatrix(
|
|
makeAdapter(tree, notes),
|
|
"host",
|
|
"root",
|
|
parseMatrixConfig({
|
|
scope: "children",
|
|
group: "notebook",
|
|
notes: "none",
|
|
todos: "all",
|
|
})
|
|
);
|
|
expect(result.layout.kind).toBe("notebooks");
|
|
if (result.layout.kind !== "notebooks") throw new Error("grouped expected");
|
|
expect(result.layout.groups.map((group) => group.folderId)).toEqual([
|
|
"child",
|
|
]);
|
|
expect(result.layout.groups[0].warnings).toEqual([]);
|
|
expect(result.cardCount).toBe(1);
|
|
expect(result.scannedNotes).toBe(2);
|
|
});
|
|
|
|
test.each(["skeleton", "eisenhower"] as const)(
|
|
"groups mixed cards exactly once in %s mode",
|
|
async (mode) => {
|
|
const tree: RawFolder[] = [
|
|
{ id: "root-board", parent_id: "", title: "Projects" },
|
|
{ id: "empty", parent_id: "root-board", title: "Empty" },
|
|
{ id: "child", parent_id: "root-board", title: "Child" },
|
|
];
|
|
const notes = [
|
|
makeNote({ id: "host", parent_id: "root-board", is_todo: 0 }),
|
|
makeNote({ id: "root-note", parent_id: "root-board", is_todo: 0 }),
|
|
makeNote({ id: "child-todo", parent_id: "child" }),
|
|
makeNote({
|
|
id: "child-plain",
|
|
parent_id: "child",
|
|
is_todo: 0,
|
|
title: "Alpha",
|
|
}),
|
|
makeNote({
|
|
id: "broken",
|
|
parent_id: "child",
|
|
is_todo: 0,
|
|
title: "Broken",
|
|
body: "```gtd\ntitle: [invalid\n```",
|
|
}),
|
|
makeNote({ id: "done", parent_id: "empty", todo_completed: 1 }),
|
|
];
|
|
const result = await collectMatrix(
|
|
makeAdapter(tree, notes, {
|
|
"root-note": ["important", "in-progress"],
|
|
"child-todo": ["urgent"],
|
|
}),
|
|
"host",
|
|
"root-board",
|
|
parseMatrixConfig({
|
|
scope: "children",
|
|
group: "notebook",
|
|
todos: "all",
|
|
mode,
|
|
"sort-type": "title",
|
|
})
|
|
);
|
|
|
|
expect(result.layout.kind).toBe("notebooks");
|
|
if (result.layout.kind !== "notebooks")
|
|
throw new Error("grouped layout expected");
|
|
expect(result.layout.groups.map((group) => group.folderId)).toEqual([
|
|
"root-board",
|
|
"child",
|
|
]);
|
|
expect(result.layout.groups[0].cardCount).toBe(1);
|
|
expect(result.layout.groups[1].cardCount).toBe(3);
|
|
expect(result.layout.groups[1].warnings).toHaveLength(1);
|
|
expect(
|
|
result.layout.groups[1].board.bottomRight.map((card) => card.id)
|
|
).toEqual(["child-plain", "broken"]);
|
|
const ids = result.layout.groups.flatMap((group) =>
|
|
[
|
|
...group.board.topLeft,
|
|
...group.board.topRight,
|
|
...group.board.bottomLeft,
|
|
...group.board.bottomRight,
|
|
].map((card) => card.id)
|
|
);
|
|
expect(ids.sort()).toEqual([
|
|
"broken",
|
|
"child-plain",
|
|
"child-todo",
|
|
"root-note",
|
|
]);
|
|
expect(result.board.bottomRight).toHaveLength(0);
|
|
expect(result.scannedFolders).toBe(3);
|
|
expect(result.scannedNotes).toBe(6);
|
|
expect(result.cardCount).toBe(4);
|
|
}
|
|
);
|
|
|
|
test("retains the single layout when notebook grouping is off", async () => {
|
|
const result = await collectMatrix(
|
|
makeAdapter(folders, [makeNote({ id: "one" })]),
|
|
"host",
|
|
"board",
|
|
parseMatrixConfig({ scope: "children", todos: "all" })
|
|
);
|
|
expect(result.layout).toMatchObject({
|
|
kind: "single",
|
|
board: result.board,
|
|
});
|
|
});
|
|
|
|
test.each(["skeleton", "eisenhower"] as const)(
|
|
"keeps all four %s quadrants local to the owning notebook",
|
|
async (mode) => {
|
|
const tree: RawFolder[] = [
|
|
{ id: "root-board", parent_id: "", title: "Projects" },
|
|
{ id: "child", parent_id: "root-board", title: "Child" },
|
|
];
|
|
const notes = [
|
|
makeNote({ id: "root-left", parent_id: "root-board", is_todo: 0 }),
|
|
makeNote({ id: "tl", parent_id: "child", is_todo: 0 }),
|
|
makeNote({ id: "tr", parent_id: "child", is_todo: 0 }),
|
|
makeNote({ id: "bl", parent_id: "child", is_todo: 0 }),
|
|
makeNote({ id: "br", parent_id: "child", is_todo: 0 }),
|
|
];
|
|
const tags = mode === "skeleton" ? {
|
|
"root-left": ["on-deck"], tl: ["urgent"],
|
|
tr: ["in-progress"], bl: ["on-deck"],
|
|
} : {
|
|
"root-left": ["urgent"], tl: ["urgent", "important"],
|
|
tr: ["important"], bl: ["urgent"],
|
|
};
|
|
const result = await collectMatrix(
|
|
makeAdapter(tree, notes, tags),
|
|
"host",
|
|
"root-board",
|
|
parseMatrixConfig({ scope: "children", group: "notebook", mode })
|
|
);
|
|
expect(result.layout.kind).toBe("notebooks");
|
|
if (result.layout.kind !== "notebooks")
|
|
throw new Error("grouped layout expected");
|
|
const root = result.layout.groups[0].board;
|
|
const child = result.layout.groups[1].board;
|
|
expect(root.bottomLeft.map((card) => card.id)).toEqual(["root-left"]);
|
|
expect(child.topLeft.map((card) => card.id)).toEqual(["tl"]);
|
|
expect(child.topRight.map((card) => card.id)).toEqual(["tr"]);
|
|
expect(child.bottomLeft.map((card) => card.id)).toEqual(["bl"]);
|
|
expect(child.bottomRight.map((card) => card.id)).toEqual(["br"]);
|
|
}
|
|
);
|
|
|
|
test.each([
|
|
["this-folder", "this-folder", 1],
|
|
["numeric depth", 1, 2],
|
|
["scope all", "all", 4],
|
|
] as const)(
|
|
"retains one aggregate matrix for %s",
|
|
async (_label, scope, count) => {
|
|
const tree: RawFolder[] = [
|
|
{ id: "board", parent_id: "", title: "Board" },
|
|
{ id: "child", parent_id: "board", title: "Child" },
|
|
{ id: "grand", parent_id: "child", title: "Grand" },
|
|
{ id: "other", parent_id: "", title: "Other" },
|
|
];
|
|
const notes = tree.map((folder) =>
|
|
makeNote({ id: "card-" + folder.id, parent_id: folder.id })
|
|
);
|
|
const result = await collectMatrix(
|
|
makeAdapter(tree, notes),
|
|
"host",
|
|
"board",
|
|
parseMatrixConfig({ scope, todos: "all", mode: "eisenhower" })
|
|
);
|
|
expect(result.layout.kind).toBe("single");
|
|
expect(result.cardCount).toBe(count);
|
|
if (result.layout.kind !== "single")
|
|
throw new Error("single layout expected");
|
|
expect(result.layout.board.bottomRight).toHaveLength(count);
|
|
}
|
|
);
|
|
});
|