mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
test(backup): hold the record-shaped slices at their edges
The health score day, the intraday curve, a medication with its composition and custom category, an intake row and the cycle's own symptoms had fields and defaults no slice test met, added since the floor was set; the backup-validation score had drifted to 40.2 % under its 41 % floor. It is 54.1 % with these cases, and the floor follows to 53 %.
This commit is contained in:
1 parent
fa439601c4
commit
9a3140a4d3
2 files changed
+411
-1
No files matched your search
@@ -0,0 +1,410 @@
|
|||||||
|
/**
|
||||||
|
* The record-shaped backup slices, one rule at a time: the health score
|
||||||
|
* day, the intraday curve, a medication with its composition and custom
|
||||||
|
* category, an intake row, and the cycle's own symptoms.
|
||||||
|
*
|
||||||
|
* Like `backup-slices.test.ts`, each rule meets the value just inside and
|
||||||
|
* just outside it. The defaults are pinned as well: a file written before a
|
||||||
|
* field existed restores through them, so a default that drifted would
|
||||||
|
* change what an older file means without any parse failing.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { z } from "zod/v4";
|
||||||
|
|
||||||
|
import { customCycleSymptomSchema } from "@/lib/cycle/backup-schema";
|
||||||
|
import { healthScoreRecordBackupSchema } from "@/lib/export/health-score-backup-schema";
|
||||||
|
import { intradayProfileBackupSchema } from "@/lib/export/intraday-profile-backup-schema";
|
||||||
|
import {
|
||||||
|
customMedicationCategorySchema,
|
||||||
|
intakeEventSchema,
|
||||||
|
medicationIngredientSchema,
|
||||||
|
medicationSchema,
|
||||||
|
medicationSideEffectSchema,
|
||||||
|
} from "@/lib/export/medications-backup-schema";
|
||||||
|
|
||||||
|
/** The paths a value is refused on, or `[]` when it parses. */
|
||||||
|
function refused(schema: z.ZodType, value: unknown): string[] {
|
||||||
|
const result = schema.safeParse(value);
|
||||||
|
return result.success
|
||||||
|
? []
|
||||||
|
: result.error.issues.map((issue) => issue.path.join("."));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsed<T>(schema: z.ZodType<T>, value: unknown): T {
|
||||||
|
return schema.parse(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const AT = "2026-07-01T08:00:00.000Z";
|
||||||
|
const BAD_DAY_KEYS = [
|
||||||
|
"x2026-07-01",
|
||||||
|
"2026-07-01x",
|
||||||
|
"226-07-01",
|
||||||
|
"2026-7-01",
|
||||||
|
"2026-07-1",
|
||||||
|
"abcd-07-01",
|
||||||
|
"2026-ab-01",
|
||||||
|
"2026-07-ab",
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("the health score day", () => {
|
||||||
|
const day = {
|
||||||
|
id: "hs-1",
|
||||||
|
dayKey: "2026-07-01",
|
||||||
|
timezone: "Europe/Berlin",
|
||||||
|
composite: 72,
|
||||||
|
band: "green",
|
||||||
|
scoreVersion: 3,
|
||||||
|
composition: ["sleep", "activity"],
|
||||||
|
pillarScores: { sleep: 80 },
|
||||||
|
inputFingerprint: "a".repeat(64),
|
||||||
|
computedAt: AT,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("takes a well-formed day and defaults the config stamp to null", () => {
|
||||||
|
expect(refused(healthScoreRecordBackupSchema, day)).toEqual([]);
|
||||||
|
const out = parsed(healthScoreRecordBackupSchema, day) as {
|
||||||
|
configVersion: unknown;
|
||||||
|
configChangedAt: unknown;
|
||||||
|
};
|
||||||
|
expect(out.configVersion).toBeNull();
|
||||||
|
expect(out.configChangedAt).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wants an anchored YYYY-MM-DD day key", () => {
|
||||||
|
for (const dayKey of BAD_DAY_KEYS) {
|
||||||
|
expect(
|
||||||
|
refused(healthScoreRecordBackupSchema, { ...day, dayKey }),
|
||||||
|
dayKey,
|
||||||
|
).toEqual(["dayKey"]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an empty id and an empty zone", () => {
|
||||||
|
expect(refused(healthScoreRecordBackupSchema, { ...day, id: "" })).toEqual([
|
||||||
|
"id",
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
refused(healthScoreRecordBackupSchema, { ...day, timezone: "" }),
|
||||||
|
).toEqual(["timezone"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("holds the composite to 0 through 100", () => {
|
||||||
|
for (const composite of [0, 100]) {
|
||||||
|
expect(
|
||||||
|
refused(healthScoreRecordBackupSchema, { ...day, composite }),
|
||||||
|
).toEqual([]);
|
||||||
|
}
|
||||||
|
for (const composite of [-1, 101]) {
|
||||||
|
expect(
|
||||||
|
refused(healthScoreRecordBackupSchema, { ...day, composite }),
|
||||||
|
).toEqual(["composite"]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes every band the column allows and nothing else", () => {
|
||||||
|
for (const band of ["green", "yellow", "red"]) {
|
||||||
|
expect(refused(healthScoreRecordBackupSchema, { ...day, band })).toEqual(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
refused(healthScoreRecordBackupSchema, { ...day, band: "" }),
|
||||||
|
).toEqual(["band"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wants at least one named pillar in the composition", () => {
|
||||||
|
expect(
|
||||||
|
refused(healthScoreRecordBackupSchema, { ...day, composition: ["a"] }),
|
||||||
|
).toEqual([]);
|
||||||
|
expect(
|
||||||
|
refused(healthScoreRecordBackupSchema, { ...day, composition: [] }),
|
||||||
|
).toEqual(["composition"]);
|
||||||
|
expect(
|
||||||
|
refused(healthScoreRecordBackupSchema, {
|
||||||
|
...day,
|
||||||
|
composition: ["sleep", ""],
|
||||||
|
}),
|
||||||
|
).toEqual(["composition.1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wants a fingerprint of exactly 64 lowercase hex digits", () => {
|
||||||
|
for (const inputFingerprint of [
|
||||||
|
"a".repeat(63),
|
||||||
|
"a".repeat(65),
|
||||||
|
"g".repeat(64),
|
||||||
|
`x${"a".repeat(64)}`,
|
||||||
|
`${"a".repeat(64)}x`,
|
||||||
|
"a",
|
||||||
|
]) {
|
||||||
|
expect(
|
||||||
|
refused(healthScoreRecordBackupSchema, { ...day, inputFingerprint }),
|
||||||
|
inputFingerprint,
|
||||||
|
).toEqual(["inputFingerprint"]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the intraday curve", () => {
|
||||||
|
const curve = {
|
||||||
|
id: "ip-1",
|
||||||
|
type: "ACTIVITY_STEPS",
|
||||||
|
dateKey: "2026-07-01",
|
||||||
|
hourlyCumulative: [0, 120],
|
||||||
|
dayTotal: 120,
|
||||||
|
sampleCount: 2,
|
||||||
|
timezone: "UTC",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("reads a file from before the hours travelled as a fold of samples", () => {
|
||||||
|
const out = parsed(intradayProfileBackupSchema, curve) as {
|
||||||
|
hourlyIncrements: unknown;
|
||||||
|
basis: unknown;
|
||||||
|
bucketCount: unknown;
|
||||||
|
};
|
||||||
|
expect(out.hourlyIncrements).toEqual([]);
|
||||||
|
expect(out.basis).toBe("PER_SAMPLE");
|
||||||
|
expect(out.bucketCount).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes both bases and refuses another", () => {
|
||||||
|
for (const basis of ["PER_SAMPLE", "HOUR_BUCKETS"]) {
|
||||||
|
expect(refused(intradayProfileBackupSchema, { ...curve, basis })).toEqual(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
refused(intradayProfileBackupSchema, { ...curve, basis: "" }),
|
||||||
|
).toEqual(["basis"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wants an anchored day key, a zone and a non-empty id", () => {
|
||||||
|
for (const dateKey of BAD_DAY_KEYS) {
|
||||||
|
expect(
|
||||||
|
refused(intradayProfileBackupSchema, { ...curve, dateKey }),
|
||||||
|
dateKey,
|
||||||
|
).toEqual(["dateKey"]);
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
refused(intradayProfileBackupSchema, { ...curve, timezone: "" }),
|
||||||
|
).toEqual(["timezone"]);
|
||||||
|
expect(refused(intradayProfileBackupSchema, { ...curve, id: "" })).toEqual([
|
||||||
|
"id",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a custom medication category", () => {
|
||||||
|
const KEY = "custom:0f8fad5b-d9cb-469f-a165-70867728950e";
|
||||||
|
|
||||||
|
it("takes a readable label and defaults its order and state", () => {
|
||||||
|
const out = parsed(customMedicationCategorySchema, {
|
||||||
|
key: KEY,
|
||||||
|
label: " Supplements ",
|
||||||
|
}) as { label: unknown; sortOrder: unknown; isActive: unknown };
|
||||||
|
expect(out.label).toBe("Supplements");
|
||||||
|
expect(out.sortOrder).toBe(0);
|
||||||
|
expect(out.isActive).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wants the custom: prefix and a 36-character id, anchored", () => {
|
||||||
|
for (const key of [
|
||||||
|
KEY.slice(0, -1),
|
||||||
|
`${KEY}0`,
|
||||||
|
`x${KEY}`,
|
||||||
|
"custom:",
|
||||||
|
"custom:zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz",
|
||||||
|
]) {
|
||||||
|
expect(
|
||||||
|
refused(customMedicationCategorySchema, { key, label: "A" }),
|
||||||
|
key,
|
||||||
|
).toContain("key");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("holds the label to 1 through 40 characters after trimming", () => {
|
||||||
|
expect(
|
||||||
|
refused(customMedicationCategorySchema, {
|
||||||
|
key: KEY,
|
||||||
|
label: "x".repeat(40),
|
||||||
|
}),
|
||||||
|
).toEqual([]);
|
||||||
|
expect(
|
||||||
|
refused(customMedicationCategorySchema, {
|
||||||
|
key: KEY,
|
||||||
|
label: "x".repeat(41),
|
||||||
|
}),
|
||||||
|
).toEqual(["label"]);
|
||||||
|
// Blank after trimming: the length rule and the label-or-ciphertext rule
|
||||||
|
// both refuse it.
|
||||||
|
expect(
|
||||||
|
refused(customMedicationCategorySchema, { key: KEY, label: " " }),
|
||||||
|
).toEqual(["label", "label"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a negative order", () => {
|
||||||
|
expect(
|
||||||
|
refused(customMedicationCategorySchema, {
|
||||||
|
key: KEY,
|
||||||
|
label: "A",
|
||||||
|
sortOrder: -1,
|
||||||
|
}),
|
||||||
|
).toEqual(["sortOrder"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("needs its label or its ciphertext, and names the label when both are missing", () => {
|
||||||
|
expect(
|
||||||
|
refused(customMedicationCategorySchema, {
|
||||||
|
key: KEY,
|
||||||
|
labelEncrypted: "QUJDRA==",
|
||||||
|
}),
|
||||||
|
).toEqual([]);
|
||||||
|
expect(
|
||||||
|
refused(customMedicationCategorySchema, {
|
||||||
|
key: KEY,
|
||||||
|
label: null,
|
||||||
|
labelEncrypted: "QUJDRA==",
|
||||||
|
}),
|
||||||
|
).toEqual([]);
|
||||||
|
const result = customMedicationCategorySchema.safeParse({ key: KEY });
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error?.issues).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
path: ["label"],
|
||||||
|
message: "a custom category needs its label or its ciphertext",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
refused(customMedicationCategorySchema, { key: KEY, label: null }),
|
||||||
|
).toEqual(["label"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a medication", () => {
|
||||||
|
it("restores a file from before its parts travelled with every part empty", () => {
|
||||||
|
const out = parsed(medicationSchema, { name: "Ramipril", dose: "5 mg" });
|
||||||
|
expect(out).toMatchObject({
|
||||||
|
schedules: [],
|
||||||
|
sideEffects: [],
|
||||||
|
pauseEras: [],
|
||||||
|
ingredients: [],
|
||||||
|
doseChanges: [],
|
||||||
|
inventoryItems: [],
|
||||||
|
inventoryEvents: [],
|
||||||
|
efficacyTargets: [],
|
||||||
|
scheduleRevisions: [],
|
||||||
|
});
|
||||||
|
expect((out as { courses?: unknown }).courses).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an empty name, id or ref and a category past 64 characters", () => {
|
||||||
|
const base = { name: "Ramipril", dose: "5 mg" };
|
||||||
|
expect(refused(medicationSchema, { ...base, name: "" })).toEqual(["name"]);
|
||||||
|
expect(refused(medicationSchema, { ...base, id: "" })).toEqual(["id"]);
|
||||||
|
expect(refused(medicationSchema, { ...base, ref: "" })).toEqual(["ref"]);
|
||||||
|
expect(
|
||||||
|
refused(medicationSchema, { ...base, category: "x".repeat(64) }),
|
||||||
|
).toEqual([]);
|
||||||
|
expect(
|
||||||
|
refused(medicationSchema, { ...base, category: "x".repeat(65) }),
|
||||||
|
).toEqual(["category"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the side effect severity outside 1 through 5", () => {
|
||||||
|
const effect = {
|
||||||
|
occurredAt: AT,
|
||||||
|
category: medicationSideEffectSchema.shape.category.options[0],
|
||||||
|
entry: medicationSideEffectSchema.shape.entry.options[0],
|
||||||
|
};
|
||||||
|
for (const severity of [1, 5]) {
|
||||||
|
expect(
|
||||||
|
refused(medicationSideEffectSchema, { ...effect, severity }),
|
||||||
|
).toEqual([]);
|
||||||
|
}
|
||||||
|
for (const severity of [0, 6]) {
|
||||||
|
expect(
|
||||||
|
refused(medicationSideEffectSchema, { ...effect, severity }),
|
||||||
|
).toEqual(["severity"]);
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
refused(medicationSideEffectSchema, { ...effect, severity: 3, id: "" }),
|
||||||
|
).toEqual(["id"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a composition ingredient", () => {
|
||||||
|
const ingredient = {
|
||||||
|
nutrient: "vitamin_d",
|
||||||
|
amountPerUnit: 20,
|
||||||
|
unit: "µg",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("defaults its source to a hand entry and takes the label reader", () => {
|
||||||
|
expect(
|
||||||
|
(parsed(medicationIngredientSchema, ingredient) as { source: unknown })
|
||||||
|
.source,
|
||||||
|
).toBe("MANUAL");
|
||||||
|
expect(
|
||||||
|
refused(medicationIngredientSchema, {
|
||||||
|
...ingredient,
|
||||||
|
source: "LABEL_AI",
|
||||||
|
}),
|
||||||
|
).toEqual([]);
|
||||||
|
expect(
|
||||||
|
refused(medicationIngredientSchema, { ...ingredient, source: "" }),
|
||||||
|
).toEqual(["source"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("holds the nutrient to 1 through 40 and the units to 8 characters", () => {
|
||||||
|
const at = (patch: Record<string, unknown>) =>
|
||||||
|
refused(medicationIngredientSchema, { ...ingredient, ...patch });
|
||||||
|
expect(at({ nutrient: "x".repeat(40) })).toEqual([]);
|
||||||
|
expect(at({ nutrient: "x".repeat(41) })).toEqual(["nutrient"]);
|
||||||
|
expect(at({ nutrient: "" })).toEqual(["nutrient"]);
|
||||||
|
expect(at({ unit: "x".repeat(8) })).toEqual([]);
|
||||||
|
expect(at({ unit: "x".repeat(9) })).toEqual(["unit"]);
|
||||||
|
expect(at({ unit: "" })).toEqual(["unit"]);
|
||||||
|
expect(at({ enteredUnit: "x".repeat(8) })).toEqual([]);
|
||||||
|
expect(at({ enteredUnit: "x".repeat(9) })).toEqual(["enteredUnit"]);
|
||||||
|
expect(at({ id: "" })).toEqual(["id"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("an intake row", () => {
|
||||||
|
it("names its medication, and refuses an empty one or empty ids", () => {
|
||||||
|
const row = { medication: "Ramipril", scheduledFor: AT };
|
||||||
|
expect(refused(intakeEventSchema, row)).toEqual([]);
|
||||||
|
expect(refused(intakeEventSchema, { ...row, medication: "" })).toEqual([
|
||||||
|
"medication",
|
||||||
|
]);
|
||||||
|
expect(refused(intakeEventSchema, { ...row, id: "" })).toEqual(["id"]);
|
||||||
|
expect(refused(intakeEventSchema, { ...row, medicationId: "" })).toEqual([
|
||||||
|
"medicationId",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a custom cycle symptom", () => {
|
||||||
|
const symptom = {
|
||||||
|
key: "custom-cramps",
|
||||||
|
labelKey: "cycle.symptom.custom",
|
||||||
|
categoryId: "cat-1",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("defaults to active at the top of its list", () => {
|
||||||
|
const out = parsed(customCycleSymptomSchema, symptom) as {
|
||||||
|
isActive: unknown;
|
||||||
|
sortOrder: unknown;
|
||||||
|
};
|
||||||
|
expect(out.isActive).toBe(true);
|
||||||
|
expect(out.sortOrder).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an empty key, label key, category or id", () => {
|
||||||
|
for (const field of ["key", "labelKey", "categoryId", "id"]) {
|
||||||
|
expect(
|
||||||
|
refused(customCycleSymptomSchema, { ...symptom, [field]: "" }),
|
||||||
|
field,
|
||||||
|
).toEqual([field]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
+1
-1
@@ -61,7 +61,7 @@
|
|||||||
"src/lib/export/backup-schema-kit.ts",
|
"src/lib/export/backup-schema-kit.ts",
|
||||||
"src/lib/cycle/backup-schema.ts"
|
"src/lib/cycle/backup-schema.ts"
|
||||||
],
|
],
|
||||||
"floor": 41
|
"floor": 53
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user