Wire the packet table and expandable rows into the virtual list

This commit is contained in:
MrAlders0n
2026-07-27 20:04:36 -04:00
parent 64b822f4fa
commit 9582aaaa09
6 changed files with 436 additions and 47 deletions
+17 -3
View File
@@ -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}
/>
)}
+1 -1
View File
@@ -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>
+34 -3
View File
@@ -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
+27 -4
View File
@@ -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>
);
+140 -36
View File
@@ -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();
});
});