Files
gitmost/apps/client/src/features/websocket/tree-socket-reducers.test.ts
claude code agent 227 12ff76fb89 fix(temporary-notes): live sidebar clock marker + stacked mobile create buttons
Issue 1 — the sidebar tree's temporary-note clock marker did not appear/
disappear until a page reload when a note's temporary state changed.

- Make/unmake permanent from the page header menu and the in-page banner went
  through syncTemporaryExpiresInCache(), which patched the page query cache but
  never touched treeDataAtom, so the sidebar node kept its stale
  temporaryExpiresAt. Patch the tree node there too (via jotai's default store),
  so the marker updates without a reload.
- Creating a note as temporary showed no marker until reload: the create flow's
  cache write (invalidateOnCreatePage) omitted temporaryExpiresAt, so the tree
  rebuild (buildTree -> mergeRootTrees) overwrote the optimistic/socket node's
  marker with undefined. Carry temporaryExpiresAt in that cached entry.
- Thread temporaryExpiresAt through the server addTreeNode broadcast (PAGE_CREATED
  snapshot -> TreeNodeSnapshot -> broadcastPageCreated) so OTHER clients watching
  the space also render the marker immediately, and harden handleCreate's
  idempotency guard to patch the deadline if the broadcast won the insert race.

Issue 2 — the home and space-overview "New note" / "New temporary note" buttons
sat side-by-side and the temporary label clipped on narrow mobile widths. Lay
them out full-width, stacked vertically, and tint the temporary button orange
(matching the clock marker + banner) while the regular one stays neutral gray.

Tests: extend tree-socket-reducers.test.ts (addTreeNode carries
temporaryExpiresAt). Verified live with Playwright: marker appears on create and
toggles both ways with no reload; mobile buttons are stacked, full-width,
unclipped, and differently colored.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 00:29:19 +03:00

341 lines
11 KiB
TypeScript

import { describe, it, expect } from "vitest";
import {
applyAddTreeNode,
applyMoveTreeNode,
applyDeleteTreeNode,
} from "./tree-socket-reducers";
import { treeModel } from "@/features/page/tree/model/tree-model";
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
// Minimal node factory — fills the SpaceTreeNode shape required fields while
// letting tests override the bits that matter (position, parentPageId, etc).
function node(
id: string,
overrides: Partial<SpaceTreeNode> = {},
): SpaceTreeNode {
return {
id,
slugId: `slug-${id}`,
name: id.toUpperCase(),
icon: undefined,
position: "a0",
spaceId: "space-1",
parentPageId: null as unknown as string,
hasChildren: false,
children: [],
...overrides,
};
}
describe("applyMoveTreeNode", () => {
// Destination parent `dst` is loaded with three positioned children; the moved
// node `src` is a sibling at root with a later position.
const buildTree = (): SpaceTreeNode[] => [
node("dst", {
position: "a0",
hasChildren: true,
children: [
node("c1", { position: "a1", parentPageId: "dst" }),
node("c2", { position: "a3", parentPageId: "dst" }),
node("c3", { position: "a5", parentPageId: "dst" }),
],
}),
node("src", { position: "a9" }),
];
it("places the node by position in the MIDDLE slot of the destination", () => {
const tree = buildTree();
const next = applyMoveTreeNode(tree, {
id: "src",
parentId: "dst",
oldParentId: null,
index: 0,
position: "a4",
pageData: {},
});
expect(treeModel.find(next, "dst")?.children?.map((n) => n.id)).toEqual([
"c1",
"c2",
"src",
"c3",
]);
});
it("falls back to REMOVING the node when destination parent is not loaded (no leak)", () => {
const tree = buildTree();
const next = applyMoveTreeNode(tree, {
id: "src",
parentId: "not-loaded",
oldParentId: null,
index: 0,
position: "a4",
pageData: {},
});
// The source must not linger at its old place — it is removed entirely.
expect(treeModel.find(next, "src")).toBeNull();
// Destination children are untouched.
expect(treeModel.find(next, "dst")?.children?.map((n) => n.id)).toEqual([
"c1",
"c2",
"c3",
]);
});
it("does NOT create a partial child list when the destination is loaded-but-collapsed (children unloaded) — keeps it lazy-loadable (#159)", () => {
// `dstCollapsed` is in the tree but its children were never lazy-loaded
// (children === undefined). The OLD behavior inserted `src` as the ONLY
// child ([src]), which defeated the lazy-load gate and HID the parent's
// other real children. Now the move leaves children unloaded (so expanding
// fetches the FULL set, including src) and just flags hasChildren.
const tree: SpaceTreeNode[] = [
node("dstCollapsed", {
position: "a0",
hasChildren: false,
children: undefined as unknown as SpaceTreeNode[],
}),
node("src", { position: "a9" }),
];
const next = applyMoveTreeNode(tree, {
id: "src",
parentId: "dstCollapsed",
oldParentId: null,
index: 0,
position: "a4",
pageData: {},
});
const dst = treeModel.find(next, "dstCollapsed");
// Children stay unloaded -> the lazy-load gate fetches the FULL set (incl.
// src) on expand, rather than showing a misleading partial [src] list.
expect(dst?.children).toBeUndefined();
expect(dst?.hasChildren).toBe(true);
// src moved away from its old root slot (it lives under dstCollapsed
// server-side and reappears when the parent is expanded/loaded).
expect(next.map((n) => n.id)).not.toContain("src");
});
it("flips the OLD parent's hasChildren to false when it is left childless", () => {
// src is the only child of `old`; moving it to `dst` empties `old`.
const tree: SpaceTreeNode[] = [
node("old", {
position: "a0",
hasChildren: true,
children: [node("src", { position: "a1", parentPageId: "old" })],
}),
node("dst", { position: "a2", hasChildren: false }),
];
const next = applyMoveTreeNode(tree, {
id: "src",
parentId: "dst",
oldParentId: "old",
index: 0,
position: "a1",
pageData: {},
});
expect(treeModel.find(next, "old")?.hasChildren).toBe(false);
});
it("flips the NEW parent's hasChildren to true", () => {
// dst starts as a childless leaf; moving src into it must flip the chevron.
const tree: SpaceTreeNode[] = [
node("dst", { position: "a0", hasChildren: false }),
node("src", { position: "a9" }),
];
const next = applyMoveTreeNode(tree, {
id: "src",
parentId: "dst",
oldParentId: null,
index: 0,
position: "a1",
pageData: {},
});
expect(treeModel.find(next, "dst")?.hasChildren).toBe(true);
expect(treeModel.find(next, "dst")?.children?.map((n) => n.id)).toEqual([
"src",
]);
});
it("returns prev unchanged when the source node is not found", () => {
const tree = buildTree();
const next = applyMoveTreeNode(tree, {
id: "ghost",
parentId: "dst",
oldParentId: null,
index: 0,
position: "a4",
pageData: {},
});
expect(next).toBe(tree);
});
it("applies authoritative pageData (title/icon/hasChildren) to the moved node", () => {
const tree = buildTree();
const next = applyMoveTreeNode(tree, {
id: "src",
parentId: "dst",
oldParentId: null,
index: 0,
position: "a4",
pageData: { title: "Renamed", icon: "fire", hasChildren: true },
});
const moved = treeModel.find(next, "src");
expect(moved?.name).toBe("Renamed");
expect(moved?.icon).toBe("fire");
expect(moved?.hasChildren).toBe(true);
expect(moved?.position).toBe("a4");
});
it("does NOT drop a subtree on a cyclic/out-of-order move (parent inside source) (#206 ui-state-races-1)", () => {
// Locally `b` is still nested inside `a` (an earlier "a under b" echo hasn't
// applied yet). An out-of-order "move a under b" event now arrives — b is a
// descendant of a, so re-parenting would make placeByPosition remove a (and
// its whole subtree, incl. b) and fail to re-insert. Before the fix BOTH a
// and b silently vanished; now the reducer leaves the tree untouched.
const tree: SpaceTreeNode[] = [
node("a", {
position: "a0",
hasChildren: true,
children: [node("b", { position: "a1", parentPageId: "a" })],
}),
];
const next = applyMoveTreeNode(tree, {
id: "a",
parentId: "b",
oldParentId: null,
index: 0,
position: "a4",
pageData: {},
});
// No silent data loss: both nodes survive.
expect(treeModel.find(next, "a")).not.toBeNull();
expect(treeModel.find(next, "b")).not.toBeNull();
// The cyclic move is refused as a no-op (same reference) pending reconcile.
expect(next).toBe(tree);
});
});
describe("applyDeleteTreeNode", () => {
it("removes the node together with its descendants", () => {
const tree: SpaceTreeNode[] = [
node("p", {
position: "a0",
hasChildren: true,
children: [
node("child", {
position: "a1",
parentPageId: "p",
hasChildren: true,
children: [
node("grandchild", { position: "a1", parentPageId: "child" }),
],
}),
],
}),
];
const next = applyDeleteTreeNode(tree, {
node: node("child", { parentPageId: "p" }),
});
expect(treeModel.find(next, "child")).toBeNull();
expect(treeModel.find(next, "grandchild")).toBeNull();
expect(treeModel.find(next, "p")).not.toBeNull();
});
it("returns prev unchanged when the node is already gone (idempotent)", () => {
const tree: SpaceTreeNode[] = [node("a", { position: "a0" })];
const next = applyDeleteTreeNode(tree, {
node: node("ghost"),
});
expect(next).toBe(tree);
});
it("flips the parent's hasChildren to false when it is left childless", () => {
const tree: SpaceTreeNode[] = [
node("p", {
position: "a0",
hasChildren: true,
children: [node("only", { position: "a1", parentPageId: "p" })],
}),
];
const next = applyDeleteTreeNode(tree, {
node: node("only", { parentPageId: "p" }),
});
expect(treeModel.find(next, "p")?.hasChildren).toBe(false);
expect(treeModel.find(next, "p")?.children).toEqual([]);
});
it("leaves the parent's hasChildren true when other children remain", () => {
const tree: SpaceTreeNode[] = [
node("p", {
position: "a0",
hasChildren: true,
children: [
node("c1", { position: "a1", parentPageId: "p" }),
node("c2", { position: "a2", parentPageId: "p" }),
],
}),
];
const next = applyDeleteTreeNode(tree, {
node: node("c1", { parentPageId: "p" }),
});
expect(treeModel.find(next, "p")?.hasChildren).toBe(true);
});
});
describe("applyAddTreeNode", () => {
const roots = (): SpaceTreeNode[] => [
node("a", { position: "a0" }),
node("b", { position: "a2" }),
node("c", { position: "a4" }),
];
it("inserts the new node by position among siblings", () => {
const tree = roots();
const next = applyAddTreeNode(tree, {
parentId: null as unknown as string,
index: 0,
data: node("x", { position: "a3" }),
});
expect(next.map((n) => n.id)).toEqual(["a", "b", "x", "c"]);
});
it("returns prev unchanged when the id is already present (idempotent)", () => {
const tree = roots();
const next = applyAddTreeNode(tree, {
parentId: null as unknown as string,
index: 0,
data: node("b", { position: "a9" }),
});
expect(next).toBe(tree);
expect(next.map((n) => n.id)).toEqual(["a", "b", "c"]);
});
it("flips the new parent's hasChildren to true", () => {
// Parent `p` is a childless leaf; adding a child must flip its chevron.
const tree: SpaceTreeNode[] = [
node("p", { position: "a0", hasChildren: false }),
];
const next = applyAddTreeNode(tree, {
parentId: "p",
index: 0,
data: node("child", { position: "a1", parentPageId: "p" }),
});
expect(treeModel.find(next, "p")?.hasChildren).toBe(true);
expect(treeModel.find(next, "p")?.children?.map((n) => n.id)).toEqual([
"child",
]);
});
it("carries temporaryExpiresAt onto the inserted node so the clock marker shows on create (no reload)", () => {
// A note created as temporary broadcasts addTreeNode with the death-timer
// deadline in its payload; the receiver's inserted node must keep it so
// space-tree-row renders the orange clock marker immediately.
const tree = roots();
const expiresAt = "2026-06-27T21:00:00.000Z";
const next = applyAddTreeNode(tree, {
parentId: null as unknown as string,
index: 0,
data: node("temp", { position: "a3", temporaryExpiresAt: expiresAt }),
});
expect(treeModel.find(next, "temp")?.temporaryExpiresAt).toBe(expiresAt);
});
});