Files
OwnCord/Client/tauri-client/tests/unit/audio-elements.test.ts
T
Claude 85f05e999c 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

616 lines
22 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } 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 } 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);
});
});
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("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 all audio when deafened", () => {
const mockPub = { 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 all audio when undeafened", () => {
const mockPub = { 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);
});
});
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("does not attach screenshare audio when locally deafened", () => {
mockVoiceStoreState.localDeafened = true;
const { track, audioEl } = 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).not.toHaveBeenCalled();
expect(publication.setSubscribed).toHaveBeenCalledWith(false);
});
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);
});
});
});