mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
1 parent
a85c6681bb
commit
29bad0a2b8
2 files changed
+76
-8
No files matched your search
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user