修复无限画布恢复与幂等链路
Project CI / Repository checks (pull_request) Successful in 1m0s
Project CI / Frontend tests (pull_request) Successful in 2m57s
Project CI / Backend tests (pull_request) Successful in 3m48s
Project CI / Native shell tests (pull_request) Failing after 11m11s

补齐 refine 草稿跨重启唯一恢复与生命周期门禁

完善 Provider durable handoff、认证恢复和旧密钥服务确认

修复资源事务前向恢复与异步导入迟到写入

补充前后端回归测试并同步权威文档与项目记忆
This commit is contained in:
2026-08-12 10:21:18 +08:00
parent 912df59c2b
commit daaef0917f
13 changed files with 2115 additions and 230 deletions
@@ -271,6 +271,14 @@ pub(crate) fn read_local_project_asset_canvas_draft(
read_asset_canvas_draft_at(&root, &input)
}
#[tauri::command]
pub(crate) fn discover_local_project_asset_canvas_draft(
input: DiscoverAssetCanvasDraftInput,
) -> Result<DiscoverAssetCanvasDraftResult, String> {
let root = validated_local_project_directory_path(input.project_path.trim())?;
discover_asset_canvas_draft_at(&root, &input)
}
#[tauri::command]
pub(crate) fn update_local_project_asset_canvas_draft(
input: UpdateAssetCanvasDraftInput,
@@ -1271,6 +1279,24 @@ pub(crate) async fn resume_local_project_resource_edit(
resume_local_project_resource_edit_at(input).await
}
#[tauri::command]
pub(crate) async fn request_local_project_resource_edit_service_identity_confirmation(
input: RequestResourceEditServiceIdentityConfirmationInput,
) -> Result<ResourceEditServiceIdentityConfirmation, String> {
let root = Path::new(input.project_path.trim());
enforce_project_permission_policy(root, "asset.register")?;
request_resource_edit_service_identity_confirmation_at(input).await
}
#[tauri::command]
pub(crate) async fn confirm_local_project_resource_edit_service_identity(
input: ConfirmResourceEditServiceIdentityInput,
) -> Result<ConfirmResourceEditServiceIdentityResult, String> {
let root = Path::new(input.project_path.trim());
enforce_project_permission_policy(root, "asset.register")?;
confirm_resource_edit_service_identity_at(input).await
}
#[tauri::command]
pub(crate) async fn archive_failed_local_project_resource_edit(
input: ArchiveFailedLocalProjectResourceEditInput,
@@ -2212,6 +2212,8 @@ fn main() {
derive_local_project_resource,
list_pending_local_project_resource_edits,
resume_local_project_resource_edit,
request_local_project_resource_edit_service_identity_confirmation,
confirm_local_project_resource_edit_service_identity,
archive_failed_local_project_resource_edit,
normalize_local_project_raster_resource,
import_canvas_asset,
@@ -2263,6 +2265,7 @@ fn main() {
update_local_project_resource_canvas_layout,
create_local_project_asset_canvas_draft,
read_local_project_asset_canvas_draft,
discover_local_project_asset_canvas_draft,
update_local_project_asset_canvas_draft,
store_local_project_asset_canvas_media,
stage_local_project_asset_canvas_image,
@@ -23,6 +23,7 @@ const ASSET_CANVAS_MAX_DRAFT_MEDIA_BYTES: u64 = 512 * 1024 * 1024;
const ASSET_CANVAS_MAX_LAYERS: usize = 4096;
const ASSET_CANVAS_MAX_GENERATIONS: usize = 64;
const ASSET_CANVAS_MAX_REFERENCES: usize = 128;
const ASSET_CANVAS_MAX_DRAFT_SCAN_ENTRIES: usize = 4096;
const ASSET_CANVAS_MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
const ASSET_CANVAS_MAX_DIMENSION: u32 = 16_384;
const ASSET_CANVAS_MAX_PIXELS: u64 = 268_435_456;
@@ -248,6 +249,29 @@ pub(crate) struct ReadAssetCanvasDraftResult {
pub(crate) draft: Option<AssetCanvasDraft>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(crate) struct DiscoverAssetCanvasDraftInput {
pub(crate) project_path: String,
pub(crate) expected_project_id: String,
pub(crate) intent: AssetCanvasIntent,
pub(crate) source_asset_id: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum DiscoverAssetCanvasDraftStatus {
Found,
NotFound,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DiscoverAssetCanvasDraftResult {
pub(crate) status: DiscoverAssetCanvasDraftStatus,
pub(crate) draft: Option<AssetCanvasDraft>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(crate) struct UpdateAssetCanvasDraftInput {
@@ -1231,6 +1255,94 @@ pub(crate) fn read_asset_canvas_draft_at(
})
}
pub(crate) fn discover_asset_canvas_draft_at(
root: &Path,
input: &DiscoverAssetCanvasDraftInput,
) -> Result<DiscoverAssetCanvasDraftResult, String> {
match (&input.intent, input.source_asset_id.as_deref()) {
(AssetCanvasIntent::Create, None) => {}
(AssetCanvasIntent::Refine, Some(source_asset_id)) => {
validate_plain_component(source_asset_id, "sourceAssetId", 512)?;
}
(AssetCanvasIntent::Create, Some(_)) => {
return Err("create 草稿发现不能指定 sourceAssetId".to_string());
}
(AssetCanvasIntent::Refine, None) => {
return Err("refine 草稿发现必须指定 sourceAssetId".to_string());
}
}
let preflight = validate_asset_canvas_project_identity(root, &input.expected_project_id)?;
let _lock = acquire_asset_canvas_draft_lock(root)?;
let manifest = validate_asset_canvas_project_identity(root, &preflight.project_id)?;
if preflight.project_id != manifest.project_id {
return Err("项目身份在素材画布草稿发现期间发生变化".to_string());
}
let directory = resolve_local_project_path(root, &format!("{ASSET_CANVAS_ROOT}/drafts"))?;
let entries = match fs::read_dir(&directory) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(DiscoverAssetCanvasDraftResult {
status: DiscoverAssetCanvasDraftStatus::NotFound,
draft: None,
});
}
Err(error) => return Err(format!("读取素材画布草稿目录失败:{error}")),
};
let mut scanned_entries = 0_usize;
let mut matching = Vec::new();
for entry in entries {
let entry = entry.map_err(|error| format!("读取素材画布草稿条目失败:{error}"))?;
scanned_entries += 1;
if scanned_entries > ASSET_CANVAS_MAX_DRAFT_SCAN_ENTRIES {
return Err("素材画布草稿数量超过安全扫描上限".to_string());
}
let path = entry.path();
if path.extension().and_then(|value| value.to_str()) != Some("json") {
continue;
}
if !entry
.file_type()
.map_err(|error| format!("读取素材画布草稿类型失败:{error}"))?
.is_file()
{
return Err("素材画布草稿条目必须是普通 JSON 文件".to_string());
}
let draft_id = path
.file_stem()
.and_then(|value| value.to_str())
.ok_or_else(|| "素材画布草稿文件名无效".to_string())?;
validate_uuid_v4(draft_id, "草稿文件名")?;
let draft = read_asset_canvas_draft_locked(root, &manifest.project_id, draft_id)?
.ok_or_else(|| "素材画布草稿扫描结果不一致".to_string())?;
if draft.intent == input.intent
&& draft.source_asset_id == input.source_asset_id
&& matches!(
draft.status,
AssetCanvasDraftStatus::Editing
| AssetCanvasDraftStatus::Generating
| AssetCanvasDraftStatus::CommitPrepared
| AssetCanvasDraftStatus::ReconciliationRequired
)
{
matching.push(draft);
}
}
match matching.len() {
0 => Ok(DiscoverAssetCanvasDraftResult {
status: DiscoverAssetCanvasDraftStatus::NotFound,
draft: None,
}),
1 => Ok(DiscoverAssetCanvasDraftResult {
status: DiscoverAssetCanvasDraftStatus::Found,
draft: matching.pop(),
}),
_ => Err(
"reconciliation-required: 同一来源存在多个可恢复素材画布草稿,不能猜测恢复目标"
.to_string(),
),
}
}
pub(crate) fn update_asset_canvas_draft_at(
root: &Path,
input: &UpdateAssetCanvasDraftInput,
@@ -328,6 +328,73 @@ fn refine_preserves_source_and_records_non_destructive_lineage() {
.contains(&"local-asset:source-asset".to_string()));
}
#[test]
fn discovers_unique_active_refine_draft_and_rejects_ambiguous_candidates() {
let directory = tempfile::tempdir().expect("create refine discovery fixture");
let root = directory.path();
init_local_game_project_at(root, PROJECT_ID, "精修草稿发现测试项目")
.expect("initialize project");
let source_bytes = png_bytes([80, 120, 160, 255]);
fs::write(root.join("assets/source.png"), source_bytes).expect("write source asset");
let manifest_path = root.join(".agent/manifest.json");
let mut manifest = read_manifest(&manifest_path).expect("read manifest");
manifest.assets.push(GameCreationAppAssetManifestEntry {
id: "source-asset".to_string(),
kind: "illustration".to_string(),
media_type: "image/png".to_string(),
local_path: "assets/source.png".to_string(),
source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Uploaded,
canvas_project_id: None,
resource_id: None,
asset_object_id: None,
task_id: None,
prompt: None,
model: None,
generation_route: None,
generation_kind: None,
reference_resource_ids: Vec::new(),
},
});
write_manifest(&manifest_path, &manifest).expect("register source asset");
let discovery = DiscoverAssetCanvasDraftInput {
project_path: project_path(root),
expected_project_id: PROJECT_ID.to_string(),
intent: AssetCanvasIntent::Refine,
source_asset_id: Some("source-asset".to_string()),
};
let first_id = Uuid::new_v4().to_string();
create_asset_canvas_draft_at(
root,
&create_input(
root,
&first_id,
AssetCanvasIntent::Refine,
Some("source-asset".to_string()),
),
)
.expect("create first refine draft");
let found = discover_asset_canvas_draft_at(root, &discovery).expect("discover refine draft");
assert_eq!(found.status, DiscoverAssetCanvasDraftStatus::Found);
assert_eq!(found.draft.expect("found draft").draft_id, first_id);
let second_id = Uuid::new_v4().to_string();
create_asset_canvas_draft_at(
root,
&create_input(
root,
&second_id,
AssetCanvasIntent::Refine,
Some("source-asset".to_string()),
),
)
.expect("create second refine draft");
let error = discover_asset_canvas_draft_at(root, &discovery)
.expect_err("ambiguous refine drafts must fail closed");
assert!(error.contains("多个可恢复素材画布草稿"));
}
#[test]
fn draft_cas_continuous_saves_and_trusted_restart_recovery() {
let fixture = initialize_fixture();
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -98,6 +98,16 @@ type ResourceSortMode = ProjectResourceCanvasLayoutMode;
type WorkbenchMode = 'resources' | 'run';
type ApprovalMode = 'strict' | 'risk' | 'none';
type DiscoverAssetCanvasDraftResult = {
status: 'found' | 'not-found';
draft: {
draftId: string;
projectId: string;
intent: 'create' | 'refine';
sourceAssetId: string | null;
} | null;
};
type AssetCanvasRoute = {
flowId: string;
sessionId: string;
@@ -136,6 +146,15 @@ type PendingLocalProjectResourceEdit = {
type PendingResourceEditsLoadState = 'idle' | 'loading' | 'ready' | 'failed';
type ResourceEditServiceIdentityConfirmation = {
operationId: string;
remoteOperationId: string | null;
operationState: string;
serviceOrigin: string;
challenge: string;
expiresAt: number;
};
function pendingResourceEditKindLabel(editKind: string) {
if (editKind === 'image') return '图片编辑';
if (editKind === 'text') return '文本编辑';
@@ -549,6 +568,12 @@ export default function ProjectDevelopmentView({
useState<Set<string>>(() => new Set());
const [pendingResourceEditActionErrors, setPendingResourceEditActionErrors] =
useState<Map<string, string>>(() => new Map());
const [
resourceEditServiceIdentityConfirmations,
setResourceEditServiceIdentityConfirmations,
] = useState<Map<string, ResourceEditServiceIdentityConfirmation>>(
() => new Map(),
);
const [hiddenCommittedResourceId, setHiddenCommittedResourceId] = useState<
string | null
>(null);
@@ -586,7 +611,6 @@ export default function ProjectDevelopmentView({
const focusedCommitIdsRef = useRef(new Set<string>());
const activeFocusFlowIdRef = useRef<string | null>(null);
const assetCanvasRouteRef = useRef<AssetCanvasRoute | null>(null);
const resumableRefineDraftsRef = useRef(new Map<string, string>());
const resourceEditorRevisionRef = useRef(new Map<string, number>());
const pendingResourceEditsReadEpochRef = useRef(0);
const pendingResourceEditActionIdsRef = useRef(new Set<string>());
@@ -1057,6 +1081,7 @@ export default function ProjectDevelopmentView({
setPendingResourceEditsLoadState('idle');
setPendingResourceEditsError('');
setPendingResourceEditActionErrors(new Map());
setResourceEditServiceIdentityConfirmations(new Map());
resourceRecoveryPanelEpochRef.current += 1;
setResourceRecoveryPanelOpen(false);
pendingResourceEditActionIdsRef.current = new Set();
@@ -1472,7 +1497,7 @@ export default function ProjectDevelopmentView({
);
const beginAssetCanvas = useCallback(
(
async (
sourceAsset: GameCreationAppAssetManifestEntry,
expectedProjectRevision: number,
) => {
@@ -1484,13 +1509,55 @@ export default function ProjectDevelopmentView({
advanceFocusGeneration();
pendingResourceFocusRef.current = null;
setHiddenCommittedResourceId(null);
const openEpoch = canvasOpenEpochRef.current + 1;
canvasOpenEpochRef.current = openEpoch;
const flowId = crypto.randomUUID();
const sessionId = crypto.randomUUID();
let discovered: DiscoverAssetCanvasDraftResult;
try {
discovered = await invoke<DiscoverAssetCanvasDraftResult>(
'discover_local_project_asset_canvas_draft',
{
input: {
projectPath,
expectedProjectId: manifest.projectId,
intent: 'refine',
sourceAssetId: sourceAsset.id,
},
},
);
} catch (error) {
if (canvasOpenEpochRef.current === openEpoch) {
setAssetCanvasNotice(
error instanceof Error ? error.message : String(error),
);
}
return;
}
const currentProject = currentProjectIdentityRef.current;
if (
canvasOpenEpochRef.current !== openEpoch ||
currentProject.projectPath !== projectPath ||
currentProject.projectId !== manifest.projectId
) {
return;
}
const draftId =
discovered.status === 'found' &&
discovered.draft?.projectId === manifest.projectId &&
discovered.draft.intent === 'refine' &&
discovered.draft.sourceAssetId === sourceAsset.id
? discovered.draft.draftId
: discovered.status === 'not-found' && discovered.draft === null
? crypto.randomUUID()
: null;
if (!draftId) {
setAssetCanvasNotice('可恢复素材草稿与当前资源身份不一致');
return;
}
const scope: ImageCanvasHostScope = {
projectId: manifest.projectId,
draftId:
resumableRefineDraftsRef.current.get(sourceAsset.id) ??
crypto.randomUUID(),
draftId,
intent: 'refine',
sourceAssetId: sourceAsset.id,
};
@@ -1547,7 +1614,7 @@ export default function ProjectDevelopmentView({
if (!Number.isSafeInteger(status.revision) || status.revision < 0) {
throw new Error('项目 revision 无效');
}
beginAssetCanvas(sourceAsset, status.revision);
void beginAssetCanvas(sourceAsset, status.revision);
})
.catch((error: unknown) => {
if (canvasOpenEpochRef.current !== openEpoch) {
@@ -1620,7 +1687,10 @@ export default function ProjectDevelopmentView({
source: 'asset-command',
commitId: `normalize:${normalized.asset.id}`,
});
beginAssetCanvas(normalized.asset, normalized.committedProjectRevision);
void beginAssetCanvas(
normalized.asset,
normalized.committedProjectRevision,
);
} catch (error) {
if (canvasOpenEpochRef.current === openEpoch) {
setAssetCanvasNotice(
@@ -1781,9 +1851,47 @@ export default function ProjectDevelopmentView({
} catch (error) {
if (!isCurrentRecoveryProject(actionProject)) return;
const message = error instanceof Error ? error.message : String(error);
let displayMessage = message;
if (message.includes('service-identity-confirmation-required')) {
try {
const confirmation =
await invoke<ResourceEditServiceIdentityConfirmation>(
'request_local_project_resource_edit_service_identity_confirmation',
{
input: {
projectPath: actionProject.projectPath,
expectedProjectId: actionProject.projectId,
operationId: pending.operationId,
},
},
);
if (
!isCurrentRecoveryProject(actionProject) ||
confirmation.operationId !== pending.operationId ||
!confirmation.serviceOrigin ||
!confirmation.challenge
) {
throw new Error(
'资源编辑服务身份确认结果与当前 operation 不一致',
);
}
setResourceEditServiceIdentityConfirmations((current) => {
const next = new Map(current);
next.set(pending.operationId, confirmation);
return next;
});
displayMessage = `请确认“${confirmation.serviceOrigin}”仍是该 operation 的原服务后再继续`;
} catch (confirmationError) {
if (!isCurrentRecoveryProject(actionProject)) return;
displayMessage =
confirmationError instanceof Error
? confirmationError.message
: String(confirmationError);
}
}
setPendingResourceEditActionErrors((current) => {
const next = new Map(current);
next.set(pending.operationId, message);
next.set(pending.operationId, displayMessage);
return next;
});
await refreshPendingResourceEdits();
@@ -1809,6 +1917,83 @@ export default function ProjectDevelopmentView({
],
);
const confirmPendingResourceEditServiceIdentity = useCallback(
async (pending: PendingLocalProjectResourceEdit) => {
const confirmation = resourceEditServiceIdentityConfirmations.get(
pending.operationId,
);
const invoke = window.__TAURI__?.core?.invoke;
const actionProject = {
projectPath,
projectId: manifest.projectId,
};
if (
!confirmation ||
!invoke ||
!isCurrentRecoveryProject(actionProject) ||
!beginPendingResourceEditAction(pending.operationId)
) {
return;
}
let shouldResume = false;
setPendingResourceEditActionErrors((current) => {
if (!current.has(pending.operationId)) return current;
const next = new Map(current);
next.delete(pending.operationId);
return next;
});
try {
await invoke('confirm_local_project_resource_edit_service_identity', {
input: {
projectPath: actionProject.projectPath,
expectedProjectId: actionProject.projectId,
operationId: pending.operationId,
remoteOperationId: confirmation.remoteOperationId,
challenge: confirmation.challenge,
},
});
if (!isCurrentRecoveryProject(actionProject)) return;
setResourceEditServiceIdentityConfirmations((current) => {
if (!current.has(pending.operationId)) return current;
const next = new Map(current);
next.delete(pending.operationId);
return next;
});
shouldResume = true;
} catch (error) {
if (!isCurrentRecoveryProject(actionProject)) return;
const message = error instanceof Error ? error.message : String(error);
setResourceEditServiceIdentityConfirmations((current) => {
if (!current.has(pending.operationId)) return current;
const next = new Map(current);
next.delete(pending.operationId);
return next;
});
setPendingResourceEditActionErrors((current) => {
const next = new Map(current);
next.set(pending.operationId, message);
return next;
});
} finally {
if (isCurrentRecoveryProject(actionProject)) {
finishPendingResourceEditAction(pending.operationId);
}
}
if (shouldResume && isCurrentRecoveryProject(actionProject)) {
void resumePendingResourceEdit(pending);
}
},
[
beginPendingResourceEditAction,
finishPendingResourceEditAction,
isCurrentRecoveryProject,
manifest.projectId,
projectPath,
resourceEditServiceIdentityConfirmations,
resumePendingResourceEdit,
],
);
const archiveFailedPendingResourceEdit = useCallback(
async (pending: PendingLocalProjectResourceEdit) => {
const invoke = window.__TAURI__?.core?.invoke;
@@ -1979,16 +2164,7 @@ export default function ProjectDevelopmentView({
);
const cancelAssetCanvas = useCallback(
(result: AssetCanvasExitResult) => {
const route = assetCanvasRouteRef.current;
const sourceAssetId = route?.scope.sourceAssetId;
if (sourceAssetId) {
if (result.disposition === 'kept') {
resumableRefineDraftsRef.current.set(sourceAssetId, result.draftId);
} else {
resumableRefineDraftsRef.current.delete(sourceAssetId);
}
}
(_result: AssetCanvasExitResult) => {
canvasOpenEpochRef.current += 1;
advanceFocusGeneration();
activeFocusFlowIdRef.current = null;
@@ -2061,9 +2237,6 @@ export default function ProjectDevelopmentView({
...focusIntent,
resourceId: `asset:${notification.assetId}`,
};
if (route.scope.sourceAssetId) {
resumableRefineDraftsRef.current.delete(route.scope.sourceAssetId);
}
setAssetCanvasRoute(null);
setMode('resources');
setFocusedResourceId(null);
@@ -3038,6 +3211,10 @@ export default function ProjectDevelopmentView({
const actionPending = pendingResourceEditActionIds.has(
pending.operationId,
);
const identityConfirmation =
resourceEditServiceIdentityConfirmations.get(
pending.operationId,
);
return (
<article
className="game-resource-recovery-item"
@@ -3053,7 +3230,24 @@ export default function ProjectDevelopmentView({
{pendingResourceEditPhaseLabel(pending.phase)}
</span>
</div>
{pending.phase === 'remote-failed' ? (
{identityConfirmation ? (
<div className="game-resource-recovery-confirmation">
<span>
当前服务:{identityConfirmation.serviceOrigin}
</span>
<button
type="button"
onClick={() =>
void confirmPendingResourceEditServiceIdentity(
pending,
)
}
disabled={actionPending}
>
{actionPending ? '正在确认…' : '确认当前服务并继续'}
</button>
</div>
) : pending.phase === 'remote-failed' ? (
<button
type="button"
onClick={() =>
@@ -161,6 +161,8 @@ function deferred<T>(): Deferred<T> {
function memoryHost(input?: {
initialDraft?: ImageCanvasDraft | null;
recoverGate?: Deferred<unknown>;
recoverImagesGate?: Deferred<unknown>;
importGate?: Deferred<unknown>;
commitGate?: Deferred<void>;
generationGate?: Deferred<void>;
generationFailure?: { code: string; message: string };
@@ -233,6 +235,7 @@ function memoryHost(input?: {
recoverInput: Parameters<ImageCanvasGenerationPort['recoverImages']>[0],
) => {
recoveryProgressListener = recoverInput.onProgress;
await input?.recoverImagesGate?.promise;
return {
status: 'ok' as const,
value: {
@@ -318,6 +321,7 @@ function memoryHost(input?: {
},
asset: {
async importImages(importInput) {
await input?.importGate?.promise;
return {
status: 'ok',
value: importInput.images.map((image, index) => ({
@@ -529,6 +533,51 @@ function renderSurface(
};
}
function expectAssetCanvasBackgroundLocked() {
const toolbarShell = document.querySelector(
'.asset-canvas-surface__toolbar-shell',
);
const viewport = document.querySelector('.asset-canvas-surface__viewport');
const generate = screen.getByRole('button', {
name: 'AI 生成图片',
hidden: true,
}) as HTMLButtonElement;
const commit = screen.getByRole('button', {
name: '保存到项目',
hidden: true,
}) as HTMLButtonElement;
expect(toolbarShell?.hasAttribute('inert')).toBe(true);
expect(viewport?.hasAttribute('inert')).toBe(true);
expect(generate.disabled).toBe(true);
expect(commit.disabled).toBe(true);
}
function expectLockedBackgroundCannotCallPaidPorts(
memory: ReturnType<typeof memoryHost>,
) {
const generationCallCount = memory.generationCalls.length;
const commitCallCount = memory.commits.length;
const generate = screen.getByRole('button', {
name: 'AI 生成图片',
hidden: true,
}) as HTMLButtonElement;
const commit = screen.getByRole('button', {
name: '保存到项目',
hidden: true,
}) as HTMLButtonElement;
// 模拟宿主或测试绕过 disabled,确认 handler 自身仍有生命周期硬门禁。
generate.disabled = false;
commit.disabled = false;
fireEvent.click(generate);
fireEvent.click(commit);
generate.disabled = true;
commit.disabled = true;
expect(memory.generationCalls).toHaveLength(generationCallCount);
expect(memory.commits).toHaveLength(commitCallCount);
}
beforeEach(() => {
clearStoredAuthAccessToken();
Object.defineProperty(window, 'PointerEvent', {
@@ -596,6 +645,82 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
expect(screen.getByRole('button', { name: '取消' })).toBeTruthy();
});
it('任一独立 modal 打开时都隔离背景焦点并阻断生成与提交端口', async () => {
const memory = memoryHost({
initialDraft: draftFixture(scope, keyboardCanvas()),
});
const view = renderSurface(memory.host);
await screen.findByText('画布可编辑');
fireEvent.click(screen.getByRole('button', { name: 'AI 生成图片' }));
expect(screen.getByRole('dialog', { name: 'AI 图片生成' })).toBeTruthy();
expectAssetCanvasBackgroundLocked();
expectLockedBackgroundCannotCallPaidPorts(memory);
await waitFor(() =>
expect(document.activeElement).toBe(
screen.getByRole('button', { name: '关闭图片生成面板' }),
),
);
fireEvent.click(screen.getByRole('button', { name: '关闭图片生成面板' }));
fireEvent.click(screen.getByRole('button', { name: '选择图层 第一层' }));
fireEvent.click(screen.getByRole('button', { name: '取消并返回' }));
expect(screen.getByRole('dialog', { name: '返回资源总览' })).toBeTruthy();
expectAssetCanvasBackgroundLocked();
expectLockedBackgroundCannotCallPaidPorts(memory);
await waitFor(() =>
expect(document.activeElement).toBe(
screen.getByRole('button', { name: '继续编辑' }),
),
);
fireEvent.click(screen.getByRole('button', { name: '继续编辑' }));
view.unmount();
const identityMemory = memoryHost({
initialDraft: draftFixture(scope),
serviceIdentityConfirmation: {
generationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
operationId: 'operation-one',
operationState: 'accepted',
serviceOrigin: 'https://editor.example.test',
challenge: 'challenge-one-that-is-long-enough-for-the-contract',
expiresAt: Date.now() + 60_000,
},
});
renderSurface(identityMemory.host);
expect(
await screen.findByRole('dialog', { name: '确认旧生成任务服务' }),
).toBeTruthy();
expectAssetCanvasBackgroundLocked();
expectLockedBackgroundCannotCallPaidPorts(identityMemory);
await waitFor(() =>
expect(document.activeElement).toBe(
screen.getByRole('button', { name: '暂不恢复旧任务' }),
),
);
});
it('原 generation recovery 完成前保持 recovering 并锁闭付费端口', async () => {
const recoverImagesGate = deferred<unknown>();
const memory = memoryHost({
initialDraft: draftFixture(scope, keyboardCanvas()),
recoverImagesGate,
});
renderSurface(memory.host);
await waitFor(() => expect(memory.recoverImages).toHaveBeenCalledTimes(1));
expect(
screen.getByRole('region', { name: '素材创作无限画布' }).dataset.state,
).toBe('canvas.recovering');
expect(screen.queryByRole('button', { name: 'AI 生成图片' })).toBeNull();
expect(memory.generationCalls).toHaveLength(0);
expect(memory.commits).toHaveLength(0);
expect(screen.queryByText('画布可编辑')).toBeNull();
recoverImagesGate.resolve(undefined);
expect(await screen.findByText('画布可编辑')).not.toBeNull();
});
it('保留可编辑名称,并把新建资源用途限制为中文固定选项', async () => {
const memory = memoryHost();
renderSurface(memory.host);
@@ -762,6 +887,8 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
).toContain('测试草稿保存失败');
expect(screen.queryByRole('button', { name: '返回修改' })).toBeNull();
expect(screen.queryByRole('button', { name: '重新确认' })).toBeNull();
expectAssetCanvasBackgroundLocked();
expectLockedBackgroundCannotCallPaidPorts(memory);
fireEvent.click(screen.getByRole('button', { name: '返回资源总览' }));
expect(screen.getByRole('dialog', { name: '返回资源总览' })).toBeTruthy();
@@ -789,6 +916,8 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
).not.toBeNull();
expect(screen.queryByRole('button', { name: '重新确认' })).toBeNull();
expect(memory.generationCalls).toHaveLength(0);
expectAssetCanvasBackgroundLocked();
expectLockedBackgroundCannotCallPaidPorts(memory);
});
it('恢复与取消故障使用各自的可访问名称和允许动作', async () => {
@@ -804,6 +933,8 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
await screen.findByRole('alert', { name: '原任务恢复未完成' }),
).not.toBeNull();
expect(screen.queryByRole('button', { name: '返回修改' })).toBeNull();
expectAssetCanvasBackgroundLocked();
expectLockedBackgroundCannotCallPaidPorts(recoveryMemory);
fireEvent.click(screen.getByRole('button', { name: '返回资源总览' }));
expect(recoveryView.onCancel).toHaveBeenCalledWith({
draftId: scope.draftId,
@@ -832,6 +963,8 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
screen.getByRole('button', { name: '保留草稿继续编辑' }),
).not.toBeNull();
expect(screen.queryByRole('button', { name: '重新确认' })).toBeNull();
expectAssetCanvasBackgroundLocked();
expectLockedBackgroundCannotCallPaidPorts(cancellationMemory);
});
it('只有明确放弃草稿时才写入 cancelled', async () => {
@@ -1429,6 +1562,34 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
expect(screen.getByText(/已保存到项目 assets/)).toBeTruthy();
});
it('导入回包到达时 modal 已打开则不追加未计 revision 的图层', async () => {
const importGate = deferred<unknown>();
const memory = memoryHost({ importGate });
renderSurface(memory.host);
expect(await screen.findByText('画布可编辑')).toBeTruthy();
const file = new File([Uint8Array.from([1, 2, 3])], 'late.png', {
type: 'image/png',
});
Object.defineProperty(file, 'arrayBuffer', {
configurable: true,
value: async () => Uint8Array.from([1, 2, 3]).buffer,
});
fireEvent.change(screen.getByLabelText('导入本地图片'), {
target: { files: [file] },
});
fireEvent.click(screen.getByRole('button', { name: 'AI 生成图片' }));
expect(screen.getByRole('dialog', { name: 'AI 图片生成' })).not.toBeNull();
await act(async () => {
importGate.resolve(undefined);
await importGate.promise;
});
await waitFor(() =>
expect(screen.queryByLabelText('图层 late.png')).toBeNull(),
);
expect(memory.updates).toHaveLength(0);
});
it('保存连点保持 single-flight,命令事件重放不重复投影', async () => {
const commitGate = deferred<void>();
const canvas = emptyCanvas();
@@ -1744,6 +1905,8 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
expect(failure.textContent).toContain('泥点余额不足');
expect(screen.getByRole('button', { name: '返回修改' })).toBeTruthy();
expect(screen.getByRole('button', { name: '重新确认' })).toBeTruthy();
expectAssetCanvasBackgroundLocked();
expectLockedBackgroundCannotCallPaidPorts(memory);
fireEvent.click(screen.getByRole('button', { name: '返回修改' }));
expect(screen.queryByRole('alert', { name: '图片生成失败' })).toBeNull();
@@ -8,6 +8,7 @@ const canvasFixture = vi.hoisted(() => ({
manifest: null as GameCreationAppManifest | null,
revision: 0,
sequence: 0,
recoveredDraftIds: [] as string[],
}));
vi.mock('../src/features/asset-canvas/AssetCanvasSurface', async () => {
@@ -35,8 +36,23 @@ vi.mock('../src/features/asset-canvas/AssetCanvasSurface', async () => {
commitId: string;
}) => void;
onCommitted?: (notification: Record<string, unknown>) => void;
}) =>
ReactModule.createElement(
host: {
generation: {
recoverImages: (input: {
scope: {
projectId: string;
draftId: string;
intent: 'create' | 'refine';
sourceAssetId: string | null;
};
}) => Promise<unknown>;
};
};
}) => {
ReactModule.useEffect(() => {
void props.host.generation.recoverImages({ scope: props.scope });
}, [props.host, props.scope]);
return ReactModule.createElement(
'section',
{ 'aria-label': '测试素材创作画布' },
ReactModule.createElement(
@@ -114,7 +130,8 @@ vi.mock('../src/features/asset-canvas/AssetCanvasSurface', async () => {
},
'完成测试保存',
),
),
);
},
};
});
@@ -354,6 +371,7 @@ describe('project resource live canvas integration', () => {
canvasFixture.manifest = null;
canvasFixture.revision = 0;
canvasFixture.sequence = 0;
canvasFixture.recoveredDraftIds = [];
});
function installTauri(
@@ -369,6 +387,12 @@ describe('project resource live canvas integration', () => {
resumeResourceEdit?: (
input: Record<string, unknown>,
) => Promise<Record<string, unknown>>;
requestResourceEditServiceIdentityConfirmation?: (
input: Record<string, unknown>,
) => Promise<Record<string, unknown>>;
confirmResourceEditServiceIdentity?: (
input: Record<string, unknown>,
) => Promise<Record<string, unknown>>;
} = {},
) {
const layoutWrites: Array<Record<string, unknown>> = [];
@@ -376,9 +400,12 @@ describe('project resource live canvas integration', () => {
const deriveCalls: Array<Record<string, unknown>> = [];
const normalizeCalls: Array<Record<string, unknown>> = [];
const resumeCalls: Array<Record<string, unknown>> = [];
const serviceIdentityRequestCalls: Array<Record<string, unknown>> = [];
const serviceIdentityConfirmCalls: Array<Record<string, unknown>> = [];
const archiveCalls: Array<Record<string, unknown>> = [];
const pendingReadCalls: Array<Record<string, unknown>> = [];
let pendingReadCount = 0;
let discoverableDraftId: string | null = null;
let pendingEdits =
options.pendingResourceEdits ??
(options.pendingResourceEdit
@@ -395,6 +422,20 @@ describe('project resource live canvas integration', () => {
: []);
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'discover_local_project_asset_canvas_draft') {
const input = (args?.input ?? {}) as Record<string, unknown>;
return discoverableDraftId
? {
status: 'found',
draft: {
draftId: discoverableDraftId,
projectId: String(input.expectedProjectId),
intent: 'refine',
sourceAssetId: String(input.sourceAssetId),
},
}
: { status: 'not-found', draft: null };
}
if (command === 'get_local_game_project_revision') {
return { revision: canvasFixture.revision };
}
@@ -437,6 +478,17 @@ describe('project resource live canvas integration', () => {
dataUrl: 'data:image/png;base64,AA==',
};
}
if (command === 'recover_local_project_asset_canvas_generations') {
const input = structuredClone(
(args?.input ?? {}) as Record<string, unknown>,
);
discoverableDraftId = String(input.draftId);
canvasFixture.recoveredDraftIds.push(String(input.draftId));
return {
resumedGenerationIds: [`generation-for-${String(input.draftId)}`],
serviceIdentityConfirmations: [],
};
}
if (command === 'read_local_project_text_preview') {
return {
path: String(args?.relativePath ?? ''),
@@ -499,6 +551,33 @@ describe('project resource live canvas integration', () => {
manifest: nextManifest,
};
}
if (
command ===
'request_local_project_resource_edit_service_identity_confirmation'
) {
const input = structuredClone(
(args?.input ?? {}) as Record<string, unknown>,
);
serviceIdentityRequestCalls.push(input);
if (options.requestResourceEditServiceIdentityConfirmation) {
return await options.requestResourceEditServiceIdentityConfirmation(
input,
);
}
throw new Error('unexpected resource edit service identity request');
}
if (
command === 'confirm_local_project_resource_edit_service_identity'
) {
const input = structuredClone(
(args?.input ?? {}) as Record<string, unknown>,
);
serviceIdentityConfirmCalls.push(input);
if (options.confirmResourceEditServiceIdentity) {
return await options.confirmResourceEditServiceIdentity(input);
}
throw new Error('unexpected resource edit service identity confirm');
}
if (command === 'archive_failed_local_project_resource_edit') {
const input = structuredClone(
(args?.input ?? {}) as Record<string, unknown>,
@@ -593,6 +672,8 @@ describe('project resource live canvas integration', () => {
pendingReadCalls,
resumeCalls,
archiveCalls,
serviceIdentityRequestCalls,
serviceIdentityConfirmCalls,
};
}
@@ -668,6 +749,35 @@ describe('project resource live canvas integration', () => {
).toBe(true);
});
it('reuses a kept refine draft and recovers its generation after a real workbench remount', async () => {
installTauri();
const firstWorkbench = render(<LiveWorkbench />);
fireEvent.click(
await screen.findByRole('button', { name: /source-art\.png/ }),
);
fireEvent.click(screen.getByRole('button', { name: '编辑资源' }));
await waitFor(() => {
expect(canvasFixture.recoveredDraftIds).toHaveLength(1);
});
const keptDraftId = canvasFixture.recoveredDraftIds[0];
fireEvent.click(screen.getByRole('button', { name: '取消并返回' }));
firstWorkbench.unmount();
render(<LiveWorkbench />);
fireEvent.click(
await screen.findByRole('button', { name: /source-art\.png/ }),
);
fireEvent.click(screen.getByRole('button', { name: '编辑资源' }));
await waitFor(() => {
expect(canvasFixture.recoveredDraftIds).toEqual([
keptDraftId,
keptDraftId,
]);
});
});
it('keeps an existing search when an edited result is hidden and locates only after the explicit action', async () => {
installTauri();
render(<LiveWorkbench />);
@@ -773,6 +883,102 @@ describe('project resource live canvas integration', () => {
).not.toBeNull();
});
it('显式确认旧 Key-bound 服务后只用原 operation 继续恢复', async () => {
const operation: PendingResourceEditFixture = {
operationId: 'abababab-abab-4bab-8bab-abababababab',
editKind: 'video',
sourceResourceId: 'source-video',
assetName: '旧 Key 视频编辑',
phase: 'accepted',
createdAt: 1,
};
let identityConfirmed = false;
let committed = false;
const {
resumeCalls,
serviceIdentityRequestCalls,
serviceIdentityConfirmCalls,
} = installTauri({
readPendingResourceEdits: () => (committed ? [] : [operation]),
resumeResourceEdit: async () => {
if (!identityConfirmed) {
throw new Error(
'service-identity-confirmation-required: 当前服务地址需要用户确认',
);
}
const base = canvasFixture.manifest!;
const asset = {
id: `edit-${operation.operationId}`,
kind: 'video',
mediaType: 'video/mp4',
localPath: `assets/edits/${operation.operationId}.mp4`,
source: {
kind: 'canvas' as const,
resourceId: 'remote-video-resource',
referenceResourceIds: ['source-video'],
},
};
committed = true;
canvasFixture.revision += 1;
const manifest = { ...base, assets: [...base.assets, asset] };
canvasFixture.manifest = manifest;
return {
operationId: operation.operationId,
editKind: 'video',
sourceResourceId: 'source-video',
committedProjectRevision: canvasFixture.revision,
asset,
version: null,
manifest,
};
},
requestResourceEditServiceIdentityConfirmation: async (input) => ({
operationId: input.operationId,
remoteOperationId: 'remote-existing-operation',
operationState: 'accepted',
serviceOrigin: 'https://editor.example.test',
challenge: 'challenge-bound-to-the-current-ledger-snapshot',
expiresAt: 9_999_999_999,
}),
confirmResourceEditServiceIdentity: async (input) => {
expect(input).toMatchObject({
operationId: operation.operationId,
remoteOperationId: 'remote-existing-operation',
challenge: 'challenge-bound-to-the-current-ledger-snapshot',
});
identityConfirmed = true;
return {
operationId: operation.operationId,
remoteOperationId: 'remote-existing-operation',
operationState: 'accepted',
serviceOrigin: 'https://editor.example.test',
identityScheme: 'service-origin-v1',
};
},
});
render(<DerivedWorkbench />);
fireEvent.click(
await screen.findByRole('button', { name: '管理未完成编辑 (1)' }),
);
fireEvent.click(screen.getByRole('button', { name: '继续原编辑' }));
expect(
await screen.findByText('当前服务:https://editor.example.test'),
).not.toBeNull();
expect(serviceIdentityRequestCalls).toHaveLength(1);
expect(resumeCalls).toHaveLength(1);
fireEvent.click(screen.getByRole('button', { name: '确认当前服务并继续' }));
await waitFor(() => expect(resumeCalls).toHaveLength(2));
expect(serviceIdentityConfirmCalls).toHaveLength(1);
expect(resumeCalls[0]).toEqual(resumeCalls[1]);
expect(resumeCalls[1]).toEqual({
projectPath,
expectedProjectId: 'live-canvas-project',
operationId: operation.operationId,
});
});
it('恢复面板可跳过首条失败任务继续任意 operation,且对账项不会被重放', async () => {
const failedOperationId = '11111111-1111-4111-8111-111111111111';
const resumableOperationId = '22222222-2222-4222-8222-222222222222';