Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/route-stop-network-detours.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@openmapx/core": minor
"@openmapx/i18n": patch
---

Add bounded remaining-itinerary network detour evaluation and ordered stop insertion,
explicit entrance fallback, confidence labels, and cancellable provider-pinned requests.
153 changes: 153 additions & 0 deletions apps/web/src/components/navigation/RouteSearchControl.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
// @vitest-environment jsdom

import { type AlongRoutePoi, type CategoryPlace, useNavigationStore } from "@openmapx/core";
import en from "@openmapx/i18n/locales/en.json";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { NextIntlClientProvider } from "next-intl";
import { beforeEach, describe, expect, it, vi } from "vitest";

const fixture = vi.hoisted(() => ({
results: [] as AlongRoutePoi<CategoryPlace>[],
addStop: vi.fn(),
cancel: vi.fn(),
}));
vi.mock("@/lib/navigation/useRouteSearch", () => ({
routeSearchQueryFor: (value: unknown) => value,
useRouteSearch: () => ({
results: fixture.results,
isLoading: false,
addStop: fixture.addStop,
cancelAddStop: fixture.cancel,
}),
}));
vi.mock("@/integration-api/map/MapContext", () => ({ useMapOptional: () => null }));
vi.mock("@openmapx/core", async (original) => ({
...(await original<typeof import("@openmapx/core")>()),
useBrandSuggest: () => ({ data: undefined }),
}));
vi.mock("./RouteSearchResultsLayer", () => ({
RouteSearchResultsLayer: ({
results,
onSelect,
}: {
results: AlongRoutePoi<CategoryPlace>[];
onSelect: (poi: AlongRoutePoi<CategoryPlace>) => void;
}) => (
<button type="button" onClick={() => onSelect(results[0])}>
Select stop
</button>
),
}));

import { useRouteSearchStore } from "@/lib/navigation/routeSearchStore";
import { RouteSearchControl } from "./RouteSearchControl";

function view() {
return (
<NextIntlClientProvider locale="en" messages={en}>
<RouteSearchControl />
</NextIntlClientProvider>
);
}
const poi = () =>
({
place: { id: "stop", name: "River services", coordinates: [0.001, 0.001] },
alongMeters: 100,
deviationMeters: 20,
detourSeconds: 3,
detourMeters: 40,
}) as AlongRoutePoi<CategoryPlace>;
beforeEach(() => {
fixture.results = [poi()];
fixture.cancel.mockReset();
fixture.addStop.mockReset();
useRouteSearchStore.getState().reset();
useRouteSearchStore.getState().setCategoryKey("preset:amenity/fuel");
});
describe("route stop confidence", () => {
it("exits search after its successful add changes the active route", async () => {
let finish!: (value: boolean) => void;
fixture.addStop.mockReturnValue(
new Promise((resolve) => {
finish = resolve;
}),
);
render(view());
fireEvent.click(screen.getByText("Select stop"));
fireEvent.click(screen.getByRole("button", { name: "Add" }));
act(() =>
useNavigationStore.setState({
route: {
geometry: [
[0, 0],
[0.002, 0],
],
} as never,
}),
);
await act(async () => finish(true));
expect(useRouteSearchStore.getState().categoryKey).toBeNull();
});
it("cancels a pending request when the selected entrance changes", () => {
fixture.addStop.mockReturnValue(new Promise(() => {}));
const { rerender } = render(view());
fireEvent.click(screen.getByText("Select stop"));
fireEvent.click(screen.getByRole("button", { name: "Add" }));
fixture.cancel.mockClear();
fixture.results = [{ ...poi(), place: { ...poi().place, routingEntrance: [0.003, 0] } }];
rerender(view());
expect(fixture.cancel).toHaveBeenCalled();
expect(screen.getByRole("button", { name: "Add" }).hasAttribute("disabled")).toBe(false);
});
it("labels approximation and coordinate fallback", () => {
render(view());
fireEvent.click(screen.getByText("Select stop"));
expect(screen.getByText(/straight-line estimate/)).toBeTruthy();
expect(screen.getByText(/entrance unknown/)).toBeTruthy();
});
it("updates the selected card and disables unreachable stops", () => {
const { rerender } = render(view());
fireEvent.click(screen.getByText("Select stop"));
fixture.results = [
{
...poi(),
detour: {
id: "stop",
kind: "network",
seconds: 600,
meters: 4000,
provider: "routing-fixture",
access: { kind: "entrance", coordinates: [0.002, 0] },
waypoints: [],
},
},
];
rerender(view());
expect(screen.getByText("+10 min by road")).toBeTruthy();
expect(screen.getByText("+4.0 km")).toBeTruthy();
expect(screen.getByText("Routing to known entrance")).toBeTruthy();
fixture.results = [
{
...poi(),
detour: {
id: "stop",
kind: "unreachable",
access: { kind: "coordinate", coordinates: [0.001, 0.001] },
waypoints: [],
},
},
];
rerender(view());
expect(screen.getByText("No route to this stop")).toBeTruthy();
expect(screen.getByRole("button", { name: "Add" }).hasAttribute("disabled")).toBe(true);
});
it("cancels a pending selection when the card is dismissed", () => {
fixture.addStop.mockReturnValue(new Promise(() => {}));
render(view());
fireEvent.click(screen.getByText("Select stop"));
fireEvent.click(screen.getByRole("button", { name: "Add" }));
fixture.cancel.mockClear();
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(fixture.cancel).toHaveBeenCalled();
});
});
94 changes: 84 additions & 10 deletions apps/web/src/components/navigation/RouteSearchControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { BrandLogo } from "@/components/search/BrandLogo";
import { useMapOptional } from "@/integration-api/map/MapContext";
import { BRAND } from "@/integration-api/runtime/theme";
import { useRouteSearchStore } from "@/lib/navigation/routeSearchStore";
import { routeStopCopy } from "@/lib/navigation/routeStopCopy";
import { routeSearchQueryFor, useRouteSearch } from "@/lib/navigation/useRouteSearch";
import { RouteSearchResultsLayer } from "./RouteSearchResultsLayer";

