summaryrefslogtreecommitdiffhomepage
path: root/src/features/smart-scroll/logic/smart-scroll.test.ts
blob: 94ba1d7111cbf0fd4f6bf3617b97ab75df2151fa (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
import { describe, expect, it } from "vitest";
import {
  createSmartScrollState,
  isNearBottom,
  NEAR_BOTTOM_THRESHOLD,
  onContentChange,
  onReset,
  onResume,
  onScroll,
  type ScrollGeometry,
} from "./smart-scroll";

// A viewport 100px tall over 1000px of content: scrollTop 900 == pinned to bottom.
const atBottom: ScrollGeometry = { scrollTop: 900, scrollHeight: 1000, clientHeight: 100 };
const nearBottom: ScrollGeometry = {
  scrollTop: 900 - NEAR_BOTTOM_THRESHOLD,
  scrollHeight: 1000,
  clientHeight: 100,
};
const scrolledUp: ScrollGeometry = { scrollTop: 200, scrollHeight: 1000, clientHeight: 100 };

describe("isNearBottom", () => {
  it("is true exactly at the bottom", () => {
    expect(isNearBottom(atBottom)).toBe(true);
  });

  it("is true within the threshold of the bottom", () => {
    expect(isNearBottom(nearBottom)).toBe(true);
  });

  it("is false just beyond the threshold", () => {
    expect(
      isNearBottom({
        scrollTop: 900 - NEAR_BOTTOM_THRESHOLD - 1,
        scrollHeight: 1000,
        clientHeight: 100,
      }),
    ).toBe(false);
  });

  it("is false when scrolled well up", () => {
    expect(isNearBottom(scrolledUp)).toBe(false);
  });

  it("honours a custom threshold", () => {
    const geom: ScrollGeometry = { scrollTop: 800, scrollHeight: 1000, clientHeight: 100 };
    expect(isNearBottom(geom, 50)).toBe(false);
    expect(isNearBottom(geom, 150)).toBe(true);
  });
});

describe("smart-scroll reducer", () => {
  it("starts stuck and hides the button", () => {
    const s = createSmartScrollState();
    expect(s.stuck).toBe(true);
  });

  it("onScroll up unsticks and shows the button, with no command", () => {
    const r = onScroll(createSmartScrollState(), scrolledUp);
    expect(r.state.stuck).toBe(false);
    expect(r.showButton).toBe(true);
    expect(r.command).toBeNull();
  });

  it("onScroll back to the bottom re-sticks and hides the button", () => {
    const up = onScroll(createSmartScrollState(), scrolledUp).state;
    const r = onScroll(up, atBottom);
    expect(r.state.stuck).toBe(true);
    expect(r.showButton).toBe(false);
    expect(r.command).toBeNull();
  });

  it("onContentChange while stuck emits a NON-animated scroll (keep up with the stream)", () => {
    const r = onContentChange(createSmartScrollState(), atBottom);
    expect(r.command).toEqual({ kind: "scroll-to-bottom", animate: false });
    expect(r.state.stuck).toBe(true);
  });

  it("onContentChange while unstuck emits NO command (leave the reader in place)", () => {
    const up = onScroll(createSmartScrollState(), scrolledUp).state;
    const r = onContentChange(up, scrolledUp);
    expect(r.command).toBeNull();
    expect(r.state.stuck).toBe(false);
    expect(r.showButton).toBe(true);
  });

  it("onResume re-sticks and emits an ANIMATED scroll", () => {
    const up = onScroll(createSmartScrollState(), scrolledUp).state;
    const r = onResume(up);
    expect(r.state.stuck).toBe(true);
    expect(r.showButton).toBe(false);
    expect(r.command).toEqual({ kind: "scroll-to-bottom", animate: true });
  });

  it("onReset returns to stuck and snaps (non-animated) to the bottom", () => {
    const up = onScroll(createSmartScrollState(), scrolledUp).state;
    const r = onReset();
    void up;
    expect(r.state.stuck).toBe(true);
    expect(r.command).toEqual({ kind: "scroll-to-bottom", animate: false });
    expect(r.showButton).toBe(false);
  });
});