murashit_codex-panel/tests/features/chat/ui/thread-stream/flow-scroll.test.ts
2026-07-09 07:14:06 +09:00

537 lines
19 KiB
TypeScript

// @vitest-environment jsdom
import { h, type ComponentChild as UiNode } from "preact";
import { act } from "preact/test-utils";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { THREAD_STREAM_CONTENT_RENDERED_EVENT } from "../../../../../src/features/chat/ui/thread-stream/content-rendered-event.dom";
import {
ThreadStreamFlowFrame,
type ThreadStreamScrollCommand,
type ThreadStreamScrollPort,
type ThreadStreamScrollPortBinding,
} from "../../../../../src/features/chat/ui/thread-stream/flow-scroll.measure";
import { renderUiRoot } from "../../../../../src/shared/dom/preact-root.dom";
import { installObsidianDomShims } from "../../../../support/dom";
installObsidianDomShims();
describe("thread stream flow scrolling", () => {
beforeEach(() => {
resizeObserverCallbacks = [];
animationFrameCallbacks = [];
window.ResizeObserver = TestResizeObserver;
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
animationFrameCallbacks.push(callback);
return animationFrameCallbacks.length;
}) as typeof window.requestAnimationFrame;
window.cancelAnimationFrame = (() => undefined) as typeof window.cancelAnimationFrame;
window.matchMedia = createTestMatchMedia(false);
});
afterEach(() => {
document.body.replaceChildren();
});
it("pins to the DOM scroll end and follows appended content while pinned", () => {
const { controller, scrollViewport, render } = renderFlowThreadStream(["first"], { first: 300 });
const scrollCalls = installScrollToCapture(scrollViewport);
void act(() => {
controller.dispatch({ kind: "show-latest" });
});
expect(scrollViewport.scrollTop).toBe(200);
expect(scrollCalls).toEqual([]);
render(["first", "second"], { first: 300, second: 180 });
expect(scrollViewport.scrollTop).toBe(380);
expect(scrollCalls).toEqual([]);
});
it("keeps the current reading position when content is appended after the user scrolls away", () => {
const { controller, scrollViewport, render } = renderFlowThreadStream(["first"], { first: 300 });
void act(() => {
controller.dispatch({ kind: "show-latest" });
});
scrollViewport.scrollTop = 80;
scrollViewport.dispatchEvent(new Event("scroll"));
render(["first", "second"], { first: 300, second: 180 });
expect(scrollViewport.scrollTop).toBe(80);
});
it("preserves the visible block when older history is prepended", () => {
const { scrollViewport, render } = renderFlowThreadStream(["first", "second"], { first: 300, second: 300 });
scrollViewport.scrollTop = 320;
scrollViewport.dispatchEvent(new Event("scroll"));
render(["older", "first", "second"], { older: 180, first: 300, second: 300 });
expect(scrollViewport.scrollTop).toBe(500);
});
it("preserves the visible block when older history is inserted after the history bar", () => {
const { scrollViewport, render } = renderFlowThreadStream(["history-bar", "current", "next"], {
"history-bar": 40,
current: 300,
next: 300,
});
scrollViewport.scrollTop = 80;
scrollViewport.dispatchEvent(new Event("scroll"));
render(["history-bar", "older", "current", "next"], {
"history-bar": 40,
older: 180,
current: 300,
next: 300,
});
expect(scrollViewport.scrollTop).toBe(260);
});
it("does not force the end when delayed content changes while the user is reading", () => {
const { scrollViewport, blockElement, setHeights } = renderFlowThreadStream(["first", "second", "third"], {
first: 300,
second: 300,
third: 300,
});
scrollViewport.scrollTop = 360;
scrollViewport.dispatchEvent(new Event("scroll"));
setHeights({ first: 500, second: 300, third: 300 });
void act(() => {
blockElement("first").dispatchEvent(new Event(THREAD_STREAM_CONTENT_RENDERED_EVENT, { bubbles: true }));
});
expect(scrollViewport.scrollTop).toBe(360);
});
it("keeps hidden thread streams pinned when the viewport size returns", () => {
const restoreInitialMetrics = installThreadStreamViewportPrototypeMetrics({ scrollHeight: 600, viewport: { width: 0, height: 0 } });
const { scrollViewport, resizeViewport } = renderFlowThreadStream(
["first", "second"],
{ first: 300, second: 300 },
{
viewport: { width: 0, height: 0 },
},
);
restoreInitialMetrics();
expect(scrollViewport.scrollTop).toBe(600);
resizeViewport({ width: 240, height: 100 });
triggerResizeObserver();
flushAnimationFrame();
expect(scrollViewport.scrollTop).toBe(500);
});
it("does not double-adjust when the browser anchors a prepended block before effects run", () => {
let nativeAnchoringApplied = false;
const { scrollViewport, render } = renderFlowThreadStream(
["first", "second"],
{ first: 300, second: 300 },
{
blockNode(key) {
if (key !== "older") return h("div", null, key);
return h("div", {
ref(element: HTMLDivElement | null) {
if (!element || nativeAnchoringApplied) return;
nativeAnchoringApplied = true;
const viewport = element.closest<HTMLElement>(".codex-panel__thread-stream");
if (viewport) viewport.scrollTop += 180;
},
});
},
},
);
scrollViewport.scrollTop = 320;
scrollViewport.dispatchEvent(new Event("scroll"));
render(["older", "first", "second"], { older: 180, first: 300, second: 300 });
expect(scrollViewport.scrollTop).toBe(500);
});
it("returns to the DOM scroll end after delayed stream content renders while pinned", () => {
const { controller, scrollViewport, blockElement, setHeights } = renderFlowThreadStream(["item"], { item: 300 });
void act(() => {
controller.dispatch({ kind: "show-latest" });
});
expect(scrollViewport.scrollTop).toBe(200);
setHeights({ item: 420 });
void act(() => {
blockElement("item").dispatchEvent(new Event(THREAD_STREAM_CONTENT_RENDERED_EVENT, { bubbles: true }));
});
expect(scrollViewport.scrollTop).toBe(320);
});
it("scrolls by composer text-line and page commands", () => {
const { controller, scrollViewport } = renderFlowThreadStream(["first", "second"], { first: 300, second: 300 });
const scrollCalls = installScrollToCapture(scrollViewport);
scrollViewport.style.lineHeight = "20px";
scrollViewport.scrollTop = 240;
scrollViewport.dispatchEvent(new Event("scroll"));
void act(() => {
controller.dispatch({ kind: "scroll-by", amount: "text-lines", direction: -1 });
});
expect(scrollViewport.scrollTop).toBe(160);
expect(scrollCalls).toEqual([{ top: 160, behavior: "smooth" }]);
void act(() => {
controller.dispatch({ kind: "scroll-by", amount: "page", direction: 1 });
});
expect(scrollViewport.scrollTop).toBe(240);
expect(scrollCalls).toEqual([
{ top: 160, behavior: "smooth" },
{ top: 240, behavior: "smooth" },
]);
});
it("uses instant composer scrolling when reduced motion is preferred", () => {
window.matchMedia = createTestMatchMedia(true);
const { controller, scrollViewport } = renderFlowThreadStream(["first", "second"], { first: 300, second: 300 });
const scrollCalls = installScrollToCapture(scrollViewport);
scrollViewport.style.lineHeight = "20px";
scrollViewport.scrollTop = 240;
scrollViewport.dispatchEvent(new Event("scroll"));
void act(() => {
controller.dispatch({ kind: "scroll-by", amount: "text-lines", direction: -1 });
});
expect(scrollViewport.scrollTop).toBe(160);
expect(scrollCalls).toEqual([]);
});
it("uses the normal text-line distance for repeated composer scrolling without smooth animation", () => {
const { controller, scrollViewport } = renderFlowThreadStream(["first", "second"], { first: 300, second: 300 });
const scrollCalls = installScrollToCapture(scrollViewport);
scrollViewport.style.lineHeight = "20px";
scrollViewport.scrollTop = 240;
scrollViewport.dispatchEvent(new Event("scroll"));
void act(() => {
controller.dispatch({ kind: "scroll-by", amount: "text-lines", direction: -1, repeated: true });
});
expect(scrollViewport.scrollTop).toBe(160);
expect(scrollCalls).toEqual([]);
});
it("uses the normal page distance for repeated composer page scrolling without smooth animation", () => {
const { controller, scrollViewport } = renderFlowThreadStream(["first", "second"], { first: 300, second: 300 });
const scrollCalls = installScrollToCapture(scrollViewport);
scrollViewport.scrollTop = 240;
scrollViewport.dispatchEvent(new Event("scroll"));
void act(() => {
controller.dispatch({ kind: "scroll-by", amount: "page", direction: 1, repeated: true });
});
expect(scrollViewport.scrollTop).toBe(320);
expect(scrollCalls).toEqual([]);
});
it("scrolls to stream edges from composer commands", () => {
const { controller, scrollViewport } = renderFlowThreadStream(["first", "second"], { first: 300, second: 300 });
const scrollCalls = installScrollToCapture(scrollViewport);
scrollViewport.scrollTop = 240;
scrollViewport.dispatchEvent(new Event("scroll"));
void act(() => {
controller.dispatch({ kind: "scroll-to", edge: "start" });
});
expect(scrollViewport.scrollTop).toBe(0);
expect(scrollCalls).toEqual([{ top: 0, behavior: "smooth" }]);
void act(() => {
controller.dispatch({ kind: "scroll-to", edge: "end" });
});
expect(scrollViewport.scrollTop).toBe(500);
expect(scrollCalls).toEqual([
{ top: 0, behavior: "smooth" },
{ top: 500, behavior: "smooth" },
]);
});
});
interface CapturedScrollToOptions {
top: number | undefined;
behavior: ScrollBehavior | undefined;
}
interface TestThreadStreamScrollPortBinding extends ThreadStreamScrollPortBinding {
dispatch(command: ThreadStreamScrollCommand): void;
}
function createTestThreadStreamScrollPortBinding(): TestThreadStreamScrollPortBinding {
let port: ThreadStreamScrollPort | null = null;
return {
mountScrollPort(nextPort) {
port = nextPort;
return () => {
if (port === nextPort) port = null;
};
},
dispatch(command) {
port?.dispatchScrollCommand(command);
},
};
}
function renderFlowThreadStream(
keys: readonly string[],
heights: Record<string, number>,
options: {
blockNode?: (key: string) => UiNode;
viewport?: { width: number; height: number };
} = {},
): {
controller: TestThreadStreamScrollPortBinding;
scrollViewport: HTMLElement;
render: (nextKeys: readonly string[], nextHeights: Record<string, number>) => void;
setHeights: (nextHeights: Record<string, number>) => void;
resizeViewport: (nextViewport: { width: number; height: number }) => void;
blockElement: (key: string) => HTMLElement;
} {
const parent = document.createElement("div");
document.body.append(parent);
const controller = createTestThreadStreamScrollPortBinding();
let currentHeights = heights;
let currentKeys = keys;
let viewport = options.viewport ?? { width: 240, height: 100 };
const render = (nextKeys: readonly string[], nextHeights: Record<string, number>) => {
currentKeys = nextKeys;
currentHeights = nextHeights;
void act(() => {
renderUiRoot(
parent,
h(ThreadStreamFlowFrame, {
blocks: nextKeys.map((key) => ({ key })),
scrollPortBinding: controller,
renderBlockContent: (block) => options.blockNode?.(block.key) ?? h("div", null, block.key),
}),
);
});
};
render(keys, heights);
const scrollViewport = threadStreamViewport(parent);
installFlowMetrics(
scrollViewport,
() => currentKeys,
() => currentHeights,
() => viewport,
);
return {
controller,
scrollViewport,
render,
setHeights(nextHeights) {
currentHeights = nextHeights;
},
resizeViewport(nextViewport) {
viewport = nextViewport;
},
blockElement(key) {
const element = parent.querySelector<HTMLElement>(`[data-codex-panel-block-key="${key}"]`);
if (!element) throw new Error(`Missing block ${key}`);
return element;
},
};
}
function threadStreamViewport(parent: HTMLElement): HTMLElement {
const element = parent.querySelector<HTMLElement>(".codex-panel__thread-stream");
if (!element) throw new Error("Expected thread stream viewport.");
return element;
}
function installFlowMetrics(
scrollViewport: HTMLElement,
keys: () => readonly string[],
heights: () => Record<string, number>,
viewport: () => { width: number; height: number },
): void {
const initialScrollTop = scrollViewport.scrollTop;
Object.defineProperties(scrollViewport, {
clientHeight: { get: () => viewport().height, configurable: true },
offsetHeight: { get: () => viewport().height, configurable: true },
clientWidth: { get: () => viewport().width, configurable: true },
offsetWidth: { get: () => viewport().width, configurable: true },
scrollHeight: {
get: () => keys().reduce((total, key) => total + (heights()[key] ?? 0), 0),
configurable: true,
},
});
Object.defineProperty(scrollViewport, "scrollTop", {
get: () => viewportScrollTop.get(scrollViewport) ?? 0,
set: (value: number) => {
const max = Math.max(0, scrollViewport.scrollHeight - scrollViewport.clientHeight);
viewportScrollTop.set(scrollViewport, Math.max(0, Math.min(value, max)));
},
configurable: true,
});
scrollViewport.scrollTop = initialScrollTop;
scrollViewport.getBoundingClientRect = () => blockRect(0, viewport().height);
Array.from(scrollViewport.querySelectorAll<HTMLElement>(".codex-panel__thread-stream-block")).forEach((element) => {
installBlockRect(element, scrollViewport, heights);
});
}
function installScrollToCapture(scrollViewport: HTMLElement): CapturedScrollToOptions[] {
const calls: CapturedScrollToOptions[] = [];
scrollViewport.scrollTo = ((optionsOrX?: ScrollToOptions | number, y?: number) => {
const top = typeof optionsOrX === "number" ? y : optionsOrX?.top;
const behavior = typeof optionsOrX === "number" ? undefined : optionsOrX?.behavior;
calls.push({ top, behavior });
if (top !== undefined) scrollViewport.scrollTop = top;
}) as typeof scrollViewport.scrollTo;
return calls;
}
const viewportScrollTop = new WeakMap<HTMLElement, number>();
let resizeObserverCallbacks: ResizeObserverCallback[] = [];
let animationFrameCallbacks: FrameRequestCallback[] = [];
function createTestMatchMedia(matches: boolean): typeof window.matchMedia {
return ((query: string) => ({
matches,
media: query,
onchange: null,
addEventListener: () => undefined,
removeEventListener: () => undefined,
addListener: () => undefined,
removeListener: () => undefined,
dispatchEvent: () => false,
})) as typeof window.matchMedia;
}
class TestResizeObserver implements ResizeObserver {
private readonly callback: ResizeObserverCallback;
constructor(callback: ResizeObserverCallback) {
this.callback = callback;
resizeObserverCallbacks.push(callback);
}
observe(): void {
// Test callback triggering is explicit.
}
unobserve(): void {
// Test callback triggering is explicit.
}
disconnect(): void {
resizeObserverCallbacks = resizeObserverCallbacks.filter((callback) => callback !== this.callback);
}
}
function triggerResizeObserver(): void {
void act(() => {
for (const callback of resizeObserverCallbacks) callback([], {} as ResizeObserver);
});
}
function flushAnimationFrame(): void {
const callbacks = animationFrameCallbacks.splice(0);
void act(() => {
for (const callback of callbacks) callback(0);
});
}
function installThreadStreamViewportPrototypeMetrics(metrics: {
scrollHeight: number;
viewport: { width: number; height: number };
}): () => void {
const scrollHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollHeight");
const clientHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientHeight");
const clientWidthDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientWidth");
const offsetHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetHeight");
const offsetWidthDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetWidth");
Object.defineProperties(HTMLElement.prototype, {
scrollHeight: {
get() {
return this instanceof HTMLElement && this.classList.contains("codex-panel__thread-stream") ? metrics.scrollHeight : 0;
},
configurable: true,
},
clientHeight: {
get() {
return this instanceof HTMLElement && this.classList.contains("codex-panel__thread-stream") ? metrics.viewport.height : 0;
},
configurable: true,
},
clientWidth: {
get() {
return this instanceof HTMLElement && this.classList.contains("codex-panel__thread-stream") ? metrics.viewport.width : 0;
},
configurable: true,
},
offsetHeight: {
get() {
return this instanceof HTMLElement && this.classList.contains("codex-panel__thread-stream") ? metrics.viewport.height : 0;
},
configurable: true,
},
offsetWidth: {
get() {
return this instanceof HTMLElement && this.classList.contains("codex-panel__thread-stream") ? metrics.viewport.width : 0;
},
configurable: true,
},
});
return () => {
restorePrototypeProperty(HTMLElement.prototype, "scrollHeight", scrollHeightDescriptor);
restorePrototypeProperty(HTMLElement.prototype, "clientHeight", clientHeightDescriptor);
restorePrototypeProperty(HTMLElement.prototype, "clientWidth", clientWidthDescriptor);
restorePrototypeProperty(HTMLElement.prototype, "offsetHeight", offsetHeightDescriptor);
restorePrototypeProperty(HTMLElement.prototype, "offsetWidth", offsetWidthDescriptor);
};
}
function restorePrototypeProperty(object: object, key: PropertyKey, descriptor: PropertyDescriptor | undefined): void {
if (descriptor) {
Object.defineProperty(object, key, descriptor);
} else {
Reflect.deleteProperty(object, key);
}
}
function installBlockRect(element: HTMLElement, scrollViewport: HTMLElement, heights: () => Record<string, number>): void {
element.getBoundingClientRect = () => {
const key = element.dataset["codexPanelBlockKey"] ?? "";
const siblings = Array.from(scrollViewport.querySelectorAll<HTMLElement>(".codex-panel__thread-stream-block"));
const index = siblings.indexOf(element);
const top = siblings
.slice(0, Math.max(0, index))
.reduce((total, candidate) => total + (heights()[candidate.dataset["codexPanelBlockKey"] ?? ""] ?? 0), -scrollViewport.scrollTop);
return blockRect(top, heights()[key] ?? 0);
};
}
function blockRect(top: number, height: number): DOMRect {
return {
x: 0,
y: top,
top,
right: 240,
bottom: top + height,
left: 0,
width: 240,
height,
toJSON: () => ({}),
};
}