feat(nav): M300 导航轨折叠按钮 + 折叠宽度令牌 + 宽度过渡与落盘
用户原话「默认左侧固定,允许折叠」:M297 盘查确认 iced 从来没接过折叠按钮, presenter 的 toggle_nav/nav_collapsed 是 Slint 遗留、唯一调用方在已退役的 crates/app。 ## 令牌(先定数值再动手) - nav_collapsed_width = 56 + 2 × Space::X1 = **64**:胶囊(M283 定稿 56×32,本轮不动) 两侧各留 4px 才不被裁切;旧值 48 的死因写进注释(48 − 2×4 = 40 < 56)。 - 断言:折叠宽 ≥ 胶囊宽 + 2×X1、≥ touch_target 48、在 4px 栅格上。 ## 折叠按钮与折叠态 - 按钮放**轨底、头像位之上**:轨顶一个像素都不动(M283/M299 的展开态对照图仍可比), 轨底本来就是「固定贴底」的槽,两态下位置不变。几何与导航项同一套(56×32 药丸 + 52 命中高)。 - 折叠态:隐藏文字标签与账户名,每项/头像/按钮都补 M3 气泡 tooltip(折叠态不许是一列无名符号); 纵节奏不变(项高仍 52),所以切换时图标不上下跳。五项图标两两不同由用例钉住。 ## 动效(复用底盘、零新常数) - 帧泵加**第二条并列宽度轨**(nav ⇄ rail),时长/曲线复用同一张 rail_spec(); 借用右栗那条会让左轨折叠改掉右栗的目标(暗耦合),tower 已否掉那个方案。 - 起跑在 update 侧;启动按落盘档对表(幂等,仅在没有轨道在跑时);折叠**不置 view_dirty** —— 宽度在 view 里逐帧采样,帧由轨道自己续,跑完 needs_frames() 回 false。 ## 落盘(任务要求「重启读回仍折叠」) - core schema 登记 ui.nav.collapsed(纯增量 bool,不碰 schemaVersion);presenter 桥加 load/persist 一对(走唯一写入口 write_with,现读→改→落盘);启动 construct 读回。 - 截图通道不读用户配置(D30),折叠态出图走 ULL_NAV_COLLAPSED=1。 ## 判据 - app-iced: 1053 passed(新增 3 条接线用例 + 7 条 shell/几何/树结构用例 + 6 条帧泵用例); presenter: 408 passed(新增落盘往返 1 条);fmt / clippy --workspace --all-targets 均 0。 - 变异实验:删 observe_nav ⇒ 宽度停在 88 红;删启动对表 ⇒ 首次展开硬跳红。 - 像素(DevDocs/artifacts/m300/):折叠态内容左缘 112→88(24px 真回收)、药丸两态都 56 宽 且折叠档右缘 59 < 64(不裁切)、图标行是展开态子集(纵节奏不变)。 ## 已知偏离(已登记,tower 批了紧随 mission) crates/app-iced/src/pages/layout.rs 的 rail_width() 仍是 const fn(恒 88),所以折叠态内容块 宽度不变、整块左移 24px ⇒ 右侧多一条 24px 空白带(像素图里保留、未裁)。
This commit is contained in:
1 parent
1ce494631c
commit
fa53d72f73
26 files changed
+1640
-320
No files matched your search
@@ -3,7 +3,8 @@
|
||||
//! 偏好不自己写盘:core::setting 的 schema 按 [`keys::ALL`] 登记这些键,
|
||||
//! app 启动时读一次、变更时写回(跨模块约定见 `DevDocs/SubAgentsTalk/app-DTO通道约定.md`)。
|
||||
//! 落盘实现是 [`crate::settings_bridge`](同一份 `config/launcher.json`,与 `ull config set` 同源),
|
||||
//! 主题三态 + `appearance.*` 高级键已接(M41);其余 UI 偏好键仍只在内存里(后续 mission 接)。
|
||||
//! 主题三态 + `appearance.*` 高级键已接(M41);其余 UI 偏好键仍只在内存里(后续 mission 接),
|
||||
//! **唯一例外是 [`UiPrefs::nav_collapsed`](M300 已接:schema 键 + 读 + 写三处都在)**。
|
||||
|
||||
/// 背景图填充模式(09 §101)。直接复用 core 的枚举:取值与落盘词表只有一处真相。
|
||||
pub use unlucky_core::setting::appearance::BgFill;
|
||||
@@ -304,7 +305,7 @@ impl AppearanceBase {
|
||||
/// 注意:`Eq` 不成立([`AppearancePrefs`] 带 `f32` 滑条值),用到的地方按 `PartialEq` 比。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct UiPrefs {
|
||||
/// 左侧导航栏折叠成图标条
|
||||
/// 左侧导航栏折叠成图标条(M300 起**已落盘**:键 [`keys::NAV_COLLAPSED`],读 `SettingsBridge::load_nav_collapsed`)
|
||||
pub nav_collapsed: bool,
|
||||
/// 右侧 AI 栏折叠成 icon 轨
|
||||
pub ai_collapsed: bool,
|
||||
@@ -337,7 +338,7 @@ impl Default for UiPrefs {
|
||||
|
||||
/// 持久化键名登记(core::setting schema 按此注册;改名等于迁移,慎动)。
|
||||
pub mod keys {
|
||||
/// 导航栏折叠
|
||||
/// 导航栏折叠(M300 起是**真落盘**键,core schema 同名字符串)
|
||||
pub const NAV_COLLAPSED: &str = "ui.nav.collapsed";
|
||||
/// AI 栏折叠
|
||||
pub const AI_COLLAPSED: &str = "ui.ai.collapsed";
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::SystemTime;
|
||||
|
||||
use serde_json::Value;
|
||||
use unlucky_core::platform::data_dir::Resolver;
|
||||
use unlucky_core::platform::paths::Platform;
|
||||
use unlucky_core::setting::appearance::Appearance;
|
||||
@@ -133,6 +134,47 @@ impl SettingsBridge {
|
||||
}
|
||||
}
|
||||
|
||||
/// 左侧导航轨的折叠档(**M300**):`UiPrefs::nav_collapsed` 的**唯一读入口**。
|
||||
///
|
||||
/// `ui.*` 这一族偏好此前**全部只在内存里**(见 `prefs.rs` 模块文档的「其余 UI 偏好键仍只在
|
||||
/// 内存里」)——M300 起那句对 `nav_collapsed` 不再成立:它在 core 的 schema 里登记了
|
||||
/// (`ui.nav.collapsed`)、在这里读、在 [`SettingsBridge::persist_nav_collapsed`] 写。
|
||||
///
|
||||
/// 缺键 / 值被手改成非布尔 → **`false`(展开)**:出厂态是展开,坏配置不许把用户关进图标条。
|
||||
#[must_use]
|
||||
pub fn load_nav_collapsed(&self) -> bool {
|
||||
self.store
|
||||
.as_ref()
|
||||
.and_then(|store| {
|
||||
store
|
||||
.doc()
|
||||
.effective_bool(crate::prefs::keys::NAV_COLLAPSED)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 写回左侧导航轨的折叠档(**M300**)。返回 `true` = 磁盘上真的有新内容。
|
||||
///
|
||||
/// 走 [`SettingsBridge::write_with`](唯一写入口:现读 → 改 → 落盘 → 刷 mtime),于是
|
||||
/// 「刚存完 AI 配置就点折叠」不会把 `ai.*` 连密钥一起抹掉(M174 两轮复审的教训)。
|
||||
/// 值没变时返回 `Edit::Unchanged`:重复点同一个档位不该产生一次写盘 + `mtime` 抖动。
|
||||
pub fn persist_nav_collapsed(&mut self, collapsed: bool) -> bool {
|
||||
self.write_with(|store| {
|
||||
if store
|
||||
.doc()
|
||||
.effective_bool(crate::prefs::keys::NAV_COLLAPSED)
|
||||
== Some(collapsed)
|
||||
{
|
||||
return Edit::Unchanged;
|
||||
}
|
||||
match store.set(crate::prefs::keys::NAV_COLLAPSED, Value::from(collapsed)) {
|
||||
Ok(()) => Edit::Changed,
|
||||
Err(_) => Edit::Rejected,
|
||||
}
|
||||
})
|
||||
.is_saved()
|
||||
}
|
||||
|
||||
/// 落盘(失败静默——配置写不进去不该把 UI 卡住,诊断走日志)。
|
||||
///
|
||||
/// **写走 [`SettingsBridge::write_with`]**(M174 r1/r2 复审):本桥的 store 是长生命的
|
||||
@@ -197,200 +239,6 @@ fn resolve_config_path() -> Option<PathBuf> {
|
||||
fn mtime(path: &Path) -> Option<SystemTime> {
|
||||
std::fs::metadata(path).ok()?.modified().ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::prefs::PaletteSource;
|
||||
use unlucky_core::setting::appearance::BgFill;
|
||||
|
||||
fn temp_config(label: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"ull-bridge-{label}-{}-{}",
|
||||
std::process::id(),
|
||||
line!()
|
||||
));
|
||||
std::fs::create_dir_all(dir.join("config")).expect("临时目录");
|
||||
let path = dir.join("config/launcher.json");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
path
|
||||
}
|
||||
|
||||
/// **M216 ①**:数据根四键的写回落点 = 启动解析读的那份文件。
|
||||
///
|
||||
/// 抓得住:四键 → 便携那份、其余键 → 解析后那份、拿不到 exe 目录时退回。抓不住:真实
|
||||
/// `current_exe()` 的取值与 `Resolver` 的 `redirect.json` 跟随(那两条靠 `set` 的回合用例)。
|
||||
#[test]
|
||||
fn data_root_keys_are_written_where_startup_reads_them() {
|
||||
let exe = PathBuf::from("/games/ULL");
|
||||
let portable = crate::host::env::portable_config_path(&exe);
|
||||
let resolved = PathBuf::from("/appdata/ULL/config/launcher.json");
|
||||
for key in crate::host::env::DATA_ROOT_KEYS {
|
||||
assert_eq!(setting_path(&resolved, Some(&exe), key), portable, "{key}");
|
||||
}
|
||||
for key in ["java.default_memory_mb", "ai.model", "core.unknown_suffix"] {
|
||||
assert_eq!(setting_path(&resolved, Some(&exe), key), resolved, "{key}");
|
||||
}
|
||||
// 拿不到 exe 目录:退回解析后那份(宁可写「没人读的那份」也不丢写、不 panic)。
|
||||
assert_eq!(setting_path(&resolved, None, "core.data_mode"), resolved);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_defaults_when_file_missing() {
|
||||
let path = temp_config("factory");
|
||||
let bridge = SettingsBridge::with_path(&path);
|
||||
let (appearance, theme) = bridge.load();
|
||||
assert_eq!(appearance, AppearancePrefs::factory());
|
||||
assert_eq!(theme, ThemeMode::Auto);
|
||||
assert!(!path.exists(), "只读不写:缺文件不该被创建");
|
||||
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persist_then_reload_round_trips() {
|
||||
let path = temp_config("roundtrip");
|
||||
let mut bridge = SettingsBridge::with_path(&path);
|
||||
let mut appearance = AppearancePrefs::factory();
|
||||
appearance.bg_image_enabled = true;
|
||||
appearance.bg_image = "D:/wall/p.png".to_owned();
|
||||
appearance.bg_fill = BgFill::Contain;
|
||||
appearance.bg_dim = 0.5;
|
||||
appearance.panel_alpha = 0.8;
|
||||
appearance.glass = true;
|
||||
assert!(appearance.set_accent("#0f6cbd"));
|
||||
bridge.persist(&appearance, ThemeMode::Dark);
|
||||
|
||||
let reloaded = SettingsBridge::with_path(&path);
|
||||
let (value, theme) = reloaded.load();
|
||||
assert_eq!(value, appearance);
|
||||
assert_eq!(theme, ThemeMode::Dark);
|
||||
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_edit_is_picked_up_by_reload() {
|
||||
let path = temp_config("hotreload");
|
||||
let mut bridge = SettingsBridge::with_path(&path);
|
||||
// 先落一份(建立 mtime 基线)
|
||||
bridge.persist(&AppearancePrefs::factory(), ThemeMode::Light);
|
||||
assert!(bridge.reload_if_changed().is_none(), "自己写完不该报告变更");
|
||||
|
||||
// 模拟外部写入(AI / `ull config set`):改主题 + 打开背景图
|
||||
let mut external = SettingsBridge::with_path(&path);
|
||||
let mut appearance = AppearancePrefs::factory();
|
||||
appearance.bg_image_enabled = true;
|
||||
appearance.bg_image = "D:/wall/x.png".to_owned();
|
||||
external.persist(&appearance, ThemeMode::Dark);
|
||||
|
||||
// mtime 精度:有的文件系统 1s 粒度,这里显式等到时间戳变化
|
||||
for _ in 0..50 {
|
||||
if let Some((value, theme)) = bridge.reload_if_changed() {
|
||||
assert_eq!(theme, ThemeMode::Dark);
|
||||
assert!(value.bg_image_enabled);
|
||||
assert_eq!(value.bg_image, "D:/wall/x.png");
|
||||
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
}
|
||||
panic!("外部修改没有被 mtime 热切捕获");
|
||||
}
|
||||
|
||||
/// **P2-4 回归**:外观写回不许盖掉别人刚写的键(`ai.api_key` 最贵)。修之前必红。
|
||||
#[test]
|
||||
fn persist_keeps_keys_another_writer_just_saved() {
|
||||
let path = temp_config("persist-keeps-ai");
|
||||
let mut bridge = SettingsBridge::with_path(&path);
|
||||
// 先让本桥真的加载过一份文件(旧快照从这里来)。
|
||||
bridge.persist(&AppearancePrefs::factory(), ThemeMode::Light);
|
||||
|
||||
{
|
||||
use unlucky_core::setting::keys::launcher_schema;
|
||||
use unlucky_core::setting::store::SettingStore;
|
||||
// 另一个写者(M174 的助手设置页 / `ull config set`)写进三个 AI 键。
|
||||
let mut store = SettingStore::load(&path, launcher_schema());
|
||||
for (key, value) in [
|
||||
("ai.base_url", "https://api.deepseek.com/v1"),
|
||||
("ai.model", "deepseek-chat"),
|
||||
("ai.api_key", "sk-must-survive"),
|
||||
] {
|
||||
store.set(key, value.to_string().into()).expect("写 AI 键");
|
||||
}
|
||||
store.save().expect("落盘");
|
||||
}
|
||||
|
||||
// 同一秒内改主题:外观写回。
|
||||
bridge.persist(&AppearancePrefs::factory(), ThemeMode::Dark);
|
||||
|
||||
let text = std::fs::read_to_string(&path).expect("读文件");
|
||||
assert!(
|
||||
text.contains("sk-must-survive"),
|
||||
"外观写回把刚保存的密钥盖掉了:{text}"
|
||||
);
|
||||
assert!(text.contains("deepseek-chat"), "model 也被盖掉了:{text}");
|
||||
assert!(text.contains("\"dark\""), "主题这一项该写进去:{text}");
|
||||
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accent_preset_and_validation() {
|
||||
let mut appearance = AppearancePrefs::factory();
|
||||
assert_eq!(appearance.accent_color, "");
|
||||
assert!(appearance.set_accent_preset(1));
|
||||
assert_eq!(appearance.accent_color, "#0F6CBD");
|
||||
assert!(!appearance.set_accent("not-a-color"), "非法输入不改现状");
|
||||
assert_eq!(appearance.accent_color, "#0F6CBD");
|
||||
assert!(appearance.set_accent(""), "空串恢复出厂");
|
||||
assert_eq!(appearance.accent_color, "");
|
||||
assert!(!appearance.set_accent_preset(99));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bg_active_needs_both_switch_and_path() {
|
||||
let mut appearance = AppearancePrefs::factory();
|
||||
appearance.bg_image = "D:/wall/p.png".to_owned();
|
||||
assert!(!appearance.bg_active(), "开关没开不算生效");
|
||||
appearance.bg_image_enabled = true;
|
||||
assert!(appearance.bg_active());
|
||||
appearance.bg_image.clear();
|
||||
assert!(!appearance.bg_active(), "路径空也不算生效");
|
||||
}
|
||||
|
||||
/// `load_base()` 是「重启后读回整窗 α / 配色来源 / 窗口圆角」的唯一通道(M93 · M116)。
|
||||
///
|
||||
/// 抓得住:①文件里写着 `window_opacity=0.6` / `palette_source=wallpaper` 时能被**新开的**
|
||||
/// bridge 读到(= 重启语义);②缺键时回出厂(1.0 / preset),而不是 `0.0` 或随机档。
|
||||
/// 抓不住:UI 侧是否真的用它来下发窗口 α(那由 `app-iced` 的日志与单测证明)。
|
||||
#[test]
|
||||
fn load_base_reads_the_four_read_only_keys_and_defaults_when_missing() {
|
||||
let path = temp_config("base");
|
||||
// 文件由「外部写者」(CLI / AI / 设置页的 SetSetting)产生,这里直接落 JSON。
|
||||
std::fs::write(
|
||||
&path,
|
||||
r#"{"appearance":{"theme_mode":"dark","window_opacity":0.6,"palette_source":"wallpaper","panel_alpha":0.5}}"#,
|
||||
)
|
||||
.expect("写配置文件");
|
||||
|
||||
let bridge = SettingsBridge::with_path(&path);
|
||||
let base = bridge.load_base();
|
||||
assert_eq!(base.window_opacity, 0.6, "重启后读回整窗 α");
|
||||
assert_eq!(base.palette_source, PaletteSource::Wallpaper);
|
||||
assert!(base.is_transparent());
|
||||
|
||||
// 缺键 → 出厂值(与 `AppearanceBase::factory()` 逐字段相等)。
|
||||
let empty = path.with_file_name("launcher-empty.json");
|
||||
std::fs::write(&empty, "{}").expect("写空配置");
|
||||
let factory = SettingsBridge::with_path(&empty).load_base();
|
||||
assert_eq!(factory, AppearanceBase::factory());
|
||||
assert_eq!(factory.window_opacity, 1.0);
|
||||
assert_eq!(factory.palette_source, PaletteSource::Preset);
|
||||
|
||||
// 数据根解析不出来的内存态桥也要给得出出厂值(不 panic、不返回 0)。
|
||||
assert_eq!(
|
||||
SettingsBridge::memory_only().load_base(),
|
||||
AppearanceBase::factory()
|
||||
);
|
||||
|
||||
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
|
||||
}
|
||||
}
|
||||
#[path = "settings_bridge/tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,250 @@
|
||||
//! `SettingsBridge` 的单测(M300 从 `settings_bridge.rs` **纯搬移**,守 400 行硬线)。
|
||||
//!
|
||||
//! 搬移理由:M300 给桥加了 `load_nav_collapsed` / `persist_nav_collapsed` 两件(折叠档落盘),
|
||||
//! 原文件 434 行已破门禁。判据与注释一字未动,只是换文件——`settings_bridge.rs` 里现在是一行
|
||||
//! `#[cfg(test)] #[path = "settings_bridge/tests.rs"] mod tests;`,模块路径仍是
|
||||
//! `settings_bridge::tests`。
|
||||
|
||||
use super::*;
|
||||
use crate::prefs::PaletteSource;
|
||||
use unlucky_core::setting::appearance::BgFill;
|
||||
|
||||
fn temp_config(label: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"ull-bridge-{label}-{}-{}",
|
||||
std::process::id(),
|
||||
line!()
|
||||
));
|
||||
std::fs::create_dir_all(dir.join("config")).expect("临时目录");
|
||||
let path = dir.join("config/launcher.json");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
path
|
||||
}
|
||||
|
||||
/// **M216 ①**:数据根四键的写回落点 = 启动解析读的那份文件。
|
||||
///
|
||||
/// 抓得住:四键 → 便携那份、其余键 → 解析后那份、拿不到 exe 目录时退回。抓不住:真实
|
||||
/// `current_exe()` 的取值与 `Resolver` 的 `redirect.json` 跟随(那两条靠 `set` 的回合用例)。
|
||||
#[test]
|
||||
fn data_root_keys_are_written_where_startup_reads_them() {
|
||||
let exe = PathBuf::from("/games/ULL");
|
||||
let portable = crate::host::env::portable_config_path(&exe);
|
||||
let resolved = PathBuf::from("/appdata/ULL/config/launcher.json");
|
||||
for key in crate::host::env::DATA_ROOT_KEYS {
|
||||
assert_eq!(setting_path(&resolved, Some(&exe), key), portable, "{key}");
|
||||
}
|
||||
for key in ["java.default_memory_mb", "ai.model", "core.unknown_suffix"] {
|
||||
assert_eq!(setting_path(&resolved, Some(&exe), key), resolved, "{key}");
|
||||
}
|
||||
// 拿不到 exe 目录:退回解析后那份(宁可写「没人读的那份」也不丢写、不 panic)。
|
||||
assert_eq!(setting_path(&resolved, None, "core.data_mode"), resolved);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_defaults_when_file_missing() {
|
||||
let path = temp_config("factory");
|
||||
let bridge = SettingsBridge::with_path(&path);
|
||||
let (appearance, theme) = bridge.load();
|
||||
assert_eq!(appearance, AppearancePrefs::factory());
|
||||
assert_eq!(theme, ThemeMode::Auto);
|
||||
assert!(!path.exists(), "只读不写:缺文件不该被创建");
|
||||
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persist_then_reload_round_trips() {
|
||||
let path = temp_config("roundtrip");
|
||||
let mut bridge = SettingsBridge::with_path(&path);
|
||||
let mut appearance = AppearancePrefs::factory();
|
||||
appearance.bg_image_enabled = true;
|
||||
appearance.bg_image = "D:/wall/p.png".to_owned();
|
||||
appearance.bg_fill = BgFill::Contain;
|
||||
appearance.bg_dim = 0.5;
|
||||
appearance.panel_alpha = 0.8;
|
||||
appearance.glass = true;
|
||||
assert!(appearance.set_accent("#0f6cbd"));
|
||||
bridge.persist(&appearance, ThemeMode::Dark);
|
||||
|
||||
let reloaded = SettingsBridge::with_path(&path);
|
||||
let (value, theme) = reloaded.load();
|
||||
assert_eq!(value, appearance);
|
||||
assert_eq!(theme, ThemeMode::Dark);
|
||||
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_edit_is_picked_up_by_reload() {
|
||||
let path = temp_config("hotreload");
|
||||
let mut bridge = SettingsBridge::with_path(&path);
|
||||
// 先落一份(建立 mtime 基线)
|
||||
bridge.persist(&AppearancePrefs::factory(), ThemeMode::Light);
|
||||
assert!(bridge.reload_if_changed().is_none(), "自己写完不该报告变更");
|
||||
|
||||
// 模拟外部写入(AI / `ull config set`):改主题 + 打开背景图
|
||||
let mut external = SettingsBridge::with_path(&path);
|
||||
let mut appearance = AppearancePrefs::factory();
|
||||
appearance.bg_image_enabled = true;
|
||||
appearance.bg_image = "D:/wall/x.png".to_owned();
|
||||
external.persist(&appearance, ThemeMode::Dark);
|
||||
|
||||
// mtime 精度:有的文件系统 1s 粒度,这里显式等到时间戳变化
|
||||
for _ in 0..50 {
|
||||
if let Some((value, theme)) = bridge.reload_if_changed() {
|
||||
assert_eq!(theme, ThemeMode::Dark);
|
||||
assert!(value.bg_image_enabled);
|
||||
assert_eq!(value.bg_image, "D:/wall/x.png");
|
||||
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
}
|
||||
panic!("外部修改没有被 mtime 热切捕获");
|
||||
}
|
||||
|
||||
/// **P2-4 回归**:外观写回不许盖掉别人刚写的键(`ai.api_key` 最贵)。修之前必红。
|
||||
#[test]
|
||||
fn persist_keeps_keys_another_writer_just_saved() {
|
||||
let path = temp_config("persist-keeps-ai");
|
||||
let mut bridge = SettingsBridge::with_path(&path);
|
||||
// 先让本桥真的加载过一份文件(旧快照从这里来)。
|
||||
bridge.persist(&AppearancePrefs::factory(), ThemeMode::Light);
|
||||
|
||||
{
|
||||
use unlucky_core::setting::keys::launcher_schema;
|
||||
use unlucky_core::setting::store::SettingStore;
|
||||
// 另一个写者(M174 的助手设置页 / `ull config set`)写进三个 AI 键。
|
||||
let mut store = SettingStore::load(&path, launcher_schema());
|
||||
for (key, value) in [
|
||||
("ai.base_url", "https://api.deepseek.com/v1"),
|
||||
("ai.model", "deepseek-chat"),
|
||||
("ai.api_key", "sk-must-survive"),
|
||||
] {
|
||||
store.set(key, value.to_string().into()).expect("写 AI 键");
|
||||
}
|
||||
store.save().expect("落盘");
|
||||
}
|
||||
|
||||
// 同一秒内改主题:外观写回。
|
||||
bridge.persist(&AppearancePrefs::factory(), ThemeMode::Dark);
|
||||
|
||||
let text = std::fs::read_to_string(&path).expect("读文件");
|
||||
assert!(
|
||||
text.contains("sk-must-survive"),
|
||||
"外观写回把刚保存的密钥盖掉了:{text}"
|
||||
);
|
||||
assert!(text.contains("deepseek-chat"), "model 也被盖掉了:{text}");
|
||||
assert!(text.contains("\"dark\""), "主题这一项该写进去:{text}");
|
||||
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accent_preset_and_validation() {
|
||||
let mut appearance = AppearancePrefs::factory();
|
||||
assert_eq!(appearance.accent_color, "");
|
||||
assert!(appearance.set_accent_preset(1));
|
||||
assert_eq!(appearance.accent_color, "#0F6CBD");
|
||||
assert!(!appearance.set_accent("not-a-color"), "非法输入不改现状");
|
||||
assert_eq!(appearance.accent_color, "#0F6CBD");
|
||||
assert!(appearance.set_accent(""), "空串恢复出厂");
|
||||
assert_eq!(appearance.accent_color, "");
|
||||
assert!(!appearance.set_accent_preset(99));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bg_active_needs_both_switch_and_path() {
|
||||
let mut appearance = AppearancePrefs::factory();
|
||||
appearance.bg_image = "D:/wall/p.png".to_owned();
|
||||
assert!(!appearance.bg_active(), "开关没开不算生效");
|
||||
appearance.bg_image_enabled = true;
|
||||
assert!(appearance.bg_active());
|
||||
appearance.bg_image.clear();
|
||||
assert!(!appearance.bg_active(), "路径空也不算生效");
|
||||
}
|
||||
|
||||
/// `load_base()` 是「重启后读回整窗 α / 配色来源 / 窗口圆角」的唯一通道(M93 · M116)。
|
||||
///
|
||||
/// 抓得住:①文件里写着 `window_opacity=0.6` / `palette_source=wallpaper` 时能被**新开的**
|
||||
/// bridge 读到(= 重启语义);②缺键时回出厂(1.0 / preset),而不是 `0.0` 或随机档。
|
||||
/// 抓不住:UI 侧是否真的用它来下发窗口 α(那由 `app-iced` 的日志与单测证明)。
|
||||
#[test]
|
||||
fn load_base_reads_the_four_read_only_keys_and_defaults_when_missing() {
|
||||
let path = temp_config("base");
|
||||
// 文件由「外部写者」(CLI / AI / 设置页的 SetSetting)产生,这里直接落 JSON。
|
||||
std::fs::write(
|
||||
&path,
|
||||
r#"{"appearance":{"theme_mode":"dark","window_opacity":0.6,"palette_source":"wallpaper","panel_alpha":0.5}}"#,
|
||||
)
|
||||
.expect("写配置文件");
|
||||
|
||||
let bridge = SettingsBridge::with_path(&path);
|
||||
let base = bridge.load_base();
|
||||
assert_eq!(base.window_opacity, 0.6, "重启后读回整窗 α");
|
||||
assert_eq!(base.palette_source, PaletteSource::Wallpaper);
|
||||
assert!(base.is_transparent());
|
||||
|
||||
// 缺键 → 出厂值(与 `AppearanceBase::factory()` 逐字段相等)。
|
||||
let empty = path.with_file_name("launcher-empty.json");
|
||||
std::fs::write(&empty, "{}").expect("写空配置");
|
||||
let factory = SettingsBridge::with_path(&empty).load_base();
|
||||
assert_eq!(factory, AppearanceBase::factory());
|
||||
assert_eq!(factory.window_opacity, 1.0);
|
||||
assert_eq!(factory.palette_source, PaletteSource::Preset);
|
||||
|
||||
// 数据根解析不出来的内存态桥也要给得出出厂值(不 panic、不返回 0)。
|
||||
assert_eq!(
|
||||
SettingsBridge::memory_only().load_base(),
|
||||
AppearanceBase::factory()
|
||||
);
|
||||
|
||||
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
|
||||
}
|
||||
|
||||
/// **M300 落盘往返**:`ui.nav.collapsed` 写进文件 → **重新构造桥**(= 重启)→ 读回。
|
||||
///
|
||||
/// 这条用例守的是任务单里那句「不然重启就回去了」:只断言内存字段的用例对它零约束
|
||||
/// (字段本来就地改,重启后 `UiPrefs::default()` 照样是展开)。
|
||||
///
|
||||
/// **抓得住**:①`persist_nav_collapsed` 忘了 `store.save()`(第二次构造读回 false);
|
||||
/// ②键名两侧不一致(core schema 叫 `ui.nav.collapsed`、桥写成别的串 ⇒ `store.set` 被
|
||||
/// schema 拒 ⇒ 第一次写返回 false);③core 没登记这个键(`UnknownKey` ⇒ 同样第一次写就 false);
|
||||
/// ④`load_nav_collapsed` 读的是内存快照而不是刚 reload 的文档(跑完这次进程内的旧值还能骗过它,
|
||||
/// 但「换一个桥对象」那一步就露馅)。
|
||||
/// **抓不住**:真实数据根解析(`SettingsBridge::open()` 那条路,见本文件另一组用例)、
|
||||
/// 并发写盘(`save_worker` 的锁),以及 UI 那一侧有没有真的调它(那归 app 侧的
|
||||
/// `app/update/nav.rs` 用例)。
|
||||
#[test]
|
||||
fn nav_collapsed_survives_a_restart() {
|
||||
let path = temp_config("nav-collapsed");
|
||||
|
||||
// ① 出厂(文件不存在):展开。
|
||||
let mut bridge = SettingsBridge::with_path(&path);
|
||||
assert!(!bridge.load_nav_collapsed(), "缺键 = 出厂展开");
|
||||
|
||||
// ② 折叠 → 真的落盘。
|
||||
assert!(
|
||||
bridge.persist_nav_collapsed(true),
|
||||
"第一次写 `ui.nav.collapsed` 必须真的落盘(false = 被 schema 拒 / 没存)"
|
||||
);
|
||||
|
||||
// ③ 「重启」:换一个桥对象读同一份文件。
|
||||
let reopened = SettingsBridge::with_path(&path);
|
||||
assert!(
|
||||
reopened.load_nav_collapsed(),
|
||||
"重启后必须读回折叠档(否则折叠是假的)"
|
||||
);
|
||||
|
||||
// ④ 幂等:同一个值再写一次不该产生写盘(`Edit::Unchanged`)。
|
||||
let mut bridge = reopened;
|
||||
assert!(!bridge.persist_nav_collapsed(true), "值没变时不该再写盘");
|
||||
|
||||
// ⑤ 展开也要落盘(只写 true 的实现会在这里红)。
|
||||
assert!(bridge.persist_nav_collapsed(false));
|
||||
assert!(!SettingsBridge::with_path(&path).load_nav_collapsed());
|
||||
|
||||
// ⑥ 内存态桥(数据根解析不出来):不 panic、也不假装写成功。
|
||||
let mut memory = SettingsBridge::memory_only();
|
||||
assert!(!memory.load_nav_collapsed());
|
||||
assert!(!memory.persist_nav_collapsed(true));
|
||||
|
||||
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
|
||||
}
|
||||
Reference in new issue
Block a user