fix(apple-health): read the sampling rate as real exports spell it

The rate matcher demanded "Hz". Observed exports spell the unit out and
lower-case it — "511,422 hertz" — and the German ones separate it from the
number with a no-break space. The fixtures had only ever used "512 Hz", so
the mismatch never showed.

Rate and heart rate now split into a magnitude and a unit word, with the
magnitude read through the same decimal helper the waveform uses. "512 Hz",
"512 hertz" and "511,562 Hertz" all resolve; a missing unit, the wrong
unit, a grouped number and a rate past the bound are all still refused.

The unit word is matched loosely and the magnitude strictly. That is the
right way round: the unit is presentation and varies by region, while the
number is the measurement.
This commit is contained in:
Marc-André Bombeck committed 2026-08-23 19:17:02 +02:00
1 parent a85c6681bb
commit 29bad0a2b8
2 files changed
+76 -8

No files matched your search

+39 -8
View File
@@ -37,23 +37,54 @@ function parseRecordedAt(value: string | undefined): Date {
return parsed;
}
/**
* Split a measured quantity into its magnitude and its unit word.
*
* The unit is matched loosely on purpose. A real export writes the sampling
* rate as "511,422 hertz" — the word spelled out, lower case, and separated by
* a NO-BREAK space in the German files — where the fixtures had always assumed
* "512 Hz". The magnitude is read through the shared decimal helper, so a
* comma-region watch's "511,562" is the same rate as "511.422".
*
* `\s` covers U+00A0, so the no-break space needs no special case.
*/
function splitQuantity(
raw: string,
unit: RegExp,
): { magnitude: number; ok: boolean } {
const match = /^([+-]?[\d.,]+)\s*([^\s\d]+)$/.exec(raw.trim());
if (!match) return { magnitude: Number.NaN, ok: false };
const magnitude = parseDecimal(match[1]);
if (magnitude === null) return { magnitude: Number.NaN, ok: false };
return { magnitude, ok: unit.test(match[2]) };
}
/** "Hz", "hz", "hertz", "Hertz" — the same unit, spelled as the region does. */
const HERTZ = /^(?:hz|hertz)$/i;
/** "bpm" and the spelled-out forms a localised export may carry. */
const BEATS_PER_MINUTE = /^(?:bpm|spm)$/i;
function parseRate(value: string | undefined): number {
const match = /^(\d+(?:\.\d+)?)\s*Hz$/i.exec(value?.trim() ?? "");
const rate = match ? Number(match[1]) : Number.NaN;
if (!Number.isFinite(rate) || rate <= 0 || rate > 10_000) {
const { magnitude, ok } = splitQuantity(value ?? "", HERTZ);
if (
!ok ||
!Number.isFinite(magnitude) ||
magnitude <= 0 ||
magnitude > 10_000
) {
throw parserError("sample rate is invalid");
}
return Math.round(rate);
return Math.round(magnitude);
}
function parseHeartRate(value: string | undefined): number | null {
if (value === undefined || value.trim() === "") return null;
const match = /^(\d+(?:\.\d+)?)\s*bpm$/i.exec(value.trim());
const rate = match ? Number(match[1]) : Number.NaN;
if (!Number.isFinite(rate) || rate <= 0 || rate > 300) {
const { magnitude, ok } = splitQuantity(value, BEATS_PER_MINUTE);
if (!ok || !Number.isFinite(magnitude) || magnitude <= 0 || magnitude > 300) {
throw parserError("average heart rate is invalid");
}
return Math.round(rate);
return Math.round(magnitude);
}
function mapClassification(
@@ -513,6 +513,43 @@ describe("Apple Health ECG CSV — real-export shapes", () => {
).rejects.toThrow(/sample value is invalid/i);
});
it.each([
["512 Hz", 512],
["512 hz", 512],
["512 hertz", 512],
["511,422 hertz", 511],
["511.422 hertz", 511],
["511,562\u00a0Hertz", 512],
["512Hz", 512],
])("reads the sample rate written as %s", async (raw, expected) => {
// Observed exports spell the unit out, lower-case it, and (in the German
// files) separate it with a no-break space. The fixtures had only ever
// used "512 Hz".
await expect(
parse(validCsv({ "Sample Rate": raw })),
).resolves.toMatchObject({ samplingFrequency: expected });
});
it.each([
["a missing unit", "512"],
["the wrong unit", "512 bpm"],
["a rate above the bound", "20000 hertz"],
["a grouped rate", "1.234,5 hertz"],
])("still rejects a sample rate with %s", async (_label, raw) => {
await expect(parse(validCsv({ "Sample Rate": raw }))).rejects.toThrow(
/sample rate is invalid/i,
);
});
it("reads the average heart rate in both dialects", async () => {
await expect(
parse(validCsv({ "Average Heart Rate": "64 bpm" })),
).resolves.toMatchObject({ averageHeartRate: 64 });
await expect(
parse(validCsv({ "Average Heart Rate": "63,7 bpm" })),
).resolves.toMatchObject({ averageHeartRate: 64 });
});
it("still rejects a paired row that follows the single-column waveform", async () => {
await expect(parse(`${singleColumnCsv()}\nI,0.001`)).rejects.toThrow(
/sample value is invalid/i,