Expand Down Expand Up @@ -101,8 +102,10 @@ export function RouteSearchControl() {
const setBrand = useRouteSearchStore((s) => s.setBrand);
const resetStore = useRouteSearchStore((s) => s.reset);
const setCameraMode = useNavigationStore((s) => s.setCameraMode);
const [selected, setSelected] = useState<AlongRoutePoi<CategoryPlace> | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [adding, setAdding] = useState(false);
const [addFailed, setAddFailed] = useState(false);
const selectionIntent = useRef(0);
const [brandQuery, setBrandQuery] = useState("");
const debouncedBrandQuery = useDebounce(brandQuery, 200);
const { data: brandSuggestData } = useBrandSuggest(debouncedBrandQuery);
Expand All @@ -119,7 +122,33 @@ export function RouteSearchControl() {
: brand
? routeSearchQueryFor({ brand })
: null;
const { results, isLoading, addStop } = useRouteSearch(query);
const { results, isLoading, addStop, cancelAddStop } = useRouteSearch(query);
const selected = results.find((poi) => poi.place.id === selectedId) ?? null;
const copy = selected ? routeStopCopy(selected) : null;
// A new search or route must retire any pending selection in this sheet.
const route = useNavigationStore((s) => s.route);
const session = useNavigationStore((s) => s.navigationStartedAtMs);
useEffect(() => {
void activeKey;
void route;
void session;
selectionIntent.current++;
setSelectedId(null);
setAdding(false);
setAddFailed(false);
cancelAddStop();
}, [activeKey, route, session, cancelAddStop]);
const selectedTarget = selected?.place.routingEntrance ?? selected?.place.coordinates;
const targetLng = selectedTarget?.[0];
const targetLat = selectedTarget?.[1];
useEffect(() => {
void targetLng;
void targetLat;
selectionIntent.current++;
cancelAddStop();
setAdding(false);
setAddFailed(false);
}, [targetLng, targetLat, cancelAddStop]);

const handleSelectBrand = (b: BrandSummary) => {
setBrand(b);
Expand Down Expand Up @@ -174,13 +203,21 @@ export function RouteSearchControl() {
}, [searching, results]);

const reset = () => {
selectionIntent.current++;
cancelAddStop();
resetStore();
setSelected(null);
setSelectedId(null);
setAdding(false);
setAddFailed(false);
setBrandQuery("");
};

const handleSelect = (poi: AlongRoutePoi<CategoryPlace>) => {
setSelected(poi);
selectionIntent.current++;
cancelAddStop();
setSelectedId(poi.place.id);
setAdding(false);
setAddFailed(false);
mapCtx?.flyTo(poi.place.coordinates, 15);
};

Expand All @@ -190,10 +227,19 @@ export function RouteSearchControl() {

const handleAdd = async () => {
if (!selected) return;
const intent = ++selectionIntent.current;
setAddFailed(false);
setAdding(true);
const ok = await addStop(selected.place.coordinates);
const ok = await addStop(selected.place);
// Success means this selection committed. Its route change also retires
// the card's intent, so complete search before guarding stale failures.
if (ok) {
reset();
return;
}
if (intent !== selectionIntent.current) return;
setAdding(false);
if (ok) reset();
setAddFailed(true);
};

return (
Expand Down Expand Up @@ -378,21 +424,49 @@ export function RouteSearchControl() {
<Typography variant="subtitle1" sx={{ fontWeight: 600 }} noWrap>
{selected.place.name}
</Typography>
<Typography variant="body2" color="text.secondary">
{t("rsDetour", { minutes: Math.max(1, Math.round(selected.detourSeconds / 60)) })}
<Typography variant="body2" color="text.secondary" aria-live="polite">
{copy && t(copy.key, copy.values)}
</Typography>
{selected.detour?.kind === "network" && (
<Typography variant="body2" color="text.secondary">
{t("rsDetourDistance", {
kilometers: (selected.detour.meters / 1000).toFixed(1),
})}
</Typography>
)}
</Box>
</Box>
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mb: 1.5 }}>
{t(
selected.detour?.access.kind === "entrance" || selected.place.routingEntrance
? "rsDetourEntrance"
: "rsDetourCoordinate",
)}
</Typography>
{addFailed && (
<Typography role="alert" color="error" variant="body2" sx={{ mb: 1 }}>
{t("rsAddFailed")}
</Typography>
)}
<Box sx={{ display: "flex", gap: 1, justifyContent: "flex-end" }}>
<Button onClick={() => setSelected(null)} color="inherit">
<Button
onClick={() => {
selectionIntent.current++;
cancelAddStop();
setSelectedId(null);
setAdding(false);
setAddFailed(false);
}}
color="inherit"
>
{t("rsCancel")}
</Button>
<Button
variant="contained"
startIcon={
adding ? <CircularProgress size={16} color="inherit" /> : <AddLocationAltIcon />
}
disabled={adding}
disabled={adding || selected.detour?.kind === "unreachable"}
onClick={handleAdd}
>
{t("rsAdd")}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@ import type { AlongRoutePoi, CategoryPlace } from "@openmapx/core";
import { act, render, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";

vi.mock("next-intl", async () => {
const { createTranslator } = await vi.importActual<typeof import("next-intl")>("next-intl");
const { default: messages } = await import("@openmapx/i18n/locales/en.json");
const translate = createTranslator({ locale: "en", messages, namespace: "navigation" });
return { useTranslations: () => translate };
});

const fixtures = vi.hoisted(() => ({
publish: vi.fn(),
loadBrand: vi.fn(),
Expand Down Expand Up @@ -47,6 +54,34 @@ beforeEach(() => {
fixtures.map.isStyleLoaded.mockReturnValue(true);
});
describe("RouteSearchResultsLayer brand pins", () => {
it("labels unknown and unreachable detours without short geometric minutes", () => {
const unknown = {
...results[0],
detour: {
id: "lidl",
kind: "unknown",
access: { kind: "coordinate", coordinates: [13, 52] },
waypoints: [],
},
} as AlongRoutePoi<CategoryPlace>;
const { rerender } = render(<RouteSearchResultsLayer {...props} results={[unknown]} />);
const label = () =>
(
fixtures.publish.mock.calls.at(-1)?.[0] as
| { data: { features: { properties: { label: string } }[] } }[]
| undefined
)?.[0].data.features[0].properties.label;
expect(label()).toBe("Unknown detour");
rerender(
<RouteSearchResultsLayer
{...props}
results={[{ ...unknown, detour: { ...unknown.detour!, kind: "unreachable" } }]}
/>,
);
expect(label()).toBe("Unreachable");
rerender(<RouteSearchResultsLayer {...props} />);
expect(label()).toBe("~+2 min");
});
it("publishes the selected brand logo after it loads", async () => {
fixtures.loadBrand.mockResolvedValue(true);
render(<RouteSearchResultsLayer {...props} brandQid="Q151954" />);
Expand Down
Loading
Loading