mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Every bundle value that chained facts with " · " now reads as a phrase, a comma list or a parenthesis, in all seven locales, and the em-dash separators in the day, documents, correlation, sharing, environment, vaccination and Apple Health import strings become sentences or clauses. The day view's running list names the record first, with the dose in the name and a life event's category as a chip, and puts a quiet day count below: "Day 4, since 31 December" instead of a dot chain. Document meta, location periods, vaccination rows and mood crosstab rows drop their composed dots too. A guard walks every bundle and fails on any " · " outside a reasoned allowlist, and on an allowlist entry that no longer needs its place.
544 lines
18 KiB
TypeScript
544 lines
18 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
|
|
import {
|
|
buildProcedureChoices,
|
|
buildFlowTimelineItems,
|
|
buildTimelineItems,
|
|
buildVaultListApiSearch,
|
|
classifyUploadFailure,
|
|
countActiveFilters,
|
|
documentCardKeyAction,
|
|
documentDateKey,
|
|
expandRangeSelection,
|
|
formatBytes,
|
|
formatMonthLabel,
|
|
hasProcessingDocument,
|
|
isDocumentProcessing,
|
|
isRecentlyUploaded,
|
|
parseUploadResponse,
|
|
parseVaultSearchParams,
|
|
RECENT_UPLOAD_WINDOW_MS,
|
|
resolveBulkShareDocuments,
|
|
SHARE_LINK_MAX_DOCUMENTS,
|
|
vaultFiltersToSearch,
|
|
} from "../vault-utils";
|
|
|
|
function doc(overrides: Partial<InboundDocumentDto> = {}): InboundDocumentDto {
|
|
return {
|
|
id: "doc-1",
|
|
kind: "DOCTOR_REPORT",
|
|
title: null,
|
|
filename: "letter.pdf",
|
|
mimeType: "application/pdf",
|
|
byteSize: 1024,
|
|
status: "STORED",
|
|
providerType: null,
|
|
reportDate: null,
|
|
documentDate: "2026-03-10",
|
|
errorReason: null,
|
|
factCount: 0,
|
|
pendingCount: 0,
|
|
conditionLinks: [],
|
|
encounterLinks: [],
|
|
servingClass: "inline",
|
|
hasContentIndex: false,
|
|
contentIndexSource: null,
|
|
lastIndexAttemptAt: null,
|
|
lastIndexOutcome: null,
|
|
hasThumbnail: false,
|
|
sourceSystem: null,
|
|
sourceId: null,
|
|
createdAt: "2026-03-11T08:00:00.000Z",
|
|
updatedAt: "2026-03-11T08:00:00.000Z",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("URL facet round-trip", () => {
|
|
it("parses q / kind / episode / year and serialises back identically", () => {
|
|
const search = "q=MRT&kind=IMAGING%2CLAB_RESULT&episode=ep1&year=2025";
|
|
const filters = parseVaultSearchParams(new URLSearchParams(search));
|
|
expect(filters).toEqual({
|
|
q: "MRT",
|
|
kinds: ["IMAGING", "LAB_RESULT"],
|
|
episodeId: "ep1",
|
|
year: 2025,
|
|
});
|
|
expect(vaultFiltersToSearch(filters)).toBe(
|
|
"q=MRT&kind=IMAGING%2CLAB_RESULT&episode=ep1&year=2025",
|
|
);
|
|
});
|
|
|
|
it("accepts repeated kind params and drops unknown kinds", () => {
|
|
const sp = new URLSearchParams();
|
|
sp.append("kind", "IMAGING");
|
|
sp.append("kind", "NOT_A_KIND");
|
|
sp.append("kind", "IMAGING"); // duplicate collapses
|
|
const filters = parseVaultSearchParams(sp);
|
|
expect(filters.kinds).toEqual(["IMAGING"]);
|
|
});
|
|
|
|
it("normalises kind order so the cache key is stable", () => {
|
|
const a = parseVaultSearchParams(
|
|
new URLSearchParams("kind=LAB_RESULT,IMAGING"),
|
|
);
|
|
const b = parseVaultSearchParams(
|
|
new URLSearchParams("kind=IMAGING,LAB_RESULT"),
|
|
);
|
|
expect(a).toEqual(b);
|
|
});
|
|
|
|
it("ignores a malformed year and returns the empty default view", () => {
|
|
expect(parseVaultSearchParams(new URLSearchParams("year=20x5"))).toEqual(
|
|
{},
|
|
);
|
|
expect(vaultFiltersToSearch({})).toBe("");
|
|
});
|
|
|
|
it("counts active facets per chip (search counts once)", () => {
|
|
expect(countActiveFilters({})).toBe(0);
|
|
expect(
|
|
countActiveFilters({
|
|
q: "a",
|
|
kinds: ["IMAGING", "LAB_RESULT"],
|
|
episodeId: "e",
|
|
year: 2026,
|
|
}),
|
|
).toBe(5);
|
|
});
|
|
});
|
|
|
|
describe("API list search", () => {
|
|
it("pins timeline sort, maps episode → episodeId, threads the cursor", () => {
|
|
const search = buildVaultListApiSearch(
|
|
{ kinds: ["IMAGING"], episodeId: "ep1", year: 2025 },
|
|
"cursor-9",
|
|
50,
|
|
);
|
|
const sp = new URLSearchParams(search);
|
|
expect(sp.get("kind")).toBe("IMAGING");
|
|
expect(sp.get("episodeId")).toBe("ep1");
|
|
expect(sp.get("year")).toBe("2025");
|
|
expect(sp.get("sort")).toBe("documentDate");
|
|
expect(sp.get("order")).toBe("desc");
|
|
expect(sp.get("cursor")).toBe("cursor-9");
|
|
expect(sp.get("limit")).toBe("50");
|
|
});
|
|
});
|
|
|
|
describe("timeline items", () => {
|
|
it("flowing: rows run across month boundaries and mark where each month opens", () => {
|
|
const docs = [
|
|
doc({ id: "a", documentDate: "2026-03-20" }),
|
|
doc({ id: "b", documentDate: "2026-03-15" }),
|
|
doc({ id: "c", documentDate: "2026-02-28" }),
|
|
doc({ id: "d", documentDate: "2026-02-10" }),
|
|
doc({ id: "e", documentDate: "2026-01-05" }),
|
|
];
|
|
const items = buildFlowTimelineItems(docs, 3, "UTC");
|
|
// No row is spent on a month: five documents in three columns are two rows.
|
|
expect(items.map((i) => i.type)).toEqual(["row", "row"]);
|
|
expect(items[0]).toMatchObject({
|
|
documents: [docs[0], docs[1], docs[2]],
|
|
monthStarts: { a: "2026-03", c: "2026-02" },
|
|
});
|
|
// February continues into the second row without a second marker.
|
|
expect(items[1]).toMatchObject({
|
|
documents: [docs[3], docs[4]],
|
|
monthStarts: { e: "2026-01" },
|
|
});
|
|
});
|
|
|
|
it("buckets by month and chunks rows to the column count", () => {
|
|
const docs = [
|
|
doc({ id: "a", documentDate: "2026-03-20" }),
|
|
doc({ id: "b", documentDate: "2026-03-15" }),
|
|
doc({ id: "c", documentDate: "2026-03-10" }),
|
|
doc({ id: "d", documentDate: "2026-02-28" }),
|
|
];
|
|
const items = buildTimelineItems(docs, 2, "UTC");
|
|
expect(items.map((i) => i.type)).toEqual([
|
|
"month",
|
|
"row",
|
|
"row",
|
|
"month",
|
|
"row",
|
|
]);
|
|
expect(items[0]).toEqual({ type: "month", key: "2026-03" });
|
|
expect(items[1]).toMatchObject({ documents: [docs[0], docs[1]] });
|
|
expect(items[2]).toMatchObject({ documents: [docs[2]] });
|
|
expect(items[4]).toMatchObject({ documents: [docs[3]] });
|
|
});
|
|
|
|
it("files an undated document under its upload day", () => {
|
|
const d = doc({ documentDate: null, createdAt: "2026-01-05T10:00:00Z" });
|
|
expect(documentDateKey(d, "UTC")).toBe("2026-01-05");
|
|
const items = buildTimelineItems([d], 3, "UTC");
|
|
expect(items[0]).toEqual({ type: "month", key: "2026-01" });
|
|
});
|
|
|
|
it("files an undated upload under the reader's day, not the UTC day", () => {
|
|
// 22:30 on 31 January in Los Angeles is already 1 February in UTC.
|
|
const d = doc({ documentDate: null, createdAt: "2026-02-01T06:30:00Z" });
|
|
expect(documentDateKey(d, "America/Los_Angeles")).toBe("2026-01-31");
|
|
expect(buildTimelineItems([d], 3, "America/Los_Angeles")[0]).toEqual({
|
|
type: "month",
|
|
key: "2026-01",
|
|
});
|
|
// And 1 March at 00:30 in Kiritimati is still 28 February in UTC.
|
|
const e = doc({ documentDate: null, createdAt: "2026-02-28T10:30:00Z" });
|
|
expect(documentDateKey(e, "Pacific/Kiritimati")).toBe("2026-03-01");
|
|
});
|
|
|
|
it("keeps a stated document date whatever the zone", () => {
|
|
const d = doc({
|
|
documentDate: "2026-01-31",
|
|
createdAt: "2026-02-01T06:30:00Z",
|
|
});
|
|
expect(documentDateKey(d, "Pacific/Kiritimati")).toBe("2026-01-31");
|
|
});
|
|
|
|
it("keeps the DOM bounded: item count scales with rows, not documents", () => {
|
|
const docs = Array.from({ length: 1000 }, (_, i) =>
|
|
doc({ id: `d${i}`, documentDate: "2026-03-01" }),
|
|
);
|
|
const items = buildTimelineItems(docs, 4, "UTC");
|
|
// 1 month header + 250 rows — the virtualizer then windows over these.
|
|
expect(items).toHaveLength(251);
|
|
});
|
|
|
|
it("formats a month key in the viewer's locale", () => {
|
|
expect(formatMonthLabel("2026-03", "de")).toBe("März 2026");
|
|
expect(formatMonthLabel("not-a-month", "de")).toBe("not-a-month");
|
|
});
|
|
});
|
|
|
|
describe("byte formatting", () => {
|
|
it("scales units and keeps one decimal below 10", () => {
|
|
expect(formatBytes(0, "en")).toBe("0 B");
|
|
expect(formatBytes(512, "en")).toBe("512 B");
|
|
expect(formatBytes(26_214_400, "en")).toBe("25 MB");
|
|
expect(formatBytes(1_288_490_189, "en")).toBe("1.2 GB");
|
|
});
|
|
});
|
|
|
|
describe("upload response contract (§3.2)", () => {
|
|
it("HTTP 200 + meta.duplicate returns the EXISTING row as a duplicate", () => {
|
|
const body = JSON.stringify({
|
|
data: doc({ id: "existing-row" }),
|
|
error: null,
|
|
meta: { duplicate: true },
|
|
});
|
|
const result = parseUploadResponse(200, body);
|
|
expect(result).toMatchObject({
|
|
ok: true,
|
|
duplicate: true,
|
|
document: { id: "existing-row" },
|
|
});
|
|
});
|
|
|
|
it("a plain 201 success is not a duplicate", () => {
|
|
const body = JSON.stringify({ data: doc({ id: "fresh" }), error: null });
|
|
expect(parseUploadResponse(201, body)).toMatchObject({
|
|
ok: true,
|
|
duplicate: false,
|
|
document: { id: "fresh" },
|
|
});
|
|
});
|
|
|
|
it("maps every meta.reason to its translated-copy class", () => {
|
|
expect(
|
|
parseUploadResponse(
|
|
413,
|
|
JSON.stringify({
|
|
data: null,
|
|
error: "too large",
|
|
meta: { reason: "fileTooLarge", maxFileBytes: 26_214_400 },
|
|
}),
|
|
),
|
|
).toEqual({
|
|
ok: false,
|
|
reason: "fileTooLarge",
|
|
maxFileBytes: 26_214_400,
|
|
quotaBytes: undefined,
|
|
usedBytes: undefined,
|
|
existingId: undefined,
|
|
});
|
|
|
|
expect(
|
|
parseUploadResponse(
|
|
413,
|
|
JSON.stringify({
|
|
data: null,
|
|
error: "quota",
|
|
meta: {
|
|
reason: "quotaExceeded",
|
|
quotaBytes: 1_073_741_824,
|
|
usedBytes: 1_000_000_000,
|
|
},
|
|
}),
|
|
),
|
|
).toMatchObject({
|
|
ok: false,
|
|
reason: "quotaExceeded",
|
|
quotaBytes: 1_073_741_824,
|
|
usedBytes: 1_000_000_000,
|
|
});
|
|
|
|
expect(
|
|
classifyUploadFailure(415, { reason: "unsupportedType" }),
|
|
).toMatchObject({ reason: "unsupportedType" });
|
|
expect(classifyUploadFailure(409, { reason: "purged" })).toMatchObject({
|
|
reason: "purged",
|
|
});
|
|
expect(
|
|
classifyUploadFailure(409, {
|
|
reason: "duplicateExists",
|
|
existingId: "row-1",
|
|
}),
|
|
).toMatchObject({ reason: "duplicateExists", existingId: "row-1" });
|
|
});
|
|
|
|
it("falls back to the HTTP status when a proxy strips the body", () => {
|
|
expect(parseUploadResponse(413, "")).toMatchObject({
|
|
reason: "fileTooLarge",
|
|
});
|
|
expect(parseUploadResponse(415, "<html>")).toMatchObject({
|
|
reason: "unsupportedType",
|
|
});
|
|
expect(parseUploadResponse(429, "")).toMatchObject({
|
|
reason: "rateLimited",
|
|
});
|
|
expect(parseUploadResponse(500, "")).toMatchObject({ reason: "generic" });
|
|
});
|
|
});
|
|
|
|
describe("shift-click range selection", () => {
|
|
const order = ["a", "b", "c", "d", "e"];
|
|
|
|
it("selects the inclusive range between anchor and target, both ways", () => {
|
|
expect(
|
|
[...expandRangeSelection(order, new Set(["b"]), "b", "d")].sort(),
|
|
).toEqual(["b", "c", "d"]);
|
|
expect(
|
|
[...expandRangeSelection(order, new Set(["d"]), "d", "b")].sort(),
|
|
).toEqual(["b", "c", "d"]);
|
|
});
|
|
|
|
it("is additive — a range gesture never deselects", () => {
|
|
const next = expandRangeSelection(order, new Set(["a", "c"]), "c", "e");
|
|
expect([...next].sort()).toEqual(["a", "c", "d", "e"]);
|
|
});
|
|
|
|
it("falls back to a plain toggle without a live anchor", () => {
|
|
expect([...expandRangeSelection(order, new Set(), null, "c")]).toEqual([
|
|
"c",
|
|
]);
|
|
// Anchor filtered out of the current order → plain toggle (deselect).
|
|
expect([
|
|
...expandRangeSelection(order, new Set(["c"]), "gone", "c"),
|
|
]).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("resolveBulkShareDocuments", () => {
|
|
const corpus = [
|
|
doc({ id: "d1", title: "Blood panel", filename: "bp.pdf" }),
|
|
doc({ id: "d2", title: null, filename: "scan.jpg" }),
|
|
doc({ id: "d3", title: null, filename: null }),
|
|
doc({ id: "d4", title: "Referral" }),
|
|
];
|
|
|
|
it("maps the selection to {id,title}, falling back title → filename → untitled", () => {
|
|
const result = resolveBulkShareDocuments(
|
|
corpus,
|
|
new Set(["d1", "d2", "d3"]),
|
|
"Untitled",
|
|
);
|
|
expect(result.overCap).toBe(false);
|
|
if (result.overCap) throw new Error("unexpected over-cap");
|
|
expect(result.documents).toEqual([
|
|
{ id: "d1", title: "Blood panel" },
|
|
{ id: "d2", title: "scan.jpg" },
|
|
{ id: "d3", title: "Untitled" },
|
|
]);
|
|
});
|
|
|
|
it("preserves corpus order and ignores ids not in the corpus", () => {
|
|
const result = resolveBulkShareDocuments(
|
|
corpus,
|
|
new Set(["d4", "d1", "ghost"]),
|
|
"Untitled",
|
|
);
|
|
if (result.overCap) throw new Error("unexpected over-cap");
|
|
expect(result.documents.map((d) => d.id)).toEqual(["d1", "d4"]);
|
|
});
|
|
|
|
it("caps at SHARE_LINK_MAX_DOCUMENTS (50) — over the cap refuses rather than truncates", () => {
|
|
const many = Array.from({ length: 60 }, (_, i) => doc({ id: `x${i}` }));
|
|
const selected = new Set(many.map((d) => d.id));
|
|
expect(selected.size).toBeGreaterThan(SHARE_LINK_MAX_DOCUMENTS);
|
|
expect(resolveBulkShareDocuments(many, selected, "Untitled")).toEqual({
|
|
overCap: true,
|
|
});
|
|
});
|
|
|
|
it("allows exactly 50 selected documents", () => {
|
|
const fifty = Array.from({ length: SHARE_LINK_MAX_DOCUMENTS }, (_, i) =>
|
|
doc({ id: `y${i}` }),
|
|
);
|
|
const result = resolveBulkShareDocuments(
|
|
fifty,
|
|
new Set(fifty.map((d) => d.id)),
|
|
"Untitled",
|
|
);
|
|
expect(result.overCap).toBe(false);
|
|
if (result.overCap) throw new Error("unexpected over-cap");
|
|
expect(result.documents).toHaveLength(SHARE_LINK_MAX_DOCUMENTS);
|
|
});
|
|
});
|
|
|
|
// v1.29.x — the vault's "Processing…" / "Ready" chip + the poll-while-
|
|
// indexing refetch both key off the bounded recent-upload window rather than
|
|
// a bare `!hasContentIndex`, so an old / permanently-unindexed document never
|
|
// shows a stuck spinner and the list query never polls forever.
|
|
describe("isRecentlyUploaded / isDocumentProcessing / hasProcessingDocument", () => {
|
|
const NOW = new Date("2026-03-11T08:10:00.000Z").getTime();
|
|
|
|
it("is recently-uploaded just inside the window, not right at/after it", () => {
|
|
const justInside = new Date(
|
|
NOW - RECENT_UPLOAD_WINDOW_MS + 1000,
|
|
).toISOString();
|
|
expect(isRecentlyUploaded(justInside, NOW)).toBe(true);
|
|
|
|
const atTheEdge = new Date(NOW - RECENT_UPLOAD_WINDOW_MS).toISOString();
|
|
expect(isRecentlyUploaded(atTheEdge, NOW)).toBe(false);
|
|
});
|
|
|
|
it("is never recently-uploaded for a future createdAt (clock skew defensiveness)", () => {
|
|
const future = new Date(NOW + 1000).toISOString();
|
|
expect(isRecentlyUploaded(future, NOW)).toBe(false);
|
|
});
|
|
|
|
it("is processing only while unindexed AND inside the recent-upload window", () => {
|
|
const fresh = new Date(NOW - 1000).toISOString();
|
|
expect(
|
|
isDocumentProcessing({ createdAt: fresh, hasContentIndex: false }, NOW),
|
|
).toBe(true);
|
|
// Indexed already — done, not processing, regardless of age.
|
|
expect(
|
|
isDocumentProcessing({ createdAt: fresh, hasContentIndex: true }, NOW),
|
|
).toBe(false);
|
|
// Old and never indexed (unsupported format, content index disabled,
|
|
// …) — outside the window, so it does NOT read as "still processing".
|
|
const old = new Date(NOW - RECENT_UPLOAD_WINDOW_MS - 1000).toISOString();
|
|
expect(
|
|
isDocumentProcessing({ createdAt: old, hasContentIndex: false }, NOW),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("hasProcessingDocument is true when ANY loaded document is still processing", () => {
|
|
const fresh = new Date(NOW - 1000).toISOString();
|
|
const old = new Date(NOW - RECENT_UPLOAD_WINDOW_MS - 1000).toISOString();
|
|
const docs = [
|
|
doc({ id: "d1", createdAt: old, hasContentIndex: false }),
|
|
doc({ id: "d2", createdAt: fresh, hasContentIndex: false }),
|
|
];
|
|
expect(hasProcessingDocument(docs, NOW)).toBe(true);
|
|
});
|
|
|
|
it("hasProcessingDocument is false when every document is indexed or outside the window", () => {
|
|
const fresh = new Date(NOW - 1000).toISOString();
|
|
const old = new Date(NOW - RECENT_UPLOAD_WINDOW_MS - 1000).toISOString();
|
|
const docs = [
|
|
doc({ id: "d1", createdAt: old, hasContentIndex: false }),
|
|
doc({ id: "d2", createdAt: fresh, hasContentIndex: true }),
|
|
];
|
|
expect(hasProcessingDocument(docs, NOW)).toBe(false);
|
|
});
|
|
|
|
it("hasProcessingDocument is false for an empty list", () => {
|
|
expect(hasProcessingDocument([], NOW)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("documentCardKeyAction", () => {
|
|
it("deletes on Delete and selects on Space", () => {
|
|
expect(documentCardKeyAction("Delete")).toBe("delete");
|
|
expect(documentCardKeyAction(" ")).toBe("select");
|
|
});
|
|
|
|
it("does NOT delete on Backspace", () => {
|
|
// The reflex "go back" key on a card that takes focus by default, with
|
|
// no other delete control and only a transient undo toast behind it.
|
|
expect(documentCardKeyAction("Backspace")).toBeNull();
|
|
});
|
|
|
|
it("ignores every other key", () => {
|
|
for (const key of ["Enter", "Escape", "a", "ArrowDown", "Del"]) {
|
|
expect(documentCardKeyAction(key)).toBeNull();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("buildProcedureChoices", () => {
|
|
const labels = {
|
|
kindName: "Procedure or surgery",
|
|
siteText: (site: string | null, side: string | null) =>
|
|
site ? (side ? `${site} (${side.toLowerCase()})` : site) : null,
|
|
formatDate: (iso: string) => iso.slice(0, 10),
|
|
};
|
|
const knee = {
|
|
encounterId: "enc-knee",
|
|
occurredAt: "2025-10-02T08:00:00.000Z",
|
|
reason: "Knee arthroscopy",
|
|
bodySite: "Knee",
|
|
laterality: "LEFT" as const,
|
|
practitionerName: "Day clinic",
|
|
};
|
|
|
|
it("heads each choice like the visits page: reason, else practice, else kind", () => {
|
|
const choices = buildProcedureChoices(
|
|
[
|
|
knee,
|
|
{ ...knee, encounterId: "enc-2", reason: null, bodySite: null },
|
|
{
|
|
...knee,
|
|
encounterId: "enc-3",
|
|
reason: " ",
|
|
practitionerName: null,
|
|
laterality: null,
|
|
},
|
|
],
|
|
null,
|
|
labels,
|
|
);
|
|
expect(choices).toEqual([
|
|
{
|
|
encounterId: "enc-knee",
|
|
name: "Knee arthroscopy",
|
|
detail: "Knee (left), 2025-10-02",
|
|
},
|
|
{ encounterId: "enc-2", name: "Day clinic", detail: "2025-10-02" },
|
|
{
|
|
encounterId: "enc-3",
|
|
name: "Procedure or surgery",
|
|
detail: "Knee, 2025-10-02",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("keeps an active procedure the usage list no longer holds, once", () => {
|
|
const gone = { ...knee, encounterId: "enc-gone", reason: "Appendectomy" };
|
|
const choices = buildProcedureChoices([knee], gone, labels);
|
|
expect(choices.map((c) => c.encounterId)).toEqual(["enc-knee", "enc-gone"]);
|
|
// Already listed: not added a second time.
|
|
expect(buildProcedureChoices([knee], knee, labels)).toHaveLength(1);
|
|
});
|
|
|
|
it("offers nothing when no procedure carries a document", () => {
|
|
expect(buildProcedureChoices([], null, labels)).toEqual([]);
|
|
});
|
|
});
|