142 lines
4.3 KiB
TypeScript
142 lines
4.3 KiB
TypeScript
/*
|
|||
|
|
Copyright 2026 Lotus Guild
|
||
|
|
|
||
|
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||
|
|
Please see LICENSE in the repository root for full details.
|
||
|
|
*/
|
||
|
|
|
||
|
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||
|
|
import { ParticipantEvent, Track } from "livekit-client";
|
||
|
|
|
||
|
|
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||
|
|
import { startLotusDenoise } from "./lotusDenoise";
|
||
|
|
|
||
|
|
// Track constructed instances so tests can assert exactly one processor was
|
||
|
|
// built across racing apply() calls, and can assert the pending one is
|
||
|
|
// destroyed on early teardown. `vi.hoisted` is required because `vi.mock`
|
||
|
|
// factories are hoisted above this file's other top-level statements.
|
||
|
|
const instances = vi.hoisted(
|
||
|
|
() => [] as { destroy: ReturnType<typeof vi.fn> }[],
|
||
|
|
);
|
||
|
|
vi.mock("./lotusDenoiseProcessor", () => ({
|
||
|
|
LotusDenoiseProcessor: class {
|
||
|
|
public destroy = vi.fn().mockResolvedValue(undefined);
|
||
|
|
public constructor() {
|
||
|
|
instances.push(this);
|
||
|
|
}
|
||
|
|
},
|
||
|
|
}));
|
||
|
|
|
||
|
|
/** A promise plus externally-callable resolve, for controlling ordering. */
|
||
|
|
function deferred<T>(): {
|
||
|
|
promise: Promise<T>;
|
||
|
|
resolve: (v: T) => void;
|
||
|
|
} {
|
||
|
|
let resolve!: (v: T) => void;
|
||
|
|
const promise = new Promise<T>((r) => {
|
||
|
|
resolve = r;
|
||
|
|
});
|
||
|
|
return { promise, resolve };
|
||
|
|
}
|
||
|
|
|
||
|
|
function makeRoomAndVm(mic: {
|
||
|
|
getProcessor: () => unknown;
|
||
|
|
setProcessor: ReturnType<typeof vi.fn>;
|
||
|
|
}): {
|
||
|
|
vm: CallViewModel;
|
||
|
|
room: { localParticipant: Record<string, unknown> };
|
||
|
|
firePublished: () => void;
|
||
|
|
} {
|
||
|
|
const handlers = new Map<string, () => void>();
|
||
|
|
const localParticipant = {
|
||
|
|
getTrackPublication: (
|
||
|
|
source: Track.Source,
|
||
|
|
): { track: typeof mic } | undefined =>
|
||
|
|
source === Track.Source.Microphone ? { track: mic } : undefined,
|
||
|
|
on: (event: string, cb: () => void): Map<string, () => void> =>
|
||
|
|
handlers.set(event, cb),
|
||
|
|
off: (event: string): boolean => handlers.delete(event),
|
||
|
|
};
|
||
|
|
const room = { localParticipant };
|
||
|
|
const vm = {
|
||
|
|
allConnections$: {
|
||
|
|
subscribe: (
|
||
|
|
cb: (data: {
|
||
|
|
getConnections: () => { livekitRoom: unknown }[];
|
||
|
|
}) => void,
|
||
|
|
) => {
|
||
|
|
cb({ getConnections: () => [{ livekitRoom: room }] });
|
||
|
|
return { unsubscribe: (): void => undefined };
|
||
|
|
},
|
||
|
|
},
|
||
|
|
} as unknown as CallViewModel;
|
||
|
|
return {
|
||
|
|
vm,
|
||
|
|
room,
|
||
|
|
firePublished: () => handlers.get(ParticipantEvent.LocalTrackPublished)?.(),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
beforeEach(() => {
|
||
|
|
instances.length = 0;
|
||
|
|
// `lotusParam`/`lotusFlag` cache the URL params at first read; seed the hash
|
||
|
|
// before startLotusDenoise() so the dedicated `lotusDenoiseSource` flag reads on.
|
||
|
|
window.location.hash = "#/room?lotusDenoiseSource=1";
|
||
|
|
});
|
||
|
|
|
||
|
|
afterEach(() => {
|
||
|
|
vi.resetModules();
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("startLotusDenoise", () => {
|
||
|
|
test("racing LocalTrackPublished events only construct one processor", async () => {
|
||
|
|
const setProcessorDeferred = deferred<void>();
|
||
|
|
let attached: unknown;
|
||
|
|
const mic = {
|
||
|
|
getProcessor: (): unknown => attached,
|
||
|
|
setProcessor: vi.fn(async (p: unknown) => {
|
||
|
|
await setProcessorDeferred.promise;
|
||
|
|
attached = p;
|
||
|
|
}),
|
||
|
|
};
|
||
|
|
const { vm, firePublished } = makeRoomAndVm(mic);
|
||
|
|
|
||
|
|
startLotusDenoise(vm);
|
||
|
|
// Simulate a second LocalTrackPublished (e.g. camera) firing before the
|
||
|
|
// first setProcessor() has resolved.
|
||
|
|
firePublished();
|
||
|
|
|
||
|
|
expect(mic.setProcessor).toHaveBeenCalledTimes(1);
|
||
|
|
expect(instances).toHaveLength(1);
|
||
|
|
|
||
|
|
setProcessorDeferred.resolve();
|
||
|
|
await Promise.resolve();
|
||
|
|
await Promise.resolve();
|
||
|
|
|
||
|
|
// Once attached, a further publish should be a no-op (mic.getProcessor()
|
||
|
|
// is now set).
|
||
|
|
firePublished();
|
||
|
|
expect(mic.setProcessor).toHaveBeenCalledTimes(1);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("destroys the in-flight processor if torn down before setProcessor resolves", async () => {
|
||
|
|
const setProcessorDeferred = deferred<void>();
|
||
|
|
const mic = {
|
||
|
|
getProcessor: (): unknown => undefined,
|
||
|
|
setProcessor: vi.fn(async () => setProcessorDeferred.promise),
|
||
|
|
};
|
||
|
|
const { vm } = makeRoomAndVm(mic);
|
||
|
|
|
||
|
|
const teardown = startLotusDenoise(vm);
|
||
|
|
expect(instances).toHaveLength(1);
|
||
|
|
|
||
|
|
teardown();
|
||
|
|
|
||
|
|
expect(instances[0].destroy).toHaveBeenCalledTimes(1);
|
||
|
|
|
||
|
|
// Resolving afterwards must not throw/reject unhandled.
|
||
|
|
setProcessorDeferred.resolve();
|
||
|
|
await Promise.resolve();
|
||
|
|
});
|
||
|
|
});
|