mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
The level now sets, per record: the status-card regeneration gap after a sync (frugal 24 h, standard the operator's gap, full 1 h) and after a hand entry (frugal 6 h, otherwise 1 h); the status snapshot cap (10 000 compact characters on frugal, 14 000 above); background reasoning for the briefing and the period narratives (off on frugal); the daily briefing's form (a short form on frugal that keeps every field and at most one recommendation, the full contract on standard and full); and whether the automatic document read may act (frugal reads on request only, the stored switch is kept). A deterministic simulation of a watch-synced day through the invalidator counts 122 ingest-driven status generations on full (the v1.42 behaviour), 25 on standard and 2 on frugal, plus the nightly warm on each. Refs #1217
211 lines
6.7 KiB
TypeScript
211 lines
6.7 KiB
TypeScript
/**
|
|
* #1217 — what the usage levels do to a watch-synced account, simulated
|
|
* deterministically through the real invalidator.
|
|
*
|
|
* One day of a phone that delivers a watch's samples every 15 minutes from
|
|
* 07:00 to 23:00, the morning sleep and recovery figures, two hand-entered
|
|
* blood-pressure readings and a hand-entered weight. Every scope the
|
|
* invalidator enqueues counts as one generation, the worst case the issue
|
|
* describes: today's partial bucket moves on every sync, so the content-hash
|
|
* gate never short-circuits a sync-driven run. The 04:30 nightly warm
|
|
* regenerates every scope once on every level and is counted apart.
|
|
*
|
|
* The clock, the cache rows and the queue are in memory; the scope mapping,
|
|
* the per-source gap and the level resolution are the production code.
|
|
*/
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const sim = vi.hoisted(() => ({
|
|
notes: new Map<string, number>(),
|
|
generations: 0,
|
|
level: null as string | null,
|
|
}));
|
|
|
|
vi.mock("@/lib/db", () => ({
|
|
prisma: {
|
|
user: {
|
|
findUnique: vi.fn(async () => ({
|
|
locale: "en",
|
|
aiUsageLevel: sim.level,
|
|
})),
|
|
},
|
|
appSettings: {
|
|
findUnique: vi.fn(async () => ({
|
|
aiUsageMaxLevel: "FULL",
|
|
aiUsageDefaultLevel: "STANDARD",
|
|
aiSyncRegenGapHours: null,
|
|
})),
|
|
},
|
|
insightStatusCache: {
|
|
findMany: vi.fn(
|
|
async (args: {
|
|
where: {
|
|
OR: Array<{ metric: string; locale: string }>;
|
|
generatedAt: { gte: Date };
|
|
};
|
|
}) =>
|
|
args.where.OR.filter(
|
|
(key) =>
|
|
(sim.notes.get(key.metric) ?? -Infinity) >=
|
|
args.where.generatedAt.gte.getTime(),
|
|
).map((key) => ({ metric: key.metric, locale: key.locale })),
|
|
),
|
|
},
|
|
},
|
|
}));
|
|
vi.mock("@/lib/ai/capabilities/gate", () => ({
|
|
aiCapabilityForRecord: vi.fn(async () => ({
|
|
available: true,
|
|
reason: null,
|
|
onDeviceAllowed: true,
|
|
})),
|
|
}));
|
|
vi.mock("@/lib/jobs/insight-status-generate-shared", () => ({
|
|
enqueueStatusGeneration: vi.fn(async (job: { metric: string }) => {
|
|
sim.generations += 1;
|
|
sim.notes.set(job.metric, Date.now());
|
|
}),
|
|
}));
|
|
vi.mock("@/lib/measurements/live-types", () => ({
|
|
listLiveMeasurementTypes: vi.fn(async () => []),
|
|
}));
|
|
|
|
import { invalidateStatusInsightsForTypes } from "../status-invalidation";
|
|
import type { MeasurementType } from "@/generated/prisma/client";
|
|
|
|
type Event = {
|
|
at: string;
|
|
source: "sync" | "manual";
|
|
types: MeasurementType[];
|
|
};
|
|
|
|
/** One day of a watch account, minute-exact and the same on every run. */
|
|
function day(): Event[] {
|
|
const events: Event[] = [];
|
|
for (let minutes = 7 * 60; minutes <= 23 * 60; minutes += 15) {
|
|
const hh = String(Math.floor(minutes / 60)).padStart(2, "0");
|
|
const mm = String(minutes % 60).padStart(2, "0");
|
|
const types: MeasurementType[] = [
|
|
"ACTIVITY_STEPS",
|
|
"ACTIVE_ENERGY_BURNED",
|
|
"WALKING_RUNNING_DISTANCE",
|
|
"PULSE",
|
|
"FLIGHTS_CLIMBED",
|
|
];
|
|
if (minutes % 60 === 0)
|
|
types.push("TIME_IN_DAYLIGHT", "AUDIO_EXPOSURE_ENV");
|
|
if (minutes % 120 === 0) {
|
|
types.push("HEART_RATE_VARIABILITY", "WALKING_HEART_RATE_AVERAGE");
|
|
}
|
|
if (minutes === 7 * 60) {
|
|
types.push(
|
|
"SLEEP_DURATION",
|
|
"RESTING_HEART_RATE",
|
|
"RESPIRATORY_RATE",
|
|
"OXYGEN_SATURATION",
|
|
"WRIST_TEMPERATURE",
|
|
"VO2_MAX",
|
|
);
|
|
}
|
|
events.push({ at: `${hh}:${mm}`, source: "sync", types });
|
|
}
|
|
events.push(
|
|
{ at: "07:30", source: "manual", types: ["WEIGHT"] },
|
|
{
|
|
at: "08:00",
|
|
source: "manual",
|
|
types: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"],
|
|
},
|
|
{
|
|
at: "20:00",
|
|
source: "manual",
|
|
types: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"],
|
|
},
|
|
);
|
|
return events.sort((a, b) => a.at.localeCompare(b.at));
|
|
}
|
|
|
|
const ALL_SCOPES_AT_WARM = new Set<string>();
|
|
|
|
async function simulate(level: string | null): Promise<{
|
|
warm: number;
|
|
ingest: number;
|
|
bySource: Record<"sync" | "manual", number>;
|
|
}> {
|
|
sim.notes.clear();
|
|
sim.generations = 0;
|
|
sim.level = level;
|
|
// The 04:30 warm regenerates every scope the day will touch.
|
|
const warmAt = Date.parse("2026-10-15T04:30:00Z");
|
|
for (const scope of ALL_SCOPES_AT_WARM) sim.notes.set(scope, warmAt);
|
|
const warm = ALL_SCOPES_AT_WARM.size;
|
|
const bySource = { sync: 0, manual: 0 };
|
|
for (const event of day()) {
|
|
vi.setSystemTime(new Date(`2026-10-15T${event.at}:00Z`));
|
|
const before = sim.generations;
|
|
await invalidateStatusInsightsForTypes("u1", event.types, {
|
|
source: event.source,
|
|
});
|
|
bySource[event.source] += sim.generations - before;
|
|
}
|
|
return { warm, ingest: sim.generations, bySource };
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers({ toFake: ["Date"] });
|
|
});
|
|
|
|
describe("generations per day on a watch-synced account", () => {
|
|
it("counts the scopes once to seed the warm", async () => {
|
|
// Discover every scope the day dirties: run it once on `full` with no
|
|
// warm, which enqueues each scope at least once.
|
|
ALL_SCOPES_AT_WARM.clear();
|
|
sim.notes.clear();
|
|
sim.level = "FULL";
|
|
for (const event of day()) {
|
|
vi.setSystemTime(new Date(`2026-10-15T${event.at}:00Z`));
|
|
await invalidateStatusInsightsForTypes("u1", event.types, {
|
|
source: event.source,
|
|
});
|
|
}
|
|
for (const scope of sim.notes.keys()) ALL_SCOPES_AT_WARM.add(scope);
|
|
expect(ALL_SCOPES_AT_WARM.size).toBe(19);
|
|
});
|
|
|
|
it("before v1.43 (every ingest at one hour) versus each level", async () => {
|
|
const full = await simulate("FULL"); // the behaviour before v1.43
|
|
const standard = await simulate("STANDARD");
|
|
const frugal = await simulate("FRUGAL");
|
|
const result = { full, standard, frugal };
|
|
process.stdout.write(`SIMULATION ${JSON.stringify(result)}\n`);
|
|
|
|
// Ingest-driven generations a day (the warm adds 19 on every level).
|
|
expect(full).toEqual({
|
|
warm: 19,
|
|
ingest: 122,
|
|
bySource: { sync: 118, manual: 4 },
|
|
});
|
|
expect(standard).toEqual({
|
|
warm: 19,
|
|
ingest: 25,
|
|
bySource: { sync: 20, manual: 5 },
|
|
});
|
|
expect(frugal).toEqual({
|
|
warm: 19,
|
|
ingest: 2,
|
|
bySource: { sync: 0, manual: 2 },
|
|
});
|
|
// The contract's aim: standard well below a quarter of the old ingest
|
|
// volume, and below a third with the warm counted.
|
|
expect(standard.ingest / full.ingest).toBeLessThan(0.25);
|
|
expect(
|
|
(standard.warm + standard.ingest) / (full.warm + full.ingest),
|
|
).toBeLessThan(0.32);
|
|
// Hand entries are narrated within the hour on standard, as before; on
|
|
// full the sync drip had often refreshed the same scope just before.
|
|
expect(standard.bySource.manual).toBeGreaterThanOrEqual(
|
|
full.bySource.manual,
|
|
);
|
|
});
|
|
});
|