Files
OwnCord/Client/tauri-client/tests/unit/video-mode-controller.test.ts
T
J3vbandClaude b8b7a2a1f9 fix: correctness fixes across LiveKit voice, client session and transport paths (#1374)
* fix: enhance bugfix workflow documentation with detailed clustering and staging instructions

* fix(voice): 6 defect(s) (OC-0001, OC-0006, OC-0009, OC-0010, OC-0015, OC-0029)

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

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

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

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

* fix(admin): 1 defect(s) (OC-0013)

* fix(client): 3 defect(s) (OC-0014, OC-0024, OC-0031)

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

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

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

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

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

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

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

* fix(identity): 1 defect(s) (OC-0030)

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

* fix(client): 2 defect(s) (OC-0002, OC-0020)

OC-0002: chain offer handling behind the announce chain so an offer that
arrives immediately behind its sender's announce is not dropped as an
unknown peer.

OC-0020: retire a departing peer's ECDH key on participant-left so a
replayed pre-leave announce cannot overwrite the fresh key they rejoined
with.

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

handleVoiceJoin handed the client its LiveKit token before checking whether
the join had been superseded by a concurrent eviction (moderator kick/move,
the CONNECT_VOICE revocation sweep, CleanupVoiceForChannel). Those evictors
delete the voice_states row, clear the client's in-memory state, and call
RemoveParticipant — which no-ops because the join has not reached the SFU
yet. The client was left holding a live 5-minute RoomJoin credential for a
membership the server had just torn down.

Re-check the client's voice state immediately after GenerateToken and
withhold the credential if the join was superseded, with a best-effort
RemoveParticipant to match every other eviction path.

* fix(ws): 2 defect(s) (OC-0017, OC-0022)

OC-0017: sweepStaleVoiceStates re-checks the live client immediately before
deleting a snapshotted-stale voice_states row. voice_join commits the row
before calling c.setVoiceState, so a join that lands inside that window was
snapshotted as a ghost and had its just-committed row deleted, leaving the
client in voice in memory with no DB row.

OC-0022: CleanupVoiceForChannel resolves its voice_leave audience with a
variant of channelReadAudience that skips the archived short-circuit. Both
production callers archive the channel before evicting, so the plain
resolver always returned an empty audience and only the evicted
participants learned the call ended.

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

Camera and screenshare now draw from the same per-channel voice_max_video
budget. handleVoiceScreenshareV2 performed no cap check at all, and the
camera gate's slot-count subquery counted only `camera = 1` rows, so a
screensharing occupant was invisible to it. Both gates now count
`camera = 1 OR screenshare = 1` via a shared enableVideoSlot helper.

* fix(client): 2 defect(s) (OC-0032, OC-0033)

OC-0033: voice_disconnected staleness guard swallowed the kick toast when
the sibling voice_leave had already cleared currentChannelId. Treat a
cleared store as not-stale.

OC-0032: VIDEO_LIMIT rollback assumed the camera, tearing down a working
camera and leaving refused screen tracks published. Correlate by envelope
id and roll back the kind that was actually refused.

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

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

A superseded video-enable id makes rollbackPendingVideo return undefined.
The dispatcher's ternary treated undefined as "not screen" and called
disableCamera(), tearing down a working camera the user never touched.
Return early instead: undefined means there is nothing to roll back.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-15 12:57:51 +02:00

762 lines
24 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
const { mockVoiceStoreGetState, mockGetLocalCameraStream, mockGetLocalScreenshareStream } =
vi.hoisted(() => ({
mockVoiceStoreGetState: vi.fn(),
mockGetLocalCameraStream: vi.fn((): MediaStream | null => null),
mockGetLocalScreenshareStream: vi.fn((): MediaStream | null => null),
}));
vi.mock("@stores/voice.store", () => ({
voiceStore: { getState: mockVoiceStoreGetState },
}));
vi.mock("@lib/livekitSession", () => ({
getLocalCameraStream: mockGetLocalCameraStream,
getLocalScreenshareStream: mockGetLocalScreenshareStream,
setScreenshareAudioVolume: vi.fn(),
}));
// ---------------------------------------------------------------------------
// 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(),
clearStreams: vi.fn(),
hasStreams: vi.fn(() => false),
setFocusedTile: vi.fn(),
getFocusedTileId: vi.fn(() => null),
} as unknown as VideoModeControllerOptions["videoGrid"];
}
interface VoiceStateStub {
currentChannelId: number | null;
localCamera: boolean;
localScreenshare: boolean;
voiceUsers: Map<
number,
Map<number, { userId: number; camera: boolean; screenshare: boolean; username: string }>
>;
}
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);
mockGetLocalScreenshareStream.mockReturnValue(null);
});
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);
});
it("checkVideoMode does NOT auto-open for remote camera (BUG-105)", () => {
const users = new Map([[2, { userId: 2, camera: true, screenshare: false, username: "bob" }]]);
mockVoiceStoreGetState.mockReturnValue(
makeVoiceState({ currentChannelId: 10, voiceUsers: new Map([[10, users]]) }),
);
const slots = makeSlots();
const ctrl = createVideoModeController({
slots,
videoGrid: makeVideoGrid(),
getCurrentUserId: () => 1,
});
ctrl.checkVideoMode();
// Remote streams require manual click — no auto-open
expect(ctrl.isVideoMode()).toBe(false);
});
it("checkVideoMode auto-closes video grid when no streams remain", () => {
const users = new Map([[2, { userId: 2, camera: true, screenshare: false, username: "bob" }]]);
mockVoiceStoreGetState.mockReturnValue(
makeVoiceState({ currentChannelId: 10, voiceUsers: new Map([[10, users]]) }),
);
const slots = makeSlots();
const ctrl = createVideoModeController({
slots,
videoGrid: makeVideoGrid(),
getCurrentUserId: () => 1,
});
// Manually open video grid first
ctrl.showVideoGrid();
expect(ctrl.isVideoMode()).toBe(true);
// All cameras off — should auto-close
users.set(2, { userId: 2, camera: false, screenshare: false, username: "bob" });
ctrl.checkVideoMode();
expect(ctrl.isVideoMode()).toBe(false);
expect(slots.messagesSlot.style.display).toBe("");
});
it("checkVideoMode auto-opens when local camera is on (BUG-105)", () => {
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();
expect(ctrl.isVideoMode()).toBe(true);
});
it("adds local self-view tile when local camera is on", () => {
const fakeStream = {} as MediaStream;
mockGetLocalCameraStream.mockReturnValue(fakeStream);
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 vg = makeVideoGrid();
const ctrl = createVideoModeController({
slots: makeSlots(),
videoGrid: vg,
getCurrentUserId: () => 1,
});
ctrl.checkVideoMode();
expect(vg.addStream).toHaveBeenCalledWith(1, "me (You)", fakeStream, {
isSelf: true,
audioUserId: 1,
isScreenshare: false,
});
});
it("removes local tile when local camera is off", () => {
const users = new Map([[1, { userId: 1, camera: false, screenshare: false, username: "me" }]]);
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);
});
it("does NOT remove remote tiles in checkVideoMode (delegated to onRemoteVideoRemoved)", () => {
const users = new Map([
[1, { userId: 1, camera: false, screenshare: false, username: "me" }],
[2, { userId: 2, camera: false, screenshare: false, username: "bob" }],
]);
mockVoiceStoreGetState.mockReturnValue(
makeVoiceState({
currentChannelId: 10,
voiceUsers: new Map([[10, users]]),
}),
);
const vg = makeVideoGrid();
const ctrl = createVideoModeController({
slots: makeSlots(),
videoGrid: vg,
getCurrentUserId: () => 1,
});
ctrl.checkVideoMode();
// 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);
});
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");
});
it("checkVideoMode auto-opens when local screenshare is on (BUG-105)", () => {
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();
expect(ctrl.isVideoMode()).toBe(true);
});
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
expect(vg.addStream).toHaveBeenCalledWith(1_000_001, "me (Screen)", fakeStream, {
isSelf: true,
audioUserId: 1,
isScreenshare: true,
});
});
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(
makeVoiceState({
currentChannelId: 10,
localScreenshare: true,
voiceUsers: new Map([[10, users]]),
}),
);
const fakeStream = { getTracks: () => [] } as unknown as MediaStream;
mockGetLocalScreenshareStream.mockReturnValue(fakeStream);
const vg = makeVideoGrid();
const ctrl = createVideoModeController({
slots: makeSlots(),
videoGrid: vg,
getCurrentUserId: () => 1,
});
ctrl.checkVideoMode();
expect(vg.addStream).toHaveBeenCalledWith(1_000_001, "me (Screen)", fakeStream, {
isSelf: true,
audioUserId: 1,
isScreenshare: true,
});
// Second call: screenshare off — tile removed
mockVoiceStoreGetState.mockReturnValue(
makeVoiceState({
currentChannelId: 10,
localScreenshare: false,
voiceUsers: new Map([[10, users]]),
}),
);
ctrl.checkVideoMode();
expect(vg.removeStream).toHaveBeenCalledWith(1_000_001);
});
it("checkVideoMode does NOT auto-open for remote screenshare (BUG-105)", () => {
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();
// Remote streams require manual click — no auto-open
expect(ctrl.isVideoMode()).toBe(false);
});
// -----------------------------------------------------------------------
// 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();
});
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);
});
});
// -----------------------------------------------------------------------
// 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();
});
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();
});
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);
});
});
});