diff --git a/src/App.tsx b/src/App.tsx index eeb8e87c..6bf89693 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -301,7 +301,7 @@ function AppInner() { // master/detail layout and renders on any tab — same path NodeDetailPanel's onAnalyzePacket uses Traces: { if (hash) viewPacket(hash); }} onViewNode={viewNode} />, Channels: , - Analytics: , + Analytics: , Map: , }; diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 549c361e..f0698e6b 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -106,7 +106,7 @@ function CheckBox({ checked }: { checked: boolean }) { ); } -// Compact header summary of the active selection, e.g. "ALL", "YVR, YYJ", "2 regions", "1 region · 3 IATA". +// Compact header summary of the active selection, e.g. "ALL", "YVR, YYJ", "2 regions", "1 region · 3 areas". function regionSummaryLabel(selection: RegionSelection, t: TFunction): string { if (isAllRegions(selection)) return t("region.allShort"); const parts: string[] = []; @@ -135,7 +135,7 @@ function RegionSelector() { className="flex items-center gap-1.5 bg-bg-raised border border-border rounded px-3 py-1 text-text-bright font-mono text-xs font-semibold hover:border-text-dim/30 transition-colors" onClick={toggle} > - {t("region.label")} + {t("region.label")} {regionSummaryLabel(selection, t)} ▾ @@ -277,7 +277,7 @@ function RegionSelectorPanel() { <>
IATA
+ }`}>{t("region.areaGroup")} {iatas ? ( shownIatas.map((i) => { const checked = selection.iatas.includes(i.iata); diff --git a/src/components/CopyLinkButton.tsx b/src/components/CopyLinkButton.tsx index ea83a4b6..fc9bd4e5 100644 --- a/src/components/CopyLinkButton.tsx +++ b/src/components/CopyLinkButton.tsx @@ -7,7 +7,7 @@ import { VARIANT_CLASSES } from "./badge-utils"; // (e.g. the map camera) and use a null value to delete a key that's now at its default. export function CopyLinkButton({ params, - label = "Copy Link", + label = "Copy link", copiedLabel = "Copied", ariaLabel, preserveParams, diff --git a/src/features/map/MapSettingsPanel.tsx b/src/features/map/MapSettingsPanel.tsx index 8f867446..e6c87589 100644 --- a/src/features/map/MapSettingsPanel.tsx +++ b/src/features/map/MapSettingsPanel.tsx @@ -32,7 +32,7 @@ function BorderLegend() { className="inline-block h-2.5 w-4 rounded-sm border" style={{ borderColor: "var(--palette-secondary)", backgroundColor: "var(--palette-secondary)", opacity: 0.5 }} /> - IATA region outline + Area outline ); } @@ -155,9 +155,9 @@ export function MapSettingsPanel({ /> {neighborLines === "selected" && } -
+
onBordersChange(v === "on")} diff --git a/src/features/nodes/ForeignNodeBadge.tsx b/src/features/nodes/ForeignNodeBadge.tsx index 2397991d..cad22d16 100644 --- a/src/features/nodes/ForeignNodeBadge.tsx +++ b/src/features/nodes/ForeignNodeBadge.tsx @@ -4,7 +4,7 @@ import { Tooltip } from "../../components/Tooltip"; export function ForeignNodeBadge({ possiblyForeign }: { possiblyForeign?: boolean }) { if (possiblyForeign !== true) return null; return ( - + Possibly foreign ); diff --git a/src/features/nodes/NodeDetailPanel.tsx b/src/features/nodes/NodeDetailPanel.tsx index 120f9de2..4ab8d18d 100644 --- a/src/features/nodes/NodeDetailPanel.tsx +++ b/src/features/nodes/NodeDetailPanel.tsx @@ -164,8 +164,8 @@ export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, o
{node.minFirmwareVersion && } - - + + {node.radio && } {node.defaultScope && }
@@ -202,7 +202,7 @@ export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, o )}
-
+
{observations && observations.items.length > 0 ? (
{observations.items.map((obs) => ( @@ -214,7 +214,7 @@ export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, o ))}
) : ( -
No recent observations
+
No recent packets
)}
diff --git a/src/features/nodes/NodeFilterBar.tsx b/src/features/nodes/NodeFilterBar.tsx index 24d5dfd7..e967daa5 100644 --- a/src/features/nodes/NodeFilterBar.tsx +++ b/src/features/nodes/NodeFilterBar.tsx @@ -66,8 +66,8 @@ export function NodeFilterBar({ const controls = (fullWidth: boolean) => ( <> - onPathsChange(v as MultibyteFilter)} fullWidth={fullWidth} /> - onTracesChange(v as MultibyteFilter)} fullWidth={fullWidth} /> + onPathsChange(v as MultibyteFilter)} fullWidth={fullWidth} /> + onTracesChange(v as MultibyteFilter)} fullWidth={fullWidth} /> {scopeOptions.length > 0 && ( ({ value: s, label: s }))} allLabel="Any" value={scopeFilter} onChange={onScopeChange} fullWidth={fullWidth} /> )} diff --git a/src/features/nodes/NodeTable.tsx b/src/features/nodes/NodeTable.tsx index 0932fa0d..6e9fd0c5 100644 --- a/src/features/nodes/NodeTable.tsx +++ b/src/features/nodes/NodeTable.tsx @@ -78,7 +78,7 @@ const COLUMNS: Column[] = [ cell: (node) => formatRadio(node.radio) ?? "—", }, { - header: "IATAs", + header: "Areas", cell: (node) => node.iatas && node.iatas.length > 0 ? (
diff --git a/src/features/observers/ObserverComparison.tsx b/src/features/observers/ObserverComparison.tsx index 9fa407b2..a703072d 100644 --- a/src/features/observers/ObserverComparison.tsx +++ b/src/features/observers/ObserverComparison.tsx @@ -33,11 +33,13 @@ export function ObserverComparison({ observerA, activityA, range, observerBId, u const intervalMs = intervalToMs(activityParamsFor(range).interval)!; const end = until == null ? 0 : Math.floor(until / intervalMs) * intervalMs; const start = end - RANGE_MS[range]; + // raw packets only outlive 24h/7d windows; at 30d the overlap would silently cover a few days + const overlapRange = range !== "30d"; const aligned = [activityA, dataB].every(data => data?.summary && data.windowStart === start && data.windowEnd === end && intervalToMs(data.interval) === intervalMs); const overlap = useQuery({ queryKey: ["observer-dashboard-overlap", observerA.id, observerBId, start, end], queryFn: ({ signal }) => getObserverComparison(undefined, { observerA: observerA.id, observerB: observerBId, since: start, until: end }, signal), - enabled: valid && aligned && !!b.data && !b.isError, + enabled: overlapRange && valid && aligned && !!b.data && !b.isError, staleTime: 30_000, refetchInterval: false, refetchOnWindowFocus: false, retry: false, }); const option = useMemo(() => { @@ -65,13 +67,13 @@ export function ObserverComparison({ observerA, activityA, range, observerBId, u void b.refetch(); if (nextUntil === until) { void activityB.refetch(); - if (aligned) void overlap.refetch(); + if (aligned && overlapRange) void overlap.refetch(); } } }; const rows = aligned && b.data ? [ [t("observerPage.records"), activityA!.summary!.recordedPackets.toLocaleString(i18n.resolvedLanguage), dataB!.summary!.recordedPackets.toLocaleString(i18n.resolvedLanguage)], - [t("observerPage.lastHour"), `${activityA!.summary!.lastCompleteHour.toLocaleString(i18n.resolvedLanguage)} (${date(activityA!.summary!.lastCompleteHourStart)})`, `${dataB!.summary!.lastCompleteHour.toLocaleString(i18n.resolvedLanguage)} (${date(dataB!.summary!.lastCompleteHourStart)})`], + [t("observerPage.packetsLastHour"), `${activityA!.summary!.lastCompleteHour.toLocaleString(i18n.resolvedLanguage)} (${date(activityA!.summary!.lastCompleteHourStart)})`, `${dataB!.summary!.lastCompleteHour.toLocaleString(i18n.resolvedLanguage)} (${date(dataB!.summary!.lastCompleteHourStart)})`], [t("observerPage.lastPacket"), date(activityA!.summary!.latestRecordedAt), date(dataB!.summary!.latestRecordedAt)], [t("observerPage.battery"), observerA.batteryLevel == null ? "—" : formatBattery(observerA.batteryLevel), b.data.batteryLevel == null ? "—" : formatBattery(b.data.batteryLevel)], [t("observerPage.uptime"), observerA.uptimeSeconds == null ? "—" : formatUptime(observerA.uptimeSeconds), b.data.uptimeSeconds == null ? "—" : formatUptime(b.data.uptimeSeconds)], @@ -86,13 +88,13 @@ export function ObserverComparison({ observerA, activityA, range, observerBId, u

{t("observerPage.window")}: {date(start)} – {date(end)}

{rows.map(([label, a, bValue]) => )}
{t("observerCompare.metric")}AB
{label}{a}{bValue}
- }> + {!overlapRange ?

{t("observerCompare.overlapShortRanges")}

: }> {overlap.isError ?

{t("common.loadFailed")}

: !overlap.data ?

{t("common.loading")}

: <>

{t("observerCompare.total", { count: overlap.data.totalPackets })}

{overlap.data.totalPackets === 0 ?

{t("observerCompare.empty")}

:
{groups.map(g =>
)}
}
{groups.map(g =>
{g.label}
{g.count.toLocaleString(i18n.resolvedLanguage)}
)}
} - + } }
; } diff --git a/src/features/observers/ObserverSummary.tsx b/src/features/observers/ObserverSummary.tsx index 54b8bbf8..fad3d818 100644 --- a/src/features/observers/ObserverSummary.tsx +++ b/src/features/observers/ObserverSummary.tsx @@ -55,7 +55,7 @@ export function ObserverSummary({ observer, activity, points, pending = false, a { key: "uptime", label: t("observerPage.uptime"), value: observer.uptimeSeconds != null ? formatUptime(observer.uptimeSeconds) : "—" }, { key: "noise", - label: t("observerPage.lastNoise"), + label: t("observerPage.noise"), value: noise != null && Number.isFinite(noise) ? `${noise.toLocaleString(i18n.resolvedLanguage, { maximumFractionDigits: 1 })} dBm` : "—", }, ]; diff --git a/src/features/packets/ObservationTable.tsx b/src/features/packets/ObservationTable.tsx index f81b3898..320b6b53 100644 --- a/src/features/packets/ObservationTable.tsx +++ b/src/features/packets/ObservationTable.tsx @@ -16,7 +16,7 @@ export function ObservationTable({ observations, selectedId, onSelect }: Props) Observer - IATA + Area Heard SNR RSSI diff --git a/src/features/packets/PacketAnalyzerDrawer.tsx b/src/features/packets/PacketAnalyzerDrawer.tsx index 8e4e333c..199bb111 100644 --- a/src/features/packets/PacketAnalyzerDrawer.tsx +++ b/src/features/packets/PacketAnalyzerDrawer.tsx @@ -111,12 +111,12 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, o {detail.scope && {detail.scope}} ×{observerCount} diff --git a/src/features/packets/PacketExpansion.tsx b/src/features/packets/PacketExpansion.tsx index cb15a00d..4191c1e4 100644 --- a/src/features/packets/PacketExpansion.tsx +++ b/src/features/packets/PacketExpansion.tsx @@ -70,7 +70,7 @@ export function PacketExpansion({ packet, onOpenAnalyzer, onViewPath, selectedOb title={hasPath ? undefined : "No resolved path to map"} className={ACTION_BUTTON_CLASS} > - View path on map + Map all paths diff --git a/src/features/packets/PacketInvestigation.tsx b/src/features/packets/PacketInvestigation.tsx index ede24f3c..ed84bb54 100644 --- a/src/features/packets/PacketInvestigation.tsx +++ b/src/features/packets/PacketInvestigation.tsx @@ -32,13 +32,13 @@ export function PacketInvestigation({ detail, selectedId, onSelect, onViewObserv -

{t("investigation.reports", { count: detail.observations.length })} · {t("investigation.observers", { count: observerCount })}

+

{detail.observations.length === observerCount ? t("investigation.heardBy", { count: observerCount }) : `${t("investigation.heardTimes", { count: detail.observations.length })} ${t("investigation.byObservers", { count: observerCount })}`}

{groups.length === 0 ?

{t("investigation.empty")}

: open && visible.map(group =>
o.id === selectedId)} className="group/path"> ▶ {t(group.kind === "empty" ? "investigation.noEntries" : group.kind === "unavailable" ? "investigation.unavailablePath" : isTrace ? "investigation.tracePath" : "investigation.path", { number: groups.indexOf(group) + 1 })} {group.hashes.length > 0 && {group.hashes.join(" → ")}} - ×{group.reports.length} + ×{group.reports.length}
    {group.reports.map(report => { const key = isTrace ? "trace" : report.observerId; diff --git a/src/features/packets/PacketTableHeader.tsx b/src/features/packets/PacketTableHeader.tsx index e84f7ecb..ec263e47 100644 --- a/src/features/packets/PacketTableHeader.tsx +++ b/src/features/packets/PacketTableHeader.tsx @@ -15,7 +15,7 @@ export function PacketTableHeader() { Hops Hash Size Summary / Src → Dst - IATA + Area Age ); diff --git a/src/features/routes/RouteDetailPanel.tsx b/src/features/routes/RouteDetailPanel.tsx index f1a878b7..61dac87d 100644 --- a/src/features/routes/RouteDetailPanel.tsx +++ b/src/features/routes/RouteDetailPanel.tsx @@ -61,7 +61,7 @@ export function RouteDetailPanel({ route: listed, iata, pathKey, onClose, onAnal
    {route.iata} - +
@@ -109,7 +109,7 @@ export function RouteDetailPanel({ route: listed, iata, pathKey, onClose, onAnal

{report.observerName ?? report.observerId.slice(0, 8)}

· SNR {formatSnr(report.snr)}{report.snr != null && " dB"} · RSSI {report.rssi == null ? "—" : `${report.rssi} dBm`}

- {onAnalyzePacket && } + {onAnalyzePacket && } {onViewObserver && }
diff --git a/src/features/routes/RouteTable.tsx b/src/features/routes/RouteTable.tsx index 95a84225..34d24f0d 100644 --- a/src/features/routes/RouteTable.tsx +++ b/src/features/routes/RouteTable.tsx @@ -78,7 +78,7 @@ function CrossRouteCard({ route }: { route: CrossIATARoute }) { const COLUMNS: Column[] = [ { - header: "IATA", + header: "Area", sortValue: (r) => r.iata, cell: (r) => {r.iata}, }, @@ -302,7 +302,7 @@ export function RouteTable(actions: RouteActions) {
0 ? ( crossRoutes.map((r, i) => ) ) : ( -
No cross-IATA routes
+
No routes between these areas
)}
) : ( diff --git a/src/features/stats/ClockDriftTab.tsx b/src/features/stats/ClockDriftTab.tsx index 4d1235d4..9218a569 100644 --- a/src/features/stats/ClockDriftTab.tsx +++ b/src/features/stats/ClockDriftTab.tsx @@ -48,7 +48,7 @@ export function ClockDriftTab() { }, { id: "iatas", - header: "IATAs", + header: t("clockDrift.areas"), cell: (e) => (
{(e.iatas ?? []).map((i) => ( diff --git a/src/features/stats/CompareObserversTab.tsx b/src/features/stats/CompareObserversTab.tsx index f2bd7967..94d07069 100644 --- a/src/features/stats/CompareObserversTab.tsx +++ b/src/features/stats/CompareObserversTab.tsx @@ -23,9 +23,12 @@ function validation(value: Selection): string | null { return null; } +// raw packets are kept 3-7 days depending on the instance +const RETENTION_HINT_MS = 7 * 86_400_000; + function localTime(ms: number) { const date = new Date(ms); - return new Date(ms - date.getTimezoneOffset() * 60_000).toISOString().slice(0, 23); + return new Date(ms - date.getTimezoneOffset() * 60_000).toISOString().slice(0, 16); } function ObserverSelect({ label, value, onChange, excludeId }: { label: string; value: string; onChange: (id: string) => void; excludeId?: string }) { @@ -48,6 +51,7 @@ function ComparisonForm({ initial, onCompare }: { initial: Selection | null; onC const [since, setSince] = useState(() => localTime(initial?.since ?? Math.floor((Date.now() - 86_400_000) / 60_000) * 60_000)); const [until, setUntil] = useState(() => localTime(initial?.until ?? Math.floor(Date.now() / 60_000) * 60_000)); const [error, setError] = useState(null); + const [retainedSince] = useState(() => Date.now() - RETENTION_HINT_MS); function submit(e: FormEvent) { e.preventDefault(); const value = { observerA: a.toLowerCase(), observerB: b.toLowerCase(), since: new Date(since).getTime(), until: new Date(until).getTime() }; @@ -61,12 +65,13 @@ function ComparisonForm({ initial, onCompare }: { initial: Selection | null; onC
+ {new Date(since).getTime() < retainedSince &&

Only recent packets are kept, so the start of this window may be empty.

} {error &&

{t(error, { defaultValue: error })}

} @@ -124,14 +129,14 @@ export function CompareObserversTab() { {supplied && !valid &&

This comparison link has invalid or missing values. Choose observers and dates below.

} params.get(key)).join("|")} initial={selection} onCompare={compare} /> - {selection && + {selection &&

A: {observerA.data?.displayName ?? selection.observerA} · B: {observerB.data?.displayName ?? selection.observerB}

{new Date(selection.since).toLocaleString()} – {new Date(selection.until).toLocaleString()} · {iatas?.join(", ") || "All"}

- {result.isFetching &&

Comparing reported packets…

} + {result.isFetching &&

Comparing…

} {result.isError &&

{result.error.message}

} {data && !result.isError && <> -

{data.totalPackets.toLocaleString()} distinct flood packets

- {data.totalPackets === 0 ?

No flood packets were reported by either observer in this period and region.

: <> +

{data.totalPackets.toLocaleString()} flood packets

+ {data.totalPackets === 0 ?

Neither observer heard a flood packet in this period and region.

: <>
{groups.map((g) =>
)}
diff --git a/src/features/stats/MeshTab.tsx b/src/features/stats/MeshTab.tsx index 08c7e7f2..37cb3029 100644 --- a/src/features/stats/MeshTab.tsx +++ b/src/features/stats/MeshTab.tsx @@ -121,7 +121,7 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) { const observerSpark = useMemo(() => kpiObs.slice(-24).map((p) => p.activeObservers), [kpiObs]); // top-row KPIs are the overview endpoint's fixed 24h snapshot; range only drives the charts below - const ovWindow = `${ov?.windowHours ?? 24}h`; + const ovWindow = t("mesh.lastHours", { hours: ov?.windowHours ?? 24 }); return (
diff --git a/src/features/stats/NeighbourGraphTab.tsx b/src/features/stats/NeighbourGraphTab.tsx index e0b8b22a..e326696b 100644 --- a/src/features/stats/NeighbourGraphTab.tsx +++ b/src/features/stats/NeighbourGraphTab.tsx @@ -68,25 +68,25 @@ export function NeighbourGraphTab() { return ( ); - if (isError) return ; + if (isError) return ; // build only once the pager settles, or the force layout would restart on every streamed page if (isPaging) return ; - if (graph.nodes.length === 0) return ; + if (graph.nodes.length === 0) return ; return (
{ego ? (
- Neighbourhood of {selectedNode?.name ?? selectedId} · {ego.nodes.length - 1} neighbours + Neighborhood of {selectedNode?.name ?? selectedId} · {ego.nodes.length - 1} neighbors
) : (
{graph.capped ? `Showing ${CAP} of ${graph.total} nodes` : `${graph.total} nodes`} - {graph.capped && } + {graph.capped && }
diff --git a/src/features/stats/StatsOverview.tsx b/src/features/stats/StatsOverview.tsx index 1b985e68..50871e3c 100644 --- a/src/features/stats/StatsOverview.tsx +++ b/src/features/stats/StatsOverview.tsx @@ -22,11 +22,12 @@ const asRange = (v: string | null): StatsRange => (RANGES.includes(v as StatsRan interface StatsOverviewProps { wsManager: WsManager; + onViewNode?: (nodeId: string) => void; } // Stats page shell: an analytics sub-header and range over the active sub-tab. Sub-tab and range live // in the URL (?statsTab/?range) so the view is shareable. -export function StatsOverview({ wsManager }: StatsOverviewProps) { +export function StatsOverview({ wsManager, onViewNode }: StatsOverviewProps) { const [params, setParams] = useSearchParams(); const navigate = useNavigate(); const tab = asTab(params.get("statsTab")); @@ -68,7 +69,7 @@ export function StatsOverview({ wsManager }: StatsOverviewProps) { {tab === "signal" && } {tab === "paths" && } {tab === "scopes" && } - {tab === "talkers" && } + {tab === "talkers" && } {tab === "clockdrift" && } {tab === "graph" && } {tab === "compare" && } diff --git a/src/features/stats/TalkersTab.tsx b/src/features/stats/TalkersTab.tsx index e51a002d..62f87ac2 100644 --- a/src/features/stats/TalkersTab.tsx +++ b/src/features/stats/TalkersTab.tsx @@ -12,6 +12,7 @@ import type { TopAdvertiser, StatsRange } from "./types"; interface TalkersTabProps { range: StatsRange; + onViewNode?: (nodeId: string) => void; } // grow with the roster so bars stay readable; a floor keeps the loading/empty state from collapsing @@ -21,7 +22,7 @@ function leaderboardHeight(count: number) { // The "noisy nodes, politely" tab: who's loudest by adverts and by channel chatter. Advertisers list // their flood/direct advert split with a per-day rate; talkers are grouped by sender display-name. -export function TalkersTab({ range }: TalkersTabProps) { +export function TalkersTab({ range, onViewNode }: TalkersTabProps) { const colors = useChartColors(); const topAdvertisers = useTopAdvertisers(range, 20); const topTalkers = useTopTalkers(range, 20); @@ -73,7 +74,7 @@ export function TalkersTab({ range }: TalkersTabProps) { rows={advertisers} rowKey={(a) => a.nodeId} selectedKey={null} - onSelect={() => {}} + onSelect={(id) => { if (id) onViewNode?.(id); }} isLoading={advertisersLoading} emptyLabel={topAdvertisers.isError ? "Failed to load" : "No advertisers"} /> diff --git a/src/features/stats/TrafficTab.tsx b/src/features/stats/TrafficTab.tsx index c0e40e29..27eb4361 100644 --- a/src/features/stats/TrafficTab.tsx +++ b/src/features/stats/TrafficTab.tsx @@ -44,7 +44,7 @@ export function TrafficTab({ range }: { range: StatsRange }) { }> {unavailable ?

{query.isError ? t("common.dataUnavailable") : t("traffic.loading")}

: !model.areas.length ?

{t("traffic.empty")}

: (
Heard byPackets% of union
- + {model.areas.map((area, i) => diff --git a/src/features/stats/neighbour-graph.ts b/src/features/stats/neighbour-graph.ts index 6586c4f5..43221f33 100644 --- a/src/features/stats/neighbour-graph.ts +++ b/src/features/stats/neighbour-graph.ts @@ -211,7 +211,7 @@ export function neighbourGraphOption( } const d = param.data as unknown as GraphNode; const type = d.nodeTypeName || "unknown"; - return d.degree > 0 ? `${d.name}\n${type} · ${d.degree} neighbour${d.degree === 1 ? "" : "s"}` : `${d.name}\n${type}`; + return d.degree > 0 ? `${d.name}\n${type} · ${d.degree} neighbor${d.degree === 1 ? "" : "s"}` : `${d.name}\n${type}`; }, }, legend: [ diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index c57b6ad7..79cef77a 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -27,14 +27,16 @@ "rateLimited": "RATE LIMITED {{seconds}}s" }, "region": { - "label": "REGION", + "label": "Region", "allShort": "ALL", "allRegions": "All Regions", "group": "Regions", + "areaGroup": "Areas", "count_one": "{{count}} region", "count_other": "{{count}} regions", - "iataCount": "{{count}} IATA", - "filter": "Filter IATA or name…", + "iataCount_one": "{{count}} area", + "iataCount_other": "{{count}} areas", + "filter": "Search region or area code…", "failed": "Failed to load", "noMatches": "No matches" }, @@ -52,7 +54,8 @@ "totalPackets": "Total packets", "observations": "Observations", "activeObservers": "Active observers", - "activeIatas": "Active IATAs", + "activeIatas": "Active areas", + "lastHours": "Last {{hours}}h", "observationsTitle": "Observations · {{range}}", "topObservers": "Top observers · {{range}}", "payloadTypes": "Payload types · {{range}}", @@ -74,12 +77,12 @@ "mesh": "Mesh", "traffic": "Traffic", "signal": "RF / Signal", - "paths": "Paths & Hashes", + "paths": "Paths & hashes", "scopes": "Scopes", "talkers": "Talkers", - "clockdrift": "Clock Drift", + "clockdrift": "Clock drift", "compare": "Compare observers", - "graph": "Neighbour Graph" + "graph": "Neighbor graph" }, "ranges": { "24h": "24h", @@ -89,38 +92,38 @@ }, "signal": { "title": "RF / Signal", - "subtitle": "The signal behind reported receptions.", + "subtitle": "Signal strength of packets heard by observers.", "refresh": "Refresh signal", "error": "Could not load signal data. Try refreshing or choosing a shorter time period.", - "receptions": "Reported receptions", + "receptions": "Observations", "meanSnr": "Mean SNR", "meanRssi": "Mean RSSI", - "hoursWithRecords": "Hours with records", - "measurement": "Signal measured by observers on the last hop. Charts count retained receptions and use UTC time.", - "window": "Window: {{since}} to {{until}} UTC (end exclusive). Complete-hour snapshots refresh in the background; the current hour is excluded. Blank hours do not establish an outage.", + "hoursWithRecords": "Active hours", + "measurement": "Measured by each observer on the last hop. Times are UTC.", + "window": "{{since}} to {{until}} UTC, current hour excluded. Blank hours don't mean an outage.", "histogramTitle": "{{metric}} distribution · {{unit}}", "meanTitle": "Hourly mean {{metric}} · {{unit}}", "availability": "Sample availability", "averagesTitle": "What the averages include", "loading": "Loading signal…", - "empty": "No retained receptions in this window.", + "empty": "No observations in this window.", "availabilityTable": "Signal sample availability", "metric": "Metric", "samples": "Samples", "missing": "Missing", "share": "Share", - "averagesHelp": "Each mean weights every available reception equally. Missing includes absent, invalid and zero/zero unavailable readings; a measured zero SNR remains valid. SNR and RSSI can have different sample counts. These readings do not measure end-to-end quality or packet loss, and colors are not quality thresholds.", + "averagesHelp": "Each observation counts equally; missing readings are left out.", "details": "Exact histogram and hourly values", "histogramTable": "{{metric}} histogram", - "binsHelp": "Bins include the lower bound and exclude the upper bound. The outer bins include all values beyond the display range. Hourly means below are rounded to one decimal place.", + "binsHelp": "Each bin includes its lower bound; the outer bins catch everything beyond.", "hourlyTable": "Hourly signal values", "utcHour": "UTC hour", - "receptionsColumn": "Receptions", + "receptionsColumn": "Observations", "metricSamples": "{{metric}} samples", "meanUnit": "Mean {{unit}}", "binInterval": "{{lower}} to < {{upper}}", "histogramDescription": "{{metric}} distribution in {{unit}}. Exact bin boundaries and counts follow below.", - "trendDescription": "Hourly mean {{metric}} in {{unit}}, UTC. Missing readings remain gaps. Exact values are in the hourly table.", + "trendDescription": "Hourly mean {{metric}} in {{unit}}, UTC. Gaps mean no readings. Exact values are in the hourly table.", "coverageDescription": "Available and unavailable readings for SNR and RSSI. Exact counts and percentages are in the sample availability table.", "available": "Available", "unavailable": "Unavailable", @@ -130,22 +133,22 @@ "subtitle": "How received packets carry route hashes.", "refresh": "Refresh paths", "error": "Could not load path data. Try refreshing or choosing a shorter time period.", - "receptions": "Reported receptions", + "receptions": "Observations", "withHashPaths": "With hash paths", "multiByteShare": "Multi-byte share", "mostEntries": "Most path entries", - "measurement": "Path entries reported by observers, counted per reception. Hash-width shares use nonempty hash paths.", - "window": "Window: {{since}} to {{until}} UTC (end exclusive). Complete-hour snapshots refresh in the background; the current hour is excluded. Blank hours lack retained records.", + "measurement": "Path entries counted per observation.", + "window": "{{since}} to {{until}} UTC, current hour excluded.", "widthTitle": "Observed hash widths", "lengthTitle": "Received path entries", - "trendTitle": "Hash-path receptions over time", + "trendTitle": "Hash-path observations over time", "coverageTitle": "Path format coverage", "countsTitle": "What the counts include", "loading": "Loading paths…", - "empty": "No retained receptions in this window.", + "empty": "No observations in this window.", "classificationTable": "Path classification counts", "category": "Category", - "receptionsColumn": "Receptions", + "receptionsColumn": "Observations", "share": "Share", "categories": { "hashed": "Hash paths", @@ -153,67 +156,68 @@ "trace": "Trace", "unclassified": "Unclassified" }, - "countsHelp": "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.", + "countsHelp": "Empty paths have no hash width, and unclassified packets had no usable path data.", "details": "Exact width, path-length and hourly values", "widthTable": "Hash width counts", "bytesPerHash": "Bytes per hash", "lengthTable": "Path entry counts", "headerEntries": "Header entries", - "lengthsHelp": "Length counts include validated ordinary empty paths at zero. In the trend, recorded hours with no hash paths show zero; absent hours remain gaps.", + "lengthsHelp": "Zero means an empty path; hours with no data are left as gaps.", "hourlyTable": "Hourly path counts", "utcHour": "UTC hour", "hashWidth_one": "{{count}}-byte", "hashWidth_other": "{{count}}-byte", "hashPathsCenter": "HASH PATHS", - "receptionsCenter": "RECEPTIONS", - "widthDescription": "Hash-width share among nonempty ordinary paths. Empty, trace and unclassified records are excluded. Exact counts follow below.", - "coverageDescription": "Path classification for all retained receptions. The four categories partition the total; exact counts follow alongside.", - "lengthDescription": "Received path entries per reception. Zero means an empty ordinary path. Trace and unclassified records are excluded. Exact counts follow in the table.", + "receptionsCenter": "OBSERVATIONS", + "widthDescription": "Hash-width share among non-empty paths. Exact counts follow below.", + "coverageDescription": "Path type for every observation. Exact counts follow alongside.", + "lengthDescription": "Path entries per observation. Zero means an empty path. Exact counts follow in the table.", "entries": "Path entries", - "trendDescription": "Hourly receptions carrying nonempty 1, 2 or 3-byte hash paths, UTC. Absent hours remain gaps. Exact values follow in the hourly table." + "trendDescription": "Hourly observations with 1, 2 or 3-byte hash paths, UTC. Gaps mean no data. Exact values follow in the hourly table." }, "traffic": { - "subtitle": "When and where observers report receptions.", + "subtitle": "When and where observers hear packets.", "refresh": "Refresh traffic", "error": "Could not load traffic. Try refreshing.", - "receptions": "Reported receptions", - "reportingIatas": "Reporting IATAs", + "receptions": "Observations", + "reportingIatas": "Active areas", "busiestHour": "Busiest hour", - "hoursWithRecords": "Hours with records", - "measurement": "Counts are reported receptions, not unique packets. Blank hours have no retained hourly record; they do not establish an outage. Times are UTC and the latest hour may be partial.", - "trendTitle": "Reception trend by IATA", - "hourlyRecords": "Available hourly records", + "hoursWithRecords": "Active hours", + "measurement": "Counts are observations, not unique packets. Times are UTC; blank hours don't mean an outage.", + "trendTitle": "Observations by area", + "hourlyRecords": "Hourly · UTC", "hourlyTitle": "Hourly activity · {{range}}", - "shareTitle": "Reception share", - "byIata": "Receptions by IATA", + "shareTitle": "Observation share", + "byIata": "Observations by area", "loading": "Loading traffic…", - "empty": "No retained receptions in this window.", - "receptionsColumn": "Receptions", + "empty": "No observations in this window.", + "receptionsColumn": "Observations", + "area": "Area", "share": "Share", - "groupingHelp": "Charts group smaller areas into “Other IATAs”; this table keeps every area.", - "receptionsCenter": "RECEPTIONS", - "shareDescription": "Reception share by IATA. Exact counts and percentages follow in the table.", + "groupingHelp": "Charts group smaller areas into “Other areas”; this table keeps every area.", + "receptionsCenter": "OBSERVATIONS", + "shareDescription": "Observation share by area. Exact counts and percentages follow in the table.", "unassigned": "Unassigned", - "otherIatas": "Other IATAs", - "trendDescription": "Hourly reported receptions by IATA. Gaps mean no retained hourly record. Exact totals follow in the data table.", - "heatmapDescription": "Activity heatmap by UTC day and hour. Brighter cells indicate more reported receptions; blank cells have no retained record.", + "otherIatas": "Other areas", + "trendDescription": "Hourly observations by area. Gaps mean no data. Exact totals follow in the data table.", + "heatmapDescription": "Activity by UTC day and hour. Brighter cells mean more observations.", "more": "More", "fewer": "Fewer", - "heatmapTooltip_one": "{{day}} {{hour}}:00 UTC\n{{value}} reception", - "heatmapTooltip_other": "{{day}} {{hour}}:00 UTC\n{{value}} receptions" + "heatmapTooltip_one": "{{day}} {{hour}}:00 UTC\n{{value}} observation", + "heatmapTooltip_other": "{{day}} {{hour}}:00 UTC\n{{value}} observations" }, "scopes": { "title": "Transport scopes", - "subtitle": "Retained data and memberships for the selected region.", + "subtitle": "Scope activity for the selected region.", "search": "Find a scope", "error": "Could not load scopes. Try refreshing.", "active": "Scopes with data", "scopedPackets": "Scoped packets", - "memberships": "Observer memberships", + "memberships": "Observers", "defaultNodes": "Default-scope nodes", - "measurement": "An observer can appear in more than one scope. Global observer counts include stored memberships; regional counts use retained scoped receptions. Nodes are counted in their default scope. These counts have no selected time window.", + "measurement": "An observer can belong to more than one scope. These counts aren't limited to a time range.", "packetsTitle": "Packets by scope", - "membershipsTitle": "Observer memberships by scope", + "membershipsTitle": "Observers by scope", "countsTitle": "Scope counts", "visibleCount_one": "{{visible}} of {{count}} scope", "visibleCount_other": "{{visible}} of {{count}} scopes", @@ -245,7 +249,8 @@ "inSync": "in sync", "ahead": "ahead", "behind": "behind", - "empty": "No repeaters out of sync" + "empty": "No repeaters out of sync", + "areas": "Areas" }, "timestamp": { "ago": "{{duration}} ago", @@ -261,10 +266,9 @@ "detail": "Observer detail", "range": "Time range", "open": "Open observer dashboard", - "legacy": "Legacy presence count", + "legacy": "Observations (all time)", "metrics": "Observer metrics", - "records": "Recorded packets", - "lastHour": "Last complete hour", + "records": "Packets heard", "lastPacket": "Last packet", "battery": "Battery", "batteryV": "Battery V", @@ -275,11 +279,11 @@ "search": "Find an observer", "choose": "Choose an observer", "searchHelp": "Search by name to find observers outside the first 50 results.", - "summaryMissing": "Packet summary unavailable on this server. Charts remain available.", + "summaryMissing": "Packet totals unavailable.", "statusRecent": "Recent status", "statusStale": "Status is stale", "statusMissing": "No status report", - "activity": "Recorded packets per {{interval}}", + "activity": "Packets heard per {{interval}}", "mix": "Packet-type mix", "signal": "Received signal", "airtime": "Airtime TX / RX", @@ -287,9 +291,9 @@ "queue": "Queue length", "errors": "Receive errors", "busy": "Channel busy", - "empty": "No packets recorded in this period", - "noTelemetry": "No telemetry reported", - "noTelemetryHelp": "This observer reports no device telemetry for this period.", + "empty": "No packets heard in this period", + "noTelemetry": "No telemetry", + "noTelemetryHelp": "This observer sent no device telemetry for this period.", "loadFailed": "Observer could not be loaded.", "retry": "Retry", "model": "Model", @@ -314,7 +318,6 @@ "noMatches": "No matches", "none": "No observers", "packetsLastHour": "Packets last hour", - "lastNoise": "Last noise floor", "copyKey": "Copy public key", "sort": "Sort observers", "sortActivity": "Activity", @@ -331,76 +334,80 @@ "choose": "Choose an observer to compare", "invalidObserver": "Choose a different observer with a valid identifier.", "invalidTime": "This comparison time is invalid or too old. Refresh to use the latest complete window.", - "refresh": "Refresh both observers", - "unavailable": "Aligned summaries are unavailable. Refresh both observers, or update the server to support shared windows.", - "currentNote": "Activity totals use this shared window. Device values and the latest complete hour describe current conditions; the hour start is shown in UTC.", + "refresh": "Refresh", + "unavailable": "Comparison not available on this server.", + "currentNote": "Totals cover this window; battery, uptime and noise floor are current values.", "metrics": "Observer comparison metrics", "metric": "Metric", - "activity": "Recorded packets · aligned activity", - "overlap": "Retained flood-packet overlap", - "retention": "Raw packet history may expire before the longer-lived activity summaries. Overlap counts only retained flood packets in the shared window, across all regions.", - "total_one": "{{count}} distinct flood packet", - "total_other": "{{count}} distinct flood packets", - "empty": "No retained flood packets were reported by either observer in this window.", + "activity": "Packets heard", + "overlap": "Heard by both", + "retention": "Only packets the server still keeps are compared.", + "total_one": "{{count}} flood packet", + "total_other": "{{count}} flood packets", + "empty": "Neither observer heard a flood packet in this window.", "onlyA": "Only A", "both": "Both", "onlyB": "Only B", - "definition": "Each flood packet hash counts once across both observers. Repeat receptions are deduplicated. This is not a packet-loss measurement: expiry, offline devices and broker interruptions affect the available reports.", - "retainedWindow": "Packet details and overlap use retained raw records, so periods older than the server's packet retention come back incomplete." + "definition": "Each flood packet counts once. Not a packet-loss measure.", + "retainedWindow": "Packets are only kept for a few days, so older windows may be incomplete.", + "overlapShortRanges": "Packet overlap is available for 24h and 7d." }, "investigation": { "title": "Observations", - "reports_one": "{{count}} retained report", - "reports_other": "{{count}} retained reports", - "observers_one": "{{count}} reporting observer", - "observers_other": "{{count}} reporting observers", - "pathNote": "Grouped by path prefixes and hash width, not confirmed physical routes. Inspect each report for candidate identities.", - "traceNote": "TRACE entries describe the intended route, not a measured reception path. Inspect the trace readings separately.", - "empty": "No retained reports for this packet.", + "observations_one": "{{count}} observation", + "observations_other": "{{count}} observations", + "heardBy_one": "Heard by {{count}} observer", + "heardBy_other": "Heard by {{count}} observers", + "heardTimes_one": "Heard once", + "heardTimes_other": "Heard {{count}} times", + "byObservers_one": "by {{count}} observer", + "byObservers_other": "by {{count}} observers", + "pathNote": "Grouped by the repeaters each copy came through. Short IDs can match more than one node.", + "traceNote": "TRACE entries show the planned route, not the path the packet took.", + "empty": "No observations for this packet.", "path": "Path {{number}}", "tracePath": "Intended route {{number}}", "noEntries": "No path entries", "unavailablePath": "Path bytes unavailable", - "inspect": "Inspect report", + "inspect": "Select observation", "observer": "Inspect observer", - "map": "Map report", - "unmappable": "Mapping needs at least two uniquely resolved, valid locations.", - "retention": "Reports include all received regions and follow packet/observer deduplication, so not every reception or path is retained. Expired reports are unavailable; reported timing does not establish packet loss or delivery.", - "unavailableReport": "Selected report is unavailable or its identifier is invalid. Choose a retained report below.", + "map": "Map path", + "unmappable": "Needs at least two repeaters with known locations.", + "retention": "One entry per observer. Older packets expire after the server's packet retention.", + "unavailableReport": "That observation is no longer available or the link is invalid. Choose one below.", "copy": "Copy link", "copyPacket": "Copy packet link", - "viewAll": "View path on map", + "viewAll": "Map all paths", "mapDialog": "Packet path map", "mapTitle": "Packet Path", "copyPath": "Copy path link", "closeMap": "Close path map", "allPaths": "All paths", "traceRoute": "Trace route", - "mapNote": "Only uniquely resolved locations are shown. Unresolved or unlocated gaps are not connected; lines do not establish direct radio links or delivery.", - "unavailableMap": "The selected path cannot be mapped from the retained evidence. Choose an available path or All paths.", - "reportedBy_one": "Reported by {{count}} observer", - "reportedBy_other": "Reported by {{count}} observers", + "mapNote": "Lines join repeaters with known locations. They don't prove a direct radio link.", + "unavailableMap": "This path can't be mapped. Choose another path or All paths.", "first": "First", "last": "Last", - "span": "Reported span", + "span": "Heard over", "nodeDialog": "Node detail", "packetDialog": "Packet analyzer", "closeObserver": "Close observer details", "loadMore": "Load more ({{count}} left)" }, "routeEvidence": { - "recent": "Recent packets", - "loading": "Loading packets…", - "missing": "Recent packets aren't available for this route on this server.", - "error": "Could not load recent packets. Try again.", + "recent": "Recent observations", + "loading": "Loading observations…", + "missing": "Recent observations aren't available for this route on this server.", + "error": "Could not load recent observations. Try again.", "retry": "Retry", - "match": "Packets heard along exactly this hop sequence at {{width}} byte(s) per hop, newest first. TRACE packets and other prefix widths aren't included.", - "caution": "Matching prefixes don't prove the same physical path; inspect a packet to see its candidate nodes.", - "retention": "Only packets the server still keeps are listed, so older ones drop off. The observation count above covers the route's whole life.", - "unavailable": "This route's saved prefixes can't be matched against packets.", - "empty": "No packets on this route are still kept. Older ones may have expired or used a different prefix width.", + "match": "Observations along exactly these hops at {{width}} byte(s) per hop, newest first. TRACE packets aren't included.", + "caution": "Matching short IDs don't prove the same physical path.", + "retention": "Older packets drop off; the Heard count above covers the route's whole life.", + "unavailable": "This route can't be matched against packets.", + "empty": "No observations on this route are still kept. Older ones may have expired.", "more": "Load more", - "cap": "Showing the newest 500 packets." + "cap": "Showing the newest 500 observations.", + "inspect": "Inspect packet" }, "channelMessages": { "select": "Select a channel", @@ -412,14 +419,14 @@ "keyMissing": "No decryption key is configured for this channel.", "scope": "Scope", "allScopes": "All scopes", - "unknownScope": "Unresolved scope", + "unknownScope": "Unknown scope", "inspect": "Inspect packet from {{sender}}", "loading": "Loading messages…", "loadingOlder": "Loading older messages… ({{count}} shown)", "loadOlder": "Load older messages", - "empty": "No retained messages for this channel.", - "emptyScope": "No retained messages match {{scope}}.", - "emptyNoKey": "No decrypted messages are available without a known channel key.", + "empty": "No messages for this channel.", + "emptyScope": "No messages match {{scope}}.", + "emptyNoKey": "Messages can't be decrypted without the channel key.", "error": "Could not load messages.", "retry": "Retry", "viewPacket": "View packet" diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 205998e1..692b93e4 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -27,15 +27,18 @@ "rateLimited": "DÉBIT LIMITÉ {{seconds}} s" }, "region": { - "label": "RÉGION", + "label": "Région", "allShort": "TOUT", "allRegions": "Toutes les régions", "group": "Régions", + "areaGroup": "Zones", "count_one": "{{count}} région", "count_many": "{{count}} régions", "count_other": "{{count}} régions", - "iataCount": "{{count}} IATA", - "filter": "Filtrer par IATA ou nom…", + "iataCount_one": "{{count}} zone", + "iataCount_many": "{{count}} zones", + "iataCount_other": "{{count}} zones", + "filter": "Rechercher une région ou un code de zone…", "failed": "Échec du chargement", "noMatches": "Aucun résultat" }, @@ -53,7 +56,8 @@ "totalPackets": "Paquets totaux", "observations": "Observations", "activeObservers": "Observateurs actifs", - "activeIatas": "IATA actifs", + "activeIatas": "Zones actives", + "lastHours": "Dernières {{hours}} h", "observationsTitle": "Observations · {{range}}", "topObservers": "Meilleurs observateurs · {{range}}", "payloadTypes": "Types de paquets · {{range}}", @@ -90,38 +94,38 @@ }, "signal": { "title": "RF / Signal", - "subtitle": "Le signal des réceptions déclarées.", + "subtitle": "Force du signal des paquets reçus par les observateurs.", "refresh": "Actualiser le signal", "error": "Impossible de charger les données du signal. Réessayez ou choisissez une période plus courte.", - "receptions": "Réceptions déclarées", + "receptions": "Observations", "meanSnr": "SNR moyen", "meanRssi": "RSSI moyen", - "hoursWithRecords": "Heures documentées", - "measurement": "Signal mesuré par les observateurs sur le dernier saut. Les graphiques comptent les réceptions conservées et utilisent l’heure UTC.", - "window": "Période : {{since}} à {{until}} UTC (fin exclue). Les instantanés d’heures complètes sont actualisés en arrière-plan ; l’heure en cours est exclue. Les heures vides ne prouvent pas une panne.", + "hoursWithRecords": "Heures actives", + "measurement": "Mesuré par chaque observateur sur le dernier saut. Heures en UTC.", + "window": "{{since}} à {{until}} UTC, heure en cours exclue. Les heures vides ne signifient pas une panne.", "histogramTitle": "Distribution du {{metric}} · {{unit}}", "meanTitle": "Moyenne horaire du {{metric}} · {{unit}}", "availability": "Disponibilité des échantillons", "averagesTitle": "Ce que les moyennes incluent", "loading": "Chargement du signal…", - "empty": "Aucune réception conservée dans cette période.", + "empty": "Aucune observation dans cette période.", "availabilityTable": "Disponibilité des échantillons du signal", "metric": "Mesure", "samples": "Échantillons", "missing": "Manquants", "share": "Part", - "averagesHelp": "Chaque moyenne accorde le même poids à chaque réception disponible. Les valeurs manquantes incluent les mesures absentes, invalides et les couples zéro/zéro indisponibles ; un SNR mesuré à zéro reste valide. Le SNR et le RSSI peuvent avoir des nombres d’échantillons différents. Ces mesures ne représentent ni la qualité de bout en bout ni la perte de paquets, et les couleurs ne sont pas des seuils de qualité.", + "averagesHelp": "Chaque observation compte autant ; les mesures manquantes sont ignorées.", "details": "Valeurs exactes des histogrammes et par heure", "histogramTable": "Histogramme {{metric}}", - "binsHelp": "Les classes incluent la borne inférieure et excluent la borne supérieure. Les classes extrêmes incluent toutes les valeurs au-delà de la plage affichée. Les moyennes horaires ci-dessous sont arrondies à une décimale.", + "binsHelp": "Chaque classe inclut sa borne inférieure ; les classes extrêmes regroupent tout le reste.", "hourlyTable": "Valeurs horaires du signal", "utcHour": "Heure UTC", - "receptionsColumn": "Réceptions", + "receptionsColumn": "Observations", "metricSamples": "Échantillons {{metric}}", "meanUnit": "Moyenne {{unit}}", "binInterval": "{{lower}} à < {{upper}}", "histogramDescription": "Distribution du {{metric}} en {{unit}}. Les bornes exactes des classes et leurs effectifs figurent ci-dessous.", - "trendDescription": "Moyenne horaire du {{metric}} en {{unit}}, UTC. Les mesures manquantes restent des lacunes. Les valeurs exactes figurent dans le tableau horaire.", + "trendDescription": "Moyenne horaire du {{metric}} en {{unit}}, UTC. Les lacunes indiquent l’absence de mesures. Les valeurs exactes figurent dans le tableau horaire.", "coverageDescription": "Mesures disponibles et indisponibles du SNR et du RSSI. Les effectifs et pourcentages exacts figurent dans le tableau de disponibilité des échantillons.", "available": "Disponibles", "unavailable": "Indisponibles", @@ -131,22 +135,22 @@ "subtitle": "Les hachages de route portés par les paquets reçus.", "refresh": "Actualiser les chemins", "error": "Impossible de charger les données des chemins. Réessayez ou choisissez une période plus courte.", - "receptions": "Réceptions déclarées", + "receptions": "Observations", "withHashPaths": "Avec chemins hachés", "multiByteShare": "Part multi-octets", "mostEntries": "Maximum d’entrées", - "measurement": "Entrées de chemin déclarées par les observateurs, comptées par réception. Les parts par largeur de hachage utilisent les chemins hachés non vides.", - "window": "Période : {{since}} à {{until}} UTC (fin exclue). Les instantanés d’heures complètes sont actualisés en arrière-plan ; l’heure en cours est exclue. Les heures vides n’ont pas de données conservées.", + "measurement": "Entrées de chemin comptées par observation.", + "window": "{{since}} à {{until}} UTC, heure en cours exclue.", "widthTitle": "Largeurs de hachage observées", "lengthTitle": "Entrées de chemin reçues", - "trendTitle": "Réceptions avec chemins hachés au fil du temps", + "trendTitle": "Observations avec chemins hachés au fil du temps", "coverageTitle": "Répartition des formats de chemin", "countsTitle": "Ce que les effectifs incluent", "loading": "Chargement des chemins…", - "empty": "Aucune réception conservée dans cette période.", + "empty": "Aucune observation dans cette période.", "classificationTable": "Effectifs par catégorie de chemin", "category": "Catégorie", - "receptionsColumn": "Réceptions", + "receptionsColumn": "Observations", "share": "Part", "categories": { "hashed": "Chemins hachés", @@ -154,69 +158,70 @@ "trace": "Trace", "unclassified": "Non classés" }, - "countsHelp": "Les chemins de diffusion accumulent des entrées ; les routes directes contiennent les entrées restantes. Les chemins vides ne comptent dans aucune largeur de hachage. Les en-têtes de trace portent des mesures de signal. Les enregistrements non classés n’ont pas de données utiles ou de métadonnées de chemin exploitables.", + "countsHelp": "Les chemins vides n’ont pas de largeur de hachage, et les paquets non classés n’avaient pas de données de chemin utilisables.", "details": "Valeurs exactes des largeurs, longueurs et par heure", "widthTable": "Effectifs par largeur de hachage", "bytesPerHash": "Octets par hachage", "lengthTable": "Effectifs par nombre d’entrées", "headerEntries": "Entrées d’en-tête", - "lengthsHelp": "Les effectifs par longueur incluent les chemins ordinaires vides validés à zéro entrée. Dans la courbe, les heures documentées sans chemin haché affichent zéro ; les heures absentes restent des lacunes.", + "lengthsHelp": "Zéro indique un chemin vide ; les heures sans données restent des lacunes.", "hourlyTable": "Effectifs horaires des chemins", "utcHour": "Heure UTC", "hashWidth_one": "{{count}} octet", "hashWidth_many": "{{count}} octets", "hashWidth_other": "{{count}} octets", "hashPathsCenter": "CHEMINS HACHÉS", - "receptionsCenter": "RÉCEPTIONS", - "widthDescription": "Part de chaque largeur de hachage parmi les chemins ordinaires non vides. Les chemins vides, les traces et les enregistrements non classés sont exclus. Les effectifs exacts figurent ci-dessous.", - "coverageDescription": "Catégories de chemin pour toutes les réceptions conservées. Les quatre catégories répartissent le total ; les effectifs exacts figurent à côté.", - "lengthDescription": "Entrées de chemin reçues par réception. Zéro indique un chemin ordinaire vide. Les traces et les enregistrements non classés sont exclus. Les effectifs exacts figurent dans le tableau.", + "receptionsCenter": "OBSERVATIONS", + "widthDescription": "Part de chaque largeur de hachage parmi les chemins non vides. Les effectifs exacts figurent ci-dessous.", + "coverageDescription": "Type de chemin pour chaque observation. Les effectifs exacts figurent à côté.", + "lengthDescription": "Entrées de chemin par observation. Zéro indique un chemin vide. Les effectifs exacts figurent dans le tableau.", "entries": "Entrées du chemin", - "trendDescription": "Réceptions horaires portant des chemins non vides avec des hachages de 1, 2 ou 3 octets, UTC. Les heures absentes restent des lacunes. Les valeurs exactes figurent dans le tableau horaire." + "trendDescription": "Observations horaires avec des chemins hachés de 1, 2 ou 3 octets, UTC. Les lacunes indiquent l’absence de données. Les valeurs exactes figurent dans le tableau horaire." }, "traffic": { - "subtitle": "Quand et où les observateurs déclarent des réceptions.", + "subtitle": "Quand et où les observateurs reçoivent des paquets.", "refresh": "Actualiser le trafic", "error": "Impossible de charger le trafic. Réessayez.", - "receptions": "Réceptions déclarées", - "reportingIatas": "IATA actifs", + "receptions": "Observations", + "reportingIatas": "Zones actives", "busiestHour": "Pic horaire", - "hoursWithRecords": "Heures documentées", - "measurement": "Les effectifs comptent les réceptions déclarées, pas les paquets uniques. Les heures vides n’ont pas de données horaires conservées ; elles ne prouvent pas une panne. Les horaires utilisent l’heure UTC et la dernière heure peut être partielle.", - "trendTitle": "Évolution des réceptions par IATA", - "hourlyRecords": "Données horaires disponibles", + "hoursWithRecords": "Heures actives", + "measurement": "Les effectifs comptent les observations, pas les paquets uniques. Heures en UTC ; une heure vide ne signifie pas une panne.", + "trendTitle": "Observations par zone", + "hourlyRecords": "Par heure · UTC", "hourlyTitle": "Activité horaire · {{range}}", - "shareTitle": "Part des réceptions", - "byIata": "Réceptions par IATA", + "shareTitle": "Part des observations", + "byIata": "Observations par zone", "loading": "Chargement du trafic…", - "empty": "Aucune réception conservée dans cette période.", - "receptionsColumn": "Réceptions", + "empty": "Aucune observation dans cette période.", + "receptionsColumn": "Observations", + "area": "Zone", "share": "Part", - "groupingHelp": "Les graphiques regroupent les zones moins actives dans « Autres IATA » ; ce tableau conserve toutes les zones.", - "receptionsCenter": "RÉCEPTIONS", - "shareDescription": "Part des réceptions par IATA. Les effectifs et pourcentages exacts figurent dans le tableau.", + "groupingHelp": "Les graphiques regroupent les zones moins actives dans « Autres zones » ; ce tableau conserve toutes les zones.", + "receptionsCenter": "OBSERVATIONS", + "shareDescription": "Part des observations par zone. Les effectifs et pourcentages exacts figurent dans le tableau.", "unassigned": "Non attribué", - "otherIatas": "Autres IATA", - "trendDescription": "Réceptions horaires déclarées par IATA. Les lacunes indiquent l’absence de données horaires conservées. Les totaux exacts figurent dans le tableau.", - "heatmapDescription": "Carte thermique de l’activité par jour et heure UTC. Les cellules plus lumineuses indiquent davantage de réceptions déclarées ; les cellules vides n’ont pas de données conservées.", + "otherIatas": "Autres zones", + "trendDescription": "Observations horaires par zone. Les lacunes indiquent l’absence de données. Les totaux exacts figurent dans le tableau.", + "heatmapDescription": "Activité par jour et heure UTC. Les cellules plus lumineuses indiquent davantage d’observations.", "more": "Plus", "fewer": "Moins", - "heatmapTooltip_one": "{{day}} {{hour}}:00 UTC\n{{value}} réception", - "heatmapTooltip_many": "{{day}} {{hour}}:00 UTC\n{{value}} réceptions", - "heatmapTooltip_other": "{{day}} {{hour}}:00 UTC\n{{value}} réceptions" + "heatmapTooltip_one": "{{day}} {{hour}}:00 UTC\n{{value}} observation", + "heatmapTooltip_many": "{{day}} {{hour}}:00 UTC\n{{value}} observations", + "heatmapTooltip_other": "{{day}} {{hour}}:00 UTC\n{{value}} observations" }, "scopes": { "title": "Scopes de transport", - "subtitle": "Données conservées et appartenances pour la région sélectionnée.", + "subtitle": "Activité des scopes pour la région sélectionnée.", "search": "Rechercher un scope", "error": "Impossible de charger les scopes. Réessayez.", "active": "Scopes avec données", "scopedPackets": "Paquets avec scope", - "memberships": "Appartenances d’observateurs", + "memberships": "Observateurs", "defaultNodes": "Nœuds du scope par défaut", - "measurement": "Un observateur peut figurer dans plusieurs scopes. Les effectifs globaux d’observateurs incluent les appartenances enregistrées ; les effectifs régionaux utilisent les réceptions conservées associées aux scopes. Les nœuds sont comptés dans leur scope par défaut. Ces effectifs sont calculés sans période sélectionnée.", + "measurement": "Un observateur peut appartenir à plusieurs scopes. Ces effectifs ne sont pas limités à une période.", "packetsTitle": "Paquets par scope", - "membershipsTitle": "Appartenances d’observateurs par scope", + "membershipsTitle": "Observateurs par scope", "countsTitle": "Effectifs des scopes", "visibleCount_one": "{{visible}} sur {{count}} scope", "visibleCount_many": "{{visible}} sur {{count}} scopes", @@ -249,7 +254,8 @@ "inSync": "synchronisé", "ahead": "en avance", "behind": "en retard", - "empty": "Aucun répéteur désynchronisé" + "empty": "Aucun répéteur désynchronisé", + "areas": "Zones" }, "timestamp": { "ago": "il y a {{duration}}", @@ -265,10 +271,9 @@ "detail": "Détail de l'observateur", "range": "Période", "open": "Ouvrir le tableau de bord", - "legacy": "Ancien compteur de présence", + "legacy": "Observations (historique complet)", "metrics": "Mesures de l’observateur", - "records": "Paquets enregistrés", - "lastHour": "Dernière heure complète", + "records": "Paquets reçus", "lastPacket": "Dernier paquet", "battery": "Batterie", "batteryV": "Batterie V", @@ -279,11 +284,11 @@ "search": "Rechercher un observateur", "choose": "Choisir un observateur", "searchHelp": "Recherchez par nom pour trouver les observateurs au-delà des 50 premiers résultats.", - "summaryMissing": "Résumé des paquets indisponible sur ce serveur. Les graphiques restent disponibles.", + "summaryMissing": "Totaux de paquets indisponibles.", "statusRecent": "État récent", "statusStale": "État périmé", "statusMissing": "Aucun rapport d’état", - "activity": "Paquets enregistrés par {{interval}}", + "activity": "Paquets reçus par {{interval}}", "mix": "Répartition des types", "signal": "Signal reçu", "airtime": "Temps radio TX / RX", @@ -291,9 +296,9 @@ "queue": "File d’attente", "errors": "Erreurs de réception", "busy": "Occupation du canal", - "empty": "Aucun paquet enregistré sur cette période", - "noTelemetry": "Aucune télémétrie reçue", - "noTelemetryHelp": "Cet observateur ne rapporte aucune télémétrie pour cette période.", + "empty": "Aucun paquet reçu sur cette période", + "noTelemetry": "Aucune télémétrie", + "noTelemetryHelp": "Cet observateur n’a envoyé aucune télémétrie pour cette période.", "loadFailed": "Impossible de charger l’observateur.", "retry": "Réessayer", "model": "Modèle", @@ -318,7 +323,6 @@ "noMatches": "Aucun résultat", "none": "Aucun observateur", "packetsLastHour": "Paquets dernière heure", - "lastNoise": "Dernier bruit de fond", "copyKey": "Copier la clé publique", "sort": "Trier les observateurs", "sortActivity": "Activité", @@ -335,76 +339,80 @@ "choose": "Choisissez un observateur à comparer", "invalidObserver": "Choisissez un autre observateur avec un identifiant valide.", "invalidTime": "La date de comparaison est invalide ou trop ancienne. Actualisez pour utiliser la dernière période complète.", - "refresh": "Actualiser les deux observateurs", - "unavailable": "Les résumés alignés sont indisponibles. Actualisez les deux observateurs ou mettez le serveur à jour pour prendre en charge les périodes communes.", - "currentNote": "Les totaux d’activité utilisent cette période commune. Les valeurs de l’appareil et la dernière heure complète décrivent les conditions actuelles; le début de l’heure est indiqué en UTC.", + "refresh": "Actualiser", + "unavailable": "Comparaison non disponible sur ce serveur.", + "currentNote": "Les totaux couvrent cette période ; batterie, durée de fonctionnement et bruit de fond sont des valeurs actuelles.", "metrics": "Mesures de comparaison des observateurs", "metric": "Mesure", - "activity": "Paquets enregistrés · activité alignée", - "overlap": "Recoupement des paquets flood conservés", - "retention": "L’historique des paquets bruts peut expirer avant les résumés d’activité. Le recoupement compte uniquement les paquets flood conservés dans la période commune, toutes régions confondues.", - "total_one": "{{count}} paquet flood distinct", - "total_other": "{{count}} paquets flood distincts", - "empty": "Aucun paquet flood conservé n’a été signalé par ces observateurs pendant cette période.", + "activity": "Paquets reçus", + "overlap": "Reçus par les deux", + "retention": "Seuls les paquets encore conservés par le serveur sont comparés.", + "total_one": "{{count}} paquet flood", + "total_other": "{{count}} paquets flood", + "empty": "Aucun des deux observateurs n’a reçu de paquet flood pendant cette période.", "onlyA": "A seulement", "both": "Les deux", "onlyB": "B seulement", - "definition": "Chaque empreinte de paquet flood est comptée une seule fois pour les deux observateurs. Les réceptions répétées sont dédupliquées. Ce n’est pas une mesure de perte de paquets : l’expiration, les appareils hors ligne et les interruptions des courtiers affectent les rapports disponibles.", - "retainedWindow": "Les détails et les recoupements utilisent les paquets bruts conservés : une période plus ancienne que la rétention du serveur sera incomplète." + "definition": "Chaque paquet flood compte une fois. Ce n’est pas une mesure de perte de paquets.", + "retainedWindow": "Les paquets ne sont conservés que quelques jours : les périodes plus anciennes peuvent être incomplètes.", + "overlapShortRanges": "Le chevauchement des paquets est disponible pour 24 h et 7 j." }, "investigation": { "title": "Observations", - "reports_one": "{{count}} rapport conservé", - "reports_other": "{{count}} rapports conservés", - "observers_one": "{{count}} observateur déclarant", - "observers_other": "{{count}} observateurs déclarants", - "pathNote": "Regroupement par préfixes et largeur d’empreinte, pas par trajets physiques confirmés. Examinez les identités candidates de chaque rapport.", - "traceNote": "Les entrées TRACE décrivent l’itinéraire prévu, pas un trajet de réception mesuré. Examinez les mesures de trace séparément.", - "empty": "Aucun rapport conservé pour ce paquet.", + "observations_one": "{{count}} observation", + "observations_other": "{{count}} observations", + "heardBy_one": "Reçu par {{count}} observateur", + "heardBy_other": "Reçu par {{count}} observateurs", + "heardTimes_one": "Reçu une fois", + "heardTimes_other": "Reçu {{count}} fois", + "byObservers_one": "par {{count}} observateur", + "byObservers_other": "par {{count}} observateurs", + "pathNote": "Regroupé selon les répéteurs par lesquels chaque copie est passée. Un identifiant court peut correspondre à plusieurs nœuds.", + "traceNote": "Les entrées TRACE montrent l’itinéraire prévu, pas le trajet réellement suivi.", + "empty": "Aucune observation pour ce paquet.", "path": "Trajet {{number}}", "tracePath": "Itinéraire prévu {{number}}", "noEntries": "Aucune entrée de trajet", "unavailablePath": "Octets de trajet indisponibles", - "inspect": "Examiner le rapport", + "inspect": "Sélectionner l’observation", "observer": "Examiner l’observateur", - "map": "Afficher le rapport sur la carte", - "unmappable": "La carte nécessite au moins deux positions valides résolues sans ambiguïté.", - "retention": "Les rapports incluent toutes les régions reçues et suivent la déduplication paquet/observateur : toutes les réceptions ou tous les trajets ne sont donc pas conservés. Les rapports expirés sont indisponibles; les horaires signalés ne prouvent ni perte ni livraison.", - "unavailableReport": "Le rapport sélectionné est indisponible ou son identifiant est invalide. Choisissez un rapport conservé ci-dessous.", + "map": "Carte du trajet", + "unmappable": "Il faut au moins deux répéteurs dont la position est connue.", + "retention": "Une entrée par observateur. Les paquets anciens expirent selon la rétention du serveur.", + "unavailableReport": "Cette observation n’est plus disponible ou le lien est invalide. Choisissez-en une ci-dessous.", "copy": "Copier le lien", "copyPacket": "Copier le lien du paquet", - "viewAll": "Afficher le trajet sur la carte", + "viewAll": "Carte de tous les trajets", "mapDialog": "Carte du trajet du paquet", "mapTitle": "Trajet du paquet", "copyPath": "Copier le lien du trajet", "closeMap": "Fermer la carte du trajet", "allPaths": "Tous les trajets", "traceRoute": "Itinéraire de trace", - "mapNote": "Seules les positions résolues sans ambiguïté sont affichées. Les étapes inconnues ou sans position interrompent les lignes, qui ne prouvent ni liaison radio directe ni livraison.", - "unavailableMap": "Le trajet sélectionné ne peut pas être cartographié avec les données conservées. Choisissez un trajet disponible ou Tous les trajets.", - "reportedBy_one": "Signalé par {{count}} observateur", - "reportedBy_other": "Signalé par {{count}} observateurs", + "mapNote": "Les lignes relient les répéteurs dont la position est connue. Elles ne prouvent pas une liaison radio directe.", + "unavailableMap": "Ce trajet ne peut pas être cartographié. Choisissez un autre trajet ou Tous les trajets.", "first": "Premier", "last": "Dernier", - "span": "Intervalle signalé", + "span": "Durée de réception", "nodeDialog": "Détails du nœud", "packetDialog": "Analyseur de paquets", "closeObserver": "Fermer les détails de l’observateur", "loadMore": "Afficher plus ({{count}} restants)" }, "routeEvidence": { - "recent": "Paquets récents", - "loading": "Chargement des paquets…", - "missing": "Les paquets récents ne sont pas disponibles pour ce trajet sur ce serveur.", - "error": "Impossible de charger les paquets récents. Réessayez.", + "recent": "Observations récentes", + "loading": "Chargement des observations…", + "missing": "Les observations récentes ne sont pas disponibles pour ce trajet sur ce serveur.", + "error": "Impossible de charger les observations récentes. Réessayez.", "retry": "Réessayer", - "match": "Paquets reçus le long de cette séquence exacte de sauts, avec {{width}} octet(s) par saut, du plus récent au plus ancien. Les paquets TRACE et les autres tailles de préfixe sont exclus.", - "caution": "Des préfixes identiques ne prouvent pas un même trajet physique ; consultez un paquet pour voir ses nœuds possibles.", - "retention": "Seuls les paquets encore conservés par le serveur sont listés ; les plus anciens disparaissent. Le nombre d’observations ci-dessus couvre toute la vie du trajet.", - "unavailable": "Les préfixes enregistrés ne permettent pas de rechercher les paquets de ce trajet.", - "empty": "Aucun paquet de ce trajet n’est encore conservé. Les plus anciens ont pu expirer ou utiliser une autre taille de préfixe.", + "match": "Observations le long de ces sauts exacts, avec {{width}} octet(s) par saut, de la plus récente à la plus ancienne. Les paquets TRACE sont exclus.", + "caution": "Des identifiants courts identiques ne prouvent pas un même trajet physique.", + "retention": "Les paquets anciens disparaissent ; le nombre « Reçu » ci-dessus couvre toute la vie du trajet.", + "unavailable": "Ce trajet ne peut pas être comparé aux paquets.", + "empty": "Aucune observation de ce trajet n’est encore conservée. Les plus anciennes ont pu expirer.", "more": "Charger plus", - "cap": "Affichage des 500 paquets les plus récents." + "cap": "Affichage des 500 observations les plus récentes.", + "inspect": "Examiner le paquet" }, "channelMessages": { "select": "Choisir un canal", @@ -416,14 +424,14 @@ "keyMissing": "Aucune clé de déchiffrement n’est configurée pour ce canal.", "scope": "Portée", "allScopes": "Toutes les portées", - "unknownScope": "Portée non résolue", + "unknownScope": "Portée inconnue", "inspect": "Inspecter le paquet de {{sender}}", "loading": "Chargement des messages…", "loadingOlder": "Chargement des messages plus anciens… ({{count}} affichés)", "loadOlder": "Charger les messages plus anciens", - "empty": "Aucun message conservé pour ce canal.", - "emptyScope": "Aucun message conservé ne correspond à {{scope}}.", - "emptyNoKey": "Aucun message déchiffré n’est disponible sans clé de canal connue.", + "empty": "Aucun message pour ce canal.", + "emptyScope": "Aucun message ne correspond à {{scope}}.", + "emptyNoKey": "Les messages ne peuvent pas être déchiffrés sans la clé du canal.", "error": "Impossible de charger les messages.", "retry": "Réessayer", "viewPacket": "Voir le paquet" diff --git a/tests/App.analyzerObservationCarryOver.test.tsx b/tests/App.analyzerObservationCarryOver.test.tsx index 7c3639ac..eeb19aee 100644 --- a/tests/App.analyzerObservationCarryOver.test.tsx +++ b/tests/App.analyzerObservationCarryOver.test.tsx @@ -133,7 +133,7 @@ describe("opening the analyzer from an expanded row", () => { fireEvent.click(await screen.findByText("Observer Three")); const drawer = await screen.findByTestId("packet-analyzer-drawer"); - expect(within(drawer).getByRole("button", { name: "Inspect report: Observer Three" })).toHaveAttribute("aria-pressed", "true"); + expect(within(drawer).getByRole("button", { name: "Select observation: Observer Three" })).toHaveAttribute("aria-pressed", "true"); }); }); @@ -142,6 +142,6 @@ it("replaces a shared observation when another report is selected in the row", a render(); fireEvent.click(await screen.findByText("Observer Two")); const drawer = await screen.findByTestId("packet-analyzer-drawer"); - expect(within(drawer).getByRole("button", { name: "Inspect report: Observer Two" })).toHaveAttribute("aria-pressed", "true"); + expect(within(drawer).getByRole("button", { name: "Select observation: Observer Two" })).toHaveAttribute("aria-pressed", "true"); expect(new URLSearchParams(window.location.search).get("observation")).toBe("2"); }); diff --git a/tests/browser/region-input-zoom.js b/tests/browser/region-input-zoom.js index f7dce0dc..1079307a 100644 --- a/tests/browser/region-input-zoom.js +++ b/tests/browser/region-input-zoom.js @@ -2,7 +2,7 @@ // jsdom does not load Tailwind's generated CSS; inspect the rendered input instead. // Also check on iOS Safari: opening, filtering and closing must leave zoom unchanged. (function checkRegionInputZoom() { - const input = document.querySelector('header input[placeholder="Filter IATA or name…"]'); + const input = document.querySelector('header input[placeholder="Search region or area code…"]'); if (!input) throw new Error("Open the region picker first"); const fontSize = Number.parseFloat(getComputedStyle(input).fontSize); if (fontSize < 16 || !Number.isFinite(fontSize)) { diff --git a/tests/components/AppShell.test.tsx b/tests/components/AppShell.test.tsx index 5995ac93..ae2c86df 100644 --- a/tests/components/AppShell.test.tsx +++ b/tests/components/AppShell.test.tsx @@ -69,7 +69,8 @@ describe("AppShell", () => { it("region picker shows an error state when the IATA list fails to load", async () => { vi.mocked(getIatas).mockRejectedValue(new Error("boom")); renderShell(); - fireEvent.click(screen.getByRole("button", { name: /REGION/ })); + expect(screen.getByText("Region")).toHaveClass("uppercase"); + fireEvent.click(screen.getByRole("button", { name: /Region/ })); await waitFor(() => expect(screen.getByText("Failed to load")).toBeInTheDocument()); expect(screen.queryByText("Loading…")).not.toBeInTheDocument(); @@ -93,11 +94,11 @@ const REGIONS = [ // Opens the picker and returns the filter input, once both region and IATA lists have landed. // The trigger is focused first because a real browser click focuses the button; jsdom's does not. async function openPicker() { - const trigger = screen.getByRole("button", { name: /REGION/ }); + const trigger = screen.getByRole("button", { name: /Region/ }); trigger.focus(); fireEvent.click(trigger); await waitFor(() => expect(screen.getByText("Western Canada")).toBeInTheDocument()); - return screen.getByPlaceholderText(/Filter/); + return screen.getByPlaceholderText(/Search region or area code/); } describe("region picker filter", () => { @@ -111,16 +112,16 @@ describe("region picker filter", () => { renderShell(); fireEvent.click(screen.getByRole("button", { name: "Language: English" })); fireEvent.click(screen.getByRole("button", { name: "Français", exact: true })); - fireEvent.click(await screen.findByRole("button", { name: /RÉGION/ })); + fireEvent.click(await screen.findByRole("button", { name: /Région/ })); await screen.findByText("Western Canada"); - const input = screen.getByRole("textbox", { name: "Filtrer par IATA ou nom…" }); + const input = screen.getByRole("textbox", { name: "Rechercher une région ou un code de zone…" }); fireEvent.change(input, { target: { value: "toutes" } }); expect(screen.getByText("Toutes les régions")).toBeInTheDocument(); fireEvent.change(input, { target: { value: "yvr" } }); expect(screen.queryByText("Toutes les régions")).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: /Vancouver International/ })); - expect(screen.getByRole("button", { name: /RÉGION\s*YVR/ })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Région\s*YVR/ })).toBeInTheDocument(); }); it("focuses the filter input when the picker opens", async () => { @@ -135,7 +136,7 @@ describe("region picker filter", () => { expect(input).toHaveFocus(); fireEvent.keyDown(input, { key: "Escape" }); // empty query, so this closes - expect(screen.getByRole("button", { name: /REGION/ })).toHaveFocus(); + expect(screen.getByRole("button", { name: /Region/ })).toHaveFocus(); }); it("narrows the IATA list by code, ignoring surrounding whitespace", async () => { @@ -159,7 +160,7 @@ describe("region picker filter", () => { // no region name or member code contains "toronto" expect(screen.queryByText("Regions")).not.toBeInTheDocument(); // nothing above it, so the header must not draw a divider - expect(screen.getByText("IATA")).not.toHaveClass("border-t"); + expect(screen.getByText("Areas")).not.toHaveClass("border-t"); }); it("surfaces a region whose member IATA matches, tagged with the matching code", async () => { @@ -171,7 +172,7 @@ describe("region picker filter", () => { expect(screen.getByText("· YVR")).toBeInTheDocument(); expect(screen.queryByText("Eastern Canada")).not.toBeInTheDocument(); // something is above it now, so the header does draw a divider - expect(screen.getByText("IATA")).toHaveClass("border-t"); + expect(screen.getByText("Areas")).toHaveClass("border-t"); }); it("tags a code match but not a name match, for the same query", async () => { @@ -192,7 +193,7 @@ describe("region picker filter", () => { expect(screen.getByText("Western Canada")).toBeInTheDocument(); expect(screen.queryByText("· YVR")).not.toBeInTheDocument(); // nothing in the IATA group matches, so its header goes too - expect(screen.queryByText("IATA")).not.toBeInTheDocument(); + expect(screen.queryByText("Areas")).not.toBeInTheDocument(); }); it("keeps an IATA with no display name matchable by code", async () => { @@ -226,7 +227,7 @@ describe("region picker filter", () => { expect(screen.getByText("No matches")).toBeInTheDocument(); expect(screen.queryByText("Regions")).not.toBeInTheDocument(); - expect(screen.queryByText("IATA")).not.toBeInTheDocument(); + expect(screen.queryByText("Areas")).not.toBeInTheDocument(); expect(screen.queryByText("All Regions")).not.toBeInTheDocument(); }); @@ -236,10 +237,10 @@ describe("region picker filter", () => { fireEvent.change(input, { target: { value: "yvr" } }); fireEvent.keyDown(input, { key: "Escape" }); - expect(screen.getByPlaceholderText(/Filter/)).toHaveValue(""); + expect(screen.getByPlaceholderText(/Search region or area code/)).toHaveValue(""); expect(screen.getByText("All Regions")).toBeInTheDocument(); - fireEvent.keyDown(screen.getByPlaceholderText(/Filter/), { key: "Escape" }); + fireEvent.keyDown(screen.getByPlaceholderText(/Search region or area code/), { key: "Escape" }); expect(screen.queryByText("All Regions")).not.toBeInTheDocument(); }); @@ -248,10 +249,10 @@ describe("region picker filter", () => { const input = await openPicker(); fireEvent.change(input, { target: { value: "yvr" } }); - fireEvent.click(screen.getByRole("button", { name: /REGION/ })); - fireEvent.click(screen.getByRole("button", { name: /REGION/ })); + fireEvent.click(screen.getByRole("button", { name: /Region/ })); + fireEvent.click(screen.getByRole("button", { name: /Region/ })); - await waitFor(() => expect(screen.getByPlaceholderText(/Filter/)).toHaveValue("")); + await waitFor(() => expect(screen.getByPlaceholderText(/Search region or area code/)).toHaveValue("")); expect(screen.getByText("Eastern Canada")).toBeInTheDocument(); }); }); @@ -300,8 +301,8 @@ describe("AppShell region picker alignment", () => { it("opens rightward only below md so the desktop row's controls stay visible", async () => { vi.mocked(getIatas).mockResolvedValue([]); renderShell(); - fireEvent.click(screen.getByRole("button", { name: /REGION/ })); - const panel = screen.getByPlaceholderText("Filter IATA or name…").closest(".absolute")!; + fireEvent.click(screen.getByRole("button", { name: /Region/ })); + const panel = screen.getByPlaceholderText("Search region or area code…").closest(".absolute")!; expect(panel.className).toContain("left-0"); expect(panel.className).toContain("md:right-0"); }); @@ -314,7 +315,7 @@ describe("AppShell on a phone", () => { vi.spyOn(window, "matchMedia").mockImplementation((query) => ({ ...media, media: query, matches: query === "(max-width: 767px)" })); vi.mocked(getIatas).mockResolvedValue([]); renderShell(); - expect(screen.getByRole("button", { name: /REGION/ })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Region/ })).toBeInTheDocument(); expect(screen.getByRole("status", { name: "LIVE" })).toBeInTheDocument(); expect(screen.queryByRole("button", { name: /^Language:/ })).not.toBeInTheDocument(); expect(screen.queryByRole("link", { name: "GitHub" })).not.toBeInTheDocument(); diff --git a/tests/components/CopyLinkButton.test.tsx b/tests/components/CopyLinkButton.test.tsx index 5b1b012c..524fbf60 100644 --- a/tests/components/CopyLinkButton.test.tsx +++ b/tests/components/CopyLinkButton.test.tsx @@ -18,9 +18,9 @@ afterEach(() => { }); describe("CopyLinkButton", () => { - it("shows the default 'Copy Link' label", () => { + it("shows the default 'Copy link' label", () => { render(); - expect(screen.getByRole("button")).toHaveTextContent("Copy Link"); + expect(screen.getByRole("button")).toHaveTextContent("Copy link"); }); it("copies a URL carrying the given params on click", () => { @@ -42,7 +42,7 @@ describe("CopyLinkButton", () => { act(() => { vi.advanceTimersByTime(1500); }); - expect(button).toHaveTextContent("Copy Link"); + expect(button).toHaveTextContent("Copy link"); } finally { vi.useRealTimers(); } diff --git a/tests/features/channels/MessagePanel.scope.test.tsx b/tests/features/channels/MessagePanel.scope.test.tsx index b9f4ad65..d95bf885 100644 --- a/tests/features/channels/MessagePanel.scope.test.tsx +++ b/tests/features/channels/MessagePanel.scope.test.tsx @@ -56,7 +56,7 @@ describe("channel scope evidence", () => { const inspect = vi.fn(); show(); expect(await screen.findByText("#yow")).toBeInTheDocument(); - expect(screen.getByText("Unresolved scope")).toBeInTheDocument(); + expect(screen.getByText("Unknown scope")).toBeInTheDocument(); expect(screen.queryByText("No transport scope")).not.toBeInTheDocument(); expect(screen.queryByText("Scope unavailable")).not.toBeInTheDocument(); const view = screen.getAllByRole("button", { name: "Inspect packet from Fixture" })[0]!; @@ -74,7 +74,7 @@ describe("channel scope evidence", () => { show(); await screen.findByText("#yow"); await act(() => i18n.changeLanguage("fr")); - expect(screen.getByText("Portée non résolue")).toBeInTheDocument(); + expect(screen.getByText("Portée inconnue")).toBeInTheDocument(); expect(screen.getByText("#yow")).toBeInTheDocument(); expect(getChannelMessagesPage).toHaveBeenCalledTimes(1); }); @@ -84,13 +84,13 @@ describe("channel scope evidence", () => { ); const { client } = show( {}} />); fireEvent.click(await screen.findByRole("button", { name: /#test/ })); - await screen.findByText("Unresolved scope"); + await screen.findByText("Unknown scope"); fireEvent.click(screen.getByRole("button", { name: /Scope.*All scopes/ })); fireEvent.click(screen.getByRole("option", { name: "#yow", exact: true })); await waitFor(() => expect(getChannelMessagesPage).toHaveBeenLastCalledWith(1, expect.objectContaining({ scope: "#yow" })), ); - await waitFor(() => expect(screen.queryByText("Unresolved scope")).not.toBeInTheDocument()); + await waitFor(() => expect(screen.queryByText("Unknown scope")).not.toBeInTheDocument()); await waitFor(() => expect(client.getQueryData(["channel-messages", 1, "YOW", "#yow"])).toBeDefined()); const event = { ...base, @@ -127,7 +127,7 @@ describe("channel scope evidence", () => { it("explains an empty filtered history", async () => { vi.mocked(getChannelMessagesPage).mockResolvedValue(page([])); show( {}} />); - expect(await screen.findByText("No retained messages match #yow.")).toBeInTheDocument(); + expect(await screen.findByText("No messages match #yow.")).toBeInTheDocument(); }); it("keeps messages received during history loading without an extra request", async () => { let resolveHistory!: (value: ReturnType) => void; @@ -155,7 +155,7 @@ describe("channel scope evidence", () => { vi.mocked(getChannelMessagesPage).mockResolvedValue(page([])); show(); expect( - await screen.findByText("No decrypted messages are available without a known channel key."), + await screen.findByText("Messages can't be decrypted without the channel key."), ).toBeInTheDocument(); expect(screen.queryByText("No transport scope")).not.toBeInTheDocument(); }); diff --git a/tests/features/map/PacketPathMapModal.test.tsx b/tests/features/map/PacketPathMapModal.test.tsx index fe49f0d1..67c080d6 100644 --- a/tests/features/map/PacketPathMapModal.test.tsx +++ b/tests/features/map/PacketPathMapModal.test.tsx @@ -69,7 +69,7 @@ describe("PacketPathMapModal", () => { it("does not silently substitute all paths for an unavailable selected path", async () => { render( {}} initialSelectedKey="nope" />); expect(await screen.findByTestId("mini-map")).toHaveTextContent("nope"); - expect(screen.getByRole("status")).toHaveTextContent("selected path cannot be mapped"); + expect(screen.getByRole("status")).toHaveTextContent("This path can't be mapped"); fireEvent.click(screen.getByText("All paths")); expect(await screen.findByTestId("mini-map")).toHaveTextContent("all"); expect(screen.queryByRole("status")).not.toBeInTheDocument(); diff --git a/tests/features/nodes/NodeDetailPanel.test.tsx b/tests/features/nodes/NodeDetailPanel.test.tsx index 923226cb..b15ba009 100644 --- a/tests/features/nodes/NodeDetailPanel.test.tsx +++ b/tests/features/nodes/NodeDetailPanel.test.tsx @@ -193,3 +193,12 @@ describe("NodeDetailPanel 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(); + }); +}); diff --git a/tests/features/observers/ObserverComparison.test.tsx b/tests/features/observers/ObserverComparison.test.tsx index 5c42cce7..39a4ef86 100644 --- a/tests/features/observers/ObserverComparison.test.tsx +++ b/tests/features/observers/ObserverComparison.test.tsx @@ -25,9 +25,9 @@ beforeEach(() => { vi.mocked(getObserverActivity).mockResolvedValue({ ...activity, summary: { ...activity.summary!, recordedPackets: 23 } }); vi.mocked(getObserverComparison).mockResolvedValue({ observerA: A, observerB: B, since: activity.windowStart!, until, onlyA: 2, both: 3, onlyB: 4, totalPackets: 9 }); }); -function view(id = B, data = activity, anchor: number | null = until) { +function view(id = B, data = activity, anchor: number | null = until, range: "24h" | "7d" | "30d" = "7d") { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - const props = { observerA: observer, activityA: data, range: "7d" as const, observerBId: id, until: anchor, onSelect: vi.fn(), onRefresh: vi.fn(() => anchor ?? until) }; + const props = { observerA: observer, activityA: data, range, observerBId: id, until: anchor, onSelect: vi.fn(), onRefresh: vi.fn(() => anchor ?? until) }; const wrapper = ({ children }: { children: React.ReactNode }) => {children}; return { ...render(, { wrapper }), props }; } @@ -37,15 +37,15 @@ it("aligns activity and retained overlap to the primary observer's effective win expect(screen.getByText("19")).toBeInTheDocument(); expect(getObserverActivity).toHaveBeenCalledWith(B, "168h", "1h", until); expect(getObserverComparison).toHaveBeenCalledWith(undefined, { observerA: A, observerB: B, since: activity.windowStart, until }, expect.any(AbortSignal)); - expect(screen.getByText("Retained flood-packet overlap")).toBeInTheDocument(); - expect(screen.getByRole("button", { name: /Raw packet history may expire/ })).toBeInTheDocument(); + expect(screen.getByText("Heard by both")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Only packets the server still keeps/ })).toBeInTheDocument(); expect(screen.getByTestId("chart").textContent).toContain('"name":"A"'); expect(screen.getByTestId("chart").textContent).toContain('"name":"B"'); }); it("does not compare mismatched or old-server windows", async () => { vi.mocked(getObserverActivity).mockResolvedValue({ ...activity, windowEnd: until - 3_600_000 }); view(); - expect(await screen.findByText(/Aligned summaries are unavailable/)).toBeInTheDocument(); + expect(await screen.findByText(/Comparison not available on this server/)).toBeInTheDocument(); expect(getObserverComparison).not.toHaveBeenCalled(); expect(screen.queryByTestId("chart")).not.toBeInTheDocument(); }); @@ -58,7 +58,7 @@ it.each([A, "broken-id", ""]) ("never requests overlap for an invalid or missing it("rejects a bad anchor and offers a fresh common window", async () => { const { props } = view(B, activity, null); expect(await screen.findByText(/This comparison time is invalid/)).toBeInTheDocument(); - fireEvent.click(screen.getByRole("button", { name: "Refresh both observers" })); + fireEvent.click(screen.getByRole("button", { name: "Refresh" })); expect(props.onRefresh).toHaveBeenCalled(); expect(getObserverActivity).not.toHaveBeenCalled(); }); @@ -76,12 +76,23 @@ it("refreshes the current window without requesting the obsolete window after re await waitFor(() => expect(getObserverComparison).toHaveBeenCalledTimes(1)); const activityCalls = vi.mocked(getObserverActivity).mock.calls.length; props.onRefresh.mockReturnValue(until + 3_600_000); - fireEvent.click(screen.getByRole("button", { name: "Refresh both observers" })); + fireEvent.click(screen.getByRole("button", { name: "Refresh" })); expect(getObserverActivity).toHaveBeenCalledTimes(activityCalls); expect(getObserverComparison).toHaveBeenCalledTimes(1); props.onRefresh.mockReturnValue(until); - fireEvent.click(screen.getByRole("button", { name: "Refresh both observers" })); + fireEvent.click(screen.getByRole("button", { name: "Refresh" })); await waitFor(() => expect(getObserverActivity).toHaveBeenCalledTimes(activityCalls + 1)); expect(getObserverComparison).toHaveBeenCalledTimes(2); }); + +it("skips the raw-packet overlap at 30d but keeps the aligned charts", async () => { + const month: ObserverActivity = { ...activity, range: "720h", interval: "6h", windowStart: Math.floor(until / 21_600_000) * 21_600_000 - 2_592_000_000, windowEnd: Math.floor(until / 21_600_000) * 21_600_000 }; + vi.mocked(getObserverActivity).mockResolvedValue({ ...month, summary: { ...month.summary!, recordedPackets: 23 } }); + view(B, month, until, "30d"); + expect(await screen.findByText("23")).toBeInTheDocument(); + expect(screen.getByTestId("chart")).toBeInTheDocument(); + expect(screen.getByText(/Packet overlap is available for 24h and 7d/)).toBeInTheDocument(); + expect(screen.queryByText("Heard by both")).not.toBeInTheDocument(); + expect(getObserverComparison).not.toHaveBeenCalled(); +}); diff --git a/tests/features/observers/ObserverSummary.compact.test.tsx b/tests/features/observers/ObserverSummary.compact.test.tsx index f1ffc43c..1d11f7ec 100644 --- a/tests/features/observers/ObserverSummary.compact.test.tsx +++ b/tests/features/observers/ObserverSummary.compact.test.tsx @@ -43,7 +43,7 @@ describe("observer summary card notes", () => { it("labels the noise tile as the last reading and sets the unit apart from the number", () => { const points = [{ noiseFloorDb: -91 } as unknown as TelemetryPoint]; render(); - const tile = screen.getByText("Last noise floor").closest("li")!; + const tile = screen.getByText("Noise floor").closest("li")!; expect(screen.queryByText(/Latest telemetry interval|Latest status/)).not.toBeInTheDocument(); expect(within(tile).getByText("-91")).not.toHaveClass("text-sm"); expect(within(tile).getByText("dBm")).toHaveClass("text-sm"); diff --git a/tests/features/packets/PacketAnalyzerDrawer.test.tsx b/tests/features/packets/PacketAnalyzerDrawer.test.tsx index c278d9ef..50054147 100644 --- a/tests/features/packets/PacketAnalyzerDrawer.test.tsx +++ b/tests/features/packets/PacketAnalyzerDrawer.test.tsx @@ -52,12 +52,12 @@ describe("packet reception evidence", () => { const section = screen.getByRole("region", { name: "Observations" }); expect(within(section).getByText("Path 1").closest("details")).toHaveAttribute("open"); const beta = within(section).getByText(/Beta/).closest("li")!; - expect(within(beta).getByRole("button", { name: /Inspect report.*Beta/ })).toHaveAttribute("aria-pressed", "true"); + expect(within(beta).getByRole("button", { name: /Select observation.*Beta/ })).toHaveAttribute("aria-pressed", "true"); expect(within(beta).getByText("SNR")).toBeInTheDocument(); expect(screen.queryByText(/^Observations \(/)).not.toBeInTheDocument(); - for (const name of ["Inspect observer", "Map report"]) expect(within(beta).getByRole("button", { name })).toHaveClass("border", "border-border", "text-primary"); + for (const name of ["Inspect observer", "Map path"]) expect(within(beta).getByRole("button", { name })).toHaveClass("border", "border-border", "text-primary"); fireEvent.click(within(beta).getByRole("button", { name: "Inspect observer" })); expect(onViewObserver).toHaveBeenCalledWith("beta"); - fireEvent.click(within(beta).getByRole("button", { name: "Map report" })); expect(onViewPath).toHaveBeenCalledWith("beta"); + fireEvent.click(within(beta).getByRole("button", { name: "Map path" })); expect(onViewPath).toHaveBeenCalledWith("beta"); expect(screen.getByTestId("search")).toHaveTextContent("observation=2"); expect(screen.getByTestId("search")).toHaveTextContent("q=keep"); }); it("collapses the observations list unless a report was picked", () => { @@ -65,7 +65,7 @@ describe("packet reception evidence", () => { const section = screen.getByRole("region", { name: "Observations" }); const toggle = within(section).getByRole("button", { name: "Observations" }); expect(toggle).toHaveAttribute("aria-expanded", "false"); - expect(section).toHaveTextContent("2 retained reports"); + expect(section).toHaveTextContent("Heard by 2 observers"); expect(within(section).queryByText("Path 1")).not.toBeInTheDocument(); fireEvent.click(toggle); expect(toggle).toHaveAttribute("aria-expanded", "true"); @@ -74,27 +74,27 @@ describe("packet reception evidence", () => { it("keeps the evidence notes in an info tip instead of paragraphs", () => { render( {}} onViewPath={() => {}} />); const section = screen.getByRole("region", { name: "Observations" }); - expect(within(section).getByRole("button", { name: /Grouped by path prefixes.*Reports include all received regions/ })).toBeInTheDocument(); - expect(within(section).queryByText(/Grouped by path prefixes/, { selector: "p" })).not.toBeInTheDocument(); - expect(within(section).queryByText(/Reports include all received regions/, { selector: "p" })).not.toBeInTheDocument(); - expect(within(section).queryByText(/Mapping needs at least two/, { selector: "p" })).not.toBeInTheDocument(); + expect(within(section).getByRole("button", { name: /Grouped by the repeaters.*One entry per observer/ })).toBeInTheDocument(); + expect(within(section).queryByText(/Grouped by the repeaters/, { selector: "p" })).not.toBeInTheDocument(); + expect(within(section).queryByText(/One entry per observer/, { selector: "p" })).not.toBeInTheDocument(); + expect(within(section).queryByText(/Needs at least two repeaters/, { selector: "p" })).not.toBeInTheDocument(); }); it("shows the selected report only in the observations list, above the raw packet", () => { render( {}} />); const list = screen.getByRole("region", { name: "Observations" }); expect(screen.queryByText("Observation", { exact: true })).not.toBeInTheDocument(); - expect(within(list).getByRole("button", { name: /Inspect report.*Alpha/ })).toHaveAttribute("aria-pressed", "true"); + expect(within(list).getByRole("button", { name: /Select observation.*Alpha/ })).toHaveAttribute("aria-pressed", "true"); const raw = screen.getByText("Raw Packet"); expect(list.compareDocumentPosition(raw) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); }); it("reports an expired selection without quietly showing another report", () => { render( {}} />); - expect(screen.getByRole("alert")).toHaveTextContent("Selected report is unavailable"); + expect(screen.getByRole("alert")).toHaveTextContent("That observation is no longer available"); expect(screen.queryByText("Raw Packet")).not.toBeInTheDocument(); }); it("keeps route evidence selections strict without requiring packet URL state", () => { render( {}} />); - expect(screen.getByRole("alert")).toHaveTextContent("Selected report is unavailable"); + expect(screen.getByRole("alert")).toHaveTextContent("That observation is no longer available"); expect(screen.queryByText("Raw Packet")).not.toBeInTheDocument(); }); it("explains TRACE and missing-report semantics in French", async () => { @@ -102,7 +102,7 @@ describe("packet reception evidence", () => { await i18n.changeLanguage("fr"); render( {}} />); expect(screen.getByRole("region", { name: "Observations" })).toHaveTextContent("itinéraire prévu"); - expect(screen.getByText("Aucun rapport conservé pour ce paquet.")).toBeInTheDocument(); + expect(screen.getByText("Aucune observation pour ce paquet.")).toBeInTheDocument(); }); it("keeps a later selected path visible while bounding the initial list", () => { const d = reports(); @@ -191,7 +191,7 @@ describe("PacketAnalyzerDrawer view-path button", () => { {}} onViewPath={onViewPath} /> , ); - const btn = screen.getByRole("button", { name: /view path on map/i }); + const btn = screen.getByRole("button", { name: /map all paths/i }); expect(btn).toBeEnabled(); fireEvent.click(btn); expect(onViewPath).toHaveBeenCalledOnce(); @@ -203,7 +203,7 @@ describe("PacketAnalyzerDrawer view-path button", () => { {}} onViewPath={() => {}} /> , ); - expect(screen.getByRole("button", { name: /view path on map/i })).toBeDisabled(); + expect(screen.getByRole("button", { name: /map all paths/i })).toBeDisabled(); }); }); diff --git a/tests/features/packets/PacketExpansion.mobile.test.tsx b/tests/features/packets/PacketExpansion.mobile.test.tsx index b3bbdff3..02fda46c 100644 --- a/tests/features/packets/PacketExpansion.mobile.test.tsx +++ b/tests/features/packets/PacketExpansion.mobile.test.tsx @@ -125,7 +125,7 @@ describe("PacketExpansion below md", () => { usePacketDetail.mockReturnValue({ data: detail([obs(1)]) }); render(); - const button = screen.getByRole("button", { name: "View path on map" }); + const button = screen.getByRole("button", { name: "Map all paths" }); expect(tokens(button)).toEqual( expect.arrayContaining(["w-full", "md:w-auto", "py-2", "md:py-0.5", "text-[11px]", "md:text-[10px]"]), ); diff --git a/tests/features/packets/PacketExpansion.test.tsx b/tests/features/packets/PacketExpansion.test.tsx index f9361812..ef9d8b9e 100644 --- a/tests/features/packets/PacketExpansion.test.tsx +++ b/tests/features/packets/PacketExpansion.test.tsx @@ -21,7 +21,7 @@ const obs = (id: number, over: Partial = {}): Observation => ({ sourceBroker: "b1", resolvedPath: [], ...over, }); -// minimal header so buildPacketPaths(data) (View path on map's hasPath check) doesn't crash on a +// minimal header so buildPacketPaths(data) (Map all paths's hasPath check) doesn't crash on a // partial detail fixture — any non-TRACE payload type does const header = () => ({ raw: "12", routeType: RouteType.FLOOD, routeTypeName: "FLOOD", payloadType: PayloadType.ADVERT, payloadTypeName: "ADVERT", payloadVersion: 1 }); @@ -158,28 +158,28 @@ describe("PacketExpansion", () => { expect(onOpenAnalyzer).toHaveBeenCalledTimes(1); }); - it("disables View path on map while loading", () => { + it("disables Map all paths while loading", () => { usePacketDetail.mockReturnValue({ isLoading: true }); render(); - expect(screen.getByRole("button", { name: "View path on map" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Map all paths" })).toBeDisabled(); }); - it("disables View path on map on error", () => { + it("disables Map all paths on error", () => { usePacketDetail.mockReturnValue({ isError: true, refetch: vi.fn() }); render(); - expect(screen.getByRole("button", { name: "View path on map" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Map all paths" })).toBeDisabled(); }); - it("enables View path on map once the fetch resolves with a drawable path", () => { + it("enables Map all paths once the fetch resolves with a drawable path", () => { usePacketDetail.mockReturnValue({ data: detailWithPath() }); render(); - expect(screen.getByRole("button", { name: "View path on map" })).not.toBeDisabled(); + expect(screen.getByRole("button", { name: "Map all paths" })).not.toBeDisabled(); }); - it("disables View path on map when the loaded detail has no resolvable path", () => { + it("disables Map all paths when the loaded detail has no resolvable path", () => { usePacketDetail.mockReturnValue({ data: { packetHash: "AA11", header: header(), observations: [obs(1)] } }); render(); - const viewPathBtn = screen.getByRole("button", { name: "View path on map" }); + const viewPathBtn = screen.getByRole("button", { name: "Map all paths" }); expect(viewPathBtn).toBeDisabled(); expect(viewPathBtn).toHaveAttribute("title", "No resolved path to map"); }); @@ -188,7 +188,7 @@ describe("PacketExpansion", () => { const onViewPath = vi.fn(); usePacketDetail.mockReturnValue({ data: detailWithPath() }); render(); - fireEvent.click(screen.getByRole("button", { name: "View path on map" })); + fireEvent.click(screen.getByRole("button", { name: "Map all paths" })); expect(onViewPath).toHaveBeenCalledTimes(1); }); }); diff --git a/tests/features/packets/PacketList.test.tsx b/tests/features/packets/PacketList.test.tsx index eeb3b518..c7f5c11c 100644 --- a/tests/features/packets/PacketList.test.tsx +++ b/tests/features/packets/PacketList.test.tsx @@ -68,7 +68,7 @@ vi.mock("../../../src/features/packets/PacketVirtualList", () => ({
{String(expandedHash)}
- + {packets.map((p) => (
IATA{t("traffic.receptionsColumn")}{t("traffic.share")}
{t("traffic.area")}{t("traffic.receptionsColumn")}{t("traffic.share")}
{trafficAreaLabel(area.name, t)} {area.total.toLocaleString()}{(model.total ? 100 * area.total / model.total : 0).toFixed(1)}%