Files
gitmost/apps/client/src/features/page/tree/model/tree-model.test.ts
claude code agent 227 8218c1a8ef fix(tree): refresh loaded branches on reconnect so they don't go stale (#159)
Third tree-sync finding (#8). On a socket reconnect after a missed-events gap
(laptop sleep / wifi blip), the resync only invalidated the ROOT sidebar query;
a move/rename/delete that happened INSIDE an already-loaded, expanded branch was
never reflected — the branch stayed stale until the user manually interacted.
(The #2 fix reconciles the root level; this covers the deeper loaded branches.)

- `treeModel.reconcileChildren(tree, parentId, fresh)`: replace a loaded
  branch's DIRECT children with the authoritative fresh set (drop removed, add
  new, reorder to server) while PRESERVING each surviving child's already-loaded
  grandchildren, so deeper expansion is not collapsed. An unloaded branch
  (children === undefined) is left untouched (lazy-load fetches it fresh).
- `loadedOpenBranchIds(tree, openIds)`: the branches a reconnect should refresh
  (open AND loaded). `fetchAllAncestorChildren(..., { fresh: true })` bypasses
  the 30-min sidebar cache so the reconcile sees current data (handler-order
  independent).
- space-tree: on socket `connect`, re-fetch + reconcile each open loaded branch
  of the active space (space-switch-guarded; an unloaded branch is skipped).

Tests: reconcileChildren (drop/add/reorder + preserve grandchildren + unloaded
no-op) and loadedOpenBranchIds (open+loaded only, skip unloaded, nested). The
pure logic is unit-tested; the live socket-reconnect round-trip is not
browser-automated (simulating a reconnect gap is impractical) — sidebar render +
expand were smoke-tested with no regression.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:36:01 +03:00

891 lines
30 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { treeModel } from "./tree-model";
import type { TreeNode } from "./tree-model.types";
type N = TreeNode<{ name: string }>;
const fixture: N[] = [
{
id: "a",
name: "A",
children: [
{ id: "a1", name: "A1", children: [{ id: "a1a", name: "A1a" }] },
{ id: "a2", name: "A2" },
],
},
{ id: "b", name: "B" },
];
describe("treeModel.find", () => {
it("finds a root node", () => {
expect(treeModel.find(fixture, "a")?.name).toBe("A");
});
it("finds a deeply nested node", () => {
expect(treeModel.find(fixture, "a1a")?.name).toBe("A1a");
});
it("returns null for unknown id", () => {
expect(treeModel.find(fixture, "zzz")).toBeNull();
});
});
describe("treeModel.path", () => {
it("returns root-to-leaf path for nested id", () => {
const p = treeModel.path(fixture, "a1a");
expect(p?.map((n) => n.id)).toEqual(["a", "a1", "a1a"]);
});
it("returns [node] for root-level id", () => {
expect(treeModel.path(fixture, "b")?.map((n) => n.id)).toEqual(["b"]);
});
it("returns null for unknown id", () => {
expect(treeModel.path(fixture, "zzz")).toBeNull();
});
});
describe("treeModel.siblingsOf", () => {
it("returns siblings + parent + index for a child", () => {
const info = treeModel.siblingsOf(fixture, "a2");
expect(info?.parentId).toBe("a");
expect(info?.siblings.map((n) => n.id)).toEqual(["a1", "a2"]);
expect(info?.index).toBe(1);
});
it("returns parentId null + root siblings for a root id", () => {
const info = treeModel.siblingsOf(fixture, "b");
expect(info?.parentId).toBeNull();
expect(info?.siblings.map((n) => n.id)).toEqual(["a", "b"]);
expect(info?.index).toBe(1);
});
it("returns null for unknown id", () => {
expect(treeModel.siblingsOf(fixture, "zzz")).toBeNull();
});
});
describe("treeModel.isDescendant", () => {
it("returns true when descendantId is nested under ancestorId", () => {
expect(treeModel.isDescendant(fixture, "a", "a1a")).toBe(true);
});
it("returns false when ids are siblings", () => {
expect(treeModel.isDescendant(fixture, "a1", "a2")).toBe(false);
});
it("returns false when ancestorId is the same as descendantId", () => {
expect(treeModel.isDescendant(fixture, "a", "a")).toBe(false);
});
it("returns false for unknown ids", () => {
expect(treeModel.isDescendant(fixture, "zzz", "a")).toBe(false);
});
});
describe("treeModel.visible", () => {
it("returns only root nodes when no openIds", () => {
const v = treeModel.visible(fixture, new Set());
expect(v.map((n) => n.id)).toEqual(["a", "b"]);
});
it("includes children of open ids in DFS order", () => {
const v = treeModel.visible(fixture, new Set(["a"]));
expect(v.map((n) => n.id)).toEqual(["a", "a1", "a2", "b"]);
});
it("recursively descends through chains of open ids", () => {
const v = treeModel.visible(fixture, new Set(["a", "a1"]));
expect(v.map((n) => n.id)).toEqual(["a", "a1", "a1a", "a2", "b"]);
});
it("ignores openIds that are not in the tree", () => {
const v = treeModel.visible(fixture, new Set(["ghost"]));
expect(v.map((n) => n.id)).toEqual(["a", "b"]);
});
});
describe("treeModel.insert", () => {
const leaf = (id: string): N => ({ id, name: id.toUpperCase() });
it("inserts at end when index is undefined", () => {
const t = treeModel.insert(fixture, "a", leaf("a3"));
expect(treeModel.siblingsOf(t, "a3")?.siblings.map((n) => n.id)).toEqual([
"a1",
"a2",
"a3",
]);
});
it("inserts at index 0", () => {
const t = treeModel.insert(fixture, "a", leaf("a0"), 0);
expect(treeModel.siblingsOf(t, "a0")?.siblings.map((n) => n.id)).toEqual([
"a0",
"a1",
"a2",
]);
});
it("inserts in the middle", () => {
const t = treeModel.insert(fixture, "a", leaf("a1half"), 1);
expect(
treeModel.siblingsOf(t, "a1half")?.siblings.map((n) => n.id),
).toEqual(["a1", "a1half", "a2"]);
});
it("inserts at root when parentId is null", () => {
const t = treeModel.insert(fixture, null, leaf("c"));
expect(t.map((n) => n.id)).toEqual(["a", "b", "c"]);
});
it("returns same array reference for unknown parentId", () => {
const t = treeModel.insert(fixture, "ghost", leaf("zz"));
expect(t).toBe(fixture);
});
it("initializes children array when parent had no children", () => {
const t = treeModel.insert(fixture, "b", leaf("b1"));
expect(treeModel.find(t, "b")?.children?.map((n) => n.id)).toEqual(["b1"]);
});
});
describe("treeModel.insertByPosition", () => {
// Server-authoritative broadcasts ship the node's fractional `position`; the
// receiver inserts among already-loaded siblings ordered by `position`.
type P = TreeNode<{ name: string; position?: string }>;
const roots: P[] = [
{ id: "a", name: "A", position: "a0" },
{ id: "b", name: "B", position: "a2" },
{ id: "c", name: "C", position: "a4" },
];
it("inserts a root node in position order (middle)", () => {
const node: P = { id: "x", name: "X", position: "a3" };
const t = treeModel.insertByPosition(roots, null, node);
expect(t.map((n) => n.id)).toEqual(["a", "b", "x", "c"]);
});
it("inserts a root node at the front when its position sorts first", () => {
const node: P = { id: "x", name: "X", position: "a-" };
const t = treeModel.insertByPosition(roots, null, node);
expect(t.map((n) => n.id)).toEqual(["x", "a", "b", "c"]);
});
it("appends a root node when its position sorts last", () => {
const node: P = { id: "x", name: "X", position: "a9" };
const t = treeModel.insertByPosition(roots, null, node);
expect(t.map((n) => n.id)).toEqual(["a", "b", "c", "x"]);
});
it("produces the same order regardless of which siblings are loaded", () => {
// Client 1 loaded all siblings; client 2 only loaded a subset. The inserted
// node lands in a consistent relative position for both.
const full: P[] = roots;
const partial: P[] = [roots[0], roots[2]]; // a, c (b not loaded)
const node: P = { id: "x", name: "X", position: "a3" };
expect(
treeModel.insertByPosition(full, null, node).map((n) => n.id),
).toEqual(["a", "b", "x", "c"]);
expect(
treeModel.insertByPosition(partial, null, node).map((n) => n.id),
).toEqual(["a", "x", "c"]);
});
it("inserts a child in position order under the parent", () => {
const tree: P[] = [
{
id: "p",
name: "P",
position: "a0",
children: [
{ id: "p1", name: "P1", position: "a0" },
{ id: "p2", name: "P2", position: "a2" },
],
},
];
const node: P = { id: "p15", name: "P1.5", position: "a1" };
const t = treeModel.insertByPosition(tree, "p", node);
expect(treeModel.find(t, "p")?.children?.map((n) => n.id)).toEqual([
"p1",
"p15",
"p2",
]);
});
// #159 #1: inserting/moving a node under a parent whose children are NOT
// loaded (`children === undefined`, e.g. a collapsed page) must NOT materialize
// a partial `[node]` list — that would defeat the lazy-load gate and hide the
// parent's other real children. The node is left to be lazy-loaded; only
// `hasChildren` is flagged so the chevron appears.
it("does NOT materialize a child under an UNLOADED parent (children undefined)", () => {
type PH = TreeNode<{
name: string;
position?: string;
hasChildren?: boolean;
}>;
const tree: PH[] = [
{ id: "p", name: "P", position: "a0", hasChildren: false }, // children: undefined
];
const node: PH = { id: "x", name: "X", position: "a1" };
const t = treeModel.insertByPosition(tree, "p", node);
const parent = treeModel.find(t, "p");
// The node was NOT inserted (children stay unloaded -> lazy-load fetches the
// full set, including this node, on expand).
expect(parent?.children).toBeUndefined();
expect(treeModel.find(t, "x")).toBeNull();
// ...but the chevron is enabled so the user can expand to load it.
expect((parent as PH).hasChildren).toBe(true);
});
it("DOES insert under a LOADED-but-empty parent (children: [])", () => {
type PH = TreeNode<{
name: string;
position?: string;
hasChildren?: boolean;
}>;
const tree: PH[] = [
{ id: "p", name: "P", position: "a0", hasChildren: false, children: [] },
];
const node: PH = { id: "x", name: "X", position: "a1" };
const t = treeModel.insertByPosition(tree, "p", node);
// A loaded (empty) child list is complete, so the node IS inserted.
expect(treeModel.find(t, "p")?.children?.map((n) => n.id)).toEqual(["x"]);
});
it("appends when the new node has no position", () => {
const node: P = { id: "x", name: "X" };
const t = treeModel.insertByPosition(roots, null, node);
expect(t.map((n) => n.id)).toEqual(["a", "b", "c", "x"]);
});
it("tie-break: a node whose position EQUALS a sibling lands deterministically (strict >)", () => {
// The insertion index is the first sibling whose position sorts STRICTLY
// after the new node's. An equal sibling is not strictly after, so it is
// skipped — the new node lands immediately AFTER every equal-position
// sibling and before the first strictly-greater one. This is deterministic:
// a tie always resolves the same way on every client.
const node: P = { id: "x", name: "X", position: "a2" }; // equals b's position
const t = treeModel.insertByPosition(roots, null, node);
expect(t.map((n) => n.id)).toEqual(["a", "b", "x", "c"]);
});
});
// reconcileChildren (#159 #8): on a socket-reconnect refresh, an already-loaded
// branch is reconciled against a fresh server fetch — removed children drop,
// new ones appear, order follows the server, and surviving children keep their
// own loaded grandchildren (deeper expansion is not collapsed).
describe("treeModel.reconcileChildren", () => {
type N = TreeNode<{ name: string }>;
const leaf = (id: string): N => ({ id, name: id.toUpperCase() });
it("drops removed children, adds new ones, and follows the fresh order", () => {
const tree: N[] = [
{ id: "p", name: "P", children: [leaf("a"), leaf("b")] },
];
// Server now has b, c (a was deleted/moved away; c is new) in this order.
const next = treeModel.reconcileChildren(tree, "p", [leaf("b"), leaf("c")]);
expect(treeModel.find(next, "p")?.children?.map((n) => n.id)).toEqual([
"b",
"c",
]);
expect(treeModel.find(next, "a")).toBeNull();
});
it("preserves a surviving child's loaded grandchildren", () => {
const tree: N[] = [
{
id: "p",
name: "P",
children: [{ id: "a", name: "A", children: [leaf("a1")] }, leaf("b")],
},
];
// Fresh fetch returns only top-level children (no grandchildren).
const next = treeModel.reconcileChildren(tree, "p", [leaf("a"), leaf("b")]);
// 'a' keeps its previously loaded grandchild 'a1'.
expect(treeModel.find(next, "a")?.children?.map((n) => n.id)).toEqual([
"a1",
]);
});
it("leaves an UNLOADED parent (children undefined) untouched", () => {
const tree: N[] = [{ id: "p", name: "P" }]; // children: undefined
const next = treeModel.reconcileChildren(tree, "p", [leaf("a")]);
expect(next).toBe(tree); // no-op: lazy-load handles an unloaded branch
expect(treeModel.find(next, "p")?.children).toBeUndefined();
});
});
// addTreeNode idempotency: the receiver early-returns when the node id already
// exists, so re-delivery (or the author's optimistic node) is never duplicated.
// This guards the find-then-skip contract insertByPosition relies on.
describe("addTreeNode idempotency (find-then-skip)", () => {
type P = TreeNode<{ name: string; position?: string }>;
const applyAddTreeNode = (tree: P[], node: P): P[] => {
if (treeModel.find(tree, node.id)) return tree;
return treeModel.insertByPosition(tree, null, node);
};
it("does not insert a duplicate when the id already exists", () => {
const tree: P[] = [{ id: "a", name: "A", position: "a0" }];
const node: P = { id: "a", name: "A again", position: "a5" };
const t1 = applyAddTreeNode(tree, node);
expect(t1).toBe(tree);
expect(t1.map((n) => n.id)).toEqual(["a"]);
});
it("inserts once, then is a no-op on repeat delivery", () => {
let tree: P[] = [{ id: "a", name: "A", position: "a0" }];
const node: P = { id: "x", name: "X", position: "a5" };
tree = applyAddTreeNode(tree, node);
expect(tree.map((n) => n.id)).toEqual(["a", "x"]);
const again = applyAddTreeNode(tree, node);
expect(again).toBe(tree);
expect(again.filter((n) => n.id === "x")).toHaveLength(1);
});
});
// handleCreate optimistic-insert idempotency: the author's optimistic insert is
// now guarded by `treeModel.find` (same contract as the addTreeNode socket
// handler) because the server's broadcast can win the race and insert the node
// first. Whichever runs first inserts; the second is a no-op. Exactly one row.
describe("handleCreate optimistic-insert idempotency (find-then-skip)", () => {
// Mirrors the guarded optimistic insert in use-tree-mutation handleCreate.
const applyOptimisticInsert = (
tree: N[],
parentId: string | null,
node: N,
index: number,
): N[] => {
if (treeModel.find(tree, node.id)) return tree;
return treeModel.insert(tree, parentId, node, index);
};
// Mirrors the addTreeNode socket handler guard.
const applyAddTreeNode = (
tree: N[],
parentId: string | null,
node: N,
): N[] => {
if (treeModel.find(tree, node.id)) return tree;
return treeModel.insert(tree, parentId, node);
};
const created: N = { id: "new", name: "" };
it("optimistic insert is a no-op when server addTreeNode already inserted it", () => {
// Reverse-of-reverse race: server wins.
const afterServer = applyAddTreeNode(fixture, null, created);
expect(afterServer.filter((n) => n.id === "new")).toHaveLength(1);
const afterOptimistic = applyOptimisticInsert(
afterServer,
null,
created,
afterServer.length,
);
expect(afterOptimistic).toBe(afterServer); // skipped
expect(afterOptimistic.filter((n) => n.id === "new")).toHaveLength(1);
});
it("server addTreeNode is a no-op when optimistic insert already ran (optimistic-first)", () => {
const afterOptimistic = applyOptimisticInsert(
fixture,
null,
created,
fixture.length,
);
expect(afterOptimistic.filter((n) => n.id === "new")).toHaveLength(1);
const afterServer = applyAddTreeNode(afterOptimistic, null, created);
expect(afterServer).toBe(afterOptimistic); // skipped
expect(afterServer.filter((n) => n.id === "new")).toHaveLength(1);
});
it("inserts exactly once when only the optimistic path runs", () => {
const t = applyOptimisticInsert(fixture, "a", { id: "a3", name: "" }, 2);
expect(
treeModel.find(t, "a")?.children?.filter((n) => n.id === "a3"),
).toHaveLength(1);
});
});
// moveTreeNode socket-handler semantics: the receiver must place the moved node
// by `position` (NOT index 0) and apply the `pageData` the payload carries so a
// moved node's title/icon/chevron stay correct. This mirrors the reducer in
// use-tree-socket.ts so the contract is unit-tested without rendering the hook.
describe("moveTreeNode handler (place by position + apply pageData)", () => {
type P = TreeNode<{
name: string;
position?: string;
icon?: string;
hasChildren?: boolean;
parentPageId?: string | null;
}>;
const applyMoveTreeNode = (
tree: P[],
payload: {
id: string;
parentId: string | null;
position: string;
pageData?: {
title?: string | null;
icon?: string | null;
hasChildren?: boolean;
};
},
): P[] => {
if (!treeModel.find(tree, payload.id)) return tree;
const placed = treeModel.placeByPosition(tree, payload.id, {
parentId: payload.parentId,
position: payload.position,
});
if (placed === tree) return treeModel.remove(tree, payload.id);
const patch: Partial<P> = {
position: payload.position,
parentPageId: payload.parentId,
} as Partial<P>;
const pd = payload.pageData;
if (pd) {
if (pd.title !== undefined)
(patch as { name?: string }).name = pd.title ?? "";
if (pd.icon !== undefined)
(patch as { icon?: string }).icon = pd.icon ?? undefined;
if (pd.hasChildren !== undefined)
(patch as { hasChildren?: boolean }).hasChildren = pd.hasChildren;
}
return treeModel.update(placed, payload.id, patch);
};
const tree: P[] = [
{
id: "dst",
name: "DST",
position: "a0",
children: [
{ id: "c1", name: "C1", position: "a1" },
{ id: "c2", name: "C2", position: "a3" },
{ id: "c3", name: "C3", position: "a5" },
],
},
{ id: "src", name: "SRC", position: "a9" },
];
it("lands the moved node in the correct MIDDLE slot, not at index 0", () => {
const t = applyMoveTreeNode(tree, {
id: "src",
parentId: "dst",
position: "a4",
});
expect(treeModel.find(t, "dst")?.children?.map((n) => n.id)).toEqual([
"c1",
"c2",
"src",
"c3",
]);
});
it("lands the moved node at the END when position sorts last", () => {
const t = applyMoveTreeNode(tree, {
id: "src",
parentId: "dst",
position: "a8",
});
expect(treeModel.find(t, "dst")?.children?.map((n) => n.id)).toEqual([
"c1",
"c2",
"c3",
"src",
]);
});
it("applies pageData (title/icon/hasChildren) to the moved node", () => {
const t = applyMoveTreeNode(tree, {
id: "src",
parentId: "dst",
position: "a4",
pageData: { title: "Renamed", icon: "🔥", hasChildren: true },
});
const moved = treeModel.find(t, "src");
expect(moved?.name).toBe("Renamed");
expect(moved?.icon).toBe("🔥");
expect(moved?.hasChildren).toBe(true);
expect(moved?.position).toBe("a4");
});
it("falls back to removing the node when the destination parent is not loaded", () => {
const t = applyMoveTreeNode(tree, {
id: "src",
parentId: "not-loaded",
position: "a4",
});
expect(treeModel.find(t, "src")).toBeNull();
});
});
describe("treeModel.remove", () => {
it("removes a leaf", () => {
const t = treeModel.remove(fixture, "a2");
expect(treeModel.find(t, "a2")).toBeNull();
});
it("removes a subtree", () => {
const t = treeModel.remove(fixture, "a1");
expect(treeModel.find(t, "a1")).toBeNull();
expect(treeModel.find(t, "a1a")).toBeNull();
});
it("removes a root node", () => {
const t = treeModel.remove(fixture, "b");
expect(t.map((n) => n.id)).toEqual(["a"]);
});
it("returns same array reference for unknown id", () => {
expect(treeModel.remove(fixture, "ghost")).toBe(fixture);
});
});
describe("treeModel.update", () => {
it("shallow-merges a patch on the matching node", () => {
const t = treeModel.update(fixture, "a1", { name: "A1-renamed" });
expect(treeModel.find(t, "a1")?.name).toBe("A1-renamed");
});
it("returns same array reference for unknown id", () => {
expect(treeModel.update(fixture, "ghost", { name: "x" })).toBe(fixture);
});
it("preserves children when patching parent's own fields", () => {
const t = treeModel.update(fixture, "a", { name: "A-renamed" });
expect(treeModel.find(t, "a")?.children?.map((n) => n.id)).toEqual([
"a1",
"a2",
]);
});
it("preserves reference identity of unrelated subtrees", () => {
const t = treeModel.update(fixture, "a1", { name: "X" });
expect(t[1]).toBe(fixture[1]);
});
});
describe("treeModel.appendChildren", () => {
const kid = (id: string): N => ({ id, name: id });
it("appends to existing children", () => {
const t = treeModel.appendChildren(fixture, "a", [kid("a3"), kid("a4")]);
expect(treeModel.find(t, "a")?.children?.map((n) => n.id)).toEqual([
"a1",
"a2",
"a3",
"a4",
]);
});
it("initializes children when parent had none", () => {
const t = treeModel.appendChildren(fixture, "b", [kid("b1")]);
expect(treeModel.find(t, "b")?.children?.map((n) => n.id)).toEqual(["b1"]);
});
it("returns same array reference for unknown parentId", () => {
expect(treeModel.appendChildren(fixture, "ghost", [kid("zz")])).toBe(
fixture,
);
});
// Regression: lazy-load + auto-expand can race and call appendChildren with
// children that overlap what's already there. React then crashes on duplicate
// keys. Defensive dedup at the model level.
it("dedups against existing children by id", () => {
const t1 = treeModel.appendChildren(fixture, "a", [kid("a3"), kid("a4")]);
const t2 = treeModel.appendChildren(t1, "a", [
kid("a3"),
kid("a4"),
kid("a5"),
]);
expect(treeModel.find(t2, "a")?.children?.map((n) => n.id)).toEqual([
"a1",
"a2",
"a3",
"a4",
"a5",
]);
});
it("returns same array reference when every child is a duplicate", () => {
const t1 = treeModel.appendChildren(fixture, "a", [kid("a3")]);
const t2 = treeModel.appendChildren(t1, "a", [kid("a3")]);
expect(t2).toBe(t1);
});
});
describe("treeModel.place", () => {
it("moves a node to a new parent at a given index", () => {
const t = treeModel.place(fixture, "a2", { parentId: "b", index: 0 });
expect(treeModel.find(t, "a")?.children?.map((n) => n.id)).toEqual(["a1"]);
expect(treeModel.find(t, "b")?.children?.map((n) => n.id)).toEqual(["a2"]);
});
it("moves a node to root", () => {
const t = treeModel.place(fixture, "a1", { parentId: null, index: 0 });
expect(t.map((n) => n.id)).toEqual(["a1", "a", "b"]);
expect(treeModel.find(t, "a")?.children?.map((n) => n.id)).toEqual(["a2"]);
});
it("reorders within the same parent", () => {
const t = treeModel.place(fixture, "a2", { parentId: "a", index: 0 });
expect(treeModel.find(t, "a")?.children?.map((n) => n.id)).toEqual([
"a2",
"a1",
]);
});
it("returns same array reference for unknown source", () => {
expect(treeModel.place(fixture, "ghost", { parentId: "a", index: 0 })).toBe(
fixture,
);
});
it("returns same array reference for unknown destination parent", () => {
expect(
treeModel.place(fixture, "a1", { parentId: "ghost", index: 0 }),
).toBe(fixture);
});
});
describe("treeModel.placeByPosition", () => {
// Server-authoritative `moveTreeNode` ships the moved node's fractional
// `position`; the receiver must sort it into the correct slot among the new
// siblings — NOT drop it at index 0.
type P = TreeNode<{ name: string; position?: string }>;
const tree: P[] = [
{
id: "dst",
name: "DST",
position: "a0",
children: [
{ id: "c1", name: "C1", position: "a1" },
{ id: "c2", name: "C2", position: "a3" },
{ id: "c3", name: "C3", position: "a5" },
],
},
{ id: "src", name: "SRC", position: "a9" },
];
it("places the moved node in the MIDDLE of new siblings by position", () => {
const t = treeModel.placeByPosition(tree, "src", {
parentId: "dst",
position: "a4",
});
expect(treeModel.find(t, "dst")?.children?.map((n) => n.id)).toEqual([
"c1",
"c2",
"src",
"c3",
]);
});
it("places the moved node at the END when its position sorts last", () => {
const t = treeModel.placeByPosition(tree, "src", {
parentId: "dst",
position: "a8",
});
expect(treeModel.find(t, "dst")?.children?.map((n) => n.id)).toEqual([
"c1",
"c2",
"c3",
"src",
]);
});
it("places the moved node at the FRONT only when its position sorts first", () => {
const t = treeModel.placeByPosition(tree, "src", {
parentId: "dst",
position: "a0",
});
expect(treeModel.find(t, "dst")?.children?.map((n) => n.id)).toEqual([
"src",
"c1",
"c2",
"c3",
]);
});
it("stamps the authoritative position onto the moved node", () => {
const t = treeModel.placeByPosition(tree, "src", {
parentId: "dst",
position: "a4",
});
expect(treeModel.find(t, "src")?.position).toBe("a4");
});
it("reorders within the same parent by position (not to index 0)", () => {
const same: P[] = [
{
id: "p",
name: "P",
position: "a0",
children: [
{ id: "x", name: "X", position: "a1" },
{ id: "y", name: "Y", position: "a2" },
{ id: "z", name: "Z", position: "a3" },
],
},
];
// Move x to between y and z.
const t = treeModel.placeByPosition(same, "x", {
parentId: "p",
position: "a25",
});
expect(treeModel.find(t, "p")?.children?.map((n) => n.id)).toEqual([
"y",
"x",
"z",
]);
});
it("returns same array reference for unknown source", () => {
expect(
treeModel.placeByPosition(tree, "ghost", {
parentId: "dst",
position: "a4",
}),
).toBe(tree);
});
it("returns same array reference when destination parent is not loaded", () => {
expect(
treeModel.placeByPosition(tree, "src", {
parentId: "ghost",
position: "a4",
}),
).toBe(tree);
});
it("moves a node to root by position", () => {
const roots: P[] = [
{ id: "r1", name: "R1", position: "a1" },
{ id: "r2", name: "R2", position: "a5" },
{
id: "rp",
name: "RP",
position: "a7",
children: [{ id: "child", name: "CHILD", position: "a1" }],
},
];
const t = treeModel.placeByPosition(roots, "child", {
parentId: null,
position: "a3",
});
expect(t.map((n) => n.id)).toEqual(["r1", "child", "r2", "rp"]);
});
});
describe("treeModel.move", () => {
it("reorder-before within same parent: moves source to target index", () => {
const { tree: t, result } = treeModel.move(fixture, "a2", {
kind: "reorder-before",
targetId: "a1",
});
expect(treeModel.find(t, "a")?.children?.map((n) => n.id)).toEqual([
"a2",
"a1",
]);
expect(result).toEqual({ parentId: "a", index: 0 });
});
it("reorder-after within same parent", () => {
const { tree: t, result } = treeModel.move(fixture, "a1", {
kind: "reorder-after",
targetId: "a2",
});
expect(treeModel.find(t, "a")?.children?.map((n) => n.id)).toEqual([
"a2",
"a1",
]);
expect(result).toEqual({ parentId: "a", index: 1 });
});
it("make-child appends at end of target children", () => {
const { tree: t, result } = treeModel.move(fixture, "b", {
kind: "make-child",
targetId: "a",
});
expect(treeModel.find(t, "a")?.children?.map((n) => n.id)).toEqual([
"a1",
"a2",
"b",
]);
expect(result).toEqual({ parentId: "a", index: 2 });
});
it("make-child initializes children when target had none", () => {
const { tree: t, result } = treeModel.move(fixture, "a2", {
kind: "make-child",
targetId: "b",
});
expect(treeModel.find(t, "b")?.children?.map((n) => n.id)).toEqual(["a2"]);
expect(result).toEqual({ parentId: "b", index: 0 });
});
it("reorder-before across parents", () => {
const { tree: t, result } = treeModel.move(fixture, "b", {
kind: "reorder-before",
targetId: "a1",
});
expect(treeModel.find(t, "a")?.children?.map((n) => n.id)).toEqual([
"b",
"a1",
"a2",
]);
expect(result).toEqual({ parentId: "a", index: 0 });
});
it("reorder-after to root", () => {
const { tree: t, result } = treeModel.move(fixture, "a1", {
kind: "reorder-after",
targetId: "a",
});
expect(t.map((n) => n.id)).toEqual(["a", "a1", "b"]);
expect(treeModel.find(t, "a")?.children?.map((n) => n.id)).toEqual(["a2"]);
expect(result).toEqual({ parentId: null, index: 1 });
});
it("no-op when sourceId === targetId", () => {
const out = treeModel.move(fixture, "a", {
kind: "make-child",
targetId: "a",
});
expect(out.tree).toBe(fixture);
});
it("no-op when target is descendant of source", () => {
const out = treeModel.move(fixture, "a", {
kind: "make-child",
targetId: "a1a",
});
expect(out.tree).toBe(fixture);
});
it("no-op when source is unknown", () => {
const out = treeModel.move(fixture, "ghost", {
kind: "reorder-before",
targetId: "a",
});
expect(out.tree).toBe(fixture);
});
it("no-op when target is unknown", () => {
const out = treeModel.move(fixture, "a1", {
kind: "reorder-before",
targetId: "ghost",
});
expect(out.tree).toBe(fixture);
});
it("cross-parent move does NOT apply the same-parent adjust (no off-by-one)", () => {
// Source `x3` sits at index 2 in parent `x`; target `y1` sits at index 0 in
// parent `y`. sourceInfo.index (2) > info.index (0) AND the parents differ,
// so the `sameParent && source.index < info.index` adjust must be 0 — the
// node must land at index 0 in `y`, not at index -1 (which would silently
// drop it at a wrong slot / off-by-one).
const crossFixture: N[] = [
{
id: "x",
name: "X",
children: [
{ id: "x1", name: "X1" },
{ id: "x2", name: "X2" },
{ id: "x3", name: "X3" },
],
},
{
id: "y",
name: "Y",
children: [
{ id: "y1", name: "Y1" },
{ id: "y2", name: "Y2" },
],
},
];
const { tree: t, result } = treeModel.move(crossFixture, "x3", {
kind: "reorder-before",
targetId: "y1",
});
expect(result).toEqual({ parentId: "y", index: 0 });
expect(treeModel.find(t, "y")?.children?.map((n) => n.id)).toEqual([
"x3",
"y1",
"y2",
]);
expect(treeModel.find(t, "x")?.children?.map((n) => n.id)).toEqual([
"x1",
"x2",
]);
});
});