sync-outcome.test.ts

7.0 KB · raw

1import { describe, expect, it } from "vitest";2import {3  describeSyncOutcome,4  isManualSyncPending,5  syncIndicator,6  syncIndicatorLabel,7  type SyncOutcome,8} from "./sync-outcome";910describe("syncIndicator", () => {11  it("keeps a manual waiter pending until its inbound session reports an outcome", () => {12    expect(isManualSyncPending({ status: "syncing" })).toBe(true);13    expect(isManualSyncPending({ status: "idle", outcome: "waiting_for_peer" })).toBe(true);14    expect(isManualSyncPending({ status: "idle", outcome: "completed_no_changes" })).toBe(false);15  });16  it("pulses green only while a sync is actually running", () => {17    expect(syncIndicator({ status: "syncing" }, 1)).toBe("busy");18    // A running sync outranks a stale failure — the light describes now.19    expect(syncIndicator({ status: "syncing", outcome: "relay_unreachable" }, 1)).toBe("busy");20  });2122  it("is solid green for a completed sync, with or without changes", () => {23    expect(syncIndicator({ status: "idle", outcome: "completed_changes" }, 1)).toBe("ok");24    expect(syncIndicator({ status: "idle", outcome: "completed_no_changes" }, 1)).toBe("ok");25  });2627  it("never shows green for a paired-but-unsyncable device", () => {28    // The exact case that used to be a silent return: a device is paired, so29    // "you have a pair" is true, but nothing can sync. Green here would lie.30    expect(syncIndicator({ status: "error", outcome: "no_eligible_host" }, 2)).toBe("error");31    expect(syncIndicator({ status: "error", outcome: "runner_unavailable" }, 2)).toBe("error");32  });3334  it("shows listener-only mesh role as healthy while waiting for its peer", () => {35    const summary = {36      status: "idle" as const,37      outcome: "waiting_for_peer" as const,38      detail: "Android [type=android, trust=direct]",39    };40    expect(syncIndicator(summary, 1)).toBe("ok");41    expect(describeSyncOutcome(summary)).toContain("Waiting for the paired device to connect");42  });4344  it("shows yellow for network-class misses — relay/handshake/timeout are warn, not error", () => {45    // The alarm fires every 10 minutes and dials. If the peer doesn't answer,46    // the next alarm tick will try again. That is the network, not the app —47    // red would be alarm fatigue. These must be warn (yellow), not error.48    expect(syncIndicator({ status: "idle", outcome: "relay_unreachable" }, 1)).toBe("warn");49    expect(syncIndicator({ status: "idle", outcome: "handshake_failed" }, 1)).toBe("warn");50    expect(syncIndicator({ status: "idle", outcome: "sync_timeout" }, 1)).toBe("warn");51  });5253  it("keeps red on genuine configuration / identity problems that the alarm cannot fix", () => {54    expect(syncIndicator({ status: "error", outcome: "vault_id_mismatch" }, 1)).toBe("error");55    expect(syncIndicator({ status: "error", outcome: "private_relay_unset" }, 1)).toBe("error");56    expect(syncIndicator({ status: "error", outcome: "no_eligible_host" }, 1)).toBe("error");57    expect(syncIndicator({ status: "error", outcome: "runner_unavailable" }, 1)).toBe("error");58    expect(syncIndicator({ status: "error", outcome: "run_failed" }, 1)).toBe("error");59  });6061  it("treats vault_locked as warn (yellow) — the user hasn't unlocked yet, not a misconfig", () => {62    // vault_locked is the reader's vault being closed (e.g. SW restarted after63    // the popup was last dismissed). The next unlock + retry will succeed; the64    // alarm keeps trying. Same yellow semantics as a missed dial — a red pip65    // here would be misleading alarm fatigue.66    expect(syncIndicator({ status: "error", outcome: "vault_locked" }, 1)).toBe("warn");67    expect(syncIndicator({ status: "idle", outcome: "vault_locked" }, 1)).toBe("warn");68  });6970  it("returns to green after a successful dial even when the prior attempt was warn", () => {71    // The state machine must not get stuck on a warn that already cleared.72    const prior: { status: "idle"; outcome: "completed_changes" } = {73      status: "idle",74      outcome: "completed_changes",75    };76    expect(syncIndicator(prior, 1)).toBe("ok");77  });7879  it("stays unlit when there is nothing paired and nothing attempted", () => {80    expect(syncIndicator({}, 0)).toBe("idle");81    expect(syncIndicator({ status: "idle", outcome: "no_paired_devices" }, 0)).toBe("idle");82  });8384  it("reads a healthy pair as green before the first attempt of this session", () => {85    expect(syncIndicator({ status: "idle" }, 1)).toBe("ok");86  });8788  it("maps every outcome to its explicit indicator state", () => {89    const expected: Record<SyncOutcome, ReturnType<typeof syncIndicator>> = {90      completed_changes: "ok",91      completed_no_changes: "ok",92      no_paired_devices: "idle",93      no_eligible_host: "error",94      waiting_for_peer: "ok",95      runner_unavailable: "error",96      sync_disabled: "idle",97      private_relay_unset: "error",98      vault_locked: "warn",99      relay_unreachable: "warn",100      handshake_failed: "warn",101      vault_id_mismatch: "error",102      peer_protocol_outdated: "error",103      // §11: neither is a healthy sync, so neither may read green.104      reset_required: "error",105      sync_generation_unknown: "error",106      sync_timeout: "warn",107      connection_lost: "warn",108      trust_data_corrupted: "error",109      run_failed: "error",110    };111    for (const [outcome, indicator] of Object.entries(expected)) {112      expect(syncIndicator({ status: "idle", outcome: outcome as SyncOutcome }, 1)).toBe(indicator);113    }114  });115});116117describe("describeSyncOutcome", () => {118  it("names what moved on a successful sync", () => {119    expect(120      describeSyncOutcome({ outcome: "completed_changes", entriesSent: 2, entriesReceived: 1 }),121    ).toBe("Synced — 2 sent, 1 received");122  });123124  it("distinguishes a successful no-op from a failure in plain English", () => {125    expect(describeSyncOutcome({ outcome: "completed_no_changes" }))126      .toBe("Up to date — nothing to send or receive");127    expect(describeSyncOutcome({ outcome: "no_eligible_host" }))128      .not.toContain("Up to date");129  });130131  it("keeps the raw diagnostic detail visible after the plain-English reason", () => {132    // On a real device there is no devtools; the inventory is the whole clue.133    const line = describeSyncOutcome({134      outcome: "no_eligible_host",135      detail: "Pixel [type=phone, trust=direct]",136    });137    expect(line).toContain("no device here can accept a sync");138    expect(line).toContain("Pixel [type=phone, trust=direct]");139  });140141  it("says nothing has happened yet rather than implying success", () => {142    expect(describeSyncOutcome({})).toBe("Not synced yet");143  });144});145146describe("syncIndicatorLabel", () => {147  it("states the colour's claim in words", () => {148    expect(syncIndicatorLabel("error", { outcome: "vault_id_mismatch" }))149      .toBe("Sync problem — That device holds a different vault");150  });151152  it("states the warn colour's claim in words — the alarm retries", () => {153    expect(syncIndicatorLabel("warn", { outcome: "relay_unreachable" }))154      .toBe("Last sync attempt missed — will retry — Couldn't reach the relay");155  });156});