mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-10-23 20:22:39 +00:00
Collapse the packet row to a single line with hops and endpoints
The header and the rows are separate grids, so the shared track list can't use ch or auto — each resolved them differently and the columns drifted apart. Observer moves into the expansion to free the column.
This commit is contained in:
1 parent
d1ba42e692
commit
1f3fb5b7c9
11 files changed
+210
-204
No files matched your search
@@ -0,0 +1,47 @@
|
||||
import type { PacketSummary } from "../../types/api";
|
||||
import type { PathConfidence } from "../../types/enums";
|
||||
import { buildPathSummary, type PathChip } from "./path-summary";
|
||||
|
||||
// Same three-state vocabulary PathData uses in the analyzer.
|
||||
const CONFIDENCE_CLASSES: Record<PathConfidence, string> = {
|
||||
high: "bg-green/8 text-green",
|
||||
ambiguous: "bg-warn/8 text-warn",
|
||||
none: "bg-text-muted/8 text-text-dim",
|
||||
};
|
||||
|
||||
function Chip({ chip }: { chip: PathChip }) {
|
||||
if (chip.kind === "hex") {
|
||||
return <span className="font-mono text-[10px] text-text-dim tracking-wider px-1">{chip.label}</span>;
|
||||
}
|
||||
if (chip.kind === "unresolved-run") {
|
||||
return (
|
||||
<span className={`font-mono text-[10px] px-1.5 py-px rounded-sm ${CONFIDENCE_CLASSES.none}`}>
|
||||
{chip.count === 1 ? "?" : `?×${chip.count}`}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className={`font-mono text-[10px] px-1.5 py-px rounded-sm truncate ${CONFIDENCE_CLASSES[chip.confidence]}`}>
|
||||
{chip.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const Na = () => <span className="text-text-dim">n/a</span>;
|
||||
|
||||
// The packet's logical endpoints. beacon-server resolves these on the WS feed only and leaves them
|
||||
// nil on the REST list, so scrollback rows read n/a — as do payload types with no addressed
|
||||
// endpoint at all (GRP_TXT/GRP_DATA/TRACE).
|
||||
export function PacketEndpoints({ packet }: { packet: PacketSummary }) {
|
||||
const { source, destination } = buildPathSummary(packet);
|
||||
// One n/a for the pair reads better than "n/a → n/a" on every historical row.
|
||||
if (!source && !destination) return <Na />;
|
||||
|
||||
return (
|
||||
<span className="flex items-center gap-x-1 min-w-0">
|
||||
{source ? <Chip chip={source} /> : <Na />}
|
||||
<span className="text-text-dim px-0.5" aria-hidden>→</span>
|
||||
{destination ? <Chip chip={destination} /> : <Na />}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ interface Props {
|
||||
// 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);
|
||||
const observer = packet.latestObserver;
|
||||
// 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;
|
||||
@@ -43,6 +44,12 @@ export function PacketExpansion({ packet, onOpenAnalyzer, onViewPath, selectedOb
|
||||
return (
|
||||
<div data-testid="packet-expansion" className="bg-bg-surface border-l-2 border-primary pl-6 pr-3 py-2">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-[10px] text-text-muted pb-2">
|
||||
<span>
|
||||
observer{" "}
|
||||
{observer
|
||||
? <span className="text-text-normal">{observer.displayName ?? observer.id.slice(0, 8)}</span>
|
||||
: <span className="text-text-dim">n/a</span>}
|
||||
</span>
|
||||
<span>first <Timestamp value={packet.firstHeardAt} /></span>
|
||||
<span>last <Timestamp value={packet.lastHeardAt} /></span>
|
||||
<span>spread {formatPropagation(spread)}</span>
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import type { PacketSummary } from "../../types/api";
|
||||
import type { PathConfidence } from "../../types/enums";
|
||||
import { buildPathSummary, type PathChip } from "./path-summary";
|
||||
|
||||
// Same three-state vocabulary PathData uses in the analyzer.
|
||||
const CONFIDENCE_CLASSES: Record<PathConfidence, string> = {
|
||||
high: "bg-green/8 text-green",
|
||||
ambiguous: "bg-warn/8 text-warn",
|
||||
none: "bg-text-muted/8 text-text-dim",
|
||||
};
|
||||
|
||||
function Chip({ chip }: { chip: PathChip }) {
|
||||
if (chip.kind === "hex") {
|
||||
return <span className="font-mono text-[10px] text-text-dim tracking-wider px-1">{chip.label}</span>;
|
||||
}
|
||||
if (chip.kind === "unresolved-run") {
|
||||
return (
|
||||
<span className={`font-mono text-[10px] px-1.5 py-px rounded-sm ${CONFIDENCE_CLASSES.none}`}>
|
||||
{chip.count === 1 ? "?" : `?×${chip.count}`}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className={`font-mono text-[10px] px-1.5 py-px rounded-sm ${CONFIDENCE_CLASSES[chip.confidence]}`}>
|
||||
{chip.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Line 2 of a packet row: always the latest observation's path, never the selected one.
|
||||
export function PacketPathLine({ packet }: { packet: PacketSummary }) {
|
||||
const summary = buildPathSummary(packet);
|
||||
|
||||
if (summary.isNa) {
|
||||
return <span className="text-[11px] text-text-dim">n/a</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-x-1 text-[11px]">
|
||||
<span className="text-text-dim uppercase tracking-wider text-[9px] mr-1">latest</span>
|
||||
<span className="text-text-muted mr-2">{summary.hopLabel}</span>
|
||||
{summary.chips.map((chip, i) => <Chip key={i} chip={chip} />)}
|
||||
{summary.overflow > 0 && (
|
||||
<span className="font-mono text-[10px] text-primary bg-primary/8 px-1.5 py-px rounded-sm">
|
||||
+{summary.overflow} more
|
||||
</span>
|
||||
)}
|
||||
{(summary.source || summary.destination) && (
|
||||
<span className="flex items-center gap-x-1 ml-8">
|
||||
{summary.source ? <Chip chip={summary.source} /> : <span className="text-text-dim">n/a</span>}
|
||||
<span className="text-text-dim px-0.5" aria-hidden>→</span>
|
||||
{summary.destination ? <Chip chip={summary.destination} /> : <span className="text-text-dim">n/a</span>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,8 @@ export function PacketTableHeader() {
|
||||
<span>Type</span>
|
||||
<span>Route</span>
|
||||
<span>Obs</span>
|
||||
<span>Observer</span>
|
||||
<span>Hops</span>
|
||||
<span>Src → Dst</span>
|
||||
<span>IATA</span>
|
||||
<span className="text-right">Age</span>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { payloadTypeVariant } from "../../components/badge-utils";
|
||||
import { PAYLOAD_TYPE_NAMES, type PayloadTypeValue } from "../../types/enums";
|
||||
import type { PacketSummary } from "../../types/api";
|
||||
import { GRID_TEMPLATE } from "./packet-grid";
|
||||
import { PacketPathLine } from "./PacketPathLine";
|
||||
import { PacketEndpoints } from "./PacketEndpoints";
|
||||
|
||||
interface PacketTableRowProps {
|
||||
packet: PacketSummary;
|
||||
@@ -15,10 +15,11 @@ interface PacketTableRowProps {
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
// two-line table row: line 1 is the identity grid (shares GRID_TEMPLATE with the header), line 2
|
||||
// is the latest path. The whole row is the expansion click target.
|
||||
// Single-line table row sharing GRID_TEMPLATE with the sticky header. The observer lives in the
|
||||
// expansion instead, which frees the wide column for the packet's endpoints.
|
||||
export function PacketTableRow({ packet, expanded, isFresh, onToggle }: PacketTableRowProps) {
|
||||
const observer = packet.latestObserver;
|
||||
// ?? not ||, so a legitimate 0-hop direct packet still shows its count
|
||||
const hopCount = packet.latestObserver?.pathLength?.hopCount;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -53,20 +54,19 @@ export function PacketTableRow({ packet, expanded, isFresh, onToggle }: PacketTa
|
||||
{packet.scope && <ScopeTag>{packet.scope}</ScopeTag>}
|
||||
</span>
|
||||
<span className="font-mono text-text-muted">×{packet.observationCount}</span>
|
||||
<span className="text-text-normal truncate">
|
||||
{observer ? (observer.displayName ?? observer.id.slice(0, 8)) : <span className="text-text-dim">n/a</span>}
|
||||
<span className="font-mono text-text-muted">
|
||||
{hopCount ?? <span className="text-text-dim">n/a</span>}
|
||||
</span>
|
||||
<span className="min-w-0 overflow-hidden">
|
||||
<PacketEndpoints packet={packet} />
|
||||
</span>
|
||||
<span className="font-mono font-bold text-primary tracking-wider">
|
||||
{observer?.iata ?? <span className="text-text-dim font-normal">n/a</span>}
|
||||
{packet.latestObserver?.iata ?? <span className="text-text-dim font-normal">n/a</span>}
|
||||
</span>
|
||||
<span className="text-right text-text-muted">
|
||||
<Timestamp value={packet.lastHeardAt} />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="px-3 pb-1.5 pl-[2.25rem]">
|
||||
<PacketPathLine packet={packet} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -54,7 +54,8 @@ export function PacketVirtualList({
|
||||
const virtualizer = useVirtualizer({
|
||||
count: packets.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 64, // a collapsed two-line row; expanded ones are remeasured
|
||||
// a collapsed row: one grid line on desktop, a taller card below md. Expanded rows are remeasured.
|
||||
estimateSize: () => (isMobile ? 64 : 43),
|
||||
overscan: 10,
|
||||
getItemKey: (index) => packets[index]?.packetHash ?? index,
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// One track list shared by the sticky header and every row, so columns stay aligned. Line 2 spans
|
||||
// all of it via grid-column: 1 / -1.
|
||||
export const GRID_TEMPLATE = "1.25rem minmax(7ch,auto) 6rem 5rem 3rem minmax(8rem,1fr) 3.5rem 5rem";
|
||||
// One track list shared by the sticky header and every row, so columns stay aligned. Keep every
|
||||
// track font- and content-independent: the header and the rows are separate grids, so a `ch` track
|
||||
// resolves against each one's own font size (9px vs 11px) and an `auto` track against its own text
|
||||
// ("HASH" vs "4AE77F09") — either silently drifts the two apart.
|
||||
export const GRID_TEMPLATE = "1.25rem 5rem 6rem 5rem 3rem 3rem minmax(6rem,1fr) 3.5rem 5rem";
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { PacketEndpoints } from "../../../src/features/packets/PacketEndpoints";
|
||||
import type { LatestObserver, PacketSummary } from "../../../src/types/api";
|
||||
|
||||
const pkt = (observer?: LatestObserver): PacketSummary => ({
|
||||
packetHash: "AA11", payloadType: 1, payloadTypeName: "ADVERT",
|
||||
routeType: 1, routeTypeName: "FLOOD",
|
||||
firstHeardAt: 0, lastHeardAt: 0, observationCount: 1, latestObserver: observer,
|
||||
});
|
||||
|
||||
const obs = (over: Partial<LatestObserver> = {}): LatestObserver => ({
|
||||
id: "o1", iata: "YVR", pathLength: { raw: "00", hashSize: 1, hopCount: 0 }, ...over,
|
||||
});
|
||||
|
||||
describe("PacketEndpoints", () => {
|
||||
it("renders a single n/a when there is no observer at all", () => {
|
||||
render(<PacketEndpoints packet={pkt()} />);
|
||||
expect(screen.getByText("n/a")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders both endpoints with the arrow glyph between them", () => {
|
||||
render(<PacketEndpoints packet={pkt(obs({
|
||||
resolvedSource: { confidence: "high", nodes: [{ id: "s", publicKey: "aa", name: "SrcNode" }] },
|
||||
resolvedDestination: { confidence: "high", nodes: [{ id: "d", publicKey: "bb", name: "DstNode" }] },
|
||||
}))} />);
|
||||
expect(screen.getByText("SrcNode")).toBeInTheDocument();
|
||||
expect(screen.getByText("DstNode")).toBeInTheDocument();
|
||||
expect(screen.getByText("→")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows n/a for a missing endpoint while the present one still renders", () => {
|
||||
render(<PacketEndpoints packet={pkt(obs({
|
||||
resolvedSource: { confidence: "high", nodes: [{ id: "s", publicKey: "aa", name: "SrcNode" }] },
|
||||
}))} />);
|
||||
expect(screen.getByText("SrcNode")).toBeInTheDocument();
|
||||
expect(screen.getByText("n/a")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The REST list leaves both nil, so this is the common scrollback case — one n/a, no arrow.
|
||||
it("collapses to a single n/a when both endpoints are absent", () => {
|
||||
render(<PacketEndpoints packet={pkt(obs())} />);
|
||||
expect(screen.getByText("n/a")).toBeInTheDocument();
|
||||
expect(screen.queryByText("→")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("tints an ambiguous endpoint with the warn token", () => {
|
||||
render(<PacketEndpoints packet={pkt(obs({
|
||||
resolvedSource: { confidence: "ambiguous", nodes: [{ id: "n", publicKey: "ab", name: "Raven" }] },
|
||||
}))} />);
|
||||
expect(screen.getByText("Raven").className).toContain("text-warn");
|
||||
});
|
||||
|
||||
it("tints a high-confidence endpoint with the green token", () => {
|
||||
render(<PacketEndpoints packet={pkt(obs({
|
||||
resolvedSource: { confidence: "high", nodes: [{ id: "n", publicKey: "ab", name: "Falcon" }] },
|
||||
}))} />);
|
||||
expect(screen.getByText("Falcon").className).toContain("text-green");
|
||||
});
|
||||
|
||||
it("renders a bare ? for an endpoint the backend could not resolve", () => {
|
||||
render(<PacketEndpoints packet={pkt(obs({
|
||||
resolvedSource: { confidence: "none", nodes: [] },
|
||||
}))} />);
|
||||
expect(screen.getByText("?")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,114 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { PacketPathLine } from "../../../src/features/packets/PacketPathLine";
|
||||
import type { PacketSummary } from "../../../src/types/api";
|
||||
|
||||
const pkt = (over: Partial<PacketSummary> = {}): PacketSummary => ({
|
||||
packetHash: "AA11", payloadType: 1, payloadTypeName: "ADVERT",
|
||||
routeType: 1, routeTypeName: "FLOOD",
|
||||
firstHeardAt: 0, lastHeardAt: 0, observationCount: 1, ...over,
|
||||
});
|
||||
|
||||
describe("PacketPathLine", () => {
|
||||
it("renders a single n/a when there is nothing to show", () => {
|
||||
render(<PacketPathLine packet={pkt()} />);
|
||||
expect(screen.getByText("n/a")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders hop label, hex chips and the overflow count", () => {
|
||||
render(<PacketPathLine packet={pkt({
|
||||
latestObserver: {
|
||||
id: "o1", iata: "YVR",
|
||||
pathLength: { raw: "4e", hashSize: 1, hopCount: 14 },
|
||||
pathBytes: "000102030405060708090a0b0c0d",
|
||||
},
|
||||
})} />);
|
||||
expect(screen.getByText("14 hops")).toBeInTheDocument();
|
||||
expect(screen.getByText("00")).toBeInTheDocument();
|
||||
expect(screen.getByText("+9 more")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("tints an ambiguous hop with the warn token", () => {
|
||||
render(<PacketPathLine packet={pkt({
|
||||
latestObserver: {
|
||||
id: "o1", iata: "YVR",
|
||||
pathLength: { raw: "41", hashSize: 1, hopCount: 1 }, pathBytes: "7f",
|
||||
resolvedPath: [{ confidence: "ambiguous", nodes: [{ id: "n", publicKey: "ab", name: "Raven" }] }],
|
||||
},
|
||||
})} />);
|
||||
expect(screen.getByText("Raven").className).toContain("text-warn");
|
||||
});
|
||||
|
||||
it("tints a high-confidence hop with the green token", () => {
|
||||
render(<PacketPathLine packet={pkt({
|
||||
latestObserver: {
|
||||
id: "o1", iata: "YVR",
|
||||
pathLength: { raw: "41", hashSize: 1, hopCount: 1 }, pathBytes: "7f",
|
||||
resolvedPath: [{ confidence: "high", nodes: [{ id: "n", publicKey: "ab", name: "Falcon" }] }],
|
||||
},
|
||||
})} />);
|
||||
expect(screen.getByText("Falcon").className).toContain("text-green");
|
||||
});
|
||||
|
||||
it("renders both endpoints with the arrow glyph between them", () => {
|
||||
render(<PacketPathLine packet={pkt({
|
||||
latestObserver: {
|
||||
id: "o1", iata: "YVR",
|
||||
pathLength: { raw: "00", hashSize: 1, hopCount: 0 },
|
||||
resolvedSource: { confidence: "high", nodes: [{ id: "s", publicKey: "aa", name: "SrcNode" }] },
|
||||
resolvedDestination: { confidence: "high", nodes: [{ id: "d", publicKey: "bb", name: "DstNode" }] },
|
||||
},
|
||||
})} />);
|
||||
expect(screen.getByText("SrcNode")).toBeInTheDocument();
|
||||
expect(screen.getByText("DstNode")).toBeInTheDocument();
|
||||
expect(screen.getByText("→")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows n/a for a missing endpoint while the present one still renders", () => {
|
||||
render(<PacketPathLine packet={pkt({
|
||||
latestObserver: {
|
||||
id: "o1", iata: "YVR",
|
||||
pathLength: { raw: "00", hashSize: 1, hopCount: 0 },
|
||||
resolvedSource: { confidence: "high", nodes: [{ id: "s", publicKey: "aa", name: "SrcNode" }] },
|
||||
},
|
||||
})} />);
|
||||
expect(screen.getByText("SrcNode")).toBeInTheDocument();
|
||||
expect(screen.getByText("n/a")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("omits the endpoint block entirely when both endpoints are absent", () => {
|
||||
render(<PacketPathLine packet={pkt({
|
||||
latestObserver: {
|
||||
id: "o1", iata: "YVR",
|
||||
pathLength: { raw: "00", hashSize: 1, hopCount: 0 },
|
||||
},
|
||||
})} />);
|
||||
expect(screen.queryByText("→")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a bare ? for a single unresolved hop", () => {
|
||||
render(<PacketPathLine packet={pkt({
|
||||
latestObserver: {
|
||||
id: "o1", iata: "YVR",
|
||||
pathLength: { raw: "41", hashSize: 1, hopCount: 1 }, pathBytes: "7f",
|
||||
resolvedPath: [{ confidence: "none", nodes: [] }],
|
||||
},
|
||||
})} />);
|
||||
expect(screen.getByText("?")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("collapses a run of unresolved hops into a single ?×N chip", () => {
|
||||
render(<PacketPathLine packet={pkt({
|
||||
latestObserver: {
|
||||
id: "o1", iata: "YVR",
|
||||
pathLength: { raw: "43", hashSize: 1, hopCount: 3 }, pathBytes: "7f7f7f",
|
||||
resolvedPath: [
|
||||
{ confidence: "none", nodes: [] },
|
||||
{ confidence: "none", nodes: [] },
|
||||
{ confidence: "none", nodes: [] },
|
||||
],
|
||||
},
|
||||
})} />);
|
||||
expect(screen.getByText("?×3")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -6,11 +6,16 @@ import { GRID_TEMPLATE } from "../../../src/features/packets/packet-grid";
|
||||
describe("PacketTableHeader", () => {
|
||||
it("declares every column heading", () => {
|
||||
render(<PacketTableHeader />);
|
||||
for (const h of ["Hash", "Type", "Route", "Obs", "Observer", "IATA", "Age"]) {
|
||||
for (const h of ["Hash", "Type", "Route", "Obs", "Hops", "Src → Dst", "IATA", "Age"]) {
|
||||
expect(screen.getByText(h)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("no longer heads an observer column, which moved into the expansion", () => {
|
||||
render(<PacketTableHeader />);
|
||||
expect(screen.queryByText("Observer")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("is hidden below md", () => {
|
||||
const { container } = render(<PacketTableHeader />);
|
||||
expect(container.firstElementChild?.className).toContain("hidden");
|
||||
@@ -23,9 +28,17 @@ describe("PacketTableHeader", () => {
|
||||
expect(el.style.gridTemplateColumns).toBe(GRID_TEMPLATE);
|
||||
});
|
||||
|
||||
it("has exactly 8 cells, one per row column including the chevron spacer", () => {
|
||||
it("has exactly 9 cells, one per row column including the chevron spacer", () => {
|
||||
const { container } = render(<PacketTableHeader />);
|
||||
expect(container.firstElementChild?.children).toHaveLength(8);
|
||||
expect(container.firstElementChild?.children).toHaveLength(9);
|
||||
});
|
||||
|
||||
// Regression: the header and the rows are two independent grids. A `ch` track resolves against
|
||||
// each one's own font size (header 9px vs row 11px) and an `auto`/`min-content` track against its
|
||||
// own content ("HASH" vs "4AE77F09"), so either kind silently drifts the columns apart.
|
||||
it("sizes every track in font-independent units so both grids resolve identically", () => {
|
||||
expect(GRID_TEMPLATE).not.toMatch(/\bch\b/);
|
||||
expect(GRID_TEMPLATE).not.toMatch(/auto|min-content|max-content|fit-content/);
|
||||
});
|
||||
|
||||
it("leaves the leading chevron-alignment cell unlabeled and hidden from screen readers", () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent, within } from "@testing-library/react";
|
||||
import { PacketTableRow } from "../../../src/features/packets/PacketTableRow";
|
||||
import type { PacketSummary } from "../../../src/types/api";
|
||||
import type { LatestObserver, PacketSummary, ResolvedHop } from "../../../src/types/api";
|
||||
|
||||
const pkt = (over: Partial<PacketSummary> = {}): PacketSummary => ({
|
||||
packetHash: "AA11BB22", payloadType: 1, payloadTypeName: "ADVERT",
|
||||
@@ -9,6 +9,19 @@ const pkt = (over: Partial<PacketSummary> = {}): PacketSummary => ({
|
||||
firstHeardAt: 1700000000, lastHeardAt: 1700000000, observationCount: 3, ...over,
|
||||
});
|
||||
|
||||
const node = (name: string): ResolvedHop => ({
|
||||
confidence: "high",
|
||||
nodes: [{ id: "n-1", name, publicKey: "aabbccdd" }],
|
||||
});
|
||||
|
||||
// pathLength is what makes buildPathSummary produce endpoints at all, so it is always present here.
|
||||
const observer = (
|
||||
over: { hopCount?: number } & Partial<Pick<LatestObserver, "resolvedSource" | "resolvedDestination">> = {},
|
||||
): LatestObserver => {
|
||||
const { hopCount = 2, ...rest } = over;
|
||||
return { id: "abcdef1234", iata: "YVR", pathLength: { raw: "1e", hashSize: 1, hopCount }, ...rest };
|
||||
};
|
||||
|
||||
describe("PacketTableRow", () => {
|
||||
it("exposes one button carrying the expansion state", () => {
|
||||
render(<PacketTableRow packet={pkt()} expanded={false} onToggle={() => {}} />);
|
||||
@@ -23,27 +36,53 @@ describe("PacketTableRow", () => {
|
||||
expect(onToggle).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("renders line 2 even when there is no path data, so row height is constant", () => {
|
||||
render(<PacketTableRow packet={pkt()} expanded={false} onToggle={() => {}} />);
|
||||
expect(screen.getAllByText("n/a")).toHaveLength(3);
|
||||
it("is a single line, so the row height stays constant for the virtualizer", () => {
|
||||
const { container } = render(<PacketTableRow packet={pkt()} expanded={false} onToggle={() => {}} />);
|
||||
expect(container.querySelectorAll("button")).toHaveLength(1);
|
||||
expect(screen.queryByText("latest")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to n/a in the observer and IATA cells when there is no observer", () => {
|
||||
it("falls back to n/a in the hops, endpoint and IATA cells when there is no observer", () => {
|
||||
render(<PacketTableRow packet={pkt()} expanded={false} onToggle={() => {}} />);
|
||||
const line1 = within(screen.getByRole("button"));
|
||||
expect(line1.getAllByText("n/a")).toHaveLength(2);
|
||||
const row = within(screen.getByRole("button"));
|
||||
expect(row.getAllByText("n/a")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("falls back to the observer id when there is no display name", () => {
|
||||
render(<PacketTableRow packet={pkt({ latestObserver: { id: "abcdef1234", iata: "YVR" } })} expanded={false} onToggle={() => {}} />);
|
||||
expect(screen.getByText("abcdef12")).toBeInTheDocument();
|
||||
it("no longer shows the observer, which moved into the expansion", () => {
|
||||
render(<PacketTableRow packet={pkt({ latestObserver: { id: "abcdef1234", displayName: "Cypress Peak", iata: "YVR" } })} expanded={false} onToggle={() => {}} />);
|
||||
expect(screen.queryByText("Cypress Peak")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("abcdef12")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("YVR")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("prefers the observer display name over the id", () => {
|
||||
render(<PacketTableRow packet={pkt({ latestObserver: { id: "abcdef1234", displayName: "Cypress Peak", iata: "YVR" } })} expanded={false} onToggle={() => {}} />);
|
||||
expect(screen.getByText("Cypress Peak")).toBeInTheDocument();
|
||||
expect(screen.queryByText("abcdef12")).not.toBeInTheDocument();
|
||||
it("shows the hop count, which the REST list carries on every row", () => {
|
||||
render(<PacketTableRow packet={pkt({ observationCount: 7, latestObserver: observer({ hopCount: 3 }) })} expanded={false} onToggle={() => {}} />);
|
||||
expect(screen.getByText("3")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders resolved endpoints when the WS feed supplied them", () => {
|
||||
render(
|
||||
<PacketTableRow
|
||||
packet={pkt({ latestObserver: observer({ resolvedSource: node("Laprairie"), resolvedDestination: node("YUL1") }) })}
|
||||
expanded={false}
|
||||
onToggle={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Laprairie")).toBeInTheDocument();
|
||||
expect(screen.getByText("YUL1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The REST list leaves resolvedSource/Destination nil on purpose, so scrollback rows show one n/a
|
||||
// for the pair rather than "n/a → n/a".
|
||||
it("collapses the endpoint cell to a single n/a when neither endpoint resolved", () => {
|
||||
render(<PacketTableRow packet={pkt({ latestObserver: observer({ hopCount: 2 }) })} expanded={false} onToggle={() => {}} />);
|
||||
expect(screen.getAllByText("n/a")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("still marks the missing half when only one endpoint resolved", () => {
|
||||
render(<PacketTableRow packet={pkt({ latestObserver: observer({ resolvedSource: node("Laprairie") }) })} expanded={false} onToggle={() => {}} />);
|
||||
expect(screen.getByText("Laprairie")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("n/a")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("reflects the expanded state on the button and chevron", () => {
|
||||
|
||||
Reference in new issue
Block a user