0b7bc8b31b
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
525 lines
19 KiB
TypeScript
525 lines
19 KiB
TypeScript
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { MessageListHandle } from "./chat/message-list-shared";
|
|
|
|
const { mockDockviewState, mockAppStoreState, mockAppStoreApi } = vi.hoisted(() => {
|
|
const state = {
|
|
scrollTarget: null as null | {
|
|
sessionId: string;
|
|
messageId: string;
|
|
token: number;
|
|
hostPanelId: string;
|
|
},
|
|
clearScrollTarget: vi.fn((token: number) => {
|
|
if (state.scrollTarget?.token === token) state.scrollTarget = null;
|
|
}),
|
|
clearScrollTargetForOwner: vi.fn((sessionId: string, hostPanelId: string) => {
|
|
if (
|
|
state.scrollTarget?.sessionId === sessionId &&
|
|
state.scrollTarget.hostPanelId === hostPanelId
|
|
) {
|
|
state.scrollTarget = null;
|
|
}
|
|
}),
|
|
clearScrollTargetForSession: vi.fn((sessionId: string) => {
|
|
if (state.scrollTarget?.sessionId === sessionId) state.scrollTarget = null;
|
|
}),
|
|
};
|
|
const appStoreState = {
|
|
messages: { bySession: { "session-1": [{ id: "message-1" }] } },
|
|
};
|
|
return {
|
|
mockDockviewState: state,
|
|
mockAppStoreState: appStoreState,
|
|
mockAppStoreApi: {
|
|
getState: () => ({ messages: appStoreState.messages, mergeMessages: vi.fn() }),
|
|
},
|
|
};
|
|
});
|
|
|
|
vi.mock("@/lib/state/dockview-store", () => ({
|
|
useDockviewStore: Object.assign(
|
|
(selector: (state: typeof mockDockviewState) => unknown) => selector(mockDockviewState),
|
|
{ getState: () => mockDockviewState },
|
|
),
|
|
}));
|
|
|
|
vi.mock("@/components/state-provider", () => ({
|
|
useAppStore: vi.fn(),
|
|
useAppStoreApi: () => mockAppStoreApi,
|
|
}));
|
|
|
|
vi.mock("@/hooks/domains/session/load-message-window", () => ({
|
|
loadMessageWindowAround: vi.fn(),
|
|
}));
|
|
|
|
import { usePendingMessageScroll, useScrollTargetConsumption } from "./task-chat-panel";
|
|
import { loadMessageWindowAround } from "@/hooks/domains/session/load-message-window";
|
|
|
|
let pendingFrames: Array<(() => void) | undefined> = [];
|
|
|
|
/** Runs two rounds of pending requestAnimationFrame callbacks inside act. */
|
|
async function flushFrames() {
|
|
await act(async () => {
|
|
const frames = pendingFrames.splice(0);
|
|
for (const frame of frames) frame?.();
|
|
});
|
|
await act(async () => {
|
|
const frames = pendingFrames.splice(0);
|
|
for (const frame of frames) frame?.();
|
|
});
|
|
}
|
|
|
|
type ScrollTarget = NonNullable<typeof mockDockviewState.scrollTarget>;
|
|
|
|
/** Builds a dockview scroll-target object with defaults, merged with the given overrides. */
|
|
function target(overrides: Partial<ScrollTarget> = {}): ScrollTarget {
|
|
return {
|
|
sessionId: "session-1",
|
|
messageId: "message-1",
|
|
token: 7,
|
|
hostPanelId: "panel-a",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
/** Builds a MessageListHandle whose scrollToMessage mock returns the given value. */
|
|
function scrollHandle(returns: boolean): MessageListHandle {
|
|
return { scrollToMessage: vi.fn(() => returns) };
|
|
}
|
|
|
|
const PROPS = {
|
|
resolvedSessionId: "session-1",
|
|
isVisible: true,
|
|
panelId: "panel-a",
|
|
isInitialMessagesLoading: false,
|
|
renderedMessageCount: 0,
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.mocked(loadMessageWindowAround).mockReset();
|
|
pendingFrames = [];
|
|
vi.stubGlobal("requestAnimationFrame", (callback: () => void) => {
|
|
pendingFrames.push(callback);
|
|
return pendingFrames.length;
|
|
});
|
|
vi.stubGlobal("cancelAnimationFrame", (id: number) => {
|
|
pendingFrames[id - 1] = undefined;
|
|
});
|
|
mockDockviewState.scrollTarget = null;
|
|
mockAppStoreState.messages.bySession["session-1"] = [{ id: "message-1" }];
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
afterEach(() => {
|
|
act(() => {});
|
|
});
|
|
|
|
describe("useScrollTargetConsumption — success-path consumption", () => {
|
|
it("scrolls the target row and clears by token on a successful scroll", async () => {
|
|
mockDockviewState.scrollTarget = target();
|
|
const messageListRef = { current: scrollHandle(true) };
|
|
|
|
renderHook(() => useScrollTargetConsumption({ ...PROPS, messageListRef }));
|
|
await flushFrames();
|
|
|
|
expect(messageListRef.current?.scrollToMessage).toHaveBeenCalledWith("message-1", {
|
|
align: "start",
|
|
});
|
|
expect(mockDockviewState.clearScrollTarget).toHaveBeenCalledWith(7);
|
|
expect(mockDockviewState.scrollTarget).toBeNull();
|
|
});
|
|
|
|
it("does not consume when another panel owns the target (exact-owner gate)", () => {
|
|
mockDockviewState.scrollTarget = target({ hostPanelId: "panel-b" });
|
|
const messageListRef = { current: scrollHandle(true) };
|
|
|
|
renderHook(() => useScrollTargetConsumption({ ...PROPS, messageListRef }));
|
|
|
|
expect(messageListRef.current?.scrollToMessage).not.toHaveBeenCalled();
|
|
expect(mockDockviewState.clearScrollTarget).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not let a delayed consumer of request A clear or scroll request B", async () => {
|
|
// Task 03 supersession: B lands while A's deferred consumer is pending.
|
|
// A's stale consumer must not scroll A's row nor clear B; the token
|
|
// re-check in the deferred callback bails.
|
|
mockDockviewState.scrollTarget = target(); // A: token 7
|
|
const messageListRef = { current: scrollHandle(true) };
|
|
const { rerender } = renderHook(() => useScrollTargetConsumption({ ...PROPS, messageListRef }));
|
|
|
|
// B supersedes A before A's deferred frame executes.
|
|
mockDockviewState.scrollTarget = target({ messageId: "message-b", token: 8 });
|
|
await flushFrames();
|
|
|
|
expect(messageListRef.current?.scrollToMessage).not.toHaveBeenCalled();
|
|
expect(mockDockviewState.clearScrollTarget).not.toHaveBeenCalled();
|
|
expect(mockDockviewState.scrollTarget?.token).toBe(8);
|
|
|
|
// When the owner of B re-renders, B consumes normally.
|
|
rerender();
|
|
await flushFrames();
|
|
expect(messageListRef.current?.scrollToMessage).toHaveBeenCalledWith("message-b", {
|
|
align: "start",
|
|
});
|
|
expect(mockDockviewState.clearScrollTarget).toHaveBeenCalledWith(8);
|
|
expect(mockDockviewState.scrollTarget).toBeNull();
|
|
});
|
|
|
|
it("lets only the exact-owner host consume when a canonical chat and a session panel are both mounted and active", async () => {
|
|
// Task 03 race: `session:<id>` and the canonical `chat` host are mounted
|
|
// simultaneously (a real transient layout state), both reporting
|
|
// isVisible=true. Only the host whose panelId equals the target's
|
|
// hostPanelId may scroll and clear; the hidden canonical duplicate must
|
|
// never consume the target meant for the visible transcript.
|
|
mockDockviewState.scrollTarget = target();
|
|
const sessionHostRef = { current: scrollHandle(true) };
|
|
const canonicalHostRef = { current: scrollHandle(true) };
|
|
|
|
renderHook(() => useScrollTargetConsumption({ ...PROPS, messageListRef: sessionHostRef }));
|
|
renderHook(() =>
|
|
useScrollTargetConsumption({
|
|
...PROPS,
|
|
panelId: "chat",
|
|
messageListRef: canonicalHostRef,
|
|
}),
|
|
);
|
|
await flushFrames();
|
|
|
|
expect(sessionHostRef.current?.scrollToMessage).toHaveBeenCalledWith("message-1", {
|
|
align: "start",
|
|
});
|
|
expect(mockDockviewState.clearScrollTarget).toHaveBeenCalledWith(7);
|
|
expect(canonicalHostRef.current?.scrollToMessage).not.toHaveBeenCalled();
|
|
expect(mockDockviewState.clearScrollTargetForSession).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not consume when the target belongs to another session", () => {
|
|
mockDockviewState.scrollTarget = target({ sessionId: "session-2" });
|
|
const messageListRef = { current: scrollHandle(true) };
|
|
|
|
renderHook(() => useScrollTargetConsumption({ ...PROPS, messageListRef }));
|
|
|
|
expect(messageListRef.current?.scrollToMessage).not.toHaveBeenCalled();
|
|
expect(mockDockviewState.clearScrollTarget).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("usePendingMessageScroll — non-Dockview target loading", () => {
|
|
it("loads an absent target and scrolls it after the transcript merge", async () => {
|
|
const targetMessage = { id: "target" };
|
|
vi.mocked(loadMessageWindowAround).mockImplementationOnce(async () => {
|
|
mockAppStoreState.messages.bySession["session-1"] = [targetMessage];
|
|
return { kind: "merged", merged: true, current: true, targetFound: true };
|
|
});
|
|
const messageListRef = { current: scrollHandle(true) };
|
|
const onConsumed = vi.fn();
|
|
const { rerender } = renderHook(
|
|
({ readinessKey }) =>
|
|
usePendingMessageScroll({
|
|
messageListRef,
|
|
sessionId: "session-1",
|
|
messageId: "target",
|
|
onConsumed,
|
|
readinessKey,
|
|
isInitialMessagesLoading: false,
|
|
}),
|
|
{ initialProps: { readinessKey: "0" } },
|
|
);
|
|
|
|
// The first attempt sees no target row and starts one around request.
|
|
messageListRef.current = scrollHandle(false);
|
|
await flushFrames();
|
|
expect(loadMessageWindowAround).toHaveBeenCalledWith(
|
|
"session-1",
|
|
"target",
|
|
expect.any(Function),
|
|
expect.anything(),
|
|
);
|
|
|
|
// The merged row becomes renderable and the retained target is consumed.
|
|
messageListRef.current = scrollHandle(true);
|
|
rerender({ readinessKey: "1" });
|
|
await flushFrames();
|
|
expect(messageListRef.current?.scrollToMessage).toHaveBeenCalledWith("target", {
|
|
align: "start",
|
|
behavior: "auto",
|
|
});
|
|
expect(onConsumed).toHaveBeenCalledWith("target");
|
|
});
|
|
|
|
it("keeps a failed around request retryable instead of leaving a stuck target", async () => {
|
|
vi.mocked(loadMessageWindowAround).mockImplementation(async () => {
|
|
throw new Error("offline");
|
|
});
|
|
const messageListRef = { current: scrollHandle(false) };
|
|
const { result } = renderHook(() =>
|
|
usePendingMessageScroll({
|
|
messageListRef,
|
|
sessionId: "session-1",
|
|
messageId: "target",
|
|
onConsumed: undefined,
|
|
readinessKey: "0",
|
|
isInitialMessagesLoading: false,
|
|
}),
|
|
);
|
|
|
|
await flushFrames();
|
|
await waitFor(() => expect(result.current.hasError).toBe(true));
|
|
|
|
vi.mocked(loadMessageWindowAround).mockImplementationOnce(async () => {
|
|
mockAppStoreState.messages.bySession["session-1"] = [{ id: "target" }];
|
|
return { kind: "merged", merged: true, current: true, targetFound: true };
|
|
});
|
|
act(() => result.current.retry());
|
|
await flushFrames();
|
|
|
|
expect(result.current.hasError).toBe(false);
|
|
expect(loadMessageWindowAround).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("keeps the request guard valid when transcript readiness changes mid-request", async () => {
|
|
const pending = Promise.withResolvers<{
|
|
kind: "merged";
|
|
merged: true;
|
|
current: true;
|
|
targetFound: true;
|
|
}>();
|
|
vi.mocked(loadMessageWindowAround).mockReturnValueOnce(pending.promise);
|
|
const messageListRef = { current: scrollHandle(false) };
|
|
const { result, rerender } = renderHook(
|
|
({ readinessKey }) =>
|
|
usePendingMessageScroll({
|
|
messageListRef,
|
|
sessionId: "session-1",
|
|
messageId: "target",
|
|
onConsumed: undefined,
|
|
readinessKey,
|
|
isInitialMessagesLoading: false,
|
|
}),
|
|
{ initialProps: { readinessKey: "0" } },
|
|
);
|
|
|
|
await flushFrames();
|
|
rerender({ readinessKey: "1" });
|
|
await flushFrames();
|
|
expect(loadMessageWindowAround).toHaveBeenCalledTimes(1);
|
|
|
|
pending.reject(new Error("offline"));
|
|
await waitFor(() => expect(result.current.hasError).toBe(true));
|
|
});
|
|
});
|
|
|
|
describe("useScrollTargetConsumption — non-dockview hosts (no panelId)", () => {
|
|
it("does not consume without a panelId (non-dockview hosts stay unchanged)", () => {
|
|
mockDockviewState.scrollTarget = target();
|
|
const messageListRef = { current: scrollHandle(true) };
|
|
|
|
renderHook(() => useScrollTargetConsumption({ ...PROPS, panelId: null, messageListRef }));
|
|
|
|
expect(messageListRef.current?.scrollToMessage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not clear a dockview target when a non-dockview host unmounts", () => {
|
|
// Task 03: preview/center/mobile hosts (panelId=null) mount and unmount
|
|
// freely; their unmount cleanup must not clear a target a dockview host
|
|
// is waiting to consume.
|
|
mockDockviewState.scrollTarget = target();
|
|
const messageListRef = { current: scrollHandle(false) };
|
|
|
|
const { unmount } = renderHook(() =>
|
|
useScrollTargetConsumption({ ...PROPS, panelId: null, messageListRef }),
|
|
);
|
|
unmount();
|
|
|
|
expect(mockDockviewState.clearScrollTargetForSession).not.toHaveBeenCalled();
|
|
expect(mockDockviewState.scrollTarget).not.toBeNull();
|
|
});
|
|
|
|
it("does not clear a dockview target when a non-dockview host swaps sessions", () => {
|
|
mockDockviewState.scrollTarget = target();
|
|
const messageListRef = { current: scrollHandle(false) };
|
|
|
|
const { rerender } = renderHook(
|
|
({ resolvedSessionId }) =>
|
|
useScrollTargetConsumption({
|
|
...PROPS,
|
|
panelId: null,
|
|
resolvedSessionId,
|
|
messageListRef,
|
|
}),
|
|
{ initialProps: { resolvedSessionId: "session-1" } },
|
|
);
|
|
rerender({ resolvedSessionId: "session-2" });
|
|
|
|
expect(mockDockviewState.clearScrollTargetForSession).not.toHaveBeenCalled();
|
|
expect(mockDockviewState.scrollTarget).not.toBeNull();
|
|
});
|
|
|
|
it("lets the dockview owner consume the retained target after non-dockview churn", async () => {
|
|
mockDockviewState.scrollTarget = target();
|
|
const nonDockviewRef = { current: scrollHandle(false) };
|
|
const ownerRef = { current: scrollHandle(true) };
|
|
|
|
const { unmount } = renderHook(() =>
|
|
useScrollTargetConsumption({ ...PROPS, panelId: null, messageListRef: nonDockviewRef }),
|
|
);
|
|
unmount();
|
|
|
|
renderHook(() => useScrollTargetConsumption({ ...PROPS, messageListRef: ownerRef }));
|
|
await flushFrames();
|
|
|
|
expect(ownerRef.current?.scrollToMessage).toHaveBeenCalledWith("message-1", {
|
|
align: "start",
|
|
});
|
|
expect(mockDockviewState.clearScrollTarget).toHaveBeenCalledWith(7);
|
|
expect(mockDockviewState.scrollTarget).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("useScrollTargetConsumption — delayed-DOM retry", () => {
|
|
it("retains the target when the scroll no-ops and consumes once the list is ready", async () => {
|
|
mockDockviewState.scrollTarget = target();
|
|
const messageListRef = { current: scrollHandle(false) };
|
|
|
|
const { rerender } = renderHook(
|
|
({ isInitialMessagesLoading }) =>
|
|
useScrollTargetConsumption({
|
|
...PROPS,
|
|
isInitialMessagesLoading,
|
|
messageListRef,
|
|
}),
|
|
{ initialProps: { isInitialMessagesLoading: true } },
|
|
);
|
|
|
|
await flushFrames();
|
|
expect(messageListRef.current?.scrollToMessage).toHaveBeenCalled();
|
|
expect(mockDockviewState.clearScrollTarget).not.toHaveBeenCalled();
|
|
expect(mockDockviewState.scrollTarget).not.toBeNull();
|
|
|
|
// The message list renders the row; the readiness flip retries.
|
|
messageListRef.current = scrollHandle(true);
|
|
rerender({ isInitialMessagesLoading: false });
|
|
await flushFrames();
|
|
|
|
expect(mockDockviewState.clearScrollTarget).toHaveBeenCalledWith(7);
|
|
expect(mockDockviewState.scrollTarget).toBeNull();
|
|
});
|
|
|
|
it("retries when rendered message count changes after loading is complete", async () => {
|
|
mockDockviewState.scrollTarget = target();
|
|
const messageListRef = { current: scrollHandle(false) };
|
|
const { rerender } = renderHook(
|
|
({ renderedMessageCount }) =>
|
|
useScrollTargetConsumption({
|
|
...PROPS,
|
|
renderedMessageCount,
|
|
messageListRef,
|
|
}),
|
|
{ initialProps: { renderedMessageCount: 0 } },
|
|
);
|
|
|
|
await flushFrames();
|
|
expect(mockDockviewState.scrollTarget).not.toBeNull();
|
|
|
|
messageListRef.current = scrollHandle(true);
|
|
rerender({ renderedMessageCount: 1 });
|
|
await flushFrames();
|
|
|
|
expect(messageListRef.current?.scrollToMessage).toHaveBeenCalledWith("message-1", {
|
|
align: "start",
|
|
});
|
|
expect(mockDockviewState.clearScrollTarget).toHaveBeenCalledWith(7);
|
|
expect(mockDockviewState.scrollTarget).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("useScrollTargetConsumption — activation gating", () => {
|
|
it("retains the target while inactive and consumes on activation without clearing the session", async () => {
|
|
mockDockviewState.scrollTarget = target();
|
|
const messageListRef = { current: scrollHandle(true) };
|
|
|
|
const { rerender } = renderHook(
|
|
({ isVisible }) => useScrollTargetConsumption({ ...PROPS, isVisible, messageListRef }),
|
|
{ initialProps: { isVisible: false } },
|
|
);
|
|
|
|
expect(messageListRef.current?.scrollToMessage).not.toHaveBeenCalled();
|
|
expect(mockDockviewState.clearScrollTargetForSession).not.toHaveBeenCalled();
|
|
|
|
rerender({ isVisible: true });
|
|
await flushFrames();
|
|
|
|
expect(messageListRef.current?.scrollToMessage).toHaveBeenCalled();
|
|
expect(mockDockviewState.clearScrollTarget).toHaveBeenCalledWith(7);
|
|
expect(mockDockviewState.clearScrollTargetForSession).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("useScrollTargetConsumption — session-swap invalidation", () => {
|
|
it("clears a delayed target for the previous session when the canonical chat swaps sessions", () => {
|
|
mockDockviewState.scrollTarget = target();
|
|
const messageListRef = { current: scrollHandle(false) };
|
|
|
|
const { rerender } = renderHook(
|
|
({ resolvedSessionId }) =>
|
|
useScrollTargetConsumption({ ...PROPS, resolvedSessionId, messageListRef }),
|
|
{ initialProps: { resolvedSessionId: "session-1" } },
|
|
);
|
|
expect(mockDockviewState.scrollTarget).not.toBeNull();
|
|
|
|
// Canonical chat resolves session B: the A-target must be invalidated.
|
|
rerender({ resolvedSessionId: "session-2" });
|
|
|
|
expect(mockDockviewState.clearScrollTargetForOwner).toHaveBeenCalledWith(
|
|
"session-1",
|
|
"panel-a",
|
|
);
|
|
expect(mockDockviewState.scrollTarget).toBeNull();
|
|
|
|
// Returning to A must NOT re-scroll (the target was cleared).
|
|
messageListRef.current = scrollHandle(true);
|
|
rerender({ resolvedSessionId: "session-1" });
|
|
|
|
expect(messageListRef.current?.scrollToMessage).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("useScrollTargetConsumption — canonical-host unmount ownership", () => {
|
|
it("clears by the last resolved session and exact panel owner on unmount", () => {
|
|
mockDockviewState.scrollTarget = target();
|
|
const messageListRef = { current: scrollHandle(false) };
|
|
|
|
const { unmount } = renderHook(() => useScrollTargetConsumption({ ...PROPS, messageListRef }));
|
|
expect(mockDockviewState.scrollTarget).not.toBeNull();
|
|
|
|
unmount();
|
|
|
|
expect(mockDockviewState.clearScrollTargetForOwner).toHaveBeenCalledWith(
|
|
"session-1",
|
|
"panel-a",
|
|
);
|
|
expect(mockDockviewState.scrollTarget).toBeNull();
|
|
});
|
|
|
|
it("does not clear a target owned by another panel on unmount", () => {
|
|
mockDockviewState.scrollTarget = target({ hostPanelId: "panel-a" });
|
|
const messageListRef = { current: scrollHandle(false) };
|
|
|
|
const { unmount } = renderHook(() =>
|
|
useScrollTargetConsumption({ ...PROPS, panelId: "panel-b", messageListRef }),
|
|
);
|
|
unmount();
|
|
|
|
expect(mockDockviewState.clearScrollTargetForOwner).toHaveBeenCalledWith(
|
|
"session-1",
|
|
"panel-b",
|
|
);
|
|
expect(mockDockviewState.scrollTarget).not.toBeNull();
|
|
});
|
|
});
|