修复固定试玩误判祖先指针穿透样式(#547) #583

Merged
lhk229 merged 2 commits from codex/fix-playtest-pointer-events-547 into master 2026-10-02 12:56:55 +08:00
4 changed files with 135 additions and 14 deletions
@@ -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();
@@ -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#"<div style="pointer-events:none;padding:20px">
<button type="button" data-playtest-id="start" style="pointer-events:auto;padding:0">
<span style="display:block;padding:20px">Start</span>
</button>
</div>"#,
),
)
.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#"<div style="pointer-events:none"><button data-playtest-id="start">Start</button></div>"#,
),
(
"own-none",
r#"<button data-playtest-id="start" style="pointer-events:none">Start</button>"#,
),
(
"own-none-child-auto",
r#"<button data-playtest-id="start" style="pointer-events:none;padding:0"><span style="pointer-events:auto;display:block;padding:20px">Start</span></button>"#,
),
(
"disabled",
r#"<button data-playtest-id="start" disabled>Start</button>"#,
),
(
"disabled-fieldset",
r#"<fieldset disabled><button data-playtest-id="start">Start</button></fieldset>"#,
),
(
"aria-disabled",
r#"<button data-playtest-id="start" aria-disabled="true">Start</button>"#,
),
(
"inert",
r#"<div inert><button data-playtest-id="start" style="pointer-events:auto">Start</button></div>"#,
),
(
"hidden",
r#"<div style="display:none"><button data-playtest-id="start">Start</button></div>"#,
),
(
"covered",
r#"<div style="position:relative;width:max-content"><button data-playtest-id="start">Start</button><div style="position:absolute;inset:0;background:black"></div></div>"#,
),
] {
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(
</body>
</html>"#;
let html = String::from_utf8_lossy(html)
.replace(
r#"<button type="button" data-playtest-id="start">Start</button>"#,
start_markup
.unwrap_or(r#"<button type="button" data-playtest-id="start">Start</button>"#),
)
.replace(
"const mobileActionBroken = false;",
if mobile_action_broken {
@@ -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」。
@@ -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 文件树保持原有可见范围。引擎生成目录仍按项目根身份排除,不能因定向列举改变判定基准。