diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index 00bf6675d..30a7ad3bb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -4422,67 +4422,100 @@ pub(super) fn drain_analytics_test_writer( #[cfg(test)] mod tests { #[test] - fn browser_gameplay_failure_reaches_model_with_both_viewports_and_screenshots() { - let temp = tempfile::tempdir().unwrap(); - let evidence = - super::super::direct_validation::tests::failed_browser_evidence_fixture(temp.path()); - for viewport in &evidence.viewport_results { - image::RgbaImage::new(2, 2) - .save(&viewport.screenshot_path) - .unwrap(); - } - let receipt = super::super::direct_validation::browser_tool_result( - temp.path(), - &evidence, - Some(crate::browser::BrowserPlaytestScenario::GenericV1), - 1, - ); - assert!(evidence.viewport_results.iter().all(|result| result.passed)); - assert_eq!(receipt["passed"], false); - let failure = - bridge_validation_result(temp.path(), Ok(receipt.clone()), |report, screenshots| { - BrowserPlaytestError::PlaytestNotPassed { + fn browser_gameplay_results_reach_model_with_matching_viewports_and_screenshots() { + for passed in [false, true] { + let temp = tempfile::tempdir().unwrap(); + let evidence = if passed { + super::super::direct_validation::tests::browser_evidence_fixture(temp.path()) + } else { + super::super::direct_validation::tests::failed_browser_evidence_fixture(temp.path()) + }; + for viewport in &evidence.viewport_results { + image::RgbaImage::new(2, 2) + .save(&viewport.screenshot_path) + .unwrap(); + } + let receipt = super::super::direct_validation::browser_tool_result( + temp.path(), + &evidence, + Some(crate::browser::BrowserPlaytestScenario::GenericV1), + 1, + ); + assert!(evidence.viewport_results.iter().all(|result| result.passed)); + assert_eq!(receipt["passed"], passed); + let outcome = bridge_validation_result( + temp.path(), + Ok(receipt.clone()), + |report, screenshots| BrowserPlaytestError::PlaytestNotPassed { report, screenshots, - } - }) - .unwrap_err(); - let error = ToolCallError::from(&failure); - assert_eq!(error.images.len(), 2); - assert_eq!(error.error["PlaytestNotPassed"]["report"], receipt); - assert!(error.error["PlaytestNotPassed"] - .get("screenshots") - .is_none()); - let state = direct_tool_bridge_state(temp.path().to_path_buf()); - let response = - compose_direct_tool_outcome(&state, "agc_browser_playtest", "{}", false, Err(error)); - assert_eq!(response["isError"], true); - let text = response["content"][0]["text"].as_str().unwrap(); - assert_eq!( - response["content"] - .as_array() - .unwrap() - .iter() - .filter(|block| block["type"] == "image") - .count(), - 2 - ); - for (index, entry) in evidence.viewport_playtests.iter().enumerate() { - let report = &receipt["gameplayResults"][index]; + }, + ) + .map_err(|failure| { + let error = ToolCallError::from(&failure); + assert_eq!(error.error["PlaytestNotPassed"]["report"], receipt); + assert!(error.error["PlaytestNotPassed"] + .get("screenshots") + .is_none()); + error + }); + let state = direct_tool_bridge_state(temp.path().to_path_buf()); + let response = + compose_direct_tool_outcome(&state, "agc_browser_playtest", "{}", false, outcome); + assert_eq!(response["isError"], !passed); + let text = response["content"][0]["text"].as_str().unwrap(); + let model_receipt: Value = + serde_json::from_str(&text[text.find('{').unwrap()..]).unwrap(); + assert_eq!(model_receipt, receipt); assert_eq!( - report["assertions"], - serde_json::to_value(&entry.result.assertions).unwrap() + response["content"] + .as_array() + .unwrap() + .iter() + .filter(|block| block["type"] == "image") + .count(), + 2 ); - assert_eq!(report["finalPhase"], "ready"); - assert_eq!(report["finalSequence"], index as u64); - assert!(text.contains(&entry.result.diagnostics[0])); + let summary = model_receipt["summary"].as_str().unwrap(); + for (index, entry) in evidence.viewport_playtests.iter().enumerate() { + let report = &model_receipt["gameplayResults"][index]; + assert_eq!( + report["viewport"], + serde_json::to_value(entry.viewport).unwrap() + ); + assert_eq!(report["passed"], entry.result.passed); + assert_eq!( + report["assertions"], + serde_json::to_value(&entry.result.assertions).unwrap() + ); + assert_eq!(report["finalPhase"], "ready"); + assert_eq!( + report["finalSequence"], + entry.result.final_sequence.unwrap() + ); + let viewport = report["viewport"].as_str().unwrap(); + let line = summary + .lines() + .find(|line| line.starts_with(&format!("{viewport}: gameplayPassed="))) + .unwrap(); + assert!(line.starts_with(&format!( + "{viewport}: gameplayPassed={}", + entry.result.passed + ))); + if let Some(reason) = entry.result.diagnostics.first() { + assert_eq!(report["diagnostics"][0], reason.as_str()); + assert!(line.contains(reason)); + } + if let Some(assertion) = entry + .result + .assertions + .iter() + .find(|assertion| !assertion.passed) + { + assert!(line.contains(&assertion.name)); + } + } } - let summary = receipt["summary"].as_str().unwrap(); - assert!(summary.contains("desktop: gameplayPassed=false")); - assert!(summary.contains("mobile: gameplayPassed=false")); - assert!(summary.contains("start-control-clicked")); - assert!(summary.contains("start-phase-playing")); - assert!(summary.contains("未执行")); } #[tokio::test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs index ecee74473..a42aed1b0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs @@ -786,7 +786,9 @@ pub(super) mod tests { assert_eq!(receipt["passed"], mode != "missing-mobile"); if mode == "visual" { assert_eq!(receipt["gameplayResults"], json!([])); - assert!(summary.contains("未执行")); + assert!(summary + .lines() + .any(|line| line.starts_with("玩法检查:未执行"))); assert_eq!(receipt["coverage"], "visual-only"); continue; } diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs index adcc64f1b..cb5d1cf8b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs @@ -1514,6 +1514,41 @@ async fn real_chrome_generic_playtest_pointer_events_keeps_unavailable_controls_ } } +#[tokio::test] +#[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"] +async fn real_chrome_generic_playtest_reports_start_click_without_playing_phase() { + let result = run_stable_generic_viewport_fixture(false, Some( + r#""#, + )).await; + assert!(!result.passed); + assert!(result + .viewport_results + .iter() + .all(|viewport| viewport.passed)); + for entry in &result.viewport_playtests { + let playtest = &entry.result; + assert!(!playtest.passed); + assert_eq!(playtest.final_phase, Some(BrowserPlaytestPhase::Ready)); + assert_eq!(playtest.final_sequence, Some(1)); + for (name, expected) in [ + ("start-control-clicked", true), + ("start-phase-playing", false), + ] { + assert_eq!( + playtest + .assertions + .iter() + .find(|assertion| assertion.name == name) + .unwrap() + .passed, + expected + ); + } + let reason = &playtest.diagnostics[0]; + assert!(reason.contains("必须进入 playing"), "{reason}"); + } +} + async fn run_stable_generic_viewport_fixture( mobile_action_broken: bool, start_markup: Option<&str>, @@ -1556,7 +1591,7 @@ async fn run_stable_generic_viewport_fixture( }; document.querySelector('[data-playtest-id="start"]').addEventListener('click', (event) => { if (event.isTrusted && state.phase === 'ready') { - advance(() => { state.phase = 'playing'; }); + advance(() => { state.phase = event.currentTarget.dataset.startPhase || 'playing'; }); } }); const primary = document.querySelector('[data-playtest-id="primary-action"]'); @@ -1656,6 +1691,17 @@ async fn run_stable_generic_viewport_fixture( { let projected = &receipt["gameplayResults"][index]; assert_eq!(projected["viewport"], entry["viewport"]); + let viewport = entry["viewport"].as_str().unwrap(); + let line = receipt["summary"] + .as_str() + .unwrap() + .lines() + .find(|line| line.starts_with(&format!("{viewport}: gameplayPassed="))) + .unwrap(); + assert!(line.starts_with(&format!( + "{viewport}: gameplayPassed={}", + entry["result"]["passed"] + ))); for field in [ "passed", "assertions", @@ -1670,7 +1716,15 @@ async fn run_stable_generic_viewport_fixture( } if let Some(reason) = entry["result"]["diagnostics"][0].as_str() { assert_eq!(projected["diagnostics"][0], reason); - assert!(receipt["summary"].as_str().unwrap().contains(reason)); + assert!(line.contains(reason)); + } + if let Some(assertion) = entry["result"]["assertions"] + .as_array() + .unwrap() + .iter() + .find(|assertion| assertion["passed"] == false) + { + assert!(line.contains(assertion["name"].as_str().unwrap())); } } result diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index c3e60dde4..39ea7a151 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -196,6 +196,7 @@ UI 编辑器的“分析参考图”步骤、Rust 命令 `suggest_ui_design_sema - 复用客户端浏览器与现有固定玩法场景。视觉检查采集双端画面/布局/资源/诊断;玩法检查分别在 desktop/mobile 执行明确的固定场景和真实输入,按视口保存结果。旧报告缺少移动端玩法结果时保持未知,不补通过。报告必须声明检查层级;视觉通过不能宣称玩法通过,固定场景通过也不能宣称覆盖未执行的完整关卡。 - 浏览器工具回执在 `mode=gameplay` 时新增 `gameplayResults` 双端有界投影,每个视口包含 `viewport`、`passed`、`diagnostics`、`assertions` 及 `initialPhase` / `initialSequence` / `initialLevel`、`finalPhase` / `finalSequence` / `finalLevel`。`assertions` 仅列当前固定场景的断言名称和通过状态;每端最多公开 4 条诊断,每条最多 512 字符。缺少某端结果时仍返回该视口 `passed=false`,诊断说明证据缺失,断言为空且六个状态字段为 `null`;断言的 `passed=false` 可包含未执行,不能据此断言该断言已独立执行且失败。`mode=visual` 的 `gameplayResults` 为空,并在摘要中明确玩法未执行。摘要从同一投影给出每端首条诊断和首个未通过断言,不能与结构化结果相矛盾。工具回执不得展开完整宿主报告;`reportPath` 只作宿主证据引用。宿主报告结构、验收门禁、持久化预算和私有路径保护保持原合同,旧回执不回填新字段。 +- 回执验收覆盖 start 禁用、点击后 phase 不符合和玩法成功;逐视口核对持久报告与摘要中的状态、首条诊断和首个未通过断言,并检查成功、失败两条最终 MCP 回包路径。 - 输入或碰撞改变先做定点玩法检查;纯图像/颜色变化做视觉检查;首次交付和影响闭环的修改做所需玩法验证。新增失败或相关代码变化才重跑对应层,不因改说明文字重复完整验证。 - 内置试玩和客户端托管的外部 Node/npm 验证共用当前 clientTurnId 的持久化预算。客户端分配递增执行序号,模型提供的旧 attempt 仅作兼容输入,不能减少计数或重置预算;同一轮错误反馈、工具切换和进程重启均不能刷新已消费次数。 - 新增本地 validation.maxRuns(默认 3,正整数)独立于 llm.maxRetries;显式配置原样使用,不按角色或运行模式改写。超限直接返回已用/上限和最近证据,停止新的验证。预检与正常构建不计作重复试玩。