core: 新增 game/launch_overrides(LaunchOverrides 五项 All-Option + 三级回落 + 文本补丁校验)与 launch/overrides(拼装落位:全局 java.extra_args 进 user.jvm、实例 jvmArgs 进 override_jvm、gameArgs 进 user.game、memory 进 -Xmx、backend 进 --graphicsBackend 并复用既有版本门槛告警);实例 schema 登记 launch.* 五键。 presenter: dto/LaunchArgsView+LaunchArgsPatch,主机侧读/写与门槛判定(backend_allowed),AppCommand::SetInstanceLaunchArgs。 cli: 新命令 instances launch-args(读/写/--clear),launch 装配链消费回落值。 app-iced: 实例详情页「启动参数」区(JVM/游戏参数、内存、Java、三态后端 chips + 灰化提示 + 保存/恢复),表单状态归 App、i18n 中英文案同步。
332 lines
14 KiB
Rust
332 lines
14 KiB
Rust
//! 外观接线的单测:**配置 → 状态 → 下发字节 / 像素 / 落盘**。
|
||
//!
|
||
//! 每条都按本 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 → 下发字节 205(钳位链的入口那一段)。
|
||
/// 抓不住: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.image().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 → 背景真的生效(解出原图尺寸、路径留给渲染器、配色走「背景图取色」这一级)。
|
||
/// 抓不住:渲染出来的像素(由截图举证),以及三种填充的覆盖范围(由 `background::tiles` 的
|
||
/// 「覆盖 ≥ 视口」断言守)。
|
||
#[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());
|
||
assert!(app.appearance.image().is_some(), "解码成功 ⇒ 有可用图");
|
||
assert!(
|
||
!app.appearance.bg_path.trim().is_empty(),
|
||
"渲染器按路径解码(见 picture::image_handle)"
|
||
);
|
||
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, &app.launch_form);
|
||
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, &app.launch_form);
|
||
}
|
||
|
||
/// **P2-2 的守门(复审 r1)**:面板 α 只作用到允许半透明的层。
|
||
///
|
||
/// 抓得住:`Ui::layer(Canvas)` 在 α=0.55 时仍是不透明(标题栏/导航轨/页头带都取 Canvas,
|
||
/// 它们一旦透底,三个窗口键与导航文字就压在背景照片上);`Card` / `Overlay` 必须真的吃到 α。
|
||
/// 抓不住:真实合成后的观感(那由截图看)。
|
||
#[test]
|
||
fn the_canvas_layer_stays_opaque_while_panels_take_the_alpha() {
|
||
use unlucky_design::elevation::MaterialLayer;
|
||
|
||
let mut app = headless(Options::default(), SchemeFlavor::Light);
|
||
app.appearance.apply(Change::PanelAlpha(0.55));
|
||
app.rebuild_ui();
|
||
|
||
let ui = app.ui();
|
||
assert_eq!(ui.panel_alpha(), 0.55, "生效值进了 Ui");
|
||
assert_eq!(
|
||
ui.layer(MaterialLayer::Canvas).a,
|
||
1.0,
|
||
"窗口底(标题栏/导航轨/页头带)永远不透明——令牌的不变量"
|
||
);
|
||
for layer in [MaterialLayer::Card, MaterialLayer::Overlay] {
|
||
assert!(
|
||
(ui.layer(layer).a - 0.55).abs() < 1e-6,
|
||
"{layer:?} 必须吃面板 α(实际 {})",
|
||
ui.layer(layer).a
|
||
);
|
||
}
|
||
|
||
// CPU 档:α 被强制 1.0 ⇒ 连卡片也不透明。
|
||
let mut cpu = headless(
|
||
Options {
|
||
backend: Backend::Cpu,
|
||
..Options::default()
|
||
},
|
||
SchemeFlavor::Light,
|
||
);
|
||
cpu.appearance.apply(Change::PanelAlpha(0.55));
|
||
cpu.rebuild_ui();
|
||
assert_eq!(cpu.ui().layer(MaterialLayer::Card).a, 1.0, "CPU 档强制 1.0");
|
||
}
|
||
|
||
/// 抓得住:**把滑块从头拖到尾**(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.image().is_none());
|
||
}
|