Files
Unlucky-Launcher/crates/app-iced/src/pages/harness/tests.rs
T
AskaEth d298691c17 fix(app-iced): M100 r3 P2-1 —— 判据从「帧超时」升级成「终态无证据即失败」
复审在 `95530a5` 的导出上用变异实测证明:把第 3 帧的采样换成 `window::close(id)` 时,
进程 **exit 0 且没有 PNG**,日志与成功跑同一形状只少了出图那几行 —— 只看退出码的脚本会读到
「成功 + 零证据」。

根因:`window::frames()` 只在 `Event::Window(RedrawRequested)` 时产消息,窗口消失 ⇒ 不再有帧
⇒ `Message::Frame` 不再产生 ⇒ `capture_stalled` **永远求值不到**;同时 iced_winit 对「最后一个
窗口 Destroyed」直接 `Control::Exit`,`run()` 返回 `Ok(())` ⇒ 例子返回 SUCCESS。
我原来在 `CAPTURE_GIVE_UP_FRAMES` 的注释里把「窗口消失」写进了覆盖清单,实测是**说反的**。

改法(后置不变量,比超时强):
- 落盘计数改成共享 `Arc<AtomicU32>`,**只在 `shot::save` 成功后**自增(U1:不是「打算写」)。
- `run()` 里 `outcome?;` 之后加终态判据 `no_evidence(written) == 0` ⇒ 打具名错误并 `exit(1)`。
- 帧超时兜底保留(管「有帧但采样回不来」);注释订正为三路:后端报错 ⇒ Err(本就非 0)、
  窗口消失 ⇒ 帧不再产生 ⇒ 由终态判据兜住、有帧但采样卡住 ⇒ 帧超时。

实测:
- 正常路径(最终二进制 ×6):exit 0、PNG 存在、`落盘次数=1`、`passthrough_ext=on`、sha `0d7b4f5b`、错误行 0。
- **M3 变异复现**(采样换成 `window::close(id)`,×3):**exit=1**、**PNG 不存在**、
  `[harness] 取证结束但没有任何图落盘(窗口提前消失 / 落盘失败),按失败退出`。
- **M4 变异**(`CAPTURE_GIVE_UP_FRAMES` 改 0,×2):仍 exit=1 + `采样超时` ⇒ 旧兜底没被改坏。
- 新断言 `finishing_without_a_single_write_is_a_failure` 守终态判据(变异:删掉 `run()` 里那条
  检查不会让它红——它守的是判据本身,不是调用点,报告里写清了「抓不住」)。
2026-11-03 17:34:14 +08:00

