85453da49f
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled
551 lines
16 KiB
TypeScript
551 lines
16 KiB
TypeScript
// @vitest-environment happy-dom
|
|
|
|
import React, { act } from "react";
|
|
import { createRoot } from "react-dom/client";
|
|
import { afterEach } from "vitest";
|
|
import { describe, it, expect, vi } from "vitest";
|
|
import {
|
|
Timeline,
|
|
formatTimelineTickLabel,
|
|
generateTicks,
|
|
getDefaultDroppedTrack,
|
|
getTimelineCanvasHeight,
|
|
resolveTimelineAssetDrop,
|
|
getTimelinePlayheadLeft,
|
|
getTimelineScrollLeftForZoomAnchor,
|
|
getTimelineScrollLeftForZoomTransition,
|
|
shouldShowTimelineShortcutHint,
|
|
shouldHandleTimelineDeleteKey,
|
|
shouldAutoScrollTimeline,
|
|
} from "./Timeline";
|
|
import { RULER_H, TRACK_H } from "./timelineLayout";
|
|
import { formatTime } from "../lib/time";
|
|
import { usePlayerStore } from "../store/playerStore";
|
|
import { TimelineEditProvider } from "../../contexts/TimelineEditContext";
|
|
|
|
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
afterEach(() => {
|
|
document.body.innerHTML = "";
|
|
usePlayerStore.getState().reset();
|
|
});
|
|
|
|
describe("Timeline provider boundary", () => {
|
|
// fallow-ignore-next-line code-duplication
|
|
it("renders the public Timeline export without TimelineEditProvider", () => {
|
|
const host = document.createElement("div");
|
|
document.body.append(host);
|
|
Object.defineProperty(host, "clientWidth", {
|
|
configurable: true,
|
|
value: 640,
|
|
});
|
|
|
|
usePlayerStore.setState({
|
|
duration: 4,
|
|
timelineReady: true,
|
|
elements: [{ id: "clip-1", tag: "div", start: 0, duration: 2, track: 0 }],
|
|
});
|
|
|
|
const root = createRoot(host);
|
|
|
|
expect(() => {
|
|
act(() => {
|
|
root.render(React.createElement(Timeline));
|
|
});
|
|
}).not.toThrow();
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
// fallow-ignore-next-line code-duplication
|
|
it("renders the gutter without legacy icons or hue dots", () => {
|
|
const host = document.createElement("div");
|
|
document.body.append(host);
|
|
Object.defineProperty(host, "clientWidth", {
|
|
configurable: true,
|
|
value: 640,
|
|
});
|
|
|
|
usePlayerStore.setState({
|
|
duration: 4,
|
|
timelineReady: true,
|
|
elements: [{ id: "clip-1", tag: "div", start: 0, duration: 2, track: 0 }],
|
|
});
|
|
|
|
const root = createRoot(host);
|
|
act(() => {
|
|
root.render(React.createElement(Timeline));
|
|
});
|
|
|
|
const hueDot = Array.from(host.querySelectorAll("div")).find(
|
|
(node) =>
|
|
node.style.width === "6px" &&
|
|
node.style.height === "6px" &&
|
|
node.style.borderRadius === "9999px",
|
|
);
|
|
|
|
expect(host.querySelector('img[src^="/icons/timeline/"]')).toBeNull();
|
|
expect(hueDot).toBeUndefined();
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
// fallow-ignore-next-line code-duplication
|
|
it("requests persisted track visibility from the gutter without seeking", () => {
|
|
const host = document.createElement("div");
|
|
document.body.append(host);
|
|
Object.defineProperty(host, "clientWidth", {
|
|
configurable: true,
|
|
value: 640,
|
|
});
|
|
|
|
usePlayerStore.setState({
|
|
duration: 4,
|
|
timelineReady: true,
|
|
elements: [{ id: "clip-1", tag: "div", start: 0, duration: 2, track: 0, hidden: true }],
|
|
});
|
|
|
|
const onSeek = vi.fn();
|
|
const onToggleTrackHidden = vi.fn();
|
|
const root = createRoot(host);
|
|
act(() => {
|
|
root.render(
|
|
React.createElement(
|
|
TimelineEditProvider,
|
|
{ value: { onToggleTrackHidden } },
|
|
React.createElement(Timeline, { onSeek }),
|
|
),
|
|
);
|
|
});
|
|
|
|
// Flush passive effects (ResizeObserver-driven layout) so the gutter row is
|
|
// mounted before we query it.
|
|
act(() => {});
|
|
|
|
const button = host.querySelector<HTMLButtonElement>('button[aria-label="Show track 0"]');
|
|
expect(button).not.toBeNull();
|
|
if (!button) throw new Error("Expected a track visibility toggle");
|
|
|
|
act(() => {
|
|
button.dispatchEvent(
|
|
new MouseEvent("pointerdown", {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
button: 0,
|
|
clientX: 120,
|
|
clientY: 40,
|
|
}),
|
|
);
|
|
});
|
|
expect(onSeek).not.toHaveBeenCalled();
|
|
|
|
act(() => {
|
|
button.click();
|
|
});
|
|
|
|
const row = button.parentElement?.parentElement;
|
|
const trackContent = row?.children.item(1);
|
|
expect(onToggleTrackHidden).toHaveBeenCalledWith(0, false);
|
|
expect(trackContent).toBeInstanceOf(HTMLElement);
|
|
if (!(trackContent instanceof HTMLElement)) {
|
|
throw new Error("Expected track content element");
|
|
}
|
|
expect(trackContent.style.opacity).toBe("0.35");
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("opens the keyframe context menu without seeking to that keyframe", () => {
|
|
const host = document.createElement("div");
|
|
document.body.append(host);
|
|
Object.defineProperty(host, "clientWidth", {
|
|
configurable: true,
|
|
value: 720,
|
|
});
|
|
|
|
usePlayerStore.setState({
|
|
duration: 4,
|
|
timelineReady: true,
|
|
currentTime: 0.25,
|
|
selectedElementId: "clip-1",
|
|
elements: [{ id: "clip-1", tag: "div", start: 0, duration: 4, track: 0 }],
|
|
keyframeCache: new Map([
|
|
[
|
|
"clip-1",
|
|
{
|
|
format: "percentage",
|
|
keyframes: [{ percentage: 50, properties: { x: 100 }, tweenPercentage: 50 }],
|
|
},
|
|
],
|
|
]),
|
|
});
|
|
|
|
const onSeek = vi.fn();
|
|
const root = createRoot(host);
|
|
act(() => {
|
|
root.render(React.createElement(Timeline, { onSeek }));
|
|
});
|
|
|
|
const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]');
|
|
expect(diamond).not.toBeNull();
|
|
|
|
act(() => {
|
|
diamond!.dispatchEvent(
|
|
new MouseEvent("contextmenu", {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
button: 2,
|
|
clientX: 120,
|
|
clientY: 40,
|
|
}),
|
|
);
|
|
});
|
|
|
|
expect(onSeek).not.toHaveBeenCalled();
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("marks every clip in selectedElementIds as selected", () => {
|
|
const host = document.createElement("div");
|
|
document.body.append(host);
|
|
Object.defineProperty(host, "clientWidth", {
|
|
configurable: true,
|
|
value: 720,
|
|
});
|
|
|
|
usePlayerStore.setState({
|
|
duration: 6,
|
|
timelineReady: true,
|
|
selectedElementId: "clip-2",
|
|
selectedElementIds: new Set(["clip-1", "clip-2"]),
|
|
elements: [
|
|
{ id: "clip-1", tag: "div", start: 0, duration: 1, track: 0 },
|
|
{ id: "clip-2", tag: "div", start: 1.5, duration: 1, track: 1 },
|
|
{ id: "clip-3", tag: "div", start: 3, duration: 1, track: 2 },
|
|
],
|
|
});
|
|
|
|
const root = createRoot(host);
|
|
act(() => {
|
|
root.render(React.createElement(Timeline));
|
|
});
|
|
|
|
const selectedClips = host.querySelectorAll(".timeline-clip.is-selected");
|
|
expect(selectedClips).toHaveLength(2);
|
|
expect(host.querySelector('[data-el-id="clip-3"]')?.classList.contains("is-selected")).toBe(
|
|
false,
|
|
);
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
});
|
|
|
|
describe("generateTicks", () => {
|
|
it("returns empty arrays for duration <= 0", () => {
|
|
expect(generateTicks(0)).toEqual({ major: [], minor: [] });
|
|
expect(generateTicks(-5)).toEqual({ major: [], minor: [] });
|
|
});
|
|
|
|
it("generates ticks for a short duration (3 seconds)", () => {
|
|
const { major } = generateTicks(3);
|
|
expect(major.length).toBeGreaterThan(0);
|
|
expect(major[0]).toBe(0);
|
|
expect(major).toContain(0);
|
|
expect(major).toContain(1);
|
|
expect(major).toContain(2);
|
|
expect(major).toContain(3);
|
|
});
|
|
|
|
it("generates ticks for a medium duration (10 seconds)", () => {
|
|
const { major, minor } = generateTicks(10);
|
|
expect(major).toContain(0);
|
|
expect(major).toContain(2);
|
|
expect(major).toContain(4);
|
|
expect(major).toContain(6);
|
|
expect(major).toContain(8);
|
|
expect(major).toContain(10);
|
|
expect(minor).toContain(1);
|
|
expect(minor).toContain(3);
|
|
expect(minor).toContain(5);
|
|
});
|
|
|
|
it("generates ticks for a long duration (120 seconds)", () => {
|
|
const { major, minor } = generateTicks(120);
|
|
expect(major).toContain(0);
|
|
expect(major).toContain(30);
|
|
expect(major).toContain(60);
|
|
expect(major).toContain(90);
|
|
expect(major).toContain(120);
|
|
expect(minor).toContain(15);
|
|
expect(minor).toContain(45);
|
|
});
|
|
|
|
it("generates ticks for a very long duration (500 seconds)", () => {
|
|
const { major } = generateTicks(500);
|
|
expect(major).toContain(0);
|
|
expect(major).toContain(60);
|
|
expect(major).toContain(120);
|
|
});
|
|
|
|
it("major and minor ticks do not overlap", () => {
|
|
const { major, minor } = generateTicks(30);
|
|
for (const t of minor) {
|
|
expect(major).not.toContain(t);
|
|
}
|
|
});
|
|
|
|
it("all tick values are non-negative", () => {
|
|
const { major, minor } = generateTicks(60);
|
|
for (const t of [...major, ...minor]) {
|
|
expect(t).toBeGreaterThanOrEqual(0);
|
|
}
|
|
});
|
|
|
|
it("major ticks always start at 0", () => {
|
|
for (const d of [1, 5, 10, 30, 60, 120, 300]) {
|
|
const { major } = generateTicks(d);
|
|
expect(major[0]).toBe(0);
|
|
}
|
|
});
|
|
|
|
it("uses denser major labels as timeline zoom increases", () => {
|
|
const fitTicks = generateTicks(180, 10);
|
|
const zoomedTicks = generateTicks(180, 48);
|
|
expect(fitTicks.major[1] - fitTicks.major[0]).toBe(10);
|
|
expect(fitTicks.minor).toContain(5);
|
|
expect(zoomedTicks.major[1] - zoomedTicks.major[0]).toBe(2);
|
|
expect(zoomedTicks.minor).toContain(1);
|
|
});
|
|
|
|
it("keeps labels readable instead of placing one at every tiny tick", () => {
|
|
const { major } = generateTicks(180, 80);
|
|
expect(major[1] - major[0]).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe("formatTime", () => {
|
|
it("formats 0 seconds as 0:00", () => {
|
|
expect(formatTime(0)).toBe("0:00");
|
|
});
|
|
|
|
// fallow-ignore-next-line code-duplication
|
|
it("formats seconds below a minute", () => {
|
|
expect(formatTime(5)).toBe("0:05");
|
|
expect(formatTime(30)).toBe("0:30");
|
|
expect(formatTime(59)).toBe("0:59");
|
|
});
|
|
|
|
it("formats exactly one minute", () => {
|
|
expect(formatTime(60)).toBe("1:00");
|
|
});
|
|
|
|
it("formats minutes and seconds", () => {
|
|
expect(formatTime(90)).toBe("1:30");
|
|
expect(formatTime(125)).toBe("2:05");
|
|
});
|
|
|
|
it("floors fractional seconds", () => {
|
|
expect(formatTime(5.7)).toBe("0:05");
|
|
expect(formatTime(59.9)).toBe("0:59");
|
|
expect(formatTime(90.5)).toBe("1:30");
|
|
});
|
|
|
|
it("handles large values", () => {
|
|
expect(formatTime(600)).toBe("10:00");
|
|
expect(formatTime(3661)).toBe("61:01");
|
|
});
|
|
|
|
it("zero-pads seconds to two digits", () => {
|
|
expect(formatTime(1)).toBe("0:01");
|
|
expect(formatTime(9)).toBe("0:09");
|
|
expect(formatTime(61)).toBe("1:01");
|
|
});
|
|
});
|
|
|
|
describe("formatTimelineTickLabel", () => {
|
|
it("uses minute-second labels for normal timeline intervals", () => {
|
|
expect(formatTimelineTickLabel(90, 180, 5)).toBe("1:30");
|
|
});
|
|
|
|
it("uses hour labels for long timelines", () => {
|
|
expect(formatTimelineTickLabel(3661, 4000, 60)).toBe("1:01:01");
|
|
});
|
|
|
|
it("shows subsecond labels when the major ruler interval is below one second", () => {
|
|
expect(formatTimelineTickLabel(1.5, 3, 0.5)).toBe("0:01.5");
|
|
});
|
|
});
|
|
|
|
describe("shouldAutoScrollTimeline", () => {
|
|
it("never auto-scrolls in fit mode", () => {
|
|
expect(shouldAutoScrollTimeline("fit", 1200, 800)).toBe(false);
|
|
});
|
|
|
|
it("does not auto-scroll when there is no horizontal overflow", () => {
|
|
expect(shouldAutoScrollTimeline("manual", 800, 800)).toBe(false);
|
|
expect(shouldAutoScrollTimeline("manual", 800.5, 800)).toBe(false);
|
|
});
|
|
|
|
it("auto-scrolls in manual mode when horizontal overflow exists", () => {
|
|
expect(shouldAutoScrollTimeline("manual", 1200, 800)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("getTimelineScrollLeftForZoomTransition", () => {
|
|
it("resets horizontal scroll when switching from manual zoom back to fit", () => {
|
|
expect(getTimelineScrollLeftForZoomTransition("manual", "fit", 480)).toBe(0);
|
|
});
|
|
|
|
it("resets horizontal scroll whenever the next zoom mode is fit", () => {
|
|
expect(getTimelineScrollLeftForZoomTransition("fit", "fit", 480)).toBe(0);
|
|
expect(getTimelineScrollLeftForZoomTransition(null, "fit", 480)).toBe(0);
|
|
});
|
|
|
|
it("preserves the current scroll offset for manual zoom transitions", () => {
|
|
expect(getTimelineScrollLeftForZoomTransition("fit", "manual", 480)).toBe(480);
|
|
expect(getTimelineScrollLeftForZoomTransition("manual", "manual", 480)).toBe(480);
|
|
});
|
|
});
|
|
|
|
describe("getTimelineScrollLeftForZoomAnchor", () => {
|
|
it("preserves the time under the pointer when zooming in", () => {
|
|
expect(
|
|
getTimelineScrollLeftForZoomAnchor({
|
|
pointerX: 300,
|
|
currentScrollLeft: 200,
|
|
gutter: 32,
|
|
currentPixelsPerSecond: 10,
|
|
nextPixelsPerSecond: 20,
|
|
duration: 120,
|
|
}),
|
|
).toBe(668);
|
|
});
|
|
|
|
it("clamps negative scroll targets", () => {
|
|
expect(
|
|
getTimelineScrollLeftForZoomAnchor({
|
|
pointerX: 300,
|
|
currentScrollLeft: 0,
|
|
gutter: 32,
|
|
currentPixelsPerSecond: 20,
|
|
nextPixelsPerSecond: 5,
|
|
duration: 120,
|
|
}),
|
|
).toBe(0);
|
|
});
|
|
|
|
it("preserves current scroll when inputs are invalid", () => {
|
|
expect(
|
|
getTimelineScrollLeftForZoomAnchor({
|
|
pointerX: 300,
|
|
currentScrollLeft: 120,
|
|
gutter: 32,
|
|
currentPixelsPerSecond: 0,
|
|
nextPixelsPerSecond: 20,
|
|
duration: 120,
|
|
}),
|
|
).toBe(120);
|
|
});
|
|
});
|
|
|
|
describe("getTimelinePlayheadLeft", () => {
|
|
it("converts time to a pixel offset from the gutter", () => {
|
|
expect(getTimelinePlayheadLeft(4, 20)).toBe(112);
|
|
});
|
|
|
|
it("guards invalid input", () => {
|
|
expect(getTimelinePlayheadLeft(Number.NaN, 20)).toBe(32);
|
|
expect(getTimelinePlayheadLeft(4, Number.NaN)).toBe(32);
|
|
});
|
|
});
|
|
|
|
describe("getTimelineCanvasHeight", () => {
|
|
it("includes bottom scroll buffer below the last track", () => {
|
|
expect(getTimelineCanvasHeight(3)).toBeGreaterThan(RULER_H + 3 * TRACK_H);
|
|
});
|
|
|
|
it("still keeps ruler space when there are no tracks", () => {
|
|
expect(getTimelineCanvasHeight(0)).toBeGreaterThan(24);
|
|
});
|
|
});
|
|
|
|
describe("shouldShowTimelineShortcutHint", () => {
|
|
it("shows the hint when the timeline does not vertically overflow", () => {
|
|
expect(shouldShowTimelineShortcutHint(220, 220)).toBe(true);
|
|
expect(shouldShowTimelineShortcutHint(220.5, 220)).toBe(true);
|
|
});
|
|
|
|
it("hides the hint when timeline tracks need vertical scrolling", () => {
|
|
expect(shouldShowTimelineShortcutHint(221.5, 220)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("shouldHandleTimelineDeleteKey", () => {
|
|
it("handles Delete and Backspace when focus is not in an editor", () => {
|
|
expect(shouldHandleTimelineDeleteKey({ key: "Delete" })).toBe(true);
|
|
expect(shouldHandleTimelineDeleteKey({ key: "Backspace" })).toBe(true);
|
|
});
|
|
|
|
it("ignores modifier shortcuts", () => {
|
|
expect(shouldHandleTimelineDeleteKey({ key: "Delete", metaKey: true })).toBe(false);
|
|
expect(shouldHandleTimelineDeleteKey({ key: "Backspace", ctrlKey: true })).toBe(false);
|
|
});
|
|
|
|
it("ignores input and editable targets", () => {
|
|
const input = { tagName: "INPUT", isContentEditable: false };
|
|
const editable = { tagName: "DIV", isContentEditable: true };
|
|
|
|
expect(shouldHandleTimelineDeleteKey({ key: "Delete", target: input })).toBe(false);
|
|
expect(shouldHandleTimelineDeleteKey({ key: "Delete", target: editable })).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("getDefaultDroppedTrack", () => {
|
|
it("defaults to track 0 when there are no rows yet", () => {
|
|
expect(getDefaultDroppedTrack([])).toBe(0);
|
|
});
|
|
|
|
it("creates a new bottom track when dropped below existing rows", () => {
|
|
expect(getDefaultDroppedTrack([0, 1, 5], 10)).toBe(6);
|
|
});
|
|
});
|
|
|
|
describe("resolveTimelineAssetDrop", () => {
|
|
it("maps drop coordinates to a start time and visible track", () => {
|
|
expect(
|
|
resolveTimelineAssetDrop(
|
|
{
|
|
rectLeft: 100,
|
|
rectTop: 200,
|
|
scrollLeft: 0,
|
|
scrollTop: 0,
|
|
pixelsPerSecond: 100,
|
|
duration: 10,
|
|
trackHeight: 72,
|
|
trackOrder: [0, 3, 7],
|
|
},
|
|
432,
|
|
310,
|
|
),
|
|
).toEqual({ start: 3, track: 3 });
|
|
});
|
|
|
|
it("can create a new bottom track when dropped below the last visible row", () => {
|
|
expect(
|
|
resolveTimelineAssetDrop(
|
|
{
|
|
rectLeft: 100,
|
|
rectTop: 200,
|
|
scrollLeft: 0,
|
|
scrollTop: 0,
|
|
pixelsPerSecond: 100,
|
|
duration: 10,
|
|
trackHeight: 72,
|
|
trackOrder: [0, 3, 7],
|
|
},
|
|
250,
|
|
600,
|
|
),
|
|
).toEqual({ start: 1.18, track: 8 });
|
|
});
|
|
});
|