Limit and label task hierarchy

This commit is contained in:
jb
2026-08-26 15:58:40 -04:00
parent ae0ca47236
commit 01b5f3ddee
6 changed files with 179 additions and 35 deletions
+21 -4
View File
@@ -415,7 +415,7 @@ test("enforces per-mutation and per-session task limits atomically", () => {
);
});
test("accepts eight task levels and rejects deeper trees atomically", () => {
test("allows three stored levels beneath the virtual root and rejects deeper task trees atomically", () => {
const nested = (depth: number) => {
let task: { name: string; children?: Array<{ name: string; children?: unknown[] }> } = { name: `Level ${depth}` };
for (let level = depth - 1; level >= 1; level -= 1) {
@@ -425,10 +425,27 @@ test("accepts eight task levels and rejects deeper trees atomically", () => {
};
const state = new TaskState();
state.add([nested(8) as never]);
assert.equal(state.list().length, 8);
state.add([nested(3) as never, { name: "Other category" }]);
assert.equal(state.list().length, 4);
const before = state.snapshot();
assert.throws(() => state.add([nested(9) as never]), /depth must not exceed 8 levels/);
assert.throws(() => state.add([nested(4) as never]), /depth must not exceed 3 levels/);
assert.deepEqual(state.snapshot(), before);
assert.throws(() => state.update([{ id: 4, parentId: 3 }]), /depth must not exceed 3 levels/);
assert.deepEqual(state.snapshot(), before);
assert.throws(
() =>
state.restore({
tasks: [
{ id: 1, name: "Category", status: "pending", parentId: null, childrenIds: [2] },
{ id: 2, name: "Item", status: "pending", parentId: 1, childrenIds: [3] },
{ id: 3, name: "Leaf", status: "pending", parentId: 2, childrenIds: [4] },
{ id: 4, name: "Too deep", status: "pending", parentId: 3, childrenIds: [] },
],
nextId: 5,
}),
/depth must not exceed 3 levels/,
);
assert.deepEqual(state.snapshot(), before);
});
+88 -6
View File
@@ -92,11 +92,11 @@ test("collapses completed widget subtrees while keeping list trees expanded", ()
const widgetLines = renderWidgetTree(state.list(), plainTheme as never, 80).map(stripAnsi);
assert.deepEqual(widgetLines, [
" ○ 1. Release",
" ├─ ○ 2. Completed branch · 2/2 done",
" └─ ○ 5. Open branch",
" └─ ○ 6. Open leaf",
" ○ 7. Pending parent · 1/1 done",
" I. ○ Release",
" ├─ 1. ○ Completed branch · 2/2 done",
" └─ 2. ○ Open branch",
" └─ ○ Open leaf",
" II. ○ Pending parent · 1/1 done",
]);
const fullTree = renderTree(state.list(), plainTheme as never, 80).map(stripAnsi);
@@ -109,7 +109,75 @@ test("collapses completed widget subtrees while keeping list trees expanded", ()
assert.ok(reopened.some((line) => line.includes("Done two")));
});
test("caps the widget at twelve task rows with an overflow summary", () => {
test("uses sibling-local Roman and numeric widget labels while leaving leaf sub-items unlabeled", () => {
const state = new TaskState(() => 0);
state.add([
{ name: "Category one" },
{ name: "Category two" },
{ name: "Category three" },
{
name: "Category four",
children: [
{ name: "First item", children: [{ name: "Leaf sub-item" }] },
{ name: "Second item" },
],
},
{ name: "Category five", children: [{ name: "Reset item numbering" }] },
]);
const lines = renderWidgetTree(state.list(), plainTheme as never, 80).map(stripAnsi);
assert.deepEqual(lines, [
" I. ○ Category one",
" II. ○ Category two",
" III. ○ Category three",
" IV. ○ Category four",
" ├─ 1. ○ First item",
" │ └─ ○ Leaf sub-item",
" └─ 2. ○ Second item",
" V. ○ Category five",
" └─ 1. ○ Reset item numbering",
]);
const narrow = renderWidgetTree(state.list(), plainTheme as never, 18);
assert.ok(narrow.every((line) => visibleWidth(line) <= 18));
const statusPriority = renderWidgetTree(state.list(), plainTheme as never, 6).map(stripAnsi);
assert.match(statusPriority[3] ?? "", /○/);
const childStatusPriority = renderWidgetTree(state.list(), plainTheme as never, 8).map(stripAnsi);
assert.match(childStatusPriority[4] ?? "", /○/);
const timedState = new TaskState(() => 0);
timedState.add([
{ name: "Timed category", status: "in-progress", children: [{ name: "Timed item", status: "in-progress" }] },
]);
const timedRoot = stripAnsi(renderWidgetTree(timedState.list(), plainTheme as never, 15)[0] ?? "");
const timedChild = stripAnsi(renderWidgetTree(timedState.list(), plainTheme as never, 18)[1] ?? "");
assert.match(timedRoot, /◉/);
assert.match(timedChild, /◉/);
});
test("renumbers widget labels after task reordering and reparenting", () => {
const state = new TaskState(() => 0);
state.add([
{ name: "Category A", children: [{ name: "A1" }, { name: "A2" }] },
{ name: "Category B", children: [{ name: "B1" }] },
]);
state.update([
{ id: 4, position: 0 },
{ id: 3, parentId: 4, position: 0 },
]);
const lines = renderWidgetTree(state.list(), plainTheme as never, 80).map(stripAnsi);
assert.deepEqual(lines, [
" I. ○ Category B",
" ├─ 1. ○ A2",
" └─ 2. ○ B1",
" II. ○ Category A",
" └─ 1. ○ A1",
]);
});
test("caps the widget at twelve task rows with an honest overflow summary", () => {
const state = new TaskState(() => 0);
state.add(Array.from({ length: 20 }, (_, index) => ({ name: `Task ${index + 1}` })));
@@ -119,6 +187,20 @@ test("caps the widget at twelve task rows with an overflow summary", () => {
assert.match(lines[11] ?? "", /Task 12/);
assert.equal(lines[12], " … 8 more tasks");
assert.ok(lines.every((line) => visibleWidth(line) <= 60));
const collapsedOverflow = new TaskState(() => 0);
collapsedOverflow.add([
...Array.from({ length: 12 }, (_, index) => ({ name: `Visible ${index + 1}` })),
{
name: "Hidden completed branch",
children: [
{ name: "Hidden done one", status: "done" as const },
{ name: "Hidden done two", status: "done" as const },
],
},
]);
const overflowLines = renderWidgetTree(collapsedOverflow.list(), plainTheme as never, 60).map(stripAnsi);
assert.equal(overflowLines[12], " … 3 more tasks");
});
test("hides a completed widget summary before squeezing the task name", () => {