diff --git a/crates/app-iced/src/pages/agent/demo.rs b/crates/app-iced/src/pages/agent/demo.rs index 65b213c..a6eceb0 100644 --- a/crates/app-iced/src/pages/agent/demo.rs +++ b/crates/app-iced/src/pages/agent/demo.rs @@ -15,14 +15,32 @@ //! | 变量 | 取值 | 作用 | //! |---|---|---| //! | [`RAIL_ENV`] | `collapsed` / `expanded` / `detached` | 启动时直接落到某一档(三态矩阵) | +//! | [`RAIL_ENV`] | [`EXIT_COLLAPSED`] | 展开后**同一拍**收起 ⇒ 折叠轨道真的从 1.0 起跑(M279) | //! | [`FIXTURE_ENV`] | `turn` / `unread` / `focused` / `empty` | 启动时跑一轮真提交 / 折叠态跑(留未读)/ 键盘焦点环 / 什么都不做 | //! | [`WIDTH_ENV`] | `280` / `320` / `360` | 展开档宽度(磁铁档位的对照图) | //! //! 解析失败一律**忽略**(取证开关不该让进程起不来),并在 stderr 留一行,方便对照日志读图。 +//! +//! ## 为什么需要 [`EXIT_COLLAPSED`](M279) +//! +//! 前三个取值都是**静息落档**:`expanded` 从出厂折叠态展开 ⇒ 真的起一条展开轨道(所以展开腿 +//! 拍得出来),而 `collapsed` 在出厂就是折叠态时调 `collapse()` 是**空操作**(`state.rs` 的 +//! `collapse()` 只在档位是 `Expanded` 时才改档 + `observe_rail(false)`)⇒ **一条折叠轨道都不起**。 +//! 于是配 `ULL_MOTION_AT` 拍「折叠中途」时,拍到的只是「静息折叠 + 页面入场位移」,而折叠腿 +//! 恰好是「页面模型比物理区更宽」的那一侧(`content_width` 吃目标档、`rail_width` 吃动画进度), +//! 是最可能出现右侧裁切/重叠的方向。`exit-collapsed` 就是补这条腿的取证开关: +//! 先 `expand()`、把展开这一腿**落成静息**(`snap_rail`),再在同一拍 `collapse()`。 +//! +//! 为什么中间那一步 `snap_rail(true)` 不能省:钉住相位时(`ULL_MOTION_AT`)两条轨道共用同一个 +//! 起点(`Pump::start_now()` 恒为 0),若不 snap,收起那一腿会从展开的**半路**反向起跑——三张相位图 +//! 就不是同一条 1 → 0 的腿,位移表也就没法与展开腿对照了。 use super::metrics::PRESETS; use super::state::{AgentUi, RailState}; +use crate::app::components::motion; +use crate::app::diag::diag; + /// 启动档位。 pub const RAIL_ENV: &str = "ULL_AGENT_RAIL"; /// 会话夹具(`turn` / `unread` / `focused` / `empty`)。 @@ -30,6 +48,75 @@ pub const FIXTURE_ENV: &str = "ULL_AGENT_FIXTURE"; /// 展开档宽度(只接受三档预设值)。 pub const WIDTH_ENV: &str = "ULL_AGENT_WIDTH"; +/// **真的会回档**的档位夹具名(见模块文档的「为什么需要它」,M279)。 +pub const EXIT_COLLAPSED: &str = "exit-collapsed"; + +/// 夹具对档位做了什么(日志与用例都要读得出「起没起轨道」)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RailSeed { + /// 落到某个**静息**档(`collapsed` / `expanded` / `detached` / 不设变量)。展开是从出厂折叠态 + /// 真展开,所以这一档里只有 `expanded` 会起轨道。 + Settled(RailState), + /// 展开到位后同一拍收起:折叠轨道从 1.0 起跑。 + ExitCollapsed, +} + +impl RailSeed { + /// 日志里的名字(`from_env` 那一行;与 `RailState::name` 分开,因为落点档位是同一个折叠档)。 + const fn name(self) -> &'static str { + match self { + RailSeed::Settled(state) => state.name(), + RailSeed::ExitCollapsed => EXIT_COLLAPSED, + } + } +} + +/// 把 [`RAIL_ENV`] 的名字落到 `ui` 上(纯函数:用例直接喂名字,不摆环境变量)。 +/// +/// 档位一律走**真迁移**(`expand` / `detach` / `collapse`)而不是直接写字段:这样未读的语义 +/// (展开即读)在取证图里也成立,不会出现「展开着还挂着 2 条未读」这种自相矛盾的画面。 +/// 唯一要单独看徽标的情形用 `ULL_AGENT_FIXTURE=unread` 显式造(折叠态跑一轮)。 +fn seed_rail(ui: &mut AgentUi, name: Option<&str>) -> RailSeed { + let name = name.map(str::trim); + + if name == Some(EXIT_COLLAPSED) { + ui.expand(); + // 展开这一腿落成**静息**(不是「等它跑完」):见模块文档里 snap 的理由。 + motion::snap_rail(true); + ui.collapse(); + return RailSeed::ExitCollapsed; + } + + match name { + None => { + ui.expand(); + RailSeed::Settled(RailState::Expanded) + } + Some(raw) => match RailState::from_name(raw) { + Some(RailState::Collapsed) => { + ui.collapse(); + RailSeed::Settled(RailState::Collapsed) + } + Some(RailState::Detached) => { + ui.detach(); + RailSeed::Settled(RailState::Detached) + } + Some(RailState::Expanded) => { + ui.expand(); + RailSeed::Settled(RailState::Expanded) + } + None => { + eprintln!( + "[agent] {RAIL_ENV}={raw:?} 不是三档之一({})也不是 {EXIT_COLLAPSED},忽略", + RailState::ALL.map(RailState::name).join("/") + ); + // 忽略 = 一动不动(出厂就是折叠档),与 M257 之前的语义逐字一致。 + RailSeed::Settled(ui.rail) + } + }, + } +} + /// 夹具:启动时对**真 presenter** 做什么。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Fixture { @@ -74,18 +161,7 @@ pub fn from_env() -> Option<(AgentUi, Fixture)> { // 档位走**真迁移**(`expand` / `detach` / `collapse`)而不是直接写字段:这样未读的语义 // (展开即读)在取证图里也成立,不会出现「展开着还挂着 2 条未读」这种自相矛盾的画面。 // 唯一要单独看徽标的情形用 `ULL_AGENT_FIXTURE=unread` 显式造(折叠态跑一轮)。 - match rail.as_deref().map(str::trim) { - None => ui.expand(), - Some(name) => match RailState::from_name(name) { - Some(RailState::Collapsed) => ui.collapse(), - Some(RailState::Detached) => ui.detach(), - Some(RailState::Expanded) => ui.expand(), - None => eprintln!( - "[agent] {RAIL_ENV}={name:?} 不是三档之一({}),忽略", - RailState::ALL.map(RailState::name).join("/") - ), - }, - } + let seed = seed_rail(&mut ui, rail.as_deref()); let fixture = match fixture.as_deref().map(str::trim) { Some("turn") => Fixture::Turn, @@ -99,10 +175,13 @@ pub fn from_env() -> Option<(AgentUi, Fixture)> { } }; - eprintln!( - "[agent] rail={} width={} fixture={fixture:?}", + // `seed=` 是**唯一**能分辨「静息落档」与「真的起了一条折叠轨道」的地方(两者的 `rail` 都是 + // `collapsed`):`exit-collapsed` 出现在这一行 = 折叠腿的相位图确实拍的是运动。 + diag!( + "[agent] rail={} width={} fixture={fixture:?} seed={}", ui.rail.name(), ui.expanded(), + seed.name(), ); Some((ui, fixture)) @@ -110,6 +189,9 @@ pub fn from_env() -> Option<(AgentUi, Fixture)> { #[cfg(test)] mod tests { + use unlucky_design::motion::MotionScale; + + use super::super::metrics::{COLLAPSED_WIDTH, DEFAULT_EXPANDED_WIDTH}; use super::*; #[test] @@ -127,4 +209,126 @@ mod tests { } assert_eq!(RailState::from_name("nope"), None); } + + // ---------------------------------------------------------- M279:折叠腿的夹具 + + /// 取证窗口宽(与 D30 的产品默认一致,位移表的坐标系就是它)。 + const WINDOW: f32 = 1180.0; + /// 帧泵的单帧步进(`motion::FRAME_MS` 的测试口径)。 + const FRAME: f64 = 16.0; + + /// 一条**真的会跑起来**的折叠轨道:进度从 1.0 起、严格递减、末端严格 0。 + /// + /// 抓得住什么(每一条都有对应变异): + /// ①`exit-collapsed` 改回只调 `collapse()`(出厂就是折叠档 ⇒ 空操作)⇒ 「有轨道吗」当场红, + /// 这正是 M257 r2 复审抓到的旧行为(折叠腿一张相位图都拍不出来); + /// ②删掉中间的 `snap_rail(true)` ⇒ 起跑那一帧不是严格 1.0 红(收起会从展开的半路反向); + /// ③起点改成硬跳/终点不归零 ⇒ 「第一帧 320」与「末端严格 0」两条红; + /// ④时长或曲线改了 ⇒ 帧数区间与严格单调两条红。 + /// + /// 抓不住什么:**像素**。这条只证时间轴真的在跑;「折叠中途页面右侧有没有被右栗压住/裁切」 + /// 只能出图看(M279 的 `DevDocs/artifacts/railexit/`)。 + #[test] + fn exit_collapsed_really_starts_the_collapse_leg_from_full_open() { + motion::reset(); + motion::configure_for_test(MotionScale::STANDARD, None); + + let mut agent = AgentUi::new(); + assert_eq!(agent.rail, RailState::Collapsed, "前提:出厂就是折叠档"); + + assert_eq!( + seed_rail(&mut agent, Some(EXIT_COLLAPSED)), + RailSeed::ExitCollapsed + ); + assert_eq!( + agent.rail, + RailState::Collapsed, + "落点仍是折叠档(夹具改的是**过程**,不是落点)" + ); + + assert!( + motion::needs_frames(), + "没有轨道 ⇒ 三张相位图会全是同一张(M257 r2 复审判定的旧行为)" + ); + assert_eq!( + agent.collapse_progress(), + 1.0, + "起跑那一帧必须在展开档(严格 1.0,不是 0.99x)" + ); + assert_eq!(agent.rail_width(WINDOW), DEFAULT_EXPANDED_WIDTH); + + let mut progress = vec![agent.collapse_progress()]; + let mut frames = 0; + while motion::needs_frames() { + motion::advance_by(FRAME); + progress.push(agent.collapse_progress()); + frames += 1; + assert!(frames < 60, "折叠轨道没停,疑似终止条件失效"); + } + + assert_eq!( + progress.last().copied(), + Some(0.0), + "末端进度必须**严格** 0(= 48 图标条)" + ); + assert_eq!(agent.rail_width(WINDOW), COLLAPSED_WIDTH); + assert!( + (10..=20).contains(&frames), + "200ms / 16ms 应落在 10~20 帧,实测 {frames} 帧" + ); + for pair in progress.windows(2) { + assert!(pair[1] < pair[0], "进度必须严格递减:{pair:?}"); + } + assert!( + progress.iter().filter(|p| **p > 0.02 && **p < 0.98).count() >= 8, + "必须有足够多的中间相位(否则就是硬切):{progress:?}" + ); + motion::reset(); + } + + /// 原有的 `collapsed` / `expanded` 两个值**语义一字不改**(M257 的取证依赖它们)。 + /// + /// `collapsed` 仍然是「静息折叠、一条轨道都不起」——这一条正是 M279 要补的那个缺口, + /// 所以它必须继续成立:改它就等于悄悄换掉 M257 那批图的含义。 + #[test] + fn the_old_rail_values_keep_their_exact_meaning() { + motion::reset(); + motion::configure_for_test(MotionScale::STANDARD, None); + + let mut agent = AgentUi::new(); + assert_eq!( + seed_rail(&mut agent, Some("collapsed")), + RailSeed::Settled(RailState::Collapsed) + ); + assert_eq!(agent.collapse_progress(), 0.0, "静息折叠的进度严格 0"); + assert!( + !motion::needs_frames(), + "`collapsed` 不许起轨道(出厂就是折叠档 ⇒ `collapse()` 是空操作)" + ); + + motion::reset(); + motion::configure_for_test(MotionScale::STANDARD, None); + let mut agent = AgentUi::new(); + assert_eq!( + seed_rail(&mut agent, Some("expanded")), + RailSeed::Settled(RailState::Expanded) + ); + assert_eq!( + agent.collapse_progress(), + 0.0, + "展开腿的第一帧仍在折叠档(轨道从 48 起跑)" + ); + assert!(motion::needs_frames(), "`expanded` 起的是**展开**轨道"); + + // 不认识的名字:一动不动(出厂折叠档),与 M257 之前的语义一致。 + motion::reset(); + motion::configure_for_test(MotionScale::STANDARD, None); + let mut agent = AgentUi::new(); + assert_eq!( + seed_rail(&mut agent, Some("collapsed-plus")), + RailSeed::Settled(RailState::Collapsed) + ); + assert!(!motion::needs_frames(), "认不出的取值一律不动"); + motion::reset(); + } }