mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
test(coach): pin the workout launch's tenancy, closure and snapshot-once
Four suites, each mutation-checked against a reverted protection: - route-workout-scope: the workout row is read exactly once across a ten-turn conversation that keeps sending the id, the where clause always names userId, another user's id resolves to nothing and the chat proceeds, and the section sits inside the health-data fence. - workout-evidence: the numbers-only closure. An instruction-shaped sport_type — reachable through the restore path, which types the column as free text — folds to "other" and never reaches the block; a free-text leaf at any depth throws. - page-launch-params: an unknown ?scope= is ignored silently, and ?workout= yields to both ?c= and the fenced ?doc= path. - composite-score-anatomy-coach: every score sheet carries an opener, a scope narrowed to the composite's inputs, and auto-send. resolveLaunchState's shape assertion gains the workoutId field.
This commit is contained in:
1 parent
096c8c799b
commit
2bf450cf17
11 files changed
+829
-7
No files matched your search
@@ -640,7 +640,9 @@ export function CoachConversation({
|
||||
// brand-new conversation (first-turn attach).
|
||||
// Same first-turn gate as `scope` — see `initialWorkoutId`.
|
||||
const workoutId =
|
||||
currentConversationId === null ? (initialWorkoutId ?? undefined) : undefined;
|
||||
currentConversationId === null
|
||||
? (initialWorkoutId ?? undefined)
|
||||
: undefined;
|
||||
const wasFreshFenced = currentConversationId === null && fenced;
|
||||
const resolvedId = await send.send({
|
||||
conversationId: currentConversationId ?? undefined,
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
/**
|
||||
* v1.31.0 — the derived-score sheets' outbound edge.
|
||||
*
|
||||
* The score sheets were the one assessment surface with NO way out: they
|
||||
* mounted the same `<InsightStatusCard>` every metric page uses but passed
|
||||
* neither an opener nor a scope, so the card painted no action at all and the
|
||||
* sheet read one-way.
|
||||
*
|
||||
* A composite has no snapshot block of its own, so each sheet must hand off
|
||||
* narrowed to the INPUTS that drive it — a scope of `undefined` would open the
|
||||
* default all-source snapshot and lose the context the sheet is about.
|
||||
*
|
||||
* The status card is stubbed to a probe recording its props: assert the
|
||||
* resolved hand-off, never rendered text (responsive classes have broken
|
||||
* text-based queries in this repo).
|
||||
*/
|
||||
|
||||
const cardProps = vi.fn();
|
||||
vi.mock("@/components/insights/insight-status-card", () => ({
|
||||
InsightStatusCard: (props: Record<string, unknown>) => {
|
||||
cardProps(props);
|
||||
return <div data-slot="insight-assessment-probe" />;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/i18n/context", () => ({
|
||||
useTranslations: () => ({
|
||||
t: (key: string, vars?: Record<string, string>) =>
|
||||
vars ? `${key}:${Object.values(vars).join(",")}` : key,
|
||||
}),
|
||||
}));
|
||||
|
||||
const derived = { current: {} as Record<string, unknown> };
|
||||
vi.mock("../use-derived-metric", () => ({
|
||||
useDerivedMetric: () => derived.current,
|
||||
}));
|
||||
|
||||
vi.mock("../score-anatomy-view", () => ({
|
||||
ScoreAnatomyView: () => <div data-slot="score-anatomy-view-probe" />,
|
||||
}));
|
||||
|
||||
import { CompositeScoreAnatomy } from "../composite-score-anatomy";
|
||||
import type { AnatomyMetricId } from "../composite-score-anatomy";
|
||||
|
||||
const ALL_METRICS: AnatomyMetricId[] = [
|
||||
"SLEEP_SCORE",
|
||||
"READINESS",
|
||||
"RECOVERY_SCORE",
|
||||
"STRESS_SCORE",
|
||||
"STRAIN_SCORE",
|
||||
];
|
||||
|
||||
function renderSheet(metric: AnatomyMetricId): Record<string, unknown> {
|
||||
derived.current = {
|
||||
isLoading: false,
|
||||
data: {
|
||||
status: "ok",
|
||||
// Each metric reads a different value shape: SLEEP_SCORE decomposes into
|
||||
// `subScores`, READINESS / RECOVERY_SCORE into `components`, and
|
||||
// STRESS / STRAIN carry the ring alone.
|
||||
value: {
|
||||
score: 72,
|
||||
band: "good",
|
||||
subScores: [{ key: "duration", value: 70, weight: 0.5 }],
|
||||
components: [
|
||||
{ key: "hrv", value: 68, weight: 0.4 },
|
||||
{ key: "restingHr", value: 74, weight: 0.3 },
|
||||
],
|
||||
},
|
||||
coverage: 1,
|
||||
confidence: "high",
|
||||
provenance: {},
|
||||
assessment: {
|
||||
text: "Your recovery sat mid-range.",
|
||||
updatedAt: "2026-07-19T06:00:00.000Z",
|
||||
},
|
||||
},
|
||||
};
|
||||
cardProps.mockClear();
|
||||
renderToStaticMarkup(<CompositeScoreAnatomy metric={metric} />);
|
||||
const calls = cardProps.mock.calls;
|
||||
return calls[calls.length - 1][0] as Record<string, unknown>;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("derived-score sheets — every sheet has an outbound coach edge", () => {
|
||||
it.each(ALL_METRICS)(
|
||||
"%s carries an opener, a scope and auto-send",
|
||||
(metric) => {
|
||||
const props = renderSheet(metric);
|
||||
// The affordance only paints when an opener is supplied — an absent
|
||||
// question is precisely the one-way street this closes.
|
||||
expect(props.coachQuestion).toBeTruthy();
|
||||
// A composite must narrow to its inputs, never open unscoped.
|
||||
expect(props.coachScope).toBeTruthy();
|
||||
expect((props.coachScope as { metric: string }).metric).toBeTruthy();
|
||||
// Auto-send lands the answer directly rather than only seeding.
|
||||
expect(props.coachAutoSend).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("names the sheet's own title in the opener", () => {
|
||||
const props = renderSheet("STRAIN_SCORE");
|
||||
expect(String(props.coachQuestion)).toContain(
|
||||
"insights.coach.assessmentPrompt",
|
||||
);
|
||||
expect(String(props.coachQuestion)).toContain(
|
||||
"insights.derived.scores.strain",
|
||||
);
|
||||
});
|
||||
|
||||
it("anchors the sleep sheet on sleep and the strain sheet on workouts", () => {
|
||||
expect(
|
||||
(renderSheet("SLEEP_SCORE").coachScope as { metric: string }).metric,
|
||||
).toBe("sleep");
|
||||
expect(
|
||||
(renderSheet("STRAIN_SCORE").coachScope as { metric: string }).metric,
|
||||
).toBe("workouts");
|
||||
});
|
||||
|
||||
it("widens recovery-style sheets to the inputs that drive them", () => {
|
||||
// Recovery is a synthesis of HRV + resting HR + sleep; narrowing to one
|
||||
// of the three would hide the reason the score moved.
|
||||
const scope = renderSheet("RECOVERY_SCORE").coachScope as {
|
||||
metric: string;
|
||||
also?: string[];
|
||||
};
|
||||
expect(scope.metric).toBe("hrv");
|
||||
expect(scope.also).toEqual(expect.arrayContaining(["resting_hr", "sleep"]));
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user