mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
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"');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user