From f986981c1282d1191770f4e546ccd4479170a731 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 3 Nov 2026 01:09:25 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(acceptance):=20M98=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E5=85=AB=E7=B1=BB=E8=AF=8A=E6=96=AD=E6=97=A5?= =?UTF-8?q?=E5=BF=97=20+=20=E4=B8=80=E6=AC=A1=E8=BF=90=E8=A1=8C=E7=9A=84?= =?UTF-8?q?=E5=85=A8=E8=A6=86=E7=9B=96=E9=AA=8C=E6=94=B6=E7=9F=A9=E9=98=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 验收要的是「一次运行、多角度覆盖、输出能直接判读」,而不是把朋友当测试机反复问 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 拷到分发目录。 --- crates/app-iced/src/app/boot.rs | 263 ++++++++---- crates/app-iced/src/main.rs | 5 + crates/app-iced/src/pages/harness.rs | 101 +++-- .../src/renderer/acceptance/locale.rs | 232 +++++++++++ .../src/renderer/acceptance/locale_tests.rs | 141 +++++++ .../app-iced/src/renderer/acceptance/mod.rs | 379 ++++++++++++++++++ .../app-iced/src/renderer/acceptance/tests.rs | 326 +++++++++++++++ .../src/renderer/acceptance/visual.rs | 103 +++++ crates/app-iced/src/renderer/mod.rs | 8 +- 9 files changed, 1429 insertions(+), 129 deletions(-) create mode 100644 crates/app-iced/src/renderer/acceptance/locale.rs create mode 100644 crates/app-iced/src/renderer/acceptance/locale_tests.rs create mode 100644 crates/app-iced/src/renderer/acceptance/mod.rs create mode 100644 crates/app-iced/src/renderer/acceptance/tests.rs create mode 100644 crates/app-iced/src/renderer/acceptance/visual.rs diff --git a/crates/app-iced/src/app/boot.rs b/crates/app-iced/src/app/boot.rs index 897c7dd..8bb9c5d 100644 --- a/crates/app-iced/src/app/boot.rs +++ b/crates/app-iced/src/app/boot.rs @@ -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 { 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 { /// 重开失败 / 拿不到退出码时的兜底码(与 `main` 的「参数或运行错误」一致)。 const RESPAWN_FAILED: i32 = 2; @@ -183,8 +229,11 @@ fn exit_code(code: Option) -> 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, @@ -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, + } } /// 默认字体(正文档解析出来的族)。 diff --git a/crates/app-iced/src/main.rs b/crates/app-iced/src/main.rs index 801e42e..f3dad9c 100644 --- a/crates/app-iced/src/main.rs +++ b/crates/app-iced/src/main.rs @@ -41,6 +41,11 @@ fn main() -> ExitCode { [--theme=light|dark] [--screenshot ] [--screenshot-theme=light|dark] \ [--scale-factor ] [--diff ]" ); + // M98:验收开关写在用法里,不然「日志怎么变详细了」只能靠翻文档。 + eprintln!( + "验收诊断:ULL_ACCEPTANCE=1([env]/[backend]/[window]/[font]/[theme]/[perf]/[i18n]/[caps])\ + ,ULL_WINDOW_SIZE=1520x900 覆盖窗口尺寸" + ); ExitCode::from(2) } } diff --git a/crates/app-iced/src/pages/harness.rs b/crates/app-iced/src/pages/harness.rs index e9c8bd8..25cc3eb 100644 --- a/crates/app-iced/src/pages/harness.rs +++ b/crates/app-iced/src/pages/harness.rs @@ -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) -> Result { let mut request = Self::default(); let mut theme: Option = None; @@ -147,7 +135,7 @@ impl Request { Ok(request) } - /// 出图文件名片段(`--`,归档用)。 + /// 出图文件名片段(`---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 { /// 启动(阻塞到出图或关窗)。 /// -/// ## 取证通道的硬规则(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, frames: u32, + /// 进程起点(`run()` 的开头)——`[perf]` 的首帧口径与产品路径一致:从进 `run()` 算。 + started: Instant, + /// 第一帧到达的时刻(相对 `started`)。`None` = 一帧都没到过。 + first_frame: Option, /// 出这张图的后端(`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) => { diff --git a/crates/app-iced/src/renderer/acceptance/locale.rs b/crates/app-iced/src/renderer/acceptance/locale.rs new file mode 100644 index 0000000..ae3e3f0 --- /dev/null +++ b/crates/app-iced/src/renderer/acceptance/locale.rs @@ -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, 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 { + 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, + /// (源自称的名字, 原始 locale)。 + pub platform: (&'static str, Option), + /// (源自称的名字, 原始 locale)。 + pub env: (&'static str, Option), + /// 结论(由 [`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, +} + +impl LocaleSource for Captured { + fn locale(&self) -> Option { + 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 { + #[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 { + 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; diff --git a/crates/app-iced/src/renderer/acceptance/locale_tests.rs b/crates/app-iced/src/renderer/acceptance/locale_tests.rs new file mode 100644 index 0000000..ecdf8b1 --- /dev/null +++ b/crates/app-iced/src/renderer/acceptance/locale_tests.rs @@ -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 = 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 { + 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 { + 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 { + 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); +} diff --git a/crates/app-iced/src/renderer/acceptance/mod.rs b/crates/app-iced/src/renderer/acceptance/mod.rs new file mode 100644 index 0000000..f054094 --- /dev/null +++ b/crates/app-iced/src/renderer/acceptance/mod.rs @@ -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) { + 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, + 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, + 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, 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, + 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, + 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, + 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; diff --git a/crates/app-iced/src/renderer/acceptance/tests.rs b/crates/app-iced/src/renderer/acceptance/tests.rs new file mode 100644 index 0000000..dd36bea --- /dev/null +++ b/crates/app-iced/src/renderer/acceptance/tests.rs @@ -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 = 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"); +} diff --git a/crates/app-iced/src/renderer/acceptance/visual.rs b/crates/app-iced/src/renderer/acceptance/visual.rs new file mode 100644 index 0000000..010da81 --- /dev/null +++ b/crates/app-iced/src/renderer/acceptance/visual.rs @@ -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::>() + .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)); +} diff --git a/crates/app-iced/src/renderer/mod.rs b/crates/app-iced/src/renderer/mod.rs index aeb5013..40ba843 100644 --- a/crates/app-iced/src/renderer/mod.rs +++ b/crates/app-iced/src/renderer/mod.rs @@ -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; From 8153e54f873afa6492a2a5665e49dfcb61383a22 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 3 Nov 2026 12:59:57 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(acceptance):=20M98=20r1=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20[window]=20=E7=BB=91=E5=AE=9A=E7=9C=9F=20settings?= =?UTF-8?q?=E3=80=81=E8=AF=AD=E8=A8=80=E7=BB=B4=E5=BA=A6=E8=A1=A5=E6=96=AD?= =?UTF-8?q?=E8=A8=80=E3=80=81boot.rs=20=E6=8B=86=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审 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)全绿。 --- crates/app-iced/src/app/boot.rs | 286 ++++-------------- crates/app-iced/src/app/boot/diagnostics.rs | 110 +++++++ crates/app-iced/src/app/boot/fallback.rs | 117 +++++++ crates/app-iced/src/app/boot/tests.rs | 84 +++++ crates/app-iced/src/pages/harness.rs | 31 +- .../src/renderer/acceptance/locale.rs | 26 +- .../app-iced/src/renderer/acceptance/mod.rs | 164 ++++------ .../app-iced/src/renderer/acceptance/tests.rs | 139 ++++++--- .../src/renderer/acceptance/window.rs | 142 +++++++++ crates/app-iced/src/renderer/mod.rs | 12 + 10 files changed, 726 insertions(+), 385 deletions(-) create mode 100644 crates/app-iced/src/app/boot/diagnostics.rs create mode 100644 crates/app-iced/src/app/boot/fallback.rs create mode 100644 crates/app-iced/src/renderer/acceptance/window.rs diff --git a/crates/app-iced/src/app/boot.rs b/crates/app-iced/src/app/boot.rs index 8bb9c5d..182e421 100644 --- a/crates/app-iced/src/app/boot.rs +++ b/crates/app-iced/src/app/boot.rs @@ -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 { - 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 { - /// 重开失败 / 拿不到退出码时的兜底码(与 `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, - 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, } /// 渲染后端探测 + 写 `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 { diff --git a/crates/app-iced/src/app/boot/diagnostics.rs b/crates/app-iced/src/app/boot/diagnostics.rs new file mode 100644 index 0000000..9ac2c7f --- /dev/null +++ b/crates/app-iced/src/app/boot/diagnostics.rs @@ -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, 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, + 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, + via: &str, + mode: &str, + elapsed: Duration, +) { + acceptance::report_outcome(decision, error.as_deref(), via, mode, elapsed); +} diff --git a/crates/app-iced/src/app/boot/fallback.rs b/crates/app-iced/src/app/boot/fallback.rs new file mode 100644 index 0000000..ef20a28 --- /dev/null +++ b/crates/app-iced/src/app/boot/fallback.rs @@ -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 { + 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 { + /// 重开失败 / 拿不到退出码时的兜底码(与 `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, + 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 +} diff --git a/crates/app-iced/src/app/boot/tests.rs b/crates/app-iced/src/app/boot/tests.rs index 3765fd0..b247030 100644 --- a/crates/app-iced/src/app/boot/tests.rs +++ b/crates/app-iced/src/app/boot/tests.rs @@ -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}" + ); +} diff --git a/crates/app-iced/src/pages/harness.rs b/crates/app-iced/src/pages/harness.rs index 25cc3eb..258835e 100644 --- a/crates/app-iced/src/pages/harness.rs +++ b/crates/app-iced/src/pages/harness.rs @@ -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, ); diff --git a/crates/app-iced/src/renderer/acceptance/locale.rs b/crates/app-iced/src/renderer/acceptance/locale.rs index ae3e3f0..a0284f1 100644 --- a/crates/app-iced/src/renderer/acceptance/locale.rs +++ b/crates/app-iced/src/renderer/acceptance/locale.rs @@ -36,23 +36,35 @@ pub fn env_line(probe: &Probe, language: &str, scale: Option, 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, diff --git a/crates/app-iced/src/renderer/acceptance/mod.rs b/crates/app-iced/src/renderer/acceptance/mod.rs index f054094..523a2f5 100644 --- a/crates/app-iced/src/renderer/acceptance/mod.rs +++ b/crates/app-iced/src/renderer/acceptance/mod.rs @@ -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, - 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, - 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, 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, + final_kind: Option, + 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, via: &str, e pub fn backend_failed_text( primary: Kind, fallback: Option, + final_kind: Option, 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, 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, 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() ) diff --git a/crates/app-iced/src/renderer/acceptance/tests.rs b/crates/app-iced/src/renderer/acceptance/tests.rs index dd36bea..360fadb 100644 --- a/crates/app-iced/src/renderer/acceptance/tests.rs +++ b/crates/app-iced/src/renderer/acceptance/tests.rs @@ -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 的提示" + ); +} diff --git a/crates/app-iced/src/renderer/acceptance/window.rs b/crates/app-iced/src/renderer/acceptance/window.rs new file mode 100644 index 0000000..51b2ed2 --- /dev/null +++ b/crates/app-iced/src/renderer/acceptance/window.rs @@ -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, + 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, + 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 +} diff --git a/crates/app-iced/src/renderer/mod.rs b/crates/app-iced/src/renderer/mod.rs index 40ba843..90ea616 100644 --- a/crates/app-iced/src/renderer/mod.rs +++ b/crates/app-iced/src/renderer/mod.rs @@ -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=`(这张图是哪个后端出的)。