// @vitest-environment node // No DOM needed — jsdom is not installed in this workspace, so the web // default environment (jsdom, vitest.config.ts) is unreachable; node-env // tests are bannered, matching the other web unit tests. /** * TASK-0095 R2 — the offline-UX state machine (the S12 gate's named unit * test: "the banner count aggregation (the CRDT + outbox sum)"). * * Pure — feeds `computeOfflineStatus` the live inputs (the hook feeds the * real ones), asserts the banner visibility, the ONE pending number (the * CRDT + outbox SUM), the chip states, the 5 s blip threshold, and the a11y * label. */ import { describe, expect, it } from "vitest"; import { RECONNECT_BANNER_THRESHOLD_MS, computeOfflineStatus, normalizePhase, type OfflineStatusInput, } from "../offline-status"; const base: OfflineStatusInput = { navigatorOnline: true, phase: "online", phaseSinceMs: 0, nowMs: 0, crdtPending: 0, outboxPending: 0, outboxFailed: 0, }; describe("normalizePhase", () => { it("maps the legacy JSON client + the Yjs provider statuses onto the 3 phases", () => { expect(normalizePhase("connected")).toBe("online"); expect(normalizePhase("syncing")).toBe("online"); expect(normalizePhase("reconnecting")).toBe("reconnecting"); expect(normalizePhase("connecting")).toBe("reconnecting"); expect(normalizePhase("offline")).toBe("offline"); expect(normalizePhase("closed")).toBe("offline"); expect(normalizePhase("disconnected")).toBe("offline"); expect(normalizePhase(null)).toBe("offline"); // not connected yet → safe }); }); describe("the banner count = the CRDT + outbox SUM (the spec's 'one number')", () => { it("sums the provider's CRDT-pending edits + the outbox pending into ONE number", () => { const s = computeOfflineStatus({ ...base, crdtPending: 7, outboxPending: 5 }); expect(s.pendingCount).toBe(12); // 7 + 5 — one number, the sum }); it("is 0 when both are empty", () => { expect(computeOfflineStatus(base).pendingCount).toBe(0); }); it("outbox-only (today, the provider is not wired) = the outbox count", () => { const s = computeOfflineStatus({ ...base, outboxPending: 3 }); expect(s.pendingCount).toBe(3); }); }); describe("banner visibility (the 3 triggers + the 5 s blip threshold)", () => { it("is VISIBLE when navigator.onLine is false", () => { const s = computeOfflineStatus({ ...base, navigatorOnline: false }); expect(s.bannerVisible).toBe(true); expect(s.chip).toBe("offline"); }); it("is VISIBLE when the provider is offline", () => { const s = computeOfflineStatus({ ...base, phase: "offline" }); expect(s.bannerVisible).toBe(true); expect(s.chip).toBe("offline"); }); it("is HIDDEN for a brief reconnecting blip (< 5 s) — the chip flashes instead", () => { const s = computeOfflineStatus({ ...base, phase: "reconnecting", phaseSinceMs: 0, nowMs: RECONNECT_BANNER_THRESHOLD_MS - 1, // 4999 ms }); expect(s.bannerVisible).toBe(false); expect(s.chip).toBe("reconnecting"); // the amber chip expect(s.reconnectFlash).toBe(true); }); it("is VISIBLE once reconnecting passes the 5 s threshold (chip stays amber)", () => { const s = computeOfflineStatus({ ...base, phase: "reconnecting", phaseSinceMs: 0, nowMs: RECONNECT_BANNER_THRESHOLD_MS, // 5000 ms }); expect(s.bannerVisible).toBe(true); expect(s.chip).toBe("reconnecting"); // long reconnect → still amber expect(s.reconnectFlash).toBe(true); }); it("is HIDDEN when online + connected + no pending (steady state)", () => { const s = computeOfflineStatus({ ...base }); expect(s.bannerVisible).toBe(false); expect(s.chip).toBe("online"); }); it("a browser-level offline forces the gray chip even if the provider says reconnecting", () => { const s = computeOfflineStatus({ ...base, navigatorOnline: false, phase: "reconnecting" }); expect(s.chip).toBe("offline"); }); }); describe("the failed list + the 'Review failed changes' link", () => { it("shows the review link only when the failed list is non-empty", () => { expect(computeOfflineStatus({ ...base, outboxFailed: 0 }).showReviewLink).toBe(false); expect(computeOfflineStatus({ ...base, outboxFailed: 2 }).showReviewLink).toBe(true); expect(computeOfflineStatus({ ...base, outboxFailed: 2 }).failedCount).toBe(2); }); }); describe("the a11y label ('Connection: offline, 12 changes pending')", () => { it("describes the connection phase + the ONE pending number", () => { const offline = computeOfflineStatus({ ...base, navigatorOnline: false, crdtPending: 7, outboxPending: 5, }); expect(offline.ariaLabel).toBe("Connection: Offline, 12 changes pending"); const live = computeOfflineStatus(base); expect(live.ariaLabel).toBe("Connection: Live, 0 changes pending"); }); });