Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs
T
lhk229 08c5917490
Project CI / Frontend tests (pull_request) Successful in 3m5s
Project CI / Backend tests (pull_request) Failing after 3m52s
Project CI / Native shell tests (pull_request) Failing after 4m5s
Project CI / Repository checks (pull_request) Failing after 1m8s
修复配置覆盖层写入失败后的部分更新
提前序列化配置变更并在多文件写入失败时逆序回滚
保留单文件保存路径且不增加外部诊断或保存后回读
补充覆盖层写入失败回归测试及配置保存文档
2026-09-09 04:34:57 +00:00

1828 lines
62 KiB
Rust

use super::*;
#[test]
fn config_file_overrides_defaults_without_env() {
let root = unique_project_path();
fs::create_dir_all(&root).expect("test config dir");
let config_path = root.join(GAME_CREATOR_CONFIG_FILE_NAME);
fs::write(
&config_path,
r#"{
"llm": {
"apiKey": "file-key",
"baseUrl": "https://example.test/v1",
"model": "model-from-file",
"apiKind": "openai_chat",
"reasoningEffort": "medium",
"stream": true,
"webSearchEnabled": true,
"requestTimeoutMs": 42000,
"maxRetries": 2,
"retryBackoffMs": 700
},
"agentLlm": {
"planner": {
"model": "planner-model",
"apiKind": "anthropic",
"reasoningEffort": "default",
"stream": false,
"webSearchEnabled": false
},
"generator": {
"baseUrl": "https://generator.example.test/v1",
"model": "generator-model"
}
},
"editorApi": {
"baseUrl": "http://127.0.0.1:8099",
"apiKey": "editor-key"
}
}
"#,
)
.expect("write local config");
let mut config = GameCreatorAppConfig::default();
merge_game_creator_config_file(&mut config, &config_path).expect("merge config");
assert_eq!(config.llm.api_key, "file-key");
assert_eq!(config.llm.base_url, "https://example.test/v1");
assert_eq!(config.llm.model, "model-from-file");
assert_eq!(config.llm.api_kind, "openai_chat");
assert_eq!(config.llm.reasoning_effort, "medium");
assert!(config.llm.stream);
assert!(config.llm.web_search_enabled);
assert_eq!(
config.llm.context_window_tokens,
DEFAULT_GAME_CREATOR_LLM_CONTEXT_WINDOW_TOKENS
);
assert_eq!(
config.llm.auto_compact_token_limit,
DEFAULT_GAME_CREATOR_LLM_AUTO_COMPACT_TOKEN_LIMIT
);
assert_eq!(
config.llm.tool_output_token_limit,
DEFAULT_GAME_CREATOR_LLM_TOOL_OUTPUT_TOKEN_LIMIT
);
assert_eq!(config.llm.request_timeout_ms, 42_000);
assert_eq!(config.llm.max_retries, 2);
assert_eq!(config.llm.retry_backoff_ms, 700);
let planner_llm = resolve_game_creator_llm_config_for_agent(&config, "planner");
assert_eq!(planner_llm.api_key, "file-key");
assert_eq!(planner_llm.base_url, "https://example.test/v1");
assert_eq!(planner_llm.model, "planner-model");
assert_eq!(planner_llm.api_kind, "anthropic");
assert_eq!(planner_llm.reasoning_effort, "default");
assert!(!planner_llm.stream);
assert!(!planner_llm.web_search_enabled);
assert_eq!(
planner_llm.context_window_tokens,
DEFAULT_GAME_CREATOR_LLM_CONTEXT_WINDOW_TOKENS
);
assert_eq!(
planner_llm.auto_compact_token_limit,
DEFAULT_GAME_CREATOR_LLM_AUTO_COMPACT_TOKEN_LIMIT
);
assert_eq!(
planner_llm.tool_output_token_limit,
DEFAULT_GAME_CREATOR_LLM_TOOL_OUTPUT_TOKEN_LIMIT
);
let generator_llm = resolve_game_creator_llm_config_for_agent(&config, "generator");
assert_eq!(generator_llm.api_key, "file-key");
assert_eq!(generator_llm.base_url, "https://generator.example.test/v1");
assert_eq!(generator_llm.model, "generator-model");
assert_eq!(generator_llm.api_kind, "openai_chat");
assert_eq!(generator_llm.reasoning_effort, "high");
assert!(generator_llm.web_search_enabled);
assert_eq!(config.editor_api.base_url, "http://127.0.0.1:8099");
assert_eq!(config.editor_api.api_key, "editor-key");
fs::remove_dir_all(root).expect("cleanup test config dir");
}
#[test]
fn agent_mode_defaults_to_codex_app_server_and_preserves_explicit_modes() {
let default_config = GameCreatorAppConfig::default();
assert_eq!(
default_config.agent_mode,
GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER
);
assert!(
default_config.llm.stream,
"新的全局 LLM 配置默认使用流式响应"
);
let inherited_agent_llm =
resolve_game_creator_llm_config_for_agent(&default_config, "design-director");
assert!(
inherited_agent_llm.stream,
"未覆盖时 Agent 必须继承全局流式默认"
);
let mut explicitly_non_streaming_config = default_config.clone();
explicitly_non_streaming_config.agent_llm.insert(
"design-director".to_string(),
GameCreatorLlmConfigFile {
stream: Some(false),
..GameCreatorLlmConfigFile::default()
},
);
assert!(
!resolve_game_creator_llm_config_for_agent(
&explicitly_non_streaming_config,
"design-director",
)
.stream,
"Agent 显式关闭流式请求时必须覆盖全局默认"
);
let root = unique_project_path();
fs::create_dir_all(&root).expect("runtime config dir");
let config_path = root.join(GAME_CREATOR_CONFIG_FILE_NAME);
fs::write(
&config_path,
r#"{"agentMode":"provider","llm":{"stream":false}}"#,
)
.expect("write explicit provider mode and non-stream override");
let mut config = GameCreatorAppConfig::default();
merge_game_creator_config_file(&mut config, &config_path).expect("merge provider mode");
assert_eq!(config.agent_mode, GAME_CREATOR_AGENT_MODE_PROVIDER);
assert!(
!config.llm.stream,
"已有配置显式关闭流式请求时必须保留该选择"
);
assert_eq!(
normalize_game_creator_agent_mode(GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER)
.expect("app-server mode"),
GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER
);
assert_eq!(
normalize_game_creator_agent_mode(GAME_CREATOR_AGENT_MODE_CODEX_CLI).expect("cli mode"),
GAME_CREATOR_AGENT_MODE_CODEX_CLI
);
assert!(normalize_game_creator_agent_mode("unknown")
.expect_err("unknown mode")
.contains("agentMode"));
fs::remove_dir_all(root).expect("cleanup runtime config dir");
}
#[test]
fn legacy_agent_mode_migration_preserves_non_responses_provider_routes() {
let responses: GameCreatorAppConfigFile = serde_json::from_value(serde_json::json!({
"llm": { "apiKind": "openai_responses" }
}))
.expect("responses config");
assert_eq!(
legacy_game_creator_agent_mode(&responses),
Some(GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER)
);
let chat: GameCreatorAppConfigFile = serde_json::from_value(serde_json::json!({
"llm": { "apiKind": "openai_chat" }
}))
.expect("chat config");
assert_eq!(
legacy_game_creator_agent_mode(&chat),
Some(GAME_CREATOR_AGENT_MODE_PROVIDER)
);
let mixed: GameCreatorAppConfigFile = serde_json::from_value(serde_json::json!({
"llm": { "apiKind": "openai_responses" },
"agentLlm": { "art-director": { "apiKind": "anthropic" } }
}))
.expect("mixed config");
assert_eq!(
legacy_game_creator_agent_mode(&mixed),
Some(GAME_CREATOR_AGENT_MODE_PROVIDER)
);
let explicit: GameCreatorAppConfigFile = serde_json::from_value(serde_json::json!({
"agentMode": "codex_cli",
"llm": { "apiKind": "openai_chat" }
}))
.expect("explicit config");
assert_eq!(legacy_game_creator_agent_mode(&explicit), None);
}
#[test]
fn legacy_unversioned_config_migration_sets_schema_and_preserves_explicit_search_disable() {
let root = unique_project_path();
fs::create_dir_all(&root).expect("runtime config dir");
let config_path = root.join(GAME_CREATOR_CONFIG_FILE_NAME);
fs::write(
&config_path,
r#"{
"agentMode": "codex_app_server",
"llm": {
"apiKind": "openai_responses",
"webSearchEnabled": false
}
}
"#,
)
.expect("write legacy config");
migrate_legacy_game_creator_agent_mode(&config_path).expect("migrate legacy config");
let migrated = fs::read_to_string(&config_path).expect("read migrated config");
let value: serde_json::Value = serde_json::from_str(&migrated).expect("parse migrated config");
assert_eq!(
value["schemaVersion"],
GAME_CREATOR_APP_CONFIG_SCHEMA_VERSION
);
assert_eq!(value["llm"]["webSearchEnabled"], false);
fs::remove_dir_all(root).expect("cleanup migrated config");
}
#[test]
fn legacy_unversioned_direct_config_fills_omitted_controlled_search_default() {
let root = unique_project_path();
fs::create_dir_all(&root).expect("runtime config dir");
let config_path = root.join(GAME_CREATOR_CONFIG_FILE_NAME);
fs::write(
&config_path,
r#"{
"agentMode": "codex_app_server",
"llm": { "apiKind": "openai_responses" }
}
"#,
)
.expect("write legacy config without search override");
migrate_legacy_game_creator_agent_mode(&config_path).expect("migrate legacy config");
let value: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&config_path).expect("read migrated config"))
.expect("parse migrated config");
assert_eq!(
value["schemaVersion"],
GAME_CREATOR_APP_CONFIG_SCHEMA_VERSION
);
assert_eq!(value["llm"]["webSearchEnabled"], true);
fs::remove_dir_all(root).expect("cleanup migrated config");
}
#[test]
fn unsupported_config_schema_version_fails_closed() {
let root = unique_project_path();
fs::create_dir_all(&root).expect("runtime config dir");
let config_path = root.join(GAME_CREATOR_CONFIG_FILE_NAME);
fs::write(
&config_path,
r#"{"schemaVersion":"game-creator-config.v99"}"#,
)
.expect("write unsupported config");
let error = migrate_legacy_game_creator_agent_mode(&config_path)
.expect_err("unsupported config schema must fail");
assert!(error.contains("schemaVersion"));
fs::remove_dir_all(root).expect("cleanup unsupported config");
}
#[test]
fn locked_config_scrub_removes_all_legacy_provider_credentials() {
let mut config: GameCreatorAppConfigFile = serde_json::from_value(serde_json::json!({
"agentMode": "provider",
"llm": {
"apiKey": "legacy-global-key",
"baseUrl": "https://legacy.example.test/v1",
"model": "legacy-model",
"apiKind": "openai_chat",
"reasoningEffort": "high"
},
"agentLlm": {
"planner": {
"apiKey": "legacy-agent-key",
"baseUrl": "https://agent.example.test/v1",
"model": "agent-model",
"apiKind": "anthropic"
}
},
"editorApi": {
"baseUrl": "https://editor.example.test",
"apiKey": "legacy-editor-key"
}
}))
.expect("legacy release config");
assert!(scrub_locked_game_creator_config_file(&mut config));
assert_eq!(
config.agent_mode.as_deref(),
Some(GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER)
);
assert!(config.agent_llm.is_none());
assert!(config.editor_api.is_none());
let llm = config
.llm
.as_ref()
.expect("global llm remains as non-sensitive tuning");
assert!(llm.api_key.is_none());
assert!(llm.base_url.is_none());
assert!(llm.model.is_none());
assert!(llm.api_kind.is_none());
let serialized = serde_json::to_string(&config).expect("serialize scrubbed config");
assert!(!serialized.contains("legacy-global-key"));
assert!(!serialized.contains("legacy-agent-key"));
assert!(!serialized.contains("legacy-editor-key"));
assert!(!serialized.contains("legacy.example.test"));
}
#[test]
fn official_llm_route_is_locked_for_debug_and_release_platform_builds() {
assert!(game_creator_official_llm_route_locked_for_build(false));
assert!(!game_creator_official_llm_route_locked_for_build(true));
}
#[test]
fn debug_provider_e2e_flag_only_unlocks_debug_platform_build() {
assert!(debug_provider_e2e_route_unlocked_for_build(
true,
Some(std::ffi::OsStr::new("1"))
));
assert!(!debug_provider_e2e_route_unlocked_for_build(
false,
Some(std::ffi::OsStr::new("1"))
));
assert!(!debug_provider_e2e_route_unlocked_for_build(
true,
Some(std::ffi::OsStr::new("0"))
));
assert!(!debug_provider_e2e_route_unlocked_for_build(true, None));
}
#[test]
fn codex_app_server_requires_responses_route_and_disables_native_web_search() {
let mut llm = GameCreatorLlmConfig::default();
llm.api_key = "secret".to_string();
llm.api_kind = "anthropic".to_string();
assert!(game_creator_codex_app_server_llm_route_error(
GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER,
&llm,
"llm"
)
.expect("unsupported route")
.contains("openai_responses"));
llm.api_key.clear();
assert!(game_creator_codex_app_server_llm_route_error(
GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER,
&llm,
"llm"
)
.expect("unsupported empty-key route")
.contains("openai_responses"));
llm.api_kind = "openai_responses".to_string();
llm.web_search_enabled = true;
assert!(game_creator_codex_app_server_llm_route_error(
GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER,
&llm,
"llm"
)
.is_none());
llm.api_key = "secret".to_string();
assert!(game_creator_codex_app_server_llm_route_error(
GAME_CREATOR_AGENT_MODE_CODEX_CLI,
&llm,
"llm"
)
.is_none());
}
#[test]
fn legacy_llm_config_deserialization_supplies_context_budget_defaults() {
let llm: GameCreatorLlmConfig = serde_json::from_value(serde_json::json!({
"apiKey": "legacy-key",
"baseUrl": "https://legacy.example.test/v1",
"model": "legacy-model",
"apiKind": "openai_chat",
"reasoningEffort": "medium",
"stream": false,
"webSearchEnabled": false,
"requestTimeoutMs": 30_000,
"maxRetries": 1,
"retryBackoffMs": 500
}))
.expect("deserialize legacy llm config");
assert_eq!(
llm.context_window_tokens,
DEFAULT_GAME_CREATOR_LLM_CONTEXT_WINDOW_TOKENS
);
assert_eq!(
llm.auto_compact_token_limit,
DEFAULT_GAME_CREATOR_LLM_AUTO_COMPACT_TOKEN_LIMIT
);
assert_eq!(
llm.tool_output_token_limit,
DEFAULT_GAME_CREATOR_LLM_TOOL_OUTPUT_TOKEN_LIMIT
);
}
#[test]
fn canonical_agent_reasoning_effort_defaults_are_exhaustive_and_auditable() {
let expected = BTreeMap::from([
("project-supervisor", "high"),
("planner", "high"),
("orchestrator", "medium"),
("generator", "high"),
("evaluator", "high"),
("design-director", "medium"),
("design-foundation", "high"),
("balance-director", "medium"),
("balance-seed", "medium"),
("art-director", "high"),
("art-asset-plan", "high"),
("art-polish", "medium"),
("audio-director", "low"),
("audio-asset-plan", "medium"),
("code-director", "medium"),
("code-prototype", "high"),
("quality-review", "high"),
("preview-readiness", "low"),
("preview-playtest", "low"),
("publish-strategy", "low"),
("publish-package", "medium"),
]);
let rust_defaults = GAME_CREATOR_LLM_AGENT_REASONING_EFFORT_DEFAULTS
.iter()
.copied()
.collect::<BTreeMap<_, _>>();
assert_eq!(rust_defaults, expected);
assert_eq!(
GAME_CREATOR_LLM_AGENT_REASONING_EFFORT_DEFAULTS.len(),
rust_defaults.len(),
"规范 Agent 默认映射不能包含重复 ID"
);
let status_agent_ids = game_creator_llm_agent_status_definitions()
.into_iter()
.map(|definition| definition.agent_id)
.collect::<std::collections::BTreeSet<_>>();
assert_eq!(
status_agent_ids,
expected
.keys()
.map(|agent_id| (*agent_id).to_string())
.collect::<std::collections::BTreeSet<_>>(),
"新增规范 Agent 时必须先显式选择 reasoning effort,不能静默继承全局"
);
for (agent_id, effort) in &expected {
assert_eq!(
game_creator_llm_agent_default_reasoning_effort(agent_id),
Some(*effort)
);
parse_game_creator_llm_reasoning_effort(effort).expect("canonical reasoning effort");
}
let template =
serde_json::from_str::<GameCreatorAppConfigFile>(DEFAULT_GAME_CREATOR_APP_CONFIG_JSON)
.expect("parse bundled runtime config template");
assert_eq!(
template.agent_mode.as_deref(),
Some(GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER)
);
assert_eq!(
template.llm.as_ref().and_then(|llm| llm.max_retries),
Some(DEFAULT_GAME_CREATOR_LLM_MAX_RETRIES)
);
assert_eq!(
template
.planning
.as_ref()
.and_then(|planning| planning.capability_enabled),
Some(true),
"bundled runtime config must keep planning capability enabled by default"
);
assert!(
template.agent_llm.unwrap_or_default().is_empty(),
"bundled template must not persist canonical defaults as explicit overrides"
);
}
#[test]
fn canonical_reasoning_only_patch_does_not_activate_role_llm_override() {
let mut config = GameCreatorAppConfig::default();
for (agent_id, effort) in GAME_CREATOR_LLM_AGENT_REASONING_EFFORT_DEFAULTS {
config.agent_llm.insert(
agent_id.to_string(),
GameCreatorLlmConfigFile {
reasoning_effort: Some(effort.to_string()),
..GameCreatorLlmConfigFile::default()
},
);
assert!(
!has_game_creator_agent_llm_override(&config, agent_id),
"canonical reasoning-only default must not activate {agent_id} role LLM"
);
}
config.agent_llm.insert(
"design-director".to_string(),
GameCreatorLlmConfigFile {
reasoning_effort: Some("high".to_string()),
..GameCreatorLlmConfigFile::default()
},
);
assert!(has_game_creator_agent_llm_override(
&config,
"design-director"
));
}
#[test]
fn empty_legacy_agent_llm_uses_agent_defaults_and_explicit_patch_wins() {
let root = unique_project_path();
fs::create_dir_all(&root).expect("runtime config dir");
fs::write(
root.join(GAME_CREATOR_CONFIG_FILE_NAME),
r#"{
"agentMode": "provider",
"llm": {
"apiKey": "global-key",
"baseUrl": "https://global.example.test/v1",
"model": "global-model",
"reasoningEffort": "default"
},
"agentLlm": {}
}
"#,
)
.expect("write legacy empty Agent config");
let _guard = use_test_runtime_config_dir(root.clone());
let config = load_game_creator_app_config().expect("load legacy empty Agent config");
assert!(config.agent_llm.is_empty());
for (agent_id, effort) in GAME_CREATOR_LLM_AGENT_REASONING_EFFORT_DEFAULTS {
assert_eq!(
resolve_game_creator_llm_config_for_agent(&config, agent_id).reasoning_effort,
effort
);
}
assert_eq!(
resolve_game_creator_llm_config_for_agent(&config, "non-canonical-agent").reasoning_effort,
"default"
);
let status = check_game_creator_llm_config_from_config();
assert!(status.configured, "{:?}", status.error);
for (agent_id, effort) in GAME_CREATOR_LLM_AGENT_REASONING_EFFORT_DEFAULTS {
let agent = status
.agents
.iter()
.find(|agent| agent.agent_id == agent_id)
.expect("canonical Agent status");
assert_eq!(agent.reasoning_effort, effort, "{agent_id}");
}
let mut overridden = config;
overridden.agent_llm.insert(
GAME_CREATOR_LEGACY_CHAT_AGENT_CONFIG_ID.to_string(),
GameCreatorLlmConfigFile {
reasoning_effort: Some("medium".to_string()),
..GameCreatorLlmConfigFile::default()
},
);
for (agent_id, _) in GAME_CREATOR_LLM_AGENT_REASONING_EFFORT_DEFAULTS {
overridden.agent_llm.insert(
agent_id.to_string(),
GameCreatorLlmConfigFile {
reasoning_effort: Some("default".to_string()),
..GameCreatorLlmConfigFile::default()
},
);
assert_eq!(
resolve_game_creator_llm_config_for_agent(&overridden, agent_id).reasoning_effort,
"default",
"显式 agentLlm.{agent_id} patch 必须覆盖规范默认值"
);
}
fs::remove_dir_all(root).ok();
}
#[test]
fn runtime_config_dir_supplies_app_config_file() {
let root = unique_project_path();
fs::create_dir_all(&root).expect("runtime config dir");
fs::write(
root.join(GAME_CREATOR_CONFIG_FILE_NAME),
r#"{
"llm": {
"apiKey": "runtime-key",
"baseUrl": "https://runtime.example.test/v1",
"model": "runtime-model"
}
}
"#,
)
.expect("write runtime config");
let _guard = use_test_runtime_config_dir(root.clone());
let config = load_game_creator_app_config().expect("load runtime config");
assert_eq!(config.llm.api_key, "runtime-key");
assert_eq!(config.llm.base_url, "https://runtime.example.test/v1");
assert_eq!(config.llm.model, "runtime-model");
assert!(llm_api_key_config_error("llm").contains(&root.to_string_lossy().to_string()));
fs::remove_dir_all(root).expect("cleanup runtime config dir");
}
#[test]
fn runtime_config_read_returns_defaults_when_file_is_missing() {
let root = unique_project_path();
fs::create_dir_all(&root).expect("runtime config dir");
let _guard = use_test_runtime_config_dir(root.clone());
let result = read_game_creator_app_config().expect("read default runtime config");
assert_eq!(
result.path,
root.join(GAME_CREATOR_CONFIG_FILE_NAME)
.display()
.to_string()
);
assert_eq!(result.config.llm.api_key, "");
assert_eq!(
result.config.agent_mode,
GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER
);
assert_eq!(
result.config.llm.base_url,
DEFAULT_GAME_CREATOR_LLM_BASE_URL
);
assert_eq!(result.config.llm.model, DEFAULT_GAME_CREATOR_LLM_MODEL);
assert_eq!(
result.config.llm.api_kind,
DEFAULT_GAME_CREATOR_LLM_API_KIND
);
assert_eq!(
result.config.llm.reasoning_effort,
DEFAULT_GAME_CREATOR_LLM_REASONING_EFFORT
);
assert!(result.config.llm.stream);
assert!(result.config.llm.web_search_enabled);
assert_eq!(
result.config.llm.context_window_tokens,
DEFAULT_GAME_CREATOR_LLM_CONTEXT_WINDOW_TOKENS
);
assert_eq!(
result.config.llm.auto_compact_token_limit,
DEFAULT_GAME_CREATOR_LLM_AUTO_COMPACT_TOKEN_LIMIT
);
assert_eq!(
result.config.llm.tool_output_token_limit,
DEFAULT_GAME_CREATOR_LLM_TOOL_OUTPUT_TOKEN_LIMIT
);
assert_eq!(
result.config.llm.request_timeout_ms,
GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS
);
assert_eq!(
result.config.llm.max_retries,
DEFAULT_GAME_CREATOR_LLM_MAX_RETRIES
);
assert_eq!(
result.config.editor_api.base_url,
DEFAULT_CANVAS_SYNC_API_BASE_URL
);
assert_eq!(result.config.editor_api.api_key, "");
assert!(!root.join(GAME_CREATOR_CONFIG_FILE_NAME).exists());
fs::remove_dir_all(root).expect("cleanup runtime config dir");
}
#[test]
fn app_config_commands_write_runtime_config_file() {
let root = unique_project_path();
fs::create_dir_all(&root).expect("runtime config dir");
let _guard = use_test_runtime_config_dir(root.clone());
let mut agent_llm = BTreeMap::new();
agent_llm.insert(
" planner ".to_string(),
GameCreatorLlmConfigFile {
api_key: Some(" planner-key ".to_string()),
base_url: Some(" https://planner.example.test/v1 ".to_string()),
model: Some(" planner-model ".to_string()),
api_kind: Some("anthropic".to_string()),
reasoning_effort: Some(" low ".to_string()),
stream: Some(true),
web_search_enabled: Some(false),
context_window_tokens: Some(96_000),
auto_compact_token_limit: Some(48_000),
tool_output_token_limit: Some(8_000),
request_timeout_ms: Some(15_000),
max_retries: Some(1),
retry_backoff_ms: Some(300),
},
);
agent_llm.insert("generator".to_string(), GameCreatorLlmConfigFile::default());
let saved = write_game_creator_app_config(GameCreatorAppConfig {
schema_version: GAME_CREATOR_APP_CONFIG_SCHEMA_VERSION.to_string(),
agent_mode: GAME_CREATOR_AGENT_MODE_PROVIDER.to_string(),
llm: GameCreatorLlmConfig {
api_key: " unit-test-key ".to_string(),
base_url: " https://runtime.example.test/v1 ".to_string(),
model: " runtime-model ".to_string(),
api_kind: "openai_chat".to_string(),
reasoning_effort: " high ".to_string(),
stream: true,
web_search_enabled: true,
context_window_tokens: 128_000,
auto_compact_token_limit: 64_000,
tool_output_token_limit: 12_000,
request_timeout_ms: 42_000,
max_retries: 2,
retry_backoff_ms: 700,
},
editor_api: GameCreatorEditorApiConfig {
base_url: " http://127.0.0.1:8099 ".to_string(),
api_key: " editor-key ".to_string(),
},
agent_llm,
planning: GameCreatorPlanningConfig::default(),
selected_model_id: "default".to_string(),
})
.expect("write runtime config");
assert_eq!(
saved.path,
root.join(GAME_CREATOR_CONFIG_FILE_NAME)
.display()
.to_string()
);
assert_eq!(saved.config.llm.api_key, "unit-test-key");
assert_eq!(saved.config.llm.base_url, "https://runtime.example.test/v1");
assert_eq!(saved.config.llm.api_kind, "openai_chat");
assert_eq!(saved.config.llm.reasoning_effort, "high");
assert!(saved.config.llm.web_search_enabled);
assert_eq!(
saved
.config
.agent_llm
.get("planner")
.and_then(|llm| llm.api_key.as_deref()),
Some("planner-key")
);
assert_eq!(
saved
.config
.agent_llm
.get("planner")
.and_then(|llm| llm.web_search_enabled),
Some(false)
);
assert!(!saved.config.agent_llm.contains_key("generator"));
assert_eq!(saved.config.editor_api.api_key, "");
assert!(root.join(GAME_CREATOR_CONFIG_FILE_NAME).is_file());
let persisted = fs::read_to_string(root.join(GAME_CREATOR_CONFIG_FILE_NAME))
.expect("read persisted runtime config");
assert!(!persisted.contains("editorApi"));
assert!(!persisted.contains("editor-key"));
assert!(!root.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME).exists());
let read_back = read_game_creator_app_config().expect("read runtime config");
assert_eq!(read_back.config.llm.model, "runtime-model");
assert_eq!(read_back.config.llm.request_timeout_ms, 42_000);
assert_eq!(
read_back
.config
.agent_llm
.get("planner")
.and_then(|llm| llm.api_kind.as_deref()),
Some("anthropic")
);
assert_eq!(
read_back
.config
.agent_llm
.get("planner")
.and_then(|llm| llm.reasoning_effort.as_deref()),
Some("low")
);
assert_eq!(
read_back
.config
.agent_llm
.get("planner")
.and_then(|llm| llm.web_search_enabled),
Some(false)
);
fs::remove_dir_all(root).expect("cleanup runtime config dir");
}
#[test]
fn app_config_batch_restores_main_when_overlay_write_fails() {
for main_exists in [false, true] {
let root = unique_project_path();
fs::create_dir_all(&root).expect("config dir");
let main = root.join(GAME_CREATOR_CONFIG_FILE_NAME);
let overlay = root.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME);
let original = "{\"llm\":{\"stream\":false}}\n";
if main_exists {
fs::write(&main, original).expect("main config");
}
fs::write(&overlay, original).expect("overlay config");
// 普通目录占据备份路径,让覆盖文件在替换前失败。
fs::create_dir(root.join(format!(".{}.previous", GAME_CREATOR_LOCAL_CONFIG_FILE_NAME)))
.expect("block overlay replacement");
crate::config::write_game_creator_config_batch(&[
(main.clone(), "{\"llm\":{\"stream\":true}}\n".to_string()),
(overlay.clone(), "{\"llm\":{\"stream\":true}}\n".to_string()),
])
.expect_err("overlay replacement must fail");
if main_exists {
assert_eq!(fs::read_to_string(&main).expect("restored main"), original);
} else {
assert!(!main.exists());
}
assert_eq!(fs::read_to_string(&overlay).expect("unchanged overlay"), original);
fs::remove_dir_all(root).expect("cleanup config dir");
}
}
#[test]
fn app_config_save_updates_conflicting_local_overlay() {
let root = unique_project_path();
fs::create_dir_all(&root).expect("config dir");
let _guard = use_test_runtime_config_dir(root.clone());
let overlay_path = root.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME);
fs::write(
&overlay_path,
r#"{"llm":{"reasoningEffort":"low"},"selectedModelId":"existing","custom":{"keep":true}}"#,
)
.expect("write overlay");
let mut config = load_game_creator_app_config().expect("load config");
config.llm.reasoning_effort = "high".to_string();
let saved = write_game_creator_app_config(config).expect("save config");
let effective = load_game_creator_app_config().expect("effective config");
assert_eq!(saved.config.llm.reasoning_effort, "high");
assert_eq!(effective.llm.reasoning_effort, "high");
let overlay: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&overlay_path).expect("read overlay"))
.expect("parse overlay");
assert_eq!(overlay["selectedModelId"], "existing");
assert_eq!(
overlay["llm"],
serde_json::json!({"reasoningEffort": "high"})
);
assert_eq!(overlay["custom"]["keep"], true);
fs::remove_dir_all(root).expect("cleanup config dir");
}
#[test]
fn app_config_model_selection_only_updates_model_overlay() {
let root = unique_project_path();
fs::create_dir_all(&root).expect("config dir");
let _guard = use_test_runtime_config_dir(root.clone());
let overlay_path = root.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME);
fs::write(
&overlay_path,
r#"{"selectedModelId":"old","llm":{"reasoningEffort":"low"}}"#,
)
.expect("write overlay");
let saved = select_game_creator_model("new-model".to_string()).expect("select model");
let effective = load_game_creator_app_config().expect("effective config");
assert_eq!(saved.config.selected_model_id, "new-model");
assert_eq!(effective.selected_model_id, "new-model");
let overlay: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&overlay_path).expect("read overlay"))
.expect("parse overlay");
assert_eq!(
overlay["llm"],
serde_json::json!({"reasoningEffort": "low"})
);
fs::remove_dir_all(root).expect("cleanup config dir");
}
#[test]
fn app_config_write_rejects_invalid_api_kind() {
let root = unique_project_path();
fs::create_dir_all(&root).expect("runtime config dir");
let _guard = use_test_runtime_config_dir(root.clone());
let result = write_game_creator_app_config(GameCreatorAppConfig {
schema_version: GAME_CREATOR_APP_CONFIG_SCHEMA_VERSION.to_string(),
agent_mode: GAME_CREATOR_AGENT_MODE_PROVIDER.to_string(),
llm: GameCreatorLlmConfig {
api_key: String::new(),
api_kind: "legacy".to_string(),
..GameCreatorLlmConfig::default()
},
editor_api: GameCreatorEditorApiConfig::default(),
agent_llm: BTreeMap::new(),
planning: GameCreatorPlanningConfig::default(),
selected_model_id: "default".to_string(),
});
assert!(result
.expect_err("invalid api_kind")
.contains("LLM api_kind 无效"));
assert!(!root.join(GAME_CREATOR_CONFIG_FILE_NAME).exists());
fs::remove_dir_all(root).expect("cleanup runtime config dir");
}
#[test]
fn app_config_write_rejects_invalid_reasoning_effort() {
let root = unique_project_path();
fs::create_dir_all(&root).expect("runtime config dir");
let _guard = use_test_runtime_config_dir(root.clone());
let result = write_game_creator_app_config(GameCreatorAppConfig {
schema_version: GAME_CREATOR_APP_CONFIG_SCHEMA_VERSION.to_string(),
agent_mode: GAME_CREATOR_AGENT_MODE_PROVIDER.to_string(),
llm: GameCreatorLlmConfig {
reasoning_effort: "maximum".to_string(),
..GameCreatorLlmConfig::default()
},
editor_api: GameCreatorEditorApiConfig::default(),
agent_llm: BTreeMap::new(),
planning: GameCreatorPlanningConfig::default(),
selected_model_id: "default".to_string(),
});
assert!(result
.expect_err("invalid reasoning effort")
.contains("reasoningEffort"));
assert!(!root.join(GAME_CREATOR_CONFIG_FILE_NAME).exists());
fs::remove_dir_all(root).expect("cleanup runtime config dir");
}
#[test]
fn app_config_write_rejects_too_small_request_timeout() {
let root = unique_project_path();
fs::create_dir_all(&root).expect("runtime config dir");
let _guard = use_test_runtime_config_dir(root.clone());
let result = write_game_creator_app_config(GameCreatorAppConfig {
schema_version: GAME_CREATOR_APP_CONFIG_SCHEMA_VERSION.to_string(),
agent_mode: GAME_CREATOR_AGENT_MODE_PROVIDER.to_string(),
llm: GameCreatorLlmConfig {
request_timeout_ms: MIN_GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS - 1,
..GameCreatorLlmConfig::default()
},
editor_api: GameCreatorEditorApiConfig::default(),
agent_llm: BTreeMap::new(),
planning: GameCreatorPlanningConfig::default(),
selected_model_id: "default".to_string(),
});
assert!(result
.expect_err("too small timeout")
.contains("至少为 1000"));
assert!(!root.join(GAME_CREATOR_CONFIG_FILE_NAME).exists());
fs::remove_dir_all(root).expect("cleanup runtime config dir");
}
#[tokio::test]
async fn chat_with_game_creator_role_agent_plain_entry_ignores_stream_config() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "普通回复回退项目").expect("project init");
let (sender, receiver) = mpsc::channel();
let base_url = spawn_mock_llm_server_responses_with_capture(
vec!["普通回复回退成功。".to_string()],
Some(sender),
);
let _config_guard = write_test_local_config(format!(
r#"{{
"agentLlm": {{
"art-director": {{
"apiKey": "art-key",
"baseUrl": {base_url:?},
"model": "art-chat-model",
"apiKind": "openai_chat",
"stream": true,
"maxRetries": 0
}}
}}
}}"#
));
let reply =
chat_with_game_creator_role_agent_at(&root, "art-director", "事件监听不可用,改用普通回复")
.await
.expect("plain role chat reply");
assert_eq!(reply.reply_text, "普通回复回退成功。");
let request = receiver
.recv_timeout(Duration::from_secs(1))
.expect("captured plain role chat request");
assert!(request.contains("POST /chat/completions HTTP/1.1"));
assert!(request.contains("\"stream\":false"));
assert!(!request.contains("\"stream\":true"));
fs::remove_dir_all(root).ok();
}
#[test]
fn llm_config_check_reports_status_without_leaking_key() {
let missing = check_game_creator_llm_config_values(
&GameCreatorLlmConfig {
api_key: String::new(),
..GameCreatorLlmConfig::default()
},
"llm",
);
assert!(!missing.configured);
assert!(missing.error.unwrap().contains("LLM 未配置"));
let too_fast = check_game_creator_llm_config_values(
&GameCreatorLlmConfig {
api_key: "unit-test-api-key".to_string(),
base_url: "http://127.0.0.1:1/v1".to_string(),
model: "mock-game-model".to_string(),
request_timeout_ms: MIN_GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS - 1,
..GameCreatorLlmConfig::default()
},
"llm",
);
assert!(!too_fast.configured);
assert!(too_fast
.error
.as_deref()
.expect("too fast error")
.contains("至少为 1000"));
assert!(!serde_json::to_string(&too_fast)
.unwrap()
.contains("unit-test-api-key"));
let configured = check_game_creator_llm_config_values(
&GameCreatorLlmConfig {
api_key: "unit-test-api-key".to_string(),
base_url: "http://127.0.0.1:1/v1".to_string(),
model: "mock-game-model".to_string(),
..GameCreatorLlmConfig::default()
},
"llm",
);
assert!(configured.configured);
assert!(!configured.web_search_enabled);
let serialized = serde_json::to_string(&configured).unwrap();
assert!(!serialized.contains("unit-test-api-key"));
assert!(!serialized.contains("http://127.0.0.1:1/v1"));
assert!(!serialized.contains("mock-game-model"));
assert!(!serialized.contains("apiKind"));
}
#[test]
fn llm_config_check_reports_per_agent_status_without_leaking_keys() {
let root = unique_project_path();
fs::create_dir_all(&root).expect("runtime config dir");
let _guard = use_test_runtime_config_dir(root.clone());
fs::write(
root.join(GAME_CREATOR_CONFIG_FILE_NAME),
r#"{
"agentMode": "provider",
"llm": {
"apiKey": "",
"baseUrl": "https://global.example.test/v1",
"model": "global-model",
"webSearchEnabled": true
},
"agentLlm": {
"project-supervisor": {
"apiKey": "supervisor-secret-key",
"baseUrl": "https://supervisor.example.test/v1",
"model": "supervisor-model",
"apiKind": "openai_chat"
},
"planner": {
"apiKey": "planner-secret-key",
"baseUrl": "https://planner.example.test/v1",
"model": "planner-model",
"apiKind": "anthropic",
"webSearchEnabled": false
},
"generator": {
"apiKey": "generator-secret-key",
"baseUrl": "https://generator.example.test/v1",
"model": "generator-model",
"apiKind": "openai_chat"
},
"art-asset-plan": {
"apiKey": "art-secret-key",
"baseUrl": "https://art.example.test/v1",
"model": "art-model",
"apiKind": "openai_chat"
}
}
}
"#,
)
.expect("write runtime config");
let status = check_game_creator_llm_config_from_config();
assert!(status.configured, "{:?}", status.error);
assert!(status.web_search_enabled);
assert!(status.agents.len() > 2);
let planner = status
.agents
.iter()
.find(|agent| agent.agent_id == "planner")
.expect("planner status");
assert!(planner.configured);
assert!(!planner.web_search_enabled);
let generator = status
.agents
.iter()
.find(|agent| agent.agent_id == "generator")
.expect("generator status");
assert!(generator.configured);
assert!(generator.web_search_enabled);
let art = status
.agents
.iter()
.find(|agent| agent.agent_id == "art-asset-plan")
.expect("art agent status");
assert!(art.configured);
assert_eq!(art.label, "美术组 / Asset");
assert_eq!(art.reasoning_effort, "high");
let orchestrator = status
.agents
.iter()
.find(|agent| agent.agent_id == "orchestrator")
.expect("orchestrator status");
assert_eq!(orchestrator.reasoning_effort, "medium");
let preview = status
.agents
.iter()
.find(|agent| agent.agent_id == "preview-readiness")
.expect("preview status");
assert_eq!(preview.reasoning_effort, "low");
let serialized = serde_json::to_string(&status).expect("status json");
assert!(!serialized.contains("planner-secret-key"));
assert!(!serialized.contains("generator-secret-key"));
assert!(!serialized.contains("art-secret-key"));
assert!(!serialized.contains("supervisor-secret-key"));
assert!(!serialized.contains("global.example.test"));
assert!(!serialized.contains("supervisor.example.test"));
assert!(!serialized.contains("planner-model"));
assert!(!serialized.contains("generator-model"));
assert!(!serialized.contains("art-model"));
assert!(!serialized.contains("apiKind"));
fs::remove_dir_all(root).ok();
}
#[test]
fn llm_config_check_reports_agent_specific_config_paths() {
let root = unique_project_path();
fs::create_dir_all(&root).expect("runtime config dir");
let _guard = use_test_runtime_config_dir(root.clone());
fs::write(
root.join(GAME_CREATOR_CONFIG_FILE_NAME),
r#"{
"agentMode": "provider",
"llm": {
"apiKey": "global-key",
"baseUrl": "https://global.example.test/v1",
"model": "global-model"
},
"agentLlm": {
"generator": {
"apiKey": "",
"baseUrl": "https://generator.example.test/v1",
"model": "generator-model"
}
}
}
"#,
)
.expect("write runtime config");
let status = check_game_creator_llm_config_from_config();
assert!(!status.configured);
let generator = status
.agents
.iter()
.find(|agent| agent.agent_id == "generator")
.expect("generator status");
let error = generator.error.as_deref().expect("generator error");
assert!(error.contains("agentLlm.generator.apiKey"));
assert!(!serde_json::to_string(&status)
.expect("status json")
.contains("global-key"));
fs::remove_dir_all(root).ok();
}
#[tokio::test]
async fn llm_config_diagnostic_command_runs_asynchronously() {
let root = unique_project_path();
fs::create_dir_all(&root).expect("create runtime config dir");
let _guard = use_test_runtime_config_dir(root.clone());
fs::write(
root.join(GAME_CREATOR_CONFIG_FILE_NAME),
r#"{
"agentMode": "provider",
"llm": {
"apiKey": "test-key",
"baseUrl": "https://example.test/v1",
"model": "test-model"
}
}
"#,
)
.expect("write runtime config");
let status = crate::commands::check_game_creator_llm_config()
.await
.expect("run config diagnostic command");
assert!(status.configured, "{status:?}");
fs::remove_dir_all(root).ok();
}
#[test]
fn llm_status_cli_lines_include_agent_errors_without_leaking_keys() {
let status = GameCreatorLlmConfigStatus {
agent_mode: GAME_CREATOR_AGENT_MODE_PROVIDER.to_string(),
configured: false,
account_credential_state: "not_required".to_string(),
official_route_locked: false,
reasoning_effort: "high".to_string(),
stream: false,
web_search_enabled: true,
context_window_tokens: 128_000,
auto_compact_token_limit: 64_000,
tool_output_token_limit: 12_000,
request_timeout_ms: 180_000,
max_retries: 2,
retry_backoff_ms: 500,
error: Some("Generator:缺少 API Key".to_string()),
agents: vec![GameCreatorAgentLlmConfigStatus {
agent_mode: GAME_CREATOR_AGENT_MODE_PROVIDER.to_string(),
agent_id: "generator".to_string(),
label: "Generator".to_string(),
configured: false,
account_credential_state: "not_required".to_string(),
official_route_locked: false,
reasoning_effort: "medium".to_string(),
stream: true,
web_search_enabled: false,
context_window_tokens: 96_000,
auto_compact_token_limit: 48_000,
tool_output_token_limit: 8_000,
request_timeout_ms: 90_000,
max_retries: 1,
retry_backoff_ms: 250,
error: Some("LLM 未配置:请在 agentLlm.generator.apiKey 中设置 API Key".to_string()),
}],
};
let lines = game_creator_llm_status_lines(&status).join("\n");
assert!(lines.contains("llm.agent.generator.error=LLM 未配置"));
assert!(lines.contains("llm.agent.generator.stream=true"));
assert!(lines.contains("llm.webSearchEnabled=true"));
assert!(lines.contains("llm.agent.generator.webSearchEnabled=false"));
assert!(lines.contains("llm.reasoningEffort=high"));
assert!(lines.contains("llm.agent.generator.reasoningEffort=medium"));
assert!(lines.contains("llm.maxRetries=2"));
assert!(lines.contains("llm.agent.generator.maxRetries=1"));
assert!(lines.contains("llm.error=Generator:缺少 API Key"));
assert!(!lines.contains("llm.baseUrl="));
assert!(!lines.contains("llm.model="));
assert!(!lines.contains("llm.apiKind="));
assert!(!lines.contains("llm.agent.generator.baseUrl="));
assert!(!lines.contains("llm.agent.generator.model="));
assert!(!lines.contains("llm.agent.generator.apiKind="));
assert!(!lines.contains("sk-"));
assert!(!lines.contains("secret"));
}
#[test]
fn llm_api_kind_parses_canonical_names() {
assert_eq!(
parse_game_creator_llm_api_kind("anthropic"),
Ok(LlmApiKind::Anthropic)
);
assert_eq!(
parse_game_creator_llm_api_kind("openai_chat"),
Ok(LlmApiKind::OpenAiChat)
);
assert_eq!(
parse_game_creator_llm_api_kind("openai_responses"),
Ok(LlmApiKind::OpenAiResponses)
);
assert_eq!(
parse_game_creator_llm_api_kind(""),
Ok(LlmApiKind::OpenAiResponses)
);
assert!(parse_game_creator_llm_api_kind("legacy").is_err());
}
#[test]
fn llm_reasoning_effort_supports_provider_default_and_explicit_levels() {
assert_eq!(parse_game_creator_llm_reasoning_effort("default"), Ok(None));
assert_eq!(
parse_game_creator_llm_reasoning_effort("low"),
Ok(Some(platform_llm::LlmResponseReasoningEffort::Low))
);
assert_eq!(
parse_game_creator_llm_reasoning_effort("medium"),
Ok(Some(platform_llm::LlmResponseReasoningEffort::Medium))
);
assert_eq!(
parse_game_creator_llm_reasoning_effort("high"),
Ok(Some(platform_llm::LlmResponseReasoningEffort::High))
);
assert_eq!(
parse_game_creator_llm_reasoning_effort("max"),
Ok(Some(platform_llm::LlmResponseReasoningEffort::Max))
);
let error = parse_game_creator_llm_reasoning_effort("maximum")
.expect_err("maximum is not the max wire value");
assert!(error.contains("max"));
let mut llm = GameCreatorLlmConfig::default();
llm.reasoning_effort = "default".to_string();
let request =
apply_game_creator_llm_reasoning_effort(LlmRunRequest::single_turn("system", "user"), &llm)
.expect("provider default request");
assert_eq!(request.response_reasoning_effort, None);
llm.reasoning_effort = "max".to_string();
let request =
apply_game_creator_llm_reasoning_effort(LlmRunRequest::single_turn("system", "user"), &llm)
.expect("max reasoning request");
assert_eq!(
request.response_reasoning_effort,
Some(platform_llm::LlmResponseReasoningEffort::Max)
);
}
#[test]
fn llm_web_search_applies_only_when_allowed_and_rejects_anthropic() {
let mut llm = GameCreatorLlmConfig {
web_search_enabled: true,
..GameCreatorLlmConfig::default()
};
let request =
apply_game_creator_llm_web_search(LlmRunRequest::single_turn("system", "user"), &llm, true)
.expect("apply enabled web search");
assert!(request.enable_web_search);
let request = apply_game_creator_llm_web_search(
LlmRunRequest::single_turn("system", "user"),
&llm,
false,
)
.expect("disallow web search for this request");
assert!(!request.enable_web_search);
llm.api_kind = "anthropic".to_string();
let error =
apply_game_creator_llm_web_search(LlmRunRequest::single_turn("system", "user"), &llm, true)
.expect_err("Anthropic web search must fail closed");
assert!(error.contains("apiKind=anthropic"), "{error}");
}
#[test]
fn anthropic_web_search_is_rejected_with_precise_global_and_agent_paths() {
let anthropic_web_search = GameCreatorLlmConfig {
api_key: "test-key".to_string(),
base_url: "https://anthropic.example.test/v1".to_string(),
model: "anthropic-model".to_string(),
api_kind: "anthropic".to_string(),
web_search_enabled: true,
..GameCreatorLlmConfig::default()
};
let global_status = check_game_creator_llm_config_values(&anthropic_web_search, "llm");
assert!(!global_status.configured);
assert!(global_status
.error
.as_deref()
.is_some_and(|error| error.contains("llm.webSearchEnabled")));
let client_error = build_game_creator_llm_client_from_llm_config(
&anthropic_web_search,
"agentLlm.design-director",
)
.expect_err("client build must reject Anthropic web search");
assert!(
client_error.contains("agentLlm.design-director.webSearchEnabled"),
"{client_error}"
);
let global_error = normalize_game_creator_app_config(GameCreatorAppConfig {
llm: anthropic_web_search.clone(),
..GameCreatorAppConfig::default()
})
.expect_err("saving global Anthropic web search must fail");
assert!(
global_error.contains("llm.webSearchEnabled"),
"{global_error}"
);
let mut inherited_agent_llm = BTreeMap::new();
inherited_agent_llm.insert(
"planner".to_string(),
GameCreatorLlmConfigFile {
api_kind: Some("anthropic".to_string()),
..GameCreatorLlmConfigFile::default()
},
);
let agent_error = normalize_game_creator_app_config(GameCreatorAppConfig {
llm: GameCreatorLlmConfig {
web_search_enabled: true,
..GameCreatorLlmConfig::default()
},
agent_llm: inherited_agent_llm,
..GameCreatorAppConfig::default()
})
.expect_err("saving inherited Agent web search must fail");
assert!(
agent_error.contains("agentLlm.planner.webSearchEnabled"),
"{agent_error}"
);
let mut overridden_agent_llm = BTreeMap::new();
overridden_agent_llm.insert(
"planner".to_string(),
GameCreatorLlmConfigFile {
api_kind: Some("anthropic".to_string()),
web_search_enabled: Some(false),
..GameCreatorLlmConfigFile::default()
},
);
let normalized = normalize_game_creator_app_config(GameCreatorAppConfig {
llm: GameCreatorLlmConfig {
web_search_enabled: true,
..GameCreatorLlmConfig::default()
},
agent_llm: overridden_agent_llm,
..GameCreatorAppConfig::default()
})
.expect("explicit Agent false must override inherited true");
assert!(!resolve_game_creator_llm_config_for_agent(&normalized, "planner").web_search_enabled);
}
#[test]
fn llm_config_status_preserves_global_web_search_error_when_required_agents_override() {
let _config_guard = write_test_local_config(
r#"{
"agentMode": "provider",
"llm": {
"apiKey": "test-key",
"baseUrl": "https://anthropic.example.test/v1",
"model": "anthropic-model",
"apiKind": "anthropic",
"webSearchEnabled": true
},
"agentLlm": {
"project-supervisor": { "webSearchEnabled": false },
"planner": { "webSearchEnabled": false },
"generator": { "webSearchEnabled": false }
}
}"#
.to_string(),
);
let status = check_game_creator_llm_config_from_config();
assert!(!status.configured);
assert!(status
.error
.as_deref()
.is_some_and(|error| error.contains("llm.webSearchEnabled")));
for agent_id in GAME_CREATOR_REQUIRED_LLM_AGENT_IDS {
let agent = status
.agents
.iter()
.find(|agent| agent.agent_id == agent_id)
.expect("required Agent status");
assert!(agent.configured, "{agent_id} should override search off");
assert!(!agent.web_search_enabled);
}
}
#[test]
fn cli_runtime_config_dir_is_explicit_absolute_and_removed_before_command_parse() {
let temp_root = std::env::temp_dir();
let config_dir = temp_root.join("genarrative-appdata");
let project_dir = temp_root.join("genarrative-cli-game");
let mut args = vec![
"--agent-task".to_string(),
"--config-dir".to_string(),
config_dir.to_string_lossy().into_owned(),
project_dir.to_string_lossy().into_owned(),
"code-prototype".to_string(),
"修复失败测试".to_string(),
];
assert_eq!(
take_cli_runtime_config_dir(&mut args).expect("take config dir"),
Some(config_dir)
);
assert!(!args.iter().any(|arg| arg == "--config-dir"));
assert!(matches!(
parse_cli_command(&args).expect("parse command"),
Some(CliCommand::AgentTask { .. })
));
let mut relative = vec!["--config-dir".to_string(), "config".to_string()];
assert!(take_cli_runtime_config_dir(&mut relative)
.expect_err("relative config dir must fail")
.contains("绝对路径"));
let mut duplicate = vec![
"--config-dir".to_string(),
temp_root.join("a").to_string_lossy().into_owned(),
"--config-dir".to_string(),
temp_root.join("b").to_string_lossy().into_owned(),
];
assert!(take_cli_runtime_config_dir(&mut duplicate)
.expect_err("duplicate config dir must fail")
.contains("只能指定一次"));
}
#[test]
fn cli_runtime_writes_require_appdata_and_reject_project_local_config_dir() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "CLI AppData 边界测试").expect("project init");
let canonical_root = fs::canonicalize(&root).expect("canonical project");
let commands = [
CliCommand::AgentTask {
project_path: canonical_root.clone(),
agent_id: "code-prototype".to_string(),
task: "task".to_string(),
initialize: false,
},
CliCommand::AgentEnqueue {
project_path: canonical_root.clone(),
agent_id: "code-prototype".to_string(),
run_id: "run-1".to_string(),
task: "task".to_string(),
initialize: false,
},
CliCommand::AgentConfirm {
project_path: canonical_root.clone(),
agent_id: "code-prototype".to_string(),
run_id: "run-1".to_string(),
action_id: "action-1".to_string(),
},
CliCommand::AgentResume {
project_path: canonical_root.clone(),
},
];
for mut command in commands {
assert!(command.requires_external_agent_runner());
assert!(prepare_cli_command_paths(&mut command, None)
.expect_err("runtime write without AppData must fail")
.contains("--config-dir"));
}
let project_config = root.join("appdata");
fs::create_dir(&project_config).expect("create project-local appdata lure");
let mut command = CliCommand::AgentRuntimeStatus {
project_path: root.clone(),
agent_id: "code-prototype".to_string(),
};
assert!(
prepare_cli_command_paths(&mut command, Some(&project_config))
.expect_err("project-local config dir must fail")
.contains("项目目录外")
);
fs::remove_dir_all(root).ok();
}
#[cfg(unix)]
#[test]
fn appdata_config_dir_is_owned_privately() {
use std::os::unix::fs::PermissionsExt;
let config_dir = unique_project_path();
fs::create_dir(&config_dir).expect("create appdata directory");
fs::set_permissions(&config_dir, fs::Permissions::from_mode(0o755))
.expect("make appdata directory broad");
let canonical =
prepare_game_creator_runtime_config_dir(&config_dir).expect("tighten appdata directory");
assert_eq!(
fs::metadata(&canonical)
.expect("appdata metadata")
.permissions()
.mode()
& 0o777,
0o700
);
fs::remove_dir_all(config_dir).ok();
}
#[cfg(windows)]
#[test]
fn newly_created_windows_appdata_is_owned_by_token_user() {
let root = unique_project_path();
let config_dir = root.join("appdata");
let prepared = prepare_game_creator_runtime_config_dir(&config_dir)
.expect("create and secure Windows AppData directory");
// TokenOwner 可能是 Administrators;安全边界必须以 TokenUser SID 为准。
secure_windows_game_creator_path_for_current_user(&prepared, true, false)
.expect("prepared directory owner must match TokenUser SID");
fs::remove_dir_all(root).ok();
}
#[cfg(windows)]
#[test]
fn windows_foreign_owner_prepare_preserves_backup_and_recreates_private_appdata() {
let root = unique_project_path();
fs::create_dir_all(&root).expect("create backup test root");
let config_dir = root.join("appdata");
fs::create_dir(&config_dir).expect("create old config directory");
fs::write(config_dir.join("important.json"), b"preserve-me").expect("write old configuration");
if !set_windows_test_path_owner_to_distinct_token_owner(&config_dir) {
eprintln!("skip: 当前 Windows token 没有区别于 TokenUser 且可设置的默认 owner SID");
fs::remove_dir_all(root).ok();
return;
}
assert!(secure_windows_game_creator_path_for_current_user(&config_dir, true, false).is_err());
let prepared = prepare_game_creator_runtime_config_dir(&config_dir)
.expect("prepare must isolate foreign-owner AppData and recreate it");
assert_eq!(
prepared,
fs::canonicalize(&config_dir).expect("canonical AppData")
);
secure_windows_game_creator_path_for_current_user(&prepared, true, false)
.expect("new AppData must be owned privately by TokenUser SID");
let backups = fs::read_dir(&root)
.expect("read backup parent")
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| {
path.file_name().is_some_and(|name| {
name.to_string_lossy()
.starts_with("appdata.owner-mismatch-backup-")
})
})
.collect::<Vec<_>>();
assert_eq!(
backups.len(),
1,
"must create exactly one owner-mismatch backup"
);
assert_eq!(
fs::read(backups[0].join("important.json")).expect("read preserved configuration"),
b"preserve-me"
);
assert!(!config_dir.join("important.json").exists());
fs::remove_dir_all(root).ok();
}
#[cfg(windows)]
pub(crate) fn set_windows_test_path_owner_to_distinct_token_owner(path: &Path) -> bool {
use std::ffi::c_void;
use std::os::windows::ffi::OsStrExt;
type Handle = *mut c_void;
type Sid = *mut c_void;
#[repr(C)]
struct SidAndAttributes {
sid: Sid,
attributes: u32,
}
#[repr(C)]
struct TokenUser {
user: SidAndAttributes,
}
#[repr(C)]
struct TokenOwner {
owner: Sid,
}
#[link(name = "advapi32")]
unsafe extern "system" {
fn OpenProcessToken(process: Handle, access: u32, token: *mut Handle) -> i32;
fn GetTokenInformation(
token: Handle,
information_class: u32,
information: *mut c_void,
information_length: u32,
return_length: *mut u32,
) -> i32;
fn EqualSid(first: Sid, second: Sid) -> i32;
fn SetNamedSecurityInfoW(
object_name: *mut u16,
object_type: u32,
security_info: u32,
owner: Sid,
group: Sid,
dacl: *mut c_void,
sacl: *mut c_void,
) -> u32;
}
#[link(name = "kernel32")]
unsafe extern "system" {
fn GetCurrentProcess() -> Handle;
fn CloseHandle(handle: Handle) -> i32;
}
const TOKEN_QUERY: u32 = 0x0000_0008;
const TOKEN_USER_CLASS: u32 = 1;
const TOKEN_OWNER_CLASS: u32 = 4;
const SE_FILE_OBJECT: u32 = 1;
const OWNER_SECURITY_INFORMATION: u32 = 0x0000_0001;
unsafe fn token_information(token: Handle, class: u32) -> Option<Vec<usize>> {
let mut required = 0_u32;
unsafe { GetTokenInformation(token, class, std::ptr::null_mut(), 0, &mut required) };
if required == 0 {
return None;
}
let word_size = std::mem::size_of::<usize>();
let mut buffer = vec![0_usize; (required as usize).div_ceil(word_size)];
if unsafe {
GetTokenInformation(
token,
class,
buffer.as_mut_ptr().cast(),
required,
&mut required,
)
} == 0
{
return None;
}
Some(buffer)
}
let mut token = std::ptr::null_mut();
if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0
|| token.is_null()
{
return false;
}
let changed = (|| {
let user_buffer = unsafe { token_information(token, TOKEN_USER_CLASS) }?;
let owner_buffer = unsafe { token_information(token, TOKEN_OWNER_CLASS) }?;
let token_user = unsafe { (*(user_buffer.as_ptr().cast::<TokenUser>())).user.sid };
let token_owner = unsafe { (*(owner_buffer.as_ptr().cast::<TokenOwner>())).owner };
if token_user.is_null()
|| token_owner.is_null()
|| unsafe { EqualSid(token_user, token_owner) } != 0
{
return None;
}
let mut wide_path = path
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect::<Vec<_>>();
let status = unsafe {
SetNamedSecurityInfoW(
wide_path.as_mut_ptr(),
SE_FILE_OBJECT,
OWNER_SECURITY_INFORMATION,
token_owner,
std::ptr::null_mut(),
std::ptr::null_mut(),
std::ptr::null_mut(),
)
};
(status == 0).then_some(())
})()
.is_some();
unsafe { CloseHandle(token) };
changed
}
#[cfg(windows)]
#[test]
fn windows_private_dacl_does_not_reassert_an_owner_that_already_matches() {
const OWNER_SECURITY_INFORMATION: u32 = 0x0000_0001;
const DACL_SECURITY_INFORMATION: u32 = 0x0000_0004;
const PROTECTED_DACL_SECURITY_INFORMATION: u32 = 0x8000_0000;
assert_eq!(
windows_private_dacl_security_information(true, true),
DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION
);
assert_eq!(
windows_private_dacl_security_information(true, false),
OWNER_SECURITY_INFORMATION
| DACL_SECURITY_INFORMATION
| PROTECTED_DACL_SECURITY_INFORMATION
);
}
#[cfg(windows)]
#[test]
fn windows_appdata_validation_does_not_follow_directory_links() {
let root = unique_project_path();
let real = root.join("real-appdata");
let link = root.join("linked-appdata");
fs::create_dir_all(&real).expect("create real directory");
if std::os::windows::fs::symlink_dir(&real, &link).is_err() {
fs::remove_dir_all(root).ok();
return;
}
let error = inspect_game_creator_runtime_config_dir(&link)
.expect_err("AppData directory link must be rejected before canonicalize");
assert!(error.contains("链接") || error.contains("reparse point"));
fs::remove_dir_all(root).ok();
}