/* 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; audioEnabled$: BehaviorSubject; videoEnabled$: BehaviorSubject; } 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 { return { userMedia$: of(members), // no livekit connections → the muted-speech tap never starts allConnections$: of({ getConnections: () => [] }), } as unknown as CallViewModel; } 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(); });