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; 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); }); });