import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; vi.mock("@lib/logger", () => ({ createLogger: () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), }), })); import { createConnectionStatsPoller, formatBytes, formatRate, formatBitrate, type ConnectionStatsPoller, type QualityLevel, } from "../../src/lib/connectionStats"; // --------------------------------------------------------------------------- // Formatting helpers // --------------------------------------------------------------------------- describe("formatBytes", () => { it("formats bytes below 1000", () => { expect(formatBytes(0)).toBe("0 B"); expect(formatBytes(512)).toBe("512 B"); expect(formatBytes(999)).toBe("999 B"); }); it("formats kilobytes", () => { expect(formatBytes(1000)).toBe("1.00 kB"); expect(formatBytes(1500)).toBe("1.50 kB"); expect(formatBytes(999_999)).toBe("1000.00 kB"); }); it("formats megabytes", () => { expect(formatBytes(1_000_000)).toBe("1.00 MB"); expect(formatBytes(5_432_100)).toBe("5.43 MB"); }); }); describe("formatRate", () => { it("appends /s to formatted bytes", () => { expect(formatRate(0)).toBe("0 B/s"); expect(formatRate(1500)).toBe("1.50 kB/s"); expect(formatRate(2_000_000)).toBe("2.00 MB/s"); }); }); describe("formatBitrate", () => { it("returns 0 Mbps for very low rates", () => { expect(formatBitrate(0)).toBe("0 Mbps"); // Less than 0.01 Mbps = 1250 bytes/s * 8 = 10000 bits = 0.01 Mbps expect(formatBitrate(1000)).toBe("0 Mbps"); }); it("returns Kbps for sub-1 Mbps rates", () => { // 0.05 Mbps = 6250 bytes/s expect(formatBitrate(6250)).toBe("50 Kbps"); // 0.5 Mbps = 62500 bytes/s expect(formatBitrate(62500)).toBe("500 Kbps"); }); it("returns Mbps for rates above 1 Mbps", () => { // 1 Mbps = 125000 bytes/s expect(formatBitrate(125_000)).toBe("1.0 Mbps"); // 10 Mbps = 1250000 bytes/s expect(formatBitrate(1_250_000)).toBe("10.0 Mbps"); }); }); // --------------------------------------------------------------------------- // Connection stats poller // --------------------------------------------------------------------------- describe("createConnectionStatsPoller", () => { let poller: ConnectionStatsPoller; beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { poller?.stop(); vi.useRealTimers(); }); it("returns EMPTY_STATS before any polling", () => { poller = createConnectionStatsPoller(() => null); const stats = poller.getStats(); expect(stats.rtt).toBe(0); expect(stats.quality).toBe("excellent"); expect(stats.outRate).toBe(0); expect(stats.inRate).toBe(0); expect(stats.outPackets).toBe(0); expect(stats.inPackets).toBe(0); expect(stats.totalUp).toBe(0); expect(stats.totalDown).toBe(0); }); it("does not poll when getRoom returns null", () => { const getRoom = vi.fn().mockReturnValue(null); poller = createConnectionStatsPoller(getRoom); poller.start(); vi.advanceTimersByTime(5000); // Stats should remain empty expect(poller.getStats().rtt).toBe(0); }); it("start is idempotent — calling start twice does not create double intervals", () => { const getRoom = vi.fn().mockReturnValue(null); poller = createConnectionStatsPoller(getRoom); poller.start(); poller.start(); getRoom.mockClear(); vi.advanceTimersByTime(2000); // A second interval would cause the poll callback to fire twice per tick. expect(getRoom).toHaveBeenCalledTimes(1); }); it("stop is idempotent — calling stop without start does not throw", () => { poller = createConnectionStatsPoller(() => null); expect(() => poller.stop()).not.toThrow(); }); it("stop resets stats to empty", () => { poller = createConnectionStatsPoller(() => null); poller.start(); poller.stop(); expect(poller.getStats().quality).toBe("excellent"); expect(poller.getStats().rtt).toBe(0); }); // --- onUpdate callback --- it("onUpdate adds a listener and returns an unsubscribe function", () => { poller = createConnectionStatsPoller(() => null); const cb = vi.fn(); const unsub = poller.onUpdate(cb); expect(typeof unsub).toBe("function"); unsub(); // After unsubscribe, callback should not be called during polling }); // --- onQualityChanged callback --- it("onQualityChanged adds a listener and returns an unsubscribe function", () => { poller = createConnectionStatsPoller(() => null); const cb = vi.fn(); const unsub = poller.onQualityChanged(cb); expect(typeof unsub).toBe("function"); unsub(); }); // --- Polling with mock room --- function createMockRoom(statsEntries: Array>): unknown { const report = new Map>(); for (const entry of statsEntries) { report.set(String(entry.id ?? Math.random()), entry); } // Make report.forEach work like RTCStatsReport return { engine: { pcManager: { publisher: { pc: { getStats: vi.fn().mockResolvedValue(report), }, }, subscriber: { pc: { getStats: vi.fn().mockResolvedValue(report), }, }, }, }, }; } it("extracts RTT from candidate-pair entries", async () => { const room = createMockRoom([ { id: "cp1", type: "candidate-pair", currentRoundTripTime: 0.05, // 50ms bytesSent: 1000, bytesReceived: 2000, }, ]); const cb = vi.fn(); poller = createConnectionStatsPoller(() => room as any); poller.onUpdate(cb); poller.start(); // Advance past one poll interval await vi.advanceTimersByTimeAsync(2100); expect(cb).toHaveBeenCalled(); const stats = cb.mock.calls[0]![0]; expect(stats.rtt).toBe(50); // 0.05 * 1000 expect(stats.quality).toBe("excellent"); }); it("classifies quality as fair for RTT 100-200ms", async () => { const room = createMockRoom([ { id: "cp1", type: "candidate-pair", currentRoundTripTime: 0.15, bytesSent: 0, bytesReceived: 0, }, ]); const cb = vi.fn(); poller = createConnectionStatsPoller(() => room as any); poller.onUpdate(cb); poller.start(); await vi.advanceTimersByTimeAsync(2100); const stats = cb.mock.calls[0]![0]; expect(stats.quality).toBe("fair"); }); it("classifies quality as poor for RTT 200-400ms", async () => { const room = createMockRoom([ { id: "cp1", type: "candidate-pair", currentRoundTripTime: 0.3, bytesSent: 0, bytesReceived: 0, }, ]); const cb = vi.fn(); poller = createConnectionStatsPoller(() => room as any); poller.onUpdate(cb); poller.start(); await vi.advanceTimersByTimeAsync(2100); const stats = cb.mock.calls[0]![0]; expect(stats.quality).toBe("poor"); }); it("classifies quality as bad for RTT >= 400ms", async () => { const room = createMockRoom([ { id: "cp1", type: "candidate-pair", currentRoundTripTime: 0.5, bytesSent: 0, bytesReceived: 0, }, ]); const cb = vi.fn(); poller = createConnectionStatsPoller(() => room as any); poller.onUpdate(cb); poller.start(); await vi.advanceTimersByTimeAsync(2100); const stats = cb.mock.calls[0]![0]; expect(stats.quality).toBe("bad"); }); it("extracts outbound-rtp and inbound-rtp packet counts", async () => { const room = createMockRoom([ { id: "cp1", type: "candidate-pair", currentRoundTripTime: 0.01, bytesSent: 100, bytesReceived: 200, }, { id: "out1", type: "outbound-rtp", packetsSent: 500, bytesSent: 40000 }, { id: "in1", type: "inbound-rtp", packetsReceived: 300, bytesReceived: 30000 }, ]); const cb = vi.fn(); poller = createConnectionStatsPoller(() => room as any); poller.onUpdate(cb); poller.start(); await vi.advanceTimersByTimeAsync(2100); const stats = cb.mock.calls[0]![0]; // outPackets and inPackets are accumulated from both publisher and subscriber PCs expect(stats.outPackets).toBeGreaterThanOrEqual(500); expect(stats.inPackets).toBeGreaterThanOrEqual(300); }); it("computes outRate and inRate between polls", async () => { const room = createMockRoom([ { id: "out1", type: "outbound-rtp", packetsSent: 100, bytesSent: 10000 }, { id: "in1", type: "inbound-rtp", packetsReceived: 50, bytesReceived: 5000 }, { id: "cp1", type: "candidate-pair", currentRoundTripTime: 0.01, bytesSent: 0, bytesReceived: 0, }, ]); const cb = vi.fn(); poller = createConnectionStatsPoller(() => room as any); poller.onUpdate(cb); poller.start(); // First poll establishes baseline await vi.advanceTimersByTimeAsync(2100); // Second poll computes rates await vi.advanceTimersByTimeAsync(2100); // Rates should be >= 0 (exact value depends on timing) const stats = cb.mock.calls[cb.mock.calls.length - 1]![0]; expect(stats.outRate).toBeGreaterThanOrEqual(0); expect(stats.inRate).toBeGreaterThanOrEqual(0); }); it("fires quality change callback with debounce", async () => { // The debounce timer for quality changes (3s) is perpetually reset by polls // (2s interval) while quality remains changed. The timer only fires when // polls stop finding data (room returns null). This matches real usage where // the quality callback fires after the connection stabilizes. let currentRtt = 0.01; let roomActive = true; const mockPc = { getStats: vi.fn().mockImplementation(() => { const report = new Map(); report.set("cp1", { type: "candidate-pair", currentRoundTripTime: currentRtt, bytesSent: 0, bytesReceived: 0, }); return Promise.resolve(report); }), }; const room = { engine: { pcManager: { publisher: { pc: mockPc } } }, }; const qualityCb = vi.fn(); poller = createConnectionStatsPoller(() => (roomActive ? (room as any) : null)); poller.onQualityChanged(qualityCb); poller.start(); // First poll (excellent) await vi.advanceTimersByTimeAsync(2100); expect(qualityCb).not.toHaveBeenCalled(); // Change to bad — poll detects quality change and starts debounce currentRtt = 0.5; await vi.advanceTimersByTimeAsync(2100); expect(qualityCb).not.toHaveBeenCalled(); // Make room unavailable so subsequent polls return early (no timer reset) roomActive = false; // Advance past the 3s debounce — timer fires because polls no longer reset it await vi.advanceTimersByTimeAsync(3100); expect(qualityCb).toHaveBeenCalledWith("bad", "excellent"); }); it("fires quality change callback once the debounce elapses, even though the room stays active and every 2s poll keeps re-reporting the changed quality (OC-0132)", async () => { // Regression test for OC-0132: POLL_INTERVAL_MS (2000) < QUALITY_DEBOUNCE_MS (3000) // means that while the room stays active and quality remains different from // lastQuality, every poll must NOT keep clearing/rescheduling the debounce // timer — otherwise the timer can never reach its 3s deadline and the // quality-change notification never fires. let currentRtt = 0.01; const room = { engine: { pcManager: { publisher: { pc: { getStats: vi.fn().mockImplementation(() => { const report = new Map(); report.set("cp1", { type: "candidate-pair", currentRoundTripTime: currentRtt, bytesSent: 0, bytesReceived: 0, }); return Promise.resolve(report); }), }, }, }, }, }; const qualityCb = vi.fn(); poller = createConnectionStatsPoller(() => room as any); poller.onQualityChanged(qualityCb); poller.start(); // t=2000: first poll establishes "excellent" baseline. await vi.advanceTimersByTimeAsync(2100); expect(qualityCb).not.toHaveBeenCalled(); // Degrade — the room stays active, so every subsequent 2s poll will keep // observing "bad" (different from lastQuality "excellent") for as long as // the connection stays degraded. currentRtt = 0.5; // t=4000: quality first observed as "bad" -> debounce timer armed for t=7000. await vi.advanceTimersByTimeAsync(2100); expect(qualityCb).not.toHaveBeenCalled(); // t=6000: quality is STILL "bad" (still != lastQuality). Under the bug this // poll clears the pending timer and reschedules a fresh 3s one, pushing the // deadline out indefinitely for as long as the connection stays bad. await vi.advanceTimersByTimeAsync(2100); expect(qualityCb).not.toHaveBeenCalled(); // t=8000: the original debounce deadline (t=7000) has now passed. The room // never went inactive and never stopped reporting "bad" in between, so the // only way this fires is if same-quality polls stopped resetting the timer. await vi.advanceTimersByTimeAsync(2100); expect(qualityCb).toHaveBeenCalledTimes(1); expect(qualityCb).toHaveBeenCalledWith("bad", "excellent"); }); it("unsubscribed onUpdate callback is not called", async () => { const room = createMockRoom([ { id: "cp1", type: "candidate-pair", currentRoundTripTime: 0.01, bytesSent: 0, bytesReceived: 0, }, ]); const cb = vi.fn(); poller = createConnectionStatsPoller(() => room as any); const unsub = poller.onUpdate(cb); unsub(); poller.start(); await vi.advanceTimersByTimeAsync(2100); expect(cb).not.toHaveBeenCalled(); }); it("unsubscribed onQualityChanged callback is not called", async () => { let currentRtt = 0.01; const room = { engine: { pcManager: { publisher: { pc: { getStats: vi.fn().mockImplementation(() => { const report = new Map(); report.set("cp1", { type: "candidate-pair", currentRoundTripTime: currentRtt, bytesSent: 0, bytesReceived: 0, }); return Promise.resolve(report); }), }, }, }, }, }; const cb = vi.fn(); poller = createConnectionStatsPoller(() => room as any); const unsub = poller.onQualityChanged(cb); unsub(); poller.start(); currentRtt = 0.5; // Switch to bad await vi.advanceTimersByTimeAsync(2100); await vi.advanceTimersByTimeAsync(3100); expect(cb).not.toHaveBeenCalled(); }); it("handles getStats failure gracefully (returns empty reports)", async () => { const room = { engine: { pcManager: { publisher: { pc: { getStats: vi.fn().mockRejectedValue(new Error("stats error")), }, }, }, }, }; const cb = vi.fn(); poller = createConnectionStatsPoller(() => room as any); poller.onUpdate(cb); poller.start(); await vi.advanceTimersByTimeAsync(2100); // On failure, collectAllStats returns [], so no update callback fires expect(cb).not.toHaveBeenCalled(); }); it("handles room with no pcManager", async () => { const room = { engine: {} }; const cb = vi.fn(); poller = createConnectionStatsPoller(() => room as any); poller.onUpdate(cb); poller.start(); await vi.advanceTimersByTimeAsync(2100); // No pcManager = no PCs = empty reports = no update expect(cb).not.toHaveBeenCalled(); }); it("handles room with only publisher PC", async () => { const report = new Map(); report.set("cp1", { type: "candidate-pair", currentRoundTripTime: 0.05, bytesSent: 1000, bytesReceived: 2000, }); const room = { engine: { pcManager: { publisher: { pc: { getStats: vi.fn().mockResolvedValue(report) }, }, // No subscriber }, }, }; const cb = vi.fn(); poller = createConnectionStatsPoller(() => room as any); poller.onUpdate(cb); poller.start(); await vi.advanceTimersByTimeAsync(2100); expect(cb).toHaveBeenCalled(); expect(cb.mock.calls[0]![0].rtt).toBe(50); }); it("handles room with only subscriber PC", async () => { const report = new Map(); report.set("cp1", { type: "candidate-pair", currentRoundTripTime: 0.12, bytesSent: 0, bytesReceived: 0, }); const room = { engine: { pcManager: { subscriber: { pc: { getStats: vi.fn().mockResolvedValue(report) }, }, }, }, }; const cb = vi.fn(); poller = createConnectionStatsPoller(() => room as any); poller.onUpdate(cb); poller.start(); await vi.advanceTimersByTimeAsync(2100); expect(cb).toHaveBeenCalled(); expect(cb.mock.calls[0]![0].rtt).toBe(120); expect(cb.mock.calls[0]![0].quality).toBe("fair"); }); it("ignores candidate-pair entries with non-numeric or zero RTT", async () => { const room = createMockRoom([ { id: "cp1", type: "candidate-pair", currentRoundTripTime: "bad", bytesSent: 0, bytesReceived: 0, }, { id: "cp2", type: "candidate-pair", currentRoundTripTime: 0, bytesSent: 0, bytesReceived: 0, }, ]); const cb = vi.fn(); poller = createConnectionStatsPoller(() => room as any); poller.onUpdate(cb); poller.start(); await vi.advanceTimersByTimeAsync(2100); expect(cb).toHaveBeenCalled(); expect(cb.mock.calls[0]![0].rtt).toBe(0); expect(cb.mock.calls[0]![0].quality).toBe("excellent"); // rtt 0 = excellent }); it("picks the lowest RTT when multiple candidate-pairs exist", async () => { const room = createMockRoom([ { id: "cp1", type: "candidate-pair", currentRoundTripTime: 0.3, bytesSent: 0, bytesReceived: 0, }, { id: "cp2", type: "candidate-pair", currentRoundTripTime: 0.05, bytesSent: 0, bytesReceived: 0, }, ]); const cb = vi.fn(); poller = createConnectionStatsPoller(() => room as any); poller.onUpdate(cb); poller.start(); await vi.advanceTimersByTimeAsync(2100); expect(cb.mock.calls[0]![0].rtt).toBe(50); }); it("clamps outRate and inRate to non-negative", async () => { // First poll with high bytes, second poll with low bytes (simulated reset) let callCount = 0; const room = { engine: { pcManager: { publisher: { pc: { getStats: vi.fn().mockImplementation(() => { callCount++; const report = new Map(); report.set("cp1", { type: "candidate-pair", currentRoundTripTime: 0.01, bytesSent: callCount === 1 ? 10000 : 5000, bytesReceived: callCount === 1 ? 10000 : 5000, }); // Bytes that feed outRate/inRate via outbound-rtp/inbound-rtp report.set("out1", { type: "outbound-rtp", packetsSent: 100, bytesSent: callCount === 1 ? 50000 : 20000, }); report.set("in1", { type: "inbound-rtp", packetsReceived: 100, bytesReceived: callCount === 1 ? 50000 : 20000, }); return Promise.resolve(report); }), }, }, }, }, }; const cb = vi.fn(); poller = createConnectionStatsPoller(() => room as any); poller.onUpdate(cb); poller.start(); await vi.advanceTimersByTimeAsync(2100); // First poll await vi.advanceTimersByTimeAsync(2100); // Second poll const lastStats = cb.mock.calls[cb.mock.calls.length - 1]![0]; // outRate uses Math.max(0, ...) so should be >= 0 expect(lastStats.outRate).toBeGreaterThanOrEqual(0); expect(lastStats.inRate).toBeGreaterThanOrEqual(0); }); it("quality change callback is not fired if quality reverts before debounce expires", async () => { let currentRtt = 0.01; const room = { engine: { pcManager: { publisher: { pc: { getStats: vi.fn().mockImplementation(() => { const report = new Map(); report.set("cp1", { type: "candidate-pair", currentRoundTripTime: currentRtt, bytesSent: 0, bytesReceived: 0, }); return Promise.resolve(report); }), }, }, }, }, }; const qualityCb = vi.fn(); poller = createConnectionStatsPoller(() => room as any); poller.onQualityChanged(qualityCb); poller.start(); // First poll (excellent) await vi.advanceTimersByTimeAsync(2100); // Change to bad currentRtt = 0.5; await vi.advanceTimersByTimeAsync(2100); // Revert to excellent before debounce currentRtt = 0.01; await vi.advanceTimersByTimeAsync(2100); // Now wait for debounce to expire await vi.advanceTimersByTimeAsync(3100); // Quality reverted to excellent before debounce fired, so callback should not fire expect(qualityCb).not.toHaveBeenCalled(); }); it("handles candidate-pair without bytesSent/bytesReceived", async () => { const room = createMockRoom([ { id: "cp1", type: "candidate-pair", currentRoundTripTime: 0.05 }, ]); const cb = vi.fn(); poller = createConnectionStatsPoller(() => room as any); poller.onUpdate(cb); poller.start(); await vi.advanceTimersByTimeAsync(2100); expect(cb).toHaveBeenCalled(); const stats = cb.mock.calls[0]![0]; expect(stats.totalUp).toBe(0); expect(stats.totalDown).toBe(0); }); it("handles outbound-rtp without packetsSent", async () => { const room = createMockRoom([ { id: "cp1", type: "candidate-pair", currentRoundTripTime: 0.01, bytesSent: 0, bytesReceived: 0, }, { id: "out1", type: "outbound-rtp", bytesSent: 100 }, ]); const cb = vi.fn(); poller = createConnectionStatsPoller(() => room as any); poller.onUpdate(cb); poller.start(); await vi.advanceTimersByTimeAsync(2100); expect(cb).toHaveBeenCalled(); expect(cb.mock.calls[0]![0].outPackets).toBe(0); }); it("handles inbound-rtp without packetsReceived", async () => { const room = createMockRoom([ { id: "cp1", type: "candidate-pair", currentRoundTripTime: 0.01, bytesSent: 0, bytesReceived: 0, }, { id: "in1", type: "inbound-rtp", bytesReceived: 100 }, ]); const cb = vi.fn(); poller = createConnectionStatsPoller(() => room as any); poller.onUpdate(cb); poller.start(); await vi.advanceTimersByTimeAsync(2100); expect(cb).toHaveBeenCalled(); expect(cb.mock.calls[0]![0].inPackets).toBe(0); }); });