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:
1 parent
18e36c6514
commit
f986981c12
9 files changed
+1429
-129
No files matched your search
@@ -0,0 +1,232 @@
|
||||
//! `[env]` 与 `[i18n]`:**本机是什么**(系统 / arch / 核数 / GPU / 时区)与**为什么是这个语言**。
|
||||
//!
|
||||
//! ## 语言链为什么在这里而不是 `platform/`
|
||||
//!
|
||||
//! `platform::locale::SystemLocale` 是 **Windows 注册表**那一路(`reg.exe`,零 FFI)。macOS 的
|
||||
//! 真值源是 `defaults read -g AppleLanguages`,而 M98 的 scope 没有 `platform/**`,所以
|
||||
//! [`AppleLanguages`] 先落在这里。**它该搬去 `platform/locale.rs`**——两处并存会漂(见交接报告)。
|
||||
|
||||
use unlucky_i18n::lang::{self, Detection, LocaleSource};
|
||||
|
||||
use crate::Probe;
|
||||
|
||||
// ---------------------------------------------------------------- [env]
|
||||
|
||||
/// `[env]`:系统 / arch / 核数 / 语言 / 时区 / 缩放请求 / GPU。
|
||||
///
|
||||
/// `gpu` 与 `hardware` **来自真实适配器枚举**([`Probe`]),不是请求值;枚举不到就写
|
||||
/// `<无适配器>` 并把 `hardware=false` 一起打出来——「没有适配器」与「没枚举」是两件事。
|
||||
///
|
||||
/// `display=` 恒为 `needs-window`:显示器尺寸只有拿到窗口句柄才问得到,而这一行在开窗之前就打了。
|
||||
/// 真值由验收脚本用 `system_profiler SPDisplaysDataType` 采进 `logs/00-env.txt`——**写个猜的值
|
||||
/// 比留白更坏**(那会让「Monitor 4K / 无外接」这种结论变成猜测)。
|
||||
#[must_use]
|
||||
pub fn env_line(probe: &Probe, language: &str, scale: Option<f32>, tz: Option<&str>) -> String {
|
||||
format!(
|
||||
"[env] os={} arch={} cores={} language={language} tz={} scale_requested={} display=needs-window \
|
||||
adapters={} hardware={} gpu=\"{}\"",
|
||||
std::env::consts::OS,
|
||||
std::env::consts::ARCH,
|
||||
std::thread::available_parallelism().map_or(0, std::num::NonZeroUsize::get),
|
||||
tz.unwrap_or("<未设置>"),
|
||||
scale.map_or_else(|| "system".to_owned(), |value| value.to_string()),
|
||||
probe.adapters.len(),
|
||||
probe.hardware,
|
||||
gpu_summary(probe),
|
||||
)
|
||||
}
|
||||
|
||||
/// 枚举结果里**最该被当成 GPU** 的那个适配器的摘要(硬件优先,其次任何适配器)。
|
||||
///
|
||||
/// 与 `Probe::describe` 的分工:那个给全量清单(日志一行),这个给「一句话结论」(报告表格用)。
|
||||
/// 判据同源:`device_type` 不是 `Cpu` / `Other` 才算硬件(WARP 排在第一是本机实测的形状)。
|
||||
#[must_use]
|
||||
pub fn gpu_summary(probe: &Probe) -> String {
|
||||
probe
|
||||
.adapters
|
||||
.iter()
|
||||
.find(|adapter| !matches!(adapter.device_type, "Cpu" | "Other"))
|
||||
.or_else(|| probe.adapters.first())
|
||||
.map_or_else(
|
||||
|| "<无适配器>".to_owned(),
|
||||
|adapter| format!("{} [{}]", adapter.name, adapter.device_type),
|
||||
)
|
||||
}
|
||||
|
||||
/// 时区(`TZ` 环境变量)。`std` 拿不到系统时区,所以这里**只报环境变量**,没有就写未设置。
|
||||
///
|
||||
/// macOS 上的真值时区由脚本用 `date +%Z%z` 采(那份是观测值,这个只是进程视角)。
|
||||
#[must_use]
|
||||
pub fn tz() -> Option<String> {
|
||||
std::env::var("TZ")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- [i18n]
|
||||
|
||||
/// 语言决策链的四级(顺序即优先级:显式值 > 平台源 > 环境变量 > 英文)。
|
||||
///
|
||||
/// 每一级的**原始值**都留着,日志才能解释「为什么是英文」——只打结论的话,macOS 上「英文」
|
||||
/// 到底是「用户就是英文」还是「一个源都没取到」根本分不出来。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Chain {
|
||||
/// `ui.language` 的显式值(当前没有读取点,恒 `None`)。
|
||||
pub setting: Option<String>,
|
||||
/// (源自称的名字, 原始 locale)。
|
||||
pub platform: (&'static str, Option<String>),
|
||||
/// (源自称的名字, 原始 locale)。
|
||||
pub env: (&'static str, Option<String>),
|
||||
/// 结论(由 [`lang::resolve`] 算,不在本模块重写一遍优先级)。
|
||||
pub detection: Detection,
|
||||
}
|
||||
|
||||
impl Chain {
|
||||
/// 用三个源探测一次。**每个源只读一次**(`defaults` / `reg.exe` 都是子进程)。
|
||||
#[must_use]
|
||||
pub fn read(
|
||||
setting: Option<&str>,
|
||||
platform: &dyn LocaleSource,
|
||||
env: &dyn LocaleSource,
|
||||
) -> Self {
|
||||
let platform = Captured {
|
||||
label: platform.label(),
|
||||
raw: platform.locale(),
|
||||
};
|
||||
let env = Captured {
|
||||
label: env.label(),
|
||||
raw: env.locale(),
|
||||
};
|
||||
// 优先级仍由 `resolve` 单点决定:这里把**已读到的值**包成同标签的源喂回去,
|
||||
// 不复制一遍 if/else(复制就会漂)。
|
||||
let detection = lang::resolve(setting, &platform, &env);
|
||||
Self {
|
||||
setting: setting.map(str::to_owned),
|
||||
platform: (platform.label, platform.raw),
|
||||
env: (env.label, env.raw),
|
||||
detection,
|
||||
}
|
||||
}
|
||||
|
||||
/// 本机(平台源 + 环境变量;配置未接线)。
|
||||
#[must_use]
|
||||
pub fn host() -> Self {
|
||||
Self::read(None, platform_locale_source(), &lang::EnvLocale)
|
||||
}
|
||||
|
||||
/// 结论语言标签(`zh-CN` / `en`)。
|
||||
#[must_use]
|
||||
pub fn language(&self) -> &'static str {
|
||||
self.detection.lang.tag()
|
||||
}
|
||||
|
||||
/// `[i18n]` 一行:四级链 + 结论。
|
||||
///
|
||||
/// `ui_text=pinned-en` 是**今天的事实**:`pages/mod.rs::render` 里下载页固定传 `Lang::En`、
|
||||
/// 日志页传 `model.lang`(默认也是 `En`),也就是**文案还没接这条结论**。不写这一格的话,
|
||||
/// 日志会让人以为「语言已经是中文了」——那是新的假绿。
|
||||
#[must_use]
|
||||
pub fn line(&self) -> String {
|
||||
format!(
|
||||
"[i18n] setting={} platform={}:{} env={}:{} -> {} ui_text=pinned-en",
|
||||
show(self.setting.as_deref()),
|
||||
self.platform.0,
|
||||
show(self.platform.1.as_deref()),
|
||||
self.env.0,
|
||||
show(self.env.1.as_deref()),
|
||||
self.detection.describe()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn show(value: Option<&str>) -> String {
|
||||
value.map_or_else(|| "<未设置>".to_owned(), |raw| raw.to_owned())
|
||||
}
|
||||
|
||||
/// 把值钉住的源(为了「读一次、按真优先级算」)。
|
||||
struct Captured {
|
||||
label: &'static str,
|
||||
raw: Option<String>,
|
||||
}
|
||||
|
||||
impl LocaleSource for Captured {
|
||||
fn locale(&self) -> Option<String> {
|
||||
self.raw.clone()
|
||||
}
|
||||
|
||||
fn label(&self) -> &'static str {
|
||||
self.label
|
||||
}
|
||||
}
|
||||
|
||||
/// 本平台的「系统语言」真值源。
|
||||
///
|
||||
/// - Windows:`reg.exe` 读注册表([`crate::platform::locale::SystemLocale`]);
|
||||
/// - macOS:[`AppleLanguages`](`defaults read -g AppleLanguages`);
|
||||
/// - 其它:没有平台源(`resolve` 会继续往环境变量走)。
|
||||
///
|
||||
/// 为什么 macOS 不复用 `SystemLocale`:它 `cfg!(windows)` 之外恒返回 `None`,在 Mac 上等于
|
||||
/// 「没有平台源」——于是语言只剩环境变量一级,而 GUI 程序从 Finder 启动时 `LANG` 通常是空的。
|
||||
#[must_use]
|
||||
pub fn platform_locale_source() -> &'static dyn LocaleSource {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
&AppleLanguages
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
&crate::platform::locale::SystemLocale
|
||||
}
|
||||
}
|
||||
|
||||
/// macOS 的系统语言源:`defaults read -g AppleLanguages` 的第一项。
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct AppleLanguages;
|
||||
|
||||
impl LocaleSource for AppleLanguages {
|
||||
fn locale(&self) -> Option<String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let output = std::process::Command::new("defaults")
|
||||
.args(["read", "-g", "AppleLanguages"])
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
parse_apple_languages(&String::from_utf8_lossy(&output.stdout))
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn label(&self) -> &'static str {
|
||||
"applelanguages"
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析 `defaults` 的输出,取**第一条**语言(真机格式见测试夹具)。
|
||||
///
|
||||
/// ```text
|
||||
/// (
|
||||
/// "zh-Hans-CN",
|
||||
/// "en-US"
|
||||
/// )
|
||||
/// ```
|
||||
///
|
||||
/// 首项是用户的**首选语言**;后面几项是「语言与地区」里的备选,与本 crate「只看主语言子标签」
|
||||
/// 的口径无关。
|
||||
#[must_use]
|
||||
pub fn parse_apple_languages(text: &str) -> Option<String> {
|
||||
let start = text.find('"')?;
|
||||
let rest = &text[start + 1..];
|
||||
let end = rest.find('"')?;
|
||||
let value = rest[..end].trim();
|
||||
(!value.is_empty()).then(|| value.to_owned())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "locale_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,141 @@
|
||||
//! `[env]` / `[i18n]` 的钉死测试(从 `tests.rs` 拆出来:那边撞到 400 行上限)。
|
||||
//!
|
||||
//! 这两类都靠「把真实输入喂进纯函数再逐字断言」,抓得住的是本项目的头号事故类型:
|
||||
//! **在需要结果的地方打印请求或常量**。
|
||||
|
||||
use super::*;
|
||||
use crate::probe::AdapterSummary;
|
||||
use crate::Probe;
|
||||
use unlucky_i18n::lang::LocaleSource;
|
||||
|
||||
fn probe_of(adapters: &[(&str, &'static str)]) -> Probe {
|
||||
let adapters: Vec<AdapterSummary> = adapters
|
||||
.iter()
|
||||
.map(|(name, device_type)| AdapterSummary {
|
||||
name: (*name).to_owned(),
|
||||
device_type,
|
||||
})
|
||||
.collect();
|
||||
let hardware = adapters
|
||||
.iter()
|
||||
.any(|adapter| !matches!(adapter.device_type, "Cpu" | "Other"));
|
||||
Probe { adapters, hardware }
|
||||
}
|
||||
|
||||
/// 抓得住:`[env]` 的 `gpu=` 取的是**硬件**适配器而不是枚举里的第一个(WARP 排在第一是本机实测
|
||||
/// 的形状:`Microsoft Basic Render Driver [Cpu]`)。改回 `adapters.first()` 这条就红。
|
||||
#[test]
|
||||
fn env_line_prefers_the_hardware_adapter() {
|
||||
let probe = probe_of(&[
|
||||
("Microsoft Basic Render Driver", "Cpu"),
|
||||
("AMD Radeon RX 6600 XT", "DiscreteGpu"),
|
||||
]);
|
||||
let line = env_line(&probe, "zh-CN", Some(1.0), Some("Asia/Shanghai"));
|
||||
assert!(
|
||||
line.contains("gpu=\"AMD Radeon RX 6600 XT [DiscreteGpu]\""),
|
||||
"{line}"
|
||||
);
|
||||
assert!(line.contains("adapters=2"), "{line}");
|
||||
assert!(line.contains("hardware=true"), "{line}");
|
||||
assert!(line.contains("language=zh-CN"), "{line}");
|
||||
assert!(line.contains("tz=Asia/Shanghai"), "{line}");
|
||||
assert!(line.contains("scale_requested=1"), "{line}");
|
||||
// 显示器只有开窗后才知道:这里必须留白并**自证**留白(写个猜的值比留白更坏)
|
||||
assert!(line.contains("display=needs-window"), "{line}");
|
||||
assert!(line.starts_with("[env] os="), "{line}");
|
||||
}
|
||||
|
||||
/// 抓得住:「一个适配器都没枚举到」不许写成 `gpu=""` 或者省略(那会让人以为枚举到了)。
|
||||
#[test]
|
||||
fn env_line_says_so_when_there_is_no_adapter() {
|
||||
let line = env_line(&Probe::default(), "en", None, None);
|
||||
assert!(line.contains("gpu=\"<无适配器>\""), "{line}");
|
||||
assert!(line.contains("adapters=0 hardware=false"), "{line}");
|
||||
assert!(line.contains("scale_requested=system"), "{line}");
|
||||
assert!(line.contains("tz=<未设置>"), "{line}");
|
||||
}
|
||||
|
||||
/// 抓得住:`[i18n]` 必须把**每一级的原始值**打出来,且结论跟着真优先级走。
|
||||
/// 抓不住:这台机器的 `defaults` / `reg.exe` 真的返回什么(那是真机验收)。
|
||||
#[test]
|
||||
fn i18n_line_shows_every_rung_and_the_resolved_language() {
|
||||
struct Fixed(Option<&'static str>, Option<&'static str>);
|
||||
impl LocaleSource for Fixed {
|
||||
fn locale(&self) -> Option<String> {
|
||||
self.0.map(str::to_owned)
|
||||
}
|
||||
fn label(&self) -> &'static str {
|
||||
self.1.unwrap_or("injected")
|
||||
}
|
||||
}
|
||||
|
||||
let chain = Chain::read(
|
||||
None,
|
||||
&Fixed(Some("zh-Hans-CN"), Some("applelanguages")),
|
||||
&Fixed(None, Some("env")),
|
||||
);
|
||||
assert_eq!(chain.language(), "zh-CN");
|
||||
let line = chain.line();
|
||||
assert!(
|
||||
line.contains("platform=applelanguages:zh-Hans-CN"),
|
||||
"平台源原始值必须打出来:{line}"
|
||||
);
|
||||
assert!(line.contains("env=env:<未设置>"), "{line}");
|
||||
assert!(
|
||||
line.contains("-> lang=zh-CN locale=zh-Hans-CN source=applelanguages"),
|
||||
"{line}"
|
||||
);
|
||||
assert!(line.contains("ui_text=pinned-en"), "{line}");
|
||||
}
|
||||
|
||||
/// 抓得住 macOS 最典型的失败形态:**一个源都没有**(Finder 启动的 GUI 进程既没有 `LANG`
|
||||
/// 也没有注册表)。这时必须是 `lang=en source=default`,而不是「假装读到了英文」。
|
||||
#[test]
|
||||
fn i18n_line_distinguishes_no_source_from_english_source() {
|
||||
struct Nothing;
|
||||
impl LocaleSource for Nothing {
|
||||
fn locale(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
fn label(&self) -> &'static str {
|
||||
"applelanguages"
|
||||
}
|
||||
}
|
||||
|
||||
let empty = Chain::read(None, &Nothing, &Nothing);
|
||||
assert_eq!(empty.language(), "en");
|
||||
assert!(empty.line().contains("source=default"), "{}", empty.line());
|
||||
|
||||
struct EnvEnglish;
|
||||
impl LocaleSource for EnvEnglish {
|
||||
fn locale(&self) -> Option<String> {
|
||||
Some("en_US.UTF-8".to_owned())
|
||||
}
|
||||
fn label(&self) -> &'static str {
|
||||
"env"
|
||||
}
|
||||
}
|
||||
|
||||
let env = Chain::read(None, &Nothing, &EnvEnglish);
|
||||
assert!(env.line().contains("source=env"), "{}", env.line());
|
||||
assert!(
|
||||
env.line().contains("locale=en_US.UTF-8"),
|
||||
"两种「英文」必须能从日志上分开:{}",
|
||||
env.line()
|
||||
);
|
||||
}
|
||||
|
||||
/// 真机 `defaults read -g AppleLanguages` 的输出形状(多行、带引号、逗号)。
|
||||
#[test]
|
||||
fn apple_languages_takes_the_first_entry() {
|
||||
let real = "(\n \"zh-Hans-CN\",\n \"en-US\"\n)\n";
|
||||
assert_eq!(parse_apple_languages(real).as_deref(), Some("zh-Hans-CN"));
|
||||
assert_eq!(
|
||||
parse_apple_languages("(\n \"en-US\"\n)\n").as_deref(),
|
||||
Some("en-US")
|
||||
);
|
||||
assert_eq!(parse_apple_languages("()"), None);
|
||||
assert_eq!(parse_apple_languages(""), None);
|
||||
// 空串(某些机器上 `defaults` 会回 `(\n)`)不许被当成语言
|
||||
assert_eq!(parse_apple_languages("(\n \"\"\n)"), None);
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
//! 验收诊断(M98):把「借朋友一台 Mac、反复问 7 个问题」压成「一次运行、日志自己回答」。
|
||||
//!
|
||||
//! ## 八类日志的分工(M98 任务表)
|
||||
//!
|
||||
//! | 类 | 谁打 | 是不是真实结果 |
|
||||
//! |---|---|---|
|
||||
//! | `[env]` | [`locale`] | 是(`os/arch/cores` 来自编译目标;`gpu` 来自真适配器枚举;`language` 来自下面的语言链) |
|
||||
//! | `[backend]` | 本模块(`app::boot` / `pages::harness` 调用) | 是(单项 `ICED_BACKEND` ⇒ 起步成功就是它;失败带具名错误) |
|
||||
//! | `[window]` | 本模块 | **请求值**(`requested=`)。实际值由脚本从 `[shot] …scale=` 与 PNG 像素尺寸换算 |
|
||||
//! | `[font]` | `fonts`(已有)+ [`visual`](`chain`) | 是:`platform=` 是实际落点,`chain=` 是令牌声明的回退链 |
|
||||
//! | `[theme]` | 本模块 | 是(来源 = 哪个参数/系统决定;系统真值更晚,由已有 `[theme] 系统主题 = …` 打) |
|
||||
//! | `[perf]` | 本模块 | 是(`Instant` 实测) |
|
||||
//! | `[i18n]` | [`locale`] | 是(四级链的**每一级原始值**都打出来,结论由 `unlucky_i18n::lang::resolve` 给) |
|
||||
//! | `[caps]` | [`visual`] | 半观测(`bundle` / `menu_bar_name` 从可执行文件路径读)、半**请求值**(`transparent` / `decorations`) |
|
||||
//!
|
||||
//! **反模式警告(本项目反复踩的那一类)**:在需要「结果」的地方打「请求」。所以本模块凡是请求值
|
||||
//! 都给字段加后缀(`requested` / `…_requested`),凡是推断出来的都给 `_source`,拿不到就写
|
||||
//! `<未设置>` / `pending` / `none`,**不猜**。
|
||||
//!
|
||||
//! ## 开关
|
||||
//!
|
||||
//! [`ACCEPTANCE_ENV`](`ULL_ACCEPTANCE=1`)打开「贵的那部分」:`[env]` 里的适配器枚举(本机
|
||||
//! 0.65~1.2 s)在 `--renderer=gpu|cpu` 时本来会被跳过,验收要它 ⇒ 这个开关强制枚举一次。
|
||||
//! 关掉它对产品行为没有任何影响:本模块只打日志,不改决策。
|
||||
//!
|
||||
//! 便宜的行(`[backend]` / `[window] requested` / `[theme]`)**没有开关**:报 bug 的人随手截的
|
||||
//! 日志里就该有「实际用哪个后端」这件事。
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::renderer::{Decision, Kind};
|
||||
use crate::{Backend, Probe};
|
||||
|
||||
pub mod locale;
|
||||
pub mod visual;
|
||||
|
||||
pub use locale::{env_line, tz, Chain};
|
||||
pub use visual::{caps_line, font_chain_line, report_caps, report_font_chain};
|
||||
|
||||
/// 打开「贵的诊断」的开关(见模块文档 §开关)。
|
||||
pub const ACCEPTANCE_ENV: &str = "ULL_ACCEPTANCE";
|
||||
|
||||
/// 窗口尺寸覆盖(`ULL_WINDOW_SIZE=1520x900`)。
|
||||
///
|
||||
/// 为什么是环境变量而不是 `--width`:`cli.rs` 不在 M98 的 scope,而验收矩阵必须有「最小尺寸 /
|
||||
/// 宽屏」两档,且必须是**真外壳**(自绘三键 + 标题栏)——`pages_demo` example 的标题栏是留白带,
|
||||
/// 出不了「三键位置」那张图。语义与 `ULL_START_ROUTE` 一致:只影响这一次进程。
|
||||
pub const WINDOW_SIZE_ENV: &str = "ULL_WINDOW_SIZE";
|
||||
|
||||
/// `ULL_ACCEPTANCE` 是否打开(缺省关;`0` / `off` / `false` / `no` 也算关)。
|
||||
#[must_use]
|
||||
pub fn enabled() -> bool {
|
||||
match std::env::var(ACCEPTANCE_ENV) {
|
||||
Ok(value) => !matches!(
|
||||
value.trim().to_ascii_lowercase().as_str(),
|
||||
"0" | "off" | "false" | "no"
|
||||
),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 第一行:告诉读日志的人「后面这些类是刻意打开的,不是噪声」。
|
||||
pub fn announce() {
|
||||
eprintln!(
|
||||
"[acceptance] on({ACCEPTANCE_ENV})八类诊断已开:[env] [backend] [window] [font] [theme] \
|
||||
[perf] [i18n] [caps]。关掉:{ACCEPTANCE_ENV}=0"
|
||||
);
|
||||
}
|
||||
|
||||
/// 启动那一段(只在不贵的时候由调用方决定打不打):`[env]` + `[i18n]`。
|
||||
///
|
||||
/// 两行都吃**同一次**适配器枚举与**同一次**语言链(`defaults` / `reg.exe` 是子进程,读两次就是
|
||||
/// 两次开销,而且两次之间值可能变)。
|
||||
pub fn report_startup(probe: &Probe, scale: Option<f32>) {
|
||||
let chain = Chain::host();
|
||||
eprintln!(
|
||||
"{}",
|
||||
env_line(probe, chain.language(), scale, tz().as_deref())
|
||||
);
|
||||
eprintln!("{}", chain.line());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- [theme]
|
||||
|
||||
/// `[theme]`:本次主题**由谁决定**(来源)+ 语义值。
|
||||
///
|
||||
/// `follows_system=true` 时 `flavor=pending`:系统主题是异步到达的(`iced::system::theme()`),
|
||||
/// 启动这一刻还没有值。真值随后由 `app/update.rs` 的 `[theme] 系统主题 = Dark` 给出——
|
||||
/// **两行合起来才是完整答案**,缺一行就分不清「跟了系统」和「没跟」。
|
||||
pub fn report_theme(source: &str, flavor: Option<&str>, follows_system: bool) {
|
||||
eprintln!("{}", theme_text(source, flavor, follows_system));
|
||||
}
|
||||
|
||||
/// 主题**由谁决定**(纯判据,好测):`(来源, 是否跟随系统)`。
|
||||
///
|
||||
/// 顺序与 `Options::capture_flavor` 一致:`--screenshot-theme` > `--theme` > 截图默认 Light >
|
||||
/// 系统。**取值口只有一个**(`capture_flavor`),这里只回答「为什么是它」。
|
||||
#[must_use]
|
||||
pub fn theme_source(screenshot_theme: bool, theme: bool, screenshot: bool) -> (&'static str, bool) {
|
||||
match (screenshot_theme, theme, screenshot) {
|
||||
(true, _, _) => ("screenshot-theme", false),
|
||||
(_, true, _) => ("theme", false),
|
||||
(_, _, true) => ("default-light", false),
|
||||
_ => ("system", true),
|
||||
}
|
||||
}
|
||||
|
||||
/// 见 [`report_theme`](单独返回字符串是为了让测试能逐字断言)。
|
||||
#[must_use]
|
||||
pub fn theme_text(source: &str, flavor: Option<&str>, follows_system: bool) -> String {
|
||||
format!(
|
||||
"[theme] source={source} flavor={} follows_system={follows_system}",
|
||||
flavor.unwrap_or("pending")
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- [window]
|
||||
|
||||
/// `[window]` 的**请求值**一行(实际值见模块文档的表:脚本从 `[shot] … scale=` 与 PNG 换算)。
|
||||
///
|
||||
/// `min` / `title` 是 `Option`:`pages_demo` 那条通道没有最小尺寸与自定标题,这时打 `none` /
|
||||
/// `<默认>` —— 填个 `0x0` 会让人以为是「最小尺寸是 0」,那是另一种假绿。
|
||||
pub fn report_window(
|
||||
size: (f32, f32),
|
||||
min: Option<(f32, f32)>,
|
||||
scale: Option<f32>,
|
||||
size_source: &str,
|
||||
title: Option<&str>,
|
||||
) {
|
||||
eprintln!("{}", window_text(size, min, scale, size_source, title));
|
||||
}
|
||||
|
||||
/// 见 [`report_window`]。
|
||||
#[must_use]
|
||||
pub fn window_text(
|
||||
size: (f32, f32),
|
||||
min: Option<(f32, f32)>,
|
||||
scale: Option<f32>,
|
||||
size_source: &str,
|
||||
title: Option<&str>,
|
||||
) -> String {
|
||||
let min = min.map_or_else(|| "none".to_owned(), |(w, h)| format!("{w}x{h}"));
|
||||
let title = title.map_or_else(|| "<默认>".to_owned(), |text| format!("\"{text}\""));
|
||||
format!(
|
||||
"[window] requested size={}x{} min={min} resizable=true decorations=false transparent=false \
|
||||
scale_requested={} size_source={size_source} title={title}",
|
||||
size.0,
|
||||
size.1,
|
||||
scale.map_or_else(|| "system".to_owned(), |value| value.to_string()),
|
||||
)
|
||||
}
|
||||
|
||||
/// 解析 `ULL_WINDOW_SIZE`(`1520x900` / `1520X900` / `1520×900`)。
|
||||
///
|
||||
/// 返回 `None` = 不是这个格式(调用方保留默认尺寸并打一行警告,**不**静默忽略)。
|
||||
#[must_use]
|
||||
pub fn parse_window_size(raw: &str) -> Option<(f32, f32)> {
|
||||
let trimmed = raw.trim();
|
||||
let (width, height) = trimmed
|
||||
.split_once(['x', 'X', '×', '*'])
|
||||
.map(|(w, h)| (w.trim(), h.trim()))?;
|
||||
let width: f32 = width.parse().ok()?;
|
||||
let height: f32 = height.parse().ok()?;
|
||||
(width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0)
|
||||
.then_some((width, height))
|
||||
}
|
||||
|
||||
/// 尺寸覆盖 → 实际要开多大 + **尺寸来源**(进 `[window] … size_source=`)。
|
||||
///
|
||||
/// 三种来源:`default`(没设 / 设了但解析不了)与 `ULL_WINDOW_SIZE`(生效)。
|
||||
/// 小于最小尺寸时夹到最小并标 `:clamped-to-min`——iced 的 `min_size` 本来也会拦住,
|
||||
/// 但那样日志会说「要求 640x480」而实际是 880x560,是典型的「日志说请求、报告当结果」。
|
||||
#[must_use]
|
||||
pub fn resolve_window_size(
|
||||
raw: Option<&str>,
|
||||
default: (f32, f32),
|
||||
min: (f32, f32),
|
||||
) -> ((f32, f32), &'static str) {
|
||||
let Some(raw) = raw else {
|
||||
return (default, "default");
|
||||
};
|
||||
let Some((width, height)) = parse_window_size(raw) else {
|
||||
return (default, "ULL_WINDOW_SIZE:invalid");
|
||||
};
|
||||
|
||||
let clamped = (width.max(min.0), height.max(min.1));
|
||||
if clamped == (width, height) {
|
||||
(clamped, "ULL_WINDOW_SIZE")
|
||||
} else {
|
||||
(clamped, "ULL_WINDOW_SIZE:clamped-to-min")
|
||||
}
|
||||
}
|
||||
|
||||
/// 窗尺寸(含来源)从进程环境取一遍——`app::boot` 与测试共用同一条路径。
|
||||
#[must_use]
|
||||
pub fn window_size(default: (f32, f32), min: (f32, f32)) -> ((f32, f32), &'static str) {
|
||||
let raw = std::env::var(WINDOW_SIZE_ENV).ok();
|
||||
let resolved = resolve_window_size(raw.as_deref(), default, min);
|
||||
if resolved.1.ends_with("invalid") {
|
||||
eprintln!(
|
||||
"[window] {WINDOW_SIZE_ENV}={:?} 解析不了(要 1520x900 这种),按默认尺寸跑",
|
||||
raw.unwrap_or_default()
|
||||
);
|
||||
}
|
||||
resolved
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- [backend]
|
||||
|
||||
/// `[backend] plan`:这一次准备试**哪些**后端(单项 + 本 crate 的兜底,不是 iced 的候选回退)。
|
||||
pub fn report_backend_plan(decision: &Decision, request: Backend) {
|
||||
eprintln!("{}", backend_plan_text(decision, request));
|
||||
}
|
||||
|
||||
/// 见 [`report_backend_plan`]。
|
||||
#[must_use]
|
||||
pub fn backend_plan_text(decision: &Decision, request: Backend) -> String {
|
||||
format!(
|
||||
"[backend] plan request={} primary={} fallback={} candidates=1 reason={}",
|
||||
request.name(),
|
||||
decision.primary.name(),
|
||||
decision.fallback.map_or("none", Kind::name),
|
||||
decision.reason,
|
||||
)
|
||||
}
|
||||
|
||||
/// `[backend] final`:起步的**结果**。
|
||||
///
|
||||
/// 判据是 M97 的「单项 `ICED_BACKEND`」:候选只有一项 ⇒ `run()` 能正常起来就必定是它
|
||||
/// (没有静默回退的空间)。`evidence=probe-single-item` 就是这条推理的名字,别把它读成
|
||||
/// 「我们查了渲染器内部」——那件事只有 `[gpu] iced_wgpu: …Selected: AdapterInfo…` 那行能做。
|
||||
pub fn report_backend_started(primary: Kind, elapsed: Duration) {
|
||||
eprintln!("{}", backend_started_text(primary, elapsed));
|
||||
}
|
||||
|
||||
/// 见 [`report_backend_started`]。
|
||||
#[must_use]
|
||||
pub fn backend_started_text(primary: Kind, elapsed: Duration) -> String {
|
||||
format!(
|
||||
"[backend] final={} result=started elapsed={}ms evidence=probe-single-item",
|
||||
primary.name(),
|
||||
elapsed.as_millis()
|
||||
)
|
||||
}
|
||||
|
||||
/// `[backend] final`:起步失败(具名错误原样带在 `error=` 里,一行可 grep)。
|
||||
///
|
||||
/// `via` = 失败之后去了哪:
|
||||
/// - `respawn`:交互启动换了进程用兜底后端重开(子进程自己再打一遍 plan/final);
|
||||
/// - `refuse`:取证启动(`--screenshot`)拒绝降级出图,**没有图**;
|
||||
/// - `none`:没有兜底(`--renderer=cpu` / 没编 wgpu),原样失败。
|
||||
pub fn report_backend_failed(primary: Kind, fallback: Option<Kind>, via: &str, error: &str) {
|
||||
eprintln!("{}", backend_failed_text(primary, fallback, via, error));
|
||||
}
|
||||
|
||||
/// 见 [`report_backend_failed`]。
|
||||
#[must_use]
|
||||
pub fn backend_failed_text(
|
||||
primary: Kind,
|
||||
fallback: Option<Kind>,
|
||||
via: &str,
|
||||
error: &str,
|
||||
) -> String {
|
||||
let final_name = fallback.map_or("none", Kind::name);
|
||||
// `error="…"` 必须是**格式良好的单个字段**:具名错误里常自带双引号(wgpu 的 Debug 输出),
|
||||
// 原样塞进去会把这一行撕成两半,脚本按 `error="` 取值就只能取到前半截。
|
||||
let error = error.replace('"', "'").replace(['\n', '\r'], " ");
|
||||
format!(
|
||||
"[backend] final={final_name} result=failed primary={} fallback={final_name} via={via} \
|
||||
error=\"{error}\"",
|
||||
primary.name(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 起步失败后去了哪(日志字段 `via=`;与 `app::boot::OnFailure` 同一套取值)。
|
||||
#[must_use]
|
||||
pub fn failure_via(has_fallback: bool, evidence: bool) -> &'static str {
|
||||
match (has_fallback, evidence) {
|
||||
(false, _) => "none",
|
||||
(true, true) => "refuse",
|
||||
(true, false) => "respawn",
|
||||
}
|
||||
}
|
||||
|
||||
/// 起步结果 → `[backend] final` 一行 +(验收模式)`[perf] session` 一行。
|
||||
pub fn report_outcome(
|
||||
decision: &Decision,
|
||||
error: Option<&str>,
|
||||
via: &str,
|
||||
mode: &str,
|
||||
elapsed: Duration,
|
||||
) {
|
||||
match error {
|
||||
None => report_backend_started(decision.primary, elapsed),
|
||||
Some(message) => report_backend_failed(decision.primary, decision.fallback, via, message),
|
||||
}
|
||||
if enabled() {
|
||||
report_perf_session(mode, elapsed, error.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- [perf]
|
||||
|
||||
/// `[perf] boot`:启动三段(探测 / 字体 / 到 `run()` 之前)的实测毫秒数。
|
||||
pub fn report_perf_boot(probe: Duration, font: Duration, to_run: Duration) {
|
||||
eprintln!("{}", perf_boot_text(probe, font, to_run));
|
||||
}
|
||||
|
||||
/// 见 [`report_perf_boot`]。
|
||||
#[must_use]
|
||||
pub fn perf_boot_text(probe: Duration, font: Duration, to_run: Duration) -> String {
|
||||
format!(
|
||||
"[perf] boot probe={}ms font={}ms to_run={}ms",
|
||||
probe.as_millis(),
|
||||
font.as_millis(),
|
||||
to_run.as_millis()
|
||||
)
|
||||
}
|
||||
|
||||
/// `[perf] session`:`iced::application().run()` 从进到出(或到出图)的总时长。
|
||||
///
|
||||
/// 取证启动(`--screenshot`)的出图在第 3 帧,所以这条约等于「进程启动 → 出图」——验收里它就是
|
||||
/// 「这个场景要多久」。交互启动那次是「窗口开了多久」,所以带 `mode=` 区分,别混着读。
|
||||
pub fn report_perf_session(mode: &str, elapsed: Duration, ok: bool) {
|
||||
eprintln!("{}", perf_session_text(mode, elapsed, ok));
|
||||
}
|
||||
|
||||
/// 见 [`report_perf_session`]。
|
||||
#[must_use]
|
||||
pub fn perf_session_text(mode: &str, elapsed: Duration, ok: bool) -> String {
|
||||
format!(
|
||||
"[perf] session mode={mode} elapsed={}ms result={}",
|
||||
elapsed.as_millis(),
|
||||
if ok { "ok" } else { "failed" }
|
||||
)
|
||||
}
|
||||
|
||||
/// `[perf] scene`:**取证场景**(`pages_demo` 通道)的帧数据。
|
||||
///
|
||||
/// `fps` 由 `frames / elapsed` 算——注意它是**取证窗口**(第 1 帧到第 N 帧)的吞吐,不是长时间
|
||||
/// 稳态帧率:产品路径的长跑帧率看 `[tick]` 那串(`ULL_TICK_TRACE=1`,每秒一行、带累计帧数)。
|
||||
/// 两个来源刻意不合并:一个是「出这张图花了多久」,一个是「放着不动烧不烧风扇」。
|
||||
pub fn report_perf_scene(
|
||||
scene: &str,
|
||||
frames: u32,
|
||||
first_frame: Option<Duration>,
|
||||
elapsed: Duration,
|
||||
) {
|
||||
eprintln!("{}", perf_scene_text(scene, frames, first_frame, elapsed));
|
||||
}
|
||||
|
||||
/// 见 [`report_perf_scene`]。
|
||||
#[must_use]
|
||||
pub fn perf_scene_text(
|
||||
scene: &str,
|
||||
frames: u32,
|
||||
first_frame: Option<Duration>,
|
||||
elapsed: Duration,
|
||||
) -> String {
|
||||
// fps 只从**首帧之后**的帧间隔算:整段起点到首帧那段是启动成本(字体扫描、适配器枚举),
|
||||
// 把它算进帧率会让「8 fps」这种结论完全是启动慢的假象。少于 2 帧就没有间隔可测 ⇒ unknown。
|
||||
let fps = first_frame.and_then(|first| {
|
||||
let window = elapsed.checked_sub(first)?;
|
||||
(frames >= 2 && !window.is_zero()).then(|| f64::from(frames - 1) / window.as_secs_f64())
|
||||
});
|
||||
format!(
|
||||
"[perf] scene={scene} frames={frames} first_frame={}ms elapsed={}ms fps={}",
|
||||
first_frame.map_or_else(
|
||||
|| "unknown".to_owned(),
|
||||
|value| value.as_millis().to_string()
|
||||
),
|
||||
elapsed.as_millis(),
|
||||
fps.map_or_else(|| "unknown".to_owned(), |value| format!("{value:.1}")),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,326 @@
|
||||
//! `[env]` / `[i18n]` / `[window]` / `[caps]` / `[backend]` / `[perf]` 六类行的钉死测试。
|
||||
//!
|
||||
//! **这些测试只钉「给定真实输入 → 打出什么」**:格式化函数是纯的,所以能断言逐字字符串。
|
||||
//! 它们抓不住「输入本身是不是真的」(那要靠验收脚本在真机上 grep),抓得住的是这一整类事故:
|
||||
//! **在需要结果的地方打印请求 / 常量**——每一格都有一个能改坏的断言。
|
||||
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use super::*;
|
||||
use crate::probe::AdapterSummary;
|
||||
use crate::renderer::decision::{decide, Kind};
|
||||
use crate::Probe;
|
||||
use unlucky_design::typography::Platform as FontPlatform;
|
||||
|
||||
fn probe_of(adapters: &[(&str, &'static str)]) -> Probe {
|
||||
let adapters: Vec<AdapterSummary> = adapters
|
||||
.iter()
|
||||
.map(|(name, device_type)| AdapterSummary {
|
||||
name: (*name).to_owned(),
|
||||
device_type,
|
||||
})
|
||||
.collect();
|
||||
let hardware = adapters
|
||||
.iter()
|
||||
.any(|adapter| !matches!(adapter.device_type, "Cpu" | "Other"));
|
||||
Probe { adapters, hardware }
|
||||
}
|
||||
|
||||
/// 抓得住:`[window]` 打的是**请求**,字段名必须自证(`requested` / `scale_requested`)。
|
||||
/// 抓不住:窗口真的开成了多大(由验收脚本从 `[shot] … scale=` 与 PNG 像素换算)。
|
||||
#[test]
|
||||
fn window_line_is_labelled_as_a_request() {
|
||||
let line = window_text(
|
||||
(1180.0, 760.0),
|
||||
Some((880.0, 560.0)),
|
||||
None,
|
||||
"default",
|
||||
Some("Unlucky Launcher"),
|
||||
);
|
||||
assert!(
|
||||
line.starts_with("[window] requested size=1180x760"),
|
||||
"{line}"
|
||||
);
|
||||
assert!(line.contains("min=880x560"), "{line}");
|
||||
assert!(line.contains("decorations=false"), "{line}");
|
||||
assert!(line.contains("scale_requested=system"), "{line}");
|
||||
assert!(line.contains("size_source=default"), "{line}");
|
||||
assert!(line.contains("title=\"Unlucky Launcher\""), "{line}");
|
||||
|
||||
// `pages_demo` 那条通道没有最小尺寸 / 自定标题:必须写 none / <默认>,不许编 0x0
|
||||
let harness = window_text((1180.0, 760.0), None, Some(1.0), "pages_demo", None);
|
||||
assert!(harness.contains("min=none"), "{harness}");
|
||||
assert!(harness.contains("title=<默认>"), "{harness}");
|
||||
assert!(harness.contains("scale_requested=1"), "{harness}");
|
||||
}
|
||||
|
||||
/// `ULL_WINDOW_SIZE` 的三种分隔符都要认;非法值必须返回 `None`(调用方打警告,不许静默吞)。
|
||||
#[test]
|
||||
fn window_size_override_accepts_the_three_separators() {
|
||||
for raw in ["1520x900", "1520X900", "1520×900", " 1180 x 760 "] {
|
||||
assert!(parse_window_size(raw).is_some(), "{raw:?}");
|
||||
}
|
||||
assert_eq!(parse_window_size("1520x900"), Some((1520.0, 900.0)));
|
||||
for raw in [
|
||||
"",
|
||||
"1520",
|
||||
"1520x",
|
||||
"x900",
|
||||
"0x900",
|
||||
"-1x900",
|
||||
"axb",
|
||||
"1520x900x1",
|
||||
] {
|
||||
assert_eq!(parse_window_size(raw), None, "{raw:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// 抓得住:`size_source=` 必须能区分「默认」「覆盖生效」「覆盖被夹到最小」「覆盖是垃圾」——
|
||||
/// 四种情况打出一样的两张图,是最典型的假绿(脚本以为在测宽屏,其实测的是默认)。
|
||||
#[test]
|
||||
fn window_size_source_distinguishes_the_four_cases() {
|
||||
let default = (1180.0, 760.0);
|
||||
let min = (880.0, 560.0);
|
||||
|
||||
assert_eq!(
|
||||
resolve_window_size(None, default, min),
|
||||
(default, "default")
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_window_size(Some("1520x900"), default, min),
|
||||
((1520.0, 900.0), "ULL_WINDOW_SIZE")
|
||||
);
|
||||
// 最小尺寸档:正好等于 min,不该被标成 clamped
|
||||
assert_eq!(
|
||||
resolve_window_size(Some("880x560"), default, min),
|
||||
((880.0, 560.0), "ULL_WINDOW_SIZE")
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_window_size(Some("640x480"), default, min),
|
||||
(min, "ULL_WINDOW_SIZE:clamped-to-min")
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_window_size(Some("wide-ish"), default, min),
|
||||
(default, "ULL_WINDOW_SIZE:invalid")
|
||||
);
|
||||
}
|
||||
|
||||
/// 抓得住:macOS 上「菜单栏显示什么名字」从可执行文件路径就能读出来——bundle 与非 bundle
|
||||
/// 是两条不同的答案,且 `controls=self-drawn` 由 `decorations=false` 推出来。
|
||||
#[test]
|
||||
fn caps_line_reads_the_menu_bar_name_from_the_executable_path() {
|
||||
let plain = caps_line(
|
||||
Some(Path::new(
|
||||
"/Users/x/ull/target/release/unlucky-launcher-iced",
|
||||
)),
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
assert!(plain.contains("bundle=no"), "{plain}");
|
||||
assert!(
|
||||
plain.contains("menu_bar_name=\"unlucky-launcher-iced\""),
|
||||
"{plain}"
|
||||
);
|
||||
assert!(plain.contains("menu_bar_source=exe-stem"), "{plain}");
|
||||
assert!(plain.contains("controls=self-drawn"), "{plain}");
|
||||
|
||||
let bundled = caps_line(
|
||||
Some(Path::new(
|
||||
"/Applications/Unlucky Launcher.app/Contents/MacOS/unlucky-launcher-iced",
|
||||
)),
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
assert!(bundled.contains("bundle=yes"), "{bundled}");
|
||||
assert!(
|
||||
bundled.contains("menu_bar_name=\"Unlucky Launcher\""),
|
||||
"{bundled}"
|
||||
);
|
||||
assert!(bundled.contains("menu_bar_source=bundle"), "{bundled}");
|
||||
|
||||
let native = caps_line(Some(Path::new("/tmp/ull")), false, true, true);
|
||||
assert!(native.contains("controls=native"), "{native}");
|
||||
}
|
||||
|
||||
/// 抓得住:`bundle=` 与 `menu_bar_name=` 拿不到路径时不猜(`<未知>`)。
|
||||
#[test]
|
||||
fn caps_line_does_not_guess_without_a_path() {
|
||||
let line = caps_line(None, false, true, false);
|
||||
assert!(line.contains("bundle=no"), "{line}");
|
||||
assert!(line.contains("menu_bar_name=\"<未知>\""), "{line}");
|
||||
}
|
||||
|
||||
/// 抓得住:`[backend] plan` 打的是**判据产物**(primary / fallback / reason),不是请求。
|
||||
/// 改回 `request=` 当 primary 用、或把兜底写成候选列表,这条就红。
|
||||
#[test]
|
||||
fn backend_plan_prints_the_decision_not_the_request() {
|
||||
let probe = probe_of(&[("Apple M1", "IntegratedGpu")]);
|
||||
let decision = decide(crate::Backend::Auto, &probe, true);
|
||||
let line = backend_plan_text(&decision, crate::Backend::Auto);
|
||||
assert!(line.contains("request=auto"), "{line}");
|
||||
assert!(line.contains("primary=wgpu"), "{line}");
|
||||
assert!(line.contains("fallback=tiny-skia"), "{line}");
|
||||
assert!(line.contains("candidates=1"), "{line}");
|
||||
assert!(line.contains("reason=auto-hardware-adapter"), "{line}");
|
||||
}
|
||||
|
||||
/// 抓得住:`[backend] final` 的两条路都要能一眼读出「到底跑起来的是谁」。
|
||||
#[test]
|
||||
fn backend_final_lines_separate_started_from_failed() {
|
||||
let started = backend_started_text(Kind::Wgpu, Duration::from_millis(37));
|
||||
assert!(
|
||||
started.contains("final=wgpu result=started elapsed=37ms"),
|
||||
"{started}"
|
||||
);
|
||||
assert!(started.contains("evidence=probe-single-item"), "{started}");
|
||||
|
||||
let refused = backend_failed_text(
|
||||
Kind::Wgpu,
|
||||
Some(Kind::TinySkia),
|
||||
"refuse",
|
||||
"metal support not compiled in",
|
||||
);
|
||||
assert!(
|
||||
refused.contains("final=tiny-skia result=failed"),
|
||||
"{refused}"
|
||||
);
|
||||
assert!(refused.contains("primary=wgpu"), "{refused}");
|
||||
assert!(refused.contains("via=refuse"), "{refused}");
|
||||
assert!(
|
||||
refused.contains("error=\"metal support not compiled in\""),
|
||||
"具名错误必须原样出现在同一行里:{refused}"
|
||||
);
|
||||
|
||||
let none = backend_failed_text(Kind::TinySkia, None, "none", "boom");
|
||||
assert!(none.contains("final=none result=failed"), "{none}");
|
||||
|
||||
// 具名错误自带双引号时,`error="…"` 仍必须是**格式良好的单个字段**(否则脚本只能取到前半截)
|
||||
let quoted = backend_failed_text(
|
||||
Kind::Wgpu,
|
||||
Some(Kind::TinySkia),
|
||||
"refuse",
|
||||
"GraphicsAdapterNotFound { backend: \"wgpu\" }",
|
||||
);
|
||||
assert!(
|
||||
quoted.contains("error=\"GraphicsAdapterNotFound { backend: 'wgpu' }\""),
|
||||
"{quoted}"
|
||||
);
|
||||
assert_eq!(
|
||||
quoted.matches('"').count(),
|
||||
2,
|
||||
"整行只该有 error= 那一对引号:{quoted}"
|
||||
);
|
||||
}
|
||||
|
||||
/// 抓得住:`[perf]` 打的是实测毫秒数,不是「预计」;场景行的 fps 由帧数/时长算。
|
||||
#[test]
|
||||
fn perf_lines_carry_measured_millis() {
|
||||
let boot = perf_boot_text(
|
||||
Duration::from_millis(650),
|
||||
Duration::from_millis(120),
|
||||
Duration::from_millis(780),
|
||||
);
|
||||
assert_eq!(boot, "[perf] boot probe=650ms font=120ms to_run=780ms");
|
||||
|
||||
let session = perf_session_text("screenshot", Duration::from_millis(1234), true);
|
||||
assert!(session.contains("mode=screenshot elapsed=1234ms result=ok"));
|
||||
|
||||
let scene = perf_scene_text(
|
||||
"instances-minimal-w880",
|
||||
3,
|
||||
Some(Duration::from_millis(210)),
|
||||
Duration::from_millis(300),
|
||||
);
|
||||
assert!(
|
||||
scene.contains("frames=3 first_frame=210ms elapsed=300ms"),
|
||||
"{scene}"
|
||||
);
|
||||
// 只算首帧之后的帧间隔:(3-1) 帧 / (0.30-0.21) s ≈ 22.2 fps(启动那段不算进帧率)
|
||||
assert!(scene.contains("fps=22.2"), "{scene}");
|
||||
// 少于 2 帧 / 没有首帧时间 ⇒ 没有间隔可测,必须是 unknown 而不是编一个数
|
||||
let one = perf_scene_text(
|
||||
"x",
|
||||
1,
|
||||
Some(Duration::from_millis(5)),
|
||||
Duration::from_millis(9),
|
||||
);
|
||||
assert!(one.contains("fps=unknown"), "{one}");
|
||||
let no_first = perf_scene_text("x", 5, None, Duration::from_millis(9));
|
||||
assert!(no_first.contains("fps=unknown"), "{no_first}");
|
||||
}
|
||||
|
||||
/// 抓得住:`[font] chain` 来自令牌的三条栈(顺序 = 首选 → 兜底),不是某个常量字符串。
|
||||
#[test]
|
||||
fn font_chain_lists_the_token_stacks_in_order() {
|
||||
let line = font_chain_line(FontPlatform::MacOs);
|
||||
assert!(line.starts_with("[font] chain platform=macos"), "{line}");
|
||||
// macOS 的中文兜底族必须在链里(M98 要验的就是它有没有被用上)
|
||||
assert!(line.contains("PingFang SC"), "{line}");
|
||||
assert!(line.contains("mono="), "{line}");
|
||||
// 顺序:首选族在前,通用族名在最后
|
||||
let text = line.split("text=").nth(1).expect("text 栈");
|
||||
let first = text.split('>').next().unwrap_or_default();
|
||||
assert!(!first.contains("sans-serif"), "栈头不该是通用族名:{line}");
|
||||
}
|
||||
|
||||
/// 抓得住:`[theme]` 的来源判定(哪个参数赢)与「跟随系统 = pending」这条不变量。
|
||||
#[test]
|
||||
fn theme_source_follows_the_capture_flavor_order() {
|
||||
// --screenshot-theme 压过 --theme,也压过「这是截图模式」的默认值
|
||||
assert_eq!(theme_source(true, true, true), ("screenshot-theme", false));
|
||||
assert_eq!(theme_source(false, true, true), ("theme", false));
|
||||
// 截图模式没给主题 ⇒ 默认 Light(不是「等系统」)
|
||||
assert_eq!(theme_source(false, false, true), ("default-light", false));
|
||||
// 交互模式没给主题 ⇒ 跟随系统(异步,真值晚到)
|
||||
assert_eq!(theme_source(false, false, false), ("system", true));
|
||||
|
||||
let system = theme_text("system", None, true);
|
||||
assert!(system.contains("flavor=pending"), "{system}");
|
||||
}
|
||||
|
||||
/// 抓得住:`via` 的三档与判据表第 8/9 行一致(有兜底 + 取证启动 ⇒ refuse,**不是** respawn)。
|
||||
#[test]
|
||||
fn failure_via_matches_the_two_paths_of_the_criteria_table() {
|
||||
assert_eq!(
|
||||
failure_via(true, false),
|
||||
"respawn",
|
||||
"交互启动 + 有兜底 ⇒ 换进程重开"
|
||||
);
|
||||
assert_eq!(
|
||||
failure_via(true, true),
|
||||
"refuse",
|
||||
"取证启动 + 有兜底 ⇒ 拒绝出图"
|
||||
);
|
||||
assert_eq!(failure_via(false, false), "none", "没兜底 ⇒ 原样失败");
|
||||
assert_eq!(
|
||||
failure_via(false, true),
|
||||
"none",
|
||||
"没兜底时也不该冒出 refuse 的提示"
|
||||
);
|
||||
}
|
||||
|
||||
/// 抓得住:`[theme]` 的 `follows_system=true` 必须同时是 `flavor=pending`
|
||||
/// (系统主题异步到达;写死一个值就是猜)。
|
||||
#[test]
|
||||
fn theme_line_marks_the_async_system_case_as_pending() {
|
||||
let system = theme_text("system", None, true);
|
||||
assert!(system.contains("source=system"), "{system}");
|
||||
assert!(system.contains("flavor=pending"), "{system}");
|
||||
assert!(system.contains("follows_system=true"), "{system}");
|
||||
|
||||
let pinned = theme_text("screenshot-theme", Some("dark"), false);
|
||||
assert!(pinned.contains("flavor=dark"), "{pinned}");
|
||||
assert!(pinned.contains("follows_system=false"), "{pinned}");
|
||||
}
|
||||
|
||||
/// 抓得住:开关默认关(产品启动不该多花 1 s 去枚举适配器),`0/off/false/no` 也算关。
|
||||
#[test]
|
||||
fn acceptance_switch_is_off_by_default() {
|
||||
// 这个测试不读进程环境(会与其它测试抢 `set_var`),只钉常量名:
|
||||
// 名字改了验收脚本就失效,所以它必须是测试锁住的东西。
|
||||
assert_eq!(ACCEPTANCE_ENV, "ULL_ACCEPTANCE");
|
||||
assert_eq!(WINDOW_SIZE_ENV, "ULL_WINDOW_SIZE");
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
//! `[font]` 的回退链与 `[caps]` 的平台能力——两件「看一眼就知道对不对」的事。
|
||||
//!
|
||||
//! 这里的两个函数都是**纯格式化**(除了 `[caps]` 读一次 `current_exe()`,那是调用方传进来的):
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use unlucky_design::typography::{FontFamily, Platform as FontPlatform};
|
||||
|
||||
// ---------------------------------------------------------------- [font]
|
||||
|
||||
/// `[font] chain`:令牌里三条栈的**声明**顺序(首选 → 兜底 → 通用族名)。
|
||||
///
|
||||
/// 与 `fonts` 打的 `[font] platform=…` 互补:那行是「实际落到哪个族」,这行是「本来想落哪些」。
|
||||
/// 缺字体(方块字)时要看的是**两者的差**:链里有 `"PingFang SC"` 但 `text=` 落到别的族,
|
||||
/// 就是这台机器没有苹方。
|
||||
#[must_use]
|
||||
pub fn font_chain_line(platform: FontPlatform) -> String {
|
||||
let stacks = [
|
||||
("display", FontFamily::Display),
|
||||
("text", FontFamily::Text),
|
||||
("mono", FontFamily::Mono),
|
||||
]
|
||||
.map(|(name, family)| {
|
||||
let chain = platform
|
||||
.font_stack(family)
|
||||
.iter()
|
||||
.map(|face| format!("\"{}\"", face.family))
|
||||
.collect::<Vec<_>>()
|
||||
.join(">");
|
||||
format!("{name}={chain}")
|
||||
})
|
||||
.join(" ");
|
||||
|
||||
format!(
|
||||
"[font] chain platform={} {stacks}(令牌声明的回退链;实际落点见上面 [font] platform=…)",
|
||||
platform.name()
|
||||
)
|
||||
}
|
||||
|
||||
/// 打一行 [`font_chain_line`](调用点只留一句)。
|
||||
pub fn report_font_chain(platform: FontPlatform) {
|
||||
eprintln!("{}", font_chain_line(platform));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- [caps]
|
||||
|
||||
/// `[caps]`:平台能力里**本进程能自己回答**的那几条。
|
||||
///
|
||||
/// 半观测半请求,字段名照实说:
|
||||
/// - `bundle` / `menu_bar_name`:观测(从可执行文件路径读);
|
||||
/// - `transparent` / `decorations` / `resizable`:本进程给窗口的设置(**请求值**,即终端用户
|
||||
/// 实际跑的那个形态;`decorations=false` ⇒ 三键是自绘的);
|
||||
/// - `controls=self-drawn`:由 `decorations=false` 推出来的结论(自绘三键在**右上角**,
|
||||
/// 与 macOS 惯例的左上相反——这条正是「三键位置怪不怪」的机器答案,位置本身由脚本在截图上
|
||||
/// 做像素定位)。
|
||||
#[must_use]
|
||||
pub fn caps_line(
|
||||
exe: Option<&Path>,
|
||||
transparent: bool,
|
||||
resizable: bool,
|
||||
decorations: bool,
|
||||
) -> String {
|
||||
let (is_bundle, name) = bundle_of(exe);
|
||||
format!(
|
||||
"[caps] bundle={} menu_bar_name=\"{name}\" menu_bar_source={} controls={} \
|
||||
transparent={transparent} resizable={resizable} decorations={decorations}",
|
||||
if is_bundle { "yes" } else { "no" },
|
||||
if is_bundle { "bundle" } else { "exe-stem" },
|
||||
if decorations { "native" } else { "self-drawn" },
|
||||
)
|
||||
}
|
||||
|
||||
/// 可执行文件 → (是不是 `.app` 包, 菜单栏会显示的名字)。
|
||||
///
|
||||
/// macOS 上非 bundle 的二进制,菜单栏 App 名就是**可执行文件名**;打成 `.app` 时是
|
||||
/// `Foo.app` 里的 `Foo`。这条从路径读得出来,所以「菜单栏名字对不对」**不需要问人**。
|
||||
#[must_use]
|
||||
pub fn bundle_of(exe: Option<&Path>) -> (bool, String) {
|
||||
let Some(exe) = exe else {
|
||||
return (false, "<未知>".to_owned());
|
||||
};
|
||||
|
||||
// `Foo.app/Contents/MacOS/foo` 里的 `Foo`:路径里出现以 `.app` 结尾的一段就是 bundle。
|
||||
for component in exe
|
||||
.components()
|
||||
.filter_map(|part| part.as_os_str().to_str())
|
||||
{
|
||||
if let Some(name) = component.strip_suffix(".app") {
|
||||
return (true, name.to_owned());
|
||||
}
|
||||
}
|
||||
|
||||
let stem = exe
|
||||
.file_stem()
|
||||
.and_then(|stem| stem.to_str())
|
||||
.unwrap_or("<未知>");
|
||||
(false, stem.to_owned())
|
||||
}
|
||||
|
||||
/// 打一行 [`caps_line`](调用点只留一句)。
|
||||
pub fn report_caps(exe: Option<&Path>, transparent: bool, resizable: bool, decorations: bool) {
|
||||
eprintln!("{}", caps_line(exe, transparent, resizable, decorations));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user