mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-06 09:28:34 +00:00
687 lines
18 KiB
TypeScript
687 lines
18 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
|
|
import {
|
|
ThreadMotionCoordinator,
|
|
type ThreadMotionGeometry,
|
|
type ThreadMotionScheduler,
|
|
} from "@/components/thread/thread-motion";
|
|
|
|
function motionHarness(initial?: Partial<ThreadMotionGeometry>) {
|
|
let nextFrameId = 1;
|
|
let cameraFollowing = false;
|
|
const frames = new Map<number, FrameRequestCallback>();
|
|
let geometry: ThreadMotionGeometry = {
|
|
scrollTop: 400,
|
|
scrollHeight: 1_900,
|
|
clientHeight: 500,
|
|
maxScrollTop: 1_400,
|
|
composerHeight: 120,
|
|
promptTop: 404,
|
|
...initial,
|
|
};
|
|
const scheduler: ThreadMotionScheduler = {
|
|
request: vi.fn((callback) => {
|
|
const id = nextFrameId;
|
|
nextFrameId += 1;
|
|
frames.set(id, callback);
|
|
return id;
|
|
}),
|
|
cancel: vi.fn((id) => {
|
|
frames.delete(id);
|
|
}),
|
|
};
|
|
const camera = {
|
|
cancel: vi.fn(() => {
|
|
cameraFollowing = false;
|
|
}),
|
|
dispose: vi.fn(),
|
|
jumpTo: vi.fn(),
|
|
followTo: vi.fn(() => "settled" as const),
|
|
isFollowing: vi.fn(() => cameraFollowing),
|
|
navigateTo: vi.fn(() => {
|
|
cameraFollowing = true;
|
|
return "started" as const;
|
|
}),
|
|
};
|
|
const onGeometry = vi.fn();
|
|
const onAutoFollow = vi.fn();
|
|
const measure = vi.fn(() => geometry);
|
|
const coordinator = new ThreadMotionCoordinator({
|
|
camera,
|
|
measure,
|
|
onGeometry,
|
|
onAutoFollow,
|
|
scheduler,
|
|
});
|
|
const advanceFrame = () => {
|
|
const pending = [...frames.entries()];
|
|
frames.clear();
|
|
for (const [, callback] of pending) callback(16);
|
|
};
|
|
const setGeometry = (next: Partial<ThreadMotionGeometry>) => {
|
|
const updated = { ...geometry, ...next };
|
|
geometry = {
|
|
...updated,
|
|
maxScrollTop: next.maxScrollTop
|
|
?? Math.max(0, updated.scrollHeight - updated.clientHeight),
|
|
};
|
|
};
|
|
const setCameraFollowing = (following: boolean) => {
|
|
cameraFollowing = following;
|
|
};
|
|
|
|
return {
|
|
camera,
|
|
coordinator,
|
|
frames,
|
|
measure,
|
|
onAutoFollow,
|
|
onGeometry,
|
|
scheduler,
|
|
advanceFrame,
|
|
setCameraFollowing,
|
|
setGeometry,
|
|
};
|
|
}
|
|
|
|
describe("ThreadMotionCoordinator", () => {
|
|
it("coalesces discrete invalidations into one authoritative geometry frame", () => {
|
|
const {
|
|
camera,
|
|
coordinator,
|
|
frames,
|
|
measure,
|
|
scheduler,
|
|
advanceFrame,
|
|
} = motionHarness();
|
|
|
|
coordinator.updateTurn({
|
|
id: "turn-1",
|
|
promptId: "prompt-1",
|
|
hasOutput: true,
|
|
});
|
|
coordinator.invalidateGeometry();
|
|
coordinator.invalidateGeometry();
|
|
|
|
expect(scheduler.request).toHaveBeenCalledTimes(1);
|
|
expect(frames).toHaveLength(1);
|
|
expect(measure).not.toHaveBeenCalled();
|
|
|
|
advanceFrame();
|
|
|
|
expect(measure).toHaveBeenCalledTimes(1);
|
|
expect(measure).toHaveBeenCalledWith("prompt-1");
|
|
expect(camera.jumpTo).toHaveBeenCalledWith(404);
|
|
expect(camera.followTo).toHaveBeenCalledWith(1_400);
|
|
expect(coordinator.snapshot()).toMatchObject({
|
|
mode: "follow-output",
|
|
promptPositioned: true,
|
|
measurementPending: false,
|
|
});
|
|
});
|
|
|
|
it("pins repeated output growth on each authoritative geometry frame", () => {
|
|
const {
|
|
camera,
|
|
coordinator,
|
|
advanceFrame,
|
|
setGeometry,
|
|
} = motionHarness();
|
|
coordinator.updateTurn({
|
|
id: "turn-1",
|
|
promptId: "prompt-1",
|
|
hasOutput: true,
|
|
});
|
|
advanceFrame();
|
|
camera.cancel.mockClear();
|
|
camera.followTo.mockClear();
|
|
|
|
setGeometry({ scrollTop: 1_400, scrollHeight: 1_928 });
|
|
coordinator.invalidateGeometry();
|
|
coordinator.invalidateGeometry();
|
|
advanceFrame();
|
|
setGeometry({ scrollTop: 1_410, scrollHeight: 1_944 });
|
|
coordinator.invalidateGeometry();
|
|
advanceFrame();
|
|
|
|
expect(camera.followTo.mock.calls).toEqual([[1_428], [1_444]]);
|
|
expect(camera.cancel).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([
|
|
"geometry-before-completion",
|
|
"completion-before-geometry",
|
|
] as const)("keeps following final layout for %s ordering", (ordering) => {
|
|
const {
|
|
camera,
|
|
coordinator,
|
|
advanceFrame,
|
|
setGeometry,
|
|
} = motionHarness();
|
|
coordinator.updateTurn({
|
|
id: "turn-1",
|
|
promptId: "prompt-1",
|
|
hasOutput: true,
|
|
});
|
|
advanceFrame();
|
|
camera.cancel.mockClear();
|
|
camera.followTo.mockClear();
|
|
|
|
const applyFinalGeometry = () => {
|
|
setGeometry({
|
|
scrollTop: 1_400,
|
|
scrollHeight: 2_400,
|
|
});
|
|
coordinator.invalidateGeometry();
|
|
};
|
|
if (ordering === "geometry-before-completion") applyFinalGeometry();
|
|
coordinator.completeTurn();
|
|
if (ordering === "completion-before-geometry") applyFinalGeometry();
|
|
|
|
expect(coordinator.snapshot().mode).toBe("follow-completion");
|
|
expect(camera.cancel).not.toHaveBeenCalled();
|
|
advanceFrame();
|
|
expect(camera.followTo).toHaveBeenLastCalledWith(1_900);
|
|
|
|
setGeometry({
|
|
scrollTop: 1_900,
|
|
scrollHeight: 2_450,
|
|
});
|
|
coordinator.invalidateGeometry();
|
|
advanceFrame();
|
|
expect(camera.followTo).toHaveBeenLastCalledWith(1_950);
|
|
});
|
|
|
|
it("releases completion follow when composer editing begins", () => {
|
|
const {
|
|
camera,
|
|
coordinator,
|
|
advanceFrame,
|
|
setGeometry,
|
|
} = motionHarness();
|
|
coordinator.updateTurn({
|
|
id: "turn-1",
|
|
promptId: "prompt-1",
|
|
hasOutput: true,
|
|
});
|
|
advanceFrame();
|
|
coordinator.completeTurn();
|
|
camera.jumpTo.mockClear();
|
|
camera.followTo.mockClear();
|
|
camera.cancel.mockClear();
|
|
|
|
coordinator.handleComposerInput();
|
|
setGeometry({
|
|
scrollTop: 1_400,
|
|
scrollHeight: 1_940,
|
|
composerHeight: 160,
|
|
});
|
|
coordinator.invalidateGeometry();
|
|
advanceFrame();
|
|
|
|
expect(camera.cancel).toHaveBeenCalledTimes(1);
|
|
expect(camera.jumpTo).not.toHaveBeenCalled();
|
|
expect(camera.followTo).not.toHaveBeenCalled();
|
|
expect(coordinator.snapshot().mode).toBe("idle");
|
|
});
|
|
|
|
it("remembers composer editing that begins before turn completion", () => {
|
|
const {
|
|
camera,
|
|
coordinator,
|
|
advanceFrame,
|
|
setGeometry,
|
|
} = motionHarness();
|
|
coordinator.updateTurn({
|
|
id: "turn-1",
|
|
promptId: "prompt-1",
|
|
hasOutput: true,
|
|
});
|
|
advanceFrame();
|
|
camera.followTo.mockClear();
|
|
camera.cancel.mockClear();
|
|
|
|
coordinator.handleComposerInput();
|
|
expect(coordinator.snapshot().mode).toBe("follow-output");
|
|
|
|
coordinator.completeTurn();
|
|
setGeometry({
|
|
scrollTop: 1_400,
|
|
scrollHeight: 1_940,
|
|
composerHeight: 160,
|
|
});
|
|
advanceFrame();
|
|
|
|
expect(camera.cancel).toHaveBeenCalledTimes(1);
|
|
expect(camera.followTo).not.toHaveBeenCalled();
|
|
expect(coordinator.snapshot().mode).toBe("idle");
|
|
});
|
|
|
|
it("keeps turn identity stable when canonical replay replaces DOM ids", () => {
|
|
const {
|
|
camera,
|
|
coordinator,
|
|
measure,
|
|
advanceFrame,
|
|
setGeometry,
|
|
} = motionHarness();
|
|
coordinator.updateTurn({
|
|
id: "turn-stable",
|
|
promptId: "optimistic-prompt",
|
|
hasOutput: true,
|
|
});
|
|
advanceFrame();
|
|
camera.jumpTo.mockClear();
|
|
measure.mockClear();
|
|
|
|
setGeometry({ scrollHeight: 2_020, promptTop: 120 });
|
|
coordinator.updateTurn({
|
|
id: "turn-stable",
|
|
promptId: "canonical-prompt",
|
|
hasOutput: true,
|
|
});
|
|
advanceFrame();
|
|
|
|
expect(measure).toHaveBeenCalledWith(null);
|
|
expect(camera.jumpTo).not.toHaveBeenCalled();
|
|
expect(camera.followTo).toHaveBeenLastCalledWith(1_520);
|
|
});
|
|
|
|
it("continues from the bottom when a running turn is restored before prompt identity", () => {
|
|
const {
|
|
camera,
|
|
coordinator,
|
|
advanceFrame,
|
|
} = motionHarness({
|
|
scrollTop: 1_400,
|
|
});
|
|
|
|
coordinator.updateTurn({
|
|
id: "turn-restored",
|
|
promptId: null,
|
|
hasOutput: true,
|
|
entry: "restored",
|
|
});
|
|
advanceFrame();
|
|
|
|
expect(camera.jumpTo).not.toHaveBeenCalled();
|
|
expect(camera.followTo).toHaveBeenCalledWith(1_400);
|
|
expect(coordinator.snapshot()).toMatchObject({
|
|
mode: "follow-output",
|
|
promptPositioned: true,
|
|
});
|
|
});
|
|
|
|
it("continues measuring while browsing history but does not steal the viewport", () => {
|
|
const {
|
|
camera,
|
|
coordinator,
|
|
onGeometry,
|
|
advanceFrame,
|
|
setGeometry,
|
|
} = motionHarness();
|
|
coordinator.updateTurn({
|
|
id: "turn-1",
|
|
promptId: "prompt-1",
|
|
hasOutput: true,
|
|
});
|
|
advanceFrame();
|
|
camera.followTo.mockClear();
|
|
onGeometry.mockClear();
|
|
|
|
coordinator.takeUserControl();
|
|
setGeometry({ scrollTop: 200, scrollHeight: 2_100 });
|
|
coordinator.invalidateGeometry();
|
|
advanceFrame();
|
|
|
|
expect(onGeometry).toHaveBeenCalledTimes(1);
|
|
expect(camera.followTo).not.toHaveBeenCalled();
|
|
expect(coordinator.snapshot().mode).toBe("browsing-history");
|
|
|
|
coordinator.resumeAutoFollow();
|
|
advanceFrame();
|
|
expect(camera.followTo).toHaveBeenCalledWith(1_600);
|
|
});
|
|
|
|
it("keeps an explicitly resumed idle thread pinned to its latest bottom without animating", () => {
|
|
const {
|
|
camera,
|
|
coordinator,
|
|
advanceFrame,
|
|
setGeometry,
|
|
} = motionHarness();
|
|
|
|
coordinator.resumeAutoFollow();
|
|
advanceFrame();
|
|
expect(camera.jumpTo).toHaveBeenLastCalledWith(1_400);
|
|
expect(coordinator.snapshot().mode).toBe("follow-latest");
|
|
|
|
camera.jumpTo.mockClear();
|
|
setGeometry({
|
|
scrollTop: 1_400,
|
|
scrollHeight: 2_000,
|
|
});
|
|
coordinator.invalidateGeometry();
|
|
advanceFrame();
|
|
|
|
expect(camera.jumpTo).toHaveBeenCalledWith(1_500);
|
|
expect(camera.followTo).not.toHaveBeenCalled();
|
|
|
|
coordinator.takeUserControl();
|
|
camera.jumpTo.mockClear();
|
|
setGeometry({
|
|
scrollTop: 300,
|
|
scrollHeight: 2_100,
|
|
});
|
|
coordinator.invalidateGeometry();
|
|
advanceFrame();
|
|
|
|
expect(camera.jumpTo).not.toHaveBeenCalled();
|
|
expect(coordinator.snapshot().mode).toBe("browsing-history");
|
|
});
|
|
|
|
it("treats scroll events as observations until explicit user intent takes control", () => {
|
|
const {
|
|
camera,
|
|
coordinator,
|
|
advanceFrame,
|
|
setGeometry,
|
|
} = motionHarness({
|
|
scrollTop: 700,
|
|
scrollHeight: 1_200,
|
|
clientHeight: 500,
|
|
maxScrollTop: 700,
|
|
});
|
|
coordinator.updateTurn({
|
|
id: "turn-1",
|
|
promptId: "prompt-1",
|
|
hasOutput: false,
|
|
});
|
|
advanceFrame();
|
|
camera.jumpTo.mockClear();
|
|
|
|
setGeometry({
|
|
scrollTop: 700,
|
|
scrollHeight: 1_280,
|
|
});
|
|
expect(coordinator.observeScroll(false)).toBe("automatic");
|
|
expect(coordinator.snapshot().mode).toBe("anchor-prompt");
|
|
advanceFrame();
|
|
expect(camera.jumpTo).toHaveBeenCalledWith(780);
|
|
|
|
coordinator.takeUserControl();
|
|
expect(coordinator.observeScroll(true)).toBe("user");
|
|
expect(coordinator.snapshot().mode).toBe("browsing-history");
|
|
|
|
expect(coordinator.observeScroll(false)).toBe("user");
|
|
expect(coordinator.snapshot().mode).toBe("browsing-history");
|
|
|
|
expect(coordinator.observeScroll(true)).toBe("automatic");
|
|
expect(coordinator.snapshot().mode).toBe("anchor-prompt");
|
|
});
|
|
|
|
it("resumes shallow history browsing when user intent turns toward latest", () => {
|
|
const {
|
|
camera,
|
|
coordinator,
|
|
advanceFrame,
|
|
} = motionHarness({
|
|
scrollTop: 1_400,
|
|
});
|
|
coordinator.updateTurn({
|
|
id: "turn-1",
|
|
promptId: "prompt-1",
|
|
hasOutput: true,
|
|
});
|
|
advanceFrame();
|
|
camera.followTo.mockClear();
|
|
|
|
coordinator.handleUserScrollIntent(true);
|
|
expect(coordinator.observeScroll(true)).toBe("user");
|
|
advanceFrame();
|
|
expect(camera.followTo).not.toHaveBeenCalled();
|
|
|
|
coordinator.handleUserScrollIntent(true, true);
|
|
expect(coordinator.observeScroll(true)).toBe("automatic");
|
|
expect(coordinator.snapshot().mode).toBe("follow-output");
|
|
advanceFrame();
|
|
expect(camera.followTo).toHaveBeenCalledWith(1_400);
|
|
});
|
|
|
|
it("resumes shallow history browsing from forward intent at the boundary", () => {
|
|
const {
|
|
advanceFrame,
|
|
coordinator,
|
|
onAutoFollow,
|
|
} = motionHarness({
|
|
scrollTop: 1_400,
|
|
});
|
|
coordinator.updateTurn({
|
|
id: "turn-1",
|
|
promptId: "prompt-1",
|
|
hasOutput: true,
|
|
});
|
|
advanceFrame();
|
|
|
|
coordinator.handleUserScrollIntent(true);
|
|
expect(coordinator.observeScroll(true)).toBe("user");
|
|
|
|
coordinator.handleUserScrollIntent(false, true);
|
|
expect(coordinator.snapshot().mode).toBe("follow-output");
|
|
expect(onAutoFollow).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("preserves history browsing when an active turn is cleared", () => {
|
|
const {
|
|
camera,
|
|
coordinator,
|
|
advanceFrame,
|
|
} = motionHarness();
|
|
coordinator.updateTurn({
|
|
id: "turn-1",
|
|
promptId: "prompt-1",
|
|
hasOutput: true,
|
|
});
|
|
advanceFrame();
|
|
coordinator.takeUserControl();
|
|
camera.followTo.mockClear();
|
|
|
|
coordinator.updateTurn({
|
|
id: null,
|
|
promptId: null,
|
|
hasOutput: false,
|
|
});
|
|
advanceFrame();
|
|
|
|
expect(coordinator.snapshot().mode).toBe("browsing-history");
|
|
expect(camera.followTo).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps history navigation independent from active turn completion", () => {
|
|
const {
|
|
camera,
|
|
coordinator,
|
|
advanceFrame,
|
|
} = motionHarness();
|
|
coordinator.updateTurn({
|
|
id: "turn-1",
|
|
promptId: "prompt-1",
|
|
hasOutput: true,
|
|
});
|
|
advanceFrame();
|
|
coordinator.navigateHistoryTo(900);
|
|
camera.cancel.mockClear();
|
|
|
|
coordinator.completeTurn();
|
|
|
|
expect(camera.cancel).not.toHaveBeenCalled();
|
|
expect(coordinator.snapshot().mode).toBe("navigating-history");
|
|
});
|
|
|
|
it("moves from rail navigation to history browsing on user intent", () => {
|
|
const {
|
|
camera,
|
|
coordinator,
|
|
setCameraFollowing,
|
|
} = motionHarness();
|
|
|
|
coordinator.handleUserScrollIntent(false);
|
|
expect(coordinator.snapshot().mode).toBe("idle");
|
|
expect(camera.cancel).not.toHaveBeenCalled();
|
|
|
|
coordinator.navigateHistoryTo(900);
|
|
expect(camera.navigateTo).toHaveBeenCalledWith(900);
|
|
expect(coordinator.snapshot().mode).toBe("navigating-history");
|
|
expect(coordinator.observeScroll(false)).toBe("navigation");
|
|
|
|
camera.cancel.mockClear();
|
|
coordinator.handleUserScrollIntent(false);
|
|
expect(camera.cancel).toHaveBeenCalledTimes(1);
|
|
expect(coordinator.snapshot().mode).toBe("browsing-history");
|
|
expect(coordinator.observeScroll(false)).toBe("user");
|
|
|
|
coordinator.navigateHistoryTo(700);
|
|
camera.cancel.mockClear();
|
|
coordinator.takeUserControl();
|
|
expect(camera.cancel).toHaveBeenCalledTimes(1);
|
|
expect(coordinator.snapshot().mode).toBe("browsing-history");
|
|
|
|
camera.cancel.mockClear();
|
|
coordinator.takeUserControl();
|
|
expect(camera.cancel).not.toHaveBeenCalled();
|
|
|
|
coordinator.navigateHistoryTo(600);
|
|
setCameraFollowing(false);
|
|
expect(coordinator.observeScroll(false)).toBe("navigation");
|
|
expect(coordinator.snapshot().mode).toBe("browsing-history");
|
|
});
|
|
|
|
it("retargets smooth latest navigation before resuming automatic follow", () => {
|
|
const {
|
|
camera,
|
|
coordinator,
|
|
advanceFrame,
|
|
setCameraFollowing,
|
|
setGeometry,
|
|
} = motionHarness();
|
|
coordinator.updateTurn({
|
|
id: "turn-1",
|
|
promptId: "prompt-1",
|
|
hasOutput: true,
|
|
});
|
|
advanceFrame();
|
|
coordinator.takeUserControl();
|
|
camera.followTo.mockClear();
|
|
camera.navigateTo.mockClear();
|
|
|
|
expect(coordinator.navigateLatestTo(1_400)).toBe("started");
|
|
expect(coordinator.snapshot().mode).toBe("navigating-latest");
|
|
|
|
setGeometry({
|
|
scrollTop: 900,
|
|
scrollHeight: 2_100,
|
|
});
|
|
coordinator.invalidateGeometry();
|
|
advanceFrame();
|
|
|
|
expect(camera.navigateTo.mock.calls).toEqual([[1_400], [1_600]]);
|
|
expect(camera.followTo).not.toHaveBeenCalled();
|
|
|
|
setGeometry({ scrollTop: 1_600 });
|
|
setCameraFollowing(false);
|
|
expect(coordinator.observeScroll(true)).toBe("navigation");
|
|
expect(coordinator.snapshot().mode).toBe("follow-output");
|
|
|
|
advanceFrame();
|
|
expect(camera.followTo).toHaveBeenCalledWith(1_600);
|
|
});
|
|
|
|
it("keeps latest navigation through completion and final layout growth", () => {
|
|
const {
|
|
camera,
|
|
coordinator,
|
|
advanceFrame,
|
|
setCameraFollowing,
|
|
setGeometry,
|
|
} = motionHarness();
|
|
coordinator.updateTurn({
|
|
id: "turn-1",
|
|
promptId: "prompt-1",
|
|
hasOutput: true,
|
|
});
|
|
advanceFrame();
|
|
coordinator.takeUserControl();
|
|
coordinator.navigateLatestTo(1_400);
|
|
camera.followTo.mockClear();
|
|
camera.navigateTo.mockClear();
|
|
|
|
coordinator.completeTurn();
|
|
setGeometry({
|
|
scrollTop: 1_000,
|
|
scrollHeight: 2_300,
|
|
});
|
|
coordinator.invalidateGeometry();
|
|
advanceFrame();
|
|
|
|
expect(camera.navigateTo).toHaveBeenCalledWith(1_800);
|
|
expect(camera.followTo).not.toHaveBeenCalled();
|
|
expect(coordinator.snapshot().mode).toBe("navigating-latest");
|
|
|
|
setGeometry({ scrollTop: 1_800 });
|
|
setCameraFollowing(false);
|
|
expect(coordinator.observeScroll(true)).toBe("navigation");
|
|
expect(coordinator.snapshot().mode).toBe("follow-latest");
|
|
|
|
camera.jumpTo.mockClear();
|
|
setGeometry({
|
|
scrollTop: 1_800,
|
|
scrollHeight: 2_400,
|
|
});
|
|
coordinator.invalidateGeometry();
|
|
advanceFrame();
|
|
|
|
expect(camera.jumpTo).toHaveBeenCalledWith(1_900);
|
|
});
|
|
|
|
it("pins a waiting prompt to the exact lower boundary across all layout changes", () => {
|
|
const {
|
|
camera,
|
|
coordinator,
|
|
advanceFrame,
|
|
setGeometry,
|
|
} = motionHarness({
|
|
scrollHeight: 930,
|
|
clientHeight: 500,
|
|
maxScrollTop: 430,
|
|
});
|
|
coordinator.updateTurn({
|
|
id: "turn-1",
|
|
promptId: "prompt-1",
|
|
hasOutput: false,
|
|
});
|
|
advanceFrame();
|
|
expect(camera.jumpTo).toHaveBeenLastCalledWith(430);
|
|
|
|
setGeometry({
|
|
composerHeight: 148,
|
|
scrollHeight: 958,
|
|
promptTop: 404,
|
|
});
|
|
coordinator.invalidateGeometry();
|
|
advanceFrame();
|
|
|
|
expect(camera.jumpTo.mock.calls).toEqual([[430], [458]]);
|
|
expect(camera.followTo).not.toHaveBeenCalled();
|
|
expect(coordinator.snapshot().mode).toBe("anchor-prompt");
|
|
|
|
setGeometry({
|
|
scrollTop: 458,
|
|
scrollHeight: 990,
|
|
});
|
|
coordinator.invalidateGeometry();
|
|
advanceFrame();
|
|
|
|
expect(camera.jumpTo.mock.calls).toEqual([[430], [458], [490]]);
|
|
});
|
|
});
|