Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs
T
kdletters 755bf80858
Project CI / Repository checks (push) Failing after 44s
Project CI / Native shell tests (push) Failing after 1m42s
Project CI / Frontend tests (push) Successful in 2m50s
Project CI / Backend tests (push) Successful in 3m41s
支持 AI 游戏创作打开现有 Godot 项目
新增 Godot 项目选择、校验、导入和项目上下文切换入口。

只在所选项目根保留 .agent 元数据,并让总控使用标准运行档。

修复发布版原生 API 访问和 React runtime 重复加载问题。

收紧 Provider 工具 Schema 子集并增加回归门禁。

补充 Rust、前端测试及 PRD、技术方案和共享决策记录。
2026-08-11 13:25:29 +08:00

3573 lines
132 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);
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);
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 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_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_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::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());
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));
init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init");
register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec");
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 像素月光主角,适合厨房弹幕游戏" }
}
],
"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",
"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",
"为月光厨房生成首版主角素材",
"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(4))
.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/external/v1/editor/icon-spritesheets/generations ")
})
.expect("canvas generation request");
assert_eq!(
canvas_requests
.iter()
.filter(|request| request.starts_with("POST /api/external/v1/editor/"))
.count(),
1,
"queued/running polling must not submit generation again"
);
assert_eq!(
canvas_requests
.iter()
.filter(|request| request.starts_with("GET /api/external/v1/generations/"))
.count(),
3,
"fixture should exercise queued, running, and completed states"
);
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));
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());
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 "))
.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/external/v1/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,
},
)
.await;
assert!(ui_request.starts_with("POST /api/external/v1/editor/images/generations "));
assert!(ui_request.contains(r#""kind":"ui-design""#));
assert!(ui_request.contains(r#""referenceImageSrcs":["resource-icon-spec"]"#));
fs::remove_dir_all(root).ok();
fs::remove_dir_all(spec_config_dir).ok();
fs::remove_dir_all(ui_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,
);
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");
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(),
..PlatformArtAssetGenerationOptions::default()
},
))
});
let mut saw_generation_request = false;
for _ in 0..3 {
let captured = request_receiver
.recv_timeout(Duration::from_secs(15))
.expect("canvas request before generation response");
if captured.starts_with("POST /api/external/v1/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();
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)
);
assert!(trace["artifacts"]
.as_array()
.unwrap()
.iter()
.any(|artifact| artifact["path"] == ".agent/passes/pass-3/game.html"));
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 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(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(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,
project_name: 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(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_eq!(file_status.project_name, None);
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(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_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(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(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 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");
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);
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]["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");
assert_eq!(manifest["assets"][0]["source"]["kind"], "generated");
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 base_url = spawn_mock_external_canvas_api_server();
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"));
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);
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 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();
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"));
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();
}
#[test]
fn local_project_export_package_uses_runtime_whitelist_and_records() {
let root = unique_project_path();
init_local_game_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_local_game_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_local_game_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_local_game_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(project_path.clone(), None)
.expect_err("default project.status denied");
let asset_error = get_local_game_manifest(project_path.clone(), Some("asset.list".to_string()))
.expect_err("asset.list denied");
let unsupported_error = get_local_game_manifest(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();
}
#[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("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")
);
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 developer_window_uses_agent_chat_route() {
assert_eq!(developer_window_url().to_string(), "index.html?agent-chat");
}
#[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 game_chat_launch_args_are_strict_and_keep_normal_start_compatible() {
let absolute_project_path = unique_project_path()
.join("AI Game 项目")
.to_string_lossy()
.into_owned();
assert_eq!(
parse_game_chat_launch_args(&[]).expect("parse normal GUI start"),
None
);
assert_eq!(
parse_game_chat_launch_args(&["--llm-status".to_string()])
.expect("leave existing CLI command untouched"),
None
);
assert_eq!(
parse_game_chat_launch_args(&["--game-chat".to_string()])
.expect("parse game chat")
.expect("game chat options"),
GameChatLaunchOptions {
project_path: None,
initial_message: None,
}
);
assert_eq!(
parse_game_chat_launch_args(&[
"--game-chat".to_string(),
"--project-path".to_string(),
format!(" {absolute_project_path} "),
])
.expect("parse game chat project")
.expect("game chat project options"),
GameChatLaunchOptions {
project_path: Some(absolute_project_path.clone()),
initial_message: None,
}
);
assert_eq!(
parse_game_chat_launch_args(&[
"--game-chat".to_string(),
"--project-path".to_string(),
absolute_project_path.clone(),
"--initial-message".to_string(),
"继续完成贪吃蛇".to_string(),
])
.expect("parse game chat initial message")
.expect("game chat initial message options"),
GameChatLaunchOptions {
project_path: Some(absolute_project_path),
initial_message: Some("继续完成贪吃蛇".to_string()),
}
);
for invalid in [
vec!["--project-path", "/tmp/game"],
vec!["--game-chat", "--project-path"],
vec!["--game-chat", "--project-path", "relative-game"],
vec!["--game-chat", "--project-path", "/tmp/game\nnext"],
vec!["--game-chat", "--game-chat"],
vec!["--game-chat", "--initial-message", ""],
vec!["--game-chat", "--initial-message", "bad\nmessage"],
vec![
"--game-chat",
"--initial-message",
"first",
"--initial-message",
"second",
],
vec![
"--game-chat",
"--project-path",
"/tmp/game",
"--project-path",
"/tmp/other",
],
vec!["--game-chat", "--llm-status"],
vec!["--agent-run", "--game-chat"],
vec!["--game-chat", "--config-dir", "/tmp/appdata"],
] {
let invalid = invalid.into_iter().map(str::to_string).collect::<Vec<_>>();
assert!(
parse_game_chat_launch_args(&invalid).is_err(),
"unexpected valid game chat args: {invalid:?}"
);
}
}
#[test]
fn game_chat_release_flavor_selects_only_its_fixed_page_without_changing_debug() {
let explicit = GameChatLaunchOptions {
project_path: Some("/tmp/game".to_string()),
initial_message: Some("继续".to_string()),
};
assert_eq!(
select_game_chat_launch_options(None, false, true)
.expect("game-chat release default launch")
.expect("game-chat release options"),
GameChatLaunchOptions::default()
);
assert_eq!(
select_game_chat_launch_options(Some(explicit.clone()), true, false)
.expect("debug explicit launch"),
Some(explicit)
);
assert_eq!(
select_game_chat_launch_options(None, true, true).expect("debug normal launch"),
None,
"enabling the packaging feature must not change debug startup"
);
assert!(
select_game_chat_launch_options(Some(GameChatLaunchOptions::default()), false, false)
.expect_err("ordinary release must reject --game-chat")
.contains("--game-chat")
);
}
#[test]
fn game_chat_release_requests_dedicated_runner_shutdown_only_on_final_exit() {
assert!(should_shutdown_runner_on_tauri_event(
true,
&tauri::RunEvent::Exit
));
assert!(!should_shutdown_runner_on_tauri_event(
true,
&tauri::RunEvent::Ready
));
assert!(!should_shutdown_runner_on_tauri_event(
false,
&tauri::RunEvent::Exit
));
}
#[test]
fn game_chat_window_url_encodes_optional_project_path() {
assert_eq!(
game_chat_window_url(None, None).to_string(),
"index.html?game-chat"
);
assert_eq!(
game_chat_window_url(Some("/tmp/AI Game 项目"), None).to_string(),
"index.html?game-chat&projectPath=%2Ftmp%2FAI%20Game%20%E9%A1%B9%E7%9B%AE"
);
assert_eq!(
game_chat_window_url(Some("/tmp/a&b?#%+c"), None).to_string(),
"index.html?game-chat&projectPath=%2Ftmp%2Fa%26b%3F%23%25%2Bc"
);
assert_eq!(
game_chat_window_url(Some("/tmp/game"), Some("继续 & 验证")).to_string(),
"index.html?game-chat&projectPath=%2Ftmp%2Fgame&initialMessage=%E7%BB%A7%E7%BB%AD%20%26%20%E9%AA%8C%E8%AF%81"
);
}
#[test]
fn game_chat_initial_window_url_is_applied_before_tauri_creates_the_client() {
let mut context: tauri::Context<tauri::Wry> = tauri::generate_context!();
let mut secondary_window = context
.config()
.app
.windows
.first()
.expect("window config fixture")
.clone();
secondary_window.label = "fixture-secondary".to_string();
secondary_window.url = tauri::WebviewUrl::App(PathBuf::from("index.html?fixture-secondary"));
context
.config_mut()
.app
.windows
.push(secondary_window.clone());
let mut expected_windows = context.config().app.windows.clone();
let absolute_project_path = unique_project_path()
.join("AI Game 项目")
.to_string_lossy()
.into_owned();
let options = parse_game_chat_launch_args(&[
"--game-chat".to_string(),
"--project-path".to_string(),
absolute_project_path,
"--initial-message".to_string(),
"继续 & 验证".to_string(),
])
.expect("parse game-chat launch arguments")
.expect("game-chat launch options");
let expected_url = game_chat_window_url(
options.project_path.as_deref(),
options.initial_message.as_deref(),
);
expected_windows
.iter_mut()
.find(|window| window.label == "client")
.expect("expected client window config")
.url = expected_url;
apply_game_chat_initial_window_url(context.config_mut(), &options)
.expect("apply game chat initial window URL");
assert_eq!(
context.config().app.windows,
expected_windows,
"game-chat launch must only rewrite the client initial URL"
);
assert_eq!(
context
.config()
.app
.windows
.iter()
.find(|window| window.label == secondary_window.label)
.expect("secondary window config"),
&secondary_window,
"game-chat launch must leave other windows untouched"
);
}
#[test]
fn workspace_window_project_path_requires_absolute_path() {
assert!(validate_workspace_window_project_path(" /tmp/game ").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("绝对路径"));
assert!(validate_workspace_window_project_path("/tmp/game\nnext")
.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 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_local_game_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("还没有生成游戏"));
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();
}
#[test]
fn local_preview_server_drains_split_browser_headers_before_response() {
let root = unique_project_path();
init_local_game_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_local_game_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_local_game_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_local_game_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_local_game_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 local_preview_serves_generated_playable_game() {
let root = unique_project_path();
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,
};
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}");
}
}