6a02c7fb38
基于最新master接入托管式Streamable HTTP MCP并复用External API Key鉴权 统一External v1生成任务的幂等异步提交与状态查询 同步完整Skill资源、OpenAPI契约、AI游戏客户端与Worker适配 保留game-chat生命周期与首轮调度约定并完成重基验证
3542 lines
131 KiB
Rust
3542 lines
131 KiB
Rust
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), ®istry).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!({
|
||
"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 · 已生成美术素材:")));
|
||
assert!(runtime.observations.iter().any(|item| {
|
||
item.contains("透明图集可用,但自动切片未完成:测试图集保持整图,未生成独立切片。")
|
||
}));
|
||
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"]["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 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("测试图集保持整图,未生成独立切片。"));
|
||
assert!(!agent_db.contains("editor-runtime-key"));
|
||
let canvas_requests = (0..8)
|
||
.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#""referenceImageSrc":"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}"
|
||
);
|
||
}
|
||
|
||
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 request_root = root.clone();
|
||
let request_config_dir = config_dir.clone();
|
||
let request = std::thread::spawn(move || {
|
||
let _config_guard = use_test_runtime_config_dir(request_config_dir);
|
||
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,
|
||
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();
|
||
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), ®istry)
|
||
.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), ®istry).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(®istry, Some(root.to_str().unwrap()))
|
||
.expect_err("preview.status denied");
|
||
assert!(error.contains("项目权限策略拒绝执行:preview.status"));
|
||
|
||
let global_status =
|
||
get_local_game_preview_status_at(®istry, 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), ®istry)
|
||
.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), ®istry)
|
||
.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, ®istry)
|
||
.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, ®istry)
|
||
.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, ®istry)
|
||
.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}");
|
||
}
|
||
}
|