feat: add project-aware agent workspace
This commit is contained in:
@@ -1,17 +1,18 @@
|
||||
import { act, cleanup, renderHook } from "@testing-library/react";
|
||||
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||
import type { Map as MapLibreMap, MapMouseEvent } from "maplibre-gl";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { searchPlaces, type PlaceSearchItem, type PlaceSearchResults } from "../api/place-search-client";
|
||||
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() }));
|
||||
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()
|
||||
};
|
||||
@@ -22,6 +23,23 @@ beforeEach(() => {
|
||||
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 });
|
||||
@@ -84,3 +102,95 @@ describe("place search selection lifecycle", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user