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