Files
Genarrative/plugins/agc-unity-editor/native/unity-editor-bridge/src/tests.rs
T
kdletters 33f5ad68bf
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 7m19s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 7m26s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 7m32s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 7m34s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m57s
Project CI / AI game creator shell Rust crates (push) Successful in 3m18s
Project CI / Native shell tests (push) Successful in 10m54s
Project CI / Backend tests (push) Successful in 12m42s
Project CI / Frontend tests (push) Successful in 13m4s
Project CI / AI game creator shell web tests (push) Successful in 5m1s
Project CI / Repository checks (push) Successful in 12m48s
接入 DotCraft Unity 编辑器插件与受控执行链路 (#423)
AGC 原有插件系统无法直接操作已打开的 Unity Editor。本变更增加内置 `agc-unity-editor`,在 Windows x64 / Unity Mono 上支持当前项目探测、连接与 C# 执行,不向 Unity 工程安装 UPM 桥接包。

## 主要变更

- 固定复用 DotCraft.Unity 0.4.3 的 Attach 核心,提供自包含 .NET helper,保留上游许可证、来源及修改记录。
- GUI、Runtime、DirectProject 共用 Runner 执行服务;补齐项目身份、并发、总期限、回执确认与持久不确定状态阻断。
- 现有打开项目入口支持 Unity,按项目类型及开关暴露插件和 Agent 工具。
- Windows 构建准备 helper 并随包分发;插件 JS/Rust 测试接入现有 CI 组,Jenkins 增加 .NET 10 工具链预检。

## 验证

- .NET helper 27 项测试、自包含发布及最小环境协议 smoke 通过。
- Unity 6000.3.7f1 实机验证通过:连接、C# 执行、编译错误修复、断连重连、Domain Reload 后重新握手;真实 Runner 的 ACK、并发拒绝和跨重启阻断通过。
- 宿主 Unity、PluginHost、Cocos、MCP、工具目录与引擎识别定向回归通过;前端类型检查、插件 JS/Rust、CI 配置、格式、编码和文档门禁通过。

Linux CI 不代替 Windows helper/实机验证;发行安装包 UI smoke、其它 Unity 版本和 Unity CoreCLR 未验证。Unity 演示工程中的场景和组件已撤销,不在此 PR 范围内。

---------

Co-authored-by: kdletters <61648117+kdletters@users.noreply.github.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/423
2026-09-19 12:29:27 +08:00

153 lines
5.8 KiB
Rust

use super::*;
fn envelope(id: u64, result: Value) -> Vec<u8> {
serde_json::to_vec(
&json!({"jsonrpc":"2.0","protocol":PROTOCOL_VERSION,"id":id,"result":result}),
)
.unwrap()
}
#[test]
fn only_complete_execution_receipts_are_trusted() {
let complete = json!({"ok":true,"status":"completed","retryAllowed":false,"dispatched":true,"result":null});
assert!(parse_response(&envelope(1, complete.clone()), 1, "execute", "").is_ok());
assert!(parse_response(&envelope(2, complete.clone()), 1, "execute", "").is_err());
for (key, replacement) in [
("status", json!("lost")),
("retryAllowed", json!(true)),
("dispatched", json!(false)),
("ok", json!(false)),
] {
let mut bad = complete.clone();
bad[key] = replacement;
assert!(parse_response(&envelope(1, bad), 1, "execute", "").is_err());
}
assert!(parse_response(b"not-json", 1, "execute", "").is_err());
}
#[test]
fn known_runtime_and_pre_dispatch_failure_do_not_latch() {
let service = UnityEditorService::default();
for dispatched in [false, true] {
let failed = json!({"ok":false,"status":"failed","retryAllowed":false,"dispatched":dispatched,
"error":{"code":"compile-failed","message":"known failure"}});
let receipt = parse_response(&envelope(1, failed), 1, "execute", "");
assert_eq!(
service.finish_response("execute", receipt).unwrap()["status"],
"failed"
);
assert!(!service.uncertain.load(Ordering::SeqCst));
}
}
#[test]
fn malformed_top_level_errors_eof_and_uncertain_status_latch() {
for response in [
Err("EOF".into()),
parse_response(br#"{"jsonrpc":"2.0","protocol":"agc.unity.attach.v1","id":1,"error":{"code":"x","message":"x"}}"#,1,"execute",""),
Ok(reconciliation("unknown")),
] {
let service = UnityEditorService::default();
assert_eq!(service.finish_response("execute",response).unwrap()["status"],"needs-reconciliation");
service.disconnect();
service.configure(Vec::new()).unwrap();
assert!(service.uncertain.load(Ordering::SeqCst));
}
}
#[test]
fn fixed_helper_candidates_reject_arbitrary_payloads() {
let root = tempfile::tempdir().unwrap();
assert!(validate_candidate(&root.path().join("any.exe")).is_err());
assert!(validate_candidate(Path::new("dotnet/publish/win-x64/Agc.Unity.Attach.exe")).is_err());
assert!(validate_candidate(
&root
.path()
.join("dotnet/publish/win-x64/Agc.Unity.Attach.exe")
)
.is_ok());
let value = json!({"projectPath":"x","helperPath":"x"});
assert!(serde_json::from_value::<RpcParams>(value).is_err());
}
#[test]
fn code_limit_counts_utf8_bytes_and_refuses_nul() {
assert!(validate_code("").is_err());
assert!(validate_code(" \n ").is_err());
assert!(validate_code("x\0y").is_err());
assert!(validate_code(&"中".repeat(MAX_EXECUTE_CODE_BYTES / 3 + 1)).is_err());
assert!(validate_code("return 42;").is_ok());
}
#[test]
fn disconnect_does_not_wait_for_inflight_request() {
let service = UnityEditorService::default();
let _guard = service.state.lock().unwrap();
service.disconnect();
assert_eq!(service.epoch.load(Ordering::SeqCst), 1);
}
#[test]
fn repeated_configuration_does_not_invalidate_connection() {
let service = UnityEditorService::default();
let root = tempfile::tempdir().unwrap();
let candidates = vec![root
.path()
.join("dotnet/publish/win-x64/Agc.Unity.Attach.exe")];
service.configure(candidates.clone()).unwrap();
let epoch = service.epoch.load(Ordering::SeqCst);
service.configure(candidates).unwrap();
assert_eq!(service.epoch.load(Ordering::SeqCst), epoch);
}
#[test]
fn connection_receipts_require_project_process_and_generation_identity() {
let project = tempfile::tempdir().unwrap();
let other = tempfile::tempdir().unwrap();
let expected = project.path().to_str().unwrap();
let result = json!({"adapter":"unity-editor","connected":true,"pid":42,
"projectPath":expected,"version":"6000.0.0f1",
"startedUtc":"2026-09-18T00:00:00Z","generation":"domain-1"});
assert!(parse_response(&envelope(1, result.clone()), 1, "connect", expected).is_ok());
for (key, replacement) in [
("projectPath", json!(other.path())),
("pid", Value::Null),
("generation", Value::Null),
("startedUtc", json!("")),
("connected", json!(false)),
] {
let mut invalid = result.clone();
invalid[key] = replacement;
assert!(parse_response(&envelope(1, invalid), 1, "connect", expected).is_err());
}
}
#[test]
fn invalid_connect_invalidates_previous_session_before_parameter_validation() {
let service = UnityEditorService::default();
let epoch = service.epoch.load(Ordering::SeqCst);
assert!(service.call("connect", RpcParams::default()).is_err());
assert!(service.epoch.load(Ordering::SeqCst) > epoch);
assert!(!service.uncertain.load(Ordering::SeqCst));
}
#[test]
fn pre_dispatch_errors_have_explicit_non_execution_receipts() {
let service = UnityEditorService::default();
let response = execution_result(service.call("execute", RpcParams::default())).unwrap();
assert_eq!(response["status"], "failed");
assert_eq!(response["dispatched"], false);
assert_eq!(response["retryAllowed"], false);
assert!(!service.uncertain.load(Ordering::SeqCst));
}
#[test]
fn project_marker_parents_must_be_ordinary_directories() {
let project = tempfile::tempdir().unwrap();
for directory in ["Assets", "Packages"] {
fs::create_dir(project.path().join(directory)).unwrap();
}
fs::write(project.path().join("ProjectSettings"), "not a directory").unwrap();
assert!(normalize_project(project.path().to_str().unwrap()).is_err());
}