Files
OwnCord/Client/tauri-client/tests/unit/ptt.test.ts
T
J3vbandClaude Opus 5 8cf019c03f fix: 40 correctness fixes from the 2026-08-19 bug hunt (#1392)
* fix(identity): 1 defect(s) (OC-0151)

* fix(ws): 1 defect(s) (OC-0152)

* fix(admin): 1 defect(s) (OC-0153)

* fix(admin): 1 defect(s) (OC-0154)

* fix(voice): 2 defect(s) (OC-0155, OC-0167)

Replace distributeRoomKey's per-call offer counter with an instance-level
sliding-window budget shared by every voice_e2ee_offer send path.

- OC-0155: back-to-back rotations (the second run immediately by
  drainPendingRotationOrArmTimer) each got a fresh pacing budget, so their
  combined sends could exceed the server's single per-second cap.
- OC-0167: handleAnnounceInner's drain-time offer send bypassed pacing
  entirely, letting a key holder joining a large ongoing call burst every
  queued announce's offer unpaced.

The shared budget is reset in clearState() since the server's limit is
scoped per (sender, channel).

* fix(client): 1 defect(s) (OC-0156)

createPresenceSender dropped a queued custom_status when a later plain
status change superseded the pending retry. The retry now carries the
last committed custom_status forward.

* fix(client): 2 defect(s) (OC-0160, OC-0163)

OC-0160: exempt the handshake frames (ready, auth_ok) from the ws message
size limit and run the guard after parsing. A 'ready' frame grows unbounded
with member/channel/DM counts and carries no seq, so dropping it left the
client on empty stores with no error and no recovery path.

OC-0163: bracket a bare IPv6 host when building the wss:// URL so the
authority parses, and collapse bracketed/bare IPv6 literals to the same
cert_store_key so one server is not pinned (and user-confirmed) twice.

* fix(voice): 1 defect(s) (OC-0162)

updatePttKey armed the Rust poller when a PTT key was bound mid-call but
never applied the gate. The poller only emits 'ptt-state' on a press/release
transition, so an idle key produced no event and the already-published mic
stayed hot until the user's first physical press+release. Mirror the join-time
gate computation in updatePttKey, guarded on being in a call, polling actually
being live, and the mic not already being gated.

* fix(client): 1 defect(s) (OC-0164)

* fix(plugin): 1 defect(s) (OC-0165)

scanPluginDirectory now skips a malformed plugin subdirectory and joins its
error instead of aborting the whole scan, and LoadAll logs-and-continues so
one bad plugin directory cannot disable every other plugin.

* fix(ws): 1 defect(s) (OC-0166)

Route PresenceSelfEvent onto the owner's normal-priority queue instead of
letting it fall through to the UserTargetedEvent high-priority case, so a
user's own presence frames all share one FIFO and cannot be delivered out
of order relative to the visible presence_update path.

* fix(db): 1 defect(s) (OC-0168)

* fix(client): 1 defect(s) (OC-0169)

* fix(client): 1 defect(s) (OC-0171)

addMessage appended a broadcast at the tail even when trailing optimistic
rows were still unreconciled, so a message that committed while our own
send was in flight ended up ordered behind the row confirmSend later
stamped with a higher server id/timestamp. Insert before the trailing
unreconciled run instead.

* fix(voice): 1 defect(s) (OC-0172)

* fix(client): 1 defect(s) (OC-0174)

* fix(ws): 1 defect(s) (OC-0175)

* fix(client): 1 defect(s) (OC-0177)

* fix(client): 1 defect(s) (OC-0178)

* fix(voice): 1 defect(s) (OC-0179)

Undeafening no longer sends a voice_mute{muted:false} the server will
refuse while a moderator-imposed mute stands, matching the localServerMuted
guard already present in onMuteToggle.

* fix(client): 1 defect(s) (OC-0182)

* fix(plugin): 1 defect(s) (OC-0183)

* fix(client): 1 defect(s) (OC-0184)

Treat a trailing underscore as an emphasis delimiter, not part of the URL,
when scanning for the end of an autolinked URL.

* fix(client): 1 defect(s) (OC-0185)

Reveal .msg-actions-bar on .message:focus-within, not only on hover, so
keyboard users can see the per-message action buttons they Tab into
instead of activating them at opacity: 0.

Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK

* fix(client): 1 defect(s) (OC-0186)

* fix(client): 1 defect(s) (OC-0187)

The Add Server modal validated addresses with its own narrower regex that
never gained IPv6 support when api.ts's validator did, so an IPv6 server
could be logged into but never saved as a profile. Extract the validator
into src/lib/hostValidation.ts and use it from both call sites.

* fix(client): 1 defect(s) (OC-0189)

DM sidebar rows dropped mention counts entirely and the header total
excluded muted conversations outright, so a direct mention in a muted DM
was invisible. Render a mention badge that outranks the plain unread
badge, and count a muted channel's mentionCount toward the header total.

* fix(client): 1 defect(s) (OC-0190)

* fix(client): 1 defect(s) (OC-0191)

* fix(client): 2 defect(s) (OC-0157, OC-0176)

* fix(client): 1 defect(s) (OC-0161)

confirmTotp answers 401 for a wrong enrollment code while the session is still valid; firing the global onUnauthorized sink signed the user out and deleted their stored credential. Opt that one call out via a skipUnauthorized flag on doFetch.

* fix(admin): 1 defect(s) (OC-0173)

* fix(identity): 1 defect(s) (OC-0180)

* fix(admin): archived channel PATCH skips voice eviction and fan-out (OC-0158)

handlePatchChannel commits the AdminUpdateChannel write, then re-reads the
channel to drive voice eviction and the visibility fan-out. When that
post-commit re-read failed, the handler returned early: the archive was
durable but connected clients were never told and voice members were never
evicted, leaving users talking in a channel that no longer exists for them.

Drive the post-commit work off the values already in hand rather than
abandoning it when the re-read fails.

Adds SetPatchChannelPostCommitHook so the test can land a cancellation in
that exact window deterministically instead of racing wall-clock timing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK

* fix(admin): role changes commit with no client ever notified (OC-0170)

broadcastRoles derived its context from the inbound *http.Request, so the
roles_update fan-out was tied to the request lifetime. A role create,
update, or delete could commit to the database and then broadcast nothing
once that request context was done, leaving every connected client on a
stale role list until the next full resync.

Decouple the fan-out from the request context so the broadcast follows the
commit rather than the caller.

Adds BroadcastRolesForTest to reach broadcastRoles from the external test
package.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK

* fix(client): username rename stomps the profile card header (OC-0188)

The account profile card's header is a resolveDisplayName() slot, but the
username-rename save path wrote the raw username straight into it. A user
with a display name set would see the header switch from their display
name to their new username after a rename, disagreeing with every other
surface that renders the same identity.

Resolve the header through the same display-name path the initial render
uses, so a rename updates the username field without touching the header.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK

* fix(client): settings overlay never focuses when mounted already-open (OC-0181)

mount() synced initial state — including the show() that calls
focusDialog() — before appending root to the container. .focus() on a
still-detached subtree is a silent no-op, so a caller that mounts while
uiStore.settingsOpen is already true (ConnectPage's lazy first-open path)
got a visible overlay whose focus trap never captured focus: keyboard
users landed outside the dialog with Tab escaping to the page behind it.

Attach root before syncing initial state so focusDialog() runs against a
connected subtree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK

* chore: satisfy the CI gates for this fix batch

The fix batch's own commits left three CI gates red. Nothing here changes
behaviour; every edit is a lint, type, or formatting correction to code
this batch introduced.

golangci-lint:
- OC-0153 and OC-0173 replaced the last two uses of admin's setupSanitizer,
  and OC-0151 the last use of api's sanitizer, leaving both package-level
  bluemonday vars unused. Remove them along with the now-unused imports,
  and reword the comments that named them so they still explain why the
  fixpoint sanitizer is the right one without pointing at deleted symbols.
- Modernize the new handshake-deadline test's loop to range-over-int.

tsc --noEmit:
- jsdom ships no types and @types/jsdom is not a dependency, so declare the
  surface the new admin-panel test uses, following src/types/jitsi-rnnoise.d.ts.
- Narrow the last-call lookup instead of indexing under
  noUncheckedIndexedAccess, with an explicit failure message.
- membersStore.setState replaces whole state, so the presence-sender mocks
  must supply typingUsers.

prettier: reformat the five files this batch touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK

* chore(ledger): record the 2026-08-19 hunt and its fixes

Adds the 41 findings confirmed by the 2026-08-19 hunt and marks the 40
fixed on this branch, each with its commit, the test that pins it, and
revertProof "pass".

"pass" means an independent check, not the fixing agent's self-report:
every commit had its source diff reverted against the working tree, its
own test re-run and required to FAIL, then the source restored and the
test required to PASS. Commits whose tests live inline in Rust
#[cfg(test)] blocks were proven the same way at hunk level, splicing the
pre-fix source onto the post-fix test module.

OC-0159 is recorded as a duplicate of OC-0152: the flow-reconnect and
flow-message lenses independently found the same unbounded handshake
write and proposed the same helper over the same call sites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK

* test(e2e): make the voice-roster join fixture self-consistent

The voice-widget join test emitted a voice_state for user_id 4 claiming
username "newvoiceuser", but id 4 is "member2" in MOCK_MEMBERS_MULTI_ROLE.
A real server never sends a voice_state whose username disagrees with the
member record for that id, and the same file's VOICE_STATE_EVENT already
pairs id 1 with "testuser" correctly — this one event was the outlier.

The contradiction was invisible while the roster rendered the payload's
raw username. OC-0177 makes it resolve identity through membersStore so a
nickname shows the same in voice as everywhere else, at which point the
fixture's own inconsistency surfaced as a failure.

Send id 4's real username and assert on it. The test still covers what it
did before — a genuine join by a user not previously in voice, asserted by
name and by roster count.

Verified against the app unchanged: with the old fixture the spec fails
1/5 (matching CI), with this one it passes 5/5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-19 16:32:07 +02:00

1277 lines
43 KiB
TypeScript

/**
* Unit tests for the Push-to-Talk service (src/lib/ptt.ts).
*
* Covers:
* - vkName: known keys, A-Z, 0-9, Numpad, unknown (hex fallback)
* - initPtt: no-op when pttVk is 0; invokes ptt_set_key + ptt_start when set
* - stopPtt: calls invoke("ptt_stop"); no-op when not listening
* - updatePttKey: saves pref, calls ptt_set_key; calls stopPtt when vk === 0
* - captureKeyPress: calls invoke("ptt_listen_for_key")
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// ---------------------------------------------------------------------------
// Shared mock state
// ---------------------------------------------------------------------------
const mockInvoke = vi.fn();
const mockListen = vi.fn();
// Prefs storage
const testPrefs = new Map<string, unknown>();
// voiceStore state
let mockCurrentChannelId: number | null = null;
let mockLocalMuted = false;
let mockLocalDeafened = false;
let mockPttGated = false;
let mockPttPollingLive = false;
const mockSetPttGated = vi.fn();
const mockSetPttPollingLive = vi.fn((live: boolean) => {
mockPttPollingLive = live;
});
/** Captures the listener passed to voiceStore.subscribe() so tests can fire
* a simulated store notification (real createStore() batches these via
* queueMicrotask; the mock fires only when a test invokes it explicitly). */
let capturedStoreListener: ((state: { localMuted: boolean }) => void) | null = null;
const mockSubscribeStore = vi.fn((listener: (state: { localMuted: boolean }) => void) => {
capturedStoreListener = listener;
return () => {
capturedStoreListener = null;
};
});
// ---------------------------------------------------------------------------
// Module mocks (must be declared before importing the module under test)
// ---------------------------------------------------------------------------
vi.mock("@tauri-apps/api/core", () => ({
invoke: (...args: unknown[]) => mockInvoke(...args),
}));
vi.mock("@tauri-apps/api/event", () => ({
listen: (...args: unknown[]) => mockListen(...args),
}));
vi.mock("@components/settings/helpers", () => ({
loadPref: (key: string, fallback: unknown) => testPrefs.get(key) ?? fallback,
savePref: (key: string, value: unknown) => {
testPrefs.set(key, value);
},
}));
vi.mock("@stores/voice.store", () => ({
voiceStore: {
getState: () => ({
currentChannelId: mockCurrentChannelId,
localMuted: mockLocalMuted,
localDeafened: mockLocalDeafened,
pttGated: mockPttGated,
}),
subscribe: (listener: (state: { localMuted: boolean }) => void) => mockSubscribeStore(listener),
},
setPttGated: (...args: unknown[]) => mockSetPttGated(...args),
setPttPollingLive: (live: boolean) => mockSetPttPollingLive(live),
isPttPollingLive: () => mockPttPollingLive,
}));
vi.mock("@lib/logger", () => ({
createLogger: () => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}),
}));
// setMuted is called internally by the ptt-state listener — mock to isolate
vi.mock("../../src/lib/livekitSession", () => ({
setMuted: vi.fn(),
}));
// ---------------------------------------------------------------------------
// Import module under test (AFTER mocks)
// ---------------------------------------------------------------------------
import { vkName, initPtt, stopPtt, updatePttKey, captureKeyPress } from "../../src/lib/ptt";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Reset all mock state between tests. */
function resetAll(): void {
testPrefs.clear();
mockCurrentChannelId = null;
mockLocalMuted = false;
mockLocalDeafened = false;
mockPttGated = false;
mockPttPollingLive = false;
mockSetPttGated.mockReset();
mockSetPttPollingLive.mockReset();
mockSetPttPollingLive.mockImplementation((live: boolean) => {
mockPttPollingLive = live;
});
mockInvoke.mockReset();
mockListen.mockReset();
mockSubscribeStore.mockReset();
mockSubscribeStore.mockImplementation((listener: (state: { localMuted: boolean }) => void) => {
capturedStoreListener = listener;
return () => {
capturedStoreListener = null;
};
});
capturedStoreListener = null;
// Default: invoke resolves with undefined; listen resolves with a no-op unlistener
mockInvoke.mockResolvedValue(undefined);
mockListen.mockResolvedValue(() => {});
}
// ---------------------------------------------------------------------------
// Tests: vkName
// ---------------------------------------------------------------------------
describe("vkName", () => {
describe("well-known named keys", () => {
it("returns 'Mouse 4' for 0x05", () => {
expect(vkName(0x05)).toBe("Mouse 4");
});
it("returns 'Mouse 5' for 0x06", () => {
expect(vkName(0x06)).toBe("Mouse 5");
});
it("returns 'Mouse Left' for 0x01", () => {
expect(vkName(0x01)).toBe("Mouse Left");
});
it("returns 'Mouse Right' for 0x02", () => {
expect(vkName(0x02)).toBe("Mouse Right");
});
it("returns 'Mouse Middle' for 0x04", () => {
expect(vkName(0x04)).toBe("Mouse Middle");
});
it("returns 'Space' for 0x20", () => {
expect(vkName(0x20)).toBe("Space");
});
it("returns 'F1' for 0x70", () => {
expect(vkName(0x70)).toBe("F1");
});
it("returns 'F12' for 0x7B", () => {
expect(vkName(0x7b)).toBe("F12");
});
it("returns 'Enter' for 0x0D", () => {
expect(vkName(0x0d)).toBe("Enter");
});
it("returns 'Escape' for 0x1B", () => {
expect(vkName(0x1b)).toBe("Escape");
});
it("returns 'Backspace' for 0x08", () => {
expect(vkName(0x08)).toBe("Backspace");
});
it("returns 'Tab' for 0x09", () => {
expect(vkName(0x09)).toBe("Tab");
});
it("returns 'Delete' for 0x2E", () => {
expect(vkName(0x2e)).toBe("Delete");
});
it("returns 'Insert' for 0x2D", () => {
expect(vkName(0x2d)).toBe("Insert");
});
it("returns 'Arrow Left' for 0x25", () => {
expect(vkName(0x25)).toBe("Arrow Left");
});
it("returns 'Arrow Right' for 0x27", () => {
expect(vkName(0x27)).toBe("Arrow Right");
});
it("returns 'Arrow Up' for 0x26", () => {
expect(vkName(0x26)).toBe("Arrow Up");
});
it("returns 'Arrow Down' for 0x28", () => {
expect(vkName(0x28)).toBe("Arrow Down");
});
it("returns 'Page Up' for 0x21", () => {
expect(vkName(0x21)).toBe("Page Up");
});
it("returns 'Page Down' for 0x22", () => {
expect(vkName(0x22)).toBe("Page Down");
});
it("returns 'Home' for 0x24", () => {
expect(vkName(0x24)).toBe("Home");
});
it("returns 'End' for 0x23", () => {
expect(vkName(0x23)).toBe("End");
});
});
describe("digit keys 0-9 (0x30-0x39)", () => {
it("returns '0' for 0x30", () => {
expect(vkName(0x30)).toBe("0");
});
it("returns '9' for 0x39", () => {
expect(vkName(0x39)).toBe("9");
});
it("returns '5' for 0x35", () => {
expect(vkName(0x35)).toBe("5");
});
});
describe("letter keys A-Z (0x41-0x5A)", () => {
it("returns 'A' for 0x41", () => {
expect(vkName(0x41)).toBe("A");
});
it("returns 'Z' for 0x5A", () => {
expect(vkName(0x5a)).toBe("Z");
});
it("returns 'M' for 0x4D", () => {
expect(vkName(0x4d)).toBe("M");
});
});
describe("Numpad keys (0x60-0x69)", () => {
it("returns 'Numpad 0' for 0x60", () => {
expect(vkName(0x60)).toBe("Numpad 0");
});
it("returns 'Numpad 9' for 0x69", () => {
expect(vkName(0x69)).toBe("Numpad 9");
});
it("returns 'Numpad 5' for 0x65", () => {
expect(vkName(0x65)).toBe("Numpad 5");
});
});
describe("unknown keys — hex fallback", () => {
it("returns hex string for an unrecognised VK code", () => {
// 0xFF is not in the map and not in any named range
expect(vkName(0xff)).toBe("Key 0xFF");
});
it("returns uppercase hex for 0xAB", () => {
expect(vkName(0xab)).toBe("Key 0xAB");
});
it("returns 'Key 0x0' for vk code 0", () => {
// 0 is unrecognised — not in map, not in any character range
expect(vkName(0x00)).toBe("Key 0x0");
});
});
});
// ---------------------------------------------------------------------------
// Tests: initPtt
// ---------------------------------------------------------------------------
describe("initPtt", () => {
beforeEach(resetAll);
it("does nothing when pttVk pref is 0 (default)", async () => {
// pttVk defaults to 0
await initPtt();
expect(mockInvoke).not.toHaveBeenCalled();
expect(mockListen).not.toHaveBeenCalled();
});
it("does nothing when pttVk pref is explicitly saved as 0", async () => {
testPrefs.set("pttVk", 0);
await initPtt();
expect(mockInvoke).not.toHaveBeenCalled();
expect(mockListen).not.toHaveBeenCalled();
});
it("calls invoke('ptt_set_key') with the stored vk code when key is non-zero", async () => {
testPrefs.set("pttVk", 0x20); // Space
await initPtt();
expect(mockInvoke).toHaveBeenCalledWith("ptt_set_key", { vkCode: 0x20 });
});
it("calls invoke('ptt_start') when key is non-zero", async () => {
testPrefs.set("pttVk", 0x20);
await initPtt();
expect(mockInvoke).toHaveBeenCalledWith("ptt_start");
});
it("calls ptt_set_key before ptt_start", async () => {
testPrefs.set("pttVk", 0x41); // A
await initPtt();
const calls = mockInvoke.mock.calls.map((c) => c[0]);
const setKeyIdx = calls.indexOf("ptt_set_key");
const startIdx = calls.indexOf("ptt_start");
expect(setKeyIdx).toBeGreaterThanOrEqual(0);
expect(startIdx).toBeGreaterThanOrEqual(0);
expect(setKeyIdx).toBeLessThan(startIdx);
});
// v007: livekitSession mutes the mic at join when PTT is armed, and only a
// real ptt-state event can lift that mute. ptt_start spawns its thread on
// every platform, so the frontend must gate on the backend's capability
// answer instead — otherwise macOS (is_key_down stub) and pure-Wayland Linux
// (no reachable display) join muted with no way to ever unmute.
it("reports the backend's polling capability so join-time muting is safe", async () => {
testPrefs.set("pttVk", 0x20);
mockInvoke.mockImplementation((cmd: string) =>
Promise.resolve(cmd === "ptt_polling_supported" ? true : undefined),
);
await initPtt();
expect(mockInvoke).toHaveBeenCalledWith("ptt_polling_supported");
expect(mockSetPttPollingLive).toHaveBeenCalledWith(true);
});
it("reports polling as NOT live when the backend cannot observe key state", async () => {
testPrefs.set("pttVk", 0x20);
mockInvoke.mockImplementation((cmd: string) =>
Promise.resolve(cmd === "ptt_polling_supported" ? false : undefined),
);
await initPtt();
expect(mockSetPttPollingLive).toHaveBeenCalledWith(false);
expect(mockSetPttPollingLive).not.toHaveBeenCalledWith(true);
});
it("reports polling as NOT live when a backend command rejects", async () => {
testPrefs.set("pttVk", 0x20);
mockInvoke.mockRejectedValue(new Error("not in Tauri"));
await initPtt();
expect(mockSetPttPollingLive).toHaveBeenCalledWith(false);
expect(mockSetPttPollingLive).not.toHaveBeenCalledWith(true);
});
it("calls listen for 'ptt-state' events when key is non-zero", async () => {
testPrefs.set("pttVk", 0x70); // F1
await initPtt();
expect(mockListen).toHaveBeenCalledWith("ptt-state", expect.any(Function));
});
it("does not throw when Tauri is unavailable (simulated by invoke rejecting)", async () => {
testPrefs.set("pttVk", 0x20);
mockInvoke.mockRejectedValue(new Error("not in Tauri"));
await expect(initPtt()).resolves.toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Tests: stopPtt
// ---------------------------------------------------------------------------
describe("stopPtt", () => {
beforeEach(async () => {
resetAll();
// Drain any lingering listening state left by earlier test groups.
// stopPtt with listening===true would call invoke — clear it silently.
await stopPtt();
mockInvoke.mockClear();
});
it("does not call invoke when PTT was never started (not listening)", async () => {
// After beforeEach drain, listening is false — stopPtt must be a no-op.
await stopPtt();
expect(mockInvoke).not.toHaveBeenCalled();
});
it("calls invoke('ptt_stop') after PTT has been started", async () => {
// Start PTT first so the listening flag is set
testPrefs.set("pttVk", 0x20);
await initPtt();
mockInvoke.mockClear();
await stopPtt();
expect(mockInvoke).toHaveBeenCalledWith("ptt_stop");
});
it("does not throw when invoke('ptt_stop') rejects", async () => {
testPrefs.set("pttVk", 0x20);
await initPtt();
mockInvoke.mockRejectedValue(new Error("ptt_stop failed"));
await expect(stopPtt()).resolves.toBeUndefined();
});
it("is idempotent — second stopPtt does not call invoke again", async () => {
testPrefs.set("pttVk", 0x20);
await initPtt();
await stopPtt();
mockInvoke.mockClear();
await stopPtt();
expect(mockInvoke).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// Tests: updatePttKey
// ---------------------------------------------------------------------------
describe("updatePttKey", () => {
beforeEach(resetAll);
it("saves the new vk code to prefs", async () => {
await updatePttKey(0x41);
expect(testPrefs.get("pttVk")).toBe(0x41);
});
it("calls invoke('ptt_set_key') with the new vk code", async () => {
await updatePttKey(0x41);
expect(mockInvoke).toHaveBeenCalledWith("ptt_set_key", { vkCode: 0x41 });
});
it("saves 0 to prefs when called with 0 (disable PTT)", async () => {
// Start listening first
testPrefs.set("pttVk", 0x20);
await initPtt();
await updatePttKey(0);
expect(testPrefs.get("pttVk")).toBe(0);
});
it("calls invoke('ptt_stop') via stopPtt when vk is 0 and was listening", async () => {
// Establish listening state
testPrefs.set("pttVk", 0x20);
await initPtt();
mockInvoke.mockClear();
await updatePttKey(0);
expect(mockInvoke).toHaveBeenCalledWith("ptt_stop");
});
it("does not call ptt_stop when vk is 0 but was never listening", async () => {
// Never called initPtt, so listening === false
await updatePttKey(0);
// ptt_set_key should be called (updatePttKey always calls it), but not ptt_stop
const stopCalls = mockInvoke.mock.calls.filter((c) => c[0] === "ptt_stop");
expect(stopCalls).toHaveLength(0);
});
it("does not throw when invoke rejects", async () => {
mockInvoke.mockRejectedValue(new Error("invoke failed"));
await expect(updatePttKey(0x20)).resolves.toBeUndefined();
});
it("calls initPtt (triggering ptt_start) when setting a key while not yet listening", async () => {
// listening is false because initPtt was never called
await updatePttKey(0x41);
// updatePttKey calls initPtt internally when !listening && vk !== 0,
// which in turn calls ptt_set_key (again) and ptt_start
const startCalls = mockInvoke.mock.calls.filter((c) => c[0] === "ptt_start");
expect(startCalls.length).toBeGreaterThanOrEqual(1);
});
});
// ---------------------------------------------------------------------------
// Tests: updatePttKey gates an already-hot mic when bound mid-call (OC-0162)
// ---------------------------------------------------------------------------
describe("updatePttKey gates the mic when binding a key mid-call (OC-0162)", () => {
beforeEach(async () => {
resetAll();
// The module-level `listening` flag is not reset by resetAll() (it lives
// in ptt.ts, not in the mocks) and earlier describe blocks may leave it
// true — drain it so each test here starts from the same "no key bound
// yet" state the finding's repro assumes.
await stopPtt();
mockInvoke.mockClear();
});
it("gates and mutes the mic when a key is bound while already in a voice call", async () => {
const { setMuted } = await import("../../src/lib/livekitSession");
const mockSetMuted = vi.mocked(setMuted);
mockSetMuted.mockClear();
// Already in a voice call, joined with no PTT key bound — the mic was
// published ungated (pttArmed was false at join time).
mockCurrentChannelId = 7;
mockPttGated = false;
mockLocalMuted = false;
mockLocalDeafened = false;
mockInvoke.mockImplementation((cmd: string) =>
Promise.resolve(cmd === "ptt_polling_supported" ? true : undefined),
);
// The user now binds a PTT key from Settings -> Keybinds.
await updatePttKey(0x20);
// Without the fix, updatePttKey only starts the poller (initPtt) and
// never applies the gate — the idle key produces no ptt-state transition
// (see src-tauri/src/ptt.rs ptt_transition), so the mic stays hot forever
// until the user's first physical press+release.
expect(mockSetPttGated).toHaveBeenCalledWith(true);
await vi.waitFor(() => {
expect(mockSetMuted).toHaveBeenCalledWith(true);
});
});
it("does not gate the mic when binding a key while not in a voice call", async () => {
const { setMuted } = await import("../../src/lib/livekitSession");
const mockSetMuted = vi.mocked(setMuted);
mockSetMuted.mockClear();
mockCurrentChannelId = null; // not in a call
mockPttGated = false;
mockInvoke.mockImplementation((cmd: string) =>
Promise.resolve(cmd === "ptt_polling_supported" ? true : undefined),
);
await updatePttKey(0x20);
expect(mockSetPttGated).not.toHaveBeenCalledWith(true);
await new Promise((r) => setTimeout(r, 0));
expect(mockSetMuted).not.toHaveBeenCalledWith(true);
});
it("does not gate the mic when the backend cannot actually observe key state", async () => {
const { setMuted } = await import("../../src/lib/livekitSession");
const mockSetMuted = vi.mocked(setMuted);
mockSetMuted.mockClear();
mockCurrentChannelId = 7; // in a call
mockPttGated = false;
// ptt_polling_supported === false: macOS is_key_down stub / Wayland — no
// ptt-state event can ever arrive, so gating here would strand the mic
// muted forever with no press able to lift it.
mockInvoke.mockImplementation((cmd: string) =>
Promise.resolve(cmd === "ptt_polling_supported" ? false : undefined),
);
await updatePttKey(0x20);
expect(mockSetPttGated).not.toHaveBeenCalledWith(true);
await new Promise((r) => setTimeout(r, 0));
expect(mockSetMuted).not.toHaveBeenCalledWith(true);
});
it("does not re-gate when the mic is already PTT-gated", async () => {
const { setMuted } = await import("../../src/lib/livekitSession");
const mockSetMuted = vi.mocked(setMuted);
mockSetMuted.mockClear();
mockCurrentChannelId = 7;
mockPttGated = true; // already gated (e.g. join-time gate already armed)
mockInvoke.mockImplementation((cmd: string) =>
Promise.resolve(cmd === "ptt_polling_supported" ? true : undefined),
);
await updatePttKey(0x20);
await new Promise((r) => setTimeout(r, 0));
expect(mockSetPttGated).not.toHaveBeenCalledWith(true);
expect(mockSetMuted).not.toHaveBeenCalledWith(true);
});
});
// ---------------------------------------------------------------------------
// Tests: captureKeyPress
// ---------------------------------------------------------------------------
describe("captureKeyPress", () => {
beforeEach(resetAll);
it("calls invoke('ptt_listen_for_key') and returns the result", async () => {
mockInvoke.mockResolvedValue(0x41);
const result = await captureKeyPress();
expect(mockInvoke).toHaveBeenCalledWith("ptt_listen_for_key");
expect(result).toBe(0x41);
});
it("propagates the vk code returned by Tauri", async () => {
mockInvoke.mockResolvedValue(0x20);
const result = await captureKeyPress();
expect(result).toBe(0x20);
});
it("propagates rejection from invoke", async () => {
mockInvoke.mockRejectedValue(new Error("listener error"));
await expect(captureKeyPress()).rejects.toThrow("listener error");
});
});
// ---------------------------------------------------------------------------
// Tests: ptt-state event handler (integration via initPtt + listen callback)
// ---------------------------------------------------------------------------
describe("ptt-state event listener", () => {
beforeEach(resetAll);
afterEach(() => {
vi.resetModules();
});
it("calls setMuted(false) when PTT is pressed (payload true) and in a voice channel", async () => {
const { setMuted } = await import("../../src/lib/livekitSession");
const mockSetMuted = vi.mocked(setMuted);
mockSetMuted.mockClear();
mockCurrentChannelId = 7;
testPrefs.set("pttVk", 0x20);
let capturedCallback: ((event: { payload: boolean }) => void) | null = null;
mockListen.mockImplementation((_event: string, cb: (e: { payload: boolean }) => void) => {
capturedCallback = cb;
return Promise.resolve(() => {});
});
await initPtt();
expect(capturedCallback).not.toBeNull();
capturedCallback!({ payload: true }); // key pressed
// setMuted is reached via a dynamic import of livekitSession
await vi.waitFor(() => {
expect(mockSetMuted).toHaveBeenCalledWith(false);
});
});
it("calls setMuted(true) when PTT is released (payload false) and in a voice channel", async () => {
const { setMuted } = await import("../../src/lib/livekitSession");
const mockSetMuted = vi.mocked(setMuted);
mockSetMuted.mockClear();
mockCurrentChannelId = 7;
testPrefs.set("pttVk", 0x20);
let capturedCallback: ((event: { payload: boolean }) => void) | null = null;
mockListen.mockImplementation((_event: string, cb: (e: { payload: boolean }) => void) => {
capturedCallback = cb;
return Promise.resolve(() => {});
});
await initPtt();
capturedCallback!({ payload: false }); // key released
// setMuted is reached via a dynamic import of livekitSession
await vi.waitFor(() => {
expect(mockSetMuted).toHaveBeenCalledWith(true);
});
});
it("does not call setMuted when not in a voice channel", async () => {
const { setMuted } = await import("../../src/lib/livekitSession");
const mockSetMuted = vi.mocked(setMuted);
mockSetMuted.mockClear();
mockCurrentChannelId = null; // not in a channel
testPrefs.set("pttVk", 0x20);
let capturedCallback: ((event: { payload: boolean }) => void) | null = null;
mockListen.mockImplementation((_event: string, cb: (e: { payload: boolean }) => void) => {
capturedCallback = cb;
return Promise.resolve(() => {});
});
await initPtt();
capturedCallback!({ payload: true });
// Flush pending microtasks so a (wrong) dynamic-import path would have
// had the chance to call setMuted before we assert it never happens.
await new Promise((r) => setTimeout(r, 0));
expect(mockSetMuted).not.toHaveBeenCalled();
});
it("does not call setMuted(false) when PTT is pressed while the user is self-muted (v006)", async () => {
const { setMuted } = await import("../../src/lib/livekitSession");
const mockSetMuted = vi.mocked(setMuted);
mockSetMuted.mockClear();
mockCurrentChannelId = 7;
mockLocalMuted = true; // user explicitly muted themselves via the widget
testPrefs.set("pttVk", 0x20);
let capturedCallback: ((event: { payload: boolean }) => void) | null = null;
mockListen.mockImplementation((_event: string, cb: (e: { payload: boolean }) => void) => {
capturedCallback = cb;
return Promise.resolve(() => {});
});
await initPtt();
capturedCallback!({ payload: true }); // key pressed
// Give the dynamic import a chance to resolve and (wrongly) call setMuted.
await new Promise((r) => setTimeout(r, 0));
expect(mockSetMuted).not.toHaveBeenCalled();
});
it("does not call setMuted(false) when PTT is pressed while the user is deafened (v006)", async () => {
const { setMuted } = await import("../../src/lib/livekitSession");
const mockSetMuted = vi.mocked(setMuted);
mockSetMuted.mockClear();
mockCurrentChannelId = 7;
mockLocalDeafened = true;
testPrefs.set("pttVk", 0x20);
let capturedCallback: ((event: { payload: boolean }) => void) | null = null;
mockListen.mockImplementation((_event: string, cb: (e: { payload: boolean }) => void) => {
capturedCallback = cb;
return Promise.resolve(() => {});
});
await initPtt();
capturedCallback!({ payload: true });
await new Promise((r) => setTimeout(r, 0));
expect(mockSetMuted).not.toHaveBeenCalled();
});
it("still unmutes on press when the user is not self-muted or deafened", async () => {
const { setMuted } = await import("../../src/lib/livekitSession");
const mockSetMuted = vi.mocked(setMuted);
mockSetMuted.mockClear();
mockCurrentChannelId = 7;
mockLocalMuted = false;
mockLocalDeafened = false;
testPrefs.set("pttVk", 0x20);
let capturedCallback: ((event: { payload: boolean }) => void) | null = null;
mockListen.mockImplementation((_event: string, cb: (e: { payload: boolean }) => void) => {
capturedCallback = cb;
return Promise.resolve(() => {});
});
await initPtt();
capturedCallback!({ payload: true });
await vi.waitFor(() => {
expect(mockSetMuted).toHaveBeenCalledWith(false);
});
});
it("updates pttGated in the store on press and release", async () => {
mockCurrentChannelId = 7;
testPrefs.set("pttVk", 0x20);
let capturedCallback: ((event: { payload: boolean }) => void) | null = null;
mockListen.mockImplementation((_event: string, cb: (e: { payload: boolean }) => void) => {
capturedCallback = cb;
return Promise.resolve(() => {});
});
await initPtt();
mockSetPttGated.mockClear();
capturedCallback!({ payload: true }); // pressed — gate open
expect(mockSetPttGated).toHaveBeenCalledWith(false);
capturedCallback!({ payload: false }); // released — gate closed
expect(mockSetPttGated).toHaveBeenCalledWith(true);
});
it("keeps unmuting on every press even though a release writes localMuted (v006)", async () => {
const { setMuted } = await import("../../src/lib/livekitSession");
const mockSetMuted = vi.mocked(setMuted);
mockSetMuted.mockReset();
// livekitSession.setMuted() writes localMuted for every caller, PTT
// included — the self-mute guard must not read that write back as an
// explicit self-mute, or PTT unmutes exactly once and is dead after.
mockSetMuted.mockImplementation((muted: boolean) => {
mockLocalMuted = muted;
});
mockCurrentChannelId = 7;
testPrefs.set("pttVk", 0x20);
let capturedCallback: ((event: { payload: boolean }) => void) | null = null;
mockListen.mockImplementation((_event: string, cb: (e: { payload: boolean }) => void) => {
capturedCallback = cb;
return Promise.resolve(() => {});
});
await initPtt();
capturedCallback!({ payload: true });
await vi.waitFor(() => expect(mockSetMuted).toHaveBeenCalledWith(false));
capturedCallback!({ payload: false });
await vi.waitFor(() => expect(mockLocalMuted).toBe(true));
mockSetMuted.mockClear();
capturedCallback!({ payload: true }); // second press
await vi.waitFor(() => {
expect(mockSetMuted).toHaveBeenCalledWith(false);
});
});
it("stays muted on the press after the user self-mutes mid-hold (v006)", async () => {
const { setMuted } = await import("../../src/lib/livekitSession");
const mockSetMuted = vi.mocked(setMuted);
mockSetMuted.mockReset();
mockSetMuted.mockImplementation((muted: boolean) => {
mockLocalMuted = muted;
});
mockCurrentChannelId = 7;
testPrefs.set("pttVk", 0x20);
let capturedCallback: ((event: { payload: boolean }) => void) | null = null;
mockListen.mockImplementation((_event: string, cb: (e: { payload: boolean }) => void) => {
capturedCallback = cb;
return Promise.resolve(() => {});
});
await initPtt();
capturedCallback!({ payload: true });
await vi.waitFor(() => expect(mockSetMuted).toHaveBeenCalledWith(false));
// The user hits the widget mute button while still holding the key.
mockLocalMuted = true;
capturedCallback!({ payload: false }); // release — mic was already muted
await new Promise((r) => setTimeout(r, 0));
mockSetMuted.mockClear();
capturedCallback!({ payload: true }); // next press must not republish
await new Promise((r) => setTimeout(r, 0));
expect(mockSetMuted).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// Tests: pttOwnsMute latch is cleared by a non-PTT unmute (B1_voice_mic-7)
// ---------------------------------------------------------------------------
describe("pttOwnsMute latch reset on external unmute", () => {
beforeEach(resetAll);
it("stays muted on a later press after a widget unmute+re-mute clears a stale PTT-owned latch", async () => {
const { setMuted } = await import("../../src/lib/livekitSession");
const mockSetMuted = vi.mocked(setMuted);
mockSetMuted.mockReset();
mockSetMuted.mockImplementation((muted: boolean) => {
mockLocalMuted = muted;
});
mockCurrentChannelId = 7;
testPrefs.set("pttVk", 0x20);
let capturedCallback: ((event: { payload: boolean }) => void) | null = null;
mockListen.mockImplementation((_event: string, cb: (e: { payload: boolean }) => void) => {
capturedCallback = cb;
return Promise.resolve(() => {});
});
await initPtt();
expect(capturedStoreListener).not.toBeNull();
// 1. Press then release — the release is PTT's own mute, so pttOwnsMute
// latches true.
capturedCallback!({ payload: true });
await vi.waitFor(() => expect(mockSetMuted).toHaveBeenCalledWith(false));
capturedCallback!({ payload: false });
await vi.waitFor(() => expect(mockLocalMuted).toBe(true));
// 2. A non-PTT path unmutes (e.g. the widget's own mic button calling
// livekitSession.setMuted(false) directly) — simulate both the write
// and the resulting store notification our subscriber reacts to.
mockSetMuted(false);
capturedStoreListener!({ localMuted: mockLocalMuted });
// 3. The user then genuinely self-mutes via the same non-PTT path.
mockSetMuted(true);
capturedStoreListener!({ localMuted: mockLocalMuted });
// 4. The next PTT press must not lift this genuine self-mute — the
// latch from step 1 must not have survived steps 2-3.
mockSetMuted.mockClear();
capturedCallback!({ payload: true });
await new Promise((r) => setTimeout(r, 0));
expect(mockSetMuted).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// Tests: stopPtt ungates a still-gated mic when the binding is cleared
// (B1_voice_mic-9)
// ---------------------------------------------------------------------------
describe("stopPtt ungates the mic when clearing the key mid-gate", () => {
beforeEach(resetAll);
it("clears pttGated and re-opens the mic when nothing else wants it muted", async () => {
const { setMuted } = await import("../../src/lib/livekitSession");
const mockSetMuted = vi.mocked(setMuted);
mockSetMuted.mockClear();
testPrefs.set("pttVk", 0x20);
await initPtt();
// Simulates livekitSession's join-time gate, still armed because the key
// was never pressed before the user cleared the binding.
mockPttGated = true;
mockLocalMuted = false;
mockLocalDeafened = false;
await stopPtt();
expect(mockSetPttGated).toHaveBeenCalledWith(false);
await vi.waitFor(() => {
expect(mockSetMuted).toHaveBeenCalledWith(false);
});
});
it("does not force-unmute when the user is separately self-muted or deafened", async () => {
const { setMuted } = await import("../../src/lib/livekitSession");
const mockSetMuted = vi.mocked(setMuted);
mockSetMuted.mockClear();
testPrefs.set("pttVk", 0x20);
await initPtt();
mockPttGated = true;
mockLocalMuted = true;
await stopPtt();
expect(mockSetPttGated).toHaveBeenCalledWith(false);
await new Promise((r) => setTimeout(r, 0));
expect(mockSetMuted).not.toHaveBeenCalled();
});
it("does nothing when pttGated was already false", async () => {
testPrefs.set("pttVk", 0x20);
await initPtt();
mockSetPttGated.mockClear();
mockPttGated = false;
await stopPtt();
expect(mockSetPttGated).not.toHaveBeenCalledWith(false);
});
// B1_voice_mic-11: stopPtt resets pttOwnsMute to false BEFORE calling
// ungateMic (a mute must not outlive its binding), so ungateMic cannot
// read the module-level latch directly — it has already been zeroed by
// the time it runs. The value from before that reset must still decide
// the re-open.
it("re-opens the mic when stopPtt clears the binding after a PTT release applied the mute (bug fix)", async () => {
const { setMuted } = await import("../../src/lib/livekitSession");
const mockSetMuted = vi.mocked(setMuted);
mockSetMuted.mockReset();
mockSetMuted.mockImplementation((muted: boolean) => {
mockLocalMuted = muted;
});
mockCurrentChannelId = 7;
testPrefs.set("pttVk", 0x20);
let capturedCallback: ((event: { payload: boolean }) => void) | null = null;
mockListen.mockImplementation((_event: string, cb: (e: { payload: boolean }) => void) => {
capturedCallback = cb;
return Promise.resolve(() => {});
});
await initPtt();
// Press then release: the release is PTT's own mute — pttOwnsMute
// latches true and the store's pttGated closes.
capturedCallback!({ payload: true });
await vi.waitFor(() => expect(mockSetMuted).toHaveBeenCalledWith(false));
capturedCallback!({ payload: false });
await vi.waitFor(() => expect(mockLocalMuted).toBe(true));
mockPttGated = true; // mirror the gate the release set in the real store
mockLocalDeafened = false;
mockSetMuted.mockClear();
await stopPtt();
expect(mockSetPttGated).toHaveBeenCalledWith(false);
await vi.waitFor(() => {
expect(mockSetMuted).toHaveBeenCalledWith(false);
});
});
it("does not lift a user's own self-mute when stopPtt clears the binding, even after a prior PTT release owned it", async () => {
const { setMuted } = await import("../../src/lib/livekitSession");
const mockSetMuted = vi.mocked(setMuted);
mockSetMuted.mockReset();
mockSetMuted.mockImplementation((muted: boolean) => {
mockLocalMuted = muted;
});
mockCurrentChannelId = 7;
testPrefs.set("pttVk", 0x20);
let capturedCallback: ((event: { payload: boolean }) => void) | null = null;
mockListen.mockImplementation((_event: string, cb: (e: { payload: boolean }) => void) => {
capturedCallback = cb;
return Promise.resolve(() => {});
});
await initPtt();
expect(capturedStoreListener).not.toBeNull();
// A PTT release once owned the mute...
capturedCallback!({ payload: true });
await vi.waitFor(() => expect(mockSetMuted).toHaveBeenCalledWith(false));
capturedCallback!({ payload: false });
await vi.waitFor(() => expect(mockLocalMuted).toBe(true));
// ...but the user then explicitly unmutes and re-mutes via a non-PTT
// path (e.g. the widget's mic button), which clears the PTT-owned latch
// — see the "latch reset" tests above. This mute is now the user's own.
mockSetMuted(false);
capturedStoreListener!({ localMuted: mockLocalMuted });
mockSetMuted(true);
capturedStoreListener!({ localMuted: mockLocalMuted });
mockPttGated = true;
mockLocalDeafened = false;
mockSetMuted.mockClear();
await stopPtt();
expect(mockSetPttGated).toHaveBeenCalledWith(false);
await new Promise((r) => setTimeout(r, 0));
expect(mockSetMuted).not.toHaveBeenCalled();
});
it("does not re-open the mic when stopPtt clears the binding while the user is deafened, even if a PTT release owns the mute", async () => {
const { setMuted } = await import("../../src/lib/livekitSession");
const mockSetMuted = vi.mocked(setMuted);
mockSetMuted.mockReset();
mockSetMuted.mockImplementation((muted: boolean) => {
mockLocalMuted = muted;
});
mockCurrentChannelId = 7;
testPrefs.set("pttVk", 0x20);
let capturedCallback: ((event: { payload: boolean }) => void) | null = null;
mockListen.mockImplementation((_event: string, cb: (e: { payload: boolean }) => void) => {
capturedCallback = cb;
return Promise.resolve(() => {});
});
await initPtt();
capturedCallback!({ payload: true });
await vi.waitFor(() => expect(mockSetMuted).toHaveBeenCalledWith(false));
capturedCallback!({ payload: false });
await vi.waitFor(() => expect(mockLocalMuted).toBe(true));
mockPttGated = true;
mockLocalDeafened = true; // deafened independently of the PTT-owned mute
mockSetMuted.mockClear();
await stopPtt();
expect(mockSetPttGated).toHaveBeenCalledWith(false);
await new Promise((r) => setTimeout(r, 0));
expect(mockSetMuted).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// Tests: 'ptt-error' listener recovers from a backend thread panic
// (B1_voice_mic-10)
// ---------------------------------------------------------------------------
describe("ptt-error event listener", () => {
beforeEach(resetAll);
it("registers a listener for 'ptt-error' and resets polling-live on a backend panic", async () => {
testPrefs.set("pttVk", 0x20);
const capturedHandlers: Record<string, (e: { payload: unknown }) => void> = {};
mockListen.mockImplementation((event: string, cb: (e: { payload: unknown }) => void) => {
capturedHandlers[event] = cb;
return Promise.resolve(() => {});
});
await initPtt();
mockSetPttPollingLive.mockClear();
expect(capturedHandlers["ptt-error"]).toBeTypeOf("function");
capturedHandlers["ptt-error"]!({ payload: "PTT thread panicked" });
expect(mockSetPttPollingLive).toHaveBeenCalledWith(false);
});
it("re-opens a PTT-gated mic when the backend thread panics", async () => {
testPrefs.set("pttVk", 0x20);
const { setMuted } = await import("../../src/lib/livekitSession");
const mockSetMuted = vi.mocked(setMuted);
mockSetMuted.mockClear();
const capturedHandlers: Record<string, (e: { payload: unknown }) => void> = {};
mockListen.mockImplementation((event: string, cb: (e: { payload: unknown }) => void) => {
capturedHandlers[event] = cb;
return Promise.resolve(() => {});
});
await initPtt();
mockPttGated = true;
mockLocalMuted = false;
mockLocalDeafened = false;
capturedHandlers["ptt-error"]!({ payload: "PTT thread panicked" });
expect(mockSetPttGated).toHaveBeenCalledWith(false);
await vi.waitFor(() => {
expect(mockSetMuted).toHaveBeenCalledWith(false);
});
});
// B1_voice_mic-11: a PTT release calls setMuted(true), which writes
// localMuted for every caller (see pttOwnsMute's doc comment) — so at the
// moment the poller dies, localMuted is true precisely because PTT's own
// release put it there. ungateMic's old `!localMuted` guard treated that
// indistinguishably from a user self-mute and never re-opened the mic.
it("re-opens the mic when the polling thread panics after a PTT release applied the mute (bug fix)", async () => {
testPrefs.set("pttVk", 0x20);
const { setMuted } = await import("../../src/lib/livekitSession");
const mockSetMuted = vi.mocked(setMuted);
mockSetMuted.mockReset();
mockSetMuted.mockImplementation((muted: boolean) => {
mockLocalMuted = muted;
});
mockCurrentChannelId = 7;
let capturedCallback: ((event: { payload: boolean }) => void) | null = null;
const capturedHandlers: Record<string, (e: { payload: unknown }) => void> = {};
mockListen.mockImplementation((event: string, cb: (e: { payload: never }) => void) => {
capturedHandlers[event] = cb as (e: { payload: unknown }) => void;
if (event === "ptt-state")
capturedCallback = cb as unknown as (e: { payload: boolean }) => void;
return Promise.resolve(() => {});
});
await initPtt();
// Press then release: the release is PTT's own mute — pttOwnsMute
// latches true and the store's pttGated closes.
capturedCallback!({ payload: true });
await vi.waitFor(() => expect(mockSetMuted).toHaveBeenCalledWith(false));
capturedCallback!({ payload: false });
await vi.waitFor(() => expect(mockLocalMuted).toBe(true));
mockPttGated = true; // mirror the gate the release set in the real store
mockLocalDeafened = false;
mockSetMuted.mockClear();
capturedHandlers["ptt-error"]!({ payload: "PTT thread panicked" });
expect(mockSetPttGated).toHaveBeenCalledWith(false);
await vi.waitFor(() => {
expect(mockSetMuted).toHaveBeenCalledWith(false);
});
});
it("does not lift a user's own self-mute when the polling thread panics", async () => {
testPrefs.set("pttVk", 0x20);
const { setMuted } = await import("../../src/lib/livekitSession");
const mockSetMuted = vi.mocked(setMuted);
mockSetMuted.mockClear();
const capturedHandlers: Record<string, (e: { payload: unknown }) => void> = {};
mockListen.mockImplementation((event: string, cb: (e: { payload: unknown }) => void) => {
capturedHandlers[event] = cb;
return Promise.resolve(() => {});
});
await initPtt();
// The user self-muted via the widget — PTT was never pressed, so this
// mute is not PTT's to lift.
mockPttGated = true;
mockLocalMuted = true;
mockLocalDeafened = false;
capturedHandlers["ptt-error"]!({ payload: "PTT thread panicked" });
expect(mockSetPttGated).toHaveBeenCalledWith(false);
await new Promise((r) => setTimeout(r, 0));
expect(mockSetMuted).not.toHaveBeenCalled();
});
it("does not re-open the mic on a backend panic when the user is deafened, even if a PTT release owns the mute", async () => {
testPrefs.set("pttVk", 0x20);
const { setMuted } = await import("../../src/lib/livekitSession");
const mockSetMuted = vi.mocked(setMuted);
mockSetMuted.mockReset();
mockSetMuted.mockImplementation((muted: boolean) => {
mockLocalMuted = muted;
});
mockCurrentChannelId = 7;
let capturedCallback: ((event: { payload: boolean }) => void) | null = null;
const capturedHandlers: Record<string, (e: { payload: unknown }) => void> = {};
mockListen.mockImplementation((event: string, cb: (e: { payload: never }) => void) => {
capturedHandlers[event] = cb as (e: { payload: unknown }) => void;
if (event === "ptt-state")
capturedCallback = cb as unknown as (e: { payload: boolean }) => void;
return Promise.resolve(() => {});
});
await initPtt();
capturedCallback!({ payload: true });
await vi.waitFor(() => expect(mockSetMuted).toHaveBeenCalledWith(false));
capturedCallback!({ payload: false });
await vi.waitFor(() => expect(mockLocalMuted).toBe(true));
mockPttGated = true;
mockLocalDeafened = true; // deafened independently of the PTT-owned mute
mockSetMuted.mockClear();
capturedHandlers["ptt-error"]!({ payload: "PTT thread panicked" });
expect(mockSetPttGated).toHaveBeenCalledWith(false);
await new Promise((r) => setTimeout(r, 0));
expect(mockSetMuted).not.toHaveBeenCalled();
});
});