diff --git a/src/i18n/provider.test.tsx b/src/i18n/provider.test.tsx new file mode 100644 index 00000000..52e3cd32 --- /dev/null +++ b/src/i18n/provider.test.tsx @@ -0,0 +1,32 @@ +// @vitest-environment jsdom +import { renderHook } from "@testing-library/react"; +import type { ReactNode } from "react"; +import i18next from "i18next"; +import { initReactI18next } from "react-i18next"; +import { I18nProvider, useI18n, useTranslation } from "./provider.js"; + +let i18nInstance: typeof i18next; + +beforeAll(async () => { + i18nInstance = i18next.createInstance(); + await i18nInstance.use(initReactI18next).init({ + lng: "en", + resources: { en: { translation: { greeting: "hello" } } }, + }); +}); + +function wrapper({ children }: { children: ReactNode }) { + return {children}; +} + +describe("I18nProvider / useI18n / useTranslation", () => { + it("provides the i18n instance via useI18n()", () => { + const { result } = renderHook(() => useI18n(), { wrapper }); + expect(result.current.language).toBe("en"); + }); + + it("useTranslation returns working t function", () => { + const { result } = renderHook(() => useTranslation(), { wrapper }); + expect(result.current.t("greeting")).toBe("hello"); + }); +}); diff --git a/src/observability/analytics/loader.test.tsx b/src/observability/analytics/loader.test.tsx new file mode 100644 index 00000000..d03b34f8 --- /dev/null +++ b/src/observability/analytics/loader.test.tsx @@ -0,0 +1,49 @@ +// @vitest-environment jsdom +import { render, act } from "@testing-library/react"; +import { AnalyticsLoader } from "./loader.js"; +import type { Logger } from "@/observability/logger/types"; + +// Mock the facade so we don't load real adapters +vi.mock("./facade.js", () => ({ + createAnalytics: vi.fn(() => ({ track: vi.fn(), page: vi.fn() })), +})); + +const stubLogger: Logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + child: vi.fn(), +}; + +describe("AnalyticsLoader", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("renders children and initializes analytics via setTimeout fallback", async () => { + const { getByText } = render( + + child + , + ); + + expect(getByText("child")).toBeDefined(); + + // Fire the setTimeout(init, 1) callback + await act(async () => { + vi.advanceTimersByTime(10); + }); + + const { createAnalytics } = await import("./facade.js"); + expect(createAnalytics).toHaveBeenCalled(); + }); +}); diff --git a/src/observability/analytics/provider.test.tsx b/src/observability/analytics/provider.test.tsx new file mode 100644 index 00000000..c86af86f --- /dev/null +++ b/src/observability/analytics/provider.test.tsx @@ -0,0 +1,29 @@ +// @vitest-environment jsdom +import { renderHook } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { AnalyticsContext, useAnalytics } from "./provider.js"; + +describe("useAnalytics", () => { + it("returns NoopAnalytics when no provider is present", () => { + const { result } = renderHook(() => useAnalytics()); + // Should return the noop — calling track/page should not throw + expect(result.current).toBeDefined(); + expect(() => result.current.track("test")).not.toThrow(); + expect(() => result.current.page("/")).not.toThrow(); + }); + + it("returns the provided Analytics instance", () => { + const mockAnalytics = { track: vi.fn(), page: vi.fn() }; + + function wrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); + } + + const { result } = renderHook(() => useAnalytics(), { wrapper }); + expect(result.current).toBe(mockAnalytics); + }); +}); diff --git a/src/observability/logger/provider.test.tsx b/src/observability/logger/provider.test.tsx new file mode 100644 index 00000000..f5b88aa2 --- /dev/null +++ b/src/observability/logger/provider.test.tsx @@ -0,0 +1,30 @@ +// @vitest-environment jsdom +import { renderHook } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { LoggerProvider, useLogger } from "./provider.js"; +import type { Logger } from "./types.js"; + +const stubLogger: Logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + child: vi.fn(() => stubLogger), +}; + +function wrapper({ children }: { children: ReactNode }) { + return {children}; +} + +describe("LoggerProvider / useLogger", () => { + it("provides Logger to descendants", () => { + const { result } = renderHook(() => useLogger(), { wrapper }); + expect(result.current).toBe(stubLogger); + }); + + it("throws when used outside LoggerProvider", () => { + expect(() => renderHook(() => useLogger())).toThrow( + "useLogger() must be used within a ", + ); + }); +}); diff --git a/src/ui/errors/ErrorBoundary.test.tsx b/src/ui/errors/ErrorBoundary.test.tsx new file mode 100644 index 00000000..f3f6dd74 --- /dev/null +++ b/src/ui/errors/ErrorBoundary.test.tsx @@ -0,0 +1,71 @@ +// @vitest-environment jsdom +import { render, fireEvent } from "@testing-library/react"; +import { ErrorBoundary } from "./ErrorBoundary.js"; + +// Suppress React error boundary console.error noise in test output +const originalConsoleError = console.error; +beforeAll(() => { + console.error = vi.fn(); +}); +afterAll(() => { + console.error = originalConsoleError; +}); + +function ThrowingChild({ shouldThrow }: { shouldThrow: boolean }) { + if (shouldThrow) throw new Error("boom"); + return ok; +} + +describe("ErrorBoundary", () => { + it("renders children when no error", () => { + const { getByText } = render( + + hello + , + ); + expect(getByText("hello")).toBeDefined(); + }); + + it("shows default fallback with error message on throw", () => { + const { getByRole, getByText } = render( + + + , + ); + expect(getByRole("alert")).toBeDefined(); + expect(getByText("boom")).toBeDefined(); + expect(getByText("Retry")).toBeDefined(); + }); + + it("shows custom fallback when provided", () => { + const { getByText } = render( + custom error}> + + , + ); + expect(getByText("custom error")).toBeDefined(); + }); + + it("resets when Retry is clicked", () => { + // We need a component that can toggle throwing + let shouldThrow = true; + function Toggler() { + if (shouldThrow) throw new Error("fail"); + return recovered; + } + + const { getByText } = render( + + + , + ); + + expect(getByText("fail")).toBeDefined(); + + // Stop throwing before retry + shouldThrow = false; + fireEvent.click(getByText("Retry")); + + expect(getByText("recovered")).toBeDefined(); + }); +});