diff --git a/src/api/client.ts b/src/api/client.ts index 5f39f32..da77179 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -7,6 +7,7 @@ import type { NodeSummary, Node, NodeObservation, NodeNeighbor } from "../featur import type { StatsOverview, SignalStats, + PathStats, ObserverComparison, ObservationPoint, PayloadBreakdownItem, @@ -314,6 +315,10 @@ export function getSignalStats(since: number, until: number, iatas?: string[], s return request("/stats/signal", { since, until, iatas: iatasParam(iatas) }, signal); } +export function getPathStats(since: number, until: number, iatas?: string[], signal?: AbortSignal): Promise { + return request("/stats/paths", { since, until, iatas: iatasParam(iatas) }, signal); +} + export function getPayloadBreakdown(iatas?: string[], since?: number): Promise { return request("/stats/payload-breakdown", { iatas: iatasParam(iatas), since }); } diff --git a/src/features/stats/PathsTab.tsx b/src/features/stats/PathsTab.tsx new file mode 100644 index 0000000..d79b759 --- /dev/null +++ b/src/features/stats/PathsTab.tsx @@ -0,0 +1,75 @@ +import { useMemo } from "react"; +import { formatCount } from "../../lib/formatters"; +import { Card, ChartCard, StatCard } from "./cards"; +import { donutOption } from "./chartOptions"; +import { tooltipStyle, useChartColors } from "./chartTheme"; +import { pathHours, pathLengthOption, pathTrendOption } from "./paths"; +import { usePathStats } from "./usePathStats"; +import type { StatsRange } from "./types"; + +const utc = (ms: number) => new Date(ms).toISOString().slice(0, 16).replace("T", " "); + +export function PathsTab({ range }: { range: StatsRange }) { + const query = usePathStats(range), c = useChartColors(); + const loading = query.isPending || query.isPlaceholderData; + const data = loading || query.isError ? undefined : query.data; + const hours = useMemo(() => pathHours(data), [data]); + const categories = useMemo(() => [ + { name: "Hash paths", value: data?.hashed ?? 0, color: c.primary }, + { name: "Empty", value: data?.empty ?? 0, color: c.textDim }, + { name: "Trace", value: data?.trace ?? 0, color: c.warn }, + { name: "Unclassified", value: data?.unclassified ?? 0, color: c.secondary }, + ], [data, c]); + const charts = useMemo(() => { + const width = donutOption((data?.hashWidths ?? []).map((bin, i) => ({ name: `${bin.bytes}-byte`, value: bin.receptions, color: c.series[i] })), c, formatCount(data?.hashed ?? 0), "HASH PATHS"); + const coverage = donutOption(categories, c, formatCount(data?.receptions ?? 0), "RECEPTIONS"); + return { + width: { ...width, tooltip: { trigger: "item" as const, renderMode: "richText" as const, formatter: "{b}: {c} ({d}%)", ...tooltipStyle(c) }, aria: { enabled: true, label: { description: "Hash-width share among nonempty ordinary paths. Empty, trace and unclassified records are excluded. Exact counts follow below." } } }, + coverage: { ...coverage, tooltip: { trigger: "item" as const, renderMode: "richText" as const, formatter: "{b}: {c} ({d}%)", ...tooltipStyle(c) }, aria: { enabled: true, label: { description: "Path classification for all retained receptions. The four categories partition the total; exact counts follow alongside." } } }, + lengths: pathLengthOption(data?.pathLengths ?? [], c), trend: pathTrendOption(hours, c), + }; + }, [data, c, categories, hours]); + const state = { isLoading: loading, isError: query.isError }; + const multi = data?.hashWidths.filter((bin) => bin.bytes > 1).reduce((n, bin) => n + bin.receptions, 0) ?? 0; + const largest = data?.pathLengths.at(-1)?.entries; + + return
+
+

Paths & Hashes

How received packets carry route hashes.

+ +
+ {query.isError &&

Could not load path data. Try refreshing or choosing a shorter time period.

} +
+ + + + +
+

Path entries reported by observers, counted per reception. Hash-width shares use nonempty hash paths.

