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

验收要的是「一次运行、多角度覆盖、输出能直接判读」,而不是把朋友当测试机反复问 7 个问题。
这一轮把产品进程能**自己观测**的那部分日志补齐,并把人工问题压缩到 3 条。

新增 `renderer/acceptance/`(三块,全部 ≤400 行):

- `mod.rs`:`[backend]`(plan = 判据,final = **结果**:`result=started|failed` + 具名错误 +
  `via=respawn|refuse|none`)、`[perf]`(boot 三段实测 / session / scene 帧率只算首帧之后的间隔)、
  `[theme]`(来源 = 哪个参数决定,系统那条写 `pending` 等异步真值)、`[window]`(**请求值**,
  字段名自证 `requested` / `scale_requested`);开关 `ULL_ACCEPTANCE=1`、尺寸覆盖
  `ULL_WINDOW_SIZE=1520x900` 与它的解析/夹取(含 `size_source=` 四态)。
- `locale.rs`:`[env]`(真适配器枚举 + 硬件判定;显示器写 `needs-window` 不猜)、`[i18n]`
  (四级链每一级的原始值 + 结论; macOS 走 `defaults read -g AppleLanguages`)。
- `visual.rs`:`[font] chain`(令牌声明的回退链,与已有的实际落点 `[font] platform=` 互补)、
  `[caps]`(`bundle` / `menu_bar_name` 从可执行文件路径**读**出来;`controls=self-drawn` 由
  `decorations=false` 推出)。

接线:`app/boot.rs` 在正确的时刻把真值喂进去(探测产物 / 窗口设置 / 实测 `Instant`),
`pages/harness.rs` 同一套前缀(含 `[perf] scene`),`main.rs` 用法里写出两个开关。

反模式的防线(M97 那类「在需要结果的地方打印请求」):每一格都有能改坏的断言
(`acceptance/tests.rs` + `locale_tests.rs`,共 20 条新增),失败路径的 `error="…"` 会把
wgpu 自带的双引号换成单引号,保证单行可解析。

本机(Windows)实测:auto → `[backend] final=wgpu result=started` + `Selected: AdapterInfo`;
cpu → `tiny-skia`;`WGPU_BACKEND=metal` 负例 → exit 1 + 无图 + `via=refuse`;宽屏覆盖生效。
macOS 分支(Metal / AppleLanguages / 菜单栏名)需真机复核,期望字符串见 `renderer/mod.rs` 文档。

验收脚本不入库(用户要求):落在 gitignore 的 `DevDocs/ops/macos-acceptance.sh`,
由 tower 拷到分发目录。
This commit is contained in:
AskaEth committed 2026-11-03 01:09:25 +08:00
1 parent 18e36c6514
commit f986981c12
9 files changed
+1429 -129

No files matched your search

