Approve-with-comments follow-ups: - breadcrumb: fix the reverse regression where navigating A->B to a page absent from the lazily-built tree (before its ancestors load) left the previous page's clickable chain on screen. New pure computeBreadcrumbState clears a stale chain that doesn't end at the current page, while keeping one that does (no blank flash for an already-resolved page); unit-tested for the navigated-to-absent-page case. - share.service: getShareAncestorPage no longer swallows DB errors silently — now a live public-share path (isPageReachableThroughShare), so a transient error is logged with ancestor/child ids and still fails closed (caller 404s) instead of becoming a traceless misleading "not found". - i18n: register the new "Connecting… (read-only)" key (U+2026 ellipsis) in en-US (source of truth) and ru-RU (Подключение… (только чтение)). - share.service: correct the FUTURE note — 3 callers pass no shareId (share-alias.controller/.service, share-seo.controller); the two ai-chat callers already pass a real shareId. - CHANGELOG: add Unreleased Changed/Fixed/Security entries for #216 opt-in sub-pages default, #218 trimmed page-info payload + forged-shareId 404, #204 export internal-link name, #206/#218 breadcrumb, #192 callout paste, #218 editor pre-sync read-only gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
115 lines
4.3 KiB
TypeScript
115 lines
4.3 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import {
|
|
computeBreadcrumbState,
|
|
resolveBreadcrumbNodes,
|
|
} from "./breadcrumb.utils";
|
|
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
|
import { IPage } from "@/features/page/types/page.types.ts";
|
|
|
|
// Pure selection/mapping behind the breadcrumb (#218): tree-hit prefers the live
|
|
// sidebar tree, tree-miss maps the page's own ancestors, and "no data" returns
|
|
// null so the component keeps its prior state.
|
|
|
|
function treeNode(id: string, over?: Partial<SpaceTreeNode>): SpaceTreeNode {
|
|
return {
|
|
id,
|
|
slugId: `slug-${id}`,
|
|
name: `node-${id}`,
|
|
icon: null,
|
|
position: "a",
|
|
hasChildren: false,
|
|
spaceId: "space-1",
|
|
parentPageId: null,
|
|
children: [],
|
|
...over,
|
|
} as SpaceTreeNode;
|
|
}
|
|
|
|
function ancestorPage(id: string, over?: Partial<IPage>): IPage {
|
|
return {
|
|
id,
|
|
slugId: `slug-${id}`,
|
|
title: `title-${id}`,
|
|
icon: "📄",
|
|
position: "m",
|
|
spaceId: "space-1",
|
|
parentPageId: null,
|
|
hasChildren: true,
|
|
...over,
|
|
} as IPage;
|
|
}
|
|
|
|
describe("resolveBreadcrumbNodes", () => {
|
|
it("tree-hit: returns the path found in the live sidebar tree", () => {
|
|
const child = treeNode("child");
|
|
const root = treeNode("root", { hasChildren: true, children: [child] });
|
|
// findBreadcrumbPath walks the tree; the chain ends at the target page.
|
|
const result = resolveBreadcrumbNodes([root], [ancestorPage("child")], "child");
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.map((n) => n.id)).toEqual(["root", "child"]);
|
|
// Came from the tree, NOT the ancestor mapping (icon stays the tree's null).
|
|
expect(result![result!.length - 1].icon).toBeNull();
|
|
});
|
|
|
|
it("tree-miss: maps the page's own ancestors (title->name, hasChildren default)", () => {
|
|
// Tree has no node for the target page -> findBreadcrumbPath misses.
|
|
const unrelated = treeNode("unrelated");
|
|
const ancestors = [
|
|
ancestorPage("a", { hasChildren: true }),
|
|
ancestorPage("b", { hasChildren: undefined as any }),
|
|
];
|
|
|
|
const result = resolveBreadcrumbNodes([unrelated], ancestors, "missing-page");
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.map((n) => n.id)).toEqual(["a", "b"]);
|
|
// Non-trivial field transform: title -> name.
|
|
expect(result![0].name).toBe("title-a");
|
|
// hasChildren defaults to false when the ancestor row omits it.
|
|
expect(result![1].hasChildren).toBe(false);
|
|
expect(result![0].hasChildren).toBe(true);
|
|
});
|
|
|
|
it("falls back to ancestors when the tree is empty", () => {
|
|
const result = resolveBreadcrumbNodes([], [ancestorPage("a")], "a");
|
|
expect(result!.map((n) => n.id)).toEqual(["a"]);
|
|
});
|
|
|
|
it("returns null when there is no tree hit and no ancestor data", () => {
|
|
expect(resolveBreadcrumbNodes([], [], "x")).toBeNull();
|
|
expect(resolveBreadcrumbNodes(undefined, undefined, "x")).toBeNull();
|
|
expect(resolveBreadcrumbNodes(null, null, "x")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("computeBreadcrumbState (stale-chain clearing on navigation)", () => {
|
|
it("uses a freshly resolved chain when available", () => {
|
|
const child = treeNode("B");
|
|
const root = treeNode("root", { hasChildren: true, children: [child] });
|
|
const next = computeBreadcrumbState([root], null, "B", null);
|
|
expect(next!.map((n) => n.id)).toEqual(["root", "B"]);
|
|
});
|
|
|
|
it("navigating A->B to a page absent from treeData clears the previous A chain (no stale trail)", () => {
|
|
// Previous chain ends at page A; we are now on page B, which is not yet in
|
|
// the lazily-built tree and whose ancestors have not loaded.
|
|
const previous = [treeNode("rootA"), treeNode("A")];
|
|
const next = computeBreadcrumbState([treeNode("unrelated")], undefined, "B", previous);
|
|
// Must NOT keep showing A's (clickable) chain.
|
|
expect(next).toBeNull();
|
|
});
|
|
|
|
it("keeps a chain that already ends at the current page through a transient miss", () => {
|
|
// We already resolved B once (chain ends at B); a transient miss must not
|
|
// blank it.
|
|
const previous = [treeNode("rootB"), treeNode("B")];
|
|
const next = computeBreadcrumbState([], undefined, "B", previous);
|
|
expect(next).toBe(previous);
|
|
});
|
|
|
|
it("returns null when nothing resolves and there is no previous chain", () => {
|
|
expect(computeBreadcrumbState([], undefined, "B", null)).toBeNull();
|
|
});
|
|
});
|