完善全类型资源非破坏性编辑
禁用新增资源并为所有现役资源提供按类型编辑入口。 图片、视频、音频、文本、任务产物和项目版本派生新资源且保留原资源。 完善 External operation 私有账本、幂等恢复、旧指纹迁移与安全校验。 修复画布生成期间交互冻结、滚轮平移缩放与 Shift 选择行为。 同步资源编辑合同、决策记录与排障经验。
This commit is contained in:
@@ -29,7 +29,7 @@ pub(in crate::agent) use canvas_generation::{
|
||||
validate_platform_art_png_bytes_with_limits,
|
||||
};
|
||||
pub(in crate::agent) use draft_validation::validate_closed_game_script_blocks;
|
||||
pub(crate) use external_generation_state::platform_art_generation_external_configuration_fingerprint;
|
||||
pub(crate) use external_generation_state::platform_art_generation_external_service_fingerprint;
|
||||
pub(in crate::agent) use external_generation_state::{
|
||||
game_creator_agent_runtime_external_generation_exists,
|
||||
platform_art_generation_runtime_context_from_pending,
|
||||
@@ -39,6 +39,7 @@ pub(in crate::agent) use external_generation_state::{
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use external_generation_state::{
|
||||
platform_art_generation_external_configuration_fingerprint,
|
||||
setup_platform_art_generation_runtime_accepted_for_recovery_test,
|
||||
write_platform_art_generation_runtime_accepted_for_test,
|
||||
write_platform_art_generation_runtime_prepared_for_test,
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
#[cfg(test)]
|
||||
use super::external_generation_state::platform_art_generation_external_configuration_fingerprint;
|
||||
use super::external_generation_state::{
|
||||
mark_platform_art_generation_runtime_accepted,
|
||||
mark_platform_art_generation_runtime_legacy_completed,
|
||||
platform_art_generation_external_configuration_fingerprint,
|
||||
migrate_platform_art_generation_external_configuration,
|
||||
platform_art_generation_external_service_fingerprint,
|
||||
platform_art_generation_runtime_idempotency_key, platform_art_generation_runtime_legacy_result,
|
||||
platform_art_generation_runtime_request_body_json,
|
||||
platform_art_generation_runtime_request_snapshot, platform_art_generation_runtime_status,
|
||||
platform_art_generation_runtime_submission_payload,
|
||||
prepare_platform_art_generation_runtime_state, read_platform_art_generation_runtime_state,
|
||||
validate_platform_art_generation_external_configuration, PlatformArtGenerationRuntimeState,
|
||||
PlatformArtGenerationRuntimeState,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
@@ -1155,12 +1158,19 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
|
||||
};
|
||||
let api_base_url = resolve_canvas_sync_api_base_url(None)?;
|
||||
let api_key = resolve_canvas_sync_api_key(None)?;
|
||||
if let Some(state) = persisted_runtime_state.as_ref() {
|
||||
validate_platform_art_generation_external_configuration(state, &api_base_url, &api_key)
|
||||
.map_err(|error| format!("{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} {error}"))?;
|
||||
}
|
||||
let persisted_runtime_state = persisted_runtime_state
|
||||
.map(|state| {
|
||||
migrate_platform_art_generation_external_configuration(
|
||||
root,
|
||||
state,
|
||||
&api_base_url,
|
||||
&api_key,
|
||||
)
|
||||
.map_err(|error| format!("{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} {error}"))
|
||||
})
|
||||
.transpose()?;
|
||||
let external_configuration_fingerprint =
|
||||
platform_art_generation_external_configuration_fingerprint(&api_base_url, &api_key);
|
||||
platform_art_generation_external_service_fingerprint(&api_base_url);
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(60))
|
||||
.build()
|
||||
@@ -6512,7 +6522,7 @@ mod canvas_generation_tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accepted_runtime_generation_rejects_external_configuration_drift_before_get() {
|
||||
async fn accepted_runtime_generation_rejects_external_service_drift_before_get() {
|
||||
let temporary = tempfile::tempdir().expect("create configuration drift project");
|
||||
let root = temporary.path();
|
||||
init_local_game_project_at(root, "configuration-drift", "External Editor 配置漂移")
|
||||
@@ -6552,10 +6562,7 @@ mod canvas_generation_tests {
|
||||
action_fingerprint: "configuration-drift-fingerprint".to_string(),
|
||||
};
|
||||
let stale_configuration_fingerprint =
|
||||
platform_art_generation_external_configuration_fingerprint(
|
||||
"https://old-editor.example.test",
|
||||
"old-editor-key",
|
||||
);
|
||||
platform_art_generation_external_service_fingerprint("https://old-editor.example.test");
|
||||
let (state, _) = prepare_platform_art_generation_runtime_state(
|
||||
root,
|
||||
&runtime_context,
|
||||
@@ -6587,7 +6594,7 @@ mod canvas_generation_tests {
|
||||
Err(error) => error,
|
||||
Ok(_) => panic!("configuration drift must block GET-only recovery"),
|
||||
};
|
||||
assert!(error.contains("baseUrl/API Key"), "{error}");
|
||||
assert!(error.contains("服务地址身份"), "{error}");
|
||||
assert!(matches!(
|
||||
listener.accept(),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock
|
||||
|
||||
+122
-10
@@ -122,7 +122,12 @@ fn request_body_json_and_sha256(
|
||||
Ok((request_body_json, request_body_sha256))
|
||||
}
|
||||
|
||||
pub(crate) fn platform_art_generation_external_configuration_fingerprint(
|
||||
pub(crate) fn platform_art_generation_external_service_fingerprint(api_base_url: &str) -> String {
|
||||
let normalized_base_url = api_base_url.trim().trim_end_matches('/');
|
||||
format!("{:x}", Sha256::digest(normalized_base_url.as_bytes()))
|
||||
}
|
||||
|
||||
fn platform_art_generation_legacy_external_configuration_fingerprint(
|
||||
api_base_url: &str,
|
||||
api_key: &str,
|
||||
) -> String {
|
||||
@@ -134,18 +139,48 @@ pub(crate) fn platform_art_generation_external_configuration_fingerprint(
|
||||
)
|
||||
}
|
||||
|
||||
// Keep the historical helper for persisted-agent call sites while changing its
|
||||
// identity semantics: credentials authorize an operation but do not own it.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn platform_art_generation_external_configuration_fingerprint(
|
||||
api_base_url: &str,
|
||||
_api_key: &str,
|
||||
) -> String {
|
||||
platform_art_generation_external_service_fingerprint(api_base_url)
|
||||
}
|
||||
|
||||
pub(super) fn validate_platform_art_generation_external_configuration(
|
||||
state: &PlatformArtGenerationRuntimeState,
|
||||
api_base_url: &str,
|
||||
api_key: &str,
|
||||
) -> Result<(), String> {
|
||||
let current = platform_art_generation_external_configuration_fingerprint(api_base_url, api_key);
|
||||
if state.external_configuration_fingerprint != current {
|
||||
return Err("External Editor 生成账本与当前 baseUrl/API Key 身份不一致".to_string());
|
||||
let current = platform_art_generation_external_service_fingerprint(api_base_url);
|
||||
let legacy =
|
||||
platform_art_generation_legacy_external_configuration_fingerprint(api_base_url, api_key);
|
||||
if state.external_configuration_fingerprint != current
|
||||
&& state.external_configuration_fingerprint != legacy
|
||||
{
|
||||
return Err("External Editor 生成账本与当前服务地址身份不一致".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn migrate_platform_art_generation_external_configuration(
|
||||
root: &Path,
|
||||
mut state: PlatformArtGenerationRuntimeState,
|
||||
api_base_url: &str,
|
||||
api_key: &str,
|
||||
) -> Result<PlatformArtGenerationRuntimeState, String> {
|
||||
validate_platform_art_generation_external_configuration(&state, api_base_url, api_key)?;
|
||||
let current = platform_art_generation_external_service_fingerprint(api_base_url);
|
||||
if state.external_configuration_fingerprint != current {
|
||||
state.external_configuration_fingerprint = current;
|
||||
state.updated_at = unix_timestamp();
|
||||
write_platform_art_generation_runtime_state(root, &state)?;
|
||||
}
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
fn validate_platform_art_generation_runtime_identity(
|
||||
root: &Path,
|
||||
state: &PlatformArtGenerationRuntimeState,
|
||||
@@ -667,9 +702,8 @@ pub(crate) fn write_platform_art_generation_runtime_accepted_for_test(
|
||||
let context = platform_art_generation_runtime_context_from_pending(pending);
|
||||
let api_base_url =
|
||||
resolve_canvas_sync_api_base_url(None).unwrap_or_else(|_| "http://127.0.0.1:1".to_string());
|
||||
let api_key = resolve_canvas_sync_api_key(None).unwrap_or_else(|_| "test-api-key".to_string());
|
||||
let external_configuration_fingerprint =
|
||||
platform_art_generation_external_configuration_fingerprint(&api_base_url, &api_key);
|
||||
platform_art_generation_external_service_fingerprint(&api_base_url);
|
||||
let (state, created) = prepare_platform_art_generation_runtime_state(
|
||||
root,
|
||||
&context,
|
||||
@@ -700,9 +734,8 @@ pub(crate) fn write_platform_art_generation_runtime_prepared_for_test(
|
||||
let context = platform_art_generation_runtime_context_from_pending(pending);
|
||||
let api_base_url =
|
||||
resolve_canvas_sync_api_base_url(None).unwrap_or_else(|_| "http://127.0.0.1:1".to_string());
|
||||
let api_key = resolve_canvas_sync_api_key(None).unwrap_or_else(|_| "test-api-key".to_string());
|
||||
let external_configuration_fingerprint =
|
||||
platform_art_generation_external_configuration_fingerprint(&api_base_url, &api_key);
|
||||
platform_art_generation_external_service_fingerprint(&api_base_url);
|
||||
let (_, created) = prepare_platform_art_generation_runtime_state(
|
||||
root,
|
||||
&context,
|
||||
@@ -786,6 +819,85 @@ pub(crate) fn setup_platform_art_generation_runtime_accepted_for_recovery_test(
|
||||
mod external_generation_state_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn external_service_fingerprint_ignores_key_rotation_and_normalizes_trailing_slash() {
|
||||
let original = platform_art_generation_external_configuration_fingerprint(
|
||||
"https://editor.example.test/",
|
||||
"original-key",
|
||||
);
|
||||
let rotated = platform_art_generation_external_configuration_fingerprint(
|
||||
"https://editor.example.test",
|
||||
"rotated-key",
|
||||
);
|
||||
let different_service = platform_art_generation_external_service_fingerprint(
|
||||
"https://other-editor.example.test",
|
||||
);
|
||||
|
||||
assert_eq!(original, rotated);
|
||||
assert_ne!(original, different_service);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_external_configuration_fingerprint_migrates_before_key_rotation() {
|
||||
let temporary = crate::tests::canonical_test_tempdir("legacy-fingerprint-");
|
||||
let root = temporary.path();
|
||||
init_local_game_project_at(root, "legacy-fingerprint", "旧配置指纹迁移")
|
||||
.expect("init project");
|
||||
let pending = pending_canvas_generation(root);
|
||||
let context = platform_art_generation_runtime_context_from_pending(&pending);
|
||||
let api_base_url = "https://editor.example.test/";
|
||||
let legacy_fingerprint = platform_art_generation_legacy_external_configuration_fingerprint(
|
||||
api_base_url,
|
||||
"original-key",
|
||||
);
|
||||
let (legacy_state, created) = prepare_platform_art_generation_runtime_state(
|
||||
root,
|
||||
&context,
|
||||
"/api/external/v1/editor/images/generations",
|
||||
"legacy-fingerprint-canvas",
|
||||
"恢复旧请求",
|
||||
&serde_json::json!({
|
||||
"prompt": "恢复旧请求",
|
||||
"kind": "spec",
|
||||
"projectId": "canvas-project",
|
||||
"assetFolderId": "asset-folder",
|
||||
"referenceImageSrcs": []
|
||||
}),
|
||||
&legacy_fingerprint,
|
||||
)
|
||||
.expect("prepare legacy generation ledger");
|
||||
assert!(created);
|
||||
|
||||
let migrated = migrate_platform_art_generation_external_configuration(
|
||||
root,
|
||||
legacy_state,
|
||||
api_base_url,
|
||||
"original-key",
|
||||
)
|
||||
.expect("migrate legacy fingerprint");
|
||||
assert_eq!(
|
||||
migrated.external_configuration_fingerprint,
|
||||
platform_art_generation_external_service_fingerprint(api_base_url)
|
||||
);
|
||||
validate_platform_art_generation_external_configuration(
|
||||
&migrated,
|
||||
"https://editor.example.test",
|
||||
"rotated-key",
|
||||
)
|
||||
.expect("rotated key must recover the migrated operation");
|
||||
assert!(validate_platform_art_generation_external_configuration(
|
||||
&migrated,
|
||||
"https://other-editor.example.test",
|
||||
"rotated-key",
|
||||
)
|
||||
.is_err());
|
||||
|
||||
let persisted = read_platform_art_generation_runtime_state(root, &context)
|
||||
.expect("read migrated ledger")
|
||||
.expect("migrated ledger exists");
|
||||
assert_eq!(persisted, migrated);
|
||||
}
|
||||
|
||||
fn pending_canvas_generation(root: &Path) -> AgentRuntimePendingToolAction {
|
||||
let mut runtime = start_game_creator_agent_runtime_task_at(
|
||||
root,
|
||||
@@ -915,12 +1027,12 @@ mod external_generation_state_tests {
|
||||
"test-api-key",
|
||||
)
|
||||
.is_err());
|
||||
assert!(validate_platform_art_generation_external_configuration(
|
||||
validate_platform_art_generation_external_configuration(
|
||||
&prepared,
|
||||
"https://editor.example.test",
|
||||
"different-api-key",
|
||||
)
|
||||
.is_err());
|
||||
.expect("rotated API Key must keep ownership of the accepted service operation");
|
||||
assert_eq!(
|
||||
platform_art_generation_runtime_recovery_at(root, &pending)
|
||||
.expect("read prepared recovery"),
|
||||
|
||||
@@ -1222,6 +1222,24 @@ pub(crate) async fn derive_local_project_resource(
|
||||
derive_local_project_resource_at(input).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn list_pending_local_project_resource_edits(
|
||||
input: ListPendingLocalProjectResourceEditsInput,
|
||||
) -> Result<Vec<PendingLocalProjectResourceEdit>, String> {
|
||||
let root = Path::new(input.project_path.trim());
|
||||
enforce_project_permission_policy(root, "file.list")?;
|
||||
list_pending_local_project_resource_edits_at(input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn resume_local_project_resource_edit(
|
||||
input: ResumeLocalProjectResourceEditInput,
|
||||
) -> Result<DeriveLocalProjectResourceResult, String> {
|
||||
let root = Path::new(input.project_path.trim());
|
||||
enforce_project_permission_policy(root, "asset.register")?;
|
||||
resume_local_project_resource_edit_at(input).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn normalize_local_project_raster_resource(
|
||||
input: NormalizeLocalProjectRasterResourceInput,
|
||||
|
||||
@@ -2207,6 +2207,8 @@ fn main() {
|
||||
upload_local_asset,
|
||||
register_local_asset,
|
||||
derive_local_project_resource,
|
||||
list_pending_local_project_resource_edits,
|
||||
resume_local_project_resource_edit,
|
||||
normalize_local_project_raster_resource,
|
||||
import_canvas_asset,
|
||||
import_canvas_export,
|
||||
|
||||
@@ -1273,7 +1273,9 @@ pub(crate) fn update_asset_canvas_draft_at(
|
||||
validate_safe_revision(draft.revision, "草稿 revision")?;
|
||||
draft.status = input.status.clone();
|
||||
draft.canvas = input.canvas.clone();
|
||||
draft.generations = input.generations.clone();
|
||||
// Generation records are advanced by the Rust generation ledger. A regular
|
||||
// canvas autosave may carry an older frontend snapshot and must never erase
|
||||
// accepted/running/reconciliation facts written by the backend.
|
||||
draft.updated_at = asset_canvas_now();
|
||||
write_asset_canvas_draft_locked(root, &draft)?;
|
||||
Ok(UpdateAssetCanvasDraftResult {
|
||||
|
||||
@@ -304,7 +304,8 @@ fn resolve_generation_api_mode() -> Result<(String, CanvasGenerationApiMode), St
|
||||
}
|
||||
|
||||
fn canvas_api_identity_fingerprint(api_base_url: &str, mode: &CanvasGenerationApiMode) -> String {
|
||||
platform_art_generation_external_configuration_fingerprint(api_base_url, mode.bearer_token())
|
||||
let _ = mode;
|
||||
platform_art_generation_external_service_fingerprint(api_base_url)
|
||||
}
|
||||
|
||||
fn authorize_canvas_request(
|
||||
@@ -842,6 +843,7 @@ fn stable_manifest_reference(asset: &GameCreationAppAssetManifestEntry) -> Optio
|
||||
!value.is_empty()
|
||||
&& !value.starts_with("local-asset:")
|
||||
&& !value.starts_with("draft-media:")
|
||||
&& !value.starts_with("task:")
|
||||
})
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
@@ -938,3 +938,44 @@ fn rejects_bad_signature_oversize_identity_replacement_and_linked_sidecars() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_draft_update_preserves_backend_authoritative_generation_records() {
|
||||
let fixture = initialize_fixture();
|
||||
let mut authoritative = fixture.draft.clone();
|
||||
authoritative.generations.push(AssetCanvasGenerationRecord {
|
||||
generation_id: Uuid::new_v4().to_string(),
|
||||
intent_id: Uuid::new_v4().to_string(),
|
||||
phase: AssetCanvasGenerationStatus::GenerationRunning,
|
||||
reference_resource_ids: Vec::new(),
|
||||
output_asset_id: None,
|
||||
error_code: None,
|
||||
created_at: 1,
|
||||
updated_at: 2,
|
||||
idempotency_key: None,
|
||||
status: None,
|
||||
prompt: None,
|
||||
operation_id: None,
|
||||
output_media_ids: Vec::new(),
|
||||
});
|
||||
write_asset_canvas_draft_locked(fixture.root(), &authoritative)
|
||||
.expect("write authoritative generation record");
|
||||
|
||||
let mut canvas = authoritative.canvas.clone();
|
||||
canvas.viewport.x = 42.0;
|
||||
let updated = update_asset_canvas_draft_at(
|
||||
fixture.root(),
|
||||
&UpdateAssetCanvasDraftInput {
|
||||
project_path: project_path(fixture.root()),
|
||||
expected_project_id: PROJECT_ID.to_string(),
|
||||
draft_id: authoritative.draft_id.clone(),
|
||||
expected_draft_revision: authoritative.revision,
|
||||
status: AssetCanvasDraftStatus::Editing,
|
||||
canvas,
|
||||
generations: Vec::new(),
|
||||
},
|
||||
)
|
||||
.expect("save stale frontend draft");
|
||||
assert_eq!(updated.draft.generations, authoritative.generations);
|
||||
assert_eq!(updated.draft.canvas.viewport.x, 42.0);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,7 @@ import {
|
||||
moveViewportFromPan,
|
||||
removeCanvasLayers,
|
||||
resizeCanvasLayerBounds,
|
||||
resolveViewportFromWheel,
|
||||
scaleViewportFromScreenPoint,
|
||||
transformCanvasLayers,
|
||||
} from '@genarrative/image-canvas-core';
|
||||
@@ -387,6 +388,7 @@ export function AssetCanvasSurface({
|
||||
const draftRef = useRef(draft);
|
||||
const lifecycleRef = useRef(lifecycle);
|
||||
const documentVersionRef = useRef(documentVersion);
|
||||
const persistedDocumentVersionRef = useRef(0);
|
||||
const epochRef = useRef(0);
|
||||
const dragRef = useRef<DragState | null>(null);
|
||||
const saveQueueRef = useRef<Promise<unknown>>(Promise.resolve());
|
||||
@@ -402,6 +404,7 @@ export function AssetCanvasSurface({
|
||||
const pendingGenerationRef = useRef<PendingGenerationIdentity | null>(null);
|
||||
const generationStartingRef = useRef(false);
|
||||
const generationFocusEpochRef = useRef(0);
|
||||
const generationStopButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const onWalletBalanceMayHaveChanged = useWalletStore(
|
||||
(state) => state.onWalletBalanceMayHaveChanged,
|
||||
);
|
||||
@@ -421,7 +424,12 @@ export function AssetCanvasSurface({
|
||||
documentVersionRef.current = documentVersion;
|
||||
|
||||
const markDirty = useCallback(() => {
|
||||
setDocumentVersion((value) => value + 1);
|
||||
if (lifecycleRef.current.kind !== 'canvas.editing') return;
|
||||
setDocumentVersion((value) => {
|
||||
const next = value + 1;
|
||||
documentVersionRef.current = next;
|
||||
return next;
|
||||
});
|
||||
setLifecycle({ kind: 'canvas.editing', dirty: true });
|
||||
}, []);
|
||||
|
||||
@@ -441,8 +449,21 @@ export function AssetCanvasSurface({
|
||||
const nextDraft = { ...currentDraft, revision };
|
||||
draftRef.current = nextDraft;
|
||||
setDraft(nextDraft);
|
||||
const epoch = epochRef.current;
|
||||
void host.project.loadDraft(stableScope).then((loaded) => {
|
||||
if (
|
||||
epoch !== epochRef.current ||
|
||||
loaded.status !== 'ok' ||
|
||||
!loaded.value ||
|
||||
loaded.value.revision < revision
|
||||
) {
|
||||
return;
|
||||
}
|
||||
draftRef.current = loaded.value;
|
||||
setDraft(loaded.value);
|
||||
});
|
||||
},
|
||||
[],
|
||||
[host.project, stableScope],
|
||||
);
|
||||
|
||||
const canvasHistoryRefs = useMemo(
|
||||
@@ -538,6 +559,9 @@ export function AssetCanvasSurface({
|
||||
setBackgroundColor(nextDraft.canvas.backgroundColor);
|
||||
setSelectedLayerIds(nextDraft.canvas.selectedLayerIds);
|
||||
resetCanvasHistory();
|
||||
documentVersionRef.current = 0;
|
||||
persistedDocumentVersionRef.current = 0;
|
||||
setDocumentVersion(0);
|
||||
setLifecycle({ kind: 'canvas.editing', dirty: false });
|
||||
},
|
||||
[host, resetCanvasHistory, stableScope],
|
||||
@@ -552,6 +576,7 @@ export function AssetCanvasSurface({
|
||||
pendingCommitRef.current = null;
|
||||
pendingGenerationRef.current = null;
|
||||
generationStartingRef.current = false;
|
||||
dragRef.current = null;
|
||||
generationFocusEpochRef.current += 1;
|
||||
hostRevisionRef.current = expectedHostRevision;
|
||||
deliveredEventsRef.current.clear();
|
||||
@@ -710,7 +735,7 @@ export function AssetCanvasSurface({
|
||||
const persistDraft =
|
||||
useCallback(async (): Promise<ImageCanvasDraft | null> => {
|
||||
const epoch = epochRef.current;
|
||||
const requestedVersion = documentVersion;
|
||||
const requestedVersion = documentVersionRef.current;
|
||||
const task = saveQueueRef.current.then(async () => {
|
||||
const currentDraft = draftRef.current;
|
||||
if (!currentDraft || epoch !== epochRef.current) return null;
|
||||
@@ -748,14 +773,21 @@ export function AssetCanvasSurface({
|
||||
}
|
||||
draftRef.current = result.value;
|
||||
setDraft(result.value);
|
||||
if (requestedVersion === documentVersionRef.current) {
|
||||
persistedDocumentVersionRef.current = Math.max(
|
||||
persistedDocumentVersionRef.current,
|
||||
requestedVersion,
|
||||
);
|
||||
if (
|
||||
requestedVersion === documentVersionRef.current &&
|
||||
lifecycleRef.current.kind === 'canvas.editing'
|
||||
) {
|
||||
setLifecycle({ kind: 'canvas.editing', dirty: false });
|
||||
}
|
||||
return result.value;
|
||||
});
|
||||
saveQueueRef.current = task.catch(() => undefined);
|
||||
return await task;
|
||||
}, [documentVersion, host.project, stableScope]);
|
||||
}, [host.project, stableScope]);
|
||||
|
||||
useEffect(() => {
|
||||
if (lifecycle.kind !== 'canvas.editing' || !lifecycle.dirty || !draft) {
|
||||
@@ -767,6 +799,10 @@ export function AssetCanvasSurface({
|
||||
|
||||
useEffect(() => {
|
||||
const onMove = (event: PointerEvent) => {
|
||||
if (lifecycleRef.current.kind !== 'canvas.editing') {
|
||||
dragRef.current = null;
|
||||
return;
|
||||
}
|
||||
const drag = dragRef.current;
|
||||
if (!drag) return;
|
||||
if (drag.kind === 'pan') {
|
||||
@@ -826,6 +862,10 @@ export function AssetCanvasSurface({
|
||||
}
|
||||
};
|
||||
const onUp = () => {
|
||||
if (lifecycleRef.current.kind !== 'canvas.editing') {
|
||||
dragRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (!dragRef.current) return;
|
||||
dragRef.current = null;
|
||||
markDirty();
|
||||
@@ -845,7 +885,12 @@ export function AssetCanvasSurface({
|
||||
const files = Array.from(event.target.files ?? []);
|
||||
event.target.value = '';
|
||||
const currentDraft = draftRef.current;
|
||||
if (!currentDraft || !files.length) return;
|
||||
if (
|
||||
lifecycleRef.current.kind !== 'canvas.editing' ||
|
||||
!currentDraft ||
|
||||
!files.length
|
||||
)
|
||||
return;
|
||||
const images = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
const mediaType = mediaTypeForFile(file);
|
||||
@@ -931,7 +976,11 @@ export function AssetCanvasSurface({
|
||||
);
|
||||
|
||||
const deleteSelected = useCallback(() => {
|
||||
if (!selectionRef.current.length) return;
|
||||
if (
|
||||
lifecycleRef.current.kind !== 'canvas.editing' ||
|
||||
!selectionRef.current.length
|
||||
)
|
||||
return;
|
||||
captureHistory({
|
||||
type: 'delete-image',
|
||||
count: selectionRef.current.length,
|
||||
@@ -971,8 +1020,7 @@ export function AssetCanvasSurface({
|
||||
const epoch = saveEpoch;
|
||||
if (!draftRef.current) return;
|
||||
if (
|
||||
lifecycleRef.current.kind === 'canvas.editing' &&
|
||||
lifecycleRef.current.dirty
|
||||
documentVersionRef.current !== persistedDocumentVersionRef.current
|
||||
) {
|
||||
setLifecycle({ kind: 'canvas.saving', stage: 'draft' });
|
||||
const persisted = await persistDraft();
|
||||
@@ -1053,6 +1101,7 @@ export function AssetCanvasSurface({
|
||||
});
|
||||
}
|
||||
pendingCommitRef.current = null;
|
||||
persistedDocumentVersionRef.current = documentVersionRef.current;
|
||||
setNotice(
|
||||
result.value.commitStatus === 'already-committed'
|
||||
? '素材已提交,本次返回原幂等结果'
|
||||
@@ -1162,10 +1211,7 @@ export function AssetCanvasSurface({
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
lifecycleRef.current.kind === 'canvas.editing' &&
|
||||
lifecycleRef.current.dirty
|
||||
) {
|
||||
if (documentVersionRef.current !== persistedDocumentVersionRef.current) {
|
||||
setExitDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
@@ -1237,9 +1283,9 @@ export function AssetCanvasSurface({
|
||||
const frozenAssetKind = assetKind;
|
||||
const frozenAssetName = assetName;
|
||||
const needsDraftPersist =
|
||||
lifecycleRef.current.kind === 'canvas.editing' &&
|
||||
lifecycleRef.current.dirty;
|
||||
documentVersionRef.current !== persistedDocumentVersionRef.current;
|
||||
setGenerationDialog(null);
|
||||
dragRef.current = null;
|
||||
setLifecycle({
|
||||
kind: 'canvas.generating',
|
||||
phase: 'confirmation-required',
|
||||
@@ -1347,6 +1393,7 @@ export function AssetCanvasSurface({
|
||||
});
|
||||
}
|
||||
pendingGenerationRef.current = null;
|
||||
persistedDocumentVersionRef.current = documentVersionRef.current;
|
||||
setLifecycle({ kind: 'canvas.editing', dirty: false });
|
||||
setNotice('AI 图片已正式提交并进入资源总览');
|
||||
})().catch((error: unknown) => {
|
||||
@@ -1419,6 +1466,14 @@ export function AssetCanvasSurface({
|
||||
() => createMinimapModel({ layers, viewport, canvasSize }),
|
||||
[canvasSize, layers, viewport],
|
||||
);
|
||||
const generationInteractionLocked = lifecycle.kind === 'canvas.generating';
|
||||
|
||||
useEffect(() => {
|
||||
if (generationInteractionLocked) {
|
||||
dragRef.current = null;
|
||||
generationStopButtonRef.current?.focus();
|
||||
}
|
||||
}, [generationInteractionLocked]);
|
||||
|
||||
if (
|
||||
!draft ||
|
||||
@@ -1829,6 +1884,7 @@ export function AssetCanvasSurface({
|
||||
/>
|
||||
<p>生成会由平台按当前账号扣除泥点,完成后自动保存到本地项目。</p>
|
||||
<CanvasChromeButton
|
||||
ref={generationStopButtonRef}
|
||||
label="停止等待并返回"
|
||||
icon={<ArrowLeft size={15} aria-hidden="true" />}
|
||||
onClick={stopWaitingForGeneration}
|
||||
@@ -1889,8 +1945,11 @@ export function AssetCanvasSurface({
|
||||
ref={viewportElementRef}
|
||||
className="asset-canvas-surface__viewport"
|
||||
backgroundColor={backgroundColor}
|
||||
isInteractionPaused={generationInteractionLocked}
|
||||
inert={generationInteractionLocked || undefined}
|
||||
isPanning={dragRef.current?.kind === 'pan'}
|
||||
onPointerDown={(event) => {
|
||||
if (lifecycleRef.current.kind !== 'canvas.editing') return;
|
||||
if (event.target !== event.currentTarget) return;
|
||||
captureHistory({ type: 'change-viewport' });
|
||||
setSelectedLayerIds([]);
|
||||
@@ -1903,17 +1962,23 @@ export function AssetCanvasSurface({
|
||||
};
|
||||
}}
|
||||
onWheel={(event) => {
|
||||
if (lifecycleRef.current.kind !== 'canvas.editing') return;
|
||||
event.preventDefault();
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
setViewport((current) =>
|
||||
scaleViewportFromScreenPoint({
|
||||
viewport: current,
|
||||
nextScale: current.scale * (event.deltaY < 0 ? 1.12 : 0.88),
|
||||
screenPoint: {
|
||||
x: event.clientX - rect.left,
|
||||
y: event.clientY - rect.top,
|
||||
},
|
||||
}),
|
||||
setViewport(
|
||||
(current) =>
|
||||
resolveViewportFromWheel({
|
||||
viewport: current,
|
||||
deltaX: event.deltaX,
|
||||
deltaY: event.deltaY,
|
||||
shiftKey: event.shiftKey,
|
||||
ctrlKey: event.ctrlKey,
|
||||
metaKey: event.metaKey,
|
||||
screenPoint: {
|
||||
x: event.clientX - rect.left,
|
||||
y: event.clientY - rect.top,
|
||||
},
|
||||
}).viewport,
|
||||
);
|
||||
markDirty();
|
||||
}}
|
||||
@@ -1936,6 +2001,11 @@ export function AssetCanvasSurface({
|
||||
aria-label={`选择图层 ${layer.title}`}
|
||||
aria-pressed={selected}
|
||||
onClick={(event) => {
|
||||
if (
|
||||
event.detail !== 0 ||
|
||||
lifecycleRef.current.kind !== 'canvas.editing'
|
||||
)
|
||||
return;
|
||||
const targetIds = event.shiftKey
|
||||
? selected
|
||||
? selectedLayerIds.filter((id) => id !== layer.id)
|
||||
@@ -1952,6 +2022,7 @@ export function AssetCanvasSurface({
|
||||
}
|
||||
}}
|
||||
onPointerDown={(event: ReactPointerEvent<HTMLButtonElement>) => {
|
||||
if (lifecycleRef.current.kind !== 'canvas.editing') return;
|
||||
event.stopPropagation();
|
||||
const targetIds = event.shiftKey
|
||||
? selected
|
||||
@@ -2005,6 +2076,7 @@ export function AssetCanvasSurface({
|
||||
aria-label={`缩放图层 ${layer.title}`}
|
||||
className="asset-canvas-surface__resize-handle"
|
||||
onPointerDown={(event) => {
|
||||
if (lifecycleRef.current.kind !== 'canvas.editing') return;
|
||||
event.stopPropagation();
|
||||
captureHistory({
|
||||
type: 'resize-image',
|
||||
@@ -2022,6 +2094,7 @@ export function AssetCanvasSurface({
|
||||
};
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (lifecycleRef.current.kind !== 'canvas.editing') return;
|
||||
if (!event.key.startsWith('Arrow')) return;
|
||||
event.preventDefault();
|
||||
const direction =
|
||||
@@ -2058,10 +2131,15 @@ export function AssetCanvasSurface({
|
||||
</CanvasWorld>
|
||||
</SharedCanvasViewport>
|
||||
|
||||
<aside className="asset-canvas-surface__viewport-tools">
|
||||
<aside
|
||||
className="asset-canvas-surface__viewport-tools"
|
||||
inert={generationInteractionLocked || undefined}
|
||||
aria-hidden={generationInteractionLocked || undefined}
|
||||
>
|
||||
<ZoomControls
|
||||
viewport={viewport}
|
||||
onFit={() => {
|
||||
if (lifecycleRef.current.kind !== 'canvas.editing') return;
|
||||
const next = fitViewportToLayers({ layers, canvasSize });
|
||||
if (next) {
|
||||
captureHistory({ type: 'change-viewport' });
|
||||
@@ -2070,6 +2148,7 @@ export function AssetCanvasSurface({
|
||||
}
|
||||
}}
|
||||
onScaleFromCenter={(scale) => {
|
||||
if (lifecycleRef.current.kind !== 'canvas.editing') return;
|
||||
captureHistory({ type: 'change-viewport' });
|
||||
setViewport((current) =>
|
||||
scaleViewportFromScreenPoint({
|
||||
@@ -2114,6 +2193,7 @@ export function AssetCanvasSurface({
|
||||
<Minimap
|
||||
model={minimapModel}
|
||||
onPointerDown={(event) => {
|
||||
if (lifecycleRef.current.kind !== 'canvas.editing') return;
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
captureHistory({ type: 'change-viewport' });
|
||||
setViewport(
|
||||
|
||||
@@ -125,6 +125,15 @@ type DeriveLocalProjectResourceResult = {
|
||||
manifest: GameCreationAppManifest;
|
||||
};
|
||||
|
||||
type PendingLocalProjectResourceEdit = {
|
||||
operationId: string;
|
||||
editKind: string;
|
||||
sourceResourceId: string;
|
||||
assetName: string;
|
||||
phase: string;
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
type NormalizeLocalProjectRasterResourceResult = {
|
||||
committedProjectRevision: number;
|
||||
asset: GameCreationAppAssetManifestEntry;
|
||||
@@ -498,6 +507,12 @@ export default function ProjectDevelopmentView({
|
||||
const [resourceEditorRoute, setResourceEditorRoute] =
|
||||
useState<ResourceEditorRoute | null>(null);
|
||||
const [assetCanvasNotice, setAssetCanvasNotice] = useState('');
|
||||
const [pendingResourceEdits, setPendingResourceEdits] = useState<
|
||||
PendingLocalProjectResourceEdit[]
|
||||
>([]);
|
||||
const [resumingResourceEditId, setResumingResourceEditId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [hiddenCommittedResourceId, setHiddenCommittedResourceId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
@@ -974,6 +989,8 @@ export default function ProjectDevelopmentView({
|
||||
setResourceEditorRoute(null);
|
||||
resourceEditorRevisionRef.current.clear();
|
||||
setHiddenCommittedResourceId(null);
|
||||
setPendingResourceEdits([]);
|
||||
setResumingResourceEditId(null);
|
||||
suppressResourceFocusRestoreRef.current = true;
|
||||
previousFocusedResourceIdRef.current = null;
|
||||
resourceFocusTriggerIdRef.current = null;
|
||||
@@ -983,6 +1000,33 @@ export default function ProjectDevelopmentView({
|
||||
restoreResourceListScrollRef.current = false;
|
||||
}, [advanceFocusGeneration, projectPath]);
|
||||
|
||||
useEffect(() => {
|
||||
const invoke = window.__TAURI__?.core?.invoke;
|
||||
if (!invoke) {
|
||||
setPendingResourceEdits([]);
|
||||
return undefined;
|
||||
}
|
||||
let active = true;
|
||||
void invoke<PendingLocalProjectResourceEdit[]>(
|
||||
'list_pending_local_project_resource_edits',
|
||||
{
|
||||
input: {
|
||||
projectPath,
|
||||
expectedProjectId: manifest.projectId,
|
||||
},
|
||||
},
|
||||
)
|
||||
.then((edits) => {
|
||||
if (active) setPendingResourceEdits(edits);
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setPendingResourceEdits([]);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [manifest.projectId, projectPath]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!focusedResourceId || resourceEditorRoute) {
|
||||
return undefined;
|
||||
@@ -1454,6 +1498,71 @@ export default function ProjectDevelopmentView({
|
||||
setAssetCanvasNotice('');
|
||||
}, [advanceFocusGeneration, resourceEditorRoute]);
|
||||
|
||||
const resumePendingResourceEdit = useCallback(
|
||||
async (pending: PendingLocalProjectResourceEdit) => {
|
||||
const invoke = window.__TAURI__?.core?.invoke;
|
||||
if (!invoke || resumingResourceEditId) return;
|
||||
const flowId = crypto.randomUUID();
|
||||
activeFocusFlowIdRef.current = flowId;
|
||||
setResumingResourceEditId(pending.operationId);
|
||||
setAssetCanvasNotice(`正在继续“${pending.assetName}”的原生成 operation…`);
|
||||
try {
|
||||
const result = await invoke<DeriveLocalProjectResourceResult>(
|
||||
'resume_local_project_resource_edit',
|
||||
{
|
||||
input: {
|
||||
projectPath,
|
||||
expectedProjectId: manifest.projectId,
|
||||
operationId: pending.operationId,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (result.manifest.projectId !== manifest.projectId) {
|
||||
throw new Error('恢复的资源编辑结果与当前项目不一致');
|
||||
}
|
||||
const resourceId = result.asset
|
||||
? `asset:${result.asset.id}`
|
||||
: result.version
|
||||
? `version:${result.version.versionId}`
|
||||
: null;
|
||||
if (!resourceId) {
|
||||
throw new Error('资源编辑恢复完成但没有返回派生资源');
|
||||
}
|
||||
onManifestChange?.(projectPath, result.manifest, {
|
||||
projectId: result.manifest.projectId,
|
||||
revision: result.committedProjectRevision,
|
||||
source: 'asset-command',
|
||||
commitId: pending.operationId,
|
||||
});
|
||||
pendingResourceFocusRef.current = {
|
||||
flowId,
|
||||
saveAttemptId: pending.operationId,
|
||||
sessionId: pending.operationId,
|
||||
draftId: pending.operationId,
|
||||
commitId: pending.operationId,
|
||||
projectPath,
|
||||
projectId: result.manifest.projectId,
|
||||
focusGeneration: focusGenerationRef.current,
|
||||
resourceId,
|
||||
completed: false,
|
||||
};
|
||||
setPendingResourceEdits((current) =>
|
||||
current.filter((edit) => edit.operationId !== pending.operationId),
|
||||
);
|
||||
setFocusedResourceId(null);
|
||||
setMode('resources');
|
||||
setAssetCanvasNotice('原资源编辑已恢复,正在同步派生资源与布局…');
|
||||
} catch (error) {
|
||||
setAssetCanvasNotice(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
} finally {
|
||||
setResumingResourceEditId(null);
|
||||
}
|
||||
},
|
||||
[manifest.projectId, onManifestChange, projectPath, resumingResourceEditId],
|
||||
);
|
||||
|
||||
const submitResourceEdit = useCallback(
|
||||
async ({ prompt, assetName }: ResourceEditSubmitInput) => {
|
||||
const route = resourceEditorRoute;
|
||||
@@ -1854,6 +1963,21 @@ export default function ProjectDevelopmentView({
|
||||
!assetCanvasRoute &&
|
||||
!resourceEditorRoute ? (
|
||||
<>
|
||||
{pendingResourceEdits.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={resumingResourceEditId !== null}
|
||||
onClick={() => {
|
||||
const pending = pendingResourceEdits[0];
|
||||
if (pending) void resumePendingResourceEdit(pending);
|
||||
}}
|
||||
>
|
||||
<Sparkles size={15} aria-hidden="true" />
|
||||
{resumingResourceEditId
|
||||
? '正在继续编辑…'
|
||||
: `继续未完成编辑 (${pendingResourceEdits.length})`}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
|
||||
@@ -159,6 +159,7 @@ function memoryHost(input?: {
|
||||
commitGate?: Deferred<void>;
|
||||
generationGate?: Deferred<void>;
|
||||
generationFailure?: { code: string; message: string };
|
||||
updateFailure?: { code: string; message: string };
|
||||
recoveryEvent?: LocalAssetCommittedEvent;
|
||||
}) {
|
||||
let draft = input?.initialDraft ?? null;
|
||||
@@ -217,6 +218,13 @@ function memoryHost(input?: {
|
||||
return { status: 'ok', value: draft };
|
||||
},
|
||||
async updateDraft(update) {
|
||||
if (input?.updateFailure) {
|
||||
return {
|
||||
status: 'failed' as const,
|
||||
code: input.updateFailure.code,
|
||||
message: input.updateFailure.message,
|
||||
};
|
||||
}
|
||||
if (!draft || update.expectedDraftRevision !== draft.revision) {
|
||||
return {
|
||||
status: 'conflict',
|
||||
@@ -668,6 +676,29 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
|
||||
expect(memory.discardDraft).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('草稿自动保存失败后返回仍要求明确保留或放弃', async () => {
|
||||
const memory = memoryHost({
|
||||
initialDraft: draftFixture(scope, keyboardCanvas()),
|
||||
updateFailure: {
|
||||
code: 'fixture-save-failed',
|
||||
message: '测试草稿保存失败',
|
||||
},
|
||||
});
|
||||
const { onCancel } = renderSurface(memory.host);
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', { name: '选择图层 第一层' }),
|
||||
);
|
||||
expect((await screen.findByRole('alert')).textContent).toContain(
|
||||
'测试草稿保存失败',
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '返回资源总览' }));
|
||||
expect(
|
||||
screen.getByRole('dialog', { name: '返回资源总览' }),
|
||||
).toBeTruthy();
|
||||
expect(onCancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('只有明确放弃草稿时才写入 cancelled', async () => {
|
||||
const memory = memoryHost({
|
||||
initialDraft: draftFixture(scope, keyboardCanvas()),
|
||||
@@ -718,6 +749,76 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('Shift 指针选择在 pointerdown 到 click 的完整序列中只切换一次', async () => {
|
||||
const memory = memoryHost({
|
||||
initialDraft: draftFixture(scope, keyboardCanvas()),
|
||||
});
|
||||
renderSurface(memory.host);
|
||||
const first = await screen.findByRole('button', {
|
||||
name: '选择图层 第一层',
|
||||
});
|
||||
const second = screen.getByRole('button', { name: '选择图层 第二层' });
|
||||
fireEvent.pointerDown(first, { pointerId: 1, clientX: 10, clientY: 10 });
|
||||
fireEvent.pointerUp(window, { pointerId: 1, clientX: 10, clientY: 10 });
|
||||
fireEvent.click(first, { detail: 1 });
|
||||
expect(first.getAttribute('aria-pressed')).toBe('true');
|
||||
|
||||
fireEvent.pointerDown(second, {
|
||||
pointerId: 2,
|
||||
clientX: 70,
|
||||
clientY: 10,
|
||||
shiftKey: true,
|
||||
});
|
||||
fireEvent.pointerUp(window, {
|
||||
pointerId: 2,
|
||||
clientX: 70,
|
||||
clientY: 10,
|
||||
shiftKey: true,
|
||||
});
|
||||
fireEvent.click(second, { detail: 1, shiftKey: true });
|
||||
|
||||
expect(first.getAttribute('aria-pressed')).toBe('true');
|
||||
expect(second.getAttribute('aria-pressed')).toBe('true');
|
||||
});
|
||||
|
||||
it('Tauri 画布滚轮支持二维平移、Shift 横移和 Ctrl 缩放', async () => {
|
||||
const memory = memoryHost({
|
||||
initialDraft: draftFixture(scope, keyboardCanvas()),
|
||||
});
|
||||
renderSurface(memory.host);
|
||||
await screen.findByRole('button', { name: '选择图层 第一层' });
|
||||
const viewport = document.querySelector(
|
||||
'.asset-canvas-surface__viewport',
|
||||
) as HTMLElement;
|
||||
const world = document.querySelector(
|
||||
'.genarrative-image-canvas__world',
|
||||
) as HTMLElement;
|
||||
expect(world.style.transform).toBe('translate(0px, 0px) scale(0.5)');
|
||||
|
||||
fireEvent.wheel(viewport, { deltaX: 4, deltaY: 10, clientX: 100, clientY: 80 });
|
||||
await waitFor(() =>
|
||||
expect(world.style.transform).toBe('translate(-4px, -10px) scale(0.5)'),
|
||||
);
|
||||
fireEvent.wheel(viewport, {
|
||||
deltaX: 0,
|
||||
deltaY: 10,
|
||||
clientX: 100,
|
||||
clientY: 80,
|
||||
shiftKey: true,
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(world.style.transform).toBe('translate(-14px, -10px) scale(0.5)'),
|
||||
);
|
||||
fireEvent.wheel(viewport, {
|
||||
deltaX: 0,
|
||||
deltaY: -10,
|
||||
clientX: 100,
|
||||
clientY: 80,
|
||||
ctrlKey: true,
|
||||
});
|
||||
await waitFor(() => expect(world.style.transform).not.toContain('scale(0.5)'));
|
||||
});
|
||||
|
||||
it('新建、导入、编辑、撤销重做并完成 durable commit', async () => {
|
||||
const memory = memoryHost();
|
||||
const { onCommitted, onSaveAttempt } = renderSurface(memory.host);
|
||||
@@ -998,6 +1099,63 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
|
||||
expect(onCommitted).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('生成期间聚焦停止等待按钮并阻止所有画布输入修改', async () => {
|
||||
const gate = deferred<void>();
|
||||
const memory = memoryHost({
|
||||
initialDraft: draftFixture(scope, keyboardCanvas()),
|
||||
generationGate: gate,
|
||||
});
|
||||
renderSurface(memory.host);
|
||||
const first = await screen.findByRole('button', {
|
||||
name: '选择图层 第一层',
|
||||
});
|
||||
fireEvent.click(first);
|
||||
await waitFor(() => expect(first.getAttribute('aria-pressed')).toBe('true'));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'AI 生成图片' }));
|
||||
fireEvent.change(screen.getByLabelText('图片提示词'), {
|
||||
target: { value: '生成期间完全锁定画布交互' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '继续确认' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认并生成' }));
|
||||
await waitFor(() => expect(memory.generationCalls).toHaveLength(1));
|
||||
|
||||
const stopWaiting = screen
|
||||
.getAllByRole('button', { name: '停止等待并返回' })
|
||||
.at(-1)!;
|
||||
await waitFor(() => expect(document.activeElement).toBe(stopWaiting));
|
||||
const viewport = document.querySelector(
|
||||
'.asset-canvas-surface__viewport',
|
||||
) as HTMLElement;
|
||||
const world = document.querySelector(
|
||||
'.genarrative-image-canvas__world',
|
||||
) as HTMLElement;
|
||||
const transformBefore = world.style.transform;
|
||||
const resize = screen.getByRole('button', { name: '缩放图层 第一层' });
|
||||
const second = screen.getByRole('button', { name: '选择图层 第二层' });
|
||||
|
||||
fireEvent.wheel(viewport, {
|
||||
deltaY: -120,
|
||||
ctrlKey: true,
|
||||
clientX: 100,
|
||||
clientY: 80,
|
||||
});
|
||||
fireEvent.pointerDown(viewport, { pointerId: 3, clientX: 10, clientY: 10 });
|
||||
fireEvent.pointerMove(window, { pointerId: 3, clientX: 100, clientY: 100 });
|
||||
fireEvent.pointerUp(window, { pointerId: 3, clientX: 100, clientY: 100 });
|
||||
fireEvent.pointerDown(second, { pointerId: 4, shiftKey: true });
|
||||
fireEvent.pointerUp(window, { pointerId: 4, shiftKey: true });
|
||||
fireEvent.click(second, { detail: 1, shiftKey: true });
|
||||
fireEvent.keyDown(resize, { key: 'ArrowRight' });
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '放大画布', hidden: true }),
|
||||
);
|
||||
|
||||
expect(world.style.transform).toBe(transformBefore);
|
||||
expect(first.getAttribute('aria-pressed')).toBe('true');
|
||||
expect(second.getAttribute('aria-pressed')).toBe('false');
|
||||
await act(async () => gate.resolve());
|
||||
});
|
||||
|
||||
it('泥点不足时在中央保留失败状态和生成参数,并可返回修改', async () => {
|
||||
const prompt = '保留参数的原创游戏场景';
|
||||
const memory = memoryHost({
|
||||
|
||||
@@ -282,11 +282,14 @@ describe('project resource live canvas integration', () => {
|
||||
canvasFixture.sequence = 0;
|
||||
});
|
||||
|
||||
function installTauri(options: { failFirstDerive?: boolean } = {}) {
|
||||
function installTauri(
|
||||
options: { failFirstDerive?: boolean; pendingResourceEdit?: boolean } = {},
|
||||
) {
|
||||
const layoutWrites: Array<Record<string, unknown>> = [];
|
||||
const graphReads: Array<Record<string, unknown>> = [];
|
||||
const deriveCalls: Array<Record<string, unknown>> = [];
|
||||
const normalizeCalls: Array<Record<string, unknown>> = [];
|
||||
const resumeCalls: Array<Record<string, unknown>> = [];
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'get_local_game_project_revision') {
|
||||
@@ -339,6 +342,52 @@ describe('project resource live canvas integration', () => {
|
||||
content: '# 玩法规则',
|
||||
};
|
||||
}
|
||||
if (command === 'list_pending_local_project_resource_edits') {
|
||||
return options.pendingResourceEdit
|
||||
? [
|
||||
{
|
||||
operationId: '99999999-9999-4999-8999-999999999999',
|
||||
editKind: 'text',
|
||||
sourceResourceId: 'rules-resource',
|
||||
assetName: '恢复的规则编辑版',
|
||||
phase: 'media-downloaded',
|
||||
createdAt: 1,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}
|
||||
if (command === 'resume_local_project_resource_edit') {
|
||||
const input = structuredClone(
|
||||
(args?.input ?? {}) as Record<string, unknown>,
|
||||
);
|
||||
resumeCalls.push(input);
|
||||
const base = canvasFixture.manifest;
|
||||
if (!base) throw new Error('missing manifest fixture');
|
||||
const operationId = String(input.operationId);
|
||||
const asset = {
|
||||
id: `edit-${operationId}`,
|
||||
kind: 'game-rules',
|
||||
mediaType: 'text/markdown',
|
||||
localPath: `assets/edits/${operationId}-recovered-rules.md`,
|
||||
source: {
|
||||
kind: 'generated' as const,
|
||||
resourceId: `local-asset:edit-${operationId}`,
|
||||
referenceResourceIds: ['rules-resource'],
|
||||
},
|
||||
};
|
||||
canvasFixture.revision += 1;
|
||||
const nextManifest = { ...base, assets: [...base.assets, asset] };
|
||||
canvasFixture.manifest = nextManifest;
|
||||
return {
|
||||
operationId,
|
||||
editKind: 'text',
|
||||
sourceResourceId: 'rules-resource',
|
||||
committedProjectRevision: canvasFixture.revision,
|
||||
asset,
|
||||
version: null,
|
||||
manifest: nextManifest,
|
||||
};
|
||||
}
|
||||
if (command === 'derive_local_project_resource') {
|
||||
const input = structuredClone(
|
||||
(args?.input ?? {}) as Record<string, unknown>,
|
||||
@@ -412,7 +461,13 @@ describe('project resource live canvas integration', () => {
|
||||
core: { invoke },
|
||||
event: { listen: async () => () => undefined },
|
||||
};
|
||||
return { deriveCalls, graphReads, layoutWrites, normalizeCalls };
|
||||
return {
|
||||
deriveCalls,
|
||||
graphReads,
|
||||
layoutWrites,
|
||||
normalizeCalls,
|
||||
resumeCalls,
|
||||
};
|
||||
}
|
||||
|
||||
it('enters refine in the central view, keeps the draft on return, and preserves the source after a durable edit', async () => {
|
||||
@@ -563,4 +618,28 @@ describe('project resource live canvas integration', () => {
|
||||
expect(normalizeCalls[0]?.sourcePath).toBe('assets/task-hero.png');
|
||||
expect(normalizeCalls[0]?.producerTaskId).toBe('art-asset-plan');
|
||||
});
|
||||
|
||||
it('lists an unfinished edit and resumes it using only the private-ledger operation id', async () => {
|
||||
const { resumeCalls } = installTauri({ pendingResourceEdit: true });
|
||||
render(<DerivedWorkbench />);
|
||||
|
||||
const resume = await screen.findByRole('button', {
|
||||
name: '继续未完成编辑 (1)',
|
||||
});
|
||||
fireEvent.click(resume);
|
||||
await waitFor(() => expect(resumeCalls).toHaveLength(1));
|
||||
expect(resumeCalls[0]).toEqual({
|
||||
projectPath,
|
||||
expectedProjectId: 'live-canvas-project',
|
||||
operationId: '99999999-9999-4999-8999-999999999999',
|
||||
});
|
||||
expect(resumeCalls[0]).not.toHaveProperty('prompt');
|
||||
expect(resumeCalls[0]).not.toHaveProperty('endpoint');
|
||||
expect(resumeCalls[0]).not.toHaveProperty('idempotencyKey');
|
||||
expect(
|
||||
await screen.findByRole('region', {
|
||||
name: /recovered-rules\.md/,
|
||||
}),
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user