689 lines
20 KiB
TypeScript
689 lines
20 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import {
|
|
__private__,
|
|
deriveBottomAnchorBlockedReason,
|
|
type BottomAnchorMode,
|
|
} from "./use-bottom-anchor-controller";
|
|
import type { BottomAnchorTransportBehavior } from "./agent-stream-render-strategy";
|
|
|
|
type MeasurementState = ReturnType<typeof createMeasurementState>;
|
|
|
|
function createMeasurementState(
|
|
overrides?: Partial<{
|
|
containerKey: string;
|
|
viewportWidth: number;
|
|
viewportHeight: number;
|
|
contentHeight: number;
|
|
offsetY: number;
|
|
viewportMeasuredForKey: string | null;
|
|
contentMeasuredForKey: string | null;
|
|
}>,
|
|
) {
|
|
return {
|
|
containerKey: "scroll-view",
|
|
viewportWidth: 0,
|
|
viewportHeight: 0,
|
|
contentHeight: 0,
|
|
offsetY: 0,
|
|
viewportMeasuredForKey: null,
|
|
contentMeasuredForKey: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function createPendingRequest() {
|
|
return {
|
|
id: 1,
|
|
agentId: "agent-1",
|
|
reason: "initial-entry" as const,
|
|
requestKey: "route:agent-1",
|
|
};
|
|
}
|
|
|
|
function createFrameScheduler() {
|
|
let sequence = 0;
|
|
const tasks = new Map<
|
|
number,
|
|
{
|
|
cancelled: boolean;
|
|
remainingFrames: number;
|
|
callback: () => void;
|
|
kind: "attempt" | "verification";
|
|
}
|
|
>();
|
|
|
|
return {
|
|
schedule(params: {
|
|
kind: "attempt" | "verification";
|
|
callback: () => void;
|
|
delayFrames?: number;
|
|
}) {
|
|
const id = ++sequence;
|
|
tasks.set(id, {
|
|
cancelled: false,
|
|
remainingFrames: Math.max(0, params.delayFrames ?? 0),
|
|
callback: params.callback,
|
|
kind: params.kind,
|
|
});
|
|
return id;
|
|
},
|
|
cancel(handle: unknown) {
|
|
const task = tasks.get(handle as number);
|
|
if (task) {
|
|
task.cancelled = true;
|
|
}
|
|
},
|
|
flushFrame() {
|
|
const due: Array<() => void> = [];
|
|
for (const [id, task] of Array.from(tasks.entries())) {
|
|
if (task.cancelled) {
|
|
tasks.delete(id);
|
|
continue;
|
|
}
|
|
if (task.remainingFrames > 0) {
|
|
task.remainingFrames -= 1;
|
|
continue;
|
|
}
|
|
tasks.delete(id);
|
|
due.push(task.callback);
|
|
}
|
|
for (const callback of due) {
|
|
callback();
|
|
}
|
|
},
|
|
flushAll(limit = 20) {
|
|
for (let index = 0; index < limit && tasks.size > 0; index += 1) {
|
|
this.flushFrame();
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
function createDriverHarness(input?: {
|
|
transportBehavior?: BottomAnchorTransportBehavior;
|
|
isNearBottom?: boolean;
|
|
measurementState?: MeasurementState;
|
|
authoritativeReady?: boolean;
|
|
}) {
|
|
const scheduler = createFrameScheduler();
|
|
const measurementState =
|
|
input?.measurementState ??
|
|
createMeasurementState({
|
|
viewportWidth: 800,
|
|
viewportHeight: 480,
|
|
contentHeight: 1200,
|
|
viewportMeasuredForKey: "scroll-view",
|
|
contentMeasuredForKey: "scroll-view",
|
|
});
|
|
const context = {
|
|
agentId: "agent-1",
|
|
authoritativeReady: input?.authoritativeReady ?? true,
|
|
renderStrategy: "forward-stream",
|
|
transportBehavior: input?.transportBehavior ?? {
|
|
verificationDelayFrames: 0,
|
|
verificationRetryMode: "rescroll",
|
|
},
|
|
measurementState,
|
|
nearBottom: input?.isNearBottom ?? true,
|
|
};
|
|
const scrollToBottom = vi.fn(() => {
|
|
context.nearBottom = true;
|
|
context.measurementState.offsetY = 720;
|
|
});
|
|
const modeChanges: BottomAnchorMode[] = [];
|
|
const driver = __private__.createBottomAnchorControllerDriver({
|
|
getAgentId: () => context.agentId,
|
|
getIsAuthoritativeHistoryReady: () => context.authoritativeReady,
|
|
getRenderStrategy: () => context.renderStrategy,
|
|
getTransportBehavior: () => context.transportBehavior,
|
|
getMeasurementState: () => context.measurementState,
|
|
isNearBottom: () => context.nearBottom,
|
|
scrollToBottom,
|
|
onModeChange: (mode) => {
|
|
modeChanges.push(mode);
|
|
},
|
|
scheduleFrame: (params) => scheduler.schedule(params),
|
|
cancelFrame: (handle) => scheduler.cancel(handle),
|
|
});
|
|
|
|
return {
|
|
context,
|
|
driver,
|
|
scheduler,
|
|
scrollToBottom,
|
|
modeChanges,
|
|
};
|
|
}
|
|
|
|
describe("deriveBottomAnchorBlockedReason", () => {
|
|
it("keeps initial-entry pending until history is ready and geometry is measurable", () => {
|
|
const pendingRequest = createPendingRequest();
|
|
|
|
expect(
|
|
deriveBottomAnchorBlockedReason({
|
|
pendingRequest,
|
|
isAuthoritativeHistoryReady: false,
|
|
measurementState: createMeasurementState(),
|
|
pendingVerificationRequestId: null,
|
|
}),
|
|
).toBe("waiting_for_history_readiness");
|
|
|
|
expect(
|
|
deriveBottomAnchorBlockedReason({
|
|
pendingRequest,
|
|
isAuthoritativeHistoryReady: true,
|
|
measurementState: createMeasurementState({
|
|
viewportHeight: 480,
|
|
viewportMeasuredForKey: "scroll-view",
|
|
}),
|
|
pendingVerificationRequestId: null,
|
|
}),
|
|
).toBe("waiting_for_measurable_content");
|
|
|
|
expect(
|
|
deriveBottomAnchorBlockedReason({
|
|
pendingRequest,
|
|
isAuthoritativeHistoryReady: true,
|
|
measurementState: createMeasurementState({
|
|
viewportHeight: 480,
|
|
contentHeight: 1200,
|
|
viewportMeasuredForKey: "scroll-view",
|
|
contentMeasuredForKey: "scroll-view",
|
|
}),
|
|
pendingVerificationRequestId: pendingRequest.id,
|
|
}),
|
|
).toBe("waiting_for_post_layout_verification");
|
|
});
|
|
});
|
|
|
|
describe("bottom anchor controller driver", () => {
|
|
it("keeps initial-entry pending until authoritative history and current geometry exist", () => {
|
|
const harness = createDriverHarness({
|
|
authoritativeReady: false,
|
|
measurementState: createMeasurementState(),
|
|
});
|
|
|
|
harness.driver.applyRouteRequest({
|
|
agentId: "agent-1",
|
|
reason: "initial-entry",
|
|
requestKey: "route:agent-1:initial-entry",
|
|
});
|
|
harness.scheduler.flushAll();
|
|
|
|
expect(harness.scrollToBottom).not.toHaveBeenCalled();
|
|
expect(harness.driver.getSnapshot()).toMatchObject({
|
|
mode: "sticky-bottom",
|
|
blockedReason: "waiting_for_history_readiness",
|
|
pendingRequest: {
|
|
reason: "initial-entry",
|
|
},
|
|
});
|
|
|
|
harness.context.authoritativeReady = true;
|
|
harness.context.measurementState.viewportHeight = 480;
|
|
harness.context.measurementState.contentHeight = 1200;
|
|
harness.context.measurementState.viewportMeasuredForKey = "scroll-view";
|
|
harness.context.measurementState.contentMeasuredForKey = "scroll-view";
|
|
harness.context.nearBottom = true;
|
|
harness.driver.notifyAuthoritativeHistoryMaybeChanged();
|
|
harness.driver.reevaluate();
|
|
harness.scheduler.flushAll();
|
|
|
|
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
|
|
expect(harness.driver.getSnapshot()).toMatchObject({
|
|
blockedReason: null,
|
|
pendingRequest: null,
|
|
pendingVerification: null,
|
|
});
|
|
});
|
|
|
|
it("suppresses sticky maintenance while detached", () => {
|
|
const harness = createDriverHarness();
|
|
|
|
harness.driver.detachByUser();
|
|
harness.driver.handleContentSizeChange({
|
|
previousContentHeight: 1200,
|
|
contentHeight: 1500,
|
|
});
|
|
harness.driver.handleViewportMetricsChange({
|
|
previousViewportWidth: 800,
|
|
viewportWidth: 640,
|
|
previousViewportHeight: 480,
|
|
viewportHeight: 420,
|
|
});
|
|
harness.scheduler.flushAll();
|
|
|
|
expect(harness.driver.getSnapshot().mode).toBe("detached");
|
|
expect(harness.scrollToBottom).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("switches back to sticky-bottom for explicit jump-to-bottom", () => {
|
|
const harness = createDriverHarness({
|
|
isNearBottom: false,
|
|
});
|
|
|
|
harness.driver.detachByUser();
|
|
harness.driver.requestLocalAnchor({
|
|
agentId: "agent-1",
|
|
reason: "jump-to-bottom",
|
|
});
|
|
harness.scheduler.flushAll();
|
|
|
|
expect(harness.modeChanges).toContain("detached");
|
|
expect(harness.modeChanges).toContain("sticky-bottom");
|
|
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
|
|
expect(harness.driver.getSnapshot().mode).toBe("sticky-bottom");
|
|
});
|
|
|
|
it("schedules sticky maintenance on viewport and content growth", () => {
|
|
const harness = createDriverHarness();
|
|
|
|
harness.driver.handleViewportMetricsChange({
|
|
previousViewportWidth: 800,
|
|
viewportWidth: 640,
|
|
previousViewportHeight: 480,
|
|
viewportHeight: 420,
|
|
});
|
|
harness.scheduler.flushAll();
|
|
|
|
harness.driver.handleContentSizeChange({
|
|
previousContentHeight: 1200,
|
|
contentHeight: 1600,
|
|
});
|
|
harness.scheduler.flushAll();
|
|
|
|
expect(harness.scrollToBottom).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("keeps a pending request blocked when stale container measurements arrive", () => {
|
|
const harness = createDriverHarness({
|
|
measurementState: createMeasurementState({
|
|
containerKey: "web-partial-virtualized",
|
|
viewportHeight: 420,
|
|
contentHeight: 1200,
|
|
viewportMeasuredForKey: "scroll-view",
|
|
contentMeasuredForKey: "scroll-view",
|
|
}),
|
|
});
|
|
|
|
harness.driver.applyRouteRequest({
|
|
agentId: "agent-1",
|
|
reason: "resume",
|
|
requestKey: "route:agent-1:resume",
|
|
});
|
|
harness.scheduler.flushAll();
|
|
|
|
expect(harness.scrollToBottom).not.toHaveBeenCalled();
|
|
expect(harness.driver.getSnapshot()).toMatchObject({
|
|
blockedReason: "waiting_for_measurable_viewport",
|
|
pendingRequest: {
|
|
reason: "resume",
|
|
},
|
|
});
|
|
|
|
harness.context.measurementState.viewportMeasuredForKey = "web-partial-virtualized";
|
|
harness.context.measurementState.contentMeasuredForKey = "web-partial-virtualized";
|
|
harness.driver.reevaluate();
|
|
harness.scheduler.flushAll();
|
|
|
|
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
|
|
expect(harness.driver.getSnapshot().pendingRequest).toBeNull();
|
|
});
|
|
|
|
it("uses delayed rechecks instead of repeated rescroll loops for native transport", () => {
|
|
const harness = createDriverHarness({
|
|
transportBehavior: {
|
|
verificationDelayFrames: 2,
|
|
verificationRetryMode: "recheck",
|
|
},
|
|
isNearBottom: false,
|
|
});
|
|
harness.scrollToBottom.mockImplementation(() => {
|
|
harness.context.measurementState.offsetY = 0;
|
|
});
|
|
|
|
harness.driver.requestLocalAnchor({
|
|
agentId: "agent-1",
|
|
reason: "jump-to-bottom",
|
|
});
|
|
|
|
harness.scheduler.flushFrame();
|
|
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
|
|
|
|
harness.scheduler.flushFrame();
|
|
harness.scheduler.flushFrame();
|
|
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
|
|
|
|
harness.context.nearBottom = true;
|
|
harness.scheduler.flushAll();
|
|
|
|
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
|
|
expect(harness.driver.getSnapshot().pendingRequest).toBeNull();
|
|
});
|
|
|
|
it("does not stay blocked on post-layout verification after a retry-scroll request", () => {
|
|
const harness = createDriverHarness({
|
|
measurementState: createMeasurementState({
|
|
containerKey: "web-partial-virtualized",
|
|
viewportWidth: 828,
|
|
viewportHeight: 846,
|
|
contentHeight: 14322,
|
|
offsetY: 0,
|
|
viewportMeasuredForKey: "web-partial-virtualized",
|
|
contentMeasuredForKey: "web-partial-virtualized",
|
|
}),
|
|
isNearBottom: false,
|
|
});
|
|
|
|
harness.scrollToBottom.mockImplementation(() => {
|
|
harness.context.measurementState.offsetY = 13476;
|
|
});
|
|
|
|
harness.driver.applyRouteRequest({
|
|
agentId: "agent-1",
|
|
reason: "resume",
|
|
requestKey: "route:agent-1:resume",
|
|
});
|
|
|
|
harness.scheduler.flushFrame();
|
|
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
|
|
|
|
harness.context.measurementState.contentHeight = 14804;
|
|
harness.context.nearBottom = false;
|
|
harness.driver.handleContentSizeChange({
|
|
previousContentHeight: 14322,
|
|
contentHeight: 14804,
|
|
});
|
|
|
|
harness.scheduler.flushFrame();
|
|
|
|
expect(harness.driver.getSnapshot()).toMatchObject({
|
|
pendingRequest: {
|
|
reason: "resume",
|
|
},
|
|
pendingVerification: {
|
|
requestId: 1,
|
|
retries: 1,
|
|
},
|
|
});
|
|
|
|
harness.scheduler.flushFrame();
|
|
|
|
expect(harness.scrollToBottom).toHaveBeenCalledTimes(2);
|
|
expect(harness.driver.getSnapshot()).toMatchObject({
|
|
blockedReason: "waiting_for_post_layout_verification",
|
|
pendingRequest: {
|
|
reason: "resume",
|
|
},
|
|
pendingVerification: {
|
|
requestId: 1,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("does not fulfill a web partial-virtualized resume request before a confirmation pass", () => {
|
|
const harness = createDriverHarness({
|
|
measurementState: createMeasurementState({
|
|
containerKey: "web-partial-virtualized",
|
|
viewportWidth: 828,
|
|
viewportHeight: 846,
|
|
contentHeight: 14322,
|
|
offsetY: 0,
|
|
viewportMeasuredForKey: "web-partial-virtualized",
|
|
contentMeasuredForKey: "web-partial-virtualized",
|
|
}),
|
|
isNearBottom: false,
|
|
});
|
|
|
|
harness.scrollToBottom.mockImplementation(() => {
|
|
harness.context.measurementState.offsetY = Math.max(
|
|
0,
|
|
harness.context.measurementState.contentHeight -
|
|
harness.context.measurementState.viewportHeight,
|
|
);
|
|
harness.context.nearBottom = true;
|
|
});
|
|
|
|
harness.driver.applyRouteRequest({
|
|
agentId: "agent-1",
|
|
reason: "resume",
|
|
requestKey: "route:agent-1:resume-confirmation",
|
|
});
|
|
|
|
harness.scheduler.flushFrame();
|
|
harness.scheduler.flushFrame();
|
|
|
|
expect(harness.driver.getSnapshot()).toMatchObject({
|
|
pendingRequest: {
|
|
reason: "resume",
|
|
},
|
|
blockedReason: "waiting_for_post_layout_verification",
|
|
});
|
|
|
|
harness.context.measurementState.contentHeight = 16230;
|
|
harness.context.nearBottom = false;
|
|
harness.driver.handleContentSizeChange({
|
|
previousContentHeight: 14322,
|
|
contentHeight: 16230,
|
|
});
|
|
|
|
harness.scheduler.flushFrame();
|
|
harness.scheduler.flushFrame();
|
|
harness.scheduler.flushFrame();
|
|
|
|
expect(harness.scrollToBottom).toHaveBeenCalledTimes(2);
|
|
expect(harness.driver.getSnapshot().pendingRequest).toMatchObject({
|
|
reason: "resume",
|
|
});
|
|
});
|
|
|
|
it("keeps sticky-bottom during viewport growth until bottom is re-verified", () => {
|
|
const harness = createDriverHarness();
|
|
harness.context.nearBottom = false;
|
|
harness.scrollToBottom.mockImplementation(() => {
|
|
harness.context.measurementState.offsetY = 720;
|
|
});
|
|
|
|
harness.driver.handleViewportMetricsChange({
|
|
previousViewportWidth: 800,
|
|
viewportWidth: 800,
|
|
previousViewportHeight: 480,
|
|
viewportHeight: 420,
|
|
});
|
|
harness.scheduler.flushAll();
|
|
|
|
expect(harness.scrollToBottom).toHaveBeenCalledTimes(4);
|
|
expect(harness.driver.getSnapshot()).toMatchObject({
|
|
mode: "sticky-bottom",
|
|
pendingRequest: null,
|
|
pendingVerification: null,
|
|
});
|
|
|
|
harness.driver.handleScrollNearBottomChange({
|
|
nextIsNearBottom: false,
|
|
scrollDelta: 0,
|
|
});
|
|
|
|
expect(harness.driver.getSnapshot().mode).toBe("sticky-bottom");
|
|
|
|
harness.context.nearBottom = true;
|
|
harness.driver.handleScrollNearBottomChange({
|
|
nextIsNearBottom: true,
|
|
scrollDelta: 0,
|
|
});
|
|
harness.scheduler.flushAll();
|
|
harness.driver.handleScrollNearBottomChange({
|
|
nextIsNearBottom: false,
|
|
scrollDelta: 64,
|
|
});
|
|
|
|
expect(harness.driver.getSnapshot().mode).toBe("detached");
|
|
});
|
|
|
|
it("keeps sticky-bottom during streaming growth until bottom is re-verified", () => {
|
|
const harness = createDriverHarness();
|
|
harness.context.nearBottom = false;
|
|
harness.scrollToBottom.mockImplementation(() => {
|
|
harness.context.measurementState.offsetY = 900;
|
|
});
|
|
|
|
harness.driver.handleContentSizeChange({
|
|
previousContentHeight: 1200,
|
|
contentHeight: 1400,
|
|
});
|
|
harness.scheduler.flushAll();
|
|
|
|
expect(harness.scrollToBottom).toHaveBeenCalledTimes(4);
|
|
expect(harness.driver.getSnapshot()).toMatchObject({
|
|
mode: "sticky-bottom",
|
|
pendingRequest: null,
|
|
pendingVerification: null,
|
|
});
|
|
|
|
harness.driver.handleScrollNearBottomChange({
|
|
nextIsNearBottom: false,
|
|
scrollDelta: 0,
|
|
});
|
|
|
|
expect(harness.driver.getSnapshot().mode).toBe("sticky-bottom");
|
|
|
|
harness.context.nearBottom = true;
|
|
harness.driver.handleScrollNearBottomChange({
|
|
nextIsNearBottom: true,
|
|
scrollDelta: 0,
|
|
});
|
|
harness.scheduler.flushAll();
|
|
harness.driver.handleScrollNearBottomChange({
|
|
nextIsNearBottom: false,
|
|
scrollDelta: 64,
|
|
});
|
|
|
|
expect(harness.driver.getSnapshot().mode).toBe("detached");
|
|
});
|
|
});
|
|
|
|
describe("controller helper predicates", () => {
|
|
it("rejects stale container measurements during post-scroll verification", () => {
|
|
expect(
|
|
__private__.deriveVerificationBlockedReason({
|
|
isAuthoritativeHistoryReady: true,
|
|
measurementState: createMeasurementState({
|
|
containerKey: "web-partial-virtualized",
|
|
viewportHeight: 420,
|
|
contentHeight: 1200,
|
|
viewportMeasuredForKey: "scroll-view",
|
|
contentMeasuredForKey: "scroll-view",
|
|
}),
|
|
}),
|
|
).toBe("waiting_for_measurable_viewport");
|
|
});
|
|
|
|
it("allows verification only after authoritative readiness and current geometry exist", () => {
|
|
expect(
|
|
__private__.deriveVerificationBlockedReason({
|
|
isAuthoritativeHistoryReady: false,
|
|
measurementState: createMeasurementState({
|
|
containerKey: "scroll-view",
|
|
viewportHeight: 420,
|
|
contentHeight: 1200,
|
|
viewportMeasuredForKey: "scroll-view",
|
|
contentMeasuredForKey: "scroll-view",
|
|
}),
|
|
}),
|
|
).toBe("waiting_for_history_readiness");
|
|
|
|
expect(
|
|
__private__.deriveVerificationBlockedReason({
|
|
isAuthoritativeHistoryReady: true,
|
|
measurementState: createMeasurementState({
|
|
containerKey: "scroll-view",
|
|
viewportHeight: 420,
|
|
contentHeight: 1200,
|
|
viewportMeasuredForKey: "scroll-view",
|
|
contentMeasuredForKey: "scroll-view",
|
|
}),
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("suppresses auto-anchor helpers while detached", () => {
|
|
const mode: BottomAnchorMode = "detached";
|
|
|
|
expect(
|
|
__private__.shouldRestickOnContentChange({
|
|
mode,
|
|
previousContentHeight: 1000,
|
|
contentHeight: 1100,
|
|
}),
|
|
).toBe(false);
|
|
expect(
|
|
__private__.shouldRestickOnViewportChange({
|
|
mode,
|
|
previousViewportWidth: 800,
|
|
viewportWidth: 640,
|
|
previousViewportHeight: 400,
|
|
viewportHeight: 360,
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("does not detach from sticky while a restick request is still pending", () => {
|
|
expect(
|
|
__private__.shouldDetachFromScrollAway({
|
|
mode: "sticky-bottom",
|
|
nextIsNearBottom: false,
|
|
scrollDelta: 0,
|
|
hasPendingRequest: true,
|
|
hasPendingVerification: false,
|
|
hasUnverifiedStickyMeasurementChange: false,
|
|
}),
|
|
).toBe(false);
|
|
|
|
expect(
|
|
__private__.shouldDetachFromScrollAway({
|
|
mode: "sticky-bottom",
|
|
nextIsNearBottom: false,
|
|
scrollDelta: 0,
|
|
hasPendingRequest: false,
|
|
hasPendingVerification: true,
|
|
hasUnverifiedStickyMeasurementChange: false,
|
|
}),
|
|
).toBe(false);
|
|
|
|
expect(
|
|
__private__.shouldDetachFromScrollAway({
|
|
mode: "sticky-bottom",
|
|
nextIsNearBottom: false,
|
|
scrollDelta: 0,
|
|
hasPendingRequest: false,
|
|
hasPendingVerification: false,
|
|
hasUnverifiedStickyMeasurementChange: true,
|
|
}),
|
|
).toBe(false);
|
|
|
|
expect(
|
|
__private__.shouldDetachFromScrollAway({
|
|
mode: "sticky-bottom",
|
|
nextIsNearBottom: false,
|
|
scrollDelta: 0,
|
|
hasPendingRequest: false,
|
|
hasPendingVerification: false,
|
|
hasUnverifiedStickyMeasurementChange: false,
|
|
}),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("treats a large scroll delta as user detach even during an unverified sticky change", () => {
|
|
expect(
|
|
__private__.shouldDetachFromScrollAway({
|
|
mode: "sticky-bottom",
|
|
nextIsNearBottom: false,
|
|
scrollDelta: 48,
|
|
hasPendingRequest: false,
|
|
hasPendingVerification: false,
|
|
hasUnverifiedStickyMeasurementChange: true,
|
|
}),
|
|
).toBe(true);
|
|
});
|
|
});
|