import { describe, expect, it, vi, beforeEach } from "vitest"; import { render, screen, fireEvent, act } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { ReactNode } from "react"; import { NodeDetailPanel } from "../../../src/features/nodes/NodeDetailPanel"; import i18n from "../../../src/i18n"; import { getNode, getNodeObservations, getNodeNeighbors } from "../../../src/api/client"; import type { Node, NodeNeighbor } from "../../../src/features/nodes/types"; vi.mock("../../../src/api/client", () => ({ getNode: vi.fn(), getNodeObservations: vi.fn(), getNodeNeighbors: vi.fn(), })); const mockGetNode = vi.mocked(getNode); const mockGetNodeObservations = vi.mocked(getNodeObservations); const mockGetNodeNeighbors = vi.mocked(getNodeNeighbors); const node: Node = { id: "node-self", publicKey: "aabbccddeeff", nodeType: 2, nodeTypeName: "REPEATER", name: "Self Node", lat: null, lng: null, iatas: [], locationSource: null, lastAdvertAt: null, supportsMultibytePaths: false, supportsMultibyteTraces: false, minFirmwareVersion: null, firstSeen: 1, lastSeen: 2, metadata: null, }; function neighbor(id: string, name: string): NodeNeighbor { return { id, name, nodeType: 2, nodeTypeName: "REPEATER", iata: "YVR", observationCount: 5, firstSeen: 1, lastSeen: 2 }; } function renderPanel(onViewNode = vi.fn()) { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const wrapper = ({ children }: { children: ReactNode }) => ( {children} ); render(, { wrapper }); return { onViewNode }; } beforeEach(() => { mockGetNode.mockReset(); mockGetNodeObservations.mockReset(); mockGetNodeNeighbors.mockReset(); mockGetNode.mockResolvedValue(node); mockGetNodeObservations.mockResolvedValue({ items: [], nextCursor: null, hasMore: false }); mockGetNodeNeighbors.mockResolvedValue([]); }); describe("NodeDetailPanel neighbors", () => { it("shows the server's possibly-foreign indication", async () => { mockGetNode.mockResolvedValue({ ...node, possiblyForeign: true }); renderPanel(); expect(await screen.findByText("Possibly foreign")).toBeInTheDocument(); }); it.each([false, undefined])("does not label unflagged nodes as foreign (%s)", async (possiblyForeign) => { mockGetNode.mockResolvedValue({ ...node, possiblyForeign }); renderPanel(); await screen.findByText("Self Node"); expect(screen.queryByText("Possibly foreign")).not.toBeInTheDocument(); }); it("lists each neighbor's name in a Neighbors section", async () => { mockGetNodeNeighbors.mockResolvedValue([neighbor("n-1", "Neighbor A"), neighbor("n-2", "Neighbor B")]); renderPanel(); expect(await screen.findByText("Neighbors")).toBeInTheDocument(); expect(await screen.findByText("Neighbor A")).toBeInTheDocument(); expect(screen.getByText("Neighbor B")).toBeInTheDocument(); expect(mockGetNodeNeighbors).toHaveBeenCalledWith("node-self"); }); it("navigates to a neighbor when its row is clicked", async () => { mockGetNodeNeighbors.mockResolvedValue([neighbor("n-1", "Neighbor A")]); const { onViewNode } = renderPanel(); fireEvent.click(await screen.findByText("Neighbor A")); expect(onViewNode).toHaveBeenCalledWith("n-1"); }); it("shows an empty state when there are no neighbors", async () => { mockGetNodeNeighbors.mockResolvedValue([]); renderPanel(); expect(await screen.findByText("No known neighbors")).toBeInTheDocument(); }); }); describe("NodeDetailPanel location", () => { it("hides Lat/Lng but keeps the source for a 0/0 advert reset", async () => { mockGetNode.mockResolvedValue({ ...node, lat: 0, lng: 0, locationSource: "advert" }); renderPanel(); expect(await screen.findByText("Source")).toBeInTheDocument(); expect(screen.queryByText("Lat")).not.toBeInTheDocument(); expect(screen.queryByText("Lng")).not.toBeInTheDocument(); }); }); describe("NodeDetailPanel clock drift", () => { it("shows a repeater's clock drift in amber when the server flags it out of sync", async () => { mockGetNode.mockResolvedValue({ ...node, lastAdvertAt: 2, clockDriftSeconds: 432, clockOutOfSync: true, clockCheckedAt: 2 }); renderPanel(); const drift = await screen.findByText("+7m 12s ahead"); expect(drift.className).toContain("text-warn"); }); it("shows an in-sync drift in green", async () => { mockGetNode.mockResolvedValue({ ...node, lastAdvertAt: 2, clockDriftSeconds: 20, clockOutOfSync: false, clockCheckedAt: 2 }); renderPanel(); const drift = await screen.findByText("+20s ahead"); expect(drift.className).toContain("text-green"); }); it("omits clock drift entirely when the node reports none", async () => { renderPanel(); await screen.findByText("Timestamps"); expect(screen.queryByText(/Clock drift/i)).not.toBeInTheDocument(); }); }); describe("NodeDetailPanel View on map", () => { function renderWithMap(onViewOnMap?: (lat: number, lng: number) => void) { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); render( , ); } it("sends the node's coordinates", async () => { mockGetNode.mockResolvedValue({ ...node, lat: 45.42153, lng: -75.69719 }); const onViewOnMap = vi.fn(); renderWithMap(onViewOnMap); fireEvent.click(await screen.findByRole("button", { name: "View on map" })); expect(onViewOnMap).toHaveBeenCalledWith(45.42153, -75.69719); }); it("is not offered for a node without a location", async () => { renderWithMap(vi.fn()); await screen.findByText("Self Node"); expect(screen.queryByRole("button", { name: "View on map" })).not.toBeInTheDocument(); }); it("is not offered when the caller has no map to open", async () => { mockGetNode.mockResolvedValue({ ...node, lat: 45.42153, lng: -75.69719 }); renderWithMap(); await screen.findByText("Self Node"); expect(screen.queryByRole("button", { name: "View on map" })).not.toBeInTheDocument(); }); it("shows the French label", async () => { mockGetNode.mockResolvedValue({ ...node, lat: 45.42153, lng: -75.69719 }); await act(() => i18n.changeLanguage("fr")); renderWithMap(vi.fn()); expect(await screen.findByRole("button", { name: "Voir sur la carte" })).toBeInTheDocument(); await act(() => i18n.changeLanguage("en")); }); it("is not offered for an explicit 0/0 advert reset", async () => { mockGetNode.mockResolvedValue({ ...node, lat: 0, lng: 0 }); renderWithMap(vi.fn()); await screen.findByText("Self Node"); expect(screen.queryByRole("button", { name: "View on map" })).not.toBeInTheDocument(); }); it("is still offered when only one axis is zero", async () => { mockGetNode.mockResolvedValue({ ...node, lat: 0, lng: 10 }); const onViewOnMap = vi.fn(); renderWithMap(onViewOnMap); fireEvent.click(await screen.findByRole("button", { name: "View on map" })); expect(onViewOnMap).toHaveBeenCalledWith(0, 10); }); }); describe("NodeDetailPanel recent packets", () => { it("labels the expiring packet list as recent packets", async () => { renderPanel(); expect(await screen.findByText("Recent packets")).toBeInTheDocument(); expect(await screen.findByText("No recent packets")).toBeInTheDocument(); expect(screen.queryByText("Observations")).not.toBeInTheDocument(); }); }); describe("NodeDetailPanel in French", () => { it("translates section titles, labels, yes/no and empty states", async () => { mockGetNode.mockResolvedValue({ ...node, knownNeighborCount: 2, clockDriftSeconds: 20, clockOutOfSync: false, clockCheckedAt: 2 }); await act(() => i18n.changeLanguage("fr")); renderPanel(); expect(await screen.findByText("Capacités")).toBeInTheDocument(); expect(screen.getByText("Détail du nœud")).toBeInTheDocument(); expect(screen.getByText("Chemins multi-octets")).toBeInTheDocument(); expect(screen.getAllByText("non")).toHaveLength(2); expect(screen.getByText("+20 s en avance")).toBeInTheDocument(); expect(screen.getByText("Voisins (2)")).toBeInTheDocument(); expect(await screen.findByText("Aucun voisin connu")).toBeInTheDocument(); expect(screen.getByText("Aucun paquet récent")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Copier la clé publique" })).toBeInTheDocument(); }); it("translates the possibly-foreign badge", async () => { mockGetNode.mockResolvedValue({ ...node, possiblyForeign: true }); await act(() => i18n.changeLanguage("fr")); renderPanel(); expect(await screen.findByText("Possiblement étranger")).toBeInTheDocument(); }); });