From 98cb0d237e95be27266b3437e5c51784f9c326f5 Mon Sep 17 00:00:00 2001 From: Linghong Date: Fri, 2 Oct 2026 12:56:55 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=9B=BA=E5=AE=9A=E8=AF=95?= =?UTF-8?q?=E7=8E=A9=E8=AF=AF=E5=88=A4=E7=A5=96=E5=85=88=E6=8C=87=E9=92=88?= =?UTF-8?q?=E7=A9=BF=E9=80=8F=E6=A0=B7=E5=BC=8F=EF=BC=88#547=EF=BC=89=20(#?= =?UTF-8?q?583)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 关联 #547。 固定试玩将任意祖先的 `pointer-events:none` 累计为按钮禁用,导致覆盖层穿透、按钮恢复 `auto` 的合法布局在实际点击前被拒绝。现在只检查目标控件自身的计算样式;未覆盖的继承 `none` 仍拒绝,原有命中、可见性、disabled、aria-disabled、inert 和真实输入校验保持有效。 复用现有浏览器夹具,补充双视口穿透布局回归及九种不可用控件拒绝场景,并同步场景指纹快照、技术方案和共享排障记录。 验证: - 修复前真实 Chrome 在 desktop/mobile 均复现 start 被误判 disabled;修复后合法布局完整通过开始、主要操作和重开,sequence 从 0 到 3、最终 phase 为 ready。 - 浏览器模块单元测试:27 passed;14 个真实浏览器测试默认 ignored,其中本次显式执行并通过 4 个相关用例(新增 2 个,已有 2 个)。新增拒绝用例在双视口覆盖继承 none、目标 none、目标 none/子元素 auto、disabled、禁用 fieldset、aria-disabled、inert、隐藏和遮挡。 - rustfmt、编码检查、文档索引检查、git diff --check 通过;完成独立代码审查。 验证边界:未运行全量测试;当前工作区未找到现场游戏导出,尚未复核该游戏开始流程。generic 的 mobile 结果为移动视口下的真实鼠标输入,未进行真实触摸验收。 Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/583 Co-authored-by: Linghong Co-committed-by: Linghong --- .../src-tauri/src/browser/playtest/mod.rs | 4 +- .../src-tauri/src/browser/tests.rs | 134 ++++++++++++++++-- docs/project-memory/shared-memory/pitfalls.md | 5 + ...¹案】AI游戏创作智能体App实施计划-2026-06-24.md | 6 + 4 files changed, 135 insertions(+), 14 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/mod.rs index df4619c02..1df7ccf9a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/mod.rs @@ -422,7 +422,8 @@ pub(super) const PROBE_PLAYTEST_CONTROL_SCRIPT: &str = r#"function() { } let stylesVisible = true; - let pointerBlocked = false; + // 后代可用 auto 覆盖祖先的 none;未覆盖时计算样式仍会继承 none。 + const pointerBlocked = getComputedStyle(this).pointerEvents === 'none'; for (let current = this; current instanceof HTMLElement; current = current.parentElement) { const style = getComputedStyle(current); const opacity = Number.parseFloat(style.opacity); @@ -431,7 +432,6 @@ pub(super) const PROBE_PLAYTEST_CONTROL_SCRIPT: &str = r#"function() { && style.visibility !== 'hidden' && style.visibility !== 'collapse' && (Number.isNaN(opacity) || opacity > 0); - pointerBlocked ||= style.pointerEvents === 'none'; } const rect = this.getBoundingClientRect(); 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 0d98e7c97..9d8733862 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 @@ -270,16 +270,12 @@ fn playtest_scenario_fingerprints_are_fixed_lowercase_sha256_values() { let tetris = browser_playtest_scenario_fingerprint(BrowserPlaytestScenario::TetrisV1); let lane = browser_playtest_scenario_fingerprint(BrowserPlaytestScenario::LaneDefenseV1); assert_eq!( - generic, - "e5e1b4d01e39cb7b0dd2fd5a56982c3ddf825a926738d99152875ee1ff412a8d" - ); - assert_eq!( - tetris, - "b3c49f43f70291ea5c9431a21bb751fead2e244c8e97fecec20ee3e38215a59c" - ); - assert_eq!( - lane, - "6a24072ce7a570dd29edac0ca3fa905546140e44ca4ab6412d8fc7fe1239aa5a" + [generic.as_str(), tetris.as_str(), lane.as_str()], + [ + "85e7ec677411745ad3a7385db67a2c89441cb2ccc12a7a1853bb87ba74f15e0d", + "60c202a9fe6313dd414365db52df8b5b63110971f89708ac1563d531f510f4ff", + "a3120c8cf585f2e06091229c3b7c3a5bd8aa08821704064edc611297ee54f466", + ] ); assert!(generic.bytes().all(|byte| byte.is_ascii_hexdigit())); assert!(tetris.bytes().all(|byte| byte.is_ascii_hexdigit())); @@ -1354,7 +1350,7 @@ async fn real_chrome_generic_playtest_rejects_one_frame_playing_state() { #[tokio::test] #[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"] async fn real_chrome_generic_playtest_accepts_earlier_window_capture_stop_immediate_flow() { - let result = run_stable_generic_viewport_fixture(false).await; + let result = run_stable_generic_viewport_fixture(false, None).await; assert!( result.passed, "diagnostics={:#?}\nviewports={:#?}", @@ -1388,7 +1384,7 @@ async fn real_chrome_generic_playtest_accepts_earlier_window_capture_stop_immedi #[tokio::test] #[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"] async fn real_chrome_generic_playtest_rejects_mobile_only_unwired_control() { - let result = run_stable_generic_viewport_fixture(true).await; + let result = run_stable_generic_viewport_fixture(true, None).await; assert!( !result.passed, "a successful desktop cannot hide mobile gameplay failure" @@ -1410,8 +1406,117 @@ async fn real_chrome_generic_playtest_rejects_mobile_only_unwired_control() { .any(|message| message.starts_with("playtest mobile:"))); } +#[tokio::test] +#[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"] +async fn real_chrome_generic_playtest_pointer_events_allows_auto_under_none() { + let result = run_stable_generic_viewport_fixture( + false, + Some( + r#"
+ +
"#, + ), + ) + .await; + assert!(result.passed, "{:?}", result.diagnostics); + for viewport in REQUIRED_VIEWPORTS { + let playtest = &result + .viewport_playtests + .iter() + .find(|entry| entry.viewport == viewport) + .expect("each viewport must have gameplay evidence") + .result; + assert!(playtest.passed, "{viewport:?}: {:?}", playtest.diagnostics); + assert_eq!(playtest.initial_sequence, Some(0)); + assert_eq!(playtest.final_sequence, Some(3)); + assert_eq!(playtest.final_phase, Some(BrowserPlaytestPhase::Ready)); + assert!(playtest.assertions.iter().all(|assertion| assertion.passed)); + } +} + +#[tokio::test] +#[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"] +async fn real_chrome_generic_playtest_pointer_events_keeps_unavailable_controls_blocked() { + for (case, markup) in [ + ( + "inherited-none", + r#"
"#, + ), + ( + "own-none", + r#""#, + ), + ( + "own-none-child-auto", + r#""#, + ), + ( + "disabled", + r#""#, + ), + ( + "disabled-fieldset", + r#"
"#, + ), + ( + "aria-disabled", + r#""#, + ), + ( + "inert", + r#"
"#, + ), + ( + "hidden", + r#"
"#, + ), + ( + "covered", + r#"
"#, + ), + ] { + let result = run_stable_generic_viewport_fixture(false, Some(markup)).await; + assert!(!result.passed, "{case} must fail"); + for viewport in REQUIRED_VIEWPORTS { + let playtest = &result + .viewport_playtests + .iter() + .find(|entry| entry.viewport == viewport) + .expect("each viewport must have gameplay evidence") + .result; + assert!(!playtest.passed, "{case} / {viewport:?} must fail"); + assert!( + playtest + .diagnostics + .iter() + .any(|message| message.contains("固定试玩控件 start")), + "{case} / {viewport:?}: {:?}", + playtest.diagnostics + ); + assert!( + playtest.assertions.iter().any(|assertion| { + assertion.name == "start-control-clicked" && !assertion.passed + }), + "{case} / {viewport:?} must be rejected before input" + ); + if case == "own-none-child-auto" { + assert!( + playtest.diagnostics.iter().any(|message| { + message == "固定试玩控件 start 处于 disabled 状态" + }), + "{viewport:?}: child hit must not enable the pointer-blocked target: {:?}", + playtest.diagnostics + ); + } + } + } +} + async fn run_stable_generic_viewport_fixture( mobile_action_broken: bool, + start_markup: Option<&str>, ) -> BrowserValidationResult { use std::io::{Read, Write}; use std::net::TcpListener; @@ -1482,6 +1587,11 @@ async fn run_stable_generic_viewport_fixture( "#; let html = String::from_utf8_lossy(html) + .replace( + r#""#, + start_markup + .unwrap_or(r#""#), + ) .replace( "const mobileActionBroken = false;", if mobile_action_broken { diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index ef8834e21..bd5b91ae2 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -2,6 +2,11 @@ 这里只记录对当前开发仍有用的症状、根因、排查方法和风险边界。同一事实保留一个当前口径;退役对象的专属过程与单轮测试结果由 Git 历史追溯。遇到旧路径或版本时,以现行代码和专题文档为准。 +## 2026-10-02 固定试玩误判祖先的指针穿透样式 + +- `pointer-events:none` 不会强制禁用整棵子树;后代显式 `auto` 可以恢复命中。控件探针只检查目标的计算样式,继承未覆盖的 `none` 仍拒绝;可见性、遮挡、disabled 与 inert 保留各自检查。 +- 修复和回归必须经过生产输入入口及可信事件驱动的状态变化,不能用程序化点击证明真实可玩。双视口 generic 回归与真实触摸验收需要区分,详见 AGC 实施计划“固定试玩控件的指针命中边界”。 + ## 2026-10-01 Rust 分片编译失败只剩汇总错误 - **原因**:`--message-format=json` 把编译诊断写到 stdout;只读取 `compiler-artifact` 的运行器会丢弃 `compiler-message`,CI 只能看到「due to 1 previous error」。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 02d6de4ba..b06f3589a 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1,5 +1,11 @@ # AI 游戏创作智能体 App 实施计划 +## 2026-10-02 固定试玩控件的指针命中边界 + +固定试玩只按目标控件自身的计算样式判断 `pointer-events:none`,不累计祖先的 `none`。覆盖层为 `none`、按钮显式恢复 `auto` 是合法布局;按钮未覆盖时仍会继承 `none` 并被拒绝。保留祖先可见性、中心点 `elementFromPoint` 命中、原生 disabled、aria-disabled 和 inert 检查;目标自身为 `none` 时,即使内部子元素恢复 `auto`,也不放行目标控件。 + +继续使用现有真实鼠标/触摸输入及游戏状态变化验收,不以 JS 强制点击或改写游戏样式绕过宿主检查。公共探针源码已进入场景指纹,修改后旧指纹证据按现行 stale 规则处理。定向回归入口为 `real_chrome_generic_playtest_pointer_events_`:在 desktop/mobile 两个视口验证祖先穿透、子元素命中和开始/主要操作/重开状态推进,并验证继承禁用、目标禁用、原生禁用、aria-disabled、inert、隐藏及遮挡仍拒绝。generic 的移动视口沿用鼠标输入,不将其表述为真实触摸验收;现场游戏开始流程需另行复核。 + ## 2026-10-01 项目文件列举的范围与并发边界 `agc_list_project_files` 的项目内 `path` 在遍历时限制到目标文件、目标目录及其必要祖先;不递归无关子树,也不读取无关条目的元数据。Agent 的控制面、敏感文件和快照排除规则在读取条目元数据之前生效;通用 UI 文件树保持原有可见范围。引擎生成目录仍按项目根身份排除,不能因定向列举改变判定基准。