refactor(api-server): split puzzle module

This commit is contained in:
kdletters
2026-05-18 17:50:16 +08:00
parent ddc061bb6f
commit 472a47eae7
9 changed files with 6515 additions and 6452 deletions

View File

@@ -0,0 +1,880 @@
use super::*;
#[test]
fn puzzle_generated_image_size_is_square_1_1() {
assert_eq!(PUZZLE_GENERATED_IMAGE_SIZE, "1024*1024");
assert_eq!(PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE, "1024x1024");
}
#[test]
fn puzzle_vector_engine_request_uses_gpt_image_2_all_and_reference_images() {
let body = build_puzzle_vector_engine_image_request_body(
PuzzleImageModel::Gemini31FlashPreview,
"一只猫在雨夜灯牌下回头。",
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
4,
None,
);
assert_eq!(body["model"], VECTOR_ENGINE_GPT_IMAGE_2_MODEL);
assert_eq!(body["size"], PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE);
assert_eq!(body["n"], 1);
assert!(body.get("official_fallback").is_none());
assert!(body.get("image").is_none());
assert!(
body["prompt"]
.as_str()
.unwrap_or_default()
.contains("文字水印")
);
}
#[test]
fn puzzle_vector_engine_generation_fallback_includes_reference_image() {
let image = image::DynamicImage::ImageRgb8(image::RgbImage::new(4, 4));
let mut cursor = std::io::Cursor::new(Vec::new());
image
.write_to(&mut cursor, ImageFormat::Png)
.expect("test image should encode");
let reference_image = PuzzleResolvedReferenceImage {
mime_type: "image/png".to_string(),
bytes_len: cursor.get_ref().len(),
bytes: cursor.into_inner(),
};
let body = build_puzzle_vector_engine_image_request_body(
PuzzleImageModel::GptImage2,
"参考图里的小猫做成拼图主图。",
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
1,
Some(&reference_image),
);
let images = body["image"]
.as_array()
.expect("fallback generation should include reference image array");
assert_eq!(images.len(), 1);
assert!(
images[0]
.as_str()
.unwrap_or_default()
.starts_with("data:image/png;base64,")
);
}
#[test]
fn puzzle_vector_engine_edit_url_uses_images_edits_endpoint() {
let settings = PuzzleVectorEngineSettings {
base_url: "https://vector.example/v1".to_string(),
api_key: "test-key".to_string(),
};
assert_eq!(
puzzle_vector_engine_images_edit_url(&settings),
"https://vector.example/v1/images/edits"
);
}
#[test]
fn puzzle_vector_engine_edit_response_decodes_b64_image() {
let images = puzzle_images_from_base64(
"edit-1".to_string(),
vec![BASE64_STANDARD.encode(b"\x89PNG\r\n\x1A\nrest")],
1,
);
assert_eq!(images.images.len(), 1);
assert_eq!(images.images[0].mime_type, "image/png");
assert_eq!(images.images[0].extension, "png");
}
#[test]
fn puzzle_vector_engine_prompt_strongly_uses_reference_image() {
let prompt = build_puzzle_vector_engine_generation_prompt("请生成雨夜猫街。", true);
assert!(prompt.contains("参考图作为第一优先级"));
assert!(prompt.contains("严格保留参考图的主要主体、构图关系、视角、姿态、配色和光影氛围"));
assert!(prompt.contains("请生成雨夜猫街。"));
}
#[test]
fn puzzle_vector_engine_prompt_keeps_text_only_prompt_unchanged() {
let prompt = build_puzzle_vector_engine_generation_prompt("请生成雨夜猫街。", false);
assert_eq!(prompt, "请生成雨夜猫街。");
}
#[test]
fn puzzle_reference_image_edit_requires_ai_redraw() {
assert!(!should_use_puzzle_reference_image_edit(None, true));
assert!(!should_use_puzzle_reference_image_edit(
Some("data:image/png;base64,abcd"),
false
));
assert!(should_use_puzzle_reference_image_edit(
Some("data:image/png;base64,abcd"),
true
));
}
#[test]
fn puzzle_reference_image_sources_are_deduped_and_limited() {
let sources = collect_puzzle_reference_image_sources(
Some("data:image/png;base64,a"),
&[
"data:image/png;base64,a".to_string(),
"data:image/png;base64,b".to_string(),
"data:image/png;base64,c".to_string(),
"data:image/png;base64,d".to_string(),
"data:image/png;base64,e".to_string(),
"data:image/png;base64,f".to_string(),
],
);
assert_eq!(sources.len(), 5);
assert_eq!(sources[0], "data:image/png;base64,a");
assert_eq!(sources[1], "data:image/png;base64,b");
assert!(!sources.contains(&"data:image/png;base64,f".to_string()));
}
#[test]
fn puzzle_vector_engine_timeout_maps_to_gateway_timeout() {
let error = map_puzzle_vector_engine_request_error(
"创建拼图 VectorEngine 图片生成任务失败operation timed out".to_string(),
);
let response = error.into_response();
assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT);
}
#[test]
fn puzzle_vector_engine_upstream_timeout_maps_to_gateway_timeout() {
let error = map_puzzle_vector_engine_upstream_error(
reqwest::StatusCode::GATEWAY_TIMEOUT,
r#"{"error":{"message":"VectorEngine edit endpoint timeout"}}"#,
"创建拼图 VectorEngine 图片编辑任务失败",
);
let response = error.into_response();
assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT);
}
#[test]
fn puzzle_reference_edit_fallback_only_accepts_timeout_errors() {
let timeout_error = map_puzzle_vector_engine_upstream_error(
reqwest::StatusCode::GATEWAY_TIMEOUT,
r#"{"error":{"message":"VectorEngine edit endpoint timeout"}}"#,
"创建拼图 VectorEngine 图片编辑任务失败",
);
assert!(should_fallback_puzzle_reference_edit_to_generation(
&timeout_error
));
let auth_error = map_puzzle_vector_engine_upstream_error(
reqwest::StatusCode::UNAUTHORIZED,
r#"{"error":{"message":"invalid api key"}}"#,
"创建拼图 VectorEngine 图片编辑任务失败",
);
assert!(!should_fallback_puzzle_reference_edit_to_generation(
&auth_error
));
}
#[test]
fn puzzle_vector_engine_reqwest_error_maps_to_bad_gateway() {
let error = match reqwest::Client::new().get("http://[::1").build() {
Ok(_) => panic!("invalid url should fail request build"),
Err(error) => error,
};
let app_error = map_puzzle_vector_engine_reqwest_error(
"创建拼图 VectorEngine 图片编辑任务失败",
"https://api.vectorengine.ai/v1/images/edits",
error,
);
let response = app_error.into_response();
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
}
#[test]
fn puzzle_compile_error_preserves_vector_engine_unavailable_status() {
let error = map_puzzle_compile_error(SpacetimeClientError::Runtime(
"VECTOR_ENGINE_API_KEY 未配置".to_string(),
));
let response = error.into_response();
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[tokio::test]
async fn puzzle_compile_error_normalizes_legacy_apimart_image_message() {
let error = map_puzzle_compile_error(SpacetimeClientError::Runtime(
"APIMart 图片生成密钥未配置".to_string(),
));
let response = error.into_response();
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let body = response.into_body();
let bytes = axum::body::to_bytes(body, usize::MAX)
.await
.expect("body bytes should read");
let payload: Value =
serde_json::from_slice(&bytes).expect("error response should be valid json");
assert_eq!(
payload["error"]["details"]["provider"],
Value::String(VECTOR_ENGINE_PROVIDER.to_string())
);
assert_eq!(
payload["error"]["details"]["message"],
Value::String("VectorEngine 图片生成密钥未配置".to_string())
);
}
#[test]
fn puzzle_image_generation_builds_fallback_session_from_levels_snapshot() {
let levels_json = serde_json::to_string(&vec![json!({
"level_id": "puzzle-level-1",
"level_name": "雨夜猫街",
"picture_description": "一只猫在雨夜灯牌下回头。",
"candidates": [],
"selected_candidate_id": null,
"cover_image_src": null,
"cover_asset_id": null,
"generation_status": "idle",
})])
.expect("levels json");
let payload = ExecutePuzzleAgentActionRequest {
action: "generate_puzzle_images".to_string(),
prompt_text: None,
reference_image_src: None,
reference_image_srcs: Vec::new(),
image_model: Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2.to_string()),
ai_redraw: None,
candidate_count: Some(1),
candidate_id: None,
level_id: Some("puzzle-level-1".to_string()),
work_title: Some("暖灯猫街作品".to_string()),
work_description: Some("一套雨夜猫街主题拼图。".to_string()),
picture_description: None,
level_name: None,
summary: Some("当前关卡画面。".to_string()),
theme_tags: Some(vec!["猫咪".to_string(), "雨夜".to_string()]),
levels_json: Some(levels_json.clone()),
};
let session = build_puzzle_session_snapshot_from_action_payload(
"puzzle-session-1",
&payload,
Some(levels_json.as_str()),
1_713_686_401_234_567,
)
.expect("fallback session");
let draft = session.draft.expect("draft");
assert_eq!(session.stage, "ready_to_publish");
assert_eq!(draft.work_title, "暖灯猫街作品");
assert_eq!(draft.theme_tags, vec!["猫咪", "雨夜"]);
assert_eq!(draft.levels[0].level_id, "puzzle-level-1");
assert_eq!(
draft.levels[0].picture_description,
"一只猫在雨夜灯牌下回头。"
);
}
#[test]
fn puzzle_first_level_name_parser_accepts_json_and_normalizes_text() {
assert_eq!(
parse_puzzle_first_level_name_from_text(r#"{"levelName":"雨夜猫街"}"#),
Some("雨夜猫街".to_string())
);
assert_eq!(
parse_puzzle_first_level_name_from_text("1. 《暖灯猫街》"),
Some("暖灯猫街".to_string())
);
assert_eq!(
parse_puzzle_first_level_name_from_text(r#"{"levelName":"雨夜猫街画面"}"#),
Some("雨夜猫街".to_string())
);
assert_eq!(
parse_puzzle_first_level_name_from_text(r#"{"levelNam"#),
None
);
}
#[test]
fn puzzle_level_naming_parser_accepts_metadata_and_ui_background_prompt() {
let naming = parse_puzzle_level_naming_from_text(
r#"{"levelName":"雨夜猫街","workDescription":"在湿润灯牌与猫影之间完成一套雨夜街角拼图。","workTags":["雨夜","猫咪","灯牌","街角","暖色","插画"],"uiBackgroundPrompt":"雨夜老街延展成竖屏空间,湿润石板路倒映暖色灯牌,远处屋檐和薄雾形成柔和层次"}"#,
)
.expect("naming should parse");
assert_eq!(naming.level_name, "雨夜猫街");
assert_eq!(
naming.work_description.as_deref(),
Some("在湿润灯牌与猫影之间完成一套雨夜街角拼图")
);
assert_eq!(naming.work_tags.len(), module_puzzle::PUZZLE_MAX_TAG_COUNT);
assert!(naming.work_tags.contains(&"雨夜".to_string()));
assert!(naming.work_tags.contains(&"猫咪".to_string()));
assert!(naming.work_tags.contains(&"灯牌".to_string()));
assert_eq!(
naming.ui_background_prompt.as_deref(),
Some("雨夜老街延展成竖屏空间,湿润石板路倒映暖色灯牌,远处屋檐和薄雾形成柔和层次")
);
}
#[test]
fn puzzle_level_naming_parser_filters_forbidden_ui_prompt_words() {
let naming = parse_puzzle_level_naming_from_text(
r#"{"levelName":"雨夜猫街","uiBackgroundPrompt":"雨夜老街背景中央不要出现拼图槽、棋盘、HUD、按钮、文字或水印保留暖色灯光"}"#,
)
.expect("naming should parse");
let prompt = naming
.ui_background_prompt
.as_deref()
.expect("prompt should parse");
assert!(!prompt.contains("拼图槽"));
assert!(!prompt.contains("棋盘"));
assert!(!prompt.contains("HUD"));
assert!(!prompt.contains("按钮"));
assert!(!prompt.contains("文字"));
assert!(!prompt.contains("水印"));
}
#[test]
fn puzzle_first_level_name_fallback_uses_picture_keywords() {
assert_eq!(
build_fallback_puzzle_first_level_name("一只猫在雨夜灯牌下回头。"),
"雨夜猫街"
);
assert_eq!(
build_fallback_puzzle_first_level_name("看不出关键词的抽象色块。"),
"奇境初见"
);
}
#[test]
fn puzzle_level_name_image_data_url_downsizes_generated_image() {
let image = image::DynamicImage::ImageRgb8(image::RgbImage::new(4, 4));
let mut cursor = std::io::Cursor::new(Vec::new());
image
.write_to(&mut cursor, ImageFormat::Png)
.expect("test image should encode");
let downloaded = PuzzleDownloadedImage {
extension: "png".to_string(),
mime_type: "image/png".to_string(),
bytes: cursor.into_inner(),
};
let data_url =
build_puzzle_level_name_image_data_url(&downloaded).expect("data url should be generated");
assert!(data_url.starts_with("data:image/png;base64,"));
assert!(data_url.len() > "data:image/png;base64,".len());
}
#[test]
fn puzzle_first_level_name_snapshot_defaults_work_title() {
let levels_json = serde_json::to_string(&vec![json!({
"level_id": "puzzle-level-1",
"level_name": "猫画面",
"picture_description": "一只猫在雨夜灯牌下回头。",
"candidates": [],
"selected_candidate_id": null,
"cover_image_src": null,
"cover_asset_id": null,
"generation_status": "idle",
})])
.expect("levels json");
let payload = ExecutePuzzleAgentActionRequest {
action: "generate_puzzle_images".to_string(),
prompt_text: None,
reference_image_src: None,
reference_image_srcs: Vec::new(),
image_model: Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2.to_string()),
ai_redraw: None,
candidate_count: Some(1),
candidate_id: None,
level_id: Some("puzzle-level-1".to_string()),
work_title: Some("猫画面".to_string()),
work_description: None,
picture_description: None,
level_name: None,
summary: None,
theme_tags: Some(vec![]),
levels_json: Some(levels_json.clone()),
};
let session = build_puzzle_session_snapshot_from_action_payload(
"puzzle-session-1",
&payload,
Some(levels_json.as_str()),
1_713_686_401_234_567,
)
.expect("fallback session");
let renamed = apply_generated_puzzle_first_level_name_to_session_snapshot(
session,
"puzzle-level-1",
"雨夜猫街",
"猫画面",
1_713_686_401_234_568,
);
let draft = renamed.draft.expect("draft");
assert_eq!(draft.level_name, "雨夜猫街");
assert_eq!(draft.work_title, "雨夜猫街");
assert_eq!(draft.levels[0].level_name, "雨夜猫街");
}
#[test]
fn puzzle_initial_metadata_defaults_empty_work_description_and_tags() {
let mut session = PuzzleAgentSessionRecord {
session_id: "puzzle-session-1".to_string(),
seed_text: "画面描述:一只猫在雨夜灯牌下回头。".to_string(),
current_turn: 1,
progress_percent: 94,
stage: "ready_to_publish".to_string(),
anchor_pack: test_puzzle_anchor_pack_record(),
draft: Some(test_puzzle_draft_record()),
messages: Vec::new(),
last_assistant_reply: None,
published_profile_id: None,
suggested_actions: Vec::new(),
result_preview: None,
updated_at: "2024-01-01T00:00:00Z".to_string(),
};
{
let draft = session.draft.as_mut().expect("draft");
draft.work_title = "猫画面".to_string();
draft.work_description = String::new();
draft.summary = String::new();
draft.theme_tags = Vec::new();
}
let metadata = PuzzleLevelNaming {
level_name: "雨夜猫街".to_string(),
work_description: Some("在湿润灯牌与猫影之间完成一套雨夜街角拼图".to_string()),
work_tags: vec![
"插画".to_string(),
"灯牌".to_string(),
"街角".to_string(),
"猫咪".to_string(),
"暖色".to_string(),
"雨夜".to_string(),
],
ui_background_prompt: None,
};
let session = apply_generated_puzzle_initial_metadata_to_session_snapshot(
session,
&metadata,
"猫画面",
1_713_686_401_234_568,
);
let draft = session.draft.expect("draft");
assert_eq!(draft.work_title, "雨夜猫街");
assert_eq!(
draft.work_description,
"在湿润灯牌与猫影之间完成一套雨夜街角拼图"
);
assert_eq!(draft.summary, draft.work_description);
assert_eq!(draft.theme_tags, metadata.work_tags);
}
#[test]
fn puzzle_level_audio_asset_roundtrips_between_response_and_module_json() {
let level = PuzzleDraftLevelResponse {
level_id: "puzzle-level-1".to_string(),
level_name: "雨夜猫街".to_string(),
picture_description: "一只猫在雨夜灯牌下回头。".to_string(),
picture_reference: None,
ui_background_prompt: None,
ui_background_image_src: None,
ui_background_image_object_key: None,
background_music: Some(CreationAudioAsset {
task_id: "suno-task-1".to_string(),
provider: "vector-engine-suno".to_string(),
asset_object_id: Some("assetobj_1".to_string()),
asset_kind: Some("puzzle_background_music".to_string()),
audio_src: "/generated-puzzle-assets/audio.mp3".to_string(),
prompt: Some("轻快拼图音乐".to_string()),
title: Some("雨夜猫街背景音乐".to_string()),
updated_at: Some("2026-05-11T00:00:00Z".to_string()),
}),
candidates: vec![],
selected_candidate_id: None,
cover_image_src: None,
cover_asset_id: None,
generation_status: "ready".to_string(),
};
let request_context = RequestContext::new(
"test-request".to_string(),
"PUT /api/runtime/puzzle/works/test".to_string(),
Duration::ZERO,
false,
);
let levels_json = serialize_puzzle_levels_response(&request_context, &[level])
.expect("levels should serialize");
let payload: Value = serde_json::from_str(&levels_json).expect("levels json should parse");
assert_eq!(
payload[0]["background_music"]["audio_src"],
Value::String("/generated-puzzle-assets/audio.mp3".to_string())
);
assert!(payload[0]["background_music"].get("audioSrc").is_none());
let records = parse_puzzle_level_records_from_module_json(&levels_json)
.expect("levels should map back into records");
let music = records[0]
.background_music
.as_ref()
.expect("background music should exist");
assert_eq!(music.audio_src, "/generated-puzzle-assets/audio.mp3");
assert_eq!(music.asset_kind.as_deref(), Some("puzzle_background_music"));
let response = map_puzzle_draft_level_response(records[0].clone());
assert_eq!(
response
.background_music
.as_ref()
.map(|asset| asset.audio_src.as_str()),
Some("/generated-puzzle-assets/audio.mp3")
);
}
#[test]
fn puzzle_ui_background_fields_roundtrip_between_response_and_module_json() {
let level = PuzzleDraftLevelResponse {
level_id: "puzzle-level-1".to_string(),
level_name: "雨夜猫街".to_string(),
picture_description: "一只猫在雨夜灯牌下回头。".to_string(),
picture_reference: None,
ui_background_prompt: Some("雨夜猫街竖屏拼图UI背景".to_string()),
ui_background_image_src: Some(
"/generated-puzzle-assets/session/ui/background.png".to_string(),
),
ui_background_image_object_key: Some(
"generated-puzzle-assets/session/ui/background.png".to_string(),
),
background_music: None,
candidates: vec![],
selected_candidate_id: None,
cover_image_src: Some("/generated-puzzle-assets/session/cover.png".to_string()),
cover_asset_id: Some("asset-1".to_string()),
generation_status: "ready".to_string(),
};
let request_context = RequestContext::new(
"test-request".to_string(),
"PUT /api/runtime/puzzle/works/test".to_string(),
Duration::ZERO,
false,
);
let levels_json = serialize_puzzle_levels_response(&request_context, &[level])
.expect("levels should serialize");
let payload: Value = serde_json::from_str(&levels_json).expect("levels json should parse");
assert_eq!(
payload[0]["ui_background_prompt"],
Value::String("雨夜猫街竖屏拼图UI背景".to_string())
);
assert!(payload[0].get("uiBackgroundPrompt").is_none());
let records = parse_puzzle_level_records_from_module_json(&levels_json)
.expect("levels should map back into records");
assert_eq!(
records[0].ui_background_image_src.as_deref(),
Some("/generated-puzzle-assets/session/ui/background.png")
);
let response = map_puzzle_draft_level_response(records[0].clone());
assert_eq!(
response.ui_background_image_object_key.as_deref(),
Some("generated-puzzle-assets/session/ui/background.png")
);
}
#[test]
fn puzzle_work_summary_response_keeps_levels_for_shelf_cover() {
let state = AppState::new(crate::config::AppConfig::default()).expect("state should build");
let level = PuzzleDraftLevelRecord {
level_id: "puzzle-level-1".to_string(),
level_name: "雨夜猫街".to_string(),
picture_description: "一只猫在雨夜灯牌下回头。".to_string(),
picture_reference: None,
ui_background_prompt: None,
ui_background_image_src: None,
ui_background_image_object_key: None,
background_music: None,
candidates: vec![PuzzleGeneratedImageCandidateRecord {
candidate_id: "candidate-1".to_string(),
image_src: "/generated-puzzle-assets/session/candidate-1.png".to_string(),
asset_id: "asset-1".to_string(),
prompt: "雨夜猫街".to_string(),
actual_prompt: None,
source_type: "generated".to_string(),
selected: true,
}],
selected_candidate_id: Some("candidate-1".to_string()),
cover_image_src: Some("/generated-puzzle-assets/session/cover.png".to_string()),
cover_asset_id: Some("asset-1".to_string()),
generation_status: "ready".to_string(),
};
let response = map_puzzle_work_summary_response(
&state,
PuzzleWorkProfileRecord {
work_id: "puzzle-work-1".to_string(),
profile_id: "puzzle-profile-1".to_string(),
owner_user_id: "user-1".to_string(),
source_session_id: Some("puzzle-session-1".to_string()),
author_display_name: "玩家".to_string(),
work_title: "雨夜猫街".to_string(),
work_description: "一只猫在雨夜灯牌下回头。".to_string(),
level_name: "雨夜猫街".to_string(),
summary: "一只猫在雨夜灯牌下回头。".to_string(),
theme_tags: vec!["".to_string()],
cover_image_src: None,
cover_asset_id: None,
publication_status: "draft".to_string(),
updated_at: "2026-05-08T00:00:00.000Z".to_string(),
published_at: None,
play_count: 0,
remix_count: 0,
like_count: 0,
recent_play_count_7d: 0,
point_incentive_total_half_points: 0,
point_incentive_claimed_points: 0,
publish_ready: false,
anchor_pack: test_puzzle_anchor_pack_record(),
levels: vec![level],
},
);
assert_eq!(response.levels.len(), 1);
assert_eq!(response.generation_status.as_deref(), Some("ready"));
assert_eq!(
response.levels[0].cover_image_src.as_deref(),
Some("/generated-puzzle-assets/session/cover.png")
);
assert_eq!(
response.levels[0].candidates[0].image_src,
"/generated-puzzle-assets/session/candidate-1.png"
);
}
#[test]
fn puzzle_ui_background_prompt_keeps_generated_slots_out_of_background() {
let prompt = build_puzzle_ui_background_request_prompt_for_test("雨夜猫街", "雨夜猫街主题背景");
assert!(prompt.contains("9:16"));
assert!(prompt.contains("纯背景图"));
assert!(prompt.contains("不得出现拼图槽"));
assert!(prompt.contains("默认拼图槽"));
assert!(prompt.contains("文字"));
}
#[test]
fn puzzle_initial_ui_background_prompt_prefers_ai_generated_prompt() {
let mut draft = test_puzzle_draft_record();
draft.work_title = "模板作品名".to_string();
draft.work_description = "模板作品描述".to_string();
let mut target_level = draft.levels[0].clone();
target_level.level_name = "雨夜猫街".to_string();
let ai_prompt = "雨夜老街延展成竖屏空间,湿润石板路倒映暖色灯牌,远处屋檐和薄雾形成柔和层次";
target_level.ui_background_prompt = Some(ai_prompt.to_string());
let prompt = resolve_puzzle_initial_ui_background_prompt(&draft, &target_level);
assert_eq!(prompt, ai_prompt);
assert!(!prompt.contains(PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER));
}
#[test]
fn puzzle_initial_ui_background_prompt_falls_back_to_context_template() {
let draft = test_puzzle_draft_record();
let target_level = draft.levels[0].clone();
let prompt = resolve_puzzle_initial_ui_background_prompt(&draft, &target_level);
assert!(prompt.contains("雨夜猫街"));
assert!(prompt.contains(PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER));
}
#[test]
fn puzzle_ui_background_initial_attach_updates_first_level_fields() {
let draft = test_puzzle_draft_record();
let generated = GeneratedPuzzleUiBackgroundResponse {
image_src: "/generated-puzzle-assets/session/ui/background.png".to_string(),
object_key: "generated-puzzle-assets/session/ui/background.png".to_string(),
};
let mut levels = draft.levels.clone();
attach_puzzle_level_ui_background(
&mut levels,
"puzzle-level-1",
"雨夜猫街移动端拼图UI背景".to_string(),
generated,
);
assert_eq!(
levels[0].ui_background_prompt.as_deref(),
Some("雨夜猫街移动端拼图UI背景")
);
assert_eq!(
levels[0].ui_background_image_src.as_deref(),
Some("/generated-puzzle-assets/session/ui/background.png")
);
assert_eq!(
levels[0].ui_background_image_object_key.as_deref(),
Some("generated-puzzle-assets/session/ui/background.png")
);
}
#[test]
fn puzzle_initial_draft_assets_must_include_ui_background() {
let mut draft = test_puzzle_draft_record();
let missing_all = ensure_puzzle_initial_level_assets_ready(&draft.levels[0])
.expect_err("缺少自动生成资产时不能把草稿标记为完成");
assert_eq!(missing_all.status_code(), StatusCode::BAD_GATEWAY);
assert!(missing_all.body_text().contains("UI背景图"));
draft.levels[0].ui_background_image_src =
Some("/generated-puzzle-assets/session/ui/background.png".to_string());
ensure_puzzle_initial_level_assets_ready(&draft.levels[0])
.expect("UI 背景存在时即可完成自动草稿资源检查");
}
fn test_puzzle_anchor_pack_record() -> PuzzleAnchorPackRecord {
let item = PuzzleAnchorItemRecord {
key: "visualSubject".to_string(),
label: "画面".to_string(),
value: "雨夜猫街".to_string(),
status: "confirmed".to_string(),
};
PuzzleAnchorPackRecord {
theme_promise: item.clone(),
visual_subject: item.clone(),
visual_mood: item.clone(),
composition_hooks: item.clone(),
tags_and_forbidden: item,
}
}
fn test_puzzle_draft_record() -> PuzzleResultDraftRecord {
let anchor_pack = test_puzzle_anchor_pack_record();
PuzzleResultDraftRecord {
work_title: "雨夜猫街".to_string(),
work_description: "一只猫在雨夜灯牌下回头。".to_string(),
level_name: "猫画面".to_string(),
summary: "一只猫在雨夜灯牌下回头。".to_string(),
theme_tags: vec![],
forbidden_directives: vec![],
creator_intent: None,
anchor_pack,
candidates: vec![],
selected_candidate_id: None,
cover_image_src: None,
cover_asset_id: None,
generation_status: "idle".to_string(),
levels: vec![PuzzleDraftLevelRecord {
level_id: "puzzle-level-1".to_string(),
level_name: "猫画面".to_string(),
picture_description: "一只猫在雨夜灯牌下回头。".to_string(),
picture_reference: None,
ui_background_prompt: None,
ui_background_image_src: None,
ui_background_image_object_key: None,
background_music: None,
candidates: vec![],
selected_candidate_id: None,
cover_image_src: None,
cover_asset_id: None,
generation_status: "idle".to_string(),
}],
form_draft: None,
}
}
#[test]
fn puzzle_primary_level_update_preserves_reference_for_regeneration() {
let draft = test_puzzle_draft_record();
let mut target_level = draft.levels[0].clone();
target_level.level_name = "雨夜猫街".to_string();
let levels = build_puzzle_levels_with_primary_update(
&draft,
&target_level,
Some("data:image/png;base64,abcd"),
);
assert_eq!(levels[0].level_name, "雨夜猫街");
assert_eq!(
levels[0].picture_reference.as_deref(),
Some("data:image/png;base64,abcd")
);
}
#[test]
fn puzzle_generated_fallback_snapshot_preserves_picture_reference() {
let anchor_pack = test_puzzle_anchor_pack_record();
let session = PuzzleAgentSessionRecord {
session_id: "puzzle-session-1".to_string(),
seed_text: "雨夜猫街".to_string(),
current_turn: 1,
progress_percent: 0,
stage: "draft_ready".to_string(),
anchor_pack: anchor_pack.clone(),
draft: Some(test_puzzle_draft_record()),
messages: Vec::new(),
last_assistant_reply: None,
published_profile_id: None,
suggested_actions: Vec::new(),
result_preview: None,
updated_at: "2024-01-01T00:00:00Z".to_string(),
};
let candidate = PuzzleGeneratedImageCandidateRecord {
candidate_id: "puzzle-session-1-candidate-1".to_string(),
image_src: "/generated-puzzle-assets/puzzle-session-1/1/cover.png".to_string(),
asset_id: "puzzle-cover-1".to_string(),
prompt: "雨夜猫街".to_string(),
actual_prompt: Some("雨夜猫街".to_string()),
source_type: "generated:gpt-image-2".to_string(),
selected: true,
};
let session = apply_generated_puzzle_candidates_to_session_snapshot(
session,
"puzzle-level-1",
vec![candidate],
Some("data:image/png;base64,abcd"),
1_713_686_401_234_568,
);
let draft = session.draft.expect("draft");
assert_eq!(
draft.levels[0].picture_reference.as_deref(),
Some("data:image/png;base64,abcd")
);
}
#[test]
fn freeze_boundary_sync_only_matches_freeze_invalid_operation() {
let invalid_operation = AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "spacetimedb",
"message": "操作不合法",
}));
let other_error = AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "spacetimedb",
"message": "泥点余额不足",
}));
assert!(should_sync_puzzle_freeze_boundary(&invalid_operation, true));
assert!(!should_sync_puzzle_freeze_boundary(
&invalid_operation,
false
));
assert!(!should_sync_puzzle_freeze_boundary(&other_error, true));
}