验收要的是「一次运行、多角度覆盖、输出能直接判读」,而不是把朋友当测试机反复问 7 个问题。 这一轮把产品进程能**自己观测**的那部分日志补齐,并把人工问题压缩到 3 条。 新增 `renderer/acceptance/`(三块,全部 ≤400 行): - `mod.rs`:`[backend]`(plan = 判据,final = **结果**:`result=started|failed` + 具名错误 + `via=respawn|refuse|none`)、`[perf]`(boot 三段实测 / session / scene 帧率只算首帧之后的间隔)、 `[theme]`(来源 = 哪个参数决定,系统那条写 `pending` 等异步真值)、`[window]`(**请求值**, 字段名自证 `requested` / `scale_requested`);开关 `ULL_ACCEPTANCE=1`、尺寸覆盖 `ULL_WINDOW_SIZE=1520x900` 与它的解析/夹取(含 `size_source=` 四态)。 - `locale.rs`:`[env]`(真适配器枚举 + 硬件判定;显示器写 `needs-window` 不猜)、`[i18n]` (四级链每一级的原始值 + 结论; macOS 走 `defaults read -g AppleLanguages`)。 - `visual.rs`:`[font] chain`(令牌声明的回退链,与已有的实际落点 `[font] platform=` 互补)、 `[caps]`(`bundle` / `menu_bar_name` 从可执行文件路径**读**出来;`controls=self-drawn` 由 `decorations=false` 推出)。 接线:`app/boot.rs` 在正确的时刻把真值喂进去(探测产物 / 窗口设置 / 实测 `Instant`), `pages/harness.rs` 同一套前缀(含 `[perf] scene`),`main.rs` 用法里写出两个开关。 反模式的防线(M97 那类「在需要结果的地方打印请求」):每一格都有能改坏的断言 (`acceptance/tests.rs` + `locale_tests.rs`,共 20 条新增),失败路径的 `error="…"` 会把 wgpu 自带的双引号换成单引号,保证单行可解析。 本机(Windows)实测:auto → `[backend] final=wgpu result=started` + `Selected: AdapterInfo`; cpu → `tiny-skia`;`WGPU_BACKEND=metal` 负例 → exit 1 + 无图 + `via=refuse`;宽屏覆盖生效。 macOS 分支(Metal / AppleLanguages / 菜单栏名)需真机复核,期望字符串见 `renderer/mod.rs` 文档。 验收脚本不入库(用户要求):落在 gitignore 的 `DevDocs/ops/macos-acceptance.sh`, 由 tower 拷到分发目录。
327 lines
13 KiB
Rust
327 lines
13 KiB
Rust
//! `[env]` / `[i18n]` / `[window]` / `[caps]` / `[backend]` / `[perf]` 六类行的钉死测试。
|
||
//!
|
||
//! **这些测试只钉「给定真实输入 → 打出什么」**:格式化函数是纯的,所以能断言逐字字符串。
|
||
//! 它们抓不住「输入本身是不是真的」(那要靠验收脚本在真机上 grep),抓得住的是这一整类事故:
|
||
//! **在需要结果的地方打印请求 / 常量**——每一格都有一个能改坏的断言。
|
||
|
||
use std::path::Path;
|
||
use std::time::Duration;
|
||
|
||
use super::*;
|
||
use crate::probe::AdapterSummary;
|
||
use crate::renderer::decision::{decide, Kind};
|
||
use crate::Probe;
|
||
use unlucky_design::typography::Platform as FontPlatform;
|
||
|
||
fn probe_of(adapters: &[(&str, &'static str)]) -> Probe {
|
||
let adapters: Vec<AdapterSummary> = adapters
|
||
.iter()
|
||
.map(|(name, device_type)| AdapterSummary {
|
||
name: (*name).to_owned(),
|
||
device_type,
|
||
})
|
||
.collect();
|
||
let hardware = adapters
|
||
.iter()
|
||
.any(|adapter| !matches!(adapter.device_type, "Cpu" | "Other"));
|
||
Probe { adapters, hardware }
|
||
}
|
||
|
||
/// 抓得住:`[window]` 打的是**请求**,字段名必须自证(`requested` / `scale_requested`)。
|
||
/// 抓不住:窗口真的开成了多大(由验收脚本从 `[shot] … scale=` 与 PNG 像素换算)。
|
||
#[test]
|
||
fn window_line_is_labelled_as_a_request() {
|
||
let line = window_text(
|
||
(1180.0, 760.0),
|
||
Some((880.0, 560.0)),
|
||
None,
|
||
"default",
|
||
Some("Unlucky Launcher"),
|
||
);
|
||
assert!(
|
||
line.starts_with("[window] requested size=1180x760"),
|
||
"{line}"
|
||
);
|
||
assert!(line.contains("min=880x560"), "{line}");
|
||
assert!(line.contains("decorations=false"), "{line}");
|
||
assert!(line.contains("scale_requested=system"), "{line}");
|
||
assert!(line.contains("size_source=default"), "{line}");
|
||
assert!(line.contains("title=\"Unlucky Launcher\""), "{line}");
|
||
|
||
// `pages_demo` 那条通道没有最小尺寸 / 自定标题:必须写 none / <默认>,不许编 0x0
|
||
let harness = window_text((1180.0, 760.0), None, Some(1.0), "pages_demo", None);
|
||
assert!(harness.contains("min=none"), "{harness}");
|
||
assert!(harness.contains("title=<默认>"), "{harness}");
|
||
assert!(harness.contains("scale_requested=1"), "{harness}");
|
||
}
|
||
|
||
/// `ULL_WINDOW_SIZE` 的三种分隔符都要认;非法值必须返回 `None`(调用方打警告,不许静默吞)。
|
||
#[test]
|
||
fn window_size_override_accepts_the_three_separators() {
|
||
for raw in ["1520x900", "1520X900", "1520×900", " 1180 x 760 "] {
|
||
assert!(parse_window_size(raw).is_some(), "{raw:?}");
|
||
}
|
||
assert_eq!(parse_window_size("1520x900"), Some((1520.0, 900.0)));
|
||
for raw in [
|
||
"",
|
||
"1520",
|
||
"1520x",
|
||
"x900",
|
||
"0x900",
|
||
"-1x900",
|
||
"axb",
|
||
"1520x900x1",
|
||
] {
|
||
assert_eq!(parse_window_size(raw), None, "{raw:?}");
|
||
}
|
||
}
|
||
|
||
/// 抓得住:`size_source=` 必须能区分「默认」「覆盖生效」「覆盖被夹到最小」「覆盖是垃圾」——
|
||
/// 四种情况打出一样的两张图,是最典型的假绿(脚本以为在测宽屏,其实测的是默认)。
|
||
#[test]
|
||
fn window_size_source_distinguishes_the_four_cases() {
|
||
let default = (1180.0, 760.0);
|
||
let min = (880.0, 560.0);
|
||
|
||
assert_eq!(
|
||
resolve_window_size(None, default, min),
|
||
(default, "default")
|
||
);
|
||
assert_eq!(
|
||
resolve_window_size(Some("1520x900"), default, min),
|
||
((1520.0, 900.0), "ULL_WINDOW_SIZE")
|
||
);
|
||
// 最小尺寸档:正好等于 min,不该被标成 clamped
|
||
assert_eq!(
|
||
resolve_window_size(Some("880x560"), default, min),
|
||
((880.0, 560.0), "ULL_WINDOW_SIZE")
|
||
);
|
||
assert_eq!(
|
||
resolve_window_size(Some("640x480"), default, min),
|
||
(min, "ULL_WINDOW_SIZE:clamped-to-min")
|
||
);
|
||
assert_eq!(
|
||
resolve_window_size(Some("wide-ish"), default, min),
|
||
(default, "ULL_WINDOW_SIZE:invalid")
|
||
);
|
||
}
|
||
|
||
/// 抓得住:macOS 上「菜单栏显示什么名字」从可执行文件路径就能读出来——bundle 与非 bundle
|
||
/// 是两条不同的答案,且 `controls=self-drawn` 由 `decorations=false` 推出来。
|
||
#[test]
|
||
fn caps_line_reads_the_menu_bar_name_from_the_executable_path() {
|
||
let plain = caps_line(
|
||
Some(Path::new(
|
||
"/Users/x/ull/target/release/unlucky-launcher-iced",
|
||
)),
|
||
false,
|
||
true,
|
||
false,
|
||
);
|
||
assert!(plain.contains("bundle=no"), "{plain}");
|
||
assert!(
|
||
plain.contains("menu_bar_name=\"unlucky-launcher-iced\""),
|
||
"{plain}"
|
||
);
|
||
assert!(plain.contains("menu_bar_source=exe-stem"), "{plain}");
|
||
assert!(plain.contains("controls=self-drawn"), "{plain}");
|
||
|
||
let bundled = caps_line(
|
||
Some(Path::new(
|
||
"/Applications/Unlucky Launcher.app/Contents/MacOS/unlucky-launcher-iced",
|
||
)),
|
||
false,
|
||
true,
|
||
false,
|
||
);
|
||
assert!(bundled.contains("bundle=yes"), "{bundled}");
|
||
assert!(
|
||
bundled.contains("menu_bar_name=\"Unlucky Launcher\""),
|
||
"{bundled}"
|
||
);
|
||
assert!(bundled.contains("menu_bar_source=bundle"), "{bundled}");
|
||
|
||
let native = caps_line(Some(Path::new("/tmp/ull")), false, true, true);
|
||
assert!(native.contains("controls=native"), "{native}");
|
||
}
|
||
|
||
/// 抓得住:`bundle=` 与 `menu_bar_name=` 拿不到路径时不猜(`<未知>`)。
|
||
#[test]
|
||
fn caps_line_does_not_guess_without_a_path() {
|
||
let line = caps_line(None, false, true, false);
|
||
assert!(line.contains("bundle=no"), "{line}");
|
||
assert!(line.contains("menu_bar_name=\"<未知>\""), "{line}");
|
||
}
|
||
|
||
/// 抓得住:`[backend] plan` 打的是**判据产物**(primary / fallback / reason),不是请求。
|
||
/// 改回 `request=` 当 primary 用、或把兜底写成候选列表,这条就红。
|
||
#[test]
|
||
fn backend_plan_prints_the_decision_not_the_request() {
|
||
let probe = probe_of(&[("Apple M1", "IntegratedGpu")]);
|
||
let decision = decide(crate::Backend::Auto, &probe, true);
|
||
let line = backend_plan_text(&decision, crate::Backend::Auto);
|
||
assert!(line.contains("request=auto"), "{line}");
|
||
assert!(line.contains("primary=wgpu"), "{line}");
|
||
assert!(line.contains("fallback=tiny-skia"), "{line}");
|
||
assert!(line.contains("candidates=1"), "{line}");
|
||
assert!(line.contains("reason=auto-hardware-adapter"), "{line}");
|
||
}
|
||
|
||
/// 抓得住:`[backend] final` 的两条路都要能一眼读出「到底跑起来的是谁」。
|
||
#[test]
|
||
fn backend_final_lines_separate_started_from_failed() {
|
||
let started = backend_started_text(Kind::Wgpu, Duration::from_millis(37));
|
||
assert!(
|
||
started.contains("final=wgpu result=started elapsed=37ms"),
|
||
"{started}"
|
||
);
|
||
assert!(started.contains("evidence=probe-single-item"), "{started}");
|
||
|
||
let refused = backend_failed_text(
|
||
Kind::Wgpu,
|
||
Some(Kind::TinySkia),
|
||
"refuse",
|
||
"metal support not compiled in",
|
||
);
|
||
assert!(
|
||
refused.contains("final=tiny-skia result=failed"),
|
||
"{refused}"
|
||
);
|
||
assert!(refused.contains("primary=wgpu"), "{refused}");
|
||
assert!(refused.contains("via=refuse"), "{refused}");
|
||
assert!(
|
||
refused.contains("error=\"metal support not compiled in\""),
|
||
"具名错误必须原样出现在同一行里:{refused}"
|
||
);
|
||
|
||
let none = backend_failed_text(Kind::TinySkia, None, "none", "boom");
|
||
assert!(none.contains("final=none result=failed"), "{none}");
|
||
|
||
// 具名错误自带双引号时,`error="…"` 仍必须是**格式良好的单个字段**(否则脚本只能取到前半截)
|
||
let quoted = backend_failed_text(
|
||
Kind::Wgpu,
|
||
Some(Kind::TinySkia),
|
||
"refuse",
|
||
"GraphicsAdapterNotFound { backend: \"wgpu\" }",
|
||
);
|
||
assert!(
|
||
quoted.contains("error=\"GraphicsAdapterNotFound { backend: 'wgpu' }\""),
|
||
"{quoted}"
|
||
);
|
||
assert_eq!(
|
||
quoted.matches('"').count(),
|
||
2,
|
||
"整行只该有 error= 那一对引号:{quoted}"
|
||
);
|
||
}
|
||
|
||
/// 抓得住:`[perf]` 打的是实测毫秒数,不是「预计」;场景行的 fps 由帧数/时长算。
|
||
#[test]
|
||
fn perf_lines_carry_measured_millis() {
|
||
let boot = perf_boot_text(
|
||
Duration::from_millis(650),
|
||
Duration::from_millis(120),
|
||
Duration::from_millis(780),
|
||
);
|
||
assert_eq!(boot, "[perf] boot probe=650ms font=120ms to_run=780ms");
|
||
|
||
let session = perf_session_text("screenshot", Duration::from_millis(1234), true);
|
||
assert!(session.contains("mode=screenshot elapsed=1234ms result=ok"));
|
||
|
||
let scene = perf_scene_text(
|
||
"instances-minimal-w880",
|
||
3,
|
||
Some(Duration::from_millis(210)),
|
||
Duration::from_millis(300),
|
||
);
|
||
assert!(
|
||
scene.contains("frames=3 first_frame=210ms elapsed=300ms"),
|
||
"{scene}"
|
||
);
|
||
// 只算首帧之后的帧间隔:(3-1) 帧 / (0.30-0.21) s ≈ 22.2 fps(启动那段不算进帧率)
|
||
assert!(scene.contains("fps=22.2"), "{scene}");
|
||
// 少于 2 帧 / 没有首帧时间 ⇒ 没有间隔可测,必须是 unknown 而不是编一个数
|
||
let one = perf_scene_text(
|
||
"x",
|
||
1,
|
||
Some(Duration::from_millis(5)),
|
||
Duration::from_millis(9),
|
||
);
|
||
assert!(one.contains("fps=unknown"), "{one}");
|
||
let no_first = perf_scene_text("x", 5, None, Duration::from_millis(9));
|
||
assert!(no_first.contains("fps=unknown"), "{no_first}");
|
||
}
|
||
|
||
/// 抓得住:`[font] chain` 来自令牌的三条栈(顺序 = 首选 → 兜底),不是某个常量字符串。
|
||
#[test]
|
||
fn font_chain_lists_the_token_stacks_in_order() {
|
||
let line = font_chain_line(FontPlatform::MacOs);
|
||
assert!(line.starts_with("[font] chain platform=macos"), "{line}");
|
||
// macOS 的中文兜底族必须在链里(M98 要验的就是它有没有被用上)
|
||
assert!(line.contains("PingFang SC"), "{line}");
|
||
assert!(line.contains("mono="), "{line}");
|
||
// 顺序:首选族在前,通用族名在最后
|
||
let text = line.split("text=").nth(1).expect("text 栈");
|
||
let first = text.split('>').next().unwrap_or_default();
|
||
assert!(!first.contains("sans-serif"), "栈头不该是通用族名:{line}");
|
||
}
|
||
|
||
/// 抓得住:`[theme]` 的来源判定(哪个参数赢)与「跟随系统 = pending」这条不变量。
|
||
#[test]
|
||
fn theme_source_follows_the_capture_flavor_order() {
|
||
// --screenshot-theme 压过 --theme,也压过「这是截图模式」的默认值
|
||
assert_eq!(theme_source(true, true, true), ("screenshot-theme", false));
|
||
assert_eq!(theme_source(false, true, true), ("theme", false));
|
||
// 截图模式没给主题 ⇒ 默认 Light(不是「等系统」)
|
||
assert_eq!(theme_source(false, false, true), ("default-light", false));
|
||
// 交互模式没给主题 ⇒ 跟随系统(异步,真值晚到)
|
||
assert_eq!(theme_source(false, false, false), ("system", true));
|
||
|
||
let system = theme_text("system", None, true);
|
||
assert!(system.contains("flavor=pending"), "{system}");
|
||
}
|
||
|
||
/// 抓得住:`via` 的三档与判据表第 8/9 行一致(有兜底 + 取证启动 ⇒ refuse,**不是** respawn)。
|
||
#[test]
|
||
fn failure_via_matches_the_two_paths_of_the_criteria_table() {
|
||
assert_eq!(
|
||
failure_via(true, false),
|
||
"respawn",
|
||
"交互启动 + 有兜底 ⇒ 换进程重开"
|
||
);
|
||
assert_eq!(
|
||
failure_via(true, true),
|
||
"refuse",
|
||
"取证启动 + 有兜底 ⇒ 拒绝出图"
|
||
);
|
||
assert_eq!(failure_via(false, false), "none", "没兜底 ⇒ 原样失败");
|
||
assert_eq!(
|
||
failure_via(false, true),
|
||
"none",
|
||
"没兜底时也不该冒出 refuse 的提示"
|
||
);
|
||
}
|
||
|
||
/// 抓得住:`[theme]` 的 `follows_system=true` 必须同时是 `flavor=pending`
|
||
/// (系统主题异步到达;写死一个值就是猜)。
|
||
#[test]
|
||
fn theme_line_marks_the_async_system_case_as_pending() {
|
||
let system = theme_text("system", None, true);
|
||
assert!(system.contains("source=system"), "{system}");
|
||
assert!(system.contains("flavor=pending"), "{system}");
|
||
assert!(system.contains("follows_system=true"), "{system}");
|
||
|
||
let pinned = theme_text("screenshot-theme", Some("dark"), false);
|
||
assert!(pinned.contains("flavor=dark"), "{pinned}");
|
||
assert!(pinned.contains("follows_system=false"), "{pinned}");
|
||
}
|
||
|
||
/// 抓得住:开关默认关(产品启动不该多花 1 s 去枚举适配器),`0/off/false/no` 也算关。
|
||
#[test]
|
||
fn acceptance_switch_is_off_by_default() {
|
||
// 这个测试不读进程环境(会与其它测试抢 `set_var`),只钉常量名:
|
||
// 名字改了验收脚本就失效,所以它必须是测试锁住的东西。
|
||
assert_eq!(ACCEPTANCE_ENV, "ULL_ACCEPTANCE");
|
||
assert_eq!(WINDOW_SIZE_ENV, "ULL_WINDOW_SIZE");
|
||
}
|