修复图片处理后的画布同步基准和冲突恢复
This commit is contained in:
parent
feaa18ed3b
commit
ff6381d02e
@ -4,7 +4,7 @@ import { createCanvasSaveQueue } from "@/lib/client/canvas-save-queue";
|
||||
import { readCanvasDraft, writeCanvasDraft, clearCanvasDraft } from "@/lib/client/canvas-draft";
|
||||
import { mergeCanvasSnapshots } from "@/lib/video-canvas/merge-snapshot";
|
||||
import { BEFORE_PAGE_REFRESH, type PageRefreshRequest } from "@/lib/client/page-refresh";
|
||||
import { CanvasRemoteOperationContext } from "@/components/canvas/CanvasRemoteOperationContext";
|
||||
import { CanvasRemoteOperationContext, type CanvasRemoteOperation, type CanvasRemoteOperationResult } from "@/components/canvas/CanvasRemoteOperationContext";
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
@ -1937,6 +1937,7 @@ export default function CanvasEditorLegacyPage({
|
||||
const canvasReadyRef = useRef(Boolean(initialProjectForCurrentRoute));
|
||||
const savedCanvasSnapshotRef = useRef<{ id: string; snapshot: string } | null>(null);
|
||||
const canvasSyncErrorRef = useRef("");
|
||||
const [canvasSyncConflicts, setCanvasSyncConflicts] = useState<string[]>([]);
|
||||
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
||||
const [isAddImageDialogOpen, setIsAddImageDialogOpen] = useState(false);
|
||||
const [editTitle, setEditTitle] = useState("");
|
||||
@ -3378,13 +3379,17 @@ export default function CanvasEditorLegacyPage({
|
||||
const base = JSON.stringify({ canvasData });
|
||||
savedCanvasSnapshotRef.current = { id, snapshot: base };
|
||||
externalCanvasConflictRef.current = false;
|
||||
setCanvasSyncConflicts([]);
|
||||
const draft = readCanvasDraft(id);
|
||||
if (draft) {
|
||||
const merged = mergeCanvasSnapshots(draft.base, draft.snapshot, base);
|
||||
if (merged.conflicts.length) {
|
||||
externalCanvasConflictRef.current = true;
|
||||
useCanvasStore.getState().loadCanvas(JSON.parse(draft.snapshot).canvasData);
|
||||
toast.warning("同一画布内容在多处被修改,本地草稿已恢复;请保留此页并联系管理员核对冲突。");
|
||||
savedCanvasSnapshotRef.current = { id, snapshot: draft.base };
|
||||
setCanvasSyncConflicts(merged.conflicts);
|
||||
canvasSyncErrorRef.current = "画布同步发生冲突,改动已保留。请在画布上方选择保留本页或云端的冲突内容,再继续操作。";
|
||||
useCanvasStore.getState().loadCanvas(JSON.parse(merged.snapshot).canvasData);
|
||||
toast.warning(canvasSyncErrorRef.current);
|
||||
return;
|
||||
}
|
||||
useCanvasStore.getState().loadCanvas(JSON.parse(merged.snapshot).canvasData);
|
||||
@ -5116,6 +5121,30 @@ export default function CanvasEditorLegacyPage({
|
||||
const canvasPersistenceScope = useRef(id);
|
||||
const canvasRemoteOperationActive = useRef(false);
|
||||
canvasPersistenceScope.current = id;
|
||||
const applyCanvasRemoteSnapshot = useCallback((result: CanvasRemoteOperationResult) => {
|
||||
if (result.projectId !== id || canvasPersistenceScope.current !== id || useCanvasStore.getState().projectId !== id) return false;
|
||||
const remoteSnapshot = JSON.stringify({ canvasData: result.canvasData });
|
||||
const merged = mergeCanvasSnapshots(
|
||||
savedCanvasSnapshotRef.current?.id === id ? savedCanvasSnapshotRef.current.snapshot : remoteSnapshot,
|
||||
JSON.stringify({ canvasData: buildPersistedCanvasData() }),
|
||||
remoteSnapshot,
|
||||
);
|
||||
if (merged.changed) useCanvasStore.getState().loadCanvas(JSON.parse(merged.snapshot).canvasData);
|
||||
useCanvasStore.getState().setCanvasRevision(result.updatedAt, result.updatedAt);
|
||||
if (merged.conflicts.length) {
|
||||
canvasSyncErrorRef.current = "画布同步发生冲突,改动已保留。请在画布上方选择保留本页或云端的冲突内容,再继续操作。";
|
||||
if (!externalCanvasConflictRef.current) toast.warning(canvasSyncErrorRef.current);
|
||||
externalCanvasConflictRef.current = true;
|
||||
setCanvasSyncConflicts(merged.conflicts);
|
||||
writeCanvasDraft(id, { base: savedCanvasSnapshotRef.current?.snapshot || remoteSnapshot, snapshot: merged.snapshot });
|
||||
return false;
|
||||
}
|
||||
savedCanvasSnapshotRef.current = { id, snapshot: remoteSnapshot };
|
||||
externalCanvasConflictRef.current = false;
|
||||
setCanvasSyncConflicts([]);
|
||||
canvasSyncErrorRef.current = "";
|
||||
return true;
|
||||
}, [buildPersistedCanvasData, id]);
|
||||
const persistCanvasStateNow = useCallback(
|
||||
async (reason: "autosave" | "delete" | "beforeunload") => {
|
||||
if (canvasPersistenceScope.current !== id || !canvasReadyRef.current || useCanvasStore.getState().projectId !== id) return false;
|
||||
@ -5162,6 +5191,7 @@ export default function CanvasEditorLegacyPage({
|
||||
if (canvasPersistenceScope.current !== id) return false;
|
||||
} else {
|
||||
externalCanvasConflictRef.current = true;
|
||||
setCanvasSyncConflicts(merged.conflicts);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -5171,7 +5201,7 @@ export default function CanvasEditorLegacyPage({
|
||||
snapshot: JSON.stringify({ canvasData: buildPersistedCanvasData() }),
|
||||
});
|
||||
canvasSyncErrorRef.current = result.status === 409
|
||||
? "画布同步发生冲突,本次任务未提交。当前改动未覆盖服务端内容,请保留此页并联系管理员核对。"
|
||||
? "画布同步发生冲突,本次任务未提交,改动已保留。请在画布上方选择保留本页或云端的冲突内容,再继续操作。"
|
||||
: `画布自动同步失败${result.status ? `(HTTP ${result.status})` : "(网络连接失败)"},本次任务未提交。${retained ? "本地草稿已保留,请检查网络后再次发送。" : "请保留当前页面,检查网络后再次发送,暂勿刷新。"}`;
|
||||
console.error(`[Canvas] ${reason} save failed:`, result.error);
|
||||
} else {
|
||||
@ -5179,7 +5209,11 @@ export default function CanvasEditorLegacyPage({
|
||||
savedCanvasSnapshotRef.current = { id, snapshot };
|
||||
clearCanvasDraft(id);
|
||||
externalCanvasConflictRef.current = false;
|
||||
setCanvasSyncConflicts([]);
|
||||
externalCanvasConflictNotifiedRef.current = false;
|
||||
if (result.data?.canvasData && result.data?.updatedAt) {
|
||||
return applyCanvasRemoteSnapshot({ projectId: id, canvasData: result.data.canvasData, updatedAt: String(result.data.updatedAt) });
|
||||
}
|
||||
if (result.data?.updatedAt) useCanvasStore
|
||||
.getState()
|
||||
.setCanvasRevision(String(result.data.updatedAt), String(result.data.updatedAt));
|
||||
@ -5189,7 +5223,7 @@ export default function CanvasEditorLegacyPage({
|
||||
if (canvasPersistenceScope.current === id) setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[activeCanvasWorkspaceType, buildPersistedCanvasData, id]
|
||||
[activeCanvasWorkspaceType, applyCanvasRemoteSnapshot, buildPersistedCanvasData, id]
|
||||
);
|
||||
|
||||
const persistCanvasState = useCallback(
|
||||
@ -5198,25 +5232,66 @@ export default function CanvasEditorLegacyPage({
|
||||
[persistCanvasStateNow]
|
||||
);
|
||||
|
||||
const resolveCanvasSyncConflict = useCallback((choice: "local" | "remote") => {
|
||||
return canvasSaveQueue.current!.enqueue(async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const remote = await canvasService.getProject(id);
|
||||
if (canvasPersistenceScope.current !== id || useCanvasStore.getState().projectId !== id) return;
|
||||
if (!remote.success || !remote.data?.canvasData || savedCanvasSnapshotRef.current?.id !== id) {
|
||||
throw new Error(remote.error || "画布暂时无法读取,改动已保留,请稍后重试同步。");
|
||||
}
|
||||
const remoteSnapshot = JSON.stringify({ canvasData: remote.data.canvasData });
|
||||
const merged = mergeCanvasSnapshots(
|
||||
savedCanvasSnapshotRef.current.snapshot,
|
||||
JSON.stringify({ canvasData: buildPersistedCanvasData() }),
|
||||
remoteSnapshot,
|
||||
choice,
|
||||
);
|
||||
savedCanvasSnapshotRef.current = { id, snapshot: remoteSnapshot };
|
||||
useCanvasStore.getState().loadCanvas(JSON.parse(merged.snapshot).canvasData);
|
||||
const revision = String(remote.data.updatedAt);
|
||||
useCanvasStore.getState().setCanvasRevision(revision, revision);
|
||||
externalCanvasConflictRef.current = false;
|
||||
canvasSyncErrorRef.current = "";
|
||||
setCanvasSyncConflicts([]);
|
||||
if (!(await persistCanvasStateNow("autosave"))) throw new Error(canvasSyncErrorRef.current);
|
||||
toast.success("画布已合并同步,可以继续操作");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "画布同步失败,改动已保留,请稍后重试。");
|
||||
} finally {
|
||||
if (canvasPersistenceScope.current === id) setIsSaving(false);
|
||||
}
|
||||
});
|
||||
}, [buildPersistedCanvasData, id, persistCanvasStateNow]);
|
||||
|
||||
const runCanvasRemoteOperation = useCallback(
|
||||
(operation: () => Promise<void>) => {
|
||||
(operation: CanvasRemoteOperation) => {
|
||||
if (canvasAutosaveTimer.current) {
|
||||
clearTimeout(canvasAutosaveTimer.current);
|
||||
canvasAutosaveTimer.current = null;
|
||||
}
|
||||
return canvasSaveQueue.current!.enqueueOperation(
|
||||
() => persistCanvasStateNow("autosave"),
|
||||
async () => {
|
||||
const saved = await persistCanvasStateNow("autosave");
|
||||
if (!saved) throw new Error(canvasSyncErrorRef.current || "画布尚未完成加载,本次处理未提交,请等待加载完成后重试。");
|
||||
return true;
|
||||
},
|
||||
async () => {
|
||||
canvasRemoteOperationActive.current = true;
|
||||
try {
|
||||
await operation();
|
||||
const result = await operation();
|
||||
if (result && result.projectId === id && canvasPersistenceScope.current === id) {
|
||||
savedCanvasSnapshotRef.current = { id, snapshot: JSON.stringify({ canvasData: result.canvasData }) };
|
||||
canvasSyncErrorRef.current = "";
|
||||
}
|
||||
} finally {
|
||||
canvasRemoteOperationActive.current = false;
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
[persistCanvasStateNow]
|
||||
[persistCanvasStateNow, id]
|
||||
);
|
||||
|
||||
const persistCanvasBeforeAgent = useCallback(async () => {
|
||||
@ -5236,6 +5311,11 @@ export default function CanvasEditorLegacyPage({
|
||||
return true;
|
||||
}, [persistCanvasState, buildPersistedCanvasData, id]);
|
||||
|
||||
const canvasRemoteOperationContext = useMemo(() => ({
|
||||
run: runCanvasRemoteOperation,
|
||||
applySnapshot: applyCanvasRemoteSnapshot,
|
||||
}), [runCanvasRemoteOperation, applyCanvasRemoteSnapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVideoMode) return;
|
||||
const beforeRefresh = (event: Event) => {
|
||||
@ -10413,7 +10493,7 @@ export default function CanvasEditorLegacyPage({
|
||||
}
|
||||
|
||||
return (
|
||||
<CanvasRemoteOperationContext.Provider value={runCanvasRemoteOperation}>
|
||||
<CanvasRemoteOperationContext.Provider value={canvasRemoteOperationContext}>
|
||||
<TooltipProvider>
|
||||
<div
|
||||
data-canvas-panel-open={isPanelOpen ? "true" : "false"}
|
||||
@ -10429,6 +10509,16 @@ export default function CanvasEditorLegacyPage({
|
||||
}
|
||||
>
|
||||
<CanvasAgentUiListener projectId={id} workspace="canvas" />
|
||||
{canvasSyncConflicts.length > 0 ? (
|
||||
<div role="alert" className="absolute inset-x-3 top-28 z-[60] mx-auto flex max-w-2xl flex-wrap items-center gap-3 rounded-lg border bg-background p-4 text-sm shadow-md">
|
||||
<div className="min-w-0 flex-1 basis-64">
|
||||
<p className="font-medium">画布同步发生冲突</p>
|
||||
<p className="mt-1 text-muted-foreground">本页和云端的同一内容有不同修改。请选择保留哪边的冲突内容,其余图片和修改会合并保留。</p>
|
||||
</div>
|
||||
<Button disabled={isSaving} onClick={() => void resolveCanvasSyncConflict("local")}>保留本页修改</Button>
|
||||
<Button disabled={isSaving} variant="outline" onClick={() => void resolveCanvasSyncConflict("remote")}>保留云端修改</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{/* 1. Canvas Layer (Full Screen) - 使用 key 强制在 id 变化时重新挂载 */}
|
||||
<div
|
||||
className="absolute inset-0 z-0"
|
||||
|
||||
@ -6,7 +6,7 @@ import { useStickToBottom } from "use-stick-to-bottom";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
import { shouldUseCanvasAgentRuntime } from "@/lib/ai/canvas-agent-runtime/routing";
|
||||
import { CanvasAgentProgressMessage } from "@/components/canvas/chat-panel/canvas-agent-progress";
|
||||
import { useCanvasRemoteOperation } from "@/components/canvas/CanvasRemoteOperationContext";
|
||||
import { useCanvasRemoteOperation, useCanvasRemoteSnapshot } from "@/components/canvas/CanvasRemoteOperationContext";
|
||||
import { readCanvasComposerDraft, writeCanvasComposerDraft, clearCanvasComposerDraft } from "@/lib/client/canvas-draft";
|
||||
import { BEFORE_PAGE_REFRESH, type PageRefreshRequest } from "@/lib/client/page-refresh";
|
||||
import { canvasHistoryForChat } from "@/lib/client/canvas-history-response";
|
||||
@ -3248,6 +3248,7 @@ export function CanvasChatPanel({
|
||||
onBeforeAgentRequest,
|
||||
}: CanvasChatPanelProps) {
|
||||
const runCanvasRemoteOperation = useCanvasRemoteOperation();
|
||||
const applyCanvasRemoteSnapshot = useCanvasRemoteSnapshot();
|
||||
const isWorkspacePresentation = presentation === "workspace";
|
||||
const isVideoWorkspace = workspaceVariant === "video";
|
||||
const propCanvasToolbarConfig = useMemo(
|
||||
@ -7990,8 +7991,12 @@ export function CanvasChatPanel({
|
||||
|
||||
const store = useCanvasStore.getState();
|
||||
store.markAgentOperationRefresh();
|
||||
store.loadCanvas(result.data.canvasData);
|
||||
store.setCanvasRevision(String(result.data.updatedAt || ""), String(result.data.updatedAt || ""));
|
||||
if (applyCanvasRemoteSnapshot) {
|
||||
if (!applyCanvasRemoteSnapshot({ projectId: targetProjectId, canvasData: result.data.canvasData, updatedAt: String(result.data.updatedAt || "") })) return false;
|
||||
} else {
|
||||
store.loadCanvas(result.data.canvasData);
|
||||
store.setCanvasRevision(String(result.data.updatedAt || ""), String(result.data.updatedAt || ""));
|
||||
}
|
||||
if (createdNodeIds.length > 0) {
|
||||
const refreshedStore = useCanvasStore.getState();
|
||||
refreshedStore.selectNodes(createdNodeIds);
|
||||
@ -8019,7 +8024,7 @@ export function CanvasChatPanel({
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[applyCanvasViewOperation, canvasId, focusCanvasOnNode, mutate]
|
||||
[applyCanvasRemoteSnapshot, applyCanvasViewOperation, canvasId, focusCanvasOnNode, mutate]
|
||||
);
|
||||
|
||||
const {
|
||||
|
||||
@ -1,11 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
import type { CanvasState } from "@/types/canvas";
|
||||
|
||||
export type CanvasRemoteOperationResult = {
|
||||
projectId: string;
|
||||
canvasData: CanvasState;
|
||||
updatedAt: string;
|
||||
};
|
||||
export type CanvasRemoteOperation = (() => Promise<void>) | (() => Promise<CanvasRemoteOperationResult | undefined>);
|
||||
|
||||
export const CanvasRemoteOperationContext = createContext<
|
||||
((operation: () => Promise<void>) => Promise<void>) | null
|
||||
{
|
||||
run: (operation: CanvasRemoteOperation) => Promise<void>;
|
||||
applySnapshot: (result: CanvasRemoteOperationResult) => boolean;
|
||||
} | null
|
||||
>(null);
|
||||
|
||||
export function useCanvasRemoteOperation() {
|
||||
return useContext(CanvasRemoteOperationContext);
|
||||
return useContext(CanvasRemoteOperationContext)?.run || null;
|
||||
}
|
||||
|
||||
export function useCanvasRemoteSnapshot() {
|
||||
return useContext(CanvasRemoteOperationContext)?.applySnapshot || null;
|
||||
}
|
||||
|
||||
@ -63,4 +63,5 @@ export async function executeCanvasToolbarCapability(
|
||||
}
|
||||
current.loadCanvas(JSON.parse(merged.snapshot).canvasData);
|
||||
current.setCanvasRevision(String(result.data.updatedAt), String(result.data.updatedAt));
|
||||
return { projectId, canvasData: result.data.canvasData, updatedAt: String(result.data.updatedAt) };
|
||||
}
|
||||
|
||||
@ -107,5 +107,5 @@ export function mergeCanvasSnapshots(
|
||||
(e: any) => ids.has(e.source) && ids.has(e.target),
|
||||
);
|
||||
}
|
||||
return { snapshot: JSON.stringify(snapshot), conflicts };
|
||||
return { snapshot: JSON.stringify(snapshot), conflicts, changed: !same(snapshot, local) };
|
||||
}
|
||||
|
||||
@ -10,8 +10,9 @@ async function setup(page: Page, failRead = false) {
|
||||
const state = {
|
||||
canvasData: { nodes: [imageNode("source")], edges: [], selectedNodeIds: [], viewport: { x: 0, y: 0, zoom: 1 }, tool: "select" },
|
||||
revision: 1, failSave: false, failRead, conflictNextSave: false, conflictSameNode: false,
|
||||
writes: [] as any[], metadataWrites: 0, submissions: [] as any[],
|
||||
writes: [] as any[], metadataWrites: 0, submissions: [] as any[], capabilitySubmissions: [] as any[],
|
||||
appVersion: "development", holdRequest: false, finishRequest: null as (() => void) | null,
|
||||
nativeResultNext: false,
|
||||
};
|
||||
const revision = () => new Date(Date.UTC(2026, 10, 6, 0, 0, state.revision)).toISOString();
|
||||
const project = () => ({ id: projectId, title: "同步验证", canvasData: state.canvasData, updatedAt: revision() });
|
||||
@ -53,11 +54,23 @@ async function setup(page: Page, failRead = false) {
|
||||
data = { success: true, previousCanvasRevision, data: project() };
|
||||
}
|
||||
}
|
||||
} else if (path === `/api/canvas/${projectId}/capabilities` && request.method() === "POST") {
|
||||
state.capabilitySubmissions.push(request.postDataJSON());
|
||||
state.canvasData.nodes.push({ ...imageNode(`enhanced-${state.capabilitySubmissions.length}`), position: { x: 520, y: 130 } });
|
||||
state.revision++;
|
||||
data = { success: true, task: { status: "succeeded" } };
|
||||
} else if (path === "/api/canvas/agent-chat" && request.method() === "POST") {
|
||||
state.submissions.push(request.postDataJSON());
|
||||
let operation = "";
|
||||
if (state.nativeResultNext) {
|
||||
state.nativeResultNext = false;
|
||||
state.canvasData.nodes.push({ ...imageNode("agent-result"), position: { x: 520, y: 130 } });
|
||||
state.revision++;
|
||||
operation = `data: ${JSON.stringify({ type: "data-canvasOperation", data: { status: "applied", projectId, createdNodeIds: ["agent-result"] } })}\n\n`;
|
||||
}
|
||||
if (state.holdRequest) await new Promise<void>(resolve => { state.finishRequest = resolve; });
|
||||
await route.fulfill({ headers: { "content-type": "text/event-stream", "x-vercel-ai-ui-message-stream": "v1" }, body:
|
||||
'data: {"type":"start","messageId":"sync-assistant"}\n\ndata: {"type":"text-start","id":"text"}\n\ndata: {"type":"text-delta","id":"text","delta":"已收到请求"}\n\ndata: {"type":"text-end","id":"text"}\n\ndata: {"type":"finish"}\n\ndata: [DONE]\n\n' });
|
||||
'data: {"type":"start","messageId":"sync-assistant"}\n\n' + operation + 'data: {"type":"text-start","id":"text"}\n\ndata: {"type":"text-delta","id":"text","delta":"已收到请求"}\n\ndata: {"type":"text-end","id":"text"}\n\ndata: {"type":"finish"}\n\ndata: [DONE]\n\n' });
|
||||
return;
|
||||
} else if (path === "/api/app-version") data = { version: state.appVersion };
|
||||
else if (path === "/api/files/direct-upload") {
|
||||
@ -93,6 +106,72 @@ async function moveImage(page: Page) {
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
test("an enhanced result can be moved and archived before the next save without blocking Agent", async ({ page }) => {
|
||||
const { state, errors } = await setup(page);
|
||||
await page.locator('[data-canvas-node-id="source"]').click();
|
||||
await page.locator('[data-tool-key="enhance"]').click();
|
||||
const result = page.locator('[data-canvas-node-id="enhanced-1"]');
|
||||
await expect(result).toBeVisible();
|
||||
const box = (await result.boundingBox())!;
|
||||
await page.mouse.move(box.x + 100, box.y + 80);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + 165, box.y + 110, { steps: 5 });
|
||||
await page.mouse.up();
|
||||
state.canvasData.nodes.find(node => node.id === "enhanced-1")!.data.src = `${imageOrigin}/uploads/archived-result.png`;
|
||||
state.revision++;
|
||||
const editor = page.getByLabel("描述你的设计需求", { exact: true });
|
||||
await editor.fill("保留增强结果的位置,继续分析");
|
||||
await editor.press("Enter");
|
||||
await expect.poll(() => state.submissions.length).toBe(1);
|
||||
const saved = state.canvasData.nodes.find(node => node.id === "enhanced-1")!;
|
||||
expect(saved.position.x).toBeGreaterThan(520);
|
||||
expect(saved.data.src).toContain("archived-result.png");
|
||||
expect(state.capabilitySubmissions).toHaveLength(1);
|
||||
await page.reload();
|
||||
await expect(page.locator('[data-canvas-node-id="enhanced-1"]')).toBeVisible();
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("toolbar save failures show the cause and recover without a paid submission", async ({ page }) => {
|
||||
const { state, errors } = await setup(page);
|
||||
await moveImage(page);
|
||||
state.failSave = true;
|
||||
await page.locator('[data-tool-key="enhance"]').click();
|
||||
await expect(page.getByText(/画布自动同步失败(HTTP 503)/)).toBeVisible();
|
||||
expect(state.capabilitySubmissions).toHaveLength(0);
|
||||
state.failSave = false;
|
||||
await page.locator('[data-tool-key="enhance"]').click();
|
||||
await expect(page.locator('[data-canvas-node-id="enhanced-1"]')).toBeVisible();
|
||||
expect(state.capabilitySubmissions).toHaveLength(1);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("native Agent results update the save baseline before moving and archiving the result", async ({ page }) => {
|
||||
const { state, errors } = await setup(page);
|
||||
state.nativeResultNext = true;
|
||||
const editor = page.getByLabel("描述你的设计需求", { exact: true });
|
||||
await editor.fill("先分析再处理图片");
|
||||
await editor.press("Enter");
|
||||
const result = page.locator('[data-canvas-node-id="agent-result"]');
|
||||
await expect(result).toBeVisible();
|
||||
await expect(editor).toBeEditable();
|
||||
const box = (await result.boundingBox())!;
|
||||
await page.mouse.move(box.x + 100, box.y + 80);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + 165, box.y + 110, { steps: 5 });
|
||||
await page.mouse.up();
|
||||
state.canvasData.nodes.find(node => node.id === "agent-result")!.data.src = `${imageOrigin}/uploads/agent-archived.png`;
|
||||
state.revision++;
|
||||
await editor.fill("保留位置,继续分析");
|
||||
await editor.press("Enter");
|
||||
await expect.poll(() => state.submissions.length).toBe(2);
|
||||
const saved = state.canvasData.nodes.find(node => node.id === "agent-result")!;
|
||||
expect(saved.position.x).toBeGreaterThan(520);
|
||||
expect(saved.data.src).toContain("agent-archived.png");
|
||||
expect(state.capabilitySubmissions).toHaveLength(0);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("upload naming and consecutive Agent messages sync automatically without manual saves", async ({ page }) => {
|
||||
const { state, errors } = await setup(page);
|
||||
const editor = page.getByLabel("描述你的设计需求", { exact: true });
|
||||
@ -169,6 +248,39 @@ test("conflicting edits keep both canvases and do not submit an Agent task", asy
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
for (const choice of ["local", "remote"] as const) {
|
||||
test(`an existing conflicting draft can keep the ${choice} edit and resume without losing other images`, async ({ page }) => {
|
||||
const { state, errors } = await setup(page);
|
||||
await moveImage(page);
|
||||
state.conflictSameNode = true;
|
||||
state.conflictNextSave = true;
|
||||
const editor = page.getByLabel("描述你的设计需求", { exact: true });
|
||||
await editor.fill("继续分析图片");
|
||||
await editor.press("Enter");
|
||||
await expect(page.getByRole("button", { name: "保留本页修改", exact: true })).toBeVisible();
|
||||
state.canvasData.nodes.push(imageNode("remote-addition"));
|
||||
state.revision++;
|
||||
await page.reload();
|
||||
await expect(page.locator('[data-canvas-node-id="remote-addition"]')).toBeVisible();
|
||||
if (choice === "local") {
|
||||
await page.screenshot({ path: test.info().outputPath("conflict-recovery-desktop.png"), animations: "disabled" });
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.screenshot({ path: test.info().outputPath("conflict-recovery-mobile.png"), animations: "disabled" });
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
}
|
||||
await page.getByRole("button", { name: choice === "local" ? "保留本页修改" : "保留云端修改", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: "保留本页修改", exact: true })).toHaveCount(0);
|
||||
await editor.press("Enter");
|
||||
await expect.poll(() => state.submissions.length).toBe(1);
|
||||
const x = state.canvasData.nodes.find(node => node.id === "source")!.position.x;
|
||||
if (choice === "remote") expect(x).toBe(500);
|
||||
else { expect(x).toBeGreaterThan(160); expect(x).not.toBe(500); }
|
||||
expect(state.canvasData.nodes.map(node => node.id)).toEqual(["source", "remote-addition"]);
|
||||
expect(state.capabilitySubmissions).toHaveLength(0);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
test("a failed canvas read cannot create or save an empty replacement", async ({ page }) => {
|
||||
const { state, errors } = await setup(page, true);
|
||||
await expect(page.getByRole("alert").filter({ hasText: "画布暂时无法读取" })).toBeVisible();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user