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); }); 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; 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 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); }); });