fix(app-iced): M90 r3 —— 空闲 1Hz 心跳恢复配置热切 + 导航轨口径订正 + 文档收口

复审 P2-2(空闲态掐时钟 ⇒ config_poll_due 再也不跑):
- 空闲态改用 1 Hz 自续心跳(Task::perform(sleep(1s)) → Message::Heartbeat),忙碌态仍用
  window::frames() + 33ms 闸门;两条时钟不并行(心跳在 needs_frames() 为真时只续排不推进)。
- ticks 改成「33 ms 一拍」记账(1 s 心跳记 30 拍),config 轮询判据从「整除」改成「跨越」——
  实测整除版在 ticks=137 之后每拍 +30 永远不命中,配置轮询静默死掉。
- 顺手修一个真 bug:Message::SystemTheme 原来无条件 set_flavor,会把配置文件里的显式主题盖掉
  (优先级现在是 --theme > 配置文件 > 系统),否则「改 launcher.json 主题 1 秒内生效」验收根本看不到。
- 实测:14 s 交互跑,空闲段 [tick] clock=idle-1hz 每秒一行、frames 冻结在 2947、refreshes 冻结在 6;
  t≈7s / t≈11s 两次改 launcher.json,各自 ≤1 s 内打出 [settings] 配置文件变了 + [theme] 翻转。

复审 P2-1(导航轨口径):
- pages/mod.rs 交付表改成事实(产品路径的 rail 由 shell/nav.rs 画,组件版只给 harness/单测,
  像素等价是复审判的),补偏离表第 10 条(两套 rail 各测各的,漂移没人拦);
- pages/tests.rs 的 the_real_rail_agrees_with_the_layout_system 注释同步订正。

P3 文档五条:shell 的「8px 拖动热区」改成事实(拖动面=整条 48px 标题栏到第一个 caption;
缩放热区 4px)、state/update 的「每拍重建视图模型」改成 view_dirty 门控、message 的 Frame 注释
改成「只在忙碌时订阅」、pages/model 的 From<&AppState> 改成自由函数 model(..)、pages/tests 的守门
目录补 src/app。

r3 浅/深产品图与 r2 逐字节相同(视觉零变化)。
This commit is contained in:
AskaEth committed 2026-11-02 15:56:59 +08:00
1 parent 022778a5a7
commit 6b66adff44
9 files changed
+254 -42

No files matched your search

