From 1b0c4b2b7d6d4188811907afe77e92dd41bcbc2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 15 Jun 2026 00:39:34 +0200 Subject: [PATCH] test(insights): pin the device-score data-gating across the new surfaces Cover the contract that matters for the vendor-native score tiles: a tile or section appears only when its metric has stored readings. Asserts the empty tile collapses to nothing, the sparse series swaps to the learning state, the Sleep quality block renders only the metrics with data, and the Recovery page shows its calm empty note with no data and groups + cross-links the score once present. --- .../__tests__/device-score-surfaces.test.tsx | 194 ++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 src/components/insights/__tests__/device-score-surfaces.test.tsx 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"'); + }); +});