diff --git a/src/components/insights/__tests__/device-score-surfaces.test.tsx b/src/components/insights/__tests__/device-score-surfaces.test.tsx new file mode 100644 index 000000000..e861f6bc1 --- /dev/null +++ b/src/components/insights/__tests__/device-score-surfaces.test.tsx @@ -0,0 +1,194 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import type { DataSummary } from "@/lib/analytics/trends"; + +/** + * v1.17.1 — the WHOOP / Oura / Polar device-native score surfaces. + * + * `` (sleep page) and `` + * (`/insights/recovery`) surface metrics that were ingested end-to-end but + * never rendered. The contract under test is the DATA-GATING: a tile / section + * appears ONLY when its metric has stored readings, so a non-wearable account + * never sees an empty card. `` carries the same gate plus a + * sparse-data `` swap. + */ + +// The mini chart is `next/dynamic`-imported; SSR renders the stub. +vi.mock("next/dynamic", () => ({ + default: () => { + const Stub = () =>
; + Stub.displayName = "MiniChartStub"; + return Stub; + }, +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { timezone: "UTC" }, + isAuthenticated: true, + }), +})); + +const analyticsMock = vi.fn(); +vi.mock("@/hooks/use-insights-analytics", () => ({ + useInsightsAnalytics: () => analyticsMock(), +})); + +import { DeviceScoreTile } from "../device-score-tile"; +import { SleepQualitySection } from "../sleep/sleep-quality-section"; +import { RecoverySection } from "../recovery/recovery-section"; + +function render(node: React.ReactNode) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + return renderToStaticMarkup( + + {node} + , + ); +} + +function summary(over: Partial): DataSummary { + return { + count: 0, + latest: null, + min: null, + max: null, + mean: null, + median: null, + avg7: null, + avg30: null, + slope7: null, + slope30: null, + slope90: null, + anomalyCount: 0, + ...over, + }; +} + +function analyticsWith(summaries: Record) { + return { + data: { summaries }, + isLoading: false, + isEmpty: false, + error: null, + refetch: () => {}, + }; +} + +beforeEach(() => { + analyticsMock.mockReset(); +}); + +describe(" gating", () => { + it("renders nothing when the metric has no readings", () => { + const html = render( + null} + color="#50fa7b" + unit="%" + />, + ); + expect(html).toBe(""); + }); + + it("shows the latest reading + sparkline once it has enough data", () => { + const html = render( + null} + color="#50fa7b" + unit="%" + />, + ); + expect(html).toContain('data-slot="device-score-tile"'); + expect(html).toContain('data-slot="device-score-latest"'); + expect(html).toContain("91"); + expect(html).toContain('data-slot="mini-chart-stub"'); + // No learning gate once past the threshold. + expect(html).not.toContain('data-slot="device-score-learning"'); + }); + + it("swaps the sparkline for the LearningGate while the series is sparse", () => { + const html = render( + null} + color="#ffb86c" + />, + ); + expect(html).toContain('data-slot="device-score-learning"'); + expect(html).not.toContain('data-slot="mini-chart-stub"'); + }); +}); + +describe(" data-gating", () => { + it("collapses to nothing when no sleep-quality metric has data", () => { + analyticsMock.mockReturnValue( + analyticsWith({ SLEEP_DURATION: summary({ count: 30 }) }), + ); + const html = render(); + expect(html).not.toContain('data-slot="sleep-quality-section"'); + }); + + it("renders only the metrics that have readings", () => { + analyticsMock.mockReturnValue( + analyticsWith({ + SLEEP_EFFICIENCY: summary({ count: 20, latest: 92, mean: 90 }), + SLEEP_SCORE: summary({ count: 0 }), + }), + ); + const html = render(); + expect(html).toContain('data-slot="sleep-quality-section"'); + expect(html).toContain('data-metric="SLEEP_EFFICIENCY"'); + expect(html).not.toContain('data-metric="SLEEP_SCORE"'); + }); + + it("renders nothing when disabled", () => { + analyticsMock.mockReturnValue( + analyticsWith({ SLEEP_EFFICIENCY: summary({ count: 20 }) }), + ); + const html = render(); + expect(html).toBe(""); + }); +}); + +describe(" data-gating", () => { + it("shows the calm empty note when there is no recovery data at all", () => { + analyticsMock.mockReturnValue( + analyticsWith({ SLEEP_DURATION: summary({ count: 10 }) }), + ); + const html = render(); + expect(html).toContain('data-slot="recovery-empty"'); + expect(html).not.toContain('data-slot="recovery-group-strain"'); + }); + + it("renders the strain group and cross-links the recovery score when present", () => { + analyticsMock.mockReturnValue( + analyticsWith({ + DAY_STRAIN: summary({ count: 14, latest: 12, mean: 11 }), + RECOVERY_SCORE: summary({ count: 14, latest: 66, mean: 60 }), + ANS_CHARGE: summary({ count: 0 }), + }), + ); + const html = render(); + expect(html).toContain('data-slot="recovery-group-strain"'); + expect(html).toContain('data-metric="DAY_STRAIN"'); + expect(html).toContain('data-slot="recovery-score-link"'); + // The recharge group (ANS charge only) has no data → hidden. + expect(html).not.toContain('data-slot="recovery-group-recharge"'); + // Empty note must not show when at least one signal is present. + expect(html).not.toContain('data-slot="recovery-empty"'); + }); +});