Ctrl K
ComunidadGuías…

src/lib/logbook-import.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { describe, expect, it } from "vitest";
import {
  collectionLabel,
  collectionRowIsEmpty,
  fileRestoreHint,
  importButtonLabel,
  importCountsSentence,
  importFileRefusal,
  importOutcomeLabel,
  importRowHasCard,
  importSelectionRefusal,
  importStartText,
  importSummaryTerms,
  importTotals,
  isHiddenImportPath,
  isZipFile,
  noteGroupPlacesSentence,
  noteKindLabel,
  noteKindTone,
  reviewCollectionRows,
  truncatedDiveNotesSentence,
  truncatedNotesSentence,
} from "./logbook-import";
import type {
  ImportCollectionReport,
  ImportFileReport,
  ImportNoteGroup,
} from "@/lib/api/logbook-import";
import { importDiveRow as dive } from "@/test/dive-fixtures";

function row(
  collection: string,
  counts: Partial<Omit<ImportCollectionReport, "collection">> = {},
): ImportCollectionReport {
  return {
    collection,
    created: 0,
    linked: 0,
    skipped: 0,
    ...counts,
  };
}

function fileReport(
  overrides: Partial<ImportFileReport> = {},
): ImportFileReport {
  return {
    referenced: 0,
    restored: 0,
    not_contained: 0,
    skipped: 0,
    ...overrides,
  };
}

describe("collectionLabel", () => {
  it("names each envelope collection", () => {
    // The envelope's own order, which is the order the API returns them in.
    expect(
      [
        "dives",
        "trips",
        "courses",
        "sites",
        "species",
        "gear",
        "gear_sets",
        "gear_service_schedules",
        "gear_service_records",
        "certifications",
        "contacts",
        "people",
      ].map(collectionLabel),
    ).toEqual([
      "Dives",
      "Trips",
      "Courses",
      "Dive sites",
      "Marine life",
      "Gear",
      "Gear sets",
      "Service schedules",
      "Service records",
      "Certifications",
      "Contacts",
      "People",
    ]);
  });

  it("renders a collection this build has never heard of", () => {
    // A newer API that grew a collection this build does not know must produce a
    // readable row, not a blank heading over correct numbers.
    expect(collectionLabel("dive_computers")).toBe("Dive computers");
  });
});

describe("importTotals", () => {
  it("sums each count across the rows", () => {
    const totals = importTotals([
      row("dives", { created: 4, skipped: 1 }),
      row("sites", { created: 1, linked: 3 }),
    ]);

    expect(totals).toEqual({ created: 5, linked: 3, skipped: 1 });
  });

  it("is all zeroes for no rows", () => {
    expect(importTotals([])).toEqual({ created: 0, linked: 0, skipped: 0 });
  });
});

describe("collectionRowIsEmpty", () => {
  it("is true only when all three counts are zero", () => {
    expect(collectionRowIsEmpty(row("dives"))).toBe(true);
    expect(collectionRowIsEmpty(row("dives", { skipped: 1 }))).toBe(false);
    expect(collectionRowIsEmpty(row("dives", { linked: 1 }))).toBe(false);
  });
});

describe("truncatedNotesSentence", () => {
  it("says nothing when the list is the whole story", () => {
    expect(truncatedNotesSentence(0)).toBeNull();
  });

  it("says how many are missing", () => {
    // Without it a capped list reads as if it were all of them.
    expect(truncatedNotesSentence(112)).toBe(
      "112 further notes are not shown.",
    );
  });

  it("does not say '1 further notes'", () => {
    expect(truncatedNotesSentence(1)).toBe("1 further note is not shown.");
  });
});

describe("fileRestoreHint", () => {
  it("tells a bare document's importer where the bytes are", () => {
    const hint = fileRestoreHint(
      fileReport({ referenced: 3, not_contained: 3 }),
      false,
    );

    expect(hint).toContain("3 files");
    expect(hint).toContain(".zip");
  });

  it("says nothing after an archive import, where the bytes already came", () => {
    expect(
      fileRestoreHint(fileReport({ referenced: 3, restored: 3 }), true),
    ).toBeNull();
  });

  it("says nothing for a document that references no files at all", () => {
    expect(fileRestoreHint(fileReport(), false)).toBeNull();
  });

  it("does not say 'These 1 files'", () => {
    expect(
      fileRestoreHint(fileReport({ referenced: 1, not_contained: 1 }), false),
    ).toContain("This file is");
  });
});

function group(overrides: Partial<ImportNoteGroup> = {}): ImportNoteGroup {
  return {
    code: "value_dropped",
    kind: "dropped",
    collection: "dives",
    message: "Visibility is a star rating here and has no metric equivalent.",
    count: 1,
    wheres: ["dive/0"],
    ...overrides,
  };
}

