Files
Unlucky-Launcher/crates/core/tests/setting/appearance_keys.rs
T
AskaEth 2ecb33afae feat(download): download.* 三个超时/阈值键接进 schema 与引擎(M292 2/2)
P21 第 1 条:M289 的四个超时/阈值只挂在 `EngineConfig`,`DownloadTuning::apply_to` 只搬并发与
每源尝试次数 ⇒ 慢链路用户没有任何自救手段。出厂等价于「平均吞吐 ≥32 KiB/s 才活得下来」:
188MB 在 20 KiB/s 上要 160min > 100.3min 预算,必被杀;被杀后不再重试、不再换源,所以是
「下不完」而不是「慢一点」。

## 键(schema 1.9.0,新键推 minor)

| 键 | 默认 | 范围 | 落到 |
|---|---|---|---|
| `download.throughput_floor_kib` | 32 KiB/s | 4~4096 | `EngineConfig::min_avg_throughput`(×1024 → 字节/秒)|
| `download.stall_timeout_secs` | 120s | 30~600 | `EngineConfig::chunk_stall_timeout` |
| `download.response_timeout_secs` | 60s | 10~600 | `EngineConfig::response_timeout` |

三个默认值与 M289 的硬编码**逐值相等** ⇒ 不设 = 当前行为。上界(`MAX_*` 常量)就是「把挂住变成
无界」那道兜底:越界值在写入口被 schema 拒、引擎侧再回落一次出厂值。

## 通路

`RawTuning`(宿主只按 `KEY_*` 常量取裸值)→ `DownloadTuning::from_raw`(默认/区间/回落/KiB→字节
全在 core 一处)→ `apply_to` → `EngineConfig` → `stall::job_budget(total, floor, min_throughput)`。
`job_budget` 内部 `min_throughput.max(1)` 挡除零(设置层已拒 0,这是纵深防御)。

两个宿主都读六个键:`crates/cli/src/download.rs::tuning`、`presenter::host::settings::download::
tuning_of`。CLI 在**非出厂值**时往 stderr 打一行 `DownloadTuning::summary()`(`--dry-run` 也会经过
它;实测 `ull --data-dir … java install 21 --dry-run` 的 stderr 是
「下载调优:并发=8 单源尝试=3 吞吐下限=16KiB/s 无进展窗口=300s 单次请求上界=180s gh_proxy=跟随内置默认」,
出厂/非法值时一行都不打)。

## 文档口径(各只写一处)

- `stall.rs` 模块头:「改这几个数要付的代价」(调低下限换到的是「慢」不是「无界」;两档窗口是
  单次尝试的界,最坏要乘候选数×尝试次数)+「数字从哪来」改成「默认值 / 能不能改」+ 补上 P21
  第 3 条要的「`permit_timeout` 是按一次尝试反推,不是一个作业」。

## 两个纯搬移(为 400 行硬线)

- `config.rs` 423 → 261:`DownloadTuning` / `RawTuning` / `secs_in_range` / `impl` 整段搬到
  `config/tuning.rs`(194 行),`pub use tuning::{DownloadTuning, RawTuning};` 保住原路径;
  搬移块 172 行原样 diff = 0(无归一化)。
- `stall.rs` 440 → 343:测试搬到 `stall/tests.rs`(103 行),唯一变换是整体去 4 格缩进;两个
  未改动的用例函数体与 HEAD 逐字符(去空白)相同。

## 用例

core(`config/tests.rs` + `stall/tests.rs` + `keys/download.rs`):默认值与 schema/引擎三处同源、
改设置 ⇒ 引擎配置与总期限都变、0 与越界回落且不除零、doc 里的数字、`summary()` 的生效值、键顺序。
cli:生产入口 `build_engine` 拿到三个新值 + `announce` 只在非出厂值时给行 + 非法值回落。
presenter:文件 → `tuning_of` → `EngineConfig` 三段一致 + 键名常量与 schema 登记绑定。
2026-11-13 03:35:08 +08:00

