Files
Genarrative/server-rs/crates/api-server/src/puzzle/tests.rs

1184 lines
43 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::*;
use crate::openai_image_generation::{GPT_IMAGE_2_MODEL, map_platform_image_error};
use platform_image::{PlatformImageError, VECTOR_ENGINE_PROVIDER};
use std::time::Duration;
#[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_create_request_uses_gpt_image_2_without_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"], 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_create_request_never_embeds_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(),
signed_read_url: None,
};
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),
);
assert!(body.get("image").is_none());
}
#[test]
fn puzzle_level_scene_spritesheet_and_background_requests_use_references() {
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 = PuzzleDownloadedImage {
extension: "png".to_string(),
mime_type: "image/png".to_string(),
bytes: cursor.into_inner(),
};
let scene_body = build_puzzle_level_scene_image_request_body_for_test(&reference_image)
.expect("scene request should build");
assert_eq!(scene_body["model"], GPT_IMAGE_2_MODEL);
assert_eq!(scene_body["size"], "1024x1536");
assert!(scene_body.get("image").is_none());
assert!(
scene_body["prompt"]
.as_str()
.unwrap_or_default()
.contains("参考图作为拼图画面")
);
assert!(
scene_body["prompt"]
.as_str()
.unwrap_or_default()
.contains("道具按钮上不要显示次数标注")
);
assert!(
scene_body["prompt"]
.as_str()
.unwrap_or_default()
.contains("返回按钮和设置按钮旁禁止标注文字")
);
let spritesheet_body = build_puzzle_ui_spritesheet_request_body_for_test(&reference_image)
.expect("spritesheet request should build");
assert_eq!(spritesheet_body["model"], GPT_IMAGE_2_MODEL);
assert_eq!(spritesheet_body["size"], "1024x1024");
assert!(
spritesheet_body["prompt"]
.as_str()
.unwrap_or_default()
.contains("返回按钮、设置按钮、下一关按钮、提示按钮、原图按钮、冻结按钮")
);
assert!(
spritesheet_body["prompt"]
.as_str()
.unwrap_or_default()
.contains("纯绿色绿幕背景")
);
assert!(
spritesheet_body["prompt"]
.as_str()
.unwrap_or_default()
.contains("绿幕扣成透明")
);
assert!(
spritesheet_body["prompt"]
.as_str()
.unwrap_or_default()
.contains("自动边界检测")
);
assert!(
spritesheet_body["prompt"]
.as_str()
.unwrap_or_default()
.contains("按钮素材内必须保留对应中文文字")
);
assert!(
spritesheet_body["prompt"]
.as_str()
.unwrap_or_default()
.contains("不要额外画白色外圈")
);
assert!(
spritesheet_body["prompt"]
.as_str()
.unwrap_or_default()
.contains("白底圆环")
);
assert!(
!spritesheet_body["prompt"]
.as_str()
.unwrap_or_default()
.contains("禁止文字")
);
let background_body = build_puzzle_level_background_request_body_for_test(&reference_image)
.expect("background request should build");
assert_eq!(background_body["model"], GPT_IMAGE_2_MODEL);
assert_eq!(background_body["size"], "1024x1536");
assert!(
background_body["prompt"]
.as_str()
.unwrap_or_default()
.contains("移除参考图中所有UI元素")
);
assert!(
background_body["prompt"]
.as_str()
.unwrap_or_default()
.contains("禁止在背景中出现人像或和拼图画面中主体一致的内容")
);
}
#[test]
fn puzzle_ui_spritesheet_postprocess_turns_green_screen_transparent() {
let mut source = image::RgbaImage::from_pixel(8, 8, image::Rgba([0, 255, 0, 255]));
for y in 2..6 {
for x in 2..6 {
source.put_pixel(x, y, image::Rgba([190, 78, 42, 255]));
}
}
let mut cursor = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(source)
.write_to(&mut cursor, ImageFormat::Png)
.expect("test image should encode");
let processed = make_puzzle_ui_spritesheet_image_transparent_for_test(PuzzleDownloadedImage {
extension: "png".to_string(),
mime_type: "image/png".to_string(),
bytes: cursor.into_inner(),
})
.expect("green screen postprocess should succeed");
assert_eq!(processed.extension, "png");
assert_eq!(processed.mime_type, "image/png");
let decoded = image::load_from_memory(processed.bytes.as_slice())
.expect("processed image should decode")
.to_rgba8();
assert_eq!(decoded.get_pixel(0, 0).0[3], 0);
assert_eq!(decoded.get_pixel(3, 3).0[3], 255);
}
#[test]
fn puzzle_vector_engine_create_request_never_embeds_signed_reference_url() {
let reference_image = PuzzleResolvedReferenceImage {
mime_type: "image/png".to_string(),
bytes_len: 4,
bytes: b"test".to_vec(),
signed_read_url: Some(
"https://oss.example/generated-puzzle-assets/reference.png?x-oss-signature=abc"
.to_string(),
),
};
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),
);
assert!(body.get("image").is_none());
}
#[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_generation_requires_ai_redraw() {
assert!(!should_use_puzzle_reference_image_generation(None, true));
assert!(!should_use_puzzle_reference_image_generation(
Some("data:image/png;base64,abcd"),
false
));
assert!(should_use_puzzle_reference_image_generation(
Some("data:image/png;base64,abcd"),
true
));
}
#[test]
fn puzzle_result_level_direct_upload_skips_cover_image_generation() {
assert!(should_use_uploaded_puzzle_image_directly(
Some("data:image/png;base64,abcd"),
false
));
assert!(!should_use_uploaded_puzzle_image_directly(
Some("data:image/png;base64,abcd"),
true
));
assert!(!should_use_uploaded_puzzle_image_directly(None, false));
}
#[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(),
],
None,
&[],
);
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_reference_image_sources_prefer_asset_object_ids() {
let sources = collect_puzzle_reference_image_sources(
Some("data:image/png;base64,legacy"),
&["/generated-puzzle-assets/legacy.png".to_string()],
Some("asset-main-1"),
&[
"asset-main-1".to_string(),
"asset-prompt-1".to_string(),
"asset-prompt-2".to_string(),
],
);
assert_eq!(
sources,
vec![
"asset-object:asset-main-1".to_string(),
"asset-object:asset-prompt-1".to_string(),
"asset-object:asset-prompt-2".to_string(),
"data:image/png;base64,legacy".to_string(),
"/generated-puzzle-assets/legacy.png".to_string(),
]
);
}
#[test]
fn puzzle_asset_object_reference_requires_matching_owner() {
let asset_object = module_assets::AssetObjectRecord {
asset_object_id: "assetobj_reference_1".to_string(),
bucket: "genarrative-assets".to_string(),
object_key: "generated-puzzle-assets/reference/image.png".to_string(),
access_policy: module_assets::AssetObjectAccessPolicy::Private,
content_type: Some("image/png".to_string()),
content_length: 1024,
content_hash: None,
version: 1,
source_job_id: None,
owner_user_id: Some("user-other".to_string()),
profile_id: None,
entity_id: None,
asset_kind: "puzzle_cover_image".to_string(),
created_at: "2026-05-21T00:00:00Z".to_string(),
updated_at: "2026-05-21T00:00:00Z".to_string(),
};
let error = validate_puzzle_reference_asset_object(
&asset_object,
Some("user-current"),
"genarrative-assets",
)
.expect_err("其他账号的参考图资产应被拒绝");
assert_eq!(error.status_code(), StatusCode::FORBIDDEN);
assert!(error.body_text().contains("不属于当前账号"));
}
#[test]
fn puzzle_vector_engine_timeout_maps_to_gateway_timeout() {
let error = map_platform_image_error(PlatformImageError::Request {
provider: VECTOR_ENGINE_PROVIDER,
message: "创建拼图 VectorEngine 图片生成任务失败operation timed out".to_string(),
endpoint: Some("https://vector.example/v1/images/generations".to_string()),
timeout: true,
connect: false,
request: true,
body: false,
status_code: None,
source: None,
audit: None,
});
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_platform_image_error(PlatformImageError::Upstream {
provider: VECTOR_ENGINE_PROVIDER,
message: "VectorEngine generation endpoint timeout".to_string(),
upstream_status: reqwest::StatusCode::GATEWAY_TIMEOUT.as_u16(),
raw_excerpt: r#"{"error":{"message":"VectorEngine generation endpoint timeout"}}"#
.to_string(),
audit: None,
});
let response = error.into_response();
assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT);
}
#[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(),
reference_image_asset_object_id: None,
reference_image_asset_object_ids: Vec::new(),
image_model: Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2.to_string()),
ai_redraw: None,
candidate_count: Some(1),
should_auto_name_level: None,
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_uploaded_cover_can_reuse_resolved_history_image() {
let resolved = PuzzleResolvedReferenceImage {
mime_type: "image/png".to_string(),
bytes_len: 8,
bytes: b"pngbytes".to_vec(),
signed_read_url: None,
};
let downloaded = PuzzleDownloadedImage::from_resolved_reference_image(resolved);
assert_eq!(downloaded.extension, "png");
assert_eq!(downloaded.mime_type, "image/png");
assert_eq!(downloaded.bytes, b"pngbytes");
}
#[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(),
reference_image_asset_object_id: None,
reference_image_asset_object_ids: Vec::new(),
image_model: Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2.to_string()),
ai_redraw: None,
candidate_count: Some(1),
should_auto_name_level: None,
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,
level_scene_image_src: None,
level_scene_image_object_key: None,
ui_spritesheet_image_src: None,
ui_spritesheet_image_object_key: None,
level_background_image_src: None,
level_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(),
),
level_scene_image_src: None,
level_scene_image_object_key: None,
ui_spritesheet_image_src: None,
ui_spritesheet_image_object_key: None,
level_background_image_src: None,
level_background_image_object_key: None,
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_level_asset_bundle_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/legacy-ui/background.png".to_string(),
),
ui_background_image_object_key: Some(
"generated-puzzle-assets/session/legacy-ui/background.png".to_string(),
),
level_scene_image_src: Some(
"/generated-puzzle-assets/session/level-scene/scene.png".to_string(),
),
level_scene_image_object_key: Some(
"generated-puzzle-assets/session/level-scene/scene.png".to_string(),
),
ui_spritesheet_image_src: Some(
"/generated-puzzle-assets/session/ui-spritesheet/sheet.png".to_string(),
),
ui_spritesheet_image_object_key: Some(
"generated-puzzle-assets/session/ui-spritesheet/sheet.png".to_string(),
),
level_background_image_src: Some(
"/generated-puzzle-assets/session/level-background/background.png".to_string(),
),
level_background_image_object_key: Some(
"generated-puzzle-assets/session/level-background/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]["level_background_image_object_key"],
Value::String(
"generated-puzzle-assets/session/level-background/background.png".to_string()
)
);
assert!(payload[0].get("levelBackgroundImageObjectKey").is_none());
let records = parse_puzzle_level_records_from_module_json(&levels_json)
.expect("levels should map back into records");
assert_eq!(
records[0].level_scene_image_src.as_deref(),
Some("/generated-puzzle-assets/session/level-scene/scene.png")
);
assert_eq!(
records[0].ui_spritesheet_image_object_key.as_deref(),
Some("generated-puzzle-assets/session/ui-spritesheet/sheet.png")
);
let response = map_puzzle_draft_level_response(records[0].clone());
assert_eq!(
response.level_background_image_src.as_deref(),
Some("/generated-puzzle-assets/session/level-background/background.png")
);
}
#[test]
fn puzzle_work_summary_response_keeps_levels_for_shelf_cover() {
let app_state = crate::state::AppState::new(crate::config::AppConfig::default())
.expect("state should build");
let state: PuzzleApiState = axum::extract::FromRef::from_ref(&app_state);
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,
level_scene_image_src: None,
level_scene_image_object_key: None,
ui_spritesheet_image_src: None,
ui_spritesheet_image_object_key: None,
level_background_image_src: None,
level_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: "generating".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].generation_status, "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("关卡背景图"));
assert!(missing_all.body_text().contains("UI spritesheet"));
draft.levels[0].level_background_image_src =
Some("/generated-puzzle-assets/session/background/background.png".to_string());
draft.levels[0].ui_spritesheet_image_src =
Some("/generated-puzzle-assets/session/spritesheet/sheet.png".to_string());
ensure_puzzle_initial_level_assets_ready(&draft.levels[0])
.expect("关卡背景和 UI spritesheet 存在时即可完成自动草稿资源检查");
}
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,
level_scene_image_src: None,
level_scene_image_object_key: None,
ui_spritesheet_image_src: None,
ui_spritesheet_image_object_key: None,
level_background_image_src: None,
level_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));
}