Files
kandev/apps/web/components/task/chat/transcript-auto-scroll.test.ts
T
2026-08-04 22:15:32 +01:00

371 lines
10 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
hasTranscriptProgressedPastView,
hasTranscriptAppendedSinceBaseline,
shouldCatchUpOnAutoScrollEnable,
resolveNativeInitialScrollTop,
isPrependUpdate,
createFrameCoalescer,
} from "./transcript-auto-scroll";
describe("isPrependUpdate", () => {
it("is a prepend when item count grows and the first item's key changed", () => {
expect(
isPrependUpdate({
prevItemCount: 20,
nextItemCount: 40,
prevFirstKey: "msg-20",
nextFirstKey: "msg-0",
}),
).toBe(true);
});
it("is NOT a prepend when item count grows but the first item's key is unchanged (append)", () => {
expect(
isPrependUpdate({
prevItemCount: 20,
nextItemCount: 21,
prevFirstKey: "msg-0",
nextFirstKey: "msg-0",
}),
).toBe(false);
});
it("is not a prepend when item count did not grow", () => {
expect(
isPrependUpdate({
prevItemCount: 20,
nextItemCount: 20,
prevFirstKey: "msg-0",
nextFirstKey: "msg-5",
}),
).toBe(false);
});
it("is not a prepend when there is no first item yet", () => {
expect(
isPrependUpdate({
prevItemCount: 0,
nextItemCount: 1,
prevFirstKey: null,
nextFirstKey: null,
}),
).toBe(false);
});
it("is NOT a prepend for the empty-to-first-message transition (initial population, not a prepend)", () => {
expect(
isPrependUpdate({
prevItemCount: 0,
nextItemCount: 1,
prevFirstKey: null,
nextFirstKey: "msg-0",
}),
).toBe(false);
});
});
describe("hasTranscriptProgressedPastView", () => {
it("is false when the viewport bottom already matches the content bottom", () => {
expect(
hasTranscriptProgressedPastView({ scrollTop: 800, scrollHeight: 900, clientHeight: 100 }),
).toBe(false);
});
it("is false within the sub-pixel settle tolerance", () => {
expect(
hasTranscriptProgressedPastView({ scrollTop: 799, scrollHeight: 900, clientHeight: 100 }),
).toBe(false);
});
it("is true once content extends meaningfully past the current viewport", () => {
expect(
hasTranscriptProgressedPastView({ scrollTop: 500, scrollHeight: 900, clientHeight: 100 }),
).toBe(true);
});
});
const BASELINE_UPDATED_AT = "2026-01-01T00:00:00Z";
const baseline = {
baselineCount: 20,
baselineLastId: "msg-19",
baselineLastUpdatedAt: BASELINE_UPDATED_AT,
};
describe("hasTranscriptAppendedSinceBaseline", () => {
it("is true when a new row was appended (count grew and the last id changed)", () => {
expect(
hasTranscriptAppendedSinceBaseline({
...baseline,
currentCount: 21,
currentLastId: "msg-20",
currentLastUpdatedAt: "2026-01-01T00:00:05Z",
}),
).toBe(true);
});
it("is true when the same last row streamed new content (id unchanged, updated_at advanced)", () => {
expect(
hasTranscriptAppendedSinceBaseline({
...baseline,
currentCount: 20,
currentLastId: "msg-19",
currentLastUpdatedAt: "2026-01-01T00:00:05Z",
}),
).toBe(true);
});
it("is false for a prepend-only load (count grew but the last id and its updated_at are unchanged)", () => {
expect(
hasTranscriptAppendedSinceBaseline({
...baseline,
currentCount: 40,
currentLastId: "msg-19",
currentLastUpdatedAt: BASELINE_UPDATED_AT,
}),
).toBe(false);
});
it("is false when nothing changed", () => {
expect(
hasTranscriptAppendedSinceBaseline({
...baseline,
currentCount: 20,
currentLastId: "msg-19",
currentLastUpdatedAt: BASELINE_UPDATED_AT,
}),
).toBe(false);
});
it("does not treat a missing current updated_at as a change from a defined baseline", () => {
expect(
hasTranscriptAppendedSinceBaseline({
...baseline,
currentCount: 20,
currentLastId: "msg-19",
currentLastUpdatedAt: undefined,
}),
).toBe(false);
});
it("is false when there is no current last id yet", () => {
expect(
hasTranscriptAppendedSinceBaseline({
baselineCount: 0,
baselineLastId: null,
baselineLastUpdatedAt: undefined,
currentCount: 0,
currentLastId: null,
currentLastUpdatedAt: undefined,
}),
).toBe(false);
});
it("is true for the very first message appearing", () => {
expect(
hasTranscriptAppendedSinceBaseline({
baselineCount: 0,
baselineLastId: null,
baselineLastUpdatedAt: undefined,
currentCount: 1,
currentLastId: "msg-0",
currentLastUpdatedAt: BASELINE_UPDATED_AT,
}),
).toBe(true);
});
});
describe("shouldCatchUpOnAutoScrollEnable", () => {
it("catches up when content was appended while disabled and it is not yet in view", () => {
expect(
shouldCatchUpOnAutoScrollEnable({
wasEnabled: false,
nowEnabled: true,
appendedSinceDisable: true,
isAtBottom: false,
}),
).toBe(true);
});
it("does NOT catch up when nothing genuinely progressed while disabled — neither pre-existing unseen history nor the user scrolling further away themselves counts (appendedSinceDisable is derived purely from message identity/updated_at, so neither can flip it)", () => {
expect(
shouldCatchUpOnAutoScrollEnable({
wasEnabled: false,
nowEnabled: true,
appendedSinceDisable: false,
isAtBottom: false,
}),
).toBe(false);
});
it("does not catch up when content appended but the user already scrolled down to see it", () => {
expect(
shouldCatchUpOnAutoScrollEnable({
wasEnabled: false,
nowEnabled: true,
appendedSinceDisable: true,
isAtBottom: true,
}),
).toBe(false);
});
it("does nothing when flipping from enabled to disabled", () => {
expect(
shouldCatchUpOnAutoScrollEnable({
wasEnabled: true,
nowEnabled: false,
appendedSinceDisable: true,
isAtBottom: false,
}),
).toBe(false);
});
it("does nothing when the enabled state is unchanged", () => {
expect(
shouldCatchUpOnAutoScrollEnable({
wasEnabled: true,
nowEnabled: true,
appendedSinceDisable: true,
isAtBottom: false,
}),
).toBe(false);
expect(
shouldCatchUpOnAutoScrollEnable({
wasEnabled: false,
nowEnabled: false,
appendedSinceDisable: true,
isAtBottom: false,
}),
).toBe(false);
});
});
describe("resolveNativeInitialScrollTop", () => {
it("defers to a pending dockview layout restore regardless of the toggle", () => {
expect(
resolveNativeInitialScrollTop({
enabled: false,
hasPendingLayoutRestore: true,
savedScrollTop: 42,
scrollHeight: 900,
}),
).toBeNull();
});
it("restores the saved offset when disabled", () => {
expect(
resolveNativeInitialScrollTop({
enabled: false,
hasPendingLayoutRestore: false,
savedScrollTop: 250,
scrollHeight: 900,
}),
).toBe(250);
});
it("falls back to the bottom when disabled but nothing was ever captured", () => {
expect(
resolveNativeInitialScrollTop({
enabled: false,
hasPendingLayoutRestore: false,
savedScrollTop: undefined,
scrollHeight: 900,
}),
).toBe(900);
});
it("scrolls to the bottom when enabled, ignoring any saved offset", () => {
expect(
resolveNativeInitialScrollTop({
enabled: true,
hasPendingLayoutRestore: false,
savedScrollTop: 250,
scrollHeight: 900,
}),
).toBe(900);
});
});
describe("createFrameCoalescer", () => {
let frameCallbacks: Map<number, () => void>;
let nextFrameId: number;
let pendingOrder: number[];
beforeEach(() => {
frameCallbacks = new Map();
pendingOrder = [];
nextFrameId = 1;
vi.stubGlobal("requestAnimationFrame", (cb: () => void) => {
const id = nextFrameId++;
frameCallbacks.set(id, cb);
pendingOrder.push(id);
return id;
});
vi.stubGlobal("cancelAnimationFrame", (id: number) => {
frameCallbacks.delete(id);
pendingOrder = pendingOrder.filter((pendingId) => pendingId !== id);
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
function runPendingFrame() {
const id = pendingOrder.shift();
if (id === undefined) return;
const cb = frameCallbacks.get(id);
frameCallbacks.delete(id);
cb?.();
}
it("coalesces multiple schedule() calls within a frame into a single run() call", () => {
const run = vi.fn();
const coalescer = createFrameCoalescer(run);
coalescer.schedule();
coalescer.schedule();
coalescer.schedule();
expect(run).not.toHaveBeenCalled();
runPendingFrame();
expect(run).toHaveBeenCalledTimes(1);
});
it("schedules a new frame after the previous one has fired", () => {
const run = vi.fn();
const coalescer = createFrameCoalescer(run);
coalescer.schedule();
runPendingFrame();
coalescer.schedule();
runPendingFrame();
expect(run).toHaveBeenCalledTimes(2);
});
it("flush() runs immediately and cancels a pending frame so it does not also fire", () => {
const run = vi.fn();
const coalescer = createFrameCoalescer(run);
coalescer.schedule();
coalescer.flush();
expect(run).toHaveBeenCalledTimes(1);
runPendingFrame();
expect(run).toHaveBeenCalledTimes(1);
});
it("flush() with no pending frame still runs once", () => {
const run = vi.fn();
const coalescer = createFrameCoalescer(run);
coalescer.flush();
expect(run).toHaveBeenCalledTimes(1);
});
it("schedule() after a flush() can schedule again", () => {
const run = vi.fn();
const coalescer = createFrameCoalescer(run);
coalescer.schedule();
coalescer.flush();
coalescer.schedule();
runPendingFrame();
expect(run).toHaveBeenCalledTimes(2);
});
});