205 lines
7.8 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 「外观(高级)」键簇(`setting::appearance` + `keys::launcher_schema` 的 appearance.* 段)。
//!
//! 守三条设计约束(09-ui-layout §111):
//! 1. 一切高级特性**默认关闭**(出厂 Metro 纯色)——默认值即出厂态;
//! 2. 全部走 D6 schema 且 **AI 可写**(白名单内,可被 agent 调节);
//! 3. 值域有硬边界(滑条夹取),坏值/手改配置不崩(回落默认)。
use serde_json::json;
use unlucky_core::setting::appearance::{
hex_to_rgb, normalize_hex, relative_luminance, Appearance, BgFill, DEFAULT_ACCENT,
PANEL_ALPHA_MIN,
};
use unlucky_core::setting::document::SettingDocument;
use unlucky_core::setting::keys::{launcher_schema, launcher_version};
use unlucky_core::setting::spec::ValueKind;
fn doc(raw: serde_json::Value) -> SettingDocument {
SettingDocument::load(&launcher_schema(), &raw).0
}
#[test]
fn factory_defaults_are_all_advanced_features_off() {
let appearance = Appearance::from_store(&unlucky_core::setting::store::SettingStore::load(
std::env::temp_dir().join(format!("ull-appearance-none-{}.json", std::process::id())),
launcher_schema(),
));
assert_eq!(appearance.theme_mode, "auto", "D17:出厂跟随系统");
assert!(!appearance.bg_image_enabled, "背景图默认关");
assert!(!appearance.bg_image_active());
assert_eq!(appearance.bg_fill, BgFill::Cover);
assert_eq!(appearance.panel_alpha, 1.0, "出厂完全不透明(Metro 纯色)");
assert!(!appearance.glass, "玻璃拟态默认关");
assert_eq!(appearance.accent_color, "", "空串 = 未自定义");
assert_eq!(appearance.accent_or_default(), DEFAULT_ACCENT);
}
#[test]
fn reading_a_configured_document_reflects_each_key() {
let document = doc(json!({
"appearance": {
"theme_mode": "dark",
"bg_image_enabled": true,
"bg_image": "D:/wall/paper.png",
"bg_fill": "tile",
"bg_dim": 0.6,
"panel_alpha": 0.8,
"glass": true,
"accent_color": "#ff5722",
}
}));
let appearance = Appearance::read(&document);
assert_eq!(appearance.theme_mode, "dark");
assert!(appearance.bg_image_active());
assert_eq!(appearance.bg_image, "D:/wall/paper.png");
assert_eq!(appearance.bg_fill, BgFill::Tile);
assert!((appearance.bg_dim - 0.6).abs() < 1e-9);
assert!((appearance.panel_alpha - 0.8).abs() < 1e-9);
assert!(appearance.glass);
assert_eq!(appearance.accent_color, "#FF5722", "读入即规范化大写");
assert_eq!(appearance.accent_or_default(), "#FF5722");
}
#[test]
fn bad_values_fall_back_instead_of_panicking() {
// 手改坏的配置:枚举拼错 / 开关类型错 / accent 不是 #RRGGBB。
let document = doc(json!({
"appearance": {
"bg_fill": "stretch",
"bg_image_enabled": "yes",
"accent_color": "rebeccapurple",
}
}));
let appearance = Appearance::read(&document);
assert_eq!(appearance.bg_fill, BgFill::Cover);
assert!(!appearance.bg_image_enabled);
assert_eq!(appearance.accent_color, "");
assert_eq!(appearance.accent_or_default(), DEFAULT_ACCENT);
}
#[test]
fn panel_alpha_is_clamped_to_the_contrast_floor() {
// schema 下限 0.4;读侧再夹一次(挡手改文件绕过 schema 的情况)。
let document = doc(json!({ "appearance": { "panel_alpha": 0.05 } }));
assert!((Appearance::read(&document).panel_alpha - PANEL_ALPHA_MIN).abs() < 1e-9);
}
#[test]
fn appearance_keys_are_all_ai_writable_and_registered() {
let schema = launcher_schema();
// 主题三态是 D17 已定案的最轻量键(silent/Low),其余高级键留审计痕迹(audited/Medium)。
let audited_keys = [
"appearance.bg_image_enabled",
"appearance.bg_image",
"appearance.bg_fill",
"appearance.bg_dim",
"appearance.panel_alpha",
"appearance.glass",
"appearance.accent_color",
// M91(D36/D37/D38)的外观下层三键,同样 audited。
"appearance.window_opacity",
"appearance.titlebar_mode",
"appearance.palette_source",
];
for key in audited_keys {
let spec = schema.spec(key).unwrap_or_else(|| panic!("{key} 必须登记"));
assert!(spec.ai.writable, "{key} 必须在 AI 可写白名单内");
// 纯视觉、可一键复原 → 不要求弹窗确认(High 级留给凭据/数据根/代理)。
assert!(
!spec.ai.sensitivity.requires_confirmation(),
"{key} 不该要确认"
);
assert_eq!(
spec.ai.sensitivity.as_str(),
"medium",
"{key} 要留审计痕迹(AI 改过什么可回看)"
);
}
// 主题三态仍是 silent(D17 已定案的最轻量键)。
assert_eq!(
schema
.spec("appearance.theme_mode")
.unwrap()
.ai
.sensitivity
.as_str(),
"low"
);
}
#[test]
fn schema_registration_shape_is_right() {
assert_eq!(
launcher_version().minor,
9,
"1.9.0 登记 M292 的三个下载超时键(1.8.0 是 M197 的 download.gh_proxy,1.7.0 是 M168 的 ui.font_cdn)"
);
let schema = launcher_schema();
assert_eq!(
schema.spec("appearance.bg_fill").unwrap().kind,
ValueKind::Enum(&["cover", "contain", "tile"])
);
assert!(matches!(
schema.spec("appearance.panel_alpha").unwrap().kind,
ValueKind::Float { min, max } if (min - PANEL_ALPHA_MIN).abs() < 1e-9 && (max - 1.0).abs() < 1e-9
));
assert_eq!(
schema.spec("appearance.accent_color").unwrap().kind,
ValueKind::Text { max_len: 9 },
"#RRGGBB 最多 7 字符,留 9 容错带井号与空白"
);
// 默认值 = 出厂态
assert_eq!(
schema.spec("appearance.panel_alpha").unwrap().default,
serde_json::Value::from(1.0)
);
assert_eq!(
schema.spec("appearance.glass").unwrap().default,
serde_json::Value::from(false)
);
}
#[test]
fn write_round_trips_through_the_store() {
let temp =
std::env::temp_dir().join(format!("ull-appearance-{}-{}", std::process::id(), line!()));
std::fs::create_dir_all(temp.join("config")).expect("临时目录");
let path = temp.join("config/launcher.json");
let _ = std::fs::remove_file(&path);
let mut store = unlucky_core::setting::store::SettingStore::load(&path, launcher_schema());
let mut appearance = Appearance::from_store(&store);
appearance.theme_mode = "dark".to_owned();
appearance.panel_alpha = 0.8;
appearance.bg_image_enabled = true;
appearance.bg_image = "D:/wall/p.png".to_owned();
appearance.accent_color = "#0f6cbd".to_owned();
appearance.write(&mut store).expect("写外观键");
store.save().expect("落盘");
let reloaded = Appearance::from_store(&unlucky_core::setting::store::SettingStore::load(
&path,
launcher_schema(),
));
assert_eq!(reloaded.theme_mode, "dark");
assert!((reloaded.panel_alpha - 0.8).abs() < 1e-9);
assert!(reloaded.bg_image_active());
assert_eq!(reloaded.accent_color, "#0F6CBD");
std::fs::remove_dir_all(&temp).ok();
}
#[test]
fn hex_helpers_validate_and_convert() {
assert_eq!(normalize_hex(" 0f6cbd "), Some("#0F6CBD".to_owned()));
assert_eq!(normalize_hex("#0F6CBD"), Some("#0F6CBD".to_owned()));
assert_eq!(normalize_hex("#0F6CB"), None);
assert_eq!(normalize_hex("#GGGGGG"), None);
assert_eq!(hex_to_rgb("#0078D4"), Some((0x00, 0x78, 0xD4)));
// WCAG 相对亮度:纯黑 0、纯白 1、Metro 蓝居中偏暗。
assert!(relative_luminance("#000000").unwrap().abs() < 1e-9);
assert!((relative_luminance("#FFFFFF").unwrap() - 1.0).abs() < 1e-9);
let metro = relative_luminance(DEFAULT_ACCENT).unwrap();
assert!(metro > 0.05 && metro < 0.3, "Metro 蓝亮度 {metro}");
}