244 lines
6.7 KiB
TypeScript
244 lines
6.7 KiB
TypeScript
import { act, useEffect } from "react";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { createRoot, type Root } from "react-dom/client";
|
|
import type { SidebarInitialData } from "@/components/sidebar/types";
|
|
import { buildSidebarInitialData } from "@/lib/sidebar-data";
|
|
import type { DocumentRecord } from "@/lib/documents";
|
|
import { usePreferredSidebarSnapshot } from "./use-preferred-sidebar-snapshot";
|
|
|
|
function buildDocument(overrides: Partial<DocumentRecord> = {}): DocumentRecord {
|
|
return {
|
|
access_scope: "private",
|
|
id: "doc-1",
|
|
workspace_id: "ws-1",
|
|
title: "标题 A",
|
|
parent_id: null,
|
|
sort_order: 0,
|
|
is_starred: false,
|
|
is_template: false,
|
|
created_at: "2026-04-21T00:00:00.000Z",
|
|
updated_at: "2026-04-21T00:00:00.000Z",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function buildSidebarData(documents: DocumentRecord[]): SidebarInitialData {
|
|
return buildSidebarInitialData({
|
|
activeWorkspaceId: "ws-1",
|
|
workspaces: [],
|
|
documents,
|
|
trashedDocuments: [],
|
|
mindmaps: [],
|
|
mediaAssets: [],
|
|
trashedMediaAssets: [],
|
|
tables: [],
|
|
});
|
|
}
|
|
|
|
function Harness(props: {
|
|
initialData: SidebarInitialData;
|
|
sidebarQueryData: SidebarInitialData | null;
|
|
treeStreamData: SidebarInitialData | null;
|
|
treeStreamStatus?: "idle" | "connecting" | "live" | "fallback";
|
|
onState: (state: ReturnType<typeof usePreferredSidebarSnapshot>) => void;
|
|
}) {
|
|
const state = usePreferredSidebarSnapshot(props);
|
|
|
|
useEffect(() => {
|
|
props.onState(state);
|
|
}, [props, state]);
|
|
|
|
return null;
|
|
}
|
|
|
|
describe("usePreferredSidebarSnapshot", () => {
|
|
let container: HTMLDivElement;
|
|
let root: Root;
|
|
const onState = vi.fn();
|
|
|
|
beforeEach(() => {
|
|
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
|
true;
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
onState.mockClear();
|
|
});
|
|
|
|
afterEach(() => {
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
container.remove();
|
|
});
|
|
|
|
it("tree stream 仍停在 initial 时,query 新快照不应被旧 stream 压住", async () => {
|
|
const initialData = buildSidebarData([buildDocument({ title: "标题 A" })]);
|
|
const staleTreeStream = buildSidebarData([buildDocument({ title: "标题 A" })]);
|
|
const refreshedQuery = buildSidebarData([
|
|
buildDocument({
|
|
title: "标题 B",
|
|
updated_at: "2026-04-21T00:00:01.000Z",
|
|
}),
|
|
]);
|
|
const caughtUpTreeStream = buildSidebarData([
|
|
buildDocument({
|
|
title: "标题 B",
|
|
updated_at: "2026-04-21T00:00:01.000Z",
|
|
}),
|
|
]);
|
|
await act(async () => {
|
|
root.render(
|
|
<Harness
|
|
initialData={initialData}
|
|
sidebarQueryData={initialData}
|
|
treeStreamData={staleTreeStream}
|
|
treeStreamStatus="live"
|
|
onState={onState}
|
|
/>,
|
|
);
|
|
});
|
|
|
|
expect(onState.mock.lastCall?.[0]).toMatchObject({
|
|
source: "tree_stream",
|
|
data: expect.objectContaining({
|
|
kernelSidebarTree: [expect.objectContaining({ title: "标题 A" })],
|
|
}),
|
|
});
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<Harness
|
|
initialData={initialData}
|
|
sidebarQueryData={refreshedQuery}
|
|
treeStreamData={staleTreeStream}
|
|
treeStreamStatus="live"
|
|
onState={onState}
|
|
/>,
|
|
);
|
|
});
|
|
|
|
expect(onState.mock.lastCall?.[0]).toMatchObject({
|
|
source: "query",
|
|
data: expect.objectContaining({
|
|
kernelSidebarTree: [expect.objectContaining({ title: "标题 B" })],
|
|
}),
|
|
});
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<Harness
|
|
initialData={initialData}
|
|
sidebarQueryData={refreshedQuery}
|
|
treeStreamData={caughtUpTreeStream}
|
|
treeStreamStatus="live"
|
|
onState={onState}
|
|
/>,
|
|
);
|
|
});
|
|
|
|
expect(onState.mock.lastCall?.[0]).toMatchObject({
|
|
source: "tree_stream",
|
|
data: expect.objectContaining({
|
|
kernelSidebarTree: [expect.objectContaining({ title: "标题 B" })],
|
|
}),
|
|
});
|
|
});
|
|
|
|
it("tree stream 进入 fallback 后应回退到 query 快照", async () => {
|
|
const initialData = buildSidebarData([buildDocument({ title: "标题 A" })]);
|
|
const staleTreeStream = buildSidebarData([buildDocument({ title: "标题 A" })]);
|
|
const refreshedQuery = buildSidebarData([
|
|
buildDocument({
|
|
title: "标题 B",
|
|
updated_at: "2026-04-21T00:00:01.000Z",
|
|
}),
|
|
]);
|
|
const caughtUpTreeStream = buildSidebarData([
|
|
buildDocument({
|
|
title: "标题 B",
|
|
updated_at: "2026-04-21T00:00:01.000Z",
|
|
}),
|
|
]);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<Harness
|
|
initialData={initialData}
|
|
sidebarQueryData={refreshedQuery}
|
|
treeStreamData={staleTreeStream}
|
|
treeStreamStatus="fallback"
|
|
onState={onState}
|
|
/>,
|
|
);
|
|
});
|
|
|
|
expect(onState.mock.lastCall?.[0]).toMatchObject({
|
|
source: "query",
|
|
data: expect.objectContaining({
|
|
kernelSidebarTree: [expect.objectContaining({ title: "标题 B" })],
|
|
}),
|
|
});
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<Harness
|
|
initialData={initialData}
|
|
sidebarQueryData={refreshedQuery}
|
|
treeStreamData={caughtUpTreeStream}
|
|
treeStreamStatus="live"
|
|
onState={onState}
|
|
/>,
|
|
);
|
|
});
|
|
|
|
expect(onState.mock.lastCall?.[0]).toMatchObject({
|
|
source: "tree_stream",
|
|
data: expect.objectContaining({
|
|
kernelSidebarTree: [expect.objectContaining({ title: "标题 B" })],
|
|
}),
|
|
});
|
|
});
|
|
|
|
it("fallback 且 query 不可用时应回到 initial 快照,而不是继续复用旧 stream 数据", async () => {
|
|
const initialData = buildSidebarData([buildDocument({ title: "初始标题" })]);
|
|
const staleTreeStream = buildSidebarData([
|
|
buildDocument({
|
|
title: "旧 stream 标题",
|
|
updated_at: "2026-04-21T00:00:01.000Z",
|
|
}),
|
|
]);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<Harness
|
|
initialData={initialData}
|
|
sidebarQueryData={initialData}
|
|
treeStreamData={staleTreeStream}
|
|
treeStreamStatus="live"
|
|
onState={onState}
|
|
/>,
|
|
);
|
|
});
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<Harness
|
|
initialData={initialData}
|
|
sidebarQueryData={null}
|
|
treeStreamData={staleTreeStream}
|
|
treeStreamStatus="fallback"
|
|
onState={onState}
|
|
/>,
|
|
);
|
|
});
|
|
|
|
expect(onState.mock.lastCall?.[0]).toMatchObject({
|
|
source: "initial",
|
|
data: expect.objectContaining({
|
|
kernelSidebarTree: [expect.objectContaining({ title: "初始标题" })],
|
|
}),
|
|
});
|
|
});
|
|
});
|