1import { MessageId, RunId } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3import {
4 observeTimelineRun,
5 getAnchoredTurnMetrics,
6 getRowBottom,
7 readTimelinePosition,
8 rememberTimelinePosition,
9 timelineContentOverflowsViewport,
10} from "./timelineScrollAnchoring";
11
12function buildState({
13 positions,
14 sizes,
15 scroll = 0,
16 scrollLength = 700,
17}: {
18 readonly positions: readonly number[];
19 readonly sizes: readonly number[];
20 readonly scroll?: number;
21 readonly scrollLength?: number;
22}) {
23 return {
24 data: positions.map((_, index) => index),
25 scroll,
26 scrollLength,
27 positionAtIndex: (index: number) => positions[index],
28 sizeAtIndex: (index: number) => sizes[index],
29 };
30}
31
32describe("timelineContentOverflowsViewport", () => {
33 const inset = { composerInset: 100, anchorOffset: 24 };
34
35 it("reports overflow from the last row, not the inset spacer", () => {
36 const fits = buildState({ positions: [0, 200], sizes: [200, 300], scrollLength: 700 });
37 expect(timelineContentOverflowsViewport(fits, inset)).toBe(false);
38
39 const overflows = buildState({ positions: [0, 200], sizes: [200, 400], scrollLength: 700 });
40 expect(timelineContentOverflowsViewport(overflows, inset)).toBe(true);
41 });
42
43 it("treats an empty or unmeasured list as fitting", () => {
44 expect(timelineContentOverflowsViewport(undefined, inset)).toBe(false);
45 expect(
46 timelineContentOverflowsViewport(
47 buildState({ positions: [0, 200], sizes: [200, 400], scrollLength: 0 }),
48 inset,
49 ),
50 ).toBe(false);
51 expect(timelineContentOverflowsViewport(buildState({ positions: [], sizes: [] }), inset)).toBe(
52 false,
53 );
54 expect(
55 timelineContentOverflowsViewport(
56 buildState({ positions: [0, 200], sizes: [200, Number.NaN] }),
57 inset,
58 ),
59 ).toBe(false);
60 });
61});
62
63describe("timeline scroll anchoring", () => {
64 it("measures row bottoms from LegendList row position and size", () => {
65 const state = buildState({
66 positions: [0, 120],
67 sizes: [80, 40],
68 });
69
70 expect(getRowBottom(state, 1)).toBe(160);
71 });
72
73 it("treats the active turn as fitting when it fits above the composer", () => {
74 const state = buildState({
75 positions: [0, 300, 460],
76 sizes: [240, 80, 140],
77 scrollLength: 760,
78 });
79
80 const metrics = getAnchoredTurnMetrics({
81 state,
82 anchorIndex: 1,
83 composerOverlayHeight: 180,
84 anchorOffset: 16,
85 });
86
87 expect(metrics?.turnHeight).toBe(300);
88 expect(metrics?.usableViewportHeight).toBe(564);
89 expect(metrics?.overflowsUsableViewport).toBe(false);
90 expect(metrics?.targetScrollToRevealEnd).toBe(36);
91 expect(metrics?.scrollDeltaToRevealEnd).toBe(36);
92 });
93
94 it("targets the real row end instead of any temporary reserved tail", () => {
95 const state = buildState({
96 positions: [0, 1720, 1880],
97 sizes: [1600, 80, 120],
98 scroll: 1900,
99 scrollLength: 760,
100 });
101
102 const metrics = getAnchoredTurnMetrics({
103 state,
104 anchorIndex: 1,
105 composerOverlayHeight: 180,
106 anchorOffset: 16,
107 });
108
109 expect(metrics?.lastBottom).toBe(2000);
110 expect(metrics?.targetScrollToRevealEnd).toBe(1436);
111 expect(metrics?.scrollDeltaToRevealEnd).toBe(0);
112 });
113
114 it("reports overflow only for the current anchored turn", () => {
115 const state = buildState({
116 positions: [0, 900, 1180],
117 sizes: [800, 220, 300],
118 scroll: 900,
119 scrollLength: 760,
120 });
121
122 const metrics = getAnchoredTurnMetrics({
123 state,
124 anchorIndex: 1,
125 composerOverlayHeight: 180,
126 anchorOffset: 16,
127 });
128
129 expect(metrics?.turnHeight).toBe(580);
130 expect(metrics?.usableViewportHeight).toBe(564);
131 expect(metrics?.overflowsUsableViewport).toBe(true);
132 });
133
134 it("returns the minimal positive scroll delta needed to reveal the turn end", () => {
135 const state = buildState({
136 positions: [0, 900, 1180],
137 sizes: [800, 220, 360],
138 scroll: 900,
139 scrollLength: 760,
140 });
141
142 const metrics = getAnchoredTurnMetrics({
143 state,
144 anchorIndex: 1,
145 composerOverlayHeight: 180,
146 anchorOffset: 16,
147 });
148
149 expect(metrics?.lastBottom).toBe(1540);
150 expect(metrics?.visibleUsableBottom).toBe(1464);
151 expect(metrics?.scrollDeltaToRevealEnd).toBe(76);
152 });
153
154 it("subtracts composer height from usable viewport height", () => {
155 const state = buildState({
156 positions: [0, 300],
157 sizes: [120, 470],
158 scrollLength: 700,
159 });
160
161 const withoutComposer = getAnchoredTurnMetrics({
162 state,
163 anchorIndex: 1,
164 composerOverlayHeight: 0,
165 anchorOffset: 16,
166 });
167 const withComposer = getAnchoredTurnMetrics({
168 state,
169 anchorIndex: 1,
170 composerOverlayHeight: 220,
171 anchorOffset: 16,
172 });
173
174 expect(withoutComposer?.overflowsUsableViewport).toBe(false);
175 expect(withComposer?.overflowsUsableViewport).toBe(true);
176 });
177});
178
179describe("observeTimelineRun", () => {
180 const existing = {
181 threadKey: "environment:thread",
182 hydrated: true,
183 runId: RunId.make("existing-run"),
184 queued: false,
185 messageId: MessageId.make("existing-message"),
186 };
187 const next = {
188 ...existing,
189 runId: RunId.make("new-run"),
190 messageId: MessageId.make("new-message"),
191 };
192
193 it("opens an already hydrated thread at the end instead of framing its existing run", () => {
194 const opened = observeTimelineRun(null, existing);
195 expect(opened.anchorMessageId).toBeNull();
196 expect(observeTimelineRun(opened.observation, existing).anchorMessageId).toBeNull();
197 expect(observeTimelineRun(opened.observation, next).anchorMessageId).toBe(next.messageId);
198 });
199
200 it("does not mistake delayed hydration for a newly started turn", () => {
201 const loading = { ...existing, hydrated: false, runId: null, messageId: null };
202 const opened = observeTimelineRun(null, loading);
203 const stillLoading = observeTimelineRun(opened.observation, loading);
204 const hydrated = observeTimelineRun(stillLoading.observation, existing);
205 expect(hydrated.anchorMessageId).toBeNull();
206 expect(observeTimelineRun(hydrated.observation, next).anchorMessageId).toBe(next.messageId);
207 });
208
209 it("keeps the end position while a stale cached run is replaced during synchronization", () => {
210 const cached = observeTimelineRun(null, { ...existing, hydrated: false });
211 const synchronizing = observeTimelineRun(cached.observation, { ...next, hydrated: false });
212 expect(synchronizing.anchorMessageId).toBeNull();
213 const live = observeTimelineRun(synchronizing.observation, next);
214 expect(live.anchorMessageId).toBeNull();
215 expect(
216 observeTimelineRun(live.observation, {
217 ...next,
218 runId: RunId.make("later-run"),
219 messageId: MessageId.make("later-message"),
220 }).anchorMessageId,
221 ).toBe("later-message");
222 });
223
224 it("does not anchor when an existing run's user message arrives after its run", () => {
225 const opened = observeTimelineRun(null, { ...existing, messageId: null });
226 expect(observeTimelineRun(opened.observation, existing).anchorMessageId).toBeNull();
227 });
228
229 it("waits for a new run's user message and frames it only once", () => {
230 const opened = observeTimelineRun(null, existing);
231 const waiting = observeTimelineRun(opened.observation, { ...next, messageId: null });
232 expect(waiting.anchorMessageId).toBeNull();
233 const ready = observeTimelineRun(waiting.observation, next);
234 expect(ready.anchorMessageId).toBe(next.messageId);
235 expect(observeTimelineRun(ready.observation, next).anchorMessageId).toBeNull();
236 });
237
238 it("establishes a separate baseline when the same thread ID belongs to another environment", () => {
239 const opened = observeTimelineRun(null, existing);
240 expect(
241 observeTimelineRun(opened.observation, { ...next, threadKey: "other:thread" })
242 .anchorMessageId,
243 ).toBeNull();
244 });
245
246 it("does not reset the baseline during a temporary loss of projection data", () => {
247 const opened = observeTimelineRun(null, existing);
248 const reconnecting = observeTimelineRun(opened.observation, {
249 ...existing,
250 hydrated: false,
251 runId: null,
252 });
253 expect(observeTimelineRun(reconnecting.observation, existing).anchorMessageId).toBeNull();
254 });
255
256 it("frames the first new run in an initially empty thread", () => {
257 const opened = observeTimelineRun(null, { ...existing, runId: null, messageId: null });
258 expect(observeTimelineRun(opened.observation, next).anchorMessageId).toBe(next.messageId);
259 });
260
261 it("waits for a queued run to start before framing it", () => {
262 const opened = observeTimelineRun(null, { ...existing, queued: true });
263 expect(opened.anchorMessageId).toBeNull();
264 expect(observeTimelineRun(opened.observation, existing).anchorMessageId).toBe(
265 existing.messageId,
266 );
267 });
268});
269
270describe("remembered timeline positions", () => {
271 it("keeps reading positions and end-follow independent across threads and environments", () => {
272 const reading = { rowId: "message-4", offsetWithinRow: 32, scrollOffset: 932, atEnd: false };
273 const following = { rowId: "message-9", offsetWithinRow: 10, scrollOffset: 2010, atEnd: true };
274 rememberTimelinePosition("scroll-test-a:thread-1", reading, false);
275 rememberTimelinePosition("scroll-test-a:thread-2", following, true);
276 rememberTimelinePosition("scroll-test-b:thread-1", following, true);
277 expect(readTimelinePosition("scroll-test-a:thread-1")).toEqual(reading);
278 expect(readTimelinePosition("scroll-test-a:thread-2")).toEqual(following);
279 expect(readTimelinePosition("scroll-test-b:thread-1")).toEqual(following);
280 expect(readTimelinePosition("scroll-test-a:unvisited")).toBeUndefined();
281 rememberTimelinePosition("scroll-test-a:thread-1", following, true);
282 expect(readTimelinePosition("scroll-test-a:thread-1")).toEqual(following);
283 });
284});