use super::*; use super::*; fn test_match3d_generated_item_asset(index: u32, name: &str) -> Match3DGeneratedItemAsset { Match3DGeneratedItemAsset { item_id: format!("match3d-item-{index}"), item_name: name.to_string(), item_size: Some(infer_match3d_item_size(name)), image_src: Some(format!( "/generated-match3d-assets/s/p/items/i{index}/views/view-01.png" )), image_object_key: Some(format!( "generated-match3d-assets/s/p/items/i{index}/views/view-01.png" )), image_views: (1..=MATCH3D_ITEM_VIEW_COUNT) .map(|view_index| Match3DGeneratedItemImageView { view_id: format!("view-{view_index:02}"), view_index: view_index as u32, image_src: Some(format!( "/generated-match3d-assets/s/p/items/i{index}/views/view-{view_index:02}.png" )), image_object_key: Some(format!( "generated-match3d-assets/s/p/items/i{index}/views/view-{view_index:02}.png" )), }) .collect(), model_src: Some(format!( "/generated-match3d-assets/s/p/items/i{index}/model/model.glb" )), model_object_key: Some(format!( "generated-match3d-assets/s/p/items/i{index}/model/model.glb" )), model_file_name: Some("model.glb".to_string()), task_uuid: Some(format!("task-{index}")), subscription_key: Some(format!("sub-{index}")), sound_prompt: Some(format!("{name}点击音效")), background_music_title: None, background_music_style: None, background_music_prompt: None, background_music: None, click_sound: None, background_asset: None, status: "image_ready".to_string(), error: None, } } fn config(theme_text: &str, clear_count: u32, difficulty: u32) -> Match3DConfigJson { Match3DConfigJson { theme_text: theme_text.to_string(), reference_image_src: None, clear_count, difficulty, asset_style_id: None, asset_style_label: None, asset_style_prompt: None, generate_click_sound: false, } } #[test] fn match3d_agent_reply_asks_three_questions_before_confirmation() { let current = config("水果", 4, 6); assert_eq!( build_match3d_assistant_reply_for_turn(¤t, 0), MATCH3D_QUESTION_THEME ); assert_eq!( build_match3d_assistant_reply_for_turn(¤t, 1), MATCH3D_QUESTION_CLEAR_COUNT ); assert_eq!( build_match3d_assistant_reply_for_turn(¤t, 2), MATCH3D_QUESTION_DIFFICULTY ); assert_eq!( build_match3d_assistant_reply_for_turn(¤t, 3), "已确认:水果题材,需要消除 4 次,共 12 件物品,难度 6。" ); } #[test] fn match3d_agent_progress_follows_question_turns() { assert_eq!(resolve_progress_percent_for_turn(0), 0); assert_eq!(resolve_progress_percent_for_turn(1), 33); assert_eq!(resolve_progress_percent_for_turn(2), 66); assert_eq!(resolve_progress_percent_for_turn(3), 100); assert_eq!(resolve_progress_percent_for_turn(8), 100); } #[test] fn match3d_anchor_pack_masks_uncollected_default_values() { let pack = Match3DAnchorPackRecord { theme: Match3DAnchorItemRecord { key: "theme".to_string(), label: "题材主题".to_string(), value: "缤纷玩具".to_string(), status: "confirmed".to_string(), }, clear_count: Match3DAnchorItemRecord { key: "clearCount".to_string(), label: "需要消除次数".to_string(), value: "12".to_string(), status: "confirmed".to_string(), }, difficulty: Match3DAnchorItemRecord { key: "difficulty".to_string(), label: "难度".to_string(), value: "4".to_string(), status: "confirmed".to_string(), }, }; let response = map_match3d_anchor_pack_response_for_turn(pack, 0, "Collecting"); assert_eq!(response.theme.value, ""); assert_eq!(response.theme.status, "missing"); assert_eq!(response.clear_count.value, ""); assert_eq!(response.clear_count.status, "missing"); assert_eq!(response.difficulty.value, ""); assert_eq!(response.difficulty.status, "missing"); } #[test] fn match3d_item_image_path_segments_stay_unique_for_chinese_names() { let item_names = ["草莓", "苹果", "香蕉"]; let slugs = item_names .iter() .enumerate() .map(|(index, item_name)| { let item_id = format!("match3d-item-{}", index + 1); format!( "{item_id}-{}", sanitize_match3d_asset_segment(item_name, "item") ) }) .collect::>(); assert_eq!( slugs, vec![ "match3d-item-1-item", "match3d-item-2-item", "match3d-item-3-item", ] ); } #[test] fn match3d_material_sheet_slicing_uses_fixed_five_by_five_rows() { let width = 500; let height = 500; let item_names = vec!["樱桃".to_string(), "苹果".to_string(), "香蕉".to_string()]; let mut sheet = image::RgbaImage::new(width, height); for row in 0..5 { for col in 0..5 { let color = image::Rgba([ 32 + row as u8 * 40, 24 + col as u8 * 36, 210 - row as u8 * 30, 255, ]); for y in row * 100..(row + 1) * 100 { for x in col * 100..(col + 1) * 100 { sheet.put_pixel(x, y, color); } } } } let mut encoded = std::io::Cursor::new(Vec::new()); image::DynamicImage::ImageRgba8(sheet) .write_to(&mut encoded, ImageFormat::Png) .expect("sheet should encode"); let image = DownloadedOpenAiImage { bytes: encoded.into_inner(), mime_type: "image/png".to_string(), extension: "png".to_string(), }; let slices = slice_match3d_material_sheet(&image, &item_names).expect("sheet should slice"); assert_eq!(slices.len(), 3); for (row, views) in slices.iter().enumerate() { assert_eq!(views.len(), MATCH3D_ITEM_VIEW_COUNT); for (col, view) in views.iter().enumerate() { let decoded = image::load_from_memory(view.bytes.as_slice()) .expect("view should decode") .to_rgba8(); let pixel = decoded.get_pixel(decoded.width() / 2, decoded.height() / 2); assert_eq!( pixel.0, [ 32 + row as u8 * 40, 24 + col as u8 * 36, 210 - row as u8 * 30, 255, ], "row {row} col {col} should be cut from the fixed 5*5 grid row" ); } } } #[test] fn match3d_material_sheet_slicing_keeps_near_edge_foreground_pixels() { let width = 500; let height = 500; let item_names = vec!["樱桃".to_string(), "苹果".to_string(), "香蕉".to_string()]; let mut sheet = image::RgbaImage::from_pixel(width, height, image::Rgba([255, 255, 255, 255])); for y in 1..5 { for x in 18..82 { sheet.put_pixel(x, y, image::Rgba([20, 80, 240, 255])); } } for y in 5..96 { for x in 18..82 { sheet.put_pixel(x, y, image::Rgba([220, 32, 48, 255])); } } for y in 96..99 { for x in 18..82 { sheet.put_pixel(x, y, image::Rgba([20, 180, 64, 255])); } } let mut encoded = std::io::Cursor::new(Vec::new()); image::DynamicImage::ImageRgba8(sheet) .write_to(&mut encoded, ImageFormat::Png) .expect("sheet should encode"); let image = DownloadedOpenAiImage { bytes: encoded.into_inner(), mime_type: "image/png".to_string(), extension: "png".to_string(), }; let slices = slice_match3d_material_sheet(&image, &item_names).expect("sheet should slice"); let decoded = image::load_from_memory(slices[0][0].bytes.as_slice()) .expect("view should decode") .to_rgba8(); let pixels = decoded.pixels().map(|pixel| pixel.0).collect::>(); assert!( pixels.iter().any(|pixel| *pixel == [20, 80, 240, 255]), "贴近顶部的前景像素不能被固定内缩切掉" ); assert!( pixels.iter().any(|pixel| *pixel == [20, 180, 64, 255]), "贴近底部的前景像素不能被固定内缩切掉" ); } #[test] fn match3d_material_sheet_slicing_makes_green_screen_transparent_before_crop() { let width = 500; let height = 500; let item_names = vec!["草莓".to_string()]; let mut sheet = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 255, 0, 255])); for y in 35..65 { for x in 35..65 { sheet.put_pixel(x, y, image::Rgba([220, 32, 48, 255])); } } let mut encoded = std::io::Cursor::new(Vec::new()); image::DynamicImage::ImageRgba8(sheet) .write_to(&mut encoded, ImageFormat::Png) .expect("sheet should encode"); let image = DownloadedOpenAiImage { bytes: encoded.into_inner(), mime_type: "image/png".to_string(), extension: "png".to_string(), }; let slices = slice_match3d_material_sheet(&image, &item_names).expect("sheet should slice"); let decoded = image::load_from_memory(slices[0][0].bytes.as_slice()) .expect("view should decode") .to_rgba8(); assert!( decoded.pixels().all(|pixel| { let [red, green, blue, alpha] = pixel.0; alpha == 0 || !(green > red.saturating_add(32) && green > blue.saturating_add(32)) }), "绿幕背景必须在切割输出中变成透明或被单素材二次裁边移除" ); assert!( decoded.pixels().any(|pixel| pixel.0 == [220, 32, 48, 255]), "物品主体不能被绿幕去背误删" ); } #[test] fn match3d_material_sheet_slicing_removes_isolated_green_cell_background() { let width = 500; let height = 500; let item_names = vec!["葡萄".to_string()]; let mut sheet = image::RgbaImage::from_pixel(width, height, image::Rgba([245, 245, 245, 255])); for y in 8..92 { for x in 8..92 { sheet.put_pixel(x, y, image::Rgba([0, 236, 18, 255])); } } for y in 35..65 { for x in 35..65 { sheet.put_pixel(x, y, image::Rgba([136, 64, 210, 255])); } } let mut encoded = std::io::Cursor::new(Vec::new()); image::DynamicImage::ImageRgba8(sheet) .write_to(&mut encoded, ImageFormat::Png) .expect("sheet should encode"); let image = DownloadedOpenAiImage { bytes: encoded.into_inner(), mime_type: "image/png".to_string(), extension: "png".to_string(), }; let slices = slice_match3d_material_sheet(&image, &item_names).expect("sheet should slice"); let decoded = image::load_from_memory(slices[0][0].bytes.as_slice()) .expect("view should decode") .to_rgba8(); assert!( decoded .pixels() .all(|pixel| pixel.0[3] == 0 || pixel.0[1] < 180), "没有连到整张 sheet 外边缘的绿幕块也必须被转成透明" ); assert!( decoded.pixels().any(|pixel| pixel.0 == [136, 64, 210, 255]), "绿幕清理不能误删物品主体" ); } #[test] fn match3d_material_sheet_slicing_removes_soft_green_matte_before_crop() { let width = 500; let height = 500; let item_names = vec!["草莓".to_string()]; let mut sheet = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 255, 0, 255])); for y in 28..72 { for x in 28..72 { sheet.put_pixel(x, y, image::Rgba([64, 198, 112, 255])); } } for y in 36..64 { for x in 36..64 { sheet.put_pixel(x, y, image::Rgba([220, 32, 48, 255])); } } let mut encoded = std::io::Cursor::new(Vec::new()); image::DynamicImage::ImageRgba8(sheet) .write_to(&mut encoded, ImageFormat::Png) .expect("sheet should encode"); let image = DownloadedOpenAiImage { bytes: encoded.into_inner(), mime_type: "image/png".to_string(), extension: "png".to_string(), }; let slices = slice_match3d_material_sheet(&image, &item_names).expect("sheet should slice"); let decoded = image::load_from_memory(slices[0][0].bytes.as_slice()) .expect("view should decode") .to_rgba8(); assert!( decoded.pixels().all(|pixel| { let [red, green, blue, alpha] = pixel.0; alpha == 0 || green <= red.max(blue).saturating_add(32) }), "整张 sheet 去绿后再裁剪,输出 PNG 不能保留可见软绿边" ); assert!( decoded.pixels().any(|pixel| pixel.0 == [220, 32, 48, 255]), "软绿边清理不能误删物品主体" ); } #[test] fn match3d_material_sheet_slicing_crops_single_view_green_antialias_border() { let width = 500; let height = 500; let item_names = vec!["丸子".to_string()]; let mut sheet = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 255, 0, 255])); for y in 22..78 { for x in 22..78 { if x <= 24 || x >= 75 || y <= 24 || y >= 75 { sheet.put_pixel(x, y, image::Rgba([168, 246, 176, 255])); } } } for y in 40..60 { for x in 40..60 { sheet.put_pixel(x, y, image::Rgba([174, 92, 72, 255])); } } let mut encoded = std::io::Cursor::new(Vec::new()); image::DynamicImage::ImageRgba8(sheet) .write_to(&mut encoded, ImageFormat::Png) .expect("sheet should encode"); let image = DownloadedOpenAiImage { bytes: encoded.into_inner(), mime_type: "image/png".to_string(), extension: "png".to_string(), }; let slices = slice_match3d_material_sheet(&image, &item_names).expect("sheet should slice"); let decoded = image::load_from_memory(slices[0][0].bytes.as_slice()) .expect("view should decode") .to_rgba8(); assert!( decoded.width() <= 24 && decoded.height() <= 24, "单素材裁剪后必须再吃掉浅绿抗锯齿边,不能把素材自带绿边算进输出尺寸;got {}x{}", decoded.width(), decoded.height() ); assert!( decoded .pixels() .all(|pixel| pixel.0[3] == 0 || pixel.0 != [168, 246, 176, 255]), "单素材输出 PNG 不能保留浅绿抗锯齿边像素" ); assert!( decoded.pixels().any(|pixel| pixel.0 == [174, 92, 72, 255]), "单素材二次裁边不能误删物品主体" ); } #[test] fn match3d_material_view_edge_matte_removes_green_border_touching_png_edge() { let width = 72; let height = 72; let mut view = image::RgbaImage::from_pixel(width, height, image::Rgba([168, 246, 176, 255])); for y in 10..62 { for x in 10..62 { view.put_pixel(x, y, image::Rgba([0, 0, 0, 0])); } } for y in 24..48 { for x in 24..48 { view.put_pixel(x, y, image::Rgba([174, 92, 72, 255])); } } let cleaned = crop_match3d_material_view_edge_matte(image::DynamicImage::ImageRgba8(view)).to_rgba8(); assert!( cleaned.width() <= 28 && cleaned.height() <= 28, "单图外缘浅绿框即使贴住 PNG 边界,也必须被透明化并从可见边界中移除;got {}x{}", cleaned.width(), cleaned.height() ); assert!( cleaned .pixels() .all(|pixel| pixel.0[3] == 0 || pixel.0 != [168, 246, 176, 255]), "单图外缘浅绿框不能残留为可见像素" ); assert!( cleaned.pixels().any(|pixel| pixel.0 == [174, 92, 72, 255]), "扩大边缘清理宽度不能误删物品主体" ); } #[test] fn match3d_material_view_edge_matte_neutralizes_dark_green_contour_pixels() { let width = 64; let height = 64; let mut view = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 0, 0, 0])); for y in 16..48 { for x in 16..48 { if x <= 18 || x >= 45 || y <= 18 || y >= 45 { view.put_pixel(x, y, image::Rgba([42, 118, 36, 255])); } else { view.put_pixel(x, y, image::Rgba([174, 92, 72, 255])); } } } let cleaned = crop_match3d_material_view_edge_matte(image::DynamicImage::ImageRgba8(view)).to_rgba8(); assert!( cleaned.pixels().all(|pixel| { let [red, green, blue, alpha] = pixel.0; alpha == 0 || green <= red.max(blue).saturating_add(18) }), "暗绿轮廓污染也必须被透明化或去绿,不能残留可见绿边" ); assert!( cleaned.pixels().any(|pixel| pixel.0 == [174, 92, 72, 255]), "暗绿轮廓清理不能误删物品主体" ); } #[test] fn match3d_material_sheet_slicing_cleans_white_matte_edge() { let width = 500; let height = 500; let item_names = vec!["羽毛".to_string()]; let mut sheet = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 255, 0, 255])); for y in 32..68 { for x in 32..68 { sheet.put_pixel(x, y, image::Rgba([248, 248, 244, 255])); } } for y in 36..64 { for x in 36..64 { sheet.put_pixel(x, y, image::Rgba([225, 174, 58, 255])); } } let mut encoded = std::io::Cursor::new(Vec::new()); image::DynamicImage::ImageRgba8(sheet) .write_to(&mut encoded, ImageFormat::Png) .expect("sheet should encode"); let image = DownloadedOpenAiImage { bytes: encoded.into_inner(), mime_type: "image/png".to_string(), extension: "png".to_string(), }; let slices = slice_match3d_material_sheet(&image, &item_names).expect("sheet should slice"); let decoded = image::load_from_memory(slices[0][0].bytes.as_slice()) .expect("view should decode") .to_rgba8(); assert!( decoded.pixels().all(|pixel| { let [red, green, blue, alpha] = pixel.0; alpha == 0 || !(red >= 238 && green >= 238 && blue >= 232) }), "近白抠图边必须被清成透明或去污染,不能在输出 PNG 中形成白边" ); assert!( decoded.pixels().any(|pixel| pixel.0 == [225, 174, 58, 255]), "白边清理不能误删物品主体" ); } #[test] fn match3d_container_image_postprocess_removes_plain_background() { let width = 256; let height = 256; let mut image = image::RgbaImage::from_pixel(width, height, image::Rgba([248, 248, 246, 255])); for y in 68..190 { for x in 38..218 { image.put_pixel(x, y, image::Rgba([160, 104, 54, 255])); } } let mut encoded = std::io::Cursor::new(Vec::new()); image::DynamicImage::ImageRgba8(image) .write_to(&mut encoded, ImageFormat::Png) .expect("container should encode"); let processed = make_match3d_container_image_transparent(DownloadedOpenAiImage { bytes: encoded.into_inner(), mime_type: "image/png".to_string(), extension: "png".to_string(), }) .expect("container should postprocess"); let decoded = image::load_from_memory(processed.bytes.as_slice()) .expect("processed container should decode") .to_rgba8(); assert_eq!(processed.mime_type, "image/png"); assert_eq!(processed.extension, "png"); assert_eq!( decoded.get_pixel(0, 0).0[3], 0, "容器图四周白底必须在入库前转成透明 alpha" ); assert_eq!( decoded.get_pixel(width / 2, height / 2).0[3], 255, "容器主体不能被透明化误删" ); } #[test] fn match3d_background_image_postprocess_removes_transparent_pixels() { let width = 16; let height = 16; let mut image = image::RgbaImage::from_pixel(width, height, image::Rgba([80, 140, 190, 255])); image.put_pixel(0, 0, image::Rgba([0, 0, 0, 0])); image.put_pixel(8, 8, image::Rgba([240, 120, 40, 128])); let mut encoded = std::io::Cursor::new(Vec::new()); image::DynamicImage::ImageRgba8(image) .write_to(&mut encoded, ImageFormat::Png) .expect("background should encode"); let processed = make_match3d_background_image_opaque(DownloadedOpenAiImage { bytes: encoded.into_inner(), mime_type: "image/png".to_string(), extension: "png".to_string(), }) .expect("background should postprocess"); let decoded = image::load_from_memory(processed.bytes.as_slice()) .expect("processed background should decode") .to_rgba8(); assert_eq!(processed.mime_type, "image/png"); assert_eq!(processed.extension, "png"); assert!( decoded.pixels().all(|pixel| pixel.0[3] == 255), "抓大鹅 9:16 背景图入库前必须移除所有透明 alpha" ); assert_ne!( decoded.get_pixel(0, 0).0, [0, 0, 0, 0], "原透明角落必须被合成到不透明背景色上" ); } #[test] fn match3d_work_metadata_parses_gpt4o_json() { let metadata = parse_match3d_work_metadata( r#"{"gameName":"果园大鹅宴","summary":"在明亮果园里收集水果小物件,节奏轻快适合随手游玩。","tags":["水果","抓大鹅","经典消除","轻量休闲"],"backgroundMusic":{"title":"果园轻舞","style":"轻快, 休闲","prompt":"果园主题循环背景音乐"},"backgroundPrompt":"果园主题绿色果园竖屏纯背景图","items":[{"name":"草莓","soundPrompt":"草莓点击音效"},{"name":"苹果","soundPrompt":"苹果点击音效"},{"name":"香蕉","soundPrompt":"香蕉点击音效"}]}"#, ) .expect("metadata should parse"); assert_eq!(metadata.game_name, "果园大鹅宴"); assert_eq!( metadata.summary, "在明亮果园里收集水果小物件,节奏轻快适合随手游玩。" ); assert_eq!( metadata.tags, vec!["水果", "抓大鹅", "经典消除", "轻量休闲", "2D素材", "收集"] ); } #[test] fn match3d_work_metadata_fallback_keeps_empty_description_boundary() { let metadata = fallback_match3d_work_metadata("水果"); assert_eq!(metadata.game_name, "水果抓大鹅"); assert!(metadata.summary.contains("水果主题")); assert!(metadata.tags.contains(&"水果".to_string())); assert!(metadata.tags.contains(&"抓大鹅".to_string())); } #[test] fn match3d_draft_plan_parses_audio_prompts() { let plan = parse_match3d_draft_plan( r#"{"gameName":"果园大鹅宴","summary":"明亮果园里堆满水果小物,轻快收集感突出。","tags":["水果","抓大鹅"],"backgroundMusic":{"title":"果园轻舞","style":"轻快, 休闲","prompt":""},"backgroundPrompt":"果园主题抓大鹅竖屏纯背景,绿色渐变和明亮果园氛围","items":[{"name":"草莓","soundPrompt":"草莓点击消除的清脆音效"},{"name":"苹果","soundPrompt":"苹果落入托盘的弹跳音"},{"name":"香蕉","soundPrompt":"香蕉消除时的轻快提示音"}]}"#, &config("水果", 3, 3), ) .expect("draft plan should parse"); assert_eq!(plan.metadata.game_name, "果园大鹅宴"); assert_eq!( plan.metadata.summary, "明亮果园里堆满水果小物,轻快收集感突出。" ); assert!(plan.background_prompt.contains("纯背景")); assert_eq!(plan.items[0].name, "草莓"); assert_eq!(plan.items[0].item_size, MATCH3D_ITEM_SIZE_SMALL); assert!(plan.items[0].sound_prompt.contains("草莓")); } #[test] fn match3d_draft_plan_parses_relative_item_sizes() { let plan = parse_match3d_draft_plan( r#"{"gameName":"果园大鹅宴","summary":"果园小物堆满浅盘,轻快明亮适合随手消除。","tags":["水果","抓大鹅"],"backgroundPrompt":"果园主题竖屏纯背景","items":[{"name":"西瓜","itemSize":"大","soundPrompt":""},{"name":"苹果","itemSize":"中","soundPrompt":""},{"name":"糖果","itemSize":"小","soundPrompt":""}]}"#, &config("水果", 3, 3), ) .expect("draft plan should parse"); assert_eq!(plan.items[0].item_size, MATCH3D_ITEM_SIZE_LARGE); assert_eq!(plan.items[1].item_size, MATCH3D_ITEM_SIZE_MEDIUM); assert_eq!(plan.items[2].item_size, MATCH3D_ITEM_SIZE_SMALL); } #[test] fn match3d_legacy_item_asset_without_size_defaults_to_large() { let assets = parse_match3d_generated_item_assets(Some( r#"[{"itemId":"match3d-item-1","itemName":"草莓","status":"image_ready"}]"#, )); let asset = Match3DGeneratedItemAsset::from(assets[0].clone()); assert_eq!(asset.item_size.as_deref(), Some(MATCH3D_ITEM_SIZE_LARGE)); } #[test] fn match3d_draft_item_plan_rounds_up_to_full_five_item_sheets() { let plan = parse_match3d_draft_plan( r#"{"gameName":"果园大鹅宴","tags":["水果","抓大鹅"],"backgroundMusic":{"title":"果园轻舞","style":"轻快, 休闲","prompt":""},"backgroundPrompt":"果园主题竖屏纯背景","items":[{"name":"草莓","soundPrompt":"草莓点击音效"},{"name":"苹果","soundPrompt":"苹果点击音效"},{"name":"香蕉","soundPrompt":"香蕉点击音效"},{"name":"葡萄","soundPrompt":"葡萄点击音效"},{"name":"西瓜","soundPrompt":"西瓜点击音效"},{"name":"梨子","soundPrompt":"梨子点击音效"},{"name":"桃子","soundPrompt":"桃子点击音效"},{"name":"橙子","soundPrompt":"橙子点击音效"},{"name":"蓝莓","soundPrompt":"蓝莓点击音效"}]}"#, &config("水果", 12, 4), ) .expect("draft plan should parse"); assert_eq!(plan.items.len(), 10); assert_eq!(plan.items[8].name, "蓝莓"); assert_ne!(plan.items[9].name, "蓝莓"); } #[test] fn match3d_generated_item_count_rounds_up_to_five_multiples() { assert_eq!( resolve_match3d_generated_item_count(&config("水果", 8, 2)), 5 ); assert_eq!( resolve_match3d_generated_item_count(&config("水果", 12, 4)), 10 ); assert_eq!( resolve_match3d_generated_item_count(&config("水果", 16, 6)), 15 ); assert_eq!( resolve_match3d_generated_item_count(&config("水果", 21, 8)), 25 ); } #[test] fn match3d_generated_assets_require_only_images_when_click_sound_is_closed() { let assets = vec![test_match3d_generated_item_asset(1, "草莓")]; assert!(has_match3d_required_generated_assets( &assets, 1, &config("水果", 3, 3) )); } #[test] fn match3d_item_asset_points_cost_counts_five_item_batches() { assert_eq!(calculate_match3d_item_assets_points_cost(0), 0); assert_eq!(calculate_match3d_item_assets_points_cost(1), 2); assert_eq!(calculate_match3d_item_assets_points_cost(5), 2); assert_eq!(calculate_match3d_item_assets_points_cost(6), 4); assert_eq!(calculate_match3d_item_assets_points_cost(10), 4); } #[test] fn match3d_item_asset_append_plan_pads_generation_without_persisting_padding() { let existing_assets = vec![Match3DGeneratedItemAsset { item_id: "match3d-item-1".to_string(), item_name: "草莓".to_string(), item_size: Some(MATCH3D_ITEM_SIZE_SMALL.to_string()), image_src: None, image_object_key: None, image_views: Vec::new(), model_src: None, model_object_key: None, model_file_name: None, task_uuid: None, subscription_key: None, sound_prompt: None, background_music_title: None, background_music_style: None, background_music_prompt: None, background_music: None, click_sound: None, background_asset: None, status: "image_ready".to_string(), error: None, }]; let plan = build_match3d_item_asset_append_plan( vec![ "草莓".to_string(), "苹果".to_string(), "香蕉".to_string(), "梨子".to_string(), ], &existing_assets, ); assert_eq!(plan.requested_item_names, vec!["苹果", "香蕉", "梨子"]); assert_eq!(plan.padded_item_names.len(), 5); assert_eq!(&plan.padded_item_names[..3], ["苹果", "香蕉", "梨子"]); assert_eq!( calculate_match3d_item_assets_points_cost(plan.requested_item_names.len()), 2 ); } #[test] fn match3d_item_asset_append_plan_still_generates_full_sheet_when_capacity_is_low() { let existing_assets = (1..MATCH3D_MAX_GENERATED_ITEM_COUNT) .map(|index| Match3DGeneratedItemAsset { item_id: format!("match3d-item-{index}"), item_name: format!("已有物品{index}"), item_size: Some(MATCH3D_ITEM_SIZE_LARGE.to_string()), image_src: None, image_object_key: None, image_views: Vec::new(), model_src: None, model_object_key: None, model_file_name: None, task_uuid: None, subscription_key: None, sound_prompt: None, background_music_title: None, background_music_style: None, background_music_prompt: None, background_music: None, click_sound: None, background_asset: None, status: "image_ready".to_string(), error: None, }) .collect::>(); let plan = build_match3d_item_asset_append_plan(vec!["新物品".to_string()], &existing_assets); assert_eq!(plan.requested_item_names, vec!["新物品"]); assert_eq!( plan.padded_item_names.len(), MATCH3D_MATERIAL_ITEM_BATCH_SIZE ); assert_eq!(plan.padded_item_names[0], "新物品"); } #[test] fn match3d_item_asset_replace_plan_only_targets_existing_names() { let existing_assets = vec![ test_match3d_generated_item_asset(1, "草莓"), test_match3d_generated_item_asset(2, "苹果"), ]; let plan = build_match3d_item_asset_replace_plan( vec!["苹果".to_string(), "不存在".to_string(), "苹果".to_string()], &existing_assets, ); assert_eq!(plan.requested_item_names, vec!["苹果"]); assert_eq!(plan.target_assets.len(), 1); assert_eq!(plan.target_assets[0].item_id, "match3d-item-2"); assert_eq!( plan.padded_item_names.len(), MATCH3D_MATERIAL_ITEM_BATCH_SIZE ); assert_eq!(plan.padded_item_names[0], "苹果"); } #[test] fn match3d_item_assets_generation_mode_defaults_to_append() { assert!(matches!( normalize_match3d_item_assets_generation_mode(None), Match3DItemAssetsGenerationMode::Append )); assert!(matches!( normalize_match3d_item_assets_generation_mode(Some("replace")), Match3DItemAssetsGenerationMode::Replace )); assert!(matches!( normalize_match3d_item_assets_generation_mode(Some("regenerate")), Match3DItemAssetsGenerationMode::Replace )); } #[test] fn match3d_regenerated_asset_keeps_stable_identity_and_side_assets() { let mut current_asset = test_match3d_generated_item_asset(1, "草莓"); current_asset.background_music_title = Some("果园轻舞".to_string()); current_asset.background_asset = Some(Match3DGeneratedBackgroundAsset { prompt: "果园背景".to_string(), image_src: Some("/generated-match3d-assets/s/p/background/bg.png".to_string()), image_object_key: None, container_prompt: Some("果园容器".to_string()), container_image_src: Some( "/generated-match3d-assets/s/p/ui-container/container.png".to_string(), ), container_image_object_key: None, status: "image_ready".to_string(), error: None, }); let mut generated_asset = test_match3d_generated_item_asset(99, "新草莓"); generated_asset.image_src = Some("/generated-match3d-assets/s/p/items/new/views/view-01.png".to_string()); generated_asset.model_src = None; generated_asset.model_object_key = None; let merged = merge_regenerated_match3d_item_asset(Some(current_asset.clone()), generated_asset); assert_eq!(merged.item_id, "match3d-item-1"); assert_eq!(merged.item_name, "草莓"); assert_eq!( merged.image_src.as_deref(), Some("/generated-match3d-assets/s/p/items/new/views/view-01.png") ); assert_eq!( merged.model_src.as_deref(), current_asset.model_src.as_deref() ); assert_eq!(merged.background_music_title.as_deref(), Some("果园轻舞")); assert!(merged.background_asset.is_some()); assert_eq!(merged.status, "image_ready"); } #[test] fn match3d_material_sheet_prompt_requires_uniform_five_by_five_layout() { let prompt = build_match3d_material_sheet_prompt( &config("水果", 12, 4), &["草莓".to_string(), "苹果".to_string(), "香蕉".to_string()], ); assert!(prompt.contains("5行*5列")); assert!(prompt.contains("严格5*5均匀排布")); assert!(prompt.contains("绿幕背景")); assert!(prompt.contains("#00FF00")); assert!(prompt.contains("单个素材格宽度的1/4空白间距")); assert!(prompt.contains("约25%单格宽度")); assert!(prompt.contains("禁止主体跨格")); assert!(prompt.contains("贴边或越界")); } #[test] fn match3d_material_sheet_prompt_hardens_pixel_retro_style() { let mut config = config("水果", 12, 4); config.asset_style_id = Some("pixel-retro".to_string()); config.asset_style_label = Some("像素复古".to_string()); let prompt = build_match3d_material_sheet_prompt(&config, &["草莓".to_string()]); let negative_prompt = build_match3d_material_sheet_negative_prompt(&config); assert!(prompt.contains("64x64")); assert!(prompt.contains("整数倍放大")); assert!(prompt.contains("禁止抗锯齿")); assert!(prompt.contains("真实 3D 渲染")); assert!(prompt.contains("PBR 材质")); assert!(negative_prompt.contains("抗锯齿")); assert!(negative_prompt.contains("平滑插画")); assert!(negative_prompt.contains("真实 3D 渲染")); } #[test] fn match3d_material_sheet_request_uses_vector_engine_gemini_contract() { let body = build_match3d_vector_engine_gemini_image_request_body( "生成水果素材图", "文字、水印", MATCH3D_MATERIAL_VECTOR_ENGINE_GEMINI_ASPECT_RATIO, ); assert_eq!(body["generationConfig"]["responseModalities"][0], "TEXT"); assert_eq!(body["generationConfig"]["responseModalities"][1], "IMAGE"); assert_eq!( body["generationConfig"]["imageConfig"]["aspectRatio"], MATCH3D_MATERIAL_VECTOR_ENGINE_GEMINI_ASPECT_RATIO ); assert!(body.get("model").is_none()); assert!(body.get("n").is_none()); assert!(body.get("official_fallback").is_none()); assert!(body.get("image").is_none()); assert!(body.get("image_urls").is_none()); assert!( body["contents"][0]["parts"][0]["text"] .as_str() .unwrap_or_default() .contains("文字、水印") ); } #[test] fn match3d_extracts_vector_engine_gemini_inline_image_data() { let payload = json!({ "candidates": [{ "content": { "parts": [ { "text": "已生成" }, { "inlineData": { "mimeType": "image/png", "data": "iVBORw0KGgo=" } }, { "inline_data": { "mime_type": "image/webp", "data": "UklGRg==" } }, { "inlineData": { "mimeType": "text/plain", "data": "not-image-data" } }, { "data": "not-inline-image-data" } ] } }] }); assert_eq!( extract_match3d_b64_images(&payload), vec!["iVBORw0KGgo=", "UklGRg=="] ); } #[test] fn match3d_vector_engine_gemini_url_accepts_root_or_v1_base() { let root_settings = Match3DVectorEngineGeminiImageSettings { base_url: "https://api.vectorengine.cn".to_string(), api_key: "test-key".to_string(), request_timeout_ms: 1_000_000, }; let v1_settings = Match3DVectorEngineGeminiImageSettings { base_url: "https://api.vectorengine.cn/v1".to_string(), api_key: "test-key".to_string(), request_timeout_ms: 1_000_000, }; assert_eq!( build_match3d_vector_engine_gemini_generate_content_url(&root_settings), "https://api.vectorengine.cn/v1beta/models/gemini-3-pro-image-preview:generateContent" ); assert_eq!( build_match3d_vector_engine_gemini_generate_content_url(&v1_settings), "https://api.vectorengine.cn/v1beta/models/gemini-3-pro-image-preview:generateContent" ); } #[test] fn match3d_background_and_container_prompts_keep_ui_layers_split() { let config = config("水果", 3, 3); let background_prompt = build_match3d_background_generation_prompt(&config, "果园绿色竖屏纯背景"); let container_prompt = build_match3d_container_generation_prompt(&config, "果园绿色竖屏纯背景"); assert!(background_prompt.contains("9:16")); assert!(background_prompt.contains("纯背景图")); assert!(background_prompt.contains("不得出现锅")); assert!(background_prompt.contains("拼图槽")); assert!(background_prompt.contains("物品槽")); assert!(background_prompt.contains("全画幅不透明")); assert!(background_prompt.contains("透明 alpha")); assert!(background_prompt.contains("默认交互容器")); assert!(container_prompt.contains("1:1")); assert!(container_prompt.contains("中心容器 UI 图")); assert!(container_prompt.contains("贴合题材设定")); assert!(container_prompt.contains("占画布宽度约 86%-92%")); assert!(container_prompt.contains("轻俯视 3/4")); assert!(container_prompt.contains("横向椭圆形内口")); assert!(container_prompt.contains("不能画成正俯视扁圆盘")); assert!(container_prompt.contains("透明 alpha")); assert!(container_prompt.contains("白底")); assert!(container_prompt.contains("整页背景")); assert!(container_prompt.contains("禁止文字")); } #[test] fn match3d_background_asset_requires_background_and_container_images() { let background_only = Match3DGeneratedBackgroundAsset { prompt: "果园背景".to_string(), image_src: Some( "/generated-match3d-assets/session/profile/background/bg.png".to_string(), ), image_object_key: None, container_prompt: None, container_image_src: None, container_image_object_key: None, status: "image_ready".to_string(), error: None, }; let with_container = Match3DGeneratedBackgroundAsset { container_prompt: Some("果园容器".to_string()), container_image_src: Some( "/generated-match3d-assets/session/profile/ui-container/container.png".to_string(), ), ..background_only.clone() }; assert!(!is_match3d_background_asset_ready(&background_only)); assert!(is_match3d_background_asset_ready(&with_container)); } #[test] fn match3d_default_cover_prefers_generated_container_ui_image() { let assets = vec![Match3DGeneratedItemAsset { item_id: "match3d-item-1".to_string(), item_name: "草莓".to_string(), item_size: Some(MATCH3D_ITEM_SIZE_SMALL.to_string()), image_src: None, image_object_key: None, image_views: Vec::new(), model_src: None, model_object_key: None, model_file_name: None, task_uuid: None, subscription_key: None, sound_prompt: None, background_music_title: None, background_music_style: None, background_music_prompt: None, background_music: None, click_sound: None, background_asset: Some(Match3DGeneratedBackgroundAsset { prompt: "果园背景".to_string(), image_src: Some( "/generated-match3d-assets/session/profile/background/background.png" .to_string(), ), image_object_key: None, container_prompt: Some("果园容器".to_string()), container_image_src: Some( "/generated-match3d-assets/session/profile/ui-container/container.png" .to_string(), ), container_image_object_key: None, status: "image_ready".to_string(), error: None, }), status: "image_ready".to_string(), error: None, }]; assert_eq!( resolve_match3d_default_cover_image_src(&assets).as_deref(), Some("/generated-match3d-assets/session/profile/ui-container/container.png") ); } #[test] fn match3d_cover_reference_sources_are_deduped_and_limited() { let sources = collect_match3d_cover_reference_image_sources( Some("/generated-match3d-assets/a.png".to_string()), vec![ "/generated-match3d-assets/a.png".to_string(), "data:image/png;base64,b".to_string(), "/generated-match3d-assets/c.png".to_string(), "/generated-match3d-assets/d.png".to_string(), "/generated-match3d-assets/e.png".to_string(), "/generated-match3d-assets/f.png".to_string(), "/generated-match3d-assets/g.png".to_string(), ], ); assert_eq!(sources.len(), 6); assert_eq!(sources[0], "/generated-match3d-assets/a.png"); assert_eq!(sources[1], "data:image/png;base64,b"); assert!(!sources.contains(&"/generated-match3d-assets/g.png".to_string())); } #[test] fn match3d_public_reference_image_paths_are_limited_to_known_assets() { assert_eq!( normalize_match3d_public_reference_image_path( "/match3d-background-references/pot-fused-reference.png?cache=1" ) .as_deref(), Some("public/match3d-background-references/pot-fused-reference.png") ); assert!(normalize_match3d_public_reference_image_path("/icons/logo.png").is_none()); assert!( normalize_match3d_public_reference_image_path( "/match3d-background-references/../secret.png" ) .is_none() ); } #[test] fn match3d_cover_reference_prompt_marks_reference_images() { let prompt = build_match3d_cover_reference_generation_prompt("水果封面", true); assert!(prompt.contains("一张或多张图片")); assert!(prompt.contains("不要拼贴成素材墙")); assert!(prompt.contains("水果封面")); } #[test] fn match3d_cover_edit_prompt_preserves_uploaded_image() { let prompt = build_match3d_cover_edit_prompt("水果封面"); assert!(prompt.contains("上传的封面图作为第一优先级")); assert!(prompt.contains("保留主图的主体、构图、视角和主要配色")); } #[test] fn match3d_fallback_work_profile_keeps_generated_background_asset() { let assets = vec![Match3DGeneratedItemAsset { item_id: "match3d-item-1".to_string(), item_name: "草莓".to_string(), item_size: Some(MATCH3D_ITEM_SIZE_SMALL.to_string()), image_src: None, image_object_key: None, image_views: Vec::new(), model_src: None, model_object_key: None, model_file_name: None, task_uuid: None, subscription_key: None, sound_prompt: None, background_music_title: None, background_music_style: None, background_music_prompt: None, background_music: None, click_sound: None, background_asset: Some(Match3DGeneratedBackgroundAsset { prompt: "果园背景".to_string(), image_src: Some( "/generated-match3d-assets/session/profile/background/background.png" .to_string(), ), image_object_key: Some( "generated-match3d-assets/session/profile/background/background.png" .to_string(), ), container_prompt: Some("果园容器".to_string()), container_image_src: Some( "/generated-match3d-assets/session/profile/ui-container/container.png" .to_string(), ), container_image_object_key: Some( "generated-match3d-assets/session/profile/ui-container/container.png" .to_string(), ), status: "image_ready".to_string(), error: None, }), status: "image_ready".to_string(), error: None, }]; let profile = build_match3d_work_profile_record_with_assets( Match3DWorkProfileRecord { work_id: "match3d-profile-1".to_string(), profile_id: "match3d-profile-1".to_string(), owner_user_id: "user-1".to_string(), source_session_id: Some("match3d-session-1".to_string()), author_display_name: "玩家".to_string(), game_name: "水果抓大鹅".to_string(), theme_text: "水果".to_string(), summary: "水果主题".to_string(), tags: vec!["水果".to_string()], cover_image_src: None, cover_asset_id: None, reference_image_src: None, clear_count: 3, difficulty: 3, publication_status: "draft".to_string(), play_count: 0, updated_at: "2026-05-14T00:00:00Z".to_string(), published_at: None, publish_ready: false, generated_item_assets_json: None, }, &assets, ); let response = map_match3d_work_summary_response(profile); assert_eq!( response.background_image_src.as_deref(), Some("/generated-match3d-assets/session/profile/background/background.png") ); assert_eq!( response.cover_image_src.as_deref(), Some("/generated-match3d-assets/session/profile/ui-container/container.png") ); assert_eq!(response.generated_item_assets.len(), 1); assert_eq!( response .generated_background_asset .as_ref() .and_then(|asset| asset.container_image_src.as_deref()), Some("/generated-match3d-assets/session/profile/ui-container/container.png") ); } #[test] fn match3d_agent_session_response_hydrates_persisted_ui_assets() { let session = Match3DAgentSessionRecord { session_id: "match3d-session-1".to_string(), current_turn: 3, progress_percent: 100, stage: "DraftCompiled".to_string(), anchor_pack: Match3DAnchorPackRecord { theme: Match3DAnchorItemRecord { key: "theme".to_string(), label: "题材主题".to_string(), value: "水果".to_string(), status: "confirmed".to_string(), }, clear_count: Match3DAnchorItemRecord { key: "clearCount".to_string(), label: "消除次数".to_string(), value: "12".to_string(), status: "confirmed".to_string(), }, difficulty: Match3DAnchorItemRecord { key: "difficulty".to_string(), label: "难度".to_string(), value: "4".to_string(), status: "confirmed".to_string(), }, }, config: None, draft: Some(Match3DResultDraftRecord { profile_id: "match3d-profile-1".to_string(), game_name: "水果抓大鹅".to_string(), theme_text: "水果".to_string(), summary_text: "水果主题".to_string(), tags: vec!["水果".to_string(), "抓大鹅".to_string()], cover_image_src: None, reference_image_src: None, clear_count: 12, difficulty: 4, total_item_count: 36, publish_ready: false, blockers: Vec::new(), generated_item_assets_json: None, }), messages: Vec::new(), last_assistant_reply: None, published_profile_id: None, updated_at: "2026-05-15T00:00:00.000Z".to_string(), }; let assets = vec![Match3DGeneratedItemAsset { item_id: "match3d-item-1".to_string(), item_name: "草莓".to_string(), item_size: Some(MATCH3D_ITEM_SIZE_SMALL.to_string()), image_src: Some( "/generated-match3d-assets/session/profile/items/strawberry/view-01.png" .to_string(), ), image_object_key: Some( "generated-match3d-assets/session/profile/items/strawberry/view-01.png".to_string(), ), image_views: Vec::new(), model_src: None, model_object_key: None, model_file_name: None, task_uuid: None, subscription_key: None, sound_prompt: None, background_music_title: None, background_music_style: None, background_music_prompt: None, background_music: None, click_sound: None, background_asset: Some(Match3DGeneratedBackgroundAsset { prompt: "果园背景".to_string(), image_src: Some( "/generated-match3d-assets/session/profile/background/background.png" .to_string(), ), image_object_key: Some( "generated-match3d-assets/session/profile/background/background.png" .to_string(), ), container_prompt: Some("果园容器".to_string()), container_image_src: Some( "/generated-match3d-assets/session/profile/ui-container/container.png" .to_string(), ), container_image_object_key: Some( "generated-match3d-assets/session/profile/ui-container/container.png" .to_string(), ), status: "image_ready".to_string(), error: None, }), status: "image_ready".to_string(), error: None, }]; let response = map_match3d_agent_session_response_with_assets(session, &assets); let draft = response.draft.expect("session draft should exist"); assert_eq!(draft.generated_item_assets.len(), 1); assert_eq!(draft.background_prompt.as_deref(), Some("果园背景")); assert_eq!( draft.background_image_src.as_deref(), Some("/generated-match3d-assets/session/profile/background/background.png") ); assert_eq!( draft.cover_image_src.as_deref(), Some("/generated-match3d-assets/session/profile/ui-container/container.png") ); assert_eq!( draft .generated_background_asset .as_ref() .and_then(|asset| asset.container_image_src.as_deref()), Some("/generated-match3d-assets/session/profile/ui-container/container.png") ); assert_eq!( draft.generated_item_assets[0] .background_asset .as_ref() .and_then(|asset| asset.image_src.as_deref()), Some("/generated-match3d-assets/session/profile/background/background.png") ); } #[test] fn match3d_agent_session_response_keeps_draft_ui_assets_without_work_detail_hydration() { let assets = vec![Match3DGeneratedItemAsset { item_id: "match3d-item-1".to_string(), item_name: "草莓".to_string(), item_size: Some(MATCH3D_ITEM_SIZE_SMALL.to_string()), image_src: Some( "/generated-match3d-assets/session/profile/items/strawberry/view-01.png" .to_string(), ), image_object_key: Some( "generated-match3d-assets/session/profile/items/strawberry/view-01.png".to_string(), ), image_views: Vec::new(), model_src: None, model_object_key: None, model_file_name: None, task_uuid: None, subscription_key: None, sound_prompt: None, background_music_title: None, background_music_style: None, background_music_prompt: None, background_music: None, click_sound: None, background_asset: Some(Match3DGeneratedBackgroundAsset { prompt: "果园背景".to_string(), image_src: Some( "/generated-match3d-assets/session/profile/background/background.png" .to_string(), ), image_object_key: Some( "generated-match3d-assets/session/profile/background/background.png" .to_string(), ), container_prompt: Some("果园容器".to_string()), container_image_src: Some( "/generated-match3d-assets/session/profile/ui-container/container.png" .to_string(), ), container_image_object_key: Some( "generated-match3d-assets/session/profile/ui-container/container.png" .to_string(), ), status: "image_ready".to_string(), error: None, }), status: "image_ready".to_string(), error: None, }]; let session = Match3DAgentSessionRecord { session_id: "match3d-session-1".to_string(), current_turn: 3, progress_percent: 100, stage: "DraftCompiled".to_string(), anchor_pack: Match3DAnchorPackRecord { theme: Match3DAnchorItemRecord { key: "theme".to_string(), label: "题材主题".to_string(), value: "水果".to_string(), status: "confirmed".to_string(), }, clear_count: Match3DAnchorItemRecord { key: "clearCount".to_string(), label: "消除次数".to_string(), value: "12".to_string(), status: "confirmed".to_string(), }, difficulty: Match3DAnchorItemRecord { key: "difficulty".to_string(), label: "难度".to_string(), value: "4".to_string(), status: "confirmed".to_string(), }, }, config: None, draft: Some(Match3DResultDraftRecord { profile_id: "match3d-profile-1".to_string(), game_name: "水果抓大鹅".to_string(), theme_text: "水果".to_string(), summary_text: "水果主题".to_string(), tags: vec!["水果".to_string(), "抓大鹅".to_string()], cover_image_src: None, reference_image_src: None, clear_count: 12, difficulty: 4, total_item_count: 36, publish_ready: false, blockers: Vec::new(), generated_item_assets_json: serialize_match3d_generated_item_assets(&assets), }), messages: Vec::new(), last_assistant_reply: None, published_profile_id: None, updated_at: "2026-05-15T00:00:00.000Z".to_string(), }; let response = map_match3d_agent_session_response_with_assets(session, &[]); let draft = response.draft.expect("session draft should exist"); assert_eq!(draft.generated_item_assets.len(), 1); assert_eq!( draft.background_image_src.as_deref(), Some("/generated-match3d-assets/session/profile/background/background.png") ); assert_eq!( draft.background_image_object_key.as_deref(), Some("generated-match3d-assets/session/profile/background/background.png") ); assert_eq!( draft .generated_background_asset .as_ref() .and_then(|asset| asset.container_image_src.as_deref()), Some("/generated-match3d-assets/session/profile/ui-container/container.png") ); assert_eq!( draft.generated_item_assets[0] .background_asset .as_ref() .and_then(|asset| asset.image_src.as_deref()), Some("/generated-match3d-assets/session/profile/background/background.png") ); } #[test] fn match3d_tag_normalization_only_strips_numbered_list_prefix() { assert_eq!(normalize_match3d_tag("3D素材"), "3D素材"); assert_eq!(normalize_match3d_tag("1. 3D素材"), "3D素材"); assert_eq!(normalize_match3d_tag("2、轻量休闲"), "轻量休闲"); } #[test] fn match3d_plan_tags_are_kept_before_local_fallback_tags() { let tags = merge_match3d_plan_tags_with_fallback( "果园大鹅宴", "水果", &["果园".to_string(), "轻快".to_string(), "抓大鹅".to_string()], ); assert_eq!(tags[0], "果园"); assert_eq!(tags[1], "轻快"); assert_eq!(tags[2], "抓大鹅"); assert!(tags.contains(&"水果".to_string())); assert!(tags.contains(&"经典消除".to_string())); } #[test] fn match3d_model_download_metadata_normalizes_to_glb() { assert_eq!( normalize_match3d_model_file_name("https://example.com/Fruit Model.GLB?token=1"), "fruit-model.glb" ); assert_eq!(normalize_match3d_model_file_name("模型文件"), "model.glb"); assert_eq!( normalize_match3d_model_content_type("application/octet-stream"), "model/gltf-binary" ); assert_eq!( normalize_match3d_model_content_type("model/gltf-binary; charset=utf-8"), "model/gltf-binary" ); } #[test] fn match3d_model_download_requires_valid_glb_header() { let mut glb = Vec::new(); glb.extend_from_slice(&0x4654_6c67_u32.to_le_bytes()); glb.extend_from_slice(&2_u32.to_le_bytes()); glb.extend_from_slice(&12_u32.to_le_bytes()); assert!(is_match3d_glb_binary_payload(&glb)); assert!(!is_match3d_glb_binary_payload(b"expired")); let mut wrong_length = glb.clone(); wrong_length[8..12].copy_from_slice(&16_u32.to_le_bytes()); assert!(!is_match3d_glb_binary_payload(&wrong_length)); } #[test] fn match3d_generated_asset_resume_keeps_stable_item_order() { let assets = normalize_match3d_generated_item_assets_for_resume(vec![ Match3DGeneratedItemAsset { item_id: "match3d-item-2".to_string(), item_name: "苹果".to_string(), item_size: Some(MATCH3D_ITEM_SIZE_MEDIUM.to_string()), image_src: Some("/generated-match3d-assets/s/p/items/i2/image.png".to_string()), image_object_key: Some( "generated-match3d-assets/s/p/items/i2/image.png".to_string(), ), image_views: Vec::new(), model_src: Some( "/generated-match3d-assets/s/p/items/i2/model/model.glb".to_string(), ), model_object_key: Some( "generated-match3d-assets/s/p/items/i2/model/model.glb".to_string(), ), model_file_name: Some("model.glb".to_string()), task_uuid: Some("task-2".to_string()), subscription_key: Some("sub-2".to_string()), sound_prompt: None, background_music_title: None, background_music_style: None, background_music_prompt: None, background_music: None, click_sound: None, background_asset: None, status: "model_ready".to_string(), error: None, }, Match3DGeneratedItemAsset { item_id: "match3d-item-1".to_string(), item_name: "草莓".to_string(), item_size: Some(MATCH3D_ITEM_SIZE_SMALL.to_string()), image_src: Some("/generated-match3d-assets/s/p/items/i1/image.png".to_string()), image_object_key: Some( "generated-match3d-assets/s/p/items/i1/image.png".to_string(), ), image_views: Vec::new(), model_src: None, model_object_key: None, model_file_name: None, task_uuid: None, subscription_key: None, sound_prompt: None, background_music_title: None, background_music_style: None, background_music_prompt: None, background_music: None, click_sound: None, background_asset: None, status: "image_ready".to_string(), error: None, }, ]); assert_eq!(assets[0].item_id, "match3d-item-1"); assert_eq!(assets[1].item_id, "match3d-item-2"); } #[test] fn match3d_required_item_images_require_five_views() { let assets = vec![ Match3DGeneratedItemAsset { item_id: "match3d-item-1".to_string(), item_name: "草莓".to_string(), item_size: Some(MATCH3D_ITEM_SIZE_SMALL.to_string()), image_src: Some("/generated-match3d-assets/s/p/items/i1/image.png".to_string()), image_object_key: Some( "generated-match3d-assets/s/p/items/i1/image.png".to_string(), ), image_views: Vec::new(), model_src: None, model_object_key: None, model_file_name: None, task_uuid: None, subscription_key: None, sound_prompt: None, background_music_title: None, background_music_style: None, background_music_prompt: None, background_music: None, click_sound: None, background_asset: None, status: "image_ready".to_string(), error: None, }, Match3DGeneratedItemAsset { item_id: "match3d-item-2".to_string(), item_name: "苹果".to_string(), item_size: Some(MATCH3D_ITEM_SIZE_MEDIUM.to_string()), image_src: Some("/generated-match3d-assets/s/p/items/i2/image.png".to_string()), image_object_key: Some( "generated-match3d-assets/s/p/items/i2/image.png".to_string(), ), image_views: Vec::new(), model_src: None, model_object_key: None, model_file_name: None, task_uuid: None, subscription_key: None, sound_prompt: None, background_music_title: None, background_music_style: None, background_music_prompt: None, background_music: None, click_sound: None, background_asset: None, status: "image_ready".to_string(), error: None, }, Match3DGeneratedItemAsset { item_id: "match3d-item-3".to_string(), item_name: "香蕉".to_string(), item_size: Some(MATCH3D_ITEM_SIZE_MEDIUM.to_string()), image_src: Some("/generated-match3d-assets/s/p/items/i3/image.png".to_string()), image_object_key: None, image_views: Vec::new(), model_src: None, model_object_key: None, model_file_name: None, task_uuid: None, subscription_key: None, sound_prompt: None, background_music_title: None, background_music_style: None, background_music_prompt: None, background_music: None, click_sound: None, background_asset: None, status: "image_ready".to_string(), error: None, }, ]; assert!(!has_match3d_required_item_images(&assets, 3)); let five_view_assets = (1..=3) .map(|index| Match3DGeneratedItemAsset { item_id: format!("match3d-item-{index}"), item_name: format!("物品{index}"), item_size: Some(MATCH3D_ITEM_SIZE_LARGE.to_string()), image_src: Some(format!( "/generated-match3d-assets/s/p/items/i{index}/views/view-01.png" )), image_object_key: Some(format!( "generated-match3d-assets/s/p/items/i{index}/views/view-01.png" )), image_views: (1..=MATCH3D_ITEM_VIEW_COUNT) .map(|view_index| Match3DGeneratedItemImageView { view_id: format!("view-{view_index:02}"), view_index: view_index as u32, image_src: Some(format!( "/generated-match3d-assets/s/p/items/i{index}/views/view-{view_index:02}.png" )), image_object_key: Some(format!( "generated-match3d-assets/s/p/items/i{index}/views/view-{view_index:02}.png" )), }) .collect(), model_src: None, model_object_key: None, model_file_name: None, task_uuid: None, subscription_key: None, sound_prompt: None, background_music_title: None, background_music_style: None, background_music_prompt: None, background_music: None, click_sound: None, background_asset: None, status: "image_ready".to_string(), error: None, }) .collect::>(); assert!(has_match3d_required_item_images(&five_view_assets, 3)); } #[test] fn match3d_oss_config_error_lists_missing_env_keys() { let mut app_config = AppConfig { oss_bucket: Some("genarrative-assets".to_string()), oss_endpoint: Some("oss-cn-shanghai.aliyuncs.com".to_string()), ..AppConfig::default() }; let missing = missing_match3d_oss_env_keys(&app_config); assert_eq!( missing, vec!["ALIYUN_OSS_ACCESS_KEY_ID", "ALIYUN_OSS_ACCESS_KEY_SECRET"] ); assert_eq!( match3d_oss_missing_reason(&missing), "OSS 未完成环境变量配置,缺少:ALIYUN_OSS_ACCESS_KEY_ID, ALIYUN_OSS_ACCESS_KEY_SECRET" ); app_config.oss_access_key_id = Some("ak".to_string()); app_config.oss_access_key_secret = Some("sk".to_string()); assert!(missing_match3d_oss_env_keys(&app_config).is_empty()); } #[test] fn match3d_work_summary_maps_persisted_generated_item_assets() { let response = map_match3d_work_summary_response(Match3DWorkProfileRecord { work_id: "match3d-profile-1".to_string(), profile_id: "match3d-profile-1".to_string(), owner_user_id: "user-1".to_string(), source_session_id: Some("match3d-session-1".to_string()), author_display_name: "玩家".to_string(), game_name: "水果抓大鹅".to_string(), theme_text: "水果".to_string(), summary: "水果主题".to_string(), tags: vec!["水果".to_string()], cover_image_src: None, cover_asset_id: None, reference_image_src: None, clear_count: 3, difficulty: 3, publication_status: "draft".to_string(), play_count: 0, updated_at: "2026-05-10T00:00:00.000Z".to_string(), published_at: None, publish_ready: false, generated_item_assets_json: Some( r#"[{"itemId":"match3d-item-1","itemName":"草莓","imageSrc":"/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png","imageObjectKey":"generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png","status":"image_ready"}]"# .to_string(), ), }); assert_eq!(response.generated_item_assets.len(), 1); assert_eq!(response.generated_item_assets[0].item_name, "草莓"); assert_eq!(response.generated_item_assets[0].status, "image_ready"); assert_eq!(response.generation_status.as_deref(), Some("generating")); assert_eq!( response.generated_item_assets[0].image_src.as_deref(), Some("/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png") ); } #[test] fn match3d_work_summary_marks_complete_generated_assets_ready() { let assets = vec![Match3DGeneratedItemAsset { background_asset: Some(Match3DGeneratedBackgroundAsset { prompt: "水果厨房背景".to_string(), image_src: Some( "/generated-match3d-assets/session/profile/background.png".to_string(), ), image_object_key: Some( "generated-match3d-assets/session/profile/background.png".to_string(), ), container_prompt: None, container_image_src: Some( "/generated-match3d-assets/session/profile/container.png".to_string(), ), container_image_object_key: Some( "generated-match3d-assets/session/profile/container.png".to_string(), ), status: "image_ready".to_string(), error: None, }), ..test_match3d_generated_item_asset(1, "草莓") }]; let response = map_match3d_work_summary_response(Match3DWorkProfileRecord { work_id: "match3d-profile-1".to_string(), profile_id: "match3d-profile-1".to_string(), owner_user_id: "user-1".to_string(), source_session_id: Some("match3d-session-1".to_string()), author_display_name: "玩家".to_string(), game_name: "水果抓大鹅".to_string(), theme_text: "水果".to_string(), summary: "水果主题".to_string(), tags: vec!["水果".to_string()], cover_image_src: None, cover_asset_id: None, reference_image_src: None, clear_count: 3, difficulty: 3, publication_status: "draft".to_string(), play_count: 0, updated_at: "2026-05-10T00:00:00.000Z".to_string(), published_at: None, publish_ready: false, generated_item_assets_json: serialize_match3d_generated_item_assets(&assets), }); assert_eq!(response.generation_status.as_deref(), Some("ready")); }