+ {data &&

Window: {utc(data.since)} to {utc(data.until)} UTC (end exclusive). Complete-hour snapshots refresh in the background; the current hour is excluded. Blank hours lack retained records.

} +
+ + +
+ +
+ + + {!data ?

{query.isError ? "Data unavailable" : "Loading paths…"}

: !data.receptions ?

No retained receptions in this window.

: + + {categories.map((item) => )} +
CategoryReceptionsShare
{item.name}{item.value.toLocaleString()}{(100 * item.value / data.receptions).toFixed(1)}%
} +

Flood paths accumulate entries; direct routes contain remaining entries. Empty paths have no hash-width vote. Trace headers carry signal readings. Unclassified records lack usable payload or path metadata.

+
+
+ {data && data.receptions > 0 &&
+ Exact width, path-length and hourly values +
+ {data.hashWidths.map((bin) => )}
Bytes per hashReceptions
{bin.bytes}{bin.receptions.toLocaleString()}
+
{data.pathLengths.map((bin) => )}
Header entriesReceptions
{bin.entries}{bin.receptions.toLocaleString()}
+
+

Length counts include validated ordinary empty paths at zero. In the trend, recorded hours with no hash paths show zero; absent hours remain gaps.

+
{["UTC hour", "Receptions", "1-byte", "2-byte", "3-byte", "Empty", "Trace", "Unclassified"].map((label) => )}{data.hourly.map((row) => {[row.receptions, row.oneByte, row.twoByte, row.threeByte, row.empty, row.trace, row.unclassified].map((n, i) => )})}
{label}
{utc(row.hour)}{n.toLocaleString()}
+
} +
; +} diff --git a/src/features/stats/StatsOverview.tsx b/src/features/stats/StatsOverview.tsx index a996d12..aa920f9 100644 --- a/src/features/stats/StatsOverview.tsx +++ b/src/features/stats/StatsOverview.tsx @@ -5,6 +5,7 @@ import { StatsSubHeader } from "./StatsSubHeader"; import { MeshTab } from "./MeshTab"; import { TrafficTab } from "./TrafficTab"; import { SignalTab } from "./SignalTab"; +import { PathsTab } from "./PathsTab"; import { ScopesTab } from "./ScopesTab"; import { TalkersTab } from "./TalkersTab"; import { ClockDriftTab } from "./ClockDriftTab"; @@ -13,7 +14,7 @@ import { CompareObserversTab } from "./CompareObserversTab"; import { NeighbourGraphTab } from "./NeighbourGraphTab"; import type { StatsRange, StatsTab } from "./types"; -const TABS: StatsTab[] = ["mesh", "traffic", "signal", "scopes", "talkers", "clockdrift", "observer", "compare", "graph"]; +const TABS: StatsTab[] = ["mesh", "traffic", "signal", "paths", "scopes", "talkers", "clockdrift", "observer", "compare", "graph"]; const RANGES: StatsRange[] = ["24h", "7d", "30d"]; const asTab = (v: string | null): StatsTab => (TABS.includes(v as StatsTab) ? (v as StatsTab) : "mesh"); @@ -60,6 +61,7 @@ export function StatsOverview({ wsManager }: StatsOverviewProps) { {tab === "mesh" && } {tab === "traffic" && } {tab === "signal" && } + {tab === "paths" && } {tab === "scopes" && } {tab === "talkers" && } {tab === "clockdrift" && } diff --git a/src/features/stats/StatsSubHeader.tsx b/src/features/stats/StatsSubHeader.tsx index e2cff8b..9f18862 100644 --- a/src/features/stats/StatsSubHeader.tsx +++ b/src/features/stats/StatsSubHeader.tsx @@ -66,6 +66,13 @@ function ClockDriftIcon() { ); } +function PathsIcon() { + return + + + ; +} + function GraphIcon() { return ( @@ -83,6 +90,7 @@ const TAB_OPTIONS = [ { value: "mesh", label: "Mesh", icon: }, { value: "traffic", label: "Traffic", icon: }, { value: "signal", label: "RF / Signal", icon: }, + { value: "paths", label: "Paths & Hashes", icon: }, { value: "scopes", label: "Scopes", icon: }, { value: "talkers", label: "Talkers", icon: }, { value: "clockdrift", label: "Clock Drift", icon: }, diff --git a/src/features/stats/paths.ts b/src/features/stats/paths.ts new file mode 100644 index 0000000..6425d08 --- /dev/null +++ b/src/features/stats/paths.ts @@ -0,0 +1,46 @@ +import type { EChartsOption } from "echarts"; +import type { PathLengthBin, PathStats } from "./types"; +import { blend, tooltipStyle, withAlpha, type ChartColors } from "./chartTheme"; + +const HOUR = 3_600_000; +export function pathHours(data: PathStats | undefined) { + if (!data || data.until <= data.since) return []; + const first = Math.floor(data.since / HOUR) * HOUR; + const rows = new Map(data.hourly.map((row) => [row.hour, row])); + return Array.from({ length: Math.min(721, Math.ceil((data.until - first) / HOUR)) }, (_, i) => { + const hour = first + i * HOUR, row = rows.get(hour); + return { hour, oneByte: row?.oneByte ?? null, twoByte: row?.twoByte ?? null, threeByte: row?.threeByte ?? null }; + }); +} + +export function pathLengthOption(bins: PathLengthBin[], c: ChartColors): EChartsOption { + const max = Math.min(63, Math.max(0, ...bins.map((bin) => bin.entries))); + const counts = new Map(bins.map((bin) => [bin.entries, bin.receptions])); + const entries = Array.from({ length: max + 1 }, (_, i) => i); + return { + animation: false, + aria: { enabled: true, label: { description: "Received path entries per reception. Zero means an empty ordinary path. Trace and unclassified records are excluded. Exact counts follow in the table." } }, + grid: { left: 8, right: 12, top: 18, bottom: 38, containLabel: true }, + tooltip: { trigger: "axis", renderMode: "richText", ...tooltipStyle(c) }, + xAxis: { type: "category", data: entries, name: "Path entries", nameLocation: "middle", nameGap: 25, nameTextStyle: { color: c.textMuted }, axisLabel: { color: c.textMuted, fontSize: 10 }, axisLine: { lineStyle: { color: c.border } } }, + yAxis: { type: "value", minInterval: 1, axisLabel: { color: c.textMuted, fontSize: 10 }, splitLine: { lineStyle: { color: c.borderSubtle } } }, + series: [{ name: "Receptions", type: "bar", barMaxWidth: 26, data: entries.map((i) => ({ value: counts.get(i) ?? 0, itemStyle: { color: i === 0 ? c.textDim : blend(c.primary, c.secondary, i / Math.max(1, max)), borderRadius: [3, 3, 0, 0] } })) }], + }; +} + +export function pathTrendOption(hours: ReturnType, c: ChartColors): EChartsOption { + return { + animation: false, useUTC: true, + aria: { enabled: true, label: { description: "Hourly receptions carrying nonempty 1, 2 or 3-byte hash paths, UTC. Absent hours remain gaps. Exact values follow in the hourly table." } }, + legend: { top: 0, textStyle: { color: c.textNormal, fontSize: 10 }, itemWidth: 10, itemHeight: 10 }, + grid: { left: 8, right: 18, top: 35, bottom: 15, containLabel: true }, + tooltip: { trigger: "axis", renderMode: "richText", ...tooltipStyle(c) }, + xAxis: { type: "time", axisLabel: { color: c.textMuted, fontSize: 10, hideOverlap: true }, axisLine: { lineStyle: { color: c.border } } }, + yAxis: { type: "value", minInterval: 1, axisLabel: { color: c.textMuted, fontSize: 10 }, splitLine: { lineStyle: { color: c.borderSubtle } } }, + series: (["oneByte", "twoByte", "threeByte"] as const).map((field, i) => ({ + name: `${i + 1}-byte`, type: "line", stack: "hash-paths", connectNulls: false, showSymbol: true, symbolSize: 4, + lineStyle: { width: 1.5, color: c.series[i] }, itemStyle: { color: c.series[i] }, areaStyle: { color: withAlpha(c.series[i]!, 0.32) }, + data: hours.map((hour) => [hour.hour, hour[field]]), + })), + }; +} diff --git a/src/features/stats/types.ts b/src/features/stats/types.ts index 66c6706..0750518 100644 --- a/src/features/stats/types.ts +++ b/src/features/stats/types.ts @@ -153,7 +153,7 @@ export interface ObserverActivity { } // Sub-tab + time-range identifiers shared across the Stats page. -export type StatsTab = "mesh" | "traffic" | "signal" | "scopes" | "talkers" | "clockdrift" | "observer" | "compare" | "graph"; +export type StatsTab = "mesh" | "traffic" | "signal" | "paths" | "scopes" | "talkers" | "clockdrift" | "observer" | "compare" | "graph"; export type StatsRange = "24h" | "7d" | "30d"; export const RANGE_MS: Record = { @@ -181,3 +181,29 @@ export interface SignalStats { rssi: SignalMetric; hourly: SignalHour[]; } + +// beacon-server /stats/paths: retained receptions; the four categories partition the total. +export interface PathHashWidth { bytes: number; receptions: number } +export interface PathLengthBin { entries: number; receptions: number } +export interface PathHour { + hour: number; + receptions: number; + oneByte: number; + twoByte: number; + threeByte: number; + empty: number; + trace: number; + unclassified: number; +} +export interface PathStats { + since: number; + until: number; + receptions: number; + hashed: number; + empty: number; + trace: number; + unclassified: number; + hashWidths: PathHashWidth[]; + pathLengths: PathLengthBin[]; + hourly: PathHour[]; +} diff --git a/src/features/stats/usePathStats.ts b/src/features/stats/usePathStats.ts new file mode 100644 index 0000000..6a9668c --- /dev/null +++ b/src/features/stats/usePathStats.ts @@ -0,0 +1,22 @@ +import { useQuery } from "@tanstack/react-query"; +import { getPathStats } from "../../api/client"; +import { useRegion } from "../../hooks/useRegion"; +import { RANGE_MS, type StatsRange } from "./types"; + +export function usePathStats(range: StatsRange) { + const { iatas, regionKey, isResolved } = useRegion(); + return useQuery({ + queryKey: ["stats-paths", isResolved === false ? `${regionKey}:pending` : regionKey, range], + enabled: isResolved !== false, + queryFn: ({ signal }) => { + if (isResolved === false) throw new Error("Selected region is not available yet"); + // Shared minute boundaries let viewers reuse server aggregates without changing the key every render. + const until = Math.floor(Date.now() / 60_000) * 60_000; + return getPathStats(until - RANGE_MS[range], until, iatas, signal); + }, + staleTime: 30_000, + refetchInterval: 60_000, + refetchOnWindowFocus: false, + retry: false, + }); +} diff --git a/tests/api/analytics-client.test.ts b/tests/api/analytics-client.test.ts index ddd5dfd..2684a2d 100644 --- a/tests/api/analytics-client.test.ts +++ b/tests/api/analytics-client.test.ts @@ -1,8 +1,19 @@ import { afterEach, expect, it, vi } from "vitest"; -import { getStatsObservations, getStatsScopes, getSignalStats } from "../../src/api/client"; +import { getStatsObservations, getStatsScopes, getSignalStats, getPathStats } from "../../src/api/client"; afterEach(() => vi.unstubAllGlobals()); +it("sends path window bounds, regional filters and cancellation", async () => { + const fetcher = vi.fn(async () => ({ ok: true, json: async () => ({}) }) as Response); + vi.stubGlobal("fetch", fetcher); + const controller = new AbortController(); + await getPathStats(0, 1000, ["YVR", "YYJ"], controller.signal); + const url = new URL(fetcher.mock.calls[0]![0] as string); + expect(url.pathname).toContain("/stats/paths"); + expect(Object.fromEntries(url.searchParams)).toEqual({ since: "0", until: "1000", iatas: "YVR,YYJ" }); + expect(fetcher.mock.calls[0]![1]).toEqual({ signal: controller.signal }); +}); + it("forwards region and cancellation to both aggregate endpoints, omitting an empty global filter", async () => { const fetcher = vi.fn(async () => ({ ok: true, json: async () => [] }) as Response); vi.stubGlobal("fetch", fetcher); diff --git a/tests/features/stats/PathsTab.test.tsx b/tests/features/stats/PathsTab.test.tsx new file mode 100644 index 0000000..da0091e --- /dev/null +++ b/tests/features/stats/PathsTab.test.tsx @@ -0,0 +1,29 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { PathsTab } from "../../../src/features/stats/PathsTab"; +import { usePathStats } from "../../../src/features/stats/usePathStats"; + +const query = { data: { since: 0, until: 3600000, receptions: 100, hashed: 60, empty: 20, trace: 10, unclassified: 10, + hashWidths: [{ bytes: 1, receptions: 20 }, { bytes: 2, receptions: 30 }, { bytes: 3, receptions: 10 }], pathLengths: [{ entries: 0, receptions: 20 }, { entries: 2, receptions: 60 }], + hourly: [{ hour: 0, receptions: 100, oneByte: 20, twoByte: 30, threeByte: 10, empty: 20, trace: 10, unclassified: 10 }] }, isPending: false, isPlaceholderData: false, isError: false, isFetching: false, refetch: vi.fn() }; +vi.mock("../../../src/features/stats/usePathStats", () => ({ usePathStats: vi.fn(() => query) })); +vi.mock("../../../src/features/stats/EChart", () => ({ EChart: () =>
})); +beforeEach(() => { query.isPending = false; query.isPlaceholderData = false; query.isError = false; vi.clearAllMocks(); }); + +it("uses only nonempty hash paths for multi-byte share, exposes categories and explains remaining routes", () => { + render(); + expect(usePathStats).toHaveBeenCalledWith("24h"); + expect(screen.getByText("66.7%")).toBeInTheDocument(); + expect(screen.getByText(/direct routes/i)).toHaveTextContent("remaining"); + const table = screen.getByRole("table", { name: "Path classification counts" }); + expect(within(table).getByRole("row", { name: /Hash paths.*60.*60.0%/ })).toBeInTheDocument(); + expect(within(table).getByRole("row", { name: /Empty.*20.*20.0%/ })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Refresh paths" })); + expect(query.refetch).toHaveBeenCalledOnce(); +}); +it.each(["isPending", "isPlaceholderData", "isError"] as const)("hides old values when %s", (state) => { + query[state] = true; render(); + expect(screen.queryByText("66.7%")).not.toBeInTheDocument(); + expect(screen.queryAllByTestId("chart")).toHaveLength(0); + if (state === "isError") expect(screen.getByRole("alert")).toHaveTextContent("shorter"); +}); diff --git a/tests/features/stats/analytics-navigation.test.tsx b/tests/features/stats/analytics-navigation.test.tsx index 7a443ea..2a523c2 100644 --- a/tests/features/stats/analytics-navigation.test.tsx +++ b/tests/features/stats/analytics-navigation.test.tsx @@ -6,10 +6,20 @@ import type { WsManager } from "../../../src/api/ws-manager"; vi.mock("../../../src/features/stats/TrafficTab", () => ({ TrafficTab: ({ range }: { range: string }) =>

Traffic range {range}

})); vi.mock("../../../src/features/stats/SignalTab", () => ({ SignalTab: ({ range }: { range: string }) =>

Signal range {range}

})); +vi.mock("../../../src/features/stats/PathsTab", () => ({ PathsTab: ({ range }: { range: string }) =>

Paths range {range}

})); vi.mock("../../../src/features/stats/MeshTab", () => ({ MeshTab: () =>

Mesh charts

})); vi.mock("../../../src/features/stats/ScopesTab", () => ({ ScopesTab: () =>

Scope charts

})); function Location() { return {useLocation().search}; } +it("opens Paths & Hashes from a shared URL and retains the region while changing range", () => { + render(); + expect(screen.getByText("Paths range 24h")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Paths & Hashes" })).toHaveAttribute("aria-pressed", "true"); + fireEvent.click(screen.getByRole("button", { name: "30d" })); + expect(screen.getByText("Paths range 30d")).toBeInTheDocument(); + expect(screen.getByLabelText("Analytics URL")).toHaveTextContent("iata=YOW"); +}); + it("opens Signal from a shared URL and retains regional state when changing range", () => { render(); expect(screen.getByText("Signal range 24h")).toBeInTheDocument(); diff --git a/tests/features/stats/path-query.test.tsx b/tests/features/stats/path-query.test.tsx new file mode 100644 index 0000000..d0071c6 --- /dev/null +++ b/tests/features/stats/path-query.test.tsx @@ -0,0 +1,33 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { usePathStats } from "../../../src/features/stats/usePathStats"; +import { getPathStats } from "../../../src/api/client"; +import type { StatsRange } from "../../../src/features/stats/types"; + +const region = { iatas: ["YVR"], regionKey: "YVR", isResolved: true }; +vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => region })); +vi.mock("../../../src/api/client", () => ({ getPathStats: vi.fn(() => new Promise(() => {})) })); +afterEach(() => { vi.clearAllMocks(); region.iatas = ["YVR"]; region.regionKey = "YVR"; region.isResolved = true; }); + +it("blocks unresolved regions and then uses bounded minute windows and cancellation", async () => { + region.isResolved = false; + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const { result, rerender, unmount } = renderHook(({ range }: { range: StatsRange }) => usePathStats(range), { initialProps: { range: "24h" }, wrapper: ({ children }: { children: ReactNode }) => {children} }); + expect(result.current.isPending).toBe(true); + expect(getPathStats).not.toHaveBeenCalled(); + region.isResolved = true; rerender({ range: "24h" }); + await waitFor(() => expect(getPathStats).toHaveBeenCalledOnce()); + const first = vi.mocked(getPathStats).mock.calls[0]!; + expect(first[1] % 60_000).toBe(0); + expect(first[1] - first[0]).toBe(24 * 3_600_000); + expect(first[2]).toEqual(["YVR"]); + region.iatas = ["YOW"]; region.regionKey = "YOW"; rerender({ range: "30d" }); + await waitFor(() => expect(getPathStats).toHaveBeenCalledTimes(2)); + expect(first[3]?.aborted).toBe(true); + const second = vi.mocked(getPathStats).mock.calls[1]!; + expect(second[1] - second[0]).toBe(30 * 24 * 3_600_000); + expect(second[2]).toEqual(["YOW"]); + unmount(); expect(second[3]?.aborted).toBe(true); client.clear(); +}); diff --git a/tests/features/stats/paths.test.ts b/tests/features/stats/paths.test.ts new file mode 100644 index 0000000..3dcbe61 --- /dev/null +++ b/tests/features/stats/paths.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { pathHours, pathLengthOption, pathTrendOption } from "../../../src/features/stats/paths"; +import { readChartColors } from "../../../src/features/stats/chartTheme"; +import type { PathStats } from "../../../src/features/stats/types"; + +const hour = 3_600_000; +const fixture: PathStats = { since: hour / 2, until: 3 * hour, receptions: 10, hashed: 6, empty: 2, trace: 1, unclassified: 1, + hashWidths: [{ bytes: 1, receptions: 2 }, { bytes: 2, receptions: 3 }, { bytes: 3, receptions: 1 }], + pathLengths: [{ entries: 0, receptions: 2 }, { entries: 2, receptions: 5 }, { entries: 63, receptions: 1 }], + hourly: [{ hour: 0, receptions: 8, oneByte: 2, twoByte: 3, threeByte: 1, empty: 1, trace: 1, unclassified: 0 }, { hour: 2 * hour, receptions: 2, oneByte: 0, twoByte: 0, threeByte: 0, empty: 1, trace: 0, unclassified: 1 }] }; + +describe("path analytics charts", () => { + it("keeps absent hours distinct from known zero hash-path hours and preserves response values", () => { + const before = JSON.stringify(fixture), hours = pathHours(fixture); + expect(hours.map((h) => h.hour)).toEqual([0, hour, 2 * hour]); + expect(hours.map((h) => h.oneByte)).toEqual([2, null, 0]); + expect(hours.map((h) => h.twoByte)).toEqual([3, null, 0]); + expect(JSON.stringify(fixture)).toBe(before); + expect(pathHours(undefined)).toEqual([]); + expect(pathHours({ ...fixture, until: 30 * 24 * hour + hour / 2 })).toHaveLength(721); + }); + it("fills zero-count length bins while preserving empty-path counts and visible single-hour points", () => { + const c = readChartColors(); + const length = pathLengthOption(fixture.pathLengths, c); + expect(length).toMatchObject({ animation: false, tooltip: { renderMode: "richText" }, aria: { enabled: true } }); + const option = length as { xAxis: { data: number[] }; series: { data: { value: number }[] }[] }; + expect(option.xAxis.data).toHaveLength(64); + expect(option.series[0]!.data[0]!.value).toBe(2); + expect(option.series[0]!.data[1]!.value).toBe(0); + expect(option.series[0]!.data.reduce((n, d) => n + d.value, 0)).toBe(8); + expect(pathTrendOption(pathHours(fixture), c)).toMatchObject({ animation: false, useUTC: true, legend: { top: 0 }, series: [{ connectNulls: false, showSymbol: true, data: [[0, 2], [hour, null], [2 * hour, 0]] }, { data: [[0, 3], [hour, null], [2 * hour, 0]] }, { data: [[0, 1], [hour, null], [2 * hour, 0]] }] }); + }); +});