forked from BasedHardware/omi
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtimelineGeometry.test.ts
More file actions
387 lines (327 loc) · 14.5 KB
/
Copy pathtimelineGeometry.test.ts
File metadata and controls
387 lines (327 loc) · 14.5 KB
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
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
import { describe, it, expect } from 'vitest'
import {
buildTimelineMapping,
tsToX,
xToTs,
tsInBreak,
nearestFrameIndex,
axisTicks,
activitySegments,
gapSegments,
frameIndexAtCursor,
shouldRecenterTimeline,
REWIND_BREAK_THRESHOLD_MS,
REWIND_LINEAR_MIN_PX
} from './timelineGeometry'
const H = 3_600_000
// pxPerHour 3600 → 1px per real second; minWidth 0 → no viewport stretch, so
// piece widths equal their natural (unstretched) sizes and are easy to assert.
// breakThresholdMs is pinned (not the shipped default) so the mapping-math
// scenarios below stay deterministic regardless of the tuning knob; a separate
// describe covers the real default threshold.
const OPTS = { pxPerHour: 3600, minWidth: 0, breakThresholdMs: 300_000 }
const MIN = 60_000 // 60s activity blocks in the scenarios below
describe('axisTicks', () => {
it('picks a "nice" interval keeping the tick count within the budget', () => {
const ticks = axisTicks(0, 6 * H, 8)
expect(ticks).toEqual([0, H, 2 * H, 3 * H, 4 * H, 5 * H, 6 * H])
})
it('aligns ticks to interval boundaries (not the raw min)', () => {
const ticks = axisTicks(1000, 6 * H + 1000, 8)
expect(ticks).toEqual([H, 2 * H, 3 * H, 4 * H, 5 * H, 6 * H])
})
it('uses a coarser interval for a long span to stay within budget', () => {
const ticks = axisTicks(0, 48 * H, 8)
expect(ticks.every((t) => t % (12 * H) === 0)).toBe(true)
expect(ticks.length).toBeLessThanOrEqual(8)
})
it('returns nothing for a non-positive span', () => {
expect(axisTicks(500, 500, 8)).toEqual([])
expect(axisTicks(900, 500, 8)).toEqual([])
})
})
describe('buildTimelineMapping', () => {
// Two 60s activity blocks (0–60s and 600–660s) with a 540s blank gap between.
// The gap (540s ≥ 5min break threshold) collapses; each block lays out to
// scale (60px at 1px/s).
const twoBlocks = [0, MIN, 600_000, 660_000]
it('collapses a ≥threshold gap to a fixed-width break and lays activity to scale', () => {
const m = buildTimelineMapping(twoBlocks, 0, 660_000, OPTS)
expect(m.pieces.map((p) => p.kind)).toEqual(['linear', 'break', 'linear'])
const [a, gap, b] = m.pieces
expect(a.xEnd - a.xStart).toBe(60) // 60s activity → 60px
expect(gap.xEnd - gap.xStart).toBe(16) // 540s gap → fixed 16px break
expect(b.xEnd - b.xStart).toBe(60)
expect(m.width).toBe(136)
})
it('leaves a sub-threshold gap linear (uncompressed) — no break piece', () => {
// 120s gap (< 5min) between two blocks stays real-duration: one linear run.
const m = buildTimelineMapping([0, MIN, 180_000, 240_000], 0, 240_000, OPTS)
expect(m.pieces.every((p) => p.kind === 'linear')).toBe(true)
expect(m.pieces).toHaveLength(1)
expect(m.width).toBe(240) // full 240s to scale, gap NOT collapsed
})
it('handles a window that is one giant gap (zero frames)', () => {
const m = buildTimelineMapping([], 0, 1_000_000, OPTS)
expect(m.pieces.map((p) => p.kind)).toEqual(['break'])
expect(m.width).toBe(16)
expect(tsToX(0, m)).toBe(0)
expect(tsToX(1_000_000, m)).toBe(16)
})
it('puts a break at the window start when activity is only late', () => {
const m = buildTimelineMapping([600_000, 660_000], 0, 660_000, OPTS)
expect(m.pieces[0].kind).toBe('break')
expect(m.pieces[0].tStart).toBe(0)
expect(m.pieces.at(-1)?.kind).toBe('linear')
})
it('puts a break at the window end when activity is only early', () => {
const m = buildTimelineMapping([0, MIN], 0, 660_000, OPTS)
expect(m.pieces.map((p) => p.kind)).toEqual(['linear', 'break'])
expect(m.pieces.at(-1)?.tEnd).toBe(660_000)
})
it('emits one break per collapsed gap for multiple consecutive breaks', () => {
const m = buildTimelineMapping(
[0, MIN, 600_000, 660_000, 1_200_000, 1_260_000],
0,
1_260_000,
OPTS
)
expect(m.pieces.map((p) => p.kind)).toEqual(['linear', 'break', 'linear', 'break', 'linear'])
})
it('stretches to minWidth by widening activity, never the breaks', () => {
const m = buildTimelineMapping(twoBlocks, 0, 660_000, { ...OPTS, minWidth: 400 })
expect(m.width).toBe(400)
const gap = m.pieces.find((p) => p.kind === 'break')!
expect(gap.xEnd - gap.xStart).toBe(16) // break stays fixed
// The two activity pieces absorb all 264px of extra width equally.
const acts = m.pieces.filter((p) => p.kind === 'linear')
expect(acts[0].xEnd - acts[0].xStart).toBeCloseTo(192, 5)
expect(acts[1].xEnd - acts[1].xStart).toBeCloseTo(192, 5)
})
it('fills the viewport even when the whole window is one break (no activity to widen)', () => {
const m = buildTimelineMapping([], 0, 1_000_000, { ...OPTS, minWidth: 300 })
expect(m.width).toBe(300)
expect(m.pieces[0].xStart).toBe(0)
expect(m.pieces[0].xEnd).toBe(300)
})
it('returns an empty mapping for a non-positive window', () => {
const m = buildTimelineMapping([0, MIN], 500, 500, OPTS)
expect(m.pieces).toEqual([])
expect(tsToX(500, m)).toBe(0)
expect(xToTs(10, m)).toBe(500)
})
})
describe('buildTimelineMapping default break threshold', () => {
// No breakThresholdMs override → the shipped default applies. Only genuinely
// long dead stretches should collapse, so the bar isn't littered with seams.
const DEFAULTS = { pxPerHour: 3600, minWidth: 0 }
const hasBreak = (m: ReturnType<typeof buildTimelineMapping>): boolean =>
m.pieces.some((p) => p.kind === 'break')
it('defaults to at least 30 minutes', () => {
expect(REWIND_BREAK_THRESHOLD_MS).toBeGreaterThanOrEqual(30 * 60_000)
})
it('does NOT collapse a 20-minute gap (stays linear, unmarked)', () => {
const m = buildTimelineMapping(
[0, MIN, 20 * 60_000, 20 * 60_000 + MIN],
0,
20 * 60_000 + MIN,
DEFAULTS
)
expect(hasBreak(m)).toBe(false)
})
it('DOES collapse a 45-minute gap', () => {
const m = buildTimelineMapping(
[0, MIN, 45 * 60_000, 45 * 60_000 + MIN],
0,
45 * 60_000 + MIN,
DEFAULTS
)
expect(hasBreak(m)).toBe(true)
})
})
describe('tsToX / xToTs (non-linear mapping)', () => {
const twoBlocks = [0, MIN, 600_000, 660_000]
const m = buildTimelineMapping(twoBlocks, 0, 660_000, OPTS) // width 136, break 60–76px
it('maps windowStart→0 and windowEnd→width', () => {
expect(tsToX(0, m)).toBe(0)
expect(tsToX(660_000, m)).toBe(136)
})
it('places activity to scale and collapses the gap between the blocks', () => {
expect(tsToX(MIN, m)).toBe(60) // end of block A
expect(tsToX(600_000, m)).toBe(76) // start of block B — only 16px past A
})
it('is monotonic non-decreasing across the whole domain (including the break)', () => {
const samples = [0, 30_000, MIN, 200_000, 400_000, 600_000, 630_000, 660_000]
const xs = samples.map((t) => tsToX(t, m))
for (let i = 1; i < xs.length; i++) expect(xs[i]).toBeGreaterThanOrEqual(xs[i - 1])
})
it('round-trips a click inside an activity block', () => {
expect(xToTs(30, m)).toBe(30_000) // 30px into block A → 30s
})
it('snaps a click inside a break to the nearest activity edge', () => {
// Break spans x 60–76 (center 68). Left half → block A end; right half → block B start.
expect(xToTs(63, m)).toBe(MIN) // near A edge
expect(xToTs(74, m)).toBe(600_000) // near B edge
})
it('clamps out-of-range input to the ends', () => {
expect(tsToX(-5000, m)).toBe(0)
expect(tsToX(9_999_999, m)).toBe(136)
expect(xToTs(-10, m)).toBe(0)
expect(xToTs(9999, m)).toBe(660_000)
})
})
describe('tsInBreak', () => {
const m = buildTimelineMapping([0, MIN, 600_000, 660_000], 0, 660_000, OPTS)
it('is true strictly inside a collapsed break', () => {
expect(tsInBreak(300_000, m)).toBe(true)
})
it('is false at the break edges (they are activity boundaries)', () => {
expect(tsInBreak(MIN, m)).toBe(false)
expect(tsInBreak(600_000, m)).toBe(false)
})
it('is false inside an activity block', () => {
expect(tsInBreak(30_000, m)).toBe(false)
})
})
describe('activitySegments', () => {
it('returns nothing for no frames', () => {
expect(activitySegments([], 60_000)).toEqual([])
})
it('wraps a single frame as a zero-length segment', () => {
expect(activitySegments([1000], 60_000)).toEqual([{ start: 1000, end: 1000 }])
})
it('merges frames within the gap into one segment', () => {
expect(activitySegments([0, 30_000, 60_000], 60_000)).toEqual([{ start: 0, end: 60_000 }])
})
it('splits when the gap between frames exceeds the threshold', () => {
expect(activitySegments([0, 1000, 200_000, 201_000], 60_000)).toEqual([
{ start: 0, end: 1000 },
{ start: 200_000, end: 201_000 }
])
})
it('keeps frames exactly at the gap threshold together', () => {
expect(activitySegments([0, 60_000], 60_000)).toEqual([{ start: 0, end: 60_000 }])
})
})
describe('gapSegments', () => {
const GAP = 60_000
it('yields one full-width gap for an empty timeline', () => {
expect(gapSegments([], GAP, 0, 100_000)).toEqual([{ start: 0, end: 100_000 }])
})
it('returns the blank stretch between two activity blocks', () => {
// Blocks 0–1000 and 200000–201000; the gap is the span between them.
expect(gapSegments([0, 1000, 200_000, 201_000], GAP, 0, 201_000)).toEqual([
{ start: 1000, end: 200_000 }
])
})
it('includes leading and trailing gaps when the window overhangs the frames', () => {
expect(gapSegments([1000, 2000], GAP, 0, 5000)).toEqual([
{ start: 0, end: 1000 },
{ start: 2000, end: 5000 }
])
})
it('leaves no gap where frames sit exactly one threshold apart (merged, not split)', () => {
// Each hop is exactly the gap threshold, so activitySegments keeps them in
// one block 0–120000 — the window is fully covered, no blank.
expect(gapSegments([0, 60_000, 120_000], GAP, 0, 120_000)).toEqual([])
})
it('does not emit sub-threshold gaps that live inside one merged segment', () => {
// 30s apart (< 60s) → one segment 0–90000, so the whole window is covered.
expect(gapSegments([0, 30_000, 60_000, 90_000], GAP, 0, 90_000)).toEqual([])
})
it('is the exact complement of the activity blocks across the window', () => {
expect(gapSegments([0, 1000, 200_000], GAP, 0, 200_000)).toEqual([
{ start: 1000, end: 200_000 }
])
})
it('returns nothing for a non-positive window', () => {
expect(gapSegments([1000], GAP, 500, 500)).toEqual([])
expect(gapSegments([1000], GAP, 900, 500)).toEqual([])
})
})
describe('frameIndexAtCursor', () => {
// Two activity blocks (0–2000 and 100000–101000) with a wide blank gap between.
const ts = [0, 1000, 2000, 100_000, 101_000]
const GAP = 60_000
const PAD = 2_000
it('returns the nearest frame when the cursor is inside a block', () => {
expect(frameIndexAtCursor(ts, 1000, GAP, PAD)).toBe(1)
expect(frameIndexAtCursor(ts, 100_400, GAP, PAD)).toBe(3)
})
it('returns -1 when the cursor is in a blank gap between blocks', () => {
expect(frameIndexAtCursor(ts, 50_000, GAP, PAD)).toBe(-1)
})
it('tolerates clicks within the pad of a block edge', () => {
expect(frameIndexAtCursor(ts, 3000, GAP, PAD)).toBe(2) // 1s past the block end
})
it('returns -1 just beyond the pad', () => {
expect(frameIndexAtCursor(ts, 5000, GAP, PAD)).toBe(-1)
})
it('returns -1 for no frames', () => {
expect(frameIndexAtCursor([], 1000, GAP, PAD)).toBe(-1)
})
})
describe('nearestFrameIndex', () => {
it('finds the frame closest in time', () => {
expect(nearestFrameIndex([0, 100, 200], 130)).toBe(1)
expect(nearestFrameIndex([0, 100, 200], 160)).toBe(2)
expect(nearestFrameIndex([], 50)).toBe(-1)
})
})
describe('shouldRecenterTimeline', () => {
const CW = 800 // viewport width → tolerance is CW/4 = 200px
it('re-centers on the initial open (scrolled to 0, playhead far to the right)', () => {
expect(shouldRecenterTimeline(0, CW, 1200)).toBe(true)
})
it('re-centers on a large seek/jump away from the current scroll', () => {
expect(shouldRecenterTimeline(2000, CW, 100)).toBe(true)
})
it('leaves a pan alone while the playhead stays within a quarter-viewport', () => {
// The per-frame advance while PLAYING nudges the target a few px each tick — it
// must not yank a bar the user just panned. 0/150/200 px off all stay put.
expect(shouldRecenterTimeline(1000, CW, 1000)).toBe(false)
expect(shouldRecenterTimeline(1000, CW, 1150)).toBe(false)
expect(shouldRecenterTimeline(1000, CW, 850)).toBe(false)
expect(shouldRecenterTimeline(1000, CW, 1200)).toBe(false) // exactly CW/4 → still tolerated
})
it('re-centers once the playhead drifts just past a quarter-viewport', () => {
expect(shouldRecenterTimeline(1000, CW, 1201)).toBe(true)
expect(shouldRecenterTimeline(1000, CW, 799)).toBe(true)
})
})
// The three cases below came out of a mutation audit against the 47 tests above.
// Each survived them, and each is something a user would see.
describe('timeline geometry gaps found by mutation', () => {
const HOUR = 3_600_000
it('gives a two-second activity block a clickable width', () => {
// Natural width here is (2000 / 3_600_000) * 100 = 0.056px. The floor is
// the only reason a short burst of activity between two long idle gaps is
// visible on the bar at all, let alone clickable. Nothing exercised it, so
// dropping the floor to zero left every test green while making the piece
// disappear.
const m = buildTimelineMapping([HOUR, HOUR + 2_000], 0, 2 * HOUR, {
pxPerHour: 100,
minWidth: 0
})
const linear = m.pieces.filter((p) => p.kind === 'linear')
expect(linear).toHaveLength(1)
expect(linear[0].xEnd - linear[0].xStart).toBe(REWIND_LINEAR_MIN_PX)
expect(REWIND_LINEAR_MIN_PX).toBe(3)
})
it('takes the finest interval that still fits the tick budget', () => {
// The cap is `span / interval <= maxTicks - 1`, an inclusive bound, because
// the tick count across a span is floor(span/interval) + 1. At exactly the
// bound the finer interval is still allowed; one step to `<` and the axis
// silently jumps to the next coarser interval everywhere.
const ticks = axisTicks(0, 4 * 60_000, 5)
expect(ticks).toHaveLength(5)
expect(ticks[1] - ticks[0]).toBe(60_000)
})
it('can select the six-hour interval', () => {
// A twenty-hour span with a five-tick budget is the only shape that picks
// it: three hours gives seven ticks, twelve gives two. Nothing reached this
// entry, so it could be removed from the table without a test noticing.
const ticks = axisTicks(0, 20 * HOUR, 5)
expect(ticks[1] - ticks[0]).toBe(6 * HOUR)
})
})