fix: deduplicate workbench requests and remove unused map paths
This commit is contained in:
@@ -233,35 +233,11 @@ describe("Agent API client sessions", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back when a candidate is not the agent service", async () => {
|
it("uses the configured Agent address once and propagates its error", async () => {
|
||||||
const fetchMock = vi
|
const fetchMock = vi.fn().mockResolvedValue(Response.json({ message: "unavailable" }, { status: 503 }));
|
||||||
.fn()
|
|
||||||
.mockResolvedValueOnce(new Response(JSON.stringify({ message: "not found" }), { status: 404 }))
|
|
||||||
.mockResolvedValueOnce(
|
|
||||||
new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
schema_version: "agent-ui-registry@1",
|
|
||||||
chart_grammars: [],
|
|
||||||
components: [],
|
|
||||||
actions: []
|
|
||||||
}),
|
|
||||||
{ status: 200 }
|
|
||||||
)
|
|
||||||
);
|
|
||||||
vi.stubGlobal("fetch", fetchMock);
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
await expect(createAgentApiClient("http://agent.local/prefix/").getUiRegistry()).rejects.toThrow("unavailable");
|
||||||
await expect(createAgentApiClient(["http://not-agent.local", "http://agent.local"]).getUiRegistry()).resolves.toEqual({
|
expect(fetchMock).toHaveBeenCalledExactlyOnceWith("http://agent.local/prefix/api/v1/ui-registry", undefined);
|
||||||
schema_version: "agent-ui-registry@1",
|
|
||||||
chart_grammars: [],
|
|
||||||
components: [],
|
|
||||||
actions: []
|
|
||||||
});
|
|
||||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
|
||||||
1,
|
|
||||||
"http://not-agent.local/api/v1/ui-registry",
|
|
||||||
undefined
|
|
||||||
);
|
|
||||||
expect(fetchMock).toHaveBeenNthCalledWith(2, "http://agent.local/api/v1/ui-registry", undefined);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sends title update payloads using backend field names", async () => {
|
it("sends title update payloads using backend field names", async () => {
|
||||||
|
|||||||
@@ -220,24 +220,16 @@ export type AgentApiClientOptions = {
|
|||||||
getProjectId?: () => Promise<string | undefined>;
|
getProjectId?: () => Promise<string | undefined>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const AGENT_API_BASE_URLS = [env.TJWATER_AGENT_URL.replace(/\/$/, "")];
|
|
||||||
|
|
||||||
const API_PATH = "/api/v1";
|
const API_PATH = "/api/v1";
|
||||||
|
|
||||||
export function createAgentApiClient(
|
export function createAgentApiClient(
|
||||||
baseUrls: string | string[] = AGENT_API_BASE_URLS,
|
baseUrl: string = env.TJWATER_AGENT_URL,
|
||||||
options: AgentApiClientOptions = {}
|
options: AgentApiClientOptions = {}
|
||||||
): AgentApiClient {
|
): AgentApiClient {
|
||||||
const candidates = (Array.isArray(baseUrls) ? baseUrls : [baseUrls]).map((item) => item.replace(/\/$/, ""));
|
const normalizedBaseUrl = baseUrl.replace(/\/$/, "");
|
||||||
let activeBaseUrl = candidates[0] ?? "";
|
|
||||||
const setActiveBaseUrl = (baseUrl: string) => {
|
|
||||||
activeBaseUrl = baseUrl;
|
|
||||||
};
|
|
||||||
const request = (path: string, init?: RequestInit) =>
|
const request = (path: string, init?: RequestInit) =>
|
||||||
fetchWithFallback(
|
fetchAgent(
|
||||||
candidates,
|
normalizedBaseUrl,
|
||||||
activeBaseUrl,
|
|
||||||
setActiveBaseUrl,
|
|
||||||
path,
|
path,
|
||||||
init,
|
init,
|
||||||
options.getAccessToken,
|
options.getAccessToken,
|
||||||
@@ -265,7 +257,7 @@ export function createAgentApiClient(
|
|||||||
return token;
|
return token;
|
||||||
} : undefined;
|
} : undefined;
|
||||||
const answerStateRequest = async (path: string, init?: RequestInit) => {
|
const answerStateRequest = async (path: string, init?: RequestInit) => {
|
||||||
const response = await fetchWithFallback(candidates, activeBaseUrl, setActiveBaseUrl, path, init,
|
const response = await fetchAgent(normalizedBaseUrl, path, init,
|
||||||
checkedAnswerStateToken, options.getProjectId);
|
checkedAnswerStateToken, options.getProjectId);
|
||||||
// An identity change while awaiting the network also invalidates the result.
|
// An identity change while awaiting the network also invalidates the result.
|
||||||
await checkedAnswerStateToken?.();
|
await checkedAnswerStateToken?.();
|
||||||
@@ -275,7 +267,7 @@ export function createAgentApiClient(
|
|||||||
return {
|
return {
|
||||||
...createInteractionRequestApi(answerStateRequest),
|
...createInteractionRequestApi(answerStateRequest),
|
||||||
async getHealth(signal) {
|
async getHealth(signal) {
|
||||||
const response = await fetchWithFallback(candidates, activeBaseUrl, setActiveBaseUrl, "/health", { signal, cache: "no-store" }, undefined, undefined, "");
|
const response = await fetchAgent(normalizedBaseUrl, "/health", { signal, cache: "no-store" }, undefined, undefined, "");
|
||||||
if (!response.ok) throw new Error("Agent 服务暂不可用");
|
if (!response.ok) throw new Error("Agent 服务暂不可用");
|
||||||
const data: unknown = await response.json();
|
const data: unknown = await response.json();
|
||||||
if (!isRecord(data) || data.ok !== true || data.ready !== true) throw new Error("Agent 服务尚未就绪");
|
if (!isRecord(data) || data.ok !== true || data.ready !== true) throw new Error("Agent 服务尚未就绪");
|
||||||
@@ -579,19 +571,14 @@ export function createAgentApiClient(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchWithFallback(
|
async function fetchAgent(
|
||||||
baseUrls: string[],
|
baseUrl: string,
|
||||||
activeBaseUrl: string,
|
|
||||||
setActiveBaseUrl: (baseUrl: string) => void,
|
|
||||||
path: string,
|
path: string,
|
||||||
init?: RequestInit,
|
init?: RequestInit,
|
||||||
getAccessToken?: AccessTokenProvider,
|
getAccessToken?: AccessTokenProvider,
|
||||||
getProjectId?: () => Promise<string | undefined>,
|
getProjectId?: () => Promise<string | undefined>,
|
||||||
prefix = API_PATH
|
prefix = API_PATH
|
||||||
) {
|
) {
|
||||||
const orderedBaseUrls = [activeBaseUrl, ...baseUrls.filter((item) => item !== activeBaseUrl)];
|
|
||||||
let lastError: unknown;
|
|
||||||
let lastResponse: Response | null = null;
|
|
||||||
const accessToken = await getAccessToken?.();
|
const accessToken = await getAccessToken?.();
|
||||||
const projectId = await getProjectId?.();
|
const projectId = await getProjectId?.();
|
||||||
const hasContextHeaders = Boolean(accessToken || projectId);
|
const hasContextHeaders = Boolean(accessToken || projectId);
|
||||||
@@ -600,34 +587,7 @@ async function fetchWithFallback(
|
|||||||
if (projectId) headers?.set("X-Project-Id", projectId);
|
if (projectId) headers?.set("X-Project-Id", projectId);
|
||||||
const requestInit = hasContextHeaders ? { ...init, headers } : init;
|
const requestInit = hasContextHeaders ? { ...init, headers } : init;
|
||||||
|
|
||||||
for (const baseUrl of orderedBaseUrls) {
|
return fetch(`${baseUrl}${prefix}${path}`, requestInit);
|
||||||
try {
|
|
||||||
const response = await fetch(`${baseUrl}${prefix}${path}`, requestInit);
|
|
||||||
if (response.ok) {
|
|
||||||
setActiveBaseUrl(baseUrl);
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
lastResponse = response;
|
|
||||||
if (shouldFallbackOnHttpStatus(response.status) && baseUrl !== orderedBaseUrls[orderedBaseUrls.length - 1]) {
|
|
||||||
await response.body?.cancel();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
return response;
|
|
||||||
} catch (error) {
|
|
||||||
lastError = error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lastResponse) {
|
|
||||||
return lastResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw lastError instanceof Error ? lastError : new Error("Agent API unavailable");
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldFallbackOnHttpStatus(status: number) {
|
|
||||||
return status === 404 || status === 405 || status === 502 || status === 503 || status === 504;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { AgentApiClient } from "../api/client";
|
|||||||
|
|
||||||
export function useAgentMessageInbox(client: AgentApiClient, sessionId: string | null) {
|
export function useAgentMessageInbox(client: AgentApiClient, sessionId: string | null) {
|
||||||
const inbox = useSWR(sessionId ? ["agent-message-inbox", sessionId] : null,
|
const inbox = useSWR(sessionId ? ["agent-message-inbox", sessionId] : null,
|
||||||
([, id]) => client.listAgentMessages(id), { revalidateOnFocus: true, shouldRetryOnError: false });
|
([, id]) => client.listAgentMessages(id), { revalidateOnMount: false, revalidateOnFocus: true, shouldRetryOnError: false });
|
||||||
const [loadingMore, setLoadingMore] = useState(false);
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
const mutate = inbox.mutate;
|
const mutate = inbox.mutate;
|
||||||
const refresh = useCallback(() => { void mutate().catch(() => undefined); }, [mutate]);
|
const refresh = useCallback(() => { void mutate().catch(() => undefined); }, [mutate]);
|
||||||
@@ -15,20 +15,27 @@ export function useAgentMessageInbox(client: AgentApiClient, sessionId: string |
|
|||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
let retryDelay = 1500;
|
let retryDelay = 1500;
|
||||||
|
let initialized = false;
|
||||||
|
// Prefer the initial stream notification, but do not wait indefinitely for its first frame.
|
||||||
|
const initialLoadTimer = setTimeout(() => { initialized = true; refreshRef.current(); }, 1500);
|
||||||
function subscribe() {
|
function subscribe() {
|
||||||
void client.streamAgentMessages(sessionId!, { signal: controller.signal, onMessage: () => { retryDelay = 1500; refreshRef.current(); } })
|
void client.streamAgentMessages(sessionId!, { signal: controller.signal, onMessage: () => { initialized = true; clearTimeout(initialLoadTimer); retryDelay = 1500; refreshRef.current(); } })
|
||||||
.catch(() => { retryDelay = Math.min(retryDelay * 2, 30_000); })
|
.catch(() => {
|
||||||
|
// A failed subscription must not prevent the initial list from loading.
|
||||||
|
if (!initialized && !controller.signal.aborted) { initialized = true; clearTimeout(initialLoadTimer); refreshRef.current(); }
|
||||||
|
retryDelay = Math.min(retryDelay * 2, 30_000);
|
||||||
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!controller.signal.aborted) timer = setTimeout(subscribe, retryDelay);
|
if (!controller.signal.aborted) timer = setTimeout(subscribe, retryDelay);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
subscribe();
|
subscribe();
|
||||||
return () => { controller.abort(); clearTimeout(timer); };
|
return () => { controller.abort(); clearTimeout(timer); clearTimeout(initialLoadTimer); };
|
||||||
}, [client, sessionId]);
|
}, [client, sessionId]);
|
||||||
return {
|
return {
|
||||||
messages: inbox.data?.items ?? [],
|
messages: inbox.data?.items ?? [],
|
||||||
hasMore: Boolean(inbox.data?.next_cursor),
|
hasMore: Boolean(inbox.data?.next_cursor),
|
||||||
loading: inbox.isLoading,
|
loading: Boolean(sessionId) && (inbox.isLoading || (!inbox.data && !inbox.error)),
|
||||||
error: inbox.error ? "任务消息暂时无法加载" : null,
|
error: inbox.error ? "任务消息暂时无法加载" : null,
|
||||||
refresh,
|
refresh,
|
||||||
loadingMore,
|
loadingMore,
|
||||||
|
|||||||
@@ -138,8 +138,6 @@ function createCommands() {
|
|||||||
clearValueLabel: vi.fn(),
|
clearValueLabel: vi.fn(),
|
||||||
applyLayerGroupStyle: vi.fn(),
|
applyLayerGroupStyle: vi.fn(),
|
||||||
resetLayerGroupStyle: vi.fn(),
|
resetLayerGroupStyle: vi.fn(),
|
||||||
renderScadaAnalysis: vi.fn(),
|
|
||||||
clearScadaAnalysis: vi.fn(),
|
|
||||||
resetAll: vi.fn()
|
resetAll: vi.fn()
|
||||||
} satisfies WorkbenchMapCommands;
|
} satisfies WorkbenchMapCommands;
|
||||||
}
|
}
|
||||||
@@ -163,7 +161,6 @@ function createControllerState(
|
|||||||
invalid: 0
|
invalid: 0
|
||||||
},
|
},
|
||||||
styledGroups: [],
|
styledGroups: [],
|
||||||
scadaAnalysis: null,
|
|
||||||
...patch
|
...patch
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export function useOperationalTasks(
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [refreshGeneration, setRefreshGeneration] = useState(0);
|
const [refreshGeneration, setRefreshGeneration] = useState(0);
|
||||||
const [eventRetryGeneration, setEventRetryGeneration] = useState(0);
|
const enqueueEvents = useRef<(events: WorkbenchEvent[]) => void>(() => {});
|
||||||
const eventRetryMsRef = useRef(1_000);
|
const eventRetryMsRef = useRef(1_000);
|
||||||
const forceRefreshRef = useRef(false);
|
const forceRefreshRef = useRef(false);
|
||||||
const detailResetGenerationRef = useRef(sync.detailResetGeneration);
|
const detailResetGenerationRef = useRef(sync.detailResetGeneration);
|
||||||
@@ -131,50 +131,76 @@ export function useOperationalTasks(
|
|||||||
}, [getAccessToken, refreshGeneration, sync.detailResetGeneration, sync.snapshot]);
|
}, [getAccessToken, refreshGeneration, sync.detailResetGeneration, sync.snapshot]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const events = syncEvents;
|
|
||||||
if (events.length === 0) return;
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
let retryTimer: number | undefined;
|
let retryTimer: number | undefined;
|
||||||
const lastSequence = events.at(-1)!.event_seq;
|
let pendingEvents: WorkbenchEvent[] = [];
|
||||||
const { taskIds, deletedTaskIds, notificationsChanged } = collectOperationalTaskEventChanges(events);
|
let appliedSequence = 0;
|
||||||
if (deletedTaskIds.size > 0) {
|
let processing = false;
|
||||||
|
const removeDeletedTasks = (deletedTaskIds: Set<string>) => {
|
||||||
|
if (deletedTaskIds.size === 0) return;
|
||||||
tasksRef.current = tasksRef.current.filter((task) => !deletedTaskIds.has(task.task_id));
|
tasksRef.current = tasksRef.current.filter((task) => !deletedTaskIds.has(task.task_id));
|
||||||
setTasks(tasksRef.current);
|
setTasks(tasksRef.current);
|
||||||
setNotifications((current) => current.filter((item) => !deletedTaskIds.has(item.task_id)));
|
setNotifications((current) => current.filter((item) => !deletedTaskIds.has(item.task_id)));
|
||||||
}
|
};
|
||||||
void Promise.all([
|
const load = async () => {
|
||||||
|
if (processing || controller.signal.aborted) return;
|
||||||
|
processing = true;
|
||||||
|
window.clearTimeout(retryTimer);
|
||||||
|
try {
|
||||||
|
while (pendingEvents.length > 0 && !controller.signal.aborted) {
|
||||||
|
const events = pendingEvents;
|
||||||
|
const lastSequence = events.at(-1)!.event_seq;
|
||||||
|
const { taskIds, notificationsChanged } = collectOperationalTaskEventChanges(events);
|
||||||
|
const [nextNotifications, changedTasks] = await Promise.all([
|
||||||
notificationsChanged
|
notificationsChanged
|
||||||
? fetchOperationalTaskNotifications(getAccessToken, controller.signal)
|
? fetchOperationalTaskNotifications(getAccessToken, controller.signal)
|
||||||
: Promise.resolve(null),
|
: Promise.resolve(null),
|
||||||
Promise.all([...taskIds].map((taskId) =>
|
Promise.all([...taskIds].map((taskId) =>
|
||||||
fetchOperationalTask(taskId, getAccessToken, controller.signal)
|
fetchOperationalTask(taskId, getAccessToken, controller.signal)
|
||||||
))
|
))
|
||||||
]).then(([nextNotifications, changedTasks]) => {
|
]);
|
||||||
if (controller.signal.aborted) return;
|
if (controller.signal.aborted) return;
|
||||||
if (nextNotifications) setNotifications(nextNotifications);
|
// A deletion received during this read must not be undone by its response.
|
||||||
if (changedTasks.length > 0) {
|
const { deletedTaskIds } = collectOperationalTaskEventChanges(pendingEvents);
|
||||||
const changed = new Map(changedTasks.map((task) => [task.task_id, task]));
|
if (nextNotifications) setNotifications(nextNotifications.filter((item) => !deletedTaskIds.has(item.task_id)));
|
||||||
|
const visibleTasks = changedTasks.filter((task) => !deletedTaskIds.has(task.task_id));
|
||||||
|
if (visibleTasks.length > 0) {
|
||||||
|
const changed = new Map(visibleTasks.map((task) => [task.task_id, task]));
|
||||||
tasksRef.current = [
|
tasksRef.current = [
|
||||||
...changedTasks,
|
...visibleTasks,
|
||||||
...tasksRef.current.filter((task) => !changed.has(task.task_id))
|
...tasksRef.current.filter((task) => !changed.has(task.task_id))
|
||||||
];
|
];
|
||||||
setTasks(tasksRef.current);
|
setTasks(tasksRef.current);
|
||||||
}
|
}
|
||||||
|
appliedSequence = lastSequence;
|
||||||
|
pendingEvents = pendingEvents.filter((event) => event.event_seq > appliedSequence);
|
||||||
acknowledgeEventsThrough(lastSequence);
|
acknowledgeEventsThrough(lastSequence);
|
||||||
eventRetryMsRef.current = 1_000;
|
eventRetryMsRef.current = 1_000;
|
||||||
setError(null);
|
setError(null);
|
||||||
}).catch((nextError) => {
|
}
|
||||||
|
} catch (nextError) {
|
||||||
if (!controller.signal.aborted) {
|
if (!controller.signal.aborted) {
|
||||||
setError(nextError instanceof Error ? nextError.message : "工况任务增量刷新失败。");
|
setError(nextError instanceof Error ? nextError.message : "工况任务增量刷新失败。");
|
||||||
retryTimer = window.setTimeout(() => setEventRetryGeneration((current) => current + 1), eventRetryMsRef.current);
|
retryTimer = window.setTimeout(() => void load(), eventRetryMsRef.current);
|
||||||
eventRetryMsRef.current = Math.min(eventRetryMsRef.current * 2, 30_000);
|
eventRetryMsRef.current = Math.min(eventRetryMsRef.current * 2, 30_000);
|
||||||
}
|
}
|
||||||
});
|
} finally { processing = false; }
|
||||||
|
};
|
||||||
|
enqueueEvents.current = (events) => {
|
||||||
|
pendingEvents = events.filter((event) => event.event_seq > appliedSequence);
|
||||||
|
removeDeletedTasks(collectOperationalTaskEventChanges(pendingEvents).deletedTaskIds);
|
||||||
|
void load();
|
||||||
|
};
|
||||||
return () => {
|
return () => {
|
||||||
controller.abort();
|
controller.abort();
|
||||||
if (retryTimer !== undefined) window.clearTimeout(retryTimer);
|
enqueueEvents.current = () => {};
|
||||||
|
window.clearTimeout(retryTimer);
|
||||||
};
|
};
|
||||||
}, [acknowledgeEventsThrough, eventRetryGeneration, getAccessToken, syncEvents]);
|
}, [acknowledgeEventsThrough, getAccessToken, sync.detailResetGeneration]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
enqueueEvents.current(syncEvents);
|
||||||
|
}, [acknowledgeEventsThrough, getAccessToken, sync.detailResetGeneration, syncEvents]);
|
||||||
|
|
||||||
const conditions = useMemo(() => tasks.map((task) => ({ ...toOperationalTaskCondition(task),
|
const conditions = useMemo(() => tasks.map((task) => ({ ...toOperationalTaskCondition(task),
|
||||||
hasNewReport: notifications.some((item) => item.task_id === task.task_id && item.kind === "report_updated"
|
hasNewReport: notifications.some((item) => item.task_id === task.task_id && item.kind === "report_updated"
|
||||||
|
|||||||
@@ -531,12 +531,15 @@ export function useWorkbenchAgent({
|
|||||||
() => clientRef.current.listResponseInputs(sessionId!),
|
() => clientRef.current.listResponseInputs(sessionId!),
|
||||||
{ refreshInterval: streaming ? 1000 : 0, shouldRetryOnError: false }
|
{ refreshInterval: streaming ? 1000 : 0, shouldRetryOnError: false }
|
||||||
);
|
);
|
||||||
|
const handledRecovery = useRef({ scopeKey, version: 0 });
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (handledRecovery.current.scopeKey === scopeKey && handledRecovery.current.version === recoveryVersion) return;
|
||||||
|
handledRecovery.current = { scopeKey, version: recoveryVersion };
|
||||||
if (!recoveryVersion) return;
|
if (!recoveryVersion) return;
|
||||||
void refreshBootstrap();
|
void refreshBootstrap();
|
||||||
void refreshInteractionRequests();
|
void refreshInteractionRequests();
|
||||||
void refreshPendingInputs();
|
void refreshPendingInputs();
|
||||||
}, [recoveryVersion, refreshBootstrap, refreshInteractionRequests, refreshPendingInputs]);
|
}, [scopeKey, recoveryVersion, refreshBootstrap, refreshInteractionRequests, refreshPendingInputs]);
|
||||||
const updateInputDelivery = useCallback(async (id: string, delivery: AgentInputDelivery) => {
|
const updateInputDelivery = useCallback(async (id: string, delivery: AgentInputDelivery) => {
|
||||||
if (!sessionIdRef.current) return;
|
if (!sessionIdRef.current) return;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,298 @@
|
|||||||
|
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import { afterEach, expect, it, vi } from "vitest";
|
||||||
|
import { SWRConfig } from "swr";
|
||||||
|
const harness = vi.hoisted(() => ({
|
||||||
|
api: null as any,
|
||||||
|
recovery: 0,
|
||||||
|
flow: vi.fn(),
|
||||||
|
task: vi.fn(),
|
||||||
|
check: vi.fn()
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/agent", async (original) => ({
|
||||||
|
...(await original<any>()),
|
||||||
|
createAgentApiClient: () => harness.api
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/agent/hooks/use-agent-connection", () => ({
|
||||||
|
useAgentConnection: () => ({
|
||||||
|
connectionStatus: "online",
|
||||||
|
recoveryVersion: harness.recovery,
|
||||||
|
recheck: harness.check
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/workbench/api/realtime-flow-client", () => ({
|
||||||
|
fetchRealtimePipeFlows: harness.flow
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/workbench/api/operational-task-client", async (original) => ({
|
||||||
|
...(await original<any>()),
|
||||||
|
fetchOperationalTask: harness.task,
|
||||||
|
fetchOperationalTaskNotifications: async () => []
|
||||||
|
}));
|
||||||
|
import { useWorkbenchRuntimeData } from "@/features/workbench/hooks/use-workbench-runtime-data";
|
||||||
|
import { useWorkbenchAgent } from "@/features/workbench/hooks/use-workbench-agent";
|
||||||
|
import { useOperationalTasks } from "@/features/workbench/hooks/use-operational-tasks";
|
||||||
|
import { WorkbenchSessionProvider } from "@/features/workbench/hooks/workbench-session-provider";
|
||||||
|
import { useAgentMessageInbox } from "@/features/agent/hooks/use-agent-message-inbox";
|
||||||
|
const access: any = {
|
||||||
|
project: { project_id: "p", gs_workspace: "tjwater_next", status: "active" },
|
||||||
|
getAccessToken: async () => "test"
|
||||||
|
};
|
||||||
|
const sync: any = {
|
||||||
|
snapshot: null,
|
||||||
|
events: [],
|
||||||
|
status: "live",
|
||||||
|
error: null,
|
||||||
|
detailResetGeneration: 0,
|
||||||
|
acknowledgeEventsThrough: vi.fn(),
|
||||||
|
refresh: vi.fn(),
|
||||||
|
subscribePresentationEvents: () => () => {}
|
||||||
|
};
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
harness.flow.mockReset();
|
||||||
|
harness.task.mockReset();
|
||||||
|
harness.recovery = 0;
|
||||||
|
});
|
||||||
|
function wrapperFactory() {
|
||||||
|
const cache = new Map();
|
||||||
|
return ({ children }: any) => (
|
||||||
|
<WorkbenchSessionProvider owner={access}>
|
||||||
|
<SWRConfig
|
||||||
|
value={{ provider: () => cache, revalidateOnFocus: false, shouldRetryOnError: false }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SWRConfig>
|
||||||
|
</WorkbenchSessionProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
it("keeps one flow request and one polling timer when visibility changes during a request", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
let visibility = "visible";
|
||||||
|
vi.spyOn(document, "visibilityState", "get").mockImplementation(() => visibility as any);
|
||||||
|
const done: Array<() => void> = [];
|
||||||
|
harness.flow.mockImplementation(
|
||||||
|
() => new Promise((resolve) => done.push(() => resolve({ records: [], observedAt: null })))
|
||||||
|
);
|
||||||
|
renderHook(() => useWorkbenchRuntimeData(access, sync));
|
||||||
|
expect(harness.flow).toHaveBeenCalledTimes(1);
|
||||||
|
await act(async () => {
|
||||||
|
visibility = "hidden";
|
||||||
|
document.dispatchEvent(new Event("visibilitychange"));
|
||||||
|
visibility = "visible";
|
||||||
|
document.dispatchEvent(new Event("visibilitychange"));
|
||||||
|
});
|
||||||
|
expect(harness.flow).toHaveBeenCalledTimes(1);
|
||||||
|
await act(async () => {
|
||||||
|
done[0]();
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(15000);
|
||||||
|
});
|
||||||
|
expect(harness.flow).toHaveBeenCalledTimes(2);
|
||||||
|
await act(async () => {
|
||||||
|
done[1]();
|
||||||
|
await vi.advanceTimersByTimeAsync(15000);
|
||||||
|
});
|
||||||
|
expect(harness.flow).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
it("queues new task events without cancelling or repeating the current detail request", async () => {
|
||||||
|
const resolve: Array<(value: unknown) => void> = [];
|
||||||
|
harness.task.mockImplementation(() => new Promise((done) => resolve.push(done)));
|
||||||
|
const event = (seq: number, id: string) => ({
|
||||||
|
event_seq: seq,
|
||||||
|
topic: "operational.progress.changed",
|
||||||
|
aggregate_type: "operational_task",
|
||||||
|
aggregate_id: id,
|
||||||
|
metadata: { task_id: id }
|
||||||
|
});
|
||||||
|
const first = event(1, "task-a");
|
||||||
|
const { result, rerender, unmount } = renderHook(
|
||||||
|
({ events }) => useOperationalTasks(access, { ...sync, events }),
|
||||||
|
{ initialProps: { events: [first] } }
|
||||||
|
);
|
||||||
|
rerender({ events: [first, event(2, "task-b")] });
|
||||||
|
expect(harness.task.mock.calls.map((call) => call[0])).toEqual(["task-a"]);
|
||||||
|
expect(harness.task.mock.calls[0][2].aborted).toBe(false);
|
||||||
|
await act(async () => {
|
||||||
|
resolve[0]({
|
||||||
|
task_id: "task-a",
|
||||||
|
title: "A",
|
||||||
|
trigger: { kind: "manual" },
|
||||||
|
source: { kind: "operator" },
|
||||||
|
task_type: "event_investigation",
|
||||||
|
evidence: [],
|
||||||
|
updated_at: "2026-11-09T00:00:00Z"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(harness.task.mock.calls.map((call) => call[0])).toEqual(["task-a", "task-b"]);
|
||||||
|
await act(async () => {
|
||||||
|
resolve[1]({
|
||||||
|
task_id: "task-b",
|
||||||
|
title: "B",
|
||||||
|
trigger: { kind: "manual" },
|
||||||
|
source: { kind: "operator" },
|
||||||
|
task_type: "event_investigation",
|
||||||
|
evidence: [],
|
||||||
|
updated_at: "2026-11-09T00:00:00Z"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(result.current.tasks.map((task) => task.task_id).sort()).toEqual(["task-a", "task-b"]);
|
||||||
|
expect(sync.acknowledgeEventsThrough).toHaveBeenLastCalledWith(2);
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
it("refreshes once per health recovery and uses normal loads on session changes", async () => {
|
||||||
|
const api: any = {
|
||||||
|
getModels: vi.fn(async () => ({ models: [], defaultModel: "" })),
|
||||||
|
getUiRegistry: vi.fn(async () => ({})),
|
||||||
|
getFrontendActionRegistry: vi.fn(async () => ({})),
|
||||||
|
listSessionPage: vi.fn(async () => ({ sessions: [], hasMore: false, nextCursor: null })),
|
||||||
|
listTopics: vi.fn(async () => []),
|
||||||
|
listInteractionRequests: vi.fn(async () => ({ items: [] })),
|
||||||
|
listResponseInputs: vi.fn(async () => []),
|
||||||
|
listAgentMessages: vi.fn(async () => ({ items: [], next_cursor: null })),
|
||||||
|
streamAgentMessages: vi.fn(
|
||||||
|
(_id: any, options: any) =>
|
||||||
|
new Promise<void>((resolve) =>
|
||||||
|
options.signal.addEventListener("abort", () => resolve(), { once: true })
|
||||||
|
)
|
||||||
|
),
|
||||||
|
loadSession: vi.fn(async (id: string) => ({
|
||||||
|
id,
|
||||||
|
sessionId: id,
|
||||||
|
title: id,
|
||||||
|
purpose: "ordinary_chat",
|
||||||
|
messages: [],
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 1,
|
||||||
|
runStatus: "completed",
|
||||||
|
isStreaming: false,
|
||||||
|
isTitleManuallyEdited: false
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
harness.api = api;
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
() =>
|
||||||
|
useWorkbenchAgent({
|
||||||
|
getAccessToken: access,
|
||||||
|
onUiEnvelope: () => {},
|
||||||
|
onFrontendAction: async () => ({})
|
||||||
|
}),
|
||||||
|
{ wrapper: wrapperFactory() }
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(api.getModels).toHaveBeenCalledTimes(1));
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.loadHistorySession("a");
|
||||||
|
});
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.listInteractionRequests.mock.calls.filter((c: any) => c[0] === "a").length).toBe(1)
|
||||||
|
);
|
||||||
|
harness.recovery = 1;
|
||||||
|
rerender();
|
||||||
|
await waitFor(() => expect(api.getModels).toHaveBeenCalledTimes(2));
|
||||||
|
const before = api.getModels.mock.calls.length;
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.loadHistorySession("b");
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(api.getModels.mock.calls.length).toBe(before));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.listInteractionRequests.mock.calls.filter((c: any) => c[0] === "b").length).toBe(1)
|
||||||
|
);
|
||||||
|
const pending = api.listResponseInputs.mock.calls.filter((c: any) => c[0] === "b").length;
|
||||||
|
expect(pending).toBe(1);
|
||||||
|
});
|
||||||
|
it("loads the inbox once when the stream is ready and refreshes on later notifications", async () => {
|
||||||
|
const list = vi.fn(async () => ({ items: [], next_cursor: null }));
|
||||||
|
const stream = vi.fn((_id: any, options: any) => {
|
||||||
|
options.onMessage();
|
||||||
|
return new Promise<void>((resolve) =>
|
||||||
|
options.signal.addEventListener("abort", () => resolve(), { once: true })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const api = { listAgentMessages: list, streamAgentMessages: stream } as any;
|
||||||
|
renderHook(() => useAgentMessageInbox(api, "s"), { wrapper: wrapperFactory() });
|
||||||
|
await waitFor(() => expect(list).toHaveBeenCalledTimes(1));
|
||||||
|
await act(async () => {
|
||||||
|
stream.mock.calls[0][1].onMessage();
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(list).toHaveBeenCalledTimes(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps newer task changes and does not revive a task deleted during a read", async () => {
|
||||||
|
const complete: Array<(value: unknown) => void> = [];
|
||||||
|
harness.task.mockImplementation(() => new Promise((resolve) => complete.push(resolve)));
|
||||||
|
const event = (seq: number, deleted = false) => ({
|
||||||
|
event_seq: seq,
|
||||||
|
topic: "operational.task.changed",
|
||||||
|
aggregate_type: "operational_task",
|
||||||
|
aggregate_id: "a",
|
||||||
|
metadata: { task_id: "a", ...(deleted ? { change: "deleted" } : {}) }
|
||||||
|
});
|
||||||
|
const a = event(1);
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ events }) => useOperationalTasks(access, { ...sync, events }),
|
||||||
|
{
|
||||||
|
initialProps: { events: [a] }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
rerender({ events: [a, event(2)] });
|
||||||
|
const task = {
|
||||||
|
task_id: "a",
|
||||||
|
title: "A",
|
||||||
|
trigger: { kind: "manual" },
|
||||||
|
source: { kind: "operator" },
|
||||||
|
task_type: "event_investigation",
|
||||||
|
evidence: [],
|
||||||
|
updated_at: "2026-11-09T00:00:00Z"
|
||||||
|
};
|
||||||
|
await act(async () => {
|
||||||
|
complete[0](task);
|
||||||
|
});
|
||||||
|
expect(harness.task).toHaveBeenCalledTimes(2);
|
||||||
|
rerender({ events: [a, event(2), event(3, true)] });
|
||||||
|
expect(result.current.tasks).toEqual([]);
|
||||||
|
await act(async () => {
|
||||||
|
complete[1](task);
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(sync.acknowledgeEventsThrough).toHaveBeenLastCalledWith(3));
|
||||||
|
expect(result.current.tasks).toEqual([]);
|
||||||
|
expect(harness.task).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads the initial inbox even when its stream cannot connect", async () => {
|
||||||
|
const list = vi.fn(async () => ({ items: [], next_cursor: null }));
|
||||||
|
const api = {
|
||||||
|
listAgentMessages: list,
|
||||||
|
streamAgentMessages: vi.fn(async () => {
|
||||||
|
throw new Error("offline");
|
||||||
|
})
|
||||||
|
} as any;
|
||||||
|
const { result } = renderHook(() => useAgentMessageInbox(api, "s"), {
|
||||||
|
wrapper: wrapperFactory()
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
expect(list).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads the inbox when its stream remains pending without a first frame", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const list = vi.fn(async () => ({ items: [], next_cursor: null }));
|
||||||
|
const api = {
|
||||||
|
listAgentMessages: list,
|
||||||
|
streamAgentMessages: vi.fn(
|
||||||
|
(_id: string, options: { signal: AbortSignal }) =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
options.signal.addEventListener("abort", () => resolve(), { once: true });
|
||||||
|
})
|
||||||
|
)
|
||||||
|
} as any;
|
||||||
|
const { result } = renderHook(() => useAgentMessageInbox(api, "s"), {
|
||||||
|
wrapper: wrapperFactory()
|
||||||
|
});
|
||||||
|
expect(list).not.toHaveBeenCalled();
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(1500);
|
||||||
|
});
|
||||||
|
expect(list).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result.current.loading).toBe(false);
|
||||||
|
});
|
||||||
@@ -177,12 +177,15 @@ export function useWorkbenchRuntimeData(
|
|||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
let timeoutId: number | undefined;
|
let timeoutId: number | undefined;
|
||||||
|
|
||||||
|
let refreshing = false;
|
||||||
const refreshRealtimePipeFlows = async () => {
|
const refreshRealtimePipeFlows = async () => {
|
||||||
|
if (controller.signal.aborted || refreshing) return;
|
||||||
if (document.visibilityState === "hidden") {
|
if (document.visibilityState === "hidden") {
|
||||||
timeoutId = window.setTimeout(refreshRealtimePipeFlows, BACKEND_RUNTIME_REFRESH_INTERVAL_MS);
|
timeoutId = window.setTimeout(refreshRealtimePipeFlows, BACKEND_RUNTIME_REFRESH_INTERVAL_MS);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
refreshing = true;
|
||||||
const snapshot = await fetchRealtimePipeFlows(getAccessToken, controller.signal);
|
const snapshot = await fetchRealtimePipeFlows(getAccessToken, controller.signal);
|
||||||
if (!controller.signal.aborted) {
|
if (!controller.signal.aborted) {
|
||||||
setRealtimePipeFlows(snapshot.records.length > 0 ? snapshot : null);
|
setRealtimePipeFlows(snapshot.records.length > 0 ? snapshot : null);
|
||||||
@@ -190,6 +193,7 @@ export function useWorkbenchRuntimeData(
|
|||||||
} catch {
|
} catch {
|
||||||
if (!controller.signal.aborted) setRealtimePipeFlows(null);
|
if (!controller.signal.aborted) setRealtimePipeFlows(null);
|
||||||
} finally {
|
} finally {
|
||||||
|
refreshing = false;
|
||||||
if (!controller.signal.aborted) {
|
if (!controller.signal.aborted) {
|
||||||
timeoutId = window.setTimeout(
|
timeoutId = window.setTimeout(
|
||||||
refreshRealtimePipeFlows,
|
refreshRealtimePipeFlows,
|
||||||
|
|||||||
@@ -956,19 +956,15 @@ function MapWorkbenchContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleStartNewAgentSession() {
|
async function handleStartNewAgentSession() {
|
||||||
mapController.clearScadaAnalysis();
|
|
||||||
await agent.startNewSession();
|
await agent.startNewSession();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleLoadAgentHistorySession(sessionId: string) {
|
async function handleLoadAgentHistorySession(sessionId: string) {
|
||||||
const changesSession = sessionId !== agent.sessionId;
|
|
||||||
const loaded = await agent.loadHistorySession(sessionId);
|
const loaded = await agent.loadHistorySession(sessionId);
|
||||||
if (!loaded) throw new Error("会话打开已取消");
|
if (!loaded) throw new Error("会话打开已取消");
|
||||||
if (changesSession) mapController.clearScadaAnalysis();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleOpenTaskCollaboration(taskId: string, discussion?: InvestigationDiscussion) {
|
async function handleOpenTaskCollaboration(taskId: string, discussion?: InvestigationDiscussion) {
|
||||||
mapController.clearScadaAnalysis();
|
|
||||||
const id = await agent.openTaskCollaboration(taskId, discussion);
|
const id = await agent.openTaskCollaboration(taskId, discussion);
|
||||||
if (discussion?.reference && id) agent.setContentReference(discussion.reference);
|
if (discussion?.reference && id) agent.setContentReference(discussion.reference);
|
||||||
readingWorkspace?.close();
|
readingWorkspace?.close();
|
||||||
@@ -980,7 +976,6 @@ function MapWorkbenchContent({
|
|||||||
target: TaskExecutionHistoryTarget,
|
target: TaskExecutionHistoryTarget,
|
||||||
knownRunId?: string
|
knownRunId?: string
|
||||||
) {
|
) {
|
||||||
mapController.clearScadaAnalysis();
|
|
||||||
let runId = knownRunId;
|
let runId = knownRunId;
|
||||||
if (!runId) {
|
if (!runId) {
|
||||||
const runs = await fetchOperationalTaskRuns(taskId, getAccessToken);
|
const runs = await fetchOperationalTaskRuns(taskId, getAccessToken);
|
||||||
@@ -999,12 +994,8 @@ function MapWorkbenchContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleDeleteAgentHistorySession(sessionId: string) {
|
async function handleDeleteAgentHistorySession(sessionId: string) {
|
||||||
const clearsActiveAnalysis = sessionId === agent.sessionId;
|
|
||||||
const deleted = await agent.deleteHistorySession(sessionId);
|
const deleted = await agent.deleteHistorySession(sessionId);
|
||||||
if (!deleted) throw new Error("会话删除失败,请重试");
|
if (!deleted) throw new Error("会话删除失败,请重试");
|
||||||
if (deleted && clearsActiveAnalysis) {
|
|
||||||
mapController.clearScadaAnalysis();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleAgentMapAction(
|
async function handleAgentMapAction(
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Feature, FeatureCollection, Point } from "geojson";
|
import type { Feature, FeatureCollection, Point } from "geojson";
|
||||||
import type { ExpressionSpecification, Map as MapLibreMap, StyleSpecification } from "maplibre-gl";
|
import type { ExpressionSpecification, StyleSpecification } from "maplibre-gl";
|
||||||
import { MAP_STYLE_TOKENS } from "./map-colors";
|
import { MAP_STYLE_TOKENS } from "./map-colors";
|
||||||
|
|
||||||
export const SCADA_ANALYSIS_SOURCE_ID = "agent-scada-analysis";
|
export const SCADA_ANALYSIS_SOURCE_ID = "agent-scada-analysis";
|
||||||
@@ -121,19 +121,6 @@ export const scadaAnalysisLayers: Layer[] = [
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
export function ensureScadaAnalysisLayers(
|
|
||||||
map: Pick<MapLibreMap, "addLayer" | "getLayer">
|
|
||||||
): void {
|
|
||||||
scadaAnalysisLayers.forEach((layer, index) => {
|
|
||||||
if (map.getLayer(layer.id)) return;
|
|
||||||
const beforeId = scadaAnalysisLayers
|
|
||||||
.slice(index + 1)
|
|
||||||
.map((candidate) => candidate.id)
|
|
||||||
.find((candidateId) => Boolean(map.getLayer(candidateId)));
|
|
||||||
map.addLayer(layer, beforeId);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createEmptyScadaAnalysisCollection(): FeatureCollection<Point> {
|
export function createEmptyScadaAnalysisCollection(): FeatureCollection<Point> {
|
||||||
return { type: "FeatureCollection", features: [] };
|
return { type: "FeatureCollection", features: [] };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type { StyleSpecification, VectorSourceSpecification } from "maplibre-gl"
|
|||||||
|
|
||||||
import { GEOSERVER_WORKSPACE, MAP_URL } from "./geoserver-config";
|
import { GEOSERVER_WORKSPACE, MAP_URL } from "./geoserver-config";
|
||||||
import { MAP_STYLE_TOKENS } from "./map-colors";
|
import { MAP_STYLE_TOKENS } from "./map-colors";
|
||||||
import { SCADA_ANALYSIS_SOURCE_ID, createEmptyScadaAnalysisCollection } from "./scada-analysis";
|
|
||||||
import { SCADA_SOURCE_ID } from "./scada";
|
import { SCADA_SOURCE_ID } from "./scada";
|
||||||
|
|
||||||
export const WATER_NETWORK_GLOBAL_VIEW = {
|
export const WATER_NETWORK_GLOBAL_VIEW = {
|
||||||
@@ -325,10 +324,6 @@ export function createBaseStyle(mapboxToken?: string, tiandituToken?: string): S
|
|||||||
type: "geojson",
|
type: "geojson",
|
||||||
promoteId: "device_id",
|
promoteId: "device_id",
|
||||||
data: { type: "FeatureCollection", features: [] }
|
data: { type: "FeatureCollection", features: [] }
|
||||||
},
|
|
||||||
[SCADA_ANALYSIS_SOURCE_ID]: {
|
|
||||||
type: "geojson",
|
|
||||||
data: createEmptyScadaAnalysisCollection()
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const layers: StyleSpecification["layers"] = [
|
const layers: StyleSpecification["layers"] = [
|
||||||
|
|||||||
@@ -20,29 +20,23 @@ describe("WorkbenchMapController", () => {
|
|||||||
expect(map.easeTo).not.toHaveBeenCalled();
|
expect(map.easeTo).not.toHaveBeenCalled();
|
||||||
expect(controller.getSnapshot().targets).toEqual([]);
|
expect(controller.getSnapshot().targets).toEqual([]);
|
||||||
});
|
});
|
||||||
it("clears every controller overlay, preserves layer styles, and invalidates pending analysis", async () => {
|
it("clears controller overlays and preserves layer styles", async () => {
|
||||||
const map = createMap();
|
const map = createMap();
|
||||||
map.getLayer.mockReturnValue({} as never);
|
map.getLayer.mockReturnValue({} as never);
|
||||||
const setData = vi.fn();
|
const setData = vi.fn();
|
||||||
map.getSource.mockReturnValue({ setData });
|
map.getSource.mockReturnValue({ setData });
|
||||||
let resolveFeatures = (_value: FeatureCollection) => {};
|
|
||||||
const pendingAnalysis = new Promise<FeatureCollection>((resolve) => { resolveFeatures = resolve; });
|
|
||||||
const controller = new WorkbenchMapController({
|
const controller = new WorkbenchMapController({
|
||||||
getMap: () => map as unknown as MapLibreMap, isReady: () => true,
|
getMap: () => map as unknown as MapLibreMap, isReady: () => true,
|
||||||
getPadding: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
|
getPadding: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
|
||||||
fetchFeatures: (source) => source === "scada" ? pendingAnalysis : Promise.resolve(pointCollection)
|
fetchFeatures: async () => pointCollection
|
||||||
});
|
});
|
||||||
await controller.highlight({ sourceId: "junctions", featureId: "J-1" });
|
await controller.highlight({ sourceId: "junctions", featureId: "J-1" });
|
||||||
controller.applyLayerGroupStyle("pipes", { color: "#123456" });
|
controller.applyLayerGroupStyle("pipes", { color: "#123456" });
|
||||||
const showingFlow = controller.setFlowVisible(true);
|
const showingFlow = controller.setFlowVisible(true);
|
||||||
const analysis = controller.renderScadaAnalysis([{ device_id: "J-1", level: "high" }]);
|
|
||||||
controller.clearOverlays();
|
controller.clearOverlays();
|
||||||
await showingFlow;
|
await showingFlow;
|
||||||
resolveFeatures(pointCollection);
|
|
||||||
await expect(analysis).rejects.toThrow("ACTION_SUPERSEDED");
|
|
||||||
expect(setData).toHaveBeenCalledWith({ type: "FeatureCollection", features: [] });
|
|
||||||
expect(controller.getSnapshot()).toMatchObject({
|
expect(controller.getSnapshot()).toMatchObject({
|
||||||
target: null, targets: [], targetResult: null, scadaAnalysis: null,
|
target: null, targets: [], targetResult: null,
|
||||||
flowVisible: false, pending: false, valueLabels: {}, styledGroups: ["pipes"]
|
flowVisible: false, pending: false, valueLabels: {}, styledGroups: ["pipes"]
|
||||||
});
|
});
|
||||||
expect(map.removeFeatureState).toHaveBeenCalledWith(expect.objectContaining({ id: "J-1" }), "highlighted");
|
expect(map.removeFeatureState).toHaveBeenCalledWith(expect.objectContaining({ id: "J-1" }), "highlighted");
|
||||||
@@ -552,37 +546,7 @@ describe("WorkbenchMapController", () => {
|
|||||||
expect(map.removeFeatureState).not.toHaveBeenCalled();
|
expect(map.removeFeatureState).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders SCADA analysis through the registered GeoJSON source", async () => {
|
|
||||||
const map = createMap();
|
|
||||||
const source = { setData: vi.fn() };
|
|
||||||
map.getSource.mockReturnValue(source);
|
|
||||||
const controller = createController(map, {
|
|
||||||
type: "FeatureCollection",
|
|
||||||
features: [
|
|
||||||
{
|
|
||||||
type: "Feature",
|
|
||||||
geometry: { type: "Point", coordinates: [120.7, 28] },
|
|
||||||
properties: { device_id: "MP01" }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
controller.renderScadaAnalysis([{ device_id: "MP01", level: "high" }])
|
|
||||||
).resolves.toMatchObject({
|
|
||||||
rendered_ids: ["MP01"],
|
|
||||||
fitted: true
|
|
||||||
});
|
|
||||||
expect(source.setData).toHaveBeenCalled();
|
|
||||||
expect(controller.getSnapshot()).toMatchObject({
|
|
||||||
errorCode: null,
|
|
||||||
pending: false,
|
|
||||||
scadaAnalysis: {
|
|
||||||
rendered_ids: ["MP01"],
|
|
||||||
fitted: true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const pointCollection: FeatureCollection = { type: "FeatureCollection", features: [{ type: "Feature", geometry: { type: "Point", coordinates: [120.7, 28] }, properties: { id: "J-1" } }] };
|
const pointCollection: FeatureCollection = { type: "FeatureCollection", features: [{ type: "Feature", geometry: { type: "Point", coordinates: [120.7, 28] }, properties: { id: "J-1" } }] };
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Feature, FeatureCollection, Geometry, Position } from "geojson";
|
import type { Feature, FeatureCollection, Geometry, Position } from "geojson";
|
||||||
import type { GeoJSONSource, Map as MapLibreMap, PaddingOptions } from "maplibre-gl";
|
import type { Map as MapLibreMap, PaddingOptions } from "maplibre-gl";
|
||||||
import { SOURCE_LAYERS, type WaterNetworkSourceId } from "./sources";
|
import { SOURCE_LAYERS, type WaterNetworkSourceId } from "./sources";
|
||||||
import { PRESENTATION_FEATURE_SOURCE } from "./presentation-highlight-style";
|
import { PRESENTATION_FEATURE_SOURCE } from "./presentation-highlight-style";
|
||||||
import {
|
import {
|
||||||
@@ -36,14 +36,7 @@ import {
|
|||||||
type ValueLabelSourceId,
|
type ValueLabelSourceId,
|
||||||
type ValueLabelStyle
|
type ValueLabelStyle
|
||||||
} from "./value-label";
|
} from "./value-label";
|
||||||
import {
|
|
||||||
SCADA_ANALYSIS_SOURCE_ID,
|
|
||||||
createEmptyScadaAnalysisCollection,
|
|
||||||
createScadaAnalysisCollection,
|
|
||||||
ensureScadaAnalysisLayers,
|
|
||||||
type ScadaAnalysisItem,
|
|
||||||
type ScadaAnalysisRenderResult
|
|
||||||
} from "./scada-analysis";
|
|
||||||
|
|
||||||
export type FeatureTarget = { sourceId: WaterNetworkSourceId; featureId: string };
|
export type FeatureTarget = { sourceId: WaterNetworkSourceId; featureId: string };
|
||||||
export type FeatureBatchTarget = { sourceId: WaterNetworkSourceId; featureIds: string[] };
|
export type FeatureBatchTarget = { sourceId: WaterNetworkSourceId; featureIds: string[] };
|
||||||
@@ -56,7 +49,6 @@ export type WorkbenchMapErrorCode =
|
|||||||
| "INVALID_FEATURE_IDS"
|
| "INVALID_FEATURE_IDS"
|
||||||
| "WFS_UNAVAILABLE"
|
| "WFS_UNAVAILABLE"
|
||||||
| "SCADA_API_UNAVAILABLE"
|
| "SCADA_API_UNAVAILABLE"
|
||||||
| "SCADA_FEATURES_NOT_FOUND"
|
|
||||||
| "ACTION_SUPERSEDED"
|
| "ACTION_SUPERSEDED"
|
||||||
| "FLOW_UNAVAILABLE"
|
| "FLOW_UNAVAILABLE"
|
||||||
| "INVALID_FLOW_CALCULATION"
|
| "INVALID_FLOW_CALCULATION"
|
||||||
@@ -72,7 +64,6 @@ export type WorkbenchMapControllerState = {
|
|||||||
flowVisible: boolean;
|
flowVisible: boolean;
|
||||||
flowSummary: PipeFlowSummary;
|
flowSummary: PipeFlowSummary;
|
||||||
styledGroups: LayerGroupId[];
|
styledGroups: LayerGroupId[];
|
||||||
scadaAnalysis: ScadaAnalysisRenderResult | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ValueLabelRequest = Omit<ValueLabelStyle, "unit"> & { unit?: string };
|
export type ValueLabelRequest = Omit<ValueLabelStyle, "unit"> & { unit?: string };
|
||||||
@@ -89,8 +80,6 @@ export type WorkbenchMapCommands = {
|
|||||||
clearValueLabel: (sourceId?: ValueLabelSourceId) => void;
|
clearValueLabel: (sourceId?: ValueLabelSourceId) => void;
|
||||||
applyLayerGroupStyle: (groupId: LayerGroupId, patch: LayerGroupStylePatch) => void;
|
applyLayerGroupStyle: (groupId: LayerGroupId, patch: LayerGroupStylePatch) => void;
|
||||||
resetLayerGroupStyle: (groupId: LayerGroupId) => void;
|
resetLayerGroupStyle: (groupId: LayerGroupId) => void;
|
||||||
renderScadaAnalysis: (items: ScadaAnalysisItem[], signal?: AbortSignal) => Promise<ScadaAnalysisRenderResult>;
|
|
||||||
clearScadaAnalysis: () => { cleared: true };
|
|
||||||
resetAll: () => void;
|
resetAll: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -114,15 +103,13 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
|||||||
errorCode: null,
|
errorCode: null,
|
||||||
flowVisible: false,
|
flowVisible: false,
|
||||||
flowSummary: createEmptyPipeFlowSummary(),
|
flowSummary: createEmptyPipeFlowSummary(),
|
||||||
styledGroups: [],
|
styledGroups: []
|
||||||
scadaAnalysis: null
|
|
||||||
};
|
};
|
||||||
private listeners = new Set<() => void>();
|
private listeners = new Set<() => void>();
|
||||||
private cachedFeatures = new Map<string, Feature>();
|
private cachedFeatures = new Map<string, Feature>();
|
||||||
private flowState: FlowOverlayState = createFlowOverlayState();
|
private flowState: FlowOverlayState = createFlowOverlayState();
|
||||||
private flowUpdateRevision = 0;
|
private flowUpdateRevision = 0;
|
||||||
private flowVisibilityRevision = 0;
|
private flowVisibilityRevision = 0;
|
||||||
private scadaAnalysisRevision = 0;
|
|
||||||
private targetRequestRevision = 0;
|
private targetRequestRevision = 0;
|
||||||
private pendingTargetSourceId: WaterNetworkSourceId | null = null;
|
private pendingTargetSourceId: WaterNetworkSourceId | null = null;
|
||||||
private availableSourceIds: ReadonlySet<WaterNetworkSourceId> | null = null;
|
private availableSourceIds: ReadonlySet<WaterNetworkSourceId> | null = null;
|
||||||
@@ -399,125 +386,13 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
|||||||
this.patchState({ styledGroups: this.state.styledGroups.filter((item) => item !== groupId), errorCode: null });
|
this.patchState({ styledGroups: this.state.styledGroups.filter((item) => item !== groupId), errorCode: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
async renderScadaAnalysis(
|
|
||||||
items: ScadaAnalysisItem[],
|
|
||||||
signal?: AbortSignal
|
|
||||||
): Promise<ScadaAnalysisRenderResult> {
|
|
||||||
const revision = ++this.scadaAnalysisRevision;
|
|
||||||
const map = this.options.getMap();
|
|
||||||
if (!map || !this.options.isReady()) {
|
|
||||||
this.setError("MAP_NOT_READY");
|
|
||||||
throw new Error("MAP_NOT_READY");
|
|
||||||
}
|
|
||||||
this.patchState({ pending: true, errorCode: null });
|
|
||||||
|
|
||||||
let sourceCollection: FeatureCollection;
|
|
||||||
try {
|
|
||||||
sourceCollection = await this.fetchFeatures(
|
|
||||||
"scada",
|
|
||||||
items.map((item) => item.device_id),
|
|
||||||
signal
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
if (revision === this.scadaAnalysisRevision) {
|
|
||||||
this.patchState({ pending: false, errorCode: "SCADA_API_UNAVAILABLE" });
|
|
||||||
}
|
|
||||||
throw new Error(
|
|
||||||
revision === this.scadaAnalysisRevision
|
|
||||||
? "SCADA_API_UNAVAILABLE"
|
|
||||||
: "ACTION_SUPERSEDED"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (revision !== this.scadaAnalysisRevision) {
|
|
||||||
throw new Error("ACTION_SUPERSEDED");
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = createScadaAnalysisCollection(sourceCollection, items);
|
|
||||||
if (next.collection.features.length === 0) {
|
|
||||||
this.patchState({
|
|
||||||
pending: false,
|
|
||||||
errorCode: "SCADA_FEATURES_NOT_FOUND"
|
|
||||||
});
|
|
||||||
throw new Error("SCADA_FEATURES_NOT_FOUND");
|
|
||||||
}
|
|
||||||
const source = map.getSource(
|
|
||||||
SCADA_ANALYSIS_SOURCE_ID
|
|
||||||
) as GeoJSONSource | undefined;
|
|
||||||
if (!source) {
|
|
||||||
this.patchState({ pending: false, errorCode: "MAP_NOT_READY" });
|
|
||||||
throw new Error("MAP_NOT_READY");
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
ensureScadaAnalysisLayers(map);
|
|
||||||
source.setData(next.collection);
|
|
||||||
} catch {
|
|
||||||
this.patchState({ pending: false, errorCode: "MAP_NOT_READY" });
|
|
||||||
throw new Error("MAP_NOT_READY");
|
|
||||||
}
|
|
||||||
|
|
||||||
const result: ScadaAnalysisRenderResult = {
|
|
||||||
...next.result,
|
|
||||||
fitted: true
|
|
||||||
};
|
|
||||||
this.patchState({
|
|
||||||
pending: false,
|
|
||||||
errorCode: null,
|
|
||||||
scadaAnalysis: result
|
|
||||||
});
|
|
||||||
const coordinates = next.collection.features.map(
|
|
||||||
(feature) => feature.geometry.coordinates
|
|
||||||
);
|
|
||||||
if (coordinates.length === 1) {
|
|
||||||
map.easeTo({
|
|
||||||
center: [coordinates[0][0], coordinates[0][1]],
|
|
||||||
zoom: 18,
|
|
||||||
padding: this.options.getPadding(),
|
|
||||||
duration: this.options.reducedMotion?.() ? 0 : 600
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const bounds = coordinates.reduce<[number, number, number, number]>(
|
|
||||||
(value, coordinate) => [
|
|
||||||
Math.min(value[0], coordinate[0]),
|
|
||||||
Math.min(value[1], coordinate[1]),
|
|
||||||
Math.max(value[2], coordinate[0]),
|
|
||||||
Math.max(value[3], coordinate[1])
|
|
||||||
],
|
|
||||||
[Infinity, Infinity, -Infinity, -Infinity]
|
|
||||||
);
|
|
||||||
map.fitBounds(
|
|
||||||
[
|
|
||||||
[bounds[0], bounds[1]],
|
|
||||||
[bounds[2], bounds[3]]
|
|
||||||
],
|
|
||||||
{
|
|
||||||
padding: this.options.getPadding(),
|
|
||||||
maxZoom: 18,
|
|
||||||
duration: this.options.reducedMotion?.() ? 0 : 600
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
clearScadaAnalysis() {
|
|
||||||
this.scadaAnalysisRevision += 1;
|
|
||||||
const map = this.options.getMap();
|
|
||||||
(map?.getSource(SCADA_ANALYSIS_SOURCE_ID) as GeoJSONSource | undefined)?.setData(
|
|
||||||
createEmptyScadaAnalysisCollection()
|
|
||||||
);
|
|
||||||
this.patchState({ scadaAnalysis: null, pending: false, errorCode: null });
|
|
||||||
return { cleared: true as const };
|
|
||||||
}
|
|
||||||
|
|
||||||
clearOverlays = () => {
|
clearOverlays = () => {
|
||||||
this.clearHighlight();
|
this.clearHighlight();
|
||||||
this.clearValueLabel();
|
this.clearValueLabel();
|
||||||
this.clearScadaAnalysis();
|
|
||||||
this.flowVisibilityRevision += 1;
|
this.flowVisibilityRevision += 1;
|
||||||
const map = this.options.getMap();
|
const map = this.options.getMap();
|
||||||
if (map) hideFlowOverlay(map, this.flowState);
|
if (map) hideFlowOverlay(map, this.flowState);
|
||||||
this.patchState({ flowVisible: false });
|
this.patchState({ flowVisible: false, pending: false, errorCode: null });
|
||||||
};
|
};
|
||||||
|
|
||||||
resetAll = () => {
|
resetAll = () => {
|
||||||
@@ -536,8 +411,7 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
|||||||
errorCode: null,
|
errorCode: null,
|
||||||
flowVisible: false,
|
flowVisible: false,
|
||||||
flowSummary: createEmptyPipeFlowSummary(),
|
flowSummary: createEmptyPipeFlowSummary(),
|
||||||
styledGroups: [],
|
styledGroups: []
|
||||||
scadaAnalysis: null
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user