From f400dc5f762461adcd7490435cf8f96e7b7008bd Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 27 Jul 2026 19:37:18 -0400 Subject: [PATCH] Add the expanded packet region with skeleton, error and empty states --- src/features/packets/PacketExpansion.tsx | 76 ++++++++++ .../features/packets/PacketExpansion.test.tsx | 131 ++++++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 src/features/packets/PacketExpansion.tsx create mode 100644 tests/features/packets/PacketExpansion.test.tsx diff --git a/src/features/packets/PacketExpansion.tsx b/src/features/packets/PacketExpansion.tsx new file mode 100644 index 0000000..ee196a6 --- /dev/null +++ b/src/features/packets/PacketExpansion.tsx @@ -0,0 +1,76 @@ +import type { PacketSummary } from "../../types/api"; +import { formatPropagation } from "../../lib/formatters"; +import { Timestamp } from "../../components/Timestamp"; +import { usePacketDetail } from "./usePacketDetail"; +import { ObservationTable } from "./ObservationTable"; + +// Roughly what fits the scroll cap; observations are unbounded server-side. +const SKELETON_ROW_CAP = 12; + +const ACTION_BUTTON_CLASS = + "border border-border rounded-sm px-2 py-0.5 bg-bg-raised text-text-normal hover:bg-text-normal/3 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer transition-colors"; + +interface Props { + packet: PacketSummary; + onOpenAnalyzer: () => void; + onViewPath: () => void; + selectedObservationId: number | null; + onSelectObservation: (id: number) => void; +} + +// Expanded region under a packet row: a summary-driven timing strip (instant, no fetch wait) plus +// the per-observer table, which does wait on usePacketDetail. +export function PacketExpansion({ packet, onOpenAnalyzer, onViewPath, selectedObservationId, onSelectObservation }: Props) { + const { data, isLoading, isError, refetch } = usePacketDetail(packet.packetHash); + // firstHeardAt/lastHeardAt are epoch ms (same unit Timestamp expects), so the difference is + // already in ms for formatPropagation -- no *1000 here. + const spread = packet.lastHeardAt - packet.firstHeardAt; + const ready = !isLoading && !isError; + // The summary already knows the count is zero, so skip the fetch-driven states entirely rather + // than showing a blank (0-row) skeleton while it loads. + const noObservations = packet.observationCount === 0; + const emptyState =
No observations
; + + return ( +
+
+ first + last + spread {formatPropagation(spread)} + + +
+ +
+ {noObservations ? ( + emptyState + ) : isLoading ? ( +
+ {Array.from({ length: Math.min(packet.observationCount, SKELETON_ROW_CAP) }).map((_, i) => ( +
+ ))} +
+ ) : isError ? ( +
+ Failed to load observations + +
+ ) : data && data.observations.length === 0 ? ( + emptyState + ) : data ? ( + + ) : null} +
+
+ ); +} diff --git a/tests/features/packets/PacketExpansion.test.tsx b/tests/features/packets/PacketExpansion.test.tsx new file mode 100644 index 0000000..b1b9074 --- /dev/null +++ b/tests/features/packets/PacketExpansion.test.tsx @@ -0,0 +1,131 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { PacketExpansion } from "../../../src/features/packets/PacketExpansion"; +import type { PacketSummary, Observation } from "../../../src/types/api"; + +const usePacketDetail = vi.fn(); +vi.mock("../../../src/features/packets/usePacketDetail", () => ({ + usePacketDetail: (h: string | null) => usePacketDetail(h), +})); + +const pkt = (over: Partial = {}): PacketSummary => ({ + packetHash: "AA11", payloadType: 1, payloadTypeName: "ADVERT", + routeType: 1, routeTypeName: "FLOOD", + firstHeardAt: 1700000000, lastHeardAt: 1700000002, observationCount: 3, ...over, +}); + +const obs = (id: number, over: Partial = {}): Observation => ({ + id, observerId: `o${id}`, observerName: `Observer ${id}`, iata: "YVR", + heardAt: 1700000000 + id, pathLength: { raw: "41", hashSize: 1, hopCount: 1 }, + sourceBroker: "b1", resolvedPath: [], ...over, +}); + +const props = { + packet: pkt(), onOpenAnalyzer: () => {}, onViewPath: () => {}, + selectedObservationId: null, onSelectObservation: () => {}, +}; + +beforeEach(() => usePacketDetail.mockReset()); + +describe("PacketExpansion", () => { + it("shows one skeleton row per expected observation while loading", () => { + usePacketDetail.mockReturnValue({ isLoading: true }); + render(); + expect(screen.getAllByTestId("observation-skeleton")).toHaveLength(3); + }); + + it("caps skeleton rows at the scroll cap for a large observation count", () => { + usePacketDetail.mockReturnValue({ isLoading: true }); + render(); + expect(screen.getAllByTestId("observation-skeleton")).toHaveLength(12); + }); + + it("shows an error line with retry on failure", () => { + usePacketDetail.mockReturnValue({ isError: true, refetch: vi.fn() }); + render(); + expect(screen.getByText("Failed to load observations")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument(); + }); + + it("calls refetch when retry is clicked", () => { + const refetch = vi.fn(); + usePacketDetail.mockReturnValue({ isError: true, refetch }); + render(); + fireEvent.click(screen.getByRole("button", { name: /retry/i })); + expect(refetch).toHaveBeenCalled(); + }); + + it("shows an empty state when the packet has no observations", () => { + usePacketDetail.mockReturnValue({ data: { packetHash: "AA11", observations: [] } }); + render(); + expect(screen.getByText("No observations")).toBeInTheDocument(); + }); + + it("shows the empty state immediately when the summary count is zero, without a skeleton", () => { + usePacketDetail.mockReturnValue({ isLoading: true }); + render(); + expect(screen.getByText("No observations")).toBeInTheDocument(); + expect(screen.queryByTestId("observation-skeleton")).not.toBeInTheDocument(); + }); + + it("renders the timing strip from the summary without waiting for the fetch", () => { + usePacketDetail.mockReturnValue({ isLoading: true }); + render(); + expect(screen.getByText(/spread/i)).toBeInTheDocument(); + }); + + it("formats the spread as first/last converted from milliseconds, not re-scaled", () => { + usePacketDetail.mockReturnValue({ isLoading: true }); + render(); + expect(screen.getByText("spread 2.500s")).toBeInTheDocument(); + }); + + it("renders the observation table once data resolves", () => { + usePacketDetail.mockReturnValue({ data: { packetHash: "AA11", observations: [obs(1), obs(2)] } }); + render(); + expect(screen.getByText("Observer 1")).toBeInTheDocument(); + expect(screen.getByText("Observer 2")).toBeInTheDocument(); + }); + + it("wires selectedObservationId and onSelectObservation through to the observation table", () => { + const onSelectObservation = vi.fn(); + usePacketDetail.mockReturnValue({ data: { packetHash: "AA11", observations: [obs(1), obs(2)] } }); + render(); + const rows = screen.getAllByRole("row").slice(1); + expect(rows[1]).toHaveAttribute("aria-selected", "true"); + fireEvent.click(screen.getByText("Observer 1")); + expect(onSelectObservation).toHaveBeenCalledWith(1); + }); + + it("disables both action buttons while loading", () => { + usePacketDetail.mockReturnValue({ isLoading: true }); + render(); + expect(screen.getByRole("button", { name: "Open analyzer" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "View path on map" })).toBeDisabled(); + }); + + it("disables both action buttons on error", () => { + usePacketDetail.mockReturnValue({ isError: true, refetch: vi.fn() }); + render(); + expect(screen.getByRole("button", { name: "Open analyzer" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "View path on map" })).toBeDisabled(); + }); + + it("enables both action buttons once the fetch resolves", () => { + usePacketDetail.mockReturnValue({ data: { packetHash: "AA11", observations: [] } }); + render(); + expect(screen.getByRole("button", { name: "Open analyzer" })).not.toBeDisabled(); + expect(screen.getByRole("button", { name: "View path on map" })).not.toBeDisabled(); + }); + + it("calls onOpenAnalyzer and onViewPath when their buttons are clicked", () => { + const onOpenAnalyzer = vi.fn(); + const onViewPath = vi.fn(); + usePacketDetail.mockReturnValue({ data: { packetHash: "AA11", observations: [] } }); + render(); + fireEvent.click(screen.getByRole("button", { name: "Open analyzer" })); + fireEvent.click(screen.getByRole("button", { name: "View path on map" })); + expect(onOpenAnalyzer).toHaveBeenCalledTimes(1); + expect(onViewPath).toHaveBeenCalledTimes(1); + }); +});