use super::*; use crate::mapper::{ map_wooden_fish_agent_session_procedure_result, map_wooden_fish_gallery_card_view_row, map_wooden_fish_run_procedure_result, map_wooden_fish_work_procedure_result, map_wooden_fish_works_procedure_result, }; use shared_contracts::wooden_fish::{ WoodenFishActionRequest, WoodenFishActionResponse, WoodenFishActionType, WoodenFishCheckpointRunRequest, WoodenFishDraftResponse, WoodenFishFinishRunRequest, WoodenFishGalleryResponse, WoodenFishGenerationStatus, WoodenFishRuntimeRunSnapshotResponse, WoodenFishSessionSnapshotResponse, WoodenFishStartRunRequest, WoodenFishWorkProfileResponse, }; use shared_kernel::build_prefixed_uuid_id; const WOODEN_FISH_TEMPLATE_ID: &str = "wooden-fish"; const WOODEN_FISH_TEMPLATE_NAME: &str = "敲木鱼"; const DEFAULT_HIT_OBJECT_PROMPT: &str = "默认敲击物图案,圆润木质质感,透明背景"; const DEFAULT_HIT_SOUND_PROMPT: &str = "清脆短促的木鱼敲击声"; impl SpacetimeClient { pub async fn create_wooden_fish_session( &self, session: WoodenFishSessionSnapshotResponse, ) -> Result { let draft = session.draft.clone().ok_or_else(|| { SpacetimeClientError::validation_failed("wooden-fish session 缺少 draft") })?; let theme_tags_json = Some(json_string(&draft.theme_tags)?); let config_json = Some(build_config_json(&draft)?); let draft_json = Some(json_string(&draft)?); let procedure_input = WoodenFishAgentSessionCreateInput { session_id: session.session_id, owner_user_id: session.owner_user_id, work_title: draft.work_title, work_description: draft.work_description, theme_tags_json, config_json, draft_json, created_at_micros: current_unix_micros(), }; self.call_after_connect( "create_wooden_fish_agent_session", move |connection, sender| { connection .procedures() .create_wooden_fish_agent_session_then(procedure_input, move |_, result| { let mapped = result .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_wooden_fish_agent_session_procedure_result); send_once(&sender, mapped); }); }, ) .await } pub async fn get_wooden_fish_session( &self, session_id: String, owner_user_id: String, ) -> Result { let procedure_input = WoodenFishAgentSessionGetInput { session_id, owner_user_id, }; self.call_after_connect( "get_wooden_fish_agent_session", move |connection, sender| { connection.procedures().get_wooden_fish_agent_session_then( procedure_input, move |_, result| { let mapped = result .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_wooden_fish_agent_session_procedure_result); send_once(&sender, mapped); }, ); }, ) .await } pub async fn execute_wooden_fish_action( &self, session_id: String, owner_user_id: String, payload: WoodenFishActionRequest, ) -> Result { let current = self .get_wooden_fish_session(session_id.clone(), owner_user_id.clone()) .await?; let (procedure, _) = build_wooden_fish_action_plan( ¤t, &owner_user_id, &payload, current_unix_micros(), )?; let (session, work) = match procedure { WoodenFishActionProcedure::Compile(input) => { let profile_id = input.profile_id.clone(); let session = self.compile_wooden_fish_draft(input).await?; let work = self .get_wooden_fish_work_profile(profile_id, owner_user_id) .await .ok(); (session, work) } WoodenFishActionProcedure::Update(input) => { let work = self.update_wooden_fish_work(input).await?; let session = apply_wooden_fish_work_to_session(current, &work); (session, Some(work)) } }; Ok(WoodenFishActionResponse { action_type: payload.action_type, session, work, }) } pub async fn compile_wooden_fish_draft( &self, procedure_input: WoodenFishDraftCompileInput, ) -> Result { self.call_after_connect("compile_wooden_fish_draft", move |connection, sender| { connection.procedures().compile_wooden_fish_draft_then( procedure_input, move |_, result| { let mapped = result .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_wooden_fish_agent_session_procedure_result); send_once(&sender, mapped); }, ); }) .await } pub async fn get_wooden_fish_work_profile( &self, profile_id: String, owner_user_id: String, ) -> Result { let procedure_input = WoodenFishWorkGetInput { profile_id, owner_user_id, }; self.call_after_connect("get_wooden_fish_work_profile", move |connection, sender| { connection.procedures().get_wooden_fish_work_profile_then( procedure_input, move |_, result| { let mapped = result .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_wooden_fish_work_procedure_result); send_once(&sender, mapped); }, ); }) .await } pub async fn update_wooden_fish_work( &self, procedure_input: WoodenFishWorkUpdateInput, ) -> Result { self.call_after_connect("update_wooden_fish_work", move |connection, sender| { connection.procedures().update_wooden_fish_work_then( procedure_input, move |_, result| { let mapped = result .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_wooden_fish_work_procedure_result); send_once(&sender, mapped); }, ); }) .await } pub async fn publish_wooden_fish_work( &self, profile_id: String, owner_user_id: String, ) -> Result { let procedure_input = WoodenFishWorkPublishInput { profile_id, owner_user_id, published_at_micros: current_unix_micros(), }; self.call_after_connect("publish_wooden_fish_work", move |connection, sender| { connection.procedures().publish_wooden_fish_work_then( procedure_input, move |_, result| { let mapped = result .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_wooden_fish_work_procedure_result); send_once(&sender, mapped); }, ); }) .await } pub async fn list_wooden_fish_works( &self, owner_user_id: String, ) -> Result, SpacetimeClientError> { let procedure_input = WoodenFishWorksListInput { owner_user_id, published_only: false, }; self.call_after_connect("list_wooden_fish_works", move |connection, sender| { connection.procedures().list_wooden_fish_works_then( procedure_input, move |_, result| { let mapped = result .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_wooden_fish_works_procedure_result); send_once(&sender, mapped); }, ); }) .await } pub async fn get_wooden_fish_runtime_work( &self, profile_id: String, ) -> Result { self.get_wooden_fish_work_profile(profile_id, String::new()) .await } pub async fn start_wooden_fish_run( &self, payload: WoodenFishStartRunRequest, owner_user_id: String, ) -> Result { let run_id = build_prefixed_uuid_id("wooden-fish-run-"); let procedure_input = WoodenFishRunStartInput { client_event_id: format!("{run_id}:start"), run_id, owner_user_id, profile_id: payload.profile_id, started_at_ms: current_unix_micros().div_euclid(1000), }; self.start_wooden_fish_run_with_input(procedure_input).await } pub async fn start_wooden_fish_run_with_input( &self, procedure_input: WoodenFishRunStartInput, ) -> Result { self.call_after_connect("start_wooden_fish_run", move |connection, sender| { connection.procedures().start_wooden_fish_run_then( procedure_input, move |_, result| { let mapped = result .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_wooden_fish_run_procedure_result); send_once(&sender, mapped); }, ); }) .await } pub async fn checkpoint_wooden_fish_run( &self, run_id: String, owner_user_id: String, payload: WoodenFishCheckpointRunRequest, ) -> Result { let procedure_input = WoodenFishRunCheckpointInput { run_id, owner_user_id, total_tap_count: payload.total_tap_count, word_counters_json: json_string(&payload.word_counters)?, client_event_id: payload.client_event_id, checkpoint_at_ms: current_unix_micros().div_euclid(1000), }; self.call_after_connect("checkpoint_wooden_fish_run", move |connection, sender| { connection.procedures().checkpoint_wooden_fish_run_then( procedure_input, move |_, result| { let mapped = result .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_wooden_fish_run_procedure_result); send_once(&sender, mapped); }, ); }) .await } pub async fn finish_wooden_fish_run( &self, run_id: String, owner_user_id: String, payload: WoodenFishFinishRunRequest, ) -> Result { let procedure_input = WoodenFishRunFinishInput { run_id, owner_user_id, total_tap_count: payload.total_tap_count, word_counters_json: json_string(&payload.word_counters)?, client_event_id: payload.client_event_id, finished_at_ms: current_unix_micros().div_euclid(1000), }; self.call_after_connect("finish_wooden_fish_run", move |connection, sender| { connection.procedures().finish_wooden_fish_run_then( procedure_input, move |_, result| { let mapped = result .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_wooden_fish_run_procedure_result); send_once(&sender, mapped); }, ); }) .await } pub async fn list_wooden_fish_gallery( &self, ) -> Result { self.read_after_connect("list_wooden_fish_gallery", move |connection| { let mut items = connection .db() .wooden_fish_gallery_card_view() .iter() .collect::>(); items.sort_by(|left, right| { right .updated_at_micros .cmp(&left.updated_at_micros) .then_with(|| left.profile_id.cmp(&right.profile_id)) }); Ok(WoodenFishGalleryResponse { items: items .into_iter() .map(map_wooden_fish_gallery_card_view_row) .collect(), has_more: false, next_cursor: None, }) }) .await } pub async fn get_wooden_fish_gallery_detail( &self, public_work_code: String, ) -> Result { let normalized_code = public_work_code.trim().to_string(); if normalized_code.is_empty() { return Err(SpacetimeClientError::validation_failed( "wooden-fish public_work_code 不能为空", )); } let profile_id = self .read_after_connect("resolve_wooden_fish_gallery_detail", move |connection| { connection .db() .wooden_fish_gallery_card_view() .iter() .find(|row| { row.public_work_code.eq_ignore_ascii_case(&normalized_code) || row.profile_id == normalized_code }) .map(|row| row.profile_id) .ok_or_else(|| { SpacetimeClientError::procedure_failed(Some( "敲木鱼公开作品不存在".to_string(), )) }) }) .await?; self.get_wooden_fish_work_profile(profile_id, String::new()) .await } } enum WoodenFishActionProcedure { Compile(WoodenFishDraftCompileInput), Update(WoodenFishWorkUpdateInput), } #[derive(Clone, Copy)] enum WoodenFishDraftMergeScope { CompileDraft, RegenerateHitObject, GenerateHitSound, ReplaceHitSound, UpdateWorkMeta, UpdateFloatingWords, } #[derive(Clone, Copy)] enum WoodenFishAssetRefresh { Preserve, HitObject, HitSound, } fn build_wooden_fish_action_plan( current: &WoodenFishSessionSnapshotResponse, owner_user_id: &str, payload: &WoodenFishActionRequest, now_micros: i64, ) -> Result<(WoodenFishActionProcedure, WoodenFishDraftResponse), SpacetimeClientError> { let scope = match payload.action_type { WoodenFishActionType::CompileDraft => WoodenFishDraftMergeScope::CompileDraft, WoodenFishActionType::RegenerateHitObject => WoodenFishDraftMergeScope::RegenerateHitObject, WoodenFishActionType::GenerateHitSound => WoodenFishDraftMergeScope::GenerateHitSound, WoodenFishActionType::ReplaceHitSound => WoodenFishDraftMergeScope::ReplaceHitSound, WoodenFishActionType::UpdateWorkMeta => WoodenFishDraftMergeScope::UpdateWorkMeta, WoodenFishActionType::UpdateFloatingWords => WoodenFishDraftMergeScope::UpdateFloatingWords, }; let mut draft = merge_action_into_draft(current.draft.clone(), payload, scope)?; let profile_id = resolve_wooden_fish_profile_id( &draft, &payload.action_type, payload.profile_id.as_deref(), )?; draft.profile_id = Some(profile_id.clone()); let procedure = match payload.action_type { WoodenFishActionType::CompileDraft => { WoodenFishActionProcedure::Compile(build_compile_input( current, owner_user_id, &profile_id, &mut draft, WoodenFishAssetRefresh::Preserve, now_micros, )?) } WoodenFishActionType::RegenerateHitObject => { WoodenFishActionProcedure::Compile(build_compile_input( current, owner_user_id, &profile_id, &mut draft, WoodenFishAssetRefresh::HitObject, now_micros, )?) } WoodenFishActionType::GenerateHitSound => { WoodenFishActionProcedure::Compile(build_compile_input( current, owner_user_id, &profile_id, &mut draft, WoodenFishAssetRefresh::HitSound, now_micros, )?) } WoodenFishActionType::ReplaceHitSound => WoodenFishActionProcedure::Update( build_update_input(owner_user_id, &profile_id, &draft, true, now_micros)?, ), WoodenFishActionType::UpdateWorkMeta | WoodenFishActionType::UpdateFloatingWords => { WoodenFishActionProcedure::Update(build_update_input( owner_user_id, &profile_id, &draft, false, now_micros, )?) } }; Ok((procedure, draft)) } fn merge_action_into_draft( draft: Option, payload: &WoodenFishActionRequest, scope: WoodenFishDraftMergeScope, ) -> Result { let mut draft = draft.unwrap_or_else(default_draft); if matches!( scope, WoodenFishDraftMergeScope::CompileDraft | WoodenFishDraftMergeScope::UpdateWorkMeta ) { if let Some(value) = payload .work_title .as_ref() .filter(|value| !value.trim().is_empty()) { draft.work_title = value.trim().to_string(); } if let Some(value) = payload.work_description.as_ref() { draft.work_description = value.trim().to_string(); } if let Some(value) = payload.theme_tags.clone() { draft.theme_tags = normalize_tags(value); } } if matches!( scope, WoodenFishDraftMergeScope::CompileDraft | WoodenFishDraftMergeScope::RegenerateHitObject ) { if let Some(value) = payload .hit_object_prompt .as_ref() .filter(|value| !value.trim().is_empty()) { draft.hit_object_prompt = value.trim().to_string(); } if payload.hit_object_reference_image_src.is_some() { draft.hit_object_reference_image_src = payload .hit_object_reference_image_src .as_ref() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()); } if let Some(asset) = payload.hit_object_asset.clone() { draft.hit_object_asset = Some(asset); } if let Some(asset) = payload.background_asset.clone() { draft.background_asset = Some(asset); } } if matches!( scope, WoodenFishDraftMergeScope::CompileDraft | WoodenFishDraftMergeScope::GenerateHitSound | WoodenFishDraftMergeScope::ReplaceHitSound ) { if let Some(value) = payload .hit_sound_prompt .as_ref() .filter(|value| !value.trim().is_empty()) { draft.hit_sound_prompt = Some(value.trim().to_string()); } } if matches!(scope, WoodenFishDraftMergeScope::GenerateHitSound) { draft.hit_sound_asset = payload.hit_sound_asset.clone(); } else if matches!( scope, WoodenFishDraftMergeScope::CompileDraft | WoodenFishDraftMergeScope::ReplaceHitSound ) && let Some(asset) = payload.hit_sound_asset.clone() { draft.hit_sound_asset = Some(asset); } if matches!( scope, WoodenFishDraftMergeScope::CompileDraft | WoodenFishDraftMergeScope::UpdateFloatingWords ) && let Some(words) = payload.floating_words.clone() { draft.floating_words = normalize_floating_words(words); } if draft.work_title.trim().is_empty() { return Err(SpacetimeClientError::validation_failed( "wooden-fish work_title 不能为空", )); } if draft.hit_object_prompt.trim().is_empty() { draft.hit_object_prompt = DEFAULT_HIT_OBJECT_PROMPT.to_string(); } if draft.hit_object_asset.is_some() && matches!(scope, WoodenFishDraftMergeScope::RegenerateHitObject) && payload.hit_object_asset.is_none() { draft.hit_object_asset = None; draft.background_asset = None; } if draft.floating_words.is_empty() { draft.floating_words = default_floating_words(); } Ok(draft) } fn build_compile_input( current: &WoodenFishSessionSnapshotResponse, owner_user_id: &str, profile_id: &str, draft: &mut WoodenFishDraftResponse, refresh: WoodenFishAssetRefresh, now_micros: i64, ) -> Result { let _refresh = refresh; let hit_object_asset = draft.hit_object_asset.clone().ok_or_else(|| { SpacetimeClientError::validation_failed("wooden fish hit object asset 缺少真实生成资产") })?; draft.hit_object_asset = Some(hit_object_asset); draft.cover_image_src = draft .hit_object_asset .as_ref() .map(|asset| asset.image_src.clone()); draft.generation_status = WoodenFishGenerationStatus::Ready; let hit_object_asset = draft .hit_object_asset .clone() .ok_or_else(|| SpacetimeClientError::missing_snapshot("wooden fish hit object asset"))?; let hit_sound_asset = draft.hit_sound_asset.clone().ok_or_else(|| { SpacetimeClientError::validation_failed("wooden fish hit sound asset 缺少真实生成资产") })?; let background_asset = draft.background_asset.clone().ok_or_else(|| { SpacetimeClientError::validation_failed("wooden fish background asset 缺少真实生成资产") })?; Ok(WoodenFishDraftCompileInput { session_id: current.session_id.clone(), owner_user_id: owner_user_id.to_string(), profile_id: profile_id.to_string(), author_display_name: "敲木鱼玩家".to_string(), work_title: draft.work_title.clone(), work_description: draft.work_description.clone(), theme_tags_json: Some(json_string(&draft.theme_tags)?), hit_object_prompt: draft.hit_object_prompt.clone(), hit_object_reference_image_src: draft.hit_object_reference_image_src.clone(), hit_sound_prompt: draft.hit_sound_prompt.clone(), hit_object_asset_json: Some(json_string(&hit_object_asset)?), background_asset_json: Some(json_string(&background_asset)?), hit_sound_asset_json: Some(json_string(&hit_sound_asset)?), floating_words_json: Some(json_string(&draft.floating_words)?), cover_image_src: draft.cover_image_src.clone(), generation_status: Some("ready".to_string()), compiled_at_micros: now_micros, }) } fn build_update_input( owner_user_id: &str, profile_id: &str, draft: &WoodenFishDraftResponse, include_hit_sound_asset: bool, now_micros: i64, ) -> Result { Ok(WoodenFishWorkUpdateInput { profile_id: profile_id.to_string(), owner_user_id: owner_user_id.to_string(), work_title: draft.work_title.clone(), work_description: draft.work_description.clone(), theme_tags_json: json_string(&draft.theme_tags)?, hit_object_prompt: Some(draft.hit_object_prompt.clone()), hit_object_reference_image_src: draft.hit_object_reference_image_src.clone(), hit_sound_prompt: draft.hit_sound_prompt.clone(), hit_object_asset_json: None, background_asset_json: None, hit_sound_asset_json: if include_hit_sound_asset { draft .hit_sound_asset .as_ref() .map(json_string) .transpose()? } else { None }, floating_words_json: Some(json_string(&draft.floating_words)?), cover_image_src: draft.cover_image_src.clone(), generation_status: None, updated_at_micros: now_micros, }) } fn resolve_wooden_fish_profile_id( draft: &WoodenFishDraftResponse, action_type: &WoodenFishActionType, requested_profile_id: Option<&str>, ) -> Result { if let Some(profile_id) = requested_profile_id .map(str::trim) .filter(|value| !value.is_empty()) { return Ok(profile_id.to_string()); } if let Some(profile_id) = draft .profile_id .as_ref() .map(|value| value.trim()) .filter(|value| !value.is_empty()) { return Ok(profile_id.to_string()); } if matches!(action_type, WoodenFishActionType::CompileDraft) { return Ok(build_prefixed_uuid_id("wooden-fish-profile-")); } Err(SpacetimeClientError::validation_failed( "wooden-fish action 需要先完成 compile-draft", )) } fn apply_wooden_fish_work_to_session( mut session: WoodenFishSessionSnapshotResponse, work: &WoodenFishWorkProfileResponse, ) -> WoodenFishSessionSnapshotResponse { session.status = work.draft.generation_status.clone(); session.draft = Some(work.draft.clone()); session.updated_at = work.summary.updated_at.clone(); session } fn default_draft() -> WoodenFishDraftResponse { WoodenFishDraftResponse { template_id: WOODEN_FISH_TEMPLATE_ID.to_string(), template_name: WOODEN_FISH_TEMPLATE_NAME.to_string(), profile_id: None, work_title: WOODEN_FISH_TEMPLATE_NAME.to_string(), work_description: String::new(), theme_tags: vec!["休闲".to_string()], hit_object_prompt: DEFAULT_HIT_OBJECT_PROMPT.to_string(), hit_object_reference_image_src: None, hit_sound_prompt: Some(DEFAULT_HIT_SOUND_PROMPT.to_string()), floating_words: default_floating_words(), hit_object_asset: None, background_asset: None, hit_sound_asset: None, cover_image_src: None, generation_status: WoodenFishGenerationStatus::Draft, } } fn build_config_json(draft: &WoodenFishDraftResponse) -> Result { serde_json::to_string(&serde_json::json!({ "workTitle": draft.work_title, "workDescription": draft.work_description, "themeTags": draft.theme_tags, "hitObjectPrompt": draft.hit_object_prompt, "hitObjectReferenceImageSrc": draft.hit_object_reference_image_src, "hitSoundPrompt": draft.hit_sound_prompt, "floatingWords": draft.floating_words, })) .map_err(SpacetimeClientError::validation_failed) } fn normalize_tags(tags: Vec) -> Vec { tags.into_iter() .map(|tag| tag.trim().to_string()) .filter(|tag| !tag.is_empty()) .take(8) .collect() } fn default_floating_words() -> Vec { vec![ "幸运".to_string(), "健康".to_string(), "财富".to_string(), "姻缘".to_string(), "幸福".to_string(), "事业".to_string(), "成功".to_string(), "功德".to_string(), ] } fn normalize_floating_words(words: Vec) -> Vec { let mut normalized = Vec::new(); for word in words { let word = normalize_floating_word(&word); if word.is_empty() || normalized.iter().any(|item| item == &word) { continue; } normalized.push(word); if normalized.len() >= 8 { break; } } if normalized.is_empty() { default_floating_words() } else { normalized } } fn normalize_floating_word(word: &str) -> String { word.trim() .trim_end_matches(|ch: char| ch == '1' || ch.is_whitespace()) .trim_end_matches(['+', '+']) .trim() .to_string() } fn json_string(value: &T) -> Result { serde_json::to_string(value).map_err(SpacetimeClientError::validation_failed) } #[cfg(test)] mod tests { use super::*; use shared_contracts::wooden_fish::WoodenFishAudioAsset; const SESSION_ID: &str = "wooden-fish-session-test"; const OWNER_USER_ID: &str = "user-test"; const PROFILE_ID: &str = "wooden-fish-profile-test"; const NOW_MICROS: i64 = 1_763_456_789_000_000; #[test] fn wooden_fish_action_compile_draft_builds_compile_input_with_assets() { let session = session_with_draft(draft_without_assets()); let mut payload = action(WoodenFishActionType::CompileDraft); payload.hit_object_asset = Some(generated_hit_object_asset("generated-compile-object")); payload.background_asset = Some(generated_background_asset("generated-compile-background")); payload.hit_sound_asset = Some(generated_hit_sound_asset("generated-compile-sound")); let (plan, draft) = build_wooden_fish_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) .expect("compile-draft should build plan"); let WoodenFishActionProcedure::Compile(input) = plan else { panic!("compile-draft should call compile_wooden_fish_draft"); }; assert_eq!(input.session_id, SESSION_ID); assert_eq!(input.owner_user_id, OWNER_USER_ID); assert_eq!(input.generation_status.as_deref(), Some("ready")); assert!( input .hit_object_asset_json .as_deref() .unwrap_or("") .contains("generated-compile-object") ); assert!( input .hit_sound_asset_json .as_deref() .unwrap_or("") .contains("generated-compile-sound") ); assert!( input .background_asset_json .as_deref() .unwrap_or("") .contains("generated-compile-background") ); assert_eq!(draft.generation_status, WoodenFishGenerationStatus::Ready); } #[test] fn wooden_fish_compile_requires_real_hit_sound_asset_from_api_server() { let session = session_with_draft(draft_without_assets()); let mut payload = action(WoodenFishActionType::CompileDraft); payload.hit_object_asset = Some(generated_hit_object_asset("generated-compile-object")); payload.background_asset = Some(generated_background_asset("generated-compile-background")); let error = match build_wooden_fish_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) { Ok(_) => panic!("compile-draft should not synthesize fake hit sound assets"), Err(error) => error, }; assert!( error .to_string() .contains("hit sound asset 缺少真实生成资产") ); } #[test] fn wooden_fish_compile_requires_real_background_asset_from_api_server() { let session = session_with_draft(draft_without_assets()); let mut payload = action(WoodenFishActionType::CompileDraft); payload.hit_object_asset = Some(generated_hit_object_asset("generated-compile-object")); payload.hit_sound_asset = Some(generated_hit_sound_asset("generated-compile-sound")); let error = match build_wooden_fish_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) { Ok(_) => panic!("compile-draft should not publish without background asset"), Err(error) => error, }; assert!( error .to_string() .contains("background asset 缺少真实生成资产") ); } #[test] fn wooden_fish_action_regenerate_hit_object_replaces_only_object_asset() { let session = session_with_draft(draft_with_assets()); let mut payload = action(WoodenFishActionType::RegenerateHitObject); payload.hit_object_prompt = Some("新的敲击物".to_string()); payload.hit_object_asset = Some(generated_hit_object_asset("generated-object")); payload.background_asset = Some(generated_background_asset("generated-background")); let (plan, _draft) = build_wooden_fish_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) .expect("regenerate-hit-object should build plan"); let WoodenFishActionProcedure::Compile(input) = plan else { panic!("regenerate-hit-object should call compile_wooden_fish_draft"); }; assert!( !input .hit_object_asset_json .as_deref() .unwrap_or("") .contains("old-object") ); assert!( input .hit_object_asset_json .as_deref() .unwrap_or("") .contains("real-profile") ); assert!( !input .hit_object_asset_json .as_deref() .unwrap_or("") .contains(&NOW_MICROS.to_string()) ); assert!( input .hit_sound_asset_json .as_deref() .unwrap_or("") .contains("old-sound") ); assert!( input .background_asset_json .as_deref() .unwrap_or("") .contains("generated-background") ); } #[test] fn wooden_fish_action_update_floating_words_builds_update_input() { let session = session_with_draft(draft_with_assets()); let mut payload = action(WoodenFishActionType::UpdateFloatingWords); payload.floating_words = Some(vec![ " 功德+1 ".to_string(), "功德+1".to_string(), "健康+1".to_string(), ]); let (plan, draft) = build_wooden_fish_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) .expect("update-floating-words should build plan"); let WoodenFishActionProcedure::Update(input) = plan else { panic!("update-floating-words should call update_wooden_fish_work"); }; assert_eq!(input.profile_id, PROFILE_ID); assert!(input.hit_sound_asset_json.is_none()); assert_eq!( draft.floating_words, vec!["功德".to_string(), "健康".to_string()] ); assert!( input .floating_words_json .as_deref() .unwrap_or("") .contains("健康") ); } fn action(action_type: WoodenFishActionType) -> WoodenFishActionRequest { WoodenFishActionRequest { action_type, profile_id: None, work_title: None, work_description: None, theme_tags: None, hit_object_prompt: None, hit_object_reference_image_src: None, hit_object_asset: None, background_asset: None, hit_sound_prompt: None, hit_sound_asset: None, floating_words: None, } } fn session_with_draft(draft: WoodenFishDraftResponse) -> WoodenFishSessionSnapshotResponse { WoodenFishSessionSnapshotResponse { session_id: SESSION_ID.to_string(), owner_user_id: OWNER_USER_ID.to_string(), status: draft.generation_status.clone(), draft: Some(draft), created_at: "2026-05-20T00:00:00Z".to_string(), updated_at: "2026-05-20T00:00:00Z".to_string(), } } fn draft_without_assets() -> WoodenFishDraftResponse { WoodenFishDraftResponse { profile_id: None, ..base_draft() } } fn generated_hit_object_asset(asset_id: &str) -> WoodenFishImageAsset { WoodenFishImageAsset { asset_id: asset_id.to_string(), image_src: "/generated-wooden-fish-assets/real-profile/hit-object/image.png" .to_string(), image_object_key: "generated-wooden-fish-assets/real-profile/hit-object/image.png" .to_string(), asset_object_id: format!("{asset_id}-asset"), generation_provider: "image2".to_string(), prompt: "新的敲击物".to_string(), width: 1024, height: 1024, } } fn generated_background_asset(asset_id: &str) -> WoodenFishImageAsset { WoodenFishImageAsset { asset_id: asset_id.to_string(), image_src: "/generated-wooden-fish-assets/real-profile/background/image.png" .to_string(), image_object_key: "generated-wooden-fish-assets/real-profile/background/image.png" .to_string(), asset_object_id: format!("{asset_id}-asset"), generation_provider: "image2".to_string(), prompt: "新的敲击背景".to_string(), width: 1024, height: 1536, } } fn generated_hit_sound_asset(asset_id: &str) -> WoodenFishAudioAsset { WoodenFishAudioAsset { asset_id: asset_id.to_string(), audio_src: "/generated-wooden-fish-assets/real-profile/hit-sound/sound.mp3".to_string(), audio_object_key: "generated-wooden-fish-assets/real-profile/hit-sound/sound.mp3" .to_string(), asset_object_id: format!("{asset_id}-asset"), source: "generated".to_string(), prompt: Some("新的木鱼音效".to_string()), duration_ms: Some(3000), } } fn draft_with_assets() -> WoodenFishDraftResponse { WoodenFishDraftResponse { profile_id: Some(PROFILE_ID.to_string()), hit_object_asset: Some(WoodenFishImageAsset { asset_id: "old-object".to_string(), image_src: "/generated-wooden-fish-assets/old-object.png".to_string(), image_object_key: "generated-wooden-fish-assets/old-object.png".to_string(), asset_object_id: "old-object-asset".to_string(), generation_provider: "image2".to_string(), prompt: "旧敲击物".to_string(), width: 1024, height: 1024, }), background_asset: Some(WoodenFishImageAsset { asset_id: "old-background".to_string(), image_src: "/generated-wooden-fish-assets/old-background.png".to_string(), image_object_key: "generated-wooden-fish-assets/old-background.png".to_string(), asset_object_id: "old-background-asset".to_string(), generation_provider: "image2".to_string(), prompt: "旧背景".to_string(), width: 1024, height: 1536, }), hit_sound_asset: Some(WoodenFishAudioAsset { asset_id: "old-sound".to_string(), audio_src: "/generated-wooden-fish-assets/old-sound.mp3".to_string(), audio_object_key: "generated-wooden-fish-assets/old-sound.mp3".to_string(), asset_object_id: "old-sound-asset".to_string(), source: "generated".to_string(), prompt: Some("旧音效".to_string()), duration_ms: Some(700), }), cover_image_src: Some("/generated-wooden-fish-assets/old-object.png".to_string()), generation_status: WoodenFishGenerationStatus::Ready, ..base_draft() } } fn base_draft() -> WoodenFishDraftResponse { WoodenFishDraftResponse { template_id: WOODEN_FISH_TEMPLATE_ID.to_string(), template_name: WOODEN_FISH_TEMPLATE_NAME.to_string(), profile_id: None, work_title: "旧标题".to_string(), work_description: "旧描述".to_string(), theme_tags: vec!["旧标签".to_string()], hit_object_prompt: "旧敲击物".to_string(), hit_object_reference_image_src: None, hit_sound_prompt: Some("旧音效".to_string()), floating_words: default_floating_words(), hit_object_asset: None, background_asset: None, hit_sound_asset: None, cover_image_src: None, generation_status: WoodenFishGenerationStatus::Draft, } } }