245 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `pages::harness` 的断言(M90 r2 从 `harness.rs` 外置:那边曾 402 行,guards 的 file-size 会判红)。
//!
//! 只测**参数解析 / slug / 采样闸门 / 三态标签**,不碰 iced 运行时。所以「穿透在真实窗口上真的
//! 关掉了命中测试」这条这里证明不了——它要看出图日志里的 `[caps] passthrough_ext=`(三态,
//! 来自 `GWL_EXSTYLE` 的 `WS_EX_TRANSPARENT`);`指针事件=N` 只是辅助指标(它只证明消息到没到)。
use super::request::Request;
use super::{capture_stalled, should_request_capture, CAPTURE_AT_FRAME, CAPTURE_GIVE_UP_FRAMES};
use crate::cli::Backend;
use crate::pages::Screen;
use unlucky_design::color::SchemeFlavor;
fn parse(args: &[&str]) -> Result<Request, String> {
Request::parse(args.iter().map(|arg| (*arg).to_owned()))
}
#[test]
fn defaults_are_the_acceptance_frame() {
let request = parse(&[]).expect("空参数要能解析");
assert_eq!(request.screen, Screen::Instances);
assert_eq!((request.width, request.height), (1180.0, 760.0));
assert_eq!(request.flavor, SchemeFlavor::Light);
assert!(request.screenshot.is_none());
assert!(!request.real_appearance, "默认画演示行");
}
#[test]
fn screen_and_width_parse_in_both_argv_forms() {
let spaced = parse(&["--screen", "settings", "--width", "900"]).unwrap();
let inline = parse(&["--screen=settings", "--width=900"]).unwrap();
assert_eq!(spaced, inline);
assert_eq!(spaced.screen, Screen::Settings);
assert_eq!(spaced.width, 900.0);
assert!(parse(&["--screen=gallery"]).is_err());
assert!(parse(&["--width=abc"]).is_err());
}
#[test]
fn screenshot_theme_wins_over_theme() {
let request = parse(&["--theme=dark", "--screenshot-theme=light"]).unwrap();
assert_eq!(request.flavor, SchemeFlavor::Light);
assert_eq!(parse(&["--theme=dark"]).unwrap().flavor, SchemeFlavor::Dark);
}
#[test]
fn software_renderer_is_an_alias_of_cpu() {
assert_eq!(
parse(&["--software-renderer"]).unwrap().backend,
Backend::Cpu
);
assert_eq!(parse(&["--renderer=cpu"]).unwrap().backend, Backend::Cpu);
assert_eq!(parse(&["--renderer=gpu"]).unwrap().backend, Backend::Gpu);
}
#[test]
fn slug_carries_screen_class_and_flavor() {
let request = parse(&[
"--screen=instances",
"--width=700",
"--screenshot-theme=dark",
])
.unwrap();
assert_eq!(request.slug(), "instances-medium-dark-w700");
}
#[test]
fn tiny_windows_are_rejected() {
assert!(parse(&["--width=200"]).is_err());
assert!(parse(&["--height=100"]).is_err());
}
/// **密闭不变量(M100)**:截图通道默认开指针穿透;交互模式(没有 `--screenshot`)一律关。
///
/// 抓得住:① 谁把取证默认值改成关(那会让取证图重新受系统指针影响);
/// ② 谁让交互模式也开穿透(用户窗口收不到鼠标 = 产品事故)。
/// 抓不住:穿透**真的**生效了没有——那要看出图日志的 `指针事件=N`,测试替不了。
#[test]
fn capture_is_sealed_by_default_and_interactive_never_is() {
assert!(
parse(&["--screenshot", "o.png"]).unwrap().passthrough,
"截图通道默认必须开指针穿透"
);
assert!(!parse(&[]).unwrap().passthrough, "交互模式不许开指针穿透");
assert!(
!parse(&["--screen=settings"]).unwrap().passthrough,
"交互模式(带页名)同样不许开"
);
// 显式给值在交互模式下是**配置错误**,不是「忽略」:静默忽略会让人以为交互窗口也密闭了
for flags in [
&["--passthrough=on"][..],
&["--passthrough", "off"],
&["--screen=settings", "--passthrough=on"],
] {
assert!(parse(flags).is_err(), "{flags:?} 应该报错");
}
}
/// 对照实验的口子(`--passthrough=off`):只服务「不开穿透时 hover 会不会漏进图」这一条。
#[test]
fn the_control_run_can_turn_the_passthrough_off() {
let shot = parse(&["--screenshot", "o.png", "--passthrough=off"]).unwrap();
assert!(!shot.passthrough, "显式 off 必须尊重");
assert!(shot.screenshot.is_some(), "off 只对截图通道有意义");
}
/// **外观真状态那一屏(M100)**:`--appearance=real` 只在设置页成立,且 slug 要与演示行版分开。///
/// 抓得住:① 谁把 real 接到别的页(那会出一张「假装接了线」的图);
/// ② 两张图落成同名文件(归档后无法区分哪张是真控件版)。
/// 抓不住:那张图里到底有没有控件——那是像素的事,由两张图的 sha256 不同 + tower 亲自看图判。
#[test]
fn the_real_control_screen_is_settings_only_and_says_so_in_the_slug() {
let real = parse(&["--screen=settings", "--appearance=real"]).unwrap();
assert!(real.real_appearance);
assert_eq!(real.slug(), "settings-real-expanded-light-w1180");
assert_eq!(
parse(&["--screen=settings"]).unwrap().slug(),
"settings-expanded-light-w1180",
"演示行版的 slug 不许变(既有归档脚本按它找图)"
);
assert!(
parse(&["--screen=instances", "--appearance=real"]).is_err(),
"别的页没有外观分组,静默接受会出一张假图"
);
assert!(parse(&["--screen=settings", "--appearance=x"]).is_err());
}
/// **一次一采样(M100 r1 P2-1)**:采样请求最多发一次,且最早在 `CAPTURE_AT_FRAME`。
///
/// 抓得住:谁把闸门改回 `frames >= CAPTURE_AT_FRAME`(那样第 3 帧之后每帧都发一次采样,
/// `Captured` 每次都覆盖同一文件 ⇒ **最后写入者说了算**;复审实测 12 次里 7 次写了两次,
/// 并把窗口在两次采样之间改成 900×600 之后落盘的就是 900×600 那张)。
/// 抓不住:「落盘次数真的是 1」——那要看真实运行的日志 `落盘次数=1`(本测试只测闸门逻辑)。
#[test]
fn the_capture_gate_opens_once_and_never_again() {
// 窗口到第 4 帧才就绪也要恰好请求一次(`window` 未就绪时不置位,保留重试,不会挂死)
let mut dispatched = false;
let mut requests = 0;
for frames in 1..=10u32 {
let window_ready = frames >= 4;
if should_request_capture(frames, dispatched, true) && window_ready {
dispatched = true;
requests += 1;
}
}
assert_eq!(requests, 1, "整个进程只许请求一次采样");
assert!(!should_request_capture(1, false, true), "第 3 帧前不许采样");
assert!(should_request_capture(3, false, true), "第 3 帧取第一张");
assert!(
!should_request_capture(99, true, true),
"已派发过就不再请求(这条就是 P2-1 的修复本身)"
);
assert!(
!should_request_capture(99, false, false),
"交互模式(没有 --screenshot)不采样"
);
}
/// **密闭日志三态(M100 r1 P2-2)**:拿不到句柄必须记 `unknown`,不许记 `off`。
///
/// 抓得住:谁把 `None` 掉成 `off`(那会把「没读到」说成「读到了、没开」——这台机器上从此永远
/// 看不出密闭到底生效没有)。
/// 抓不住:真实窗口上的位(那要看运行日志里的 `passthrough_ext=on`)。
#[test]
fn the_passthrough_capability_is_three_states() {
assert_eq!(super::caps::passthrough_label(None), "unknown");
assert_eq!(
super::caps::passthrough_label(Some(0)),
"unknown",
"句柄 0 = 还没拿到窗口"
);
}
/// **兜底不许在正常路径误触发**(M100 r2 p3-b)。
///
/// 抓得住:宽限帧数被改小到「采样还在飞行中就判死」(例如把它写成 0)——那时第 4 帧
/// (第 3 帧派发、第 5 帧才落盘)就会误触发,CI 上表现为「正常出图却红」。
/// 抓不住:真实 `window::screenshot` 卡死的时序(那要注入一个不回的采样,测试替不了)。
#[test]
fn the_stall_guard_never_fires_on_the_normal_path() {
// 实测时序:第 3 帧派发,第 4~5 帧才收到 Captured(采样在飞行中 writes 还是 0)
let mut first_bad: Option<(u32, bool, u32)> = None;
for frames in 1..=200u32 {
let dispatched = frames >= CAPTURE_AT_FRAME;
let writes = u32::from(dispatched && frames >= CAPTURE_AT_FRAME + 2);
if capture_stalled(frames, writes) {
first_bad.get_or_insert((frames, dispatched, writes));
}
}
assert!(
first_bad.is_none(),
"正常路径误触发兜底(帧, dispatched, writes)={first_bad:?}"
);
}
/// **兜底只在「有帧但没采样」时触发**,且必须过了宽限窗口才触发。
///
/// 抓得住:① 判据写成 `frames >= CAPTURE_AT_FRAME`(宽限为 0)——正常路径会立刻误触发;
/// ② 判据漏掉 `writes == 0`(已经落过盘还报超时);
/// ③ 宽限窗口被写成 `>=` 边界错位(差一帧)。
/// 抓不住:`exit(1)` 到底有没有被操作系统收到(那要跑真二进制,见报告里的变异实测)。
#[test]
fn the_stall_guard_fires_only_after_the_grace_window() {
let threshold = CAPTURE_AT_FRAME + CAPTURE_GIVE_UP_FRAMES;
assert!(
!capture_stalled(threshold, 0),
"刚好到阈值还不算超时(要给最后一帧留余量)"
);
assert!(
capture_stalled(threshold + 1, 0),
"越过阈值且一次都没落盘 ⇒ 必须放弃(红着退出,不许挂死)"
);
assert!(
!capture_stalled(u32::MAX, 1),
"已经落过盘就不算卡住(正常路径由 exit() 结束)"
);
assert!(!capture_stalled(1, 0), "前几帧本来就没有图,不算卡住");
// 宽限必须明显大于「派发→落盘」的实测间隔(约 2 帧),否则正常路径会误触发。
// 用局部绑定而不是直接比较常量:`assert!(CONST >= 10)` 会被 clippy 判成常量断言。
let grace = CAPTURE_GIVE_UP_FRAMES;
assert!(grace >= 10, "宽限帧数 {grace} 太小,正常路径会误触发");
}
/// **终态不变量:跑完了没有图就是失败**(M100 r3 P2-1)。
///
/// 抓得住:谁把 `run()` 里那条后置检查删掉/改弱(删掉之后,窗口在第 3 帧消失会让进程
/// **exit 0 且没有 PNG** —— 复审变异实测就是这个形状,只看退出码的脚本会读到「成功 + 零证据」)。
/// 抓不住:这条检查**有没有被走到**(窗口消失的时序要真窗口,见报告里的变异复现);
/// 也抓不住「图写到了错误的路径」(那要校验目标文件,属另一件事)。
#[test]
fn finishing_without_a_single_write_is_a_failure() {
use super::app::no_evidence;
assert!(
no_evidence(0),
"一张图都没落盘时,取证结束必须判失败(不许 exit 0)"
);
assert!(!no_evidence(1), "落过盘就算有证据");
assert!(
!no_evidence(2),
"重复落盘也算有证据(虽然不该发生,见一次性闸门)"
);
}