Skip to content

Commit e5c872e

Browse files
jaysin586claude
andcommitted
test: add unit tests for ChatHeightCache and chatAnchoring
31 vitest unit tests covering: - ChatHeightCache: get/set/delete/has/clear/size/version tracking - calculateTotalHeight: empty, estimated, measured, mixed - calculateOffsetForIndex: boundaries, mixed heights, clamping - captureScrollAnchor: empty, top, partway, estimated, fallback - restoreScrollAnchor: missing anchor, exact restore, prepend, negative offset Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4356d45 commit e5c872e

2 files changed

Lines changed: 308 additions & 0 deletions

File tree

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { captureScrollAnchor, restoreScrollAnchor } from './chatAnchoring.js'
3+
import { ChatHeightCache } from './chatMeasurement.svelte.js'
4+
5+
type Msg = { id: string }
6+
const getId = (m: Msg) => m.id
7+
const msgs = (ids: string[]): Msg[] => ids.map((id) => ({ id }))
8+
9+
describe('captureScrollAnchor', () => {
10+
it('returns null for empty messages', () => {
11+
const cache = new ChatHeightCache()
12+
expect(captureScrollAnchor([], getId, cache, 40, 0)).toBeNull()
13+
})
14+
15+
it('captures the first visible message at scrollTop=0', () => {
16+
const cache = new ChatHeightCache()
17+
cache.set('1', 50)
18+
cache.set('2', 50)
19+
cache.set('3', 50)
20+
const m = msgs(['1', '2', '3'])
21+
22+
const anchor = captureScrollAnchor(m, getId, cache, 50, 0)
23+
expect(anchor).not.toBeNull()
24+
expect(anchor!.messageId).toBe('1')
25+
expect(anchor!.offsetFromViewportTop).toBe(0)
26+
})
27+
28+
it('captures correct message when scrolled partway', () => {
29+
const cache = new ChatHeightCache()
30+
cache.set('1', 50)
31+
cache.set('2', 50)
32+
cache.set('3', 50)
33+
const m = msgs(['1', '2', '3'])
34+
35+
// scrollTop=60 means message 1 (0-50) is above viewport,
36+
// message 2 (50-100) is the first visible
37+
const anchor = captureScrollAnchor(m, getId, cache, 50, 60)
38+
expect(anchor!.messageId).toBe('2')
39+
// message 2 starts at offset 50, scrollTop=60, so offset from viewport top = 50 - 60 = -10
40+
expect(anchor!.offsetFromViewportTop).toBe(-10)
41+
})
42+
43+
it('uses estimated height for unmeasured messages', () => {
44+
const cache = new ChatHeightCache()
45+
const m = msgs(['1', '2', '3'])
46+
47+
// All unmeasured at estimated=40. scrollTop=50 means:
48+
// msg 1 (0-40) fully above, msg 2 (40-80) is first visible
49+
const anchor = captureScrollAnchor(m, getId, cache, 40, 50)
50+
expect(anchor!.messageId).toBe('2')
51+
expect(anchor!.offsetFromViewportTop).toBe(40 - 50)
52+
})
53+
54+
it('falls back to last message if scrolled past all', () => {
55+
const cache = new ChatHeightCache()
56+
cache.set('1', 50)
57+
cache.set('2', 50)
58+
const m = msgs(['1', '2'])
59+
60+
// scrollTop=200 is past all content (total = 100)
61+
const anchor = captureScrollAnchor(m, getId, cache, 50, 200)
62+
expect(anchor!.messageId).toBe('2')
63+
})
64+
})
65+
66+
describe('restoreScrollAnchor', () => {
67+
it('returns 0 if anchor message is not found', () => {
68+
const cache = new ChatHeightCache()
69+
const anchor = { messageId: 'nonexistent', offsetFromViewportTop: 0 }
70+
const m = msgs(['1', '2'])
71+
72+
expect(restoreScrollAnchor(anchor, m, getId, cache, 40)).toBe(0)
73+
})
74+
75+
it('restores exact scrollTop for anchor at top of viewport', () => {
76+
const cache = new ChatHeightCache()
77+
cache.set('1', 50)
78+
cache.set('2', 50)
79+
cache.set('3', 50)
80+
const m = msgs(['1', '2', '3'])
81+
82+
// Anchor: message 2 was at offset 0 from viewport top
83+
const anchor = { messageId: '2', offsetFromViewportTop: 0 }
84+
// Message 2 is at offset 50 (after message 1)
85+
// scrollTop = 50 - 0 = 50
86+
expect(restoreScrollAnchor(anchor, m, getId, cache, 50)).toBe(50)
87+
})
88+
89+
it('accounts for offsetFromViewportTop', () => {
90+
const cache = new ChatHeightCache()
91+
cache.set('1', 50)
92+
cache.set('2', 50)
93+
cache.set('3', 50)
94+
const m = msgs(['1', '2', '3'])
95+
96+
// Anchor: message 2 was 10px below the viewport top
97+
const anchor = { messageId: '2', offsetFromViewportTop: 10 }
98+
// Message 2 is at offset 50. scrollTop = 50 - 10 = 40
99+
expect(restoreScrollAnchor(anchor, m, getId, cache, 50)).toBe(40)
100+
})
101+
102+
it('restores correctly after prepending messages', () => {
103+
const cache = new ChatHeightCache()
104+
// Original: messages 3, 4, 5 with measured heights
105+
cache.set('3', 60)
106+
cache.set('4', 60)
107+
cache.set('5', 60)
108+
109+
// Before prepend: anchor was message 3 at viewport top
110+
const anchor = { messageId: '3', offsetFromViewportTop: 0 }
111+
112+
// After prepend: messages 1, 2, 3, 4, 5
113+
cache.set('1', 50)
114+
cache.set('2', 50)
115+
const afterPrepend = msgs(['1', '2', '3', '4', '5'])
116+
117+
// Message 3 is now at offset 50 + 50 = 100
118+
// scrollTop = 100 - 0 = 100
119+
expect(restoreScrollAnchor(anchor, afterPrepend, getId, cache, 40)).toBe(100)
120+
})
121+
122+
it('handles negative offsetFromViewportTop', () => {
123+
const cache = new ChatHeightCache()
124+
cache.set('1', 50)
125+
cache.set('2', 50)
126+
const m = msgs(['1', '2'])
127+
128+
// Message 1 was partially scrolled out: its top was 10px above viewport
129+
const anchor = { messageId: '1', offsetFromViewportTop: -10 }
130+
// Message 1 is at offset 0. scrollTop = 0 - (-10) = 10
131+
expect(restoreScrollAnchor(anchor, m, getId, cache, 50)).toBe(10)
132+
})
133+
})
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import { describe, expect, it } from 'vitest'
2+
import {
3+
ChatHeightCache,
4+
calculateOffsetForIndex,
5+
calculateTotalHeight
6+
} from './chatMeasurement.svelte.js'
7+
8+
type Msg = { id: string }
9+
const getId = (m: Msg) => m.id
10+
const msgs = (ids: string[]): Msg[] => ids.map((id) => ({ id }))
11+
12+
describe('ChatHeightCache', () => {
13+
it('returns undefined for unmeasured messages', () => {
14+
const cache = new ChatHeightCache()
15+
expect(cache.get('a')).toBeUndefined()
16+
})
17+
18+
it('stores and retrieves heights', () => {
19+
const cache = new ChatHeightCache()
20+
cache.set('a', 100)
21+
expect(cache.get('a')).toBe(100)
22+
})
23+
24+
it('returns true when height changes', () => {
25+
const cache = new ChatHeightCache()
26+
expect(cache.set('a', 100)).toBe(true)
27+
expect(cache.set('a', 200)).toBe(true)
28+
})
29+
30+
it('returns false when height is the same', () => {
31+
const cache = new ChatHeightCache()
32+
cache.set('a', 100)
33+
expect(cache.set('a', 100)).toBe(false)
34+
})
35+
36+
it('tracks size correctly', () => {
37+
const cache = new ChatHeightCache()
38+
expect(cache.size).toBe(0)
39+
cache.set('a', 50)
40+
expect(cache.size).toBe(1)
41+
cache.set('b', 60)
42+
expect(cache.size).toBe(2)
43+
})
44+
45+
it('has() works correctly', () => {
46+
const cache = new ChatHeightCache()
47+
expect(cache.has('a')).toBe(false)
48+
cache.set('a', 50)
49+
expect(cache.has('a')).toBe(true)
50+
})
51+
52+
it('delete removes an entry', () => {
53+
const cache = new ChatHeightCache()
54+
cache.set('a', 50)
55+
cache.set('b', 60)
56+
cache.delete('a')
57+
expect(cache.has('a')).toBe(false)
58+
expect(cache.get('a')).toBeUndefined()
59+
expect(cache.size).toBe(1)
60+
})
61+
62+
it('delete on nonexistent key is a no-op', () => {
63+
const cache = new ChatHeightCache()
64+
const vBefore = cache.version
65+
cache.delete('nonexistent')
66+
expect(cache.version).toBe(vBefore)
67+
})
68+
69+
it('clear removes all entries', () => {
70+
const cache = new ChatHeightCache()
71+
cache.set('a', 50)
72+
cache.set('b', 60)
73+
cache.clear()
74+
expect(cache.size).toBe(0)
75+
expect(cache.has('a')).toBe(false)
76+
expect(cache.has('b')).toBe(false)
77+
})
78+
79+
it('version increments on set, delete, clear', () => {
80+
const cache = new ChatHeightCache()
81+
const v0 = cache.version
82+
cache.set('a', 50)
83+
expect(cache.version).toBe(v0 + 1)
84+
cache.set('a', 60)
85+
expect(cache.version).toBe(v0 + 2)
86+
cache.delete('a')
87+
expect(cache.version).toBe(v0 + 3)
88+
cache.set('b', 70)
89+
cache.clear()
90+
expect(cache.version).toBe(v0 + 5)
91+
})
92+
93+
it('version does not increment on no-op set', () => {
94+
const cache = new ChatHeightCache()
95+
cache.set('a', 50)
96+
const v = cache.version
97+
cache.set('a', 50)
98+
expect(cache.version).toBe(v)
99+
})
100+
})
101+
102+
describe('calculateTotalHeight', () => {
103+
it('returns 0 for empty messages', () => {
104+
const cache = new ChatHeightCache()
105+
expect(calculateTotalHeight([], getId, cache, 40)).toBe(0)
106+
})
107+
108+
it('uses estimated height for unmeasured messages', () => {
109+
const cache = new ChatHeightCache()
110+
const m = msgs(['1', '2', '3'])
111+
expect(calculateTotalHeight(m, getId, cache, 40)).toBe(120)
112+
})
113+
114+
it('uses measured height when available', () => {
115+
const cache = new ChatHeightCache()
116+
cache.set('1', 100)
117+
cache.set('2', 50)
118+
const m = msgs(['1', '2', '3'])
119+
// 100 + 50 + 40(estimated)
120+
expect(calculateTotalHeight(m, getId, cache, 40)).toBe(190)
121+
})
122+
123+
it('uses all measured heights when fully measured', () => {
124+
const cache = new ChatHeightCache()
125+
cache.set('1', 80)
126+
cache.set('2', 60)
127+
cache.set('3', 100)
128+
const m = msgs(['1', '2', '3'])
129+
expect(calculateTotalHeight(m, getId, cache, 40)).toBe(240)
130+
})
131+
})
132+
133+
describe('calculateOffsetForIndex', () => {
134+
it('returns 0 for index 0', () => {
135+
const cache = new ChatHeightCache()
136+
const m = msgs(['1', '2', '3'])
137+
expect(calculateOffsetForIndex(m, 0, getId, cache, 40)).toBe(0)
138+
})
139+
140+
it('sums estimated heights for unmeasured messages', () => {
141+
const cache = new ChatHeightCache()
142+
const m = msgs(['1', '2', '3', '4'])
143+
// offset for index 2 = height[0] + height[1] = 40 + 40
144+
expect(calculateOffsetForIndex(m, 2, getId, cache, 40)).toBe(80)
145+
})
146+
147+
it('uses measured heights when available', () => {
148+
const cache = new ChatHeightCache()
149+
cache.set('1', 100)
150+
cache.set('2', 50)
151+
const m = msgs(['1', '2', '3'])
152+
// offset for index 2 = 100 + 50
153+
expect(calculateOffsetForIndex(m, 2, getId, cache, 40)).toBe(150)
154+
})
155+
156+
it('mixes measured and estimated', () => {
157+
const cache = new ChatHeightCache()
158+
cache.set('2', 80)
159+
const m = msgs(['1', '2', '3', '4'])
160+
// offset for index 3 = 40(est) + 80(measured) + 40(est)
161+
expect(calculateOffsetForIndex(m, 3, getId, cache, 40)).toBe(160)
162+
})
163+
164+
it('clamps to messages length', () => {
165+
const cache = new ChatHeightCache()
166+
const m = msgs(['1', '2'])
167+
// index 10 is beyond array — should sum all heights
168+
expect(calculateOffsetForIndex(m, 10, getId, cache, 40)).toBe(80)
169+
})
170+
171+
it('returns 0 for empty messages', () => {
172+
const cache = new ChatHeightCache()
173+
expect(calculateOffsetForIndex([], 5, getId, cache, 40)).toBe(0)
174+
})
175+
})

0 commit comments

Comments
 (0)