mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* refactor: move Client/tauri-client to Client (pure move, no content change) * refactor: re-point paths after the Client flatten (mechanical, no behaviour change) --------- Co-authored-by: Claude <noreply@anthropic.com>
734 lines
26 KiB
TypeScript
734 lines
26 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
|
|
const { mockLoadPref, mockSavePref } = vi.hoisted(() => ({
|
|
mockLoadPref: vi.fn((_key: string, defaultVal: unknown) => defaultVal),
|
|
mockSavePref: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@components/settings/helpers", () => ({
|
|
loadPref: (key: string, defaultVal: unknown) => mockLoadPref(key, defaultVal),
|
|
savePref: (key: string, val: unknown) => mockSavePref(key, val),
|
|
}));
|
|
|
|
vi.mock("@lib/logger", () => ({
|
|
createLogger: () => ({
|
|
debug: vi.fn(),
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
}),
|
|
}));
|
|
|
|
vi.mock("livekit-client", () => ({
|
|
Track: {
|
|
Source: {
|
|
Microphone: "microphone",
|
|
Camera: "camera",
|
|
ScreenShare: "screenShare",
|
|
ScreenShareAudio: "screenShareAudio",
|
|
},
|
|
},
|
|
}));
|
|
|
|
const mockVoiceStoreState = {
|
|
localDeafened: false,
|
|
currentChannelId: null as number | null,
|
|
};
|
|
|
|
vi.mock("@stores/voice.store", () => ({
|
|
voiceStore: {
|
|
getState: () => mockVoiceStoreState,
|
|
},
|
|
}));
|
|
|
|
vi.mock("@lib/livekitSession", () => ({
|
|
parseUserId: (identity: string) => {
|
|
const match = identity.match(/^user-(\d+)$/);
|
|
if (match !== null && match[1] !== undefined) return parseInt(match[1], 10);
|
|
return 0;
|
|
},
|
|
}));
|
|
|
|
import { AudioElements, setAudioVolumeHost } from "../../src/lib/audioElements";
|
|
|
|
function createMockTrack(kind: string, sid: string) {
|
|
const audioEl = document.createElement("audio");
|
|
// The track passed to handleTrackSubscribedAudio is a RemoteTrack directly
|
|
// (not wrapped in a publication), so detach/attach are on the top-level object
|
|
const track = {
|
|
kind,
|
|
sid,
|
|
detach: vi.fn(() => [audioEl]),
|
|
attach: vi.fn(() => audioEl),
|
|
};
|
|
return { track, audioEl };
|
|
}
|
|
|
|
describe("AudioElements", () => {
|
|
let elements: AudioElements;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockVoiceStoreState.localDeafened = false;
|
|
mockVoiceStoreState.currentChannelId = null;
|
|
elements = new AudioElements();
|
|
});
|
|
|
|
describe("initial state", () => {
|
|
it("has default output volume multiplier of 1.0", () => {
|
|
expect(elements.getOutputVolumeMultiplier()).toBe(1.0);
|
|
});
|
|
|
|
it("returns effective volume with default multiplier", () => {
|
|
expect(elements.getEffectiveVolume(1)).toBe(1.0);
|
|
});
|
|
});
|
|
|
|
describe("setRoom", () => {
|
|
it("accepts null without throwing", () => {
|
|
expect(() => elements.setRoom(null)).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("handleTrackSubscribedAudio — mic", () => {
|
|
it("attaches mic audio element to DOM", () => {
|
|
const { track, audioEl } = createMockTrack("audio", "track-1");
|
|
const publication = { source: "microphone" };
|
|
const participant = { identity: "user-42", setVolume: vi.fn() };
|
|
|
|
elements.handleTrackSubscribedAudio(track as any, publication as any, participant as any);
|
|
|
|
expect(track.attach).toHaveBeenCalled();
|
|
expect(audioEl.style.display).toBe("none");
|
|
expect(participant.setVolume).toHaveBeenCalledWith(1.0);
|
|
});
|
|
});
|
|
|
|
describe("handleTrackSubscribedAudio — screenshare", () => {
|
|
it("attaches screenshare audio element to DOM", () => {
|
|
const { track, audioEl } = createMockTrack("audio", "track-ss-1");
|
|
const publication = { source: "screenShareAudio" };
|
|
const participant = { identity: "user-42", setVolume: vi.fn() };
|
|
|
|
elements.handleTrackSubscribedAudio(track as any, publication as any, participant as any);
|
|
|
|
expect(track.attach).toHaveBeenCalled();
|
|
expect(audioEl.volume).toBe(1);
|
|
});
|
|
|
|
it("inherits muted state from previous tracks", () => {
|
|
elements.muteScreenshareAudio(42, true);
|
|
|
|
const { track, audioEl } = createMockTrack("audio", "track-ss-2");
|
|
const publication = { source: "screenShareAudio" };
|
|
const participant = { identity: "user-42", setVolume: vi.fn() };
|
|
|
|
elements.handleTrackSubscribedAudio(track as any, publication as any, participant as any);
|
|
|
|
expect(audioEl.muted).toBe(true);
|
|
});
|
|
|
|
it("does not leak the previously-attached element into the tracking set on a fast re-subscribe (OC-0135)", () => {
|
|
// LiveKit can fire TrackSubscribed for an already-attached screenshare-
|
|
// audio track before the old TrackUnsubscribed lands (fast reconnect).
|
|
// The same underlying track's detach() then returns the element from
|
|
// the prior attach(), which the handler removes from the DOM but must
|
|
// also drop from screenshareAudioElements — otherwise it lives on in
|
|
// the Set forever.
|
|
let lastEl: HTMLAudioElement | null = null;
|
|
const track = {
|
|
kind: "audio",
|
|
sid: "track-ss-resub",
|
|
attach: vi.fn(() => {
|
|
const el = document.createElement("audio");
|
|
lastEl = el;
|
|
return el;
|
|
}),
|
|
detach: vi.fn(() => (lastEl === null ? [] : [lastEl])),
|
|
};
|
|
const publication = { source: "screenShareAudio" };
|
|
const participant = { identity: "user-42", setVolume: vi.fn() };
|
|
|
|
elements.handleTrackSubscribedAudio(track as any, publication as any, participant as any);
|
|
const firstEl = lastEl as unknown as HTMLAudioElement;
|
|
|
|
// Re-fire subscribe for the same track before any unsubscribe arrives.
|
|
elements.handleTrackSubscribedAudio(track as any, publication as any, participant as any);
|
|
const secondEl = lastEl as unknown as HTMLAudioElement;
|
|
|
|
const trackedEls = (elements as any).screenshareAudioElements.get(
|
|
42,
|
|
) as Set<HTMLAudioElement>;
|
|
expect(trackedEls.has(firstEl)).toBe(false);
|
|
expect(trackedEls.has(secondEl)).toBe(true);
|
|
expect(trackedEls.size).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("handleTrackUnsubscribedAudio", () => {
|
|
it("removes mic audio element from tracking", () => {
|
|
const { track } = createMockTrack("audio", "track-1");
|
|
const publication = { source: "microphone" };
|
|
const participant = { identity: "user-42", setVolume: vi.fn() };
|
|
|
|
// Subscribe first
|
|
elements.handleTrackSubscribedAudio(track as any, publication as any, participant as any);
|
|
// Then unsubscribe
|
|
elements.handleTrackUnsubscribedAudio(track as any, publication as any, participant as any);
|
|
|
|
expect(track.detach).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("setUserVolume", () => {
|
|
it("saves clamped volume to preferences", () => {
|
|
elements.setUserVolume(42, 150);
|
|
expect(mockSavePref).toHaveBeenCalledWith("userVolume_42", 150);
|
|
});
|
|
|
|
it("clamps to 0-200 range", () => {
|
|
elements.setUserVolume(42, -10);
|
|
expect(mockSavePref).toHaveBeenCalledWith("userVolume_42", 0);
|
|
|
|
elements.setUserVolume(42, 250);
|
|
expect(mockSavePref).toHaveBeenCalledWith("userVolume_42", 200);
|
|
});
|
|
|
|
it("applies volume to matching remote participant", () => {
|
|
const mockParticipant = {
|
|
identity: "user-42",
|
|
setVolume: vi.fn(),
|
|
};
|
|
const mockRoom = {
|
|
remoteParticipants: new Map([["user-42", mockParticipant]]),
|
|
} as any;
|
|
elements.setRoom(mockRoom);
|
|
|
|
elements.setUserVolume(42, 80);
|
|
|
|
expect(mockParticipant.setVolume).toHaveBeenCalledWith(0.8);
|
|
});
|
|
});
|
|
|
|
describe("getUserVolume", () => {
|
|
it("returns default volume of 100", () => {
|
|
expect(elements.getUserVolume(42)).toBe(100);
|
|
});
|
|
});
|
|
|
|
describe("per-user volume — host scoping (B3-6)", () => {
|
|
afterEach(() => {
|
|
// currentHost is module-level state that outlives a single test.
|
|
setAudioVolumeHost(null);
|
|
});
|
|
|
|
it("falls back to the legacy unscoped key when no host is set", () => {
|
|
elements.setUserVolume(42, 150);
|
|
expect(mockSavePref).toHaveBeenCalledWith("userVolume_42", 150);
|
|
});
|
|
|
|
it("scopes the saved-volume key to the current host", () => {
|
|
setAudioVolumeHost("a.example.com");
|
|
elements.setUserVolume(42, 150);
|
|
expect(mockSavePref).toHaveBeenCalledWith("userVolume_42:a.example.com", 150);
|
|
});
|
|
|
|
it("reads the volume back under the scoped key, not the unscoped one", () => {
|
|
setAudioVolumeHost("a.example.com");
|
|
elements.getUserVolume(42);
|
|
expect(mockLoadPref).toHaveBeenCalledWith("userVolume_42:a.example.com", 100);
|
|
});
|
|
|
|
it("does not leak a volume set on one host onto another", () => {
|
|
// Regression for B3-6: a mute (volume 0) set for user 7 on server A
|
|
// must not silence user 7 on server B.
|
|
mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => {
|
|
if (key === "userVolume_7:a.example.com") return 0;
|
|
return defaultVal;
|
|
});
|
|
|
|
setAudioVolumeHost("a.example.com");
|
|
expect(elements.getUserVolume(7)).toBe(0);
|
|
|
|
setAudioVolumeHost("b.example.com");
|
|
expect(elements.getUserVolume(7)).toBe(100);
|
|
});
|
|
|
|
it("migrates a pre-scoping legacy volume through to the scoped key without leaking to a different host", () => {
|
|
// A save made before host-scoping existed lives at the bare
|
|
// `userVolume_7` key. It must still apply once a host is set, and the
|
|
// migration must write under THAT host's scoped key specifically —
|
|
// not clobber a different host's own explicit choice.
|
|
mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => {
|
|
if (key === "userVolume_7") return 30;
|
|
if (key === "userVolume_7:b.example.com") return 80;
|
|
return defaultVal;
|
|
});
|
|
|
|
setAudioVolumeHost("a.example.com");
|
|
expect(elements.getUserVolume(7)).toBe(30);
|
|
expect(mockSavePref).toHaveBeenCalledWith("userVolume_7:a.example.com", 30);
|
|
|
|
setAudioVolumeHost("b.example.com");
|
|
expect(elements.getUserVolume(7)).toBe(80);
|
|
});
|
|
});
|
|
|
|
describe("setOutputVolume", () => {
|
|
it("saves clamped volume to preferences", () => {
|
|
elements.setOutputVolume(80);
|
|
expect(mockSavePref).toHaveBeenCalledWith("outputVolume", 80);
|
|
});
|
|
|
|
it("clamps to 0-200 range", () => {
|
|
elements.setOutputVolume(-10);
|
|
expect(mockSavePref).toHaveBeenCalledWith("outputVolume", 0);
|
|
|
|
elements.setOutputVolume(250);
|
|
expect(mockSavePref).toHaveBeenCalledWith("outputVolume", 200);
|
|
});
|
|
|
|
it("updates screenshare audio element volumes", () => {
|
|
const audioEl = document.createElement("audio");
|
|
(elements as any).screenshareAudioElements = new Map([[42, new Set([audioEl])]]);
|
|
|
|
elements.setOutputVolume(80);
|
|
|
|
expect(audioEl.volume).toBe(0.8);
|
|
});
|
|
|
|
it("clamps screenshare volume to browser max of 1.0", () => {
|
|
const audioEl = document.createElement("audio");
|
|
(elements as any).screenshareAudioElements = new Map([[42, new Set([audioEl])]]);
|
|
|
|
elements.setOutputVolume(150);
|
|
|
|
expect(audioEl.volume).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("screenshare audio", () => {
|
|
it("setScreenshareAudioVolume does not throw for unknown userId", () => {
|
|
expect(() => elements.setScreenshareAudioVolume(999, 0.5)).not.toThrow();
|
|
});
|
|
|
|
it("setScreenshareAudioVolume sets volume on tracked elements", () => {
|
|
const audioEl = document.createElement("audio");
|
|
(elements as any).screenshareAudioElements = new Map([[42, new Set([audioEl])]]);
|
|
|
|
elements.setScreenshareAudioVolume(42, 0.7);
|
|
expect(audioEl.volume).toBe(0.7);
|
|
});
|
|
|
|
it("setScreenshareAudioVolume clamps to 0-1 range", () => {
|
|
const audioEl = document.createElement("audio");
|
|
(elements as any).screenshareAudioElements = new Map([[42, new Set([audioEl])]]);
|
|
|
|
elements.setScreenshareAudioVolume(42, -0.5);
|
|
expect(audioEl.volume).toBe(0);
|
|
|
|
elements.setScreenshareAudioVolume(42, 2.0);
|
|
expect(audioEl.volume).toBe(1);
|
|
});
|
|
|
|
it("getScreenshareAudioVolume defaults to 1 and reflects stored value", () => {
|
|
expect(elements.getScreenshareAudioVolume(42)).toBe(1);
|
|
elements.setScreenshareAudioVolume(42, 0.4);
|
|
expect(elements.getScreenshareAudioVolume(42)).toBe(0.4);
|
|
});
|
|
|
|
it("volume chosen before the track attaches applies on attach", () => {
|
|
elements.setScreenshareAudioVolume(42, 0.25);
|
|
|
|
const { track, audioEl } = createMockTrack("audio", "track-ss-early");
|
|
const publication = { source: "screenShareAudio" };
|
|
const participant = { identity: "user-42", setVolume: vi.fn() };
|
|
elements.handleTrackSubscribedAudio(track as any, publication as any, participant as any);
|
|
|
|
expect(audioEl.volume).toBe(0.25);
|
|
});
|
|
|
|
it("setOutputVolume preserves per-user screenshare volume", () => {
|
|
const audioEl = document.createElement("audio");
|
|
(elements as any).screenshareAudioElements = new Map([[42, new Set([audioEl])]]);
|
|
|
|
elements.setScreenshareAudioVolume(42, 0.5);
|
|
elements.setOutputVolume(50);
|
|
|
|
expect(audioEl.volume).toBe(0.25); // 0.5 user * 0.5 master
|
|
});
|
|
|
|
it("setScreenshareAudioVolume scales by the master output multiplier", () => {
|
|
const audioEl = document.createElement("audio");
|
|
(elements as any).screenshareAudioElements = new Map([[42, new Set([audioEl])]]);
|
|
|
|
elements.setOutputVolume(50);
|
|
elements.setScreenshareAudioVolume(42, 0.8);
|
|
|
|
expect(audioEl.volume).toBe(0.4); // 0.8 user * 0.5 master
|
|
});
|
|
|
|
it("muteScreenshareAudio persists muted state", () => {
|
|
elements.muteScreenshareAudio(42, true);
|
|
expect(elements.getScreenshareAudioMuted(42)).toBe(true);
|
|
});
|
|
|
|
it("muteScreenshareAudio toggles back to unmuted", () => {
|
|
elements.muteScreenshareAudio(42, true);
|
|
elements.muteScreenshareAudio(42, false);
|
|
expect(elements.getScreenshareAudioMuted(42)).toBe(false);
|
|
});
|
|
|
|
it("muteScreenshareAudio applies muted to tracked audio elements", () => {
|
|
const audioEl = document.createElement("audio");
|
|
(elements as any).screenshareAudioElements = new Map([[42, new Set([audioEl])]]);
|
|
|
|
elements.muteScreenshareAudio(42, true);
|
|
expect(audioEl.muted).toBe(true);
|
|
|
|
elements.muteScreenshareAudio(42, false);
|
|
expect(audioEl.muted).toBe(false);
|
|
});
|
|
|
|
it("getScreenshareAudioMuted returns false for unknown userId", () => {
|
|
expect(elements.getScreenshareAudioMuted(999)).toBe(false);
|
|
});
|
|
|
|
it("getScreenshareAudioMuted reads from audio element when no persisted state", () => {
|
|
const audioEl = document.createElement("audio");
|
|
audioEl.muted = true;
|
|
(elements as any).screenshareAudioElements = new Map([[42, new Set([audioEl])]]);
|
|
// No call to muteScreenshareAudio, so no persisted state for user 42
|
|
|
|
expect(elements.getScreenshareAudioMuted(42)).toBe(true);
|
|
});
|
|
|
|
it("getScreenshareAudioMuted returns false for empty audio element set", () => {
|
|
(elements as any).screenshareAudioElements = new Map([[42, new Set()]]);
|
|
|
|
expect(elements.getScreenshareAudioMuted(42)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("applyRemoteAudioSubscriptionState", () => {
|
|
it("does not throw when no room is set", () => {
|
|
expect(() => elements.applyRemoteAudioSubscriptionState(true)).not.toThrow();
|
|
});
|
|
|
|
it("unsubscribes voice audio when deafened", () => {
|
|
const mockPub = { source: "microphone", setSubscribed: vi.fn() };
|
|
const mockParticipant = {
|
|
audioTrackPublications: new Map([["pub-1", mockPub]]),
|
|
};
|
|
const mockRoom = {
|
|
remoteParticipants: new Map([["user-1", mockParticipant]]),
|
|
} as any;
|
|
elements.setRoom(mockRoom);
|
|
|
|
elements.applyRemoteAudioSubscriptionState(true);
|
|
|
|
expect(mockPub.setSubscribed).toHaveBeenCalledWith(false);
|
|
});
|
|
|
|
it("re-subscribes voice audio when undeafened", () => {
|
|
const mockPub = { source: "microphone", setSubscribed: vi.fn() };
|
|
const mockParticipant = {
|
|
audioTrackPublications: new Map([["pub-1", mockPub]]),
|
|
};
|
|
const mockRoom = {
|
|
remoteParticipants: new Map([["user-1", mockParticipant]]),
|
|
} as any;
|
|
elements.setRoom(mockRoom);
|
|
|
|
elements.applyRemoteAudioSubscriptionState(false);
|
|
|
|
expect(mockPub.setSubscribed).toHaveBeenCalledWith(true);
|
|
});
|
|
|
|
it("leaves screenshare audio subscribed when deafened", () => {
|
|
// Muting/deafening yourself gates voices, not the stream someone is
|
|
// sharing — its audio keeps playing under its own per-tile controls.
|
|
const micPub = { source: "microphone", setSubscribed: vi.fn() };
|
|
const streamPub = { source: "screenShareAudio", setSubscribed: vi.fn() };
|
|
const mockParticipant = {
|
|
audioTrackPublications: new Map([
|
|
["pub-mic", micPub],
|
|
["pub-stream", streamPub],
|
|
]),
|
|
};
|
|
const mockRoom = {
|
|
remoteParticipants: new Map([["user-1", mockParticipant]]),
|
|
} as any;
|
|
elements.setRoom(mockRoom);
|
|
|
|
elements.applyRemoteAudioSubscriptionState(true);
|
|
|
|
expect(micPub.setSubscribed).toHaveBeenCalledWith(false);
|
|
expect(streamPub.setSubscribed).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("getEffectiveVolume", () => {
|
|
it("returns 1.0 for default user with default multiplier", () => {
|
|
expect(elements.getEffectiveVolume(42)).toBe(1.0);
|
|
});
|
|
|
|
it("returns outputVolumeMultiplier for userId <= 0", () => {
|
|
// userId <= 0 uses default 100, so effective = (100/100) * multiplier
|
|
expect(elements.getEffectiveVolume(0)).toBe(1.0);
|
|
expect(elements.getEffectiveVolume(-1)).toBe(1.0);
|
|
});
|
|
|
|
it("scales with output volume multiplier", () => {
|
|
elements.setOutputVolume(50);
|
|
expect(elements.getEffectiveVolume(42)).toBe(0.5);
|
|
});
|
|
});
|
|
|
|
describe("applyAllVolumes", () => {
|
|
it("does not throw when no room is set", () => {
|
|
expect(() => elements.applyAllVolumes()).not.toThrow();
|
|
});
|
|
|
|
it("applies effective volume to all remote participants", () => {
|
|
const mockParticipant1 = { identity: "user-10", setVolume: vi.fn() };
|
|
const mockParticipant2 = { identity: "user-20", setVolume: vi.fn() };
|
|
const mockRoom = {
|
|
remoteParticipants: new Map([
|
|
["user-10", mockParticipant1],
|
|
["user-20", mockParticipant2],
|
|
]),
|
|
} as any;
|
|
elements.setRoom(mockRoom);
|
|
|
|
elements.applyAllVolumes();
|
|
|
|
expect(mockParticipant1.setVolume).toHaveBeenCalledWith(1.0);
|
|
expect(mockParticipant2.setVolume).toHaveBeenCalledWith(1.0);
|
|
});
|
|
});
|
|
|
|
describe("handleTrackUnsubscribedAudio — screenshare", () => {
|
|
it("removes screenshare audio elements and cleans up tracking", () => {
|
|
const audioEl = document.createElement("audio");
|
|
const { track } = createMockTrack("audio", "ss-track-1");
|
|
track.detach = vi.fn(() => [audioEl]);
|
|
const publication = { source: "screenShareAudio" };
|
|
const participant = { identity: "user-42", setVolume: vi.fn() };
|
|
|
|
// Set up screenshare elements manually
|
|
(elements as any).screenshareAudioElements = new Map([[42, new Set([audioEl])]]);
|
|
|
|
elements.handleTrackUnsubscribedAudio(track as any, publication as any, participant as any);
|
|
|
|
expect(track.detach).toHaveBeenCalled();
|
|
// Should clean up from tracking map
|
|
expect((elements as any).screenshareAudioElements.has(42)).toBe(false);
|
|
});
|
|
|
|
it("handles screenshare unsubscribe when no audio elements are tracked", () => {
|
|
const { track } = createMockTrack("audio", "ss-track-2");
|
|
const publication = { source: "screenShareAudio" };
|
|
const participant = { identity: "user-42", setVolume: vi.fn() };
|
|
|
|
// No screenshare elements tracked
|
|
expect(() => {
|
|
elements.handleTrackUnsubscribedAudio(track as any, publication as any, participant as any);
|
|
}).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("handleTrackSubscribedAudio — mic with undefined sid", () => {
|
|
it("handles track with undefined sid gracefully", () => {
|
|
const audioEl = document.createElement("audio");
|
|
const track = {
|
|
kind: "audio",
|
|
sid: undefined,
|
|
detach: vi.fn(() => [audioEl]),
|
|
attach: vi.fn(() => audioEl),
|
|
};
|
|
const publication = { source: "microphone" };
|
|
const participant = { identity: "user-42", setVolume: vi.fn() };
|
|
|
|
expect(() => {
|
|
elements.handleTrackSubscribedAudio(track as any, publication as any, participant as any);
|
|
}).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("handleTrackSubscribedAudio — with saved output device", () => {
|
|
it("calls setSinkId for mic audio when audioOutputDevice preference is set", () => {
|
|
mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => {
|
|
if (key === "audioOutputDevice") return "device-123";
|
|
return defaultVal;
|
|
});
|
|
|
|
const audioEl = document.createElement("audio");
|
|
audioEl.setSinkId = vi.fn().mockResolvedValue(undefined);
|
|
const track = {
|
|
kind: "audio",
|
|
sid: "track-dev-1",
|
|
detach: vi.fn(() => [audioEl]),
|
|
attach: vi.fn(() => audioEl),
|
|
};
|
|
const publication = { source: "microphone" };
|
|
const participant = { identity: "user-42", setVolume: vi.fn() };
|
|
|
|
elements.handleTrackSubscribedAudio(track as any, publication as any, participant as any);
|
|
|
|
expect(audioEl.setSinkId).toHaveBeenCalledWith("device-123");
|
|
});
|
|
|
|
it("calls setSinkId for screenshare audio when audioOutputDevice preference is set", () => {
|
|
mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => {
|
|
if (key === "audioOutputDevice") return "device-456";
|
|
return defaultVal;
|
|
});
|
|
|
|
const audioEl = document.createElement("audio");
|
|
audioEl.setSinkId = vi.fn().mockResolvedValue(undefined);
|
|
const track = {
|
|
kind: "audio",
|
|
sid: "track-ss-dev-1",
|
|
detach: vi.fn(() => [audioEl]),
|
|
attach: vi.fn(() => audioEl),
|
|
};
|
|
const publication = { source: "screenShareAudio" };
|
|
const participant = { identity: "user-42", setVolume: vi.fn() };
|
|
|
|
elements.handleTrackSubscribedAudio(track as any, publication as any, participant as any);
|
|
|
|
expect(audioEl.setSinkId).toHaveBeenCalledWith("device-456");
|
|
});
|
|
|
|
it("handles setSinkId failure gracefully for screenshare audio", () => {
|
|
mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => {
|
|
if (key === "audioOutputDevice") return "bad-device";
|
|
return defaultVal;
|
|
});
|
|
|
|
const audioEl = document.createElement("audio");
|
|
audioEl.setSinkId = vi.fn().mockRejectedValue(new Error("Device not found"));
|
|
const track = {
|
|
kind: "audio",
|
|
sid: "track-ss-err",
|
|
detach: vi.fn(() => [audioEl]),
|
|
attach: vi.fn(() => audioEl),
|
|
};
|
|
const publication = { source: "screenShareAudio" };
|
|
const participant = { identity: "user-42", setVolume: vi.fn() };
|
|
|
|
expect(() => {
|
|
elements.handleTrackSubscribedAudio(track as any, publication as any, participant as any);
|
|
}).not.toThrow();
|
|
});
|
|
|
|
it("handles setSinkId failure gracefully for mic audio", () => {
|
|
mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => {
|
|
if (key === "audioOutputDevice") return "bad-device";
|
|
return defaultVal;
|
|
});
|
|
|
|
const audioEl = document.createElement("audio");
|
|
audioEl.setSinkId = vi.fn().mockRejectedValue(new Error("Device not found"));
|
|
const track = {
|
|
kind: "audio",
|
|
sid: "track-mic-err",
|
|
detach: vi.fn(() => [audioEl]),
|
|
attach: vi.fn(() => audioEl),
|
|
};
|
|
const publication = { source: "microphone" };
|
|
const participant = { identity: "user-42", setVolume: vi.fn() };
|
|
|
|
expect(() => {
|
|
elements.handleTrackSubscribedAudio(track as any, publication as any, participant as any);
|
|
}).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("setUserVolume — without room", () => {
|
|
it("saves volume even when no room is set", () => {
|
|
elements.setUserVolume(42, 120);
|
|
expect(mockSavePref).toHaveBeenCalledWith("userVolume_42", 120);
|
|
});
|
|
});
|
|
|
|
describe("deafen guard — handleTrackSubscribedAudio", () => {
|
|
it("does not attach mic audio when locally deafened", () => {
|
|
mockVoiceStoreState.localDeafened = true;
|
|
|
|
const { track, audioEl } = createMockTrack("audio", "track-deaf-1");
|
|
const publication = { source: "microphone", setSubscribed: vi.fn() };
|
|
const participant = { identity: "user-42", setVolume: vi.fn() };
|
|
|
|
elements.handleTrackSubscribedAudio(track as any, publication as any, participant as any);
|
|
|
|
// Should NOT attach any audio element
|
|
expect(track.attach).not.toHaveBeenCalled();
|
|
// Should unsubscribe the publication
|
|
expect(publication.setSubscribed).toHaveBeenCalledWith(false);
|
|
});
|
|
|
|
it("attaches screenshare audio even when locally deafened", () => {
|
|
// Stream audio is exempt from the deafen gate: muting/deafening
|
|
// yourself silences voices, not the content someone is streaming.
|
|
mockVoiceStoreState.localDeafened = true;
|
|
|
|
const { track } = createMockTrack("audio", "track-deaf-ss");
|
|
const publication = { source: "screenShareAudio", setSubscribed: vi.fn() };
|
|
const participant = { identity: "user-42", setVolume: vi.fn() };
|
|
|
|
elements.handleTrackSubscribedAudio(track as any, publication as any, participant as any);
|
|
|
|
expect(track.attach).toHaveBeenCalled();
|
|
expect(publication.setSubscribed).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("attaches mic audio normally when not deafened", () => {
|
|
mockVoiceStoreState.localDeafened = false;
|
|
|
|
const { track } = createMockTrack("audio", "track-ok-1");
|
|
const publication = { source: "microphone", setSubscribed: vi.fn() };
|
|
const participant = { identity: "user-42", setVolume: vi.fn() };
|
|
|
|
elements.handleTrackSubscribedAudio(track as any, publication as any, participant as any);
|
|
|
|
expect(track.attach).toHaveBeenCalled();
|
|
expect(publication.setSubscribed).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("cleanupAllAudioElements", () => {
|
|
it("does not throw when empty", () => {
|
|
expect(() => elements.cleanupAllAudioElements()).not.toThrow();
|
|
});
|
|
|
|
it("removes all tracked audio elements", () => {
|
|
const micEl = document.createElement("audio");
|
|
const ssEl = document.createElement("audio");
|
|
const removeMic = vi.spyOn(micEl, "remove");
|
|
const removeSs = vi.spyOn(ssEl, "remove");
|
|
|
|
(elements as any).remoteMicAudioElements = new Map([["track-1", micEl]]);
|
|
(elements as any).screenshareAudioElements = new Map([[42, new Set([ssEl])]]);
|
|
|
|
elements.cleanupAllAudioElements();
|
|
|
|
expect(removeMic).toHaveBeenCalled();
|
|
expect(removeSs).toHaveBeenCalled();
|
|
});
|
|
|
|
it("preserves muted-by-user state for reconnecting tracks", () => {
|
|
elements.muteScreenshareAudio(42, true);
|
|
elements.cleanupAllAudioElements();
|
|
expect(elements.getScreenshareAudioMuted(42)).toBe(true);
|
|
});
|
|
|
|
it("clears muted-by-user state with full cleanup", () => {
|
|
elements.muteScreenshareAudio(42, true);
|
|
elements.cleanupAllAudioElementsFull();
|
|
expect(elements.getScreenshareAudioMuted(42)).toBe(false);
|
|
});
|
|
});
|
|
});
|