|
1 | | -import { describe, it, expect, vi } from "vitest"; |
2 | | -import { render, screen, fireEvent } from "@testing-library/react"; |
| 1 | +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; |
| 2 | +import { render, screen, fireEvent, act } from "@testing-library/react"; |
3 | 3 | import { SessionPicker } from "./SessionPicker"; |
4 | 4 | import type { SessionInfo } from "../types"; |
5 | 5 |
|
| 6 | +type IOCallback = (entries: IntersectionObserverEntry[], observer: IntersectionObserver) => void; |
| 7 | + |
| 8 | +class FakeIO { |
| 9 | + static last: FakeIO | null = null; |
| 10 | + cb: IOCallback; |
| 11 | + observed: Element[] = []; |
| 12 | + observe = (el: Element) => { |
| 13 | + this.observed.push(el); |
| 14 | + }; |
| 15 | + unobserve = vi.fn(); |
| 16 | + disconnect = vi.fn(); |
| 17 | + takeRecords = vi.fn(() => [] as IntersectionObserverEntry[]); |
| 18 | + root = null; |
| 19 | + rootMargin = ""; |
| 20 | + thresholds: number[] = []; |
| 21 | + constructor(cb: IOCallback) { |
| 22 | + this.cb = cb; |
| 23 | + FakeIO.last = this; |
| 24 | + } |
| 25 | + trigger(els: Element[]) { |
| 26 | + const entries = els.map( |
| 27 | + (el) => |
| 28 | + ({ |
| 29 | + target: el, |
| 30 | + isIntersecting: true, |
| 31 | + intersectionRatio: 1, |
| 32 | + boundingClientRect: new DOMRect(), |
| 33 | + intersectionRect: new DOMRect(), |
| 34 | + rootBounds: new DOMRect(), |
| 35 | + time: 0, |
| 36 | + }) as unknown as IntersectionObserverEntry, |
| 37 | + ); |
| 38 | + this.cb(entries, this as unknown as IntersectionObserver); |
| 39 | + } |
| 40 | +} |
| 41 | + |
6 | 42 | function makeSession(overrides: Partial<SessionInfo> = {}): SessionInfo { |
7 | 43 | return { |
8 | 44 | path: "/home/user/.claude/projects/proj/session1.jsonl", |
@@ -191,4 +227,60 @@ describe("SessionPicker", () => { |
191 | 227 | ); |
192 | 228 | expect(screen.queryByText(/Discovering sessions/)).not.toBeInTheDocument(); |
193 | 229 | }); |
| 230 | + |
| 231 | + describe("viewport-aware visibility tracking", () => { |
| 232 | + beforeEach(() => { |
| 233 | + FakeIO.last = null; |
| 234 | + (globalThis as { IntersectionObserver: unknown }).IntersectionObserver = FakeIO; |
| 235 | + vi.useFakeTimers(); |
| 236 | + }); |
| 237 | + afterEach(() => { |
| 238 | + vi.useRealTimers(); |
| 239 | + }); |
| 240 | + |
| 241 | + it("observes each session card and reports visible paths via onVisiblePathsChange", () => { |
| 242 | + const onVisible = vi.fn(); |
| 243 | + const sessions = [ |
| 244 | + makeSession({ path: "/a.jsonl", session_id: "a", first_message: "alpha" }), |
| 245 | + makeSession({ path: "/b.jsonl", session_id: "b", first_message: "beta" }), |
| 246 | + ]; |
| 247 | + render( |
| 248 | + <SessionPicker |
| 249 | + sessions={sessions} |
| 250 | + loading={false} |
| 251 | + searchQuery="" |
| 252 | + selectedIndex={0} |
| 253 | + onSelect={vi.fn()} |
| 254 | + onSearchChange={vi.fn()} |
| 255 | + onVisiblePathsChange={onVisible} |
| 256 | + />, |
| 257 | + ); |
| 258 | + expect(FakeIO.last).not.toBeNull(); |
| 259 | + expect(FakeIO.last!.observed).toHaveLength(2); |
| 260 | + |
| 261 | + act(() => { |
| 262 | + FakeIO.last!.trigger(FakeIO.last!.observed); |
| 263 | + vi.advanceTimersByTime(150); |
| 264 | + }); |
| 265 | + expect(onVisible).toHaveBeenCalledExactlyOnceWith( |
| 266 | + expect.arrayContaining(["/a.jsonl", "/b.jsonl"]), |
| 267 | + ); |
| 268 | + }); |
| 269 | + |
| 270 | + it("works without onVisiblePathsChange (no-op observer)", () => { |
| 271 | + const sessions = [makeSession()]; |
| 272 | + expect(() => |
| 273 | + render( |
| 274 | + <SessionPicker |
| 275 | + sessions={sessions} |
| 276 | + loading={false} |
| 277 | + searchQuery="" |
| 278 | + selectedIndex={0} |
| 279 | + onSelect={vi.fn()} |
| 280 | + onSearchChange={vi.fn()} |
| 281 | + />, |
| 282 | + ), |
| 283 | + ).not.toThrow(); |
| 284 | + }); |
| 285 | + }); |
194 | 286 | }); |
0 commit comments