feat: add project-aware agent workspace
Generic Container CI/CD / test-build-publish (push) Successful in 2m9s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 2m39s

This commit is contained in:
2026-10-22 18:52:18 +08:00
parent c6a0e74e72
commit 2d8506fea0
191 changed files with 12373 additions and 3556 deletions
@@ -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);
});
});