Files
beacon-web/tests/features/packets/PacketAnalyzerDrawer.test.tsx
n30nex 139d3ce508 feat: stage current preview on dev for dev1 evaluation
Port the qualified web preview at c41e6fa8940fba929704bc2f9a94f119fef4a4ca onto
org dev b4d498e697. Preserve upstream changes and
branch policies; keep the independent private Collector service separate.

Preview-Version: 2.2.1-n30nex.1
Preview-Revision: c41e6fa8940fba929704bc2f9a94f119fef4a4ca
2026-10-04 16:31:50 -04:00

265 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, within } from "@testing-library/react";
import i18n from "../../../src/i18n";
import { MemoryRouter, useLocation } from "react-router-dom";
import { PacketAnalyzerDrawer } from "../../../src/features/packets/PacketAnalyzerDrawer";
import type { PacketDetail } from "../../../src/types/api";
import { PayloadType, RouteType } from "../../../src/types/enums";
function LocationProbe() {
const location = useLocation();
return <div data-testid="search">{location.search}</div>;
}
describe("PacketAnalyzerDrawer close", () => {
it("leaves the originating packet URL unchanged when used in an overlay", () => {
const onClose = vi.fn();
render(<MemoryRouter initialEntries={["/?tab=Packets&hash=origin&analyze=1&observation=7"]}><PacketAnalyzerDrawer detail={undefined} selectedObservationId={null} onClose={onClose} syncUrl={false} /><LocationProbe /></MemoryRouter>);
fireEvent.click(screen.getByLabelText("Close analyzer"));
expect(screen.getByTestId("search")).toHaveTextContent("hash=origin&analyze=1&observation=7");
expect(onClose).toHaveBeenCalledOnce();
});
it("removes ?analyze but keeps ?hash, and calls onClose", () => {
const onClose = vi.fn();
render(
<MemoryRouter initialEntries={["/?tab=Packets&hash=abc123&analyze=1"]}>
<PacketAnalyzerDrawer detail={undefined} selectedObservationId={null} onClose={onClose} />
<LocationProbe />
</MemoryRouter>,
);
fireEvent.click(screen.getByLabelText("Close analyzer"));
expect(onClose).toHaveBeenCalledOnce();
const search = screen.getByTestId("search").textContent ?? "";
expect(search).not.toContain("analyze=");
expect(search).toContain("hash=abc123"); // row stays expanded
expect(search).toContain("tab=Packets"); // other params survive
});
});
describe("packet reception evidence", () => {
const reports = () => {
const d = makeDetail([hop("a", -79, 43), hop("b", -75, 45)]);
d.observations[0].pathBytes = "aabb";
d.observations[0].observerName = "Alpha";
d.observations.push({ ...d.observations[0], id: 2, observerId: "beta", observerName: "Beta" });
return d;
};
it("restores a shared report and targets its observer and map", () => {
const onViewObserver = vi.fn(); const onViewPath = vi.fn();
render(<MemoryRouter initialEntries={["/?tab=Packets&hash=abcdef12&analyze=1&observation=2&q=keep"]}><PacketAnalyzerDrawer detail={reports()} selectedObservationId={null} onClose={() => {}} onViewObserver={onViewObserver} onViewPath={onViewPath} /><LocationProbe /></MemoryRouter>);
const section = screen.getByRole("region", { name: "Observations" });
expect(within(section).getByText("Path 1").closest("details")).toHaveAttribute("open");
const beta = within(section).getByText(/Beta/).closest("li")!;
expect(within(beta).getByRole("button", { name: /Select observation.*Beta/ })).toHaveAttribute("aria-pressed", "true");
expect(within(beta).getByText("SNR")).toBeInTheDocument();
expect(screen.queryByText(/^Observations \(/)).not.toBeInTheDocument();
for (const name of ["Inspect observer", "Map path"]) expect(within(beta).getByRole("button", { name })).toHaveClass("border", "border-border", "text-primary");
fireEvent.click(within(beta).getByRole("button", { name: "Inspect observer" })); expect(onViewObserver).toHaveBeenCalledWith("beta", 2);
fireEvent.click(within(beta).getByRole("button", { name: "Map path" })); expect(onViewPath).toHaveBeenCalledWith("beta");
expect(screen.getByTestId("search")).toHaveTextContent("observation=2"); expect(screen.getByTestId("search")).toHaveTextContent("q=keep");
});
it("collapses the observations list unless a report was picked", () => {
render(<MemoryRouter><PacketAnalyzerDrawer detail={reports()} selectedObservationId={null} onClose={() => {}} /></MemoryRouter>);
const section = screen.getByRole("region", { name: "Observations" });
const toggle = within(section).getByRole("button", { name: "Observations" });
expect(toggle).toHaveAttribute("aria-expanded", "false");
expect(section).toHaveTextContent("Heard by 2 observers");
expect(within(section).queryByText("Path 1")).not.toBeInTheDocument();
fireEvent.click(toggle);
expect(toggle).toHaveAttribute("aria-expanded", "true");
expect(within(section).getByText("Path 1")).toBeInTheDocument();
});
it("keeps the evidence notes in an info tip instead of paragraphs", () => {
render(<MemoryRouter><PacketAnalyzerDrawer detail={reports()} selectedObservationId={null} onClose={() => {}} onViewPath={() => {}} /></MemoryRouter>);
const section = screen.getByRole("region", { name: "Observations" });
expect(within(section).getByRole("button", { name: /Grouped by the repeaters.*One entry per observer/ })).toBeInTheDocument();
expect(within(section).queryByText(/Grouped by the repeaters/, { selector: "p" })).not.toBeInTheDocument();
expect(within(section).queryByText(/One entry per observer/, { selector: "p" })).not.toBeInTheDocument();
expect(within(section).queryByText(/Needs at least two repeaters/, { selector: "p" })).not.toBeInTheDocument();
});
it("shows the selected report only in the observations list, above the raw packet", () => {
render(<MemoryRouter><PacketAnalyzerDrawer detail={reports()} selectedObservationId={1} onClose={() => {}} /></MemoryRouter>);
const list = screen.getByRole("region", { name: "Observations" });
expect(screen.queryByText("Observation", { exact: true })).not.toBeInTheDocument();
expect(within(list).getByRole("button", { name: /Select observation.*Alpha/ })).toHaveAttribute("aria-pressed", "true");
const raw = screen.getByText("Raw Packet");
expect(list.compareDocumentPosition(raw) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it("reports an expired selection without quietly showing another report", () => {
render(<MemoryRouter initialEntries={["/?tab=Packets&hash=abcdef12&analyze=1&observation=99"]}><PacketAnalyzerDrawer detail={reports()} selectedObservationId={1} onClose={() => {}} /></MemoryRouter>);
expect(screen.getByRole("alert")).toHaveTextContent("That observation is no longer available");
expect(screen.queryByText("Raw Packet")).not.toBeInTheDocument();
});
it("keeps route evidence selections strict without requiring packet URL state", () => {
render(<MemoryRouter initialEntries={["/?tab=Routes&route=keep"]}><PacketAnalyzerDrawer detail={reports()} selectedObservationId={99} requireSelectedObservation onClose={() => {}} /></MemoryRouter>);
expect(screen.getByRole("alert")).toHaveTextContent("That observation is no longer available");
expect(screen.queryByText("Raw Packet")).not.toBeInTheDocument();
});
it("explains TRACE and missing-report semantics in French", async () => {
const d = reports(); d.header.payloadType = PayloadType.TRACE; d.observations = [];
await i18n.changeLanguage("fr");
render(<MemoryRouter><PacketAnalyzerDrawer detail={d} selectedObservationId={null} onClose={() => {}} /></MemoryRouter>);
expect(screen.getByRole("region", { name: "Observations" })).toHaveTextContent("itinéraire prévu");
expect(screen.getByText("Aucune observation pour ce paquet.")).toBeInTheDocument();
});
it("keeps a later selected path visible while bounding the initial list", () => {
const d = reports();
d.observations = Array.from({ length: 12 }, (_, i) => ({ ...d.observations[0], id: i + 1, observerId: `observer-${i}`, pathBytes: i.toString(16).padStart(4, "0") }));
render(<MemoryRouter initialEntries={["/?hash=abcdef12&analyze=1&observation=12"]}><PacketAnalyzerDrawer detail={d} selectedObservationId={null} onClose={() => {}} /></MemoryRouter>);
const section = screen.getByRole("region", { name: "Observations" });
expect(within(section).getByText("Path 12").closest("details")).toHaveAttribute("open");
expect(within(section).queryByText("Path 4")).not.toBeInTheDocument();
fireEvent.click(within(section).getByRole("button", { name: "Load more (9 left)" }));
expect(within(section).getByText("Path 4")).toBeInTheDocument();
expect(within(section).getByText("Path 6")).toBeInTheDocument();
expect(within(section).queryByText("Path 7")).not.toBeInTheDocument();
});
});
describe("PacketAnalyzerDrawer in French", () => {
it("translates the empty state and close control", async () => {
await i18n.changeLanguage("fr");
render(<MemoryRouter><PacketAnalyzerDrawer detail={undefined} selectedObservationId={null} onClose={() => {}} /></MemoryRouter>);
expect(screen.getByText("Analyseur de paquets")).toBeInTheDocument();
expect(screen.getByText("Sélectionnez un paquet à analyser")).toBeInTheDocument();
expect(screen.getByLabelText("Fermer l’analyseur")).toBeInTheDocument();
});
it("translates the structure section and payload fields", async () => {
const d = makeDetail([]);
d.observations[0].pathBytes = "aabb";
d.header = { ...d.header, raw: "14", routeType: RouteType.TRANSPORT_FLOOD, routeTypeName: "TRANSPORT_FLOOD" };
d.transportCodes = { regionCode: 49240, subRegionCode: 0 };
d.parsedPayload = { type: "TEXT_MESSAGE", destinationHash: "aa", sourceHash: "bb", ciphertext: "0011" };
await i18n.changeLanguage("fr");
render(<MemoryRouter><PacketAnalyzerDrawer detail={d} selectedObservationId={1} onClose={() => {}} /></MemoryRouter>);
for (const text of ["Résumé", "Paquet brut", "Structure du paquet", "Octet d’en-tête", "Longueur du trajet", "Données du trajet", "Détail de la charge utile"]) {
expect(screen.getByText(text)).toBeInTheDocument();
}
expect(screen.getByText("Codes de transport").parentElement!).toHaveTextContent("Code de portée 58C0 (2B) = 49240");
expect(screen.getByText("Codes de transport").parentElement!).toHaveTextContent("aucune portée connue");
expect(screen.getByText("sauts=2")).toBeInTheDocument();
expect(screen.getByText("Chiffré — clé non disponible")).toBeInTheDocument();
});
});
describe("transport scope", () => {
const transport = () => {
const d = makeDetail([]);
d.header = { ...d.header, raw: "14", routeType: RouteType.TRANSPORT_FLOOD, routeTypeName: "TRANSPORT_FLOOD" };
d.transportCodes = { regionCode: 49240, subRegionCode: 0 };
d.scope = "#yow";
return d;
};
it("boxes the summary scope chip like the type badge", () => {
render(<MemoryRouter><PacketAnalyzerDrawer detail={transport()} selectedObservationId={null} onClose={() => {}} /></MemoryRouter>);
const chip = within(screen.getByText("Summary").parentElement!).getByText("#yow");
expect((chip.getAttribute("class") ?? "").split(/\s+/)).toEqual(expect.arrayContaining(["border", "px-2", "py-0.5"]));
});
it("breaks the transport codes out of the frame and shows the matched scope", () => {
render(<MemoryRouter><PacketAnalyzerDrawer detail={transport()} selectedObservationId={null} onClose={() => {}} /></MemoryRouter>);
const field = screen.getByText("Transport Codes").parentElement!;
expect(field).toHaveTextContent("Scope code 58C0 (2B) = 49240");
expect(within(field).getByText("#yow")).toBeInTheDocument();
expect(field).toHaveTextContent("Code 2 0000 (2B) = 0");
});
it("says when a scope code matched no known scope", () => {
const d = transport(); delete d.scope;
render(<MemoryRouter><PacketAnalyzerDrawer detail={d} selectedObservationId={null} onClose={() => {}} /></MemoryRouter>);
expect(screen.getByText("Transport Codes").parentElement!).toHaveTextContent("no known scope");
});
});
const hop = (id: string, lng: number, lat: number) => ({ confidence: "high" as const, nodes: [{ id, publicKey: "pk", longitude: lng, latitude: lat }] });
function makeDetail(resolvedPath: unknown[]): PacketDetail {
return {
packetHash: "abcdef12",
header: { raw: "12", routeType: RouteType.FLOOD, routeTypeName: "FLOOD", payloadType: PayloadType.TEXT, payloadTypeName: "TXT_MSG", payloadVersion: 1 },
firstHeardAt: 0, lastHeardAt: 0, firstToLastMs: 0, observationCount: 1,
rawPayload: "", decrypted: false,
observations: [{ id: 1, observerId: "obs12345", iata: "YYZ", heardAt: 0, sourceBroker: "b", pathLength: { raw: "02", hashSize: 1, hopCount: resolvedPath.length }, resolvedPath }],
} as unknown as PacketDetail;
}
describe("PacketAnalyzerDrawer copy link", () => {
const writeText = vi.fn();
beforeEach(() => {
Object.defineProperty(navigator, "clipboard", { value: { writeText }, writable: true, configurable: true });
writeText.mockClear();
});
afterEach(() => window.history.replaceState({}, "", "/"));
it("copies a link that reopens the drawer over the expanded row", () => {
render(
<MemoryRouter initialEntries={["/?tab=Packets&hash=abcdef12&analyze=1"]}>
<PacketAnalyzerDrawer detail={makeDetail([])} selectedObservationId={null} onClose={() => {}} />
</MemoryRouter>,
);
fireEvent.click(screen.getByRole("button", { name: "Copy packet link" }));
const copied = new URL(writeText.mock.calls[0]![0] as string);
expect(copied.searchParams.get("tab")).toBe("Packets");
expect(copied.searchParams.get("hash")).toBe("abcdef12");
expect(copied.searchParams.get("analyze")).toBe("1"); // the drawer is part of the shared state
});
});
describe("PacketAnalyzerDrawer view-path button", () => {
it("enables the button and calls onViewPath when a path is drawable", () => {
const onViewPath = vi.fn();
render(
<MemoryRouter initialEntries={["/?tab=Packets"]}>
<PacketAnalyzerDrawer detail={makeDetail([hop("a", -79, 43), hop("b", -75, 45)])} selectedObservationId={null} onClose={() => {}} onViewPath={onViewPath} />
</MemoryRouter>,
);
const btn = screen.getByRole("button", { name: /map all paths/i });
expect(btn).toBeEnabled();
fireEvent.click(btn);
expect(onViewPath).toHaveBeenCalledOnce();
});
it("disables the button when no path is drawable", () => {
render(
<MemoryRouter initialEntries={["/?tab=Packets"]}>
<PacketAnalyzerDrawer detail={makeDetail([hop("a", -79, 43)])} selectedObservationId={null} onClose={() => {}} onViewPath={() => {}} />
</MemoryRouter>,
);
expect(screen.getByRole("button", { name: /map all paths/i })).toBeDisabled();
});
});
describe("PacketAnalyzerDrawer TRACE path data", () => {
// TRACE wire path carries SNR bytes; requested hashes belong to its payload.
function traceDetail(): PacketDetail {
return {
packetHash: "abcdef12",
header: { raw: "12", routeType: RouteType.FLOOD, routeTypeName: "FLOOD", payloadType: PayloadType.TRACE, payloadTypeName: "TRACE", payloadVersion: 1 },
firstHeardAt: 0, lastHeardAt: 0, firstToLastMs: 0, observationCount: 1,
rawPayload: "", decrypted: false,
observations: [{
id: 1, observerId: "obs12345", iata: "YYZ", heardAt: 0, sourceBroker: "b",
pathLength: { raw: "02", hashSize: 1, hopCount: 2 },
pathBytes: "abcd", wirePathBytes: "fc00",
resolvedPath: [hop("a", -79, 43), hop("b", -75, 45)],
}],
} as unknown as PacketDetail;
}
it("shows trace wire SNR separately from requested payload hashes", () => {
render(
<MemoryRouter initialEntries={["/?tab=Packets"]}>
<PacketAnalyzerDrawer detail={traceDetail()} selectedObservationId={null} onClose={() => {}} />
</MemoryRouter>,
);
expect(screen.queryByText("Path SNR Data")).not.toBeInTheDocument();
expect(screen.queryByText("Path Data")).not.toBeInTheDocument();
expect(screen.getByText("Recorded SNR bytes")).toBeInTheDocument();
expect(screen.getByText("FC00")).toBeInTheDocument();
});
});