Files
OwnCord/Client/tauri-client/tests/unit/stream-preview.test.ts
T
J3vbandClaude c3837fa32c fix(client): batch of 15 client correctness fixes (#1367)
* fix(client): 1 defect(s) (OC-0078)

* fix(client): 1 defect(s) (OC-0147)

* fix(client): 1 defect(s) (OC-0141)

* fix(voice): 1 defect(s) (OC-0125)

* fix(client): 1 defect(s) (OC-0138)

* fix(client): 1 defect(s) (OC-0136)

* fix(client): 1 defect(s) (OC-0121)

* fix(voice): 1 defect(s) (OC-0132)

* fix(client): 1 defect(s) (OC-0057)

* fix(client): 1 defect(s) (OC-0122)

* fix(client): 1 defect(s) (OC-0130)

* fix(client): 1 defect(s) (OC-0060)

* fix(client): 1 defect(s) (OC-0123)

* fix(client): 1 defect(s) (OC-0124)

* fix(ws): 1 defect(s) (OC-0056)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-14 08:11:16 +02:00

637 lines
24 KiB
TypeScript

import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from "vitest";
// Mock livekitSession before importing streamPreview
const mockGetRemoteVideoStream =
vi.fn<(uid: number, type: "camera" | "screenshare") => MediaStream | null>();
vi.mock("@lib/livekitSession", () => ({
getRemoteVideoStream: (uid: number, type: "camera" | "screenshare") =>
mockGetRemoteVideoStream(uid, type),
setUserVolume: vi.fn(),
getUserVolume: vi.fn(() => 1),
}));
const mockVoiceStoreState = {
currentChannelId: null as number | null,
localDeafened: false,
};
vi.mock("@stores/voice.store", () => ({
voiceStore: {
getState: () => mockVoiceStoreState,
},
}));
vi.mock("@lib/icons", () => ({
createIcon: (name: string, size: number) => {
const el = document.createElement("span");
el.dataset.icon = name;
el.dataset.size = String(size);
return el;
},
}));
import { attachStreamPreview, attachScrollCollapse } from "../../src/lib/streamPreview";
// jsdom doesn't implement HTMLVideoElement.play() — provide a mock
beforeAll(() => {
HTMLVideoElement.prototype.play = vi.fn(() => Promise.resolve());
});
/** Get the preview sibling div after a row (preview is inserted as next sibling). */
function getPreview(row: HTMLElement): HTMLElement | null {
const next = row.nextElementSibling;
return next !== null && next.classList.contains("vu-preview") ? (next as HTMLElement) : null;
}
function createRow(userId: number): HTMLElement {
const row = document.createElement("div");
row.className = "voice-user-item";
row.dataset.voiceUid = String(userId);
document.body.appendChild(row);
return row;
}
function createMockMediaStream(trackState: "live" | "ended" = "live"): MediaStream {
const track = new EventTarget() as MediaStreamTrack;
Object.defineProperty(track, "readyState", { value: trackState });
Object.defineProperty(track, "kind", { value: "video" });
const stream = {
getVideoTracks: () => [track],
getTracks: () => [track],
} as unknown as MediaStream;
return stream;
}
describe("streamPreview", () => {
let ac: AbortController;
beforeEach(() => {
ac = new AbortController();
mockGetRemoteVideoStream.mockReset();
mockVoiceStoreState.currentChannelId = null;
mockVoiceStoreState.localDeafened = false;
vi.useFakeTimers();
});
afterEach(() => {
ac.abort();
vi.useRealTimers();
document.body.innerHTML = "";
});
// T3: getRemoteVideoStream room null → null (via mock returning null)
it("shows placeholder when getRemoteVideoStream returns null", () => {
mockGetRemoteVideoStream.mockReturnValue(null);
const row = createRow(42);
attachStreamPreview(row, 42, "Alice", false, true, ac.signal);
row.dispatchEvent(new MouseEvent("mouseenter"));
vi.advanceTimersByTime(300);
const placeholder = getPreview(row)?.querySelector(".vu-preview-placeholder") ?? null;
expect(placeholder).not.toBeNull();
expect(placeholder?.textContent).toContain("Join to preview");
});
// T7: getRemoteVideoStream success → shows video
it("shows video when getRemoteVideoStream returns a stream", () => {
const stream = createMockMediaStream();
mockGetRemoteVideoStream.mockReturnValue(stream);
const row = createRow(42);
attachStreamPreview(row, 42, "Alice", false, true, ac.signal);
row.dispatchEvent(new MouseEvent("mouseenter"));
vi.advanceTimersByTime(300);
const video = getPreview(row)?.querySelector("video") ?? null;
expect(video).not.toBeNull();
expect(video?.srcObject).toBe(stream);
expect(video?.muted).toBe(true);
});
// T8: Hover creates preview video element
it("creates .vu-preview container on hover", () => {
mockGetRemoteVideoStream.mockReturnValue(createMockMediaStream());
const row = createRow(42);
attachStreamPreview(row, 42, "Alice", false, true, ac.signal);
row.dispatchEvent(new MouseEvent("mouseenter"));
vi.advanceTimersByTime(300);
expect(getPreview(row)).not.toBeNull();
});
// T9: Hover with no stream → shows placeholder
it("shows placeholder with icon and actionable text", () => {
mockGetRemoteVideoStream.mockReturnValue(null);
const row = createRow(42);
attachStreamPreview(row, 42, "Alice", true, false, ac.signal);
row.dispatchEvent(new MouseEvent("mouseenter"));
vi.advanceTimersByTime(300);
const placeholder = getPreview(row)?.querySelector(".vu-preview-placeholder") ?? null;
expect(placeholder).not.toBeNull();
expect(placeholder?.getAttribute("role")).toBe("button");
expect(placeholder?.getAttribute("aria-label")).toContain("Join channel to preview");
});
// T10: Mouseleave removes preview
it("removes preview on mouseleave after animation", () => {
mockGetRemoteVideoStream.mockReturnValue(createMockMediaStream());
const row = createRow(42);
attachStreamPreview(row, 42, "Alice", false, true, ac.signal);
row.dispatchEvent(new MouseEvent("mouseenter"));
vi.advanceTimersByTime(300);
expect(getPreview(row)).not.toBeNull();
row.dispatchEvent(new MouseEvent("mouseleave"));
vi.advanceTimersByTime(150 + 200); // 150ms delayed check + 200ms animation
expect(getPreview(row)).toBeNull();
});
// T11: Debounce: rapid hover/unhover → no preview
it("does not show preview if mouse leaves within debounce period", () => {
mockGetRemoteVideoStream.mockReturnValue(createMockMediaStream());
const row = createRow(42);
attachStreamPreview(row, 42, "Alice", false, true, ac.signal);
row.dispatchEvent(new MouseEvent("mouseenter"));
vi.advanceTimersByTime(100); // Only 100ms, below 300ms debounce
row.dispatchEvent(new MouseEvent("mouseleave"));
vi.advanceTimersByTime(300);
expect(getPreview(row)).toBeNull();
});
// T13: Track ended → swaps to placeholder
it("swaps to placeholder when track ends", () => {
const stream = createMockMediaStream();
const track = stream.getVideoTracks()[0]!;
mockGetRemoteVideoStream.mockReturnValue(stream);
const row = createRow(42);
attachStreamPreview(row, 42, "Alice", false, true, ac.signal);
row.dispatchEvent(new MouseEvent("mouseenter"));
vi.advanceTimersByTime(300);
expect(getPreview(row)?.querySelector("video") ?? null).not.toBeNull();
// Simulate track ending
track.dispatchEvent(new Event("ended"));
expect(getPreview(row)?.querySelector("video") ?? null).toBeNull();
expect(getPreview(row)?.querySelector(".vu-preview-placeholder") ?? null).not.toBeNull();
});
// T15: Focus/blur mirrors hover/leave
it("shows preview on focusin and hides on focusout", () => {
mockGetRemoteVideoStream.mockReturnValue(createMockMediaStream());
const row = createRow(42);
attachStreamPreview(row, 42, "Alice", false, true, ac.signal);
row.dispatchEvent(new FocusEvent("focusin"));
vi.advanceTimersByTime(300);
expect(getPreview(row)).not.toBeNull();
row.dispatchEvent(new FocusEvent("focusout"));
vi.advanceTimersByTime(200);
expect(getPreview(row)).toBeNull();
});
// T16: ARIA labels present on video
it("sets aria-label on video element", () => {
mockGetRemoteVideoStream.mockReturnValue(createMockMediaStream());
const row = createRow(42);
attachStreamPreview(row, 42, "Alice", false, true, ac.signal);
row.dispatchEvent(new MouseEvent("mouseenter"));
vi.advanceTimersByTime(300);
const video = getPreview(row)?.querySelector("video") ?? null;
expect(video?.getAttribute("aria-label")).toBe("Stream preview for Alice");
});
// T16b: Screen reader announcement
it("includes screen reader announcement", () => {
mockGetRemoteVideoStream.mockReturnValue(createMockMediaStream());
const row = createRow(42);
attachStreamPreview(row, 42, "Alice", false, true, ac.signal);
row.dispatchEvent(new MouseEvent("mouseenter"));
vi.advanceTimersByTime(300);
const srAnnouncement = getPreview(row)?.querySelector(".sr-only") ?? null;
expect(srAnnouncement?.textContent).toContain("Showing stream preview for Alice");
});
// T17: Camera uses preview-camera class
it("uses preview-camera class for camera streams", () => {
mockGetRemoteVideoStream.mockReturnValue(createMockMediaStream());
const row = createRow(42);
attachStreamPreview(row, 42, "Alice", false, true, ac.signal);
row.dispatchEvent(new MouseEvent("mouseenter"));
vi.advanceTimersByTime(300);
const video = getPreview(row)?.querySelector("video") ?? null;
expect(video?.className).toBe("preview-camera");
});
// T18: Screenshare uses preview-screen class
it("uses preview-screen class for screenshare streams", () => {
const stream = createMockMediaStream();
mockGetRemoteVideoStream.mockImplementation((uid, type) =>
type === "screenshare" ? stream : null,
);
const row = createRow(42);
attachStreamPreview(row, 42, "Alice", true, false, ac.signal);
row.dispatchEvent(new MouseEvent("mouseenter"));
vi.advanceTimersByTime(300);
const video = getPreview(row)?.querySelector("video") ?? null;
expect(video?.className).toBe("preview-screen");
});
// T4+T5+T6: getRemoteVideoStream various null returns → placeholder
it("tries screenshare first, falls back to camera", () => {
const cameraStream = createMockMediaStream();
mockGetRemoteVideoStream.mockImplementation((uid, type) =>
type === "camera" ? cameraStream : null,
);
const row = createRow(42);
attachStreamPreview(row, 42, "Alice", true, true, ac.signal);
row.dispatchEvent(new MouseEvent("mouseenter"));
vi.advanceTimersByTime(300);
// Should have tried screenshare first, then camera
expect(mockGetRemoteVideoStream).toHaveBeenCalledWith(42, "screenshare");
expect(mockGetRemoteVideoStream).toHaveBeenCalledWith(42, "camera");
// Should show camera stream since screenshare returned null
const video = getPreview(row)?.querySelector("video") ?? null;
expect(video?.srcObject).toBe(cameraStream);
expect(video?.className).toBe("preview-camera");
});
// Abort signal cleanup
it("cleans up on abort signal", () => {
mockGetRemoteVideoStream.mockReturnValue(createMockMediaStream());
const row = createRow(42);
attachStreamPreview(row, 42, "Alice", false, true, ac.signal);
row.dispatchEvent(new MouseEvent("mouseenter"));
vi.advanceTimersByTime(300);
expect(getPreview(row)).not.toBeNull();
ac.abort();
expect(getPreview(row)).toBeNull();
});
// OC-0124: hidePreview overwrites state.animation without clearing the
// timer already sitting there. When stopPreviewDelayed's 150ms grace timer
// (T1) is still pending and something else (scroll/focusout) calls
// hidePreview directly, T1 survives uncancelled, fires later, and calls
// hidePreview a second time — orphaning that call's own 200ms removal
// timer in turn. One of those orphaned removal timers eventually runs
// `previewTimers.delete(row)` against whatever state a *later* hover
// installed, deleting it before its debounce ever fires. showPreview then
// finds no state to store trackCleanup on, so the ended/mute listeners it
// just registered on the live MediaStreamTrack become permanently
// unreachable by hidePreview, clearPreviewState, and the abort handler.
it("does not leak track listeners when hidePreview interrupts a pending stopPreviewDelayed timer (OC-0124)", () => {
const stream = createMockMediaStream();
const track = stream.getVideoTracks()[0]!;
const removeSpy = vi.spyOn(track, "removeEventListener");
mockGetRemoteVideoStream.mockReturnValue(stream);
const row = createRow(42);
attachStreamPreview(row, 42, "Alice", false, true, ac.signal);
// t=0: hover -> debounce armed.
row.dispatchEvent(new MouseEvent("mouseenter"));
// t=300: debounce fires -> preview shown, ended/mute listeners #1 registered.
vi.advanceTimersByTime(300);
expect(getPreview(row)).not.toBeNull();
// t=400: mouse leaves -> stopPreviewDelayed arms its 150ms grace timer.
vi.advanceTimersByTime(100);
row.dispatchEvent(new MouseEvent("mouseleave"));
// t=450: a second, independent trigger (e.g. focusout from a Tab, or a
// scroll-collapse) calls hidePreview directly while the grace timer from
// t=400 is still pending. This must cancel that timer, not just
// overwrite the handle to it.
vi.advanceTimersByTime(50);
row.dispatchEvent(new FocusEvent("focusout"));
// trackCleanup #1 runs synchronously inside this hidePreview call.
expect(removeSpy).toHaveBeenCalledTimes(2); // "ended" + "mute" for listener #1
// t=650: the removal timer armed by the t=450 hidePreview call fires and
// tears down the (now empty) preview + state.
vi.advanceTimersByTime(200);
expect(getPreview(row)).toBeNull();
// t=660: user hovers again -> a fresh debounce/state is installed.
vi.advanceTimersByTime(10);
row.dispatchEvent(new MouseEvent("mouseenter"));
// t=960: the new debounce fires and showPreview runs again, registering
// ended/mute listeners #2 on the same track and trying to store
// trackCleanup on the freshly-installed state.
vi.advanceTimersByTime(300);
expect(getPreview(row)).not.toBeNull();
// Close the second preview. If the fresh state survived intact,
// trackCleanup #2 fires here, removing listener set #2 as well.
row.dispatchEvent(new FocusEvent("focusout"));
expect(removeSpy).toHaveBeenCalledTimes(4); // "ended" + "mute" for BOTH listener sets
});
// Abort-listener accumulation (leak fix)
it("registers only one abort listener per signal, not one per attach call", () => {
mockGetRemoteVideoStream.mockReturnValue(null);
const addSpy = vi.spyOn(ac.signal, "addEventListener");
const row1 = createRow(1);
const row2 = createRow(2);
attachStreamPreview(row1, 1, "Alice", false, true, ac.signal);
attachStreamPreview(row2, 2, "Bob", false, true, ac.signal);
// A re-render re-attaches the same row to the same sidebar-lifetime signal.
attachStreamPreview(row1, 1, "Alice", false, true, ac.signal);
const abortCalls = addSpy.mock.calls.filter(([type]) => type === "abort");
expect(abortCalls).toHaveLength(1);
});
// v091: a structural re-render (clearChildren + rebuild) detaches the old
// row from the DOM without running any preview cleanup on it. Without
// retiring it, that row (and any live track-event listeners it registered)
// is retained by the shared rowsBySignal map for the sidebar's entire
// lifetime instead of being cleaned up as soon as the next render proves
// it's dead.
it("cleans up a superseded row's track listeners as soon as the next attach call sees it (v091)", () => {
const stream = createMockMediaStream();
const track = stream.getVideoTracks()[0]!;
const removeSpy = vi.spyOn(track, "removeEventListener");
mockGetRemoteVideoStream.mockReturnValue(stream);
const row1 = createRow(1);
attachStreamPreview(row1, 1, "Alice", false, true, ac.signal);
row1.dispatchEvent(new MouseEvent("mouseenter"));
vi.advanceTimersByTime(300);
expect(getPreview(row1)).not.toBeNull();
// Simulate renderChannels()'s clearChildren + rebuild: the old row (and
// its preview sibling) is removed from the DOM directly, without going
// through hidePreview/mouseleave.
row1.remove();
expect(removeSpy).not.toHaveBeenCalled();
// The next render re-attaches a fresh row for (possibly) the same user
// to the same sidebar-lifetime signal.
const row2 = createRow(1);
attachStreamPreview(row2, 1, "Alice", false, true, ac.signal);
// Retiring the dead row1 entry must have run its cleanup immediately —
// not deferred until the signal eventually aborts.
expect(removeSpy).toHaveBeenCalledWith("ended", expect.any(Function));
expect(removeSpy).toHaveBeenCalledWith("mute", expect.any(Function));
});
it("leaves another user's live row alone when a new row attaches", () => {
mockGetRemoteVideoStream.mockReturnValue(createMockMediaStream());
const row1 = createRow(1);
attachStreamPreview(row1, 1, "Alice", false, true, ac.signal);
row1.dispatchEvent(new MouseEvent("mouseenter"));
vi.advanceTimersByTime(300);
expect(getPreview(row1)).not.toBeNull();
// A second row attaches to the same signal while row1 is still live.
const row2 = createRow(2);
attachStreamPreview(row2, 2, "Bob", false, true, ac.signal);
// row1's preview must be untouched — only the row a re-render replaced
// for the *same* user is retired.
expect(getPreview(row1)).not.toBeNull();
});
// ChannelSidebar builds a whole category subtree (rows included) and only
// appends it to the live channel list afterwards, so every row is still
// disconnected when attachStreamPreview runs. Deciding which tracked rows
// are dead by liveness at that moment therefore drops rows that are about
// to be inserted, losing their abort-time cleanup entirely (v091).
it("still tracks rows attached before their subtree is inserted (v091)", () => {
const stream = createMockMediaStream();
const track = stream.getVideoTracks()[0]!;
const removeSpy = vi.spyOn(track, "removeEventListener");
mockGetRemoteVideoStream.mockReturnValue(stream);
const group = document.createElement("div");
const row1 = document.createElement("div");
row1.className = "voice-user-item";
const row2 = document.createElement("div");
row2.className = "voice-user-item";
group.appendChild(row1);
group.appendChild(row2);
attachStreamPreview(row1, 1, "Alice", false, true, ac.signal);
attachStreamPreview(row2, 2, "Bob", false, true, ac.signal);
document.body.appendChild(group);
row1.dispatchEvent(new MouseEvent("mouseenter"));
vi.advanceTimersByTime(300);
expect(getPreview(row1)).not.toBeNull();
ac.abort();
expect(getPreview(row1)).toBeNull();
expect(removeSpy).toHaveBeenCalledWith("ended", expect.any(Function));
expect(removeSpy).toHaveBeenCalledWith("mute", expect.any(Function));
});
it("still cleans up every row attached to a signal when it aborts", () => {
mockGetRemoteVideoStream.mockReturnValue(createMockMediaStream());
const row1 = createRow(1);
const row2 = createRow(2);
attachStreamPreview(row1, 1, "Alice", false, true, ac.signal);
attachStreamPreview(row2, 2, "Bob", false, true, ac.signal);
row1.dispatchEvent(new MouseEvent("mouseenter"));
vi.advanceTimersByTime(300);
row2.dispatchEvent(new MouseEvent("mouseenter"));
vi.advanceTimersByTime(300);
expect(getPreview(row1)).not.toBeNull();
expect(getPreview(row2)).not.toBeNull();
ac.abort();
expect(getPreview(row1)).toBeNull();
expect(getPreview(row2)).toBeNull();
});
// Track mute event → placeholder
it("swaps to placeholder on track mute event", () => {
const stream = createMockMediaStream();
const track = stream.getVideoTracks()[0]!;
mockGetRemoteVideoStream.mockReturnValue(stream);
const row = createRow(42);
attachStreamPreview(row, 42, "Alice", false, true, ac.signal);
row.dispatchEvent(new MouseEvent("mouseenter"));
vi.advanceTimersByTime(300);
track.dispatchEvent(new Event("mute"));
expect(getPreview(row)?.querySelector("video") ?? null).toBeNull();
expect(getPreview(row)?.querySelector(".vu-preview-placeholder") ?? null).not.toBeNull();
});
});
describe("streamPreview — channel-aware placeholder", () => {
let ac: AbortController;
beforeEach(() => {
ac = new AbortController();
mockGetRemoteVideoStream.mockReset();
mockVoiceStoreState.currentChannelId = null;
mockVoiceStoreState.localDeafened = false;
vi.useFakeTimers();
});
afterEach(() => {
ac.abort();
vi.useRealTimers();
document.body.innerHTML = "";
});
it("shows 'Join to preview' when NOT in a voice channel and stream is null", () => {
mockGetRemoteVideoStream.mockReturnValue(null);
mockVoiceStoreState.currentChannelId = null;
const row = createRow(42);
attachStreamPreview(row, 42, "Alice", false, true, ac.signal);
row.dispatchEvent(new MouseEvent("mouseenter"));
vi.advanceTimersByTime(300);
const placeholder = row.nextElementSibling?.querySelector(".vu-preview-placeholder");
expect(placeholder?.textContent).toContain("Join to preview");
});
it("does NOT show 'Join to preview' when already in a voice channel and stream is null", () => {
mockGetRemoteVideoStream.mockReturnValue(null);
mockVoiceStoreState.currentChannelId = 1;
const row = createRow(42);
attachStreamPreview(row, 42, "Alice", false, true, ac.signal);
row.dispatchEvent(new MouseEvent("mouseenter"));
vi.advanceTimersByTime(300);
const placeholder = row.nextElementSibling?.querySelector(".vu-preview-placeholder");
expect(placeholder).not.toBeNull();
// Should NOT say "Join to preview" when already in channel
expect(placeholder?.textContent).not.toContain("Join to preview");
});
it("shows 'Stream unavailable' when in channel but no stream available", () => {
mockGetRemoteVideoStream.mockReturnValue(null);
mockVoiceStoreState.currentChannelId = 1;
const row = createRow(42);
attachStreamPreview(row, 42, "Alice", false, true, ac.signal);
row.dispatchEvent(new MouseEvent("mouseenter"));
vi.advanceTimersByTime(300);
const placeholder = row.nextElementSibling?.querySelector(".vu-preview-placeholder");
expect(placeholder?.textContent).toContain("Stream unavailable");
});
it("uses onClickWatch when in channel and stream is null but onClickWatch provided", () => {
mockGetRemoteVideoStream.mockReturnValue(null);
mockVoiceStoreState.currentChannelId = 1;
const onClickJoin = vi.fn();
const onClickWatch = vi.fn();
const row = createRow(42);
attachStreamPreview(row, 42, "Alice", false, true, ac.signal, onClickJoin, onClickWatch);
row.dispatchEvent(new MouseEvent("mouseenter"));
vi.advanceTimersByTime(300);
const placeholder = row.nextElementSibling?.querySelector(
".vu-preview-placeholder",
) as HTMLElement;
placeholder?.click();
expect(onClickWatch).toHaveBeenCalledOnce();
expect(onClickJoin).not.toHaveBeenCalled();
});
it("uses onClickJoin when NOT in channel and stream is null", () => {
mockGetRemoteVideoStream.mockReturnValue(null);
mockVoiceStoreState.currentChannelId = null;
const onClickJoin = vi.fn();
const onClickWatch = vi.fn();
const row = createRow(42);
attachStreamPreview(row, 42, "Alice", false, true, ac.signal, onClickJoin, onClickWatch);
row.dispatchEvent(new MouseEvent("mouseenter"));
vi.advanceTimersByTime(300);
const placeholder = row.nextElementSibling?.querySelector(
".vu-preview-placeholder",
) as HTMLElement;
placeholder?.click();
expect(onClickJoin).toHaveBeenCalledOnce();
expect(onClickWatch).not.toHaveBeenCalled();
});
});
describe("attachScrollCollapse", () => {
let ac: AbortController;
beforeEach(() => {
ac = new AbortController();
vi.useFakeTimers();
mockGetRemoteVideoStream.mockReset();
});
afterEach(() => {
ac.abort();
vi.useRealTimers();
document.body.innerHTML = "";
});
// T14: Scroll collapses open preview
it("collapses open previews on scroll", () => {
mockGetRemoteVideoStream.mockReturnValue(createMockMediaStream());
const container = document.createElement("div");
container.className = "voice-users-list";
document.body.appendChild(container);
const row = createRow(42);
container.appendChild(row);
attachStreamPreview(row, 42, "Alice", false, true, ac.signal);
attachScrollCollapse(container, ac.signal);
// Show preview
row.dispatchEvent(new MouseEvent("mouseenter"));
vi.advanceTimersByTime(300);
expect(getPreview(row)).not.toBeNull();
// Scroll
container.dispatchEvent(new Event("scroll"));
vi.advanceTimersByTime(200);
expect(getPreview(row)).toBeNull();
});
});