src/components/dives/dive-file-import.test.ts
Rama main · réplica de lectura del repositorio original.
import { describe, it, expect } from "vitest";
import {
applyParsedDiveToForm,
type InferredFigures,
} from "./dive-file-import";
import { describeMixtureImport } from "@/lib/dive-import";
import { DEFAULT_MIXTURE } from "./mixture-fields";
import type { ParsedDive, ParsedDiveMixture } from "@/lib/api/dives";
import type { DiveMixtureInput } from "@/lib/validations/dive";
// A mixture exactly as the API returns one, i.e. with every field explicitly
// present - `null` is a real answer here ("the file didn't record this"), not an
// omission, so the fixture spells all of them out.
function parsed(overrides: Partial<ParsedDiveMixture> = {}): ParsedDiveMixture {
return {
volume: null,
start_pressure: null,
end_pressure: null,
oxygen: null,
helium: null,
po2_limit: null,
gas_number: null,
role: null,
...overrides,
};
}
// A cylinder already on the form, as an earlier import or the diver left it.
function onForm(overrides: Partial<DiveMixtureInput> = {}): DiveMixtureInput {
return { ...DEFAULT_MIXTURE, ...overrides };
}
// A form stub with just the two methods `applyParsedDiveToForm` touches. The
// mixtures it returns are the literal initial state both dive pages use.
function formHolding(mixtures: DiveMixtureInput[]) {
return {
getValues: (name?: string) => (name === "mixtures" ? mixtures : undefined),
setValue: () => {},
} as unknown as Parameters<typeof applyParsedDiveToForm>[0];
}
function parsedDive(
mixtures: ParsedDiveMixture[],
overrides: Partial<ParsedDive> = {},
) {
return {
dive_number: null,
start_time: null,
submerged_time: null,
max_depth: null,
avg_depth: null,
bottom_temperature: null,
mixtures,
// The rest of the dive, which most dive-computer files leave unstated.
notes: null,
visibility: null,
weight: null,
water_type: null,
altitude: null,
type: null,
rating: null,
air_temperature: null,
current: null,
waves: null,
weather: null,
entry_type: null,
boat_name: null,
tags: [],
// A setting of the device, never applied to the form - null is the ordinary
// case, since only a FIT file records it at all.
salinity: null,
// Returned by the parse but never applied to the form - the API writes these
// itself when the file is attached. Spelled out so this fixture stays a complete
// `ParsedDive` rather than a partial one the compiler happens to accept.
cns_start: null,
cns_end: null,
otu_start: null,
otu_end: null,
surface_pressure_bar: null,
file_token: "token",
...overrides,
};
}
// Records what `applyParsedDiveToForm` wrote, for the scalar fields the mixture
// tests above don't reach. `formHolding`'s stub swallows `setValue` entirely.
function recordingForm(mixtures: DiveMixtureInput[] = []) {
const written: Record<string, unknown> = {};
const form = {
getValues: (name?: string) => (name === "mixtures" ? mixtures : undefined),
setValue: (name: string, value: unknown) => {
written[name] = value;
},
} as unknown as Parameters<typeof applyParsedDiveToForm>[0];
return { form, written };
}
describe("applyParsedDiveToForm", () => {
// What an import actually meets, rather than the hand-picked `existing` the unit
// tests above pass in. Two real starting states, and the difference is the
// whole point: the create form starts empty (see `new-dive-page-content.tsx` - a form must
// not write gas the diver never entered), while a form the diver has added a
// cylinder to, or that the last-dive prefill filled in, holds one.
const seededForm = () => formHolding([{ ...DEFAULT_MIXTURE }]);
const emptyForm = () => formHolding([]);
it("flags a cylinder size the file didn't record, against a seeded form", () => {
// Pins that the note fires even though the seed has a value: requiring that
// *no* source had one silences it on the ordinary one-cylinder import, and the
// seeded 11.1 L goes in looking like a reading - the ~26 %-low RMV case. A
// seeded cylinder's size is the only way this note fires, and it is exactly
// the case worth firing on.
const notes = applyParsedDiveToForm(
seededForm(),
parsedDive([parsed({ oxygen: 32 })]),
() => {},
);
expect(notes.guessed.volume).toBe("form");
expect(describeMixtureImport(notes)).toContain("cylinder size");
});
it("flags gas and volume for an export that records neither", () => {
// The 2026 Suunto Ocean JSON shape: pressures and nothing else.
const notes = applyParsedDiveToForm(
seededForm(),
parsedDive([parsed({ start_pressure: 205.11, end_pressure: 91.55 })]),
() => {},
);
expect(notes.guessed).toEqual({
volume: "form",
oxygen: "form",
helium: "form",
});
const note = describeMixtureImport(notes);
expect(note).toContain("cylinder size");
expect(note).toContain("gas mix");
});
it("leaves the size blank and says nothing when the form held nothing", () => {
// The create form's actual starting state. `existingMixtureFor` won't pair a
// one-cylinder file against a zero-cylinder form, so nothing supplies the volume
// and it stays empty - which is what the API stores and what the dive page shows.
// There is nothing on screen to warn about, and the empty box says it better
// than a sentence 2 200 px above it could.
let applied: DiveMixtureInput[] = [];
const notes = applyParsedDiveToForm(
emptyForm(),
parsedDive([parsed({ oxygen: 32 })]),
(mixtures) => {
applied = mixtures;
},
);
expect(applied[0].volume).toBe("");
expect(applied[0].helium).toBe("");
expect(applied[0].oxygen).toBe(32);
expect(notes.guessed).toEqual({});
expect(describeMixtureImport(notes)).toBeNull();
});
it("reports no lost pressures importing onto an empty form", () => {
// The counts differ (nought against one), which is what makes
// `existingMixtureFor` refuse to pair - and on the *seeded* form that refusal
// could cost real pressures. Here there was nothing to lose, so saying they were
// cleared would send the diver looking for data that never existed.
const notes = applyParsedDiveToForm(
emptyForm(),
parsedDive([parsed({ oxygen: 32, helium: 0, volume: 12 })]),
() => {},
);
expect(notes.discardedPressures).toBe(false);
expect(describeMixtureImport(notes)).toBeNull();
});
it("says nothing when the file recorded the lot", () => {
const notes = applyParsedDiveToForm(
seededForm(),
parsedDive([parsed({ volume: 12, oxygen: 32, helium: 0 })]),
() => {},
);
expect(notes.guessed).toEqual({});
expect(describeMixtureImport(notes)).toBeNull();
});
it("reports a carried-over value as the form's, not as a default", () => {
// The diver's own 15 L is still not something the file recorded, so it is still
// worth flagging - but it is not a default, and saying so is the whole distinction.
const notes = applyParsedDiveToForm(
formHolding([{ ...DEFAULT_MIXTURE, volume: 15 }]),
parsedDive([parsed({ oxygen: 32 })]),
() => {},
);
expect(notes.guessed.volume).toBe("form");
expect(describeMixtureImport(notes)).toContain("already on this form");
});
it("reports pressures carried onto a file that had none", () => {
const notes = applyParsedDiveToForm(
formHolding([{ ...DEFAULT_MIXTURE, start_pressure: 211.62 }]),
parsedDive([parsed({ oxygen: 21, helium: 0, volume: 12 })]),
() => {},
);
expect(notes.keptPressures).toBe(true);
});
it("does not pair cylinders when the counts differ", () => {
// The safety-critical half of the carry-over: position is the only signal,
// so pairing a one-cylinder form onto a two-cylinder file could put a back
// gas's pressures on a stage bottle and yield a confidently wrong RMV.
// Inverting or deleting the guard left the whole suite green.
let applied: DiveMixtureInput[] = [];
const notes = applyParsedDiveToForm(
formHolding([onForm({ start_pressure: 211.62, end_pressure: 127.16 })]),
parsedDive([parsed({ oxygen: 21 }), parsed({ oxygen: 50 })]),
(mixtures) => {
applied = mixtures;
},
);
expect(applied[0].start_pressure).toBe("");
// Blank rather than the form's 11.1 L, for the same reason the pressure above it
// is blank: with no pairing there is no cylinder to carry anything from, and the
// import path invents nothing.
expect(applied[0].volume).toBe("");
expect(notes.keptPressures).toBe(false);
// ...and the diver is told the pressures went, which on the edit form takes the
// dive's `gas_use` with it.
expect(notes.discardedPressures).toBe(true);
expect(describeMixtureImport(notes)).toContain("were cleared");
});
describe("over a tank linked to the diver's cylinder", () => {
const vessel = {
uuid: "vessel-1",
name: "AL80",
rented: false,
is_archived: false,
volume: 11.1,
};
const linked = onForm({
volume: 11.1,
gear_item_uuid: vessel.uuid,
gear_item: vessel,
});
it("keeps the link and its volume where the counts match", () => {
let applied: DiveMixtureInput[] = [];
const notes = applyParsedDiveToForm(
formHolding([linked]),
parsedDive([parsed({ volume: 12, oxygen: 32, helium: 0 })]),
(mixtures) => {
applied = mixtures;
},
);
expect(applied[0].gear_item_uuid).toBe(vessel.uuid);
expect(applied[0].volume).toBe(11.1);
expect(applied[0].oxygen).toBe(32);
expect(notes.keptLinkedVolume).toBe(true);
});
it("replaces the tanks and their links where the counts differ", () => {
let applied: DiveMixtureInput[] = [];
const notes = applyParsedDiveToForm(
formHolding([linked]),
parsedDive([parsed({ volume: 12 }), parsed({ volume: 7 })]),
(mixtures) => {
applied = mixtures;
},
);
expect(applied.map((row) => row.gear_item_uuid)).toEqual(["", ""]);
expect(applied.map((row) => row.volume)).toEqual([12, 7]);
expect(notes.droppedLinks).toBe(true);
});
it("keeps the link on a later file's fill, which touches only blanks", () => {
const replaced: DiveMixtureInput[][] = [];
const { form } = formHoldingValues({}, [{ ...linked, oxygen: "" }]);
applyParsedDiveToForm(
form,
parsedDive([parsed({ volume: 12, oxygen: 32 })]),
(mixtures) => replaced.push(mixtures),
"fill-only",
);
expect(replaced[0][0].gear_item_uuid).toBe(vessel.uuid);
expect(replaced[0][0].volume).toBe(11.1);
expect(replaced[0][0].oxygen).toBe(32);
});
});
it("returns empty notes when the file carries no mixtures at all", () => {
const notes = applyParsedDiveToForm(seededForm(), parsedDive([]), () => {});
expect(notes).toEqual({
guessed: {},
keptPressures: false,
discardedPressures: false,
keptLinkedVolume: false,
droppedLinks: false,
});
});
it("never takes the dive's water type from a file's salinity", () => {
// A FIT file's `dive_settings.water_type` is the density the computer was
// set to - `en13319` is a calibration, not a kind of water - so it stays the
// recording's setting, and the dive's water type stays the diver's answer.
const { form, written } = recordingForm();
applyParsedDiveToForm(
form,
parsedDive([], { salinity: "en13319" }),
() => {},
);
expect(written).not.toHaveProperty("water_type");
});
it("takes the water type a file states for the dive itself", () => {
const { form, written } = recordingForm();
applyParsedDiveToForm(
form,
parsedDive([], { water_type: "fresh", salinity: "en13319" }),
() => {},
);
expect(written.water_type).toBe("fresh");
});
it("prefills the rest of the dive a logbook file states", () => {
// A UDDF dive carries the diver's own entries beside the readings, and logbook
// import stores every one of them; the form takes the same file the same way.
const { form, written } = recordingForm();
applyParsedDiveToForm(
form,
parsedDive([], {
notes: "Turtle at the safety stop",
visibility: 15,
weight: 4,
altitude: 0,
rating: 4,
air_temperature: 31.5,
current: "light",
entry_type: "boat",
tags: ["reef"],
}),
() => {},
);
expect(written).toMatchObject({
notes: "Turtle at the safety stop",
visibility: 15,
weight: 4,
altitude: 0,
rating: 4,
air_temperature: 31.5,
current: "light",
entry_type: "boat",
tags: ["reef"],
});
});
it("leaves a member the file states nothing about to the form", () => {
// The Suunto app writes `""` for notes on every Ocean file, and a file with no
// tags reads as an empty list: neither may wipe what the last dive carried over.
const { form, written } = recordingForm();
applyParsedDiveToForm(
form,
parsedDive([], { notes: "", tags: [], weight: null, boat_name: null }),
() => {},
);
expect(written).not.toHaveProperty("notes");
expect(written).not.toHaveProperty("tags");
expect(written).not.toHaveProperty("weight");
expect(written).not.toHaveProperty("boat_name");
});
});
// A form already holding values, for the fill-only cases below. Unlike
// `recordingForm` above it answers `getValues(name)` for scalars as well as for
// `mixtures`, because fill-only asks the form what it already has before writing
// anything - which is the whole mechanism under test.
function formHoldingValues(
values: Record<string, unknown>,
mixtures: DiveMixtureInput[] = [],
) {
const written: Record<string, unknown> = {};
const form = {
getValues: (name?: string) =>
name === "mixtures" ? mixtures : values[name as string],
setValue: (name: string, value: unknown) => {
written[name] = value;
values[name] = value;
},
} as unknown as Parameters<typeof applyParsedDiveToForm>[0];
return { form, written };
}
describe("applyParsedDiveToForm with a file that states only a day", () => {
it("keeps a date-only dive a date rather than giving it a midnight", () => {
const { form, written } = formHoldingValues({ start_time: "2002-06-18" });
applyParsedDiveToForm(
form,
parsedDive([], { start_time: "2002-06-19" }),
() => {},
);
expect(written.start_time).toBe("2002-06-19");
});
it("still gives a dive with a clock the file's day at midnight", () => {
// The create form needs an instant, and a diver can correct the hour.
const { form, written } = formHoldingValues({
start_time: "2026-04-17T11:49:23+02:00",
});
applyParsedDiveToForm(
form,
parsedDive([], { start_time: "2002-06-19" }),
() => {},
);
expect(written.start_time).toMatch(/^2002-06-19T00:00:00[+-]\d{2}:\d{2}$/);
});
});
describe("applyParsedDiveToForm in fill-only mode", () => {
it("leaves a figure the form already carries exactly as it is", () => {
// The case the rule exists for: the Suunto app's JSON has been imported, and
// the same computer's FIT of the same dive follows. The FIT's own `avg_depth`
// 9.49 and `submerged_time` 3474 are that machine's second telling of numbers the
// form already holds from the first, and overwriting them would silently
// replace figures the diver has been looking at - and may have corrected.
// Marked as stating them: a figure the file derived is the case below.
const { form, written } = formHoldingValues({
avg_depth: 10.74,
submerged_time: "50:51",
max_depth: 19.04,
});
applyParsedDiveToForm(
form,
parsedDive([], {
avg_depth: 9.49,
submerged_time: 3474,
max_depth: 19.1,
inferred: [],
}),
() => {},
"fill-only",
);
expect(written).not.toHaveProperty("avg_depth");
expect(written).not.toHaveProperty("submerged_time");
expect(written).not.toHaveProperty("max_depth");
});
it("still fills a field the form left empty", () => {
// "Fills, never overwrites" is two claims, and this is the half that makes
// the second file worth attaching at all. A bottom temperature the JSON
// never recorded is one the FIT can supply.
const { form, written } = formHoldingValues({
avg_depth: 10.74,
bottom_temperature: undefined,
});
applyParsedDiveToForm(
form,
parsedDive([], { avg_depth: 9.49, bottom_temperature: 21.8 }),
() => {},
"fill-only",
);
expect(written.bottom_temperature).toBe(21.8);
expect(written).not.toHaveProperty("avg_depth");
});
it("reads a cleared number input as empty rather than as a value", () => {
// A number field the diver cleared reads back as `NaN`, not `undefined` -
// the one non-obvious empty state on this form, and the one a `!= null`
// guard gets wrong.
const { form, written } = formHoldingValues({ max_depth: Number.NaN });
applyParsedDiveToForm(
form,
parsedDive([], { max_depth: 19.1 }),
() => {},
"fill-only",
);
expect(written.max_depth).toBe(19.1);
});
it("fills the dive's other members only where the form is empty", () => {
const { form, written } = formHoldingValues({
notes: "Typed on the boat",
tags: [],
weight: undefined,
});
applyParsedDiveToForm(
form,
parsedDive([], { notes: "From the logbook", tags: ["reef"], weight: 4 }),
() => {},
"fill-only",
);
expect(written).not.toHaveProperty("notes");
expect(written.tags).toEqual(["reef"]);
expect(written.weight).toBe(4);
});
it("treats zero as a reading, not as an absence", () => {
// A freedive that surfaced logs `max_depth` 0, and a falsiness check would
// overwrite it from the second file.
const { form, written } = formHoldingValues({ max_depth: 0 });
applyParsedDiveToForm(
form,
parsedDive([], { max_depth: 19.1 }),
() => {},
"fill-only",
);
expect(written).not.toHaveProperty("max_depth");
});
it("fills a blank cylinder member without touching the ones that are filled", () => {
// The case the whole rule was designed around, on the form: the Suunto
// app's JSON gives cylinder 0 its pressures and no oxygen fraction, and the
// same computer's FIT of the same dive has the fraction. The FIT's oxygen
// lands; the pressures stay the JSON's, because the FIT's would be a second
// fill's figures read against the first's.
const replaced: DiveMixtureInput[][] = [];
const { form } = formHoldingValues({}, [
onForm({ start_pressure: 205.11, end_pressure: 91.55, oxygen: "" }),
]);
applyParsedDiveToForm(
form,
parsedDive([
parsed({ oxygen: 33, start_pressure: 210, end_pressure: 80 }),
]),
(mixtures) => replaced.push(mixtures),
"fill-only",
);
expect(replaced).toHaveLength(1);
expect(replaced[0][0].oxygen).toBe(33);
expect(replaced[0][0].start_pressure).toBe(205.11);
expect(replaced[0][0].end_pressure).toBe(91.55);
});
it("will not reshape a cylinder list it cannot line up", () => {
// A file describing a different number of cylinders cannot be lined up
// with the form's rows by position, and the API pairs it at the attach.
// Carrying a stage bottle's readings onto a back gas is worse than leaving
// a blank.
const replaced: DiveMixtureInput[][] = [];
const { form } = formHoldingValues({}, [onForm({ oxygen: 32 })]);
applyParsedDiveToForm(
form,
parsedDive([parsed({ oxygen: 33 }), parsed({ oxygen: 50 })]),
(mixtures) => replaced.push(mixtures),
"fill-only",
);
expect(replaced).toHaveLength(0);
});
it("takes the file's cylinders whole onto a form that has none", () => {
// Filling by definition: there is nothing to overwrite. The create form
// starts here, and so does an edit form for a dive logged without gas.
const replaced: DiveMixtureInput[][] = [];
const { form } = formHoldingValues({}, []);
applyParsedDiveToForm(
form,
parsedDive([parsed({ oxygen: 33 })]),
(mixtures) => replaced.push(mixtures),
"fill-only",
);
expect(replaced[0]).toHaveLength(1);
expect(replaced[0][0].oxygen).toBe(33);
});
it("raises no guessed-value notes, having guessed nothing", () => {
// Those sentences exist to flag a value the import invented and the diver
// should check. A fill that writes only into blanks has invented nothing,
// and a note here would send them to check a field nothing touched.
const { form } = formHoldingValues({}, [onForm({ volume: 12 })]);
const notes = applyParsedDiveToForm(
form,
parsedDive([parsed({ oxygen: 33 })]),
() => {},
"fill-only",
);
expect(notes.guessed).toEqual({});
expect(describeMixtureImport(notes)).toBeNull();
});
});
// A form row recording only what the overrides say - `onForm` starts from
// `DEFAULT_MIXTURE`'s air, which would be a recorded mix in every case below.
function blankRow(overrides: Partial<DiveMixtureInput> = {}): DiveMixtureInput {
return {
volume: "",
start_pressure: "",
end_pressure: "",
oxygen: "",
helium: "",
po2_limit: "",
role: "",
usage: "",
...overrides,
};
}
// Applies a later file's cylinders onto a form holding `rows`, and returns the
// list the form was given, or `undefined` where it was left alone.
function fillOnto(rows: DiveMixtureInput[], cylinders: ParsedDiveMixture[]) {
const replaced: DiveMixtureInput[][] = [];
const { form } = formHoldingValues({}, rows);
applyParsedDiveToForm(
form,
parsedDive(cylinders),
(mixtures) => replaced.push(mixtures),
"fill-only",
);
return replaced[0];
}
describe("a later file's cylinders on a form that has some", () => {
it("fills no cylinder from a file listing the form's in another order", () => {
expect(
fillOnto(
[
blankRow({ oxygen: 21, helium: 0 }),
blankRow({ oxygen: 50, helium: 0 }),
],
[
parsed({ oxygen: 50, helium: 0, start_pressure: 200 }),
parsed({ oxygen: 21, helium: 0, start_pressure: 210 }),
],
),
).toBeUndefined();
});
it("fills none where a cylinder's mix is another row's", () => {
// The API pairs the file's 21 % with the form's second row by mix, and the
// file's other cylinder with the first row by position - not the pairs a
// positional fill would make.
expect(
fillOnto(
[blankRow(), blankRow({ oxygen: 21, helium: 0 })],
[
parsed({ oxygen: 21, helium: 0, volume: 12 }),
parsed({ start_pressure: 200, end_pressure: 60 }),
],
),
).toBeUndefined();
});
it("fills none where a position pair records two different fractions", () => {
expect(
fillOnto([blankRow({ oxygen: 32 })], [parsed({ oxygen: 33 })]),
).toBeUndefined();
});
it("fills none from pressures with no mix over rows of 21 % and 50 %", () => {
// Which tank drained to 60 bar is a guess the file's mixes would settle.
expect(
fillOnto(
[
blankRow({ oxygen: 21, helium: 0 }),
blankRow({ oxygen: 50, helium: 0 }),
],
[
parsed({ start_pressure: 200, end_pressure: 60 }),
parsed({ start_pressure: 210, end_pressure: 150 }),
],
),
).toBeUndefined();
});
it("fills none from a second computer's mixes over rows carrying pressures and no mix", () => {
expect(
fillOnto(
[
blankRow({ start_pressure: 200, end_pressure: 60 }),
blankRow({ start_pressure: 210, end_pressure: 150 }),
],
[parsed({ oxygen: 21, helium: 0 }), parsed({ oxygen: 50, helium: 0 })],
),
).toBeUndefined();
});
it("fills a row that records nothing, and leaves the one that records a mix", () => {
const rows = [blankRow({ oxygen: 21, helium: 0 }), blankRow()];
const filled = fillOnto(rows, [
parsed({ start_pressure: 200, end_pressure: 60 }),
parsed({ start_pressure: 210, end_pressure: 150 }),
]);
expect(filled?.[0]).toEqual(rows[0]);
expect(filled?.[1].start_pressure).toBe(210);
expect(filled?.[1].end_pressure).toBe(150);
});
it("fills every row a cylinder pairs with by mix", () => {
const filled = fillOnto(
[
blankRow({ oxygen: 21, helium: 0 }),
blankRow({ oxygen: 50, helium: 0 }),
],
[
parsed({ oxygen: 21, helium: 0, start_pressure: 200 }),
parsed({ oxygen: 50, helium: 0, start_pressure: 210 }),
],
);
expect(filled?.map((row) => row.start_pressure)).toEqual([200, 210]);
});
it("fills the one-cylinder pair's row whichever file came first", () => {
// The Suunto Ocean's JSON carries the pressures and no mix, its FIT the mix
// and no pressures. One row meeting one cylinder is a pair no later file can
// re-pair, so it fills even though each side records what the other lacks.
const fitThenJson = fillOnto(
[blankRow({ oxygen: 21, helium: 0 })],
[parsed({ start_pressure: 212.81, end_pressure: 83.59, gas_number: 0 })],
);
const jsonThenFit = fillOnto(
[blankRow({ start_pressure: 212.81, end_pressure: 83.59 })],
[parsed({ oxygen: 21, helium: 0 })],
);
for (const filled of [fitThenJson, jsonThenFit]) {
expect(filled?.[0]).toMatchObject({
oxygen: 21,
helium: 0,
start_pressure: 212.81,
end_pressure: 83.59,
});
}
});
it("writes no label, leaving the one a row has", () => {
const filled = fillOnto(
[blankRow(), blankRow({ gas_number: 1 })],
[
parsed({ start_pressure: 200, gas_number: 0 }),
parsed({ start_pressure: 210, gas_number: 3 }),
],
);
expect(filled?.[0].start_pressure).toBe(200);
expect(filled?.[0].gas_number).toBeUndefined();
expect(filled?.[1].gas_number).toBe(1);
});
it("writes no label onto a form that had no cylinders", () => {
const filled = fillOnto([], [parsed({ oxygen: 21, gas_number: 0 })]);
expect(filled?.[0].oxygen).toBe(21);
expect(filled?.[0].gas_number).toBeUndefined();
});
});
describe("a later file's fill that would re-pair the rows it fills", () => {
it("fills none where a mix it writes makes a row another cylinder's match", () => {
// Two air tanks. The form's second row records nothing, so it could take
// the file's second cylinder's 21 %; but then the API, pairing the saved
// rows by mix, gives it the file's first cylinder and that tank's
// pressures, beside the first row's own.
expect(
fillOnto(
[blankRow({ start_pressure: 200, end_pressure: 50 }), blankRow()],
[
parsed({ oxygen: 21, start_pressure: 198, end_pressure: 52 }),
parsed({ oxygen: 21 }),
],
),
).toBeUndefined();
});
});
describe("a later file's cylinders sharing one mix", () => {
it("fills two blank rows from two air cylinders, which the API pairs in order", () => {
const filled = fillOnto(
[blankRow(), blankRow()],
[
parsed({ oxygen: 21, helium: 0, start_pressure: 200 }),
parsed({ oxygen: 21, helium: 0, start_pressure: 190 }),
],
);
expect(filled?.map((row) => [row.oxygen, row.start_pressure])).toEqual([
[21, 200],
[21, 190],
]);
});
it("fills none where the API would give a shared mix's first row to a later cylinder", () => {
// The first cylinder records no mix, so the API gives the second the first
// row recording 21 % - the form's first, not its second.
expect(
fillOnto(
[
blankRow({ oxygen: 21, helium: 0 }),
blankRow({ oxygen: 21, helium: 0 }),
],
[parsed({ start_pressure: 200 }), parsed({ oxygen: 21, helium: 0 })],
),
).toBeUndefined();
});
});
describe("applyParsedDiveToForm with a figure an earlier file derived", () => {
// One Suunto Ocean dive's two exports, as the API reads them. The FIT states
// neither figure, so the reader derives both; the JSON states both. Their
// devices are as each file names the watch, and are one computer.
const oceanFit = () =>
parsedDive([], {
submerged_time: 3440,
avg_depth: 10.58,
max_depth: 19.1,
inferred: ["submerged_time", "avg_depth"],
device: { brand: "suunto", model: "Suunto Ocean" },
});
const oceanJson = () =>
parsedDive([], {
submerged_time: 3424,
avg_depth: 10.62,
max_depth: 19.04,
inferred: [],
device: { brand: "Suunto", serial: "253810000400" },
});
const perdixUddf = () =>
parsedDive([], {
submerged_time: 3380,
avg_depth: 10.4,
max_depth: 18.9,
inferred: [],
device: { brand: "Shearwater", model: "Perdix 3", serial: "D9772626" },
});
// Applies each file as the form does within one session: the first prefills,
// every later one fills, all against the one session's marks.
function applyInTurn(
values: Record<string, unknown>,
...files: (ReturnType<typeof parsedDive> | ((v: typeof values) => void))[]
) {
const { form } = formHoldingValues(values);
const marks: InferredFigures = {};
let first = true;
for (const file of files) {
if (typeof file === "function") {
file(values);
continue;
}
applyParsedDiveToForm(
form,
file,
() => {},
first ? "prefill" : "fill-only",
marks,
);
first = false;
}
return values;
}
it("takes the JSON's figures when the FIT came first", () => {
const values = applyInTurn({}, oceanFit(), oceanJson());
expect(values.submerged_time).toBe("57:04");
expect(values.avg_depth).toBe(10.62);
// The FIT stated its maximum, so the JSON only fills.
expect(values.max_depth).toBe(19.1);
});
it("keeps the JSON's figures when the FIT comes second", () => {
const values = applyInTurn({}, oceanJson(), oceanFit());
expect(values.submerged_time).toBe("57:04");
expect(values.avg_depth).toBe(10.62);
});
it("keeps a dive time the diver typed between the two", () => {
const values = applyInTurn(
{},
oceanFit(),
(v) => {
v.submerged_time = "57:00";
},
oceanJson(),
);
expect(values.submerged_time).toBe("57:00");
expect(values.avg_depth).toBe(10.62);
});
it("keeps the first of two FITs, neither stating its figures", () => {
const second = { ...oceanFit(), submerged_time: 3500, avg_depth: 10.1 };
const values = applyInTurn({}, oceanFit(), second);
expect(values.submerged_time).toBe("57:20");
expect(values.avg_depth).toBe(10.58);
});
it("keeps the Ocean's derived figures against another computer's stated ones", () => {
const values = applyInTurn({}, oceanFit(), perdixUddf());
expect(values.submerged_time).toBe("57:20");
expect(values.avg_depth).toBe(10.58);
});
it("replaces a derived figure only once", () => {
// The JSON's figure is stated, so a third file of the computer only fills.
const values = applyInTurn({}, oceanFit(), oceanJson(), {
...oceanJson(),
submerged_time: 3400,
});
expect(values.submerged_time).toBe("57:04");
});
it("fills only, as before, from an API build that marks nothing", () => {
const { inferred: _fit, ...fit } = oceanFit();
const { inferred: _json, ...json } = oceanJson();
const values = applyInTurn({}, fit, json);
expect(values.submerged_time).toBe("57:20");
expect(values.avg_depth).toBe(10.58);
});
});