fix(lotus): set_quality null actually clears a cap on the sender

A null cap only removed the key from the sticky settings and applyToRoom
then skipped that sender, leaving the previously written maxBitrate /
maxFramerate live on the RTCRtpSender — contrary to the host contract
documented in cinny's CallControl. Track which keys this module wrote per
sender and write them back as undefined on clear. Unit-tested.

Fixes #11

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
Lotus CI
2026-09-12 11:34:08 -04:00
co-authored by Claude Opus 5
parent fd957badff
commit cbd42bf975
2 changed files with 155 additions and 16 deletions
+93
View File
@@ -0,0 +1,93 @@
/*
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 { describe, expect, test, vi } from "vitest";
import { buildPatch, patchSender } from "./lotusQuality";
function makeSender(
initialEncodings: RTCRtpEncodingParameters[] = [{}],
): RTCRtpSender {
let encodings = initialEncodings;
return {
getParameters: vi.fn(() => ({ encodings })),
setParameters: vi.fn(async (params: RTCRtpSendParameters) => {
await Promise.resolve();
encodings = params.encodings ?? [];
}),
} as unknown as RTCRtpSender;
}
describe("lotusQuality set_quality -> clear (#11)", () => {
test("clearing a previously-set cap actively unsets it on the sender", async () => {
const sender = makeSender();
const writtenKeys = new WeakMap<
RTCRtpSender,
Set<keyof RTCRtpEncodingParameters>
>();
// Set: audioMaxBitrate = 64000.
const setPatch = buildPatch(sender, { maxBitrate: 64_000 }, writtenKeys);
expect(setPatch).toEqual({ maxBitrate: 64_000 });
await patchSender(sender, setPatch, writtenKeys);
expect(sender.getParameters().encodings[0].maxBitrate).toBe(64_000);
expect(writtenKeys.get(sender)).toEqual(new Set(["maxBitrate"]));
// Clear: host sends `null`, so the caller now wants an empty desired
// patch. buildPatch must still emit an explicit `undefined` for the key
// it previously wrote, instead of an empty patch that leaves the stale
// cap on the sender.
const clearPatch = buildPatch(sender, {}, writtenKeys);
expect(clearPatch).toEqual({ maxBitrate: undefined });
await patchSender(sender, clearPatch, writtenKeys);
const finalEncoding = sender.getParameters().encodings[0];
expect(finalEncoding.maxBitrate).toBeUndefined();
expect("maxBitrate" in finalEncoding).toBe(true); // explicitly cleared, not merely absent
expect(writtenKeys.has(sender)).toBe(false);
});
test("clearing one of several caps leaves the others (and their tracking) intact", async () => {
const sender = makeSender();
const writtenKeys = new WeakMap<
RTCRtpSender,
Set<keyof RTCRtpEncodingParameters>
>();
const setPatch = buildPatch(
sender,
{ maxBitrate: 500_000, maxFramerate: 24 },
writtenKeys,
);
await patchSender(sender, setPatch, writtenKeys);
expect(writtenKeys.get(sender)).toEqual(
new Set(["maxBitrate", "maxFramerate"]),
);
// Only maxFramerate is still desired; maxBitrate should be actively
// cleared.
const clearPatch = buildPatch(sender, { maxFramerate: 24 }, writtenKeys);
expect(clearPatch).toEqual({ maxFramerate: 24, maxBitrate: undefined });
await patchSender(sender, clearPatch, writtenKeys);
const finalEncoding = sender.getParameters().encodings[0];
expect(finalEncoding.maxBitrate).toBeUndefined();
expect(finalEncoding.maxFramerate).toBe(24);
expect(writtenKeys.get(sender)).toEqual(new Set(["maxFramerate"]));
});
test("no sender means an empty patch and no call", () => {
const writtenKeys = new WeakMap<
RTCRtpSender,
Set<keyof RTCRtpEncodingParameters>
>();
expect(buildPatch(undefined, { maxBitrate: 1000 }, writtenKeys)).toEqual(
{},
);
});
});