mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
Merge remote-tracking branch 'origin/main' into fix/settings-ekg-polish
This commit is contained in:
commit
4c2dfa57b4
138 files changed
+12046
-137
No files matched your search
@@ -1,8 +1,11 @@
|
||||
/**
|
||||
* Copy-matches-behaviour guard.
|
||||
*
|
||||
* Three classes of copy that drifted away from the code they describe, each
|
||||
* pinned here so the pair fails loudly if either half moves again:
|
||||
* Four classes of copy that must not drift away from the code they describe,
|
||||
* each pinned here so the pair fails loudly if either half moves. The first
|
||||
* three are repairs — copy that had already drifted. The fourth arrives with
|
||||
* the feature it governs, because a wording rule that depends on review is a
|
||||
* wording rule that survives until the fifth locale.
|
||||
*
|
||||
* 1. DELETE CONFIRMATIONS. A confirmation is the moment a user weighs the
|
||||
* consequence. Four dialogs promised permanence on surfaces that render
|
||||
@@ -224,3 +227,150 @@ describe("the glucose reference band control is translated everywhere", () => {
|
||||
expect(copy).toContain("never inferred");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 4. THE MOOD PROGNOSIS. The forecast is a comparison with somebody's own past
|
||||
* days and it may never be phrased as a cause. "A value around 5.4 would
|
||||
* have been expected" is what the arithmetic supports; "overtime lowered
|
||||
* your mood" is not, and the difference is not a matter of taste — the model
|
||||
* is a correlation over a dozen columns and a causal sentence would be a
|
||||
* claim it cannot make about somebody's health.
|
||||
*
|
||||
* The second half is the count. A forecast built from thirty days and one
|
||||
* built from three hundred render identically, so every statement that
|
||||
* quotes a value has to quote what it rests on. Both halves are a test here
|
||||
* rather than a review habit, because a review habit does not survive the
|
||||
* fifth locale.
|
||||
*/
|
||||
describe("the mood prognosis never states a cause", () => {
|
||||
/**
|
||||
* Causal constructions, per locale. Multi-word where a single word would
|
||||
* over-match — Italian `causa` is a noun in ordinary use and only `a causa
|
||||
* di` is the construction being banned.
|
||||
*/
|
||||
const CAUSAL_PHRASES: Record<string, readonly string[]> = {
|
||||
de: [
|
||||
"weil",
|
||||
"verursacht",
|
||||
"führt zu",
|
||||
"sorgt für",
|
||||
"wegen",
|
||||
"verbessert deine",
|
||||
"verschlechtert deine",
|
||||
"senkt deine",
|
||||
"hebt deine",
|
||||
],
|
||||
en: [
|
||||
"because",
|
||||
"causes",
|
||||
"caused",
|
||||
"leads to",
|
||||
"due to",
|
||||
"makes you",
|
||||
"lowers your",
|
||||
"raises your",
|
||||
"improves your",
|
||||
"worsens your",
|
||||
],
|
||||
es: [
|
||||
"porque",
|
||||
"provoca",
|
||||
"debido a",
|
||||
"a causa de",
|
||||
"mejora tu",
|
||||
"empeora tu",
|
||||
],
|
||||
fr: [
|
||||
"parce que",
|
||||
"provoque",
|
||||
"en raison de",
|
||||
"à cause de",
|
||||
"améliore ton",
|
||||
"dégrade ton",
|
||||
],
|
||||
it: [
|
||||
"perché",
|
||||
"provoca",
|
||||
"a causa di",
|
||||
"migliora il tuo",
|
||||
"peggiora il tuo",
|
||||
],
|
||||
pl: ["ponieważ", "powoduje", "z powodu", "wywołuje", "poprawia twój"],
|
||||
};
|
||||
|
||||
/** Every leaf string under `insights.mood.prognosis`, flattened. */
|
||||
function prognosisStrings(locale: string): Array<[string, string]> {
|
||||
const root = (
|
||||
bundle(locale).insights as Record<string, Record<string, unknown>>
|
||||
).mood.prognosis as Record<string, unknown>;
|
||||
expect(
|
||||
root,
|
||||
`${locale} carries no insights.mood.prognosis block`,
|
||||
).toBeTypeOf("object");
|
||||
return Object.entries(root).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === "string",
|
||||
);
|
||||
}
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
it(`${locale} carries the block, and every string in it`, () => {
|
||||
const strings = prognosisStrings(locale);
|
||||
// The positive control. Every assertion below passes on an empty list,
|
||||
// so the count is the evidence and the empty offender set is only the
|
||||
// verdict.
|
||||
expect(strings.length).toBeGreaterThanOrEqual(17);
|
||||
for (const [key, value] of strings) {
|
||||
expect(value.length, `${locale}/${key} is empty`).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it(`${locale} names no cause`, () => {
|
||||
const offenders = prognosisStrings(locale)
|
||||
.filter(([, value]) =>
|
||||
CAUSAL_PHRASES[locale].some((phrase) =>
|
||||
value.toLowerCase().includes(phrase),
|
||||
),
|
||||
)
|
||||
.map(([key]) => key);
|
||||
expect(
|
||||
offenders,
|
||||
`${locale} prognosis copy states a cause; the forecast is a comparison with the account's own past days and cannot support one`,
|
||||
).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Statements that quote a value must quote what it rests on. The list is
|
||||
* explicit rather than derived, so adding a statement key forces a decision
|
||||
* here instead of inheriting silence.
|
||||
*/
|
||||
const COUNTED_STATEMENTS: Record<string, readonly string[]> = {
|
||||
statement: ["{value}", "{n}"],
|
||||
learning: ["{entries}", "{threshold}"],
|
||||
noPattern: ["{entries}"],
|
||||
band: ["{low}", "{high}"],
|
||||
};
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
it(`${locale} keeps the count beside the claim`, () => {
|
||||
const strings = new Map(prognosisStrings(locale));
|
||||
for (const [key, placeholders] of Object.entries(COUNTED_STATEMENTS)) {
|
||||
const copy = strings.get(key);
|
||||
expect(copy, `${locale}/${key} is missing`).toBeTypeOf("string");
|
||||
for (const placeholder of placeholders) {
|
||||
expect(
|
||||
copy,
|
||||
`${locale}/${key} quotes a value without ${placeholder}; a forecast without the days behind it cannot be weighed by the reader`,
|
||||
).toContain(placeholder);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it("the forecast is phrased as a counterfactual, not as a measurement (EN + DE)", () => {
|
||||
const en = resolve(bundle("en"), "insights.mood.prognosis.statement");
|
||||
expect(en.toLowerCase()).toContain("would have been expected");
|
||||
const de = resolve(bundle("de"), "insights.mood.prognosis.statement");
|
||||
expect(de.toLowerCase()).toContain("zu erwarten gewesen");
|
||||
});
|
||||
});
|
||||
@@ -381,6 +381,26 @@ const REGISTRY: DestructiveEntry[] = [
|
||||
confirm: ["AlertDialog"],
|
||||
},
|
||||
|
||||
// ── Visits and the address book ─────────────────────────────────────────
|
||||
{
|
||||
file: "hooks/use-encounters.ts",
|
||||
destroys:
|
||||
"a visit with its reason, outcome and the links it collected — and, for a booked one, the appointment reminder that would have nudged about it",
|
||||
triggers: ["components/encounters/encounter-sheet.tsx"],
|
||||
// The route soft-deletes and a restore route exists, but nothing in the
|
||||
// product reaches it yet, so from a person's side the visit is gone. This
|
||||
// becomes "restorable" the day a surface offers the undo, not before.
|
||||
recovery: "tombstoned-no-restore",
|
||||
confirm: ["AlertDialog"],
|
||||
},
|
||||
{
|
||||
file: "hooks/use-practitioners.ts",
|
||||
destroys: "one address-book entry",
|
||||
triggers: ["components/practitioners/practitioner-list.tsx"],
|
||||
recovery: "tombstoned-no-restore",
|
||||
confirm: ["AlertDialog"],
|
||||
},
|
||||
|
||||
// ── Cycle ───────────────────────────────────────────────────────────────
|
||||
{
|
||||
file: "components/cycle/use-cycle.ts",
|
||||
|
||||
@@ -74,9 +74,9 @@ describe("report scope gating — structural guard", () => {
|
||||
it("reads a plausible catalogue", () => {
|
||||
// Sanity floor: a silently degraded catalogue would pass everything below
|
||||
// vacuously.
|
||||
expect(ALL_LEAF_IDS.length).toBe(91);
|
||||
expect(ALL_LEAF_IDS.length).toBe(92);
|
||||
expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(77);
|
||||
expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(14);
|
||||
expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(15);
|
||||
});
|
||||
|
||||
it("places every leaf in exactly one group", () => {
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* `MoodPrediction` has exactly one writer, and it is not a request.
|
||||
*
|
||||
* The product rule underneath this guard: a day carries two readings — the
|
||||
* one the person gave it and the one their own past days imply — and they are
|
||||
* never merged. The self-assessment leads and is stored exactly as it was
|
||||
* given; the prognosis is computed, carries a band and a case count, and the
|
||||
* user cannot overwrite it. A route that wrote `moodPrediction` would make the
|
||||
* second value editable, and an editable prognosis is not a prognosis: the
|
||||
* deviation between the two, which is the only thing either number is for,
|
||||
* would quietly become a number somebody could tune to zero.
|
||||
*
|
||||
* The second failure the single writer prevents is the one the cycle module
|
||||
* already paid for: a forecast written as a side effect of a page view makes
|
||||
* the output a function of browsing rather than of data
|
||||
* (`src/lib/cycle/prediction-cache.ts`, `src/lib/jobs/cycle-prediction-refresh.ts`).
|
||||
*
|
||||
* **Every matcher here asserts a non-zero match count.** Legs (a) and (b)
|
||||
* expect an EMPTY offender set, so "found nothing" and "nothing is wrong" look
|
||||
* identical from the outside — which is precisely how the bearer-scope guard
|
||||
* stayed green for months while matching nothing at all. Each leg therefore
|
||||
* pins a separate positive: the number of files SCANNED, and the write calls
|
||||
* the legitimate writer really does make. `describe("the matcher sees what it
|
||||
* looks for")` proves the regex against synthetic sources, including the
|
||||
* broken-across-lines form that defeated an earlier guard in this repository.
|
||||
*
|
||||
* **What this guard structurally cannot see, stated like the session-surface
|
||||
* guard states its own limit.** The matcher keys on a literal `moodPrediction`
|
||||
* delegate access. The "Delete All Data" wipe DOES delete this table — the
|
||||
* model is in `WIPE_MODELS` (`src/lib/data-wipe/wipe-plan.ts`) — but it does so
|
||||
* through a delegate resolved dynamically from the model name (`client[key]
|
||||
* .deleteMany`), so no `moodPrediction.deleteMany(` literal exists for the
|
||||
* matcher to catch, and that deletion is correctly NOT counted as a writer.
|
||||
* The wipe path's own completeness is proven elsewhere, by
|
||||
* `data-wipe-completeness.test.ts` enumerating the schema; this guard is only
|
||||
* the one about request-time writers, and it cannot be the gate on a dynamic
|
||||
* delegate. A future writer that reached the table through a computed property
|
||||
* would slip this matcher for the same reason.
|
||||
*/
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import { join, sep } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
/**
|
||||
* A write against the `moodPrediction` delegate, on any receiver.
|
||||
*
|
||||
* Whitespace-tolerant in every joint: Prettier breaks a long call across
|
||||
* lines, and a matcher demanding `prisma.moodPrediction.upsert(` on one line
|
||||
* is a matcher that stops matching the day the line gets long enough. The
|
||||
* receiver is left open (`prisma`, `tx`, `db`, the worker client) because
|
||||
* pinning one name would let a rename walk straight past.
|
||||
*/
|
||||
const WRITE_CALL =
|
||||
/\bmoodPrediction\s*\.\s*(createMany|createManyAndReturn|create|upsert|updateMany|update|deleteMany|delete)\s*\(/g;
|
||||
|
||||
/** The one file allowed to write, relative to `src/`. */
|
||||
const SOLE_WRITER = join("lib", "jobs", "mood-prognosis-refresh.ts");
|
||||
|
||||
function sourceFiles(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === "generated" || entry.name === "node_modules") continue;
|
||||
out.push(...sourceFiles(full));
|
||||
continue;
|
||||
}
|
||||
if (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function writeCallsIn(source: string): string[] {
|
||||
return [...source.matchAll(WRITE_CALL)].map((m) => m[1]);
|
||||
}
|
||||
|
||||
const ALL_FILES = sourceFiles(SRC);
|
||||
const PRODUCTION_FILES = ALL_FILES.filter(
|
||||
(f) => !f.includes(`${sep}__tests__${sep}`) && !f.endsWith(".test.ts"),
|
||||
);
|
||||
|
||||
describe("MoodPrediction is written by one file", () => {
|
||||
it("scans a plausible number of source files", () => {
|
||||
// The positive control for legs (a) and (b) below, both of which pass on
|
||||
// an empty result. If this collapses, they are asserting nothing.
|
||||
expect(ALL_FILES.length).toBeGreaterThan(1000);
|
||||
expect(PRODUCTION_FILES.length).toBeGreaterThan(800);
|
||||
});
|
||||
|
||||
it("(a) no route, page or component writes it", () => {
|
||||
const offenders = PRODUCTION_FILES.filter(
|
||||
(f) =>
|
||||
f.startsWith(join(SRC, "app")) || f.startsWith(join(SRC, "components")),
|
||||
).filter((f) => writeCallsIn(readFileSync(f, "utf8")).length > 0);
|
||||
|
||||
expect(
|
||||
offenders.map((f) => f.slice(SRC.length + 1)),
|
||||
"a request-time surface writes the prognosis — it is computed by the nightly job and the user cannot overwrite it",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("(b) the writer set is exactly the refresh job", () => {
|
||||
const writers = PRODUCTION_FILES.filter(
|
||||
(f) => writeCallsIn(readFileSync(f, "utf8")).length > 0,
|
||||
).map((f) => f.slice(SRC.length + 1));
|
||||
|
||||
expect(writers.sort()).toEqual([SOLE_WRITER]);
|
||||
});
|
||||
|
||||
it("(c) that writer really does write — the matcher is not matching nothing", () => {
|
||||
expect(
|
||||
existsSync(join(SRC, SOLE_WRITER)),
|
||||
`${SOLE_WRITER} does not exist — the table has a guard and no writer`,
|
||||
).toBe(true);
|
||||
const source = readFileSync(join(SRC, SOLE_WRITER), "utf8");
|
||||
const calls = writeCallsIn(source);
|
||||
expect(
|
||||
calls.length,
|
||||
"the sole writer makes no write call the matcher can see; leg (b) is then satisfied by an empty set rather than by the truth",
|
||||
).toBeGreaterThan(0);
|
||||
// It replaces rather than accumulates: a pass writes the current window
|
||||
// and clears whatever fell out of it.
|
||||
expect(calls).toContain("deleteMany");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the matcher sees what it looks for", () => {
|
||||
it("matches a call Prettier broke across lines", () => {
|
||||
const wrapped = [
|
||||
"await prisma.moodPrediction",
|
||||
" .upsert(",
|
||||
" { where: { userId_date: { userId, date } } },",
|
||||
" );",
|
||||
].join("\n");
|
||||
expect(writeCallsIn(wrapped)).toEqual(["upsert"]);
|
||||
});
|
||||
|
||||
it("matches through an aliased client", () => {
|
||||
expect(writeCallsIn("tx.moodPrediction.deleteMany({ where })")).toEqual([
|
||||
"deleteMany",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not match a read", () => {
|
||||
expect(
|
||||
writeCallsIn("const rows = await prisma.moodPrediction.findMany({})"),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not match a different model", () => {
|
||||
expect(writeCallsIn("prisma.moodEntry.create({ data })")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -44,12 +44,44 @@ describe("shared-record navigation", () => {
|
||||
it("keeps profile facts outside Settings and makes profile the scoped doorway", () => {
|
||||
const navigation = resolveSharedRecordNavigation(["profile"]);
|
||||
|
||||
expect(SHARED_RECORD_DOMAIN_ROUTE_FAMILIES.profile).toEqual(["/profile"]);
|
||||
expect(navigation.destinationHrefs).toEqual(["/profile"]);
|
||||
expect(SHARED_RECORD_DOMAIN_ROUTE_FAMILIES.profile).toEqual([
|
||||
"/profile",
|
||||
"/checkups",
|
||||
]);
|
||||
expect(navigation.destinationHrefs).toEqual(["/profile", "/checkups"]);
|
||||
expect(navigation.allowsPath("/profile")).toBe(true);
|
||||
expect(navigation.allowsPath("/settings/anamnesis")).toBe(false);
|
||||
});
|
||||
|
||||
it("presents the checkups page under both domains whose content it shows", () => {
|
||||
// The page holds two things: the preventive-care list, which is a
|
||||
// `measurements` read, and the visits section, which is `profile`. A grant
|
||||
// naming either one must be able to reach it; a grant naming neither must
|
||||
// not. Before the visits section the `measurements` omission was invisible,
|
||||
// because a whole-record grant admits every path regardless.
|
||||
expect(
|
||||
resolveSharedRecordNavigation(["measurements"]).allowsPath("/checkups"),
|
||||
).toBe(true);
|
||||
expect(
|
||||
resolveSharedRecordNavigation(["profile"]).allowsPath("/checkups"),
|
||||
).toBe(true);
|
||||
expect(resolveSharedRecordNavigation(null).allowsPath("/checkups")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
resolveSharedRecordNavigation(["labs"]).allowsPath("/checkups"),
|
||||
).toBe(false);
|
||||
|
||||
// Listed twice, offered once: a grant holding both domains must not put
|
||||
// two identical entries in the navigation.
|
||||
expect(
|
||||
resolveSharedRecordNavigation([
|
||||
"measurements",
|
||||
"profile",
|
||||
]).destinationHrefs.filter((href) => href === "/checkups"),
|
||||
).toEqual(["/checkups"]);
|
||||
});
|
||||
|
||||
it("uses the active record scope instead of the actor's module flags", () => {
|
||||
const actorModules = {
|
||||
medications: false,
|
||||
|
||||
@@ -891,6 +891,10 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
|
||||
domain: "record",
|
||||
why: "What the sleep, activity, vitals and illness modules already hold for one of the record's days, read-only. It is reached from a mood surface and it is NOT a mind read: the payload is sleep minutes, steps, active energy, resting heart rate, heart-rate variability and an illness day-log with its symptom keys. Declared `mind` it let a mood-only delegate walk arbitrary dates and read two sections the owner had declined to share, with no entry needing to exist for the date. Admitted on the whole-record grant only, like the other cross-section reads; a scoped grant is refused rather than served a filtered day.",
|
||||
},
|
||||
"app/api/mood/prognosis/route.ts": {
|
||||
domain: "record",
|
||||
why: "The day's two readings — the record's own rating and what its past days imply — read-only. It is NOT a mind read: the fit takes the day's context AND the sleep, activity and recovery figures the owning modules hold, and the payload names the features that carried weight, so a reader sees that sleep or steps or resting heart rate mattered on a given day. Declared `mind` it would hand a mood-only delegate a derived view of sections the owner declined to share. Admitted on the whole-record grant only, like the linked-day read beside it; a scoped grant is refused rather than served a filtered contribution list, which would falsely read as \"the model weighted only the mood fields\".",
|
||||
},
|
||||
"app/api/mood-entries/[id]/route.ts": {
|
||||
domain: "mind",
|
||||
why: "One mood entry of the record, fetch-then-guard against the resolved user.",
|
||||
@@ -935,6 +939,10 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
|
||||
domain: "profile",
|
||||
why: "One visit of the record, fetch-then-guard against the resolved user. The links it resolves are read through the link service, which narrows both ends to the same resolved id.",
|
||||
},
|
||||
"app/api/encounters/suggest/route.ts": {
|
||||
domain: "profile",
|
||||
why: "Which of the record's visits a document or lab panel dated around a given day belongs to. A read over the same rows the visit list serves, reduced to a verdict — the caller learns nothing about the record it could not learn from the list itself, and the anchor it passes is a date rather than an id, so it cannot address a row.",
|
||||
},
|
||||
"app/api/practitioners/route.ts": {
|
||||
domain: "profile",
|
||||
why: "The record's own address book of doctors and practices. Record content, not account configuration — it touches no credential, no integration and no notification channel, which is the fence the classification turns on. The create arm is a delegable write.",
|
||||
@@ -1860,9 +1868,13 @@ const ACTOR_ROUTES: Record<string, string> = {
|
||||
* managed-record configuration adds one more: 196 → 197. The bounded
|
||||
* read-only profile summary makes the next explicit admission: 197 → 198.
|
||||
* The two initial Cycle reads follow as separate, scoped admissions: 198 → 200.
|
||||
* The mood day's linked figures, read-only, add one: 200 → 201.
|
||||
* The mood day's linked figures, read-only, add one: 200 -> 201. The visit
|
||||
* record and its address book bring twelve: 201 -> 213. The mood prognosis,
|
||||
* read-only and admitted on the whole record for the same reason as the linked
|
||||
* figures, adds one: 213 -> 214. The visit-suggestion read, which serves the
|
||||
* three incidental-linking moments from the same rule, adds one: 214 -> 215.
|
||||
*/
|
||||
const FROZEN_ENTRY_COUNT = 213;
|
||||
const FROZEN_ENTRY_COUNT = 215;
|
||||
|
||||
/**
|
||||
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
|
||||
|
||||
Reference in new issue
Block a user