feat(app-iced): M93 —— 外观接线:整窗 α / 面板 α / 背景图 / 配色来源
把 appearance.* 那批键接到窗口与像素上(M91 只落了键与判据,零调用方)。
整窗 α(D34/D36)
- `ensure_layered(hwnd)` 落在唯一 unsafe 文件 `platform/win32/transparency.rs`
(`WS_EX_LAYERED` 只有 Get/SetWindowLongPtrW 能加,且 CI 的 unsafe-scope 是集合相等);
`app/boot.rs` 只负责开窗,句柄到手后由 `Message::WindowHandle` → `window_alpha::dispatch`
先补样式再下发。本机实测 `layered=ok outcome=Applied { byte: 205 }`(α=0.8)。
- 三条入口都会重算并下发:启动(句柄到达)、设置页改动、配置文件热切。
- `layered` 日志有三态:`n/a`(α≥1,压根不需要)/ `ok` / `FAILED`,避免把「没用到」写成假红。
面板 α 与 CPU 档(D36)
- 面板 α 独立于整窗 α:只作用到外壳层(根容器 / 标题栏 / 导航轨)与外观卡片;
CPU 档强制 1.0、玻璃强制关(`pages/appearance/alpha.rs` 的真值表 + 单测)。
- 色卡档继续走令牌 baseline,只有取色档(①②级)才换 `Ui` 的 scheme(`Ui::from_scheme`)。
背景图(D38)
- 读盘解码(png)→ cover/contain/tile 三种填充几何 → `bg_dim` 向底色退 → 36dp 网格马赛克;
iced 的 `image` widget 需要 `iced/image` feature(Cargo.toml 不在 scope),故为已登记降级。
- 读盘失败静默回落 + 一条可读诊断(设置页错误行 + 日志),配色经 `Provenance` 回落色卡。
设置页「外观」分组
- 整窗透明度滑块(下限 0.08)/ 面板透明度(CPU 档禁用并写明 55%→100% 的降级)/
背景图开关与路径 / 填充三档 / 暗化 / 配色来源三档;改完即时生效并写回:
bridge 覆盖的 5 个键走 `SettingsBridge::persist`,`window_opacity` / `palette_source`
走 `AppCommand::SetSetting`(每个键只有一个写者)。
- `SettingsBridge::load_base()`(presenter)让 app-iced 不需要直接依赖 core 就能读回
`window_opacity` / `palette_source`。
取证
- `ULL_APPEARANCE_*` / `ULL_START_ROUTE` 只影响本次进程(语义同 ULL_TICK_TRACE),
截图通道不读用户配置(D30)。
This commit is contained in:
1 parent
eae7ae0ff4
commit
b8ab46662c
24 files changed
+3119
-48
No files matched your search
@@ -0,0 +1,290 @@
|
||||
//! 外观接线的单测:**配置 → 状态 → 下发字节 / 像素 / 落盘**。
|
||||
//!
|
||||
//! 每条都按本 mission 的验收纪律写「抓得住 / 抓不住」。真机才有的东西(`WS_EX_LAYERED` 是否补上、
|
||||
//! `LWA_ALPHA` 的观感)不在这里假装——那是 `[appearance]` 日志与报告里「没验的部分」的事。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_presenter::mock::MockBackend;
|
||||
use unlucky_presenter::prefs::{BgFill, PaletteSource};
|
||||
use unlucky_presenter::settings_bridge::SettingsBridge;
|
||||
|
||||
use crate::cli::{Backend, Options};
|
||||
use crate::pages::appearance::{self, Change, State};
|
||||
use crate::pages::{self, Screen};
|
||||
|
||||
use super::{headless, FAMILIES};
|
||||
|
||||
use super::super::state::App;
|
||||
|
||||
/// 一个把配置写在临时文件里的 App(测「读回 / 写回」必须真的过文件)。
|
||||
fn with_config(json: &str, backend: Backend, label: &str) -> (App, PathBuf) {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"ull-appear-{label}-{}-{}",
|
||||
std::process::id(),
|
||||
line!()
|
||||
));
|
||||
std::fs::create_dir_all(dir.join("config")).expect("临时目录");
|
||||
let path = dir.join("config/launcher.json");
|
||||
std::fs::write(&path, json).expect("写配置文件");
|
||||
|
||||
let app = App::with_presenter(
|
||||
Options {
|
||||
backend,
|
||||
..Options::default()
|
||||
},
|
||||
FAMILIES,
|
||||
SchemeFlavor::Light,
|
||||
Box::new(MockBackend::new()),
|
||||
SettingsBridge::with_path(&path),
|
||||
);
|
||||
(app, dir)
|
||||
}
|
||||
|
||||
fn cleanup(dir: &PathBuf) {
|
||||
std::fs::remove_dir_all(dir).ok();
|
||||
}
|
||||
|
||||
/// 抓得住:配置里写 `window_opacity=0.8` → 启动读回 0.8 → 下发字节 204(钳位链的入口那一段)。
|
||||
/// 抓不住:Win32 是否真的收下 204(`transparency` 的单测与本机日志各自举证)。
|
||||
#[test]
|
||||
fn the_configured_window_alpha_is_read_back_and_turned_into_a_byte() {
|
||||
let (app, dir) = with_config(
|
||||
r#"{"appearance":{"window_opacity":0.8,"palette_source":"preset"}}"#,
|
||||
Backend::Cpu,
|
||||
"read",
|
||||
);
|
||||
|
||||
assert_eq!(app.appearance.window_opacity, 0.8);
|
||||
assert_eq!(
|
||||
app.appearance.window_alpha_byte(),
|
||||
Some(205),
|
||||
"f32 0.8 → f64 略大于 0.8 → ceil 205(不向下取整)"
|
||||
);
|
||||
// CPU 档:窗口 α 允许,面板 α 强制 1.0。
|
||||
assert_eq!(app.appearance.effective_panel_alpha(), 1.0);
|
||||
assert!(app.appearance.panel_is_forced());
|
||||
cleanup(&dir);
|
||||
}
|
||||
|
||||
/// 抓得住:①面板 α 滑到 0.8 → 状态是 0.8、生效值 GPU 档也是 0.8;
|
||||
/// ②CPU 档下滑到 0.5 → 生效值仍是 1.0,而**请求值**仍是 0.5(界面上的「requested → forced」靠这条)。
|
||||
/// 抓不住:这层半透明在屏幕上到底多透(RDP 上不可信,见报告)。
|
||||
#[test]
|
||||
fn panel_alpha_is_independent_of_the_window_alpha_and_forced_on_cpu() {
|
||||
let mut gpu = headless(Options::default(), SchemeFlavor::Light);
|
||||
gpu.appearance.apply(Change::PanelAlpha(0.8));
|
||||
assert_eq!(gpu.appearance.panel_alpha, 0.8);
|
||||
assert_eq!(gpu.appearance.effective_panel_alpha(), 0.8);
|
||||
assert_eq!(gpu.appearance.window_opacity, 1.0, "面板 α 不许带动整窗 α");
|
||||
|
||||
let mut cpu = headless(
|
||||
Options {
|
||||
backend: Backend::Cpu,
|
||||
..Options::default()
|
||||
},
|
||||
SchemeFlavor::Light,
|
||||
);
|
||||
assert!(
|
||||
!cpu.appearance.apply(Change::PanelAlpha(0.5)),
|
||||
"只有整窗 α 需要立刻下发(面板 α 只重绘)"
|
||||
);
|
||||
assert_eq!(cpu.appearance.panel_alpha, 0.5, "请求值保留");
|
||||
assert_eq!(
|
||||
cpu.appearance.effective_panel_alpha(),
|
||||
1.0,
|
||||
"CPU 档强制 1.0"
|
||||
);
|
||||
assert_eq!(cpu.appearance.window_alpha_byte(), None, "整窗 α 仍是 1.0");
|
||||
}
|
||||
|
||||
/// 抓得住:一次滑块改动 → 落盘 → **新开一个 bridge** 能读回(= 重启后还在)。
|
||||
/// 抓不住:真 host 是否也写了同一份文件(`AppCommand::SetSetting` 走的是同一份 schema)。
|
||||
#[test]
|
||||
fn a_panel_alpha_change_is_written_back_to_the_config_file() {
|
||||
let (mut app, dir) = with_config("{}", Backend::Gpu, "write");
|
||||
app.on_appearance(Change::PanelAlpha(0.8));
|
||||
app.on_appearance(Change::BgFill(BgFill::Tile));
|
||||
app.on_appearance(Change::BgDim(0.5));
|
||||
|
||||
let path = dir.join("config/launcher.json");
|
||||
let reopened = SettingsBridge::with_path(&path).load().0;
|
||||
assert_eq!(reopened.panel_alpha, 0.8);
|
||||
assert_eq!(reopened.bg_fill, BgFill::Tile);
|
||||
assert!((reopened.bg_dim - 0.5).abs() < 1e-6);
|
||||
assert_eq!(
|
||||
app.appearance_prefs.panel_alpha, 0.8,
|
||||
"内存里的「已落盘快照」也要跟上,否则下一次写回会拿旧基底覆盖"
|
||||
);
|
||||
cleanup(&dir);
|
||||
}
|
||||
|
||||
/// 抓得住:路径读不出来时 ①不 panic ②`background_active()` 为假(静默回落)
|
||||
/// ③诊断里有一句人话(不是空串)④配色回落到色卡档(不换 scheme)。
|
||||
/// 抓不住:这张图在别的机器上路径存在时会怎样(那是成功路径,另一条测试管)。
|
||||
#[test]
|
||||
fn a_broken_background_path_falls_back_silently_but_says_why() {
|
||||
let mut app = headless(Options::default(), SchemeFlavor::Light);
|
||||
app.appearance
|
||||
.apply(Change::BgImage("D:/definitely/not/here.png".to_owned()));
|
||||
|
||||
assert!(!app.appearance.background_active());
|
||||
assert!(app.appearance.mosaic((1180.0, 760.0), [0, 0, 0]).is_none());
|
||||
assert_eq!(app.appearance.diagnostics().len(), 1, "恰好一条诊断");
|
||||
assert!(
|
||||
app.appearance.diagnostics()[0].contains("could not be read"),
|
||||
"诊断要是人话:{:?}",
|
||||
app.appearance.diagnostics()
|
||||
);
|
||||
assert_eq!(app.appearance.palette_level(), "preset", "回落色卡");
|
||||
assert!(
|
||||
app.appearance.derived_scheme().is_none(),
|
||||
"色卡档不换 scheme"
|
||||
);
|
||||
}
|
||||
|
||||
/// 抓得住:真 PNG → 背景真的生效(有马赛克、有格数、配色走「背景图取色」这一级)。
|
||||
/// 抓不住:马赛克的观感(36dp 网格是已登记的降级),以及填充三种档的视觉差异(几何由
|
||||
/// `background` 的纯函数测试守)。
|
||||
#[test]
|
||||
fn a_decodable_png_makes_the_background_and_the_palette_active() {
|
||||
let dir = std::env::temp_dir().join(format!("ull-appear-png-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).expect("临时目录");
|
||||
let path = dir.join("bg.png");
|
||||
let image = image::RgbImage::from_fn(64, 32, |x, _y| {
|
||||
if x < 32 {
|
||||
image::Rgb([200, 30, 30])
|
||||
} else {
|
||||
image::Rgb([20, 60, 200])
|
||||
}
|
||||
});
|
||||
image.save(&path).expect("写 png");
|
||||
|
||||
let mut app = headless(Options::default(), SchemeFlavor::Light);
|
||||
app.appearance
|
||||
.apply(Change::PaletteSource(PaletteSource::BackgroundImage));
|
||||
app.appearance
|
||||
.apply(Change::BgImage(path.to_string_lossy().to_string()));
|
||||
app.rebuild_ui();
|
||||
|
||||
assert!(app.appearance.background_active());
|
||||
let mosaic = app
|
||||
.appearance
|
||||
.mosaic((1180.0, 760.0), [0, 0, 0])
|
||||
.expect("有背景层");
|
||||
assert!(mosaic.cols > 1 && mosaic.rows > 1);
|
||||
assert_eq!(app.appearance.diagnostics().len(), 0, "成功时没有诊断");
|
||||
assert_eq!(app.appearance.palette_level(), "background-image");
|
||||
assert!(
|
||||
app.appearance.derived_scheme().is_some(),
|
||||
"取色成功后 Ui 要换成解析出的方案"
|
||||
);
|
||||
// 换 scheme 之后主题名仍是同一个(深浅没变),但 primary 必须与 baseline 不同。
|
||||
assert_eq!(app.ui().name(), "ull-m3-light");
|
||||
let baseline = crate::theme::Ui::new(SchemeFlavor::Light, FAMILIES);
|
||||
assert_ne!(
|
||||
app.ui().color(unlucky_design::color::Role::Primary),
|
||||
baseline.color(unlucky_design::color::Role::Primary)
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// 抓得住:设置页在有外观状态时能真的构图(真控件树,含文本框/滑块/互斥按钮),并且
|
||||
/// 「外观」分组的下标与 `appearance::APPEARANCE_GROUP` 一致(点左列能切过去)。
|
||||
/// 抓不住:这些控件点下去之后的交互(`Message` 的落点由 `on_appearance` 的其它测试管)。
|
||||
#[test]
|
||||
fn the_settings_page_renders_the_real_appearance_group() {
|
||||
let mut app = headless(Options::default(), SchemeFlavor::Light);
|
||||
app.state
|
||||
.navigate(unlucky_presenter::router::Route::Settings);
|
||||
app.refresh_view();
|
||||
assert_eq!(app.view.screen, Screen::Settings);
|
||||
assert_eq!(app.appearance.selected_group, appearance::APPEARANCE_GROUP);
|
||||
|
||||
let _tree = pages::page_with_appearance(&app.ui, &app.view, &app.appearance);
|
||||
drop(_tree);
|
||||
|
||||
// 切分组:只改视图状态,不落盘(`Change::SelectGroup` 的 key() == None)。
|
||||
app.appearance.apply(Change::SelectGroup(0));
|
||||
assert_eq!(app.appearance.selected_group, 0);
|
||||
let _tree = pages::page_with_appearance(&app.ui, &app.view, &app.appearance);
|
||||
}
|
||||
|
||||
/// 抓得住:**把滑块从头拖到尾**(0.00 → 1.00 的 11 个档)时,状态里的整窗 α 与下发字节逐档单调
|
||||
/// 上升、且最低一档就是下限字节 21(= 0.0824 ≥ 0.08)、最后一档不透明(不给字节)。
|
||||
/// 这就是「拖动 → 实际 α」的链条在数值上的等价物;真机拖动的那一次由二进制日志举证。
|
||||
/// 抓不住:拖动的手感与真实观感(RDP 上不可信)。
|
||||
#[test]
|
||||
fn a_slider_sweep_moves_the_alpha_and_the_byte_monotonically() {
|
||||
let mut app = headless(Options::default(), SchemeFlavor::Light);
|
||||
let mut bytes = Vec::new();
|
||||
for step in 0..=10 {
|
||||
let fraction = step as f32 / 10.0;
|
||||
let alpha = crate::pages::appearance::alpha::window_alpha_from_fraction(fraction);
|
||||
app.appearance.apply(Change::WindowOpacity(alpha));
|
||||
let byte = app.appearance.window_alpha_byte();
|
||||
bytes.push(byte);
|
||||
// 每一档都要能打出一行(数值而不是观感)。
|
||||
assert!(app.appearance.log_line("sweep").contains("window_alpha="));
|
||||
}
|
||||
|
||||
assert_eq!(bytes[0], Some(21), "0% 一档 = 下限 0.08 → 21");
|
||||
assert_eq!(bytes[10], None, "100% 一档 = 不透明 → 不下发");
|
||||
let sent: Vec<u8> = bytes.iter().flatten().copied().collect();
|
||||
assert!(
|
||||
sent.windows(2).all(|pair| pair[0] <= pair[1]),
|
||||
"字节必须单调不减:{sent:?}"
|
||||
);
|
||||
assert_eq!(sent.len(), 10, "除最后一档(不透明)之外每档都有字节");
|
||||
assert!(
|
||||
sent.iter().all(|byte| *byte >= 21),
|
||||
"任何一档都不许低于下限字节:{sent:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// 抓得住:日志行里有请求值、生效值与下发字节(RDP 上唯一可信的证据形态)。
|
||||
/// 抓不住:这行日志是否真的被写进了 stderr(本测试只钉格式,真机日志由跑起来的二进制举证)。
|
||||
#[test]
|
||||
fn the_log_line_carries_requests_effective_values_and_the_byte() {
|
||||
let mut cpu = headless(
|
||||
Options {
|
||||
backend: Backend::Cpu,
|
||||
..Options::default()
|
||||
},
|
||||
SchemeFlavor::Light,
|
||||
);
|
||||
cpu.appearance.apply(Change::WindowOpacity(0.5));
|
||||
cpu.appearance.apply(Change::PanelAlpha(0.4));
|
||||
|
||||
let line = cpu.appearance.log_line("change");
|
||||
assert!(line.starts_with("[appearance] change "), "{line}");
|
||||
assert!(line.contains("window_alpha=0.500"), "{line}");
|
||||
assert!(line.contains("panel_alpha=1.000"), "CPU 档生效值:{line}");
|
||||
assert!(line.contains("(requested 0.400)"), "请求值也要在:{line}");
|
||||
assert!(
|
||||
line.contains("byte=128"),
|
||||
"0.5×255=127.5 → 向上取整 128:{line}"
|
||||
);
|
||||
|
||||
// 不透明时不给字节(而不是给 255)。
|
||||
let opaque = headless(Options::default(), SchemeFlavor::Light);
|
||||
assert!(opaque.appearance.log_line("boot").contains("byte=none"));
|
||||
}
|
||||
|
||||
/// 抓得住:`State::new` 与 `apply_env` 之外,`State` 的构造不会偷偷读环境(测试之间不串味)。
|
||||
/// 抓不住:`ULL_APPEARANCE_*` 在真机上的解析(`apply_env` 由截图矩阵覆盖,见报告)。
|
||||
#[test]
|
||||
fn a_fresh_state_is_the_factory_appearance() {
|
||||
let state = State::new(
|
||||
&unlucky_presenter::prefs::AppearancePrefs::factory(),
|
||||
&unlucky_presenter::prefs::AppearanceBase::factory(),
|
||||
Backend::Gpu,
|
||||
SchemeFlavor::Light,
|
||||
);
|
||||
assert_eq!(state.window_opacity, 1.0);
|
||||
assert_eq!(state.effective_panel_alpha(), 1.0);
|
||||
assert!(!state.background_active());
|
||||
assert!(state.diagnostics().is_empty());
|
||||
assert!(state.mosaic((1180.0, 760.0), [0, 0, 0]).is_none());
|
||||
}
|
||||
Reference in new issue
Block a user