mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 21:45:31 +09:00
fix(app): keep hydrated state across gateway session resumes (#1862)
This commit is contained in:
1 parent
b7f75e25ad
commit
943b948de7
41 files changed
+81
-690
No files matched your search
@@ -1845,8 +1845,8 @@ class MediaEngineFacade extends Store {
|
||||
);
|
||||
}
|
||||
|
||||
handleConnectionOpen(guilds: Array<GuildReadyData>): void {
|
||||
voiceEngineV2AppVoiceStateAdapter.handleConnectionOpen(guilds);
|
||||
handleGatewayReady(guilds: Array<GuildReadyData>): void {
|
||||
voiceEngineV2AppVoiceStateAdapter.handleGatewayReady(guilds);
|
||||
}
|
||||
|
||||
handleGuildCreate(guild: GuildReadyData): void {
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {Room} from 'livekit-client';
|
||||
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const updateSettings = vi.fn();
|
||||
let outputDevices: Array<{deviceId: string}> = [];
|
||||
|
||||
vi.mock('@app/features/voice/state/VoiceSettings', () => ({
|
||||
default: {
|
||||
getOutputDeviceId: () => 'default',
|
||||
updateSettings: (update: unknown) => updateSettings(update),
|
||||
subscribe: () => () => undefined,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@app/features/voice/utils/VoiceDeviceManager', () => ({
|
||||
voiceDeviceManager: {
|
||||
ensureDevices: async () => ({
|
||||
inputDevices: [],
|
||||
outputDevices,
|
||||
videoDevices: [],
|
||||
permissionStatus: 'granted',
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
const {applyOutputDeviceToRoom} = await import('./VoiceOutputDeviceSync');
|
||||
|
||||
class FakeMediaElement {
|
||||
setSinkId = vi.fn(async () => undefined);
|
||||
}
|
||||
|
||||
Object.defineProperty(FakeMediaElement.prototype, 'setSinkId', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: async (): Promise<void> => undefined,
|
||||
});
|
||||
|
||||
vi.stubGlobal('HTMLMediaElement', FakeMediaElement);
|
||||
|
||||
interface FakeRoomOptions {
|
||||
webAudioMix?: boolean;
|
||||
audioContextSupportsSetSinkId?: boolean;
|
||||
switchActiveDevice?: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
function createRoom(options: FakeRoomOptions = {}) {
|
||||
const {webAudioMix = true, audioContextSupportsSetSinkId = true} = options;
|
||||
const switchActiveDevice = vi.fn(options.switchActiveDevice ?? (async () => true));
|
||||
const mixerSetSinkId = vi.fn(async () => undefined);
|
||||
const trackSetSinkId = vi.fn(async () => undefined);
|
||||
const element = new FakeMediaElement();
|
||||
const audioContext = audioContextSupportsSetSinkId ? {setSinkId: mixerSetSinkId} : {};
|
||||
const room = {
|
||||
options: {webAudioMix},
|
||||
audioContext,
|
||||
switchActiveDevice,
|
||||
remoteParticipants: new Map([
|
||||
[
|
||||
'participant-with-track-sink',
|
||||
{
|
||||
audioTrackPublications: new Map([
|
||||
['pub-track-sink', {kind: 'audio', track: {kind: 'audio', setSinkId: trackSetSinkId}}],
|
||||
]),
|
||||
},
|
||||
],
|
||||
[
|
||||
'participant-with-elements',
|
||||
{
|
||||
audioTrackPublications: new Map([
|
||||
['pub-elements', {kind: 'audio', track: {kind: 'audio', attachedElements: [element]}}],
|
||||
]),
|
||||
},
|
||||
],
|
||||
]),
|
||||
};
|
||||
return {room: room as unknown as Room, switchActiveDevice, mixerSetSinkId, trackSetSinkId, element};
|
||||
}
|
||||
|
||||
describe('applyOutputDeviceToRoom', () => {
|
||||
beforeEach(() => {
|
||||
updateSettings.mockClear();
|
||||
outputDevices = [{deviceId: 'speaker-1'}];
|
||||
});
|
||||
|
||||
it('hands the Web Audio mixer room the literal default id so LiveKit resolves it to a real device', async () => {
|
||||
const {room, switchActiveDevice} = createRoom();
|
||||
|
||||
await applyOutputDeviceToRoom(room, 'default');
|
||||
|
||||
expect(switchActiveDevice).toHaveBeenCalledWith('audiooutput', 'default');
|
||||
expect(switchActiveDevice).not.toHaveBeenCalledWith('audiooutput', '');
|
||||
});
|
||||
|
||||
it('passes a concrete device id through verbatim', async () => {
|
||||
const {room, switchActiveDevice} = createRoom();
|
||||
|
||||
await applyOutputDeviceToRoom(room, 'speaker-1');
|
||||
|
||||
expect(switchActiveDevice).toHaveBeenCalledWith('audiooutput', 'speaker-1');
|
||||
});
|
||||
|
||||
it('applies the normalized sink id to the Web Audio mixer and the attached elements', async () => {
|
||||
const {room, mixerSetSinkId, trackSetSinkId, element} = createRoom();
|
||||
|
||||
await applyOutputDeviceToRoom(room, 'default');
|
||||
|
||||
expect(mixerSetSinkId).toHaveBeenCalledWith('');
|
||||
expect(trackSetSinkId).toHaveBeenCalledWith('');
|
||||
expect(element.setSinkId).toHaveBeenCalledWith('');
|
||||
});
|
||||
|
||||
it('applies a concrete sink id to the Web Audio mixer and the attached elements', async () => {
|
||||
const {room, mixerSetSinkId, trackSetSinkId, element} = createRoom();
|
||||
|
||||
await applyOutputDeviceToRoom(room, 'speaker-1');
|
||||
|
||||
expect(mixerSetSinkId).toHaveBeenCalledWith('speaker-1');
|
||||
expect(trackSetSinkId).toHaveBeenCalledWith('speaker-1');
|
||||
expect(element.setSinkId).toHaveBeenCalledWith('speaker-1');
|
||||
});
|
||||
|
||||
it('still runs the mixer and element paths when LiveKit rejects with NotFoundError', async () => {
|
||||
const notFound = new Error('Requested device not found');
|
||||
notFound.name = 'NotFoundError';
|
||||
const {room, switchActiveDevice, mixerSetSinkId, trackSetSinkId, element} = createRoom({
|
||||
switchActiveDevice: async () => {
|
||||
throw notFound;
|
||||
},
|
||||
});
|
||||
|
||||
await expect(applyOutputDeviceToRoom(room, 'default')).resolves.toBeUndefined();
|
||||
|
||||
expect(switchActiveDevice).toHaveBeenCalledWith('audiooutput', 'default');
|
||||
expect(mixerSetSinkId).toHaveBeenCalledWith('');
|
||||
expect(trackSetSinkId).toHaveBeenCalledWith('');
|
||||
expect(element.setSinkId).toHaveBeenCalledWith('');
|
||||
});
|
||||
|
||||
it('skips the LiveKit switch when the Web Audio mixer context cannot switch sinks', async () => {
|
||||
const {room, switchActiveDevice, trackSetSinkId, element} = createRoom({
|
||||
audioContextSupportsSetSinkId: false,
|
||||
});
|
||||
|
||||
await applyOutputDeviceToRoom(room, 'default');
|
||||
|
||||
expect(switchActiveDevice).not.toHaveBeenCalled();
|
||||
expect(trackSetSinkId).toHaveBeenCalledWith('');
|
||||
expect(element.setSinkId).toHaveBeenCalledWith('');
|
||||
});
|
||||
|
||||
it('normalizes the sink id only when LiveKit forwards it straight to the browser', async () => {
|
||||
const {room, switchActiveDevice} = createRoom({
|
||||
webAudioMix: false,
|
||||
audioContextSupportsSetSinkId: false,
|
||||
});
|
||||
|
||||
await applyOutputDeviceToRoom(room, 'default');
|
||||
|
||||
expect(switchActiveDevice).toHaveBeenCalledWith('audiooutput', '');
|
||||
});
|
||||
|
||||
it('falls back to the default device when the stored output device disappeared', async () => {
|
||||
outputDevices = [{deviceId: 'speaker-2'}];
|
||||
const {room, switchActiveDevice} = createRoom();
|
||||
|
||||
await applyOutputDeviceToRoom(room, 'speaker-1');
|
||||
|
||||
expect(updateSettings).toHaveBeenCalledWith({outputDeviceId: 'default'});
|
||||
expect(switchActiveDevice).toHaveBeenCalledWith('audiooutput', 'default');
|
||||
});
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describe, expect, it, vi} from 'vitest';
|
||||
import {computeTimeDomainRms} from './VoiceEngineV2AppRemoteSpeakingAdapter';
|
||||
|
||||
vi.mock('@app/features/voice/state/ParticipantVolume', () => ({default: {}}));
|
||||
vi.mock('@app/features/voice/state/VoiceSettings', () => ({default: {getVadThreshold: () => 50}}));
|
||||
|
||||
describe('computeTimeDomainRms', () => {
|
||||
it('returns the amplitude of an alternating full-scale-relative signal', () => {
|
||||
const samples = new Float32Array(512);
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
samples[i] = i % 2 === 0 ? 0.09 : -0.09;
|
||||
}
|
||||
expect(computeTimeDomainRms(samples)).toBe(Math.fround(0.09));
|
||||
});
|
||||
|
||||
it('returns zero for digital silence', () => {
|
||||
expect(computeTimeDomainRms(new Float32Array(512))).toBe(0);
|
||||
});
|
||||
|
||||
it('returns zero for an empty buffer', () => {
|
||||
expect(computeTimeDomainRms(new Float32Array(0))).toBe(0);
|
||||
});
|
||||
|
||||
it('spreads a single full-scale sample across the window', () => {
|
||||
const samples = new Float32Array(512);
|
||||
samples[17] = 1;
|
||||
expect(computeTimeDomainRms(samples)).toBeCloseTo(1 / Math.sqrt(512), 12);
|
||||
});
|
||||
|
||||
it('resolves levels far below one 8-bit quantisation step', () => {
|
||||
expect(computeTimeDomainRms(Float32Array.of(0.0005, -0.0005))).toBe(Math.fround(0.0005));
|
||||
});
|
||||
});
|
||||
@@ -40,7 +40,7 @@ function guild(id: string, voiceStates: ReadonlyArray<VoiceState> = []): GuildRe
|
||||
describe('VoiceEngineV2AppVoiceStateAdapter', () => {
|
||||
it('keeps gateway voice-state indexes as immutable replacement projections', () => {
|
||||
const adapter = new VoiceEngineV2AppVoiceStateAdapter();
|
||||
adapter.handleConnectionOpen([
|
||||
adapter.handleGatewayReady([
|
||||
guild('guild-1', [voiceState({connection_id: 'connection-1', channel_id: 'channel-1'})]),
|
||||
]);
|
||||
const before = adapter.getConnectionVoiceStates();
|
||||
|
||||
@@ -95,7 +95,7 @@ export class VoiceEngineV2AppVoiceStateAdapter extends Store {
|
||||
return connectionId != null && this.ignoredConnectionIds.has(connectionId);
|
||||
}
|
||||
|
||||
handleConnectionOpen(guilds: Array<GuildReadyData>): void {
|
||||
handleGatewayReady(guilds: Array<GuildReadyData>): void {
|
||||
this.send({
|
||||
type: 'connection.open',
|
||||
guilds: guilds.map((guild) => filterIgnoredGuildVoiceStates(this.ignoredConnectionIds, guild)),
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {getStreamKey} from '@app/features/voice/components/StreamKeys';
|
||||
import {VoiceTrackKind, VoiceTrackSource} from '@app/features/voice/engine/VoiceTrackSource';
|
||||
import {
|
||||
clearRemoteVoicePlaybackBoost,
|
||||
setRemoteVoicePlaybackBoost,
|
||||
} from '@app/features/voice/state/RemoteVoicePlaybackBoost';
|
||||
import StreamAudioPrefs from '@app/features/voice/state/StreamAudioPrefs';
|
||||
import type {RemoteParticipant} from 'livekit-client';
|
||||
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const outputVolumePercent = vi.fn<() => number>();
|
||||
const effectiveDeaf = vi.fn<() => boolean>();
|
||||
|
||||
vi.mock('@app/features/platform/utils/MobXPersistence', () => ({
|
||||
makePersistent: async () => {},
|
||||
}));
|
||||
|
||||
vi.mock('@app/features/voice/state/VoiceSettings', () => ({
|
||||
default: {getOutputVolume: () => outputVolumePercent()},
|
||||
}));
|
||||
|
||||
vi.mock('@app/features/voice/engine/VoiceEffectiveAudioState', () => ({
|
||||
getEffectiveAudioState: () => ({effectiveDeaf: effectiveDeaf()}),
|
||||
}));
|
||||
|
||||
const GUILD_ID = 'guild-1';
|
||||
const CHANNEL_ID = 'channel-1';
|
||||
const LOCAL_CONNECTION_ID = 'local-connection';
|
||||
const REMOTE_CONNECTION_ID = 'remote-connection';
|
||||
const USER_ID = '42';
|
||||
const IDENTITY = `user_${USER_ID}_${REMOTE_CONNECTION_ID}`;
|
||||
const STREAM_KEY = getStreamKey(GUILD_ID, CHANNEL_ID, REMOTE_CONNECTION_ID);
|
||||
|
||||
interface FakePublication {
|
||||
source: string;
|
||||
trackName: string;
|
||||
trackSid: string;
|
||||
isDesired: boolean;
|
||||
track: {kind: string; setVolume: (volume: number) => void};
|
||||
setEnabled: (enabled: boolean) => void;
|
||||
volumes: Array<number>;
|
||||
enabled: Array<boolean>;
|
||||
}
|
||||
|
||||
(
|
||||
window as typeof window & {
|
||||
_mediaEngineFacade?: {guildId: string; channelId: string; connectionId: string};
|
||||
}
|
||||
)._mediaEngineFacade = {guildId: GUILD_ID, channelId: CHANNEL_ID, connectionId: LOCAL_CONNECTION_ID};
|
||||
|
||||
const {default: ParticipantVolume} = await import('@app/features/voice/state/ParticipantVolume');
|
||||
|
||||
function createPublication(source: string, trackSid: string, throwOnSetVolume: boolean): FakePublication {
|
||||
const volumes: Array<number> = [];
|
||||
const enabled: Array<boolean> = [];
|
||||
return {
|
||||
source,
|
||||
trackName: source,
|
||||
trackSid,
|
||||
isDesired: true,
|
||||
track: {
|
||||
kind: VoiceTrackKind.Audio,
|
||||
setVolume: (volume: number) => {
|
||||
volumes.push(volume);
|
||||
if (throwOnSetVolume) {
|
||||
throw new Error('setVolume failed');
|
||||
}
|
||||
},
|
||||
},
|
||||
setEnabled: (value: boolean) => {
|
||||
enabled.push(value);
|
||||
},
|
||||
volumes,
|
||||
enabled,
|
||||
};
|
||||
}
|
||||
|
||||
function applyToParticipant(options?: {throwOnMicrophoneVolume?: boolean}): {
|
||||
microphone: FakePublication;
|
||||
screenShareAudio: FakePublication;
|
||||
} {
|
||||
const microphone = createPublication(
|
||||
VoiceTrackSource.Microphone,
|
||||
'microphone-sid',
|
||||
options?.throwOnMicrophoneVolume === true,
|
||||
);
|
||||
const screenShareAudio = createPublication(VoiceTrackSource.ScreenShareAudio, 'screen-share-audio-sid', false);
|
||||
const audioTrackPublications = new Map<string, FakePublication>([
|
||||
[microphone.trackSid, microphone],
|
||||
[screenShareAudio.trackSid, screenShareAudio],
|
||||
]);
|
||||
ParticipantVolume.applySettingsToParticipant({
|
||||
identity: IDENTITY,
|
||||
audioTrackPublications,
|
||||
} as unknown as RemoteParticipant);
|
||||
return {microphone, screenShareAudio};
|
||||
}
|
||||
|
||||
describe('ParticipantVolume.applySettingsToParticipant', () => {
|
||||
beforeEach(() => {
|
||||
outputVolumePercent.mockReturnValue(100);
|
||||
effectiveDeaf.mockReturnValue(false);
|
||||
clearRemoteVoicePlaybackBoost(IDENTITY);
|
||||
ParticipantVolume.resetUserSettings(USER_ID);
|
||||
StreamAudioPrefs.entries = {};
|
||||
});
|
||||
|
||||
it('gives a saturated auto-leveller participant strictly more gain at output 200% than at 100%', () => {
|
||||
setRemoteVoicePlaybackBoost(IDENTITY, 3);
|
||||
const atUnity = applyToParticipant().microphone.volumes;
|
||||
outputVolumePercent.mockReturnValue(200);
|
||||
const atCeiling = applyToParticipant().microphone.volumes;
|
||||
expect(atUnity).toEqual([3]);
|
||||
expect(atCeiling).toEqual([12]);
|
||||
expect(atCeiling[0]).toBeGreaterThan(atUnity[0]);
|
||||
});
|
||||
|
||||
it('clamps the total, not each control', () => {
|
||||
ParticipantVolume.setVolume(USER_ID, 200);
|
||||
outputVolumePercent.mockReturnValue(200);
|
||||
setRemoteVoicePlaybackBoost(IDENTITY, 3);
|
||||
expect(applyToParticipant().microphone.volumes).toEqual([12]);
|
||||
});
|
||||
|
||||
it('the per-user slider still moves with the leveller idle', () => {
|
||||
ParticipantVolume.setVolume(USER_ID, 100);
|
||||
expect(applyToParticipant().microphone.volumes).toEqual([1]);
|
||||
ParticipantVolume.setVolume(USER_ID, 200);
|
||||
expect(applyToParticipant().microphone.volumes).toEqual([4]);
|
||||
});
|
||||
|
||||
it('screen-share audio composes stream and output volume', () => {
|
||||
setRemoteVoicePlaybackBoost(IDENTITY, 3);
|
||||
StreamAudioPrefs.setVolume(STREAM_KEY, 100);
|
||||
expect(applyToParticipant().screenShareAudio.volumes).toEqual([1]);
|
||||
StreamAudioPrefs.setVolume(STREAM_KEY, 200);
|
||||
outputVolumePercent.mockReturnValue(200);
|
||||
expect(applyToParticipant().screenShareAudio.volumes).toEqual([12]);
|
||||
});
|
||||
|
||||
it('leaves the microphone gain untouched when the stream volume changes', () => {
|
||||
StreamAudioPrefs.setVolume(STREAM_KEY, 50);
|
||||
const atQuietStream = applyToParticipant().microphone.volumes;
|
||||
StreamAudioPrefs.setVolume(STREAM_KEY, 200);
|
||||
const atLoudStream = applyToParticipant().microphone.volumes;
|
||||
expect(atQuietStream).toEqual([1]);
|
||||
expect(atLoudStream).toEqual(atQuietStream);
|
||||
});
|
||||
|
||||
it('leaves the screen share gain untouched when the per-user volume changes', () => {
|
||||
StreamAudioPrefs.setVolume(STREAM_KEY, 100);
|
||||
ParticipantVolume.setVolume(USER_ID, 50);
|
||||
const atQuietUser = applyToParticipant().screenShareAudio.volumes;
|
||||
ParticipantVolume.setVolume(USER_ID, 200);
|
||||
const atLoudUser = applyToParticipant().screenShareAudio.volumes;
|
||||
expect(atQuietUser).toEqual([1]);
|
||||
expect(atLoudUser).toEqual(atQuietUser);
|
||||
});
|
||||
|
||||
it('leaves the microphone publication enabled when the stream is muted', () => {
|
||||
StreamAudioPrefs.setMuted(STREAM_KEY, true);
|
||||
expect(applyToParticipant().microphone.enabled).toEqual([true]);
|
||||
});
|
||||
|
||||
it('leaves screen share audio enabled when the participant is locally muted', () => {
|
||||
ParticipantVolume.setLocalMute(USER_ID, true);
|
||||
expect(applyToParticipant().screenShareAudio.enabled).toEqual([true]);
|
||||
});
|
||||
|
||||
it('disables screen share audio while deafened', () => {
|
||||
effectiveDeaf.mockReturnValue(true);
|
||||
expect(applyToParticipant().screenShareAudio.enabled).toEqual([false]);
|
||||
});
|
||||
|
||||
it('still applies the enabled state when a track volume write throws', () => {
|
||||
ParticipantVolume.setLocalMute(USER_ID, true);
|
||||
const {microphone} = applyToParticipant({throwOnMicrophoneVolume: true});
|
||||
expect(microphone.volumes).toEqual([1]);
|
||||
expect(microphone.enabled).toEqual([false]);
|
||||
});
|
||||
});
|
||||
@@ -610,7 +610,7 @@ class VoiceSettings {
|
||||
this.adaptiveScreenShareQualityPrefV2 = value;
|
||||
}
|
||||
|
||||
handleConnectionOpen(user: UserPrivate): void {
|
||||
handleGatewayReady(user: UserPrivate): void {
|
||||
if (this.isUserPremium(user.premium_type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const voiceBackgroundsAvailable = vi.fn<() => boolean>();
|
||||
|
||||
vi.mock('@app/features/voice/utils/VoiceBackgroundAvailability', () => ({
|
||||
areVoiceBackgroundsAvailable: () => voiceBackgroundsAvailable(),
|
||||
}));
|
||||
|
||||
vi.mock('@app/features/app/utils/LimitResolverAdapter', () => ({
|
||||
LimitResolver: {resolve: () => 0},
|
||||
}));
|
||||
|
||||
vi.mock('@app/features/app/utils/LimitUtils', () => ({
|
||||
isLimitToggleEnabled: () => false,
|
||||
}));
|
||||
|
||||
const {default: VoiceSettings, NONE_BACKGROUND_ID} = await import('./VoiceSettings');
|
||||
|
||||
const SAVED_BACKGROUND = {id: 'saved-background', createdAt: 1, mediaKind: 'static' as const};
|
||||
|
||||
describe('VoiceSettings background persistence when voice backgrounds are unavailable', () => {
|
||||
beforeEach(() => {
|
||||
voiceBackgroundsAvailable.mockReturnValue(true);
|
||||
VoiceSettings.updateSettings({backgroundImages: [SAVED_BACKGROUND], backgroundImageId: SAVED_BACKGROUND.id});
|
||||
voiceBackgroundsAvailable.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('keeps the uploaded background list across a no-op revalidation', () => {
|
||||
VoiceSettings.updateSettings({});
|
||||
expect(VoiceSettings.backgroundImages).toEqual([SAVED_BACKGROUND]);
|
||||
});
|
||||
|
||||
it('keeps the stored selection so it comes back once backgrounds are available again', () => {
|
||||
VoiceSettings.updateSettings({});
|
||||
expect(VoiceSettings.backgroundImageId).toBe(SAVED_BACKGROUND.id);
|
||||
voiceBackgroundsAvailable.mockReturnValue(true);
|
||||
expect(VoiceSettings.getBackgroundImageId()).toBe(SAVED_BACKGROUND.id);
|
||||
});
|
||||
|
||||
it('never hands out an active background while the feature is unavailable', () => {
|
||||
VoiceSettings.updateSettings({});
|
||||
expect(VoiceSettings.getBackgroundImageId()).toBe(NONE_BACKGROUND_ID);
|
||||
expect(VoiceSettings.getBackgroundImages()).toEqual([SAVED_BACKGROUND]);
|
||||
});
|
||||
|
||||
it('still drops a selection that no longer matches a stored background', () => {
|
||||
VoiceSettings.updateSettings({backgroundImages: []});
|
||||
expect(VoiceSettings.backgroundImageId).toBe(NONE_BACKGROUND_ID);
|
||||
});
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {beforeAll, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
vi.mock('@app/features/voice/utils/VoiceBackgroundAvailability', () => ({
|
||||
areVoiceBackgroundsAvailable: () => true,
|
||||
}));
|
||||
|
||||
vi.mock('@app/features/app/utils/LimitResolverAdapter', () => ({
|
||||
LimitResolver: {resolve: () => 0},
|
||||
}));
|
||||
|
||||
vi.mock('@app/features/app/utils/LimitUtils', () => ({
|
||||
isLimitToggleEnabled: () => false,
|
||||
}));
|
||||
|
||||
const STORAGE_KEY = 'VoiceSettings';
|
||||
|
||||
async function loadVoiceSettingsWith(stored: Record<string, unknown> | null) {
|
||||
vi.resetModules();
|
||||
const {default: AppStorage} = await import('@app/features/platform/state/PersistentStorage');
|
||||
AppStorage.removeItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
AppStorage.setItem(STORAGE_KEY, JSON.stringify({...stored, __mps__: {version: 1}}));
|
||||
}
|
||||
const {default: VoiceSettings} = await import('./VoiceSettings');
|
||||
const {awaitHydration} = await import('@app/features/platform/utils/MobXPersistence');
|
||||
await awaitHydration(STORAGE_KEY);
|
||||
const readStored = (): Record<string, unknown> => {
|
||||
const raw = AppStorage.getItem(STORAGE_KEY);
|
||||
return raw ? JSON.parse(raw) : {};
|
||||
};
|
||||
return {VoiceSettings, readStored};
|
||||
}
|
||||
|
||||
describe('VoiceSettings output volume recalibration migration', () => {
|
||||
beforeAll(async () => {
|
||||
await import('./VoiceSettings');
|
||||
});
|
||||
|
||||
it('resets a boosted output volume once and records that it ran', async () => {
|
||||
const {VoiceSettings, readStored} = await loadVoiceSettingsWith({outputVolume: 200});
|
||||
expect(VoiceSettings.outputVolume).toBe(100);
|
||||
expect(VoiceSettings.outputVolumeRecalibratedV1).toBe(true);
|
||||
expect(readStored().outputVolumeRecalibratedV1).toBe(true);
|
||||
});
|
||||
|
||||
it('never touches an output volume the user set after the migration ran', async () => {
|
||||
const {VoiceSettings} = await loadVoiceSettingsWith({
|
||||
outputVolume: 200,
|
||||
outputVolumeRecalibratedV1: true,
|
||||
});
|
||||
expect(VoiceSettings.outputVolume).toBe(200);
|
||||
expect(VoiceSettings.outputVolumeRecalibratedV1).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves an output volume at or below unity alone', async () => {
|
||||
const {VoiceSettings, readStored} = await loadVoiceSettingsWith({outputVolume: 60});
|
||||
expect(VoiceSettings.outputVolume).toBe(60);
|
||||
expect(readStored().outputVolumeRecalibratedV1).toBe(true);
|
||||
});
|
||||
|
||||
it('marks an empty stored record as recalibrated', async () => {
|
||||
const {VoiceSettings, readStored} = await loadVoiceSettingsWith({});
|
||||
expect(VoiceSettings.outputVolume).toBe(100);
|
||||
expect(VoiceSettings.outputVolumeRecalibratedV1).toBe(true);
|
||||
expect(readStored().outputVolumeRecalibratedV1).toBe(true);
|
||||
});
|
||||
|
||||
it('marks a fresh install as recalibrated so a later launch cannot reset a volume set today', async () => {
|
||||
const {VoiceSettings} = await loadVoiceSettingsWith(null);
|
||||
expect(VoiceSettings.outputVolume).toBe(100);
|
||||
expect(VoiceSettings.outputVolumeRecalibratedV1).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user