Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs
T
suzmii deff6319e2
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 6m53s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 6m31s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 7m12s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m5s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 4m54s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m12s
Project CI / Backend tests (pull_request) Successful in 9m37s
Project CI / Native shell tests (pull_request) Successful in 12m21s
Project CI / Repository checks (pull_request) Successful in 11m10s
Project CI / AI game creator shell web tests (pull_request) Successful in 12m51s
Project CI / Frontend tests (pull_request) Successful in 23m14s
合并主线项目创建目录选择更新
合入 master ee33af7b7 的项目目录选择与模板建项说明。
保留本批画布和对话修复,自动合并无冲突,类型检查通过。
2026-09-18 15:48:10 +08:00

6277 lines
233 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::*;
#[test]
fn agent_runtime_context_window_restores_progress_and_counts_dynamic_detail_changes() {
let dynamic_observation = |detail: &str| AgentRuntimeToolObservation {
tool: "agent.run_status".to_string(),
status: "ok".to_string(),
summary: "已读取 Agent 状态:code-prototype".to_string(),
detail: Some(detail.to_string()),
};
let mut dynamic_tracker = AgentRuntimeContextWindowTracker::default();
for next_loop_index in 1..AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT {
dynamic_tracker.record(&dynamic_observation("status: running"));
assert_eq!(
dynamic_tracker.complete_loop(next_loop_index),
AgentRuntimeContextCheckpoint::Continue
);
}
dynamic_tracker.record(&dynamic_observation("status: completed"));
assert_eq!(
dynamic_tracker.complete_loop(AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT),
AgentRuntimeContextCheckpoint::Compacted,
"detail-only runtime progress must count as a new observation"
);
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "上下文窗口恢复项目").expect("project init");
let mut state = start_game_creator_agent_runtime_task_at(
&root,
"design-director",
"验证窗口中段恢复",
"design-context-window-resume-run",
"agent-background-task",
"窗口恢复测试",
vec!["恢复窗口 tracker".to_string()],
)
.expect("start window runtime state");
let mut tracker = AgentRuntimeContextWindowTracker::default();
for next_loop_index in 1..=3 {
tracker.record(&AgentRuntimeToolObservation {
tool: "project.search".to_string(),
status: "ok".to_string(),
summary: "重复窗口标记".to_string(),
detail: None,
});
assert_eq!(
tracker.complete_loop(next_loop_index),
AgentRuntimeContextCheckpoint::Continue
);
}
let bundle = build_game_creator_agent_runtime_context_bundle(
&root,
&state,
&state.current_task,
&AgentRuntimeToolPlan::default(),
&[],
3,
&tracker,
)
.expect("build mid-window context bundle");
assert_eq!(bundle.window_completed_loops, 3);
assert_eq!(bundle.window_observation_fingerprints.len(), 1);
write_game_creator_agent_runtime_context_bundle(&root, &bundle)
.expect("write mid-window context bundle");
state.loop_iteration = 3;
let loaded = read_game_creator_agent_runtime_context_bundle(&root, &state)
.expect("read mid-window context bundle")
.expect("mid-window bundle exists");
let continuation = continuation_from_game_creator_agent_runtime_context_bundle(loaded);
let mut restored = AgentRuntimeContextWindowTracker::from_continuation(&continuation, &state);
for next_loop_index in 4..AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT {
restored.record(&AgentRuntimeToolObservation {
tool: "project.search".to_string(),
status: "ok".to_string(),
summary: "重复窗口标记".to_string(),
detail: None,
});
assert_eq!(
restored.complete_loop(next_loop_index),
AgentRuntimeContextCheckpoint::Continue
);
}
restored.record(&AgentRuntimeToolObservation {
tool: "project.search".to_string(),
status: "ok".to_string(),
summary: "重复窗口标记".to_string(),
detail: None,
});
assert_eq!(
restored.complete_loop(AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT),
AgentRuntimeContextCheckpoint::Stalled,
"restored tracker must retain the original six-loop stall budget"
);
fs::remove_dir_all(root).ok();
}
#[test]
fn agent_runtime_context_window_persists_stall_across_revision_drift_and_restart() {
fn verification_blocker(current_revision: u64) -> AgentRuntimeToolObservation {
AgentRuntimeToolObservation {
tool: "runtime.verification".to_string(),
status: "blocked".to_string(),
summary: "项目在当前 revision 上尚未通过验证,不能把任务标记为完成".to_string(),
detail: Some(format!(
"currentRevision={current_revision}, mutationRevision=1, verifiedRevision=1;请重新执行并通过 project.verify 或 game.static_smoke。"
)),
}
}
let mut tracker = AgentRuntimeContextWindowTracker::default();
for next_loop_index in 1..AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT {
tracker.record(&verification_blocker(next_loop_index as u64));
assert_eq!(
tracker.complete_loop(next_loop_index),
AgentRuntimeContextCheckpoint::Continue
);
}
tracker.record(&verification_blocker(
AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT as u64,
));
assert_eq!(
tracker.complete_loop(AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT),
AgentRuntimeContextCheckpoint::Stalled,
"revision numbers alone must not manufacture independent progress"
);
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "验证停滞恢复项目").expect("project init");
let mut state = start_game_creator_agent_runtime_task_at(
&root,
"design-director",
"验证 revision 漂移不能绕过停滞预算",
"design-verification-stall-restart-run",
"agent-background-task",
"验证停滞恢复测试",
vec!["保持停滞预算".to_string()],
)
.expect("start verification stall runtime");
state.loop_iteration = AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT as u32;
state.max_loop_iterations = AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT as u32;
write_game_creator_agent_runtime_state(&root, &state)
.expect("persist verification stall runtime");
let observations = vec![verification_blocker(
AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT as u64,
)];
let bundle = build_game_creator_agent_runtime_context_bundle(
&root,
&state,
&state.current_task,
&AgentRuntimeToolPlan::default(),
&observations,
AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT,
&tracker,
)
.expect("build stalled context bundle");
assert!(bundle.context_stalled);
write_game_creator_agent_runtime_context_bundle(&root, &bundle)
.expect("write stalled context bundle");
let loaded = read_game_creator_agent_runtime_context_bundle(&root, &state)
.expect("read stalled context bundle")
.expect("stalled context bundle exists");
assert!(loaded.context_stalled);
let continuation = continuation_from_game_creator_agent_runtime_context_bundle(loaded);
let mut restored = AgentRuntimeContextWindowTracker::from_continuation(&continuation, &state);
assert_eq!(
restored.complete_loop(AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT + 1),
AgentRuntimeContextCheckpoint::Stalled,
"restart must not reset an exhausted context window"
);
fs::remove_dir_all(root).ok();
}
#[test]
fn agent_runtime_context_window_ignores_dynamic_successful_verification_output() {
let mut tracker = AgentRuntimeContextWindowTracker::default();
for next_loop_index in 1..=AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT * 2 {
let observation = if next_loop_index % 2 == 1 {
AgentRuntimeToolObservation {
tool: "project.verify".to_string(),
status: "ok".to_string(),
summary: "npm run check:agent 已通过".to_string(),
detail: Some(format!(
"AGENT_RUNTIME_CURRENT_REVISION_OK timestamp={next_loop_index}"
)),
}
} else {
AgentRuntimeToolObservation {
tool: "runtime.verification".to_string(),
status: "blocked".to_string(),
summary: "最终回复基于的项目 revision 已过期,不能把任务标记为完成".to_string(),
detail: Some(format!(
"responseRevision={}, currentRevision={next_loop_index};请根据最新项目状态重新规划后再生成最终回复。",
next_loop_index - 1
)),
}
};
tracker.record(&observation);
let checkpoint = tracker.complete_loop(next_loop_index);
if next_loop_index == AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT {
assert_eq!(checkpoint, AgentRuntimeContextCheckpoint::Compacted);
} else if next_loop_index == AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT * 2 {
assert_eq!(
checkpoint,
AgentRuntimeContextCheckpoint::Stalled,
"dynamic verification output and revision values must not keep stale replanning alive"
);
} else {
assert_eq!(checkpoint, AgentRuntimeContextCheckpoint::Continue);
}
}
}
#[test]
fn agent_runtime_context_window_skips_duplicate_agent_message_noops() {
let mut tracker = AgentRuntimeContextWindowTracker::default();
for next_loop_index in 1..=AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT {
tracker.record(&AgentRuntimeToolObservation {
tool: "agent.message".to_string(),
status: "ok".to_string(),
summary: "给 project-supervisor 的相同定向消息已存在,未重复追加".to_string(),
detail: Some("messageAppended=false".to_string()),
});
let checkpoint = tracker.complete_loop(next_loop_index);
if next_loop_index == AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT {
assert_eq!(
checkpoint,
AgentRuntimeContextCheckpoint::Stalled,
"durable duplicate messages must not keep a run alive"
);
} else {
assert_eq!(checkpoint, AgentRuntimeContextCheckpoint::Continue);
}
}
}
#[test]
fn agent_runtime_context_window_counts_distinct_agent_message_bodies() {
let mut tracker = AgentRuntimeContextWindowTracker::default();
for next_loop_index in 1..=AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT {
let detail = if next_loop_index % 2 == 0 {
"第二条真实新消息"
} else {
"第一条真实新消息"
};
tracker.record(&AgentRuntimeToolObservation {
tool: "agent.message".to_string(),
status: "ok".to_string(),
summary: "已给 project-supervisor 留消息".to_string(),
detail: Some(detail.to_string()),
});
let checkpoint = tracker.complete_loop(next_loop_index);
if next_loop_index == AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT {
assert_eq!(
checkpoint,
AgentRuntimeContextCheckpoint::Compacted,
"two distinct durable messages must still count as independent progress"
);
} else {
assert_eq!(checkpoint, AgentRuntimeContextCheckpoint::Continue);
}
}
}
#[test]
fn agent_runtime_context_window_applies_to_every_lane() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-window-lane", "窗口分档项目").expect("project init");
let started = start_game_creator_agent_runtime_task_at(
&root,
"design-director",
"验证窗口分档",
"window-lane-run",
"agent-background-task",
"窗口分档测试",
vec!["跑满一个窗口".to_string()],
)
.expect("start window lane runtime state");
let lane_state = |agent_id: &str, source: &str| {
let mut state = started.clone();
state.agent_id = agent_id.to_string();
state.source = source.to_string();
state
};
// 做游戏链路:窗口照常记账,跑满一轮窗口后给出 checkpoint。
let game_state = lane_state("design-director", "agent-background-task");
assert!(agent_runtime_context_window_applies(&game_state));
let mut game_tracker =
AgentRuntimeContextWindowTracker::from_continuation(&Default::default(), &game_state);
for next_loop_index in 1..=AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT {
game_tracker.record(&AgentRuntimeToolObservation {
tool: "project.search".to_string(),
status: "ok".to_string(),
summary: format!("第 {next_loop_index} 轮"),
detail: None,
});
let checkpoint = game_tracker.complete_loop(next_loop_index);
if next_loop_index == AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT {
assert_ne!(
checkpoint,
AgentRuntimeContextCheckpoint::Continue,
"做游戏链路必须保留窗口边界 checkpoint"
);
} else {
assert_eq!(checkpoint, AgentRuntimeContextCheckpoint::Continue);
}
}
}
#[test]
fn platform_art_asset_output_path_rejects_escape_overwrite_and_symlink() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "图片路径安全测试").expect("project init");
let prepared = prepare_platform_art_asset_output_path(&root, Some("assets/ui-prototype.png"))
.expect("valid deterministic image path")
.expect("prepared path");
assert_eq!(prepared.0, "assets/ui-prototype.png");
assert_eq!(prepared.1, root.join("assets/ui-prototype.png"));
for invalid in [
"../ui-prototype.png",
"/tmp/ui-prototype.png",
"game/ui-prototype.png",
"assets/ui-prototype.svg",
] {
assert!(
prepare_platform_art_asset_output_path(&root, Some(invalid)).is_err(),
"unsafe output path must fail: {invalid}"
);
}
fs::write(root.join("assets/existing.png"), b"existing").expect("existing image");
assert!(
prepare_platform_art_asset_output_path(&root, Some("assets/existing.png"))
.expect_err("existing image must not be overwritten")
.contains("禁止静默覆盖")
);
assert!(platform_art_asset_output_extension_matches(
"assets/output.jpg",
"jpeg"
));
assert!(!platform_art_asset_output_extension_matches(
"assets/output.png",
"webp"
));
#[cfg(unix)]
{
use std::os::unix::fs::symlink;
let outside = unique_project_path();
fs::create_dir_all(&outside).expect("outside dir");
symlink(&outside, root.join("assets/link")).expect("asset symlink");
assert!(
prepare_platform_art_asset_output_path(&root, Some("assets/link/escape.png")).is_err()
);
fs::remove_dir_all(outside).ok();
}
fs::remove_dir_all(root).ok();
}
#[test]
fn canonical_visual_completion_requires_persisted_route_kind_and_current_spec_reference() {
let root = unique_project_path();
init_local_game_project_at(&root, "visual-provenance", "视觉来源门禁")
.expect("init visual provenance project");
register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec");
register_canvas_visual_asset_fixture(&root, "assets/ui-prototype.png", "ui-prototype");
register_canvas_visual_asset_fixture(&root, "assets/art-spritesheet.png", "art-spritesheet");
let mut manifest = read_manifest_for_project(&root).expect("read visual manifest");
for task_id in ["art-director", "design-foundation", "art-asset-plan"] {
validate_manifest_required_visual_asset(&root, &manifest, task_id)
.unwrap_or_else(|error| panic!("{task_id} provenance should pass: {error}"));
}
// 规范图与派生素材都允许用户参考(同一项目已登记的图片素材):参考只是风格输入,
// 规范身份仍由参考序列第一项承担,多出的用户参考不能让校验失败。
let art_spec = manifest
.assets
.iter_mut()
.find(|asset| asset.local_path == "assets/art-spec.png")
.expect("art spec asset");
art_spec.source.reference_resource_ids = vec!["user-reference-1".to_string()];
let ui = manifest
.assets
.iter_mut()
.find(|asset| asset.local_path == "assets/ui-prototype.png")
.expect("ui prototype asset");
ui.source.reference_resource_ids = vec![
"resource-icon-spec".to_string(),
"user-reference-1".to_string(),
];
for task_id in ["art-director", "design-foundation"] {
validate_manifest_required_visual_asset(&root, &manifest, task_id)
.unwrap_or_else(|error| panic!("{task_id} must accept user references: {error}"));
}
// 用户参考不能顶替规范图:首项不是当前规范引用时仍必须失败关闭。
let ui = manifest
.assets
.iter_mut()
.find(|asset| asset.local_path == "assets/ui-prototype.png")
.expect("ui prototype asset");
ui.source.reference_resource_ids = vec![
"user-reference-1".to_string(),
"resource-icon-spec".to_string(),
];
assert!(
validate_manifest_required_visual_asset(&root, &manifest, "design-foundation")
.expect_err("a leading user reference must not replace the canonical spec")
.contains("未绑定当前统一视觉规范图的本地内容身份")
);
// 只接受单规范引用的图集不接受额外用户参考。
let ui = manifest
.assets
.iter_mut()
.find(|asset| asset.local_path == "assets/ui-prototype.png")
.expect("ui prototype asset");
ui.source.reference_resource_ids = vec!["resource-icon-spec".to_string()];
let spritesheet = manifest
.assets
.iter_mut()
.find(|asset| asset.local_path == "assets/art-spritesheet.png")
.expect("art spritesheet asset");
spritesheet.source.reference_resource_ids = vec![
"resource-icon-spec".to_string(),
"user-reference-1".to_string(),
];
assert!(
validate_manifest_required_visual_asset(&root, &manifest, "art-asset-plan")
.expect_err("art spritesheet must reject extra user references")
.contains("未精确引用当前统一视觉规范图")
);
let spritesheet = manifest
.assets
.iter_mut()
.find(|asset| asset.local_path == "assets/art-spritesheet.png")
.expect("art spritesheet asset");
spritesheet.source.reference_resource_ids = vec!["resource-icon-spec".to_string()];
let art_spec = manifest
.assets
.iter_mut()
.find(|asset| asset.local_path == "assets/art-spec.png")
.expect("art spec asset");
art_spec.source.reference_resource_ids = Vec::new();
let art_spec_path = root.join("assets/art-spec.png");
let valid_art_spec = fs::read(&art_spec_path).expect("read valid art spec fixture");
fs::write(&art_spec_path, &valid_art_spec[..valid_art_spec.len() / 2])
.expect("write truncated art spec PNG");
assert!(
validate_manifest_required_visual_asset(&root, &manifest, "art-director")
.expect_err("truncated PNG must fail complete decode")
.contains("无法完整解码")
);
fs::write(&art_spec_path, valid_art_spec).expect("restore valid art spec PNG");
let ui = manifest
.assets
.iter_mut()
.find(|asset| asset.local_path == "assets/ui-prototype.png")
.expect("ui prototype asset");
ui.source.reference_resource_ids = vec!["stale-art-spec-resource".to_string()];
assert!(
validate_manifest_required_visual_asset(&root, &manifest, "design-foundation")
.expect_err("stale UI reference must fail")
.contains("未绑定当前统一视觉规范图的本地内容身份")
);
let ui = manifest
.assets
.iter_mut()
.find(|asset| asset.local_path == "assets/ui-prototype.png")
.expect("ui prototype asset");
ui.source.reference_resource_ids = vec!["resource-icon-spec".to_string()];
ui.source.generation_route =
Some("/api/external/v1/editor/icon-spritesheets/generations".to_string());
assert!(
validate_manifest_required_visual_asset(&root, &manifest, "design-foundation")
.expect_err("wrong UI route must fail")
.contains("legacy")
);
let ui = manifest
.assets
.iter_mut()
.find(|asset| asset.local_path == "assets/ui-prototype.png")
.expect("ui prototype asset");
ui.source.generation_route = Some("/api/external/v1/editor/images/generations".to_string());
let spritesheet = manifest
.assets
.iter_mut()
.find(|asset| asset.local_path == "assets/art-spritesheet.png")
.expect("art spritesheet asset");
spritesheet.source.generation_kind = Some("spec".to_string());
assert!(
validate_manifest_required_visual_asset(&root, &manifest, "art-asset-plan")
.expect_err("wrong spritesheet kind must fail")
.contains("legacy")
);
let spritesheet = manifest
.assets
.iter_mut()
.find(|asset| asset.local_path == "assets/art-spritesheet.png")
.expect("art spritesheet asset");
spritesheet.source.generation_kind = Some("icon-spritesheet".to_string());
let art_spec = manifest
.assets
.iter_mut()
.find(|asset| asset.local_path == "assets/art-spec.png")
.expect("art spec asset");
art_spec.source.resource_id = Some("resource-icon-spec-v2".to_string());
for task_id in ["design-foundation", "art-asset-plan"] {
assert!(
validate_manifest_required_visual_asset(&root, &manifest, task_id)
.unwrap_err()
.contains("未绑定当前统一视觉规范图的本地内容身份"),
"{task_id} must become stale after art-spec resource changes"
);
}
fs::remove_dir_all(root).ok();
}
#[tokio::test]
async fn background_agent_runtime_can_start_local_preview() {
let root = unique_project_path();
init_existing_html_project_at(&root, "project-1", "月光厨房").expect("project init");
fs::write(
root.join("game/index.html"),
fake_llm_game_draft().game_html,
)
.expect("write playable game html");
write_project_permission_policy_at(
&root,
ProjectPermissionPolicy {
denied_commands: Vec::new(),
confirm_commands: Vec::new(),
agent_policies: BTreeMap::new(),
},
)
.expect("allow preview start");
let (sender, receiver) = mpsc::channel();
let plan_json = serde_json::json!({
"thinkingSummary": "当前原型已经可运行,需要启动本地预览给用户验收",
"plan": ["启动本地预览", "回报预览地址"],
"actions": [
{
"tool": "preview.start",
"reason": "让用户可以在浏览器试玩当前原型",
"input": {}
}
],
"response": ""
})
.to_string();
let base_url = spawn_mock_llm_server_responses_with_capture(
vec![
plan_json,
final_tool_plan_response("本地预览已经启动,请打开链接试玩。"),
],
Some(sender),
);
let _config_guard = write_test_local_config(format!(
r#"{{
"agentLlm": {{
"code-prototype": {{
"apiKey": "code-key",
"baseUrl": {base_url:?},
"model": "code-runtime-model",
"apiKind": "openai_responses"
}}
}}
}}"#
));
start_game_creator_agent_background_task_at(
&root,
"code-prototype",
"启动当前可玩原型预览",
"code-preview-run",
)
.expect("start background task");
let plan_request = receiver
.recv_timeout(Duration::from_secs(2))
.expect("plan llm request");
assert!(plan_request.contains("preview.start"));
let final_request = receiver
.recv_timeout(Duration::from_secs(2))
.expect("final reply llm request");
assert!(final_request.contains("preview.start 已启动"));
assert!(final_request.contains("http://127.0.0.1:"));
let root_display = root.to_string_lossy();
assert!(!final_request.contains(root_display.as_ref()));
let mut runtime = read_game_creator_agent_runtime_at(&root, "code-prototype")
.expect("read runtime")
.state;
for _ in 0..50 {
if runtime.status == "idle" {
break;
}
std::thread::sleep(Duration::from_millis(20));
runtime = read_game_creator_agent_runtime_at(&root, "code-prototype")
.expect("read runtime")
.state;
}
assert_eq!(runtime.status, "idle");
assert!(runtime
.observations
.iter()
.any(|item| { item.contains("preview.start:ok · preview.start 已启动") }));
let preview_log =
fs::read_to_string(root.join(".agent/logs/preview.log")).expect("preview log");
assert!(preview_log.contains("preview.running http://127.0.0.1:"));
let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db");
assert!(agent_db.contains("\"recordType\":\"agent.runtime.preview.start\""));
let manifest: Value =
serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap())
.expect("manifest json");
assert_eq!(manifest["preview"]["status"], "running");
assert!(manifest["preview"]["url"]
.as_str()
.unwrap()
.starts_with("http://127.0.0.1:"));
let registry = game_creator_preview_registry();
stop_local_game_preview_for_root(Some(&root), &registry).expect("stop preview");
fs::remove_dir_all(root).ok();
}
#[tokio::test]
async fn background_agent_runtime_preview_start_respects_project_policy() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init");
fs::write(
root.join("game/index.html"),
fake_llm_game_draft().game_html,
)
.expect("write playable game html");
write_project_permission_policy_at(
&root,
ProjectPermissionPolicy {
denied_commands: Vec::new(),
confirm_commands: vec!["preview.start".to_string()],
agent_policies: BTreeMap::new(),
},
)
.expect("write policy");
let (sender, receiver) = mpsc::channel();
let plan_json = serde_json::json!({
"thinkingSummary": "尝试启动本地预览",
"plan": ["启动本地预览"],
"actions": [
{
"tool": "preview.start",
"reason": "测试策略拦截预览启动",
"input": {}
}
],
"response": ""
})
.to_string();
let base_url = spawn_mock_llm_server_responses_with_capture(vec![plan_json], Some(sender));
let _config_guard = write_test_local_config(format!(
r#"{{
"agentLlm": {{
"code-prototype": {{
"apiKey": "code-key",
"baseUrl": {base_url:?},
"model": "code-runtime-model",
"apiKind": "openai_responses"
}}
}}
}}"#
));
start_game_creator_agent_background_task_at(
&root,
"code-prototype",
"后台尝试启动当前预览",
"code-preview-policy-run",
)
.expect("start background task");
let _plan_request = receiver
.recv_timeout(Duration::from_secs(2))
.expect("plan llm request");
assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err());
let runtime = wait_for_agent_runtime_confirmation(&root, "code-prototype");
assert_eq!(runtime.status, "waiting-for-confirmation");
assert_eq!(runtime.task_queue.waiting_for_confirmation, 1);
assert!(runtime.observations.iter().any(|item| item.contains(
"preview.start:waiting-for-confirmation · 项目权限策略要求用户确认:preview.start"
)));
assert!(!root.join(".agent/logs/preview.log").exists());
cancel_game_creator_agent_runtime_task_at(&root, "code-prototype", "code-preview-policy-run")
.expect("cancel waiting preview policy task");
wait_for_agent_runtime_terminal_and_lane_release(
&root,
"code-prototype",
"code-preview-policy-run",
"cancelled",
"cancelled",
);
fs::remove_dir_all(root).ok();
}
#[tokio::test]
#[ignore = "requires an installed Chrome/Edge and explicit local browser execution"]
async fn background_agent_runtime_preview_validate_writes_real_browser_evidence() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "浏览器验证测试").expect("project init");
fs::write(
root.join("game/index.html"),
r#"<!doctype html><html><head><meta charset="utf-8"><title>Runtime Browser Smoke</title></head><body><h1>Runtime Browser Smoke</h1><canvas id="game" width="160" height="90"></canvas><script>const c=document.getElementById('game');const x=c.getContext('2d');x.fillStyle='#f44';x.fillRect(0,0,80,90);x.fillStyle='#4cf';x.fillRect(80,0,80,90);</script></body></html>"#,
)
.expect("write browser fixture");
let action = AgentRuntimeToolAction {
tool: "preview.validate".to_string(),
reason: Some("采集桌面和移动浏览器证据".to_string()),
input: serde_json::json!({
"viewports": ["desktop", "mobile"],
"expectedText": ["Runtime Browser Smoke"],
"settleMs": 50,
"failOnConsoleError": true
}),
};
let observation = execute_game_creator_agent_runtime_tool_action_with_action_id(
&root,
"code-prototype",
"browser-runtime-smoke-run",
"验证本地预览",
&action,
Some("browser-runtime-smoke-action"),
)
.await;
assert_eq!(observation.status, "ok", "{:?}", observation);
let evidence =
root.join(".agent/runtime/browser-validations/code-prototype/browser-runtime-smoke-run/0");
for file in ["validation.json", "desktop.png", "mobile.png"] {
let bytes = fs::read(evidence.join(file)).expect("browser evidence");
assert!(!bytes.is_empty(), "empty browser evidence: {file}");
if file.ends_with(".png") {
assert!(bytes.starts_with(b"\x89PNG\r\n\x1a\n"));
}
}
let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db");
assert!(agent_db.contains("agent.runtime.preview.validation"));
assert!(agent_db.contains("\"passed\":true"));
fs::remove_dir_all(root).ok();
}
#[tokio::test]
async fn background_agent_runtime_can_generate_platform_art_asset() {
let root = unique_project_path();
let config_dir = unique_project_path();
let (canvas_sender, canvas_receiver) = mpsc::channel();
let canvas_base_url = spawn_mock_external_canvas_generation_api_server(Some(canvas_sender));
let _platform_session = crate::platform_session::install_test_platform_session(
"background-art-user",
"editor-runtime-key",
&canvas_base_url,
);
init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init");
register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec");
bind_canvas_visual_asset_fixture_to_current_editor(
&root,
"assets/art-spec.png",
"canvas-project-1",
);
write_project_permission_policy_at(
&root,
ProjectPermissionPolicy {
denied_commands: Vec::new(),
confirm_commands: Vec::new(),
agent_policies: BTreeMap::new(),
},
)
.expect("allow asset generation");
let (sender, receiver) = mpsc::channel();
let plan_json = serde_json::json!({
"thinkingSummary": "当前项目缺少首版角色素材,需要通过平台生成一张核心美术图",
"plan": ["生成主角素材", "登记到本地资产清单", "回报素材路径"],
"actions": [
{
"tool": "canvas.asset_generate",
"reason": "生成可用于首版原型的主角素材",
"input": {
"prompt": "透明 PNG 像素月光主角图集,按 2 行 2 列等分网格排布,适合厨房弹幕游戏",
"outputPath": "assets/art-spritesheet.png",
"aspectRatio": "1:1",
"imageSize": "1K",
"assetKind": "art-spritesheet",
"assetLabel": "游戏首版核心美术素材",
"replaceExisting": false,
"sliceMode": "grid",
"gridX": 2,
"gridY": 2,
"sliceCount": null
}
}
],
"response": ""
})
.to_string();
let llm_base_url = spawn_mock_llm_server_responses_with_capture(
vec![
plan_json,
final_tool_plan_response("首版主角素材已经生成并登记到本地项目。"),
],
Some(sender),
);
fs::create_dir_all(&config_dir).expect("runtime config dir");
fs::write(
config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME),
serde_json::json!({
"agentMode": "provider",
"llm": { "stream": false },
"editorApi": {
"baseUrl": canvas_base_url,
"apiKey": "editor-runtime-key"
},
"agentLlm": {
"art-asset-plan": {
"apiKey": "art-key",
"baseUrl": llm_base_url,
"model": "art-runtime-model",
"apiKind": "openai_responses"
}
}
})
.to_string(),
)
.expect("write runtime config");
let _config_guard = use_test_runtime_config_dir(config_dir.clone());
start_game_creator_agent_background_task_at(
&root,
"art-asset-plan",
"为月光厨房生成首版主角素材图集,按 2 行 2 列等分网格排布",
"art-generate-run",
)
.expect("start background task");
let plan_request = receiver
.recv_timeout(Duration::from_secs(2))
.expect("plan llm request");
assert!(plan_request.contains("canvas.asset_generate"));
let final_request = receiver
.recv_timeout(Duration::from_secs(20))
.expect("final reply llm request");
assert!(final_request.contains("canvas.asset_generate"));
assert!(final_request.contains("canvas.asset_generate"));
assert!(final_request.contains("assets/art-spritesheet.png"));
assert!(!final_request.contains("editor-runtime-key"));
let root_display = root.to_string_lossy();
assert!(!final_request.contains(root_display.as_ref()));
let mut runtime = read_game_creator_agent_runtime_at(&root, "art-asset-plan")
.expect("read runtime")
.state;
for _ in 0..50 {
if runtime.status == "idle" {
break;
}
std::thread::sleep(Duration::from_millis(20));
runtime = read_game_creator_agent_runtime_at(&root, "art-asset-plan")
.expect("read runtime")
.state;
}
assert_eq!(runtime.status, "idle");
assert!(runtime
.observations
.iter()
.any(|item| item.contains("canvas.asset_generate:ok · 已生成美术素材:")));
let revision = read_game_creator_agent_runtime_project_revision(&root)
.expect("read generated art revision")
.revision;
let gate = read_game_creator_agent_runtime_verification_gate(
&root,
"art-asset-plan",
"art-generate-run",
)
.expect("read generated art verification gate");
assert_eq!(gate.mutation_revision, Some(revision));
assert_eq!(gate.verified_revision, Some(revision));
assert_eq!(
gate.last_mutation_tool.as_deref(),
Some("canvas.asset_generate")
);
assert_eq!(
gate.last_verification_tool.as_deref(),
Some("canvas.asset_generate")
);
assert_eq!(gate.last_verification_status.as_deref(), Some("passed"));
let manifest: Value =
serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap())
.expect("manifest json");
let asset = manifest["assets"]
.as_array()
.and_then(|assets| {
assets
.iter()
.find(|asset| asset["localPath"].as_str() == Some("assets/art-spritesheet.png"))
})
.expect("generated art spritesheet asset");
assert_eq!(asset["source"]["kind"], "canvas");
assert_eq!(asset["source"]["resourceId"], "resource-1");
assert_eq!(asset["source"]["assetObjectId"], "asset-object-1");
assert_eq!(
asset["source"]["generationRoute"],
"/api/external/v1/editor/icon-spritesheets/generations"
);
assert_eq!(asset["source"]["generationKind"], "icon-spritesheet");
assert_eq!(
asset["source"]["referenceResourceIds"],
serde_json::json!(["resource-icon-spec"])
);
assert_eq!(asset["kind"], "art-spritesheet");
assert_eq!(asset["localPath"], "assets/art-spritesheet.png");
assert_eq!(
fs::read(root.join(asset["localPath"].as_str().unwrap())).unwrap(),
transparent_test_png_bytes()
);
let slice_manifest: Value = serde_json::from_slice(
&fs::read(root.join("assets/art-spritesheet-slices/manifest.json"))
.expect("read strict slice manifest"),
)
.expect("parse strict slice manifest");
assert_eq!(slice_manifest["slices"].as_array().map(Vec::len), Some(4));
for (index, usage) in [
"player",
"blocks-and-targets",
"obstacles-and-scene",
"feedback-effects",
]
.into_iter()
.enumerate()
{
assert_eq!(slice_manifest["slices"][index]["usage"], usage);
assert_eq!(
fs::read(root.join(format!("assets/art-spritesheet-slices/{usage}.png")))
.expect("read canonical slice"),
spritesheet_slice_test_png_bytes(index)
);
}
let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db");
assert!(agent_db.contains("\"recordType\":\"canvas.asset_generate\""));
assert!(agent_db.contains("\"recordType\":\"agent.runtime.canvas.asset_generate\""));
assert!(agent_db.contains("\"agentId\":\"art-asset-plan\""));
assert!(agent_db.contains("\"sliceCount\":4"));
assert!(!agent_db.contains("editor-runtime-key"));
assert!(!agent_db.contains("idempotencyKey"));
assert!(!root
.join(".agent/runtime/canvas-generation-requests/art-asset-plan/art-generate-run.json")
.exists());
let canvas_requests = (0..16)
.map(|_| {
canvas_receiver
.recv_timeout(Duration::from_secs(2))
.expect("canvas api request")
})
.collect::<Vec<_>>();
let generation_request = canvas_requests
.iter()
.find(|request| request.starts_with("POST /api/editor/icon-spritesheets/generations "))
.expect("canvas generation request");
let request_header = |request: &str, expected_name: &str| {
request.lines().find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case(expected_name)
.then(|| value.trim().to_string())
})
};
let main_site_requests = canvas_requests
.iter()
.filter(|request| {
request.starts_with("GET /api/editor/")
|| request.starts_with("POST /api/editor/")
|| request.starts_with("GET /api/assets/")
|| request.starts_with("POST /api/assets/")
|| request.starts_with("GET /api/runtime/")
})
.collect::<Vec<_>>();
assert!(!main_site_requests.is_empty());
assert!(main_site_requests.iter().all(|request| {
request
.to_ascii_lowercase()
.contains("x-genarrative-client: agc")
}));
assert_eq!(
request_header(generation_request, "authorization"),
Some("Bearer editor-runtime-key".to_string())
);
let generation_idempotency_key =
request_header(generation_request, "idempotency-key").expect("generation idempotency key");
assert!(uuid::Uuid::parse_str(&generation_idempotency_key).is_ok());
let generation_body: Value = serde_json::from_str(
generation_request
.split_once("\r\n\r\n")
.expect("generation request body")
.1,
)
.expect("generation request json");
assert_eq!(generation_body["sliceMode"], "grid");
assert_eq!(generation_body["gridX"], 2);
assert_eq!(generation_body["gridY"], 2);
assert!(generation_body["sliceCount"].is_null());
assert!(generation_request.contains(r#""source":"ai-game-creator-client""#));
assert_eq!(
canvas_requests
.iter()
.filter(|request| {
request.starts_with("POST /api/editor/icon-spritesheets/generations ")
})
.count(),
1,
"queued/running polling must not submit generation again"
);
assert_eq!(
canvas_requests
.iter()
.filter(|request| request.starts_with("GET /api/runtime/external-generation/jobs/"))
.count(),
3,
"fixture should exercise queued, running, and completed states"
);
assert!(canvas_requests
.iter()
.filter(|request| request.starts_with("GET /api/runtime/external-generation/jobs/"))
.all(|request| {
request
.to_ascii_lowercase()
.contains("x-genarrative-client: agc")
}));
for expected in [
r#""referenceId":"resource-icon-spec""#,
r#""iconDescriptions":"#,
r#""screenColor":"auto""#,
r#""projectId":"canvas-project-1""#,
r#""assetFolderId":"folder-1""#,
r#""canvasCompletion":"#,
r#""assetLabel":"游戏首版核心美术素材""#,
r#""aspectRatio":"1:1""#,
] {
assert!(
generation_request.contains(expected),
"generation request missing {expected}: {generation_request}"
);
}
assert!(
!generation_request.contains(r#""referenceImageSrc":"#),
"generation request must not send the retired primary reference field: {generation_request}"
);
fs::remove_dir_all(root).ok();
fs::remove_dir_all(config_dir).ok();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn canonical_art_spec_and_ui_requests_use_the_shared_reference_chain() {
async fn capture_generation_request(
root: &Path,
config_dir: &Path,
options: PlatformArtAssetGenerationOptions,
) -> String {
let (request_sender, request_receiver) = mpsc::channel();
let canvas_base_url =
spawn_mock_external_canvas_generation_api_server(Some(request_sender));
let _platform_session = crate::platform_session::install_test_platform_session(
"canonical-art-user",
"editor-runtime-key",
&canvas_base_url,
);
fs::create_dir_all(config_dir).expect("create runtime config dir");
fs::write(
config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME),
serde_json::json!({
"editorApi": {
"baseUrl": canvas_base_url,
"apiKey": "editor-runtime-key"
}
})
.to_string(),
)
.expect("write runtime config");
let _config_guard = use_test_runtime_config_dir(config_dir.to_path_buf());
if root.join("assets/art-spec.png").is_file() {
bind_canvas_visual_asset_fixture_to_current_editor(
root,
"assets/art-spec.png",
"canvas-project-1",
);
}
if root.join("assets/user-reference.png").is_file() {
bind_canvas_visual_asset_fixture_to_current_editor(
root,
"assets/user-reference.png",
"canvas-project-1",
);
}
request_platform_art_asset_with_options_for_test(root, "原创贪吃蛇视觉", &options)
.await
.expect("prepare canonical visual request");
(0..8)
.map(|_| {
request_receiver
.recv_timeout(Duration::from_secs(2))
.expect("canvas api request")
})
.find(|request| {
request.starts_with("POST /api/editor/images/generations ")
|| request.starts_with("POST /api/editor/icon-spritesheets/generations ")
})
.expect("generation request")
}
let root = unique_project_path();
let spec_config_dir = unique_project_path();
init_local_game_project_at(&root, "project-visual-chain", "月光厨房").expect("project init");
write_project_permission_policy_at(
&root,
ProjectPermissionPolicy {
denied_commands: Vec::new(),
confirm_commands: Vec::new(),
agent_policies: BTreeMap::new(),
},
)
.expect("allow visual generation");
let spec_request = capture_generation_request(
&root,
&spec_config_dir,
PlatformArtAssetGenerationOptions {
output_path: Some("assets/art-spec.png".to_string()),
asset_kind: "icon-spec".to_string(),
asset_label: "游戏统一视觉规范图".to_string(),
..PlatformArtAssetGenerationOptions::default()
},
)
.await;
assert!(spec_request.starts_with("POST /api/editor/images/generations "));
assert!(spec_request.contains(r#""kind":"spec""#));
assert!(spec_request.contains(r#""assetKind":"icon-spec""#));
assert!(spec_request.contains(r#""referenceImageSrcs":[]"#));
register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec");
let ui_config_dir = unique_project_path();
let ui_request = capture_generation_request(
&root,
&ui_config_dir,
PlatformArtAssetGenerationOptions {
output_path: Some("assets/ui-prototype.png".to_string()),
aspect_ratio: "16:9".to_string(),
image_size: "2K".to_string(),
asset_kind: "ui-prototype".to_string(),
asset_label: "游戏横屏界面原型图".to_string(),
replace_existing: false,
slice_count: None,
slice_mode: None,
grid_x: None,
grid_y: None,
reference_asset_ids: Vec::new(),
target_category: None,
screen_color: None,
},
)
.await;
assert!(ui_request.starts_with("POST /api/editor/images/generations "));
assert!(ui_request.contains(r#""kind":"ui-design""#));
assert!(ui_request.contains(r#""referenceImageSrcs":["resource-icon-spec"]"#));
// 用户参考只接受当前项目已登记图片素材 id,并换成当前账号绑定下的远端资源 ID。
register_canvas_visual_asset_fixture(&root, "assets/user-reference.png", "image");
let user_reference_asset_id = manifest_asset_id(&root, "assets/user-reference.png");
let icon_spec_config_dir = unique_project_path();
let icon_spec_with_reference = capture_generation_request(
&root,
&icon_spec_config_dir,
PlatformArtAssetGenerationOptions {
output_path: Some("assets/icon-spec-custom.png".to_string()),
asset_kind: "icon-spec".to_string(),
asset_label: "带用户参考的图标规范".to_string(),
reference_asset_ids: vec![
user_reference_asset_id.clone(),
user_reference_asset_id.clone(),
],
..PlatformArtAssetGenerationOptions::default()
},
)
.await;
assert!(icon_spec_with_reference.starts_with("POST /api/editor/images/generations "));
// 图标规范没有规范前置:用户参考原样提交,且不带任何伪造的规范引用。
assert!(
icon_spec_with_reference.contains(r#""referenceImageSrcs":["resource-image"]"#),
"{icon_spec_with_reference}"
);
let ui_with_reference_config_dir = unique_project_path();
let ui_with_reference = capture_generation_request(
&root,
&ui_with_reference_config_dir,
PlatformArtAssetGenerationOptions {
output_path: Some("assets/ui-prototype-custom.png".to_string()),
aspect_ratio: "16:9".to_string(),
image_size: "2K".to_string(),
asset_kind: "ui-prototype".to_string(),
asset_label: "带用户参考的界面原型图".to_string(),
reference_asset_ids: vec![user_reference_asset_id.clone()],
..PlatformArtAssetGenerationOptions::default()
},
)
.await;
// 有规范前置的生成:规范图始终是第一项,用户参考按给出顺序追加在后。
assert!(
ui_with_reference
.contains(r#""referenceImageSrcs":["resource-icon-spec","resource-image"]"#),
"{ui_with_reference}"
);
fs::remove_dir_all(root).ok();
fs::remove_dir_all(spec_config_dir).ok();
fs::remove_dir_all(ui_config_dir).ok();
fs::remove_dir_all(icon_spec_config_dir).ok();
fs::remove_dir_all(ui_with_reference_config_dir).ok();
}
/// 参考上传凭证请求:同一路由在 External v1 与平台会话下会落到两种前缀,两边的写操作都要看住。
fn is_reference_upload_ticket_request(request: &str) -> bool {
request.starts_with("POST /api/assets/direct-upload-tickets ")
|| request.starts_with("POST /api/external/v1/assets/direct-upload-tickets ")
}
/// 图片生成提交:同样两种前缀都要算。
fn is_image_generation_request(request: &str) -> bool {
request.starts_with("POST /api/editor/images/generations ")
|| request.starts_with("POST /api/external/v1/editor/images/generations ")
|| request.starts_with("POST /api/editor/icon-spritesheets/generations ")
|| request.starts_with("POST /api/external/v1/editor/icon-spritesheets/generations ")
}
/// 当前项目清单里某条素材的 manifest 资产 id(参考选择只接受这个身份)。
fn manifest_asset_id(root: &Path, local_path: &str) -> String {
read_manifest_for_project(root)
.expect("read manifest for asset id")
.assets
.into_iter()
.find(|asset| asset.local_path == local_path)
.unwrap_or_else(|| panic!("registered asset is present: {local_path}"))
.id
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn reference_selection_rejects_unsupported_inputs_before_any_generation_post() {
let root = unique_project_path();
let config_dir = unique_project_path();
let (request_sender, request_receiver) = mpsc::channel();
// 这条用例要连续跑十几次「画布上下文 + 参考预检」,超过默认 20 次请求预算会被判成 502。
let canvas_base_url =
spawn_mock_external_canvas_api_server_with_capture(200, Some(request_sender));
let _platform_session = crate::platform_session::install_test_platform_session(
"reference-guard-user",
"editor-runtime-key",
&canvas_base_url,
);
fs::create_dir_all(&config_dir).expect("create reference guard config dir");
fs::write(
config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME),
serde_json::json!({
"editorApi": { "baseUrl": canvas_base_url, "apiKey": "editor-runtime-key" }
})
.to_string(),
)
.expect("write reference guard config");
let _config_guard = use_test_runtime_config_dir(config_dir.clone());
init_local_game_project_at(&root, "reference-guard", "参考素材门禁")
.expect("init reference guard project");
write_project_permission_policy_at(
&root,
ProjectPermissionPolicy {
denied_commands: Vec::new(),
confirm_commands: Vec::new(),
agent_policies: BTreeMap::new(),
},
)
.expect("allow reference guard generation");
let options =
|asset_kind: &str, reference_asset_ids: Vec<String>| PlatformArtAssetGenerationOptions {
output_path: Some(format!("assets/reference-guard-{asset_kind}.png")),
asset_kind: asset_kind.to_string(),
asset_label: "参考素材门禁".to_string(),
reference_asset_ids,
..PlatformArtAssetGenerationOptions::default()
};
async fn reject(root: &Path, options: PlatformArtAssetGenerationOptions) -> String {
request_platform_art_asset_with_options_for_test(root, "参考素材门禁", &options)
.await
.expect_err("reference selection must fail closed")
}
let canvas_source = || GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Canvas,
canvas_project_id: None,
resource_id: None,
asset_object_id: None,
task_id: None,
prompt: None,
model: None,
generation_route: None,
generation_kind: None,
reference_resource_ids: Vec::new(),
};
// 只接受单规范引用的图集不接受用户参考,且必须明确拒绝而不是静默丢弃。
let error = reject(
&root,
options("art-spritesheet", vec!["user-asset".to_string()]),
)
.await;
assert!(error.contains("透明美术图集只接受规范图引用"), "{error}");
// 路径、URL 等形状不是素材身份。
for rejected in [
"assets/hero.png",
"..\\hero.png",
"https://example.com/hero.png",
] {
let error = reject(&root, options("icon-spec", vec![rejected.to_string()])).await;
assert!(error.contains("不接受路径或远端资源 ID"), "{error}");
}
// 未登记的 id 不能冒充当前项目素材(历史账号的远端资源 ID 也不在此列)。
let error = reject(
&root,
options(
"icon-spec",
vec!["editor-resource-from-older-account".to_string()],
),
)
.await;
assert!(
error.contains("参考素材不在当前项目已登记清单中"),
"{error}"
);
// 非图片素材不能当参考。
fs::create_dir_all(root.join("assets")).expect("create reference guard asset dir");
fs::write(root.join("assets/document.json"), b"{}").expect("write non image reference fixture");
register_local_asset_at(
&root,
"assets/document.json",
"image",
"application/json",
"canvas",
canvas_source(),
)
.expect("register non image reference fixture");
let error = reject(
&root,
options(
"icon-spec",
vec![manifest_asset_id(&root, "assets/document.json")],
),
)
.await;
assert!(error.contains("参考素材必须是图片"), "{error}");
// 已登记但本地文件缺失的素材不能被引用。
register_canvas_visual_asset_fixture(&root, "assets/missing-reference.png", "image");
let missing_asset_id = manifest_asset_id(&root, "assets/missing-reference.png");
fs::remove_file(root.join("assets/missing-reference.png"))
.expect("remove missing reference fixture file");
let error = reject(&root, options("icon-spec", vec![missing_asset_id])).await;
assert!(error.contains("不存在;请重新登记后再引用"), "{error}");
// 超过总上限:无规范前置最多 5 张。
let error = reject(
&root,
options(
"icon-spec",
(0..6).map(|index| format!("reference-{index}")).collect(),
),
)
.await;
assert!(error.contains("普通图片生成最多 5 张参考素材"), "{error}");
// SVG 与坏图同样必须在提交前失败:本次不做 SVG 转换,也不允许把「已登记」当成可解码。
register_canvas_visual_asset_fixture(&root, "assets/plain-reference.png", "image");
let plain_reference_asset_id = manifest_asset_id(&root, "assets/plain-reference.png");
fs::write(
root.join("assets/vector-reference.svg"),
b"<svg xmlns=\"http://www.w3.org/2000/svg\"/>",
)
.expect("write svg reference fixture");
register_local_asset_at(
&root,
"assets/vector-reference.svg",
"image",
"image/svg+xml",
"canvas",
canvas_source(),
)
.expect("register svg reference fixture");
let vector_asset_id = manifest_asset_id(&root, "assets/vector-reference.svg");
let error = reject(
&root,
options(
"icon-spec",
vec![plain_reference_asset_id.clone(), vector_asset_id],
),
)
.await;
assert!(error.contains("不支持 SVG 等矢量格式"), "{error}");
// 声明成位图、实际是矢量扩展名的素材同样要按矢量拒绝。
fs::write(
root.join("assets/mislabeled-reference.svg"),
valid_test_png_bytes(),
)
.expect("write mislabeled svg reference fixture");
register_local_asset_at(
&root,
"assets/mislabeled-reference.svg",
"image",
"image/png",
"canvas",
canvas_source(),
)
.expect("register mislabeled svg reference fixture");
let mislabeled_asset_id = manifest_asset_id(&root, "assets/mislabeled-reference.svg");
let error = reject(
&root,
options(
"icon-spec",
vec![plain_reference_asset_id.clone(), mislabeled_asset_id],
),
)
.await;
assert!(error.contains("不支持 SVG 等矢量格式"), "{error}");
fs::write(root.join("assets/broken-reference.png"), b"not-a-png")
.expect("write broken reference fixture");
register_local_asset_at(
&root,
"assets/broken-reference.png",
"image",
"image/png",
"canvas",
canvas_source(),
)
.expect("register broken reference fixture");
let broken_asset_id = manifest_asset_id(&root, "assets/broken-reference.png");
let error = reject(
&root,
options(
"icon-spec",
vec![plain_reference_asset_id.clone(), broken_asset_id],
),
)
.await;
assert!(error.contains("不是可解析图片"), "{error}");
// 以上全部在提交前失败:没有生成 POST,也没有任何参考上传凭证被签发。
// 合格参考故意不做 binding:旧路径会先为它签发凭证,所以这条断言对半完成上传有实际约束。
while let Ok(request) = request_receiver.recv_timeout(Duration::from_millis(200)) {
assert!(!is_image_generation_request(&request), "{request}");
assert!(!is_reference_upload_ticket_request(&request), "{request}");
}
// 反证:同一张合格参考单独提交时确实会去签发上传凭证,说明上面的「零上传」不是空断言。
let differential = request_platform_art_asset_with_options_for_test(
&root,
"参考素材门禁",
&options("icon-spec", vec![plain_reference_asset_id.clone()]),
)
.await;
let mut upload_ticket_attempted = false;
while let Ok(request) = request_receiver.recv_timeout(Duration::from_millis(300)) {
if is_reference_upload_ticket_request(&request) {
upload_ticket_attempted = true;
}
}
assert!(
upload_ticket_attempted,
"the same single reference must attempt an upload ticket: {differential:?}"
);
fs::remove_dir_all(root).ok();
fs::remove_dir_all(config_dir).ok();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn reference_upload_permission_gate_fails_closed_before_any_upload() {
let root = unique_project_path();
let config_dir = unique_project_path();
let (request_sender, request_receiver) = mpsc::channel();
let canvas_base_url = spawn_mock_external_canvas_generation_api_server(Some(request_sender));
let _platform_session = crate::platform_session::install_test_platform_session(
"reference-permission-user",
"editor-runtime-key",
&canvas_base_url,
);
fs::create_dir_all(&config_dir).expect("create reference permission config dir");
fs::write(
config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME),
serde_json::json!({
"editorApi": { "baseUrl": canvas_base_url, "apiKey": "editor-runtime-key" }
})
.to_string(),
)
.expect("write reference permission config");
let _config_guard = use_test_runtime_config_dir(config_dir.clone());
init_local_game_project_at(&root, "reference-permission", "参考上传门禁")
.expect("init reference permission project");
// 只拒绝 asset.upload:参考素材要上传到平台账号,这个门禁必须在本地失败关闭。
write_project_permission_policy_at(
&root,
ProjectPermissionPolicy {
denied_commands: vec!["asset.upload".to_string()],
confirm_commands: Vec::new(),
agent_policies: BTreeMap::new(),
},
)
.expect("deny reference upload");
register_canvas_visual_asset_fixture(&root, "assets/plain-reference.png", "image");
let plain_reference_asset_id = manifest_asset_id(&root, "assets/plain-reference.png");
let error = request_platform_art_asset_with_options_for_test(
&root,
"参考素材门禁",
&PlatformArtAssetGenerationOptions {
output_path: Some("assets/reference-permission.png".to_string()),
asset_kind: "icon-spec".to_string(),
asset_label: "参考素材门禁".to_string(),
reference_asset_ids: vec![plain_reference_asset_id],
..PlatformArtAssetGenerationOptions::default()
},
)
.await
.expect_err("a denied asset.upload must fail closed");
assert!(
error.contains("项目权限策略拒绝执行:asset.upload"),
"{error}"
);
while let Ok(request) = request_receiver.recv_timeout(Duration::from_millis(200)) {
assert!(!is_reference_upload_ticket_request(&request), "{request}");
assert!(!is_image_generation_request(&request), "{request}");
}
fs::remove_dir_all(root).ok();
fs::remove_dir_all(config_dir).ok();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn direct_image_generation_notifies_after_manifest_commit() {
let root = unique_project_path();
let config_dir = unique_project_path();
let canvas_base_url = spawn_mock_external_canvas_generation_api_server(None);
let _session = crate::platform_session::install_test_platform_session(
"direct-image-refresh-user",
"editor-runtime-key",
&canvas_base_url,
);
fs::create_dir_all(&config_dir).expect("create config directory");
fs::write(
config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME),
serde_json::json!({
"editorApi": { "baseUrl": canvas_base_url, "apiKey": "editor-runtime-key" }
})
.to_string(),
)
.expect("write config");
let _config = use_test_runtime_config_dir(config_dir.clone());
init_local_game_project_at(&root, "direct-image-refresh", "生成图片刷新测试")
.expect("init project");
write_project_permission_policy_at(
&root,
ProjectPermissionPolicy {
denied_commands: Vec::new(),
confirm_commands: Vec::new(),
agent_policies: BTreeMap::new(),
},
)
.expect("allow generation");
let listener =
TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)).expect("bind event receiver");
let sink = acquire_game_creator_manifest_invalidation_event_sink_test_guard();
sink.configure(listener.local_addr().unwrap().port(), &"d".repeat(64))
.expect("configure event receiver");
let bridge = start_direct_tool_bridge(&root, false)
.await
.expect("start tool bridge");
let client = reqwest::Client::new();
let result: Value = client.post(bridge.url()).json(&serde_json::json!({
"tool": "agc_generate_image",
"arguments": { "prompt": "像素月光主角", "kind": "icon-spec", "outputPath": "assets/art-spec.png" }
})).send().await.expect("generate through bridge").json().await.expect("read tool result");
assert_eq!(result["isError"], false, "{result}");
let manifest = read_existing_manifest_for_project(&root).expect("read committed manifest");
assert!(manifest
.assets
.iter()
.any(|asset| asset.local_path == "assets/art-spec.png"));
assert!(root.join("assets/art-spec.png").is_file());
let payload = read_manifest_invalidation_relay_payload_with_deadline(&listener)
.expect("generation must notify the client");
let envelope: GameCreatorManifestInvalidationRelayEnvelope =
serde_json::from_slice(&payload).expect("event envelope");
assert_eq!(
envelope.event.project_path,
fs::canonicalize(&root).unwrap().to_string_lossy()
);
assert_eq!(envelope.event.agent_id, "direct-codex-art");
let rejected: Value = client
.post(bridge.url())
.json(&serde_json::json!({
"tool": "agc_generate_image", "arguments": { "prompt": "", "kind": "icon-spec" }
}))
.send()
.await
.expect("send rejected request")
.json()
.await
.expect("read rejected result");
assert_eq!(rejected["isError"], true);
assert_eq!(
read_manifest_invalidation_relay_payload_with_deadline(&listener)
.expect_err("rejected generation must not emit a commit")
.kind(),
io::ErrorKind::TimedOut
);
drop(bridge);
fs::remove_dir_all(root).ok();
fs::remove_dir_all(config_dir).ok();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn platform_art_external_request_does_not_hold_project_lock_or_overwrite_manifest() {
let root = unique_project_path();
let config_dir = unique_project_path();
let (request_sender, request_receiver) = mpsc::channel();
let (generation_release_sender, generation_release_receiver) = mpsc::channel();
let canvas_base_url = spawn_mock_external_canvas_generation_api_server_with_gate(
request_sender,
generation_release_receiver,
);
let _platform_session = crate::platform_session::install_test_platform_session(
"project-lock-art-user",
"editor-runtime-key",
&canvas_base_url,
);
init_local_game_project_at(&root, "project-canvas-request-lock", "月光厨房")
.expect("project init");
register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec");
bind_canvas_visual_asset_fixture_to_current_editor(
&root,
"assets/art-spec.png",
"canvas-project-1",
);
write_project_permission_policy_at(
&root,
ProjectPermissionPolicy {
denied_commands: Vec::new(),
confirm_commands: Vec::new(),
agent_policies: BTreeMap::new(),
},
)
.expect("allow asset generation");
fs::create_dir_all(&config_dir).expect("runtime config dir");
fs::write(
config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME),
serde_json::json!({
"editorApi": {
"baseUrl": canvas_base_url,
"apiKey": "editor-runtime-key"
}
})
.to_string(),
)
.expect("write runtime config");
let _config_guard = use_test_runtime_config_dir(config_dir.clone());
let request_root = root.clone();
let request = std::thread::spawn(move || {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("build canvas request runtime")
.block_on(request_platform_art_asset_with_options_for_test(
&request_root,
"生成首版花园素材",
&PlatformArtAssetGenerationOptions {
output_path: Some("assets/art-spritesheet.png".to_string()),
asset_kind: "art-spritesheet".to_string(),
asset_label: "游戏首版核心美术素材".to_string(),
slice_mode: Some("connected-components".to_string()),
..PlatformArtAssetGenerationOptions::default()
},
))
});
let mut saw_generation_request = false;
for _ in 0..5 {
let captured = request_receiver
.recv_timeout(Duration::from_secs(15))
.expect("canvas request before generation response");
if captured.starts_with("POST /api/editor/icon-spritesheets/generations ") {
saw_generation_request = true;
break;
}
}
assert!(saw_generation_request);
let project_lock = acquire_project_write_lock(&root, "test.canvas.external-request")
.expect("external canvas request must not hold project lock");
create_manifest_task_at(
&root,
"concurrent-manifest-task",
"网络请求期间写入的并行任务",
GameCreationAppAgentGroup::Code,
"Code",
GameCreationAppTaskStatus::Pending,
Vec::new(),
Vec::new(),
Vec::new(),
)
.expect("write manifest while external request waits");
drop(project_lock);
generation_release_sender
.send(())
.expect("release mock generation response");
request
.join()
.expect("join external request")
.expect("prepare external canvas asset");
let manifest = read_existing_manifest_for_project(&root).expect("read manifest after request");
assert!(manifest
.tasks
.iter()
.any(|task| task.id == "concurrent-manifest-task"));
assert!(!root.join("assets/art-spritesheet.png").exists());
fs::remove_dir_all(root).ok();
fs::remove_dir_all(config_dir).ok();
}
#[tokio::test]
async fn generate_local_game_draft_fails_after_max_passes_without_final_artifacts() {
let root = unique_project_path();
init_existing_html_project_at(&root, "project-1", "HTML fixture").expect("project init");
let mut invalid_draft = fake_llm_game_draft();
invalid_draft.game_html = r#"<!doctype html>
<html lang="zh-CN">
<body>
<canvas id="game" width="320" height="180"></canvas>
<p>目标:点亮厨房。胜利 / 失败后按 R 重开。</p>
<script>
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
function frame() { requestAnimationFrame(frame); }
requestAnimationFrame(frame);
</script>
</body>
</html>"#
.to_string();
let invalid_draft_json = serde_json::to_string(&invalid_draft).expect("invalid draft json");
let mut responses = vec!["## 核心循环\n\n点亮厨房,但必须通过 Evaluator。".to_string()];
responses.push(invalid_draft_json.clone());
responses.push(invalid_draft_json.clone());
responses.push(invalid_draft_json);
let base_url = spawn_mock_llm_server_responses(responses);
let _config_guard = write_test_local_config(format!(
r#"{{
"llm": {{
"apiKey": "test-key",
"baseUrl": {base_url:?},
"model": "mock-game-model",
"apiKind": "openai_responses"
}}
}}"#
));
let error = generate_local_game_draft_at(&root, "做一个会失败三轮的厨房游戏", None)
.await
.expect_err("max-pass failure should bubble out");
assert!(error.contains("已重试"));
assert!(error.contains(&GAME_CREATOR_AGENT_LOOP_MAX_PASSES.to_string()));
assert!(!root.join("memory/session.md").exists());
assert!(!root.join("memory/project.md").exists());
assert!(!root.join("game/game_design.md").exists());
assert!(!root.join("game/balance.json").exists());
assert!(!root.join("assets/manifest.art.json").exists());
assert!(!root.join("assets/manifest.audio.json").exists());
assert!(!root.join("exports/README.md").exists());
let game_html = fs::read_to_string(root.join("game/index.html")).expect("default game html");
assert!(!game_html.contains("MOCK_UNIQUE_MECHANIC:moon-kitchen-reflect"));
assert!(!game_html.contains("点亮厨房"));
let trace: Value =
serde_json::from_str(&fs::read_to_string(root.join(".agent/run.latest.json")).unwrap())
.expect("run trace json");
assert_eq!(trace["status"], "failed");
assert_eq!(
trace["passes"],
serde_json::json!(GAME_CREATOR_AGENT_LOOP_MAX_PASSES)
);
assert_eq!(trace["stopReason"], "max-passes-exhausted");
assert_eq!(trace["nextStep"], "inspect-error");
assert!(trace["error"]
.as_str()
.is_some_and(|message| message.contains("Evaluator")));
assert!(
trace["passPlans"].as_array().unwrap().len()
== usize::from(GAME_CREATOR_AGENT_LOOP_MAX_PASSES)
);
let artifact_paths = trace["artifacts"]
.as_array()
.unwrap()
.iter()
.filter_map(|artifact| artifact["path"].as_str())
.map(|path| path.replace('\\', "/"))
.collect::<Vec<_>>();
assert!(
artifact_paths.iter().any(|path| path.as_str()
== format!(".agent/passes/pass-{GAME_CREATOR_AGENT_LOOP_MAX_PASSES}/game.html")),
"unexpected max-pass artifact paths: {artifact_paths:?}"
);
assert!(!trace["steps"]
.as_array()
.unwrap()
.iter()
.any(|step| step["agent"] == "ArtifactWriter"));
assert!(!trace["steps"]
.as_array()
.unwrap()
.iter()
.any(|step| step["agent"] == "Playtest"));
let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db");
let records = agent_db
.lines()
.map(|line| serde_json::from_str::<Value>(line).expect("agent db record"))
.collect::<Vec<_>>();
assert!(records
.iter()
.any(|record| record["recordType"] == "project.init"));
assert!(!records
.iter()
.any(|record| record["recordType"] == "game.generate_draft"));
fs::remove_dir_all(root).ok();
}
#[test]
fn canvas_sync_suggestion_is_media_type_aware() {
let art_group = GAME_CREATOR_AGENT_GROUP_DEFINITIONS
.iter()
.find(|definition| definition.id == "art")
.copied()
.expect("art group");
let audio_group = GAME_CREATOR_AGENT_GROUP_DEFINITIONS
.iter()
.find(|definition| definition.id == "audio")
.copied()
.expect("audio group");
let art_role = ART_AGENT_ROLES
.iter()
.find(|role| role.id == "asset")
.copied()
.expect("art asset role");
let audio_role = AUDIO_AGENT_ROLES
.iter()
.find(|role| role.id == "sfx")
.copied()
.expect("audio sfx role");
let art_brief = AgentRoleBrief {
group_definition: art_group,
role_definition: art_role,
markdown: String::new(),
relative_path: ".agent/passes/pass-1/groups/art/asset.md".to_string(),
memory_relative_path: agent_role_memory_relative_path(art_group, art_role),
status: "completed".to_string(),
tool_id: art_role.tool_id.to_string(),
summary: String::new(),
};
let audio_brief = AgentRoleBrief {
group_definition: audio_group,
role_definition: audio_role,
markdown: String::new(),
relative_path: ".agent/passes/pass-1/groups/audio/sfx.md".to_string(),
memory_relative_path: agent_role_memory_relative_path(audio_group, audio_role),
status: "completed".to_string(),
tool_id: audio_role.tool_id.to_string(),
summary: String::new(),
};
let input_paths = vec![".agent/manifest.json".to_string()];
let image_canvas_assets = vec!["image/png".to_string()];
let audio_canvas_assets = vec!["audio/wav".to_string()];
assert!(suggested_canvas_tool_call(&art_brief, &input_paths, &image_canvas_assets).is_none());
assert!(suggested_canvas_tool_call(&audio_brief, &input_paths, &image_canvas_assets).is_some());
assert!(suggested_canvas_tool_call(&audio_brief, &input_paths, &audio_canvas_assets).is_none());
assert!(suggested_canvas_tool_call(&art_brief, &input_paths, &audio_canvas_assets).is_some());
}
#[test]
fn init_local_game_project_creates_manifest_and_dirs() {
let root = unique_project_path();
let result =
init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init");
assert_eq!(result.project_path, root.to_string_lossy().into_owned());
assert!(root.join("game").is_dir());
assert!(root.join("assets").is_dir());
assert!(root.join("memory").is_dir());
assert!(root.join("exports").is_dir());
assert!(root.join(".agent/logs").is_dir());
assert!(root.join(".agent/agent.db").is_file());
assert!(root.join("game/index.html").is_file());
let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db");
let first_record: Value =
serde_json::from_str(agent_db.lines().next().unwrap()).expect("agent db record");
assert_eq!(
first_record["schemaVersion"],
GAME_CREATOR_AGENT_DB_SCHEMA_VERSION
);
assert_eq!(first_record["recordType"], "project.init");
assert_eq!(first_record["projectId"], "project-1");
let manifest: Value =
serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap())
.expect("manifest json");
assert_eq!(manifest["projectId"], "project-1");
assert_eq!(manifest["name"], "像素动作原型");
assert_eq!(manifest["assets"].as_array().unwrap().len(), 0);
assert_eq!(manifest["tasks"].as_array().unwrap().len(), 16);
assert_eq!(manifest["tasks"][0]["id"], "design-director");
fs::remove_dir_all(root).ok();
}
#[test]
fn project_picker_uses_the_closest_existing_parent_for_a_suggested_new_path() {
let documents_root = unique_project_path();
fs::create_dir_all(&documents_root).expect("create documents fixture");
let suggested_project_path = documents_root
.join("Genarrative GameAgent")
.join("gameagent-new");
assert_eq!(
closest_existing_project_picker_directory(&suggested_project_path),
Some(documents_root.clone())
);
fs::remove_dir_all(documents_root).ok();
}
#[test]
fn automatic_local_game_project_allocates_unique_initialized_workspaces() {
let projects_root = unique_project_path();
let first = create_automatic_local_game_project_at(&projects_root, None, false)
.expect("create first automatic workspace");
let second =
create_automatic_local_game_project_at(&projects_root, Some("自定义项目名"), false)
.expect("create second automatic workspace");
let planning = create_automatic_local_game_project_at(&projects_root, None, true)
.expect("create planning workspace");
assert_ne!(first.project_path, second.project_path);
assert!(first.manifest.name.starts_with("GameAgent 项目 "));
assert_eq!(second.manifest.name, "自定义项目名");
let planning_directory = Path::new(&planning.project_path)
.file_name()
.and_then(|name| name.to_str())
.expect("planning directory name");
let short_id = planning_directory.strip_prefix("gameagent-").unwrap();
assert_eq!(short_id.len(), 8);
assert_eq!(planning.manifest.name, format!("策划项目 {short_id}"));
assert_ne!(planning.project_path, first.project_path);
assert_ne!(planning.project_path, second.project_path);
for result in [first, second, planning] {
let root = PathBuf::from(&result.project_path);
assert_eq!(root.parent(), Some(projects_root.as_path()));
assert!(root
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with("gameagent-")));
assert!(root.join(".agent/manifest.json").is_file());
assert!(root.join("game/index.html").is_file());
}
fs::remove_dir_all(projects_root).ok();
}
#[test]
fn requested_project_creation_root_accepts_only_an_absolute_regular_directory() {
let root = unique_project_path();
fs::create_dir_all(&root).expect("create creation-root fixture");
let not_a_directory = root.join("not-a-directory.txt");
fs::write(&not_a_directory, b"x").expect("write file fixture");
assert_eq!(
validate_requested_game_project_creation_root(" ").expect_err("blank root is rejected"),
"项目创建目录必须是绝对路径"
);
assert_eq!(
validate_requested_game_project_creation_root("relative/projects")
.expect_err("relative root is rejected"),
"项目创建目录必须是绝对路径"
);
assert_eq!(
validate_requested_game_project_creation_root(&format!("{}\\pro\nject", root.display()))
.expect_err("control character is rejected"),
"项目创建目录不能包含控制字符"
);
assert_eq!(
validate_requested_game_project_creation_root(&not_a_directory.to_string_lossy())
.expect_err("file root is rejected"),
"项目创建目录必须是普通文件夹"
);
assert_eq!(
validate_requested_game_project_creation_root(&format!(" {}\n", root.display()))
.expect("trimmed directory root is accepted"),
root
);
assert!(
validate_requested_game_project_creation_root(&root.join("missing").to_string_lossy())
.is_err(),
"a not-yet-existing creation root must fail instead of being created silently"
);
fs::remove_dir_all(root).ok();
}
#[test]
fn automatic_local_game_project_creates_inside_the_requested_creation_root() {
let projects_root = unique_project_path();
fs::create_dir_all(&projects_root).expect("create creation-root fixture");
let requested = validate_requested_game_project_creation_root(&projects_root.to_string_lossy())
.expect("valid creation root");
let result = create_automatic_local_game_project_at(&requested, None, false)
.expect("create workspace in requested root");
let project_root = PathBuf::from(&result.project_path);
assert_eq!(project_root.parent(), Some(projects_root.as_path()));
assert!(project_root.join(".agent/manifest.json").is_file());
fs::remove_dir_all(projects_root).ok();
}
#[test]
fn automatic_local_game_project_accepts_only_a_safe_custom_name() {
let projects_root = unique_project_path();
let result =
create_automatic_local_game_project_at(&projects_root, Some(" 星轨夜航 "), false)
.expect("create named automatic workspace");
assert_eq!(result.manifest.name, "星轨夜航");
for name in [
" \n\t",
"名称\n包含控制字符",
&"长".repeat(GAME_CREATION_PROJECT_NAME_MAX_CHARS + 1),
] {
assert!(
create_automatic_local_game_project_at(&projects_root, Some(name), false).is_err(),
"unsafe name must fail: {name:?}"
);
}
fs::remove_dir_all(projects_root).ok();
}
#[test]
fn rename_local_game_project_updates_only_the_manifest_name() {
let root = unique_project_path();
let before =
init_local_game_project_at(&root, "rename-project", "旧项目名").expect("project init");
let revision = read_game_creator_agent_runtime_project_revision(&root).expect("read revision");
let task_count = before.manifest.tasks.len();
let asset_count = before.manifest.assets.len();
let renamed = rename_local_game_project(
root.to_string_lossy().into_owned(),
" 新项目名 ".to_string(),
)
.expect("rename project");
assert_eq!(renamed.manifest.name, "新项目名");
assert_eq!(renamed.manifest.project_id, before.manifest.project_id);
assert_eq!(renamed.manifest.tasks.len(), task_count);
assert_eq!(renamed.manifest.assets.len(), asset_count);
assert_eq!(renamed.revision, revision.revision);
assert_eq!(
renamed.manifest,
read_manifest_for_project(&root).expect("reread manifest")
);
fs::remove_dir_all(root).ok();
}
#[test]
fn rename_local_game_project_rejects_unsafe_names_and_uninitialized_projects() {
let root = unique_project_path();
init_local_game_project_at(&root, "rename-invalid", "原项目名").expect("project init");
for name in [
" \n\t".to_string(),
"名称\n包含控制字符".to_string(),
"长".repeat(GAME_CREATION_PROJECT_NAME_MAX_CHARS + 1),
] {
let error = rename_local_game_project(root.to_string_lossy().into_owned(), name)
.expect_err("unsafe rename must fail");
assert!(!error.is_empty());
}
assert_eq!(
read_manifest_for_project(&root)
.expect("reread manifest")
.name,
"原项目名"
);
let empty_root = unique_project_path();
fs::create_dir_all(&empty_root).expect("create empty root");
assert!(rename_local_game_project(
empty_root.to_string_lossy().into_owned(),
"未初始化".to_string(),
)
.is_err());
fs::remove_dir_all(root).ok();
fs::remove_dir_all(empty_root).ok();
}
#[test]
fn automatic_project_name_suggestions_are_normalized_fail_closed() {
assert_eq!(
normalize_suggested_project_name(" 星轨夜航 "),
Some("星轨夜航".to_string())
);
assert_eq!(
normalize_suggested_project_name("星河"),
Some("星河".to_string())
);
assert_eq!(
normalize_suggested_project_name("一二三四五六七八九十甲乙丙丁戊己"),
Some("一二三四五六七八九十甲乙丙丁戊己".to_string())
);
assert_eq!(
normalize_suggested_project_name("星轨夜航\n解释"),
None,
"multi-line suggestions must fail closed"
);
for value in [
"",
" \t\r\n",
"名",
"项目名:名",
"名称\n控制字符",
"**星轨夜航**",
"https://example.com",
"C:\\projects\\game",
"Bearer sk-secret",
"“星轨夜航”",
"《星轨夜航》",
"项目名称:星轨夜航",
"星轨夜航2",
"星轨Game",
&"长".repeat(17),
&"长".repeat(81),
] {
assert!(
normalize_suggested_project_name(value).is_none(),
"invalid suggestion: {value:?}"
);
}
}
#[test]
fn automatic_project_name_prompt_is_bounded_and_uses_user_requirement_only() {
let system_prompt = include_str!("../../prompts/automatic-project-name.md");
assert!(system_prompt.contains("长度为 2 到 16 个字符"));
assert!(system_prompt.contains("不要使用知名作品名、路径、URL、凭据、Markdown、引号或解释"));
let prompt = build_automatic_project_name_prompt("做一个在月球邮局送信的解谜游戏")
.expect("build prompt");
assert_eq!(prompt, "做一个在月球邮局送信的解谜游戏");
assert_eq!(
build_automatic_project_name_prompt(&"长".repeat(8_001)).expect("bound prompt"),
"长".repeat(8_000)
);
assert!(build_automatic_project_name_prompt("").is_err());
}
#[cfg(unix)]
#[test]
fn automatic_local_game_project_rejects_symlinked_projects_root() {
use std::os::unix::fs::symlink;
let container = unique_project_path();
let target = container.join("target");
let projects_root = container.join("projects");
fs::create_dir_all(&target).expect("create automatic workspace target");
symlink(&target, &projects_root).expect("symlink automatic workspace root");
let error = create_automatic_local_game_project_at(&projects_root, None, false)
.expect_err("symlinked automatic workspace root must fail");
assert!(error.contains("不能包含符号链接"));
assert!(fs::read_dir(&target).expect("read target").next().is_none());
fs::remove_dir_all(container).ok();
}
#[test]
fn init_local_game_project_requires_absolute_path() {
let error = init_local_game_project_at(Path::new("relative-game"), "project-1", "demo")
.expect_err("relative path should fail");
assert!(error.contains("绝对路径"));
}
#[test]
fn project_directory_commands_reject_control_characters() {
let project_path = format!("{}\nnext", unique_project_path().display());
assert!(is_local_project_directory_non_empty(project_path.clone())
.expect_err("non-empty check should reject control characters")
.contains("控制字符"));
assert!(inspect_local_project_directory_sync(project_path.clone())
.expect_err("directory inspect should reject control characters")
.contains("控制字符"));
assert!(
init_local_game_project_at(Path::new(&project_path), "project-1", "demo")
.expect_err("project init should reject control characters")
.contains("控制字符")
);
}
#[test]
fn project_directory_non_empty_check_reports_existing_content() {
let root = unique_project_path();
assert!(
!is_local_project_directory_non_empty(root.to_string_lossy().to_string())
.expect("missing dir should be empty")
);
fs::create_dir_all(&root).expect("project dir");
assert!(
!is_local_project_directory_non_empty(root.to_string_lossy().to_string())
.expect("empty dir should be empty")
);
fs::write(root.join("old.txt"), "existing").expect("existing file");
assert!(
is_local_project_directory_non_empty(root.to_string_lossy().to_string())
.expect("non-empty dir should be reported")
);
fs::remove_dir_all(root).ok();
}
#[test]
fn project_directory_status_distinguishes_missing_file_and_dir() {
let root = unique_project_path();
let missing = inspect_local_project_directory_sync(root.to_string_lossy().to_string())
.expect("missing status");
assert_eq!(
missing,
LocalProjectDirectoryStatus {
project_path: root.to_string_lossy().into_owned(),
exists: false,
is_directory: false,
is_game_creator_project: false,
is_godot_project: false,
godot_project_root: None,
is_cocos_project: false,
cocos_project_root: None,
project_name: None,
modified_at: None,
manifest_error: None,
recent_run_status: None,
recent_run_stop_reason: None,
}
);
fs::write(&root, "not a dir").expect("file");
let file_status = inspect_local_project_directory_sync(root.to_string_lossy().to_string())
.expect("file status");
assert!(file_status.exists);
assert!(!file_status.is_directory);
assert!(!file_status.is_game_creator_project);
assert!(!file_status.is_godot_project);
assert_eq!(file_status.godot_project_root, None);
assert_eq!(file_status.project_name, None);
assert!(file_status.modified_at.is_some());
assert_eq!(file_status.manifest_error, None);
assert_eq!(file_status.recent_run_status, None);
fs::remove_file(&root).expect("remove file");
fs::create_dir_all(&root).expect("dir");
let dir_status = inspect_local_project_directory_sync(root.to_string_lossy().to_string())
.expect("dir status");
assert!(dir_status.exists);
assert!(dir_status.is_directory);
assert!(!dir_status.is_game_creator_project);
assert!(!dir_status.is_godot_project);
assert_eq!(dir_status.godot_project_root, None);
assert_eq!(dir_status.project_name, None);
assert_eq!(dir_status.manifest_error, None);
assert_eq!(dir_status.recent_run_status, None);
fs::create_dir_all(root.join(".agent")).expect("agent dir");
fs::write(root.join(".agent/manifest.json"), "{broken").expect("broken manifest");
let broken_manifest_status =
inspect_local_project_directory_sync(root.to_string_lossy().to_string())
.expect("broken manifest status");
assert!(broken_manifest_status.exists);
assert!(broken_manifest_status.is_directory);
assert!(!broken_manifest_status.is_game_creator_project);
assert!(broken_manifest_status
.manifest_error
.as_deref()
.is_some_and(|error| error.contains("解析 manifest 失败")));
fs::remove_file(root.join(".agent/manifest.json")).expect("remove broken manifest");
init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init");
fs::write(
root.join(".agent/run.latest.json"),
serde_json::json!({
"schemaVersion": "game-creator-agent-run.v1",
"runId": "run-1",
"commandId": "game.generate_draft",
"status": "failed",
"passes": 2,
"goal": "demo",
"coordination": "demo",
"steps": [],
"artifacts": [],
"nextStep": "fix",
"error": null,
"updatedAt": 1,
"stopReason": "max-passes-exhausted"
})
.to_string(),
)
.expect("run trace");
let project_status = inspect_local_project_directory_sync(root.to_string_lossy().to_string())
.expect("project status");
assert!(project_status.exists);
assert!(project_status.is_directory);
assert!(project_status.is_game_creator_project);
assert_eq!(
project_status.project_name,
Some("像素动作原型".to_string())
);
assert_eq!(project_status.manifest_error, None);
assert_eq!(project_status.recent_run_status, Some("failed".to_string()));
assert_eq!(
project_status.recent_run_stop_reason,
Some("max-passes-exhausted".to_string())
);
fs::remove_dir_all(root).ok();
}
#[test]
fn project_directory_status_detects_cocos_creator_project() {
let root = tempfile::tempdir().expect("cocos project root");
fs::create_dir(root.path().join("assets")).expect("assets");
fs::write(
root.path().join("package.json"),
r#"{"name":"cocos-project","creator":{"version":"3.8.8"},"uuid":"fixture"}"#,
)
.expect("package.json");
let detected = discover_local_cocos_project_root(root.path()).expect("cocos discovery");
assert_eq!(detected.as_deref(), Some("."));
}
#[test]
fn project_directory_status_reports_workspace_relative_godot_root() {
let root = unique_project_path();
let godot_root = root.join("godot-game");
fs::create_dir_all(&godot_root).expect("create nested Godot project");
fs::write(godot_root.join("project.godot"), "[application]\n")
.expect("write nested project.godot");
let status = inspect_local_project_directory_sync(root.to_string_lossy().to_string())
.expect("inspect nested Godot workspace");
assert!(status.exists);
assert!(status.is_directory);
assert!(status.is_godot_project);
assert_eq!(status.godot_project_root.as_deref(), Some("godot-game"));
fs::remove_dir_all(root).ok();
}
#[test]
fn project_directory_status_rejects_ambiguous_direct_child_godot_projects() {
let root = unique_project_path();
for child in ["alpha", "beta"] {
let godot_root = root.join(child);
fs::create_dir_all(&godot_root).expect("create nested Godot project");
fs::write(godot_root.join("project.godot"), "[application]\n")
.expect("write nested project.godot");
}
let error = inspect_local_project_directory_sync(root.to_string_lossy().to_string())
.expect_err("ambiguous Godot workspace must fail inspection");
assert!(error.contains("多个 Godot 项目"), "{error}");
fs::remove_dir_all(root).ok();
}
#[test]
fn godot_import_command_rejects_ambiguity_before_creating_the_project_lock() {
let root = unique_project_path();
for child in ["alpha", "beta"] {
let godot_root = root.join(child);
fs::create_dir_all(&godot_root).expect("create nested Godot project");
fs::write(godot_root.join("project.godot"), "[application]\n")
.expect("write nested project.godot");
}
let error = import_local_godot_project(
root.to_string_lossy().into_owned(),
"ambiguous".to_string(),
"Ambiguous".to_string(),
)
.expect_err("ambiguous Godot workspace must fail before locking");
assert!(error.contains("多个 Godot 项目"), "{error}");
assert!(!root.join(PROJECT_WRITE_LOCK_PATH).exists());
assert!(!root.join(".agent").exists());
fs::remove_dir_all(root).ok();
}
#[test]
fn local_project_directory_open_path_requires_existing_absolute_directory() {
let root = unique_project_path();
assert!(validated_local_project_directory_path("relative-game").is_err());
assert!(validated_local_project_directory_path(&root.to_string_lossy()).is_err());
fs::write(&root, "not a dir").expect("file");
assert!(validated_local_project_directory_path(&root.to_string_lossy()).is_err());
fs::remove_file(&root).expect("remove file");
fs::create_dir_all(&root).expect("dir");
assert_eq!(
validated_local_project_directory_path(&root.to_string_lossy()).expect("valid dir"),
root
);
fs::remove_dir_all(root).ok();
}
#[test]
fn generate_local_game_draft_requires_prompt() {
let root = unique_project_path();
let error = write_local_game_draft_at(&root, " ", &fake_llm_game_draft())
.expect_err("empty prompt should fail");
assert!(error.contains("创作想法"));
}
#[test]
fn upload_local_asset_writes_file_and_manifest_entry() {
let root = unique_project_path();
let result = upload_local_asset_at(&root, "../角色.png", "image/png", b"fake-png")
.expect("asset upload");
assert_eq!(fs::read(&result.absolute_path).unwrap(), b"fake-png");
assert!(result.local_path.starts_with("assets/uploads/upload-"));
assert!(result.local_path.ends_with("_角色.png"));
let manifest: Value =
serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap())
.expect("manifest json");
assert_eq!(manifest["assets"][0]["id"], result.id);
assert_eq!(manifest["assets"][0]["mediaType"], "image/png");
assert_eq!(manifest["assets"][0]["source"]["kind"], "uploaded");
// 落盘 `kind` 只能由内容证据推导:图片是 `image`(→ `unclassified` → 「待归类」)。
// 不许写 `uploaded`(来源词当类型),更不许把 `ui` 当图片默认值——那会把任意上传图片
// 钉死在「UI 交互」栏,且落盘 `category` 非 `unclassified` 后读时自愈救不回来。
assert_eq!(manifest["assets"][0]["kind"], "image");
assert_eq!(manifest["assets"][0]["category"], "unclassified");
assert_eq!(manifest["assets"][0]["localPath"], result.local_path);
let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db");
assert!(agent_db.lines().any(|line| {
let record: Value = serde_json::from_str(line).expect("agent db record");
record["recordType"] == "asset.register"
&& record["assetId"] == result.id
&& record["localPath"] == result.local_path
&& record["source"]["kind"] == "uploaded"
}));
upload_local_asset_at(&root, "sound.wav", "audio/wav", b"fake-wav").expect("asset upload");
let manifest: Value =
serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap())
.expect("manifest json");
assert_eq!(manifest["assets"].as_array().unwrap().len(), 2);
// 同一上传命令的音频素材必须归「音频」,不能被图片口径顶掉。
assert_eq!(manifest["assets"][1]["kind"], "audio");
assert_eq!(manifest["assets"][1]["category"], "audio");
assert!(
!manifest["assets"]
.as_array()
.unwrap()
.iter()
.any(|asset| asset["kind"] == "uploaded"
|| asset["kind"] == "ui"
|| asset["category"] == "ui-interaction"),
"上传登记不得产出 `uploaded` / `ui` / `ui-interaction`:{manifest}"
);
fs::remove_dir_all(root).ok();
}
#[test]
fn upload_local_asset_rejects_empty_file() {
let root = unique_project_path();
let error =
upload_local_asset_at(&root, "empty.txt", "text/plain", b"").expect_err("empty file");
assert!(error.contains("不能为空"));
}
#[test]
fn register_local_asset_records_existing_asset_with_canvas_source() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init");
write_local_project_file_at(&root, "assets/hero.png", "fake-image").expect("asset file");
let result = register_local_asset_at(
&root,
"assets/hero.png",
"character",
"image/png",
"canvas",
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Canvas,
canvas_project_id: Some("canvas-project-1".to_string()),
resource_id: Some("resource-1".to_string()),
asset_object_id: Some("asset-object-1".to_string()),
task_id: Some("task-1".to_string()),
prompt: Some("像素主角".to_string()),
model: Some("image-model".to_string()),
generation_route: None,
generation_kind: None,
reference_resource_ids: Vec::new(),
},
)
.expect("asset register");
let manifest: Value =
serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap())
.expect("manifest json");
assert_eq!(manifest["assets"][0]["id"], result.id);
assert_eq!(manifest["assets"][0]["kind"], "character");
assert_eq!(manifest["assets"][0]["category"], "character");
assert_eq!(manifest["assets"][0]["localPath"], "assets/hero.png");
assert_eq!(manifest["assets"][0]["source"]["kind"], "canvas");
assert_eq!(
manifest["assets"][0]["source"]["canvasProjectId"],
"canvas-project-1"
);
assert_eq!(manifest["assets"][0]["source"]["prompt"], "像素主角");
let asset_record = fs::read_to_string(root.join(".agent/agent.db"))
.expect("agent db")
.lines()
.map(|line| serde_json::from_str::<Value>(line).expect("agent db record"))
.find(|record| record["recordType"] == "asset.register")
.expect("asset register record");
assert!(asset_record["source"].get("prompt").is_none());
assert_eq!(asset_record["source"]["model"], "image-model");
let updated = register_local_asset_at(
&root,
"assets/hero.png",
"ui",
"image/png",
"generated",
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Generated,
canvas_project_id: None,
resource_id: None,
asset_object_id: None,
task_id: None,
prompt: None,
model: None,
generation_route: None,
generation_kind: None,
reference_resource_ids: Vec::new(),
},
)
.expect("asset update");
assert_eq!(updated.id, result.id);
let manifest: Value =
serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap())
.expect("manifest json");
assert_eq!(manifest["assets"].as_array().unwrap().len(), 1);
assert_eq!(manifest["assets"][0]["kind"], "ui");
// kind 变了必须重派生 category:旧分类 `character` 是非 unclassified 值,会被读侧
// 无条件信任、自愈也不会触发,资产就永远停在「角色与对象」。
assert_eq!(manifest["assets"][0]["category"], "ui-interaction");
assert_eq!(manifest["assets"][0]["source"]["kind"], "generated");
fs::remove_dir_all(root).ok();
}
/// kind 未变时,同路径重登记必须保留落盘分类。
///
/// 分类是权威值(`update_local_project_resource_classification` 由 Agent / 客户端写入),
/// 无条件按 kind 重派生会把显式设置静默改回派生值;而只在 kind 变化时重派生,才既能修掉
/// 「新 kind 配旧分类」的错位,又不丢显式分类。
#[test]
fn register_local_asset_keeps_explicit_category_when_kind_is_unchanged() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "同 kind 重登记保留分类").expect("project init");
write_local_project_file_at(&root, "ui/UI 设计 1.json", "{}").expect("ui asset file");
let source = || GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Generated,
canvas_project_id: None,
resource_id: None,
asset_object_id: None,
task_id: None,
prompt: None,
model: None,
generation_route: None,
generation_kind: None,
reference_resource_ids: Vec::new(),
};
let registered = register_local_asset_at(
&root,
"ui/UI 设计 1.json",
"UI",
"application/json",
"ui-workflow",
source(),
)
.expect("register UI asset");
let revision = read_game_creator_agent_runtime_project_revision(&root)
.expect("read revision")
.revision;
update_manifest_asset_classification_at(
&root,
"project-1",
revision,
&registered.id,
"audio",
vec!["界面".to_string()],
)
.expect("set explicit category");
register_local_asset_at(
&root,
"ui/UI 设计 1.json",
"UI",
"application/json",
"ui-workflow",
source(),
)
.expect("re-register UI asset");
let manifest: Value =
serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap())
.expect("manifest json");
assert_eq!(manifest["assets"][0]["kind"], "UI");
assert_eq!(manifest["assets"][0]["category"], "audio");
assert_eq!(manifest["assets"][0]["tags"], serde_json::json!(["界面"]));
fs::remove_dir_all(root).ok();
}
/// 写侧 → 分类的端到端口径:现役写入侧直接写出的非 canonical kind 字面量必须落进明确栏目。
///
/// 这里的字面量与写入侧逐字一致:UI 设计资产是
/// `ui_editor/resource_bridge.rs` / `workflow.rs` / `persistence.rs` 的
/// `register_local_asset_at(root, path, "UI", "application/json", ...)`,
/// 字体是 `commands.rs` 字体上传的 `register_local_asset_entry(root, path, "font", ...)`。
/// 只要别名表漏掉它们,真机资产就会永远停在「待归类」且读时自愈也救不回来。
#[test]
fn register_local_asset_derives_category_from_real_write_side_kinds() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "写侧 kind 分类口径").expect("project init");
let source = || GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Generated,
canvas_project_id: None,
resource_id: None,
asset_object_id: None,
task_id: None,
prompt: None,
model: None,
generation_route: None,
generation_kind: None,
reference_resource_ids: Vec::new(),
};
write_local_project_file_at(&root, "ui/UI 设计 1.json", "{}").expect("ui asset file");
write_local_project_file_at(&root, "assets/ui-font.ttf", "fake-font").expect("font file");
register_local_asset_at(
&root,
"ui/UI 设计 1.json",
"UI",
"application/json",
"ui-workflow",
source(),
)
.expect("register UI asset");
register_local_asset_at(
&root,
"assets/ui-font.ttf",
"font",
"font/ttf",
"font",
source(),
)
.expect("register font asset");
let manifest: Value =
serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap())
.expect("manifest json");
let mut categories = manifest["assets"]
.as_array()
.unwrap()
.iter()
.map(|asset| {
(
asset["kind"].as_str().unwrap().to_string(),
asset["category"].as_str().unwrap().to_string(),
)
})
.collect::<Vec<_>>();
categories.sort();
assert_eq!(
categories,
vec![
("UI".to_string(), "ui-interaction".to_string()),
("font".to_string(), "document".to_string()),
]
);
fs::remove_dir_all(root).ok();
}
#[test]
fn register_local_asset_rejects_missing_or_unsafe_path() {
let root = unique_project_path();
let source = GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Generated,
canvas_project_id: None,
resource_id: None,
asset_object_id: None,
task_id: None,
prompt: None,
model: None,
generation_route: None,
generation_kind: None,
reference_resource_ids: Vec::new(),
};
assert!(register_local_asset_at(
&root,
"../outside.png",
"asset",
"image/png",
"generated",
source.clone()
)
.is_err());
assert!(register_local_asset_at(
&root,
"assets/missing.png",
"asset",
"image/png",
"generated",
source
)
.is_err());
}
#[test]
fn import_canvas_asset_registers_canvas_source_metadata() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init");
write_local_project_file_at(&root, "assets/canvas-hero.png", "fake-image")
.expect("canvas asset file");
let result = import_canvas_asset_at(
&root,
"assets/canvas-hero.png",
"character",
"image/png",
"canvas-project-1",
Some("resource-1".to_string()),
Some("asset-object-1".to_string()),
Some("task-1".to_string()),
Some("像素主角".to_string()),
Some("image-model".to_string()),
)
.expect("canvas import");
let manifest: Value =
serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap())
.expect("manifest json");
assert_eq!(manifest["assets"][0]["id"], result.id);
assert_eq!(manifest["assets"][0]["source"]["kind"], "canvas");
assert_eq!(
manifest["assets"][0]["source"]["canvasProjectId"],
"canvas-project-1"
);
assert_eq!(manifest["assets"][0]["source"]["resourceId"], "resource-1");
assert_eq!(
manifest["assets"][0]["source"]["assetObjectId"],
"asset-object-1"
);
let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db");
assert!(agent_db.lines().any(|line| {
let record: Value = serde_json::from_str(line).expect("agent db record");
record["recordType"] == "asset.register"
&& record["assetId"] == result.id
&& record["source"]["kind"] == "canvas"
&& record["source"]["canvasProjectId"] == "canvas-project-1"
}));
fs::remove_dir_all(root).ok();
}
#[test]
fn import_canvas_asset_requires_traceable_canvas_ids() {
let root = unique_project_path();
write_local_project_file_at(&root, "assets/canvas-hero.png", "fake-image")
.expect("canvas asset file");
let missing_project = import_canvas_asset_at(
&root,
"assets/canvas-hero.png",
"character",
"image/png",
"",
Some("resource-1".to_string()),
None,
None,
None,
None,
)
.expect_err("missing canvas project should fail");
assert!(missing_project.contains("画板项目"));
let missing_asset = import_canvas_asset_at(
&root,
"assets/canvas-hero.png",
"character",
"image/png",
"canvas-project-1",
None,
None,
None,
None,
None,
)
.expect_err("missing canvas asset ids should fail");
assert!(missing_asset.contains("至少需要一个"));
fs::remove_dir_all(root).ok();
}
#[test]
fn import_canvas_export_zip_copies_files_and_registers_assets() {
let root = unique_project_path();
let zip_path = root.with_extension("zip");
write_test_canvas_export_zip(&zip_path);
let result = import_canvas_export_at(&root, &zip_path, "canvas-project-1")
.expect("canvas export import");
assert_eq!(result.imported_count, 2);
assert!(result.import_root.starts_with("assets/canvas-imports/"));
assert!(root.join(&result.metadata_path).is_file());
assert!(root
.join(&result.import_root)
.join("images/001-月光主角.png")
.is_file());
assert!(root
.join(&result.import_root)
.join("media/002-玻璃月光.mp3")
.is_file());
assert!(result
.assets
.iter()
.any(|asset| asset.local_path.ends_with("images/001-月光主角.png")));
assert!(result
.assets
.iter()
.any(|asset| asset.local_path.ends_with("media/002-玻璃月光.mp3")));
let manifest: Value =
serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap())
.expect("manifest json");
let assets = manifest["assets"].as_array().unwrap();
assert_eq!(assets.len(), 2);
assert!(assets.iter().any(|asset| {
asset["kind"] == "character"
&& asset["mediaType"] == "image/png"
&& asset["source"]["kind"] == "canvas"
&& asset["source"]["canvasProjectId"] == "canvas-project-1"
&& asset["source"]["assetObjectId"] == "asset-object-1"
&& asset["source"]["taskId"] == "42"
&& asset["source"]["model"] == "gpt-image-2"
}));
assert!(assets.iter().any(|asset| {
asset["kind"] == "audio"
&& asset["mediaType"] == "audio/mpeg"
&& asset["source"]["assetObjectId"]
.as_str()
.is_some_and(|value| value.starts_with("canvas-export:media/"))
}));
let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db");
let records = agent_db
.lines()
.map(|line| serde_json::from_str::<Value>(line).expect("agent db record"))
.collect::<Vec<_>>();
assert!(records.iter().any(|record| {
record["recordType"] == "canvas.export_import"
&& record["canvasProjectId"] == "canvas-project-1"
&& record["projectTitle"] == "月光画布"
&& record["importedCount"] == 2
}));
assert!(!agent_db.contains(zip_path.to_string_lossy().as_ref()));
fs::remove_file(zip_path).ok();
fs::remove_dir_all(root).ok();
}
#[tokio::test]
async fn sync_canvas_project_assets_downloads_external_resources() {
let root = unique_project_path();
let (request_sender, request_receiver) = mpsc::channel();
let base_url = spawn_mock_external_canvas_api_server_with_capture(3, Some(request_sender));
let _platform_session = crate::platform_session::install_test_platform_session(
"canvas-sync-user",
"test-editor-api-key",
&base_url,
);
let result = sync_canvas_project_assets_at(
&root,
"canvas-project-1",
Some(base_url),
Some("test-editor-api-key".to_string()),
)
.await
.expect("canvas project sync");
assert_eq!(result.canvas_project_id, "canvas-project-1");
assert_eq!(result.imported_count, 1);
assert!(result.import_root.starts_with("assets/canvas-sync/"));
assert_eq!(
fs::read(&result.assets[0].absolute_path).unwrap(),
valid_test_png_bytes()
);
let manifest: Value =
serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap())
.expect("manifest json");
assert_eq!(manifest["assets"][0]["kind"], "character");
assert_eq!(manifest["assets"][0]["mediaType"], "image/png");
assert_eq!(manifest["assets"][0]["source"]["kind"], "canvas");
assert_eq!(
manifest["assets"][0]["source"]["canvasProjectId"],
"canvas-project-1"
);
assert_eq!(manifest["assets"][0]["source"]["resourceId"], "resource-1");
assert_eq!(
manifest["assets"][0]["source"]["assetObjectId"],
"asset-object-1"
);
assert_eq!(manifest["assets"][0]["source"]["model"], "gpt-image-2");
let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db");
assert!(agent_db.contains("\"recordType\":\"canvas.project_sync\""));
assert!(!agent_db.contains("test-editor-api-key"));
let requests = request_receiver.try_iter().collect::<Vec<_>>();
assert_eq!(requests.len(), 3, "画板同步应产生项目、换签和媒体请求");
let main_site_requests = requests
.iter()
.filter(|request| !request.starts_with("GET /signed/"))
.collect::<Vec<_>>();
assert_eq!(main_site_requests.len(), 2);
assert!(main_site_requests.iter().all(|request| {
request
.to_ascii_lowercase()
.contains("x-genarrative-client: agc")
}));
let signed_download = requests
.iter()
.find(|request| request.starts_with("GET /signed/"))
.expect("signed media download request");
assert!(!signed_download
.to_ascii_lowercase()
.contains("x-genarrative-client:"));
fs::remove_dir_all(root).ok();
}
#[tokio::test]
async fn sync_canvas_project_assets_with_developer_key_uses_external_route_and_marker() {
let _platform_session = crate::platform_session::clear_test_platform_session();
let root = unique_project_path();
let (request_sender, request_receiver) = mpsc::channel();
let base_url = spawn_mock_external_canvas_api_server_with_capture(3, Some(request_sender));
let api_key = "tnr_sk_phase5_fixture";
let result = crate::assets::with_external_editor_api_credentials(
crate::assets::external_editor_api_credentials_for_test(
base_url.clone(),
api_key.to_string(),
),
sync_canvas_project_assets_at(
&root,
"canvas-project-1",
Some(base_url.clone()),
Some(api_key.to_string()),
),
)
.await
.expect("developer key canvas project sync");
assert_eq!(result.canvas_project_id, "canvas-project-1");
let requests = request_receiver.try_iter().collect::<Vec<_>>();
assert_eq!(requests.len(), 3);
let project_request = requests
.iter()
.find(|request| request.starts_with("GET /api/external/v1/editor/projects/"))
.expect("External v1 project request");
let read_url_request = requests
.iter()
.find(|request| request.starts_with("GET /api/external/v1/assets/read-url?"))
.expect("External v1 read URL request");
for request in [project_request, read_url_request] {
let normalized = request.to_ascii_lowercase();
assert!(normalized.contains("x-genarrative-client: agc"));
assert!(normalized.contains("authorization: bearer tnr_sk_phase5_fixture"));
}
let signed_download = requests
.iter()
.find(|request| request.starts_with("GET /signed/"))
.expect("signed media download request");
assert!(!signed_download
.to_ascii_lowercase()
.contains("x-genarrative-client:"));
fs::remove_dir_all(root).ok();
}
#[tokio::test]
async fn generate_platform_art_asset_downloads_and_registers_external_image() {
let root = unique_project_path();
let config_dir = unique_project_path();
let base_url = spawn_mock_external_canvas_generation_api_server(None);
let _platform_session = crate::platform_session::install_test_platform_session(
"platform-art-user",
"editor-unit-key",
&base_url,
);
init_local_game_project_at(&root, "project-platform-art", "未命名游戏原型")
.expect("project init");
fs::create_dir_all(&config_dir).expect("runtime config dir");
fs::write(
config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME),
serde_json::json!({
"editorApi": {
"baseUrl": base_url,
"apiKey": "editor-unit-key"
}
})
.to_string(),
)
.expect("write runtime config");
let _guard = use_test_runtime_config_dir(config_dir.clone());
let art_role = AgentRoleBrief {
group_definition: GAME_CREATOR_AGENT_GROUP_DEFINITIONS[2],
role_definition: ART_AGENT_ROLES[1],
markdown: "需要像素月光主角和厨房场景素材。".to_string(),
relative_path: ".agent/passes/pass-1/groups/art/asset.md".to_string(),
memory_relative_path: agent_role_memory_relative_path(
GAME_CREATOR_AGENT_GROUP_DEFINITIONS[2],
ART_AGENT_ROLES[1],
),
status: "completed".to_string(),
tool_id: "agent.role.brief.art.asset".to_string(),
summary: "规划首版美术资产".to_string(),
};
let briefs = vec![AgentGroupBrief {
definition: GAME_CREATOR_AGENT_GROUP_DEFINITIONS[2],
markdown: "美术组汇总。".to_string(),
relative_path: ".agent/passes/pass-1/groups/art.md".to_string(),
role_briefs: vec![art_role],
}];
let result = generate_platform_art_asset_at(&root, "月光弹幕厨房", &briefs)
.await
.expect("platform art generation");
assert!(result
.asset
.local_path
.starts_with("assets/canvas-generated/"));
assert_eq!(
fs::read(&result.asset.absolute_path).unwrap(),
valid_test_png_bytes()
);
assert_eq!(result.resource_id.as_deref(), Some("resource-1"));
assert_eq!(result.asset_object_id.as_deref(), Some("asset-object-1"));
assert_eq!(result.task_id.as_deref(), Some("task-1"));
assert_eq!(result.model.as_deref(), Some("gpt-image-2"));
let manifest: Value =
serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap())
.expect("manifest json");
let asset = &manifest["assets"][0];
assert_eq!(asset["kind"], "game-art");
assert_eq!(asset["mediaType"], "image/png");
assert_eq!(asset["source"]["kind"], "canvas");
assert_eq!(asset["source"]["canvasProjectId"], "canvas-project-1");
assert_eq!(asset["source"]["resourceId"], "resource-1");
assert_eq!(asset["source"]["assetObjectId"], "asset-object-1");
assert_eq!(asset["source"]["taskId"], "task-1");
assert_eq!(asset["source"]["model"], "gpt-image-2");
let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db");
assert!(agent_db.contains("\"recordType\":\"canvas.asset_generate\""));
assert!(!agent_db.contains("editor-unit-key"));
fs::remove_dir_all(root).ok();
fs::remove_dir_all(config_dir).ok();
}
#[tokio::test]
async fn generate_local_project_asset_command_registers_the_requested_toolbar_kind() {
let root = unique_project_path();
let config_dir = unique_project_path();
let base_url = spawn_mock_external_canvas_generation_api_server(None);
let _platform_session = crate::platform_session::install_test_platform_session(
"toolbar-asset-user",
"editor-toolbar-key",
&base_url,
);
init_local_game_project_at(&root, "project-toolbar-asset", "未命名游戏原型")
.expect("project init");
fs::create_dir_all(&config_dir).expect("runtime config dir");
fs::write(
config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME),
serde_json::json!({
"editorApi": {
"baseUrl": base_url,
"apiKey": "editor-toolbar-key"
}
})
.to_string(),
)
.expect("write runtime config");
let _guard = use_test_runtime_config_dir(config_dir.clone());
let asset = generate_local_project_asset(
root.to_string_lossy().into_owned(),
"image".to_string(),
"透明 PNG 像素月光主角".to_string(),
Some("16:9".to_string()),
Some("2K".to_string()),
Some("工具栏图片".to_string()),
None,
None,
None,
)
.await
.expect("toolbar asset generation");
assert!(asset.local_path.starts_with("assets/canvas-generated/"));
assert_eq!(
fs::read(&asset.absolute_path).expect("read generated toolbar asset bytes"),
valid_test_png_bytes()
);
assert!(Path::new(&asset.manifest_path).is_file());
let manifest: Value =
serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap())
.expect("manifest json");
let entry = manifest["assets"]
.as_array()
.expect("manifest assets")
.iter()
.find(|entry| entry["id"].as_str() == Some(asset.id.as_str()))
.expect("registered toolbar asset");
// 工具栏请求的 kind 必须一路落到 manifest,而不是退回旧 IPC 的 game-art 默认值。
assert_eq!(entry["kind"], "image");
assert_eq!(entry["mediaType"], "image/png");
assert_eq!(entry["localPath"], serde_json::json!(asset.local_path));
assert_eq!(entry["source"]["kind"], "canvas");
assert_eq!(entry["source"]["resourceId"], "resource-1");
assert_eq!(entry["source"]["assetObjectId"], "asset-object-1");
fs::remove_dir_all(root).ok();
fs::remove_dir_all(config_dir).ok();
}
#[tokio::test]
async fn generate_local_project_asset_command_lands_the_requested_target_category() {
// 入口栏目与生成 kind 不是同一套词汇:kind=image 按 kind 派生只会落到 unclassified,
// 必须靠 targetCategory 才能落回入口栏目,否则生成完成后占位无法被原位接管。
let root = unique_project_path();
let config_dir = unique_project_path();
let (request_sender, request_receiver) = mpsc::channel();
let canvas_base_url = spawn_mock_external_canvas_generation_api_server(Some(request_sender));
let _platform_session = crate::platform_session::install_test_platform_session(
"toolbar-category-user",
"editor-toolbar-category-key",
&canvas_base_url,
);
fs::create_dir_all(&config_dir).expect("create runtime config dir");
fs::write(
config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME),
serde_json::json!({
"editorApi": { "baseUrl": canvas_base_url, "apiKey": "editor-toolbar-category-key" }
})
.to_string(),
)
.expect("write runtime config");
let _guard = use_test_runtime_config_dir(config_dir.clone());
init_local_game_project_at(&root, "project-toolbar-category", "未命名游戏原型")
.expect("init project");
write_project_permission_policy_at(
&root,
ProjectPermissionPolicy {
denied_commands: Vec::new(),
confirm_commands: Vec::new(),
agent_policies: BTreeMap::new(),
},
)
.expect("allow generation");
let asset = generate_local_project_asset(
root.to_string_lossy().into_owned(),
"image".to_string(),
"像素月光厨房主角".to_string(),
Some("1:1".to_string()),
Some("1K".to_string()),
Some("主角图".to_string()),
None,
None,
Some("character".to_string()),
)
.await
.expect("target category generation");
let manifest: Value =
serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap())
.expect("manifest json");
let entry = manifest["assets"]
.as_array()
.expect("manifest assets")
.iter()
.find(|entry| entry["id"].as_str() == Some(asset.id.as_str()))
.expect("registered asset with target category");
// kind 仍是请求的生成 kind,栏目取显式目标分类,而不是 kind 派生的 unclassified。
assert_eq!(entry["kind"], "image");
assert_eq!(entry["category"], "character");
// 先排掉这一次生成自己的请求,再验证非法栏目值不会带来任何新的生成请求。
while request_receiver
.recv_timeout(Duration::from_millis(200))
.is_ok()
{}
for rejected in ["version", "all", "bogus"] {
let error = generate_local_project_asset(
root.to_string_lossy().into_owned(),
"image".to_string(),
"像素月光厨房主角".to_string(),
None,
None,
None,
None,
None,
Some(rejected.to_string()),
)
.await
.expect_err("illegal target category must fail closed");
assert!(
error.contains("目标分类不是合法素材分类"),
"{rejected}: {error}"
);
}
while let Ok(request) = request_receiver.recv_timeout(Duration::from_millis(200)) {
assert!(!is_image_generation_request(&request), "{request}");
}
fs::remove_dir_all(root).ok();
fs::remove_dir_all(config_dir).ok();
}
#[tokio::test]
async fn generate_local_project_asset_command_maps_spec_onto_the_verified_icon_spec_channel() {
let root = unique_project_path();
let config_dir = unique_project_path();
let base_url = spawn_mock_external_canvas_generation_api_server(None);
let _platform_session = crate::platform_session::install_test_platform_session(
"toolbar-spec-user",
"editor-toolbar-spec-key",
&base_url,
);
init_local_game_project_at(&root, "project-toolbar-spec", "未命名游戏原型")
.expect("project init");
fs::create_dir_all(&config_dir).expect("runtime config dir");
fs::write(
config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME),
serde_json::json!({
"editorApi": {
"baseUrl": base_url,
"apiKey": "editor-toolbar-spec-key"
}
})
.to_string(),
)
.expect("write runtime config");
let _guard = use_test_runtime_config_dir(config_dir.clone());
let asset = generate_local_project_asset(
root.to_string_lossy().into_owned(),
"spec".to_string(),
"原创收集玩法的统一视觉规范".to_string(),
None,
None,
Some("视觉规范图".to_string()),
None,
None,
None,
)
.await
.expect("toolbar spec generation");
let manifest: Value =
serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap())
.expect("manifest json");
let entry = manifest["assets"]
.as_array()
.expect("manifest assets")
.iter()
.find(|entry| entry["id"].as_str() == Some(asset.id.as_str()))
.expect("registered toolbar spec asset");
// spec 必须收口到客户端验证过的 icon-spec,否则规范图无法作为 UI 原型与透明图集的权威参考。
assert_eq!(entry["kind"], "icon-spec");
assert_eq!(entry["source"]["generationKind"], "spec");
assert_eq!(
entry["source"]["generationRoute"],
"/api/external/v1/editor/images/generations"
);
// 规范图不引用任何既有资源,manifest 因此不写 referenceResourceIds 字段。
assert!(
entry["source"].get("referenceResourceIds").is_none(),
"规范图不应带参考图引用:{}",
entry["source"]
);
fs::remove_dir_all(root).ok();
fs::remove_dir_all(config_dir).ok();
}
#[tokio::test]
async fn generate_local_project_asset_command_generates_art_spritesheet_from_the_registered_spec() {
let root = unique_project_path();
let config_dir = unique_project_path();
let base_url = spawn_mock_external_canvas_generation_api_server(None);
let _platform_session = crate::platform_session::install_test_platform_session(
"toolbar-spritesheet-user",
"editor-toolbar-spritesheet-key",
&base_url,
);
init_local_game_project_at(&root, "project-toolbar-spritesheet", "未命名游戏原型")
.expect("project init");
fs::create_dir_all(&config_dir).expect("runtime config dir");
fs::write(
config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME),
serde_json::json!({
"editorApi": {
"baseUrl": base_url,
"apiKey": "editor-toolbar-spritesheet-key"
}
})
.to_string(),
)
.expect("write runtime config");
let _guard = use_test_runtime_config_dir(config_dir.clone());
// 透明图集必须有权威规范图:没有已登记的 icon-spec 时必须明确失败,不能静默降级成普通图。
let missing_reference = generate_local_project_asset(
root.to_string_lossy().into_owned(),
"art-spritesheet".to_string(),
"原创收集玩法素材图集".to_string(),
None,
None,
None,
None,
None,
None,
)
.await
.expect_err("art-spritesheet requires a registered icon-spec");
assert!(
missing_reference.contains("assets/art-spec.png"),
"{missing_reference}"
);
register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec");
bind_canvas_visual_asset_fixture_to_current_editor(
&root,
"assets/art-spec.png",
"canvas-project-1",
);
let asset = generate_local_project_asset(
root.to_string_lossy().into_owned(),
"art-spritesheet".to_string(),
"原创收集玩法素材图集".to_string(),
Some("1:1".to_string()),
Some("1K".to_string()),
Some("游戏首版图集".to_string()),
None,
None,
None,
)
.await
.expect("toolbar spritesheet generation");
assert!(asset.local_path.starts_with("assets/canvas-generated/"));
let manifest: Value =
serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap())
.expect("manifest json");
let entry = manifest["assets"]
.as_array()
.expect("manifest assets")
.iter()
.find(|entry| entry["id"].as_str() == Some(asset.id.as_str()))
.expect("registered toolbar spritesheet asset");
assert_eq!(entry["kind"], "art-spritesheet");
assert_eq!(
entry["source"]["generationRoute"],
"/api/external/v1/editor/icon-spritesheets/generations"
);
assert_eq!(entry["source"]["generationKind"], "icon-spritesheet");
assert_eq!(
entry["source"]["referenceResourceIds"],
serde_json::json!(["resource-icon-spec"])
);
fs::remove_dir_all(root).ok();
fs::remove_dir_all(config_dir).ok();
}
#[tokio::test]
async fn platform_art_generation_step_falls_back_without_leaking_editor_key() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-platform-art-fallback", "未命名游戏原型")
.expect("project init");
let config_dir = unique_project_path();
let base_url = spawn_mock_external_canvas_generation_failure_server();
let _platform_session = crate::platform_session::install_test_platform_session(
"platform-art-fallback-user",
"editor-fallback-secret",
&base_url,
);
fs::create_dir_all(&config_dir).expect("runtime config dir");
fs::write(
config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME),
serde_json::json!({
"editorApi": {
"baseUrl": base_url,
"apiKey": "editor-fallback-secret"
}
})
.to_string(),
)
.expect("write runtime config");
let _guard = use_test_runtime_config_dir(config_dir.clone());
let art_role = AgentRoleBrief {
group_definition: GAME_CREATOR_AGENT_GROUP_DEFINITIONS[2],
role_definition: ART_AGENT_ROLES[1],
markdown: "需要像素月光主角和厨房场景素材。".to_string(),
relative_path: ".agent/passes/pass-1/groups/art/asset.md".to_string(),
memory_relative_path: agent_role_memory_relative_path(
GAME_CREATOR_AGENT_GROUP_DEFINITIONS[2],
ART_AGENT_ROLES[1],
),
status: "completed".to_string(),
tool_id: "agent.role.brief.art.asset".to_string(),
summary: "规划首版美术资产".to_string(),
};
let briefs = vec![AgentGroupBrief {
definition: GAME_CREATOR_AGENT_GROUP_DEFINITIONS[2],
markdown: "美术组汇总。".to_string(),
relative_path: ".agent/passes/pass-1/groups/art.md".to_string(),
role_briefs: vec![art_role],
}];
let step = maybe_generate_platform_art_asset_step(&root, "月光弹幕厨房", &briefs, 1, None)
.await
.expect("platform art generation step");
assert_eq!(step.status, "failed");
assert!(step.output_paths.is_empty());
assert!(
step.summary.contains("HTTP 500")
|| step.summary.contains("500")
|| step.summary.contains("平台图片生成服务暂不可用"),
"provider failure summary should retain a structured failure signal: {}",
step.summary
);
assert!(!step.summary.contains("editor-fallback-secret"));
assert!(read_manifest_for_project(&root).unwrap().assets.is_empty());
fs::remove_dir_all(root).ok();
fs::remove_dir_all(config_dir).ok();
}
#[tokio::test]
async fn generate_platform_art_asset_respects_project_policy() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init");
write_project_permission_policy_at(
&root,
ProjectPermissionPolicy {
denied_commands: vec!["canvas.asset_generate".to_string()],
confirm_commands: Vec::new(),
agent_policies: BTreeMap::new(),
},
)
.expect("write policy");
let error = generate_platform_art_asset_at(&root, "月光弹幕厨房", &[])
.await
.expect_err("policy should deny platform art generation");
assert!(error.contains("项目权限策略拒绝执行:canvas.asset_generate"));
fs::remove_dir_all(root).ok();
}
#[test]
fn canvas_project_url_defaults_to_local_editor_route() {
let url = build_canvas_project_url(None, Some("canvas-project-1")).expect("canvas url");
assert_eq!(
url,
"http://127.0.0.1:3000/editor/canvas?projectid=canvas-project-1"
);
}
#[test]
fn canvas_project_url_normalizes_localhost_base() {
let url = build_canvas_project_url(
Some("http://localhost:3100/old/path?ignored=1#fragment"),
Some("项目 1"),
)
.expect("canvas url");
assert_eq!(
url,
"http://localhost:3100/editor/canvas?projectid=%E9%A1%B9%E7%9B%AE+1"
);
}
#[test]
fn canvas_project_url_rejects_non_local_editor_base() {
assert!(build_canvas_project_url(Some("https://example.com"), Some("p")).is_err());
assert!(build_canvas_project_url(Some("file:///tmp/editor"), Some("p")).is_err());
assert!(build_canvas_project_url(Some("http://192.168.1.5:3000"), Some("p")).is_err());
}
#[test]
fn local_project_file_commands_read_write_list_and_delete_text_files() {
let root = unique_project_path();
let written =
write_local_project_file_at(&root, "game/notes.txt", "hello").expect("write project file");
assert_eq!(written.path, "game/notes.txt");
assert!(!written.deleted);
let read = read_local_project_file_at(&root, "game/notes.txt").expect("read project file");
assert_eq!(read.content, "hello");
let listed = list_local_project_files_at(&root).expect("list project files");
assert!(listed
.files
.iter()
.any(|file| file.path == "game/notes.txt" && file.kind == "file"));
assert!(listed
.files
.iter()
.any(|file| file.path == "game/notes.txt" && file.modified_at > 0));
let deleted =
delete_local_project_file_at(&root, "game/notes.txt").expect("delete project file");
assert!(deleted.deleted);
assert!(!root.join("game/notes.txt").exists());
fs::remove_dir_all(root).ok();
}
/// 引擎生成目录不进发现结果,但**只在当前目录确实是 Cocos Creator 工程时**生效:
/// 同名目录在别的工程里可能是真实源码目录,全局跳过会把用户代码从发现结果里删掉。
#[test]
fn local_project_file_listing_skips_engine_generated_directories_only_for_engine_projects() {
let cocos = canonical_test_tempdir("engine-generated-dirs-");
let cocos = cocos.path();
fs::create_dir_all(cocos.join("assets")).expect("create assets");
fs::create_dir_all(cocos.join("library/imported")).expect("create library");
fs::create_dir_all(cocos.join("temp/programming")).expect("create temp");
fs::create_dir_all(cocos.join("profiles/v2")).expect("create profiles");
fs::create_dir_all(cocos.join("local")).expect("create local");
fs::write(cocos.join("assets/hero.glb"), b"glTF").expect("write model");
fs::write(cocos.join("library/imported/hero.json"), b"{}").expect("write library file");
fs::write(cocos.join("temp/programming/packer.cpp"), b"//").expect("write temp file");
fs::write(cocos.join("profiles/v2/user.json"), b"{}").expect("write profile file");
fs::write(cocos.join("local/settings.json"), b"{}").expect("write local file");
fs::write(
cocos.join("package.json"),
r#"{ "name": "cocos-project", "creator": { "version": "3.8.8" } }"#,
)
.expect("write cocos package.json");
let listed = list_local_project_files_at(cocos).expect("list cocos project files");
let paths = listed
.files
.iter()
.map(|file| file.path.as_str())
.collect::<Vec<_>>();
assert!(paths.contains(&"assets/hero.glb"), "{paths:?}");
for skipped in [
"library",
"library/imported/hero.json",
"temp",
"temp/programming/packer.cpp",
"profiles",
"profiles/v2/user.json",
"local",
"local/settings.json",
] {
assert!(
!paths.contains(&skipped),
"引擎生成目录必须被过滤:{skipped} / {paths:?}"
);
}
let plain = canonical_test_tempdir("plain-project-dirs-");
let plain = plain.path();
fs::create_dir_all(plain.join("library")).expect("create plain library");
fs::write(plain.join("library/index.ts"), b"//").expect("write plain library file");
let listed = list_local_project_files_at(plain).expect("list plain project files");
assert!(
listed
.files
.iter()
.any(|file| file.path == "library/index.ts"),
"非引擎工程的同名目录不得被过滤"
);
}
#[test]
fn local_project_export_package_uses_runtime_whitelist_and_records() {
let root = unique_project_path();
init_existing_html_project_at(&root, "project-1", "像素动作原型").expect("project init");
write_local_project_file_at(&root, "game/index.html", &fake_llm_game_draft().game_html)
.expect("write playable html");
write_local_project_file_at(&root, "assets/hero.txt", "hero asset").expect("write asset");
write_local_project_file_at(&root, "exports/README.md", "playtest notes")
.expect("write readme");
write_local_project_file_at(&root, "memory/project.md", "private memory")
.expect("write memory");
fs::write(root.join(".env"), "LOCAL_PLACEHOLDER=not-for-export").expect("write local config");
fs::write(root.join(".agent/run.latest.json"), "{}").expect("write trace");
let result = export_local_project_package_at(&root).expect("export package");
assert_eq!(result.file_count, 3);
assert!(result
.package_relative_path
.starts_with("exports/playtest-package-"));
assert!(result.package_relative_path.ends_with(".zip"));
let file = File::open(&result.package_path).expect("open export package");
let mut archive = zip::ZipArchive::new(file).expect("read export package");
let mut names = Vec::new();
for index in 0..archive.len() {
names.push(
archive
.by_index(index)
.expect("zip entry")
.name()
.to_string(),
);
}
names.sort();
assert_eq!(
names,
vec![
"assets/hero.txt".to_string(),
"exports/README.md".to_string(),
"game/index.html".to_string(),
]
);
assert!(!names.iter().any(|name| name.starts_with(".agent/")));
assert!(!names.iter().any(|name| name.starts_with("memory/")));
assert!(!names.iter().any(|name| name.contains(".env")));
let manifest = serde_json::from_str::<Value>(
&fs::read_to_string(root.join(".agent/manifest.json")).expect("manifest"),
)
.expect("manifest json");
assert!(manifest["commandRuns"]
.as_array()
.expect("command runs")
.iter()
.any(|run| run["commandId"] == "project.export_package"));
let command_log =
fs::read_to_string(root.join(".agent/logs/command.log")).expect("command log");
assert!(command_log.contains("project.export_package"));
let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db");
assert!(agent_db.contains("\"recordType\":\"project.export_package\""));
assert!(!agent_db.contains("LOCAL_PLACEHOLDER=not-for-export"));
fs::remove_dir_all(root).ok();
}
#[test]
fn local_project_export_package_list_only_returns_recent_playtest_zips() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init");
fs::write(root.join("exports/playtest-package-001.zip"), "old").expect("write old zip");
fs::write(root.join("exports/not-playtest.zip"), "ignore").expect("write ignored zip");
fs::write(root.join("exports/playtest-package-002.zip"), "newer").expect("write newer zip");
fs::write(root.join("game/playtest-package-003.zip"), "wrong dir")
.expect("write wrong dir zip");
let result = list_local_project_export_packages_at(&root).expect("list packages");
assert_eq!(result.project_path, root.to_string_lossy().into_owned());
let paths = result
.packages
.iter()
.map(|package| package.package_relative_path.as_str())
.collect::<Vec<_>>();
assert_eq!(
paths,
vec![
"exports/playtest-package-002.zip",
"exports/playtest-package-001.zip"
]
);
assert_eq!(result.packages[0].total_bytes, 5);
assert!(result.packages[0].modified_at > 0);
fs::remove_dir_all(root).ok();
}
#[cfg(unix)]
#[test]
fn local_project_export_package_list_skips_symlink_packages() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init");
fs::write(root.join("exports/playtest-package-real.zip"), "real").expect("write zip");
std::os::unix::fs::symlink(
root.join("memory/project.md"),
root.join("exports/playtest-package-link.zip"),
)
.expect("create package symlink");
let result = list_local_project_export_packages_at(&root).expect("list packages");
assert_eq!(result.packages.len(), 1);
assert_eq!(
result.packages[0].package_relative_path,
"exports/playtest-package-real.zip"
);
fs::remove_dir_all(root).ok();
}
#[cfg(unix)]
#[test]
fn local_project_export_package_rejects_symlink_assets() {
let root = unique_project_path();
init_existing_html_project_at(&root, "project-1", "像素动作原型").expect("project init");
write_local_project_file_at(&root, "game/index.html", &fake_llm_game_draft().game_html)
.expect("write playable html");
write_local_project_file_at(&root, "exports/README.md", "playtest notes")
.expect("write readme");
std::os::unix::fs::symlink(
root.join("memory/project.md"),
root.join("assets/private.md"),
)
.expect("create symlink asset");
let error = export_local_project_package_at(&root).expect_err("symlink should be rejected");
assert!(error.contains("试玩包不能包含符号链接"));
assert!(!root.join("exports").join("playtest-package-").exists());
fs::remove_dir_all(root).ok();
}
#[cfg(unix)]
#[test]
fn local_project_export_package_rejects_symlink_runtime_dirs_and_readme() {
let root = unique_project_path();
init_existing_html_project_at(&root, "project-1", "像素动作原型").expect("project init");
write_local_project_file_at(&root, "game/index.html", &fake_llm_game_draft().game_html)
.expect("write playable html");
write_local_project_file_at(&root, "memory/project.md", "private memory")
.expect("write memory");
std::os::unix::fs::symlink(
root.join("memory/project.md"),
root.join("exports/README.md"),
)
.expect("create readme symlink");
let error = export_local_project_package_at(&root).expect_err("readme symlink rejected");
assert!(error.contains("符号链接"), "{error}");
fs::remove_file(root.join("exports/README.md")).expect("remove readme symlink");
write_local_project_file_at(&root, "exports/README.md", "playtest notes")
.expect("write readme");
fs::remove_dir_all(root.join("assets")).expect("remove assets dir");
fs::create_dir_all(root.join("memory/assets")).expect("create memory assets");
fs::write(root.join("memory/assets/hero.txt"), "private asset").expect("write asset");
std::os::unix::fs::symlink(root.join("memory/assets"), root.join("assets"))
.expect("create assets symlink");
let error = export_local_project_package_at(&root).expect_err("assets symlink rejected");
assert!(error.contains("符号链接"), "{error}");
fs::remove_dir_all(root).ok();
}
#[test]
fn local_project_export_package_requires_playable_html_and_readme() {
let root = unique_project_path();
init_existing_html_project_at(&root, "project-1", "像素动作原型").expect("project init");
let missing_readme =
export_local_project_package_at(&root).expect_err("default html is not playable");
assert!(missing_readme.contains("游戏入口必须包含可渲染画布"));
write_local_project_file_at(&root, "game/index.html", &fake_llm_game_draft().game_html)
.expect("write playable html");
let missing_readme =
export_local_project_package_at(&root).expect_err("readme should be required");
assert!(missing_readme.contains("exports/README.md"));
fs::remove_dir_all(root).ok();
}
#[test]
fn local_game_manifest_reads_respect_declared_command_policy() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init");
write_project_permission_policy_at(
&root,
ProjectPermissionPolicy {
denied_commands: vec!["project.status".to_string(), "asset.list".to_string()],
confirm_commands: Vec::new(),
agent_policies: BTreeMap::new(),
},
)
.expect("write policy");
let project_path = root.to_string_lossy().into_owned();
let status_error = get_local_game_manifest_sync(project_path.clone(), None)
.expect_err("default project.status denied");
let asset_error =
get_local_game_manifest_sync(project_path.clone(), Some("asset.list".to_string()))
.expect_err("asset.list denied");
let unsupported_error =
get_local_game_manifest_sync(project_path, Some("file.read".to_string()))
.expect_err("unsupported manifest command denied");
assert!(status_error.contains("项目权限策略拒绝执行:project.status"));
assert!(asset_error.contains("项目权限策略拒绝执行:asset.list"));
assert!(unsupported_error.contains("不支持通过 manifest 执行命令:file.read"));
fs::remove_dir_all(root).ok();
}
fn resource_canvas_layout_position(resource_id: &str) -> ProjectResourceCanvasPosition {
ProjectResourceCanvasPosition {
resource_id: resource_id.to_string(),
section: shared_contracts::game_creation_app::ProjectResourceCanvasSection::Scene,
x: 32,
y: 48,
manually_placed: true,
}
}
/// 资源画布布局的读写命令必须和紧邻的 `read_local_project_resource_graph` 一样过项目权限策略:
/// 读用 `asset.list` 的 auto 口径,写用 `asset.register` 的普通写门(与
/// `update_local_project_resource_classification` 同口径)。assertions 同时钉住两条口径的区别,
/// 以及「被拒绝的写不产生任何落盘副作用」。
#[test]
fn local_project_resource_canvas_layout_commands_respect_declared_command_policy() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init");
let project_path = root.to_string_lossy().into_owned();
let layout_path = root.join(".agent/workbench/resource-layouts/type.json");
// 默认策略:`asset.list` 是 auto、`asset.register` 是 confirm,读与写都必须照旧放行。
let layout = read_local_project_resource_canvas_layout(
project_path.clone(),
ProjectResourceCanvasLayoutMode::Type,
)
.expect("default policy must allow the layout read");
assert_eq!(layout.revision, 0);
update_local_project_resource_canvas_layout(
project_path.clone(),
"project-1".to_string(),
ProjectResourceCanvasLayoutMode::Type,
0,
vec![resource_canvas_layout_position("asset-hero")],
)
.expect("default policy must allow the layout write");
assert!(layout_path.is_file(), "默认策略下布局应已落盘");
write_project_permission_policy_at(
&root,
ProjectPermissionPolicy {
denied_commands: vec!["asset.list".to_string()],
confirm_commands: Vec::new(),
agent_policies: BTreeMap::new(),
},
)
.expect("write denied read policy");
let read_error = read_local_project_resource_canvas_layout(
project_path.clone(),
ProjectResourceCanvasLayoutMode::Type,
)
.expect_err("denied asset.list must reject the layout read");
assert!(
read_error.contains("项目权限策略拒绝执行:asset.list"),
"unexpected error: {read_error}"
);
// 读路径没有可插入的确认交互,所以 auto 口径下「要求确认」等同于拒绝。
write_project_permission_policy_at(
&root,
ProjectPermissionPolicy {
denied_commands: Vec::new(),
confirm_commands: vec!["asset.list".to_string()],
agent_policies: BTreeMap::new(),
},
)
.expect("write confirm read policy");
let confirm_error = read_local_project_resource_canvas_layout(
project_path.clone(),
ProjectResourceCanvasLayoutMode::Type,
)
.expect_err("confirm-required asset.list must reject the layout read");
assert!(
confirm_error.contains("项目权限策略要求用户确认:asset.list"),
"unexpected error: {confirm_error}"
);
write_project_permission_policy_at(
&root,
ProjectPermissionPolicy {
denied_commands: vec!["asset.register".to_string()],
confirm_commands: Vec::new(),
agent_policies: BTreeMap::new(),
},
)
.expect("write denied write policy");
read_local_project_resource_canvas_layout(
project_path.clone(),
ProjectResourceCanvasLayoutMode::Type,
)
.expect("only asset.register is denied, so the layout read must still succeed");
let write_error = update_local_project_resource_canvas_layout(
project_path.clone(),
"project-1".to_string(),
ProjectResourceCanvasLayoutMode::Type,
1,
vec![resource_canvas_layout_position("asset-hero")],
)
.expect_err("denied asset.register must reject the layout write");
assert!(
write_error.contains("项目权限策略拒绝执行:asset.register"),
"unexpected error: {write_error}"
);
let persisted: ProjectResourceCanvasLayout =
serde_json::from_str(&fs::read_to_string(&layout_path).expect("read persisted layout"))
.expect("parse persisted layout");
assert_eq!(
persisted.revision, 1,
"被权限门拒绝的写入不得留下任何落盘副作用"
);
// 恢复默认策略后写入照旧成功:新增的门只在策略显式拒绝时生效。
write_project_permission_policy_at(&root, ProjectPermissionPolicy::default())
.expect("reset policy");
let updated = update_local_project_resource_canvas_layout(
project_path,
"project-1".to_string(),
ProjectResourceCanvasLayoutMode::Type,
1,
vec![resource_canvas_layout_position("asset-hero")],
)
.expect("default policy must allow the layout write again");
assert_eq!(updated.layout.revision, 2);
fs::remove_dir_all(root).ok();
}
#[test]
fn local_project_file_commands_reject_unsafe_paths() {
let root = unique_project_path();
assert!(read_local_project_file_at(&root, "../secret.txt").is_err());
assert!(read_local_project_file_at(&root, "/tmp/secret.txt").is_err());
assert!(write_local_project_file_at(&root, "game\\secret.txt", "x").is_err());
assert!(write_local_project_file_at(&root, "C:/secret.txt", "x").is_err());
}
#[test]
fn local_project_file_commands_hide_runtime_private_control_files() {
let root = unique_project_path();
let pending_path =
root.join(".agent/runtime/pending-actions/design-director/runtime-private.json");
fs::create_dir_all(pending_path.parent().expect("pending parent"))
.expect("runtime private dir");
fs::write(&pending_path, r#"{"action":{"tool":"file.write"}}"#)
.expect("runtime private payload");
let listed = list_local_project_files_at(&root).expect("list project files");
assert!(!listed.files.iter().any(|file| {
file.path
.to_ascii_lowercase()
.starts_with(".agent/runtime/")
}));
for path in [
".agent/runtime/pending-actions/design-director/runtime-private.json",
".AGENT/RUNTIME/confirmations/design-director/run/file.read.json",
".agent/runtime/cancel/design-director/run.json",
".agent/runtime/locks/design-director.lock",
] {
let read_error = read_local_project_file_at(&root, path)
.expect_err("runtime private path must not be readable");
assert!(read_error.contains("Runtime 私有控制面"));
let write_error = write_local_project_file_at(&root, path, "tampered")
.expect_err("runtime private path must not be writable");
assert!(write_error.contains("Runtime 私有控制面"));
let delete_error = delete_local_project_file_at(&root, path)
.expect_err("runtime private path must not be deletable");
assert!(delete_error.contains("Runtime 私有控制面"));
}
write_local_project_file_at(&root, ".agent/runtime-backup/notes.txt", "allowed")
.expect("similar non-runtime path remains accessible");
assert_eq!(
read_local_project_file_at(&root, ".agent/runtime-backup/notes.txt")
.expect("read similar path")
.content,
"allowed"
);
fs::remove_dir_all(root).ok();
}
#[test]
fn preview_state_is_persisted_to_manifest() {
let root = unique_project_path();
record_preview_state(
&root,
GameCreationAppPreviewStatus::Running,
Some("http://127.0.0.1:3210/".to_string()),
Some(3210),
)
.expect("preview state");
let manifest: Value =
serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap())
.expect("manifest json");
assert_eq!(manifest["preview"]["status"], "running");
assert_eq!(manifest["preview"]["port"], 3210);
record_preview_state(&root, GameCreationAppPreviewStatus::Stopped, None, None)
.expect("preview stopped");
let manifest: Value =
serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap())
.expect("manifest json");
assert_eq!(manifest["preview"]["status"], "stopped");
assert!(manifest["preview"].get("url").is_none());
fs::remove_dir_all(root).ok();
}
#[test]
fn preview_start_appends_to_existing_agent_run_trace() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init");
write_agent_run_trace(
&root,
"run-1",
"像素风横版动作",
"passed",
1,
&[agent_trace_step(
1,
"Playtest",
"completed",
&["game/index.html"],
&[".agent/logs/command.log"],
"静态入口自检通过",
"game.static_smoke",
)],
None,
)
.expect("run trace");
append_preview_start_trace_step(
&root,
&LocalPreviewResult {
url: "http://127.0.0.1:3210/".to_string(),
port: 3210,
root: root.join("game").to_string_lossy().into_owned(),
},
)
.expect("append preview trace");
let trace: Value =
serde_json::from_str(&fs::read_to_string(root.join(".agent/run.latest.json")).unwrap())
.expect("run trace json");
assert_eq!(trace["status"], "preview-running");
assert_eq!(trace["stopReason"], "preview-running");
assert_eq!(trace["nextStep"], "manual-playtest");
assert_eq!(trace["steps"].as_array().unwrap().len(), 2);
assert_eq!(trace["steps"][1]["agent"], "Preview");
assert_eq!(trace["steps"][1]["toolCalls"][0]["toolId"], "preview.start");
assert!(trace["steps"][1]["outputPaths"]
.as_array()
.unwrap()
.iter()
.any(|path| path == ".agent/logs/preview.log"));
assert!(trace["steps"][1]["summary"]
.as_str()
.unwrap()
.contains("http://127.0.0.1:3210/"));
let run_history: Value =
serde_json::from_str(&fs::read_to_string(root.join(".agent/runs/run-1.json")).unwrap())
.expect("run history json");
assert_eq!(run_history["status"], "preview-running");
assert_eq!(run_history["stopReason"], "preview-running");
assert_eq!(run_history["nextStep"], "manual-playtest");
assert_eq!(run_history["steps"].as_array().unwrap().len(), 2);
append_preview_stop_trace_step(&root).expect("append preview stop trace");
let trace: Value =
serde_json::from_str(&fs::read_to_string(root.join(".agent/run.latest.json")).unwrap())
.expect("run trace json");
assert_eq!(trace["status"], "preview-stopped");
assert_eq!(trace["stopReason"], "preview-stopped");
assert_eq!(trace["nextStep"], "inspect-artifacts");
assert_eq!(trace["steps"].as_array().unwrap().len(), 3);
assert_eq!(trace["steps"][2]["agent"], "Preview");
assert_eq!(trace["steps"][2]["status"], "stopped");
assert_eq!(trace["steps"][2]["toolCalls"][0]["toolId"], "preview.stop");
assert!(trace["steps"][2]["outputPaths"]
.as_array()
.unwrap()
.iter()
.any(|path| path == ".agent/logs/preview.log"));
fs::remove_dir_all(root).ok();
}
#[test]
fn preview_log_records_start_and_stop_events() {
let root = unique_project_path();
append_preview_log(&root, "running", Some("http://127.0.0.1:3210/"))
.expect("preview start log");
append_preview_log(&root, "stopped", None).expect("preview stop log");
let log = fs::read_to_string(root.join(".agent/logs/preview.log")).expect("preview log");
assert!(log.contains("preview.running http://127.0.0.1:3210/"));
assert!(log.contains("preview.stopped"));
fs::remove_dir_all(root).ok();
}
#[test]
fn preview_start_trace_noops_without_agent_run_trace() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init");
append_preview_start_trace_step(
&root,
&LocalPreviewResult {
url: "http://127.0.0.1:3210/".to_string(),
port: 3210,
root: root.join("game").to_string_lossy().into_owned(),
},
)
.expect("missing trace should not block preview");
assert!(!root.join(".agent/run.latest.json").exists());
fs::remove_dir_all(root).ok();
}
#[test]
fn preview_registry_reports_status_and_stops_previous_server() {
let registry = PreviewRegistry::default();
assert_eq!(registry.status().status, "stopped");
let (first_stop, first_receiver) = mpsc::channel();
let (_first_preview, previous) = registry.set_running(
LocalPreviewResult {
url: "http://127.0.0.1:1/".to_string(),
port: 1,
root: "/tmp/game-one/game".to_string(),
},
first_stop,
);
assert!(previous.is_none());
assert_eq!(registry.status().port, Some(1));
let (second_stop, _second_receiver) = mpsc::channel();
let (_second_preview, previous) = registry.set_running(
LocalPreviewResult {
url: "http://127.0.0.1:2/".to_string(),
port: 2,
root: "/tmp/game-two/game".to_string(),
},
second_stop,
);
assert!(first_receiver.try_recv().is_ok());
assert_eq!(
previous.expect("previous preview").root,
"/tmp/game-one/game"
);
assert_eq!(registry.status().port, Some(2));
assert_eq!(registry.stop().status, "stopped");
assert_eq!(registry.status().status, "stopped");
}
#[test]
fn replaced_preview_records_stopped_state() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init");
record_preview_state(
&root,
GameCreationAppPreviewStatus::Running,
Some("http://127.0.0.1:3210/".to_string()),
Some(3210),
)
.expect("preview running state");
record_replaced_preview_stop(&LocalPreviewResult {
url: "http://127.0.0.1:3210/".to_string(),
port: 3210,
root: root.to_string_lossy().into_owned(),
});
let manifest: Value =
serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap())
.expect("manifest json");
assert_eq!(manifest["preview"]["status"], "stopped");
let log = fs::read_to_string(root.join(".agent/logs/preview.log")).expect("preview log");
assert!(log.contains("preview.stopped"));
fs::remove_dir_all(root).ok();
}
#[test]
fn preview_registry_does_not_stop_other_project_preview() {
let first = unique_project_path();
let second = unique_project_path();
fs::create_dir_all(&first).expect("first project");
fs::create_dir_all(&second).expect("second project");
let registry = PreviewRegistry::default();
let (first_stop, first_receiver) = mpsc::channel();
registry.set_running(
LocalPreviewResult {
url: "http://127.0.0.1:1/".to_string(),
port: 1,
root: first.to_string_lossy().into_owned(),
},
first_stop,
);
let (status, stopped) = registry.stop_for_project(Some(&second));
assert_eq!(status.status, "stopped");
assert!(!stopped);
assert!(first_receiver.try_recv().is_err());
assert_eq!(registry.status().port, Some(1));
let (_status, stopped) = registry.stop_for_project(Some(&first));
assert!(stopped);
assert!(first_receiver.try_recv().is_ok());
assert_eq!(registry.status().status, "stopped");
fs::remove_dir_all(first).ok();
fs::remove_dir_all(second).ok();
}
#[test]
fn preview_stop_for_other_project_does_not_write_stopped_evidence() {
let first = unique_project_path();
let second = unique_project_path();
init_local_game_project_at(&first, "project-1", "预览项目一").expect("first project");
init_local_game_project_at(&second, "project-2", "预览项目二").expect("second project");
write_agent_run_trace(
&second,
"run-second",
"第二个项目",
"passed",
1,
&[agent_trace_step(
1,
"Playtest",
"completed",
&["game/index.html"],
&[".agent/logs/command.log"],
"静态入口自检通过",
"game.static_smoke",
)],
None,
)
.expect("second trace");
let registry = PreviewRegistry::default();
let (first_stop, first_receiver) = mpsc::channel();
registry.set_running(
LocalPreviewResult {
url: "http://127.0.0.1:1/".to_string(),
port: 1,
root: first.to_string_lossy().into_owned(),
},
first_stop,
);
let status = stop_local_game_preview_for_root(Some(&second), &registry)
.expect("stop other project preview");
assert_eq!(status.status, "stopped");
assert!(first_receiver.try_recv().is_err());
assert_eq!(registry.status().port, Some(1));
let second_manifest: Value =
serde_json::from_str(&fs::read_to_string(second.join(".agent/manifest.json")).unwrap())
.expect("second manifest");
assert!(second_manifest.get("preview").is_none());
assert!(!second.join(".agent/logs/preview.log").exists());
let second_trace: Value =
serde_json::from_str(&fs::read_to_string(second.join(".agent/run.latest.json")).unwrap())
.expect("second trace");
assert_eq!(second_trace["status"], "passed");
assert!(second_trace["steps"]
.as_array()
.unwrap()
.iter()
.all(|step| step["toolCalls"][0]["toolId"] != "preview.stop"));
stop_local_game_preview_for_root(Some(&first), &registry).expect("cleanup first preview");
assert!(first_receiver.try_recv().is_ok());
fs::remove_dir_all(first).ok();
fs::remove_dir_all(second).ok();
}
#[test]
fn preview_open_url_requires_running_localhost_preview() {
assert_eq!(
preview_open_url(&LocalPreviewStatus {
status: "running".to_string(),
url: Some("http://127.0.0.1:3001/".to_string()),
port: Some(3001),
root: Some("/tmp/game".to_string()),
})
.unwrap(),
"http://127.0.0.1:3001/"
);
assert!(preview_open_url(&stopped_preview_status()).is_err());
assert!(preview_open_url(&LocalPreviewStatus {
status: "running".to_string(),
url: Some("https://example.com/".to_string()),
port: Some(443),
root: Some("/tmp/game".to_string()),
})
.is_err());
}
#[test]
fn preview_project_guard_rejects_other_project_preview() {
let first = unique_project_path();
let second = unique_project_path();
fs::create_dir_all(&first).expect("first project");
fs::create_dir_all(&second).expect("second project");
let status = LocalPreviewStatus {
status: "running".to_string(),
url: Some("http://127.0.0.1:3001/".to_string()),
port: Some(3001),
root: Some(first.to_string_lossy().into_owned()),
};
ensure_preview_belongs_to_project(&status, &first).expect("same project preview");
let error = ensure_preview_belongs_to_project(&status, &second)
.expect_err("other project preview should fail");
assert!(error.contains("不属于已授权本地项目"));
fs::remove_dir_all(first).ok();
fs::remove_dir_all(second).ok();
}
#[test]
fn preview_open_respects_project_policy_when_project_is_provided() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init");
write_project_permission_policy_at(
&root,
ProjectPermissionPolicy {
denied_commands: vec!["preview.open".to_string()],
confirm_commands: Vec::new(),
agent_policies: BTreeMap::new(),
},
)
.expect("write policy");
let status = LocalPreviewStatus {
status: "running".to_string(),
url: Some("http://127.0.0.1:3210/".to_string()),
port: Some(3210),
root: Some(root.to_string_lossy().into_owned()),
};
let error = validate_preview_open_project(&status, Some(root.to_str().unwrap()))
.expect_err("preview.open denied");
assert!(error.contains("项目权限策略拒绝执行:preview.open"));
validate_preview_open_project(&status, None).expect("global preview open allowed");
fs::remove_dir_all(root).ok();
}
#[test]
fn preview_status_respects_project_policy_when_project_is_provided() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init");
write_project_permission_policy_at(
&root,
ProjectPermissionPolicy {
denied_commands: vec!["preview.status".to_string()],
confirm_commands: Vec::new(),
agent_policies: BTreeMap::new(),
},
)
.expect("write policy");
let registry = PreviewRegistry::default();
let error = get_local_game_preview_status_at(&registry, Some(root.to_str().unwrap()))
.expect_err("preview.status denied");
assert!(error.contains("项目权限策略拒绝执行:preview.status"));
let global_status =
get_local_game_preview_status_at(&registry, None).expect("global status allowed");
assert_eq!(global_status.status, "stopped");
fs::remove_dir_all(root).ok();
}
#[test]
fn preview_status_filter_hides_other_project_preview() {
let first = unique_project_path();
let second = unique_project_path();
fs::create_dir_all(&first).expect("first project");
fs::create_dir_all(&second).expect("second project");
let status = LocalPreviewStatus {
status: "running".to_string(),
url: Some("http://127.0.0.1:3001/".to_string()),
port: Some(3001),
root: Some(first.to_string_lossy().into_owned()),
};
assert_eq!(
filter_preview_status_for_project(status.clone(), Some(first.to_str().unwrap())).status,
"running"
);
assert_eq!(
filter_preview_status_for_project(status, Some(second.to_str().unwrap())).status,
"stopped"
);
fs::remove_dir_all(first).ok();
fs::remove_dir_all(second).ok();
}
#[test]
fn preview_path_rejects_traversal() {
let project = unique_project_path();
fs::create_dir_all(project.join("game")).expect("game dir");
fs::create_dir_all(project.join("assets")).expect("assets dir");
fs::create_dir_all(project.join("memory")).expect("memory dir");
fs::create_dir_all(project.join(".agent")).expect("agent dir");
fs::write(project.join("game/index.html"), "<!doctype html>").expect("index");
fs::write(project.join("game/style.css"), "body {}").expect("game style");
fs::write(project.join("assets/player.png"), b"png").expect("asset");
fs::write(project.join("memory/project.md"), "secret memory").expect("memory");
fs::write(project.join(".agent/run.latest.json"), "{}").expect("trace");
assert_eq!(
resolve_preview_path(&project, "/").unwrap(),
project
.join("game/index.html")
.canonicalize()
.expect("canonical index")
);
assert!(resolve_preview_path(&project, "/../secret.txt").is_err());
assert!(resolve_preview_path(&project, "/%2e%2e/secret.txt").is_err());
assert!(resolve_preview_path(&project, "/memory/project.md").is_err());
assert!(resolve_preview_path(&project, "/.agent/run.latest.json").is_err());
assert_eq!(
resolve_preview_path(&project, "/assets/player.png?cache=1").unwrap(),
project
.join("assets/player.png")
.canonicalize()
.expect("canonical asset")
);
assert_eq!(
resolve_preview_path(&project, "/style.css?cache=1").unwrap(),
project
.join("game/style.css")
.canonicalize()
.expect("canonical game style")
);
fs::remove_dir_all(project).ok();
}
#[cfg(unix)]
#[test]
fn preview_path_rejects_symlink_escape() {
use std::os::unix::fs::symlink;
let project = unique_project_path();
let outside_secret = project.with_extension("secret.txt");
fs::create_dir_all(project.join("game")).expect("game dir");
fs::create_dir_all(project.join("assets")).expect("assets dir");
fs::write(project.join("game/index.html"), "<!doctype html>").expect("index");
fs::write(&outside_secret, "secret").expect("secret");
symlink(&outside_secret, project.join("assets/leak.txt")).expect("symlink");
assert!(resolve_preview_path(&project, "/assets/leak.txt").is_err());
fs::remove_file(outside_secret).ok();
fs::remove_dir_all(project).ok();
}
#[cfg(unix)]
#[test]
fn preview_path_rejects_symlink_to_private_project_dirs() {
use std::os::unix::fs::symlink;
let project = unique_project_path();
fs::create_dir_all(project.join("game")).expect("game dir");
fs::create_dir_all(project.join("assets")).expect("assets dir");
fs::create_dir_all(project.join("memory")).expect("memory dir");
fs::write(project.join("game/index.html"), "<!doctype html>").expect("index");
fs::write(project.join("memory/project.md"), "secret memory").expect("memory");
symlink(
project.join("memory/project.md"),
project.join("assets/memory-link.md"),
)
.expect("memory symlink");
assert!(resolve_preview_path(&project, "/assets/memory-link.md").is_err());
fs::remove_dir_all(project).ok();
}
#[cfg(unix)]
#[test]
fn preview_path_rejects_symlinked_allowed_root_dir() {
use std::os::unix::fs::symlink;
let project = unique_project_path();
fs::create_dir_all(project.join("game")).expect("game dir");
fs::create_dir_all(project.join("memory")).expect("memory dir");
fs::write(project.join("game/index.html"), "<!doctype html>").expect("index");
fs::write(project.join("memory/project.md"), "secret memory").expect("memory");
symlink(project.join("memory"), project.join("assets")).expect("assets symlink");
assert!(resolve_preview_path(&project, "/assets/project.md").is_err());
fs::remove_dir_all(project).ok();
}
#[test]
fn launcher_window_uses_launcher_route() {
assert_eq!(launcher_window_url().to_string(), "index.html?launcher");
}
#[test]
fn workspace_window_url_carries_encoded_project_path() {
assert_eq!(
workspace_window_url("/tmp/AI Game 项目").to_string(),
"index.html?main&projectPath=%2Ftmp%2FAI%20Game%20%E9%A1%B9%E7%9B%AE"
);
}
#[test]
fn workspace_window_project_path_requires_absolute_path() {
let absolute = std::env::temp_dir().join("game");
let padded_absolute = format!(" {} ", absolute.display());
assert!(validate_workspace_window_project_path(&padded_absolute).is_ok());
assert!(validate_workspace_window_project_path("relative-game")
.expect_err("relative path should be rejected")
.contains("绝对路径"));
assert!(validate_workspace_window_project_path(" ")
.expect_err("empty path should be rejected")
.contains("绝对路径"));
let control_character_path = format!("{}\nnext", absolute.display());
assert!(
validate_workspace_window_project_path(&control_character_path)
.expect_err("control character path should be rejected")
.contains("控制字符")
);
}
#[test]
fn cli_agent_run_requires_project_and_prompt() {
assert_eq!(
parse_cli_command(&["--llm-status".to_string()])
.expect("parse llm status")
.expect("llm status command"),
CliCommand::LlmStatus
);
let args = vec![
"--agent-run".to_string(),
"/tmp/genarrative-cli-game".to_string(),
"做一个弹幕厨房".to_string(),
"带反弹".to_string(),
];
let command = parse_cli_command(&args)
.expect("parse cli")
.expect("cli command");
assert_eq!(
command,
CliCommand::AgentRun {
project_path: PathBuf::from("/tmp/genarrative-cli-game"),
prompt: "做一个弹幕厨房 带反弹".to_string(),
wait_for_enter: true,
}
);
let agent_chat = parse_cli_command(&[
"--agent-chat".to_string(),
"/tmp/genarrative-cli-game".to_string(),
"art-director".to_string(),
"我要生成一个开罗风格的 dota".to_string(),
])
.expect("parse agent chat")
.expect("agent chat command");
assert_eq!(
agent_chat,
CliCommand::AgentChat {
project_path: PathBuf::from("/tmp/genarrative-cli-game"),
agent_id: "art-director".to_string(),
prompt: "我要生成一个开罗风格的 dota".to_string(),
}
);
let agent_task = parse_cli_command(&[
"--agent-task".to_string(),
"--init".to_string(),
"/tmp/genarrative-cli-game".to_string(),
"code-prototype".to_string(),
"修复失败测试".to_string(),
"并完成验证".to_string(),
])
.expect("parse agent task")
.expect("agent task command");
assert_eq!(
agent_task,
CliCommand::AgentTask {
project_path: PathBuf::from("/tmp/genarrative-cli-game"),
agent_id: "code-prototype".to_string(),
task: "修复失败测试 并完成验证".to_string(),
initialize: true,
}
);
let root = unique_project_path();
init_local_game_project_at(&root, "cli-supervisor-source", "CLI Supervisor 来源")
.expect("init CLI Supervisor project");
let started = start_cli_agent_task_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
"检查项目",
"cli-supervisor-source-run",
)
.expect("start CLI Supervisor task");
assert_eq!(started.state.source, AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE);
assert_eq!(
started.state.run_profile,
AGENT_RUNTIME_RUN_PROFILE_STANDARD
);
cancel_game_creator_agent_runtime_task_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
"cli-supervisor-source-run",
)
.expect("cancel CLI Supervisor source task");
wait_for_agent_runtime_terminal_and_lane_release(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
"cli-supervisor-source-run",
"cancelled",
"cancelled",
);
fs::remove_dir_all(&root).ok();
let agent_enqueue = parse_cli_command(&[
"--agent-enqueue".to_string(),
"--init".to_string(),
"/tmp/genarrative-cli-game".to_string(),
"code-prototype".to_string(),
"explicit-run-1".to_string(),
"执行真实".to_string(),
"E2E".to_string(),
])
.expect("parse agent enqueue")
.expect("agent enqueue command");
assert_eq!(
agent_enqueue,
CliCommand::AgentEnqueue {
project_path: PathBuf::from("/tmp/genarrative-cli-game"),
agent_id: "code-prototype".to_string(),
run_id: "explicit-run-1".to_string(),
task: "执行真实 E2E".to_string(),
initialize: true,
}
);
assert_eq!(
parse_cli_command(&[
"--agent-runtime-status".to_string(),
"/tmp/genarrative-cli-game".to_string(),
"code-prototype".to_string(),
])
.expect("parse runtime status")
.expect("runtime status command"),
CliCommand::AgentRuntimeStatus {
project_path: PathBuf::from("/tmp/genarrative-cli-game"),
agent_id: "code-prototype".to_string(),
}
);
assert_eq!(
parse_cli_command(&[
"--agent-confirm".to_string(),
"/tmp/genarrative-cli-game".to_string(),
"code-prototype".to_string(),
"explicit-run-1".to_string(),
"action-1".to_string(),
])
.expect("parse agent confirm")
.expect("agent confirm command"),
CliCommand::AgentConfirm {
project_path: PathBuf::from("/tmp/genarrative-cli-game"),
agent_id: "code-prototype".to_string(),
run_id: "explicit-run-1".to_string(),
action_id: "action-1".to_string(),
}
);
assert_eq!(
parse_cli_command(&[
"--agent-resume".to_string(),
"/tmp/genarrative-cli-game".to_string(),
])
.expect("parse agent resume")
.expect("agent resume command"),
CliCommand::AgentResume {
project_path: PathBuf::from("/tmp/genarrative-cli-game"),
}
);
assert_eq!(
parse_cli_command(&["--runner-status".to_string()])
.expect("parse runner status")
.expect("runner status command"),
CliCommand::RunnerStatus
);
let no_wait = parse_cli_command(&[
"--agent-run".to_string(),
"--no-wait".to_string(),
"/tmp/genarrative-cli-game".to_string(),
"做一个弹幕厨房".to_string(),
])
.expect("parse no wait")
.expect("cli command");
assert_eq!(
no_wait,
CliCommand::AgentRun {
project_path: PathBuf::from("/tmp/genarrative-cli-game"),
prompt: "做一个弹幕厨房".to_string(),
wait_for_enter: false,
}
);
assert!(parse_cli_command(&[]).expect("parse no cli").is_none());
assert!(parse_cli_command(&["--agent-run".to_string()]).is_err());
assert!(parse_cli_command(&["--agent-chat".to_string()]).is_err());
assert!(parse_cli_command(&["--agent-task".to_string()]).is_err());
assert!(parse_cli_command(&["--agent-enqueue".to_string()]).is_err());
assert!(parse_cli_command(&["--agent-runtime-status".to_string()]).is_err());
assert!(parse_cli_command(&["--agent-confirm".to_string()]).is_err());
assert!(parse_cli_command(&["--agent-resume".to_string()]).is_err());
}
#[test]
fn cli_runtime_and_runner_status_are_pure_read_commands() {
let commands = [
CliCommand::AgentRuntimeStatus {
project_path: PathBuf::from("/tmp/genarrative-cli-status-project"),
agent_id: "code-prototype".to_string(),
},
CliCommand::RunnerStatus,
];
for command in commands {
assert!(command.is_read_only_status());
assert!(!command.requires_external_agent_runner());
}
}
#[test]
fn cli_agent_resume_without_appdata_fails_before_runtime_dispatch() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "CLI Runner 必填测试").expect("project init");
let error = run_cli_command(CliCommand::AgentResume {
project_path: root.clone(),
})
.expect_err("runtime write command without AppData must fail");
assert!(error.contains("--config-dir") || error.contains("AppData"));
fs::remove_dir_all(root).ok();
}
#[test]
fn local_preview_server_serves_game_index() {
let root = unique_project_path();
init_existing_html_project_at(&root, "project-1", "像素动作原型").expect("project init");
fs::write(root.join("assets/player.png"), b"PNGDATA").expect("asset");
let (preview, stop) = start_local_game_preview_for_project(&root).expect("preview start");
let mut stream = TcpStream::connect(("127.0.0.1", preview.port)).expect("preview connect");
stream
.write_all(b"GET / HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n")
.expect("request");
let mut response = String::new();
stream.read_to_string(&mut response).expect("response");
assert!(response.contains("200 OK"), "{response}");
assert!(response.contains("还没有生成游戏"));
assert!(
response.contains("/__genarrative/local-preview-fit.js"),
"{response}"
);
let mut stream = TcpStream::connect(("127.0.0.1", preview.port)).expect("preview connect");
stream
.write_all(b"GET /__genarrative/local-preview-fit.js HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n")
.expect("fit bridge request");
let mut response = String::new();
stream
.read_to_string(&mut response)
.expect("fit bridge response");
assert!(response.contains("200 OK"), "{response}");
assert!(
response.contains("Content-Type: text/javascript; charset=utf-8"),
"{response}"
);
assert!(
response.contains("genarrative.local-preview-size.v1"),
"{response}"
);
let mut stream = TcpStream::connect(("127.0.0.1", preview.port)).expect("preview connect");
stream
.write_all(b"GET /assets/player.png HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n")
.expect("asset request");
let mut response = String::new();
stream
.read_to_string(&mut response)
.expect("asset response");
assert!(response.contains("200 OK"), "{response}");
assert!(response.contains("PNGDATA"), "{response}");
assert_eq!(preview.root, root.to_string_lossy());
let _ = stop.send(());
fs::remove_dir_all(root).ok();
}
fn local_preview_html_response_body(root: &Path, html: &str) -> String {
fs::create_dir_all(root.join("game")).expect("game dir");
fs::write(root.join("game/index.html"), html).expect("write preview html");
let response = build_preview_response(root, "GET", "/");
let header_end = response
.windows(b"\r\n\r\n".len())
.position(|window| window == b"\r\n\r\n")
.expect("complete preview response headers")
+ b"\r\n\r\n".len();
String::from_utf8(response[header_end..].to_vec()).expect("UTF-8 preview HTML")
}
#[test]
fn local_preview_fit_bridge_only_publishes_changed_layout_measurements() {
let root = unique_project_path();
let response = String::from_utf8(build_preview_response(
&root,
"GET",
"/__genarrative/local-preview-fit.js",
))
.expect("fit bridge response");
assert!(
response.contains("let lastPublishedSize = ''"),
"{response}"
);
assert!(
response.contains("if (sizeKey === lastPublishedSize) return"),
"{response}"
);
assert!(
response.contains("new ResizeObserver(schedule)"),
"{response}"
);
assert!(
response.contains("document.addEventListener('load', schedule, true)"),
"{response}"
);
assert!(
response.contains("document.fonts?.ready.then(schedule)"),
"{response}"
);
assert!(
response.contains("const maxContentProbes = 512"),
"{response}"
);
assert!(
response.contains("if (!document.hidden) schedule()"),
"{response}"
);
assert!(response.contains("}, 500)"), "{response}");
assert!(response.contains("truncated || !bodyCoupled"), "{response}");
assert!(
response.contains("extentDelta * viewportDelta > 0"),
"{response}"
);
assert!(
response.contains("previous?.edgeBaseline ?? previous?.baseline ?? previous?.edge ?? edge"),
"{response}"
);
assert!(
response.contains("previous?.sizeBaseline\n ?? previous?.baseline"),
"{response}"
);
assert!(
response.contains("previous?.minimumBaseline\n ?? previous?.baseline"),
"{response}"
);
assert!(
response.contains("element, widthExtent, heightExtent, viewportWidth, viewportHeight"),
"{response}"
);
assert!(
response.contains("heightExtent, style.height, style.minHeight, viewportHeight"),
"{response}"
);
assert!(
response.contains("truncated: element !== null"),
"{response}"
);
assert!(
response.contains("intrinsicBodyWidth, bodyCoupling.width,"),
"{response}"
);
assert!(
response.contains("const rootRect = root?.getBoundingClientRect()"),
"{response}"
);
assert!(
response.contains("intrinsicRootHeight, rootCoupling.height || rootHeightMirrorsBody,"),
"{response}"
);
assert!(response.contains("rootHeightMirrorsBody"), "{response}");
assert!(response.contains("rootIndependentLayout"), "{response}");
assert!(response.contains("computedStyleMap?.()"), "{response}");
assert!(
response.contains("rootHeightMirrorsBody ? 0 : rootCoupling.heightBaseline || 0"),
"{response}"
);
assert!(
response.contains("contentResolutionState.width, viewportWidth, scrollWidth"),
"{response}"
);
assert!(
response.contains("contentResolutionState.height, viewportHeight, scrollHeight"),
"{response}"
);
assert!(
response.contains("? Math.max(coupledBaseline || 0, state.resolved || 1)"),
"{response}"
);
assert!(
response.contains("resetLayoutCouplingSamples(state.axis)"),
"{response}"
);
assert!(
response.contains("state.responsiveCandidateCount >= 2"),
"{response}"
);
assert!(
response.contains("} else if (candidateContradicted)"),
"{response}"
);
assert!(
response.contains("viewportChanged && !hostAppliedPrevious"),
"{response}"
);
assert!(!response.contains("MutationObserver"), "{response}");
assert!(!response.contains("childList: true"), "{response}");
let resolve_intrinsic_extent =
|body: u32, body_coupled: bool, content: u32, truncated: bool| {
if truncated || !body_coupled {
body.max(content)
} else {
content
}
};
let resolve_document_extent = |root: u32,
root_coupled: bool,
root_mirrors_body: bool,
body: u32,
body_coupled: bool,
content: u32,
truncated: bool| {
let body_and_content = resolve_intrinsic_extent(body, body_coupled, content, truncated);
resolve_intrinsic_extent(
root,
root_coupled || root_mirrors_body,
body_and_content,
truncated,
)
};
let resolve_document_baseline =
|root_baseline: i32, root_mirrors_body: bool, body_baseline: i32, content_baseline: i32| {
(if root_mirrors_body { 0 } else { root_baseline })
.max(body_baseline)
.max(content_baseline)
};
let root_mirrors_body = |same_extent: bool,
typed_size: Option<&str>,
typed_minimum: Option<&str>,
independent_latch: &mut bool| {
let typed_available = typed_size.is_some() || typed_minimum.is_some();
if typed_available {
let size_is_independent = typed_size.is_some_and(|value| value != "auto");
let minimum_is_independent = typed_minimum
.is_some_and(|value| value != "auto" && value != "0px" && value != "0%");
*independent_latch = size_is_independent || minimum_is_independent;
} else if !same_extent {
*independent_latch = true;
}
same_extent && !*independent_latch
};
let resolve_viewport_coupling = |extent: i32,
viewport: i32,
previous_extent: i32,
previous_viewport: i32,
previous_coupled: bool| {
let viewport_delta = viewport - previous_viewport;
if viewport_delta.abs() < 1 {
if (extent - previous_extent).abs() < 1 {
previous_coupled
} else {
false
}
} else {
let extent_delta = extent - previous_extent;
extent_delta.abs() >= 1 && extent_delta * viewport_delta > 0
}
};
let fixed_body_edge_coupled = resolve_viewport_coupling(2_000, 2_000, 2_000, 700, false);
assert!(!fixed_body_edge_coupled);
let viewport_min_height_coupled = resolve_viewport_coupling(2_000, 2_000, 700, 700, false);
assert!(viewport_min_height_coupled);
let bottom_edge_coupled = resolve_viewport_coupling(2_000, 2_000, 700, 700, false);
assert!(bottom_edge_coupled);
assert!(resolve_viewport_coupling(2_000, 2_000, 2_000, 2_000, true));
assert!(!resolve_viewport_coupling(1_000, 2_000, 2_000, 2_000, true));
assert_eq!(resolve_intrinsic_extent(2_000, true, 1_000, false), 1_000);
assert_eq!(resolve_intrinsic_extent(2_000, true, 0, false), 0);
assert_eq!(resolve_intrinsic_extent(2_000, true, 0, false).max(1), 1);
assert_eq!(resolve_intrinsic_extent(2_000, false, 1_000, false), 2_000);
assert_eq!(resolve_intrinsic_extent(2_000, false, 2_000, false), 2_000);
assert_eq!(resolve_intrinsic_extent(10_000, true, 5_120, true), 10_000);
assert_eq!(
resolve_document_extent(2_000, false, false, 1_000, false, 1_000, false),
2_000,
"a fixed documentElement extent must survive body/descendant aggregation"
);
assert_eq!(
resolve_document_extent(2_000, false, true, 2_000, true, 1_000, false),
1_000,
"a documentElement extent mirrored from a responsive body must not become a sticky floor"
);
assert_eq!(
resolve_document_extent(2_000, false, true, 2_000, false, 1_000, false),
2_000,
"equal fixed root/body extents must be preserved by the non-coupled body"
);
let mut typed_root_independent = false;
assert!(!root_mirrors_body(
true,
Some("2000px"),
Some("0px"),
&mut typed_root_independent,
));
assert_eq!(
resolve_document_extent(2_000, false, false, 2_000, true, 1_000, false),
2_000,
"a fixed root equal to a coupled body must remain independently authoritative"
);
let mut typed_root_auto = false;
assert!(root_mirrors_body(
true,
Some("auto"),
Some("0px"),
&mut typed_root_auto,
));
let mut fallback_root_independent = false;
assert!(root_mirrors_body(
true,
None,
None,
&mut fallback_root_independent,
));
assert!(!root_mirrors_body(
false,
None,
None,
&mut fallback_root_independent,
));
assert!(!root_mirrors_body(
true,
None,
None,
&mut fallback_root_independent,
));
#[derive(Default)]
struct ResolutionState {
viewport: Option<i32>,
scroll: Option<i32>,
resolved: Option<i32>,
responsive_floor: Option<i32>,
responsive: bool,
responsive_candidate_count: u8,
responsive_candidate_floor: Option<i32>,
responsive_candidate_direction: i32,
}
let resolve_content_extent = |state: &mut ResolutionState,
viewport: i32,
scroll: i32,
intrinsic: i32,
probed: i32,
truncated: bool,
coupled_baseline: Option<i32>| {
let viewport_changed = state
.viewport
.is_some_and(|previous| (viewport - previous).abs() >= 1);
let host_applied_previous = state
.resolved
.is_some_and(|previous| (viewport - previous).abs() <= 1);
let native_reset = viewport_changed && !host_applied_previous;
if native_reset {
state.responsive = false;
state.responsive_floor = None;
state.responsive_candidate_count = 0;
state.responsive_candidate_floor = None;
state.responsive_candidate_direction = 0;
}
let viewport_delta = state.viewport.map_or(0, |previous| viewport - previous);
let scroll_delta = state.scroll.map_or(0, |previous| scroll - previous);
let responsive_scroll = !native_reset
&& host_applied_previous
&& viewport_delta.abs() >= 1
&& scroll_delta.abs() >= 1
&& viewport_delta * scroll_delta > 0;
let candidate_contradicted = !state.responsive
&& state.responsive_candidate_count > 0
&& ((viewport_changed && host_applied_previous && !responsive_scroll)
|| (scroll_delta.abs() >= 1
&& state.responsive_candidate_direction * scroll_delta < 0));
if responsive_scroll && !state.responsive {
let candidate_floor = if truncated {
Some(
coupled_baseline
.unwrap_or(0)
.max(state.resolved.unwrap_or(1)),
)
} else {
coupled_baseline.or(state.resolved)
};
if truncated {
state.responsive = true;
state.responsive_floor = candidate_floor;
state.responsive_candidate_count = 0;
state.responsive_candidate_floor = None;
state.responsive_candidate_direction = 0;
} else {
if state.responsive_candidate_count == 0 {
state.responsive_candidate_direction = viewport_delta.signum();
}
state.responsive_candidate_count += 1;
state.responsive_candidate_floor =
state.responsive_candidate_floor.or(candidate_floor);
if state.responsive_candidate_count >= 2 {
state.responsive = true;
state.responsive_floor = state.responsive_candidate_floor;
state.responsive_candidate_count = 0;
state.responsive_candidate_floor = None;
state.responsive_candidate_direction = 0;
}
}
} else if candidate_contradicted {
state.responsive_candidate_count = 0;
state.responsive_candidate_floor = None;
state.responsive_candidate_direction = 0;
}
let resolved = if state.responsive {
state
.responsive_floor
.unwrap_or(1)
.max(if truncated { probed } else { intrinsic })
} else if scroll > viewport {
scroll
} else {
intrinsic
};
if state.responsive && truncated {
state.responsive_floor = Some(state.responsive_floor.unwrap_or(1).max(resolved));
}
state.viewport = Some(viewport);
state.scroll = Some(scroll);
state.resolved = Some(resolved);
resolved
};
let mut proportional_vh = ResolutionState::default();
assert_eq!(
resolve_content_extent(&mut proportional_vh, 700, 840, 840, 0, false, None),
840
);
assert_eq!(
resolve_content_extent(&mut proportional_vh, 840, 1_008, 0, 0, false, Some(840),),
1_008
);
assert_eq!(
resolve_content_extent(&mut proportional_vh, 840, 1_008, 0, 0, false, Some(840),),
1_008
);
assert_eq!(
resolve_content_extent(&mut proportional_vh, 1_008, 1_210, 0, 0, false, Some(840),),
840
);
assert_eq!(
resolve_content_extent(&mut proportional_vh, 840, 1_008, 840, 840, false, Some(840),),
840
);
let mut calculated_vh = ResolutionState::default();
assert_eq!(
resolve_content_extent(&mut calculated_vh, 700, 900, 900, 0, false, None),
900
);
assert_eq!(
resolve_content_extent(&mut calculated_vh, 900, 1_100, 0, 0, false, Some(900),),
1_100
);
assert_eq!(
resolve_content_extent(&mut calculated_vh, 1_100, 1_300, 0, 0, false, Some(900),),
900
);
let mut fixed = ResolutionState::default();
assert_eq!(
resolve_content_extent(&mut fixed, 700, 2_000, 2_000, 2_000, false, None),
2_000
);
assert_eq!(
resolve_content_extent(&mut fixed, 2_000, 2_000, 2_000, 2_000, false, None),
2_000
);
let mut fixed_root = ResolutionState::default();
let native_fixed_root =
resolve_document_extent(2_000, false, false, 1_000, false, 1_000, false) as i32;
assert_eq!(
resolve_content_extent(
&mut fixed_root,
700,
2_000,
native_fixed_root,
1_000,
false,
None,
),
2_000
);
let fitted_fixed_root =
resolve_document_extent(2_000, false, false, 1_000, false, 1_000, false) as i32;
assert_eq!(
resolve_content_extent(
&mut fixed_root,
2_000,
2_000,
fitted_fixed_root,
1_000,
false,
None,
),
2_000,
"fitting the viewport must not discard a fixed documentElement extent"
);
let mut fixed_grows_during_fit = ResolutionState::default();
assert_eq!(
resolve_content_extent(
&mut fixed_grows_during_fit,
700,
2_000,
2_000,
2_000,
false,
None,
),
2_000
);
assert_eq!(
resolve_content_extent(
&mut fixed_grows_during_fit,
2_000,
2_500,
0,
0,
false,
Some(2_000),
),
2_500
);
assert_eq!(
resolve_content_extent(
&mut fixed_grows_during_fit,
2_500,
2_500,
2_500,
2_500,
false,
None,
),
2_500
);
assert_eq!(
resolve_content_extent(
&mut proportional_vh,
840,
1_500,
1_500,
1_500,
false,
Some(840),
),
1_500
);
assert_eq!(
resolve_content_extent(
&mut proportional_vh,
1_500,
1_800,
1_500,
1_500,
false,
Some(840),
),
1_500
);
assert_eq!(
resolve_content_extent(
&mut proportional_vh,
1_500,
1_800,
500,
500,
false,
Some(840),
),
840
);
assert_eq!(
resolve_content_extent(&mut proportional_vh, 800, 960, 960, 960, false, None,),
960
);
assert_eq!(
resolve_content_extent(&mut proportional_vh, 960, 1_152, 0, 0, false, None,),
1_152
);
assert_eq!(
resolve_content_extent(&mut proportional_vh, 1_152, 1_382, 0, 0, false, Some(960),),
960
);
let mut min_height = ResolutionState::default();
assert_eq!(
resolve_content_extent(&mut min_height, 700, 2_000, 2_000, 2_000, false, None),
2_000
);
assert_eq!(
resolve_content_extent(
&mut min_height,
2_000,
2_000,
2_000,
2_000,
false,
Some(700),
),
2_000
);
assert_eq!(
resolve_content_extent(
&mut min_height,
2_000,
2_000,
1_000,
1_000,
false,
Some(700),
),
1_000
);
assert_eq!(
resolve_content_extent(
&mut min_height,
1_000,
1_000,
1_000,
1_000,
false,
Some(700),
),
1_000
);
assert_eq!(
resolve_content_extent(&mut min_height, 1_000, 1_000, 700, 500, false, Some(700),),
700
);
assert_eq!(
resolve_content_extent(&mut min_height, 700, 700, 700, 500, false, Some(700),),
700
);
let mut mirrored_root_min_height = ResolutionState::default();
let native_mirrored_intrinsic =
resolve_document_extent(2_000, false, true, 2_000, false, 2_000, false) as i32;
assert_eq!(
resolve_content_extent(
&mut mirrored_root_min_height,
700,
2_000,
native_mirrored_intrinsic,
2_000,
false,
None,
),
2_000
);
let fitted_mirrored_baseline = resolve_document_baseline(0, true, 700, 0);
let fitted_mirrored_intrinsic = fitted_mirrored_baseline
.max(resolve_document_extent(2_000, false, true, 2_000, true, 2_000, false) as i32);
assert_eq!(
resolve_content_extent(
&mut mirrored_root_min_height,
2_000,
2_000,
fitted_mirrored_intrinsic,
2_000,
false,
Some(fitted_mirrored_baseline),
),
2_000
);
let shrunk_mirrored_intrinsic = fitted_mirrored_baseline
.max(resolve_document_extent(2_000, false, true, 2_000, true, 1_000, false) as i32);
assert_eq!(
resolve_content_extent(
&mut mirrored_root_min_height,
2_000,
2_000,
shrunk_mirrored_intrinsic,
1_000,
false,
Some(fitted_mirrored_baseline),
),
1_000
);
let host_applied_mirrored_baseline = resolve_document_baseline(2_000, true, 700, 0);
let host_applied_mirrored_intrinsic = host_applied_mirrored_baseline
.max(resolve_document_extent(1_000, true, true, 1_000, true, 1_000, false) as i32);
assert_eq!(
resolve_content_extent(
&mut mirrored_root_min_height,
1_000,
1_000,
host_applied_mirrored_intrinsic,
1_000,
false,
Some(host_applied_mirrored_baseline),
),
1_000,
"applying the shrink must not restore the mirrored root's old fitted baseline"
);
let mut truncated_responsive = ResolutionState::default();
assert_eq!(
resolve_content_extent(
&mut truncated_responsive,
700,
10_000,
10_000,
5_120,
false,
None,
),
10_000
);
assert_eq!(
resolve_content_extent(
&mut truncated_responsive,
10_000,
12_000,
5_120,
5_120,
true,
Some(700),
),
10_000
);
assert_eq!(
resolve_content_extent(
&mut truncated_responsive,
10_000,
12_000,
1_000,
1_000,
true,
Some(700),
),
10_000
);
}
#[test]
fn local_preview_bridge_injection_uses_real_html_context_and_supports_omitted_end_tags() {
let bridge = r#"<script src="/__genarrative/local-preview-fit.js"></script>"#;
let cases = [
(
"missing body and html end tags",
"<!doctype html><html><body><main>game</main>",
format!("<!doctype html><html><body><main>game</main>{bridge}"),
),
(
"doctype greater-than ends the token even inside a public identifier quote",
"<!DOCTYPE html PUBLIC \"x > </body> y\"><body><main>game</main></body>",
format!(
"<!DOCTYPE html PUBLIC \"x > {bridge}</body> y\"><body><main>game</main></body>"
),
),
(
"malformed doctype quote before script body decoy",
"<!doctype html '><body><script>const a='>'; const b='</body>';</script><main>game</main></body>",
format!(
"<!doctype html '><body><script>const a='>'; const b='</body>';</script><main>game</main>{bridge}</body>"
),
),
(
"public identifier remainder contains a script body decoy",
"<!DOCTYPE html PUBLIC \"x > <script>const bait='</body>';</script> y\"><body><main>game</main></body>",
format!(
"<!DOCTYPE html PUBLIC \"x > <script>const bait='</body>';</script> y\"><body><main>game</main>{bridge}</body>"
),
),
(
"doctype keyword accepts a missing whitespace parse error",
"<!DOCTYPEhtml><body><script>const bait='</body>';</script><main>game</main></body>",
format!(
"<!DOCTYPEhtml><body><script>const bait='</body>';</script><main>game</main>{bridge}</body>"
),
),
(
"script string body decoy",
"<!doctype html><body><script>const bait = '</body>';</script><main>game</main>",
format!(
"<!doctype html><body><script>const bait = '</body>';</script><main>game</main>{bridge}"
),
),
(
"script double escaped body decoy",
"<!doctype html><body><main>x</main><script><!--<script></script></body></script><p>after</p></body>",
format!(
"<!doctype html><body><main>x</main>{bridge}<script><!--<script></script></body></script><p>after</p></body>"
),
),
(
"trailing comment body decoy",
"<!doctype html><body><main>game</main><!-- fake </body> -->",
format!(
"<!doctype html><body><main>game</main><!-- fake </body> -->{bridge}"
),
),
(
"comment end bang before script body decoy",
"<!doctype html><body><!--x--!><script>const a='-->'; const b='</body>';</script><main>game</main>",
format!(
"<!doctype html><body><!--x--!><script>const a='-->'; const b='</body>';</script><main>game</main>{bridge}"
),
),
(
"template body decoy",
"<!doctype html><body><template><section></body></section></template><main>game</main>",
format!(
"<!doctype html><body><template><section></body></section></template><main>game</main>{bridge}"
),
),
(
"raw text body decoy",
"<!doctype html><body><style>.bait::after{content:'</body>'}</style><main>game</main>",
format!(
"<!doctype html><body><style>.bait::after{{content:'</body>'}}</style><main>game</main>{bridge}"
),
),
(
"case insensitive real end tags",
"<!doctype html><HTML><BoDy><main>game</main></bOdY></hTmL>",
format!(
"<!doctype html><HTML><BoDy><main>game</main>{bridge}</bOdY></hTmL>"
),
),
];
for (label, input, expected) in cases {
let root = unique_project_path();
let actual = local_preview_html_response_body(&root, input);
assert_eq!(actual, expected, "{label}");
assert_eq!(actual.matches(bridge).count(), 1, "{label}");
fs::remove_dir_all(root).ok();
}
}
#[test]
fn local_preview_bridge_injection_is_idempotent_and_ignores_inert_path_decoys() {
let bridge = r#"<script src="/__genarrative/local-preview-fit.js"></script>"#;
let root = unique_project_path();
let first = local_preview_html_response_body(
&root,
"<!doctype html><body><script>const bait='/__genarrative/local-preview-fit.js';</script><template>/__genarrative/local-preview-fit.js</template><main>game</main></body>",
);
assert_eq!(first.matches(bridge).count(), 1, "{first}");
assert!(first.contains(&format!("<main>game</main>{bridge}</body>")));
let second = local_preview_html_response_body(&root, &first);
assert_eq!(second, first);
assert_eq!(second.matches(bridge).count(), 1, "{second}");
fs::remove_dir_all(root).ok();
}
#[test]
fn local_preview_bridge_injection_uses_the_first_duplicate_src_attribute() {
let bridge = r#"<script src="/__genarrative/local-preview-fit.js"></script>"#;
let root = unique_project_path();
let actual = local_preview_html_response_body(
&root,
"<!doctype html><body><script src=\"/game.js\" src=\"/__genarrative/local-preview-fit.js\"></script><main>game</main></body>",
);
assert!(actual.contains(&format!("<main>game</main>{bridge}</body>")));
assert_eq!(actual.matches(bridge).count(), 1, "{actual}");
fs::remove_dir_all(root).ok();
}
#[test]
fn local_preview_bridge_injection_ignores_inert_script_src_decoys() {
let bridge = r#"<script src="/__genarrative/local-preview-fit.js"></script>"#;
let cases = [
(
"non executable type",
"<script type=\"application/json\" src=\"/__genarrative/local-preview-fit.js\"></script>",
),
(
"nomodule classic script",
"<script nomodule src=\"/__genarrative/local-preview-fit.js\"></script>",
),
];
for (label, decoy) in cases {
let root = unique_project_path();
let actual = local_preview_html_response_body(
&root,
&format!("<!doctype html><body>{decoy}<main>game</main></body>"),
);
assert!(
actual.contains(&format!("<main>game</main>{bridge}</body>")),
"{label}"
);
assert_eq!(
actual
.matches("/__genarrative/local-preview-fit.js")
.count(),
2,
"{label}"
);
fs::remove_dir_all(root).ok();
}
}
#[test]
fn local_preview_bridge_injection_stays_outside_unclosed_inert_html_contexts() {
let bridge = r#"<script src="/__genarrative/local-preview-fit.js"></script>"#;
let cases = [
(
"comment",
"<!doctype html><body><main>game</main><!-- fake </body>",
format!("<!doctype html><body><main>game</main>{bridge}<!-- fake </body>"),
),
(
"script",
"<!doctype html><body><main>game</main><script>const bait='</body>';",
format!("<!doctype html><body><main>game</main>{bridge}<script>const bait='</body>';"),
),
(
"template",
"<!doctype html><body><main>game</main><template><section></body>",
format!("<!doctype html><body><main>game</main>{bridge}<template><section></body>"),
),
(
"raw text",
"<!doctype html><body><main>game</main><style>.bait{content:'</body>'}",
format!(
"<!doctype html><body><main>game</main>{bridge}<style>.bait{{content:'</body>'}}"
),
),
(
"plaintext",
"<!doctype html><body><main>game</main><plaintext>fake </body>",
format!("<!doctype html><body><main>game</main>{bridge}<plaintext>fake </body>"),
),
(
"abrupt empty comment",
"<!doctype html><body><main>game</main><!--><script>const bait='</body>';",
format!(
"<!doctype html><body><main>game</main><!-->{bridge}<script>const bait='</body>';"
),
),
(
"abrupt dash comment",
"<!doctype html><body><main>game</main><!---><script>const bait='</body>';",
format!(
"<!doctype html><body><main>game</main><!--->{bridge}<script>const bait='</body>';"
),
),
(
"unknown declaration",
"<!doctype html><body><main>game</main><![x]><script>const a=']]>'; const b='</body>';",
format!(
"<!doctype html><body><main>game</main>{bridge}<![x]><script>const a=']]>'; const b='</body>';"
),
),
(
"bogus comment declaration",
"<!doctype html><body><main>game</main><!x '><script>const a='>'; const b='</body>';",
format!(
"<!doctype html><body><main>game</main>{bridge}<!x '><script>const a='>'; const b='</body>';"
),
),
(
"svg namespace body decoy",
"<!doctype html><body><main>game</main><svg><text></body></text></svg><p>after</p></body>",
format!(
"<!doctype html><body><main>game</main>{bridge}<svg><text></body></text></svg><p>after</p></body>"
),
),
(
"math namespace body decoy",
"<!doctype html><body><main>game</main><math><mtext></body></mtext></math><p>after</p></body>",
format!(
"<!doctype html><body><main>game</main>{bridge}<math><mtext></body></mtext></math><p>after</p></body>"
),
),
(
"unclosed svg namespace",
"<!doctype html><body><main>game</main><svg><text>after",
format!("<!doctype html><body><main>game</main>{bridge}<svg><text>after"),
),
];
for (label, input, expected) in cases {
let root = unique_project_path();
let actual = local_preview_html_response_body(&root, input);
assert_eq!(actual, expected, "{label}");
fs::remove_dir_all(root).ok();
}
}
#[test]
fn local_preview_server_drains_split_browser_headers_before_response() {
let root = unique_project_path();
init_existing_html_project_at(&root, "project-1", "鍍忕礌鍔ㄤ綔鍘熷瀷").expect("project init");
let (preview, stop) = start_local_game_preview_for_project(&root).expect("preview start");
let mut stream = TcpStream::connect(("127.0.0.1", preview.port)).expect("preview connect");
stream
.set_read_timeout(Some(Duration::from_secs(2)))
.expect("read timeout");
stream
.write_all(b"GET / HTTP/1.1\r\n")
.expect("request line");
// Chromium may send the request line before the rest of its headers. Keep this split
// deliberate so the server must consume the complete header block before responding.
thread::sleep(Duration::from_millis(100));
stream
.write_all(
b"Host: 127.0.0.1\r\nConnection: close\r\nUser-Agent: Mozilla/5.0\r\nAccept: text/html\r\n\r\n",
)
.expect("browser headers");
let mut response = Vec::new();
stream.read_to_end(&mut response).expect("response");
let header_end = response
.windows(b"\r\n\r\n".len())
.position(|window| window == b"\r\n\r\n")
.expect("complete HTTP header block")
+ b"\r\n\r\n".len();
let headers = String::from_utf8(response[..header_end].to_vec()).expect("HTTP headers");
assert!(headers.starts_with("HTTP/1.1 200 OK\r\n"), "{headers}");
let content_length = headers
.lines()
.find_map(|line| line.strip_prefix("Content-Length: "))
.and_then(|value| value.parse::<usize>().ok())
.expect("valid content length");
assert_eq!(response.len() - header_end, content_length);
assert!(headers.contains("Connection: close"), "{headers}");
assert!(String::from_utf8_lossy(&response[header_end..]).contains("还没有生成游戏"));
let _ = stop.send(());
fs::remove_dir_all(root).ok();
}
#[test]
fn preview_listener_retries_transient_accept_errors() {
assert_eq!(
classify_preview_listener_accept_error(&std::io::Error::from(
std::io::ErrorKind::WouldBlock,
)),
PreviewListenerAcceptDisposition::Sleep
);
for kind in [
std::io::ErrorKind::ConnectionAborted,
std::io::ErrorKind::ConnectionReset,
std::io::ErrorKind::Interrupted,
std::io::ErrorKind::TimedOut,
] {
assert_eq!(
classify_preview_listener_accept_error(&std::io::Error::from(kind)),
PreviewListenerAcceptDisposition::Retry,
"transient accept error {kind:?} must keep preview server alive"
);
}
assert_eq!(
classify_preview_listener_accept_error(&std::io::Error::from(
std::io::ErrorKind::InvalidData,
)),
PreviewListenerAcceptDisposition::Stop
);
}
#[test]
fn preview_content_type_covers_common_game_assets() {
assert_eq!(content_type(Path::new("hero.webp")), "image/webp");
assert_eq!(content_type(Path::new("cover.jpg")), "image/jpeg");
assert_eq!(content_type(Path::new("bgm.mp3")), "audio/mpeg");
assert_eq!(content_type(Path::new("hit.wav")), "audio/wav");
assert_eq!(content_type(Path::new("intro.mp4")), "video/mp4");
}
#[test]
fn local_preview_head_preserves_asset_content_length() {
let root = unique_project_path();
init_existing_html_project_at(&root, "project-1", "像素动作原型").expect("project init");
fs::write(root.join("assets/player.png"), b"PNGDATA").expect("asset");
let response = build_preview_response(&root, "HEAD", "/assets/player.png");
let response = String::from_utf8(response).expect("head response");
assert!(response.contains("200 OK"), "{response}");
assert!(response.contains("Content-Type: image/png"), "{response}");
assert!(
response.contains("Cache-Control: no-store, no-cache, must-revalidate, max-age=0"),
"{response}"
);
assert!(response.contains("Pragma: no-cache"), "{response}");
assert!(response.contains("Expires: 0"), "{response}");
assert!(response.contains("Content-Length: 7"), "{response}");
assert!(!response.contains("PNGDATA"), "{response}");
assert!(response.ends_with("\r\n\r\n"), "{response}");
fs::remove_dir_all(root).ok();
}
#[test]
fn local_preview_project_revision_reports_the_current_atomic_sidecar() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "预览 revision 状态测试").expect("project init");
let revision = advance_project_revision_for_test(
&root,
"code-prototype",
"preview-revision-status-run",
"file.write",
);
let status =
get_local_game_project_revision_at(&root).expect("read local preview project revision");
assert_eq!(status.revision, revision);
fs::remove_dir_all(root).ok();
}
#[test]
fn local_preview_start_rejects_a_stale_validated_revision_atomically() {
let root = unique_project_path();
init_existing_html_project_at(&root, "project-1", "预览 revision 启动门禁测试")
.expect("project init");
let revision = advance_project_revision_for_test(
&root,
"code-prototype",
"preview-revision-start-run",
"file.write",
);
let registry = PreviewRegistry::default();
let error = start_local_game_preview_at_revision(&root, Some(revision + 1), &registry)
.expect_err("stale validated revision must not start a preview");
assert!(error.contains("已验证 revision"), "{error}");
assert!(error.contains(&revision.to_string()), "{error}");
assert_eq!(registry.status(), stopped_preview_status());
let preview = start_local_game_preview_at_revision(&root, Some(revision), &registry)
.expect("matching revision starts preview");
assert_eq!(registry.status().url.as_deref(), Some(preview.url.as_str()));
let _ = registry.stop();
fs::remove_dir_all(root).ok();
}
#[test]
fn stale_preview_cleanup_does_not_stop_a_newer_matching_project_server() {
let root = unique_project_path();
init_existing_html_project_at(&root, "project-1", "预览原子停止测试").expect("project init");
let registry = PreviewRegistry::default();
let (first, first_stop) =
start_local_game_preview_for_project(&root).expect("first preview start");
registry.set_running(first.clone(), first_stop);
let (second, second_stop) =
start_local_game_preview_for_project(&root).expect("second preview start");
registry.set_running(second.clone(), second_stop);
assert_ne!(first.url, second.url);
assert!(
!stop_local_game_preview_if_matches_at(&root, &first, &registry)
.expect("stale cleanup is a no-op")
);
let running = registry.status();
assert_eq!(running.status, "running");
assert_eq!(running.url.as_deref(), Some(second.url.as_str()));
assert_eq!(running.port, Some(second.port));
assert!(
stop_local_game_preview_if_matches_at(&root, &second, &registry)
.expect("matching cleanup stops current preview")
);
assert_eq!(registry.status(), stopped_preview_status());
fs::remove_dir_all(root).ok();
}
#[test]
fn stale_preview_cleanup_cannot_be_blocked_by_project_stop_policy() {
let root = unique_project_path();
init_existing_html_project_at(&root, "project-1", "预览补偿清理策略测试")
.expect("project init");
write_project_permission_policy_at(
&root,
ProjectPermissionPolicy {
denied_commands: vec!["preview.open".to_string(), "preview.stop".to_string()],
confirm_commands: Vec::new(),
agent_policies: BTreeMap::new(),
},
)
.expect("deny user preview open and stop commands");
let registry = PreviewRegistry::default();
let (preview, stop) =
start_local_game_preview_for_project(&root).expect("preview server start");
registry.set_running(preview.clone(), stop);
assert!(
stop_local_game_preview_if_matches_at(&root, &preview, &registry)
.expect("stale compensating cleanup")
);
assert_eq!(registry.status(), stopped_preview_status());
fs::remove_dir_all(root).ok();
}
#[test]
fn restarting_preview_for_the_same_project_keeps_the_running_record() {
let root = unique_project_path();
init_existing_html_project_at(&root, "project-1", "同项目重启预览记录测试")
.expect("project init");
let registry = PreviewRegistry::default();
let first = start_local_game_preview_at(&root, &registry).expect("first preview start");
let second = start_local_game_preview_at(&root, &registry).expect("second preview start");
assert_ne!(first.url, second.url, "重启必须换一个监听端口");
let manifest: Value =
serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap())
.expect("manifest json");
assert_eq!(
manifest["preview"]["status"], "running",
"同一个项目重启预览不能让旧预览的收尾把刚写进去的 running 覆盖成 stopped"
);
assert_eq!(manifest["preview"]["port"], second.port);
let _ = registry.stop();
fs::remove_dir_all(root).ok();
}
#[test]
fn replacing_preview_for_another_project_closes_the_previous_project_record() {
let first_root = unique_project_path();
let second_root = unique_project_path();
init_existing_html_project_at(&first_root, "project-1", "旧项目预览记录测试")
.expect("first project init");
init_existing_html_project_at(&second_root, "project-2", "新项目预览记录测试")
.expect("second project init");
let registry = PreviewRegistry::default();
let _first = start_local_game_preview_at(&first_root, &registry).expect("first preview");
let second = start_local_game_preview_at(&second_root, &registry).expect("second preview");
let previous_manifest: Value =
serde_json::from_str(&fs::read_to_string(first_root.join(".agent/manifest.json")).unwrap())
.expect("previous manifest json");
assert_eq!(
previous_manifest["preview"]["status"], "stopped",
"换项目时旧项目的落盘记录必须收尾"
);
let next_manifest: Value = serde_json::from_str(
&fs::read_to_string(second_root.join(".agent/manifest.json")).unwrap(),
)
.expect("next manifest json");
assert_eq!(next_manifest["preview"]["status"], "running");
assert_eq!(next_manifest["preview"]["port"], second.port);
let _ = registry.stop();
fs::remove_dir_all(first_root).ok();
fs::remove_dir_all(second_root).ok();
}
#[test]
fn gui_exit_stops_the_preview_and_clears_the_stale_record() {
let root = unique_project_path();
init_existing_html_project_at(&root, "project-1", "退出收尾预览记录测试")
.expect("project init");
let registry = PreviewRegistry::default();
let _preview = start_local_game_preview_at(&root, &registry).expect("preview start");
stop_local_game_preview_on_exit(&registry).expect("exit cleanup");
assert_eq!(registry.status(), stopped_preview_status());
let manifest: Value =
serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap())
.expect("manifest json");
assert_eq!(
manifest["preview"]["status"], "stopped",
"退出时必须把落盘记录收尾,否则下次进项目会照着 running 渲染打不开的运行界面"
);
fs::remove_dir_all(root).ok();
}
#[test]
fn stopping_without_a_live_preview_still_reconciles_a_stale_running_record() {
let root = unique_project_path();
init_existing_html_project_at(&root, "project-1", "陈旧记录对齐测试").expect("project init");
// 模拟"上次留下的 running 记录":直接写记录,registry 里没有任何活体预览。
record_preview_state(
&root,
GameCreationAppPreviewStatus::Running,
Some("http://127.0.0.1:3210/".to_string()),
Some(3210),
)
.expect("stale record");
let registry = PreviewRegistry::default();
let status = stop_local_game_preview_for_root(Some(&root), &registry).expect("reconcile");
assert_eq!(status, stopped_preview_status());
let manifest: Value =
serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap())
.expect("manifest json");
assert_eq!(manifest["preview"]["status"], "stopped");
fs::remove_dir_all(root).ok();
}
#[test]
fn local_preview_serves_generated_playable_game() {
let root = unique_project_path();
init_existing_html_project_at(&root, "project-1", "HTML fixture").expect("project init");
write_local_game_draft_at(&root, "像素风横版动作", &fake_llm_game_draft())
.expect("draft should generate");
let (preview, stop) = start_local_game_preview_for_project(&root).expect("preview start");
let mut stream = TcpStream::connect(("127.0.0.1", preview.port)).expect("preview connect");
stream
.write_all(b"GET / HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n")
.expect("request");
let mut response = String::new();
stream.read_to_string(&mut response).expect("response");
assert!(response.contains("200 OK"), "{response}");
assert!(response.contains("requestAnimationFrame(frame)"));
assert!(response.contains("MOCK_UNIQUE_MECHANIC:moon-kitchen-reflect"));
assert!(response.contains("月光弹幕厨房"));
assert!(response.contains("player.hp"));
assert!(!response.contains("<button id=\"action\""));
let _ = stop.send(());
fs::remove_dir_all(root).ok();
}
#[test]
fn ui_prototype_generation_uses_dedicated_prompt_and_art_spec() {
let options = PlatformArtAssetGenerationOptions {
output_path: Some("assets/ui-prototype.png".to_string()),
aspect_ratio: "16:9".to_string(),
image_size: "2K".to_string(),
asset_kind: "ui-prototype".to_string(),
asset_label: "游戏横屏界面原型图".to_string(),
replace_existing: false,
slice_count: None,
slice_mode: None,
grid_x: None,
grid_y: None,
reference_asset_ids: Vec::new(),
target_category: None,
screen_color: None,
};
let prompt = build_platform_art_asset_prompt(
"原创网格贪吃蛇:分数与状态 HUD、四类不同分值食物、开始、方向键/WASD、触控方向键、失败与重开",
&[],
&options,
);
for expected in [
"真正的游戏 UI/UX 原型图",
"不得自行假设它属于塔防",
"状态 HUD",
"主要可玩区域",
"目标/收集物/危险物",
"失败状态与重新开始",
"键盘和触控提示",
"禁止只画",
"玩法机制只用于理解功能",
"原创网格贪吃蛇",
] {
assert!(
prompt.contains(expected),
"UI generation prompt missing {expected}"
);
}
let art_spec = platform_art_asset_art_spec(&options);
assert_eq!(art_spec["assetType"], "ui");
assert_eq!(art_spec["format"], "16:9 2K");
for expected in [
"HUD",
"主要可玩区域",
"目标或收集物说明",
"失败与重开状态",
"不得假设项目属于塔防",
"无 HUD 的场景插画",
"原创标题",
"角色轮廓",
] {
assert!(
art_spec.to_string().contains(expected),
"UI art spec missing {expected}"
);
}
}
#[test]
fn art_spritesheet_generation_prompt_uses_current_game_instead_of_fixed_tower_defense() {
let options = PlatformArtAssetGenerationOptions {
output_path: Some("assets/art-spritesheet.png".to_string()),
asset_kind: "art-spritesheet".to_string(),
asset_label: "游戏首版核心美术素材".to_string(),
..PlatformArtAssetGenerationOptions::default()
};
let prompt = build_platform_art_asset_prompt(
"原创网格贪吃蛇,需要蛇头、直身、转角、尾部和四类食物",
&[],
&options,
);
for expected in [
"原创透明核心美术素材图集",
"严格从用户需求和美术 brief 提取",
"不得自行假设为塔防",
"蛇头、直身、转角、尾部和四类食物",
"不得把知名角色改色",
] {
assert!(
prompt.contains(expected),
"art generation prompt missing {expected}"
);
}
let art_spec = platform_art_asset_art_spec(&options).to_string();
for expected in [
"原创 Web 游戏素材图集",
"不预设塔防或其他固定玩法",
"玩家主体及状态",
"目标或收集物",
"不得擅自加入单位卡牌",
] {
assert!(art_spec.contains(expected), "art spec missing {expected}");
}
}