describe("noteKindLabel", () => {
  it("names each kind the API sends today", () => {
    expect(noteKindLabel("absent")).toBe("Not recorded");
    expect(noteKindLabel("inferred")).toBe("Worked out");
    expect(noteKindLabel("resolved")).toBe("Resolved");
    expect(noteKindLabel("dropped")).toBe("Dropped");
  });

  it("gives a note with no kind no badge", () => {
    expect(noteKindLabel(null)).toBeNull();
  });

  it("de-snakes a kind this build has never heard of", () => {
    // The API sends `kind` as an opaque string deliberately: the readers' kind
    // set grew from three to four once, and the pin deciding which set a build
    // sees moves with no change here. A blank badge would be worse than the raw
    // word.
    expect(noteKindLabel("partially_carried")).toBe("Partially carried");
  });
});

describe("noteKindTone", () => {
  it("is a warning only where the diver ends up with less", () => {
    expect(noteKindTone("dropped")).toBe("warning");
  });

  it("keeps the import's working plain, and an absence quietest", () => {
    // `inferred` and `resolved` explain rather than report a loss, and a note
    // with no kind is a decision such as a linked record or a file a bare
    // document does not carry - the import working as designed. `absent` is a
    // property of the file: it never recorded the thing at all.
    expect(noteKindTone("inferred")).toBe("neutral");
    expect(noteKindTone("resolved")).toBe("neutral");
    expect(noteKindTone(null)).toBe("neutral");
    expect(noteKindTone("absent")).toBe("muted");
  });

  it("reads a kind from a newer API as information, not alarm", () => {
    // Reachable without anyone touching this repository - which is the whole
    // reason it is here.
    expect(noteKindTone("stretched")).toBe("neutral");
  });
});

describe("noteGroupPlacesSentence", () => {
  it("says how many places raised it, and where three of them were", () => {
    expect(
      noteGroupPlacesSentence(
        group({ count: 15, wheres: ["dive/0", "dive/1", "dive/2"] }),
      ),
    ).toBe("15 places — dive/0, dive/1, dive/2 and 12 more");
  });

  it("does not claim more when it listed all of them", () => {
    expect(
      noteGroupPlacesSentence(
        group({ count: 2, wheres: ["dive/0", "dive/3"] }),
      ),
    ).toBe("2 places — dive/0, dive/3");
  });

  it("does not say '1 places'", () => {
    expect(noteGroupPlacesSentence(group({ count: 1 }))).toBe(
      "1 place — dive/0",
    );
  });

  it("counts the import's own notes, which name no place", () => {
    expect(
      noteGroupPlacesSentence(
        group({ code: "record_linked", kind: null, count: 4, wheres: [] }),
      ),
    ).toBe("4 times");
  });

  it("says nothing of a note that came up once and names no place", () => {
    expect(noteGroupPlacesSentence(group({ count: 1, wheres: [] }))).toBeNull();
  });
});

describe("truncatedDiveNotesSentence", () => {
  it("says nothing when the row lists every note", () => {
    expect(truncatedDiveNotesSentence(0)).toBeNull();
  });

  it("says how many of the dive's notes are missing, and where they went", () => {
    expect(truncatedDiveNotesSentence(1)).toBe(
      "1 further note about this dive is listed with the import's notes below.",
    );
    expect(truncatedDiveNotesSentence(3)).toBe(
      "3 further notes about this dive are listed with the import's notes below.",
    );
  });
});

describe("reviewCollectionRows", () => {
  const collections = [
    row("dives", { created: 23, skipped: 24 }),
    row("sites", { linked: 1 }),
    row("trips"),
  ];

  it("leaves the dives row to the dive rows wherever there are any", () => {
    // `collections` counts a pair's second file as a skipped dive; its row says
    // "new". Showing both is "Skipped 24" under 23 new dives.
    expect(
      reviewCollectionRows({ collections, dives: [dive()] }).map(
        (r) => r.collection,
      ),
    ).toEqual(["sites"]);
  });

  it("keeps the dives row where there are no dive rows to report them", () => {
    expect(
      reviewCollectionRows({ collections, dives: [] }).map((r) => r.collection),
    ).toEqual(["dives", "sites"]);
  });

  it("is empty when nothing but dives counts", () => {
    expect(
      reviewCollectionRows({
        collections: [row("dives", { created: 2 })],
        dives: [dive()],
      }),
    ).toEqual([]);
  });
});

describe("importOutcomeLabel", () => {
  it("says each outcome in the diver's terms", () => {
    expect(importOutcomeLabel("new")).toBe("New");
    expect(importOutcomeLabel("modified")).toBe("Modified");
    expect(importOutcomeLabel("unchanged")).toBe("Unchanged");
    expect(importOutcomeLabel("skipped")).toBe("Skipped");
  });
});

