197 lines
10 KiB
TypeScript
197 lines
10 KiB
TypeScript
import { act, cleanup, renderHook } from "@testing-library/react";
|
|
import type { Map as MapLibreMap, MapMouseEvent } from "maplibre-gl";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { reverseGeocodePoint, searchPlaces, type PlaceSearchItem, type PlaceSearchResults } from "../api/place-search-client";
|
|
import { locateGeocodingResult, markGeocodingLocation } from "../map/geocoding-location";
|
|
import { useWorkbenchGeocoding } from "./use-workbench-geocoding";
|
|
|
|
vi.mock("../api/place-search-client", () => ({ searchPlaces: vi.fn(), reverseGeocodePoint: vi.fn() }));
|
|
vi.mock("../map/geocoding-location", () => ({ locateGeocodingResult: vi.fn(), markGeocodingLocation: vi.fn() }));
|
|
const location: PlaceSearchItem = { id: "place-1", name: "南桥路", address: "奉贤区南桥路", longitude: 121.44, latitude: 31.22, category: "道路名", district: "奉贤区" };
|
|
const other = { ...location, id: "place-2", address: "奉贤区南桥路", longitude: 121.35, latitude: 30.77 };
|
|
const results: PlaceSearchResults = { items: [location, other], total: 2, truncated: false, region: "奉贤区" };
|
|
const remove = vi.fn();
|
|
const options = {
|
|
placeSearch: { enabled: true, provider: "tianditu" as const, admin_code: "310120", region: "奉贤区" },
|
|
active: true, projectId: "project-1", mapRef: { current: {} as MapLibreMap }, mapReady: true,
|
|
getPadding: () => ({ top: 48, right: 400, bottom: 48, left: 48 }), reducedMotion: true, onLocate: vi.fn()
|
|
};
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.mocked(markGeocodingLocation).mockReturnValue({ remove } as unknown as ReturnType<typeof markGeocodingLocation>);
|
|
});
|
|
afterEach(cleanup);
|
|
|
|
describe("place search selection lifecycle", () => {
|
|
it("disables search without project configuration, even for the original workspace", async () => {
|
|
const { result } = renderHook(useWorkbenchGeocoding, {
|
|
initialProps: { ...options, placeSearch: null }
|
|
});
|
|
act(() => result.current.changeAddress("南桥路"));
|
|
await act(() => result.current.search());
|
|
expect(result.current.configured).toBe(false);
|
|
expect(result.current.error).toBe("当前项目暂未配置地名搜索");
|
|
expect(searchPlaces).not.toHaveBeenCalled();
|
|
});
|
|
it("uses the selected project's configured region", async () => {
|
|
const { result } = renderHook(useWorkbenchGeocoding, {
|
|
initialProps: { ...options, placeSearch: { ...options.placeSearch, admin_code: "341126", region: "凤阳县" } }
|
|
});
|
|
expect(result.current.configured).toBe(true);
|
|
expect(result.current.region).toBe("凤阳县");
|
|
});
|
|
it("does not move until selection and replaces the previous marker", async () => {
|
|
vi.mocked(searchPlaces).mockResolvedValue(results);
|
|
const { result, rerender } = renderHook(useWorkbenchGeocoding, { initialProps: options });
|
|
act(() => result.current.changeAddress("南桥路"));
|
|
await act(() => result.current.search());
|
|
expect(result.current.results).toEqual(results);
|
|
expect(result.current.result).toBeNull();
|
|
expect(result.current.choosing).toBe(true);
|
|
expect(markGeocodingLocation).not.toHaveBeenCalled();
|
|
act(() => result.current.select(location.id));
|
|
expect(result.current.choosing).toBe(false);
|
|
expect(locateGeocodingResult).toHaveBeenCalledWith(options.mapRef.current, location, options.getPadding(), true);
|
|
act(() => result.current.select(other.id));
|
|
expect(result.current.result).toEqual(other);
|
|
expect(remove).toHaveBeenCalledOnce();
|
|
expect(markGeocodingLocation).toHaveBeenLastCalledWith(options.mapRef.current, other);
|
|
act(() => result.current.changeSelection());
|
|
expect(result.current.choosing).toBe(true);
|
|
expect(result.current.result).toEqual(other);
|
|
const moves = vi.mocked(locateGeocodingResult).mock.calls.length;
|
|
act(() => result.current.select(other.id));
|
|
expect(result.current.choosing).toBe(false);
|
|
expect(locateGeocodingResult).toHaveBeenCalledTimes(moves + 1);
|
|
rerender({ ...options, active: false });
|
|
expect(result.current.result).toEqual(other);
|
|
act(() => result.current.clear());
|
|
expect(remove).toHaveBeenCalledTimes(2);
|
|
expect(result.current.results).toBeNull();
|
|
});
|
|
it.each(["edit", "close", "project"])("ignores a late search after %s", async (action) => {
|
|
let resolve!: (value: PlaceSearchResults) => void;
|
|
vi.mocked(searchPlaces).mockReturnValue(new Promise((done) => { resolve = done; }));
|
|
const { result, rerender } = renderHook(useWorkbenchGeocoding, { initialProps: options });
|
|
act(() => result.current.changeAddress("旧地址"));
|
|
let pending!: Promise<void>;
|
|
act(() => { pending = result.current.search(); });
|
|
const signal = vi.mocked(searchPlaces).mock.calls[0][3];
|
|
if (action === "edit") act(() => result.current.changeAddress("新地址"));
|
|
if (action === "close") rerender({ ...options, active: false });
|
|
if (action === "project") rerender({ ...options, projectId: "project-2" });
|
|
expect(signal?.aborted).toBe(true);
|
|
await act(async () => { resolve(results); await pending; });
|
|
expect(result.current.results).toBeNull();
|
|
expect(result.current.result).toBeNull();
|
|
expect(result.current.pending).toBe(false);
|
|
});
|
|
it("recovers from errors and clears the map when editing the query", async () => {
|
|
vi.mocked(searchPlaces).mockRejectedValueOnce(new Error("查询暂不可用")).mockResolvedValueOnce(results);
|
|
const { result, unmount } = renderHook(() => useWorkbenchGeocoding(options));
|
|
act(() => result.current.changeAddress("南桥路"));
|
|
await act(() => result.current.search());
|
|
expect(result.current.error).toBe("查询暂不可用");
|
|
await act(() => result.current.search());
|
|
expect(result.current.error).toBeNull();
|
|
act(() => result.current.select(location.id));
|
|
act(() => result.current.changeAddress("新地址"));
|
|
expect(remove).toHaveBeenCalledOnce();
|
|
expect(result.current.result).toBeNull();
|
|
expect(result.current.results).toBeNull();
|
|
unmount();
|
|
});
|
|
});
|
|
|
|
|
|
function pickingMap() {
|
|
const canvas = document.createElement("canvas");
|
|
const listeners = new Map<string, (event: MapMouseEvent) => void>();
|
|
const map = {
|
|
on: vi.fn((name: string, handler: (event: MapMouseEvent) => void) => listeners.set(name, handler)),
|
|
off: vi.fn((name: string) => listeners.delete(name)),
|
|
getCanvas: () => canvas
|
|
};
|
|
return {
|
|
map: map as unknown as MapLibreMap,
|
|
canvas,
|
|
click: (longitude = location.longitude, latitude = location.latitude) => listeners.get("click")?.({
|
|
lngLat: { lng: longitude, lat: latitude }, preventDefault: vi.fn()
|
|
} as unknown as MapMouseEvent)
|
|
};
|
|
}
|
|
|
|
describe("map point place lookup", () => {
|
|
it("ends picking after a successful lookup and keeps the marker and camera unchanged", async () => {
|
|
const map = pickingMap();
|
|
const onPickingChange = vi.fn();
|
|
const props = { ...options, picking: true, onPickingChange, mapRef: { current: map.map } };
|
|
vi.mocked(reverseGeocodePoint).mockResolvedValue(location);
|
|
const { result, rerender } = renderHook(useWorkbenchGeocoding, { initialProps: props });
|
|
onPickingChange.mockClear();
|
|
expect(map.canvas.style.cursor).toBe("crosshair");
|
|
await act(async () => { map.click(); });
|
|
expect(result.current.result).toEqual(location);
|
|
expect(result.current.origin).toBe("map");
|
|
expect(result.current.choosing).toBe(false);
|
|
expect(onPickingChange).toHaveBeenCalledExactlyOnceWith(false);
|
|
expect(markGeocodingLocation).toHaveBeenCalledWith(map.map, location);
|
|
expect(locateGeocodingResult).not.toHaveBeenCalled();
|
|
act(() => result.current.locate());
|
|
expect(locateGeocodingResult).toHaveBeenCalledOnce();
|
|
rerender({ ...props, picking: false });
|
|
expect(map.canvas.style.cursor).toBe("");
|
|
expect(map.map.off).toHaveBeenCalledWith("click", expect.any(Function));
|
|
});
|
|
|
|
it("ignores an older point after a second click", async () => {
|
|
const map = pickingMap();
|
|
let first!: (value: PlaceSearchItem) => void;
|
|
let second!: (value: PlaceSearchItem) => void;
|
|
vi.mocked(reverseGeocodePoint)
|
|
.mockReturnValueOnce(new Promise((resolve) => { first = resolve; }))
|
|
.mockReturnValueOnce(new Promise((resolve) => { second = resolve; }));
|
|
const { result } = renderHook(() => useWorkbenchGeocoding({ ...options, picking: true, mapRef: { current: map.map } }));
|
|
act(() => map.click());
|
|
const firstSignal = vi.mocked(reverseGeocodePoint).mock.calls[0][4];
|
|
act(() => map.click(other.longitude, other.latitude));
|
|
expect(firstSignal?.aborted).toBe(true);
|
|
await act(async () => { second(other); });
|
|
await act(async () => { first(location); });
|
|
expect(result.current.result).toEqual(other);
|
|
});
|
|
|
|
it.each(["close", "project", "escape"])("aborts an outstanding map lookup on %s", async (action) => {
|
|
const map = pickingMap();
|
|
let resolve!: (value: PlaceSearchItem) => void;
|
|
vi.mocked(reverseGeocodePoint).mockReturnValue(new Promise((done) => { resolve = done; }));
|
|
const props = { ...options, picking: true, onPickingChange: vi.fn(), mapRef: { current: map.map } };
|
|
const { result, rerender } = renderHook(useWorkbenchGeocoding, { initialProps: props });
|
|
act(() => map.click());
|
|
const signal = vi.mocked(reverseGeocodePoint).mock.calls[0][4];
|
|
if (action === "close") rerender({ ...props, active: false });
|
|
if (action === "project") rerender({ ...props, projectId: "other-project" });
|
|
if (action === "escape") act(() => window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })));
|
|
expect(signal?.aborted).toBe(true);
|
|
await act(async () => { resolve(location); });
|
|
expect(result.current.result).toBeNull();
|
|
expect(result.current.pending).toBe(false);
|
|
expect(props.onPickingChange).toHaveBeenCalledWith(false);
|
|
});
|
|
|
|
it("shows a lookup error and accepts another point", async () => {
|
|
const map = pickingMap();
|
|
const onPickingChange = vi.fn();
|
|
vi.mocked(reverseGeocodePoint).mockRejectedValueOnce(new Error("取点超出项目范围")).mockResolvedValueOnce(location);
|
|
const { result } = renderHook(() => useWorkbenchGeocoding({ ...options, picking: true, onPickingChange, mapRef: { current: map.map } }));
|
|
onPickingChange.mockClear();
|
|
await act(async () => { map.click(); });
|
|
expect(result.current.error).toBe("取点超出项目范围");
|
|
expect(onPickingChange).not.toHaveBeenCalled();
|
|
await act(async () => { map.click(); });
|
|
expect(result.current.error).toBeNull();
|
|
expect(result.current.result).toEqual(location);
|
|
expect(onPickingChange).toHaveBeenCalledExactlyOnceWith(false);
|
|
});
|
|
});
|