fix(acceptance): M98 r1 —— [window] 绑定真 settings、语言维度补断言、boot.rs 拆分
复审 r1(reviewer-accscope-code,p2-2items / fix-then-merge)的两条阻塞 + 四条顺手的 p3。 ## p2-1 `[window]` 的三个窗口属性曾是格式串常量(U1:常量冒充观测结果) 第一版把 `resizable=true decorations=false transparent=false` 写在格式化串里,与交给 iced 的 `window::Settings` 没有任何绑定;定向变异把 `settings.decorations` 改成 `true`,**371 个测试全绿**, 而验收包里 `[caps]` 与 `[window]` 会互相矛盾。 - 新增 `acceptance::window::WindowFacts` + `from_settings()`:字段只能从**真实 settings** 抽出来, 没有手抄入口;`window_text()` 逐字段打印。 - 窗口设置收成单一产地 `app/boot/diagnostics::window_settings()`,`launch()` 与 `[window]`/`[caps]` 两行日志共用同一个 `&window::Settings`;`pages/harness.rs` 同样从自己那份 settings 取。 - 守门测试 `window_settings_match_what_the_acceptance_logs_claim`,含「from_settings 必须逐字段 搬运」的反向断言。变异实证:`window_settings` 的 `decorations → true` ⇒ 红并点名; `from_settings` 里写死 `decorations: false` ⇒ 红(第一次写测试时这条漏抓,补强后实测红)。 ## p2-2 语言维度空转、且没有任何结论断言 `lang::resolve` 是 setting → 平台源 → env,macOS 的 AppleLanguages 几乎恒有值 ⇒ `LANG=zh_CN` / `LANG=en_US` 两档同值,而原脚本只断言「`[i18n]` 这行存在」——整条链退化成 `source=default lang=en` 时核心集一条都不红。 - 脚本加两条结论断言:`^\[i18n\].*platform=applelanguages:[A-Za-z-]+` 与 ` source=applelanguages` (退化日志上实测红、健康日志上实测绿)。 - `REPORT.md` 语言行下注明:env 优先级低于平台源,正常 Mac 上两档同值,这一维验的是「平台源 真读到了 + 中文字体回退」,不是中英对照。 ## p3(复审 + 第二路自跑复核同源的三条) - `[perf] boot probe=0ms` → `probe=skipped`(没计时就不报数;降级重开那一轮改为**真计时**)。 变异实证:把 skipped 写成 0ms ⇒ `perf_lines_carry_measured_millis` 红。 - `[env] gpu=` 排序改成 `DiscreteGpu > IntegratedGpu > 其它`:本机原来报核显,而 iced 实际选的是 独显(现在报 `AMD Radeon RX 6600 XT [DiscreteGpu]`,与 `Selected:` 一致)。 - `[i18n] setting=<未设置>` → `<未接线>`(配置侧根本没读取点,写「未设置」等于把「我们没读」 说成「用户没设」)。 - `[backend] final=` 失败行:`refuse` / `none` 两条路是 `final=none`(什么都没跑起来),只有 `respawn` 才是兜底名;`pages/harness.rs` 不再写死 `via="refuse"`,改用共用的 `failure_via()`。 实跑负例:`[backend] final=none result=failed primary=wgpu fallback=tiny-skia via=refuse`。 - 脚本 `[tick]` 那处唯一未锚定的 `field()`:加 `^` 锚定、正则改 `clock=[a-z0-9-]+`(真值是 `idle-1hz`,旧 `[a-z]+` 把它截成 `idle`,靠前缀巧合通过);断言也改成 `clock=idle-1hz`。 - 脚本 `[backend] final` 取值改 `field_backend()`:优先 `result=started`(respawn 场景一条日志里 有父 failed + 子 started 两条,旧 `head -1` 会把「这次没跑起来」写进报告)。 - 脚本 `exec 3>&1` 全程留痕(终端提示也进 `logs/00-run.log`)、跑前清空 `$OUT/shots/`、 python3 缺失时在 REPORT 里写明「几何列未采」而不是留空。 - `renderer/mod.rs` 补一段依据:为什么「单项 ICED_BACKEND ⇒ 没有静默回退」成立(`iced_tiny_skia` 的 `with_backend` 只接受 `tiny-skia`),以及它依赖 iced 实现细节的两层缓解。 ## 结构 `app/boot.rs` 400 行零余量 → 拆成 `boot.rs`(248) + `boot/fallback.rs`(117,兜底/重开/退出码) + `boot/diagnostics.rs`(110,窗口设置与四段报告);`run()` 回到「探测 → 字体 → 打印 → 起 iced」。 `acceptance` 再把 `[window]` 拆成 `window.rs`(142)。 门禁:`cargo fmt --check` / `clippy --all-targets -- -D warnings` / `test`(373 lib + 6 integration)全绿。
This commit is contained in:
1 parent
f986981c12
commit
8153e54f87
10 files changed
+726
-385
No files matched your search
+67
-219
@@ -1,56 +1,49 @@
|
||||
//! 启动:后端探测 → 字体求值 → `iced::application(..).run()`,外加 M98 的八类验收诊断。
|
||||
//! 启动编排:**探测 → 字体 → 打印诊断 → 起 iced**(四步,别把它撑回一坨)。
|
||||
//!
|
||||
//! ## M97:起步后端起不来时怎么办(两条路,策略不同,别合成一条)
|
||||
//! ## 三条纪律(每条都对应一次返工)
|
||||
//!
|
||||
//! `ICED_BACKEND` 永远是**单项**(见 [`crate::renderer`] 的判据表)。单项的代价是 iced 不再自己
|
||||
//! 回退,于是「wgpu 起不来」会变成 `iced::Error::GraphicsCreationFailed`——**这正是我们要的**:
|
||||
//! 那里面装着 wgpu 的具名错误(哪个 backend、卡在 adapter 还是 device)。
|
||||
//! 1. **后端起不来时的两条路策略不同**(判据表第 8/9 行):交互启动换进程重开、取证启动
|
||||
//! (`--screenshot`)拒绝出图 —— 实现与测试都在 [`fallback`]。
|
||||
//! 2. **窗口设置只有一个产地**([`diagnostics::window_settings`]),`[window]` / `[caps]` 两行日志
|
||||
//! 从**同一个** `&window::Settings` 取字段。M98 r1 p2-1 的教训:把 `decorations=false` 写进
|
||||
//! 格式化串,等于让日志与真实行为可以各走各的(踩坑记录 U1)。
|
||||
//! 3. **没测过的就写 `skipped`**,不许拿 `0ms` 冒充实测([`diagnostics::report_font`] 的 `probe`)。
|
||||
//!
|
||||
//! - **交互启动**:换一个**进程**用 `tiny-skia` 重开([`OnFailure::Respawn`])——安全网,用户还能用。
|
||||
//! - **取证启动**(带 `--screenshot`,含 `--screen=gallery`):**拒绝出图**、退出码 1
|
||||
//! ([`OnFailure::Refuse`])——换了后端出的图会让报告撒谎。
|
||||
//!
|
||||
//! 为什么必须换进程:Windows 实测本进程里再 `run()` 一次直接 panic
|
||||
//! (`iced_winit-0.14.1/src/lib.rs:81` → `Create event loop: RecreationAttempt`)。别再猜着改回去。
|
||||
//!
|
||||
//! ## M98:诊断的落点
|
||||
//!
|
||||
//! 八类日志由 [`crate::renderer::acceptance`] 排版;本文件只在**正确的时刻**把真值喂给它(探测
|
||||
//! 产物 / 窗口设置 / 实测 `Instant`);判据与证据的边界写在那边的模块文档里。
|
||||
//! 为什么重开必须是**换进程**:Windows 实测本进程里再 `run()` 一次直接 panic
|
||||
//! (`iced_winit-0.14.1/src/lib.rs:81` → `Create event loop: RecreationAttempt`)。
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use iced::{window, Task};
|
||||
use iced::Task;
|
||||
|
||||
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::renderer::{self, acceptance, Decision};
|
||||
use crate::theme::Families;
|
||||
|
||||
use super::message::Message;
|
||||
use super::state::{App, STARTED};
|
||||
use super::{APP_TITLE, MIN_SIZE, WINDOW_SIZE};
|
||||
use super::{MIN_SIZE, WINDOW_SIZE};
|
||||
|
||||
mod diagnostics;
|
||||
mod fallback;
|
||||
|
||||
use fallback::{on_failure, respawn, OnFailure};
|
||||
|
||||
/// 启动 iced(阻塞到退出)。`--diff` 不走这里,见 `main.rs`。
|
||||
pub(crate) fn run(options: Options) -> iced::Result {
|
||||
let _ = STARTED.set(Instant::now());
|
||||
let boot_started = Instant::now();
|
||||
|
||||
// 贵的诊断(适配器枚举 / `defaults` / `reg.exe`)只在验收模式下跑;便宜的行恒开。
|
||||
if acceptance::enabled() {
|
||||
acceptance::announce();
|
||||
}
|
||||
|
||||
// ① 探测(判据取在哪、日志在 `report_boot` 里统一打)
|
||||
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);
|
||||
}
|
||||
diagnostics::report_boot(&options, &boot.probe, &decision);
|
||||
|
||||
// ② 字体(`fonts` 自己打两行既有日志;新加的那行在 `report_font` 里)
|
||||
let font_started = Instant::now();
|
||||
let resolved = fonts::resolve();
|
||||
let font_ms = font_started.elapsed();
|
||||
@@ -61,35 +54,24 @@ pub(crate) fn run(options: Options) -> iced::Result {
|
||||
"[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 文档)。
|
||||
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(),
|
||||
diagnostics::report_theme(&options, flavor);
|
||||
diagnostics::report_font(
|
||||
resolved.platform,
|
||||
boot.probe_ms,
|
||||
font_ms,
|
||||
boot_started.elapsed(),
|
||||
);
|
||||
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 上了。
|
||||
// ④ 起 iced(失败按判据表第 8/9 行分流;具名原因在分流之前就打到同一个 stderr 上了)
|
||||
let launch_started = Instant::now();
|
||||
let result = match launch(&options, families, flavor, decision.primary) {
|
||||
Err(iced::Error::GraphicsCreationFailed(error)) => {
|
||||
@@ -119,141 +101,29 @@ pub(crate) fn run(options: Options) -> iced::Result {
|
||||
};
|
||||
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(),
|
||||
);
|
||||
}
|
||||
diagnostics::report_outcome(
|
||||
&decision,
|
||||
result.as_ref().err().map(|error| {
|
||||
renderer::named_graphics_error(error).unwrap_or_else(|| error.to_string())
|
||||
}),
|
||||
acceptance::failure_via(decision.fallback.is_some(), options.screenshot.is_some()),
|
||||
if options.screenshot.is_some() {
|
||||
"screenshot"
|
||||
} else {
|
||||
"interactive"
|
||||
},
|
||||
launch_elapsed,
|
||||
);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// 起步后端失败后的去向(判据表第 8 行 vs 第 9 行——**两条路策略不同,别合成一条**)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum OnFailure {
|
||||
/// 交互启动:安全网优先,换进程用这个后端重开。
|
||||
Respawn(Kind),
|
||||
/// 取证启动:红着退出,**不出图**。
|
||||
Refuse,
|
||||
}
|
||||
|
||||
/// 纯判据:起步后端失败时走哪条路。
|
||||
///
|
||||
/// `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 {
|
||||
Some(_) => OnFailure::Refuse,
|
||||
None => OnFailure::Respawn(fallback),
|
||||
})
|
||||
}
|
||||
|
||||
/// 降级重开要传给子进程的环境变量(**两条缺一不可**)。
|
||||
///
|
||||
/// ① `ICED_BACKEND`:让子进程直接用 `fallback` 跑。
|
||||
/// ② [`renderer::FALLBACK_ENV`]:**防无限重开的闸门**——子进程见它就必须跳过探测
|
||||
/// (`boot_probe` 的第一个分支),否则会算出同一个结论、再失败、再 respawn。
|
||||
///
|
||||
/// 抽成纯函数是为了让「漏传其中之一」变成**测试能抓到**的错误:两条都是静默失效型
|
||||
/// (漏 ① 重开又跑一遍 wgpu、漏 ② 无限重开),线上极难看出来。
|
||||
fn fallback_env(fallback: Kind) -> [(&'static str, &'static str); 2] {
|
||||
[
|
||||
("ICED_BACKEND", fallback.env_value()),
|
||||
(renderer::FALLBACK_ENV, fallback.env_value()),
|
||||
]
|
||||
}
|
||||
|
||||
/// 安全网:起一个**新进程**用 `fallback` 后端重开,然后带着它的退出码退出。
|
||||
///
|
||||
/// 只在**交互启动**时走到这里(取证启动宁可红着退出也不换后端出图);子进程看到
|
||||
/// [`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));
|
||||
});
|
||||
eprintln!(
|
||||
"[gpu] 降级 → {}({} 已失败,原因见上两行;换一个进程重开:winit 不允许同进程建两次事件循环)。\
|
||||
下一次的日志从 `[probe] 本次是降级重开` 开始。",
|
||||
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 把失败当成功。
|
||||
let code = match status {
|
||||
Ok(status) => exit_code(status.code()),
|
||||
Err(err) => {
|
||||
eprintln!("[gpu] 降级重开失败:{err}");
|
||||
exit_code(None)
|
||||
}
|
||||
};
|
||||
std::process::exit(code)
|
||||
}
|
||||
|
||||
/// 子进程退出码 → 本进程退出码。
|
||||
///
|
||||
/// `None` = 子进程被信号杀死(只有 Unix):走兜底码而**不是** panic、也不是 0——上层判据是
|
||||
/// 「非零 = 失败」,「拿不到退出码」和「退出码是某个非零值」对脚本是一回事。
|
||||
fn exit_code(code: Option<i32>) -> i32 {
|
||||
/// 重开失败 / 拿不到退出码时的兜底码(与 `main` 的「参数或运行错误」一致)。
|
||||
const RESPAWN_FAILED: i32 = 2;
|
||||
code.unwrap_or(RESPAWN_FAILED)
|
||||
}
|
||||
|
||||
/// 构造降级重开的子进程命令(**可测**:program / argv / env 都能在测试里断言)。
|
||||
///
|
||||
/// `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>,
|
||||
fallback: Kind,
|
||||
) -> std::process::Command {
|
||||
let mut command = std::process::Command::new(program);
|
||||
command.args(args).envs(fallback_env(fallback));
|
||||
command
|
||||
.stdin(std::process::Stdio::inherit())
|
||||
.stdout(std::process::Stdio::inherit())
|
||||
.stderr(std::process::Stdio::inherit());
|
||||
command
|
||||
}
|
||||
|
||||
/// 按一次后端启动 iced。`backend` 决定这次写进 `ICED_BACKEND` 的唯一一项。
|
||||
fn launch(
|
||||
options: &Options,
|
||||
families: Families,
|
||||
flavor: SchemeFlavor,
|
||||
backend: Kind,
|
||||
backend: renderer::Kind,
|
||||
) -> iced::Result {
|
||||
std::env::set_var("ICED_BACKEND", backend.env_value());
|
||||
|
||||
@@ -265,34 +135,9 @@ fn launch(
|
||||
(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 settings = diagnostics::window_settings(size);
|
||||
// 两行日志与开窗用的是**同一个** settings(见模块文档纪律 2)
|
||||
diagnostics::report_window(&settings, options.scale_factor, size_source);
|
||||
|
||||
let application = iced::application(
|
||||
move || {
|
||||
@@ -316,14 +161,14 @@ fn launch(
|
||||
}
|
||||
}
|
||||
|
||||
/// [`boot_probe`] 的产物:判据 + 探测到的适配器 + 探测耗时。
|
||||
/// [`boot_probe`] 的产物:判据 + 探测到的适配器 + **探测耗时(没测就是 `None`)**。
|
||||
///
|
||||
/// 为什么把 `Probe` 带出来:`[env]` 的 `gpu=` / `hardware=` 必须是**这一次真枚举到的**结果
|
||||
/// (再枚举一遍多花 0.65~1.2 s,还可能给出不同状态)。判据与诊断看同一份数据。
|
||||
struct Boot {
|
||||
decision: Decision,
|
||||
probe: probe::Probe,
|
||||
probe_ms: Duration,
|
||||
probe_ms: Option<Duration>,
|
||||
}
|
||||
|
||||
/// 渲染后端探测 + 写 `ICED_BACKEND`,返回判据产物(供 `run` 决定要不要换进程重开)。
|
||||
@@ -340,44 +185,47 @@ fn boot_probe(options: &Options) -> Boot {
|
||||
kind.env_value()
|
||||
);
|
||||
eprintln!("[probe] {}", decision.describe());
|
||||
// 验收要 `[env]` 里的真实适配器:这一轮也枚举(否则日志会写 `adapters=0`,与事实相反
|
||||
// ——GPU 刚刚才被枚举到、只是起不来)。只有验收开关打开时才付这 0.65~1.2 s。
|
||||
let probe = if acceptance::enabled() {
|
||||
probe::Probe::run()
|
||||
// 验收要 `[env]` 里的真实适配器:这一轮**也枚举一次,并如实计时**(没计时就 `None`
|
||||
// ⇒ 日志写 `probe=skipped`,不写 0ms)。
|
||||
let (probe, probe_ms) = if acceptance::enabled() {
|
||||
let started = Instant::now();
|
||||
let probe = probe::Probe::run();
|
||||
(probe, Some(started.elapsed()))
|
||||
} else {
|
||||
probe::Probe::default()
|
||||
(probe::Probe::default(), None)
|
||||
};
|
||||
return Boot {
|
||||
decision,
|
||||
probe,
|
||||
probe_ms: Duration::ZERO,
|
||||
probe_ms,
|
||||
};
|
||||
}
|
||||
|
||||
let probe_started = Instant::now();
|
||||
// 只有 `auto` 需要探测(`cpu`/`gpu` 的决策与硬件无关,枚举 13 个适配器本机要 0.65~1.2 s)。
|
||||
// 验收模式例外:`[env]` 那行要 GPU 名,不管请求是哪个后端。
|
||||
let needs_probe = options.backend == crate::Backend::Auto || acceptance::enabled();
|
||||
let probe_started = Instant::now();
|
||||
let probe = if needs_probe {
|
||||
probe::Probe::run()
|
||||
} else {
|
||||
probe::Probe::default()
|
||||
};
|
||||
let probe_ms = probe_started.elapsed();
|
||||
let probe_ms = needs_probe.then(|| 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] 探测耗时 {ms:.0} ms(wgpu 实例 + 全后端适配器枚举)");
|
||||
} else {
|
||||
eprintln!(
|
||||
"[probe] 探测耗时 {ms:.0} ms(--renderer={} 与硬件无关,跳过枚举)",
|
||||
match probe_ms {
|
||||
Some(elapsed) => eprintln!(
|
||||
"[probe] 探测耗时 {:.0} ms(wgpu 实例 + 全后端适配器枚举)",
|
||||
elapsed.as_secs_f64() * 1000.0
|
||||
),
|
||||
None => eprintln!(
|
||||
"[probe] 探测耗时 跳过(--renderer={} 与硬件无关,未枚举)",
|
||||
options.backend.name()
|
||||
);
|
||||
),
|
||||
}
|
||||
}
|
||||
Boot {
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
//! 启动阶段的**验收诊断编排**:把 `app/boot.rs` 的 `run()` 收成「探测 → 字体 → 打印 → 起 iced」。
|
||||
//!
|
||||
//! ## 为什么单独一个文件(M98 r1 p2-1 的根因)
|
||||
//!
|
||||
//! 第一版把 `[window] requested … resizable=true decorations=false transparent=false` 直接写在
|
||||
//! 格式化串里,而 `boot.rs` 给窗口的 `settings` 是另一处常量 —— 两者**没有任何绑定**。定向变异
|
||||
//! 把 `settings.decorations` 改成 `true`,371 个测试全绿,而验收包里 `[caps] decorations=true` 与
|
||||
//! `[window] decorations=false` 会互相矛盾(踩坑记录 U1:常量冒充观测结果)。
|
||||
//!
|
||||
//! 现在:窗口设置由 [`window_settings`] **一处**产生,日志由 [`report_window`] 从**同一个
|
||||
//! `&window::Settings`** 取字段。钉死它的守门测试在 `boot/tests.rs`(`window_settings_*`)。
|
||||
//!
|
||||
//! 另一件事:所有「跑没跑」都要能如实说。降级重开那一轮没计时就写 `probe=skipped`,
|
||||
//! 不许拿 `0ms` 冒充一次实测(M98 r1 p3-1)。
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use iced::window;
|
||||
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_design::typography::Platform as FontPlatform;
|
||||
|
||||
use crate::cli::Options;
|
||||
use crate::probe::Probe;
|
||||
use crate::renderer::{acceptance, Decision};
|
||||
|
||||
use super::MIN_SIZE;
|
||||
use crate::app::APP_TITLE;
|
||||
|
||||
/// 产品窗口设置(**唯一产地**:`launch()` 用它开窗,`[window]` / `[caps]` 从它取字段)。
|
||||
///
|
||||
/// `ULL_WINDOW_SIZE` 的覆盖在 `boot.rs` 解析成 `size` 后从这里进来 —— 日志与真窗口永远同源。
|
||||
pub(super) fn window_settings(size: (f32, f32)) -> window::Settings {
|
||||
window::Settings {
|
||||
size: iced::Size::new(size.0, size.1),
|
||||
min_size: Some(MIN_SIZE),
|
||||
// 自绘三键(M93):窗口**不许**带系统装饰,否则 macOS 上会出现原生标题栏 + 自绘三键两套。
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
/// `[window] requested` + `[caps]` 两行,**都从同一个 `settings` 取**(不给手抄字面量的机会)。
|
||||
pub(super) fn report_window(settings: &window::Settings, scale: Option<f32>, size_source: &str) {
|
||||
let facts = acceptance::WindowFacts::from_settings(settings);
|
||||
acceptance::report_window(&facts, scale, size_source, Some(APP_TITLE));
|
||||
acceptance::report_caps(
|
||||
std::env::current_exe().ok().as_deref(),
|
||||
facts.transparent,
|
||||
facts.resizable,
|
||||
facts.decorations,
|
||||
);
|
||||
}
|
||||
|
||||
/// 第一段:`[acceptance] on` + `[backend] plan` +(验收模式)`[env]` / `[i18n]`。
|
||||
pub(super) fn report_boot(options: &Options, probe: &Probe, decision: &Decision) {
|
||||
if acceptance::enabled() {
|
||||
acceptance::announce();
|
||||
}
|
||||
acceptance::report_backend_plan(decision, options.backend);
|
||||
if acceptance::enabled() {
|
||||
acceptance::report_startup(probe, options.scale_factor);
|
||||
}
|
||||
}
|
||||
|
||||
/// 第二段:`[font] chain` + `[perf] boot`(都要 `ULL_ACCEPTANCE=1`)。
|
||||
///
|
||||
/// `probe = None` = 这一轮**没有探测**(既不是 `auto` 也没开验收)⇒ 打 `probe=skipped`。
|
||||
pub(super) fn report_font(
|
||||
platform: FontPlatform,
|
||||
probe: Option<Duration>,
|
||||
font: Duration,
|
||||
to_run: Duration,
|
||||
) {
|
||||
if acceptance::enabled() {
|
||||
acceptance::report_font_chain(platform);
|
||||
acceptance::report_perf_boot(probe, font, to_run);
|
||||
}
|
||||
}
|
||||
|
||||
/// 第三段:`[theme]`(恒开:它解释的是「这次主题凭什么是这个」)。
|
||||
pub(super) fn report_theme(options: &Options, flavor: SchemeFlavor) {
|
||||
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);
|
||||
}
|
||||
|
||||
/// 第四段:`[backend] final`(恒开)+ `[perf] session`(验收模式)。
|
||||
///
|
||||
/// `error` = 具名失败原因(`None` = 起来了);`via` 见 `renderer::acceptance::failure_via`。
|
||||
/// `refuse` / `none` 两条路**什么都没跑起来** ⇒ `final=none`;只有 `respawn` 才有「换到哪个
|
||||
/// 后端」可言(那一格是兜底名,子进程自己还会再打一遍自己的 plan/final)。
|
||||
pub(super) fn report_outcome(
|
||||
decision: &Decision,
|
||||
error: Option<String>,
|
||||
via: &str,
|
||||
mode: &str,
|
||||
elapsed: Duration,
|
||||
) {
|
||||
acceptance::report_outcome(decision, error.as_deref(), via, mode, elapsed);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
//! 起步后端失败后的**兜底/重开**一簇:判据、进程环境交接、`respawn`、退出码透传。
|
||||
//!
|
||||
//! 从 `app/boot.rs` 拆出来的理由:这一簇**与启动编排零耦合**,但已有 5 条测试,而且它是
|
||||
//! M97 判据表第 8/9 行的落点。`boot.rs` 那边只负责「什么时候调它」。
|
||||
//!
|
||||
//! ## 为什么必须换进程(别改回进程内重试)
|
||||
//!
|
||||
//! Windows 实测本进程里再 `run()` 一次直接 panic
|
||||
//! (`iced_winit-0.14.1/src/lib.rs:81` → `Create event loop: RecreationAttempt`),winit 一个进程
|
||||
//! 只能建一次事件循环。
|
||||
|
||||
use crate::renderer::Decision;
|
||||
use crate::renderer::{self, acceptance, Kind};
|
||||
|
||||
/// 起步后端失败后的去向(判据表第 8 行 vs 第 9 行——**两条路策略不同,别合成一条**)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum OnFailure {
|
||||
/// 交互启动:安全网优先,换进程用这个后端重开。
|
||||
Respawn(Kind),
|
||||
/// 取证启动:红着退出,**不出图**。
|
||||
Refuse,
|
||||
}
|
||||
|
||||
/// 纯判据:起步后端失败时走哪条路。
|
||||
///
|
||||
/// `evidence` 非空 = 本次是取证启动(带 `--screenshot`);返回 `None` = 没有兜底可用
|
||||
/// (`--renderer=cpu` / 未编 wgpu),原样把错误交回去。抽成纯函数只为一个理由:这条分支以前
|
||||
/// **没有任何测试**,`respawn` / `Refuse` 两条路可以被静默改反(M97 r1 p2-1 就是这么测出来的)。
|
||||
pub(super) fn on_failure(
|
||||
decision: Decision,
|
||||
evidence: Option<&std::path::PathBuf>,
|
||||
) -> Option<OnFailure> {
|
||||
let fallback = decision.fallback?;
|
||||
Some(match evidence {
|
||||
Some(_) => OnFailure::Refuse,
|
||||
None => OnFailure::Respawn(fallback),
|
||||
})
|
||||
}
|
||||
|
||||
/// 降级重开要传给子进程的环境变量(**两条缺一不可**)。
|
||||
///
|
||||
/// ① `ICED_BACKEND`:让子进程直接用 `fallback` 跑。
|
||||
/// ② [`renderer::FALLBACK_ENV`]:**防无限重开的闸门**——子进程见它就必须跳过探测
|
||||
/// (`boot_probe` 的第一个分支),否则会算出同一个结论、再失败、再 respawn。
|
||||
///
|
||||
/// 抽成纯函数是为了让「漏传其中之一」变成**测试能抓到**的错误:两条都是静默失效型
|
||||
/// (漏 ① 重开又跑一遍 wgpu、漏 ② 无限重开),线上极难看出来。
|
||||
pub(super) fn fallback_env(fallback: Kind) -> [(&'static str, &'static str); 2] {
|
||||
[
|
||||
("ICED_BACKEND", fallback.env_value()),
|
||||
(renderer::FALLBACK_ENV, fallback.env_value()),
|
||||
]
|
||||
}
|
||||
|
||||
/// 安全网:起一个**新进程**用 `fallback` 后端重开,然后带着它的退出码退出。
|
||||
///
|
||||
/// 只在**交互启动**时走到这里(取证启动宁可红着退出也不换后端出图);子进程看到
|
||||
/// [`renderer::FALLBACK_ENV`] 必须跳过探测,否则无限递归重开(闸门在 `boot_probe` 入口)。
|
||||
pub(super) 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));
|
||||
});
|
||||
eprintln!(
|
||||
"[gpu] 降级 → {}({} 已失败,原因见上两行;换一个进程重开:winit 不允许同进程建两次事件循环)。\
|
||||
下一次的日志从 `[probe] 本次是降级重开` 开始。",
|
||||
fallback.name(),
|
||||
failed.name()
|
||||
);
|
||||
// `[backend] final` 一行把「哪个后端失败了、去了哪、为什么」收在一起(可 grep 的单一结论行)。
|
||||
// 这里 `final=` 装的是**兜底**(进程即将带着它重开);`via=respawn` 说明它是换进程去的。
|
||||
acceptance::report_backend_failed(failed, Some(fallback), Some(fallback), "respawn", error);
|
||||
|
||||
let status = fallback_command(&exe, std::env::args_os().skip(1), fallback).status();
|
||||
|
||||
// 退出码**透传**子进程的:上层脚本/CI 靠它判断成败。写错的失效方式是「安静地给出一个错
|
||||
// 结论」——不崩、不静默降级、不无限重开,只是让 CI 把失败当成功。
|
||||
let code = match status {
|
||||
Ok(status) => exit_code(status.code()),
|
||||
Err(err) => {
|
||||
eprintln!("[gpu] 降级重开失败:{err}");
|
||||
exit_code(None)
|
||||
}
|
||||
};
|
||||
std::process::exit(code)
|
||||
}
|
||||
|
||||
/// 子进程退出码 → 本进程退出码。
|
||||
///
|
||||
/// `None` = 子进程被信号杀死(只有 Unix):走兜底码而**不是** panic、也不是 0——上层判据是
|
||||
/// 「非零 = 失败」,「拿不到退出码」和「退出码是某个非零值」对脚本是一回事。
|
||||
pub(super) fn exit_code(code: Option<i32>) -> i32 {
|
||||
/// 重开失败 / 拿不到退出码时的兜底码(与 `main` 的「参数或运行错误」一致)。
|
||||
const RESPAWN_FAILED: i32 = 2;
|
||||
code.unwrap_or(RESPAWN_FAILED)
|
||||
}
|
||||
|
||||
/// 构造降级重开的子进程命令(**可测**:program / argv / env 都能在测试里断言)。
|
||||
///
|
||||
/// `program` / `args` 是参数而不是在函数里读 `std::env`,唯一目的是让测试能喂假命令
|
||||
/// (`cmd /c exit 7` / `sh -c "exit 7"`)来验退出码透传——真用 `current_exe()` 就没法测。
|
||||
///
|
||||
/// 三路 stdio **显式**继承:子进程日志必须和父进程同一路输出,否则验收只抓到父进程那两行具名
|
||||
/// 错误。`Command` 没有 stdio 的 getter(stable 没有),所以这条只能靠真跑一次证明。
|
||||
pub(super) fn fallback_command(
|
||||
program: &std::path::Path,
|
||||
args: impl IntoIterator<Item = std::ffi::OsString>,
|
||||
fallback: Kind,
|
||||
) -> std::process::Command {
|
||||
let mut command = std::process::Command::new(program);
|
||||
command.args(args).envs(fallback_env(fallback));
|
||||
command
|
||||
.stdin(std::process::Stdio::inherit())
|
||||
.stdout(std::process::Stdio::inherit())
|
||||
.stderr(std::process::Stdio::inherit());
|
||||
command
|
||||
}
|
||||
@@ -18,9 +18,12 @@
|
||||
//! 把断言一起改了。这类假绿最阴:变异验证本来是我们判断「测试够不够」的终极手段,
|
||||
//! 手段本身假绿时,它还会附赠「已验证」的强信心。
|
||||
|
||||
use super::fallback::*;
|
||||
use super::*;
|
||||
|
||||
use crate::renderer::acceptance::WindowFacts;
|
||||
use crate::renderer::decision;
|
||||
use crate::renderer::Kind;
|
||||
use std::ffi::{OsStr, OsString};
|
||||
use std::path::Path;
|
||||
|
||||
@@ -185,3 +188,84 @@ fn no_fallback_means_no_respawn() {
|
||||
let not_compiled = decision::decide(crate::Backend::Auto, &probe("IntegratedGpu"), false);
|
||||
assert_eq!(on_failure(not_compiled, None), None);
|
||||
}
|
||||
|
||||
/// **窗口设置就是验收日志里那三个字段的来源**(M98 r1 p2-1 的守门)。
|
||||
///
|
||||
/// 抓得住:把 `decorations` 改回 `true`(自绘三键退回系统装饰)、把 `resizable` / `transparent`
|
||||
/// 打开 —— 这三种改动都会让 `[window]` 与 `[caps]` 两行与代码自相矛盾,而在此之前**371 个测试
|
||||
/// 全绿**(复审 M6 变异实证)。现在它们必须红在这里。
|
||||
/// 抓不住:winit 是否真按这些设置建窗(那要真机看窗口,见判据表「谁能验证」列)。
|
||||
#[test]
|
||||
fn window_settings_match_what_the_acceptance_logs_claim() {
|
||||
let settings = diagnostics::window_settings((1180.0, 760.0));
|
||||
|
||||
assert!(
|
||||
!settings.decorations,
|
||||
"M93 自绘三键:窗口不能带系统装饰(否则 macOS 上会出现两套标题栏)"
|
||||
);
|
||||
assert!(
|
||||
settings.resizable,
|
||||
"验收要验「能否缩放」,窗口必须声明可缩放"
|
||||
);
|
||||
assert!(
|
||||
!settings.transparent,
|
||||
"整窗透明只在 win32 拿到句柄后下发(D36),窗口创建侧不许打开它"
|
||||
);
|
||||
assert_eq!(
|
||||
settings.min_size,
|
||||
Some(MIN_SIZE),
|
||||
"最小尺寸档(880×560)是矩阵的一格,不能被改动"
|
||||
);
|
||||
|
||||
// 日志确实从上面这个 settings 取值(而不是另抄一份字面量)
|
||||
let facts = WindowFacts::from_settings(&settings);
|
||||
let line = crate::renderer::acceptance::window_text(
|
||||
&facts,
|
||||
None,
|
||||
"default",
|
||||
Some(super::super::APP_TITLE),
|
||||
);
|
||||
assert!(line.contains("resizable=true"), "{line}");
|
||||
assert!(line.contains("decorations=false"), "{line}");
|
||||
assert!(line.contains("transparent=false"), "{line}");
|
||||
assert!(line.contains("size=1180x760"), "{line}");
|
||||
assert!(line.contains("min=880x560"), "{line}");
|
||||
|
||||
// 抽字段这一步必须**忠实**:拿一份处处相反的 settings 进来,facts 必须跟着反
|
||||
// (抓的是「from_settings 里又写死一份常量」这条回退路 —— M98 r1 M7 变异实证它当年漏抓)
|
||||
let odd = iced::window::Settings {
|
||||
size: iced::Size::new(640.0, 480.0),
|
||||
min_size: None,
|
||||
decorations: true,
|
||||
resizable: false,
|
||||
transparent: true,
|
||||
..iced::window::Settings::default()
|
||||
};
|
||||
assert_eq!(
|
||||
WindowFacts::from_settings(&odd),
|
||||
WindowFacts {
|
||||
size: (640.0, 480.0),
|
||||
min: None,
|
||||
resizable: false,
|
||||
decorations: true,
|
||||
transparent: true,
|
||||
},
|
||||
"from_settings 必须逐字段搬运,不能在内部再写死一份常量"
|
||||
);
|
||||
|
||||
// 反向:把事实翻个身,日志必须跟着变(证明没有第二份真值)
|
||||
let flipped = WindowFacts {
|
||||
decorations: true,
|
||||
..facts
|
||||
};
|
||||
let line = crate::renderer::acceptance::window_text(
|
||||
&flipped,
|
||||
None,
|
||||
"default",
|
||||
Some(super::super::APP_TITLE),
|
||||
);
|
||||
assert!(
|
||||
line.contains("decorations=true"),
|
||||
"日志必须跟着 settings 走:{line}"
|
||||
);
|
||||
}
|
||||
@@ -203,18 +203,25 @@ pub fn run(request: Request) -> Result<(), String> {
|
||||
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());
|
||||
let families = resolved.families();
|
||||
|
||||
// 这一份就是喂给 iced 的真值:`[window]` 那行从它取字段(M98 r1 p2-1 的同一个教训)
|
||||
let settings = window::Settings {
|
||||
size: iced::Size::new(request.width, request.height),
|
||||
decorations: false,
|
||||
resizable: true,
|
||||
..window::Settings::default()
|
||||
};
|
||||
renderer::acceptance::report_window(
|
||||
&renderer::acceptance::WindowFacts::from_settings(&settings),
|
||||
request.scale_factor,
|
||||
"pages_demo",
|
||||
None,
|
||||
);
|
||||
|
||||
let application = |backend: renderer::Kind| {
|
||||
let boot = request.clone();
|
||||
iced::application(
|
||||
@@ -242,12 +249,7 @@ pub fn run(request: Request) -> Result<(), String> {
|
||||
family: iced::font::Family::Name(families.text),
|
||||
..iced::Font::DEFAULT
|
||||
})
|
||||
.window(window::Settings {
|
||||
size: iced::Size::new(request.width, request.height),
|
||||
decorations: false,
|
||||
resizable: true,
|
||||
..window::Settings::default()
|
||||
})
|
||||
.window(settings)
|
||||
};
|
||||
|
||||
eprintln!(
|
||||
@@ -280,10 +282,11 @@ pub fn run(request: Request) -> Result<(), String> {
|
||||
None => format!("iced 启动失败:{error}"),
|
||||
});
|
||||
|
||||
// 取证通道恒为 evidence 启动 ⇒ 有兜底时 via=refuse、没兜底时 none(不再写死,M98 r1 p3-4)
|
||||
renderer::acceptance::report_outcome(
|
||||
&decision,
|
||||
outcome.as_ref().err().map(String::as_str),
|
||||
"refuse",
|
||||
renderer::acceptance::failure_via(decision.fallback.is_some(), true),
|
||||
"screenshot",
|
||||
session,
|
||||
);
|
||||
|
||||
@@ -36,23 +36,35 @@ pub fn env_line(probe: &Probe, language: &str, scale: Option<f32>, tz: Option<&s
|
||||
)
|
||||
}
|
||||
|
||||
/// 枚举结果里**最该被当成 GPU** 的那个适配器的摘要(硬件优先,其次任何适配器)。
|
||||
/// 枚举结果里**最该被当成 GPU** 的那个适配器的摘要:`DiscreteGpu > IntegratedGpu > 其它`。
|
||||
///
|
||||
/// 与 `Probe::describe` 的分工:那个给全量清单(日志一行),这个给「一句话结论」(报告表格用)。
|
||||
/// 判据同源:`device_type` 不是 `Cpu` / `Other` 才算硬件(WARP 排在第一是本机实测的形状)。
|
||||
/// 为什么不是「第一个非 Cpu/Other」(M98 r1 p3-2):本机枚举顺序是核显在前,于是 `[env] gpu=`
|
||||
/// 报的是 `AMD Radeon(TM) Graphics [IntegratedGpu]`,而 iced 实际选的是
|
||||
/// `AMD Radeon RX 6600 XT [DiscreteGpu]`。一行「机器有什么显卡」的日志报错型号,比不报更坏。
|
||||
/// 判据仍然只管**型号名称**:真正的「iced 选了谁」只有 `[gpu] …Selected: AdapterInfo` 那行有。
|
||||
#[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())
|
||||
.max_by_key(|adapter| gpu_rank(adapter.device_type))
|
||||
.map_or_else(
|
||||
|| "<无适配器>".to_owned(),
|
||||
|adapter| format!("{} [{}]", adapter.name, adapter.device_type),
|
||||
)
|
||||
}
|
||||
|
||||
/// 适配器的「该不该被当成这台机器的显卡」排序权重(越大越该)。
|
||||
fn gpu_rank(device_type: &str) -> u8 {
|
||||
match device_type {
|
||||
"DiscreteGpu" => 3,
|
||||
"IntegratedGpu" => 2,
|
||||
"VirtualGpu" => 1,
|
||||
"Cpu" | "Other" => 0,
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// 时区(`TZ` 环境变量)。`std` 拿不到系统时区,所以这里**只报环境变量**,没有就写未设置。
|
||||
///
|
||||
/// macOS 上的真值时区由脚本用 `date +%Z%z` 采(那份是观测值,这个只是进程视角)。
|
||||
@@ -129,7 +141,9 @@ impl Chain {
|
||||
pub fn line(&self) -> String {
|
||||
format!(
|
||||
"[i18n] setting={} platform={}:{} env={}:{} -> {} ui_text=pinned-en",
|
||||
show(self.setting.as_deref()),
|
||||
// `<未接线>` 而不是 `<未设置>`:配置侧的读取点**还不存在**(M98 r1 p3-3)。
|
||||
// 写「未设置」会让读的人以为用户没配过;真实情况是我们没读。
|
||||
self.setting.as_deref().unwrap_or("<未接线>"),
|
||||
self.platform.0,
|
||||
show(self.platform.1.as_deref()),
|
||||
self.env.0,
|
||||
|
||||
@@ -33,20 +33,18 @@ use crate::{Backend, Probe};
|
||||
|
||||
pub mod locale;
|
||||
pub mod visual;
|
||||
pub mod window;
|
||||
|
||||
pub use locale::{env_line, tz, Chain};
|
||||
pub use visual::{caps_line, font_chain_line, report_caps, report_font_chain};
|
||||
pub use window::{
|
||||
parse_window_size, report_window, resolve_window_size, window_size, window_text, WindowFacts,
|
||||
WINDOW_SIZE_ENV,
|
||||
};
|
||||
|
||||
/// 打开「贵的诊断」的开关(见模块文档 §开关)。
|
||||
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 {
|
||||
@@ -114,97 +112,6 @@ pub fn theme_text(source: &str, flavor: Option<&str>, follows_system: bool) -> S
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- [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 的候选回退)。
|
||||
@@ -249,8 +156,21 @@ pub fn backend_started_text(primary: Kind, elapsed: Duration) -> String {
|
||||
/// - `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));
|
||||
///
|
||||
/// `final` 是**终态**:`refuse` / `none` 两条路什么都没跑起来 ⇒ `final=none`(不是兜底名)。
|
||||
/// 第一版让 `final=` 与 `fallback=` 装同一个值,于是 `[backend] final=tiny-skia result=failed`
|
||||
/// 会被 REPORT 的「后端实际选中」照抄成「这次跑的是 tiny-skia」——报告说谎(M98 r1 p3-4)。
|
||||
pub fn report_backend_failed(
|
||||
primary: Kind,
|
||||
fallback: Option<Kind>,
|
||||
final_kind: Option<Kind>,
|
||||
via: &str,
|
||||
error: &str,
|
||||
) {
|
||||
eprintln!(
|
||||
"{}",
|
||||
backend_failed_text(primary, fallback, final_kind, via, error)
|
||||
);
|
||||
}
|
||||
|
||||
/// 见 [`report_backend_failed`]。
|
||||
@@ -258,21 +178,26 @@ pub fn report_backend_failed(primary: Kind, fallback: Option<Kind>, via: &str, e
|
||||
pub fn backend_failed_text(
|
||||
primary: Kind,
|
||||
fallback: Option<Kind>,
|
||||
final_kind: Option<Kind>,
|
||||
via: &str,
|
||||
error: &str,
|
||||
) -> String {
|
||||
let final_name = fallback.map_or("none", Kind::name);
|
||||
let fallback_name = fallback.map_or("none", Kind::name);
|
||||
let final_name = final_kind.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}\"",
|
||||
"[backend] final={final_name} result=failed primary={} fallback={fallback_name} \
|
||||
via={via} error=\"{error}\"",
|
||||
primary.name(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 起步失败后去了哪(日志字段 `via=`;与 `app::boot::OnFailure` 同一套取值)。
|
||||
/// 起步失败后去了哪(日志字段 `via=`;与 `app::boot::fallback::OnFailure` 同一套取值)。
|
||||
///
|
||||
/// 放在这里而不是 `app/boot/fallback.rs`:`mod boot` 在 `app/mod.rs` 里是私有的(那份文件不在
|
||||
/// M98 的 scope),`pages::harness` 够不到它,而两个调用点必须用**同一个**判据。
|
||||
#[must_use]
|
||||
pub fn failure_via(has_fallback: bool, evidence: bool) -> &'static str {
|
||||
match (has_fallback, evidence) {
|
||||
@@ -283,6 +208,9 @@ pub fn failure_via(has_fallback: bool, evidence: bool) -> &'static str {
|
||||
}
|
||||
|
||||
/// 起步结果 → `[backend] final` 一行 +(验收模式)`[perf] session` 一行。
|
||||
///
|
||||
/// `final_kind` 的口径:`respawn` 时是**兜底**(进程即将带着它重开),`refuse` / `none` 时是
|
||||
/// `None`(什么都没跑起来)——见 [`backend_failed_text`]。成功时恒为 `primary`。
|
||||
pub fn report_outcome(
|
||||
decision: &Decision,
|
||||
error: Option<&str>,
|
||||
@@ -290,28 +218,42 @@ pub fn report_outcome(
|
||||
mode: &str,
|
||||
elapsed: Duration,
|
||||
) {
|
||||
let final_kind = match via {
|
||||
"respawn" => decision.fallback,
|
||||
_ => None,
|
||||
};
|
||||
match error {
|
||||
None => report_backend_started(decision.primary, elapsed),
|
||||
Some(message) => report_backend_failed(decision.primary, decision.fallback, via, message),
|
||||
Some(message) => report_backend_failed(
|
||||
decision.primary,
|
||||
decision.fallback,
|
||||
final_kind,
|
||||
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) {
|
||||
///
|
||||
/// `probe = None` ⇒ 打 `probe=skipped`。**不许拿 `0ms` 冒充一次实测**(M98 r1 p3-1:降级重开那一轮
|
||||
/// 没计时却报了 0,而报告说「[perf] 全是 Instant 实测」)。
|
||||
pub fn report_perf_boot(probe: Option<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 {
|
||||
pub fn perf_boot_text(probe: Option<Duration>, font: Duration, to_run: Duration) -> String {
|
||||
format!(
|
||||
"[perf] boot probe={}ms font={}ms to_run={}ms",
|
||||
probe.as_millis(),
|
||||
"[perf] boot probe={} font={}ms to_run={}ms",
|
||||
probe.map_or_else(
|
||||
|| "skipped".to_owned(),
|
||||
|value| format!("{}ms", value.as_millis())
|
||||
),
|
||||
font.as_millis(),
|
||||
to_run.as_millis()
|
||||
)
|
||||
|
||||
@@ -31,25 +31,33 @@ fn probe_of(adapters: &[(&str, &'static str)]) -> Probe {
|
||||
/// 抓不住:窗口真的开成了多大(由验收脚本从 `[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"),
|
||||
);
|
||||
let facts = WindowFacts {
|
||||
size: (1180.0, 760.0),
|
||||
min: Some((880.0, 560.0)),
|
||||
resizable: true,
|
||||
decorations: false,
|
||||
transparent: false,
|
||||
};
|
||||
let line = window_text(&facts, None, "default", Some("Unlucky Launcher"));
|
||||
assert!(
|
||||
line.starts_with("[window] requested size=1180x760"),
|
||||
"{line}"
|
||||
);
|
||||
assert!(line.contains("min=880x560"), "{line}");
|
||||
assert!(line.contains("resizable=true"), "{line}");
|
||||
assert!(line.contains("decorations=false"), "{line}");
|
||||
assert!(line.contains("transparent=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);
|
||||
let harness = window_text(
|
||||
&WindowFacts { min: None, ..facts },
|
||||
Some(1.0),
|
||||
"pages_demo",
|
||||
None,
|
||||
);
|
||||
assert!(harness.contains("min=none"), "{harness}");
|
||||
assert!(harness.contains("title=<默认>"), "{harness}");
|
||||
assert!(harness.contains("scale_requested=1"), "{harness}");
|
||||
@@ -177,16 +185,16 @@ fn backend_final_lines_separate_started_from_failed() {
|
||||
);
|
||||
assert!(started.contains("evidence=probe-single-item"), "{started}");
|
||||
|
||||
// refuse:**什么都没跑起来** ⇒ final=none(不是兜底名,否则报告会把「这次用的是 tiny-skia」当真)
|
||||
let refused = backend_failed_text(
|
||||
Kind::Wgpu,
|
||||
Some(Kind::TinySkia),
|
||||
None,
|
||||
"refuse",
|
||||
"metal support not compiled in",
|
||||
);
|
||||
assert!(
|
||||
refused.contains("final=tiny-skia result=failed"),
|
||||
"{refused}"
|
||||
);
|
||||
assert!(refused.contains("final=none result=failed"), "{refused}");
|
||||
assert!(refused.contains("fallback=tiny-skia"), "{refused}");
|
||||
assert!(refused.contains("primary=wgpu"), "{refused}");
|
||||
assert!(refused.contains("via=refuse"), "{refused}");
|
||||
assert!(
|
||||
@@ -194,13 +202,25 @@ fn backend_final_lines_separate_started_from_failed() {
|
||||
"具名错误必须原样出现在同一行里:{refused}"
|
||||
);
|
||||
|
||||
let none = backend_failed_text(Kind::TinySkia, None, "none", "boom");
|
||||
let none = backend_failed_text(Kind::TinySkia, None, None, "none", "boom");
|
||||
assert!(none.contains("final=none result=failed"), "{none}");
|
||||
|
||||
// respawn:进程即将带着兜底重开 ⇒ final=<兜底>
|
||||
let respawned = backend_failed_text(
|
||||
Kind::Wgpu,
|
||||
Some(Kind::TinySkia),
|
||||
Some(Kind::TinySkia),
|
||||
"respawn",
|
||||
"x",
|
||||
);
|
||||
assert!(respawned.contains("final=tiny-skia"), "{respawned}");
|
||||
assert!(respawned.contains("via=respawn"), "{respawned}");
|
||||
|
||||
// 具名错误自带双引号时,`error="…"` 仍必须是**格式良好的单个字段**(否则脚本只能取到前半截)
|
||||
let quoted = backend_failed_text(
|
||||
Kind::Wgpu,
|
||||
Some(Kind::TinySkia),
|
||||
None,
|
||||
"refuse",
|
||||
"GraphicsAdapterNotFound { backend: \"wgpu\" }",
|
||||
);
|
||||
@@ -219,11 +239,14 @@ fn backend_final_lines_separate_started_from_failed() {
|
||||
#[test]
|
||||
fn perf_lines_carry_measured_millis() {
|
||||
let boot = perf_boot_text(
|
||||
Duration::from_millis(650),
|
||||
Some(Duration::from_millis(650)),
|
||||
Duration::from_millis(120),
|
||||
Duration::from_millis(780),
|
||||
);
|
||||
assert_eq!(boot, "[perf] boot probe=650ms font=120ms to_run=780ms");
|
||||
// 没探测 ⇒ skipped,不是 0ms(M98 r1 p3-1:拿 0 冒充实测)
|
||||
let skipped = perf_boot_text(None, Duration::from_millis(120), Duration::from_millis(780));
|
||||
assert!(skipped.contains("probe=skipped"), "{skipped}");
|
||||
|
||||
let session = perf_session_text("screenshot", Duration::from_millis(1234), true);
|
||||
assert!(session.contains("mode=screenshot elapsed=1234ms result=ok"));
|
||||
@@ -281,27 +304,6 @@ fn theme_source_follows_the_capture_flavor_order() {
|
||||
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]
|
||||
@@ -324,3 +326,70 @@ fn acceptance_switch_is_off_by_default() {
|
||||
assert_eq!(ACCEPTANCE_ENV, "ULL_ACCEPTANCE");
|
||||
assert_eq!(WINDOW_SIZE_ENV, "ULL_WINDOW_SIZE");
|
||||
}
|
||||
|
||||
/// 抓得住:`theme_source()`(日志用)与 `Options::capture_flavor()`(真取值口)**两处优先级漂了**——
|
||||
/// 漂了就会打出错的 `source=`,而图上的主题才是真的(M98 r1 p3-5:两份实现,没有交叉测试)。
|
||||
/// 抓不住:系统主题异步到达之后的值(那要 `[theme] 系统主题 = …` 那一行)。
|
||||
#[test]
|
||||
fn theme_source_agrees_with_the_only_flavor_gate() {
|
||||
use crate::cli::Options;
|
||||
|
||||
let cases: [&[&str]; 5] = [
|
||||
&[],
|
||||
&["--screenshot", "out.png"],
|
||||
&["--screenshot", "out.png", "--theme=dark"],
|
||||
&[
|
||||
"--screenshot",
|
||||
"out.png",
|
||||
"--theme=dark",
|
||||
"--screenshot-theme=light",
|
||||
],
|
||||
&["--theme=dark"],
|
||||
];
|
||||
|
||||
for args in cases {
|
||||
let options = Options::parse(args.iter().map(|arg| (*arg).to_owned())).expect("参数合法");
|
||||
let (source, follows) = theme_source(
|
||||
options.screenshot_theme.is_some(),
|
||||
options.theme.is_some(),
|
||||
options.screenshot.is_some(),
|
||||
);
|
||||
let flavor = options.capture_flavor();
|
||||
|
||||
assert_eq!(
|
||||
follows,
|
||||
flavor.is_none(),
|
||||
"{}:follows_system 与 capture_flavor().is_none() 必须一致",
|
||||
args.join(" ")
|
||||
);
|
||||
// 非「跟随系统」时,来源标签必须能解释 capture_flavor 给出的那个值
|
||||
match source {
|
||||
"screenshot-theme" => assert_eq!(flavor, options.screenshot_theme),
|
||||
"theme" => assert_eq!(flavor, options.theme),
|
||||
"default-light" => assert_eq!(flavor, Some(unlucky_design::color::SchemeFlavor::Light)),
|
||||
"system" => assert_eq!(flavor, None),
|
||||
other => panic!("没料到的来源 {other}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 抓得住:`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 的提示"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
//! `[window]`:**本进程给窗口的那些设置**(不是我们从别处猜的)。
|
||||
//!
|
||||
//! 唯一产地是 `app/boot/diagnostics.rs::window_settings`(`pages_demo` 通道自己也有一份,
|
||||
//! 它没有最小尺寸与自定标题)。这里只做「从 `window::Settings` 抽字段 → 排版」。
|
||||
//!
|
||||
//! **别再把这些字段写进格式化串**:M98 r1 p2-1 就是这么错的——日志说 `decorations=false`,
|
||||
//! 而真实 `settings` 可以改成 `true` 而不惊动任何断言(踩坑记录 U1)。
|
||||
|
||||
/// 窗口尺寸覆盖(`ULL_WINDOW_SIZE=1520x900`)。
|
||||
///
|
||||
/// 为什么是环境变量而不是 `--width`:`cli.rs` 不在 M98 的 scope,而验收矩阵必须有「最小尺寸 /
|
||||
/// 宽屏」两档,且必须是**真外壳**(自绘三键 + 标题栏)——`pages_demo` example 的标题栏是留白带,
|
||||
/// 出不了「三键位置」那张图。语义与 `ULL_START_ROUTE` 一致:只影响这一次进程。
|
||||
pub const WINDOW_SIZE_ENV: &str = "ULL_WINDOW_SIZE";
|
||||
|
||||
// ---------------------------------------------------------------- [window]
|
||||
|
||||
/// 窗口事实:**从真实的 `window::Settings` 抽出来**的那几个字段(唯一产地见
|
||||
/// `app/boot/diagnostics.rs` 的 `window_settings`)。
|
||||
///
|
||||
/// 为什么要有这个结构:M98 r1 p2-1 —— 第一版把 `resizable=true decorations=false transparent=false`
|
||||
/// 写在格式化串里,与真实 `settings` 无绑定;定向变异把 `settings.decorations` 改成 `true`,
|
||||
/// 371 个测试全绿,而日志会与 `[caps]` 互相矛盾(踩坑记录 U1)。现在这些字段只能从
|
||||
/// [`WindowFacts::from_settings`] 来,没有手抄的入口。
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct WindowFacts {
|
||||
/// 窗口尺寸(逻辑像素)。
|
||||
pub size: (f32, f32),
|
||||
/// 最小尺寸(`None` = 没设)。
|
||||
pub min: Option<(f32, f32)>,
|
||||
/// 可缩放。
|
||||
pub resizable: bool,
|
||||
/// 系统装饰(`false` ⇒ 自绘三键)。
|
||||
pub decorations: bool,
|
||||
/// 整窗透明。
|
||||
pub transparent: bool,
|
||||
}
|
||||
|
||||
impl WindowFacts {
|
||||
/// 从**真正交给 iced/winit 的那个** `window::Settings` 抽字段。
|
||||
#[must_use]
|
||||
pub fn from_settings(settings: &iced::window::Settings) -> Self {
|
||||
Self {
|
||||
size: (settings.size.width, settings.size.height),
|
||||
min: settings.min_size.map(|size| (size.width, size.height)),
|
||||
resizable: settings.resizable,
|
||||
decorations: settings.decorations,
|
||||
transparent: settings.transparent,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `[window]` 的**请求值**一行(实际值见模块文档的表:脚本从 `[shot] … scale=` 与 PNG 换算)。
|
||||
///
|
||||
/// `title` 是 `Option`:`pages_demo` 那条通道没有自定标题,打 `<默认>` —— 编一个空串会让
|
||||
/// 「标题是什么」变成猜的。
|
||||
pub fn report_window(
|
||||
facts: &WindowFacts,
|
||||
scale: Option<f32>,
|
||||
size_source: &str,
|
||||
title: Option<&str>,
|
||||
) {
|
||||
eprintln!("{}", window_text(facts, scale, size_source, title));
|
||||
}
|
||||
|
||||
/// 见 [`report_window`]。
|
||||
#[must_use]
|
||||
pub fn window_text(
|
||||
facts: &WindowFacts,
|
||||
scale: Option<f32>,
|
||||
size_source: &str,
|
||||
title: Option<&str>,
|
||||
) -> String {
|
||||
let min = facts
|
||||
.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={} decorations={} transparent={} scale_requested={} size_source={size_source} title={title}",
|
||||
facts.size.0,
|
||||
facts.size.1,
|
||||
facts.resizable,
|
||||
facts.decorations,
|
||||
facts.transparent,
|
||||
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
|
||||
}
|
||||
@@ -72,6 +72,18 @@
|
||||
//! | [`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` 打开 |
|
||||
//!
|
||||
//! ### 「单项 ⇒ 没有静默回退」这条推理的依据(M98 r1 复审挖到源码层)
|
||||
//!
|
||||
//! `iced_renderer` 的 `fallback::Compositor` 拿到 `ICED_BACKEND` 后**仍会把每个候选依次拿去试
|
||||
//! wgpu 和 tiny-skia**,两个子渲染器收同一个 backend 字符串,所以「单项」本身并不阻止它去试
|
||||
//! tiny-skia。真正挡住它的是 `iced_tiny_skia` 的 `with_backend`:它只接受
|
||||
//! `None | "tiny-skia" | "tiny_skia"`,`Some("wgpu")` 直接返回 `GraphicsAdapterNotFound`。
|
||||
//! 于是 `ICED_BACKEND=wgpu` 这条路**不可能**落到 tiny-skia:起不来就两项都失败、返回
|
||||
//! `GraphicsCreationFailed`(判据表第 8/9 行接住它)。
|
||||
//!
|
||||
//! 这条依赖 iced 的实现细节,所以:① `iced = "=0.14.0"` 精确钉版本;② 验收脚本另有
|
||||
//! `[gpu] …Selected: AdapterInfo` 这条**不依赖本 crate 推理**的实证断言。升级 iced 时这两条都要重看。
|
||||
//!
|
||||
//! **本模块自己只回答「判据」**;「结果」由三处分别取证,别混:① `[backend] final=`(本 crate 的
|
||||
//! 起步结果,判据是单项 `ICED_BACKEND`);② `[gpu] iced_wgpu: …Selected:`(iced 自己打的,
|
||||
//! GPU 真生效的实证);③ `[shot] … renderer=`(这张图是哪个后端出的)。
|
||||
|
||||
Reference in new issue
Block a user