- ) : crossRoutes && crossRoutes.length > 0 ? (
- crossRoutes.map((r, i) =>
+
String(r.id)}
selectedKey={pathKey ? String(rows?.find(row => row.pathKey === pathKey && row.iata === routeIata)?.id ?? "") : selectedKey}
onSelect={selectRoute}
- isLoading={search ? searchLoading : listLoading || regionPending}
- emptyLabel={t(search ? "routes.noMatches" : "routes.empty")}
+ isLoading={listLoading || regionPending}
+ emptyLabel={t("routes.empty")}
defaultSort={{ id: "lastSeen", direction: "desc" }}
- onEndReached={search ? undefined : loadMore}
+ onEndReached={loadMore}
renderCard={renderRouteCard}
/>
- {!search && (
-
- )}
+
)}
{pathKey && routeIata ? (
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index f864827..0db96f8 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -729,10 +729,17 @@
"search": "Search",
"clear": "Clear",
"searching": "Searching…",
- "noCross": "No routes between these areas",
+ "badHash": "Hashes must be 2, 4, 6 or 8 hex characters",
+ "tooManyAreas": "Pick at most 100 areas",
+ "timeout": "Route search timed out — narrow the areas or use a longer hash.",
+ "searchFailed": "Route search failed",
"noMatches": "No matching routes",
"empty": "No routes"
},
+ "versionGate": {
+ "message": "This Beacon server needs Beacon Web {{min}} or newer (this is {{current}}). Reload the page; if this keeps appearing, the web container needs updating.",
+ "reload": "Reload"
+ },
"routeDetail": {
"title": "Route Detail",
"summary": "Summary",
diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json
index 03ee2a6..0503e69 100644
--- a/src/i18n/locales/fr.json
+++ b/src/i18n/locales/fr.json
@@ -747,10 +747,17 @@
"search": "Rechercher",
"clear": "Effacer",
"searching": "Recherche…",
- "noCross": "Aucun trajet entre ces zones",
+ "badHash": "Les hash doivent faire 2, 4, 6 ou 8 caractères hexadécimaux",
+ "tooManyAreas": "Choisissez au plus 100 zones",
+ "timeout": "La recherche de trajet a expiré — réduisez les zones ou utilisez un hash plus long.",
+ "searchFailed": "La recherche de trajet a échoué",
"noMatches": "Aucun trajet correspondant",
"empty": "Aucun trajet"
},
+ "versionGate": {
+ "message": "Ce serveur Beacon nécessite Beacon Web {{min}} ou plus récent (version actuelle : {{current}}). Rechargez la page ; si ce message persiste, le conteneur web doit être mis à jour.",
+ "reload": "Recharger"
+ },
"routeDetail": {
"title": "Détail du trajet",
"summary": "Résumé",
diff --git a/src/i18n/locales/sv.json b/src/i18n/locales/sv.json
index 86178e4..197255b 100644
--- a/src/i18n/locales/sv.json
+++ b/src/i18n/locales/sv.json
@@ -729,10 +729,17 @@
"search": "Sök",
"clear": "Rensa",
"searching": "Söker…",
- "noCross": "Inga rutter mellan dessa områden",
+ "badHash": "Hashar måste vara 2, 4, 6 eller 8 hextecken",
+ "tooManyAreas": "Välj högst 100 områden",
+ "timeout": "Ruttsökningen tog för lång tid — begränsa områdena eller använd en längre hash.",
+ "searchFailed": "Ruttsökningen misslyckades",
"noMatches": "Inga matchande rutter",
"empty": "Inga rutter"
},
+ "versionGate": {
+ "message": "Den här Beacon-servern kräver Beacon Web {{min}} eller senare (detta är {{current}}). Ladda om sidan; om detta fortsätter att visas behöver webbcontainern uppdateras.",
+ "reload": "Ladda om"
+ },
"routeDetail": {
"title": "Ruttdetaljer",
"summary": "Sammanfattning",
diff --git a/src/lib/version.ts b/src/lib/version.ts
new file mode 100644
index 0000000..2989214
--- /dev/null
+++ b/src/lib/version.ts
@@ -0,0 +1,13 @@
+const VERSION_RE = /^(\d+)\.(\d+)\.(\d+)$/;
+
+// True when current is strictly older than min. Anything not plain X.Y.Z compares as "not below",
+// so a bad value from the server can never lock users out.
+export function isBelowVersion(current: string, min: string | null | undefined): boolean {
+ const a = VERSION_RE.exec(current), b = min ? VERSION_RE.exec(min) : null;
+ if (!a || !b) return false;
+ for (let i = 1; i <= 3; i++) {
+ const diff = Number(a[i]) - Number(b[i]);
+ if (diff !== 0) return diff < 0;
+ }
+ return false;
+}
diff --git a/tests/App.analyzerObservationCarryOver.test.tsx b/tests/App.analyzerObservationCarryOver.test.tsx
index 01e675c..7ec40d1 100644
--- a/tests/App.analyzerObservationCarryOver.test.tsx
+++ b/tests/App.analyzerObservationCarryOver.test.tsx
@@ -24,6 +24,7 @@ vi.mock("../src/api/ws-manager", () => {
return { WsManager };
});
+vi.mock("../src/components/VersionGate", () => ({ VersionGate: () => null }));
vi.mock("../src/api/client", () => ({
getRegions: async () => [],
getRegion: async () => ({ id: 0, slug: "", displayName: "", iatas: [] }),
diff --git a/tests/App.observerInvestigation.test.tsx b/tests/App.observerInvestigation.test.tsx
index 7ac2376..d7b213d 100644
--- a/tests/App.observerInvestigation.test.tsx
+++ b/tests/App.observerInvestigation.test.tsx
@@ -11,6 +11,7 @@ vi.mock("../src/api/ws-manager", () => ({ WsManager: class {
} }));
vi.mock("../src/api/client", () => ({ getRegions: async () => [], getRegion: async () => ({ iatas: [] }), getBrokers: async () => [], getScopes: async () => [] }));
vi.mock("../src/components/SplashScreen", () => ({ SplashScreen: () => null }));
+vi.mock("../src/components/VersionGate", () => ({ VersionGate: () => null }));
vi.mock("../src/components/AppShell", () => ({ AppShell: ({ children, onTabChange }: { children: ReactNode; onTabChange: (tab: string) => void }) => {
const { setSelection } = useRegionSelection();
return <>
{children}>;
diff --git a/tests/App.packetUrlContract.test.tsx b/tests/App.packetUrlContract.test.tsx
index 3583cc7..074126a 100644
--- a/tests/App.packetUrlContract.test.tsx
+++ b/tests/App.packetUrlContract.test.tsx
@@ -24,6 +24,7 @@ vi.mock("../src/api/ws-manager", () => {
return { WsManager };
});
+vi.mock("../src/components/VersionGate", () => ({ VersionGate: () => null }));
vi.mock("../src/api/client", () => ({
getRegions: async () => [],
getRegion: async () => ({ id: 0, slug: "", displayName: "", iatas: [] }),
diff --git a/tests/App.quickObserver.test.tsx b/tests/App.quickObserver.test.tsx
index cfcd911..486db57 100644
--- a/tests/App.quickObserver.test.tsx
+++ b/tests/App.quickObserver.test.tsx
@@ -7,6 +7,7 @@ import { ModalOverlay } from "../src/components/ModalOverlay";
vi.mock("../src/api/ws-manager", () => ({ WsManager: class { connect() {} disconnect() {} updateSubscription() {} } }));
vi.mock("../src/api/client", () => ({ getRegions: async () => [], getRegion: async () => ({ iatas: [] }) }));
vi.mock("../src/components/SplashScreen", () => ({ SplashScreen: () => null }));
+vi.mock("../src/components/VersionGate", () => ({ VersionGate: () => null }));
vi.mock("../src/components/AppShell", () => ({ AppShell: ({ children }: { children: ReactNode }) => <>{children}> }));
vi.mock("../src/features/nodes/NodeTable", () => ({ NodeTable: () => null }));
vi.mock("../src/features/nodes/NodeDetailPanel", () => ({ NodeDetailPanel: ({ onViewObserver, onAnalyzePacket }: { onViewObserver: (id: string) => void; onAnalyzePacket: (hash: string) => void }) => <>
> }));
diff --git a/tests/App.viewOnMap.test.tsx b/tests/App.viewOnMap.test.tsx
index 12e90f3..0ed374a 100644
--- a/tests/App.viewOnMap.test.tsx
+++ b/tests/App.viewOnMap.test.tsx
@@ -16,6 +16,7 @@ vi.mock("../src/api/ws-manager", () => ({
}));
vi.mock("../src/api/client", () => ({ getRegions: async () => [], getRegion: async () => ({ iatas: [] }), getScopes: async () => [] }));
vi.mock("../src/components/SplashScreen", () => ({ SplashScreen: () => null }));
+vi.mock("../src/components/VersionGate", () => ({ VersionGate: () => null }));
vi.mock("../src/components/AppShell", () => ({
AppShell: ({ children, onTabChange }: { children: ReactNode; onTabChange: (tab: string) => void }) => (
<>
diff --git a/tests/App.viewOnMapDisabled.test.tsx b/tests/App.viewOnMapDisabled.test.tsx
index 6a4450a..48ca723 100644
--- a/tests/App.viewOnMapDisabled.test.tsx
+++ b/tests/App.viewOnMapDisabled.test.tsx
@@ -20,6 +20,7 @@ vi.mock("../src/lib/constants", async (importOriginal) => {
return { ...actual, ENABLED_TABS: actual.ENABLED_TABS.filter((tab) => tab !== "Map") };
});
vi.mock("../src/components/SplashScreen", () => ({ SplashScreen: () => null }));
+vi.mock("../src/components/VersionGate", () => ({ VersionGate: () => null }));
vi.mock("../src/components/AppShell", () => ({
AppShell: ({ children, onTabChange }: { children: ReactNode; onTabChange: (tab: string) => void }) => (
<>
diff --git a/tests/api/client.test.ts b/tests/api/client.test.ts
index fac5983..df55de4 100644
--- a/tests/api/client.test.ts
+++ b/tests/api/client.test.ts
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
-import { getPackets, getNodesPage, getObserversPage, getScopes, getKnownRoutesPage, searchKnownRoutes, getChannels, getChannelMessagesPage, getTraces, getTraceDetail, getStatsSeries, getTopObservers, getTopAdvertisers, getTopTalkers, getStatsNodeTypes, getClockDrift, getIataBorder, getObserverActivity, isNotFound } from "../../src/api/client";
+import { getPackets, getNodesPage, getObserversPage, getScopes, getKnownRoutesPage, searchKnownRoutes, searchCrossIATARoutes, getChannels, getChannelMessagesPage, getTraces, getTraceDetail, getStatsSeries, getTopObservers, getTopAdvertisers, getTopTalkers, getStatsNodeTypes, getClockDrift, getIataBorder, getObserverActivity, isNotFound } from "../../src/api/client";
import type { Feature, Polygon } from "geojson";
import type { NodeSummary } from "../../src/features/nodes/types";
import type { ObserverSummary } from "../../src/features/observers/types";
@@ -198,17 +198,25 @@ describe("getKnownRoutesPage", () => {
});
});
-describe("searchKnownRoutes", () => {
- it("hits /routes/search with the required iata/from/to", async () => {
+describe("route search", () => {
+ it("sends the IATA list to /routes/search", async () => {
const getUrl = mockFetchOnce([]);
+ await searchKnownRoutes(["YYC", "YVR"], "6d", "be");
+ const url = new URL(getUrl());
+ expect(url.pathname).toContain("/routes/search");
+ expect(url.searchParams.get("iatas")).toBe("YYC,YVR");
+ expect(url.searchParams.get("from")).toBe("6d");
+ expect(url.searchParams.get("to")).toBe("be");
+ });
- await searchKnownRoutes("YYC", "6d", "be");
-
- const url = getUrl();
- expect(url).toContain("/routes/search");
- expect(url).toContain("iata=YYC");
- expect(url).toContain("from=6d");
- expect(url).toContain("to=be");
+ it("omits iatas for a global /routes/cross search", async () => {
+ const getUrl = mockFetchOnce([]);
+ await searchCrossIATARoutes(undefined, "6d", "be");
+ const url = new URL(getUrl());
+ expect(url.pathname).toContain("/routes/cross");
+ expect(url.searchParams.has("iatas")).toBe(false);
+ expect(url.searchParams.get("fromHash")).toBe("6d");
+ expect(url.searchParams.get("toHash")).toBe("be");
});
});
diff --git a/tests/components/VersionGate.test.tsx b/tests/components/VersionGate.test.tsx
new file mode 100644
index 0000000..b607af3
--- /dev/null
+++ b/tests/components/VersionGate.test.tsx
@@ -0,0 +1,85 @@
+import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
+import { render, screen, waitFor, act, fireEvent } from "@testing-library/react";
+import { QueryClient, QueryClientProvider, focusManager } from "@tanstack/react-query";
+import { VersionGate } from "../../src/components/VersionGate";
+import { getServerInfo } from "../../src/api/client";
+
+vi.mock("../../src/api/client", () => ({ getServerInfo: vi.fn() }));
+const mockGetServerInfo = vi.mocked(getServerInfo);
+
+const info = (minWebVersion: string | null) => ({ minAppVersion: null, minWebVersion, serverVersion: "2.0.4" });
+
+function renderGate(currentVersion = "2.0.2") {
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ render(
);
+}
+
+const blocked = () => screen.queryByRole("alertdialog");
+
+beforeEach(() => {
+ mockGetServerInfo.mockReset();
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+ focusManager.setFocused(undefined);
+});
+
+describe("VersionGate", () => {
+ it("blocks a web version older than the server's minimum", async () => {
+ mockGetServerInfo.mockResolvedValue(info("2.0.3"));
+ const reload = vi.fn();
+ vi.stubGlobal("location", { ...window.location, reload });
+ renderGate("2.0.2");
+
+ expect(await screen.findByRole("alertdialog")).toHaveTextContent(/needs Beacon Web 2\.0\.3 or newer \(this is 2\.0\.2\)/);
+ fireEvent.click(screen.getByRole("button", { name: "Reload" }));
+ expect(reload).toHaveBeenCalled();
+ vi.unstubAllGlobals();
+ });
+
+ it.each([
+ ["an equal version", info("2.0.3")],
+ ["no minimum", info(null)],
+ ["a malformed minimum", info("2.0")],
+ ])("lets the app through with %s", async (_label, value) => {
+ mockGetServerInfo.mockResolvedValue(value);
+ renderGate("2.0.3");
+ await waitFor(() => expect(mockGetServerInfo).toHaveBeenCalled());
+ await act(async () => {});
+ expect(blocked()).toBeNull();
+ });
+
+ it("fails open when the server has no /info", async () => {
+ mockGetServerInfo.mockRejectedValue(Object.assign(new Error("not found"), { status: 404 }));
+ renderGate("2.0.2");
+ await waitFor(() => expect(mockGetServerInfo).toHaveBeenCalled());
+ await act(async () => {});
+ expect(blocked()).toBeNull();
+ });
+
+ it("fails open on a network error", async () => {
+ mockGetServerInfo.mockRejectedValue(new TypeError("Failed to fetch"));
+ renderGate("2.0.2");
+ await waitFor(() => expect(mockGetServerInfo).toHaveBeenCalled());
+ await act(async () => {});
+ expect(blocked()).toBeNull();
+ });
+
+ it("re-checks when the tab regains focus, at most once a minute", async () => {
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+ mockGetServerInfo.mockResolvedValue(info(null));
+ renderGate("2.0.2");
+ await waitFor(() => expect(mockGetServerInfo).toHaveBeenCalledTimes(1));
+
+ act(() => { focusManager.setFocused(false); focusManager.setFocused(true); });
+ await act(async () => {});
+ expect(mockGetServerInfo).toHaveBeenCalledTimes(1);
+
+ mockGetServerInfo.mockResolvedValue(info("2.0.3"));
+ await act(async () => { vi.advanceTimersByTime(61_000); });
+ act(() => { focusManager.setFocused(false); focusManager.setFocused(true); });
+ await waitFor(() => expect(mockGetServerInfo).toHaveBeenCalledTimes(2));
+ expect(await screen.findByRole("alertdialog")).toBeInTheDocument();
+ });
+});
diff --git a/tests/features/routes/RouteTable.test.tsx b/tests/features/routes/RouteTable.test.tsx
index 60bb649..7e3dbe1 100644
--- a/tests/features/routes/RouteTable.test.tsx
+++ b/tests/features/routes/RouteTable.test.tsx
@@ -36,8 +36,7 @@ const mockGetRegions = vi.mocked(getRegions);
const node = (id: string, name: string) => ({ id, name, publicKey: "deadbeef" });
-function renderTable(selection = ALL_REGIONS) {
- const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+function renderTable(selection = ALL_REGIONS, client = new QueryClient({ defaultOptions: { queries: { retry: false } } })) {
const wrapper = ({ children }: { children: ReactNode }) => (
{children}
@@ -62,51 +61,173 @@ beforeEach(() => {
mockGetIatas.mockResolvedValue([
{ iata: "AAA", displayName: "Alpha" },
{ iata: "BBB", displayName: "Beta" },
+ { iata: "CCC", displayName: "Gamma" },
]);
});
describe("RouteTable search", () => {
- it("searches within a single IATA when exactly one IATA is selected", async () => {
+ const fillHashes = (from = "aa11", to = "bb22") => {
+ fireEvent.change(screen.getByPlaceholderText("from hash"), { target: { value: from } });
+ fireEvent.change(screen.getByPlaceholderText("to hash"), { target: { value: to } });
+ };
+ const known = (id: number, hopCount: number, lastSeen: number, iata = "AAA"): KnownRoute =>
+ ({ id, iata, hopCount, hops: [], firstSeen: 1, lastSeen, observationCount: 40 + id });
+ const crossRoute = (totalHops: number, lastSeen: number): CrossIATARoute => ({
+ sourceSegment: [{ nodeId: "n1", hashBytes: "aa11", node: node("n1", "Src Node") }],
+ crossHop: { fromNode: node("n1", "Src Node"), toNode: node("n2", "Dst Node"), fromIata: "AAA", toIata: "BBB", lastSeen },
+ targetSegment: [{ nodeId: "n2", hashBytes: "bb22", node: node("n2", "Dst Node") }],
+ totalHops,
+ });
+ const apiError = (status: number, message: string) => Object.assign(new Error(message), { status, code: "x" });
+
+ it("searches only within the area when exactly one IATA is picked", async () => {
renderTable();
await screen.findByText("Find path");
-
- fireEvent.change(screen.getByPlaceholderText("from hash"), { target: { value: "aa11" } });
- fireEvent.change(screen.getByPlaceholderText("to hash"), { target: { value: "bb22" } });
+ fillHashes("AA11", "bb22");
openIataPicker();
checkIata("AAA");
fireEvent.click(screen.getByText("Search"));
- await waitFor(() => expect(mockSearchKnownRoutes).toHaveBeenCalledWith("AAA", "aa11", "bb22"));
+ await waitFor(() => expect(mockSearchKnownRoutes).toHaveBeenCalledWith(["AAA"], "aa11", "bb22", expect.any(AbortSignal)));
expect(mockSearchCrossIATARoutes).not.toHaveBeenCalled();
});
- it("searches cross-IATA when two IATAs are selected, rendering the segmented chain", async () => {
- const cross: CrossIATARoute = {
- sourceSegment: [{ nodeId: "n1", hashBytes: "aa11", node: node("n1", "Src Node") }],
- crossHop: { fromNode: node("n1", "Src Node"), toNode: node("n2", "Dst Node"), fromIata: "AAA", toIata: "BBB", lastSeen: 2 },
- targetSegment: [{ nodeId: "n2", hashBytes: "bb22", node: node("n2", "Dst Node") }],
- totalHops: 3,
- };
- mockSearchCrossIATARoutes.mockImplementation((_f, fromIata, _t, toIata) =>
- Promise.resolve(fromIata === "AAA" && toIata === "BBB" ? [cross] : []),
- );
-
+ it("makes one call per endpoint for a multi-IATA pick, with the sorted list", async () => {
renderTable();
await screen.findByText("Find path");
-
- fireEvent.change(screen.getByPlaceholderText("from hash"), { target: { value: "aa11" } });
- fireEvent.change(screen.getByPlaceholderText("to hash"), { target: { value: "bb22" } });
+ fillHashes();
openIataPicker();
- checkIata("AAA");
checkIata("BBB");
+ checkIata("AAA");
fireEvent.click(screen.getByText("Search"));
- await waitFor(() =>
- expect(mockSearchCrossIATARoutes).toHaveBeenCalledWith("aa11", "AAA", "bb22", "BBB"),
- );
+ await waitFor(() => expect(mockSearchCrossIATARoutes).toHaveBeenCalledWith(["AAA", "BBB"], "aa11", "bb22", expect.any(AbortSignal)));
+ expect(mockSearchCrossIATARoutes).toHaveBeenCalledTimes(1);
+ expect(mockSearchKnownRoutes).toHaveBeenCalledTimes(1);
+ expect(mockSearchKnownRoutes).toHaveBeenCalledWith(["AAA", "BBB"], "aa11", "bb22", expect.any(AbortSignal));
+ });
+
+ it("falls back to the region's IATAs when nothing is picked", async () => {
+ renderTable({ regions: [], iatas: ["BBB", "AAA"] });
+ await screen.findByText("Find path");
+ fillHashes();
+ fireEvent.click(screen.getByText("Search"));
+
+ await waitFor(() => expect(mockSearchKnownRoutes).toHaveBeenCalledWith(["AAA", "BBB"], "aa11", "bb22", expect.any(AbortSignal)));
+ expect(mockSearchCrossIATARoutes).toHaveBeenCalledWith(["AAA", "BBB"], "aa11", "bb22", expect.any(AbortSignal));
+ });
+
+ it("searches globally with no region and nothing picked", async () => {
+ renderTable();
+ await screen.findByText("Find path");
+ fillHashes();
+ fireEvent.click(screen.getByText("Search"));
+
+ await waitFor(() => expect(mockSearchKnownRoutes).toHaveBeenCalledWith(undefined, "aa11", "bb22", expect.any(AbortSignal)));
+ expect(mockSearchCrossIATARoutes).toHaveBeenCalledWith(undefined, "aa11", "bb22", expect.any(AbortSignal));
+ });
+
+ it("treats every area picked as global", async () => {
+ renderTable();
+ await screen.findByText("Find path");
+ fillHashes();
+ openIataPicker();
+ fireEvent.click(screen.getByText("All"));
+ fireEvent.click(screen.getByText("Search"));
+
+ await waitFor(() => expect(mockSearchKnownRoutes).toHaveBeenCalledWith(undefined, "aa11", "bb22", expect.any(AbortSignal)));
+ });
+
+ it("blocks a search over more than 100 areas", async () => {
+ const codes = Array.from({ length: 102 }, (_, i) => `X${String(i).padStart(3, "0")}`);
+ mockGetIatas.mockResolvedValue(codes.map((iata) => ({ iata })));
+ renderTable();
+ await screen.findByText("Find path");
+ fillHashes();
+ openIataPicker();
+ fireEvent.click(screen.getByText("All"));
+ checkIata(codes[0]!);
+
+ expect(screen.getByText("Pick at most 100 areas")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Search" })).toBeDisabled();
+ });
+
+ it("rejects a hash that isn't 1–4 bytes of hex", async () => {
+ renderTable();
+ await screen.findByText("Find path");
+ fillHashes("abc", "bb22");
+ fireEvent.click(screen.getByText("Search"));
+
+ expect(await screen.findByText("Hashes must be 2, 4, 6 or 8 hex characters")).toBeInTheDocument();
expect(mockSearchKnownRoutes).not.toHaveBeenCalled();
- expect(await screen.findByText("Src Node")).toBeInTheDocument();
- expect(screen.getByText("Dst Node")).toBeInTheDocument();
+ });
+
+ it("merges both result sets by hop count, then most recently seen", async () => {
+ mockSearchKnownRoutes.mockResolvedValue([known(1, 2, 10), known(2, 4, 50)]);
+ mockSearchCrossIATARoutes.mockResolvedValue([crossRoute(2, 30)]);
+ renderTable();
+ await screen.findByText("Find path");
+ fillHashes();
+ fireEvent.click(screen.getByText("Search"));
+
+ const crossLabel = await screen.findByText("AAA → BBB");
+ const rowsText = screen.getAllByRole("row").map((r) => r.textContent ?? "");
+ const order = ["AAA → BBB", "41", "42"].map((needle) => rowsText.findIndex((t) => t.includes(needle)));
+ expect(order).toEqual([...order].sort((a, b) => a - b));
+ expect(order[0]).toBeGreaterThan(-1);
+ const crossRow = crossLabel.closest("tr")!;
+ expect(crossRow.textContent).toContain("AA11");
+ expect(crossRow.textContent).toContain("BB22");
+ });
+
+ it("shows the server's 400 message as-is and never 'no matches'", async () => {
+ const msg = "hash matches too many areas; use a longer hash or fewer iatas";
+ mockSearchKnownRoutes.mockRejectedValue(apiError(400, msg));
+ mockSearchCrossIATARoutes.mockRejectedValue(apiError(400, msg));
+ renderTable();
+ await screen.findByText("Find path");
+ fillHashes("aa", "bb");
+ fireEvent.click(screen.getByText("Search"));
+
+ expect(await screen.findByText(msg)).toBeInTheDocument();
+ expect(screen.queryByText("No matching routes")).not.toBeInTheDocument();
+ });
+
+ it("shows the timeout message on a 503", async () => {
+ mockSearchKnownRoutes.mockRejectedValue(apiError(503, "timeout"));
+ renderTable();
+ await screen.findByText("Find path");
+ fillHashes();
+ openIataPicker();
+ checkIata("AAA");
+ fireEvent.click(screen.getByText("Search"));
+
+ expect(await screen.findByText(/Route search timed out/)).toBeInTheDocument();
+ });
+
+ it("never retries a failed search, even when the app retries other queries", async () => {
+ mockSearchKnownRoutes.mockRejectedValue(apiError(503, "timeout"));
+ mockSearchCrossIATARoutes.mockRejectedValue(apiError(503, "timeout"));
+ renderTable(ALL_REGIONS, new QueryClient({ defaultOptions: { queries: { retry: 2, retryDelay: 0 } } }));
+ await screen.findByText("Find path");
+ fillHashes();
+ fireEvent.click(screen.getByText("Search"));
+
+ expect(await screen.findByText(/Route search timed out/)).toBeInTheDocument();
+ expect(mockSearchKnownRoutes).toHaveBeenCalledTimes(1);
+ expect(mockSearchCrossIATARoutes).toHaveBeenCalledTimes(1);
+ });
+
+ it("keeps the results that loaded when the other call fails", async () => {
+ mockSearchKnownRoutes.mockResolvedValue([known(1, 2, 10)]);
+ mockSearchCrossIATARoutes.mockRejectedValue(new Error("network"));
+ renderTable();
+ await screen.findByText("Find path");
+ fillHashes();
+ fireEvent.click(screen.getByText("Search"));
+
+ expect(await screen.findByText("Route search failed")).toBeInTheDocument();
+ expect(screen.getByText("41")).toBeInTheDocument();
});
it("keeps paging for a multi-IATA region until its routes surface", async () => {
diff --git a/tests/lib/version.test.ts b/tests/lib/version.test.ts
new file mode 100644
index 0000000..b6a040e
--- /dev/null
+++ b/tests/lib/version.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, it } from "vitest";
+import { isBelowVersion } from "../../src/lib/version";
+
+describe("isBelowVersion", () => {
+ it("compares X.Y.Z numerically", () => {
+ expect(isBelowVersion("2.0.2", "2.0.3")).toBe(true);
+ expect(isBelowVersion("2.0.9", "2.0.10")).toBe(true);
+ expect(isBelowVersion("1.9.9", "2.0.0")).toBe(true);
+ });
+
+ it("passes an equal or newer version", () => {
+ expect(isBelowVersion("2.0.3", "2.0.3")).toBe(false);
+ expect(isBelowVersion("2.1.0", "2.0.3")).toBe(false);
+ expect(isBelowVersion("2.0.10", "2.0.9")).toBe(false);
+ });
+
+ it("never blocks on a missing or malformed version", () => {
+ expect(isBelowVersion("2.0.2", null)).toBe(false);
+ expect(isBelowVersion("2.0.2", undefined)).toBe(false);
+ expect(isBelowVersion("2.0.2", "2.0")).toBe(false);
+ expect(isBelowVersion("2.0.2", "v2.0.3")).toBe(false);
+ expect(isBelowVersion("2.0.2", "2.0.3-rc1")).toBe(false);
+ expect(isBelowVersion("dev", "2.0.3")).toBe(false);
+ });
+});