复审 r1(p1-1/fix-then-merge)五项全改:
1. **p1-1 CI 会红的 4 条 integration test**(`crates/core/tests/setting/`,我上一轮只跑了 `--lib`,
scope 漏掉了覆盖 schema 版本号的目录式 target):
- `java_install_keys` / `isolation_keys` / `appearance_keys`:版本断言 1.6.0 → 1.7.0(M168 的
`ui.font_cdn`),并写明「每加一批键推一次 minor 是纪律」。
- `update_keys::a_read_only_launcher_config_never_retires_the_legacy_file`:夹具从**写死
1.7.0** 改成 `launcher_version().minor + 1` 经 `SchemaRef::url()` 生成。写死数字的版本一推
这条守卫就变成「同版本」⇒ 文件可写 ⇒ 迁移真的执行 ⇒ 旧文件被退役,而断言还绿着(用户数据
守卫静默失效)。相对推导不会过期。
2. **p2-1 备用镜像不备用**:`cdn` 与 `fastly` 同属 Fastly(都 CNAME 到 jsdelivr.map.fastly.net),
换掉。现在首选 `gcore.jsdelivr.net`(Cloudflare,独立失败域),二选 `cdn.jsdelivr.net`
(Fastly)。注释里放了实测表(本机 + 复审机两份数据、字节数、sha256、IPv6 慢路径),
并写清排序依据(Fastly 侧 IPv6 实测 67 KB/s ⇒ 丢掉 4 分钟;Cloudflare 侧两次都完整下完)。
3. **p2-2 整体时限**:`core::download::font` 新增 `ATTEMPT_DEADLINE = 90 s` /
`TOTAL_DEADLINE = 180 s`,落在本模块自己的候选循环里(不动引擎全体语义):每次尝试
`min(单次上限, 剩余总预算)`,超时归 `Network` 档。新增守卫
`a_trickling_connection_is_cut_by_the_deadline`(回环服务端每 100 ms 挤 1 字节、预算 300 ms)。
顺带修掉一个真 bug:`tokio::time::timeout` 在运行时外构造会 panic(5 条既有用例当场抓到),
现在整段包进 `block_on(async { … })`。UI 文案改成「网络不通或太慢(超时)」,不校验失败混。
4. **p2-3 说 `Source::PendingPin` 的两处**:`fonts/cjk.rs` 的模块文档已改;`ui/lang.rs::log_cjk`
那句「D28 的下载通道还是 PendingPin」(在下载已发起之后打印,等于当面否定本功能)改成
「已按 D28 发起下载,见 [font] cjk-download … 与设置页那一行」。全仓再扫,只剩注释里的历史说明。
5. **p2-4 假守卫**:判据抽成纯函数 `install::plan(strategy, current) -> Step`(`Skip` / `Apply` /
`Start`),`after_probe` 只执行。旧的空转用例换成打真判据的打表用例;变异证明:删掉
`Skip` 那一臂 ⇒ `a_started_or_finished_install_is_not_restarted` 红(已实跑)。
次要项也一并处理:`from_cache` 改成**本次是否真的命中缓存**(`CdnFetcher::was_cached()`,
`has_cached()` 降级为诊断字段 `cache_present=`);`FontSpec` 的 URL 口径收紧为「非回环必须
https」(`is_acceptable_url` + 守卫);`ui.font_cdn` 写进状态行 hint;文档里的 17.0 MB → 16.9 MB。
`download/font.rs` 拆出 `font/spec.rs` 守 400 行。
验证(这台机器上单个 800 MB 测试 exe 链接要 10 分钟,链接期间文件被 Defender 扫描,故耗时很长):
- `cargo test -p unlucky-core --all-targets` → **1188 passed / 0 failed**(16 个 target,含
`setting` 那个 115 条、上一轮红 4 条的 integration target)
- `cargo test -p unlucky-i18n` → 31 passed / 0 failed
- `cargo test -p unlucky-app-iced --lib` → 685 passed / 0 failed
- `cargo clippy -p unlucky-i18n -p unlucky-core -p unlucky-app-iced --all-targets -- -D warnings` → 干净
- `cargo fmt --all -- --check` → 干净;全仓受检文件行数最大 388(<400)
205 lines
7.8 KiB
Rust
205 lines
7.8 KiB
Rust
//! 「外观(高级)」键簇(`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,
|
||
7,
|
||
"1.7.0 登记 M168 的 ui.font_cdn(1.6.0 是 M161 的自更新两键)"
|
||
);
|
||
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}");
|
||
}
|