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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-15 00:39:34 +02:00
1 parent 0be1a6b8a7
commit 1b0c4b2b7d
1 file changed
+194
@@ -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.
*
* `<SleepQualitySection>` (sleep page) and `<RecoverySection>`
* (`/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. `<DeviceScoreTile>` carries the same gate plus a
* sparse-data `<LearningGate>` swap.
*/
// The mini chart is `next/dynamic`-imported; SSR renders the stub.
vi.mock("next/dynamic", () => ({
default: () => {
const Stub = () => <div data-slot="mini-chart-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(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">{node}</I18nProvider>
</QueryClientProvider>,
);
}
function summary(over: Partial<DataSummary>): 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<string, DataSummary>) {
return {
data: { summaries },
isLoading: false,
isEmpty: false,
error: null,
refetch: () => {},
};
}
beforeEach(() => {
analyticsMock.mockReset();
});
describe("<DeviceScoreTile> gating", () => {
it("renders nothing when the metric has no readings", () => {
const html = render(
<DeviceScoreTile
type="SLEEP_EFFICIENCY"
summary={summary({ count: 0 })}
title="Sleep efficiency"
icon={() => null}
color="#50fa7b"
unit="%"
/>,
);
expect(html).toBe("");
});
it("shows the latest reading + sparkline once it has enough data", () => {
const html = render(
<DeviceScoreTile
type="SLEEP_EFFICIENCY"
summary={summary({ count: 12, latest: 91, mean: 88 })}
title="Sleep efficiency"
icon={() => 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(
<DeviceScoreTile
type="DAY_STRAIN"
summary={summary({ count: 2, latest: 9, mean: 8 })}
title="Day strain"
icon={() => null}
color="#ffb86c"
/>,
);
expect(html).toContain('data-slot="device-score-learning"');
expect(html).not.toContain('data-slot="mini-chart-stub"');
});
});
describe("<SleepQualitySection> data-gating", () => {
it("collapses to nothing when no sleep-quality metric has data", () => {
analyticsMock.mockReturnValue(
analyticsWith({ SLEEP_DURATION: summary({ count: 30 }) }),
);
const html = render(<SleepQualitySection enabled />);
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(<SleepQualitySection enabled />);
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(<SleepQualitySection enabled={false} />);
expect(html).toBe("");
});
});
describe("<RecoverySection> 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(<RecoverySection />);
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(<RecoverySection />);
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"');
});
});