1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
2
3import {
4 createComposerScrollGestureState,
5 recordComposerScrollGestureEvent,
6} from "./composerScrollGesture";
7import { isTimelineScrollTarget } from "./timelineScrollTarget";
8
9class ScrollElement extends EventTarget {
10 scrollTop = 0;
11 scrollHeight = 100;
12 clientHeight = 100;
13 overflowY = "visible";
14 overscrollBehaviorY = "auto";
15
16 constructor(readonly parentElement: ScrollElement | null = null) {
17 super();
18 }
19
20 contains(target: ScrollElement): boolean {
21 return (
22 target === this || (target.parentElement !== null && this.contains(target.parentElement))
23 );
24 }
25}
26
27function targetsTimeline(target: EventTarget | null, timeline: ScrollElement, deltaY: number) {
28 return isTimelineScrollTarget(target, timeline as unknown as HTMLElement, deltaY);
29}
30
31function setup() {
32 const timeline = Object.assign(new ScrollElement(), {
33 overflowY: "auto",
34 scrollHeight: 1500,
35 clientHeight: 500,
36 scrollTop: 1000,
37 });
38 const group = Object.assign(new ScrollElement(timeline), {
39 overflowY: "auto",
40 scrollHeight: 300,
41 scrollTop: 80,
42 });
43 return { timeline, group, content: new ScrollElement(group) };
44}
45
46beforeEach(() => {
47 vi.stubGlobal("Element", ScrollElement);
48 vi.stubGlobal("getComputedStyle", (element: ScrollElement) => element);
49});
50afterEach(() => vi.unstubAllGlobals());
51
52describe("timeline scroll targets", () => {
53 it.each([-30, 30])("keeps a nested tool group's scroll out of the timeline: %i", (deltaY) => {
54 const { timeline, group, content } = setup();
55 expect(targetsTimeline(content, timeline, deltaY)).toBe(false);
56 expect(targetsTimeline(group, timeline, deltaY)).toBe(false);
57 });
58
59 it.each([
60 { scrollTop: 0, deltaY: -30 },
61 { scrollTop: 200, deltaY: 30 },
62 ])("allows chaining only past the matching edge: %j", ({ scrollTop, deltaY }) => {
63 const { timeline, group, content } = setup();
64 group.scrollTop = scrollTop;
65 expect(targetsTimeline(content, timeline, deltaY)).toBe(true);
66 expect(targetsTimeline(content, timeline, -deltaY)).toBe(false);
67 });
68
69 it.each(["contain", "none"])("respects overscroll-y %s at either edge", (overscrollBehaviorY) => {
70 const { timeline, group, content } = setup();
71 group.overscrollBehaviorY = overscrollBehaviorY;
72 group.scrollTop = 0;
73 expect(targetsTimeline(content, timeline, -30)).toBe(false);
74 group.scrollTop = 200;
75 expect(targetsTimeline(content, timeline, 30)).toBe(false);
76 group.scrollTop = 0;
77 group.scrollHeight = group.clientHeight;
78 expect(targetsTimeline(content, timeline, 30)).toBe(false);
79 });
80
81 it("checks nested results even when the tool group cannot scroll", () => {
82 const { timeline, group } = setup();
83 group.scrollTop = 0;
84 group.scrollHeight = group.clientHeight;
85 const result = Object.assign(new ScrollElement(group), {
86 overflowY: "scroll",
87 scrollHeight: 300,
88 scrollTop: 0.25,
89 });
90 expect(targetsTimeline(new ScrollElement(result), timeline, -30)).toBe(false);
91 result.scrollTop = 0;
92 expect(targetsTimeline(result, timeline, -30)).toBe(true);
93 });
94
95 it("checks an outer group when an inner result reaches its edge", () => {
96 const { timeline, group } = setup();
97 const result = Object.assign(new ScrollElement(group), { overflowY: "auto" });
98 expect(targetsTimeline(result, timeline, -30)).toBe(false);
99 group.scrollTop = 0;
100 expect(targetsTimeline(result, timeline, -30)).toBe(true);
101 });
102
103 it.each(["visible", "hidden", "clip"])("ignores overflow-y %s", (overflowY) => {
104 const { timeline, group, content } = setup();
105 group.overflowY = overflowY;
106 expect(targetsTimeline(content, timeline, -30)).toBe(true);
107 });
108
109 it("allows ordinary message content and the outer viewport", () => {
110 const { timeline } = setup();
111 expect(targetsTimeline(new ScrollElement(timeline), timeline, -30)).toBe(true);
112 expect(targetsTimeline(timeline, timeline, 30)).toBe(true);
113 });
114
115 it("rejects outside targets, non-elements, and horizontal-only scrolling", () => {
116 const { timeline, content } = setup();
117 expect(targetsTimeline(new ScrollElement(), timeline, -30)).toBe(false);
118 expect(targetsTimeline(new EventTarget(), timeline, -30)).toBe(false);
119 expect(targetsTimeline(null, timeline, -30)).toBe(false);
120 expect(targetsTimeline(content, timeline, 0)).toBe(false);
121 });
122
123 it("does not accumulate nested scrolling toward composer collapse", () => {
124 const { timeline, group, content } = setup();
125 const state = createComposerScrollGestureState();
126 const record = (target: ScrollElement, now: number, deltaPx: number) =>
127 recordComposerScrollGestureEvent(state, {
128 now,
129 deltaPx,
130 collapseThresholdPx: 24,
131 collapseEligible: targetsTimeline(target, timeline, -deltaPx),
132 canScrollInGestureDirection: timeline.scrollTop > 0,
133 scrollsTowardLogicalEnd: false,
134 });
135
136 expect(record(timeline, 0, 20)).toBe(false);
137 expect(record(content, 20, 30)).toBe(false);
138 group.scrollTop = 0;
139 expect(record(content, 40, 10)).toBe(false);
140 expect(record(content, 60, 14)).toBe(true);
141 });
142});