src/lib/dive-import.test.ts
Rama main · réplica de lectura del repositorio original.
import { describe, it, expect } from "vitest";
import {
describeMixtureImport,
mergeMixture,
mixtureImportNotes,
type MixtureImportNotes,
} from "./dive-import";
import { DEFAULT_MIXTURE } from "@/lib/dive-mixtures";
import type { 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 };
}
describe("mergeMixture", () => {
it("keeps every value the export actually recorded", () => {
const mixture = mergeMixture(
parsed({
volume: 12,
start_pressure: 207.14,
end_pressure: 122.44,
oxygen: 32,
helium: 0,
po2_limit: 1.4,
gas_number: 1,
role: "bottom",
}),
).value;
expect(mixture).toEqual({
volume: 12,
start_pressure: 207.14,
end_pressure: 122.44,
oxygen: 32,
helium: 0,
po2_limit: 1.4,
gas_number: 1,
role: "bottom",
// Cleared, and it can be nothing else: no format this app parses carries
// the flag, so `ParsedDiveMixture` has no `usage` for a file to supply.
usage: "",
// Nor does any file name the diver's own gear.
gear_item_uuid: "",
gear_item: null,
});
});
it("keeps a usage flag the diver set before importing the file", () => {
// `mergeMixture` builds its result field by field, so a field left out of that
// object is silently *dropped* rather than preserved - the exact
// form-value-lost-on-import failure its own JSDoc records. `usage` is the one
// field with no file tier at all, which makes it the one where losing the
// form's value loses it outright: nothing would ever put it back.
const mixture = mergeMixture(
parsed({ oxygen: 21, helium: 0 }),
onForm({ usage: "parallel" }),
).value;
expect(mixture.usage).toBe("parallel");
});
it("leaves the tech fields empty when the file recorded none", () => {
// An absent ppO2 limit falls back to PPO2_WORKING where a MOD is computed, and an
// absent role or gas number simply isn't shown - so there is nothing here for the
// import note to warn about. Volume and the gas fractions are left empty too now,
// but they *are* in the note's field list, because a value carried over from the
// previous dive's cylinder is a number on screen this file never recorded.
const mixture = mergeMixture(parsed({ oxygen: 21, helium: 0 })).value;
// `""` for the two the form has an input for - the cleared state their
// fields read back - and `undefined` for the one it doesn't.
expect(mixture.po2_limit).toBe("");
expect(mixture.gas_number).toBeUndefined();
expect(mixture.role).toBe("");
});
it("carries the form's tech fields when the file has none of its own", () => {
// The middle tier, same as the pressures: importing a second export for one dive
// (the Ocean's FIT and JSON are complementary) must not erase what the first
// contributed.
const mixture = mergeMixture(
parsed({ oxygen: 21, helium: 0 }),
onForm({ po2_limit: 1.6, gas_number: 2, role: "deco" }),
).value;
expect(mixture.po2_limit).toBe(1.6);
expect(mixture.gas_number).toBe(2);
expect(mixture.role).toBe("deco");
});
it("prefers the file's tech fields over the form's", () => {
const mixture = mergeMixture(
parsed({ oxygen: 21, helium: 0, po2_limit: 1.4, gas_number: 0 }),
onForm({ po2_limit: 1.6, gas_number: 2 }),
).value;
expect(mixture.po2_limit).toBe(1.4);
// Zero is a value, not an absence - the reader labels the first cylinder 0, so
// `??` rather than `||` is what keeps that number from being replaced.
expect(mixture.gas_number).toBe(0);
});
it("leaves gas and volume the export never recorded blank", () => {
// The 2026 Suunto Ocean JSON export records transmitter pressures but no gas
// fraction and no tank size anywhere, and a FIT file cannot express cylinder size
// at all. The API sends `null` rather than inventing air, and the form does not
// convert that back into a number: every one of these boxes stays empty, saves as
// NULL, and shows on the dive page as an absence. `DEFAULT_MIXTURE` is what a
// cylinder added *by hand* starts from - only the import path does not guess.
const mixture = mergeMixture(
parsed({ start_pressure: 205.11, end_pressure: 91.55 }),
).value;
expect(mixture.volume).toBe("");
expect(mixture.oxygen).toBe("");
expect(mixture.helium).toBe("");
// The values that *were* read are untouched.
expect(mixture.start_pressure).toBe(205.11);
expect(mixture.end_pressure).toBe(91.55);
});
it("does not carry a blank form field over as if it were a value", () => {
// The form's cleared state is `""`, which is a value in the union and not a gap:
// read with `!= null` it would count as something the form held, and the import
// note would report a carry-over of an empty box. A diver who cleared the size on
// the previous dive gets a blank here, and no sentence about it.
const merged = mergeMixture(
parsed({ start_pressure: 205.11 }),
onForm({ volume: "", oxygen: "", helium: "" }),
);
expect(merged.value.volume).toBe("");
expect(merged.sources.volume).toBe("blank");
expect(merged.sources.oxygen).toBe("blank");
});
it("treats a recorded zero as a reading, not as a gap", () => {
// 0 % helium on a nitrox fill is a real recorded value, and `!= null` rather than
// a truthiness test is what keeps it from being read as an absence and blanked.
const mixture = mergeMixture(parsed({ helium: 0, oxygen: 0 })).value;
expect(mixture.helium).toBe(0);
expect(mixture.oxygen).toBe(0);
});
it("leaves unrecorded pressures blank rather than zero", () => {
// "" is the form's unset placeholder for the pressure fields - an
// untransmitted deco cylinder has no start pressure, and 0 bar would read as
// an empty tank.
const mixture = mergeMixture(parsed({ oxygen: 49, volume: 11 })).value;
expect(mixture.start_pressure).toBe("");
expect(mixture.end_pressure).toBe("");
});
it("keeps what the form already held for anything the file lacks", () => {
// The two Suunto Ocean exports are complementary - the FIT carries gas mixes
// and no transmitter data, the JSON carries pressures and no gas mix - so
// importing both for one dive is the documented way to log a multi-gas dive.
// Before this, the second import silently erased the first one's half.
const mixture = mergeMixture(
parsed({ oxygen: 21, helium: 0 }),
onForm({ volume: 15, start_pressure: 211.62, end_pressure: 127.16 }),
).value;
expect(mixture.start_pressure).toBe(211.62);
expect(mixture.end_pressure).toBe(127.16);
expect(mixture.volume).toBe(15);
// The file's own values still win over what was there.
expect(mixture.oxygen).toBe(21);
});
it("never mixes a file pressure with a form pressure", () => {
// One from each describes a fill that never existed: it either trips the
// form's `end <= start` refine on a field the diver never touched, or passes
// and feeds `compute_gas_use` a consumption spanning two different fills.
const partial = mergeMixture(
parsed({ start_pressure: 180, oxygen: 21 }),
onForm({ start_pressure: 100, end_pressure: 127 }),
).value;
expect(partial.start_pressure).toBe(180);
expect(partial.end_pressure).toBe("");
// The carry-over still happens when the file supplies neither.
const carried = mergeMixture(
parsed({ oxygen: 21 }),
onForm({ start_pressure: 211.62, end_pressure: 127.16 }),
).value;
expect(carried.start_pressure).toBe(211.62);
expect(carried.end_pressure).toBe(127.16);
});
it("prefers the file's values over the form's", () => {
const mixture = mergeMixture(
parsed({ volume: 12, start_pressure: 200 }),
onForm({ volume: 15, start_pressure: 180 }),
).value;
expect(mixture.volume).toBe(12);
expect(mixture.start_pressure).toBe(200);
});
});
describe("describeMixtureImport", () => {
const notesFor = (
guessed: MixtureImportNotes["guessed"],
rest: Partial<MixtureImportNotes> = {},
): MixtureImportNotes => ({
guessed,
keptPressures: false,
discardedPressures: false,
keptLinkedVolume: false,
droppedLinks: false,
...rest,
});
it("says nothing when the file recorded everything", () => {
expect(describeMixtureImport(notesFor({}))).toBeNull();
});
it("says the values were kept when they came off the form", () => {
const note = describeMixtureImport(
notesFor({ volume: "form", oxygen: "form" }),
);
expect(note).toContain("Those values were already on this form");
expect(note).not.toContain("default");
});
it("says it in the singular for one carried-over field", () => {
expect(describeMixtureImport(notesFor({ volume: "form" }))).toContain(
"That value was already on this form",
);
});
it("says nothing about a field neither the file nor the form had", () => {
// The blank case is not a note. There is no number on screen to mistake for a
// reading - the box is empty, the save stores NULL, and the dive page renders the
// absence as an absence. A sentence 2 200 px above the empty box would be the
// second place for that fact to live and the one that could go stale.
const merged = [
mergeMixture(parsed({ start_pressure: 205.11, end_pressure: 91.55 })),
];
const notes = mixtureImportNotes(
[parsed({ start_pressure: 205.11, end_pressure: 91.55 })],
merged,
[],
);
expect(notes.guessed).toEqual({});
expect(describeMixtureImport(notes)).toBeNull();
});
it("quotes no values at all", () => {
// The note lives in the import card and the gas fields are ~2 200 px below it, so a
// quoted figure can never be compared with the one in the box - and quoting a value
// is what forced the sentence to track the form and go stale when it didn't.
const note = describeMixtureImport(
notesFor({ volume: "form", oxygen: "form", helium: "form" }),
);
expect(note).not.toMatch(/\d/);
expect(note).toContain("check the tanks below");
});
it("names helium on its own rather than an oxygen it didn't guess", () => {
const note = describeMixtureImport(notesFor({ helium: "form" }));
expect(note).toContain("helium fraction");
});
it("folds helium into the gas mix when both were guessed", () => {
const note = describeMixtureImport(
notesFor({ oxygen: "form", helium: "form" }),
);
expect(note).toContain("gas mix");
expect(note).not.toContain("helium fraction");
});
it("reports pressures carried over from the form", () => {
expect(
describeMixtureImport(notesFor({}, { keptPressures: true })),
).toContain("Tank pressures already on this dive were kept");
});
it("says nothing about lost pressures when the file brought its own", () => {
// `discardedPressures` asked only whether the old pressures had nowhere to go, never
// whether anything replaced them - so a two-cylinder transmitter file imported onto a
// one-cylinder edit form claimed a loss while the form held the file's own readings.
const cylinders = [
parsed({
start_pressure: 210,
end_pressure: 70,
oxygen: 21,
helium: 0,
volume: 12,
}),
parsed({
start_pressure: 180,
end_pressure: 90,
oxygen: 50,
helium: 0,
volume: 11,
}),
];
const merged = cylinders.map((mixture) => mergeMixture(mixture));
const notes = mixtureImportNotes(cylinders, merged, [
onForm({ start_pressure: 200, end_pressure: 60 }),
]);
expect(notes.discardedPressures).toBe(false);
expect(describeMixtureImport(notes)).toBeNull();
});
it("reports pressures lost because the cylinder counts differed", () => {
// `existingMixtureFor` refuses to pair, so there is nowhere to carry them - and on
// the edit form that takes the dive's `gas_use` with it, silently.
expect(
describeMixtureImport(notesFor({}, { discardedPressures: true })),
).toContain("were cleared");
});
});
// A tank linked to one of the diver's cylinders, as the form holds it after a pick.
const AL80 = {
uuid: "vessel-1",
name: "AL80",
type: "cylinder" as const,
rented: false,
is_archived: false,
volume: 11.1,
working_pressure: 207,
material: "aluminium",
};
const linked = (overrides: Partial<DiveMixtureInput> = {}) =>
onForm({
volume: 11.1,
gear_item_uuid: AL80.uuid,
gear_item: AL80,
...overrides,
});
describe("the first file's apply over a linked tank", () => {
it("keeps the link and the tank's own volume where the counts match", () => {
const merged = mergeMixture(parsed({ volume: 12, oxygen: 32 }), linked());
expect(merged.value.gear_item_uuid).toBe(AL80.uuid);
expect(merged.value.gear_item).toEqual(AL80);
expect(merged.value.volume).toBe(11.1);
// The rest is the file's, as on any tank.
expect(merged.value.oxygen).toBe(32);
expect(merged.sources.volume).toBe("vessel");
});
it("keeps the document's volume a link an import made", () => {
// Not the vessel's: an import keeps the cylinder volume its document stated.
const merged = mergeMixture(
parsed({ volume: 12, oxygen: 32 }),
linked({ volume: 10 }),
);
expect(merged.value.volume).toBe(10);
});
it("takes the vessel's capacity for a linked tank holding none", () => {
const merged = mergeMixture(
parsed({ volume: 12, oxygen: 32 }),
linked({ volume: "" }),
);
expect(merged.value.volume).toBe(11.1);
});
it("takes the file's volume for a tank whose vessel records no capacity", () => {
const merged = mergeMixture(
parsed({ volume: 12 }),
linked({ volume: "", gear_item: { ...AL80, volume: null } }),
);
expect(merged.value.volume).toBe(12);
expect(merged.value.gear_item_uuid).toBe(AL80.uuid);
});
it("takes the file's volume over a typed one where the vessel records no capacity", () => {
// The box was editable, so the figure is the tank's own, as on an unlinked tank.
const merged = mergeMixture(
parsed({ volume: 15 }),
linked({ volume: 12, gear_item: { ...AL80, volume: null } }),
);
expect(merged.value.volume).toBe(15);
expect(merged.sources.volume).toBe("file");
expect(merged.value.gear_item_uuid).toBe(AL80.uuid);
});
it("says the file's different size was not taken, and only then", () => {
const parsedRows = [parsed({ volume: 12, oxygen: 32 })];
const differing = mixtureImportNotes(
parsedRows,
[mergeMixture(parsedRows[0], linked())],
[linked()],
);
expect(differing.keptLinkedVolume).toBe(true);
expect(describeMixtureImport(differing)).toMatch(
/kept that cylinder's size; the file's different size was not taken/,
);
const sameRows = [parsed({ volume: 11.1, oxygen: 32, helium: 0 })];
const same = mixtureImportNotes(
sameRows,
[mergeMixture(sameRows[0], linked())],
[linked()],
);
expect(same.keptLinkedVolume).toBe(false);
expect(describeMixtureImport(same)).toBeNull();
});
it("replaces the tanks, links included, where the counts differ, and says so", () => {
const existing = [linked()];
const parsedRows = [parsed({ volume: 12 }), parsed({ volume: 7 })];
// The apply pairs nothing when the counts differ (`existingMixtureFor`).
const merged = parsedRows.map((row) => mergeMixture(row));
expect(merged.map((row) => row.value.gear_item_uuid)).toEqual(["", ""]);
expect(merged.map((row) => row.value.volume)).toEqual([12, 7]);
const notes = mixtureImportNotes(parsedRows, merged, existing);
expect(notes.droppedLinks).toBe(true);
expect(describeMixtureImport(notes)).toMatch(
/tanks were replaced .* no longer linked to your gear/,
);
});
it("says nothing about links where none stood", () => {
const parsedRows = [parsed({ volume: 12 }), parsed({ volume: 7 })];
const notes = mixtureImportNotes(
parsedRows,
parsedRows.map((row) => mergeMixture(row)),
[onForm()],
);
expect(notes.droppedLinks).toBe(false);
});
});