复审在 `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()` 里那条 检查不会让它红——它守的是判据本身,不是调用点,报告里写清了「抓不住」)。
245 lines
11 KiB
Rust
245 lines
11 KiB
Rust
//! `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),
|
||
"重复落盘也算有证据(虽然不该发生,见一次性闸门)"
|
||
);
|
||
}
|