mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-09-09 23:15:34 +00:00
Wire the packet table and expandable rows into the virtual list
This commit is contained in:
+17
-3
@@ -176,6 +176,12 @@ function AppInner() {
|
||||
setPathMapInitialKey(key);
|
||||
}, []);
|
||||
|
||||
// "View path on map" from anywhere that already holds a detail — no key, so the modal picks its own
|
||||
const handleViewPath = useCallback((detail: PacketDetail) => {
|
||||
setPathMapDetail(detail);
|
||||
setPathMapInitialKey(null);
|
||||
}, []);
|
||||
|
||||
const handleAnalyze = useCallback((hash: string | null) => {
|
||||
setSelectedObservationId(null);
|
||||
setSearchParams((p) => {
|
||||
@@ -264,7 +270,15 @@ function AppInner() {
|
||||
}, []);
|
||||
|
||||
const tabContent: Record<string, React.ReactNode> = {
|
||||
Packets: <PacketList wsManager={wsManager} onAnalyze={handleAnalyze} />,
|
||||
Packets: (
|
||||
<PacketList
|
||||
wsManager={wsManager}
|
||||
onAnalyze={handleAnalyze}
|
||||
onViewPath={handleViewPath}
|
||||
selectedObservationId={selectedObservationId}
|
||||
onSelectObservation={setSelectedObservationId}
|
||||
/>
|
||||
),
|
||||
Nodes: <NodeTable wsManager={wsManager} selectedNodeId={selectedNodeId} onSelectNode={setSelectedNodeId} />,
|
||||
Observers: <ObserverTable wsManager={wsManager} selectedObserverId={selectedObserverId} onSelectObserver={handleSelectObserver} onAnalyzePacket={setOverlayPacketHash} onViewStats={handleViewObserverStats} />,
|
||||
Routes: <RouteTable />,
|
||||
@@ -302,7 +316,7 @@ function AppInner() {
|
||||
onSelectObservation={setSelectedObservationId}
|
||||
onClose={() => handleAnalyze(null)}
|
||||
onViewNode={setOverlayNodeId}
|
||||
onViewPath={() => { if (analyzerDetail) { setPathMapDetail(analyzerDetail); setPathMapInitialKey(null); } }}
|
||||
onViewPath={() => { if (analyzerDetail) handleViewPath(analyzerDetail); }}
|
||||
/>
|
||||
)}
|
||||
{(activeTab === "Map" || activeTab === "Nodes") && selectedNodeId && (
|
||||
@@ -337,7 +351,7 @@ function AppInner() {
|
||||
handleTabChange("Observers");
|
||||
setSelectedObserverId(observerId);
|
||||
}}
|
||||
onViewPath={() => { if (overlayPacketDetail) { setPathMapDetail(overlayPacketDetail); setPathMapInitialKey(null); } }}
|
||||
onViewPath={() => { if (overlayPacketDetail) handleViewPath(overlayPacketDetail); }}
|
||||
inactive={!!pathMapDetail}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -32,7 +32,7 @@ export function PacketExpansion({ packet, onOpenAnalyzer, onViewPath, selectedOb
|
||||
const emptyState = <div className="text-[10px] text-text-dim py-2">No observations</div>;
|
||||
|
||||
return (
|
||||
<div className="bg-bg-surface border-l-2 border-primary pl-6 pr-3 py-2">
|
||||
<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>first <Timestamp value={packet.firstHeardAt} /></span>
|
||||
<span>last <Timestamp value={packet.lastHeardAt} /></span>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState, useCallback, useEffect, useMemo } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { usePackets } from "./usePackets";
|
||||
import { usePacketDetail } from "./usePacketDetail";
|
||||
import { usePacketFilters, matchesFilters, toServerFilter } from "./usePacketFilters";
|
||||
import { useScopes } from "../../hooks/useScopes";
|
||||
import { useRegion } from "../../hooks/useRegion";
|
||||
@@ -11,6 +13,8 @@ import { LoadingPill } from "../../components/LoadingPill";
|
||||
import { SkeletonRows } from "../../components/SkeletonRows";
|
||||
import { PAYLOAD_TYPE_NAMES, ROUTE_TYPE_NAMES } from "../../types/enums";
|
||||
import type { WsManager } from "../../api/ws-manager";
|
||||
import type { PacketDetail } from "../../types/api";
|
||||
import type { WsPacketObservation } from "../../types/ws";
|
||||
|
||||
// filter options and storage keys
|
||||
|
||||
@@ -27,13 +31,16 @@ const ROUTE_OPTIONS = Object.entries(ROUTE_TYPE_NAMES).map(([value, label]) => (
|
||||
interface PacketListProps {
|
||||
wsManager: WsManager;
|
||||
onAnalyze: (hash: string | null) => void;
|
||||
onViewPath: (detail: PacketDetail) => void;
|
||||
selectedObservationId: number | null;
|
||||
onSelectObservation: (id: number) => void;
|
||||
}
|
||||
|
||||
// main packet view: filters, banner, virtual list
|
||||
|
||||
// onAnalyze isn't called here — it's retained for the row-expansion's future "Open analyzer" button
|
||||
export function PacketList({ wsManager }: PacketListProps) {
|
||||
export function PacketList({ wsManager, onAnalyze, onViewPath, selectedObservationId, onSelectObservation }: PacketListProps) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const { filters, setFilter, setSearch, setSearchField, clearFilters } = usePacketFilters();
|
||||
// single-value selections go to the server so scrolling pages through matching history
|
||||
const serverFilter = useMemo(() => toServerFilter(filters), [filters]);
|
||||
@@ -81,7 +88,27 @@ export function PacketList({ wsManager }: PacketListProps) {
|
||||
}, { replace: true });
|
||||
}, [expandedHash, setSearchParams]);
|
||||
|
||||
useWsPacketHandler(wsManager, handlePacketObservation);
|
||||
// Shared with the expanded row's own usePacketDetail, so reading it here costs no extra request.
|
||||
const { data: expandedDetail } = usePacketDetail(expandedHash);
|
||||
|
||||
const handleOpenAnalyzer = useCallback(() => {
|
||||
if (expandedHash) onAnalyze(expandedHash);
|
||||
}, [expandedHash, onAnalyze]);
|
||||
|
||||
const handleViewPath = useCallback(() => {
|
||||
if (expandedDetail) onViewPath(expandedDetail);
|
||||
}, [expandedDetail, onViewPath]);
|
||||
|
||||
// Refetch only the open row's detail, so its observation table keeps pace with the count ticking
|
||||
// up beside it. Every other observation just lands in the list.
|
||||
const handleObservation = useCallback((data: WsPacketObservation["data"]) => {
|
||||
handlePacketObservation(data);
|
||||
if (data.packetHash === expandedHash) {
|
||||
queryClient.invalidateQueries({ queryKey: ["packet-detail", expandedHash] });
|
||||
}
|
||||
}, [handlePacketObservation, expandedHash, queryClient]);
|
||||
|
||||
useWsPacketHandler(wsManager, handleObservation);
|
||||
useWsLaggedHandler(wsManager, handleLagged);
|
||||
|
||||
const bannerCount = isScrolledAway ? newPacketCount : 0;
|
||||
@@ -177,6 +204,10 @@ export function PacketList({ wsManager }: PacketListProps) {
|
||||
onAtTopChange={setIsAtTop}
|
||||
expandedHash={expandedHash}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
onOpenAnalyzer={handleOpenAnalyzer}
|
||||
onViewPath={handleViewPath}
|
||||
selectedObservationId={selectedObservationId}
|
||||
onSelectObservation={onSelectObservation}
|
||||
/>
|
||||
)}
|
||||
<LoadingPill
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useRef, useCallback, useLayoutEffect } from "react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import type { PacketSummary } from "../../types/api";
|
||||
import { PacketRow } from "./PacketRow";
|
||||
import { PacketTableHeader } from "./PacketTableHeader";
|
||||
import { PacketTableRow } from "./PacketTableRow";
|
||||
import { PacketExpansion } from "./PacketExpansion";
|
||||
import { useFreshHashes } from "./useFreshHashes";
|
||||
import {
|
||||
SCROLL_TOP_THRESHOLD_PX,
|
||||
@@ -18,6 +20,11 @@ interface PacketVirtualListProps {
|
||||
onAtTopChange: (isAtTop: boolean) => void;
|
||||
expandedHash: string | null;
|
||||
onToggleExpand: (hash: string) => void;
|
||||
// only the expanded row renders an expansion, so these need no hash argument
|
||||
onOpenAnalyzer: () => void;
|
||||
onViewPath: () => void;
|
||||
selectedObservationId: number | null;
|
||||
onSelectObservation: (id: number) => void;
|
||||
}
|
||||
|
||||
// virtualized scroll list with fresh-item highlighting and infinite load
|
||||
@@ -31,6 +38,10 @@ export function PacketVirtualList({
|
||||
onAtTopChange,
|
||||
expandedHash,
|
||||
onToggleExpand,
|
||||
onOpenAnalyzer,
|
||||
onViewPath,
|
||||
selectedObservationId,
|
||||
onSelectObservation,
|
||||
}: PacketVirtualListProps) {
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
const freshHashes = useFreshHashes(packets);
|
||||
@@ -40,7 +51,7 @@ export function PacketVirtualList({
|
||||
const virtualizer = useVirtualizer({
|
||||
count: packets.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 64, // rough -- rows vary a lot when expanded, tanstack remeasures
|
||||
estimateSize: () => 64, // a collapsed two-line row; expanded ones are remeasured
|
||||
overscan: 10,
|
||||
getItemKey: (index) => packets[index]?.packetHash ?? index,
|
||||
});
|
||||
@@ -84,16 +95,19 @@ export function PacketVirtualList({
|
||||
className="flex-1 overflow-y-auto px-4 pb-10"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<PacketTableHeader />
|
||||
<div
|
||||
style={{ height: virtualizer.getTotalSize(), position: "relative" }}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const packet = packets[virtualRow.index];
|
||||
if (!packet) return null;
|
||||
const expanded = expandedHash === packet.packetHash;
|
||||
return (
|
||||
<div
|
||||
key={packet.packetHash}
|
||||
data-index={virtualRow.index}
|
||||
data-testid={`packet-item-${packet.packetHash}`}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{
|
||||
position: "absolute",
|
||||
@@ -104,12 +118,21 @@ export function PacketVirtualList({
|
||||
}}
|
||||
>
|
||||
<div className="pt-1.5">
|
||||
<PacketRow
|
||||
<PacketTableRow
|
||||
packet={packet}
|
||||
expanded={expandedHash === packet.packetHash}
|
||||
expanded={expanded}
|
||||
isFresh={freshHashes.has(packet.packetHash)}
|
||||
onToggle={() => onToggleExpand(packet.packetHash)}
|
||||
/>
|
||||
{expanded && (
|
||||
<PacketExpansion
|
||||
packet={packet}
|
||||
onOpenAnalyzer={onOpenAnalyzer}
|
||||
onViewPath={onViewPath}
|
||||
selectedObservationId={selectedObservationId}
|
||||
onSelectObservation={onSelectObservation}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { PacketList } from "../../../src/features/packets/PacketList";
|
||||
import type { WsManager } from "../../../src/api/ws-manager";
|
||||
import type { PacketSummary } from "../../../src/types/api";
|
||||
import type { PacketSummary, PacketDetail } from "../../../src/types/api";
|
||||
import type { WsPacketObservation } from "../../../src/types/ws";
|
||||
|
||||
const basePackets = () => ({
|
||||
allPackets: [] as PacketSummary[],
|
||||
@@ -26,30 +28,45 @@ vi.mock("../../../src/features/packets/usePackets", () => ({
|
||||
usePackets: (...args: unknown[]) => usePackets(...(args as [])),
|
||||
}));
|
||||
|
||||
const usePacketDetail = vi.fn(() => ({ data: undefined as PacketDetail | undefined }));
|
||||
vi.mock("../../../src/features/packets/usePacketDetail", () => ({
|
||||
usePacketDetail: (hash: string | null) => usePacketDetail(hash as never),
|
||||
}));
|
||||
|
||||
vi.mock("../../../src/hooks/useScopes", () => ({ useScopes: () => [] }));
|
||||
|
||||
vi.mock("../../../src/hooks/useRegion", () => ({
|
||||
useRegion: () => ({ iatas: ["YOW"], regionKey: "YOW" }),
|
||||
}));
|
||||
|
||||
// capture the packet handler so tests can push a live observation through it
|
||||
let packetHandler: ((data: WsPacketObservation["data"]) => void) | null = null;
|
||||
vi.mock("../../../src/hooks/useWsHandlers", () => ({
|
||||
useWsPacketHandler: () => {},
|
||||
useWsPacketHandler: (_manager: unknown, handler: (data: WsPacketObservation["data"]) => void) => {
|
||||
packetHandler = handler;
|
||||
},
|
||||
useWsLaggedHandler: () => {},
|
||||
}));
|
||||
|
||||
// the virtual list needs ResizeObserver in jsdom; stub it down to the expand wiring under test
|
||||
// the virtual list needs ResizeObserver in jsdom; stub it down to the wiring under test
|
||||
vi.mock("../../../src/features/packets/PacketVirtualList", () => ({
|
||||
PacketVirtualList: ({
|
||||
packets,
|
||||
expandedHash,
|
||||
onToggleExpand,
|
||||
onOpenAnalyzer,
|
||||
onViewPath,
|
||||
}: {
|
||||
packets: PacketSummary[];
|
||||
expandedHash: string | null;
|
||||
onToggleExpand: (hash: string) => void;
|
||||
onOpenAnalyzer: () => void;
|
||||
onViewPath: () => void;
|
||||
}) => (
|
||||
<div>
|
||||
<div data-testid="expanded">{String(expandedHash)}</div>
|
||||
<button type="button" onClick={onOpenAnalyzer}>Open analyzer</button>
|
||||
<button type="button" onClick={onViewPath}>View path on map</button>
|
||||
{packets.map((p) => (
|
||||
<button
|
||||
key={p.packetHash}
|
||||
@@ -70,24 +87,53 @@ const packet = (hash: string): PacketSummary => ({
|
||||
firstHeardAt: 1700000000, lastHeardAt: 1700000000, observationCount: 1,
|
||||
});
|
||||
|
||||
describe("PacketList server filter wiring", () => {
|
||||
function renderAt(url: string) {
|
||||
render(
|
||||
<MemoryRouter initialEntries={[url]}>
|
||||
<PacketList wsManager={{} as unknown as WsManager} onAnalyze={vi.fn()} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
const observation = (hash: string): WsPacketObservation["data"] => ({
|
||||
packetHash: hash,
|
||||
packet: {
|
||||
payloadType: 1, payloadTypeName: "ADVERT",
|
||||
routeType: 1, routeTypeName: "FLOOD",
|
||||
isFirstObservation: false, observationCount: 2,
|
||||
},
|
||||
observation: {
|
||||
observerId: "o1", observerName: "Observer 1", iata: "YOW",
|
||||
heardAt: 1700000001, rssi: -90, snr: 5, sourceBroker: "b1",
|
||||
},
|
||||
});
|
||||
|
||||
function renderList(url = "/", props: Partial<Parameters<typeof PacketList>[0]> = {}) {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const invalidate = vi.spyOn(queryClient, "invalidateQueries");
|
||||
const onAnalyze = props.onAnalyze ?? vi.fn();
|
||||
const onViewPath = props.onViewPath ?? vi.fn();
|
||||
const onSelectObservation = props.onSelectObservation ?? vi.fn();
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={[url]}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<PacketList
|
||||
wsManager={{} as unknown as WsManager}
|
||||
onAnalyze={onAnalyze}
|
||||
onViewPath={onViewPath}
|
||||
selectedObservationId={null}
|
||||
onSelectObservation={onSelectObservation}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
return { onAnalyze, onViewPath, onSelectObservation, invalidate };
|
||||
}
|
||||
|
||||
describe("PacketList server filter wiring", () => {
|
||||
it("passes a single selected type to usePackets as the server filter", () => {
|
||||
usePackets.mockClear();
|
||||
renderAt("/?types=4");
|
||||
renderList("/?types=4");
|
||||
expect(usePackets).toHaveBeenLastCalledWith(false, { payloadTypes: [4] });
|
||||
});
|
||||
|
||||
it("passes a multi-select filter server-side so history stays filtered", () => {
|
||||
usePackets.mockClear();
|
||||
renderAt("/?types=2,4");
|
||||
renderList("/?types=2,4");
|
||||
expect(usePackets).toHaveBeenLastCalledWith(false, { payloadTypes: [2, 4] });
|
||||
});
|
||||
});
|
||||
@@ -97,14 +143,6 @@ describe("PacketList loading feedback", () => {
|
||||
usePackets.mockImplementation(basePackets);
|
||||
});
|
||||
|
||||
function renderList() {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PacketList wsManager={{} as unknown as WsManager} onAnalyze={vi.fn()} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
it("shows skeletons instead of the list plus a loading pill during an empty initial load", () => {
|
||||
usePackets.mockImplementation(() => ({ ...basePackets(), isLoading: true }));
|
||||
renderList();
|
||||
@@ -138,31 +176,97 @@ describe("PacketList loading feedback", () => {
|
||||
});
|
||||
|
||||
describe("PacketList expanded row", () => {
|
||||
it("expands a row from ?hash without opening the analyzer", () => {
|
||||
const onAnalyze = vi.fn();
|
||||
usePackets.mockReturnValue({ ...basePackets(), allPackets: [packet("AA11")] });
|
||||
afterEach(() => {
|
||||
usePackets.mockImplementation(basePackets);
|
||||
usePacketDetail.mockReturnValue({ data: undefined });
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/?tab=Packets&hash=AA11"]}>
|
||||
<PacketList wsManager={{} as WsManager} onAnalyze={onAnalyze} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
it("expands a row from ?hash without opening the analyzer", () => {
|
||||
usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [packet("AA11")] }));
|
||||
|
||||
const { onAnalyze } = renderList("/?tab=Packets&hash=AA11");
|
||||
|
||||
expect(screen.getByRole("button", { name: /AA11/ })).toHaveAttribute("aria-expanded", "true");
|
||||
expect(onAnalyze).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clicking a row sets ?hash and does not open the analyzer", () => {
|
||||
const onAnalyze = vi.fn();
|
||||
usePackets.mockReturnValue({ ...basePackets(), allPackets: [packet("AA11")] });
|
||||
usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [packet("AA11")] }));
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/?tab=Packets"]}>
|
||||
<PacketList wsManager={{} as WsManager} onAnalyze={onAnalyze} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
const { onAnalyze } = renderList("/?tab=Packets");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /AA11/ }));
|
||||
expect(onAnalyze).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes the expansion's Open analyzer through onAnalyze with the expanded hash", () => {
|
||||
usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [packet("AA11")] }));
|
||||
|
||||
const { onAnalyze } = renderList("/?tab=Packets&hash=AA11");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open analyzer" }));
|
||||
expect(onAnalyze).toHaveBeenCalledWith("AA11");
|
||||
});
|
||||
|
||||
it("hands the loaded detail to onViewPath", () => {
|
||||
const detail = { packetHash: "AA11", observations: [] } as unknown as PacketDetail;
|
||||
usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [packet("AA11")] }));
|
||||
usePacketDetail.mockReturnValue({ data: detail });
|
||||
|
||||
const { onViewPath } = renderList("/?tab=Packets&hash=AA11");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "View path on map" }));
|
||||
expect(onViewPath).toHaveBeenCalledWith(detail);
|
||||
});
|
||||
|
||||
it("does not call onViewPath before the detail has loaded", () => {
|
||||
usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [packet("AA11")] }));
|
||||
|
||||
const { onViewPath } = renderList("/?tab=Packets&hash=AA11");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "View path on map" }));
|
||||
expect(onViewPath).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PacketList live observation invalidation", () => {
|
||||
afterEach(() => {
|
||||
usePackets.mockImplementation(basePackets);
|
||||
packetHandler = null;
|
||||
});
|
||||
|
||||
it("refetches the expanded row's detail when an observation arrives for it", () => {
|
||||
const handlePacketObservation = vi.fn();
|
||||
usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [packet("AA11")], handlePacketObservation }));
|
||||
|
||||
const { invalidate } = renderList("/?tab=Packets&hash=AA11");
|
||||
invalidate.mockClear();
|
||||
packetHandler!(observation("AA11"));
|
||||
|
||||
expect(handlePacketObservation).toHaveBeenCalledTimes(1);
|
||||
expect(invalidate).toHaveBeenCalledWith({ queryKey: ["packet-detail", "AA11"] });
|
||||
});
|
||||
|
||||
it("leaves the detail query alone for observations on other packets", () => {
|
||||
const handlePacketObservation = vi.fn();
|
||||
usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [packet("AA11")], handlePacketObservation }));
|
||||
|
||||
const { invalidate } = renderList("/?tab=Packets&hash=AA11");
|
||||
invalidate.mockClear();
|
||||
packetHandler!(observation("BB22"));
|
||||
|
||||
expect(handlePacketObservation).toHaveBeenCalledTimes(1);
|
||||
expect(invalidate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not invalidate when no row is expanded", () => {
|
||||
const handlePacketObservation = vi.fn();
|
||||
usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [packet("AA11")], handlePacketObservation }));
|
||||
|
||||
const { invalidate } = renderList("/?tab=Packets");
|
||||
invalidate.mockClear();
|
||||
packetHandler!(observation("AA11"));
|
||||
|
||||
expect(invalidate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||
import { PacketVirtualList } from "../../../src/features/packets/PacketVirtualList";
|
||||
import type { PacketSummary } from "../../../src/types/api";
|
||||
|
||||
// PacketExpansion fetches through usePacketDetail; stub it so the list renders without a query client.
|
||||
const usePacketDetail = vi.fn(() => ({ data: { packetHash: "AA11", observations: [] } }));
|
||||
vi.mock("../../../src/features/packets/usePacketDetail", () => ({
|
||||
usePacketDetail: (h: string | null) => usePacketDetail(h),
|
||||
}));
|
||||
|
||||
const VIEWPORT_H = 1300;
|
||||
const ROW_H = 60;
|
||||
const EXPANDED_H = 400;
|
||||
|
||||
// jsdom has no layout and no ResizeObserver, so feed the virtualizer both: offsetHeight answers for
|
||||
// the viewport and for each measured item (taller once it holds an expansion), and flushResize()
|
||||
// stands in for the browser noticing a row changed height.
|
||||
type Observed = { cb: ResizeObserverCallback; targets: Set<Element> };
|
||||
const observers: Observed[] = [];
|
||||
|
||||
class StubResizeObserver {
|
||||
private entry: Observed;
|
||||
constructor(cb: ResizeObserverCallback) {
|
||||
this.entry = { cb, targets: new Set() };
|
||||
observers.push(this.entry);
|
||||
}
|
||||
observe(target: Element) { this.entry.targets.add(target); }
|
||||
unobserve(target: Element) { this.entry.targets.delete(target); }
|
||||
disconnect() { this.entry.targets.clear(); }
|
||||
}
|
||||
vi.stubGlobal("ResizeObserver", StubResizeObserver);
|
||||
|
||||
function flushResize() {
|
||||
act(() => {
|
||||
for (const o of observers) {
|
||||
const entries = [...o.targets].map((target) => ({ target })) as unknown as ResizeObserverEntry[];
|
||||
if (entries.length > 0) o.cb(entries, {} as ResizeObserver);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
|
||||
configurable: true,
|
||||
get(this: HTMLElement) {
|
||||
if (!this.hasAttribute("data-index")) return VIEWPORT_H;
|
||||
return this.querySelector("[data-testid='packet-expansion']") ? EXPANDED_H : ROW_H;
|
||||
},
|
||||
});
|
||||
|
||||
const pkt = (hash: string): PacketSummary => ({
|
||||
packetHash: hash, payloadType: 1, payloadTypeName: "ADVERT",
|
||||
routeType: 1, routeTypeName: "FLOOD",
|
||||
firstHeardAt: 1700000000, lastHeardAt: 1700000000, observationCount: 1,
|
||||
});
|
||||
|
||||
const many = (n: number) => Array.from({ length: n }, (_, i) => pkt(`AA${i}`));
|
||||
|
||||
function makeHandlers() {
|
||||
return {
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
onScrollAwayFromTop: vi.fn(),
|
||||
onAtTopChange: vi.fn(),
|
||||
onToggleExpand: vi.fn(),
|
||||
onOpenAnalyzer: vi.fn(),
|
||||
onViewPath: vi.fn(),
|
||||
selectedObservationId: null,
|
||||
onSelectObservation: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
// the scroll container is the component's root; the spacer under it carries the virtualizer's total size
|
||||
const scroller = (container: HTMLElement) => container.firstElementChild as HTMLElement;
|
||||
function totalSize(container: HTMLElement) {
|
||||
const spacer = scroller(container).lastElementChild as HTMLElement;
|
||||
const height = parseFloat(spacer.style.height);
|
||||
expect(Number.isFinite(height)).toBe(true);
|
||||
return height;
|
||||
}
|
||||
|
||||
function setScrollMetrics(el: HTMLElement, { scrollHeight, clientHeight, scrollTop }: {
|
||||
scrollHeight: number; clientHeight: number; scrollTop: number;
|
||||
}) {
|
||||
Object.defineProperty(el, "scrollHeight", { configurable: true, value: scrollHeight });
|
||||
Object.defineProperty(el, "clientHeight", { configurable: true, value: clientHeight });
|
||||
el.scrollTop = scrollTop;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
observers.length = 0;
|
||||
usePacketDetail.mockReturnValue({ data: { packetHash: "AA11", observations: [] } });
|
||||
});
|
||||
|
||||
describe("PacketVirtualList expansion", () => {
|
||||
it("mounts the expansion inside the measured wrapper", () => {
|
||||
render(<PacketVirtualList packets={[pkt("AA11")]} expandedHash="AA11" {...makeHandlers()} />);
|
||||
|
||||
const wrapper = screen.getByTestId("packet-item-AA11");
|
||||
expect(wrapper).toHaveAttribute("data-index", "0");
|
||||
expect(wrapper.querySelector("[data-testid='packet-expansion']")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("expands only the row named by expandedHash", () => {
|
||||
render(<PacketVirtualList packets={many(30)} expandedHash="AA3" {...makeHandlers()} />);
|
||||
|
||||
expect(screen.getAllByTestId("packet-expansion")).toHaveLength(1);
|
||||
expect(screen.getByTestId("packet-item-AA3").querySelector("[data-testid='packet-expansion']")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders no expansion when nothing is expanded", () => {
|
||||
render(<PacketVirtualList packets={many(30)} expandedHash={null} {...makeHandlers()} />);
|
||||
|
||||
expect(screen.queryByTestId("packet-expansion")).toBeNull();
|
||||
});
|
||||
|
||||
it("counts the expanded height in the virtualizer's total size", () => {
|
||||
const packets = many(30);
|
||||
const handlers = makeHandlers();
|
||||
const { container, rerender } = render(
|
||||
<PacketVirtualList packets={packets} expandedHash={null} {...handlers} />,
|
||||
);
|
||||
const collapsed = totalSize(container);
|
||||
|
||||
rerender(<PacketVirtualList packets={packets} expandedHash="AA3" {...handlers} />);
|
||||
flushResize();
|
||||
|
||||
expect(totalSize(container)).toBe(collapsed + (EXPANDED_H - ROW_H));
|
||||
});
|
||||
|
||||
it("forwards the expansion's actions", () => {
|
||||
const handlers = makeHandlers();
|
||||
render(<PacketVirtualList packets={[pkt("AA11")]} expandedHash="AA11" {...handlers} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open analyzer" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "View path on map" }));
|
||||
|
||||
expect(handlers.onOpenAnalyzer).toHaveBeenCalledTimes(1);
|
||||
expect(handlers.onViewPath).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PacketVirtualList header", () => {
|
||||
it("renders the sticky header once, outside measured item space", () => {
|
||||
const { container } = render(
|
||||
<PacketVirtualList packets={many(30)} expandedHash={null} {...makeHandlers()} />,
|
||||
);
|
||||
|
||||
const headings = screen.getAllByText("Hash");
|
||||
expect(headings).toHaveLength(1);
|
||||
const header = headings[0]!.parentElement as HTMLElement;
|
||||
expect(header.closest("[data-index]")).toBeNull();
|
||||
expect(header.parentElement).toBe(scroller(container));
|
||||
expect(header.previousElementSibling).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PacketVirtualList scrolling", () => {
|
||||
it("pages when scrolled near the bottom", () => {
|
||||
const handlers = makeHandlers();
|
||||
const { container } = render(
|
||||
<PacketVirtualList packets={many(30)} expandedHash={null} {...handlers} hasNextPage />,
|
||||
);
|
||||
|
||||
const el = scroller(container);
|
||||
setScrollMetrics(el, { scrollHeight: 5000, clientHeight: 1000, scrollTop: 3800 });
|
||||
fireEvent.scroll(el);
|
||||
|
||||
expect(handlers.fetchNextPage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not page while a page is already in flight", () => {
|
||||
const handlers = makeHandlers();
|
||||
const { container } = render(
|
||||
<PacketVirtualList packets={many(30)} expandedHash={null} {...handlers} hasNextPage isFetchingNextPage />,
|
||||
);
|
||||
|
||||
const el = scroller(container);
|
||||
setScrollMetrics(el, { scrollHeight: 5000, clientHeight: 1000, scrollTop: 3800 });
|
||||
fireEvent.scroll(el);
|
||||
|
||||
expect(handlers.fetchNextPage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports at-top and scrolled-away as the scroll position moves", () => {
|
||||
const handlers = makeHandlers();
|
||||
const { container } = render(
|
||||
<PacketVirtualList packets={many(30)} expandedHash={null} {...handlers} />,
|
||||
);
|
||||
|
||||
const el = scroller(container);
|
||||
setScrollMetrics(el, { scrollHeight: 5000, clientHeight: 1000, scrollTop: 150 });
|
||||
fireEvent.scroll(el);
|
||||
expect(handlers.onAtTopChange).toHaveBeenLastCalledWith(false);
|
||||
expect(handlers.onScrollAwayFromTop).toHaveBeenLastCalledWith(true);
|
||||
|
||||
setScrollMetrics(el, { scrollHeight: 5000, clientHeight: 1000, scrollTop: 0 });
|
||||
fireEvent.scroll(el);
|
||||
expect(handlers.onAtTopChange).toHaveBeenLastCalledWith(true);
|
||||
expect(handlers.onScrollAwayFromTop).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
it("does not page when a row collapses near the bottom", () => {
|
||||
const packets = many(30);
|
||||
const handlers = makeHandlers();
|
||||
const { rerender } = render(
|
||||
<PacketVirtualList packets={packets} expandedHash="AA29" {...handlers} hasNextPage />,
|
||||
);
|
||||
handlers.fetchNextPage.mockClear();
|
||||
|
||||
rerender(<PacketVirtualList packets={packets} expandedHash={null} {...handlers} hasNextPage />);
|
||||
flushResize();
|
||||
|
||||
expect(handlers.fetchNextPage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user