fix(m192): r1 复审 P1-1——动效起跑搬进 update 侧 + 帧基准兜底 + 按运行时顺序的守卫
复审的机制三步全部复核成立(iced_winit-0.14.1 `src/lib.rs:1222` 调 update、`1338` 在 `update()` 内部求值订阅表、`1233` 之后才 `build_user_interfaces` 调 view)。第一版把 「标识变了 → play」写在 `pages::render`(= view)里,于是:同一拍的订阅表看不见轨道 ⇒ `frames()` 要等下一条消息才打开;而那一帧的 dt 是整段空闲时长 ⇒ 轨道被当场判到期删掉。 用户看到的是「一帧 12/8px 偏移 + 闪跳」,比硬切更差;截图通道因为 `has_screenshot()` 恒真, 结构上不可能发现它。 修法(三件都做) - **起跑搬进 update**:新增 `app/update/motion_sync.rs::App::sync_motion`,在 `App::update` 收尾(`route_message` 之后、`subscription()` 之前)按**路由**算内容标识并 `motion::observe`。 `pages::render` 只留 `motion::sample()`(纯采样)。 顺带:`screen == None`(账户页 / AI 独立页)只记标识不起跑,于是「从它们回到某一屏」也跑换页。 - **帧基准兜底**:`observe` 起跑成功时把 `LAST_FRAME` 重置到「现在」;`Pump::advance` 再加一道 `MAX_STEP_MS`(4 帧 = 64 ms)单次步进上限——掉帧/挂起时宁可把动画放慢,也不许一帧抹掉。 - **没有窗口就不起跑**:`window.is_none()` 且不是截图通道时 `sync_motion` 直接返回(也**不记** 标识)⇒「首屏入场」等窗口到了才算第一眼,启动期那棵空树吃不掉它(原先被 1 Hz 心跳 / 既有 `an_idle_app_...` 用例照出来的就是这个边界)。 - `on_heartbeat` 也推进动效时间轴:窗口被遮挡/最小化/合成器不送绘制时,帧一个都不来,而 「有轨道」会让 1 Hz 推进整段停摆 ⇒ 轨道靠心跳按单次上限收尾(最坏 5 拍内自然退订)。 守卫(新增 5 条,全部按运行时顺序写) - `a_state_change_opens_the_frame_subscription_in_the_same_tick`:**先** `assert!(!needs_frames)` →**一条真消息**(`Message::Page(Navigate(..))`)→ `assert!(needs_frames)`,并断言 `watch_until` 仍是 `None`(排除「观察窗顺手开帧」这条假绿)。r1 那条顺序是反的,已删。 - `opening_a_detail_also_opens_the_frames_in_the_same_tick`、`pages_that_render_elsewhere_do_not_start_a_track`、 `without_a_window_nothing_animates`、`a_stale_baseline_cannot_kill_the_track_in_one_sample`。 r1 两条 P2 - P2-1:`pump::FRAME_MS` 与 `mod.rs` 契约表补写「16 ms 只卡采样/布局推进,**整窗重绘跟随渲染器** (软件渲染 ≈970 帧/秒、一次 300 ms 换页 ≈290 次整窗重绘 ≈0.30 核·秒;GPU 档 AutoVsync 40~60)」。 - P2-2:删掉 `app/subscription/tests.rs`,测试并回 `app/subscription.rs` 的 `mod tests` ⇒ 本轮 动过的文件全部落在声明 scope 内(新文件只有 `app/update/motion_sync.rs`,在 `app/update/**` 下)。 真机证据(GPU 档,`ULL_TICK_TRACE=1`,空闲 14 s 后由心跳触发一次**真实**导航, `DevDocs/artifacts/m192/idle-click-evidence-gpu-final.log`): idle: frames=96 clock=idle-1hz motion=14(连续 7 拍不动) fire: clock=frames motion=16 → 17(同一拍订阅表就开了) end : frames=102 clock=idle-1hz motion=20(自己退订) 说明:本机会话没有前台窗口(`GetForegroundWindow()==0`),合成点击(`mouse_event` 与 `PostMessage(WM_LBUTTONDOWN)`)递不进 iced 的输入层,故用「心跳第 N 秒真导航」这条同构路径; 样本次数由**绘制送达**决定(该次动效只收到 ~5 次绘制 ⇒ +6 次采样),正常可见窗口下 60 Hz 呈现 对应 ~19 次。 变异证明 7/7 红(`DevDocs/artifacts/m192/mutate.sh`,含新增 M6「删掉 sync_motion」与 M7「去掉单次步进上限」),还原后自检全绿。 编译:cargo test -p unlucky-app-iced → 0(797 passed / 2 ignored,集成 6 passed); clippy --all-targets -D warnings → 0;fmt --check → 0;build → 0。 出图基线复验:三张 rest 基线与九张序列帧的 sha256 与 r1 **逐字节相同**(常驻的那层 container 零内距)。
This commit is contained in:
1 parent
a1876a08c0
commit
93bd704786
9 files changed
+665
-240
No files matched your search
@@ -101,6 +101,250 @@ impl App {
|
||||
.is_some_and(|deadline| Instant::now() < deadline)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
mod tests {
|
||||
//! 帧订阅的**有界**守卫(M192):动画期间订阅、结束即退订、关掉动效一帧都不订阅。
|
||||
//!
|
||||
//! ## 这一组按**运行时顺序**写(M192 r1 复审 P1-1 的教训)
|
||||
//!
|
||||
//! 第一版这里是「先 `motion::enter(...)` 再问 `needs_frames()`」——而 iced 的真实顺序是
|
||||
//! 「`update()`(订阅表在这里求值)→ `view()`」,于是这条守卫全绿、生产路径上却根本播不出来。
|
||||
//! 现在全部改走**真消息**:`app.update(Message::Page(Navigate(..)))` 之后再问订阅表,
|
||||
//! 而且断言 `watch_until` 仍是 `None`(排除「观察窗顺手开了帧」这条假绿)。
|
||||
//!
|
||||
//! 时间轴用 `motion::advance_by` 摆(不等真实时钟),每个用例在自己的线程上跑,互不干扰。
|
||||
|
||||
use super::*;
|
||||
use crate::app::components::motion::{Slot, FRAME_MS};
|
||||
use crate::cli::Options;
|
||||
use crate::pages;
|
||||
use crate::theme::Families;
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_design::motion::MotionScale;
|
||||
use unlucky_presenter::mock::MockBackend;
|
||||
use unlucky_presenter::router::Route;
|
||||
use unlucky_presenter::settings_bridge::SettingsBridge;
|
||||
|
||||
const FAMILIES: Families = Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
};
|
||||
|
||||
/// 不碰磁盘、后端是 mock 的 App(与 `app/tests.rs` 的 `headless` 同构)。
|
||||
fn headless() -> App {
|
||||
App::with_presenter(
|
||||
Options::default(),
|
||||
FAMILIES,
|
||||
SchemeFlavor::Light,
|
||||
Box::new(MockBackend::new()),
|
||||
SettingsBridge::memory_only(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 走到「真·空闲」:没有观察窗、没有忙碌实例、没有动画(首屏那一条 Boot 已跑完)。
|
||||
///
|
||||
/// `Boot` 自己就会起跑一次首屏动效(`on_boot` → `sync_motion`),所以必须先把它推完,
|
||||
/// 否则后面的 `assert!(!needs_frames())` 测的是「动画还在跑」而不是「空闲」。
|
||||
fn idle_app() -> App {
|
||||
motion::reset();
|
||||
motion::configure(MotionScale::STANDARD, None);
|
||||
let mut app = headless();
|
||||
// 有窗口才谈得上动效(`sync_motion` 在没有窗口时直接返回,见它的文档)
|
||||
app.window = Some(iced::window::Id::unique());
|
||||
let _ = app.update(Message::Boot);
|
||||
app.watch_until = None;
|
||||
run_until_idle();
|
||||
assert!(!motion::needs_frames(), "前提:首屏动效已经跑完");
|
||||
app
|
||||
}
|
||||
|
||||
/// 把时间轴推到全部轨道结束(每步 16 ms,上限 20 s)。
|
||||
fn run_until_idle() {
|
||||
for _ in 0..1250 {
|
||||
if !motion::needs_frames() {
|
||||
return;
|
||||
}
|
||||
motion::advance_by(FRAME_MS);
|
||||
}
|
||||
}
|
||||
|
||||
/// **按运行时顺序**:空闲态一条消息进来(换页)⇒ **同一拍**订阅表就要有帧;跑完立刻退订。
|
||||
///
|
||||
/// 变异证明:把起跑搬回 `view`(删掉 `App::update` 里的 `sync_motion`)⇒ 第一步的
|
||||
/// `assert!(app.needs_frames())` 红;把 `Pump::advance` 的「到期即删除」删掉 ⇒ 最后一步红。
|
||||
#[test]
|
||||
fn a_state_change_opens_the_frame_subscription_in_the_same_tick() {
|
||||
let mut app = idle_app();
|
||||
assert!(!app.needs_frames(), "前提:空闲交互态本来就不订阅帧");
|
||||
|
||||
// 用户点了一次导航轨:**一条**消息,之后不再补任何消息
|
||||
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Download)));
|
||||
|
||||
assert!(
|
||||
app.watch_until.is_none(),
|
||||
"换页不该打开观察窗(这里要证明帧是**动效**要来的,不是派发顺手开的)"
|
||||
);
|
||||
assert!(motion::needs_frames(), "换页必须在同一拍起跑");
|
||||
assert!(
|
||||
app.needs_frames(),
|
||||
"同一拍的订阅表必须能看见轨道(iced 先算订阅表、后建视图 —— 复审 P1-1)"
|
||||
);
|
||||
|
||||
run_until_idle();
|
||||
assert!(!motion::needs_frames(), "动画跑完帧泵必须清空");
|
||||
assert!(
|
||||
!app.needs_frames(),
|
||||
"动画结束后必须退回 1 Hz 心跳(占着帧订阅 = 空闲烧帧)"
|
||||
);
|
||||
motion::reset();
|
||||
}
|
||||
|
||||
/// 列表 ↔ 详情同样走「一条消息 ⇒ 当拍有帧」,且 `Detail` 槽真的是那条。
|
||||
#[test]
|
||||
fn opening_a_detail_also_opens_the_frames_in_the_same_tick() {
|
||||
let mut app = idle_app();
|
||||
// 出厂快照会自动选中第一条 ⇒ 先回列表,让「点进详情」真的是一次详情变化
|
||||
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Instances)));
|
||||
run_until_idle();
|
||||
assert!(!app.needs_frames());
|
||||
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail(
|
||||
"survival".to_string(),
|
||||
))));
|
||||
assert!(app.needs_frames(), "点进详情必须当拍订阅帧");
|
||||
run_until_idle();
|
||||
assert!(!app.needs_frames());
|
||||
motion::reset();
|
||||
}
|
||||
|
||||
/// 账户页 / AI 独立页**不归 pages 画**:切过去不起跑(否则白跑 300 ms 帧);切回来照旧跑。
|
||||
#[test]
|
||||
fn pages_that_render_elsewhere_do_not_start_a_track() {
|
||||
let mut app = idle_app();
|
||||
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Accounts)));
|
||||
assert!(
|
||||
!motion::needs_frames(),
|
||||
"账户页没有 pages 树可动,不许白开帧"
|
||||
);
|
||||
|
||||
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Instances)));
|
||||
assert!(motion::needs_frames(), "从账户页回到某一屏仍然是换页,要跑");
|
||||
run_until_idle();
|
||||
motion::reset();
|
||||
}
|
||||
|
||||
/// **没有窗口就不起跑**:`window::frames()` 在没窗口时一条消息都不会有,起跑只是白占一档;
|
||||
/// 首屏那一条也因此要等窗口到了才算「第一眼」。
|
||||
#[test]
|
||||
fn without_a_window_nothing_animates() {
|
||||
motion::reset();
|
||||
motion::configure(MotionScale::STANDARD, None);
|
||||
let mut app = headless();
|
||||
assert!(app.window.is_none(), "前提:还没拿到窗口句柄");
|
||||
let _ = app.update(Message::Boot);
|
||||
app.watch_until = None;
|
||||
assert!(!motion::needs_frames(), "没有窗口 ⇒ 不许起跑");
|
||||
|
||||
// 窗口来了:首屏入场这时才开始(不是被启动期那棵空树吃掉)
|
||||
let _ = app.update(Message::WindowReady(Some(iced::window::Id::unique())));
|
||||
assert!(app.needs_frames(), "窗口到了之后「首屏」才起跑");
|
||||
run_until_idle();
|
||||
motion::reset();
|
||||
}
|
||||
|
||||
/// **`MotionScale::NONE` 真的零订阅**:不是「跑一遍 0 时长动画」。
|
||||
///
|
||||
/// 变异证明:把 `Pump::play` 的 `is_off()` 分支删掉 ⇒ 帧采样次数非 0,本用例红。
|
||||
#[test]
|
||||
fn no_scale_requests_no_frames_at_all() {
|
||||
motion::reset();
|
||||
let mut app = headless();
|
||||
app.window = Some(iced::window::Id::unique());
|
||||
let _ = app.update(Message::Boot);
|
||||
app.watch_until = None;
|
||||
motion::configure(MotionScale::NONE, None);
|
||||
|
||||
// 三种状态变化:换页 / 换详情 / 再换回来
|
||||
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Download)));
|
||||
assert!(!app.needs_frames(), "关掉动效后换页一帧都不该订阅");
|
||||
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail(
|
||||
"survival".to_string(),
|
||||
))));
|
||||
assert!(!app.needs_frames());
|
||||
run_until_idle();
|
||||
|
||||
assert!(!motion::needs_frames());
|
||||
assert_eq!(motion::samples(), 0, "NONE 档的帧采样次数必须是 0");
|
||||
motion::reset();
|
||||
}
|
||||
|
||||
/// 帧订阅在**产品接线**里只出现一处:五条理由共享同一个布尔,所以不会出现「动画与 Busy 各订一条」。
|
||||
///
|
||||
/// 口径是**全仓 `src/**`**(复审 r1 备注:只扫 `subscription.rs` 抓不到别人另开订阅),只看
|
||||
/// 代码行(注释/文档里提到这个符号不算)。唯一允许的第二个文件是 `pages/harness.rs`——
|
||||
/// 那是 `pages_demo` 这条**独立 example** 自己的 iced app(不接 `App`、不进产品路径)。
|
||||
#[test]
|
||||
fn there_is_exactly_one_frame_subscription() {
|
||||
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||
let mut files = Vec::new();
|
||||
collect_rust_files(&src, &mut files);
|
||||
assert!(
|
||||
files.len() >= 40,
|
||||
"只扫到 {} 个文件:扫描逻辑坏了",
|
||||
files.len()
|
||||
);
|
||||
|
||||
let mut hits = Vec::new();
|
||||
for path in files {
|
||||
let text = std::fs::read_to_string(&path).expect("读源文件");
|
||||
let product = text.split("#[cfg(test)]").next().unwrap_or(&text);
|
||||
let code = product
|
||||
.lines()
|
||||
.filter(|line| !line.trim_start().starts_with("//"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(
|
||||
"
|
||||
",
|
||||
);
|
||||
let count = code.matches("window::frames()").count();
|
||||
if count > 0 {
|
||||
let name = path
|
||||
.strip_prefix(&src)
|
||||
.unwrap_or(&path)
|
||||
.to_string_lossy()
|
||||
.replace(std::path::MAIN_SEPARATOR, "/");
|
||||
hits.push((name, count));
|
||||
}
|
||||
}
|
||||
|
||||
hits.sort();
|
||||
let expected = [
|
||||
("app/subscription.rs".to_string(), 1_usize),
|
||||
("pages/harness.rs".to_string(), 1_usize),
|
||||
];
|
||||
assert_eq!(
|
||||
hits, expected,
|
||||
"`window::frames()` 只许出现在 `app/subscription.rs`(产品接线)\
|
||||
与 `pages/harness.rs`(独立 example 的自有 app),且各一处"
|
||||
);
|
||||
}
|
||||
|
||||
/// 递归收集 `dir/**/*.rs`。
|
||||
fn collect_rust_files(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
|
||||
for entry in std::fs::read_dir(dir).expect("读目录") {
|
||||
let path = entry.expect("目录项").path();
|
||||
if path.is_dir() {
|
||||
collect_rust_files(&path, out);
|
||||
} else if path.extension().is_some_and(|ext| ext == "rs") {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 槽枚举的可见性检查:`Slot` 只经帧泵导出(页面拿不到「给我 250ms」这种口子)。
|
||||
#[test]
|
||||
fn slots_are_the_three_documented_ones() {
|
||||
assert_eq!(Slot::ALL.len(), 3);
|
||||
assert_eq!(Slot::ALL.map(Slot::name), ["boot", "screen", "detail"]);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user