+60 -41
View File
@@ -1,24 +1,16 @@
//! 截图 harness:把**一页**按给定窗口宽度 / 主题单独渲染并出图。
//! 截图 harness:把**一页**按给定窗口宽度 / 主题单独渲染并出图(`examples/pages_demo.rs` 的入口)。
//!
//! 它是 `examples/pages_demo.rs` 的入口(该 example 只有三行)。**刻意不接 `cli.rs`**:
//! `cli.rs` 归 M75 改(它正在加 `--screen`),本 mission 不碰;等 `cli.rs` 具备 `--screen` /
//! `--window-width` 之后,这个 harness 应当删掉,改由正式启动参数出图。
//!
//! ## 用法
//!
//! ```text
//! cargo run -p unlucky-app-iced --example pages_demo -- \
//! --screen=instances --width=1180 --height=760 \
//! --screenshot=out.png --screenshot-theme=light --software-renderer
//! ```
//!
//! 参数:`--screen=instances|download|settings|logs`、`--width`、`--height`、
//! `--theme` / `--screenshot-theme`(后者优先)、`--screenshot`、`--renderer`、
//! **刻意不接 `cli.rs`**:等 `cli.rs` 具备 `--screen` / `--window-width` 之后,这个 harness 应当
//! 删掉,改由正式启动参数出图。参数:`--screen=instances|download|settings|logs`、`--width`、
//! `--height`、`--theme` / `--screenshot-theme`(后者优先)、`--screenshot`、`--renderer`、
//! `--software-renderer`、`--scale-factor`。没给 `--screenshot` 就只是开窗看。
//!
//! ## 为什么还要等 3 帧
//! ```text
//! cargo run -p unlucky-app-iced --example pages_demo -- --screen=instances --width=1180 \
//! --height=760 --screenshot=out.png --screenshot-theme=light --software-renderer
//! ```
//!
//! 与 `app.rs` 同一口径(M60 坑 #5):第一帧可能是启动时的旧尺寸,且字体首次上屏会抖动。
//! 等 3 帧再截图与 `app.rs` 同一口径(M60 坑 #5):第一帧可能是启动时的旧尺寸,字体首次上屏会抖动。
use std::path::PathBuf;
use std::time::Instant;
@@ -36,9 +28,8 @@ use crate::theme::Ui;
use super::{app, demo_model, Model, Screen};
/// 截图落盘的帧号(见模块文档)。
/// 截图落盘的帧号(见模块文档);验收窗口尺寸(D30:1180×760、100% DPI)。
const CAPTURE_AT_FRAME: u32 = 3;
/// 验收窗口尺寸(D30:1180×760、100% DPI)。
const DEFAULT_SIZE: (f32, f32) = (1180.0, 760.0);
/// 一次渲染请求。
@@ -75,10 +66,7 @@ impl Default for Request {
}
impl Request {
/// 从进程参数解析(`argv[1..]`)。
///
/// # Errors
/// 未知参数、缺值、数字非法时返回人话。
/// 从进程参数解析(`argv[1..]`)。未知参数 / 缺值 / 数字非法时返回人话。
pub fn parse(args: impl IntoIterator<Item = String>) -> Result<Self, String> {
let mut request = Self::default();
let mut theme: Option<SchemeFlavor> = None;
@@ -147,7 +135,7 @@ impl Request {
Ok(request)
}
/// 出图文件名片段(`<screen>-<class>-<flavor>`,归档用)。
/// 出图文件名片段(`<screen>-<class>-<flavor>-w<宽>`,归档用)。
pub fn slug(&self) -> String {
let class = super::layout::WidthClass::of(self.width);
let flavor = if self.flavor.is_dark() {
@@ -155,12 +143,8 @@ impl Request {
} else {
"light"
};
format!(
"{}-{}-{flavor}-w{}",
self.screen.slug(),
class.name(),
self.width as u32
)
let width = self.width as u32;
format!("{}-{}-{flavor}-w{width}", self.screen.slug(), class.name())
}
}
@@ -196,19 +180,16 @@ fn flavor(raw: &str) -> Result<SchemeFlavor, String> {
/// 启动(阻塞到出图或关窗)。
///
/// ## 取证通道的硬规则(M97)
///
/// **宁可红着退出,也不能悄悄换一种后端继续出图。** 以前这条路径在 wgpu 挂掉时会静默降级到
/// tiny-skia,然后照样落一张 PNG,而报告上写的是「GPU 渲染截图」——所有视觉结论的可信度都被
/// 悄悄打折,输出上看不出来。现在:① 决策被 [`renderer::strict`] 剥掉兜底;② 起不来就带着
/// **具名**错误返回;③ 每张图的日志里都带 `backend=`,图能自证来源。
/// **取证通道的硬规则(M97)**:宁可红着退出,也不能悄悄换一种后端继续出图(以前 wgpu 挂掉时
/// 会静默降级 tiny-skia 照样落 PNG,而报告上写着「GPU 渲染截图」)。所以这里 ① 决策被
/// [`renderer::strict`] 剥掉兜底、② 起不来带**具名**错误返回、③ 每张图日志都带 `backend=`。
///
/// # Errors
/// 后端探测/字体求值失败、**渲染后端起不来(含 iced 的具名图形错误)**、截图落盘失败时返回人话。
pub fn run(request: Request) -> Result<(), String> {
let started = Instant::now();
let needs_probe = request.backend == Backend::Auto;
let needs_probe = request.backend == Backend::Auto || renderer::acceptance::enabled();
let probe = if needs_probe {
Probe::run()
} else {
@@ -221,6 +202,14 @@ pub fn run(request: Request) -> Result<(), String> {
decision.primary.name(),
decision.reason
);
renderer::acceptance::report_backend_plan(&decision, request.backend);
renderer::acceptance::report_window(
(request.width, request.height),
None,
request.scale_factor,
"pages_demo",
None,
);
let resolved = fonts::resolve();
eprintln!("{}", resolved.describe());
@@ -237,6 +226,8 @@ pub fn run(request: Request) -> Result<(), String> {
request: boot.clone(),
window: None,
frames: 0,
started,
first_frame: None,
backend: backend.name(),
},
Task::done(Message::Boot),
@@ -270,21 +261,34 @@ pub fn run(request: Request) -> Result<(), String> {
// 兜底已被 `strict` 剥掉,所以这条最多跑一次;具名错误的口径与 App 启动一致,
// 但**取证通道不许降级**:出不了图就红着退出,不换后端(那是另一处 iced 行为)。
renderer::run_once(decision, |backend| {
let session_started = Instant::now();
let result = renderer::run_once(decision, |backend| {
std::env::set_var("ICED_BACKEND", backend.env_value());
let application = application(backend);
match request.scale_factor {
Some(scale) => application.scale_factor(move |_| scale).run(),
None => application.run(),
}
})
.map_err(|error| match renderer::named_graphics_error(&error) {
});
let session = session_started.elapsed();
let outcome = result.map_err(|error| match renderer::named_graphics_error(&error) {
Some(named) => format!(
"渲染后端 {} 初始化失败(取证通道无回退,未出图):{named}",
decision.primary.name()
),
None => format!("iced 启动失败:{error}"),
})?;
});
renderer::acceptance::report_outcome(
&decision,
outcome.as_ref().err().map(String::as_str),
"refuse",
"screenshot",
session,
);
outcome?;
eprintln!(
"[harness] 用时 {:.0} ms",
@@ -300,6 +304,10 @@ struct Harness {
model: Model,
window: Option<window::Id>,
frames: u32,
/// 进程起点(`run()` 的开头)——`[perf]` 的首帧口径与产品路径一致:从进 `run()` 算。
started: Instant,
/// 第一帧到达的时刻(相对 `started`)。`None` = 一帧都没到过。
first_frame: Option<std::time::Duration>,
/// 出这张图的后端(`wgpu` / `tiny-skia`)。图要能**自证来源**——以前它证不了(M97)。
backend: &'static str,
}
@@ -323,6 +331,9 @@ impl Harness {
Task::none()
}
Message::Frame => {
if self.frames == 0 {
self.first_frame = Some(self.started.elapsed());
}
self.frames += 1;
if self.frames < CAPTURE_AT_FRAME || self.request.screenshot.is_none() {
return Task::none();
@@ -347,6 +358,14 @@ impl Harness {
}
Err(error) => eprintln!("[harness] 截图失败:{error}"),
}
// `[perf]` 两格都是实测:`first_frame` = 首帧事件的时间戳,`fps` = 帧数 / 时长
// (**取证窗口**的吞吐,不是长跑稳态帧率——那个看产品路径的 `[tick]`)。
renderer::acceptance::report_perf_scene(
&self.request.slug(),
self.frames,
self.first_frame,
self.started.elapsed(),
);
iced::exit()
}
Message::Page(message) => {