fix(acceptance): M98 r1 —— [window] 绑定真 settings、语言维度补断言、boot.rs 拆分

复审 r1(reviewer-accscope-code,p2-2items / fix-then-merge)的两条阻塞 + 四条顺手的 p3。

## p2-1 `[window]` 的三个窗口属性曾是格式串常量(U1:常量冒充观测结果)

第一版把 `resizable=true decorations=false transparent=false` 写在格式化串里,与交给 iced 的
`window::Settings` 没有任何绑定;定向变异把 `settings.decorations` 改成 `true`,**371 个测试全绿**,
而验收包里 `[caps]` 与 `[window]` 会互相矛盾。

- 新增 `acceptance::window::WindowFacts` + `from_settings()`:字段只能从**真实 settings** 抽出来,
  没有手抄入口;`window_text()` 逐字段打印。
- 窗口设置收成单一产地 `app/boot/diagnostics::window_settings()`,`launch()` 与 `[window]`/`[caps]`
  两行日志共用同一个 `&window::Settings`;`pages/harness.rs` 同样从自己那份 settings 取。
- 守门测试 `window_settings_match_what_the_acceptance_logs_claim`,含「from_settings 必须逐字段
  搬运」的反向断言。变异实证:`window_settings` 的 `decorations → true` ⇒ 红并点名;
  `from_settings` 里写死 `decorations: false` ⇒ 红(第一次写测试时这条漏抓,补强后实测红)。

## p2-2 语言维度空转、且没有任何结论断言

`lang::resolve` 是 setting → 平台源 → env,macOS 的 AppleLanguages 几乎恒有值 ⇒ `LANG=zh_CN` /
`LANG=en_US` 两档同值,而原脚本只断言「`[i18n]` 这行存在」——整条链退化成
`source=default lang=en` 时核心集一条都不红。

- 脚本加两条结论断言:`^\[i18n\].*platform=applelanguages:[A-Za-z-]+` 与 ` source=applelanguages`
  (退化日志上实测红、健康日志上实测绿)。
- `REPORT.md` 语言行下注明:env 优先级低于平台源,正常 Mac 上两档同值,这一维验的是「平台源
  真读到了 + 中文字体回退」,不是中英对照。

## p3(复审 + 第二路自跑复核同源的三条)

- `[perf] boot probe=0ms` → `probe=skipped`(没计时就不报数;降级重开那一轮改为**真计时**)。
  变异实证:把 skipped 写成 0ms ⇒ `perf_lines_carry_measured_millis` 红。
- `[env] gpu=` 排序改成 `DiscreteGpu > IntegratedGpu > 其它`:本机原来报核显,而 iced 实际选的是
  独显(现在报 `AMD Radeon RX 6600 XT [DiscreteGpu]`,与 `Selected:` 一致)。
- `[i18n] setting=<未设置>` → `<未接线>`(配置侧根本没读取点,写「未设置」等于把「我们没读」
  说成「用户没设」)。
- `[backend] final=` 失败行:`refuse` / `none` 两条路是 `final=none`(什么都没跑起来),只有
  `respawn` 才是兜底名;`pages/harness.rs` 不再写死 `via="refuse"`,改用共用的 `failure_via()`。
  实跑负例:`[backend] final=none result=failed primary=wgpu fallback=tiny-skia via=refuse`。
- 脚本 `[tick]` 那处唯一未锚定的 `field()`:加 `^` 锚定、正则改 `clock=[a-z0-9-]+`(真值是
  `idle-1hz`,旧 `[a-z]+` 把它截成 `idle`,靠前缀巧合通过);断言也改成 `clock=idle-1hz`。
- 脚本 `[backend] final` 取值改 `field_backend()`:优先 `result=started`(respawn 场景一条日志里
  有父 failed + 子 started 两条,旧 `head -1` 会把「这次没跑起来」写进报告)。
- 脚本 `exec 3>&1` 全程留痕(终端提示也进 `logs/00-run.log`)、跑前清空 `$OUT/shots/`、
  python3 缺失时在 REPORT 里写明「几何列未采」而不是留空。
