refactor(api-server): split puzzle module

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

View File

@@ -16,6 +16,15 @@
--- ---
## 2026-05-18 api-server 拼图能力按 HTTP/BFF 子模块拆分
- 背景:`server-rs/crates/api-server/src/puzzle.rs` 已膨胀为数千行大文件,混合 Axum handler、草稿编译、图片生成、VectorEngine / OSS 持久化、DTO mapper、标签生成和测试继续在单文件内迭代会降低定位和评审效率。
- 决策:删除单文件 `puzzle.rs`,改为 `server-rs/crates/api-server/src/puzzle/` 目录模块。`mod.rs` 只保留聚合入口和 handler re-export`handlers.rs` 放 HTTP handler`draft.rs` 放表单草稿 / 编译 / snapshot helper`generation.rs` 放图片与 UI 背景生成编排;`vector_engine.rs` 放 VectorEngine、下载、OSS、asset object / binding 和错误归一;`mappers.rs` / `tags.rs` 保留映射和标签 / 错误 helper`tests.rs` 承接原 puzzle 单测。
- 边界:本次只改变 `api-server` 内部文件组织,不改变 `/api/runtime/puzzle/*` 路由、DTO、error envelope、SpacetimeDB schema、公开 gallery cache 语义或计费语义。领域规则后续仍应逐步沉到 `module-puzzle`SpacetimeDB 表、reducer、procedure 和 row shape 仍留在 `spacetime-module`
- 影响范围:`server-rs/crates/api-server/src/puzzle/``server-rs/crates/api-server/src/modules/puzzle.rs` 的 handler 引用、后端架构文档。
- 验证方式:执行 `cargo check -p api-server --manifest-path server-rs\Cargo.toml`;后续若改动 puzzle API 行为,再按对应路由补充定向测试和 `npm run dev:api-server` `/healthz` smoke。
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
## 2026-05-18 Windows Jenkins PowerShell 统一改为显式 powershell.exe 启动 ## 2026-05-18 Windows Jenkins PowerShell 统一改为显式 powershell.exe 启动
- 背景:`Genarrative-Stdb-Module-Build` 在 Windows Jenkins 本地环境里调用裸 `powershell` step 时触发 `CreateProcess error=5, 拒绝访问`,而 `powershell.exe` 本体与 workspace ACL 都正常。 - 背景:`Genarrative-Stdb-Module-Build` 在 Windows Jenkins 本地环境里调用裸 `powershell` step 时触发 `CreateProcess error=5, 拒绝访问`,而 `powershell.exe` 本体与 workspace ACL 都正常。

View File

