fix(apple-health): accept the valueless header row an ECG export opens with

Every real export begins with a bare `Name` when the name field is blank —
Apple omits the trailing comma rather than writing an empty value. The
parser required a comma on every header row, so it rejected the file on
line 1, before it had read anything.

A valueless header row is now skipped. It contributes no metadata entry
rather than an empty-string one, because `Lead` and `Unit` presence is what
switches the parser into the single-column waveform mode and a key with no
value must not be able to trip that switch.

The relaxation is confined to the header. Inside the single-column
waveform a comma-less row is a sample and is read as one; inside the
paired waveform every row is lead and voltage, so a missing comma there is
still malformed.
This commit is contained in:
Marc-André Bombeck committed 2026-08-23 19:17:02 +02:00
1 parent 8ecf7e5595
commit 19699ce8ce
2 files changed
+62 -1

No files matched your search

+18 -1
View File
@@ -160,7 +160,24 @@ export async function parseAppleHealthEcgCsv(input: {
}
const pair = splitPair(line);
if (!pair) {
throw parserError("row is malformed");
// A HEADER row whose value is empty is written without the trailing
// comma. Every real export opens with a bare `Name` when the name
// field is blank, which this parser used to reject on line 1 — before
// it had read anything at all.
//
// Only the header section may do this. Inside the single-column
// waveform a comma-less row is a sample and was handled above; inside
// the paired waveform every row is `lead,voltage`, so a missing comma
// there is still malformed.
//
// The row contributes NO metadata entry rather than an empty-string
// one: `Lead` and `Unit` presence is what switches the parser into the
// single-column mode below, and a valueless key must not be able to
// trip that switch.
if (inSamples) {
throw parserError("row is malformed");
}
continue;
}
const [key, value] = pair;
if (!inSamples) {
@@ -427,3 +427,47 @@ describe("Apple Health HKElectrocardiogram CSV parser", () => {
expect(spies.every((spy) => spy.mock.calls.length === 0)).toBe(true);
});
});
/**
* The shapes real exports actually use.
*
* The fixtures above were written from the format's documentation rather than
* from a file off a watch, and they drifted from it in four independent ways.
* Each block below pins one of them, and — because the differences are dialect
* rather than version — pins BOTH dialects side by side, so a later edit cannot
* quietly trade one for the other.
*/
describe("Apple Health ECG CSV — real-export shapes", () => {
async function parse(csv: string, maxSamples = 8) {
const subject = await loadParserSubject();
return subject.parseAppleHealthEcgCsv({
memberName: "apple_health_export/electrocardiograms/ecg_2026-07-18.csv",
stream: Readable.from([Buffer.from(csv)]),
maxSamples,
});
}
describe("a header row with no value", () => {
it("accepts the bare `Name` row every observed export opens with", async () => {
// The name field is blank and Apple omits the comma entirely, so the
// parser used to fail on line 1, before reading anything.
await expect(
parse(validCsv().replace("Name,Private Patient Name", "Name")),
).resolves.toMatchObject({ samplingFrequency: 512 });
});
it("keeps a valueless header row out of the metadata", async () => {
// `Lead` + `Unit` presence is what switches the parser into the
// single-column mode; a valueless key must not be able to trip it.
await expect(
parse(`${singleColumnCsv().replace("Unit,µV", "Unit")}`),
).rejects.toThrow(/samples are missing|malformed/i);
});
it("still rejects a comma-less row inside the paired waveform", async () => {
await expect(
parse(validCsv().replace("I,-0.002", "-0.002")),
).rejects.toThrow(/malformed/i);
});
});
});