2026-09-13 01:22:20 -04:00
|
|
|
/*
|
|
|
|
|
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, expect, test, vi } from "vitest";
|
|
|
|
|
import { BehaviorSubject, of } from "rxjs";
|
|
|
|
|
|
|
|
|
|
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
|
|
|
|
import { startLotusCallState } from "./lotusCallState";
|
|
|
|
|
|
|
|
|
|
const send = vi.fn().mockResolvedValue({});
|
|
|
|
|
|
|
|
|
|
vi.mock("../widget", () => ({
|
|
|
|
|
widget: { api: { transport: { send: (...a: unknown[]) => send(...a) } } },
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// `lotusFlag`/`lotusParam` (lotusWidget.ts) read `window.location` directly
|
|
|
|
|
// and memoize per module load, so drive the flag through the URL once, at
|
|
|
|
|
// import time (NOT per-test/in a hook: re-navigating with
|
|
|
|
|
// `window.history.pushState` between tests was observed to corrupt rxjs's
|
|
|
|
|
// shared `asyncScheduler` under `vi.useFakeTimers()`, silently starving a
|
|
|
|
|
// later test's `throttleTime` of any emission).
|
|
|
|
|
window.history.pushState({}, "", "/?lotusCallState=1");
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
send.mockClear();
|
|
|
|
|
vi.useFakeTimers();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
vi.useRealTimers();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
interface Member {
|
|
|
|
|
id: string;
|
|
|
|
|
userId: string;
|
|
|
|
|
speaking$: BehaviorSubject<boolean>;
|
|
|
|
|
audioEnabled$: BehaviorSubject<boolean>;
|
|
|
|
|
videoEnabled$: BehaviorSubject<boolean>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function mockMember(id: string, userId: string): Member {
|
|
|
|
|
return {
|
|
|
|
|
id,
|
|
|
|
|
userId,
|
|
|
|
|
speaking$: new BehaviorSubject(false),
|
|
|
|
|
audioEnabled$: new BehaviorSubject(true),
|
|
|
|
|
videoEnabled$: new BehaviorSubject(true),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function mockVm(members: Member[]): CallViewModel {
|
2026-09-19 22:45:23 -04:00
|
|
|
return {
|
|
|
|
|
userMedia$: of(members),
|
|
|
|
|
// no livekit connections → the muted-speech tap never starts
|
|
|
|
|
allConnections$: of({ getConnections: () => [] }),
|
|
|
|
|
} as unknown as CallViewModel;
|
2026-09-13 01:22:20 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function participantsOf(call: number): unknown[] {
|
|
|
|
|
return (send.mock.calls[call][1] as { participants: unknown[] }).participants;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
test("[lotus #20] rapid speaking toggles in a busy call are capped well under the old 8/sec rate", () => {
|
|
|
|
|
const members = Array.from({ length: 15 }, (_, i) =>
|
|
|
|
|
mockMember(`@u${i}:example.org:DEV`, `@u${i}:example.org`),
|
|
|
|
|
);
|
|
|
|
|
const stop = startLotusCallState(mockVm(members));
|
|
|
|
|
|
|
|
|
|
// Simulate a noisy multi-person conversation: flip `speaking` on every
|
|
|
|
|
// member every 20ms (50Hz of raw churn) for 2 seconds.
|
|
|
|
|
for (let t = 0; t < 2000; t += 20) {
|
|
|
|
|
for (const m of members) m.speaking$.next(!m.speaking$.value);
|
|
|
|
|
vi.advanceTimersByTime(20);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The old leading+trailing 250ms throttle allowed ~8 sends/sec => up to 16
|
|
|
|
|
// over 2s. Trailing-only 500ms must cap this to at most 4 (one per window).
|
|
|
|
|
expect(send.mock.calls.length).toBeLessThanOrEqual(5);
|
|
|
|
|
expect(send.mock.calls.length).toBeGreaterThan(0);
|
|
|
|
|
|
|
|
|
|
// Drain any still-pending trailing-edge throttle action before tearing
|
|
|
|
|
// down: `rxjs`'s default `asyncScheduler` is a process-wide singleton, and
|
|
|
|
|
// leaving a scheduled action dangling across a test/timer-implementation
|
|
|
|
|
// boundary can wedge its queue for every later test in this file.
|
|
|
|
|
vi.advanceTimersByTime(500);
|
|
|
|
|
stop();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("[lotus #20] a mute/camera change and a same-tick no-op speaking flip are deduped field-wise", () => {
|
|
|
|
|
const alice = mockMember("@alice:example.org:DEV", "@alice:example.org");
|
|
|
|
|
const stop = startLotusCallState(mockVm([alice]));
|
|
|
|
|
|
|
|
|
|
vi.advanceTimersByTime(500);
|
|
|
|
|
send.mockClear();
|
|
|
|
|
|
|
|
|
|
// No actual change: re-emitting the same speaking value must not count as
|
|
|
|
|
// a change (distinctUntilChanged happens before the throttle).
|
|
|
|
|
alice.speaking$.next(false);
|
|
|
|
|
vi.advanceTimersByTime(500);
|
|
|
|
|
expect(send).not.toHaveBeenCalled();
|
|
|
|
|
|
|
|
|
|
alice.audioEnabled$.next(false);
|
|
|
|
|
vi.advanceTimersByTime(500);
|
|
|
|
|
expect(send).toHaveBeenCalledTimes(1);
|
|
|
|
|
expect(participantsOf(0)).toEqual([
|
|
|
|
|
{
|
|
|
|
|
id: "@alice:example.org:DEV",
|
|
|
|
|
userId: "@alice:example.org",
|
|
|
|
|
speaking: false,
|
|
|
|
|
audioEnabled: false,
|
|
|
|
|
videoEnabled: true,
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
stop();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("does nothing (and sends nothing) once torn down", () => {
|
|
|
|
|
const alice = mockMember("@alice:example.org:DEV", "@alice:example.org");
|
|
|
|
|
const stop = startLotusCallState(mockVm([alice]));
|
|
|
|
|
vi.advanceTimersByTime(500);
|
|
|
|
|
send.mockClear();
|
|
|
|
|
|
|
|
|
|
stop();
|
|
|
|
|
alice.speaking$.next(true);
|
|
|
|
|
vi.advanceTimersByTime(1000);
|
|
|
|
|
expect(send).not.toHaveBeenCalled();
|
|
|
|
|
});
|