fix(lotus): focus_participant works in grid/1:1, clears on leave, keeps PiP, pins by device
- A non-null pin forces layout "spotlight" and remembers the displaced mode; clearing restores it only if the user hasn't switched since; gridLayoutMedia$ surfaces the pinned item for narrow mode (#3). - Pin clears when the user is gone for 5 s or on leave$ (#16). - Screenshare branch keeps pip$ = auto speaker unless it IS the pinned user (#29). - Payload accepts an optional media id (userId:deviceId) and prefers it; userId-only picks the speaking device (#30). 18 unit tests. Fixes #3 Fixes #16 Fixes #29 Fixes #30 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
co-authored by
Claude Opus 5
parent
e504a31efd
commit
e5d5f13923
@@ -0,0 +1,295 @@
|
||||
/*
|
||||
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 { BehaviorSubject, of, Subject } from "rxjs";
|
||||
|
||||
import { withTestScheduler } from "../utils/test";
|
||||
import { type UserMediaViewModel } from "../state/media/UserMediaViewModel";
|
||||
import { type ScreenShareViewModel } from "../state/media/ScreenShareViewModel";
|
||||
import { type LayoutMode } from "../state/LayoutSwitchViewModel";
|
||||
import {
|
||||
createManualSpotlight,
|
||||
type ManualSpotlight,
|
||||
overrideSpotlight$,
|
||||
pinnedMedia$,
|
||||
resolveManualSpotlight,
|
||||
} from "./lotusSpotlight";
|
||||
import { parseFocusPayload } from "./lotusFocus";
|
||||
|
||||
interface FakeMedia {
|
||||
id: string;
|
||||
userId: string;
|
||||
type: "user";
|
||||
local: boolean;
|
||||
speaking$: BehaviorSubject<boolean>;
|
||||
}
|
||||
|
||||
function media(
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
speaking = false,
|
||||
local = false,
|
||||
): FakeMedia {
|
||||
return {
|
||||
id: `${userId}:${deviceId}`,
|
||||
userId,
|
||||
type: "user",
|
||||
local,
|
||||
speaking$: new BehaviorSubject(speaking),
|
||||
};
|
||||
}
|
||||
const asUser = (m: FakeMedia): UserMediaViewModel =>
|
||||
m as unknown as UserMediaViewModel;
|
||||
const screen = { id: "@a:x:d1:screen", userId: "@a:x", type: "screen share" };
|
||||
|
||||
const aliceDesk = media("@alice:x", "desk");
|
||||
const alicePhone = media("@alice:x", "phone", true);
|
||||
const bob = media("@bob:x", "d");
|
||||
const all = [aliceDesk, alicePhone, bob];
|
||||
|
||||
describe("resolveManualSpotlight (#30)", () => {
|
||||
const speaking = (m: FakeMedia): boolean => m.speaking$.value;
|
||||
|
||||
test("null pin resolves to nothing", () => {
|
||||
expect(resolveManualSpotlight(null, all, speaking)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("prefers the media id when given", () => {
|
||||
expect(
|
||||
resolveManualSpotlight(
|
||||
{ userId: "@alice:x", id: "@alice:x:desk" },
|
||||
all,
|
||||
speaking,
|
||||
),
|
||||
).toBe(aliceDesk);
|
||||
});
|
||||
|
||||
test("falls back to the userId when the id is not present", () => {
|
||||
expect(
|
||||
resolveManualSpotlight(
|
||||
{ userId: "@alice:x", id: "@alice:x:tablet" },
|
||||
all,
|
||||
speaking,
|
||||
),
|
||||
).toBe(alicePhone);
|
||||
});
|
||||
|
||||
test("userId only: prefers the speaking device, else the first", () => {
|
||||
expect(
|
||||
resolveManualSpotlight({ userId: "@alice:x", id: null }, all, speaking),
|
||||
).toBe(alicePhone);
|
||||
expect(
|
||||
resolveManualSpotlight(
|
||||
{ userId: "@alice:x", id: null },
|
||||
all,
|
||||
() => false,
|
||||
),
|
||||
).toBe(aliceDesk);
|
||||
});
|
||||
|
||||
test("absent user resolves to nothing", () => {
|
||||
expect(
|
||||
resolveManualSpotlight({ userId: "@carol:x", id: null }, all, speaking),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("pinnedMedia$", () => {
|
||||
test("follows the speaking device live for a userId-only pin", () => {
|
||||
withTestScheduler(({ expectObservable, schedule }) => {
|
||||
const desk = media("@alice:x", "desk", true);
|
||||
const phone = media("@alice:x", "phone", false);
|
||||
const pin$ = new BehaviorSubject<ManualSpotlight | null>({
|
||||
userId: "@alice:x",
|
||||
id: null,
|
||||
});
|
||||
schedule("-a", {
|
||||
a: () => {
|
||||
desk.speaking$.next(false);
|
||||
phone.speaking$.next(true);
|
||||
},
|
||||
});
|
||||
expectObservable(
|
||||
pinnedMedia$(pin$, of([asUser(desk), asUser(phone)])),
|
||||
).toBe("dp", { d: desk, p: phone });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("overrideSpotlight$", () => {
|
||||
test("no pin: identical to upstream", () => {
|
||||
withTestScheduler(({ expectObservable }) => {
|
||||
const result$ = overrideSpotlight$(
|
||||
of(asUser(bob)),
|
||||
of(null),
|
||||
of([]),
|
||||
of(undefined),
|
||||
of(all.map(asUser)),
|
||||
);
|
||||
expectObservable(result$.pipe()).toBe("(a|)", {
|
||||
a: expect.objectContaining({ spotlight: [bob] }),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("pin replaces the auto speaker without a screenshare", () => {
|
||||
withTestScheduler(({ expectObservable }) => {
|
||||
const result$ = overrideSpotlight$(
|
||||
of(asUser(bob)),
|
||||
of({ userId: "@alice:x", id: "@alice:x:desk" }),
|
||||
of([]),
|
||||
of(undefined),
|
||||
of(all.map(asUser)),
|
||||
);
|
||||
expectObservable(result$).toBe("(a|)", {
|
||||
a: expect.objectContaining({ spotlight: [aliceDesk] }),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("#29: PiP keeps the auto speaker when a third party is pinned", () => {
|
||||
withTestScheduler(({ expectObservable }) => {
|
||||
let pip$;
|
||||
overrideSpotlight$(
|
||||
of(asUser(bob)),
|
||||
of({ userId: "@alice:x", id: null }),
|
||||
of([screen as unknown as ScreenShareViewModel]),
|
||||
of(undefined),
|
||||
of(all.map(asUser)),
|
||||
).subscribe((r) => {
|
||||
expect(r.spotlight).toEqual([screen, alicePhone]);
|
||||
pip$ = r.pip$;
|
||||
});
|
||||
expectObservable(pip$!).toBe("(b|)", { b: bob });
|
||||
});
|
||||
});
|
||||
|
||||
test("#29: PiP is blanked only when the auto speaker is the pinned one", () => {
|
||||
withTestScheduler(({ expectObservable }) => {
|
||||
let pip$;
|
||||
overrideSpotlight$(
|
||||
of(asUser(alicePhone)),
|
||||
of({ userId: "@alice:x", id: null }),
|
||||
of([screen as unknown as ScreenShareViewModel]),
|
||||
of(undefined),
|
||||
of(all.map(asUser)),
|
||||
).subscribe((r) => {
|
||||
pip$ = r.pip$;
|
||||
});
|
||||
expectObservable(pip$!).toBe("(u|)", { u: undefined });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("createManualSpotlight (#3 / #16)", () => {
|
||||
function setup(initialLayout: LayoutMode = "grid") {
|
||||
const pin$ = new BehaviorSubject<ManualSpotlight | null>(null);
|
||||
const layout$ = new BehaviorSubject<LayoutMode>(initialLayout);
|
||||
const setLayout = vi.fn((m: LayoutMode) => layout$.next(m));
|
||||
const userMedia$ = new BehaviorSubject<{ userId: string }[]>([
|
||||
{ userId: "@alice:x" },
|
||||
{ userId: "@bob:x" },
|
||||
]);
|
||||
const leave$ = new Subject<void>();
|
||||
const ctl = createManualSpotlight(
|
||||
pin$,
|
||||
userMedia$,
|
||||
leave$,
|
||||
{ layout$, setLayout },
|
||||
5000,
|
||||
);
|
||||
const sub = ctl.effects$.subscribe();
|
||||
return { pin$, layout$, setLayout, userMedia$, leave$, ctl, sub };
|
||||
}
|
||||
|
||||
test("pinning forces spotlight and clearing restores the displaced mode", () => {
|
||||
const { ctl, layout$, setLayout, pin$ } = setup("grid");
|
||||
ctl.setManualSpotlight({ userId: "@alice:x", id: null });
|
||||
expect(pin$.value).toEqual({ userId: "@alice:x", id: null });
|
||||
expect(setLayout).toHaveBeenLastCalledWith("spotlight");
|
||||
// Re-pinning someone else does not touch the layout again.
|
||||
ctl.setManualSpotlight({ userId: "@bob:x", id: null });
|
||||
expect(setLayout).toHaveBeenCalledTimes(1);
|
||||
ctl.setManualSpotlight(null);
|
||||
expect(layout$.value).toBe("grid");
|
||||
expect(setLayout).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("already in spotlight: nothing is forced or restored", () => {
|
||||
const { ctl, setLayout } = setup("spotlight");
|
||||
ctl.setManualSpotlight({ userId: "@alice:x", id: null });
|
||||
ctl.setManualSpotlight(null);
|
||||
expect(setLayout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not clobber a layout the user chose while pinned", () => {
|
||||
const { ctl, layout$, setLayout } = setup("grid");
|
||||
ctl.setManualSpotlight({ userId: "@alice:x", id: null });
|
||||
layout$.next("grid"); // user switched back manually
|
||||
ctl.setManualSpotlight(null);
|
||||
expect(setLayout).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("#16: pin is cleared when the user is gone for 5 s, not on a blip", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { ctl, pin$, userMedia$, layout$ } = setup("grid");
|
||||
ctl.setManualSpotlight({ userId: "@alice:x", id: null });
|
||||
// Transient absence: back within the window.
|
||||
userMedia$.next([{ userId: "@bob:x" }]);
|
||||
vi.advanceTimersByTime(2000);
|
||||
userMedia$.next([{ userId: "@alice:x" }, { userId: "@bob:x" }]);
|
||||
vi.advanceTimersByTime(6000);
|
||||
expect(pin$.value).not.toBeNull();
|
||||
// Real leave.
|
||||
userMedia$.next([{ userId: "@bob:x" }]);
|
||||
vi.advanceTimersByTime(4999);
|
||||
expect(pin$.value).not.toBeNull();
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(pin$.value).toBeNull();
|
||||
expect(layout$.value).toBe("grid");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test("#16: pin is cleared on leave$", () => {
|
||||
const { ctl, pin$, leave$, layout$ } = setup("grid");
|
||||
ctl.setManualSpotlight({ userId: "@alice:x", id: null });
|
||||
expect(layout$.value).toBe("spotlight");
|
||||
leave$.next();
|
||||
expect(pin$.value).toBeNull();
|
||||
expect(layout$.value).toBe("grid");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseFocusPayload (#30)", () => {
|
||||
test("omitted userId leaves the pin alone", () => {
|
||||
expect(parseFocusPayload(undefined)).toBeUndefined();
|
||||
expect(parseFocusPayload({})).toBeUndefined();
|
||||
expect(parseFocusPayload({ id: "@a:x:d" })).toBeUndefined();
|
||||
});
|
||||
test("null / non-string userId clears", () => {
|
||||
expect(parseFocusPayload({ userId: null })).toBeNull();
|
||||
expect(parseFocusPayload({ userId: 42 })).toBeNull();
|
||||
});
|
||||
test("userId with and without id", () => {
|
||||
expect(parseFocusPayload({ userId: "@a:x" })).toEqual({
|
||||
userId: "@a:x",
|
||||
id: null,
|
||||
});
|
||||
expect(parseFocusPayload({ userId: "@a:x", id: "@a:x:d" })).toEqual({
|
||||
userId: "@a:x",
|
||||
id: "@a:x:d",
|
||||
});
|
||||
expect(parseFocusPayload({ userId: "@a:x", id: 7 })).toEqual({
|
||||
userId: "@a:x",
|
||||
id: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user