mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
The narrative, the workout distance and the elevation chart converted through the unrounded escape hatch, and the trend caption scaled its noise floor off the raw factor. They now take the rounded helpers, so a narrated pound or mmol/L figure reads at the grain every screen shows it, and the caption's floors are declared canonically (the distance floor in metres). The trends row joins the chart-feeding surfaces that may hand a raw scale to a chart. Refs #1067
216 lines
8.6 KiB
TypeScript
216 lines
8.6 KiB
TypeScript
/**
|
|
* Guard for the converted-value precision class (issue #627, second half).
|
|
*
|
|
* Every entry in the display-transform registry declares the `decimals` its
|
|
* unit is read at. Before v1.32.39 that number was carried all the way to the
|
|
* render layer and read by exactly one screen out of all the ones that show a
|
|
* converted value, so everywhere else the raw product of a kilogram and
|
|
* 2.20462262185 reached the surface unrounded. The fix moved the rounding into
|
|
* the conversion helpers, which means a surface gets a correctly-scaled number
|
|
* whether or not it remembers to ask.
|
|
*
|
|
* This file guards the two ways that could be undone:
|
|
*
|
|
* 1. A PROPERTY over the whole registry — every branch, swept over a spread
|
|
* of raw values, must come out already at its declared grain, and a
|
|
* non-rescaling branch must come out byte-identical. A property test is
|
|
* the honest shape here: the thing being guaranteed is arithmetic, and a
|
|
* structural sweep for "did the surface remember to round" is exactly the
|
|
* per-call-site discipline this change exists to abolish.
|
|
* 2. A STRUCTURAL freeze on the two ways to get an unrounded converted value
|
|
* anyway — importing the documented escape hatch, or hand-rolling
|
|
* `value * transform.factor + transform.offset` at a call site. Both sets
|
|
* are pinned to a named allowlist, so a new converted-value surface
|
|
* cannot quietly join them.
|
|
*/
|
|
import { readFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
|
|
import { walkSourceFiles } from "./helpers/source-files";
|
|
|
|
import { describe, it, expect } from "vitest";
|
|
|
|
import {
|
|
applyDisplayTransform,
|
|
applyDisplayTransformDelta,
|
|
getDisplayTransform,
|
|
transformRescales,
|
|
TRANSFORMED_TYPES,
|
|
} from "@/lib/measurements/display-transform";
|
|
|
|
const SRC = join(process.cwd(), "src");
|
|
|
|
/** The registry itself owns the arithmetic; it is not a call site. */
|
|
const REGISTRY = "lib/measurements/display-transform.ts";
|
|
|
|
/**
|
|
* The one module allowed to import the unrounded escape hatch. The target
|
|
* adapter needs the unrounded pound edge of a canonical guardrail so it can
|
|
* round it INWARD — rounding to the nearest tenth first gives 66.1 lb for the
|
|
* 30 kg floor, which inverts to 29.98 kg and the server rejects it.
|
|
*/
|
|
const UNROUNDED_IMPORTERS = ["lib/targets/target-unit-display.ts"];
|
|
|
|
/**
|
|
* The surfaces allowed to read a transform's raw `factor` / `offset`. All five
|
|
* do it for the same reason: the chart takes the multiplier as a prop, folds
|
|
* it into its own series at the single read boundary, and formats the result
|
|
* itself. Everything those files RENDER directly goes through the rounding
|
|
* helpers. Adding a file here means arguing that its number never reaches a
|
|
* screen unrounded.
|
|
*/
|
|
const RAW_SCALE_SURFACES = [
|
|
"app/page-client.tsx",
|
|
// The insights trends row: a single transformed series hands its scale
|
|
// and offset to the mini chart, which formats every value itself.
|
|
"components/insights/trends-row.tsx",
|
|
"app/insights/weight/page.tsx",
|
|
"components/insights/healthkit-metric-page.tsx",
|
|
"components/insights/device-score-tile.tsx",
|
|
];
|
|
|
|
/** Matches a `factor` / `offset` read off a display transform. */
|
|
const RAW_SCALE_READ =
|
|
/\b\w*[Tt]ransform(?:For\([^)]*\))?\??\.(factor|offset)\b/;
|
|
|
|
function sourceFiles(): string[] {
|
|
return walkSourceFiles(SRC, { floor: 3000 }).filter(
|
|
(rel) =>
|
|
// Never walk the Prisma client, and skip the suites that legitimately
|
|
// exercise the raw arithmetic they are asserting about.
|
|
!rel.startsWith("generated/") && !rel.includes("__tests__/"),
|
|
);
|
|
}
|
|
|
|
function read(rel: string): string {
|
|
return readFileSync(join(SRC, rel), "utf8");
|
|
}
|
|
|
|
describe("display-transform rounding guard — the registry property", () => {
|
|
const RAW_VALUES = [
|
|
0, 1, 1.3, 10, 36.6, 36.66666, 37, 78.45, 83.99981845627636, 84, 95.25, 210,
|
|
1609.344, 8046.72, -0.5, -12.3456789,
|
|
];
|
|
|
|
it("sweeps a non-empty registry", () => {
|
|
// Guards against a set that silently empties out (a green void).
|
|
expect(TRANSFORMED_TYPES.size).toBeGreaterThan(5);
|
|
});
|
|
|
|
it("declares a usable decimal count on every branch", () => {
|
|
for (const type of TRANSFORMED_TYPES) {
|
|
for (const preference of ["metric", "imperial"] as const) {
|
|
const { decimals } = getDisplayTransform(type, preference);
|
|
expect(Number.isInteger(decimals)).toBe(true);
|
|
expect(decimals).toBeGreaterThanOrEqual(0);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("never returns a converted value finer than its declared decimals", () => {
|
|
const offenders: string[] = [];
|
|
for (const type of TRANSFORMED_TYPES) {
|
|
for (const preference of ["metric", "imperial"] as const) {
|
|
const transform = getDisplayTransform(type, preference);
|
|
if (!transformRescales(transform)) continue;
|
|
const scale = 10 ** transform.decimals;
|
|
for (const raw of RAW_VALUES) {
|
|
for (const [label, shown] of [
|
|
["absolute", applyDisplayTransform(raw, transform)],
|
|
["delta", applyDisplayTransformDelta(raw, transform)],
|
|
] as const) {
|
|
// Re-rounding at the declared grain must be a no-op: the value
|
|
// carries no more precision than the transform says it should.
|
|
if (Math.round(shown * scale) / scale !== shown) {
|
|
offenders.push(
|
|
`${type}/${preference} ${label} ${raw} → ${shown}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
expect(offenders).toEqual([]);
|
|
});
|
|
|
|
it("leaves a non-rescaling branch byte-identical", () => {
|
|
// The metric branch only relabels the symbol; the number it shows IS the
|
|
// stored number, and a stored 78.45 kg is not conversion noise.
|
|
const offenders: string[] = [];
|
|
for (const type of TRANSFORMED_TYPES) {
|
|
for (const preference of ["metric", "imperial"] as const) {
|
|
const transform = getDisplayTransform(type, preference);
|
|
if (transformRescales(transform)) continue;
|
|
for (const raw of RAW_VALUES) {
|
|
if (applyDisplayTransform(raw, transform) !== raw) {
|
|
offenders.push(`${type}/${preference} absolute ${raw}`);
|
|
}
|
|
if (applyDisplayTransformDelta(raw, transform) !== raw) {
|
|
offenders.push(`${type}/${preference} delta ${raw}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
expect(offenders).toEqual([]);
|
|
});
|
|
|
|
it("leaves an untransformed type byte-identical", () => {
|
|
for (const type of ["PULSE", "BLOOD_GLUCOSE", "BODY_MASS_INDEX"]) {
|
|
const transform = getDisplayTransform(type, "imperial");
|
|
for (const raw of RAW_VALUES) {
|
|
expect(applyDisplayTransform(raw, transform)).toBe(raw);
|
|
expect(applyDisplayTransformDelta(raw, transform)).toBe(raw);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("display-transform rounding guard — the bypass freeze", () => {
|
|
it("sweeps a non-empty source tree", () => {
|
|
expect(sourceFiles().length).toBeGreaterThan(500);
|
|
});
|
|
|
|
it("confines the unrounded escape hatch to its documented importers", () => {
|
|
const importers = sourceFiles().filter(
|
|
(rel) =>
|
|
rel !== REGISTRY &&
|
|
read(rel).includes("applyDisplayTransformUnrounded"),
|
|
);
|
|
expect(importers.sort()).toEqual([...UNROUNDED_IMPORTERS].sort());
|
|
});
|
|
|
|
it("confines hand-rolled transform scaling to the chart-feeding surfaces", () => {
|
|
const offenders = sourceFiles().filter(
|
|
(rel) =>
|
|
rel !== REGISTRY &&
|
|
!UNROUNDED_IMPORTERS.includes(rel) &&
|
|
RAW_SCALE_READ.test(read(rel)),
|
|
);
|
|
expect(offenders.sort()).toEqual([...RAW_SCALE_SURFACES].sort());
|
|
});
|
|
|
|
it("every allowlisted surface reads the raw scale to feed a chart", () => {
|
|
// The justification for holding the raw multiplier at all: the chart
|
|
// takes it as a prop. A file that stops passing it has no reason to be
|
|
// reading `.factor` any more and belongs back behind the helpers.
|
|
// The prop as it is actually passed — a bare substring would still
|
|
// match a renamed identifier that no longer reaches any chart.
|
|
const offenders = RAW_SCALE_SURFACES.filter(
|
|
(rel) => !/\bvalueScale=/.test(read(rel)),
|
|
);
|
|
expect(offenders).toEqual([]);
|
|
});
|
|
|
|
it("every allowlisted surface still converts its rendered values", () => {
|
|
// The chart formats its own series; anything these files render outside
|
|
// the chart must come back through the preference hook. Either reading of
|
|
// it counts: a surface that paints inside a streamed page boundary takes
|
|
// `useUnitDisplayOnceMounted()`, which is the same sugar with the
|
|
// preference withheld from the hydration render.
|
|
const offenders = RAW_SCALE_SURFACES.filter(
|
|
(rel) => !/\buseUnitDisplay(OnceMounted)?\(\)/.test(read(rel)),
|
|
);
|
|
expect(offenders).toEqual([]);
|
|
});
|
|
});
|