fix(app-iced,presenter): M93 r4 —— Float 键写不回 / prefs.rs 超限 / CPU 档判定 / 平铺退化

复审(代码)r1 的两条 P1 + 两条必修。

P1-1 `SetSetting` 写不回 Float 键(整窗透明度重启回旧值)
- `presenter/src/host/settings.rs::parse_value` 改成**按 schema 的 `ValueKind` 取类型**,
  不再硬编码 Int/Bool 键名。历史:`appearance.window_opacity`(Float)被当 String 送进
  `store.set` → schema 的 `as_f64()` 返回 None → `InvalidValue` → **`save()` 根本没执行**,
  α 只在内存生效、重启回旧值、每次拖动弹一条 Error toast。
- 测试(presenter 层,`host/settings_tests.rs`):
  ① `float_key_round_trips_through_set_setting`:set 0.8 → **新开 store(= 重启)** 读回 0.8;
  ② `every_float_key_in_the_schema_can_be_written_back`:遍历 schema 里**所有**
  `ValueKind::Float` 的键逐个写回并读回(集合级守门,下次新增 Float 键漏接会立刻红)。

P1-2 `presenter/src/prefs.rs` 404 行 → 拆测试
- `#[cfg(test)] mod tests` 整体挪到 `crates/presenter/src/prefs/tests.rs`,结构体与 re-export
  一字未动。`wc -l`:**404 → 315**(tests.rs 90 行)。

P2-2 CPU 档判定只在 `--renderer=cpu` 时成立
- 新增 `app/backend.rs`:`effective_backend()` 把 `probe::apply` 写下的 `ICED_BACKEND`
  候选列表翻回档位(`tiny-skia` / `tiny-skia,wgpu` → CPU;`wgpu,tiny-skia` → GPU;未设 → 请求值)。
  `App::with_presenter` 用它喂 `appearance::State`,于是**默认 Auto 档在没有独显的机器上**
  也会走「面板 α / 玻璃强制 1.0」。
- 测试:`every_iced_backend_list_maps_to_a_tier` + `auto_with_a_software_renderer_forces_the_panel_opaque`。
- 实跑:`ICED_BACKEND=tiny-skia`(= probe 在无独显机器上写下的值)→
  `[appearance] boot … panel_alpha=1.000 (requested 0.550) backend=cpu`。

P2-1 平铺退化成「一条竖条」——根因不是几何,是**渲染层没吃 scale**(r3 的编辑没落到 `tiled()`)
- `picture::tiled()` 原来仍用 `ContentFit::None` + 原图尺寸,所以 `tiles()` 算出的 `scale`
  完全没被消费:小图被按 32dp 画在 129dp 的格子里 ⇒ 到处是缝。
- 现在:每块 `源尺寸 × tiles.scale`、`ContentFit::Fill`、行高显式 `Fixed`、整列 `Fill`。
- 诊断:新增 `background::small_tile_note()`(短边 < 200dp 的图在 tile 档下给一句
  「scaled up so the window stays covered」),已接进设置页诊断块(首屏可见)。
- 测试:`tile_grid_always_covers_the_viewport_within_budget`(6 组尺寸,含 32×32 / 16×16 / 1×1,
  断言覆盖 ≥ 视口且块数 ≤ 预算)、`small_images_get_a_tile_diagnostic_instead_of_silently_degrading`。
- 出图:`r5-tile-tiny32.png`(32×32 铺满全窗、右缘无裸白、提示可见)、`r5-tile-120.png`、
  `r5-tile-big.png`。

顺手(复审 P2-3/P2-4/P2-5)
- P2-4:`MAX_SOURCE_EDGE` 16384 → **8192**,并给解码器加 `image::Limits`(宽/高 + `max_alloc`
  320MB)作第二道闸;补 `an_over_sized_image_reports_too_large_instead_of_trying_to_decode`
  (`TooLarge` 这条分支此前无测试)。
