mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
A PUT that omits sections (an iOS tiles-only reorder) reset the user's section customization to defaults, and symmetrically a sections-only PUT reset the stored tiles. serializeInsightsLayout now takes the resolved stored layout and falls back to it — not to defaults — for whichever dimension the body omits; the PUT handler loads the stored layout first. A first-ever single-dimension PUT (no stored layout) still fills defaults, so the v2 blob stays complete. Pins the merge behaviour with three unit cases.
416 lines
14 KiB
TypeScript
416 lines
14 KiB
TypeScript
/**
|
|
* v1.15.11 — insights layout model v2.
|
|
*
|
|
* The layout blob grew a `sections` array on top of the existing per-
|
|
* metric `tiles` list. These tests pin the v2 contract that the
|
|
* `/insights` page (web) and the iOS client share: a v1 blob resolves
|
|
* forward untouched, section reorder/hide round-trips, unknown section
|
|
* ids auto-merge invisible then drop on serialize, and an omitted
|
|
* `sections` / `tiles` field falls back to defaults.
|
|
*/
|
|
import { describe, it, expect } from "vitest";
|
|
import {
|
|
INSIGHTS_SECTION_IDS,
|
|
INSIGHTS_TILE_IDS,
|
|
DEFAULT_INSIGHTS_LAYOUT,
|
|
resolveInsightsLayout,
|
|
serializeInsightsLayout,
|
|
orderedVisibleSectionIds,
|
|
resolveTileLayout,
|
|
} from "@/lib/insights-layout";
|
|
|
|
describe("insights-layout v2 — section id universe", () => {
|
|
it("carries exactly the eight default sections in render order", () => {
|
|
expect([...INSIGHTS_SECTION_IDS]).toEqual([
|
|
"wellness-scores",
|
|
"daily-briefing",
|
|
"vitals",
|
|
"trends",
|
|
"period-review",
|
|
"cycle-summary",
|
|
"signals",
|
|
"rhythm-events",
|
|
]);
|
|
});
|
|
|
|
it("defaults every section visible with a dense 0-based order", () => {
|
|
expect(DEFAULT_INSIGHTS_LAYOUT.version).toBe(2);
|
|
expect(DEFAULT_INSIGHTS_LAYOUT.sections.length).toBe(
|
|
INSIGHTS_SECTION_IDS.length,
|
|
);
|
|
DEFAULT_INSIGHTS_LAYOUT.sections.forEach((s, i) => {
|
|
expect(s.visible).toBe(true);
|
|
expect(s.order).toBe(i);
|
|
expect(s.id).toBe(INSIGHTS_SECTION_IDS[i]);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("resolveInsightsLayout — v1 → v2 forward upgrade", () => {
|
|
it("fills all sections default-visible and leaves tiles untouched for a v1 blob", () => {
|
|
const v1blob = {
|
|
version: 1,
|
|
tiles: [
|
|
{ id: "overview", visible: true, order: 0 },
|
|
{ id: "blood-pressure", visible: false, order: 1 },
|
|
],
|
|
};
|
|
const resolved = resolveInsightsLayout(v1blob);
|
|
|
|
// Upgraded to v2 with the full default section set, all visible.
|
|
expect(resolved.version).toBe(2);
|
|
expect(resolved.sections.length).toBe(INSIGHTS_SECTION_IDS.length);
|
|
for (const s of resolved.sections) expect(s.visible).toBe(true);
|
|
expect(resolved.sections.map((s) => s.id)).toEqual([
|
|
...INSIGHTS_SECTION_IDS,
|
|
]);
|
|
|
|
// The two saved tiles survive verbatim at the head; the rest merge.
|
|
expect(resolved.tiles[0]).toEqual({
|
|
id: "overview",
|
|
visible: true,
|
|
order: 0,
|
|
});
|
|
expect(resolved.tiles[1]).toEqual({
|
|
id: "blood-pressure",
|
|
visible: false,
|
|
order: 1,
|
|
});
|
|
});
|
|
|
|
it("returns the full default layout for garbage input", () => {
|
|
expect(resolveInsightsLayout(null).sections.length).toBe(
|
|
INSIGHTS_SECTION_IDS.length,
|
|
);
|
|
expect(resolveInsightsLayout({ version: 2 }).sections.length).toBe(
|
|
INSIGHTS_SECTION_IDS.length,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("resolveInsightsLayout — section semantics", () => {
|
|
it("auto-merges a missing default section default-invisible", () => {
|
|
const resolved = resolveInsightsLayout({
|
|
version: 2,
|
|
sections: [{ id: "vitals", visible: true, order: 0 }],
|
|
tiles: [{ id: "overview", visible: true, order: 0 }],
|
|
});
|
|
const vitals = resolved.sections.find((s) => s.id === "vitals");
|
|
const trends = resolved.sections.find((s) => s.id === "trends");
|
|
expect(vitals?.visible).toBe(true);
|
|
// Every section the blob omitted merges in default-invisible.
|
|
expect(trends?.visible).toBe(false);
|
|
expect(resolved.sections.length).toBe(INSIGHTS_SECTION_IDS.length);
|
|
});
|
|
|
|
it("drops an unknown section id (auto-merge then filter)", () => {
|
|
// `resolveInsightsLayout` takes `unknown` (a raw JSON blob from the
|
|
// column), so a garbage section id models a malformed/forward blob.
|
|
const blob: unknown = {
|
|
version: 2,
|
|
sections: [
|
|
{ id: "vitals", visible: true, order: 0 },
|
|
{ id: "not-a-section", visible: true, order: 1 },
|
|
],
|
|
tiles: [{ id: "overview", visible: true, order: 0 }],
|
|
};
|
|
const resolved = resolveInsightsLayout(blob);
|
|
expect(
|
|
resolved.sections.some((s) => (s.id as string) === "not-a-section"),
|
|
).toBe(false);
|
|
expect(resolved.sections.length).toBe(INSIGHTS_SECTION_IDS.length);
|
|
});
|
|
|
|
it("dedupes a repeated section id, keeping the first occurrence", () => {
|
|
const resolved = resolveInsightsLayout({
|
|
version: 2,
|
|
sections: [
|
|
{ id: "vitals", visible: true, order: 0 },
|
|
{ id: "vitals", visible: false, order: 1 },
|
|
],
|
|
tiles: [{ id: "overview", visible: true, order: 0 }],
|
|
});
|
|
const vitalsRows = resolved.sections.filter((s) => s.id === "vitals");
|
|
expect(vitalsRows.length).toBe(1);
|
|
expect(vitalsRows[0]?.visible).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("section reorder + hide round-trips through serialize → resolve", () => {
|
|
it("preserves a custom order + hidden flag", () => {
|
|
const reordered = {
|
|
version: 2,
|
|
sections: [
|
|
{ id: "trends", visible: true, order: 0 },
|
|
{ id: "wellness-scores", visible: false, order: 1 },
|
|
{ id: "vitals", visible: true, order: 2 },
|
|
],
|
|
tiles: [{ id: "overview", visible: true, order: 0 }],
|
|
};
|
|
const serialized = serializeInsightsLayout(reordered);
|
|
const resolved = resolveInsightsLayout(serialized);
|
|
|
|
// The three explicitly-ordered sections lead in their chosen order.
|
|
expect(resolved.sections[0]?.id).toBe("trends");
|
|
expect(resolved.sections[1]?.id).toBe("wellness-scores");
|
|
expect(resolved.sections[1]?.visible).toBe(false);
|
|
expect(resolved.sections[2]?.id).toBe("vitals");
|
|
// Orders stay dense 0-based.
|
|
resolved.sections.forEach((s, i) => expect(s.order).toBe(i));
|
|
});
|
|
});
|
|
|
|
describe("serializeInsightsLayout — defaults + dense order", () => {
|
|
it("fills section defaults when the input omits sections", () => {
|
|
const serialized = serializeInsightsLayout({
|
|
version: 2,
|
|
tiles: [{ id: "overview", visible: true, order: 0 }],
|
|
});
|
|
expect(serialized.sections.length).toBe(INSIGHTS_SECTION_IDS.length);
|
|
for (const s of serialized.sections) expect(s.visible).toBe(true);
|
|
});
|
|
|
|
it("fills tile defaults when the input omits tiles", () => {
|
|
const serialized = serializeInsightsLayout({
|
|
version: 2,
|
|
sections: [{ id: "vitals", visible: true, order: 0 }],
|
|
});
|
|
expect(serialized.tiles.length).toBe(DEFAULT_INSIGHTS_LAYOUT.tiles.length);
|
|
});
|
|
|
|
it("normalises sparse section orders to a dense 0-based sequence", () => {
|
|
const serialized = serializeInsightsLayout({
|
|
version: 2,
|
|
sections: [
|
|
{ id: "vitals", visible: true, order: 40 },
|
|
{ id: "trends", visible: true, order: 10 },
|
|
{ id: "signals", visible: false, order: 99 },
|
|
],
|
|
tiles: [{ id: "overview", visible: true, order: 0 }],
|
|
});
|
|
// Sorted by input order, then re-numbered 0,1,2.
|
|
expect(serialized.sections.map((s) => ({ id: s.id, order: s.order }))).toEqual([
|
|
{ id: "trends", order: 0 },
|
|
{ id: "vitals", order: 1 },
|
|
{ id: "signals", order: 2 },
|
|
]);
|
|
});
|
|
|
|
it("drops unknown section ids on serialize", () => {
|
|
const serialized = serializeInsightsLayout({
|
|
version: 2,
|
|
sections: [
|
|
{ id: "vitals", visible: true, order: 0 },
|
|
{ id: "bogus", visible: true, order: 1 },
|
|
],
|
|
tiles: [{ id: "overview", visible: true, order: 0 }],
|
|
});
|
|
expect(serialized.sections.map((s) => s.id)).toEqual(["vitals"]);
|
|
});
|
|
});
|
|
|
|
describe("serializeInsightsLayout — stored-dimension preservation (v1.16.13)", () => {
|
|
it("keeps the stored sections when a PUT omits sections (tiles-only)", () => {
|
|
// The user previously customised sections (a hidden + reordered set).
|
|
const stored = serializeInsightsLayout({
|
|
version: 2,
|
|
sections: [
|
|
{ id: "trends", visible: true, order: 0 },
|
|
{ id: "vitals", visible: false, order: 1 },
|
|
],
|
|
tiles: [{ id: "overview", visible: true, order: 0 }],
|
|
});
|
|
|
|
// An iOS tiles-only reorder PUT — no `sections` key.
|
|
const serialized = serializeInsightsLayout(
|
|
{
|
|
version: 1,
|
|
tiles: [
|
|
{ id: "weight", visible: true, order: 0 },
|
|
{ id: "overview", visible: true, order: 1 },
|
|
],
|
|
},
|
|
stored,
|
|
);
|
|
|
|
// Stored sections survive verbatim instead of resetting to defaults.
|
|
expect(
|
|
serialized.sections.map((s) => ({
|
|
id: s.id,
|
|
visible: s.visible,
|
|
order: s.order,
|
|
})),
|
|
).toEqual([
|
|
{ id: "trends", visible: true, order: 0 },
|
|
{ id: "vitals", visible: false, order: 1 },
|
|
]);
|
|
// The PUT's tiles still apply.
|
|
expect(serialized.tiles.map((t) => t.id)).toEqual(["weight", "overview"]);
|
|
});
|
|
|
|
it("keeps the stored tiles when a PUT omits tiles (sections-only)", () => {
|
|
const stored = serializeInsightsLayout({
|
|
version: 2,
|
|
sections: [{ id: "vitals", visible: true, order: 0 }],
|
|
tiles: [
|
|
{ id: "weight", visible: true, order: 0 },
|
|
{ id: "bloodPressure", visible: false, order: 1 },
|
|
],
|
|
});
|
|
|
|
const serialized = serializeInsightsLayout(
|
|
{
|
|
version: 2,
|
|
sections: [
|
|
{ id: "trends", visible: true, order: 0 },
|
|
{ id: "vitals", visible: false, order: 1 },
|
|
],
|
|
},
|
|
stored,
|
|
);
|
|
|
|
// Stored tiles survive verbatim instead of resetting to defaults.
|
|
expect(
|
|
serialized.tiles.map((t) => ({ id: t.id, visible: t.visible })),
|
|
).toEqual([
|
|
{ id: "weight", visible: true },
|
|
{ id: "bloodPressure", visible: false },
|
|
]);
|
|
expect(serialized.sections.map((s) => s.id)).toEqual(["trends", "vitals"]);
|
|
});
|
|
|
|
it("falls back to defaults for an absent dimension when no stored layout is supplied", () => {
|
|
const serialized = serializeInsightsLayout({
|
|
version: 2,
|
|
tiles: [{ id: "overview", visible: true, order: 0 }],
|
|
});
|
|
// First-ever tiles-only PUT still persists a complete v2 blob.
|
|
expect(serialized.sections.length).toBe(INSIGHTS_SECTION_IDS.length);
|
|
});
|
|
});
|
|
|
|
describe("orderedVisibleSectionIds — W2 render-order helper", () => {
|
|
it("returns every section in default order for the default layout", () => {
|
|
expect(orderedVisibleSectionIds(DEFAULT_INSIGHTS_LAYOUT)).toEqual([
|
|
...INSIGHTS_SECTION_IDS,
|
|
]);
|
|
});
|
|
|
|
it("drops hidden sections and keeps the visible ones in order", () => {
|
|
const layout = resolveInsightsLayout({
|
|
version: 2,
|
|
sections: [
|
|
{ id: "wellness-scores", visible: true, order: 0 },
|
|
{ id: "daily-briefing", visible: false, order: 1 },
|
|
{ id: "vitals", visible: true, order: 2 },
|
|
{ id: "trends", visible: false, order: 3 },
|
|
{ id: "period-review", visible: true, order: 4 },
|
|
{ id: "cycle-summary", visible: false, order: 5 },
|
|
{ id: "signals", visible: true, order: 6 },
|
|
{ id: "rhythm-events", visible: true, order: 7 },
|
|
],
|
|
tiles: [{ id: "overview", visible: true, order: 0 }],
|
|
});
|
|
expect(orderedVisibleSectionIds(layout)).toEqual([
|
|
"wellness-scores",
|
|
"vitals",
|
|
"period-review",
|
|
"signals",
|
|
"rhythm-events",
|
|
]);
|
|
});
|
|
|
|
it("honours a reordered section list", () => {
|
|
const layout = resolveInsightsLayout({
|
|
version: 2,
|
|
sections: [
|
|
{ id: "trends", visible: true, order: 0 },
|
|
{ id: "vitals", visible: true, order: 1 },
|
|
{ id: "wellness-scores", visible: true, order: 2 },
|
|
],
|
|
tiles: [{ id: "overview", visible: true, order: 0 }],
|
|
});
|
|
const visible = orderedVisibleSectionIds(layout);
|
|
// The three explicit sections lead in their saved order; the rest
|
|
// auto-merge invisible (new-id semantics), so they do NOT appear.
|
|
expect(visible.slice(0, 3)).toEqual(["trends", "vitals", "wellness-scores"]);
|
|
});
|
|
});
|
|
|
|
describe("resolveTileLayout — W2c per-tile decision", () => {
|
|
it("returns the saved visible + order for a known tile", () => {
|
|
const layout = resolveInsightsLayout({
|
|
version: 2,
|
|
sections: [{ id: "vitals", visible: true, order: 0 }],
|
|
tiles: [
|
|
{ id: "overview", visible: true, order: 0 },
|
|
{ id: "hrv", visible: false, order: 1 },
|
|
],
|
|
});
|
|
expect(resolveTileLayout(layout, "hrv")).toEqual({
|
|
visible: false,
|
|
order: 1,
|
|
});
|
|
});
|
|
|
|
it("treats a tile the layout does not enumerate as always-on, ordered last", () => {
|
|
const res = resolveTileLayout(DEFAULT_INSIGHTS_LAYOUT, "not-a-tile");
|
|
expect(res.visible).toBe(true);
|
|
expect(res.order).toBe(Number.MAX_SAFE_INTEGER);
|
|
});
|
|
|
|
it("keeps the grid's default vitals tiles visible by default (no regression)", () => {
|
|
for (const id of [
|
|
"weight",
|
|
"bmi",
|
|
"cardio-fitness",
|
|
"vascular-age",
|
|
"hrv",
|
|
"resting-pulse",
|
|
"respiratory-rate",
|
|
"oxygen",
|
|
"body-temperature",
|
|
"blood-glucose",
|
|
"six-minute-walk",
|
|
"stair-ascent-speed",
|
|
"stair-descent-speed",
|
|
"wrist-temperature",
|
|
]) {
|
|
expect(resolveTileLayout(DEFAULT_INSIGHTS_LAYOUT, id).visible).toBe(true);
|
|
}
|
|
});
|
|
|
|
// v1.15.14 — the default is ALL-VISIBLE. The v1.15.11 curated subset
|
|
// dropped ~20 nav pills once the tab strip began gating on the layout
|
|
// (sleep, steps, active-energy, walking-*, audio-*, daylight, the body-
|
|
// composition tiles, …). The grid renders a fixed mapped subset
|
|
// regardless of `tiles.visible`, so all-visible does NOT bloat it; what
|
|
// it restores is the nav's everything-with-data default. Pin it.
|
|
it("defaults EVERY tile visible (nav-pill regression guard)", () => {
|
|
expect(DEFAULT_INSIGHTS_LAYOUT.tiles.length).toBe(INSIGHTS_TILE_IDS.length);
|
|
for (const tile of DEFAULT_INSIGHTS_LAYOUT.tiles) {
|
|
expect(tile.visible).toBe(true);
|
|
}
|
|
// Spot-check the long-tail slugs that previously regressed out of nav.
|
|
for (const id of [
|
|
"sleep",
|
|
"steps",
|
|
"active-energy",
|
|
"walking-distance",
|
|
"walking-speed",
|
|
"flights-climbed",
|
|
"environmental-audio",
|
|
"headphone-audio",
|
|
"daylight",
|
|
"breathing-disturbances",
|
|
"skin-temperature",
|
|
"body-water",
|
|
"fat-mass",
|
|
"muscle-mass",
|
|
"lean-body-mass",
|
|
]) {
|
|
expect(resolveTileLayout(DEFAULT_INSIGHTS_LAYOUT, id).visible).toBe(true);
|
|
}
|
|
});
|
|
});
|