feat(acceptance): M98 —— 八类诊断日志 + 一次运行的全覆盖验收矩阵

验收要的是「一次运行、多角度覆盖、输出能直接判读」,而不是把朋友当测试机反复问 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 拷到分发目录。
This commit is contained in:
AskaEth committed 2026-11-03 01:09:25 +08:00
1 parent 18e36c6514
commit f986981c12
9 files changed
+1429 -129

No files matched your search

+176 -87
View File
@@ -1,24 +1,23 @@
//! 启动:后端探测 → 字体求值 → `iced::application(..).run()`。
//! 启动:后端探测 → 字体求值 → `iced::application(..).run()`,外加 M98 的八类验收诊断。
//!
//! ## M97:起步后端起不来时怎么办(两条路,策略不同,别合成一条)
//!
//! `ICED_BACKEND` 现在永远是**单项**(见 [`crate::renderer`] 的判据表)。单项的代价是
//! iced 不再自己做回退,于是「wgpu 起不来」会变成 `iced::Error::GraphicsCreationFailed`
//! ——**这正是我们要的**:那里面装着 wgpu 的具名错误(哪个 backend、卡在 adapter 还是 device)。
//!
//! 拿到具名错误之后分两条路(判据表第 8 行 / 第 9 行):
//! `ICED_BACKEND` 永远是**单项**(见 [`crate::renderer`] 的判据表)。单项的代价是 iced 不再自己
//! 回退,于是「wgpu 起不来」会变成 `iced::Error::GraphicsCreationFailed`——**这正是我们要的**:
//! 那里面装着 wgpu 的具名错误(哪个 backend、卡在 adapter 还是 device)。
//!
//! - **交互启动**:换一个**进程**用 `tiny-skia` 重开([`OnFailure::Respawn`])——安全网,用户还能用。
//! 代价是子进程把启动流程**重跑一遍**(重新探测?不:`ULL_GPU_FALLBACK` 让它跳过探测;
//! 但**字体栈、配置、presenter 都会重算**,实测首帧 531 ms)。这条代价只落在失败路径上。
//! - **取证启动**(带 `--screenshot`,含 `--screen=gallery`):**拒绝出图**、退出码 1
//! ([`OnFailure::Refuse`])——换了后端出的图会让报告撒谎。
//!
//! 为什么必须是「换进程」而不是「在本进程里再 `run()` 一次」:Windows 实测直接 panic
//! (`iced_winit-0.14.1/src/lib.rs:81` → `Create event loop: RecreationAttempt`)——
//! winit 一个进程只能建一次事件循环。猜测式重试别再加回来。
use std::time::Instant;
//! 为什么必须换进程:Windows 实测本进程里再 `run()` 一次直接 panic
//! (`iced_winit-0.14.1/src/lib.rs:81` → `Create event loop: RecreationAttempt`)。别再猜着改回去。
//!
//! ## M98:诊断的落点
//!
//! 八类日志由 [`crate::renderer::acceptance`] 排版;本文件只在**正确的时刻**把真值喂给它(探测
//! 产物 / 窗口设置 / 实测 `Instant`);判据与证据的边界写在那边的模块文档里。
use std::time::{Duration, Instant};
use iced::{window, Task};
@@ -27,50 +26,80 @@ use unlucky_design::color::SchemeFlavor;
use crate::cli::Options;
use crate::fonts;
use crate::probe;
use crate::renderer::acceptance;
use crate::renderer::{self, Decision, Kind};
use crate::theme::Families;
use super::message::Message;
use super::state::{App, STARTED};
use super::{MIN_SIZE, WINDOW_SIZE};
use super::{APP_TITLE, MIN_SIZE, WINDOW_SIZE};
/// 启动 iced(阻塞到退出)。`--diff` 不走这里,见 `main.rs`。
pub(crate) fn run(options: Options) -> iced::Result {
let _ = STARTED.set(Instant::now());
let boot_started = Instant::now();
let decision = boot_probe(&options);
// 贵的诊断(适配器枚举 / `defaults` / `reg.exe`)只在验收模式下跑;便宜的行恒开。
if acceptance::enabled() {
acceptance::announce();
}
let boot = boot_probe(&options);
let decision = boot.decision;
acceptance::report_backend_plan(&decision, options.backend);
if acceptance::enabled() {
acceptance::report_startup(&boot.probe, options.scale_factor);
}
let font_started = Instant::now();
let resolved = fonts::resolve();
eprintln!(
"[font] 求值耗时 {:.0} ms",
font_started.elapsed().as_secs_f64() * 1000.0
);
let font_ms = font_started.elapsed();
eprintln!("[font] 求值耗时 {:.0} ms", font_ms.as_secs_f64() * 1000.0);
eprintln!("{}", resolved.describe());
if resolved.has_synthetic() {
eprintln!(
"[font] 警告:有字重落在「合成加粗」档(观感降级),D28 后半(CDN 字体/按档换族)要处理"
"[font] 警告:有字重落在「合成加粗」档(观感降级),D28 后半(CDN 字体/按档换字族)要处理"
);
}
if acceptance::enabled() {
acceptance::report_font_chain(resolved.platform);
}
let families = resolved.families();
// 截图模式:`--screenshot-theme` > `--theme` > **Light**(唯一取值口在 `Options::capture_flavor`)。
// 这里**不再有**「等系统主题到达」的闸门——CI 的 xvfb 没有主题会话,等它就是挂死(见 cli 文档)。
// 截图模式:`--screenshot-theme` > `--theme` > **Light**(唯一取值口 `Options::capture_flavor`)。
// **不再有**「等系统主题到达」的闸门——CI 的 xvfb 没有主题会话,等它就是挂死(见 cli 文档)。
if options.screenshot.is_some() && options.shot_flavor().is_none() {
eprintln!("[shot] 未指定主题,按 Light 出图(D30 可复现;CI 冒烟依赖这条默认值)");
}
let flavor = options.capture_flavor().unwrap_or(SchemeFlavor::Light);
// `[theme]`:系统那条是异步的 ⇒ `follows_system=true` 时 `flavor=pending`,真值随后由
// `app/update.rs` 的 `[theme] 系统主题 = …` 给出(两行合起来才是完整答案)。
let (source, follows) = acceptance::theme_source(
options.screenshot_theme.is_some(),
options.theme.is_some(),
options.screenshot.is_some(),
);
let explicit = (!follows).then_some(if flavor.is_dark() { "dark" } else { "light" });
acceptance::report_theme(source, explicit, follows);
if acceptance::enabled() {
acceptance::report_perf_boot(boot.probe_ms, font_ms, boot_started.elapsed());
}
// 起步后端起不来时按判据表第 8/9 行分流(交互降级 vs 取证拒绝)。具名原因在分流之前
// 就打到同一个 stderr 上了。
match launch(&options, families, flavor, decision.primary) {
let launch_started = Instant::now();
let result = match launch(&options, families, flavor, decision.primary) {
Err(iced::Error::GraphicsCreationFailed(error)) => {
renderer::report_failure(decision.primary, &error);
match on_failure(decision, options.screenshot.as_ref()) {
Some(OnFailure::Respawn(fallback)) => respawn(decision.primary, fallback),
Some(OnFailure::Respawn(fallback)) => {
respawn(decision.primary, fallback, &error.to_string())
}
Some(OnFailure::Refuse) => {
// 取证启动(`--screenshot`):**拒绝降级出图**。图是要拿去下结论的,
// 换了后端出的图会撒谎——以前就是这么把 CPU 截图当 GPU 结论用的。
// 取证启动:**拒绝降级出图**(换了后端出的图会让报告撒谎)。
eprintln!(
"[gpu] 拒绝降级出图:本次是取证启动(--screenshot {}),换后端出的图会撒谎。",
options
@@ -87,7 +116,36 @@ pub(crate) fn run(options: Options) -> iced::Result {
}
}
other => other,
};
let launch_elapsed = launch_started.elapsed();
// `[backend] final`:**结果**(不是请求)。判据是单项 `ICED_BACKEND`(判据表第 1~7 行):
// 候选只有一项 ⇒ 进程能起来就必定是它,没有静默回退的空间。
match &result {
Ok(()) => acceptance::report_backend_started(decision.primary, launch_elapsed),
Err(error) => {
let named = renderer::named_graphics_error(error).unwrap_or_else(|| error.to_string());
acceptance::report_backend_failed(
decision.primary,
decision.fallback,
acceptance::failure_via(decision.fallback.is_some(), options.screenshot.is_some()),
&named,
);
}
}
if acceptance::enabled() {
acceptance::report_perf_session(
if options.screenshot.is_some() {
"screenshot"
} else {
"interactive"
},
launch_elapsed,
result.is_ok(),
);
}
result
}
/// 起步后端失败后的去向(判据表第 8 行 vs 第 9 行——**两条路策略不同,别合成一条**)。
@@ -101,11 +159,9 @@ enum OnFailure {
/// 纯判据:起步后端失败时走哪条路。
///
/// `evidence` 非空 = 本次是取证启动(带 `--screenshot`,含 `--screen=gallery`),路径只用于日志。
/// 返回 `None` = 没有兜底可用(`--renderer=cpu` / 未编 wgpu),原样把错误交回去。
///
/// 抽成纯函数只为一个理由:这条分支以前**没有任何测试**,`respawn` / `Refuse` 两条路可以被
/// 静默改反(复审 M97 r1 p2-1 就是这么测出来的)。
/// `evidence` 非空 = 本次是取证启动(带 `--screenshot`);返回 `None` = 没有兜底可用
/// (`--renderer=cpu` / 未编 wgpu),原样把错误交回去。抽成纯函数只为一个理由:这条分支以前
/// **没有任何测试**,`respawn` / `Refuse` 两条路可以被静默改反(M97 r1 p2-1 就是这么测出来的)。
fn on_failure(decision: Decision, evidence: Option<&std::path::PathBuf>) -> Option<OnFailure> {
let fallback = decision.fallback?;
Some(match evidence {
@@ -120,8 +176,8 @@ fn on_failure(decision: Decision, evidence: Option<&std::path::PathBuf>) -> Opti
/// ② [`renderer::FALLBACK_ENV`]:**防无限重开的闸门**——子进程见它就必须跳过探测
/// (`boot_probe` 的第一个分支),否则会算出同一个结论、再失败、再 respawn。
///
/// 抽成纯函数是为了让「漏传其中之一」变成**测试能抓到**的错误:这两条都是静默失效型
/// (漏 ① 会让降级重开又跑一次 wgpu、漏 ② 会无限重开),线上都极难看出来。
/// 抽成纯函数是为了让「漏传其中之一」变成**测试能抓到**的错误:两条都是静默失效型
/// (漏 ① 重开又跑一遍 wgpu、漏 ② 无限重开),线上极难看出来。
fn fallback_env(fallback: Kind) -> [(&'static str, &'static str); 2] {
[
("ICED_BACKEND", fallback.env_value()),
@@ -131,19 +187,9 @@ fn fallback_env(fallback: Kind) -> [(&'static str, &'static str); 2] {
/// 安全网:起一个**新进程**用 `fallback` 后端重开,然后带着它的退出码退出。
///
/// 为什么必须是新进程:winit 一个进程只能建一次事件循环
/// (`iced_winit-0.14.1/src/lib.rs:81` → `Create event loop: RecreationAttempt`)。
///
/// 只在**交互启动**时走到这里;取证启动(`--screenshot`)宁可红着退出也不换后端出图
/// (判据表第 8 行 vs 第 9 行——**这两条路的策略不同,别合成一条**)。
///
/// ## `ULL_GPU_FALLBACK` 是防自杀循环的唯一闸门
///
/// 子进程看到 [`renderer::FALLBACK_ENV`] **必须跳过探测**(`boot_probe` 的第一段),换成
/// `tiny-skia` 直接跑;否则子进程会算出和父进程一样的结论(auto + 有硬件 → wgpu)、再失败、
/// 再 respawn —— **无限递归重开**。这种循环极难复现也极难诊断(每次重开都像是「刚刚启动」),
/// 所以闸门放在探测入口的第一个分支,不放在 respawn 那一侧。
fn respawn(failed: Kind, fallback: Kind) -> ! {
/// 只在**交互启动**时走到这里(取证启动宁可红着退出也不换后端出图);子进程看到
/// [`renderer::FALLBACK_ENV`] 必须跳过探测,否则无限递归重开(闸门在 `boot_probe` 入口)。
fn respawn(failed: Kind, fallback: Kind, error: &str) -> ! {
let exe = std::env::current_exe().unwrap_or_else(|err| {
eprintln!("[gpu] 降级重开失败:拿不到自身路径({err})");
std::process::exit(exit_code(None));
@@ -154,12 +200,13 @@ fn respawn(failed: Kind, fallback: Kind) -> ! {
fallback.name(),
failed.name()
);
// `[backend] final` 一行把「哪个后端失败了、去了哪、为什么」收在一起(可 grep 的单一结论行)。
acceptance::report_backend_failed(failed, Some(fallback), "respawn", error);
let status = fallback_command(&exe, std::env::args_os().skip(1), fallback).status();
// 退出码**透传**子进程的:上层脚本/CI 靠它判断这次到底是成功还是失败。
// 写错这一段的失效方式是「安静地给出一个错结论」——不崩、不静默降级、不无限重开,
// 只是让 CI 把失败当成功。所以它拆成 [`exit_code`] 这个纯函数,由测试钉住。
// 退出码**透传**子进程的:上层脚本/CI 靠它判断成败。写错的失效方式是「安静地给出一个错
// 结论」——不崩、不静默降级、不无限重开,只是让 CI 把失败当成功。
let code = match status {
Ok(status) => exit_code(status.code()),
Err(err) => {
@@ -172,9 +219,8 @@ fn respawn(failed: Kind, fallback: Kind) -> ! {
/// 子进程退出码 → 本进程退出码。
///
/// `None` = 子进程被信号杀死(只有 Unix 会这样,`ExitStatus::code` 返回 `None`)。
/// 这种情况走兜底码而**不是** panic,也不是 0:上层的判据是「非零 = 失败」,
/// 「拿不到退出码」和「退出码是某个非零值」对脚本是一回事。
/// `None` = 子进程被信号杀死(只有 Unix):走兜底码而**不是** panic、也不是 0——上层判据是
/// 「非零 = 失败」,「拿不到退出码」和「退出码是某个非零值」对脚本是一回事。
fn exit_code(code: Option<i32>) -> i32 {
/// 重开失败 / 拿不到退出码时的兜底码(与 `main` 的「参数或运行错误」一致)。
const RESPAWN_FAILED: i32 = 2;
@@ -183,8 +229,11 @@ fn exit_code(code: Option<i32>) -> i32 {
/// 构造降级重开的子进程命令(**可测**:program / argv / env 都能在测试里断言)。
///
/// `program` / `args` 是参数而不是在函数里读 `std::env`,唯一目的就是让测试能喂一个假命令
/// (`cmd /c exit 7` / `sh -c "exit 7"`)来验退出码透传——真用 `current_exe()` 就没法测了。
/// `program` / `args` 是参数而不是在函数里读 `std::env`,唯一目的是让测试能喂假命令
/// (`cmd /c exit 7` / `sh -c "exit 7"`)来验退出码透传——真用 `current_exe()` 就没法测。
///
/// 三路 stdio **显式**继承:子进程日志必须和父进程同一路输出,否则验收只抓到父进程那两行具名
/// 错误。`Command` 没有 stdio 的 getter(stable 没有),所以这条只能靠真跑一次证明。
fn fallback_command(
program: &std::path::Path,
args: impl IntoIterator<Item = std::ffi::OsString>,
@@ -192,12 +241,6 @@ fn fallback_command(
) -> std::process::Command {
let mut command = std::process::Command::new(program);
command.args(args).envs(fallback_env(fallback));
// 三路 stdio **显式**继承。`Command` 默认就是继承,但显式写出来有两个作用:
// ① 将来为了「安静」加上 `.stderr(Stdio::null())` 时,diff 里看得见;
// ② 降级那次的子进程日志(`[probe] 本次是降级重开`)**必须和父进程同一路输出**,
// 否则验收脚本只抓到父进程那两行具名错误,看不到降级后到底用什么跑起来了。
// 注:`Command` 没有 stdio 的 getter(stable 没有),所以这条**只能靠真跑一次**证明
// (见 §Windows 实测的 `interactive.log`:父子两段日志在同一个 stderr 文件里)。
command
.stdin(std::process::Stdio::inherit())
.stdout(std::process::Stdio::inherit())
@@ -216,6 +259,41 @@ fn launch(
let boot_options = options.clone();
// M98:`ULL_WINDOW_SIZE=1520x900` 覆盖尺寸(矩阵的「最小 / 宽屏」两档靠它,且必须走**真外壳**
// ——`pages_demo` example 的标题栏是留白带,出不了「三键位置」那张图)。
let (size, size_source) = acceptance::window_size(
(WINDOW_SIZE.width, WINDOW_SIZE.height),
(MIN_SIZE.width, MIN_SIZE.height),
);
let settings = window::Settings {
size: iced::Size::new(size.0, size.1),
min_size: Some(MIN_SIZE),
decorations: false,
resizable: true,
// M93 / D36:整窗 α 需要 `WS_EX_LAYERED`,但这里给不了——`window::Settings` 没有扩展样式
// 字段,写它只有 `Get/SetWindowLongPtrW`(`unsafe`,全仓只允许出现在
// `platform/win32/transparency.rs`)。所以 α 下发在拿到句柄之后(`Message::WindowHandle`)。
transparent: false,
..window::Settings::default()
};
// 值**直接取自**上面那个 `settings`(另抄一份就会漂;验收靠「日志说的 = 给窗口的」才有意义)。
acceptance::report_window(
(settings.size.width, settings.size.height),
Some((MIN_SIZE.width, MIN_SIZE.height)),
options.scale_factor,
size_source,
Some(APP_TITLE),
);
let exe = std::env::current_exe().ok();
acceptance::report_caps(
exe.as_deref(),
settings.transparent,
settings.resizable,
settings.decorations,
);
let application = iced::application(
move || {
(
@@ -230,22 +308,7 @@ fn launch(
.theme(App::theme)
.subscription(App::subscription)
.default_font(default_font(families))
.window(window::Settings {
size: WINDOW_SIZE,
min_size: Some(MIN_SIZE),
decorations: false,
resizable: true,
// M93 / D36:整窗 α 需要 `WS_EX_LAYERED`,但**这里给不了**——
// ① iced 0.14 的 `window::Settings` 没有扩展样式字段;
// ② winit 0.30 只在鼠标穿透(`IGNORE_CURSOR_EVENT`)时置这一位,`transparent: true`
// 走的是 DWM 那条路,与 `LWA_ALPHA` 无关;
// ③ 加扩展样式只有 `Get/SetWindowLongPtrW`,是 `unsafe`,而全仓 `unsafe` 只允许出现在
// `platform/win32/transparency.rs`(CI 的 `unsafe-scope` 是集合相等)。
// 所以窗口创建侧只负责开窗,样式补齐与 α 下发都在拿到句柄之后:
// `app::update` 的 `Message::WindowHandle` → `window_alpha::dispatch`(先 ensure_layered)。
transparent: false,
..window::Settings::default()
});
.window(settings);
match options.scale_factor {
Some(scale) => application.scale_factor(move |_| scale).run(),
@@ -253,10 +316,20 @@ fn launch(
}
}
/// [`boot_probe`] 的产物:判据 + 探测到的适配器 + 探测耗时。
///
/// 为什么把 `Probe` 带出来:`[env]` 的 `gpu=` / `hardware=` 必须是**这一次真枚举到的**结果
/// (再枚举一遍多花 0.65~1.2 s,还可能给出不同状态)。判据与诊断看同一份数据。
struct Boot {
decision: Decision,
probe: probe::Probe,
probe_ms: Duration,
}
/// 渲染后端探测 + 写 `ICED_BACKEND`,返回判据产物(供 `run` 决定要不要换进程重开)。
fn boot_probe(options: &Options) -> Decision {
// 本进程是「上一步失败后重开」的那一次:别再探一次(会得到同一个结论,然后无限重开)。
// 这一步的决策**兜底必须是 None**——由 `decision::fallback_round` 单点保证。
fn boot_probe(options: &Options) -> Boot {
// 「上一步失败后重开」的那一次:别再探(会得到同一个结论,然后无限重开)。兜底必须是 None
// ——由 `decision::fallback_round` 单点保证。
if let Some(kind) = renderer::forced_fallback() {
let decision = renderer::fallback_round(kind);
std::env::set_var("ICED_BACKEND", kind.env_value());
@@ -267,35 +340,51 @@ fn boot_probe(options: &Options) -> Decision {
kind.env_value()
);
eprintln!("[probe] {}", decision.describe());
return decision;
// 验收要 `[env]` 里的真实适配器:这一轮也枚举(否则日志会写 `adapters=0`,与事实相反
// ——GPU 刚刚才被枚举到、只是起不来)。只有验收开关打开时才付这 0.65~1.2 s。
let probe = if acceptance::enabled() {
probe::Probe::run()
} else {
probe::Probe::default()
};
return Boot {
decision,
probe,
probe_ms: Duration::ZERO,
};
}
let probe_started = Instant::now();
// 只有 `auto` 需要探测:`cpu` 与 `gpu` 的决策与「有没有硬件 GPU」无关,
// 而枚举 13 个适配器在本机要 0.65~1.2 s(实测),纯属白花。
let needs_probe = options.backend == crate::Backend::Auto;
// 只有 `auto` 需要探测(`cpu`/`gpu` 的决策与硬件无关,枚举 13 个适配器本机要 0.65~1.2 s)。
// 验收模式例外:`[env]` 那行要 GPU 名,不管请求是哪个后端。
let needs_probe = options.backend == crate::Backend::Auto || acceptance::enabled();
let probe = if needs_probe {
probe::Probe::run()
} else {
probe::Probe::default()
};
let probe_ms = probe_started.elapsed().as_secs_f64() * 1000.0;
let probe_ms = probe_started.elapsed();
// `ULL_PREFLIGHT=1` 时先自己验一遍 wgpu(判据表第 7 行);默认不跑,见 preflight 模块文档。
let preflight = renderer::preflight::run_if_requested();
let decision = probe::apply_with_preflight(options.backend, &probe, preflight.as_ref());
if cfg!(feature = "gpu") {
let ms = probe_ms.as_secs_f64() * 1000.0;
if needs_probe {
eprintln!("[probe] 探测耗时 {probe_ms:.0} ms(wgpu 实例 + 全后端适配器枚举)");
eprintln!("[probe] 探测耗时 {ms:.0} ms(wgpu 实例 + 全后端适配器枚举)");
} else {
eprintln!(
"[probe] 探测耗时 {probe_ms:.0} ms(--renderer={} 与硬件无关,跳过枚举)",
"[probe] 探测耗时 {ms:.0} ms(--renderer={} 与硬件无关,跳过枚举)",
options.backend.name()
);
}
}
decision
Boot {
decision,
probe,
probe_ms,
}
}
/// 默认字体(正文档解析出来的族)。
+5
View File
@@ -41,6 +41,11 @@ fn main() -> ExitCode {
[--theme=light|dark] [--screenshot <png>] [--screenshot-theme=light|dark] \
[--scale-factor <f32>] [--diff <a.png> <b.png>]"
);
// M98:验收开关写在用法里,不然「日志怎么变详细了」只能靠翻文档。
eprintln!(
"验收诊断:ULL_ACCEPTANCE=1([env]/[backend]/[window]/[font]/[theme]/[perf]/[i18n]/[caps])\
,ULL_WINDOW_SIZE=1520x900 覆盖窗口尺寸"
);
ExitCode::from(2)
}
}
+60 -41
View File
@@ -1,24 +1,16 @@
//! 截图 harness:把**一页**按给定窗口宽度 / 主题单独渲染并出图。
//! 截图 harness:把**一页**按给定窗口宽度 / 主题单独渲染并出图(`examples/pages_demo.rs` 的入口)。
//!
//! 它是 `examples/pages_demo.rs` 的入口(该 example 只有三行)。**刻意不接 `cli.rs`**:
//! `cli.rs` 归 M75 改(它正在加 `--screen`),本 mission 不碰;等 `cli.rs` 具备 `--screen` /
//! `--window-width` 之后,这个 harness 应当删掉,改由正式启动参数出图。
//!
//! ## 用法
//!
//! ```text
//! cargo run -p unlucky-app-iced --example pages_demo -- \
//! --screen=instances --width=1180 --height=760 \
//! --screenshot=out.png --screenshot-theme=light --software-renderer
//! ```
//!
//! 参数:`--screen=instances|download|settings|logs`、`--width`、`--height`、
//! `--theme` / `--screenshot-theme`(后者优先)、`--screenshot`、`--renderer`、
//! **刻意不接 `cli.rs`**:等 `cli.rs` 具备 `--screen` / `--window-width` 之后,这个 harness 应当
//! 删掉,改由正式启动参数出图。参数:`--screen=instances|download|settings|logs`、`--width`、
//! `--height`、`--theme` / `--screenshot-theme`(后者优先)、`--screenshot`、`--renderer`、
//! `--software-renderer`、`--scale-factor`。没给 `--screenshot` 就只是开窗看。
//!
//! ## 为什么还要等 3 帧
//! ```text
//! cargo run -p unlucky-app-iced --example pages_demo -- --screen=instances --width=1180 \
//! --height=760 --screenshot=out.png --screenshot-theme=light --software-renderer
//! ```
//!
//! 与 `app.rs` 同一口径(M60 坑 #5):第一帧可能是启动时的旧尺寸,且字体首次上屏会抖动。
//! 等 3 帧再截图与 `app.rs` 同一口径(M60 坑 #5):第一帧可能是启动时的旧尺寸,字体首次上屏会抖动。
use std::path::PathBuf;
use std::time::Instant;
@@ -36,9 +28,8 @@ use crate::theme::Ui;
use super::{app, demo_model, Model, Screen};
/// 截图落盘的帧号(见模块文档)。
/// 截图落盘的帧号(见模块文档);验收窗口尺寸(D30:1180×760、100% DPI)。
const CAPTURE_AT_FRAME: u32 = 3;
/// 验收窗口尺寸(D30:1180×760、100% DPI)。
const DEFAULT_SIZE: (f32, f32) = (1180.0, 760.0);
/// 一次渲染请求。
@@ -75,10 +66,7 @@ impl Default for Request {
}
impl Request {
/// 从进程参数解析(`argv[1..]`)。
///
/// # Errors
/// 未知参数、缺值、数字非法时返回人话。
/// 从进程参数解析(`argv[1..]`)。未知参数 / 缺值 / 数字非法时返回人话。
pub fn parse(args: impl IntoIterator<Item = String>) -> Result<Self, String> {
let mut request = Self::default();
let mut theme: Option<SchemeFlavor> = None;
@@ -147,7 +135,7 @@ impl Request {
Ok(request)
}
/// 出图文件名片段(`<screen>-<class>-<flavor>`,归档用)。
/// 出图文件名片段(`<screen>-<class>-<flavor>-w<宽>`,归档用)。
pub fn slug(&self) -> String {
let class = super::layout::WidthClass::of(self.width);
let flavor = if self.flavor.is_dark() {
@@ -155,12 +143,8 @@ impl Request {
} else {
"light"
};
format!(
"{}-{}-{flavor}-w{}",
self.screen.slug(),
class.name(),
self.width as u32
)
let width = self.width as u32;
format!("{}-{}-{flavor}-w{width}", self.screen.slug(), class.name())
}
}
@@ -196,19 +180,16 @@ fn flavor(raw: &str) -> Result<SchemeFlavor, String> {
/// 启动(阻塞到出图或关窗)。
///
/// ## 取证通道的硬规则(M97)
///
/// **宁可红着退出,也不能悄悄换一种后端继续出图。** 以前这条路径在 wgpu 挂掉时会静默降级到
/// tiny-skia,然后照样落一张 PNG,而报告上写的是「GPU 渲染截图」——所有视觉结论的可信度都被
/// 悄悄打折,输出上看不出来。现在:① 决策被 [`renderer::strict`] 剥掉兜底;② 起不来就带着
/// **具名**错误返回;③ 每张图的日志里都带 `backend=`,图能自证来源。
/// **取证通道的硬规则(M97)**:宁可红着退出,也不能悄悄换一种后端继续出图(以前 wgpu 挂掉时
/// 会静默降级 tiny-skia 照样落 PNG,而报告上写着「GPU 渲染截图」)。所以这里 ① 决策被
/// [`renderer::strict`] 剥掉兜底、② 起不来带**具名**错误返回、③ 每张图日志都带 `backend=`。
///
/// # Errors
/// 后端探测/字体求值失败、**渲染后端起不来(含 iced 的具名图形错误)**、截图落盘失败时返回人话。
pub fn run(request: Request) -> Result<(), String> {
let started = Instant::now();
let needs_probe = request.backend == Backend::Auto;
let needs_probe = request.backend == Backend::Auto || renderer::acceptance::enabled();
let probe = if needs_probe {
Probe::run()
} else {
@@ -221,6 +202,14 @@ pub fn run(request: Request) -> Result<(), String> {
decision.primary.name(),
decision.reason
);
renderer::acceptance::report_backend_plan(&decision, request.backend);
renderer::acceptance::report_window(
(request.width, request.height),
None,
request.scale_factor,
"pages_demo",
None,
);
let resolved = fonts::resolve();
eprintln!("{}", resolved.describe());
@@ -237,6 +226,8 @@ pub fn run(request: Request) -> Result<(), String> {
request: boot.clone(),
window: None,
frames: 0,
started,
first_frame: None,
backend: backend.name(),
},
Task::done(Message::Boot),
@@ -270,21 +261,34 @@ pub fn run(request: Request) -> Result<(), String> {
// 兜底已被 `strict` 剥掉,所以这条最多跑一次;具名错误的口径与 App 启动一致,
// 但**取证通道不许降级**:出不了图就红着退出,不换后端(那是另一处 iced 行为)。
renderer::run_once(decision, |backend| {
let session_started = Instant::now();
let result = renderer::run_once(decision, |backend| {
std::env::set_var("ICED_BACKEND", backend.env_value());
let application = application(backend);
match request.scale_factor {
Some(scale) => application.scale_factor(move |_| scale).run(),
None => application.run(),
}
})
.map_err(|error| match renderer::named_graphics_error(&error) {
});
let session = session_started.elapsed();
let outcome = result.map_err(|error| match renderer::named_graphics_error(&error) {
Some(named) => format!(
"渲染后端 {} 初始化失败(取证通道无回退,未出图):{named}",
decision.primary.name()
),
None => format!("iced 启动失败:{error}"),
})?;
});
renderer::acceptance::report_outcome(
&decision,
outcome.as_ref().err().map(String::as_str),
"refuse",
"screenshot",
session,
);
outcome?;
eprintln!(
"[harness] 用时 {:.0} ms",
@@ -300,6 +304,10 @@ struct Harness {
model: Model,
window: Option<window::Id>,
frames: u32,
/// 进程起点(`run()` 的开头)——`[perf]` 的首帧口径与产品路径一致:从进 `run()` 算。
started: Instant,
/// 第一帧到达的时刻(相对 `started`)。`None` = 一帧都没到过。
first_frame: Option<std::time::Duration>,
/// 出这张图的后端(`wgpu` / `tiny-skia`)。图要能**自证来源**——以前它证不了(M97)。
backend: &'static str,
}
@@ -323,6 +331,9 @@ impl Harness {
Task::none()
}
Message::Frame => {
if self.frames == 0 {
self.first_frame = Some(self.started.elapsed());
}
self.frames += 1;
if self.frames < CAPTURE_AT_FRAME || self.request.screenshot.is_none() {
return Task::none();
@@ -347,6 +358,14 @@ impl Harness {
}
Err(error) => eprintln!("[harness] 截图失败:{error}"),
}
// `[perf]` 两格都是实测:`first_frame` = 首帧事件的时间戳,`fps` = 帧数 / 时长
// (**取证窗口**的吞吐,不是长跑稳态帧率——那个看产品路径的 `[tick]`)。
renderer::acceptance::report_perf_scene(
&self.request.slug(),
self.frames,
self.first_frame,
self.started.elapsed(),
);
iced::exit()
}
Message::Page(message) => {
@@ -0,0 +1,232 @@
//! `[env]` 与 `[i18n]`:**本机是什么**(系统 / arch / 核数 / GPU / 时区)与**为什么是这个语言**。
//!
//! ## 语言链为什么在这里而不是 `platform/`
//!
//! `platform::locale::SystemLocale` 是 **Windows 注册表**那一路(`reg.exe`,零 FFI)。macOS 的
//! 真值源是 `defaults read -g AppleLanguages`,而 M98 的 scope 没有 `platform/**`,所以
//! [`AppleLanguages`] 先落在这里。**它该搬去 `platform/locale.rs`**——两处并存会漂(见交接报告)。
use unlucky_i18n::lang::{self, Detection, LocaleSource};
use crate::Probe;
// ---------------------------------------------------------------- [env]
/// `[env]`:系统 / arch / 核数 / 语言 / 时区 / 缩放请求 / GPU。
///
/// `gpu` 与 `hardware` **来自真实适配器枚举**([`Probe`]),不是请求值;枚举不到就写
/// `<无适配器>` 并把 `hardware=false` 一起打出来——「没有适配器」与「没枚举」是两件事。
///
/// `display=` 恒为 `needs-window`:显示器尺寸只有拿到窗口句柄才问得到,而这一行在开窗之前就打了。
/// 真值由验收脚本用 `system_profiler SPDisplaysDataType` 采进 `logs/00-env.txt`——**写个猜的值
/// 比留白更坏**(那会让「Monitor 4K / 无外接」这种结论变成猜测)。
#[must_use]
pub fn env_line(probe: &Probe, language: &str, scale: Option<f32>, tz: Option<&str>) -> String {
format!(
"[env] os={} arch={} cores={} language={language} tz={} scale_requested={} display=needs-window \
adapters={} hardware={} gpu=\"{}\"",
std::env::consts::OS,
std::env::consts::ARCH,
std::thread::available_parallelism().map_or(0, std::num::NonZeroUsize::get),
tz.unwrap_or("<未设置>"),
scale.map_or_else(|| "system".to_owned(), |value| value.to_string()),
probe.adapters.len(),
probe.hardware,
gpu_summary(probe),
)
}
/// 枚举结果里**最该被当成 GPU** 的那个适配器的摘要(硬件优先,其次任何适配器)。
///
/// 与 `Probe::describe` 的分工:那个给全量清单(日志一行),这个给「一句话结论」(报告表格用)。
/// 判据同源:`device_type` 不是 `Cpu` / `Other` 才算硬件(WARP 排在第一是本机实测的形状)。
#[must_use]
pub fn gpu_summary(probe: &Probe) -> String {
probe
.adapters
.iter()
.find(|adapter| !matches!(adapter.device_type, "Cpu" | "Other"))
.or_else(|| probe.adapters.first())
.map_or_else(
|| "<无适配器>".to_owned(),
|adapter| format!("{} [{}]", adapter.name, adapter.device_type),
)
}
/// 时区(`TZ` 环境变量)。`std` 拿不到系统时区,所以这里**只报环境变量**,没有就写未设置。
///
/// macOS 上的真值时区由脚本用 `date +%Z%z` 采(那份是观测值,这个只是进程视角)。
#[must_use]
pub fn tz() -> Option<String> {
std::env::var("TZ")
.ok()
.filter(|value| !value.trim().is_empty())
}
// ---------------------------------------------------------------- [i18n]
/// 语言决策链的四级(顺序即优先级:显式值 > 平台源 > 环境变量 > 英文)。
///
/// 每一级的**原始值**都留着,日志才能解释「为什么是英文」——只打结论的话,macOS 上「英文」
/// 到底是「用户就是英文」还是「一个源都没取到」根本分不出来。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Chain {
/// `ui.language` 的显式值(当前没有读取点,恒 `None`)。
pub setting: Option<String>,
/// (源自称的名字, 原始 locale)。
pub platform: (&'static str, Option<String>),
/// (源自称的名字, 原始 locale)。
pub env: (&'static str, Option<String>),
/// 结论(由 [`lang::resolve`] 算,不在本模块重写一遍优先级)。
pub detection: Detection,
}
impl Chain {
/// 用三个源探测一次。**每个源只读一次**(`defaults` / `reg.exe` 都是子进程)。
#[must_use]
pub fn read(
setting: Option<&str>,
platform: &dyn LocaleSource,
env: &dyn LocaleSource,
) -> Self {
let platform = Captured {
label: platform.label(),
raw: platform.locale(),
};
let env = Captured {
label: env.label(),
raw: env.locale(),
};
// 优先级仍由 `resolve` 单点决定:这里把**已读到的值**包成同标签的源喂回去,
// 不复制一遍 if/else(复制就会漂)。
let detection = lang::resolve(setting, &platform, &env);
Self {
setting: setting.map(str::to_owned),
platform: (platform.label, platform.raw),
env: (env.label, env.raw),
detection,
}
}
/// 本机(平台源 + 环境变量;配置未接线)。
#[must_use]
pub fn host() -> Self {
Self::read(None, platform_locale_source(), &lang::EnvLocale)
}
/// 结论语言标签(`zh-CN` / `en`)。
#[must_use]
pub fn language(&self) -> &'static str {
self.detection.lang.tag()
}
/// `[i18n]` 一行:四级链 + 结论。
///
/// `ui_text=pinned-en` 是**今天的事实**:`pages/mod.rs::render` 里下载页固定传 `Lang::En`、
/// 日志页传 `model.lang`(默认也是 `En`),也就是**文案还没接这条结论**。不写这一格的话,
/// 日志会让人以为「语言已经是中文了」——那是新的假绿。
#[must_use]
pub fn line(&self) -> String {
format!(
"[i18n] setting={} platform={}:{} env={}:{} -> {} ui_text=pinned-en",
show(self.setting.as_deref()),
self.platform.0,
show(self.platform.1.as_deref()),
self.env.0,
show(self.env.1.as_deref()),
self.detection.describe()
)
}
}
fn show(value: Option<&str>) -> String {
value.map_or_else(|| "<未设置>".to_owned(), |raw| raw.to_owned())
}
/// 把值钉住的源(为了「读一次、按真优先级算」)。
struct Captured {
label: &'static str,
raw: Option<String>,
}
impl LocaleSource for Captured {
fn locale(&self) -> Option<String> {
self.raw.clone()
}
fn label(&self) -> &'static str {
self.label
}
}
/// 本平台的「系统语言」真值源。
///
/// - Windows:`reg.exe` 读注册表([`crate::platform::locale::SystemLocale`]);
/// - macOS:[`AppleLanguages`](`defaults read -g AppleLanguages`);
/// - 其它:没有平台源(`resolve` 会继续往环境变量走)。
///
/// 为什么 macOS 不复用 `SystemLocale`:它 `cfg!(windows)` 之外恒返回 `None`,在 Mac 上等于
/// 「没有平台源」——于是语言只剩环境变量一级,而 GUI 程序从 Finder 启动时 `LANG` 通常是空的。
#[must_use]
pub fn platform_locale_source() -> &'static dyn LocaleSource {
#[cfg(target_os = "macos")]
{
&AppleLanguages
}
#[cfg(not(target_os = "macos"))]
{
&crate::platform::locale::SystemLocale
}
}
/// macOS 的系统语言源:`defaults read -g AppleLanguages` 的第一项。
#[derive(Debug, Clone, Copy, Default)]
pub struct AppleLanguages;
impl LocaleSource for AppleLanguages {
fn locale(&self) -> Option<String> {
#[cfg(target_os = "macos")]
{
let output = std::process::Command::new("defaults")
.args(["read", "-g", "AppleLanguages"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
parse_apple_languages(&String::from_utf8_lossy(&output.stdout))
}
#[cfg(not(target_os = "macos"))]
{
None
}
}
fn label(&self) -> &'static str {
"applelanguages"
}
}
/// 解析 `defaults` 的输出,取**第一条**语言(真机格式见测试夹具)。
///
/// ```text
/// (
/// "zh-Hans-CN",
/// "en-US"
/// )
/// ```
///
/// 首项是用户的**首选语言**;后面几项是「语言与地区」里的备选,与本 crate「只看主语言子标签」
/// 的口径无关。
#[must_use]
pub fn parse_apple_languages(text: &str) -> Option<String> {
let start = text.find('"')?;
let rest = &text[start + 1..];
let end = rest.find('"')?;
let value = rest[..end].trim();
(!value.is_empty()).then(|| value.to_owned())
}
#[cfg(test)]
#[path = "locale_tests.rs"]
mod tests;
@@ -0,0 +1,141 @@
//! `[env]` / `[i18n]` 的钉死测试(从 `tests.rs` 拆出来:那边撞到 400 行上限)。
//!
//! 这两类都靠「把真实输入喂进纯函数再逐字断言」,抓得住的是本项目的头号事故类型:
//! **在需要结果的地方打印请求或常量**。
use super::*;
use crate::probe::AdapterSummary;
use crate::Probe;
use unlucky_i18n::lang::LocaleSource;
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 }
}
/// 抓得住:`[env]` 的 `gpu=` 取的是**硬件**适配器而不是枚举里的第一个(WARP 排在第一是本机实测
/// 的形状:`Microsoft Basic Render Driver [Cpu]`)。改回 `adapters.first()` 这条就红。
#[test]
fn env_line_prefers_the_hardware_adapter() {
let probe = probe_of(&[
("Microsoft Basic Render Driver", "Cpu"),
("AMD Radeon RX 6600 XT", "DiscreteGpu"),
]);
let line = env_line(&probe, "zh-CN", Some(1.0), Some("Asia/Shanghai"));
assert!(
line.contains("gpu=\"AMD Radeon RX 6600 XT [DiscreteGpu]\""),
"{line}"
);
assert!(line.contains("adapters=2"), "{line}");
assert!(line.contains("hardware=true"), "{line}");
assert!(line.contains("language=zh-CN"), "{line}");
assert!(line.contains("tz=Asia/Shanghai"), "{line}");
assert!(line.contains("scale_requested=1"), "{line}");
// 显示器只有开窗后才知道:这里必须留白并**自证**留白(写个猜的值比留白更坏)
assert!(line.contains("display=needs-window"), "{line}");
assert!(line.starts_with("[env] os="), "{line}");
}
/// 抓得住:「一个适配器都没枚举到」不许写成 `gpu=""` 或者省略(那会让人以为枚举到了)。
#[test]
fn env_line_says_so_when_there_is_no_adapter() {
let line = env_line(&Probe::default(), "en", None, None);
assert!(line.contains("gpu=\"<无适配器>\""), "{line}");
assert!(line.contains("adapters=0 hardware=false"), "{line}");
assert!(line.contains("scale_requested=system"), "{line}");
assert!(line.contains("tz=<未设置>"), "{line}");
}
/// 抓得住:`[i18n]` 必须把**每一级的原始值**打出来,且结论跟着真优先级走。
/// 抓不住:这台机器的 `defaults` / `reg.exe` 真的返回什么(那是真机验收)。
#[test]
fn i18n_line_shows_every_rung_and_the_resolved_language() {
struct Fixed(Option<&'static str>, Option<&'static str>);
impl LocaleSource for Fixed {
fn locale(&self) -> Option<String> {
self.0.map(str::to_owned)
}
fn label(&self) -> &'static str {
self.1.unwrap_or("injected")
}
}
let chain = Chain::read(
None,
&Fixed(Some("zh-Hans-CN"), Some("applelanguages")),
&Fixed(None, Some("env")),
);
assert_eq!(chain.language(), "zh-CN");
let line = chain.line();
assert!(
line.contains("platform=applelanguages:zh-Hans-CN"),
"平台源原始值必须打出来:{line}"
);
assert!(line.contains("env=env:<未设置>"), "{line}");
assert!(
line.contains("-> lang=zh-CN locale=zh-Hans-CN source=applelanguages"),
"{line}"
);
assert!(line.contains("ui_text=pinned-en"), "{line}");
}
/// 抓得住 macOS 最典型的失败形态:**一个源都没有**(Finder 启动的 GUI 进程既没有 `LANG`
/// 也没有注册表)。这时必须是 `lang=en source=default`,而不是「假装读到了英文」。
#[test]
fn i18n_line_distinguishes_no_source_from_english_source() {
struct Nothing;
impl LocaleSource for Nothing {
fn locale(&self) -> Option<String> {
None
}
fn label(&self) -> &'static str {
"applelanguages"
}
}
let empty = Chain::read(None, &Nothing, &Nothing);
assert_eq!(empty.language(), "en");
assert!(empty.line().contains("source=default"), "{}", empty.line());
struct EnvEnglish;
impl LocaleSource for EnvEnglish {
fn locale(&self) -> Option<String> {
Some("en_US.UTF-8".to_owned())
}
fn label(&self) -> &'static str {
"env"
}
}
let env = Chain::read(None, &Nothing, &EnvEnglish);
assert!(env.line().contains("source=env"), "{}", env.line());
assert!(
env.line().contains("locale=en_US.UTF-8"),
"两种「英文」必须能从日志上分开:{}",
env.line()
);
}
/// 真机 `defaults read -g AppleLanguages` 的输出形状(多行、带引号、逗号)。
#[test]
fn apple_languages_takes_the_first_entry() {
let real = "(\n \"zh-Hans-CN\",\n \"en-US\"\n)\n";
assert_eq!(parse_apple_languages(real).as_deref(), Some("zh-Hans-CN"));
assert_eq!(
parse_apple_languages("(\n \"en-US\"\n)\n").as_deref(),
Some("en-US")
);
assert_eq!(parse_apple_languages("()"), None);
assert_eq!(parse_apple_languages(""), None);
// 空串(某些机器上 `defaults` 会回 `(\n)`)不许被当成语言
assert_eq!(parse_apple_languages("(\n \"\"\n)"), None);
}
@@ -0,0 +1,379 @@
//! 验收诊断(M98):把「借朋友一台 Mac、反复问 7 个问题」压成「一次运行、日志自己回答」。
//!
//! ## 八类日志的分工(M98 任务表)
//!
//! | 类 | 谁打 | 是不是真实结果 |
//! |---|---|---|
//! | `[env]` | [`locale`] | 是(`os/arch/cores` 来自编译目标;`gpu` 来自真适配器枚举;`language` 来自下面的语言链) |
//! | `[backend]` | 本模块(`app::boot` / `pages::harness` 调用) | 是(单项 `ICED_BACKEND` ⇒ 起步成功就是它;失败带具名错误) |
//! | `[window]` | 本模块 | **请求值**(`requested=`)。实际值由脚本从 `[shot] …scale=` 与 PNG 像素尺寸换算 |
//! | `[font]` | `fonts`(已有)+ [`visual`](`chain`) | 是:`platform=` 是实际落点,`chain=` 是令牌声明的回退链 |
//! | `[theme]` | 本模块 | 是(来源 = 哪个参数/系统决定;系统真值更晚,由已有 `[theme] 系统主题 = …` 打) |
//! | `[perf]` | 本模块 | 是(`Instant` 实测) |
//! | `[i18n]` | [`locale`] | 是(四级链的**每一级原始值**都打出来,结论由 `unlucky_i18n::lang::resolve` 给) |
//! | `[caps]` | [`visual`] | 半观测(`bundle` / `menu_bar_name` 从可执行文件路径读)、半**请求值**(`transparent` / `decorations`) |
//!
//! **反模式警告(本项目反复踩的那一类)**:在需要「结果」的地方打「请求」。所以本模块凡是请求值
//! 都给字段加后缀(`requested` / `…_requested`),凡是推断出来的都给 `_source`,拿不到就写
//! `<未设置>` / `pending` / `none`,**不猜**。
//!
//! ## 开关
//!
//! [`ACCEPTANCE_ENV`](`ULL_ACCEPTANCE=1`)打开「贵的那部分」:`[env]` 里的适配器枚举(本机
//! 0.65~1.2 s)在 `--renderer=gpu|cpu` 时本来会被跳过,验收要它 ⇒ 这个开关强制枚举一次。
//! 关掉它对产品行为没有任何影响:本模块只打日志,不改决策。
//!
//! 便宜的行(`[backend]` / `[window] requested` / `[theme]`)**没有开关**:报 bug 的人随手截的
//! 日志里就该有「实际用哪个后端」这件事。
use std::time::Duration;
use crate::renderer::{Decision, Kind};
use crate::{Backend, Probe};
pub mod locale;
pub mod visual;
pub use locale::{env_line, tz, Chain};
pub use visual::{caps_line, font_chain_line, report_caps, report_font_chain};
/// 打开「贵的诊断」的开关(见模块文档 §开关)。
pub const ACCEPTANCE_ENV: &str = "ULL_ACCEPTANCE";
/// 窗口尺寸覆盖(`ULL_WINDOW_SIZE=1520x900`)。
///
/// 为什么是环境变量而不是 `--width`:`cli.rs` 不在 M98 的 scope,而验收矩阵必须有「最小尺寸 /
/// 宽屏」两档,且必须是**真外壳**(自绘三键 + 标题栏)——`pages_demo` example 的标题栏是留白带,
/// 出不了「三键位置」那张图。语义与 `ULL_START_ROUTE` 一致:只影响这一次进程。
pub const WINDOW_SIZE_ENV: &str = "ULL_WINDOW_SIZE";
/// `ULL_ACCEPTANCE` 是否打开(缺省关;`0` / `off` / `false` / `no` 也算关)。
#[must_use]
pub fn enabled() -> bool {
match std::env::var(ACCEPTANCE_ENV) {
Ok(value) => !matches!(
value.trim().to_ascii_lowercase().as_str(),
"0" | "off" | "false" | "no"
),
Err(_) => false,
}
}
/// 第一行:告诉读日志的人「后面这些类是刻意打开的,不是噪声」。
pub fn announce() {
eprintln!(
"[acceptance] on({ACCEPTANCE_ENV})八类诊断已开:[env] [backend] [window] [font] [theme] \
[perf] [i18n] [caps]。关掉:{ACCEPTANCE_ENV}=0"
);
}
/// 启动那一段(只在不贵的时候由调用方决定打不打):`[env]` + `[i18n]`。
///
/// 两行都吃**同一次**适配器枚举与**同一次**语言链(`defaults` / `reg.exe` 是子进程,读两次就是
/// 两次开销,而且两次之间值可能变)。
pub fn report_startup(probe: &Probe, scale: Option<f32>) {
let chain = Chain::host();
eprintln!(
"{}",
env_line(probe, chain.language(), scale, tz().as_deref())
);
eprintln!("{}", chain.line());
}
// ---------------------------------------------------------------- [theme]
/// `[theme]`:本次主题**由谁决定**(来源)+ 语义值。
///
/// `follows_system=true` 时 `flavor=pending`:系统主题是异步到达的(`iced::system::theme()`),
/// 启动这一刻还没有值。真值随后由 `app/update.rs` 的 `[theme] 系统主题 = Dark` 给出——
/// **两行合起来才是完整答案**,缺一行就分不清「跟了系统」和「没跟」。
pub fn report_theme(source: &str, flavor: Option<&str>, follows_system: bool) {
eprintln!("{}", theme_text(source, flavor, follows_system));
}
/// 主题**由谁决定**(纯判据,好测):`(来源, 是否跟随系统)`。
///
/// 顺序与 `Options::capture_flavor` 一致:`--screenshot-theme` > `--theme` > 截图默认 Light >
/// 系统。**取值口只有一个**(`capture_flavor`),这里只回答「为什么是它」。
#[must_use]
pub fn theme_source(screenshot_theme: bool, theme: bool, screenshot: bool) -> (&'static str, bool) {
match (screenshot_theme, theme, screenshot) {
(true, _, _) => ("screenshot-theme", false),
(_, true, _) => ("theme", false),
(_, _, true) => ("default-light", false),
_ => ("system", true),
}
}
/// 见 [`report_theme`](单独返回字符串是为了让测试能逐字断言)。
#[must_use]
pub fn theme_text(source: &str, flavor: Option<&str>, follows_system: bool) -> String {
format!(
"[theme] source={source} flavor={} follows_system={follows_system}",
flavor.unwrap_or("pending")
)
}
// ---------------------------------------------------------------- [window]
/// `[window]` 的**请求值**一行(实际值见模块文档的表:脚本从 `[shot] … scale=` 与 PNG 换算)。
///
/// `min` / `title` 是 `Option`:`pages_demo` 那条通道没有最小尺寸与自定标题,这时打 `none` /
/// `<默认>` —— 填个 `0x0` 会让人以为是「最小尺寸是 0」,那是另一种假绿。
pub fn report_window(
size: (f32, f32),
min: Option<(f32, f32)>,
scale: Option<f32>,
size_source: &str,
title: Option<&str>,
) {
eprintln!("{}", window_text(size, min, scale, size_source, title));
}
/// 见 [`report_window`]。
#[must_use]
pub fn window_text(
size: (f32, f32),
min: Option<(f32, f32)>,
scale: Option<f32>,
size_source: &str,
title: Option<&str>,
) -> String {
let min = min.map_or_else(|| "none".to_owned(), |(w, h)| format!("{w}x{h}"));
let title = title.map_or_else(|| "<默认>".to_owned(), |text| format!("\"{text}\""));
format!(
"[window] requested size={}x{} min={min} resizable=true decorations=false transparent=false \
scale_requested={} size_source={size_source} title={title}",
size.0,
size.1,
scale.map_or_else(|| "system".to_owned(), |value| value.to_string()),
)
}
/// 解析 `ULL_WINDOW_SIZE`(`1520x900` / `1520X900` / `1520×900`)。
///
/// 返回 `None` = 不是这个格式(调用方保留默认尺寸并打一行警告,**不**静默忽略)。
#[must_use]
pub fn parse_window_size(raw: &str) -> Option<(f32, f32)> {
let trimmed = raw.trim();
let (width, height) = trimmed
.split_once(['x', 'X', '×', '*'])
.map(|(w, h)| (w.trim(), h.trim()))?;
let width: f32 = width.parse().ok()?;
let height: f32 = height.parse().ok()?;
(width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0)
.then_some((width, height))
}
/// 尺寸覆盖 → 实际要开多大 + **尺寸来源**(进 `[window] … size_source=`)。
///
/// 三种来源:`default`(没设 / 设了但解析不了)与 `ULL_WINDOW_SIZE`(生效)。
/// 小于最小尺寸时夹到最小并标 `:clamped-to-min`——iced 的 `min_size` 本来也会拦住,
/// 但那样日志会说「要求 640x480」而实际是 880x560,是典型的「日志说请求、报告当结果」。
#[must_use]
pub fn resolve_window_size(
raw: Option<&str>,
default: (f32, f32),
min: (f32, f32),
) -> ((f32, f32), &'static str) {
let Some(raw) = raw else {
return (default, "default");
};
let Some((width, height)) = parse_window_size(raw) else {
return (default, "ULL_WINDOW_SIZE:invalid");
};
let clamped = (width.max(min.0), height.max(min.1));
if clamped == (width, height) {
(clamped, "ULL_WINDOW_SIZE")
} else {
(clamped, "ULL_WINDOW_SIZE:clamped-to-min")
}
}
/// 窗尺寸(含来源)从进程环境取一遍——`app::boot` 与测试共用同一条路径。
#[must_use]
pub fn window_size(default: (f32, f32), min: (f32, f32)) -> ((f32, f32), &'static str) {
let raw = std::env::var(WINDOW_SIZE_ENV).ok();
let resolved = resolve_window_size(raw.as_deref(), default, min);
if resolved.1.ends_with("invalid") {
eprintln!(
"[window] {WINDOW_SIZE_ENV}={:?} 解析不了(要 1520x900 这种),按默认尺寸跑",
raw.unwrap_or_default()
);
}
resolved
}
// ---------------------------------------------------------------- [backend]
/// `[backend] plan`:这一次准备试**哪些**后端(单项 + 本 crate 的兜底,不是 iced 的候选回退)。
pub fn report_backend_plan(decision: &Decision, request: Backend) {
eprintln!("{}", backend_plan_text(decision, request));
}
/// 见 [`report_backend_plan`]。
#[must_use]
pub fn backend_plan_text(decision: &Decision, request: Backend) -> String {
format!(
"[backend] plan request={} primary={} fallback={} candidates=1 reason={}",
request.name(),
decision.primary.name(),
decision.fallback.map_or("none", Kind::name),
decision.reason,
)
}
/// `[backend] final`:起步的**结果**。
///
/// 判据是 M97 的「单项 `ICED_BACKEND`」:候选只有一项 ⇒ `run()` 能正常起来就必定是它
/// (没有静默回退的空间)。`evidence=probe-single-item` 就是这条推理的名字,别把它读成
/// 「我们查了渲染器内部」——那件事只有 `[gpu] iced_wgpu: …Selected: AdapterInfo…` 那行能做。
pub fn report_backend_started(primary: Kind, elapsed: Duration) {
eprintln!("{}", backend_started_text(primary, elapsed));
}
/// 见 [`report_backend_started`]。
#[must_use]
pub fn backend_started_text(primary: Kind, elapsed: Duration) -> String {
format!(
"[backend] final={} result=started elapsed={}ms evidence=probe-single-item",
primary.name(),
elapsed.as_millis()
)
}
/// `[backend] final`:起步失败(具名错误原样带在 `error=` 里,一行可 grep)。
///
/// `via` = 失败之后去了哪:
/// - `respawn`:交互启动换了进程用兜底后端重开(子进程自己再打一遍 plan/final);
/// - `refuse`:取证启动(`--screenshot`)拒绝降级出图,**没有图**;
/// - `none`:没有兜底(`--renderer=cpu` / 没编 wgpu),原样失败。
pub fn report_backend_failed(primary: Kind, fallback: Option<Kind>, via: &str, error: &str) {
eprintln!("{}", backend_failed_text(primary, fallback, via, error));
}
/// 见 [`report_backend_failed`]。
#[must_use]
pub fn backend_failed_text(
primary: Kind,
fallback: Option<Kind>,
via: &str,
error: &str,
) -> String {
let final_name = fallback.map_or("none", Kind::name);
// `error="…"` 必须是**格式良好的单个字段**:具名错误里常自带双引号(wgpu 的 Debug 输出),
// 原样塞进去会把这一行撕成两半,脚本按 `error="` 取值就只能取到前半截。
let error = error.replace('"', "'").replace(['\n', '\r'], " ");
format!(
"[backend] final={final_name} result=failed primary={} fallback={final_name} via={via} \
error=\"{error}\"",
primary.name(),
)
}
/// 起步失败后去了哪(日志字段 `via=`;与 `app::boot::OnFailure` 同一套取值)。
#[must_use]
pub fn failure_via(has_fallback: bool, evidence: bool) -> &'static str {
match (has_fallback, evidence) {
(false, _) => "none",
(true, true) => "refuse",
(true, false) => "respawn",
}
}
/// 起步结果 → `[backend] final` 一行 +(验收模式)`[perf] session` 一行。
pub fn report_outcome(
decision: &Decision,
error: Option<&str>,
via: &str,
mode: &str,
elapsed: Duration,
) {
match error {
None => report_backend_started(decision.primary, elapsed),
Some(message) => report_backend_failed(decision.primary, decision.fallback, via, message),
}
if enabled() {
report_perf_session(mode, elapsed, error.is_none());
}
}
// ---------------------------------------------------------------- [perf]
/// `[perf] boot`:启动三段(探测 / 字体 / 到 `run()` 之前)的实测毫秒数。
pub fn report_perf_boot(probe: Duration, font: Duration, to_run: Duration) {
eprintln!("{}", perf_boot_text(probe, font, to_run));
}
/// 见 [`report_perf_boot`]。
#[must_use]
pub fn perf_boot_text(probe: Duration, font: Duration, to_run: Duration) -> String {
format!(
"[perf] boot probe={}ms font={}ms to_run={}ms",
probe.as_millis(),
font.as_millis(),
to_run.as_millis()
)
}
/// `[perf] session`:`iced::application().run()` 从进到出(或到出图)的总时长。
///
/// 取证启动(`--screenshot`)的出图在第 3 帧,所以这条约等于「进程启动 → 出图」——验收里它就是
/// 「这个场景要多久」。交互启动那次是「窗口开了多久」,所以带 `mode=` 区分,别混着读。
pub fn report_perf_session(mode: &str, elapsed: Duration, ok: bool) {
eprintln!("{}", perf_session_text(mode, elapsed, ok));
}
/// 见 [`report_perf_session`]。
#[must_use]
pub fn perf_session_text(mode: &str, elapsed: Duration, ok: bool) -> String {
format!(
"[perf] session mode={mode} elapsed={}ms result={}",
elapsed.as_millis(),
if ok { "ok" } else { "failed" }
)
}
/// `[perf] scene`:**取证场景**(`pages_demo` 通道)的帧数据。
///
/// `fps` 由 `frames / elapsed` 算——注意它是**取证窗口**(第 1 帧到第 N 帧)的吞吐,不是长时间
/// 稳态帧率:产品路径的长跑帧率看 `[tick]` 那串(`ULL_TICK_TRACE=1`,每秒一行、带累计帧数)。
/// 两个来源刻意不合并:一个是「出这张图花了多久」,一个是「放着不动烧不烧风扇」。
pub fn report_perf_scene(
scene: &str,
frames: u32,
first_frame: Option<Duration>,
elapsed: Duration,
) {
eprintln!("{}", perf_scene_text(scene, frames, first_frame, elapsed));
}
/// 见 [`report_perf_scene`]。
#[must_use]
pub fn perf_scene_text(
scene: &str,
frames: u32,
first_frame: Option<Duration>,
elapsed: Duration,
) -> String {
// fps 只从**首帧之后**的帧间隔算:整段起点到首帧那段是启动成本(字体扫描、适配器枚举),
// 把它算进帧率会让「8 fps」这种结论完全是启动慢的假象。少于 2 帧就没有间隔可测 ⇒ unknown。
let fps = first_frame.and_then(|first| {
let window = elapsed.checked_sub(first)?;
(frames >= 2 && !window.is_zero()).then(|| f64::from(frames - 1) / window.as_secs_f64())
});
format!(
"[perf] scene={scene} frames={frames} first_frame={}ms elapsed={}ms fps={}",
first_frame.map_or_else(
|| "unknown".to_owned(),
|value| value.as_millis().to_string()
),
elapsed.as_millis(),
fps.map_or_else(|| "unknown".to_owned(), |value| format!("{value:.1}")),
)
}
#[cfg(test)]
#[path = "tests.rs"]
mod tests;
@@ -0,0 +1,326 @@
//! `[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");
}
@@ -0,0 +1,103 @@
//! `[font]` 的回退链与 `[caps]` 的平台能力——两件「看一眼就知道对不对」的事。
//!
//! 这里的两个函数都是**纯格式化**(除了 `[caps]` 读一次 `current_exe()`,那是调用方传进来的):
use std::path::Path;
use unlucky_design::typography::{FontFamily, Platform as FontPlatform};
// ---------------------------------------------------------------- [font]
/// `[font] chain`:令牌里三条栈的**声明**顺序(首选 → 兜底 → 通用族名)。
///
/// 与 `fonts` 打的 `[font] platform=…` 互补:那行是「实际落到哪个族」,这行是「本来想落哪些」。
/// 缺字体(方块字)时要看的是**两者的差**:链里有 `"PingFang SC"` 但 `text=` 落到别的族,
/// 就是这台机器没有苹方。
#[must_use]
pub fn font_chain_line(platform: FontPlatform) -> String {
let stacks = [
("display", FontFamily::Display),
("text", FontFamily::Text),
("mono", FontFamily::Mono),
]
.map(|(name, family)| {
let chain = platform
.font_stack(family)
.iter()
.map(|face| format!("\"{}\"", face.family))
.collect::<Vec<_>>()
.join(">");
format!("{name}={chain}")
})
.join(" ");
format!(
"[font] chain platform={} {stacks}(令牌声明的回退链;实际落点见上面 [font] platform=…)",
platform.name()
)
}
/// 打一行 [`font_chain_line`](调用点只留一句)。
pub fn report_font_chain(platform: FontPlatform) {
eprintln!("{}", font_chain_line(platform));
}
// ---------------------------------------------------------------- [caps]
/// `[caps]`:平台能力里**本进程能自己回答**的那几条。
///
/// 半观测半请求,字段名照实说:
/// - `bundle` / `menu_bar_name`:观测(从可执行文件路径读);
/// - `transparent` / `decorations` / `resizable`:本进程给窗口的设置(**请求值**,即终端用户
/// 实际跑的那个形态;`decorations=false` ⇒ 三键是自绘的);
/// - `controls=self-drawn`:由 `decorations=false` 推出来的结论(自绘三键在**右上角**,
/// 与 macOS 惯例的左上相反——这条正是「三键位置怪不怪」的机器答案,位置本身由脚本在截图上
/// 做像素定位)。
#[must_use]
pub fn caps_line(
exe: Option<&Path>,
transparent: bool,
resizable: bool,
decorations: bool,
) -> String {
let (is_bundle, name) = bundle_of(exe);
format!(
"[caps] bundle={} menu_bar_name=\"{name}\" menu_bar_source={} controls={} \
transparent={transparent} resizable={resizable} decorations={decorations}",
if is_bundle { "yes" } else { "no" },
if is_bundle { "bundle" } else { "exe-stem" },
if decorations { "native" } else { "self-drawn" },
)
}
/// 可执行文件 → (是不是 `.app` 包, 菜单栏会显示的名字)。
///
/// macOS 上非 bundle 的二进制,菜单栏 App 名就是**可执行文件名**;打成 `.app` 时是
/// `Foo.app` 里的 `Foo`。这条从路径读得出来,所以「菜单栏名字对不对」**不需要问人**。
#[must_use]
pub fn bundle_of(exe: Option<&Path>) -> (bool, String) {
let Some(exe) = exe else {
return (false, "<未知>".to_owned());
};
// `Foo.app/Contents/MacOS/foo` 里的 `Foo`:路径里出现以 `.app` 结尾的一段就是 bundle。
for component in exe
.components()
.filter_map(|part| part.as_os_str().to_str())
{
if let Some(name) = component.strip_suffix(".app") {
return (true, name.to_owned());
}
}
let stem = exe
.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or("<未知>");
(false, stem.to_owned())
}
/// 打一行 [`caps_line`](调用点只留一句)。
pub fn report_caps(exe: Option<&Path>, transparent: bool, resizable: bool, decorations: bool) {
eprintln!("{}", caps_line(exe, transparent, resizable, decorations));
}
+7 -1
View File
@@ -64,12 +64,17 @@
//! `iced::Error::GraphicsCreationFailed` 里那串具名错误(哪个 backend、reason 是什么),
//! 由 `app/boot.rs` 打印后决定「换进程降级」还是「拒绝出图」。
//!
//! ## 三段基建
//! ## 四段基建
//!
//! | 模块 | 作用 | 默认 |
//! |---|---|---|
//! | [`preflight`] | 在 iced 之前自己跑一遍 wgpu 的「实例→适配器→设备」,把失败原因拿在手里 | **关**(`ULL_PREFLIGHT=1` 打开;会重复创建实例/设备,别放默认路径) |
//! | [`log_bridge`] | 把 `iced_wgpu` / `wgpu` 的 `log` 输出转到 stderr(`Selected: AdapterInfo{…}` 这行就是 GPU 生效的实证) | 开(`ULL_GPU_LOG=off` 可关) |
//! | [`acceptance`] | **验收诊断(M98)**:`[env]` `[backend]` `[window]` `[font] chain` `[theme]` `[perf]` `[i18n]` `[caps]` 八类里本 crate 能观测的那部分,固定前缀、单行可 grep | 便宜的行恒开;贵的(适配器枚举)由 `ULL_ACCEPTANCE=1` 打开 |
//!
//! **本模块自己只回答「判据」**;「结果」由三处分别取证,别混:① `[backend] final=`(本 crate 的
//! 起步结果,判据是单项 `ICED_BACKEND`);② `[gpu] iced_wgpu: …Selected:`(iced 自己打的,
//! GPU 真生效的实证);③ `[shot] … renderer=`(这张图是哪个后端出的)。
//!
//! ## 真机验收(macOS Metal 路径 —— **需真机复核**,Windows 验不了这一段)
//!
@@ -121,6 +126,7 @@
//! → 具名错误 → 拒绝出图 → exit 1);macOS 上要确认打印的是 `dx12 support not compiled in` 那类
//! wgpu 原话,而不是我们自己的话。
pub mod acceptance;
pub mod decision;
pub mod log_bridge;
pub mod preflight;