summaryrefslogtreecommitdiffhomepage
path: root/src/features/smart-scroll/ui/controller.test.ts
blob: 614f4b0f369d75b00976f47b3914dd4e9377149a (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
import { describe, expect, it, vi } from "vitest";
import { createSmartScrollController } from "./controller.svelte";

// A minimal fake of the only DOM surface the controller touches: scroll
// geometry, scrollTo, and add/removeEventListener for "scroll"/"scrollend".
// Faking this outermost edge is the sanctioned mock (no internal modules mocked).
function createFakeScrollEl(opts?: { scrollHeight?: number; clientHeight?: number }) {
	const listeners = new Map<string, Set<EventListener>>();
	const el = {
		scrollTop: 0,
		scrollHeight: opts?.scrollHeight ?? 1000,
		clientHeight: opts?.clientHeight ?? 100,
		scrollTo: vi.fn((arg: ScrollToOptions) => {
			// Emulate the browser: jump scrollTop, then (for "instant") fire scrollend.
			el.scrollTop = (arg.top ?? 0) - 0;
			if (arg.behavior !== "smooth") {
				fire("scroll");
				fire("scrollend");
			}
		}),
		addEventListener: (type: string, fn: EventListener) => {
			if (!listeners.has(type)) listeners.set(type, new Set());
			listeners.get(type)?.add(fn);
		},
		removeEventListener: (type: string, fn: EventListener) => {
			listeners.get(type)?.delete(fn);
		},
	};
	function fire(type: string): void {
		for (const fn of listeners.get(type) ?? []) fn(new Event(type));
	}
	// Simulate the USER scrolling to a given offset (fires scroll, not self-driven).
	function userScrollTo(top: number): void {
		el.scrollTop = top;
		fire("scroll");
	}
	return {
		el: el as unknown as HTMLElement,
		scrollTo: el.scrollTo,
		fire,
		userScrollTo,
		listenerCount: () => listeners,
	};
}

describe("smart-scroll controller", () => {
	it("starts with the button hidden", () => {
		const c = createSmartScrollController();
		expect(c.showButton).toBe(false);
	});

	it("contentChanged while stuck scrolls to the bottom instantly", () => {
		const c = createSmartScrollController();
		const fake = createFakeScrollEl();
		c.attach(fake.el);
		c.contentChanged();
		expect(fake.scrollTo).toHaveBeenCalledWith({
			top: 1000,
			behavior: "instant",
		});
		expect(c.showButton).toBe(false);
	});

	it("a user scroll up shows the button and stops auto-following", () => {
		const c = createSmartScrollController();
		const fake = createFakeScrollEl();
		c.attach(fake.el);
		fake.userScrollTo(200); // far from the bottom
		expect(c.showButton).toBe(true);

		const scrollTo = fake.scrollTo;
		scrollTo.mockClear();
		c.contentChanged(); // streaming more content...
		expect(scrollTo).not.toHaveBeenCalled(); // ...must NOT yank the reader down
		expect(c.showButton).toBe(true);
	});

	it("self-driven scrolls are not misread as the user scrolling up", () => {
		const c = createSmartScrollController();
		const fake = createFakeScrollEl();
		c.attach(fake.el);
		// contentChanged drives an instant scrollTo, whose synthetic scroll event
		// must NOT flip us to unstuck (selfScrolling guard).
		c.contentChanged();
		expect(c.showButton).toBe(false);
	});

	it("resume re-sticks and smooth-scrolls to the bottom", () => {
		const c = createSmartScrollController();
		const fake = createFakeScrollEl();
		c.attach(fake.el);
		fake.userScrollTo(200);
		expect(c.showButton).toBe(true);

		c.resume();
		expect(fake.scrollTo).toHaveBeenCalledWith({
			top: 1000,
			behavior: "smooth",
		});
		expect(c.showButton).toBe(false);
	});

	it("reset snaps to the bottom and hides the button", () => {
		const c = createSmartScrollController();
		const fake = createFakeScrollEl();
		c.attach(fake.el);
		fake.userScrollTo(200);
		expect(c.showButton).toBe(true);
		c.reset();
		expect(fake.scrollTo).toHaveBeenCalledWith({
			top: 1000,
			behavior: "instant",
		});
		expect(c.showButton).toBe(false);
	});

	it("observes content via a ResizeObserver: follows growth while stuck, not while unstuck", () => {
		const holder: { cb: ResizeObserverCallback | null } = { cb: null };
		const observed: unknown[] = [];
		const disconnect = vi.fn();
		class FakeResizeObserver {
			constructor(cb: ResizeObserverCallback) {
				holder.cb = cb;
			}
			observe(target: Element): void {
				observed.push(target);
			}
			unobserve(): void {}
			disconnect = disconnect;
		}
		vi.stubGlobal("ResizeObserver", FakeResizeObserver);
		try {
			const c = createSmartScrollController();
			const fake = createFakeScrollEl();
			const content = { id: "content" } as unknown as HTMLElement;
			const teardown = c.attach(fake.el, content);

			// Observes both the content (it grows) and the scroll container (viewport resize).
			expect(observed).toContain(content);
			expect(observed).toContain(fake.el);

			// Stuck → a resize keeps us pinned to the bottom.
			fake.scrollTo.mockClear();
			holder.cb?.([], {} as ResizeObserver);
			expect(fake.scrollTo).toHaveBeenCalledWith({ top: 1000, behavior: "instant" });

			// Reader scrolls up → a later resize must NOT yank them down.
			fake.userScrollTo(200);
			fake.scrollTo.mockClear();
			holder.cb?.([], {} as ResizeObserver);
			expect(fake.scrollTo).not.toHaveBeenCalled();

			// Teardown disconnects the observer.
			teardown();
			expect(disconnect).toHaveBeenCalled();
		} finally {
			vi.unstubAllGlobals();
		}
	});

	it("attach returns a teardown that removes both listeners", () => {
		const c = createSmartScrollController();
		const fake = createFakeScrollEl();
		const teardown = c.attach(fake.el);
		const before = fake.listenerCount();
		expect(before.get("scroll")?.size).toBe(1);
		expect(before.get("scrollend")?.size).toBe(1);
		teardown();
		expect(before.get("scroll")?.size).toBe(0);
		expect(before.get("scrollend")?.size).toBe(0);
	});
});