diff --git a/js/hang-ui/src/shared/components/stats/__tests__/Stats.test.tsx b/js/hang-ui/src/shared/components/stats/__tests__/Stats.test.tsx deleted file mode 100644 index a9269f3ac..000000000 --- a/js/hang-ui/src/shared/components/stats/__tests__/Stats.test.tsx +++ /dev/null @@ -1,225 +0,0 @@ -import { createContext, createSignal } from "solid-js"; -import { render } from "solid-js/web"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { Stats } from ".."; -import type { ProviderProps } from "../types"; -import { createMockProviderProps } from "./utils"; - -describe("Stats Component", () => { - let container: HTMLDivElement; - let dispose: (() => void) | undefined; - - beforeEach(() => { - container = document.createElement("div"); - document.body.appendChild(container); - - // Mock fetch to prevent network requests for Icon SVG files - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - text: () => Promise.resolve(''), - }); - }); - - afterEach(() => { - dispose?.(); - dispose = undefined; - vi.restoreAllMocks(); - }); - - it("renders stats container", () => { - const mockProps = createMockProviderProps(); - const TestContext = createContext(mockProps); - - dispose = render( - () => ( - - context={TestContext} getElement={() => mockProps} /> - - ), - container, - ); - - const stats = container.querySelector(".stats"); - expect(stats).toBeTruthy(); - }); - - it("waits for audio and video before rendering", async () => { - const mockDefault = createMockProviderProps(); - const TestContext = createContext(mockDefault); - - dispose = render(() => { - const [mediaElement, setMediaElement] = createSignal(undefined); - - // Set media element after initial render - setTimeout(() => setMediaElement(createMockProviderProps()), 100); - - return ( - - context={TestContext} getElement={() => mediaElement()} /> - - ); - }, container); - - // Initially no StatsPanel should be rendered - let panel = container.querySelector(".stats__panel"); - expect(panel).toBeFalsy(); - - // Wait for setTimeout to trigger - await new Promise((resolve) => setTimeout(resolve, 150)); - - // Now panel should be rendered - panel = container.querySelector(".stats__panel"); - expect(panel).toBeTruthy(); - }); - - it("only renders when both audio and video are available", async () => { - const mockDefault = createMockProviderProps(); - const TestContext = createContext(mockDefault); - const mockWithoutVideo = createMockProviderProps({ video: false }); - - dispose = render(() => { - const [mediaElement, setMediaElement] = createSignal | undefined>(mockWithoutVideo); - - // Set full props after initial render - setTimeout(() => setMediaElement(createMockProviderProps()), 100); - - return ( - - - context={TestContext} - getElement={() => mediaElement() as ProviderProps | undefined} - /> - - ); - }, container); - - let panel = container.querySelector(".stats__panel"); - - // Wait for setTimeout to trigger - await new Promise((resolve) => setTimeout(resolve, 150)); - - panel = container.querySelector(".stats__panel"); - expect(panel).toBeTruthy(); - }); - - it("works with different context types", () => { - interface CustomContext { - hangWatch: () => ProviderProps | undefined; - } - - const mockProps = createMockProviderProps(); - const contextValue: CustomContext = { - hangWatch: () => mockProps, - }; - const CustomTestContext = createContext(contextValue); - - dispose = render( - () => ( - - context={CustomTestContext} getElement={(ctx) => ctx?.hangWatch()} /> - - ), - container, - ); - - const stats = container.querySelector(".stats"); - expect(stats).toBeTruthy(); - - const panel = container.querySelector(".stats__panel"); - expect(panel).toBeTruthy(); - }); - - it("provides context value to child components", () => { - const mockProps = createMockProviderProps(); - const TestContext = createContext(mockProps); - - dispose = render( - () => ( - - context={TestContext} getElement={() => mockProps} /> - - ), - container, - ); - - const panel = container.querySelector(".stats__panel"); - expect(panel).toBeTruthy(); - }); - - it("handles undefined context gracefully", () => { - const mockDefault = createMockProviderProps(); - const TestContext = createContext(mockDefault); - - dispose = render( - () => ( - - context={TestContext} getElement={() => undefined} /> - - ), - container, - ); - - // Should not render panel when element is undefined - const panel = container.querySelector(".stats__panel"); - expect(panel).toBeFalsy(); - }); - - it("updates when context changes", async () => { - const mockDefault = createMockProviderProps(); - const TestContext = createContext(mockDefault); - - dispose = render(() => { - const [element, setElement] = createSignal(undefined); - - // Set element after initial render - setTimeout(() => setElement(createMockProviderProps()), 100); - - return ( - - context={TestContext} getElement={() => element()} /> - - ); - }, container); - - let panel = container.querySelector(".stats__panel"); - expect(panel).toBeFalsy(); - - // Wait for setTimeout to trigger - await new Promise((resolve) => setTimeout(resolve, 150)); - - panel = container.querySelector(".stats__panel"); - expect(panel).toBeTruthy(); - }); - - it("rerenders when getElement function returns different values", async () => { - const TestContext = createContext("test"); - const element1 = createMockProviderProps(); - const element2 = createMockProviderProps(); - - dispose = render(() => { - const [key, setKey] = createSignal<"element1" | "element2">("element1"); - - const getElement = (_ctx: string) => { - return key() === "element1" ? element1 : element2; - }; - - // Change key after initial render - setTimeout(() => setKey("element2"), 100); - - return ( - - context={TestContext} getElement={getElement} /> - - ); - }, container); - - let panel = container.querySelector(".stats__panel"); - expect(panel).toBeTruthy(); - - // Wait for setTimeout to trigger - await new Promise((resolve) => setTimeout(resolve, 150)); - - panel = container.querySelector(".stats__panel"); - expect(panel).toBeTruthy(); - }); -}); diff --git a/js/hang-ui/src/shared/components/stats/__tests__/components/StatsItem.test.tsx b/js/hang-ui/src/shared/components/stats/__tests__/components/StatsItem.test.tsx deleted file mode 100644 index e97d3ae3f..000000000 --- a/js/hang-ui/src/shared/components/stats/__tests__/components/StatsItem.test.tsx +++ /dev/null @@ -1,363 +0,0 @@ -import { createSignal } from "solid-js"; -import { render } from "solid-js/web"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import * as Icon from "../../../icon/icon"; -import { StatsItem } from "../../components/StatsItem"; -import type { BaseProvider } from "../../providers/base"; -import * as registry from "../../providers/registry"; -import type { ProviderContext, ProviderProps } from "../../types"; -import { createMockProviderProps } from "../utils"; - -vi.mock("../../providers/registry", () => ({ - getStatsInformationProvider: vi.fn(), -})); - -describe("StatsItem", () => { - let container: HTMLDivElement; - let dispose: (() => void) | undefined; - let mockAudioVideo: ProviderProps; - - beforeEach(() => { - container = document.createElement("div"); - document.body.appendChild(container); - mockAudioVideo = createMockProviderProps(); - - // Mock fetch to prevent network requests for Icon SVG files - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - text: () => Promise.resolve(''), - }); - }); - - afterEach(() => { - dispose?.(); - dispose = undefined; - document.body.removeChild(container); - vi.clearAllMocks(); - }); - - it("renders with correct base structure", () => { - vi.mocked(registry.getStatsInformationProvider).mockReturnValue(undefined); - - dispose = render( - () => ( - } - audio={mockAudioVideo.audio} - video={mockAudioVideo.video} - /> - ), - container, - ); - - const item = container.querySelector(".stats__item"); - expect(item).toBeTruthy(); - expect(item?.classList.contains("stats__item--network")).toBe(true); - }); - - it("renders icon wrapper with SVG content", () => { - vi.mocked(registry.getStatsInformationProvider).mockReturnValue(undefined); - - dispose = render(() => { - const testSvg = ( - - ); - - return ( - - ); - }, container); - - const iconWrapper = container.querySelector(".stats__icon-wrapper"); - expect(iconWrapper).toBeTruthy(); - - const icon = container.querySelector("svg[data-testid='test-icon']"); - expect(icon).toBeTruthy(); - }); - - it("renders item detail with icon text", () => { - vi.mocked(registry.getStatsInformationProvider).mockReturnValue(undefined); - - dispose = render( - () => ( - } - audio={mockAudioVideo.audio} - video={mockAudioVideo.video} - /> - ), - container, - ); - - const iconText = container.querySelector(".stats__item-title"); - expect(iconText?.textContent).toBe("audio"); - }); - - it("displays N/A when no provider is available", () => { - vi.mocked(registry.getStatsInformationProvider).mockReturnValue(undefined); - - dispose = render( - () => ( - } - audio={mockAudioVideo.audio} - video={mockAudioVideo.video} - /> - ), - container, - ); - - const dataDisplay = container.querySelector(".stats__item-data"); - expect(dataDisplay?.textContent).toBe("N/A"); - }); - - it("initializes provider with audio and video props", () => { - const mockProvider: Pick = { - setup: vi.fn(), - cleanup: vi.fn(), - }; - - const MockProviderClass = vi.fn(() => mockProvider) as unknown as ReturnType< - typeof registry.getStatsInformationProvider - >; - vi.mocked(registry.getStatsInformationProvider).mockReturnValue(MockProviderClass); - - dispose = render( - () => ( - } - audio={mockAudioVideo.audio} - video={mockAudioVideo.video} - /> - ), - container, - ); - - expect(MockProviderClass).toHaveBeenCalledWith(mockAudioVideo); - }); - - it("calls provider setup with setDisplayData callback", () => { - const mockProvider: Pick = { - setup: vi.fn(), - cleanup: vi.fn(), - }; - - const MockProviderClass = vi.fn(() => mockProvider) as unknown as ReturnType< - typeof registry.getStatsInformationProvider - >; - vi.mocked(registry.getStatsInformationProvider).mockReturnValue(MockProviderClass); - - dispose = render( - () => ( - } - audio={mockAudioVideo.audio} - video={mockAudioVideo.video} - /> - ), - container, - ); - - expect(mockProvider.setup).toHaveBeenCalled(); - - const setupCall = vi.mocked(mockProvider.setup).mock.calls[0][0] as ProviderContext; - expect(setupCall.setDisplayData).toBeDefined(); - expect(typeof setupCall.setDisplayData).toBe("function"); - }); - - it("updates display data when provider calls setDisplayData", () => { - let capturedSetDisplayData: ((data: string) => void) | undefined; - - const mockProvider: Pick = { - setup: vi.fn((context: ProviderContext) => { - capturedSetDisplayData = context.setDisplayData; - }), - cleanup: vi.fn(), - }; - - const MockProviderClass = vi.fn(() => mockProvider) as unknown as ReturnType< - typeof registry.getStatsInformationProvider - >; - vi.mocked(registry.getStatsInformationProvider).mockReturnValue(MockProviderClass); - - dispose = render( - () => ( - } - audio={mockAudioVideo.audio} - video={mockAudioVideo.video} - /> - ), - container, - ); - - expect(capturedSetDisplayData).toBeDefined(); - - capturedSetDisplayData?.("42 kbps"); - - const dataDisplay = container.querySelector(".stats__item-data"); - expect(dataDisplay?.textContent).toBe("42 kbps"); - }); - - it("renders correct class for each icon type", () => { - vi.mocked(registry.getStatsInformationProvider).mockReturnValue(undefined); - - const statsProvider = [ - { name: "network", Icon: Icon.Network }, - { name: "video", Icon: Icon.Video }, - { name: "audio", Icon: Icon.Audio }, - { name: "buffer", Icon: Icon.Buffer }, - ] as const; - - statsProvider.forEach((provider) => { - const testContainer = document.createElement("div"); - document.body.appendChild(testContainer); - - const testDispose = render( - () => ( - } - audio={mockAudioVideo.audio} - video={mockAudioVideo.video} - /> - ), - testContainer, - ); - - const item = testContainer.querySelector(".stats__item"); - expect(item?.classList.contains(`stats__item--${provider.name}`)).toBe(true); - - testDispose(); - document.body.removeChild(testContainer); - }); - }); - - it("cleans up previous provider before initializing new one", async () => { - const mockProvider1: Pick = { - setup: vi.fn(), - cleanup: vi.fn(), - }; - - const mockProvider2: Pick = { - setup: vi.fn(), - cleanup: vi.fn(), - }; - - const MockProviderClass1 = vi.fn(() => mockProvider1) as unknown as ReturnType< - typeof registry.getStatsInformationProvider - >; - const MockProviderClass2 = vi.fn(() => mockProvider2) as unknown as ReturnType< - typeof registry.getStatsInformationProvider - >; - - let _callCount = 0; - vi.mocked(registry.getStatsInformationProvider).mockImplementation(() => { - if (_callCount === 0) { - _callCount++; - return MockProviderClass1; - } - return MockProviderClass2; - }); - - dispose = render(() => { - const [statProvider, setStatProvider] = createSignal<"network" | "video">("network"); - - // Switch provider after initial render - setTimeout(() => { - _callCount = 0; - vi.mocked(registry.getStatsInformationProvider).mockReturnValue(MockProviderClass2); - setStatProvider("video"); - }, 0); - - return ( - : } - audio={mockAudioVideo.audio} - video={mockAudioVideo.video} - /> - ); - }, container); - - expect(mockProvider1.setup).toHaveBeenCalled(); - - // Wait for setTimeout - await new Promise((resolve) => setTimeout(resolve, 10)); - - expect(mockProvider1.cleanup).toHaveBeenCalled(); - expect(mockProvider2.setup).toHaveBeenCalled(); - }); - - it("maintains correct DOM hierarchy", () => { - vi.mocked(registry.getStatsInformationProvider).mockReturnValue(undefined); - - dispose = render( - () => ( - } - audio={mockAudioVideo.audio} - video={mockAudioVideo.video} - /> - ), - container, - ); - - const item = container.querySelector(".stats__item"); - expect(item?.children.length).toBe(2); - - const iconWrapper = item?.querySelector(".stats__icon-wrapper"); - expect(iconWrapper).toBeTruthy(); - expect(iconWrapper?.children.length).toBe(1); - - const detail = item?.querySelector(".stats__item-detail"); - expect(detail).toBeTruthy(); - expect(detail?.children.length).toBe(2); - - expect(detail?.querySelector(".stats__item-title")).toBeTruthy(); - expect(detail?.querySelector(".stats__item-data")).toBeTruthy(); - }); - - it("calls getStatsInformationProvider with correct statProvider", () => { - vi.mocked(registry.getStatsInformationProvider).mockReturnValue(undefined); - - dispose = render( - () => ( - } - audio={mockAudioVideo.audio} - video={mockAudioVideo.video} - /> - ), - container, - ); - - expect(registry.getStatsInformationProvider).toHaveBeenCalledWith("buffer"); - }); -}); diff --git a/js/hang-ui/src/shared/components/stats/__tests__/components/StatsPanel.test.tsx b/js/hang-ui/src/shared/components/stats/__tests__/components/StatsPanel.test.tsx deleted file mode 100644 index 09a44c71a..000000000 --- a/js/hang-ui/src/shared/components/stats/__tests__/components/StatsPanel.test.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { render } from "solid-js/web"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { StatsPanel, statsDetailItems } from "../../components/StatsPanel"; -import type { ProviderProps } from "../../types"; -import { createMockProviderProps } from "../utils"; - -describe("StatsPanel", () => { - let container: HTMLDivElement; - let dispose: (() => void) | undefined; - let mockAudioVideo: ProviderProps; - - beforeEach(() => { - container = document.createElement("div"); - document.body.appendChild(container); - mockAudioVideo = createMockProviderProps(); - - // Mock fetch to prevent network requests for Icon SVG files - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - text: () => Promise.resolve(''), - }); - }); - - afterEach(() => { - dispose?.(); - dispose = undefined; - document.body.removeChild(container); - vi.restoreAllMocks(); - }); - - it("renders with correct base class", () => { - dispose = render(() => , container); - - const panel = container.querySelector(".stats__panel"); - expect(panel).toBeTruthy(); - }); - - it("renders all metric items", () => { - dispose = render(() => , container); - - const items = container.querySelectorAll(".stats__item"); - expect(items.length).toBe(statsDetailItems.length); - }); - - it("renders items with correct icon types", () => { - const expectedIcons = ["network", "video", "audio", "buffer"]; - dispose = render(() => , container); - - const items = container.querySelectorAll(".stats__item"); - items.forEach((item, index) => { - expect(item.classList.contains(`stats__item--${expectedIcons[index]}`)).toBe(true); - }); - }); - - it("renders each item with icon wrapper", () => { - dispose = render(() => , container); - - const wrappers = container.querySelectorAll(".stats__icon-wrapper"); - expect(wrappers.length).toBe(4); - }); - - it("renders each item with detail section", () => { - dispose = render(() => , container); - - const details = container.querySelectorAll(".stats__item-detail"); - expect(details.length).toBe(4); - }); - - it("maintains correct DOM structure", () => { - dispose = render(() => , container); - - const panel = container.querySelector(".stats__panel"); - const items = panel?.querySelectorAll(".stats__item"); - - expect(panel?.children.length).toBe(4); - items?.forEach((item) => { - expect(item.querySelector(".stats__icon-wrapper")).toBeTruthy(); - expect(item.querySelector(".stats__item-detail")).toBeTruthy(); - }); - }); -}); diff --git a/js/hang-ui/src/shared/components/stats/__tests__/providers/audio.test.ts b/js/hang-ui/src/shared/components/stats/__tests__/providers/audio.test.ts deleted file mode 100644 index 081f542ef..000000000 --- a/js/hang-ui/src/shared/components/stats/__tests__/providers/audio.test.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { AudioProvider } from "../../providers/audio"; -import type { AudioConfig, AudioSource, AudioStats, ProviderContext, ProviderProps } from "../../types"; -import { createMockProviderProps } from "../utils"; - -declare global { - var __advanceTime: (ms: number) => void; -} - -describe("AudioProvider", () => { - let provider: AudioProvider; - let context: ProviderContext; - let setDisplayData: ReturnType; - let intervalCallback: ((interval: number) => void) | null = null; - let originalWindow: typeof window; - let originalPerformance: typeof performance; - let mockClearInterval: ReturnType; - - beforeEach(() => { - originalWindow = global.window; - originalPerformance = global.performance as unknown as Performance; - setDisplayData = vi.fn(); - context = { setDisplayData }; - intervalCallback = null; - - // Mock window functions - const mockSetInterval = vi.fn((callback: () => void) => { - intervalCallback = callback as unknown as (interval: number) => void; - return 1; - }); - mockClearInterval = vi.fn(); - - global.window = { - setInterval: mockSetInterval, - clearInterval: mockClearInterval, - } as unknown as typeof window; - - // Mock performance.now() - let mockTime = 0; - global.performance = { - now: vi.fn(() => mockTime), - } as unknown as Performance; - - // Helper to advance mock time - global.__advanceTime = (ms: number) => { - mockTime += ms; - }; - }); - - afterEach(() => { - provider?.cleanup(); - global.window = originalWindow; - global.performance = originalPerformance as unknown as Performance; - }); - - it("should display N/A when audio source is not available", () => { - const props: ProviderProps = {}; - provider = new AudioProvider(props); - provider.setup(context); - - expect(setDisplayData).toHaveBeenCalledWith("N/A"); - }); - - it("should display audio config with stats placeholder on first call", () => { - const audioConfig: AudioConfig = { - sampleRate: 48000, - numberOfChannels: 2, - bitrate: 128000, - codec: "opus", - }; - - const stats: AudioStats = { bytesReceived: 0 }; - - const audio: AudioSource = { - source: { - active: { - peek: () => "audio", - subscribe: vi.fn(() => vi.fn()), - }, - config: { - peek: () => audioConfig, - subscribe: vi.fn(() => vi.fn()), - }, - stats: { - peek: () => stats, - subscribe: vi.fn(() => vi.fn()), - }, - }, - }; - - const props: ProviderProps = { audio }; - provider = new AudioProvider(props); - provider.setup(context); - - expect(setDisplayData).toHaveBeenCalledWith("48.0kHz\n2ch\nN/A\nopus"); - }); - - it("should calculate bitrate from bytesReceived delta", () => { - const audioConfig: AudioConfig = { - sampleRate: 48000, - numberOfChannels: 2, - bitrate: 128000, - codec: "opus", - }; - - const peekFn = vi.fn(() => ({ bytesReceived: 0 }) as AudioStats); - - const audio: AudioSource = { - source: { - active: { - peek: () => "audio", - subscribe: vi.fn(() => vi.fn()), - }, - config: { - peek: () => audioConfig, - subscribe: vi.fn(() => vi.fn()), - }, - stats: { - peek: peekFn, - subscribe: vi.fn(() => vi.fn()), - }, - }, - }; - - const props: ProviderProps = { audio }; - provider = new AudioProvider(props); - - // First call - start with bytes already flowing - peekFn.mockReturnValue({ bytesReceived: 5000 } as AudioStats); - provider.setup(context); - expect(setDisplayData).toHaveBeenCalledWith("48.0kHz\n2ch\nN/A\nopus"); - - // Simulate bytesReceived increasing: 6250 bytes delta = 200 kbps at 250ms interval - // (6250 bytes * 8 bits/byte * 4) / 1000 = 200 kbps - peekFn.mockReturnValue({ bytesReceived: 11250 } as AudioStats); - global.__advanceTime(250); - intervalCallback?.(250); - expect(setDisplayData).toHaveBeenNthCalledWith(2, "48.0kHz\n2ch\n200kbps\nopus"); - - // Increase bytes more: delta 3125 = 100 kbps - peekFn.mockReturnValue({ bytesReceived: 14375 } as AudioStats); - global.__advanceTime(250); - intervalCallback?.(250); - expect(setDisplayData).toHaveBeenNthCalledWith(3, "48.0kHz\n2ch\n100kbps\nopus"); - }); - - it("should display N/A when active or config is missing", () => { - const audio: AudioSource = { - source: { - active: { - peek: () => undefined, - subscribe: vi.fn(() => vi.fn()), - }, - config: { - peek: () => undefined, - subscribe: vi.fn(() => vi.fn()), - }, - stats: { - peek: () => ({ bytesReceived: 0 }), - subscribe: vi.fn(() => vi.fn()), - }, - }, - }; - - const props: ProviderProps = { audio }; - provider = new AudioProvider(props); - provider.setup(context); - - expect(setDisplayData).toHaveBeenCalledWith("N/A"); - }); - - it("should handle mono audio", () => { - const audioConfig: AudioConfig = { - sampleRate: 44100, - numberOfChannels: 1, - bitrate: 128000, - codec: "opus", - }; - - const audio: AudioSource = { - source: { - active: { - peek: () => "audio", - subscribe: vi.fn(() => vi.fn()), - }, - config: { - peek: () => audioConfig, - subscribe: vi.fn(() => vi.fn()), - }, - stats: { - peek: () => ({ bytesReceived: 0 }), - subscribe: vi.fn(() => vi.fn()), - }, - }, - }; - - const props: ProviderProps = { audio }; - provider = new AudioProvider(props); - provider.setup(context); - - expect(setDisplayData).toHaveBeenCalledWith("44.1kHz\n1ch\nN/A\nopus"); - }); - - it("should format Mbps for high bitrates", () => { - const audioConfig: AudioConfig = { - sampleRate: 48000, - numberOfChannels: 2, - bitrate: 128000, - codec: "opus", - }; - - const peekFn = vi.fn(() => ({ bytesReceived: 0 }) as AudioStats); - - const audio: AudioSource = { - source: { - active: { - peek: () => "audio", - subscribe: vi.fn(() => vi.fn()), - }, - config: { - peek: () => audioConfig, - subscribe: vi.fn(() => vi.fn()), - }, - stats: { - peek: peekFn, - subscribe: vi.fn(() => vi.fn()), - }, - }, - }; - - const props: ProviderProps = { audio }; - provider = new AudioProvider(props); - - // First call - display audio config with stats placeholder on first call - peekFn.mockReturnValue({ bytesReceived: 48000 } as AudioStats); - provider.setup(context); - // Simulate 5 Mbps: 156250 bytes delta = 5000000 bits/s = 5 Mbps - peekFn.mockReturnValue({ bytesReceived: 204250 } as AudioStats); - global.__advanceTime(250); - intervalCallback?.(250); - - expect(setDisplayData).toHaveBeenNthCalledWith(2, "48.0kHz\n2ch\n5.0Mbps\nopus"); - }); - - it("should cleanup interval on dispose", () => { - const props: ProviderProps = createMockProviderProps({ video: false }); - provider = new AudioProvider(props); - provider.setup(context); - - provider.cleanup(); - - expect(mockClearInterval).toHaveBeenCalledTimes(1); - expect(mockClearInterval).toHaveBeenCalledWith(1); - }); -}); diff --git a/js/hang-ui/src/shared/components/stats/__tests__/providers/base.test.ts b/js/hang-ui/src/shared/components/stats/__tests__/providers/base.test.ts deleted file mode 100644 index c70a27f27..000000000 --- a/js/hang-ui/src/shared/components/stats/__tests__/providers/base.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { BaseProvider } from "../../providers/base"; -import type { ProviderContext, ProviderProps } from "../../types"; - -class TestProvider extends BaseProvider { - public setupCalled = false; - public setupContext: ProviderContext | undefined; - public cleanupCalled = false; - - setup(context: ProviderContext): void { - this.setupCalled = true; - this.setupContext = context; - } - - cleanup(): void { - this.cleanupCalled = true; - super.cleanup(); - } -} - -describe("BaseProvider", () => { - let provider: TestProvider; - let context: ProviderContext; - - beforeEach(() => { - const props: ProviderProps = {}; - provider = new TestProvider(props); - context = { - setDisplayData: vi.fn(), - }; - }); - - it("should initialize with props", () => { - const props: ProviderProps = { audio: undefined, video: undefined }; - const testProvider = new TestProvider(props); - expect(testProvider).toBeDefined(); - }); - - it("should call setup method", () => { - provider.setup(context); - expect(provider.setupCalled).toBe(true); - expect(provider.setupContext).toEqual(context); - }); - - it("should cleanup", () => { - provider.setup(context); - expect(provider.cleanupCalled).toBe(false); - provider.cleanup(); - expect(provider.cleanupCalled).toBe(true); - expect(provider.setupCalled).toBe(true); - }); - - it("should have setup and cleanup methods", () => { - expect(provider.setup).toBeDefined(); - expect(provider.cleanup).toBeDefined(); - expect(typeof provider.setup).toBe("function"); - expect(typeof provider.cleanup).toBe("function"); - }); -}); diff --git a/js/hang-ui/src/shared/components/stats/__tests__/providers/network.test.ts b/js/hang-ui/src/shared/components/stats/__tests__/providers/network.test.ts deleted file mode 100644 index 1aed84d59..000000000 --- a/js/hang-ui/src/shared/components/stats/__tests__/providers/network.test.ts +++ /dev/null @@ -1,309 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { NetworkProvider } from "../../providers/network"; -import type { ProviderContext, ProviderProps } from "../../types"; - -interface MockConnection { - effectiveType?: "slow-2g" | "2g" | "3g" | "4g"; - downlinkMax?: number; - downlink?: number; - rtt?: number; - saveData?: boolean; - addEventListener?: (type: string, listener: () => void) => void; - removeEventListener?: (type: string, listener: () => void) => void; -} - -const mockNavigator: { onLine: boolean; connection?: MockConnection } = { - onLine: true, - connection: undefined, -}; - -describe("NetworkProvider", () => { - let provider: NetworkProvider; - let context: ProviderContext; - let setDisplayData: ReturnType; - - beforeEach(() => { - setDisplayData = vi.fn(); - context = { setDisplayData }; - - Object.defineProperty(window, "navigator", { - value: mockNavigator, - writable: true, - configurable: true, - }); - - vi.useFakeTimers(); - }); - - afterEach(() => { - provider?.cleanup(); - vi.useRealTimers(); - vi.clearAllMocks(); - }); - - it("should display N/A initially when no connection info", () => { - const props: ProviderProps = {}; - provider = new NetworkProvider(props); - mockNavigator.connection = undefined; - mockNavigator.onLine = true; - - provider.setup(context); - expect(setDisplayData).toHaveBeenCalledWith("N/A"); - }); - - it("should display offline status when browser is offline", () => { - const props: ProviderProps = {}; - provider = new NetworkProvider(props); - mockNavigator.onLine = false; - mockNavigator.connection = { - effectiveType: "4g" as const, - }; - - provider.setup(context); - - expect(setDisplayData).toHaveBeenCalledWith("offline"); - }); - - it("should display effective connection type", () => { - const props: ProviderProps = {}; - provider = new NetworkProvider(props); - mockNavigator.onLine = true; - mockNavigator.connection = { - effectiveType: "4g" as const, - }; - - provider.setup(context); - - expect(setDisplayData).toHaveBeenCalledWith("4G"); - }); - - it("should map all effective connection types", () => { - const effectiveTypes: Array<["slow-2g", string] | ["2g", string] | ["3g", string] | ["4g", string]> = [ - ["slow-2g", "Slow-2G"], - ["2g", "2G"], - ["3g", "3G"], - ["4g", "4G"], - ]; - - for (const [type, expected] of effectiveTypes) { - setDisplayData.mockClear(); - const props: ProviderProps = {}; - provider = new NetworkProvider(props); - mockNavigator.onLine = true; - mockNavigator.connection = { effectiveType: type }; - - provider.setup(context); - - expect(setDisplayData).toHaveBeenCalledWith(expected); - provider.cleanup(); - } - }); - - it("should display bandwidth in Gbps", () => { - const props: ProviderProps = {}; - provider = new NetworkProvider(props); - mockNavigator.onLine = true; - mockNavigator.connection = { - effectiveType: "4g" as const, - downlink: 5000, - }; - - provider.setup(context); - - expect(setDisplayData).toHaveBeenCalledWith("4G\n5.0Gbps"); - }); - - it("should display bandwidth in Mbps", () => { - const props: ProviderProps = {}; - provider = new NetworkProvider(props); - mockNavigator.onLine = true; - mockNavigator.connection = { - effectiveType: "4g" as const, - downlink: 50, - }; - - provider.setup(context); - - expect(setDisplayData).toHaveBeenCalledWith("4G\n50.0Mbps"); - }); - - it("should display bandwidth in Kbps", () => { - const props: ProviderProps = {}; - provider = new NetworkProvider(props); - mockNavigator.onLine = true; - mockNavigator.connection = { - effectiveType: "3g" as const, - downlink: 0.5, - }; - - provider.setup(context); - - expect(setDisplayData).toHaveBeenCalledWith("3G\n500Kbps"); - }); - - it("should display latency in milliseconds", () => { - const props: ProviderProps = {}; - provider = new NetworkProvider(props); - mockNavigator.onLine = true; - mockNavigator.connection = { - effectiveType: "4g" as const, - rtt: 50, - }; - - provider.setup(context); - - expect(setDisplayData).toHaveBeenCalledWith("4G\n50ms"); - }); - - it("should display save-data status when enabled", () => { - const props: ProviderProps = {}; - provider = new NetworkProvider(props); - mockNavigator.onLine = true; - mockNavigator.connection = { - effectiveType: "4g" as const, - saveData: true, - }; - - provider.setup(context); - - expect(setDisplayData).toHaveBeenCalledWith("4G\nSave-Data"); - }); - - it("should combine all network metrics", () => { - const props: ProviderProps = {}; - provider = new NetworkProvider(props); - mockNavigator.onLine = true; - mockNavigator.connection = { - effectiveType: "4g" as const, - downlink: 50, - rtt: 45, - saveData: false, - }; - - provider.setup(context); - - expect(setDisplayData).toHaveBeenCalledWith("4G\n50.0Mbps\n45ms"); - }); - - it("should update display on online event", () => { - const props: ProviderProps = {}; - provider = new NetworkProvider(props); - mockNavigator.onLine = true; - mockNavigator.connection = { - effectiveType: "4g" as const, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - }; - - provider.setup(context); - expect(setDisplayData).toHaveBeenLastCalledWith("4G"); - - setDisplayData.mockClear(); - mockNavigator.onLine = false; - - window.dispatchEvent(new Event("offline")); - - expect(setDisplayData).toHaveBeenCalledWith("offline"); - }); - - it("should update display on offline event", () => { - const props: ProviderProps = {}; - provider = new NetworkProvider(props); - mockNavigator.onLine = true; - mockNavigator.connection = { effectiveType: "4g" as const }; - - provider.setup(context); - expect(setDisplayData).toHaveBeenLastCalledWith("4G"); - - setDisplayData.mockClear(); - mockNavigator.onLine = false; - - window.dispatchEvent(new Event("offline")); - - expect(setDisplayData).toHaveBeenCalledWith("offline"); - }); - - it("should ignore zero or negative bandwidth", () => { - const props: ProviderProps = {}; - provider = new NetworkProvider(props); - mockNavigator.onLine = true; - mockNavigator.connection = { - effectiveType: "4g" as const, - downlink: 0, - }; - - provider.setup(context); - - expect(setDisplayData).toHaveBeenCalledWith("4G"); - }); - - it("should ignore zero or negative latency", () => { - const props: ProviderProps = {}; - provider = new NetworkProvider(props); - mockNavigator.onLine = true; - mockNavigator.connection = { - effectiveType: "4g" as const, - rtt: 0, - }; - - provider.setup(context); - - expect(setDisplayData).toHaveBeenCalledWith("4G"); - }); - - it("should cleanup event listeners", () => { - const props: ProviderProps = {}; - provider = new NetworkProvider(props); - mockNavigator.onLine = true; - mockNavigator.connection = { effectiveType: "4g" as const }; - - provider.setup(context); - - const removeEventListenerSpy = vi.spyOn(window, "removeEventListener"); - provider.cleanup(); - - expect(removeEventListenerSpy).toHaveBeenCalledWith("online", expect.any(Function)); - expect(removeEventListenerSpy).toHaveBeenCalledWith("offline", expect.any(Function)); - }); - - it("should update periodically", () => { - const props: ProviderProps = {}; - provider = new NetworkProvider(props); - mockNavigator.onLine = true; - mockNavigator.connection = { - effectiveType: "4g" as const, - downlink: 50, - }; - - provider.setup(context); - setDisplayData.mockClear(); - - vi.advanceTimersByTime(100); - - expect(setDisplayData).toHaveBeenCalled(); - }); - - it("should prefer mozilla and webkit connection fallbacks", () => { - const props: ProviderProps = {}; - provider = new NetworkProvider(props); - mockNavigator.onLine = true; - - const mozConnection = { - effectiveType: "3g" as const, - }; - - Object.defineProperty(window, "navigator", { - value: { - onLine: true, - connection: undefined, - mozConnection, - }, - writable: true, - configurable: true, - }); - - provider.setup(context); - - expect(setDisplayData).toHaveBeenCalledWith("3G"); - }); -}); diff --git a/js/hang-ui/src/shared/components/stats/__tests__/providers/registry.test.ts b/js/hang-ui/src/shared/components/stats/__tests__/providers/registry.test.ts deleted file mode 100644 index 180c997f3..000000000 --- a/js/hang-ui/src/shared/components/stats/__tests__/providers/registry.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { AudioProvider } from "../../providers/audio"; -import { BufferProvider } from "../../providers/buffer"; -import { NetworkProvider } from "../../providers/network"; -import { getStatsInformationProvider, providers } from "../../providers/registry"; -import { VideoProvider } from "../../providers/video"; -import type { KnownStatsProviders } from "../../types"; - -describe("Registry", () => { - it("should have all required providers registered", () => { - const expectedProviders: KnownStatsProviders[] = ["network", "video", "audio", "buffer"]; - - for (const statProvider of expectedProviders) { - expect(providers[statProvider]).toBeDefined(); - } - }); - - it("should map video icon to VideoProvider", () => { - expect(providers.video).toBe(VideoProvider); - }); - - it("should map audio icon to AudioProvider", () => { - expect(providers.audio).toBe(AudioProvider); - }); - - it("should map buffer icon to BufferProvider", () => { - expect(providers.buffer).toBe(BufferProvider); - }); - - it("should map network icon to NetworkProvider", () => { - expect(providers.network).toBe(NetworkProvider); - }); - - it("should return correct provider class with getStatsInformationProvider", () => { - expect(getStatsInformationProvider("video")).toBe(VideoProvider); - expect(getStatsInformationProvider("audio")).toBe(AudioProvider); - expect(getStatsInformationProvider("buffer")).toBe(BufferProvider); - expect(getStatsInformationProvider("network")).toBe(NetworkProvider); - }); - - it("should return undefined for unknown icon", () => { - expect(getStatsInformationProvider("unknown" as KnownStatsProviders)).toBeUndefined(); - }); - - it("should instantiate providers correctly", () => { - const providersList: KnownStatsProviders[] = ["network", "video", "audio", "buffer"]; - - for (const statProvider of providersList) { - const ProviderClass = getStatsInformationProvider(statProvider); - expect(ProviderClass).toBeDefined(); - - if (ProviderClass) { - const instance = new ProviderClass({}); - expect(instance).toBeDefined(); - expect(instance.setup).toBeDefined(); - expect(instance.cleanup).toBeDefined(); - } - } - }); -}); diff --git a/js/hang-ui/src/shared/components/stats/__tests__/providers/video.test.ts b/js/hang-ui/src/shared/components/stats/__tests__/providers/video.test.ts deleted file mode 100644 index ed8252a68..000000000 --- a/js/hang-ui/src/shared/components/stats/__tests__/providers/video.test.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { VideoProvider } from "../../providers/video"; -import type { ProviderContext, ProviderProps, VideoSource, VideoStats } from "../../types"; -import { createMockProviderProps } from "../utils"; - -declare global { - var __advanceTime: (ms: number) => void; -} - -describe("VideoProvider", () => { - let provider: VideoProvider; - let context: ProviderContext; - let setDisplayData: ReturnType; - let intervalCallback: ((interval: number) => void) | null = null; - - beforeEach(() => { - setDisplayData = vi.fn(); - context = { setDisplayData }; - intervalCallback = null; - - // Mock window functions - const mockSetInterval = vi.fn((callback: (interval: number) => void) => { - intervalCallback = callback; - return 1 as unknown as NodeJS.Timeout; - }); - - const mockClearInterval = vi.fn(); - - global.window = { - setInterval: mockSetInterval, - clearInterval: mockClearInterval, - } as unknown as typeof window; - - // Mock performance.now() - let mockTime = 0; - global.performance = { - now: vi.fn(() => mockTime), - } as unknown as Performance; - - // Helper to advance mock time - global.__advanceTime = (ms: number) => { - mockTime += ms; - }; - }); - - afterEach(() => { - provider?.cleanup(); - }); - - it("should display N/A when video source is not available", () => { - const props: ProviderProps = {}; - provider = new VideoProvider(props); - provider.setup(context); - - expect(setDisplayData).toHaveBeenCalledWith("N/A"); - }); - - it("should setup interval for display updates", () => { - const mockProps = createMockProviderProps({ audio: false }); - const video = mockProps.video as VideoSource; - - const props: ProviderProps = { video }; - provider = new VideoProvider(props); - provider.setup(context); - - expect(setDisplayData).toHaveBeenCalled(); - }); - - it("should display video resolution with stats placeholder on first call", () => { - const mockProps = createMockProviderProps({ audio: false }); - const video = mockProps.video as VideoSource; - - const props: ProviderProps = { video }; - provider = new VideoProvider(props); - provider.setup(context); - - expect(setDisplayData).toHaveBeenCalledWith("1920x1080\nN/A\nN/A"); - }); - - it("should calculate FPS from frame count and timestamp delta", () => { - const peekFn = vi.fn(); - const mockProps = createMockProviderProps({ audio: false }); - const video = mockProps.video as VideoSource; - video.source.stats.peek = peekFn; - - const props: ProviderProps = { video }; - provider = new VideoProvider(props); - - // First call - use non-zero timestamp so next call can calculate FPS - peekFn.mockReturnValue({ frameCount: 100, timestamp: 1000000, bytesReceived: 50000 } as VideoStats); - provider.setup(context); - expect(setDisplayData).toHaveBeenCalledWith("1920x1080\nN/A\nN/A"); - - // Second call: 6 frames in 250ms at 24fps = exactly 24 frames per second - // frameCount delta = 106 - 100 = 6 - // timestamp delta = 250000 microseconds - // FPS = 6 / 0.25 = 24.0 fps - // bytesReceived delta = 100000 - 50000 = 50000 bytes - // bitrate = 50000 * 8 * 4 = 1600000 bits/s = 1.6Mbps - peekFn.mockReturnValue({ frameCount: 106, timestamp: 1250000, bytesReceived: 100000 } as VideoStats); - global.__advanceTime(250); - intervalCallback?.(250); - - expect(setDisplayData).toHaveBeenNthCalledWith(2, "1920x1080\n@24.0 fps\n1.6Mbps"); - }); - - it("should calculate bitrate from bytesReceived delta", () => { - const peekFn = vi.fn(); - const mockProps = createMockProviderProps({ audio: false }); - const video = mockProps.video as VideoSource; - video.source.display.peek = () => ({ width: 1280, height: 720 }); - video.source.stats.peek = peekFn; - - const props: ProviderProps = { video }; - provider = new VideoProvider(props); - - // First call - use non-zero initial values - peekFn.mockReturnValue({ frameCount: 0, timestamp: 1000000, bytesReceived: 100000 } as VideoStats); - provider.setup(context); - - // Second call: 5 Mbps = 156250 bytes delta at 250ms - // (156250 * 8 * 4) / 1_000_000 = 5.0 Mbps - peekFn.mockReturnValue({ frameCount: 6, timestamp: 1250000, bytesReceived: 256250 } as VideoStats); - global.__advanceTime(250); - intervalCallback?.(250); - - expect(setDisplayData).toHaveBeenNthCalledWith(2, "1280x720\n@24.0 fps\n5.0Mbps"); - }); - - it("should display N/A for FPS and bitrate on first call", () => { - const props: ProviderProps = {}; - provider = new VideoProvider(props); - provider.setup(context); - - expect(setDisplayData).toHaveBeenCalledWith("N/A"); - }); - - it("should display only resolution when stats are not available", () => { - const mockProps = createMockProviderProps({ audio: false }); - const video = mockProps.video as VideoSource; - video.source.display.peek = () => ({ width: 1280, height: 720 }); - video.source.stats.peek = () => undefined; - - const props: ProviderProps = { video }; - provider = new VideoProvider(props); - provider.setup(context); - expect(setDisplayData).toHaveBeenCalledWith("1280x720\nN/A\nN/A"); - }); - - it("should format kbps for lower bitrates", () => { - const peekFn = vi.fn(); - const mockProps = createMockProviderProps({ audio: false }); - const video = mockProps.video as VideoSource; - video.source.stats.peek = peekFn; - - const props: ProviderProps = { video }; - provider = new VideoProvider(props); - - // First call - use non-zero initial timestamp - peekFn.mockReturnValue({ frameCount: 0, timestamp: 1000000, bytesReceived: 100000 } as VideoStats); - provider.setup(context); - - // 256 kbps = 8000 bytes at 250ms - // (8000 * 8 * 4) / 1000 = 256 kbps - peekFn.mockReturnValue({ frameCount: 6, timestamp: 1250000, bytesReceived: 108000 } as VideoStats); - global.__advanceTime(250); - intervalCallback?.(250); - - expect(setDisplayData).toHaveBeenNthCalledWith(2, "1920x1080\n@24.0 fps\n256kbps"); - }); - - it("should cleanup interval on dispose", () => { - const props: ProviderProps = {}; - provider = new VideoProvider(props); - provider.setup(context); - - provider.cleanup(); - - expect(provider).toBeDefined(); - }); -}); diff --git a/js/hang-ui/src/shared/components/stats/__tests__/utils.ts b/js/hang-ui/src/shared/components/stats/__tests__/utils.ts deleted file mode 100644 index 1df4024a2..000000000 --- a/js/hang-ui/src/shared/components/stats/__tests__/utils.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { vi } from "vitest"; -import type { ProviderProps } from "../types"; - -export interface MockProviderPropsOptions { - audio?: boolean; - video?: boolean; -} - -/** - * Creates mock ProviderProps with configurable audio and video sources. - * Each signal includes a peek() method and a subscribe() method with vi.fn(). - * - * @param options - Configuration options - * @returns ProviderProps with mocked audio and/or video sources - * - * @example - * ```ts - * // Create mock with both audio and video - * const props = createMockProviderProps(); - * - * // Create mock with audio only - * const audioOnly = createMockProviderProps({ video: false }); - * - * // Create mock with video only - * const videoOnly = createMockProviderProps({ audio: false }); - * ``` - */ -export const createMockProviderProps = (options?: MockProviderPropsOptions): ProviderProps => { - const { audio = true, video = true } = options ?? {}; - - return { - ...(audio && { - audio: { - source: { - active: { - peek: () => "audio-active", - subscribe: vi.fn(() => vi.fn()), - changed: vi.fn(() => vi.fn()), - }, - config: { - peek: () => ({ - sampleRate: 48000, - numberOfChannels: 2, - bitrate: 128000, - codec: "opus", - }), - subscribe: vi.fn(() => vi.fn()), - changed: vi.fn(() => vi.fn()), - }, - stats: { - peek: () => ({ - bytesReceived: 1024, - }), - subscribe: vi.fn(() => vi.fn()), - changed: vi.fn(() => vi.fn()), - }, - }, - }, - }), - ...(video && { - video: { - source: { - display: { - peek: () => ({ - width: 1920, - height: 1080, - }), - subscribe: vi.fn(() => vi.fn()), - changed: vi.fn(() => vi.fn()), - }, - bufferStatus: { - peek: () => ({ state: "filled" as const }), - subscribe: vi.fn(() => vi.fn()), - changed: vi.fn(() => vi.fn()), - }, - stats: { - peek: () => ({ - frameCount: 60, - timestamp: Date.now(), - bytesReceived: 2048, - }), - subscribe: vi.fn(() => vi.fn()), - changed: vi.fn(() => vi.fn()), - }, - }, - }, - }), - }; -};