describe("importStartText", () => {
  it("shows the wall clock with its offset, and nothing nothing recorded", () => {
    expect(importStartText("2026-09-07T11:04:58+03:00")).toMatch(/\+03:00$/);
    expect(importStartText("2026-09-07T11:04:58")).not.toMatch(/[+-]\d\d:\d\d/);
    expect(importStartText("2026-09-07")).not.toMatch(/:/);
    expect(importStartText(null)).toBe("—");
  });
});

describe("importRowHasCard", () => {
  it("is true for a new or modified row that carries its dive", () => {
    expect(importRowHasCard(dive())).toBe(true);
    expect(importRowHasCard(dive({ outcome: "modified" }))).toBe(true);
    expect(importRowHasCard(dive({ dive: null }))).toBe(false);
    expect(importRowHasCard(dive({ outcome: "unchanged" }))).toBe(false);
    // A row the apply skipped keeps the dive the review showed.
    expect(importRowHasCard(dive({ outcome: "skipped" }))).toBe(false);
  });
});

describe("importSummaryTerms", () => {
  const texts = (...outcomes: Parameters<typeof dive>[0][]) =>
    importSummaryTerms(outcomes.map((row) => dive(row))).map(
      (term) => term.text,
    );

  it("counts the rows by outcome, the first term naming what is counted", () => {
    expect(
      texts(
        {},
        {},
        { outcome: "modified" },
        { outcome: "unchanged" },
        { outcome: "skipped" },
      ),
    ).toEqual(["2 new dives", "1 modified", "1 unchanged", "1 skipped"]);
  });

  it("leaves out the zero terms, and is singular for one", () => {
    expect(texts({ outcome: "unchanged" })).toEqual(["1 unchanged dive"]);
    expect(importSummaryTerms([])).toEqual([]);
  });
});

describe("importCountsSentence", () => {
  it("says a history row's counts", () => {
    expect(importCountsSentence({ new: 12, modified: 2 })).toBe(
      "12 new, 2 modified",
    );
    expect(importCountsSentence({ unchanged: 5, skipped: 1 })).toBe(
      "5 unchanged, 1 skipped",
    );
  });

  it("says something for an import of no dives", () => {
    expect(importCountsSentence({})).toBe("No dives");
  });
});

describe("importButtonLabel", () => {
  it("counts the new and modified rows", () => {
    expect(
      importButtonLabel([
        dive(),
        dive({ outcome: "modified" }),
        dive({ outcome: "unchanged" }),
        dive({ outcome: "skipped" }),
      ]),
    ).toBe("Import 2 dives");
    expect(importButtonLabel([dive()])).toBe("Import 1 dive");
  });

  it("never claims the negative where no dive is written", () => {
    // A check-in detail or a tag may still be written.
    expect(
      importButtonLabel([
        dive({ outcome: "unchanged" }),
        dive({ outcome: "skipped", uuid: null }),
      ]),
    ).toBe("Import");
    expect(importButtonLabel([])).toBe("Import");
  });
});

describe("isHiddenImportPath", () => {
  it("drops dot-files, dot-folders and the macOS shadow tree", () => {
    expect(isHiddenImportPath(".DS_Store")).toBe(true);
    expect(isHiddenImportPath("Suunto/.DS_Store")).toBe(true);
    expect(isHiddenImportPath(".git/config")).toBe(true);
    expect(isHiddenImportPath("__MACOSX/Suunto/._1.fit")).toBe(true);
    expect(isHiddenImportPath("Suunto/1.fit")).toBe(false);
  });
});

describe("the client's refusals", () => {
  const sized = (name: string, megabytes: number, type = "") => ({
    name,
    type,
    size: megabytes * 1024 * 1024,
  });

  it("refuses a file that is not a zip past 100 MB, and a zip past 500 MB", () => {
    expect(importFileRefusal(sized("logbook.divejson", 100))).toBeNull();
    expect(importFileRefusal(sized("logbook.divejson", 101))).toMatch(
      /up to 100 MB/,
    );
    expect(importFileRefusal(sized("backup.zip", 499))).toBeNull();
    expect(importFileRefusal(sized("backup", 501, "application/zip"))).toMatch(
      /zip may be up to 500 MB/,
    );
  });

  it("refuses a selection past 1000 files or 500 MB in all", () => {
    expect(importSelectionRefusal([sized("a", 1)])).toBeNull();
    expect(
      importSelectionRefusal(Array.from({ length: 1001 }, () => sized("a", 0))),
    ).toMatch(/Zip them/);
    expect(importSelectionRefusal([sized("a", 300), sized("b", 201)])).toMatch(
      /in parts/,
    );
  });

  it("tells a zip by its name or its type", () => {
    expect(isZipFile({ name: "Dives.ZIP", type: "" })).toBe(true);
    expect(isZipFile({ name: "export", type: "application/zip" })).toBe(true);
    expect(isZipFile({ name: "1.fit", type: "" })).toBe(false);
  });
});