test(ui): cover reentrant Talk meter cleanup

This commit is contained in:
Vincent Koc
2026-08-02 02:48:24 +08:00
parent 02eb798857
commit b9a84f49a9
3 changed files with 93 additions and 0 deletions

View File

@@ -95,6 +95,44 @@ describe("RealtimeTalkMediaStreamMeter", () => {
expect(close).toHaveBeenCalledOnce();
});
it("reclaims its interval when the initial level callback stops it", () => {
vi.useFakeTimers();
const close = vi.fn(async () => undefined);
const disconnectSource = vi.fn();
const disconnectAnalyser = vi.fn();
class MockAudioContext {
readonly close = close;
createMediaStreamSource() {
return { connect: vi.fn(), disconnect: disconnectSource };
}
createAnalyser() {
return {
fftSize: 0,
smoothingTimeConstant: 0,
disconnect: disconnectAnalyser,
getFloatTimeDomainData: (samples: Float32Array) => samples.fill(0.25),
};
}
}
vi.stubGlobal("AudioContext", MockAudioContext);
const onLevel = vi.fn((level: number) => {
if (level > 0) {
meter.stop();
}
});
const meter = new RealtimeTalkMediaStreamMeter(onLevel);
meter.start({} as MediaStream);
meter.stop();
meter.stop();
vi.advanceTimersByTime(1_000);
expect(vi.getTimerCount()).toBe(0);
expect(disconnectSource).toHaveBeenCalledOnce();
expect(disconnectAnalyser).toHaveBeenCalledOnce();
expect(close).toHaveBeenCalledOnce();
});
it("closes an owned AudioContext when analyser setup fails", () => {
const close = vi.fn(async () => undefined);
class MockAudioContext {

View File

@@ -614,6 +614,25 @@ describe("GatewayRelayRealtimeTalkTransport", () => {
expect(onInputLevel).toHaveBeenLastCalledWith(0);
});
it("reclaims the input meter when its first level update stops the transport", async () => {
vi.useFakeTimers();
const client = createClient();
const onInputLevel = vi.fn((level: number) => {
if (level > 0) {
transport.stop();
}
});
const transport = createTransport({ client, callbacks: { onInputLevel } });
await expect(transport.start()).resolves.toBe("ready");
transport.stop();
transport.stop();
vi.advanceTimersByTime(1_000);
expect(vi.getTimerCount()).toBe(0);
expect(requestCallsFor(client, "talk.session.close")).toHaveLength(1);
});
it("bounds stalled microphone appends and aborts every owner on stop", async () => {
const onStatus = vi.fn();
const client = createClient();

View File

@@ -223,6 +223,42 @@ describe("WebRtcSdpRealtimeTalkTransport", () => {
expect(close).toHaveBeenCalledOnce();
});
it("reclaims the input meter when its first level update stops the transport", async () => {
vi.useFakeTimers();
stubAnswerSdpFetch();
const close = vi.fn(async () => undefined);
class MockAudioContext {
readonly close = close;
createMediaStreamSource() {
return { connect: vi.fn(), disconnect: vi.fn() };
}
createAnalyser() {
return {
fftSize: 0,
smoothingTimeConstant: 0,
disconnect: vi.fn(),
getFloatTimeDomainData: (samples: Float32Array) => samples.fill(0.25),
};
}
}
vi.stubGlobal("AudioContext", MockAudioContext);
const onInputLevel = vi.fn((level: number) => {
if (level > 0) {
transport.stop();
}
});
const transport = createOpenAiTransport({}, { onInputLevel });
await expect(transport.start()).resolves.toBe("cancelled");
transport.stop();
transport.stop();
vi.advanceTimersByTime(1_000);
expect(vi.getTimerCount()).toBe(0);
expect(stopInputTrack).toHaveBeenCalledOnce();
expect(close).toHaveBeenCalledOnce();
});
it("does not continue WebRTC setup when stopped while microphone access is pending", async () => {
const fetchMock = vi.fn(async () => new Response("answer-sdp"));
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);