Make task schema fields explicit with null sentinels

This commit is contained in:
lesetong
2026-08-11 13:36:09 +08:00
parent 28d7e66d8a
commit 8af1c53c83
8 changed files with 270 additions and 75 deletions
+30 -8
View File
@@ -27,7 +27,7 @@ test("adds a nested batch with generated preorder IDs and derived relations", ()
assert.equal(state.snapshot().nextId, 4);
});
test("rejects invalid nested parentId atomically", () => {
test("rejects invalid nested parentId atomically while accepting null sentinels", () => {
const state = new TaskState();
state.add([{ name: "Existing", description: "Existing" }]);
const before = state.snapshot();
@@ -44,6 +44,16 @@ test("rejects invalid nested parentId atomically", () => {
TaskValidationError,
);
assert.deepEqual(state.snapshot(), before);
const added = state.add([
{
name: "Nullable parent",
parentId: null,
children: [{ name: "Implicit child", parentId: null }],
},
]);
assert.equal(added[0]?.parentId, null);
assert.equal(added[1]?.parentId, added[0]?.id);
});
test("enforces done descendants against the final atomic update state", () => {
@@ -308,23 +318,31 @@ test("loads the latest valid mutation snapshot from a session branch", () => {
assert.deepEqual(state.list().map((task) => task.name), ["First", "Second"]);
});
test("supports optional descriptions and rejects invalid provided values", () => {
test("supports nullable descriptions and reports invalid children as validation errors", () => {
const state = new TaskState();
assert.throws(() => state.add([{ name: "Bad\nname", description: "Valid" }]), /single line/);
assert.throws(() => state.add([{ name: "Blank description", description: " " }]), /must not be empty/);
assert.throws(
() => state.add([{ name: "Invalid children", children: "not-an-array" } as never]),
/children must be an array/,
);
state.add([
{ name: "No description" },
{ name: "Null description", description: null },
{ name: "Detailed", description: "Line one\nLine two" },
]);
assert.equal(state.list()[0]?.description, undefined);
assert.equal(state.list()[1]?.description, "Line one\nLine two");
assert.equal(state.list()[1]?.description, undefined);
assert.equal(state.list()[2]?.description, "Line one\nLine two");
});
test("set append rejects force and still requires at least one task", () => {
test("set append accepts false force and rejects true force", () => {
const state = new TaskState();
assert.throws(() => state.set("append", [], undefined), /tasks must not be empty/);
assert.throws(() => state.set("append", [{ name: "Task" }], false), /force is allowed only in replace mode/);
assert.deepEqual(state.snapshot(), { tasks: [], nextId: 1 });
assert.throws(() => state.set("append", [], false), /tasks must not be empty/);
state.set("append", [{ name: "Task" }], false);
const before = state.snapshot();
assert.throws(() => state.set("append", [{ name: "Other" }], true), /force must be false in append mode/);
assert.deepEqual(state.snapshot(), before);
});
test("set replace is atomic, guarded, and resets IDs", () => {
@@ -339,7 +357,11 @@ test("set replace is atomic, guarded, and resets IDs", () => {
);
assert.deepEqual(state.snapshot(), before);
const replaced = state.set("replace", [{ name: "New", children: [{ name: "Child" }] }], true);
const replaced = state.set(
"replace",
[{ name: "New", parentId: null, children: [{ name: "Child", parentId: null }] }],
true,
);
assert.deepEqual(replaced.map((task) => task.id), [1, 2]);
assert.deepEqual(state.list().map((task) => task.name), ["New", "Child"]);
assert.equal(state.snapshot().nextId, 3);