- `renderer/mod.rs` 补一段依据:为什么「单项 ICED_BACKEND ⇒ 没有静默回退」成立(`iced_tiny_skia`
  的 `with_backend` 只接受 `tiny-skia`),以及它依赖 iced 实现细节的两层缓解。

## 结构

`app/boot.rs` 400 行零余量 → 拆成 `boot.rs`(248) + `boot/fallback.rs`(117,兜底/重开/退出码) +
`boot/diagnostics.rs`(110,窗口设置与四段报告);`run()` 回到「探测 → 字体 → 打印 → 起 iced」。
`acceptance` 再把 `[window]` 拆成 `window.rs`(142)。

门禁:`cargo fmt --check` / `clippy --all-targets -- -D warnings` / `test`(373 lib + 6 integration)全绿。
This commit is contained in:
AskaEth committed 2026-11-03 12:59:57 +08:00
1 parent f986981c12
commit 8153e54f87
10 files changed
+726 -385

No files matched your search

@@ -36,23 +36,35 @@ pub fn env_line(probe: &Probe, language: &str, scale: Option<f32>, tz: Option<&s
)
}
/// 枚举结果里**最该被当成 GPU** 的那个适配器的摘要(硬件优先,其次任何适配器)。
/// 枚举结果里**最该被当成 GPU** 的那个适配器的摘要:`DiscreteGpu > IntegratedGpu > 其它`。
///
/// 与 `Probe::describe` 的分工:那个给全量清单(日志一行),这个给「一句话结论」(报告表格用)。
/// 判据同源:`device_type` 不是 `Cpu` / `Other` 才算硬件(WARP 排在第一是本机实测的形状)。
/// 为什么不是「第一个非 Cpu/Other」(M98 r1 p3-2):本机枚举顺序是核显在前,于是 `[env] gpu=`
/// 报的是 `AMD Radeon(TM) Graphics [IntegratedGpu]`,而 iced 实际选的是
/// `AMD Radeon RX 6600 XT [DiscreteGpu]`。一行「机器有什么显卡」的日志报错型号,比不报更坏。
/// 判据仍然只管**型号名称**:真正的「iced 选了谁」只有 `[gpu] …Selected: AdapterInfo` 那行有。
#[must_use]
pub fn gpu_summary(probe: &Probe) -> String {
probe
.adapters
.iter()
.find(|adapter| !matches!(adapter.device_type, "Cpu" | "Other"))
.or_else(|| probe.adapters.first())
.max_by_key(|adapter| gpu_rank(adapter.device_type))
.map_or_else(
|| "<无适配器>".to_owned(),
|adapter| format!("{} [{}]", adapter.name, adapter.device_type),
)
}
/// 适配器的「该不该被当成这台机器的显卡」排序权重(越大越该)。
fn gpu_rank(device_type: &str) -> u8 {
match device_type {
"DiscreteGpu" => 3,
"IntegratedGpu" => 2,
"VirtualGpu" => 1,
"Cpu" | "Other" => 0,
_ => 1,
}
}
/// 时区(`TZ` 环境变量)。`std` 拿不到系统时区,所以这里**只报环境变量**,没有就写未设置。
///
/// macOS 上的真值时区由脚本用 `date +%Z%z` 采(那份是观测值,这个只是进程视角)。
@@ -129,7 +141,9 @@ impl Chain {
pub fn line(&self) -> String {
format!(
"[i18n] setting={} platform={}:{} env={}:{} -> {} ui_text=pinned-en",
show(self.setting.as_deref()),
// `<未接线>` 而不是 `<未设置>`:配置侧的读取点**还不存在**(M98 r1 p3-3)。
// 写「未设置」会让读的人以为用户没配过;真实情况是我们没读。
self.setting.as_deref().unwrap_or("<未接线>"),
self.platform.0,
show(self.platform.1.as_deref()),
self.env.0,
+53 -111
View File
@@ -33,20 +33,18 @@ use crate::{Backend, Probe};
pub mod locale;
pub mod visual;
pub mod window;
pub use locale::{env_line, tz, Chain};
pub use visual::{caps_line, font_chain_line, report_caps, report_font_chain};
pub use window::{
parse_window_size, report_window, resolve_window_size, window_size, window_text, WindowFacts,
WINDOW_SIZE_ENV,
};
/// 打开「贵的诊断」的开关(见模块文档 §开关)。
pub const ACCEPTANCE_ENV: &str = "ULL_ACCEPTANCE";
/// 窗口尺寸覆盖(`ULL_WINDOW_SIZE=1520x900`)。
///
/// 为什么是环境变量而不是 `--width`:`cli.rs` 不在 M98 的 scope,而验收矩阵必须有「最小尺寸 /
/// 宽屏」两档,且必须是**真外壳**(自绘三键 + 标题栏)——`pages_demo` example 的标题栏是留白带,
/// 出不了「三键位置」那张图。语义与 `ULL_START_ROUTE` 一致:只影响这一次进程。
pub const WINDOW_SIZE_ENV: &str = "ULL_WINDOW_SIZE";
/// `ULL_ACCEPTANCE` 是否打开(缺省关;`0` / `off` / `false` / `no` 也算关)。
#[must_use]
pub fn enabled() -> bool {
@@ -114,97 +112,6 @@ pub fn theme_text(source: &str, flavor: Option<&str>, follows_system: bool) -> S
)
}
// ---------------------------------------------------------------- [window]
/// `[window]` 的**请求值**一行(实际值见模块文档的表:脚本从 `[shot] … scale=` 与 PNG 换算)。
///
/// `min` / `title` 是 `Option`:`pages_demo` 那条通道没有最小尺寸与自定标题,这时打 `none` /
/// `<默认>` —— 填个 `0x0` 会让人以为是「最小尺寸是 0」,那是另一种假绿。
pub fn report_window(
size: (f32, f32),
min: Option<(f32, f32)>,
scale: Option<f32>,
size_source: &str,
title: Option<&str>,
) {
eprintln!("{}", window_text(size, min, scale, size_source, title));
}
/// 见 [`report_window`]。
#[must_use]
pub fn window_text(
size: (f32, f32),
min: Option<(f32, f32)>,
scale: Option<f32>,
size_source: &str,
title: Option<&str>,
) -> String {
let min = min.map_or_else(|| "none".to_owned(), |(w, h)| format!("{w}x{h}"));
let title = title.map_or_else(|| "<默认>".to_owned(), |text| format!("\"{text}\""));
format!(
"[window] requested size={}x{} min={min} resizable=true decorations=false transparent=false \
scale_requested={} size_source={size_source} title={title}",
size.0,
size.1,
scale.map_or_else(|| "system".to_owned(), |value| value.to_string()),
)
}
/// 解析 `ULL_WINDOW_SIZE`(`1520x900` / `1520X900` / `1520×900`)。
///
/// 返回 `None` = 不是这个格式(调用方保留默认尺寸并打一行警告,**不**静默忽略)。
#[must_use]
pub fn parse_window_size(raw: &str) -> Option<(f32, f32)> {
let trimmed = raw.trim();
let (width, height) = trimmed
.split_once(['x', 'X', '×', '*'])
.map(|(w, h)| (w.trim(), h.trim()))?;
let width: f32 = width.parse().ok()?;
let height: f32 = height.parse().ok()?;
(width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0)
.then_some((width, height))
}
/// 尺寸覆盖 → 实际要开多大 + **尺寸来源**(进 `[window] … size_source=`)。
///
/// 三种来源:`default`(没设 / 设了但解析不了)与 `ULL_WINDOW_SIZE`(生效)。
/// 小于最小尺寸时夹到最小并标 `:clamped-to-min`——iced 的 `min_size` 本来也会拦住,
/// 但那样日志会说「要求 640x480」而实际是 880x560,是典型的「日志说请求、报告当结果」。
#[must_use]
pub fn resolve_window_size(
raw: Option<&str>,
default: (f32, f32),
min: (f32, f32),
) -> ((f32, f32), &'static str) {
let Some(raw) = raw else {
return (default, "default");
};
let Some((width, height)) = parse_window_size(raw) else {
return (default, "ULL_WINDOW_SIZE:invalid");
};
let clamped = (width.max(min.0), height.max(min.1));
if clamped == (width, height) {
(clamped, "ULL_WINDOW_SIZE")
} else {
(clamped, "ULL_WINDOW_SIZE:clamped-to-min")
}
}
/// 窗尺寸(含来源)从进程环境取一遍——`app::boot` 与测试共用同一条路径。
#[must_use]
pub fn window_size(default: (f32, f32), min: (f32, f32)) -> ((f32, f32), &'static str) {
let raw = std::env::var(WINDOW_SIZE_ENV).ok();
let resolved = resolve_window_size(raw.as_deref(), default, min);
if resolved.1.ends_with("invalid") {
eprintln!(
"[window] {WINDOW_SIZE_ENV}={:?} 解析不了(要 1520x900 这种),按默认尺寸跑",
raw.unwrap_or_default()
);
}
resolved
}
// ---------------------------------------------------------------- [backend]
/// `[backend] plan`:这一次准备试**哪些**后端(单项 + 本 crate 的兜底,不是 iced 的候选回退)。
@@ -249,8 +156,21 @@ pub fn backend_started_text(primary: Kind, elapsed: Duration) -> String {
/// - `respawn`:交互启动换了进程用兜底后端重开(子进程自己再打一遍 plan/final);
/// - `refuse`:取证启动(`--screenshot`)拒绝降级出图,**没有图**;
/// - `none`:没有兜底(`--renderer=cpu` / 没编 wgpu),原样失败。
pub fn report_backend_failed(primary: Kind, fallback: Option<Kind>, via: &str, error: &str) {
eprintln!("{}", backend_failed_text(primary, fallback, via, error));
///
/// `final` 是**终态**:`refuse` / `none` 两条路什么都没跑起来 ⇒ `final=none`(不是兜底名)。
/// 第一版让 `final=` 与 `fallback=` 装同一个值,于是 `[backend] final=tiny-skia result=failed`
/// 会被 REPORT 的「后端实际选中」照抄成「这次跑的是 tiny-skia」——报告说谎(M98 r1 p3-4)。
pub fn report_backend_failed(
primary: Kind,
fallback: Option<Kind>,
final_kind: Option<Kind>,
via: &str,
error: &str,
) {
eprintln!(
"{}",
backend_failed_text(primary, fallback, final_kind, via, error)
);
}
/// 见 [`report_backend_failed`]。
@@ -258,21 +178,26 @@ pub fn report_backend_failed(primary: Kind, fallback: Option<Kind>, via: &str, e
pub fn backend_failed_text(
primary: Kind,
fallback: Option<Kind>,
final_kind: Option<Kind>,
via: &str,
error: &str,
) -> String {
let final_name = fallback.map_or("none", Kind::name);
let fallback_name = fallback.map_or("none", Kind::name);
let final_name = final_kind.map_or("none", Kind::name);
// `error="…"` 必须是**格式良好的单个字段**:具名错误里常自带双引号(wgpu 的 Debug 输出),
// 原样塞进去会把这一行撕成两半,脚本按 `error="` 取值就只能取到前半截。
let error = error.replace('"', "'").replace(['\n', '\r'], " ");
format!(
"[backend] final={final_name} result=failed primary={} fallback={final_name} via={via} \
error=\"{error}\"",
"[backend] final={final_name} result=failed primary={} fallback={fallback_name} \
via={via} error=\"{error}\"",
primary.name(),
)
}
/// 起步失败后去了哪(日志字段 `via=`;与 `app::boot::OnFailure` 同一套取值)。
/// 起步失败后去了哪(日志字段 `via=`;与 `app::boot::fallback::OnFailure` 同一套取值)。
///
/// 放在这里而不是 `app/boot/fallback.rs`:`mod boot` 在 `app/mod.rs` 里是私有的(那份文件不在
/// M98 的 scope),`pages::harness` 够不到它,而两个调用点必须用**同一个**判据。
#[must_use]
pub fn failure_via(has_fallback: bool, evidence: bool) -> &'static str {
match (has_fallback, evidence) {
@@ -283,6 +208,9 @@ pub fn failure_via(has_fallback: bool, evidence: bool) -> &'static str {
}
/// 起步结果 → `[backend] final` 一行 +(验收模式)`[perf] session` 一行。
///
/// `final_kind` 的口径:`respawn` 时是**兜底**(进程即将带着它重开),`refuse` / `none` 时是
/// `None`(什么都没跑起来)——见 [`backend_failed_text`]。成功时恒为 `primary`。
pub fn report_outcome(
decision: &Decision,
error: Option<&str>,
@@ -290,28 +218,42 @@ pub fn report_outcome(
mode: &str,
elapsed: Duration,
) {
let final_kind = match via {
"respawn" => decision.fallback,
_ => None,
};
match error {
None => report_backend_started(decision.primary, elapsed),
Some(message) => report_backend_failed(decision.primary, decision.fallback, via, message),
Some(message) => report_backend_failed(
decision.primary,
decision.fallback,
final_kind,
via,
message,
),
}
if enabled() {
report_perf_session(mode, elapsed, error.is_none());
}
}
// ---------------------------------------------------------------- [perf]
/// `[perf] boot`:启动三段(探测 / 字体 / 到 `run()` 之前)的实测毫秒数。
pub fn report_perf_boot(probe: Duration, font: Duration, to_run: Duration) {
///
/// `probe = None` ⇒ 打 `probe=skipped`。**不许拿 `0ms` 冒充一次实测**(M98 r1 p3-1:降级重开那一轮
/// 没计时却报了 0,而报告说「[perf] 全是 Instant 实测」)。
pub fn report_perf_boot(probe: Option<Duration>, font: Duration, to_run: Duration) {
eprintln!("{}", perf_boot_text(probe, font, to_run));
}
/// 见 [`report_perf_boot`]。
#[must_use]
pub fn perf_boot_text(probe: Duration, font: Duration, to_run: Duration) -> String {
pub fn perf_boot_text(probe: Option<Duration>, font: Duration, to_run: Duration) -> String {
format!(
"[perf] boot probe={}ms font={}ms to_run={}ms",
probe.as_millis(),
"[perf] boot probe={} font={}ms to_run={}ms",
probe.map_or_else(
|| "skipped".to_owned(),
|value| format!("{}ms", value.as_millis())
),
font.as_millis(),
to_run.as_millis()
)
+104 -35
View File
@@ -31,25 +31,33 @@ fn probe_of(adapters: &[(&str, &'static str)]) -> Probe {
/// 抓不住:窗口真的开成了多大(由验收脚本从 `[shot] … scale=` 与 PNG 像素换算)。
#[test]
fn window_line_is_labelled_as_a_request() {
let line = window_text(
(1180.0, 760.0),
Some((880.0, 560.0)),
None,
"default",
Some("Unlucky Launcher"),
);
let facts = WindowFacts {
size: (1180.0, 760.0),
min: Some((880.0, 560.0)),
resizable: true,
decorations: false,
transparent: false,
};
let line = window_text(&facts, None, "default", Some("Unlucky Launcher"));
assert!(
line.starts_with("[window] requested size=1180x760"),
"{line}"
);
assert!(line.contains("min=880x560"), "{line}");
assert!(line.contains("resizable=true"), "{line}");
assert!(line.contains("decorations=false"), "{line}");
assert!(line.contains("transparent=false"), "{line}");
assert!(line.contains("scale_requested=system"), "{line}");
assert!(line.contains("size_source=default"), "{line}");
assert!(line.contains("title=\"Unlucky Launcher\""), "{line}");
// `pages_demo` 那条通道没有最小尺寸 / 自定标题:必须写 none / <默认>,不许编 0x0
let harness = window_text((1180.0, 760.0), None, Some(1.0), "pages_demo", None);
let harness = window_text(
&WindowFacts { min: None, ..facts },
Some(1.0),
"pages_demo",
None,
);
assert!(harness.contains("min=none"), "{harness}");
assert!(harness.contains("title=<默认>"), "{harness}");
assert!(harness.contains("scale_requested=1"), "{harness}");
@@ -177,16 +185,16 @@ fn backend_final_lines_separate_started_from_failed() {
);
assert!(started.contains("evidence=probe-single-item"), "{started}");
// refuse:**什么都没跑起来** ⇒ final=none(不是兜底名,否则报告会把「这次用的是 tiny-skia」当真)
let refused = backend_failed_text(
Kind::Wgpu,
Some(Kind::TinySkia),
None,
"refuse",
"metal support not compiled in",
);
assert!(
refused.contains("final=tiny-skia result=failed"),
"{refused}"
);
assert!(refused.contains("final=none result=failed"), "{refused}");
assert!(refused.contains("fallback=tiny-skia"), "{refused}");
assert!(refused.contains("primary=wgpu"), "{refused}");
assert!(refused.contains("via=refuse"), "{refused}");
assert!(
@@ -194,13 +202,25 @@ fn backend_final_lines_separate_started_from_failed() {
"具名错误必须原样出现在同一行里:{refused}"
);
let none = backend_failed_text(Kind::TinySkia, None, "none", "boom");
let none = backend_failed_text(Kind::TinySkia, None, None, "none", "boom");
assert!(none.contains("final=none result=failed"), "{none}");
// respawn:进程即将带着兜底重开 ⇒ final=<兜底>
let respawned = backend_failed_text(
Kind::Wgpu,
Some(Kind::TinySkia),
Some(Kind::TinySkia),
"respawn",
"x",
);
assert!(respawned.contains("final=tiny-skia"), "{respawned}");
assert!(respawned.contains("via=respawn"), "{respawned}");
// 具名错误自带双引号时,`error="…"` 仍必须是**格式良好的单个字段**(否则脚本只能取到前半截)
let quoted = backend_failed_text(
Kind::Wgpu,
Some(Kind::TinySkia),
None,
"refuse",
"GraphicsAdapterNotFound { backend: \"wgpu\" }",
);
@@ -219,11 +239,14 @@ fn backend_final_lines_separate_started_from_failed() {
#[test]
fn perf_lines_carry_measured_millis() {
let boot = perf_boot_text(
Duration::from_millis(650),
Some(Duration::from_millis(650)),
Duration::from_millis(120),
Duration::from_millis(780),
);
assert_eq!(boot, "[perf] boot probe=650ms font=120ms to_run=780ms");
// 没探测 ⇒ skipped,不是 0ms(M98 r1 p3-1:拿 0 冒充实测)
let skipped = perf_boot_text(None, Duration::from_millis(120), Duration::from_millis(780));
assert!(skipped.contains("probe=skipped"), "{skipped}");
let session = perf_session_text("screenshot", Duration::from_millis(1234), true);
assert!(session.contains("mode=screenshot elapsed=1234ms result=ok"));
@@ -281,27 +304,6 @@ fn theme_source_follows_the_capture_flavor_order() {
assert!(system.contains("flavor=pending"), "{system}");
}
/// 抓得住:`via` 的三档与判据表第 8/9 行一致(有兜底 + 取证启动 ⇒ refuse,**不是** respawn)。
#[test]
fn failure_via_matches_the_two_paths_of_the_criteria_table() {
assert_eq!(
failure_via(true, false),
"respawn",
"交互启动 + 有兜底 ⇒ 换进程重开"
);
assert_eq!(
failure_via(true, true),
"refuse",
"取证启动 + 有兜底 ⇒ 拒绝出图"
);
assert_eq!(failure_via(false, false), "none", "没兜底 ⇒ 原样失败");
assert_eq!(
failure_via(false, true),
"none",
"没兜底时也不该冒出 refuse 的提示"
);
}
/// 抓得住:`[theme]` 的 `follows_system=true` 必须同时是 `flavor=pending`
/// (系统主题异步到达;写死一个值就是猜)。
#[test]
@@ -324,3 +326,70 @@ fn acceptance_switch_is_off_by_default() {
assert_eq!(ACCEPTANCE_ENV, "ULL_ACCEPTANCE");
assert_eq!(WINDOW_SIZE_ENV, "ULL_WINDOW_SIZE");
}
/// 抓得住:`theme_source()`(日志用)与 `Options::capture_flavor()`(真取值口)**两处优先级漂了**——
/// 漂了就会打出错的 `source=`,而图上的主题才是真的(M98 r1 p3-5:两份实现,没有交叉测试)。
/// 抓不住:系统主题异步到达之后的值(那要 `[theme] 系统主题 = …` 那一行)。
#[test]
fn theme_source_agrees_with_the_only_flavor_gate() {
use crate::cli::Options;
let cases: [&[&str]; 5] = [
&[],
&["--screenshot", "out.png"],
&["--screenshot", "out.png", "--theme=dark"],
&[
"--screenshot",
"out.png",
"--theme=dark",
"--screenshot-theme=light",
],
&["--theme=dark"],
];
for args in cases {
let options = Options::parse(args.iter().map(|arg| (*arg).to_owned())).expect("参数合法");
let (source, follows) = theme_source(
options.screenshot_theme.is_some(),
options.theme.is_some(),
options.screenshot.is_some(),
);
let flavor = options.capture_flavor();
assert_eq!(
follows,
flavor.is_none(),
"{}:follows_system 与 capture_flavor().is_none() 必须一致",
args.join(" ")
);
// 非「跟随系统」时,来源标签必须能解释 capture_flavor 给出的那个值
match source {
"screenshot-theme" => assert_eq!(flavor, options.screenshot_theme),
"theme" => assert_eq!(flavor, options.theme),
"default-light" => assert_eq!(flavor, Some(unlucky_design::color::SchemeFlavor::Light)),
"system" => assert_eq!(flavor, None),
other => panic!("没料到的来源 {other}"),
}
}
}
/// 抓得住:`via` 的三档与判据表第 8/9 行一致(有兜底 + 取证启动 ⇒ `refuse`,**不是** `respawn`)。
#[test]
fn failure_via_matches_the_two_paths_of_the_criteria_table() {
assert_eq!(
failure_via(true, false),
"respawn",
"交互启动 + 有兜底 ⇒ 换进程重开"
);
assert_eq!(
failure_via(true, true),
"refuse",
"取证启动 + 有兜底 ⇒ 拒绝出图"
);
assert_eq!(failure_via(false, false), "none", "没兜底 ⇒ 原样失败");
assert_eq!(
failure_via(false, true),
"none",
"没兜底时也不该冒出 refuse 的提示"
);
}
@@ -0,0 +1,142 @@
//! `[window]`:**本进程给窗口的那些设置**(不是我们从别处猜的)。
//!
//! 唯一产地是 `app/boot/diagnostics.rs::window_settings`(`pages_demo` 通道自己也有一份,
//! 它没有最小尺寸与自定标题)。这里只做「从 `window::Settings` 抽字段 → 排版」。
//!
//! **别再把这些字段写进格式化串**:M98 r1 p2-1 就是这么错的——日志说 `decorations=false`,
//! 而真实 `settings` 可以改成 `true` 而不惊动任何断言(踩坑记录 U1)。
/// 窗口尺寸覆盖(`ULL_WINDOW_SIZE=1520x900`)。
///
/// 为什么是环境变量而不是 `--width`:`cli.rs` 不在 M98 的 scope,而验收矩阵必须有「最小尺寸 /
/// 宽屏」两档,且必须是**真外壳**(自绘三键 + 标题栏)——`pages_demo` example 的标题栏是留白带,
/// 出不了「三键位置」那张图。语义与 `ULL_START_ROUTE` 一致:只影响这一次进程。
pub const WINDOW_SIZE_ENV: &str = "ULL_WINDOW_SIZE";
// ---------------------------------------------------------------- [window]
/// 窗口事实:**从真实的 `window::Settings` 抽出来**的那几个字段(唯一产地见
/// `app/boot/diagnostics.rs` 的 `window_settings`)。
///
/// 为什么要有这个结构:M98 r1 p2-1 —— 第一版把 `resizable=true decorations=false transparent=false`
/// 写在格式化串里,与真实 `settings` 无绑定;定向变异把 `settings.decorations` 改成 `true`,
/// 371 个测试全绿,而日志会与 `[caps]` 互相矛盾(踩坑记录 U1)。现在这些字段只能从
/// [`WindowFacts::from_settings`] 来,没有手抄的入口。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct WindowFacts {
/// 窗口尺寸(逻辑像素)。
pub size: (f32, f32),
/// 最小尺寸(`None` = 没设)。
pub min: Option<(f32, f32)>,
/// 可缩放。
pub resizable: bool,
/// 系统装饰(`false` ⇒ 自绘三键)。
pub decorations: bool,
/// 整窗透明。
pub transparent: bool,
}
impl WindowFacts {
/// 从**真正交给 iced/winit 的那个** `window::Settings` 抽字段。
#[must_use]
pub fn from_settings(settings: &iced::window::Settings) -> Self {
Self {
size: (settings.size.width, settings.size.height),
min: settings.min_size.map(|size| (size.width, size.height)),
resizable: settings.resizable,
decorations: settings.decorations,
transparent: settings.transparent,
}
}
}
/// `[window]` 的**请求值**一行(实际值见模块文档的表:脚本从 `[shot] … scale=` 与 PNG 换算)。
///
/// `title` 是 `Option`:`pages_demo` 那条通道没有自定标题,打 `<默认>` —— 编一个空串会让
/// 「标题是什么」变成猜的。
pub fn report_window(
facts: &WindowFacts,
scale: Option<f32>,
size_source: &str,
title: Option<&str>,
) {
eprintln!("{}", window_text(facts, scale, size_source, title));
}
/// 见 [`report_window`]。
#[must_use]
pub fn window_text(
facts: &WindowFacts,
scale: Option<f32>,
size_source: &str,
title: Option<&str>,
) -> String {
let min = facts
.min
.map_or_else(|| "none".to_owned(), |(w, h)| format!("{w}x{h}"));
let title = title.map_or_else(|| "<默认>".to_owned(), |text| format!("\"{text}\""));
format!(
"[window] requested size={}x{} min={min} resizable={} decorations={} transparent={} scale_requested={} size_source={size_source} title={title}",
facts.size.0,
facts.size.1,
facts.resizable,
facts.decorations,
facts.transparent,
scale.map_or_else(|| "system".to_owned(), |value| value.to_string()),
)
}
/// 解析 `ULL_WINDOW_SIZE`(`1520x900` / `1520X900` / `1520×900`)。
///
/// 返回 `None` = 不是这个格式(调用方保留默认尺寸并打一行警告,**不**静默忽略)。
#[must_use]
pub fn parse_window_size(raw: &str) -> Option<(f32, f32)> {
let trimmed = raw.trim();
let (width, height) = trimmed
.split_once(['x', 'X', '×', '*'])
.map(|(w, h)| (w.trim(), h.trim()))?;
let width: f32 = width.parse().ok()?;
let height: f32 = height.parse().ok()?;
(width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0)
.then_some((width, height))
}
/// 尺寸覆盖 → 实际要开多大 + **尺寸来源**(进 `[window] … size_source=`)。
///
/// 三种来源:`default`(没设 / 设了但解析不了)与 `ULL_WINDOW_SIZE`(生效)。
/// 小于最小尺寸时夹到最小并标 `:clamped-to-min`——iced 的 `min_size` 本来也会拦住,
/// 但那样日志会说「要求 640x480」而实际是 880x560,是典型的「日志说请求、报告当结果」。
#[must_use]
pub fn resolve_window_size(
raw: Option<&str>,
default: (f32, f32),
min: (f32, f32),
) -> ((f32, f32), &'static str) {
let Some(raw) = raw else {
return (default, "default");
};
let Some((width, height)) = parse_window_size(raw) else {
return (default, "ULL_WINDOW_SIZE:invalid");
};
let clamped = (width.max(min.0), height.max(min.1));
if clamped == (width, height) {
(clamped, "ULL_WINDOW_SIZE")
} else {
(clamped, "ULL_WINDOW_SIZE:clamped-to-min")
}
}
/// 窗尺寸(含来源)从进程环境取一遍——`app::boot` 与测试共用同一条路径。
#[must_use]
pub fn window_size(default: (f32, f32), min: (f32, f32)) -> ((f32, f32), &'static str) {
let raw = std::env::var(WINDOW_SIZE_ENV).ok();
let resolved = resolve_window_size(raw.as_deref(), default, min);
if resolved.1.ends_with("invalid") {
eprintln!(
"[window] {WINDOW_SIZE_ENV}={:?} 解析不了(要 1520x900 这种),按默认尺寸跑",
raw.unwrap_or_default()
);
}
resolved
}
+12
View File
@@ -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=`(这张图是哪个后端出的)。