+86 -6
View File
@@ -101,13 +101,19 @@ fn frames_are_only_requested_in_screenshot_mode() {
// --------------------------------------------------------------- P2-1 按需节拍
/// 空闲态**不要时钟**:没有忙碌实例、没有 toast、没有观察窗 ⇒ 不订阅帧事件。
/// 空闲态**不要每帧节拍**(但要有 1 Hz 心跳)。
///
/// 抓得住:`needs_frames()` 的三条触发条件(这也是 `subscription()` 的唯一判据)——
/// 谁把「无条件订阅帧」改回来,这条立刻红。
/// 意图与 r2 那条 `an_idle_app_asks_for_no_clock` 一致:空闲时不许按帧循环跑(那会以每秒几百帧
/// 的速度重绘整窗),改成「低频心跳」。断言拆成两半:
///
/// 1. `needs_frames()` 为假 ⇒ **不订阅** `window::frames()`;
/// 2. 空闲时钟的周期 = [`IDLE_TICK`] = 1 s,且心跳每拍推进的 tick 预算 = 30(= 1 s / 33 ms),
/// 这样 `theme::config_poll_due`(30 拍)仍是「约 1 秒比对一次 mtime」。
///
/// 抓得住:有人把「无条件订阅帧」改回来(第 1 半红);有人把心跳周期改快/改慢(第 2 半红)。
/// 抓不住:真实窗口的重绘次数(那要 `ULL_TICK_TRACE=1` 跑一次交互模式看日志,见报告)。
#[test]
fn an_idle_app_asks_for_no_clock() {
fn an_idle_app_uses_a_slow_heartbeat_instead_of_frames() {
let app = headless(screenshot_options(), SchemeFlavor::Light);
assert!(app.needs_frames(), "截图通道永远要帧(第 3 帧落盘)");
@@ -118,9 +124,9 @@ fn an_idle_app_asks_for_no_clock() {
"Boot 刚派发过 RefreshAll:3 秒观察窗内要节拍(等真 host 的首批事件)"
);
// 观察窗过期、也没有忙碌实例 ⇒ 空闲态不再要时钟
// 观察窗过期、也没有忙碌实例 ⇒ 空闲态不订阅帧事件(改走 1 Hz 心跳)
idle.watch_until = None;
assert!(!idle.needs_frames(), "空闲交互态:不订阅帧事件");
assert!(!idle.needs_frames(), "空闲交互态:不订阅帧事件(只有心跳)");
// 派发之后观察窗重新打开
let _ = idle.update(Message::Page(pages::Message::Navigate(Route::Detail(
@@ -139,6 +145,36 @@ fn an_idle_app_asks_for_no_clock() {
);
}
/// 心跳的**节奏**:1 Hz,且每拍折算 30 个 33 ms 单位 ⇒ `config_poll_due` ≈ 每秒一次。
///
/// 抓得住:心跳周期常量、以及 `advance()` 里「按流逝时间折算 ticks」这条记账(改回
/// `ticks += 1` 会让空闲态的配置轮询变成 30 秒一次,这条就红)。
/// 抓不住:`Task::perform` 那条自续链真的每秒触发一次(那是运行期 trace 的事,见报告)。
#[test]
fn the_idle_heartbeat_budgets_one_config_poll_per_second() {
use unlucky_presenter::theme::config_poll_due;
assert_eq!(
super::state::IDLE_TICK,
std::time::Duration::from_secs(1),
"空闲心跳周期必须是 1 s"
);
let mut app = headless(Options::default(), SchemeFlavor::Light);
let _ = app.update(Message::Boot);
app.watch_until = None;
// 一次空闲心跳(1 s)之后:ticks 增加 30,且正好落在 config 轮询点上
let before = app.ticks;
app.advance(super::state::IDLE_TICK);
assert_eq!(app.ticks - before, 30, "1 s 应折算 30 个 33 ms 拍");
assert!(
config_poll_due(app.ticks),
"空闲心跳每拍都要落在 config 轮询点上(ticks={})",
app.ticks
);
}
/// 空闲态每拍**不再重建视图模型**(P2-1 的另一半:`refresh_view` 的 30 Hz × N 实例白克隆)。
///
/// 抓得住:把 `advance()` 里的 `if self.view_dirty` 去掉(改回无条件 `refresh_view()`)时,
@@ -190,3 +226,47 @@ fn screenshot_mode_ignores_the_settings_file_theme() {
app.apply_config_theme();
assert_eq!(app.ui().name(), "ull-m3-light", "截图模式的主题只认命令行");
}
/// 主题优先级:`--theme` > 配置文件 `appearance.theme_mode` > 系统主题。
///
/// r2 之前 `Message::SystemTheme` 无条件 `set_flavor`,于是「配置文件写 dark、系统是浅色」会
/// 出现 `light -> dark -> light` 的一次抖动、配置永远不生效(r3 做 P2-2 的配置热切实测时暴露
/// 出来,一并修)。
///
/// 抓得住:系统主题不许盖掉显式配置/命令行给出的主题(三条分支各一条断言)。
/// 抓不住:真实桌面切换系统主题时的事件时序(只有消息级验证)。
#[test]
fn the_system_theme_never_overrides_an_explicit_one() {
use unlucky_presenter::prefs::ThemeMode;
// ① auto(内存桥默认):系统主题说了算
let mut auto = headless(Options::default(), SchemeFlavor::Light);
assert_eq!(auto.theme_mode, ThemeMode::Auto);
let _ = auto.update(Message::SystemTheme(iced::theme::Mode::Dark));
assert_eq!(auto.ui().name(), "ull-m3-dark", "auto 模式要跟随系统");
// ② 配置文件显式 dark:系统给浅色也不许改
let mut pinned = headless(Options::default(), SchemeFlavor::Dark);
pinned.theme_mode = ThemeMode::Dark;
let _ = pinned.update(Message::SystemTheme(iced::theme::Mode::Light));
assert_eq!(
pinned.ui().name(),
"ull-m3-dark",
"配置文件说 dark,系统说 light:不许改"
);
// ③ 命令行 `--theme` 优先级最高
let mut explicit = headless(
Options {
theme: Some(SchemeFlavor::Dark),
..Options::default()
},
SchemeFlavor::Dark,
);
let _ = explicit.update(Message::SystemTheme(iced::theme::Mode::Light));
assert_eq!(
explicit.ui().name(),
"ull-m3-dark",
"--theme 优先级最高,系统主题不许盖"
);
}