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

1876 lines
76 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 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(&current, 0),
MATCH3D_QUESTION_THEME
);
assert_eq!(
build_match3d_assistant_reply_for_turn(&current, 1),
MATCH3D_QUESTION_CLEAR_COUNT
);
assert_eq!(
build_match3d_assistant_reply_for_turn(&current, 2),
MATCH3D_QUESTION_DIFFICULTY
);
assert_eq!(
build_match3d_assistant_reply_for_turn(&current, 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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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"<html>expired</html>"));
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::<Vec<_>>();
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"));
}