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);

    // Exactly two registrations regardless of attach count: the module's own
    // guarded listener plus the single internal bookkeeping listener jsdom
    // registers per signal for `{ signal }`-scoped DOM listeners. A
    // per-attach leak would register one per attach call.
    const abortCalls = addSpy.mock.calls.filter(([type]) => type === "abort");
    expect(abortCalls).toHaveLength(2);
  });

  // 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();
  });
});
