diff --git a/package-lock.json b/package-lock.json index 71aa4a5..9df24e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "beacon-web", - "version": "2.0.5-dev1.1", + "version": "2.1.0-dev1.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "beacon-web", - "version": "2.0.5-dev1.1", + "version": "2.1.0-dev1.4", "license": "AGPL-3.0-or-later", "dependencies": { "@nazka/map-gl-js-spiderfy": "^2.0.0", diff --git a/package.json b/package.json index de590fc..e8f1223 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "beacon-web", "private": true, - "version": "2.0.5-dev1.1", + "version": "2.1.0-dev1.4", "license": "AGPL-3.0-or-later", "type": "module", "scripts": { diff --git a/src/api/client.ts b/src/api/client.ts index 41883a7..f92720e 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -267,8 +267,8 @@ export function getTraceDetail(tag: string): Promise { return request(`/traces/${tag}`); } -export async function getObserver(observerId: string): Promise { - const observer = await request(`/observers/${observerId}`); +export async function getObserver(observerId: string, signal?: AbortSignal): Promise { + const observer = await request(`/observers/${observerId}`, undefined, signal); // Servers before json.RawMessage (and cache entries written by them) send the stored JSON as base64. let metadata: unknown = observer.statusMetadata; if (typeof metadata === "string") { @@ -457,12 +457,13 @@ export function getObserverTelemetry( range: string, interval?: string, afterId?: number, + signal?: AbortSignal, ): Promise { - return request(`/observers/${observerId}/telemetry`, { range, interval, afterId }); + return request(`/observers/${observerId}/telemetry`, { range, interval, afterId }, signal); } -export function getObserverActivity(observerId: string, range: string, interval: string, until?: number): Promise { - return request(`/observers/${observerId}/activity`, { range, interval, until }); +export function getObserverActivity(observerId: string, range: string, interval: string, until?: number, signal?: AbortSignal): Promise { + return request(`/observers/${observerId}/activity`, { range, interval, until }, signal); } // Lets a caller hide a feature the server doesn't have rather than show it as failed. diff --git a/src/features/atlas/AtlasCard.tsx b/src/features/atlas/AtlasCard.tsx index e5a776a..2feacb7 100644 --- a/src/features/atlas/AtlasCard.tsx +++ b/src/features/atlas/AtlasCard.tsx @@ -146,7 +146,7 @@ export function AtlasCard({ ); const actions = ( -
+
{views.length > 1 && ( {obs.sample.battery !== null ? ( - + ) : ( - + )}
@@ -324,6 +326,7 @@ export function AtlasCard({ )} {radio && {radio}} + {obs.sample.battery !== null && obs.sample.uptime !== null && {t("atlas.uptime")} {formatUptime(obs.sample.uptime)}} {obs.loading && {t("common.loading")}} {obs.failed && {t("common.loadFailed")}} diff --git a/src/features/atlas/MyAtlasPage.tsx b/src/features/atlas/MyAtlasPage.tsx index 86fe5fd..664dbbf 100644 --- a/src/features/atlas/MyAtlasPage.tsx +++ b/src/features/atlas/MyAtlasPage.tsx @@ -169,7 +169,7 @@ export function MyAtlasPage({ active = true, ...actions }: AtlasActions & { acti />
-
+
{typed.length > 0 && ( diff --git a/src/features/atlas/atlas.ts b/src/features/atlas/atlas.ts index 0ddf248..1735b93 100644 --- a/src/features/atlas/atlas.ts +++ b/src/features/atlas/atlas.ts @@ -2,7 +2,8 @@ import { ApiError, getNode, getNodeObservations, getNodesPage, isNotFound } from import type { Node, NodeObservation, NodeSummary } from "../nodes/types"; import type { Observer } from "../observers/types"; -export const ATLAS_KEY = "beacon-my-atlas-v1"; +export const ATLAS_KEY = "beacon-my-atlas-v2"; +export const ATLAS_LEGACY_KEY = "beacon-my-atlas-v1"; export const ATLAS_LIMIT = 12; export const REPORT_LIMIT = 200; export type AtlasRange = "24h" | "3d"; diff --git a/src/features/atlas/observer-sample.ts b/src/features/atlas/observer-sample.ts index 5ce65c7..1cfef4b 100644 --- a/src/features/atlas/observer-sample.ts +++ b/src/features/atlas/observer-sample.ts @@ -11,6 +11,7 @@ const HOUR = 3_600_000; export interface ObserverSample { heard: number | null; + partial?: boolean; counts: (number | null)[]; snrHourly: (number | null)[]; snr: number | null; @@ -19,6 +20,7 @@ export interface ObserverSample { batteryHourly: (number | null)[]; battery: number | null; uptime: number | null; + uptimeHourly: (number | null)[]; } export function summarizeObserver( @@ -29,26 +31,31 @@ export function summarizeObserver( now: number, ): ObserverSample { const hours = RANGE_HOURS[range]; + const finite = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value); // Slots count back from the tick, newest last; a bucket newer than the tick lands in the newest slot. const age = (t: number) => Math.max(0, Math.floor((now - t) / HOUR)); const bin = (t: number) => hours - 1 - age(t); - const inWindow = (t: number) => Number.isFinite(t) && age(t) < hours; + const inWindow = (t: number) => Number.isFinite(t) && t <= now && age(t) < hours; - const counts = Array(hours).fill(0); + const counts = Array.from({ length: hours }, (_, i): number | null => { + if (!activity) return null; + const hour = Math.floor((now - (hours - 1 - i) * HOUR) / HOUR) * HOUR; + return activity.rawFrom != null && hour < activity.rawFrom && (activity.rolledUntil == null || hour >= activity.rolledUntil) ? null : 0; + }); const snrHourly = Array(hours).fill(null); - let heard: number | null = null; + let heard: number | null = counts.some(finite) ? 0 : null; let weighted = 0; let weight = 0; for (const p of activity?.points ?? []) { if (!inWindow(p.t)) continue; const i = bin(p.t); - if (p.observations === null) { + if (!finite(p.observations)) { counts[i] = null; continue; } counts[i] = (counts[i] ?? 0) + p.observations; heard = (heard ?? 0) + p.observations; - if (p.snrAvg !== null && p.observations > 0) { + if (finite(p.snrAvg) && p.observations > 0) { snrHourly[i] = p.snrAvg; weighted += p.snrAvg * p.observations; weight += p.observations; @@ -59,23 +66,26 @@ export function summarizeObserver( const live = (telemetry?.points ?? []).filter((p) => inWindow(p.t) && hasTelemetry([p])).sort((a, b) => a.t - b.t); const series = (key: keyof TelemetryPoint) => { const out = Array(hours).fill(null); - for (const p of live) if (p[key] !== null) out[bin(p.t)] = p[key]; + for (const p of live) if (finite(p[key])) out[bin(p.t)] = p[key]; return out; }; - const latest = (key: keyof TelemetryPoint) => [...live].reverse().find((p) => p[key] !== null)?.[key]; + const latest = (key: keyof TelemetryPoint) => [...live].reverse().find((p) => finite(p[key]))?.[key]; // Mains-powered observers report 0 mV; a battery never reads zero, so that is "no battery". - const volts = (mv: number | null | undefined) => (mv ? mv / 1000 : null); - const uptime = observer?.uptimeSeconds ?? latest("uptimeSeconds"); + const volts = (mv: number | null | undefined) => (finite(mv) && mv > 0 ? mv / 1000 : null); + const battery = observer?.batteryLevel == null ? volts(latest("batteryMv")) : finite(observer.batteryLevel) && observer.batteryLevel > 0 ? observer.batteryLevel : null; + const uptime = finite(observer?.uptimeSeconds) ? observer.uptimeSeconds : latest("uptimeSeconds"); return { heard, + partial: activity !== undefined && counts.some(value => value === null), counts, snrHourly, snr: weight ? weighted / weight : null, noiseHourly: series("noiseFloorDb"), noise: latest("noiseFloorDb") ?? null, batteryHourly: series("batteryMv").map(volts), - battery: volts(latest("batteryMv")), + battery, uptime: uptime ?? null, + uptimeHourly: series("uptimeSeconds"), }; } diff --git a/src/features/atlas/useAtlasObserver.ts b/src/features/atlas/useAtlasObserver.ts index 3664f51..6c6bbd5 100644 --- a/src/features/atlas/useAtlasObserver.ts +++ b/src/features/atlas/useAtlasObserver.ts @@ -25,7 +25,7 @@ export function useAtlasObserver(observerId: string | null, range: AtlasRange, a const observer = useQuery({ ...base, queryKey: ["atlas-observer", observerId], - queryFn: () => getObserver(observerId!), + queryFn: ({ signal }) => getObserver(observerId!, signal), enabled: active && !throttled && observerId !== null, refetchInterval: ATLAS_REFRESH_MS, }); @@ -33,14 +33,14 @@ export function useAtlasObserver(observerId: string | null, range: AtlasRange, a const activity = useQuery({ ...base, queryKey: ["atlas-observer-activity", observerId, range], - queryFn: () => getObserverActivity(observerId!, OBSERVER_RANGE[range], OBSERVER_INTERVAL, undefined), + queryFn: ({ signal }) => getObserverActivity(observerId!, OBSERVER_RANGE[range], OBSERVER_INTERVAL, undefined, signal), enabled: series, refetchInterval: (query) => pollUnlessMissing(query.state.error), }); const telemetry = useQuery({ ...base, queryKey: ["atlas-observer-telemetry", observerId, range], - queryFn: () => getObserverTelemetry(observerId!, OBSERVER_RANGE[range], OBSERVER_INTERVAL), + queryFn: ({ signal }) => getObserverTelemetry(observerId!, OBSERVER_RANGE[range], OBSERVER_INTERVAL, undefined, signal), enabled: series, refetchInterval: (query) => pollUnlessMissing(query.state.error), }); @@ -51,7 +51,7 @@ export function useAtlasObserver(observerId: string | null, range: AtlasRange, a return { observer, sample, - loading: open && (activity.isPending || telemetry.isPending), + loading: open && (activity.isLoading || telemetry.isLoading), unsupported: isNotFound(activity.error) || isNotFound(telemetry.error), failed: (activity.isError && !isNotFound(activity.error)) || (telemetry.isError && !isNotFound(telemetry.error)), }; diff --git a/src/features/atlas/useCardDrag.ts b/src/features/atlas/useCardDrag.ts index 378779d..e7f2fd6 100644 --- a/src/features/atlas/useCardDrag.ts +++ b/src/features/atlas/useCardDrag.ts @@ -14,18 +14,24 @@ export function useCardDrag(count: number, onReorder: (from: number, to: number) const cards = useRef(new Map()); const [drag, setDrag] = useState(null); const cleanup = useRef<() => void>(() => {}); + const active = useRef(false); useEffect(() => () => cleanup.current(), []); const start = useCallback((index: number, event: PointerEvent) => { - if (event.button !== 0) return; + if (event.button !== 0 || active.current) return; event.preventDefault(); + active.current = true; + const pointerId = event.pointerId; + const handle = event.currentTarget; + handle.setPointerCapture?.(pointerId); const rects = Array.from({ length: count }, (_, i) => cards.current.get(i)?.getBoundingClientRect() ?? new DOMRect()); const origin = { x: event.clientX, y: event.clientY }; let current: Drag = { from: index, over: index, dx: 0, dy: 0, rects }; setDrag(current); const onMove = (e: globalThis.PointerEvent) => { + if (e.pointerId !== pointerId) return; const hit = rects.findIndex((r) => e.clientX >= r.left && e.clientX < r.right && e.clientY >= r.top && e.clientY < r.bottom); current = { ...current, dx: e.clientX - origin.x, dy: e.clientY - origin.y, over: hit === -1 ? current.over : hit }; setDrag(current); @@ -35,20 +41,27 @@ export function useCardDrag(count: number, onReorder: (from: number, to: number) setDrag(null); if (commit && current.over !== current.from) onReorder(current.from, current.over); }; - const onUp = () => end(true); + const onUp = (e: globalThis.PointerEvent) => { if (e.pointerId === pointerId) end(true); }; const onCancel = () => end(false); + const onPointerCancel = (e: globalThis.PointerEvent) => { if (e.pointerId === pointerId) end(false); }; const onKey = (e: globalThis.KeyboardEvent) => { if (e.key === "Escape") end(false); }; window.addEventListener("pointermove", onMove); window.addEventListener("pointerup", onUp); - window.addEventListener("pointercancel", onCancel); + window.addEventListener("pointercancel", onPointerCancel); window.addEventListener("keydown", onKey); + window.addEventListener("blur", onCancel); + handle.addEventListener("lostpointercapture", onPointerCancel); cleanup.current = () => { window.removeEventListener("pointermove", onMove); window.removeEventListener("pointerup", onUp); - window.removeEventListener("pointercancel", onCancel); + window.removeEventListener("pointercancel", onPointerCancel); window.removeEventListener("keydown", onKey); + window.removeEventListener("blur", onCancel); + handle.removeEventListener("lostpointercapture", onPointerCancel); + if (handle.hasPointerCapture?.(pointerId)) handle.releasePointerCapture(pointerId); + active.current = false; cleanup.current = () => {}; }; }, [count, onReorder]); diff --git a/src/features/atlas/useSavedAtlas.ts b/src/features/atlas/useSavedAtlas.ts index 5e74d36..a3c078f 100644 --- a/src/features/atlas/useSavedAtlas.ts +++ b/src/features/atlas/useSavedAtlas.ts @@ -1,10 +1,10 @@ import { useEffect, useRef, useState } from "react"; -import { ATLAS_KEY, parseAtlas, type SavedAtlas } from "./atlas"; +import { ATLAS_KEY, ATLAS_LEGACY_KEY, parseAtlas, type SavedAtlas } from "./atlas"; export function useSavedAtlas() { const [initial] = useState(() => { try { - return { value: parseAtlas(localStorage.getItem(ATLAS_KEY)), failed: false }; + return { value: parseAtlas(localStorage.getItem(ATLAS_KEY) ?? localStorage.getItem(ATLAS_LEGACY_KEY)), failed: false }; } catch { return { value: parseAtlas(null), failed: true }; } diff --git a/src/features/stats/cards.tsx b/src/features/stats/cards.tsx index 7b14d1a..9054e94 100644 --- a/src/features/stats/cards.tsx +++ b/src/features/stats/cards.tsx @@ -32,13 +32,13 @@ const SPARK_W = 120; const SPARK_H = 20; const sparkSpacer =
; -function Sparkline({ values, color, markPeak }: { values: (number | null)[]; color: string; markPeak?: boolean }) { +function Sparkline({ values, color, markPeak, interpolateGaps }: { values: (number | null)[]; color: string; markPeak?: boolean; interpolateGaps?: boolean }) { const known = values.filter((v): v is number => v !== null); - if (values.length < 2 || known.length < 2) return sparkSpacer; + if (!known.length) return sparkSpacer; const max = Math.max(...known); const min = Math.min(...known); const range = max - min || 1; - const x = (i: number) => (i / (values.length - 1)) * SPARK_W; + const x = (i: number) => values.length === 1 ? SPARK_W / 2 : (i / (values.length - 1)) * SPARK_W; const y = (v: number) => SPARK_H - 1 - ((v - min) / range) * (SPARK_H - 2); // A null hour breaks the line, so an outage shows as a gap rather than a straight join. const runs: string[][] = [[]]; @@ -46,12 +46,15 @@ function Sparkline({ values, color, markPeak }: { values: (number | null)[]; col if (v === null) runs.push([]); else runs[runs.length - 1]!.push(`${x(i)},${y(v)}`); }); + const samples = values.flatMap((v, i) => v == null ? [] : [{ v, i }]); const peak = values.indexOf(max); return ( {runs.filter((run) => run.length > 1).map((run) => ( ))} + {runs.filter(run => run.length === 1).map(run => { const [px, py] = run[0]!.split(","); return ; })} + {interpolateGaps && samples.slice(1).map((point, i) => point.i > samples[i]!.i + 1 ? : null)} {markPeak && ( // zero-length round-capped stroke: stays a circle under preserveAspectRatio="none" @@ -91,6 +94,7 @@ export function StatCard({ presence, sublabel, compact, + interpolateGaps, }: { label: string; value: ReactNode; @@ -99,6 +103,7 @@ export function StatCard({ markPeak?: boolean; presence?: boolean[]; sublabel?: ReactNode; + interpolateGaps?: boolean; compact?: boolean; // narrow tiles: value stays text-xl so "-77.2 dBm" never wraps }) { return ( @@ -109,7 +114,7 @@ export function StatCard({
{value}
- {presence ? : spark ? : sparkSpacer} + {presence ? : spark ? : sparkSpacer}
); diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index f6b447e..42488bb 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -284,7 +284,7 @@ "observations_one": "{{formatted}} obs", "observations_other": "{{formatted}} obs", "hops": "Hops", - "hardware": "Hardware", + "hardware": "Hardware", "power": "Power", "antenna": "Antenna", "powerSource": "Power source", @@ -1008,11 +1008,12 @@ "online": "Online", "offline": "Offline", "lastStatus": "Last status", - "observerHelp": "What this observer received and reported, over the selected window. Status rows with no readings are ignored.", + "observerHelp": "What this observer received and reported, over the selected window. Status rows with no readings are ignored. Solid lines show readings; dashed lines connect missing hours as visual estimates, not measurements. Uptime gaps remain open.", "observerUnsupported": "This server does not provide observer activity or telemetry.", "observerGone": "This observer is no longer known to the server.", "dragToReorder": "Drag to reorder", - "remove": "Remove" + "remove": "Remove", + "observerPartialCoverage": "Partial history" } } } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 9f303cf..f9281d5 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1011,11 +1011,12 @@ "online": "En ligne", "offline": "Hors ligne", "lastStatus": "Dernier état", - "observerHelp": "Ce que cet observateur a reçu et signalé sur la période choisie. Les états sans mesures sont ignorés.", + "observerHelp": "Ce que cet observateur a reçu et signalé sur la période choisie. Les états sans mesures sont ignorés. Les lignes pleines indiquent les mesures; les pointillés relient les heures manquantes à titre indicatif. Les interruptions de disponibilité restent visibles.", "observerUnsupported": "Ce serveur ne fournit pas l’activité ni la télémétrie des observateurs.", "observerGone": "Cet observateur n’est plus connu du serveur.", "dragToReorder": "Glisser pour réordonner", - "remove": "Retirer" + "remove": "Retirer", + "observerPartialCoverage": "Historique partiel" } } } diff --git a/tests/features/atlas/AtlasCard.test.tsx b/tests/features/atlas/AtlasCard.test.tsx index 3dbed82..b0ecfd6 100644 --- a/tests/features/atlas/AtlasCard.test.tsx +++ b/tests/features/atlas/AtlasCard.test.tsx @@ -215,7 +215,7 @@ describe("AtlasCard", () => { expect(await screen.findByText("Packets heard")).toBeInTheDocument(); await screen.findByText("12"); expect(screen.getByText("6.5 dB")).toBeInTheDocument(); - expect(screen.getByText("-117 dB")).toBeInTheDocument(); + expect(screen.getByText("-117 dBm")).toBeInTheDocument(); expect(screen.getByText("4.12V")).toBeInTheDocument(); expect(screen.getByText("Online")).toBeInTheDocument(); expect(screen.getByText("915 MHz · SF11 · 250 kHz").closest(".flex-wrap")).toBeInTheDocument(); diff --git a/tests/features/atlas/MyAtlasPage.test.tsx b/tests/features/atlas/MyAtlasPage.test.tsx index 189378b..4d17273 100644 --- a/tests/features/atlas/MyAtlasPage.test.tsx +++ b/tests/features/atlas/MyAtlasPage.test.tsx @@ -2,7 +2,7 @@ import { beforeEach, afterEach, describe, it, expect, vi } from "vitest"; import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { MyAtlasPage } from "../../../src/features/atlas/MyAtlasPage"; -import { ATLAS_KEY, loadAtlasNode } from "../../../src/features/atlas/atlas"; +import { ATLAS_KEY, ATLAS_LEGACY_KEY, loadAtlasNode } from "../../../src/features/atlas/atlas"; import * as api from "../../../src/api/client"; import { ApiError } from "../../../src/api/client"; import type { Node } from "../../../src/features/nodes/types"; @@ -62,6 +62,15 @@ afterEach(() => { }); describe("My Atlas", () => { + it("reads legacy saved nodes and retains their original storage when saving the new model", async () => { + const legacy = JSON.stringify({ version: 1, nodes: [pin], range: "24h" }); + localStorage.setItem(ATLAS_LEGACY_KEY, legacy); + mount(); + await screen.findByRole("button", { name: "Renamed repeater" }); + fireEvent.click(screen.getByRole("button", { name: "3d" })); + expect(JSON.parse(localStorage.getItem(ATLAS_KEY)!).pins[0].publicKey).toBe(pin.publicKey); + expect(localStorage.getItem(ATLAS_LEGACY_KEY)).toBe(legacy); + }); it("renders saved nodes as Signal-style cards and nothing else before a search", async () => { save(); mount(); diff --git a/tests/features/atlas/observer-sample.test.ts b/tests/features/atlas/observer-sample.test.ts index 06c25bd..96a48aa 100644 --- a/tests/features/atlas/observer-sample.test.ts +++ b/tests/features/atlas/observer-sample.test.ts @@ -12,6 +12,31 @@ const tele = (points: Partial[]) => ({ rang t: now, batteryMv: null, airtimeTxSecs: null, airtimeRxSecs: null, noiseFloorDb: null, uptimeSeconds: null, queueLength: null, receiveErrors: null, ...p })) }) as ObserverTelemetry; describe("summarizeObserver", () => { + it("does not fill missing endpoints or unread rollup hours with measured zeros", () => { + expect(summarizeObserver(undefined, undefined, undefined, "24h", now).counts.every(v => v === null)).toBe(true); + const sample = summarizeObserver({ ...activity([]), rawFrom: now - HOUR }, undefined, undefined, "24h", now); + expect(sample.counts[0]).toBeNull(); + expect(sample.counts.at(-1)).toBe(0); + expect(sample.partial).toBe(true); + }); + + it("shows an available status battery without inventing history", () => { + const sample = summarizeObserver(undefined, undefined, { batteryLevel: 4.17 } as Observer, "24h", now); + expect(sample.battery).toBe(4.17); + expect(sample.batteryHourly.every(v => v === null)).toBe(true); + }); + + it("excludes future and non-finite readings while preserving a real zero SNR and uptime reset", () => { + const sample = summarizeObserver(activity([point(-5, 999, 99), point(0, 2, 0)]), tele([ + { t: now + HOUR, batteryMv: 99999, noiseFloorDb: -1 }, + { t: now - HOUR, noiseFloorDb: -110, uptimeSeconds: 100 }, + { t: now, noiseFloorDb: -110, batteryMv: NaN, uptimeSeconds: 0 }, + ]), undefined, "24h", now); + expect(sample.heard).toBe(2); + expect(sample.snr).toBe(0); + expect(sample.battery).toBeNull(); + expect(sample.uptimeHourly.slice(-2)).toEqual([100, 0]); + }); it("maps ranges to the server's duration strings", () => { expect(OBSERVER_RANGE).toEqual({ "24h": "24h", "3d": "72h" }); }); diff --git a/tests/features/atlas/useAtlasObserver.test.tsx b/tests/features/atlas/useAtlasObserver.test.tsx index 2675d44..131bbba 100644 --- a/tests/features/atlas/useAtlasObserver.test.tsx +++ b/tests/features/atlas/useAtlasObserver.test.tsx @@ -36,6 +36,17 @@ afterEach(() => { }); describe("useAtlasObserver", () => { + it("cancels all in-flight observer requests on unmount", async () => { + vi.mocked(api.getObserver).mockImplementation(() => new Promise(() => {})); + vi.mocked(api.getObserverActivity).mockImplementation(() => new Promise(() => {})); + vi.mocked(api.getObserverTelemetry).mockImplementation(() => new Promise(() => {})); + const view = renderHook(() => useAtlasObserver(id, "24h", true, true), { wrapper }); + await tick(10); + const signals = [vi.mocked(api.getObserver).mock.calls[0]![1]!, vi.mocked(api.getObserverActivity).mock.calls[0]![4]!, vi.mocked(api.getObserverTelemetry).mock.calls[0]![4]!]; + expect(signals.every(signal => !signal.aborted)).toBe(true); + view.unmount(); + expect(signals.every(signal => signal.aborted)).toBe(true); + }); it("loads the observer right away but activity and telemetry only while the view is open", async () => { const { result, rerender } = renderHook(({ open }) => useAtlasObserver(id, "24h", true, open), { wrapper, initialProps: { open: false } }); await tick(10); @@ -44,8 +55,8 @@ describe("useAtlasObserver", () => { rerender({ open: true }); await tick(10); expect(result.current.sample.heard).toBe(4); - expect(api.getObserverActivity).toHaveBeenCalledWith(id, "24h", "1h", undefined); - expect(api.getObserverTelemetry).toHaveBeenCalledWith(id, "24h", "1h"); + expect(api.getObserverActivity).toHaveBeenCalledWith(id, "24h", "1h", undefined, expect.any(AbortSignal)); + expect(api.getObserverTelemetry).toHaveBeenCalledWith(id, "24h", "1h", undefined, expect.any(AbortSignal)); expect(result.current.sample.uptime).toBe(400); }); @@ -53,7 +64,7 @@ describe("useAtlasObserver", () => { const { result } = renderHook(() => useAtlasObserver(id, "3d", true, true), { wrapper }); await tick(10); expect(result.current.sample.heard).toBe(4); - expect(api.getObserverActivity).toHaveBeenLastCalledWith(id, "72h", "1h", undefined); + expect(api.getObserverActivity).toHaveBeenLastCalledWith(id, "72h", "1h", undefined, expect.any(AbortSignal)); await tick(ATLAS_REFRESH_MS + 50); expect(api.getObserverActivity).toHaveBeenCalledTimes(2); expect(api.getObserver).toHaveBeenCalledTimes(2); diff --git a/tests/features/atlas/useCardDrag.test.tsx b/tests/features/atlas/useCardDrag.test.tsx index 97b0a0a..012dbf1 100644 --- a/tests/features/atlas/useCardDrag.test.tsx +++ b/tests/features/atlas/useCardDrag.test.tsx @@ -25,15 +25,54 @@ function mount() { ({ left: index * 100, right: index * 100 + 100, top: 0, bottom: 100, width: 100, height: 100, x: index * 100, y: 0, toJSON() {} }) as DOMRect; }); } -const move = (x: number, y = 50) => fireEvent(window, new MouseEvent("pointermove", { clientX: x, clientY: y })); -const drop = () => fireEvent(window, new MouseEvent("pointerup")); +const move = (x: number, y = 50) => pointer("pointermove", window, 0, x, y); +const drop = () => pointer("pointerup", window, 0); +const pointer = (type: string, target: Element | Window, id: number, x = 50, y = 50) => { + const event = new MouseEvent(type, { bubbles: true, button: 0, clientX: x, clientY: y }); + Object.defineProperty(event, "pointerId", { value: id }); + fireEvent(target, event); +}; beforeEach(() => vi.clearAllMocks()); describe("useCardDrag", () => { + it("ignores movement and release from another pointer", () => { + mount(); + pointer("pointerdown", screen.getByLabelText("handle 0"), 1); + pointer("pointermove", window, 2, 250); + pointer("pointerup", window, 2); + expect(onReorder).not.toHaveBeenCalled(); + pointer("pointermove", window, 1, 150); + pointer("pointerup", window, 1); + expect(onReorder).toHaveBeenCalledExactlyOnceWith(0, 1); + }); + + it("ignores a second drag start and removes the first drag's listeners on drop", () => { + mount(); + pointer("pointerdown", screen.getByLabelText("handle 0"), 1); + pointer("pointerdown", screen.getByLabelText("handle 1"), 2, 150); + pointer("pointermove", window, 1, 250); + pointer("pointerup", window, 1); + expect(onReorder).toHaveBeenCalledExactlyOnceWith(0, 2); + pointer("pointermove", window, 2, 150); + pointer("pointerup", window, 2); + expect(onReorder).toHaveBeenCalledTimes(1); + expect(screen.getByTestId("card-0").style.transform).toBe(""); + }); + + it("cancels when the window loses focus", () => { + mount(); + pointer("pointerdown", screen.getByLabelText("handle 0"), 1); + pointer("pointermove", window, 1, 250); + fireEvent.blur(window); + pointer("pointerup", window, 1); + expect(onReorder).not.toHaveBeenCalled(); + expect(screen.getByTestId("card-0").style.transform).toBe(""); + }); + it("moves the dragged card with the pointer and slides the others out of its way", () => { mount(); - fireEvent.pointerDown(screen.getByLabelText("handle 0"), { clientX: 50, clientY: 50 }); + pointer("pointerdown", screen.getByLabelText("handle 0"), 0); move(250); expect(screen.getByTestId("card-0")).toHaveStyle({ transform: "translate(200px, 0px)" }); expect(screen.getByTestId("card-1")).toHaveStyle({ transform: "translate(-100px, 0px)" }); @@ -45,7 +84,7 @@ describe("useCardDrag", () => { it("slides cards forward when dragging towards the start", () => { mount(); - fireEvent.pointerDown(screen.getByLabelText("handle 2"), { clientX: 250, clientY: 50 }); + pointer("pointerdown", screen.getByLabelText("handle 2"), 0, 250); move(120); expect(screen.getByTestId("card-1")).toHaveStyle({ transform: "translate(100px, 0px)" }); expect(screen.getByTestId("card-0").style.transform).toBe(""); @@ -55,7 +94,7 @@ describe("useCardDrag", () => { it("does nothing on a drop in the same slot or outside every card", () => { mount(); - fireEvent.pointerDown(screen.getByLabelText("handle 1"), { clientX: 150, clientY: 50 }); + pointer("pointerdown", screen.getByLabelText("handle 1"), 0, 150); move(160); move(160, 900); drop(); @@ -64,7 +103,7 @@ describe("useCardDrag", () => { it("cancels on Escape", () => { mount(); - fireEvent.pointerDown(screen.getByLabelText("handle 0"), { clientX: 50, clientY: 50 }); + pointer("pointerdown", screen.getByLabelText("handle 0"), 0); move(250); fireEvent.keyDown(window, { key: "Escape" }); expect(screen.getByTestId("card-0").style.transform).toBe(""); diff --git a/tests/features/stats/cards.test.tsx b/tests/features/stats/cards.test.tsx index cf5be95..5ca6433 100644 --- a/tests/features/stats/cards.test.tsx +++ b/tests/features/stats/cards.test.tsx @@ -31,3 +31,11 @@ it("draws a presence strip with one bar per run of reporting hours", () => { const bars = [...container.querySelectorAll("rect")]; expect(bars.map((b) => [b.getAttribute("x"), b.getAttribute("width")])).toEqual([["0", "48"], ["72", "24"]]); }); + + +it("draws isolated samples and dashed gap connectors without filling missing readings", () => { + const { container } = render(); + expect(container.querySelectorAll("[data-sample]")).toHaveLength(2); + expect(container.querySelectorAll("[data-gap]")).toHaveLength(1); + expect(container.querySelector("[data-gap]")).toHaveAttribute("stroke-dasharray", "3 3"); +});