Files
OwnCord/Client/tauri-client/tests/unit/video-grid.test.ts
T
Claude 7c2c9843b7 fix(client): make the screenshare volume slider actually change volume
Three defects made the screenshare tile's volume slider ineffective:

- The 0-200 slider mapped to element volume /200 clamped to [0,1], while
  the element attached at 1.0 — dragging the upper half did nothing. The
  screenshare slider is now 0-100 with 100 = 1.0 (HTMLAudioElement.volume
  cannot exceed 1.0; mic tiles keep the 0-200 boost range via LiveKit's
  GainNode-backed setVolume).
- Setting a volume before the screenshare audio track attached was silently
  dropped. The per-user volume now persists independently of the element
  map and is applied on attach.
- Changing the master output volume overwrote per-user screenshare volumes
  with just the master multiplier; they now scale together.

The slider and mute button also initialize from persisted state when a tile
is rebuilt, and unmuting via the button re-applies the restored volume.

Fixes #121

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwtnpHAoSFr1ZibQgQkNQK
2026-07-19 10:50:10 +00:00

801 lines
29 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// ---------------------------------------------------------------------------
// Mocks — must be declared before importing VideoGrid
// ---------------------------------------------------------------------------
const mockMuteScreenshareAudio = vi.fn();
const mockSetScreenshareAudioVolume = vi.fn();
const mockSetUserVolume = vi.fn();
const mockGetScreenshareAudioMuted = vi.fn((_userId?: unknown) => false);
const mockGetScreenshareAudioVolume = vi.fn((_userId?: unknown) => 1);
vi.mock("@lib/livekitSession", () => ({
muteScreenshareAudio: (...args: unknown[]) => mockMuteScreenshareAudio(...args),
setScreenshareAudioVolume: (...args: unknown[]) => mockSetScreenshareAudioVolume(...args),
setUserVolume: (...args: unknown[]) => mockSetUserVolume(...args),
getScreenshareAudioMuted: (userId: unknown) => mockGetScreenshareAudioMuted(userId),
getScreenshareAudioVolume: (userId: unknown) => mockGetScreenshareAudioVolume(userId),
}));
// ---------------------------------------------------------------------------
// Imports
// ---------------------------------------------------------------------------
import {
createVideoGrid,
computeGridLayout,
type VideoGridComponent,
type TileConfig,
} from "../../src/components/VideoGrid";
/** Minimal MediaStream stub for testing. */
function fakeStream(): MediaStream {
return { getTracks: () => [], getVideoTracks: () => [] } as unknown as MediaStream;
}
/**
* MediaStream stub with a controllable video track.
* Allows testing track lifecycle (ended/mute events).
*/
function fakeStreamWithTrack(): {
stream: MediaStream;
track: { listeners: Record<string, Array<() => void>>; dispatchEvent(type: string): void };
} {
const listeners: Record<string, Array<() => void>> = {};
const track = {
listeners,
id: `track-${Math.random()}`,
addEventListener(type: string, fn: () => void) {
(listeners[type] ??= []).push(fn);
},
removeEventListener(type: string, fn: () => void) {
const arr = listeners[type];
if (arr) {
const idx = arr.indexOf(fn);
if (idx >= 0) arr.splice(idx, 1);
}
},
dispatchEvent(type: string) {
for (const fn of listeners[type] ?? []) fn();
},
};
const stream = {
getTracks: () => [track],
getVideoTracks: () => [track],
} as unknown as MediaStream;
return { stream, track };
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeTileConfig(overrides: Partial<TileConfig> = {}): TileConfig {
return {
isSelf: false,
audioUserId: 42,
isScreenshare: false,
...overrides,
};
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("VideoGrid", () => {
let container: HTMLDivElement;
let grid: VideoGridComponent;
beforeEach(() => {
vi.clearAllMocks();
// ResizeObserver is not available in JSDOM
globalThis.ResizeObserver ??= class {
observe(): void {
/* noop */
}
unobserve(): void {
/* noop */
}
disconnect(): void {
/* noop */
}
} as unknown as typeof ResizeObserver;
container = document.createElement("div");
grid = createVideoGrid();
grid.mount(container);
});
afterEach(() => {
grid.destroy?.();
});
it("mount creates a grid container with data-testid", () => {
const root = container.querySelector("[data-testid='video-grid']");
expect(root).not.toBeNull();
expect(root!.classList.contains("video-grid")).toBe(true);
});
it("addStream creates video element and username label", () => {
grid.addStream(1, "Alice", fakeStream());
const cell = container.querySelector(".video-cell");
expect(cell).not.toBeNull();
expect(cell!.getAttribute("data-user-id")).toBe("1");
const video = cell!.querySelector("video");
expect(video).not.toBeNull();
expect(video!.muted).toBe(true);
const label = cell!.querySelector(".video-username");
expect(label).not.toBeNull();
expect(label!.textContent).toBe("Alice");
});
it("addStream replaces existing cell for same userId", () => {
grid.addStream(1, "Alice", fakeStream());
grid.addStream(1, "Alice-v2", fakeStream());
const cells = container.querySelectorAll(".video-cell");
expect(cells.length).toBe(1);
expect(cells[0]!.querySelector(".video-username")!.textContent).toBe("Alice-v2");
});
it("removeStream removes the cell", () => {
grid.addStream(1, "Alice", fakeStream());
expect(container.querySelectorAll(".video-cell").length).toBe(1);
grid.removeStream(1);
expect(container.querySelectorAll(".video-cell").length).toBe(0);
});
it("removeStream nullifies video srcObject", () => {
const stream = fakeStream();
grid.addStream(1, "Alice", stream);
const video = container.querySelector("video")!;
expect(video.srcObject).toBe(stream);
grid.removeStream(1);
// Video was removed from DOM, but we can verify hasStreams is false
expect(grid.hasStreams()).toBe(false);
});
it("hasStreams returns false when empty", () => {
expect(grid.hasStreams()).toBe(false);
});
it("hasStreams returns true when streams are present", () => {
grid.addStream(1, "Alice", fakeStream());
expect(grid.hasStreams()).toBe(true);
});
describe("computeGridLayout — Discord-style tile sizing", () => {
it("returns zero-sized tiles for 0 tile count", () => {
const layout = computeGridLayout(800, 600, 0);
expect(layout.tileW).toBe(0);
expect(layout.tileH).toBe(0);
});
it("1 tile fills the container (width-constrained)", () => {
// Wide container: tile should be width-limited
const layout = computeGridLayout(800, 600, 1);
expect(layout.cols).toBe(1);
expect(layout.rows).toBe(1);
expect(layout.tileW).toBeGreaterThan(0);
expect(layout.tileH).toBeGreaterThan(0);
// Verify 16:9 ratio (within 1px rounding)
expect(Math.abs(layout.tileW / layout.tileH - 16 / 9)).toBeLessThan(0.1);
});
it("1 tile in a tall container is height-constrained", () => {
// Tall container: tile should be height-limited
const layout = computeGridLayout(400, 800, 1);
expect(layout.cols).toBe(1);
expect(layout.tileH).toBeLessThanOrEqual(800 - 16); // minus padding
});
it("2 tiles use 2 columns in a wide container", () => {
const layout = computeGridLayout(1200, 400, 2);
expect(layout.cols).toBe(2);
expect(layout.rows).toBe(1);
});
it("4 tiles use 2x2 grid", () => {
const layout = computeGridLayout(800, 600, 4);
expect(layout.cols).toBe(2);
expect(layout.rows).toBe(2);
});
it("all tiles fit within the container", () => {
for (const count of [1, 2, 3, 4, 5, 6, 9, 10, 16]) {
const layout = computeGridLayout(800, 600, count);
const totalW = layout.cols * layout.tileW + (layout.cols - 1) * 4 + 16;
const totalH = layout.rows * layout.tileH + (layout.rows - 1) * 4 + 16;
expect(totalW).toBeLessThanOrEqual(800);
expect(totalH).toBeLessThanOrEqual(600);
}
});
it("tiles maintain approximately 16:9 aspect ratio", () => {
for (const count of [1, 2, 4, 9]) {
const layout = computeGridLayout(800, 600, count);
if (layout.tileW === 0) continue;
const ratio = layout.tileW / layout.tileH;
expect(Math.abs(ratio - 16 / 9)).toBeLessThan(0.15);
}
});
});
it("destroy cleans up all elements", () => {
grid.addStream(1, "Alice", fakeStream());
grid.addStream(2, "Bob", fakeStream());
grid.destroy?.();
expect(container.querySelector(".video-grid")).toBeNull();
expect(grid.hasStreams()).toBe(false);
});
// -----------------------------------------------------------------------
// Autoplay / .play() tests (Bug fix: black window in WebView2)
// -----------------------------------------------------------------------
describe("video autoplay", () => {
it("addStream calls .play() on the video element", () => {
const playMock = vi.fn().mockResolvedValue(undefined);
const origCreate = document.createElement.bind(document);
vi.spyOn(document, "createElement").mockImplementation(
(tag: string, opts?: ElementCreationOptions) => {
const el = origCreate(tag, opts);
if (tag === "video") {
(el as HTMLVideoElement).play = playMock;
}
return el;
},
);
grid.addStream(1, "Alice", fakeStream());
expect(playMock).toHaveBeenCalledTimes(1);
vi.restoreAllMocks();
});
it("addStream calls .play() when replacing srcObject on existing tile", () => {
const playMock = vi.fn().mockResolvedValue(undefined);
const origCreate = document.createElement.bind(document);
vi.spyOn(document, "createElement").mockImplementation(
(tag: string, opts?: ElementCreationOptions) => {
const el = origCreate(tag, opts);
if (tag === "video") {
(el as HTMLVideoElement).play = playMock;
}
return el;
},
);
const { stream: s1 } = fakeStreamWithTrack();
const { stream: s2 } = fakeStreamWithTrack();
grid.addStream(1, "Alice", s1);
playMock.mockClear();
// Different tracks → srcObject replaced → should call play again
grid.addStream(1, "Alice", s2);
expect(playMock).toHaveBeenCalledTimes(1);
vi.restoreAllMocks();
});
});
// -----------------------------------------------------------------------
// Track lifecycle tests (Bug fix: stale black tiles)
// -----------------------------------------------------------------------
describe("track lifecycle", () => {
it("removes tile when video track fires 'ended' event", () => {
const { stream, track } = fakeStreamWithTrack();
grid.addStream(1, "Alice", stream);
expect(grid.hasStreams()).toBe(true);
track.dispatchEvent("ended");
expect(grid.hasStreams()).toBe(false);
});
it("hides tile when video track fires 'mute' event (does not remove)", () => {
const { stream, track } = fakeStreamWithTrack();
grid.addStream(1, "Alice", stream);
expect(grid.hasStreams()).toBe(true);
track.dispatchEvent("mute");
// Tile should still exist but have track-muted class
expect(grid.hasStreams()).toBe(true);
const cell = container.querySelector(".video-cell") as HTMLElement;
expect(cell.classList.contains("track-muted")).toBe(true);
});
it("restores tile when video track fires 'unmute' after mute", () => {
const { stream, track } = fakeStreamWithTrack();
grid.addStream(1, "Alice", stream);
track.dispatchEvent("mute");
const cell = container.querySelector(".video-cell") as HTMLElement;
expect(cell.classList.contains("track-muted")).toBe(true);
track.dispatchEvent("unmute");
expect(cell.classList.contains("track-muted")).toBe(false);
});
it("cleans up track listeners when tile is removed via removeStream", () => {
const { stream, track } = fakeStreamWithTrack();
grid.addStream(1, "Alice", stream);
grid.removeStream(1);
// Dispatching ended after removal should not throw or cause issues
expect(() => track.dispatchEvent("ended")).not.toThrow();
expect(grid.hasStreams()).toBe(false);
});
it("cleans up track listeners on destroy", () => {
const { stream, track } = fakeStreamWithTrack();
grid.addStream(1, "Alice", stream);
grid.destroy!();
// Verify listeners were removed
expect(track.listeners["ended"]?.length ?? 0).toBe(0);
expect(track.listeners["mute"]?.length ?? 0).toBe(0);
expect(track.listeners["unmute"]?.length ?? 0).toBe(0);
});
});
// -----------------------------------------------------------------------
// TileConfig / overlay / mute button tests (Spec 1)
// -----------------------------------------------------------------------
describe("tile overlay and audio controls", () => {
it("addStream with isSelf=true does NOT render overlay", () => {
const config = makeTileConfig({ isSelf: true });
grid.addStream(1, "me (You)", fakeStream(), config);
expect(container.querySelector(".video-tile-overlay")).toBeNull();
});
it("addStream with isSelf=false renders overlay and mute button", () => {
const config = makeTileConfig({ isSelf: false });
grid.addStream(42, "alice", fakeStream(), config);
expect(container.querySelector(".video-tile-overlay")).not.toBeNull();
expect(container.querySelector(".tile-mute-btn")).not.toBeNull();
});
it("mute button toggles screenshare audio when isScreenshare=true", () => {
const config = makeTileConfig({ isSelf: false, audioUserId: 99, isScreenshare: true });
grid.addStream(99, "bob (Screen)", fakeStream(), config);
const muteBtn = container.querySelector(".tile-mute-btn") as HTMLButtonElement;
muteBtn.click();
expect(mockMuteScreenshareAudio).toHaveBeenCalledWith(99, true);
muteBtn.click();
expect(mockMuteScreenshareAudio).toHaveBeenCalledWith(99, false);
});
it("mute button toggles mic audio when isScreenshare=false", () => {
const config = makeTileConfig({ isSelf: false, audioUserId: 55, isScreenshare: false });
grid.addStream(55, "charlie", fakeStream(), config);
const muteBtn = container.querySelector(".tile-mute-btn") as HTMLButtonElement;
muteBtn.click();
expect(mockSetUserVolume).toHaveBeenCalledWith(55, 0);
muteBtn.click();
expect(mockSetUserVolume).toHaveBeenCalledWith(55, 100);
});
it("mute button icon swaps between volume and volume-x SVGs on click", () => {
const config = makeTileConfig({ isSelf: false });
grid.addStream(42, "alice", fakeStream(), config);
const muteBtn = container.querySelector(".tile-mute-btn") as HTMLButtonElement;
const initialHtml = muteBtn.innerHTML;
// Volume icon has polygon but no <line> elements
expect(initialHtml).toContain("polygon");
expect(initialHtml).not.toContain("<line");
// Click to mute — should swap to volume-x icon with <line> elements
muteBtn.click();
expect(muteBtn.innerHTML).toContain("<line");
// Click to unmute — should swap back to volume icon
muteBtn.click();
expect(muteBtn.innerHTML).not.toContain("<line");
});
it("mute button aria-label updates between Mute and Unmute", () => {
const config = makeTileConfig({ isSelf: false });
grid.addStream(42, "alice", fakeStream(), config);
const muteBtn = container.querySelector(".tile-mute-btn") as HTMLButtonElement;
expect(muteBtn.getAttribute("aria-label")).toBe("Mute");
muteBtn.click();
expect(muteBtn.getAttribute("aria-label")).toBe("Unmute");
muteBtn.click();
expect(muteBtn.getAttribute("aria-label")).toBe("Mute");
});
it("addStream without config (backward compat) renders no overlay", () => {
grid.addStream(42, "alice", fakeStream());
const cell = container.querySelector(".video-cell");
expect(cell).not.toBeNull();
expect(container.querySelector(".video-tile-overlay")).toBeNull();
});
it("volume slider adjusts user volume for non-screenshare tiles", () => {
const config = makeTileConfig({ isSelf: false, audioUserId: 77, isScreenshare: false });
grid.addStream(77, "dave", fakeStream(), config);
const slider = container.querySelector(".tile-volume-slider") as HTMLInputElement;
expect(slider).not.toBeNull();
expect(slider.value).toBe("100"); // default
// Slide to 50
slider.value = "50";
slider.dispatchEvent(new Event("input"));
expect(mockSetUserVolume).toHaveBeenCalledWith(77, 50);
});
it("volume slider at 0 triggers mute icon swap and calls setUserVolume(0)", () => {
const config = makeTileConfig({ isSelf: false, audioUserId: 77, isScreenshare: false });
grid.addStream(77, "dave", fakeStream(), config);
const slider = container.querySelector(".tile-volume-slider") as HTMLInputElement;
const muteBtn = container.querySelector(".tile-mute-btn") as HTMLButtonElement;
// Slide to 0
slider.value = "0";
slider.dispatchEvent(new Event("input"));
expect(mockSetUserVolume).toHaveBeenCalledWith(77, 0);
// Mute icon should change
expect(muteBtn.getAttribute("aria-label")).toBe("Unmute");
// Overlay should have muted class
const overlay = container.querySelector(".video-tile-overlay");
expect(overlay!.classList.contains("muted")).toBe(true);
});
it("volume slider for screenshare tiles calls muteScreenshareAudio", () => {
const config = makeTileConfig({ isSelf: false, audioUserId: 88, isScreenshare: true });
grid.addStream(88, "screen", fakeStream(), config);
const slider = container.querySelector(".tile-volume-slider") as HTMLInputElement;
// Slide to 0 — should mute screenshare
slider.value = "0";
slider.dispatchEvent(new Event("input"));
expect(mockMuteScreenshareAudio).toHaveBeenCalledWith(88, true);
// Slide to 100 — should unmute screenshare
slider.value = "100";
slider.dispatchEvent(new Event("input"));
expect(mockMuteScreenshareAudio).toHaveBeenCalledWith(88, false);
});
it("screenshare slider uses 0-100 range where 100 maps to volume 1.0", () => {
const config = makeTileConfig({ isSelf: false, audioUserId: 88, isScreenshare: true });
grid.addStream(88, "screen", fakeStream(), config);
const slider = container.querySelector(".tile-volume-slider") as HTMLInputElement;
expect(slider.max).toBe("100");
expect(slider.value).toBe("100"); // stored default 1.0 → 100
slider.value = "50";
slider.dispatchEvent(new Event("input"));
expect(mockSetScreenshareAudioVolume).toHaveBeenCalledWith(88, 0.5);
slider.value = "100";
slider.dispatchEvent(new Event("input"));
expect(mockSetScreenshareAudioVolume).toHaveBeenCalledWith(88, 1);
});
it("screenshare slider initializes from stored volume and mute state", () => {
mockGetScreenshareAudioVolume.mockReturnValueOnce(0.3);
mockGetScreenshareAudioMuted.mockReturnValueOnce(true);
const config = makeTileConfig({ isSelf: false, audioUserId: 88, isScreenshare: true });
grid.addStream(88, "screen", fakeStream(), config);
const slider = container.querySelector(".tile-volume-slider") as HTMLInputElement;
const muteBtn = container.querySelector(".tile-mute-btn") as HTMLButtonElement;
const overlay = container.querySelector(".video-tile-overlay");
expect(slider.value).toBe("30");
expect(muteBtn.getAttribute("aria-label")).toBe("Unmute");
expect(overlay!.classList.contains("muted")).toBe(true);
});
it("mic slider keeps the 0-200 boost range", () => {
const config = makeTileConfig({ isSelf: false, audioUserId: 77, isScreenshare: false });
grid.addStream(77, "dave", fakeStream(), config);
const slider = container.querySelector(".tile-volume-slider") as HTMLInputElement;
expect(slider.max).toBe("200");
});
it("mute button unmutes with previous volume when currentVolume was non-zero", () => {
const config = makeTileConfig({ isSelf: false, audioUserId: 77, isScreenshare: false });
grid.addStream(77, "dave", fakeStream(), config);
const slider = container.querySelector(".tile-volume-slider") as HTMLInputElement;
const muteBtn = container.querySelector(".tile-mute-btn") as HTMLButtonElement;
// Set volume to 150 via slider
slider.value = "150";
slider.dispatchEvent(new Event("input"));
mockSetUserVolume.mockClear();
// Mute via button
muteBtn.click();
expect(mockSetUserVolume).toHaveBeenCalledWith(77, 0);
expect(slider.value).toBe("0");
// Unmute via button — should restore to 150
muteBtn.click();
expect(mockSetUserVolume).toHaveBeenCalledWith(77, 150);
expect(slider.value).toBe("150");
});
});
// -----------------------------------------------------------------------
// Stream type attribute tests (Spec 3 — video scaling)
// -----------------------------------------------------------------------
describe("data-stream-type attribute", () => {
it("sets data-stream-type='screenshare' when isScreenshare is true", () => {
const config = makeTileConfig({ isScreenshare: true });
grid.addStream(1, "Alice (Screen)", fakeStream(), config);
const cell = container.querySelector(".video-cell") as HTMLElement;
expect(cell.dataset.streamType).toBe("screenshare");
});
it("sets data-stream-type='camera' when isScreenshare is false", () => {
const config = makeTileConfig({ isScreenshare: false });
grid.addStream(1, "Alice", fakeStream(), config);
const cell = container.querySelector(".video-cell") as HTMLElement;
expect(cell.dataset.streamType).toBe("camera");
});
it("defaults data-stream-type='camera' when no config is provided", () => {
grid.addStream(1, "Alice", fakeStream());
const cell = container.querySelector(".video-cell") as HTMLElement;
expect(cell.dataset.streamType).toBe("camera");
});
it("preserves data-stream-type when stream is updated in-place", () => {
const config = makeTileConfig({ isScreenshare: true });
grid.addStream(1, "Alice (Screen)", fakeStream(), config);
// Update stream (same userId)
grid.addStream(1, "Alice (Screen) v2", fakeStream(), config);
const cell = container.querySelector(".video-cell") as HTMLElement;
expect(cell.dataset.streamType).toBe("screenshare");
});
it("syncs data-stream-type when stream type changes on in-place update", () => {
const cameraConfig = makeTileConfig({ isScreenshare: false });
grid.addStream(1, "Alice", fakeStream(), cameraConfig);
const cell = container.querySelector(".video-cell") as HTMLElement;
expect(cell.dataset.streamType).toBe("camera");
// Re-add same userId as screenshare
const screenConfig = makeTileConfig({ isScreenshare: true });
grid.addStream(1, "Alice (Screen)", fakeStream(), screenConfig);
expect(cell.dataset.streamType).toBe("screenshare");
});
});
// -----------------------------------------------------------------------
// Focus mode tests (Spec 2)
// -----------------------------------------------------------------------
describe("focus mode", () => {
it("setFocusedTile creates focus layout with main and strip areas", () => {
grid.addStream(1, "Alice", fakeStream());
grid.addStream(2, "Bob", fakeStream());
grid.setFocusedTile(1);
const mainArea = container.querySelector(".video-focus-main");
const stripArea = container.querySelector(".video-focus-strip");
expect(mainArea).not.toBeNull();
expect(stripArea).not.toBeNull();
// Focused tile should be in main area
const focusedCell = mainArea!.querySelector('[data-user-id="1"]');
expect(focusedCell).not.toBeNull();
expect(focusedCell!.classList.contains("focused")).toBe(true);
// Other tile should be in strip area
const thumbCell = stripArea!.querySelector('[data-user-id="2"]');
expect(thumbCell).not.toBeNull();
expect(thumbCell!.classList.contains("thumb")).toBe(true);
});
it("clicking a thumbnail switches focus", () => {
grid.addStream(1, "Alice", fakeStream());
grid.addStream(2, "Bob", fakeStream());
grid.setFocusedTile(1);
// Click the second tile (thumbnail in strip)
const thumbCell = container.querySelector('[data-user-id="2"]') as HTMLElement;
expect(thumbCell).not.toBeNull();
thumbCell.click();
// Now tile 2 should be focused in main area
const mainArea = container.querySelector(".video-focus-main");
expect(mainArea).not.toBeNull();
const newFocused = mainArea!.querySelector('[data-user-id="2"]');
expect(newFocused).not.toBeNull();
expect(newFocused!.classList.contains("focused")).toBe(true);
// Tile 1 should now be a thumbnail
const stripArea = container.querySelector(".video-focus-strip");
expect(stripArea).not.toBeNull();
const oldFocused = stripArea!.querySelector('[data-user-id="1"]');
expect(oldFocused).not.toBeNull();
expect(oldFocused!.classList.contains("thumb")).toBe(true);
});
it("removeStream auto-focuses next tile when focused tile is removed", () => {
grid.addStream(1, "Alice", fakeStream());
grid.addStream(2, "Bob", fakeStream());
grid.setFocusedTile(1);
expect(grid.getFocusedTileId()).toBe(1);
grid.removeStream(1);
// Remaining tile 2 should become focused
expect(grid.getFocusedTileId()).toBe(2);
});
it("removeStream clears focus when last tile removed", () => {
grid.addStream(1, "Alice", fakeStream());
grid.setFocusedTile(1);
expect(grid.getFocusedTileId()).toBe(1);
grid.removeStream(1);
// Focus cleared — no focus-mode class
expect(grid.getFocusedTileId()).toBeNull();
const root = container.querySelector(".video-grid");
expect(root!.classList.contains("focus-mode")).toBe(false);
});
it("getFocusedTileId returns correct value", () => {
grid.addStream(1, "Alice", fakeStream());
expect(grid.getFocusedTileId()).toBeNull();
grid.setFocusedTile(1);
expect(grid.getFocusedTileId()).toBe(1);
});
it("focus-mode class is added to root when a tile is focused", () => {
grid.addStream(1, "Alice", fakeStream());
grid.addStream(2, "Bob", fakeStream());
const root = container.querySelector(".video-grid") as HTMLElement;
expect(root.classList.contains("focus-mode")).toBe(false);
grid.setFocusedTile(1);
expect(root.classList.contains("focus-mode")).toBe(true);
});
it("strip area not shown when only one tile is focused (no thumbnails)", () => {
grid.addStream(1, "Alice", fakeStream());
grid.setFocusedTile(1);
const mainArea = container.querySelector(".video-focus-main");
expect(mainArea).not.toBeNull();
// Only one tile — no strip should be rendered
const stripArea = container.querySelector(".video-focus-strip");
expect(stripArea).toBeNull();
});
it("clicking mute button on a focused tile does not switch focus", () => {
const config = makeTileConfig({ isSelf: false, audioUserId: 1, isScreenshare: false });
grid.addStream(1, "Alice", fakeStream(), config);
grid.addStream(2, "Bob", fakeStream());
grid.setFocusedTile(1);
expect(grid.getFocusedTileId()).toBe(1);
// Click the mute button on Alice's tile
const muteBtn = container.querySelector(".tile-mute-btn") as HTMLButtonElement;
muteBtn.click();
// Focus should remain on tile 1
expect(grid.getFocusedTileId()).toBe(1);
});
it("adding a stream during focus mode preserves focus layout", () => {
grid.addStream(1, "Alice", fakeStream());
grid.addStream(2, "Bob", fakeStream());
grid.setFocusedTile(1);
// Add a third stream
grid.addStream(3, "Charlie", fakeStream());
// Focus should still be on tile 1
expect(grid.getFocusedTileId()).toBe(1);
const mainArea = container.querySelector(".video-focus-main");
expect(mainArea).not.toBeNull();
expect(mainArea!.querySelector('[data-user-id="1"]')).not.toBeNull();
// Both Bob and Charlie should be in strip
const stripArea = container.querySelector(".video-focus-strip");
expect(stripArea).not.toBeNull();
expect(stripArea!.querySelectorAll(".video-cell").length).toBe(2);
});
it("focused cell retains data-stream-type attribute in focus layout", () => {
const config = makeTileConfig({ isScreenshare: true });
grid.addStream(1, "Alice (Screen)", fakeStream(), config);
grid.addStream(2, "Bob", fakeStream());
grid.setFocusedTile(1);
const mainArea = container.querySelector(".video-focus-main");
const focusedCell = mainArea!.querySelector(".video-cell") as HTMLElement;
expect(focusedCell.dataset.streamType).toBe("screenshare");
});
it("thumbnail cells retain data-stream-type attribute in strip", () => {
const cameraConfig = makeTileConfig({ isScreenshare: false });
grid.addStream(1, "Alice", fakeStream(), cameraConfig);
const screenConfig = makeTileConfig({ isScreenshare: true });
grid.addStream(2, "Bob (Screen)", fakeStream(), screenConfig);
grid.setFocusedTile(2);
const stripArea = container.querySelector(".video-focus-strip");
const thumbCell = stripArea!.querySelector(".video-cell") as HTMLElement;
expect(thumbCell.dataset.streamType).toBe("camera");
});
it("removing non-focused tile preserves current focus", () => {
grid.addStream(1, "Alice", fakeStream());
grid.addStream(2, "Bob", fakeStream());
grid.addStream(3, "Charlie", fakeStream());
grid.setFocusedTile(1);
grid.removeStream(3);
expect(grid.getFocusedTileId()).toBe(1);
const mainArea = container.querySelector(".video-focus-main");
expect(mainArea!.querySelector('[data-user-id="1"]')).not.toBeNull();
});
});
});