diff --git a/src/components/insights/__tests__/chart-tooltip-observer.test.ts b/src/components/insights/__tests__/chart-tooltip-observer.test.ts new file mode 100644 index 000000000..4a1bd3793 --- /dev/null +++ b/src/components/insights/__tests__/chart-tooltip-observer.test.ts @@ -0,0 +1,70 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + __resetChartTooltipObserverForTests, + getChartTooltipActive, + getChartTooltipActiveServer, + subscribeChartTooltipActive, +} from "../chart-tooltip-observer"; + +/** + * v1.4.33 (F15) — chart-tooltip observer contract tests. + * + * The full DOM-side behaviour (MutationObserver watching `[class*= + * "recharts-tooltip-wrapper"]` visibility flips) lives behind a guard + * that no-ops when `document` is undefined — which is the case in the + * default vitest node environment. These tests pin the SSR-safe path + * and the subscribe/snapshot contract that `useSyncExternalStore` + * leans on. + * + * The integration with a real Recharts tooltip is exercised via the + * Playwright suite (mobile insight chart hover → FAB fades). + */ + +describe("chart-tooltip-observer", () => { + beforeEach(() => { + __resetChartTooltipObserverForTests(); + }); + + afterEach(() => { + __resetChartTooltipObserverForTests(); + }); + + it("getChartTooltipActiveServer always returns false", () => { + // The SSR snapshot must never claim a tooltip is active on the + // server — there's no DOM to inspect, and a stale `true` would + // render the FAB invisible on the initial pass. + expect(getChartTooltipActiveServer()).toBe(false); + }); + + it("getChartTooltipActive returns false when no wrappers exist", () => { + expect(getChartTooltipActive()).toBe(false); + }); + + it("subscribeChartTooltipActive returns an unsubscribe function", () => { + const unsubscribe = subscribeChartTooltipActive(() => undefined); + expect(typeof unsubscribe).toBe("function"); + unsubscribe(); + }); + + it("no-ops cleanly on a server-side render (no document available)", () => { + // Document is undefined in the vitest node environment — the + // module's `start()` early-returns. Subscribing should still + // succeed; the listener simply never fires. + let calls = 0; + const unsubscribe = subscribeChartTooltipActive(() => { + calls += 1; + }); + expect(getChartTooltipActive()).toBe(false); + expect(calls).toBe(0); + unsubscribe(); + }); + + it("supports multiple subscribers without throwing", () => { + const a = subscribeChartTooltipActive(() => undefined); + const b = subscribeChartTooltipActive(() => undefined); + expect(getChartTooltipActive()).toBe(false); + a(); + b(); + }); +}); diff --git a/src/components/insights/__tests__/layout-coach-fab.test.tsx b/src/components/insights/__tests__/layout-coach-fab.test.tsx new file mode 100644 index 000000000..b8ff1d012 --- /dev/null +++ b/src/components/insights/__tests__/layout-coach-fab.test.tsx @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context"; + +import { LayoutCoachFab } from "../layout-coach-fab"; + +/** + * v1.4.33 (F15) — layout-level Coach FAB contract. + * + * The FAB sits at the mobile bottom-right. Before v1.4.33 it overlaid + * Recharts tooltips when the user tapped a data point in the chart's + * lower-right corner — the maintainer flagged this as F15 in the + * runtime audit. The fix wires the FAB to `useChartTooltipActive()` + * which reads a singleton MutationObserver of every + * `.recharts-tooltip-wrapper`; while a wrapper is visible the FAB + * fades and drops `pointer-events`. + * + * Project convention is SSR-only tests (no `@testing-library/react`), + * so we pin the initial-paint state — no tooltip can be active before + * hydration, so the FAB renders without the active-flag attribute + * and stays interactive on first paint. The end-to-end auto-hide + * behaviour is exercised in the Playwright mobile suite. + */ + +function render(node: React.ReactNode, locale: "en" | "de" = "en") { + return renderToStaticMarkup( + {node}, + ); +} + +describe("", () => { + it("returns nothing when no is mounted", () => { + // The hook returns `null` outside the provider; the FAB then + // renders nothing rather than crashing — same posture as + // ``. + const html = render(); + expect(html).not.toContain('data-slot="coach-launch-fab"'); + }); + + it("mounts the FAB with the launch-fab slot under the provider", () => { + const html = render( + + + , + ); + expect(html).toMatch(/data-slot="coach-launch-fab"/); + }); + + it("renders without the chart-tooltip-active flag on first paint", () => { + // SSR snapshot of `useChartTooltipActive()` is `false` so the + // FAB stays interactive on initial render. The active classes + // (opacity-0 + pointer-events-none) are applied client-side only + // once a Recharts tooltip wrapper flips its inline visibility. + const html = render( + + + , + ); + // The data attribute is `undefined` when inactive — React drops + // it from the DOM so the rendered markup never carries it. + expect(html).not.toContain('data-chart-tooltip-active="true"'); + // The shadcn `