mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
The tiered rollup read emits minValue/maxValue; readLiveDaily omitted them, so on a rollup-cold account or a coverage miss the chart silently dropped the min/max spread band for the same underlying data — contradicting the file's own byte-for-byte parity claim. The live aggregate now selects MIN/MAX alongside the average. useSendCoachMessage never aborted its in-flight reader on unmount. The drawer works around that through handleOpenChange, but the full-page /coach surface passes no registerReset, so navigating away mid-stream left the SSE loop alive: the provider kept generating and billing, and trailing setState fired on an unmounted component. Switching conversations reset every piece of local state except the send state, so the previous thread's last assistant or error bubble rendered appended to the newly selected thread until the next send. The two lifecycle invariants are pinned structurally, not behaviourally — the repo ships no renderHook. That is weaker, and the header says so; both defects were a missing call, which is what a structural guard does catch.
90 lines
3.6 KiB
TypeScript
90 lines
3.6 KiB
TypeScript
/**
|
|
* Structural guard for two Coach streaming-lifecycle invariants.
|
|
*
|
|
* These are STRUCTURAL assertions over the source text, not behavioural ones.
|
|
* The repo ships no `@testing-library/react` / `renderHook`, so an unmount
|
|
* cleanup and a tray-switch callback cannot be driven behaviourally here. A
|
|
* structural guard is weaker than a behavioural test — it proves the code is
|
|
* present, not that it works — but both defects were *deletions of a call*,
|
|
* which is exactly what a structural guard does catch. Matches the existing
|
|
* `*-guard.test.ts` precedent in this directory.
|
|
*
|
|
* Invariant 1 — `useSendCoachMessage` aborts its in-flight reader on unmount.
|
|
* Without it, the full-page `/coach` surface (which passes no `registerReset`,
|
|
* so the reset effect early-returns) left the SSE reader loop alive on a
|
|
* route change: the provider kept generating and billing, and trailing
|
|
* setState fired on an unmounted component. The drawer worked around this via
|
|
* `handleOpenChange`; route-change unmount had no exit at all.
|
|
*
|
|
* Invariant 2 — switching conversations resets the send state. Without it the
|
|
* previous thread's last assistant/error bubble rendered appended to the newly
|
|
* selected thread until the next send, because `streamingActive` stays true
|
|
* while that messageId is absent from the new thread's messages.
|
|
*/
|
|
import { readFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
const ROOT = join(__dirname, "..");
|
|
|
|
function read(relative: string): string {
|
|
return readFileSync(join(ROOT, relative), "utf8");
|
|
}
|
|
|
|
describe("Coach streaming lifecycle", () => {
|
|
it("useSendCoachMessage registers an unmount cleanup that aborts the reader", () => {
|
|
const source = read("components/insights/coach-panel/use-coach.ts");
|
|
|
|
// An effect with an empty dependency array whose cleanup aborts.
|
|
const hasUnmountAbort =
|
|
/useEffect\([\s\S]*?return\s*\(\)\s*=>\s*\{[^}]*abortRef\.current\?\.abort\(\)/.test(
|
|
source,
|
|
);
|
|
expect(
|
|
hasUnmountAbort,
|
|
"use-coach.ts must abort abortRef in an unmount cleanup effect",
|
|
).toBe(true);
|
|
|
|
// And it must be mount-scoped — a dependency-carrying effect would abort
|
|
// mid-stream on an unrelated re-render.
|
|
const effectWithDeps =
|
|
/return\s*\(\)\s*=>\s*\{\s*abortRef\.current\?\.abort\(\);\s*\};\s*\},\s*\[\]\s*\)/.test(
|
|
source,
|
|
);
|
|
expect(
|
|
effectWithDeps,
|
|
"the unmount abort effect must have an empty dependency array",
|
|
).toBe(true);
|
|
});
|
|
|
|
it("the conversation-switch handler resets the send state", () => {
|
|
const source = read(
|
|
"components/insights/coach-panel/coach-conversation.tsx",
|
|
);
|
|
|
|
// Isolate the HistoryRail onSelect callback body.
|
|
const match = source.match(
|
|
/<HistoryRail[\s\S]*?onSelect=\{\([\s\S]*?\n\s*\}\}/,
|
|
);
|
|
expect(match, "HistoryRail onSelect handler not found").not.toBeNull();
|
|
const onSelectBody = match![0];
|
|
|
|
expect(
|
|
onSelectBody.includes("send.reset()"),
|
|
"selecting another conversation must call send.reset() so the previous " +
|
|
"thread's trailing stream state does not bleed into the new thread",
|
|
).toBe(true);
|
|
});
|
|
|
|
it("handleNewChat still resets too — the two paths must not diverge again", () => {
|
|
const source = read(
|
|
"components/insights/coach-panel/coach-conversation.tsx",
|
|
);
|
|
expect(source).toContain("send.reset()");
|
|
// Both call sites present: new-chat and conversation-switch.
|
|
const resetCalls = source.match(/send\.reset\(\)/g) ?? [];
|
|
expect(resetCalls.length).toBeGreaterThanOrEqual(2);
|
|
});
|
|
});
|