import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { PayloadBreakdown } from "../../../src/features/packets/payload-renderers";
import { formatAbsolute, timeAgoMs } from "../../../src/lib/formatters";
import type { ResolvedHop } from "../../../src/types/api";
const tracePayload = {
type: "TRACE",
flags: 0,
pathHashes: ["ab", "cd"],
snrValues: [-5, -8],
};
const resolvedRoute: ResolvedHop[] = [
{ confidence: "high", nodes: [{ id: "n1", publicKey: "abcdef00", name: "Node A" }] },
{ confidence: "ambiguous", nodes: [{ id: "n2", publicKey: "11" }, { id: "n3", publicKey: "22" }] },
];
describe("PayloadBreakdown — trace resolvedRoute overlay", () => {
it("tints each trace-path hash block by its resolved confidence", () => {
render();
expect(screen.getByText("AB").className).toContain("text-green"); // high
expect(screen.getByText("CD").className).toContain("text-warn"); // ambiguous
});
it("falls back to the plain hash badge when there is no resolvedRoute", () => {
render();
expect(screen.getByText("AB").className).toContain("text-primary");
});
it("makes a single-resolution hop's badge directly clickable (like other packets)", () => {
const onViewNode = vi.fn();
render();
// hop 0 is high-confidence with one node → the hash badge itself opens that node
fireEvent.click(screen.getByRole("button", { name: "AB" }));
expect(onViewNode).toHaveBeenCalledWith("n1");
// hop 1 is ambiguous (two candidates) → not a direct button; resolved via the popover instead
expect(screen.queryByRole("button", { name: "CD" })).not.toBeInTheDocument();
});
it("renders a '-' placeholder under a hop that has no SNR, so badges stay aligned", () => {
// 3 hashes but only 2 SNR readings → the third hop's sub-line is a placeholder
const payload = { type: "TRACE", flags: 0, pathHashes: ["ab", "cd", "ef"], snrValues: [-5, -8] };
render();
expect(screen.getByText("-")).toBeInTheDocument();
});
});
describe("PayloadBreakdown — PING", () => {
it("renders a single-hop PING with the trace layout", () => {
const ping = { type: "PING", flags: 0, pathHashes: ["ab"], snrValues: [-5] };
render();
expect(screen.getByText("AB").className).toContain("text-green");
});
});
describe("PayloadBreakdown — resolved source/destination endpoints", () => {
const envelope = {
type: "TEXT_MESSAGE",
sourceHash: "aa",
destinationHash: "bb",
cipherMac: "0011",
ciphertext: "deadbeef",
decrypted: null,
};
const resolvedSource: ResolvedHop = { confidence: "high", nodes: [{ id: "s1", publicKey: "aa", name: "Alice" }] };
const resolvedDestination: ResolvedHop = { confidence: "high", nodes: [{ id: "d1", publicKey: "bb", name: "Bob" }] };
it("makes the resolved From/To hashes clickable node blocks", () => {
const onViewNode = vi.fn();
render();
fireEvent.click(screen.getByRole("button", { name: "BB" })); // To → destination node
expect(onViewNode).toHaveBeenCalledWith("d1");
fireEvent.click(screen.getByRole("button", { name: "AA" })); // From → source node
expect(onViewNode).toHaveBeenCalledWith("s1");
});
it("falls back to a plain hash badge when the endpoint did not resolve", () => {
render();
expect(screen.queryByRole("button", { name: "BB" })).not.toBeInTheDocument();
expect(screen.getByText("BB")).toBeInTheDocument();
});
it("renders an unresolved (none-confidence) endpoint as a non-clickable resolved block", () => {
// the backend sends resolvedSource/Destination as {confidence:"none", nodes:[]} (not omitted)
// when a 1-byte prefix matches nothing — it must not become a clickable node, but still show the hash
const none: ResolvedHop = { confidence: "none", nodes: [] };
render();
expect(screen.queryByRole("button", { name: "AA" })).not.toBeInTheDocument();
expect(screen.getByText("AA")).toBeInTheDocument();
expect(screen.getByText("BB")).toBeInTheDocument();
});
it("resolves an ANON_REQUEST destination hash to a node block", () => {
const onViewNode = vi.fn();
const anon = { type: "ANON_REQUEST", destination: 0xbb, ephemeralPubKey: "cc" };
render();
fireEvent.click(screen.getByRole("button", { name: "0xBB" }));
expect(onViewNode).toHaveBeenCalledWith("d1");
});
});
describe("PayloadBreakdown — DISCOVER_REQ", () => {
// Backend emits DISCOVER as a top-level parsedPayload.type (not nested under CONTROL).
// See beacon-server internal/ingest/packet.go parsedDiscoverReq.
const reqPayload = {
type: "DISCOVER_REQ",
raw: "0b00",
prefixOnly: true,
typeFilter: 0x06, // bits 1 and 2 → ADV_TYPE 1 (ChatNode) + 2 (Repeater)
tag: "0a0b0c0d",
since: 1_700_000_000, // epoch seconds
};
it("decodes the typeFilter bitfield into device-role names", () => {
render();
expect(screen.getByText("ChatNode")).toBeInTheDocument();
expect(screen.getByText("Repeater")).toBeInTheDocument();
});
it("renders the tag as a 0x-prefixed hex value", () => {
render();
expect(screen.getByText("0x0A0B0C0D")).toBeInTheDocument();
});
});
describe("PayloadBreakdown — DISCOVER_RESP", () => {
// See beacon-server internal/ingest/packet.go parsedDiscoverResp.
const respPayload = {
type: "DISCOVER_RESP",
raw: "0b01",
nodeType: 2,
nodeTypeName: "repeater",
requestSnr: -4.5, // responder's node-to-node reading of the request, not observer reception
tag: "0a0b0c0d",
pubKey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
pubKeyPrefixOnly: false,
};
it("renders the responder node type and its request SNR", () => {
render();
expect(screen.getByText("repeater")).toBeInTheDocument();
expect(screen.getByText(/-4\.50/)).toBeInTheDocument();
});
it("labels a full public key vs. an 8-byte prefix", () => {
const { unmount } = render();
expect(screen.getByText(/Public Key/)).toBeInTheDocument();
unmount();
render();
expect(screen.getByText(/Key Prefix/)).toBeInTheDocument();
});
});
describe("PayloadBreakdown — GROUP_TEXT decrypted channel message", () => {
// Backend GetPacket enrichment nests decrypted:{sender,content,sentAt} (sentAt is epoch ms).
// See beacon-server db/packets.go + internal/ingest/side_effects.go.
// chosen so the ms reading (5:43 p.m.) and the wrong seconds reading (1:13 p.m.) differ
const sentAt = 1_700_001_800_000; // epoch ms
const payload = {
type: "GROUP_TEXT",
channelHash: "ab",
cipherMac: "00112233",
ciphertext: "deadbeef",
decrypted: { sender: "Alice", content: "hello mesh", sentAt },
};
it("renders the decrypted sender and message body from content", () => {
render();
expect(screen.getByText("Alice")).toBeInTheDocument();
expect(screen.getByText("hello mesh")).toBeInTheDocument();
});
it("formats sentAt as epoch milliseconds, not seconds", () => {
render();
// shows a relative label; hovering reveals the absolute time in the tooltip
fireEvent.mouseEnter(screen.getByText(`${timeAgoMs(sentAt)} ago`));
expect(screen.getByRole("tooltip").textContent).toBe(formatAbsolute(sentAt));
// the seconds interpretation (×1000) would be a far-future date
expect(screen.getByRole("tooltip").textContent).not.toBe(formatAbsolute(sentAt * 1000));
});
});
describe("PayloadBreakdown — multi-line message bodies", () => {
// Newlines survive the backend intact (ingest strips only NULs/invalid UTF-8), so the browser's
// default white-space:normal was collapsing them to spaces. jsdom doesn't apply that collapsing,
// so asserting on textContent alone can't catch the bug — the class is what pins it.
const body = "🟠\nDWD aktuell: WARNUNG vor GEWITTER\nDi 17:37 - Di 19:00";
// getByText normalizes whitespace by default, which would defeat the point here
const exact = { normalizer: (s: string) => s };
it.each([
["GROUP_TEXT", { type: "GROUP_TEXT", channelHash: "ab", decrypted: { sender: "Alice", content: body } }],
["GROUP_DATA", { type: "GROUP_DATA", channelHash: "ab", decrypted: { sender: "Alice", content: body } }],
["TEXT_MESSAGE", { type: "TEXT_MESSAGE", cipherMac: "0011", ciphertext: "dead", decrypted: { message: body } }],
["RESPONSE", { type: "RESPONSE", cipherMac: "0011", ciphertext: "dead", decrypted: { content: body } }],
])("preserves linebreaks in a %s body", (_type, payload) => {
render();
expect(screen.getByText(body, exact).className).toContain("whitespace-pre-wrap");
});
it("wraps message text at word boundaries, not mid-word", () => {
const payload = { type: "GROUP_TEXT", channelHash: "ab", decrypted: { sender: "Alice", content: body } };
render();
// break-all is for hex/pubkeys; prose should use break-words
expect(screen.getByText(body, exact).className).toContain("break-words");
expect(screen.getByText(body, exact).className).not.toContain("break-all");
});
});