- P2-5:`transparency.rs` 补一段「为什么 `LWA_ALPHA` + `WS_EX_LAYERED` 不影响命中测试与
  自绘标题栏拖动」(穿透要 `WS_EX_TRANSPARENT` 或 `LWA_COLORKEY`,两者都没用;`window::drag`
  走 `WM_NCLBUTTONDOWN(HTCAPTION)`)。
- P2-3:断链 `[background::dimmed]` → `dim_alpha`;`iced/image` → `image-without-codecs`;
  测试文档 204 → 205;马赛克残留说明只保留 r1→r2 的历史一句。
- 解码在 UI 线程同步做:**登记欠账**(挪后台线程超出本 mission)。
This commit is contained in:
AskaEth committed 2026-11-02 18:46:43 +08:00
1 parent 5c40c64b5f
commit da2da4cd9b
14 files changed
+468 -132

No files matched your search

+30 -29
View File
@@ -9,6 +9,7 @@
use serde_json::Value;
use unlucky_core::game::java::JdkVendor;
use unlucky_core::setting::spec::ValueKind;
use crate::dto::{SettingGroupView, SettingRowView};
@@ -336,37 +337,37 @@ pub fn set(env: &HostEnv, key: &str, value: &str) -> Result<(), String> {
}
/// 键 → 正确类型的 JSON 值(schema 会再校验范围/枚举)。
///
/// **按 schema 的 `ValueKind` 取类型,不硬编码键名**(复审 P1-1):原先是「硬编码 Int/Bool
/// 键名,其余当字符串」,于是 `appearance.window_opacity`(Float)被当 String 送进 `store.set`,
/// schema 的 `as_f64()` 返回 `None` ⇒ `InvalidValue` ⇒ **`save()` 不执行**(重启回旧值 + 弹 toast)。
/// 现在新增任何 Float/Int/Bool 键都自动正确;集合级守门是
/// `every_float_key_in_the_schema_can_be_written_back`。
fn parse_value(key: &str, value: &str) -> Result<Value, String> {
if matches!(key, "download.concurrency" | "download.retry_limit") {
let number: u32 = value
.trim()
.parse()
.map_err(|_| format!("{key} 需要整数:{value}"))?;
return Ok(Value::from(number));
let schema = unlucky_core::setting::keys::launcher_schema();
let Some(spec) = schema.spec(key) else {
// 未知键:原样交下去,由 `store.set` 用 schema 报「未知键」(这里不猜类型)。
return Ok(Value::String(value.to_string()));
};
let trimmed = value.trim();
match &spec.kind {
ValueKind::Bool => match trimmed.to_ascii_lowercase().as_str() {
"1" | "true" | "on" | "yes" => Ok(Value::Bool(true)),
"0" | "false" | "off" | "no" => Ok(Value::Bool(false)),
_ => Err(format!("{key} 需要布尔值:{value}")),
},
ValueKind::Int { .. } => trimmed
.parse::<i64>()
.map(Value::from)
.map_err(|_| format!("{key} 需要整数:{value}")),
ValueKind::Float { .. } => match trimmed.parse::<f64>() {
Ok(number) if number.is_finite() => Ok(Value::from(number)),
_ => Err(format!("{key} 需要有限数字:{value}")),
},
// 文本 / 枚举 / 路径 / 列表 / 任意 JSON:字符串原样交下去(越界与词表由 schema 判)。
_ => Ok(Value::String(value.to_string())),
}
if matches!(key, "java.default_memory_mb" | "agent.mcp.port") {
let number: u32 = value
.trim()
.parse()
.map_err(|_| format!("{key} 需要整数:{value}"))?;
return Ok(Value::from(number));
}
if matches!(
key,
"java.auto_install"
| "ai.enabled"
| "ai.yolo"
| "agent.mcp.enabled"
| "agent.mcp.allow_write"
) {
let flag = match value.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "on" | "yes" => true,
"0" | "false" | "off" | "no" => false,
_ => return Err(format!("{key} 需要布尔值:{value}")),
};
return Ok(Value::Bool(flag));
}
Ok(Value::String(value.to_string()))
}
/// 清掉 24h 地区缓存(下次建引擎重新判定)。
@@ -75,3 +75,89 @@ fn detect_region_action_removes_cache() {
assert!(!path.exists());
std::fs::remove_dir_all(&dir).ok();
}
/// **P1-1 的回归**:整窗透明度是 `ValueKind::Float` 的键,`SetSetting` 的载荷是字符串。
///
/// 历史(复审 r1 P1-1):`parse_value` 只认硬编码的 Int/Bool 键名,Float 键被当成字符串交给
/// `store.set`,schema 的 `as_f64()` 对 String 返回 None ⇒ `InvalidValue` ⇒ **`save()` 不执行**,
/// 于是 α 只在内存生效、重启回旧值,且每次拖动弹一条 Error toast。
///
/// 抓得住:`set` → 落盘 → **新开一个 store(= 模拟重启)** 读回同一值。
/// 抓不住:UI 侧是否真的把滑块接到 `SetSetting`(由 `app` 侧的 `on_appearance` 测试管)。
#[test]
fn float_key_round_trips_through_set_setting() {
let dir = std::env::temp_dir().join(format!("ull-host-settings-float-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let env = env_in(&dir);
set(&env, "appearance.window_opacity", "0.8").expect("写整窗透明度");
set(&env, "appearance.panel_alpha", "0.55").expect("写面板透明度");
// 新开一个 store:从磁盘重新加载(等价「重启进程」)。
let reopened = env.store();
assert_eq!(
reopened.doc().effective_f64("appearance.window_opacity"),
Some(0.8)
);
assert_eq!(
reopened.doc().effective_f64("appearance.panel_alpha"),
Some(0.55)
);
// 越界仍然被 schema 拒绝(钳位在 core 的读侧,写侧是拒绝)。
assert!(set(&env, "appearance.window_opacity", "0.0").is_err());
// 非数字报人话,而不是「期望数字,实际是 \"abc\"」这种 schema 原话。
assert!(set(&env, "appearance.window_opacity", "abc").is_err());
assert!(set(&env, "appearance.window_opacity", "NaN").is_err());
std::fs::remove_dir_all(&dir).ok();
}
/// **集合级守门(复审 P1-1 要求的那条)**:schema 里**每一个** `ValueKind::Float` 的键都必须
/// 能被 `SetSetting` 写回,而不只是 `window_opacity`。
///
/// 为什么这条比单键测试重要:下次谁再加一个 Float 键(比如玻璃强度、模糊半径),
/// 只要 `parse_value` 又退化成「硬编码表」,这里立刻红——单键测试抓不住新键。
///
/// 抓得住:类型解析与 `save()` 是否真的执行(读回是**新开 store**,走磁盘)。
/// 抓不住:UI 控件是否接上了这些键(那是各页面的接线测试)。
#[test]
fn every_float_key_in_the_schema_can_be_written_back() {
let dir =
std::env::temp_dir().join(format!("ull-host-settings-allfloat-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let env = env_in(&dir);
let schema = unlucky_core::setting::keys::launcher_schema();
let mut checked = 0_usize;
for spec in schema.specs() {
let unlucky_core::setting::spec::ValueKind::Float { min, max } = spec.kind else {
continue;
};
// 取一个落在 [min, max] 内的值(0.5 是多数区间的好选择,落不进时取中点)。
let target = if (min..=max).contains(&0.5) {
0.5
} else {
(min + max) / 2.0
};
set(&env, spec.key, &format!("{target}"))
.unwrap_or_else(|error| panic!("Float 键 {} 写不回:{error}", spec.key));
let reread = env
.store()
.doc()
.effective_f64(spec.key)
.unwrap_or_else(|| panic!("Float 键 {} 写完后读不到", spec.key));
assert!(
(reread - target).abs() < 1e-9,
"Float 键 {} 写 {target} 读回 {reread}",
spec.key
);
checked += 1;
}
assert!(
checked >= 3,
"schema 里 Float 键只剩 {checked} 个:本断言要求 ≥3(window_opacity / panel_alpha / bg_dim / ui.scale)。\
若是有意删减,请同步更新这条断言"
);
std::fs::remove_dir_all(&dir).ok();
}
+1 -90
View File
@@ -312,93 +312,4 @@ pub mod keys {
}
#[cfg(test)]
mod tests {
use super::{
keys, AppearanceBase, InstanceView, PaletteSource, ThemeMode, TileSize, TitlebarMode,
UiPrefs, WINDOW_ALPHA_MIN,
};
#[test]
fn defaults_match_d16() {
let prefs = UiPrefs::default();
assert_eq!(prefs.tile_size, TileSize::Medium);
assert_eq!(prefs.tile_size.columns(), 4);
assert_eq!(prefs.instance_view, InstanceView::Tiles);
assert_eq!(prefs.theme, ThemeMode::Auto);
assert_eq!(prefs.theme.as_str(), "auto");
assert!(!prefs.nav_collapsed);
assert!(!prefs.ai_collapsed);
}
#[test]
fn tile_size_columns_follow_q24() {
assert_eq!(TileSize::Small.columns(), 6);
assert_eq!(TileSize::Medium.columns(), 4);
assert_eq!(TileSize::Large.columns(), 3);
}
#[test]
fn theme_mode_round_trips_through_key_values() {
for mode in [ThemeMode::Auto, ThemeMode::Light, ThemeMode::Dark] {
assert_eq!(ThemeMode::from_key_value(mode.as_str()), mode);
}
// 坏值兜底
assert_eq!(ThemeMode::from_key_value("solarized"), ThemeMode::Auto);
}
#[test]
fn pref_keys_are_unique() {
let mut keys = keys::ALL.to_vec();
let total = keys.len();
keys.sort_unstable();
keys.dedup();
assert_eq!(keys.len(), total);
}
fn base_of(raw: serde_json::Value) -> AppearanceBase {
let schema = unlucky_core::setting::keys::launcher_schema();
let (doc, _) = unlucky_core::setting::document::SettingDocument::load(&schema, &raw);
AppearanceBase::from_document(&doc)
}
#[test]
fn appearance_base_missing_keys_use_factory() {
let base = base_of(serde_json::json!({}));
assert_eq!(
base,
AppearanceBase::factory(),
"缺键 → 出厂态(不透明 / auto / preset)"
);
assert!(!base.is_transparent(), "出厂不透明 → 不下发窗口 α");
}
#[test]
fn appearance_base_clamps_out_of_range_opacity() {
for raw in [0.0, 0.02, -1.0] {
let base = base_of(serde_json::json!({ "appearance": { "window_opacity": raw } }));
assert_eq!(
base.window_opacity, WINDOW_ALPHA_MIN as f32,
"raw={raw} 应夹到下限"
);
}
for (raw, want) in [(0.6, 0.6), (3.0, 1.0)] {
let base = base_of(serde_json::json!({ "appearance": { "window_opacity": raw } }));
assert_eq!(base.window_opacity, want, "raw={raw}");
}
}
#[test]
fn appearance_base_illegal_enums_fall_back_and_legal_ones_read_through() {
let bad = base_of(serde_json::json!({
"appearance": { "titlebar_mode": "allways", "palette_source": "desktop" }
}));
assert_eq!(bad.titlebar_mode, TitlebarMode::Auto, "非法 → auto");
assert_eq!(bad.palette_source, PaletteSource::Preset, "非法 → preset");
// 合法值必须照读:否则「回落」测试会因为实现恒回默认而假绿。
let good = base_of(serde_json::json!({
"appearance": { "titlebar_mode": "never", "palette_source": "wallpaper" }
}));
assert_eq!(good.titlebar_mode, TitlebarMode::Never);
assert_eq!(good.palette_source, PaletteSource::Wallpaper);
}
}
mod tests;
+90
View File
@@ -0,0 +1,90 @@
//! `prefs` 的单测(拆文件守 400 行上限;结构体与 re-export 一律留在 `prefs.rs`)。
use super::{
keys, AppearanceBase, InstanceView, PaletteSource, ThemeMode, TileSize, TitlebarMode, UiPrefs,
WINDOW_ALPHA_MIN,
};
#[test]
fn defaults_match_d16() {
let prefs = UiPrefs::default();
assert_eq!(prefs.tile_size, TileSize::Medium);
assert_eq!(prefs.tile_size.columns(), 4);
assert_eq!(prefs.instance_view, InstanceView::Tiles);
assert_eq!(prefs.theme, ThemeMode::Auto);
assert_eq!(prefs.theme.as_str(), "auto");
assert!(!prefs.nav_collapsed);
assert!(!prefs.ai_collapsed);
}
#[test]
fn tile_size_columns_follow_q24() {
assert_eq!(TileSize::Small.columns(), 6);
assert_eq!(TileSize::Medium.columns(), 4);
assert_eq!(TileSize::Large.columns(), 3);
}
#[test]
fn theme_mode_round_trips_through_key_values() {
for mode in [ThemeMode::Auto, ThemeMode::Light, ThemeMode::Dark] {
assert_eq!(ThemeMode::from_key_value(mode.as_str()), mode);
}
// 坏值兜底
assert_eq!(ThemeMode::from_key_value("solarized"), ThemeMode::Auto);
}
#[test]
fn pref_keys_are_unique() {
let mut keys = keys::ALL.to_vec();
let total = keys.len();
keys.sort_unstable();
keys.dedup();
assert_eq!(keys.len(), total);
}
fn base_of(raw: serde_json::Value) -> AppearanceBase {
let schema = unlucky_core::setting::keys::launcher_schema();
let (doc, _) = unlucky_core::setting::document::SettingDocument::load(&schema, &raw);
AppearanceBase::from_document(&doc)
}
#[test]
fn appearance_base_missing_keys_use_factory() {
let base = base_of(serde_json::json!({}));
assert_eq!(
base,
AppearanceBase::factory(),
"缺键 → 出厂态(不透明 / auto / preset)"
);
assert!(!base.is_transparent(), "出厂不透明 → 不下发窗口 α");
}
#[test]
fn appearance_base_clamps_out_of_range_opacity() {
for raw in [0.0, 0.02, -1.0] {
let base = base_of(serde_json::json!({ "appearance": { "window_opacity": raw } }));
assert_eq!(
base.window_opacity, WINDOW_ALPHA_MIN as f32,
"raw={raw} 应夹到下限"
);
}
for (raw, want) in [(0.6, 0.6), (3.0, 1.0)] {
let base = base_of(serde_json::json!({ "appearance": { "window_opacity": raw } }));
assert_eq!(base.window_opacity, want, "raw={raw}");
}
}
#[test]
fn appearance_base_illegal_enums_fall_back_and_legal_ones_read_through() {
let bad = base_of(serde_json::json!({
"appearance": { "titlebar_mode": "allways", "palette_source": "desktop" }
}));
assert_eq!(bad.titlebar_mode, TitlebarMode::Auto, "非法 → auto");
assert_eq!(bad.palette_source, PaletteSource::Preset, "非法 → preset");
// 合法值必须照读:否则「回落」测试会因为实现恒回默认而假绿。
let good = base_of(serde_json::json!({
"appearance": { "titlebar_mode": "never", "palette_source": "wallpaper" }
}));
assert_eq!(good.titlebar_mode, TitlebarMode::Never);
assert_eq!(good.palette_source, PaletteSource::Wallpaper);
}