修复无限画布恢复与幂等链路
补齐 refine 草稿跨重启唯一恢复与生命周期门禁 完善 Provider durable handoff、认证恢复和旧密钥服务确认 修复资源事务前向恢复与异步导入迟到写入 补充前后端回归测试并同步权威文档与项目记忆
This commit is contained in:
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user