补齐试玩回包审查要求的关键验收(#548)
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled

绑定逐视口摘要中的状态、阻断原因与首个未通过断言
验证成功和失败结果均完整进入最终 MCP 回包
新增真实点击后未进入 playing 的浏览器场景并对照持久报告
收紧视觉未执行断言并补充长期验收要求
This commit is contained in:
2026-10-03 16:59:53 +08:00
parent 6c0f504c12
commit 1a27e40758
4 changed files with 149 additions and 59 deletions
@@ -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]
@@ -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;
}
@@ -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#"<button type="button" data-playtest-id="start" data-start-phase="ready">Start</button>"#,
)).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
@@ -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;显式配置原样使用,不按角色或运行模式改写。超限直接返回已用/上限和最近证据,停止新的验证。预检与正常构建不计作重复试玩。