@@ -74,6 +74,19 @@ npm run check:server-rs-ddd
3. 路由迁移和业务重构分阶段处理;先移动路由装配,再拆 handler 内部实现。 3. 路由迁移和业务重构分阶段处理;先移动路由装配,再拆 handler 内部实现。
4. 大 handler 拆分时优先按 `router.rs``handlers.rs``application.rs``assets.rs``mapper.rs``errors.rs` 分层。`handlers.rs` 只做 Axum extract、鉴权和 request/response业务规则继续下沉到 `module-*` 4. 大 handler 拆分时优先按 `router.rs``handlers.rs``application.rs``assets.rs``mapper.rs``errors.rs` 分层。`handlers.rs` 只做 Axum extract、鉴权和 request/response业务规则继续下沉到 `module-*`
拼图 `api-server` 内部拆分:
- `server-rs/crates/api-server/src/modules/puzzle.rs` 只负责路由装配、鉴权层和参考图 body limit对外继续引用同一批 handler 名称。
- `server-rs/crates/api-server/src/puzzle/mod.rs` 只作为聚合入口,保留共享 import / 常量、内部模块声明和 handler re-export不继续承载大段实现。
- `server-rs/crates/api-server/src/puzzle/handlers.rs` 承接 Axum handler负责 extract、鉴权上下文、调用 SpacetimeDB facade / 编排 helper并返回 HTTP/SSE 响应。
- `server-rs/crates/api-server/src/puzzle/draft.rs` 承接表单草稿保存、草稿编译、首关命名、UI 背景 prompt、降级 snapshot 和初始资产就绪校验。
- `server-rs/crates/api-server/src/puzzle/generation.rs` 承接拼图图片与 UI 背景的生成编排、计费包裹和 reference image 路径选择。
- `server-rs/crates/api-server/src/puzzle/vector_engine.rs` 承接 VectorEngine 请求体、HTTP 调用、下载 / base64 解码、OSS 写入、asset object / binding 持久化和上游错误归一。
- `server-rs/crates/api-server/src/puzzle/mappers.rs` 承接 SpacetimeDB record 到 shared-contracts DTO 的映射。
- `server-rs/crates/api-server/src/puzzle/tags.rs` 保留拼图标签生成、拼图通用错误映射和 SSE helper。
该拆分只改变 `api-server` 文件组织,不改变 `/api/runtime/puzzle/*` route、DTO、error envelope、SpacetimeDB schema、公开 gallery cache 语义或计费语义;后续继续细分时也必须先保持行为不变,再单独讨论领域规则下沉。
生成资产 Adapter 规则: 生成资产 Adapter 规则:
1. 稳定单图链路可收敛到 `api-server` 内部生成资产 Adapterprovider 生成、下载/base64 解码、MIME/extension 归一、OSS private upload、HEAD、asset object confirm、entity binding。 1. 稳定单图链路可收敛到 `api-server` 内部生成资产 Adapterprovider 生成、下载/base64 解码、MIME/extension 归一、OSS private upload、HEAD、asset object confirm、entity binding。

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,264 @@
use super::*;
pub(crate) fn map_puzzle_generation_endpoint_error(error: AppError) -> AppError {
if error.code() == "UPSTREAM_ERROR" {
let body_text = error.body_text();
return AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": format!("拼图图片生成失败:{body_text}"),
}));
}
error
}
pub(crate) fn should_fallback_puzzle_reference_edit_to_generation(error: &AppError) -> bool {
error.status_code() == StatusCode::GATEWAY_TIMEOUT
|| is_puzzle_request_timeout_message(error.body_text().as_str())
}
pub(crate) async fn generate_puzzle_image_candidates(
state: &AppState,
owner_user_id: &str,
session_id: &str,
level_name: &str,
prompt: &str,
reference_image_src: Option<&str>,
use_reference_image_edit: bool,
image_model: Option<&str>,
candidate_count: u32,
candidate_start_index: usize,
) -> Result<Vec<GeneratedPuzzleImageCandidate>, AppError> {
let total_started_at = Instant::now();
let count = candidate_count.clamp(1, 1);
let resolved_model = resolve_puzzle_image_model(image_model);
let http_client = build_puzzle_image_http_client(state, resolved_model)?;
let has_reference_image = has_puzzle_reference_image(reference_image_src);
let should_use_reference_image_edit =
should_use_puzzle_reference_image_edit(reference_image_src, use_reference_image_edit);
let actual_prompt = build_puzzle_vector_engine_generation_prompt(
build_puzzle_image_prompt(level_name, prompt).as_str(),
should_use_reference_image_edit,
);
tracing::info!(
provider = resolved_model.provider_name(),
image_model = resolved_model.request_model_name(),
session_id,
level_name,
prompt_chars = prompt.chars().count(),
actual_prompt_chars = actual_prompt.chars().count(),
has_reference_image,
use_reference_image_edit = should_use_reference_image_edit,
"拼图图片生成请求已准备"
);
let reference_image_started_at = Instant::now();
let reference_image = match reference_image_src
.map(str::trim)
.filter(|value| !value.is_empty())
.filter(|_| should_use_reference_image_edit)
{
Some(source) => {
let resolved =
resolve_puzzle_reference_image_as_data_url(state, &http_client, source).await?;
tracing::info!(
provider = resolved_model.provider_name(),
image_model = resolved_model.request_model_name(),
session_id,
level_name,
reference_mime = %resolved.mime_type,
reference_bytes = resolved.bytes_len,
elapsed_ms = reference_image_started_at.elapsed().as_millis() as u64,
"拼图参考图解析完成"
);
Some(resolved)
}
None => None,
};
if !should_use_reference_image_edit {
tracing::info!(
provider = resolved_model.provider_name(),
image_model = resolved_model.request_model_name(),
session_id,
level_name,
has_reference_image,
use_reference_image_edit = should_use_reference_image_edit,
elapsed_ms = reference_image_started_at.elapsed().as_millis() as u64,
"拼图参考图解析跳过"
);
}
// 中文注释SpacetimeDB reducer 不能做外部 I/O参考图读取与外部生图都必须停留在 api-server。
// 中文注释:拼图作品资产统一按 1:1 正方形生成,前端运行时也按正方形棋盘切块承载。
let settings = require_puzzle_vector_engine_settings(state)?;
let vector_engine_started_at = Instant::now();
let generated = if should_use_reference_image_edit {
let reference_image = reference_image.as_ref().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "puzzle",
"field": "referenceImageSrc",
"message": "AI 重绘需要提供参考图。",
}))
})?;
let edit_result = create_puzzle_vector_engine_image_edit(
&http_client,
&settings,
actual_prompt.as_str(),
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
count,
reference_image,
)
.await;
match edit_result {
Ok(generated) => Ok(generated),
Err(error) if should_fallback_puzzle_reference_edit_to_generation(&error) => {
tracing::warn!(
provider = resolved_model.provider_name(),
image_model = resolved_model.request_model_name(),
session_id,
level_name,
reference_mime = %reference_image.mime_type,
reference_bytes = reference_image.bytes_len,
error = %error,
"拼图参考图编辑接口超时,降级为带参考图的生成接口"
);
create_puzzle_vector_engine_image_generation(
&http_client,
&settings,
resolved_model,
actual_prompt.as_str(),
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
count,
Some(reference_image),
)
.await
}
Err(error) => Err(error),
}
} else {
create_puzzle_vector_engine_image_generation(
&http_client,
&settings,
resolved_model,
actual_prompt.as_str(),
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
count,
None,
)
.await
}
.map_err(map_puzzle_generation_endpoint_error)?;
tracing::info!(
provider = resolved_model.provider_name(),
image_model = resolved_model.request_model_name(),
session_id,
level_name,
generated_image_count = generated.images.len(),
elapsed_ms = vector_engine_started_at.elapsed().as_millis() as u64,
"拼图 VectorEngine 生图与下载完成"
);
let mut items = Vec::with_capacity(generated.images.len());
for (index, image) in generated.images.into_iter().enumerate() {
let candidate_id = format!(
"{session_id}-candidate-{}",
candidate_start_index + index + 1
);
let downloaded_image = image.clone();
let persist_started_at = Instant::now();
let asset = persist_puzzle_generated_asset(
state,
owner_user_id,
session_id,
level_name,
candidate_id.as_str(),
generated.task_id.as_str(),
image,
current_utc_micros(),
)
.await
.map_err(map_puzzle_generation_endpoint_error)?;
tracing::info!(
provider = resolved_model.provider_name(),
image_model = resolved_model.request_model_name(),
session_id,
level_name,
candidate_id = %candidate_id,
image_bytes = downloaded_image.bytes.len(),
image_mime = %downloaded_image.mime_type,
elapsed_ms = persist_started_at.elapsed().as_millis() as u64,
"拼图生成图片已写入 OSS 与资产索引"
);
items.push(GeneratedPuzzleImageCandidate {
record: PuzzleGeneratedImageCandidateRecord {
candidate_id,
image_src: asset.image_src,
asset_id: asset.asset_id,
prompt: prompt.to_string(),
actual_prompt: Some(actual_prompt.clone()),
source_type: resolved_model.candidate_source_type().to_string(),
// 单图生成结果总是直接成为当前正式图。
selected: index == 0,
},
downloaded_image,
});
}
tracing::info!(
provider = resolved_model.provider_name(),
image_model = resolved_model.request_model_name(),
session_id,
level_name,
candidate_count = items.len(),
has_reference_image,
elapsed_ms = total_started_at.elapsed().as_millis() as u64,
"拼图图片候选生成完成"
);
Ok(items)
}
pub(crate) async fn generate_puzzle_ui_background_image(
state: &AppState,
owner_user_id: &str,
session_id: &str,
level_name: &str,
prompt: &str,
) -> Result<GeneratedPuzzleUiBackgroundResponse, AppError> {
let settings = require_openai_image_settings(state)?;
let http_client = build_openai_image_http_client(&settings)?;
let generated = create_openai_image_generation(
&http_client,
&settings,
build_puzzle_ui_background_generation_prompt(level_name, prompt).as_str(),
Some("文字、水印、按钮文字、数字、教程浮层、拼图碎片、完整拼图图像、拼图槽、棋盘、拼图区边框、物品槽、HUD、角色手指"),
"9:16",
1,
&[],
"拼图 UI 背景图生成失败",
)
.await?;
let image = generated.images.into_iter().next().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
"message": "拼图 UI 背景图生成失败:未返回图片",
}))
})?;
persist_puzzle_ui_background_image(
state,
owner_user_id,
session_id,
level_name,
generated.task_id.as_str(),
image,
)
.await
}
#[cfg(test)]
pub(crate) fn build_puzzle_ui_background_request_prompt_for_test(
level_name: &str,
prompt: &str,
) -> String {
build_puzzle_ui_background_generation_prompt(level_name, prompt)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,158 @@
use std::{
collections::BTreeMap,
error::Error as StdError,
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
use axum::{
Json,
extract::{Extension, Path as AxumPath, State, rejection::JsonRejection},
http::{HeaderName, StatusCode, header},
response::{
IntoResponse, Response,
sse::{Event, Sse},
},
};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use image::ImageFormat;
use module_assets::{
AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input,
build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id,
};
use module_puzzle::{PuzzleGeneratedImageCandidate, PuzzleRuntimeLevelStatus};
use platform_llm::{LlmMessage, LlmMessageContentPart, LlmTextRequest};
use platform_oss::{
LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest,
OssSignedGetObjectUrlRequest,
};
use serde_json::{Map, Value, json};
use shared_contracts::{
creation_audio::CreationAudioAsset,
puzzle_agent::{
CreatePuzzleAgentSessionRequest, ExecutePuzzleAgentActionRequest,
PuzzleAgentActionResponse, PuzzleAgentMessageResponse, PuzzleAgentOperationResponse,
PuzzleAgentSessionResponse, PuzzleAgentSessionSnapshotResponse,
PuzzleAgentSuggestedActionResponse, PuzzleAnchorItemResponse, PuzzleAnchorPackResponse,
PuzzleCreatorIntentResponse, PuzzleDraftLevelResponse, PuzzleFormDraftResponse,
PuzzleGeneratedImageCandidateResponse, PuzzleResultDraftResponse,
PuzzleResultPreviewBlockerResponse, PuzzleResultPreviewEnvelopeResponse,
PuzzleResultPreviewFindingResponse, SendPuzzleAgentMessageRequest,
},
puzzle_gallery::PuzzleGalleryDetailResponse,
puzzle_runtime::{
AdvancePuzzleNextLevelRequest, DragPuzzlePieceRequest, PuzzleBoardSnapshotResponse,
PuzzleCellPositionResponse, PuzzleLeaderboardEntryResponse, PuzzleMergedGroupStateResponse,
PuzzlePieceStateResponse, PuzzleRecommendedNextWorkResponse, PuzzleRunResponse,
PuzzleRunSnapshotResponse, PuzzleRuntimeLevelSnapshotResponse, StartPuzzleRunRequest,
SubmitPuzzleLeaderboardRequest, SwapPuzzlePiecesRequest, UpdatePuzzleRuntimePauseRequest,
UsePuzzleRuntimePropRequest,
},
puzzle_works::{
PutPuzzleWorkRequest, PuzzleOnboardingGenerateRequest, PuzzleOnboardingGenerateResponse,
PuzzleOnboardingSaveRequest, PuzzleWorkDetailResponse, PuzzleWorkMutationResponse,
PuzzleWorkProfileResponse, PuzzleWorkSummaryResponse, PuzzleWorksResponse,
},
};
use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros};
use spacetime_client::{
PuzzleAgentMessageRecord, PuzzleAgentMessageSubmitRecordInput,
PuzzleAgentSessionCreateRecordInput, PuzzleAgentSessionRecord,
PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord,
PuzzleAudioAssetRecord, PuzzleCreatorIntentRecord, PuzzleDraftLevelRecord,
PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput, PuzzleGalleryCardRecord,
PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput,
PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzlePublishRecordInput,
PuzzleRecommendedNextWorkRecord, PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord,
PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord, PuzzleRunDragRecordInput,
PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord,
PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleSelectCoverImageRecordInput,
PuzzleUiBackgroundSaveRecordInput, PuzzleWorkLikeReportRecordInput,
PuzzleWorkPointIncentiveClaimRecordInput, PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput,
PuzzleWorkUpsertRecordInput, SpacetimeClientError,
};
use std::convert::Infallible;
use crate::{
ai_generation_drafts::{AiGenerationDraftContext, AiGenerationDraftWriter},
api_response::json_success_body,
asset_billing::{
execute_billable_asset_operation, execute_billable_asset_operation_with_cost,
should_skip_asset_operation_billing_for_connectivity,
},
auth::AuthenticatedAccessToken,
http_error::AppError,
llm_model_routing::{CREATION_TEMPLATE_LLM_MODEL, PUZZLE_LEVEL_NAME_VISION_LLM_MODEL},
openai_image_generation::{
DownloadedOpenAiImage, VECTOR_ENGINE_GPT_IMAGE_2_MODEL, build_openai_image_http_client,
create_openai_image_generation, require_openai_image_settings,
},
platform_errors::map_oss_error,
prompt::puzzle::{
draft::{
PuzzleFormSeedPromptParts, build_puzzle_form_seed_prompt,
resolve_puzzle_draft_cover_prompt, resolve_puzzle_level_image_prompt,
},
image::{PUZZLE_DEFAULT_NEGATIVE_PROMPT, build_puzzle_image_prompt},
level_name::{
PUZZLE_FIRST_LEVEL_NAME_SYSTEM_PROMPT, build_puzzle_first_level_name_user_prompt,
build_puzzle_first_level_name_vision_user_text,
},
tags::{PUZZLE_TAG_GENERATION_SYSTEM_PROMPT, build_puzzle_tag_generation_user_prompt},
},
puzzle_agent_turn::{
PuzzleAgentTurnRequest, build_failed_finalize_record_input, build_finalize_record_input,
run_puzzle_agent_turn,
},
puzzle_gallery_cache::{build_puzzle_gallery_window_response, puzzle_gallery_cached_json},
request_context::RequestContext,
state::AppState,
vector_engine_audio_generation::{
GeneratedCreationAudioTarget, generate_background_music_asset_for_creation,
},
work_author::resolve_work_author_by_user_id,
work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success},
};
const PUZZLE_AGENT_API_BASE_PROVIDER: &str = "puzzle-agent";
const PUZZLE_WORKS_PROVIDER: &str = "puzzle-works";
const PUZZLE_GALLERY_PROVIDER: &str = "puzzle-gallery";
const PUZZLE_RUNTIME_PROVIDER: &str = "puzzle-runtime";
const PUZZLE_IMAGE_MODEL_GPT_IMAGE_2: &str = "gpt-image-2";
const PUZZLE_IMAGE_MODEL_GEMINI_31_FLASH_PREVIEW: &str = "gemini-3.1-flash-image-preview";
const PUZZLE_IMAGE_GENERATION_POINTS_COST: u64 = 2;
const PUZZLE_ENTITY_KIND: &str = "puzzle_work";
const PUZZLE_BACKGROUND_MUSIC_ASSET_KIND: &str = "puzzle_background_music";
const PUZZLE_BACKGROUND_MUSIC_SLOT: &str = "background_music";
#[cfg(test)]
const PUZZLE_GENERATED_IMAGE_SIZE: &str = "1024*1024";
const PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE: &str = "1024x1024";
const VECTOR_ENGINE_PROVIDER: &str = "vector-engine";
const PUZZLE_LEVEL_NAME_VISION_IMAGE_MAX_SIDE: u32 = 768;
const PUZZLE_LEVEL_NAME_VISION_MAX_TOKENS: u32 = 512;
const PUZZLE_REFERENCE_IMAGE_MAX_BYTES: usize = 8 * 1024 * 1024;
const PUZZLE_REFERENCE_IMAGE_SOURCE_LIMIT: usize = 5;
const PUZZLE_VECTOR_ENGINE_IMAGE_EDIT_MODEL: &str = "gpt-image-2";
const PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER: &str =
"移动端拼图游戏纯背景,题材氛围清晰,不包含拼图槽或 UI 元素";
mod handlers;
pub(crate) use self::handlers::*;
mod mappers;
use self::mappers::*;
mod draft;
use self::draft::*;
mod tags;
use self::tags::*;
mod generation;
mod vector_engine;
use self::generation::*;
use self::vector_engine::*;
#[cfg(test)]
mod tests;

View File

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

File diff suppressed because it is too large Load Diff