2026-03-20 01:33:43 +01:00
|
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Mocks
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
2026-04-01 11:40:31 +02:00
|
|
|
const { mockVoiceStoreGetState, mockGetLocalCameraStream, mockGetLocalScreenshareStream } =
|
|
|
|
|
vi.hoisted(() => ({
|
|
|
|
|
mockVoiceStoreGetState: vi.fn(),
|
|
|
|
|
mockGetLocalCameraStream: vi.fn((): MediaStream | null => null),
|
|
|
|
|
mockGetLocalScreenshareStream: vi.fn((): MediaStream | null => null),
|
|
|
|
|
}));
|
2026-03-20 01:33:43 +01:00
|
|
|
|
|
|
|
|
vi.mock("@stores/voice.store", () => ({
|
|
|
|
|
voiceStore: { getState: mockVoiceStoreGetState },
|
|
|
|
|
}));
|
|
|
|
|
|
2026-03-20 05:46:19 +01:00
|
|
|
vi.mock("@lib/livekitSession", () => ({
|
2026-03-20 01:33:43 +01:00
|
|
|
getLocalCameraStream: mockGetLocalCameraStream,
|
2026-03-26 18:07:20 +01:00
|
|
|
getLocalScreenshareStream: mockGetLocalScreenshareStream,
|
2026-04-03 09:05:18 +02:00
|
|
|
setScreenshareAudioVolume: vi.fn(),
|
2026-03-20 01:33:43 +01:00
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Imports
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
import { createVideoModeController } from "../../src/pages/main-page/VideoModeController";
|
|
|
|
|
import type { VideoModeControllerOptions } from "../../src/pages/main-page/VideoModeController";
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Helpers
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
function makeSlots() {
|
|
|
|
|
return {
|
|
|
|
|
messagesSlot: document.createElement("div"),
|
|
|
|
|
typingSlot: document.createElement("div"),
|
|
|
|
|
inputSlot: document.createElement("div"),
|
|
|
|
|
videoGridSlot: document.createElement("div"),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function makeVideoGrid(): VideoModeControllerOptions["videoGrid"] {
|
|
|
|
|
return {
|
|
|
|
|
mount: vi.fn(),
|
|
|
|
|
destroy: vi.fn(),
|
|
|
|
|
addStream: vi.fn(),
|
|
|
|
|
removeStream: vi.fn(),
|
2026-08-08 17:56:43 +02:00
|
|
|
clearStreams: vi.fn(),
|
2026-04-01 17:07:02 +02:00
|
|
|
hasStreams: vi.fn(() => false),
|
2026-03-26 22:09:56 +01:00
|
|
|
setFocusedTile: vi.fn(),
|
|
|
|
|
getFocusedTileId: vi.fn(() => null),
|
2026-03-20 01:33:43 +01:00
|
|
|
} as unknown as VideoModeControllerOptions["videoGrid"];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface VoiceStateStub {
|
|
|
|
|
currentChannelId: number | null;
|
|
|
|
|
localCamera: boolean;
|
|
|
|
|
localScreenshare: boolean;
|
2026-04-01 11:40:31 +02:00
|
|
|
voiceUsers: Map<
|
|
|
|
|
number,
|
|
|
|
|
Map<number, { userId: number; camera: boolean; screenshare: boolean; username: string }>
|
|
|
|
|
>;
|
2026-03-20 01:33:43 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function makeVoiceState(overrides: Partial<VoiceStateStub> = {}): VoiceStateStub {
|
|
|
|
|
return {
|
|
|
|
|
currentChannelId: null,
|
|
|
|
|
localCamera: false,
|
|
|
|
|
localScreenshare: false,
|
|
|
|
|
voiceUsers: new Map(),
|
|
|
|
|
...overrides,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Tests
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
describe("createVideoModeController", () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
vi.clearAllMocks();
|
|
|
|
|
mockVoiceStoreGetState.mockReturnValue(makeVoiceState());
|
|
|
|
|
mockGetLocalCameraStream.mockReturnValue(null);
|
2026-03-26 18:07:20 +01:00
|
|
|
mockGetLocalScreenshareStream.mockReturnValue(null);
|
2026-03-20 01:33:43 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("starts in chat mode", () => {
|
|
|
|
|
const slots = makeSlots();
|
|
|
|
|
const ctrl = createVideoModeController({
|
|
|
|
|
slots,
|
|
|
|
|
videoGrid: makeVideoGrid(),
|
|
|
|
|
getCurrentUserId: () => 1,
|
|
|
|
|
});
|
|
|
|
|
expect(ctrl.isVideoMode()).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("stays in chat mode when no voice channel", () => {
|
|
|
|
|
const ctrl = createVideoModeController({
|
|
|
|
|
slots: makeSlots(),
|
|
|
|
|
videoGrid: makeVideoGrid(),
|
|
|
|
|
getCurrentUserId: () => 1,
|
|
|
|
|
});
|
|
|
|
|
ctrl.checkVideoMode();
|
|
|
|
|
expect(ctrl.isVideoMode()).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-02 13:42:00 +02:00
|
|
|
it("checkVideoMode does NOT auto-open for remote camera (BUG-105)", () => {
|
2026-03-26 18:07:20 +01:00
|
|
|
const users = new Map([[2, { userId: 2, camera: true, screenshare: false, username: "bob" }]]);
|
2026-03-20 01:33:43 +01:00
|
|
|
mockVoiceStoreGetState.mockReturnValue(
|
|
|
|
|
makeVoiceState({ currentChannelId: 10, voiceUsers: new Map([[10, users]]) }),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const slots = makeSlots();
|
|
|
|
|
const ctrl = createVideoModeController({
|
|
|
|
|
slots,
|
|
|
|
|
videoGrid: makeVideoGrid(),
|
|
|
|
|
getCurrentUserId: () => 1,
|
|
|
|
|
});
|
|
|
|
|
ctrl.checkVideoMode();
|
|
|
|
|
|
2026-04-02 13:42:00 +02:00
|
|
|
// Remote streams require manual click — no auto-open
|
|
|
|
|
expect(ctrl.isVideoMode()).toBe(false);
|
2026-03-20 01:33:43 +01:00
|
|
|
});
|
|
|
|
|
|
2026-03-26 22:09:56 +01:00
|
|
|
it("checkVideoMode auto-closes video grid when no streams remain", () => {
|
2026-03-26 18:07:20 +01:00
|
|
|
const users = new Map([[2, { userId: 2, camera: true, screenshare: false, username: "bob" }]]);
|
2026-03-20 01:33:43 +01:00
|
|
|
mockVoiceStoreGetState.mockReturnValue(
|
|
|
|
|
makeVoiceState({ currentChannelId: 10, voiceUsers: new Map([[10, users]]) }),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const slots = makeSlots();
|
|
|
|
|
const ctrl = createVideoModeController({
|
|
|
|
|
slots,
|
|
|
|
|
videoGrid: makeVideoGrid(),
|
|
|
|
|
getCurrentUserId: () => 1,
|
|
|
|
|
});
|
2026-03-26 22:09:56 +01:00
|
|
|
|
|
|
|
|
// Manually open video grid first
|
|
|
|
|
ctrl.showVideoGrid();
|
2026-03-20 01:33:43 +01:00
|
|
|
expect(ctrl.isVideoMode()).toBe(true);
|
|
|
|
|
|
2026-03-26 22:09:56 +01:00
|
|
|
// All cameras off — should auto-close
|
2026-03-26 18:07:20 +01:00
|
|
|
users.set(2, { userId: 2, camera: false, screenshare: false, username: "bob" });
|
2026-03-20 01:33:43 +01:00
|
|
|
ctrl.checkVideoMode();
|
|
|
|
|
expect(ctrl.isVideoMode()).toBe(false);
|
|
|
|
|
expect(slots.messagesSlot.style.display).toBe("");
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-02 13:39:13 +02:00
|
|
|
it("checkVideoMode auto-opens when local camera is on (BUG-105)", () => {
|
2026-03-26 18:07:20 +01:00
|
|
|
const users = new Map([[1, { userId: 1, camera: false, screenshare: false, username: "me" }]]);
|
2026-03-20 01:33:43 +01:00
|
|
|
mockVoiceStoreGetState.mockReturnValue(
|
|
|
|
|
makeVoiceState({
|
|
|
|
|
currentChannelId: 10,
|
|
|
|
|
localCamera: true,
|
|
|
|
|
voiceUsers: new Map([[10, users]]),
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const ctrl = createVideoModeController({
|
|
|
|
|
slots: makeSlots(),
|
|
|
|
|
videoGrid: makeVideoGrid(),
|
|
|
|
|
getCurrentUserId: () => 1,
|
|
|
|
|
});
|
|
|
|
|
ctrl.checkVideoMode();
|
2026-04-02 13:39:13 +02:00
|
|
|
expect(ctrl.isVideoMode()).toBe(true);
|
2026-03-20 01:33:43 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("adds local self-view tile when local camera is on", () => {
|
|
|
|
|
const fakeStream = {} as MediaStream;
|
|
|
|
|
mockGetLocalCameraStream.mockReturnValue(fakeStream);
|
2026-03-26 18:07:20 +01:00
|
|
|
const users = new Map([[1, { userId: 1, camera: false, screenshare: false, username: "me" }]]);
|
2026-03-20 01:33:43 +01:00
|
|
|
mockVoiceStoreGetState.mockReturnValue(
|
|
|
|
|
makeVoiceState({
|
|
|
|
|
currentChannelId: 10,
|
|
|
|
|
localCamera: true,
|
|
|
|
|
voiceUsers: new Map([[10, users]]),
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const vg = makeVideoGrid();
|
|
|
|
|
const ctrl = createVideoModeController({
|
|
|
|
|
slots: makeSlots(),
|
|
|
|
|
videoGrid: vg,
|
|
|
|
|
getCurrentUserId: () => 1,
|
|
|
|
|
});
|
|
|
|
|
ctrl.checkVideoMode();
|
|
|
|
|
|
2026-04-01 11:40:31 +02:00
|
|
|
expect(vg.addStream).toHaveBeenCalledWith(1, "me (You)", fakeStream, {
|
|
|
|
|
isSelf: true,
|
|
|
|
|
audioUserId: 1,
|
|
|
|
|
isScreenshare: false,
|
|
|
|
|
});
|
2026-03-20 01:33:43 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("removes local tile when local camera is off", () => {
|
2026-03-26 18:07:20 +01:00
|
|
|
const users = new Map([[1, { userId: 1, camera: false, screenshare: false, username: "me" }]]);
|
2026-03-20 01:33:43 +01:00
|
|
|
mockVoiceStoreGetState.mockReturnValue(
|
|
|
|
|
makeVoiceState({
|
|
|
|
|
currentChannelId: 10,
|
|
|
|
|
localCamera: false,
|
|
|
|
|
voiceUsers: new Map([[10, users]]),
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const vg = makeVideoGrid();
|
|
|
|
|
const ctrl = createVideoModeController({
|
|
|
|
|
slots: makeSlots(),
|
|
|
|
|
videoGrid: vg,
|
|
|
|
|
getCurrentUserId: () => 1,
|
|
|
|
|
});
|
|
|
|
|
ctrl.checkVideoMode();
|
|
|
|
|
|
|
|
|
|
expect(vg.removeStream).toHaveBeenCalledWith(1);
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-01 17:07:02 +02:00
|
|
|
it("does NOT remove remote tiles in checkVideoMode (delegated to onRemoteVideoRemoved)", () => {
|
2026-03-20 01:33:43 +01:00
|
|
|
const users = new Map([
|
2026-03-26 18:07:20 +01:00
|
|
|
[1, { userId: 1, camera: false, screenshare: false, username: "me" }],
|
|
|
|
|
[2, { userId: 2, camera: false, screenshare: false, username: "bob" }],
|
2026-03-20 01:33:43 +01:00
|
|
|
]);
|
|
|
|
|
mockVoiceStoreGetState.mockReturnValue(
|
|
|
|
|
makeVoiceState({
|
|
|
|
|
currentChannelId: 10,
|
|
|
|
|
voiceUsers: new Map([[10, users]]),
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const vg = makeVideoGrid();
|
|
|
|
|
const ctrl = createVideoModeController({
|
|
|
|
|
slots: makeSlots(),
|
|
|
|
|
videoGrid: vg,
|
|
|
|
|
getCurrentUserId: () => 1,
|
|
|
|
|
});
|
|
|
|
|
ctrl.checkVideoMode();
|
|
|
|
|
|
2026-04-01 17:07:02 +02:00
|
|
|
// Remote tile removal is handled by onRemoteVideoRemoved (LiveKit TrackUnsubscribed),
|
|
|
|
|
// not by checkVideoMode, to avoid race conditions with voice store updates.
|
|
|
|
|
expect(vg.removeStream).not.toHaveBeenCalledWith(2);
|
2026-03-20 01:33:43 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("showChat switches back to chat mode", () => {
|
|
|
|
|
const slots = makeSlots();
|
|
|
|
|
const ctrl = createVideoModeController({
|
|
|
|
|
slots,
|
|
|
|
|
videoGrid: makeVideoGrid(),
|
|
|
|
|
getCurrentUserId: () => 1,
|
|
|
|
|
});
|
|
|
|
|
ctrl.showVideoGrid();
|
|
|
|
|
expect(ctrl.isVideoMode()).toBe(true);
|
|
|
|
|
|
|
|
|
|
ctrl.showChat();
|
|
|
|
|
expect(ctrl.isVideoMode()).toBe(false);
|
|
|
|
|
expect(slots.videoGridSlot.style.display).toBe("none");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("destroy resets video mode state and restores DOM", () => {
|
|
|
|
|
const slots = makeSlots();
|
|
|
|
|
const ctrl = createVideoModeController({
|
|
|
|
|
slots,
|
|
|
|
|
videoGrid: makeVideoGrid(),
|
|
|
|
|
getCurrentUserId: () => 1,
|
|
|
|
|
});
|
|
|
|
|
ctrl.showVideoGrid();
|
|
|
|
|
expect(ctrl.isVideoMode()).toBe(true);
|
|
|
|
|
expect(slots.messagesSlot.style.display).toBe("none");
|
|
|
|
|
|
|
|
|
|
ctrl.destroy();
|
|
|
|
|
expect(ctrl.isVideoMode()).toBe(false);
|
|
|
|
|
expect(slots.messagesSlot.style.display).toBe("");
|
|
|
|
|
expect(slots.videoGridSlot.style.display).toBe("none");
|
|
|
|
|
});
|
2026-03-26 18:07:20 +01:00
|
|
|
|
2026-04-02 13:39:13 +02:00
|
|
|
it("checkVideoMode auto-opens when local screenshare is on (BUG-105)", () => {
|
2026-03-26 18:07:20 +01:00
|
|
|
const users = new Map([[1, { userId: 1, camera: false, screenshare: false, username: "me" }]]);
|
|
|
|
|
mockVoiceStoreGetState.mockReturnValue(
|
|
|
|
|
makeVoiceState({
|
|
|
|
|
currentChannelId: 10,
|
|
|
|
|
localCamera: false,
|
|
|
|
|
localScreenshare: true,
|
|
|
|
|
voiceUsers: new Map([[10, users]]),
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const slots = makeSlots();
|
|
|
|
|
const ctrl = createVideoModeController({
|
|
|
|
|
slots,
|
|
|
|
|
videoGrid: makeVideoGrid(),
|
|
|
|
|
getCurrentUserId: () => 1,
|
|
|
|
|
});
|
|
|
|
|
ctrl.checkVideoMode();
|
|
|
|
|
|
2026-04-02 13:39:13 +02:00
|
|
|
expect(ctrl.isVideoMode()).toBe(true);
|
2026-03-26 18:07:20 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("adds local screenshare self-view tile when local screenshare is on", () => {
|
|
|
|
|
const fakeStream = {} as MediaStream;
|
|
|
|
|
mockGetLocalScreenshareStream.mockReturnValue(fakeStream);
|
|
|
|
|
const users = new Map([[1, { userId: 1, camera: false, screenshare: false, username: "me" }]]);
|
|
|
|
|
mockVoiceStoreGetState.mockReturnValue(
|
|
|
|
|
makeVoiceState({
|
|
|
|
|
currentChannelId: 10,
|
|
|
|
|
localCamera: false,
|
|
|
|
|
localScreenshare: true,
|
|
|
|
|
voiceUsers: new Map([[10, users]]),
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const vg = makeVideoGrid();
|
|
|
|
|
const ctrl = createVideoModeController({
|
|
|
|
|
slots: makeSlots(),
|
|
|
|
|
videoGrid: vg,
|
|
|
|
|
getCurrentUserId: () => 1,
|
|
|
|
|
});
|
|
|
|
|
ctrl.checkVideoMode();
|
|
|
|
|
|
|
|
|
|
// screenshareUserId = currentUserId + 1_000_000 = 1 + 1_000_000 = 1_000_001
|
2026-04-01 11:40:31 +02:00
|
|
|
expect(vg.addStream).toHaveBeenCalledWith(1_000_001, "me (Screen)", fakeStream, {
|
|
|
|
|
isSelf: true,
|
|
|
|
|
audioUserId: 1,
|
|
|
|
|
isScreenshare: true,
|
|
|
|
|
});
|
2026-03-26 18:07:20 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("removes local screenshare tile when screenshare is turned off", () => {
|
|
|
|
|
const users = new Map([[1, { userId: 1, camera: false, screenshare: false, username: "me" }]]);
|
|
|
|
|
|
|
|
|
|
// First call: screenshare on — tile added
|
|
|
|
|
mockVoiceStoreGetState.mockReturnValue(
|
2026-04-01 11:40:31 +02:00
|
|
|
makeVoiceState({
|
|
|
|
|
currentChannelId: 10,
|
|
|
|
|
localScreenshare: true,
|
|
|
|
|
voiceUsers: new Map([[10, users]]),
|
|
|
|
|
}),
|
2026-03-26 18:07:20 +01:00
|
|
|
);
|
|
|
|
|
const fakeStream = { getTracks: () => [] } as unknown as MediaStream;
|
|
|
|
|
mockGetLocalScreenshareStream.mockReturnValue(fakeStream);
|
|
|
|
|
|
|
|
|
|
const vg = makeVideoGrid();
|
|
|
|
|
const ctrl = createVideoModeController({
|
|
|
|
|
slots: makeSlots(),
|
|
|
|
|
videoGrid: vg,
|
|
|
|
|
getCurrentUserId: () => 1,
|
|
|
|
|
});
|
|
|
|
|
ctrl.checkVideoMode();
|
2026-04-01 11:40:31 +02:00
|
|
|
expect(vg.addStream).toHaveBeenCalledWith(1_000_001, "me (Screen)", fakeStream, {
|
|
|
|
|
isSelf: true,
|
|
|
|
|
audioUserId: 1,
|
|
|
|
|
isScreenshare: true,
|
|
|
|
|
});
|
2026-03-26 18:07:20 +01:00
|
|
|
|
|
|
|
|
// Second call: screenshare off — tile removed
|
|
|
|
|
mockVoiceStoreGetState.mockReturnValue(
|
2026-04-01 11:40:31 +02:00
|
|
|
makeVoiceState({
|
|
|
|
|
currentChannelId: 10,
|
|
|
|
|
localScreenshare: false,
|
|
|
|
|
voiceUsers: new Map([[10, users]]),
|
|
|
|
|
}),
|
2026-03-26 18:07:20 +01:00
|
|
|
);
|
|
|
|
|
ctrl.checkVideoMode();
|
|
|
|
|
expect(vg.removeStream).toHaveBeenCalledWith(1_000_001);
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-02 13:42:00 +02:00
|
|
|
it("checkVideoMode does NOT auto-open for remote screenshare (BUG-105)", () => {
|
2026-03-26 18:07:20 +01:00
|
|
|
const users = new Map([
|
|
|
|
|
[1, { userId: 1, camera: false, screenshare: false, username: "me" }],
|
|
|
|
|
[2, { userId: 2, camera: false, screenshare: true, username: "bob" }],
|
|
|
|
|
]);
|
|
|
|
|
mockVoiceStoreGetState.mockReturnValue(
|
|
|
|
|
makeVoiceState({ currentChannelId: 10, voiceUsers: new Map([[10, users]]) }),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const ctrl = createVideoModeController({
|
|
|
|
|
slots: makeSlots(),
|
|
|
|
|
videoGrid: makeVideoGrid(),
|
|
|
|
|
getCurrentUserId: () => 1,
|
|
|
|
|
});
|
|
|
|
|
ctrl.checkVideoMode();
|
|
|
|
|
|
2026-04-02 13:42:00 +02:00
|
|
|
// Remote streams require manual click — no auto-open
|
|
|
|
|
expect(ctrl.isVideoMode()).toBe(false);
|
2026-03-26 22:09:56 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// -----------------------------------------------------------------------
|
|
|
|
|
// TileConfig verification tests (Spec 1)
|
|
|
|
|
// -----------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
it("checkVideoMode passes isSelf:true for local camera tile", () => {
|
|
|
|
|
const fakeStream = {} as MediaStream;
|
|
|
|
|
mockGetLocalCameraStream.mockReturnValue(fakeStream);
|
|
|
|
|
const users = new Map([[5, { userId: 5, camera: false, screenshare: false, username: "me" }]]);
|
|
|
|
|
mockVoiceStoreGetState.mockReturnValue(
|
|
|
|
|
makeVoiceState({
|
|
|
|
|
currentChannelId: 10,
|
|
|
|
|
localCamera: true,
|
|
|
|
|
voiceUsers: new Map([[10, users]]),
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const vg = makeVideoGrid();
|
|
|
|
|
const ctrl = createVideoModeController({
|
|
|
|
|
slots: makeSlots(),
|
|
|
|
|
videoGrid: vg,
|
|
|
|
|
getCurrentUserId: () => 5,
|
|
|
|
|
});
|
|
|
|
|
ctrl.checkVideoMode();
|
|
|
|
|
|
|
|
|
|
expect(vg.addStream).toHaveBeenCalledWith(
|
|
|
|
|
5,
|
|
|
|
|
"me (You)",
|
|
|
|
|
fakeStream,
|
|
|
|
|
expect.objectContaining({ isSelf: true, audioUserId: 5, isScreenshare: false }),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("checkVideoMode passes isSelf:true and isScreenshare:true for local screenshare tile", () => {
|
|
|
|
|
const fakeStream = {} as MediaStream;
|
|
|
|
|
mockGetLocalScreenshareStream.mockReturnValue(fakeStream);
|
|
|
|
|
const users = new Map([[5, { userId: 5, camera: false, screenshare: false, username: "me" }]]);
|
|
|
|
|
mockVoiceStoreGetState.mockReturnValue(
|
|
|
|
|
makeVoiceState({
|
|
|
|
|
currentChannelId: 10,
|
|
|
|
|
localScreenshare: true,
|
|
|
|
|
voiceUsers: new Map([[10, users]]),
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const vg = makeVideoGrid();
|
|
|
|
|
const ctrl = createVideoModeController({
|
|
|
|
|
slots: makeSlots(),
|
|
|
|
|
videoGrid: vg,
|
|
|
|
|
getCurrentUserId: () => 5,
|
|
|
|
|
});
|
|
|
|
|
ctrl.checkVideoMode();
|
|
|
|
|
|
|
|
|
|
// screenshareUserId = 5 + 1_000_000 = 1_000_005
|
|
|
|
|
expect(vg.addStream).toHaveBeenCalledWith(
|
|
|
|
|
1_000_005,
|
|
|
|
|
"me (Screen)",
|
|
|
|
|
fakeStream,
|
|
|
|
|
expect.objectContaining({ isSelf: true, audioUserId: 5, isScreenshare: true }),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// -----------------------------------------------------------------------
|
|
|
|
|
// Focus mode tests (Spec 2)
|
|
|
|
|
// -----------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
it("setFocus sets focused tile and calls videoGrid.setFocusedTile", () => {
|
|
|
|
|
const vg = makeVideoGrid();
|
|
|
|
|
const ctrl = createVideoModeController({
|
|
|
|
|
slots: makeSlots(),
|
|
|
|
|
videoGrid: vg,
|
|
|
|
|
getCurrentUserId: () => 1,
|
|
|
|
|
});
|
|
|
|
|
ctrl.setFocus(42);
|
|
|
|
|
expect(vg.setFocusedTile).toHaveBeenCalledWith(42);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("getFocusedTileId returns null initially", () => {
|
|
|
|
|
const ctrl = createVideoModeController({
|
|
|
|
|
slots: makeSlots(),
|
|
|
|
|
videoGrid: makeVideoGrid(),
|
|
|
|
|
getCurrentUserId: () => 1,
|
|
|
|
|
});
|
|
|
|
|
expect(ctrl.getFocusedTileId()).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("getFocusedTileId returns set value", () => {
|
|
|
|
|
const ctrl = createVideoModeController({
|
|
|
|
|
slots: makeSlots(),
|
|
|
|
|
videoGrid: makeVideoGrid(),
|
|
|
|
|
getCurrentUserId: () => 1,
|
|
|
|
|
});
|
|
|
|
|
ctrl.setFocus(42);
|
|
|
|
|
expect(ctrl.getFocusedTileId()).toBe(42);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("showChat resets focusedTileId", () => {
|
|
|
|
|
const ctrl = createVideoModeController({
|
|
|
|
|
slots: makeSlots(),
|
|
|
|
|
videoGrid: makeVideoGrid(),
|
|
|
|
|
getCurrentUserId: () => 1,
|
|
|
|
|
});
|
|
|
|
|
ctrl.showVideoGrid();
|
|
|
|
|
ctrl.setFocus(42);
|
|
|
|
|
expect(ctrl.getFocusedTileId()).toBe(42);
|
|
|
|
|
|
|
|
|
|
ctrl.showChat();
|
|
|
|
|
expect(ctrl.getFocusedTileId()).toBeNull();
|
2026-03-26 18:07:20 +01:00
|
|
|
});
|
2026-08-07 21:20:48 +02:00
|
|
|
|
|
|
|
|
describe("sticky video-grid dismissal (v048)", () => {
|
|
|
|
|
it("showChat while local video is on stays dismissed through a later checkVideoMode", () => {
|
|
|
|
|
const users = new Map([
|
|
|
|
|
[1, { userId: 1, camera: false, screenshare: false, username: "me" }],
|
|
|
|
|
]);
|
|
|
|
|
mockVoiceStoreGetState.mockReturnValue(
|
|
|
|
|
makeVoiceState({
|
|
|
|
|
currentChannelId: 10,
|
|
|
|
|
localCamera: true,
|
|
|
|
|
voiceUsers: new Map([[10, users]]),
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const slots = makeSlots();
|
|
|
|
|
const ctrl = createVideoModeController({
|
|
|
|
|
slots,
|
|
|
|
|
videoGrid: makeVideoGrid(),
|
|
|
|
|
getCurrentUserId: () => 1,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Auto-opens because local camera is on.
|
|
|
|
|
ctrl.checkVideoMode();
|
|
|
|
|
expect(ctrl.isVideoMode()).toBe(true);
|
|
|
|
|
|
|
|
|
|
// User switches to a text channel — explicit dismissal.
|
|
|
|
|
ctrl.showChat();
|
|
|
|
|
expect(ctrl.isVideoMode()).toBe(false);
|
|
|
|
|
|
|
|
|
|
// A remote peer's camera toggling re-invokes checkVideoMode(); local
|
|
|
|
|
// video is still on, but the dismissal must stick.
|
|
|
|
|
ctrl.checkVideoMode();
|
|
|
|
|
expect(ctrl.isVideoMode()).toBe(false);
|
|
|
|
|
expect(slots.messagesSlot.style.display).toBe("");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("re-arms auto-open once local video turns off after a dismissal", () => {
|
|
|
|
|
const users = new Map([
|
|
|
|
|
[1, { userId: 1, camera: false, screenshare: false, username: "me" }],
|
|
|
|
|
]);
|
|
|
|
|
const state = makeVoiceState({
|
|
|
|
|
currentChannelId: 10,
|
|
|
|
|
localCamera: true,
|
|
|
|
|
voiceUsers: new Map([[10, users]]),
|
|
|
|
|
});
|
|
|
|
|
mockVoiceStoreGetState.mockReturnValue(state);
|
|
|
|
|
|
|
|
|
|
const ctrl = createVideoModeController({
|
|
|
|
|
slots: makeSlots(),
|
|
|
|
|
videoGrid: makeVideoGrid(),
|
|
|
|
|
getCurrentUserId: () => 1,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
ctrl.checkVideoMode();
|
|
|
|
|
ctrl.showChat();
|
|
|
|
|
expect(ctrl.isVideoMode()).toBe(false);
|
|
|
|
|
|
|
|
|
|
// Local camera turns off entirely — dismissal is cleared.
|
|
|
|
|
mockVoiceStoreGetState.mockReturnValue({ ...state, localCamera: false });
|
|
|
|
|
ctrl.checkVideoMode();
|
|
|
|
|
expect(ctrl.isVideoMode()).toBe(false);
|
|
|
|
|
|
|
|
|
|
// Local camera turns back on — auto-open fires again since there is
|
|
|
|
|
// nothing left to have dismissed.
|
|
|
|
|
mockVoiceStoreGetState.mockReturnValue({ ...state, localCamera: true });
|
|
|
|
|
ctrl.checkVideoMode();
|
|
|
|
|
expect(ctrl.isVideoMode()).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("explicit showVideoGrid clears a prior dismissal", () => {
|
|
|
|
|
const users = new Map([
|
|
|
|
|
[1, { userId: 1, camera: false, screenshare: false, username: "me" }],
|
|
|
|
|
]);
|
|
|
|
|
mockVoiceStoreGetState.mockReturnValue(
|
|
|
|
|
makeVoiceState({
|
|
|
|
|
currentChannelId: 10,
|
|
|
|
|
localCamera: true,
|
|
|
|
|
voiceUsers: new Map([[10, users]]),
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const ctrl = createVideoModeController({
|
|
|
|
|
slots: makeSlots(),
|
|
|
|
|
videoGrid: makeVideoGrid(),
|
|
|
|
|
getCurrentUserId: () => 1,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
ctrl.checkVideoMode();
|
|
|
|
|
ctrl.showChat();
|
|
|
|
|
expect(ctrl.isVideoMode()).toBe(false);
|
|
|
|
|
|
|
|
|
|
// User manually re-opens the grid.
|
|
|
|
|
ctrl.showVideoGrid();
|
|
|
|
|
expect(ctrl.isVideoMode()).toBe(true);
|
|
|
|
|
|
|
|
|
|
ctrl.showChat();
|
|
|
|
|
ctrl.checkVideoMode();
|
|
|
|
|
// Dismissal was cleared by showVideoGrid, but showChat() re-set it —
|
|
|
|
|
// so this exercises that the flag responds to the most recent call.
|
|
|
|
|
expect(ctrl.isVideoMode()).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does not treat leaving the channel as a dismissal (v048)", () => {
|
|
|
|
|
const users = new Map([
|
|
|
|
|
[1, { userId: 1, camera: false, screenshare: false, username: "me" }],
|
|
|
|
|
]);
|
|
|
|
|
const inChannel = makeVoiceState({
|
|
|
|
|
currentChannelId: 10,
|
|
|
|
|
localCamera: true,
|
|
|
|
|
voiceUsers: new Map([[10, users]]),
|
|
|
|
|
});
|
|
|
|
|
mockVoiceStoreGetState.mockReturnValue(inChannel);
|
|
|
|
|
|
|
|
|
|
const ctrl = createVideoModeController({
|
|
|
|
|
slots: makeSlots(),
|
|
|
|
|
videoGrid: makeVideoGrid(),
|
|
|
|
|
getCurrentUserId: () => 1,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
ctrl.checkVideoMode();
|
|
|
|
|
expect(ctrl.isVideoMode()).toBe(true);
|
|
|
|
|
|
|
|
|
|
// leaveVoice() clears currentChannelId before localCamera goes false,
|
|
|
|
|
// and this checkVideoMode() returns early — closing the grid here must
|
|
|
|
|
// not record a dismissal that outlives the session.
|
|
|
|
|
mockVoiceStoreGetState.mockReturnValue({ ...inChannel, currentChannelId: null });
|
|
|
|
|
ctrl.checkVideoMode();
|
|
|
|
|
expect(ctrl.isVideoMode()).toBe(false);
|
|
|
|
|
|
|
|
|
|
// Next session, camera on again: auto-open must still work.
|
|
|
|
|
mockVoiceStoreGetState.mockReturnValue(inChannel);
|
|
|
|
|
ctrl.checkVideoMode();
|
|
|
|
|
expect(ctrl.isVideoMode()).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-08-08 17:56:43 +02:00
|
|
|
|
|
|
|
|
// -----------------------------------------------------------------------
|
|
|
|
|
// Stale remote tile / focus cleanup on close (B1-8, B5-15)
|
|
|
|
|
// -----------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
describe("grid cleanup on close", () => {
|
|
|
|
|
it("clears videoGrid streams on a real leave (currentChannelId becomes null)", () => {
|
|
|
|
|
const users = new Map([
|
|
|
|
|
[1, { userId: 1, camera: false, screenshare: false, username: "me" }],
|
|
|
|
|
]);
|
|
|
|
|
const inChannel = makeVoiceState({
|
|
|
|
|
currentChannelId: 10,
|
|
|
|
|
localCamera: true,
|
|
|
|
|
voiceUsers: new Map([[10, users]]),
|
|
|
|
|
});
|
|
|
|
|
mockVoiceStoreGetState.mockReturnValue(inChannel);
|
|
|
|
|
|
|
|
|
|
const vg = makeVideoGrid();
|
|
|
|
|
const ctrl = createVideoModeController({
|
|
|
|
|
slots: makeSlots(),
|
|
|
|
|
videoGrid: vg,
|
|
|
|
|
getCurrentUserId: () => 1,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
ctrl.checkVideoMode();
|
|
|
|
|
expect(vg.clearStreams).not.toHaveBeenCalled();
|
|
|
|
|
|
|
|
|
|
// Leaving voice clears currentChannelId — remote tiles from the ended
|
|
|
|
|
// session must not survive into the next join.
|
|
|
|
|
mockVoiceStoreGetState.mockReturnValue({ ...inChannel, currentChannelId: null });
|
|
|
|
|
ctrl.checkVideoMode();
|
|
|
|
|
|
|
|
|
|
expect(vg.clearStreams).toHaveBeenCalledTimes(1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does not clear videoGrid streams while merely stopping local video mid-session", () => {
|
|
|
|
|
// Auto-reconnect keeps currentChannelId set, so a transient no-video
|
|
|
|
|
// state (all cameras off, still in the channel) must not wipe
|
|
|
|
|
// in-flight remote tiles.
|
|
|
|
|
const users = new Map([
|
|
|
|
|
[1, { userId: 1, camera: false, screenshare: false, username: "me" }],
|
|
|
|
|
]);
|
|
|
|
|
mockVoiceStoreGetState.mockReturnValue(
|
|
|
|
|
makeVoiceState({ currentChannelId: 10, voiceUsers: new Map([[10, users]]) }),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const vg = makeVideoGrid();
|
|
|
|
|
const ctrl = createVideoModeController({
|
|
|
|
|
slots: makeSlots(),
|
|
|
|
|
videoGrid: vg,
|
|
|
|
|
getCurrentUserId: () => 1,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
ctrl.checkVideoMode();
|
|
|
|
|
|
|
|
|
|
expect(vg.clearStreams).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-15 12:57:51 +02:00
|
|
|
it("clears videoGrid streams on a voice channel switch (A -> B) even though currentChannelId never passes through null (OC-0012)", () => {
|
|
|
|
|
// VoiceCallbacks.onVoiceJoin moves currentChannelId straight from the
|
|
|
|
|
// old channel to the new one (joinVoiceChannel is optimistic), so the
|
|
|
|
|
// channelId === null branch never runs on a switch. Remote tiles left
|
|
|
|
|
// over from channel A must still be cleared when we land in channel B.
|
|
|
|
|
const usersA = new Map([
|
|
|
|
|
[1, { userId: 1, camera: false, screenshare: false, username: "me" }],
|
|
|
|
|
]);
|
|
|
|
|
mockVoiceStoreGetState.mockReturnValue(
|
|
|
|
|
makeVoiceState({ currentChannelId: 10, voiceUsers: new Map([[10, usersA]]) }),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const vg = makeVideoGrid();
|
|
|
|
|
const ctrl = createVideoModeController({
|
|
|
|
|
slots: makeSlots(),
|
|
|
|
|
videoGrid: vg,
|
|
|
|
|
getCurrentUserId: () => 1,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
ctrl.checkVideoMode();
|
|
|
|
|
expect(vg.clearStreams).not.toHaveBeenCalled();
|
|
|
|
|
|
|
|
|
|
// Switch straight to channel B — currentChannelId goes 10 -> 20, never
|
|
|
|
|
// through null.
|
|
|
|
|
const usersB = new Map([
|
|
|
|
|
[1, { userId: 1, camera: false, screenshare: false, username: "me" }],
|
|
|
|
|
]);
|
|
|
|
|
mockVoiceStoreGetState.mockReturnValue(
|
|
|
|
|
makeVoiceState({ currentChannelId: 20, voiceUsers: new Map([[20, usersB]]) }),
|
|
|
|
|
);
|
|
|
|
|
ctrl.checkVideoMode();
|
|
|
|
|
|
|
|
|
|
expect(vg.clearStreams).toHaveBeenCalledTimes(1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does not clear videoGrid streams across repeated checkVideoMode calls for the same channel (auto-reconnect)", () => {
|
|
|
|
|
const users = new Map([
|
|
|
|
|
[1, { userId: 1, camera: false, screenshare: false, username: "me" }],
|
|
|
|
|
]);
|
|
|
|
|
mockVoiceStoreGetState.mockReturnValue(
|
|
|
|
|
makeVoiceState({ currentChannelId: 10, voiceUsers: new Map([[10, users]]) }),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const vg = makeVideoGrid();
|
|
|
|
|
const ctrl = createVideoModeController({
|
|
|
|
|
slots: makeSlots(),
|
|
|
|
|
videoGrid: vg,
|
|
|
|
|
getCurrentUserId: () => 1,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
ctrl.checkVideoMode();
|
|
|
|
|
ctrl.checkVideoMode();
|
|
|
|
|
ctrl.checkVideoMode();
|
|
|
|
|
|
|
|
|
|
expect(vg.clearStreams).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-08 17:56:43 +02:00
|
|
|
it("closeVideoGrid clears the videoGrid's own focus state, not just the controller's", () => {
|
|
|
|
|
const vg = makeVideoGrid();
|
|
|
|
|
const ctrl = createVideoModeController({
|
|
|
|
|
slots: makeSlots(),
|
|
|
|
|
videoGrid: vg,
|
|
|
|
|
getCurrentUserId: () => 1,
|
|
|
|
|
});
|
|
|
|
|
ctrl.showVideoGrid();
|
|
|
|
|
ctrl.setFocus(42);
|
|
|
|
|
expect(vg.setFocusedTile).toHaveBeenCalledWith(42);
|
|
|
|
|
|
|
|
|
|
ctrl.showChat();
|
|
|
|
|
|
|
|
|
|
// Without this, the grid reopens later still pinned in focus mode on
|
|
|
|
|
// tile 42 even though the controller's own focusedTileId was reset.
|
|
|
|
|
expect(vg.setFocusedTile).toHaveBeenCalledWith(null);
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-03-20 01:33:43 +01:00
|
|
|
});
|