mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* 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>
637 lines
24 KiB
TypeScript
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();
|
|
});
|
|
});
|