From cb580a257e691d1154caf940d9833fb9bc1951de Mon Sep 17 00:00:00 2001 From: AskaEth Date: Fri, 13 Nov 2026 17:06:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(layout):=20M301=20=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E5=AE=BD=E5=BA=A6=E8=B7=9F=E9=9A=8F=E6=B4=BB=E8=BD=A8=E5=AE=BD?= =?UTF-8?q?=EF=BC=88=E6=8A=98=E5=8F=A0=20=E2=88=9264=20/=20=E5=B1=95?= =?UTF-8?q?=E5=BC=80=20=E2=88=9288=20/=20=E5=8A=A8=E6=95=88=E6=8F=92?= =?UTF-8?q?=E5=80=BC=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户口径「折叠腾出空间」只完成一半:M300 落了折叠态(ui.nav.collapsed + 一条并列宽度轨) 与折叠档令牌 nav_collapsed_width = 64,但 `pages/layout.rs` 的 `rail_width()` 仍是 `const fn` 恒返回 88 ⇒ 折叠后页面照旧按 88 扣宽,内容区右侧留一条 24px 空白带。 ## 轨宽从常量改成**当帧活值**(不存第二份状态) - 新增 `pages/layout/rail.rs`:thread-local `Option` 采样槽,`rail_width()` 读它, 未采样回落展开档令牌 88(不 panic、不 unwrap;NaN/inf/负数不写入)。 - 写入口**只有一个**:`app/view/rail.rs` 的 `rail_account()` —— M300 自己点名的「折叠档与 过渡进度的唯一构造点」,一次 `App::view()` 调用恰好一次,且排在页面树之前 (`app/view.rs` 的 `let account = self.rail_account();`),所以页面扣的宽度与外壳画的轨 是同一帧同一个数。折叠的**决定**仍在 presenter `prefs.nav_collapsed`,进度仍在 motion 轨。 - `rail_width()` 签名不变 ⇒ `pages/mod.rs`、`pages/agent/**`、20 个页面/测试文件的调用面 **零改动**(`git diff --stat` 里那两处都没有出现)。 - 动效期间自动插值:采样值就是 `RailAccount::nav_width` 的 64⇄88 插值结果。 - 门槛跟着动(公式里本来就是 `+ rail`):720/816 → 折叠 696/792。 ## 测试 - `pages/tests/rail_width.rs`(新):折叠=窗口−64、展开=窗口−88、差正好 24、块右缘贴死窗口; 无外壳通道回落 88;采样不粘滞;门槛跟活值;**唯一写入口**(扫 `src/**` 源码); **先写后读**(扫 `app/view.rs` 语句顺序)。期望值全从令牌取,不再写 88.0 / 64.0。 - `app/view/rail.rs` 新增接线用例:折叠→采样→展开→再采样必须回到 88(不粘滞)、 动效中间帧页面可用宽跟着插值(首尾逐位落在端值)。 - 原有钉 88 的三条改为「跟随模型」并逐条给了新算式(标题左缘 1180−88−48、块宽、 主列 pane);`pages/tests.rs` 400 行门禁前先拆出 `tests/demo_model.rs`。 ## 判据 - app-iced:1064 passed(M300 基线 1053 + 11 条新用例);no_hardcoded_tokens 6 passed。 - `clippy --workspace --all-targets -- -D warnings` / `fmt --all -- --check` 均 0。 - 变异:`rail_width()` 改回常量 88 ⇒ 2 条几何判据 + 门槛判据红(1068 vs 1044); 删掉外壳那一行写入 ⇒ 2 条接线用例红(折叠仍按 88 扣宽)。 - 像素(DevDocs/artifacts/m301/,本地不进仓):日志页折叠态内容右缘 1107 = 展开态 1107 (24px 全进内容),变异版折叠态 1083(那条 24px 空白带);`instances` 页因详情列被 424 封顶、列表列固定 336,改前改后**逐像素相同**,所以举证必须用单列页。 --- crates/app-iced/src/app/view/rail.rs | 156 +++++++++- crates/app-iced/src/pages/agent/metrics.rs | 4 +- crates/app-iced/src/pages/layout.rs | 24 +- crates/app-iced/src/pages/layout/rail.rs | 131 ++++++++ crates/app-iced/src/pages/tests.rs | 100 +++--- crates/app-iced/src/pages/tests/demo_model.rs | 28 ++ crates/app-iced/src/pages/tests/rail_width.rs | 289 ++++++++++++++++++ 7 files changed, 663 insertions(+), 69 deletions(-) create mode 100644 crates/app-iced/src/pages/layout/rail.rs create mode 100644 crates/app-iced/src/pages/tests/demo_model.rs create mode 100644 crates/app-iced/src/pages/tests/rail_width.rs diff --git a/crates/app-iced/src/app/view/rail.rs b/crates/app-iced/src/app/view/rail.rs index 521769d..a3d1fd2 100644 --- a/crates/app-iced/src/app/view/rail.rs +++ b/crates/app-iced/src/app/view/rail.rs @@ -1,7 +1,16 @@ -//! 导航轨底部**账户头像位**的状态换算(M114)+ 轨自身的折叠档(M300)。 +//! 导航轨底部**账户头像位**的状态换算(M114)+ 轨自身的折叠档(M300)+ 轨宽的**当帧采样**(M301)。 //! //! 为什么单独一个文件:`app/view.rs` 贴着 400 行硬线,而这块逻辑与页面树无关—— //! 它只回答「轨底那个头像位现在该显示成未登录,还是某个账户」,以及「这一帧轨多宽」。 +//! +//! ## 顺序契约(M301,给下一个改这里的人) +//! +//! [`App::rail_account`] 是轨宽的**唯一写入口**:它每次 `App::view()` 被调用**恰好一次** +//! (`app/view.rs` 的 `let account = self.rail_account();`),且**排在页面树之前**。所以 +//! 页面几何(`pages::layout::rail::rail_width`)读到的值与这里递给 `shell::NavRail` 的 +//! **是同一个数、同一拍**——不存在「轨折叠了、页面还按 88 扣宽」的二段状态。 +//! 这条顺序是代码顺序,不是约定:谁把这次写挪到页面树之后再调用,`pages/tests/rail_width.rs` +//! 的接线判据会红。 use crate::app::components::motion; use crate::shell; @@ -20,10 +29,12 @@ impl App { /// /// M300:这里同时是轨的折叠档与过渡进度的**唯一构造点**——`shell::NavRail`(不在 scope 内) /// 只负责把它转交给 `nav::rail`,几何全部来自 [`shell::RailAccount::nav_width`]。 + /// + /// M301:**同时**是页面层「这一帧扣多宽」的写入口(同一个 `nav_width`,见模块文档的顺序契约)。 pub(super) fn rail_account(&self) -> shell::RailAccount { let compact = self.height < RAIL_COMPACT_HEIGHT; let (collapsed, progress) = self.nav_state(); - match self.state.active_account() { + let account = match self.state.active_account() { Some(account) => { shell::RailAccount::signed_in(account.monogram(), account.name.clone()) .compact(compact) @@ -37,7 +48,11 @@ impl App { ) .compact(compact) .nav(collapsed, progress), - } + }; + // M301:页面几何要扣的那一段(折叠 64 / 展开 88 / 动画中间帧插值)。**先写后读**—— + // 页面树紧跟着在同一次 `view()` 里建,所以两侧永远同一帧同一个数。 + crate::pages::layout::note_rail_width(account.nav_width(self.ui().metrics())); + account } /// 轨的折叠档 + **这一帧**的过渡进度(0 = 折叠 64,1 = 展开 88)。 @@ -58,3 +73,138 @@ impl App { self.rail_account().nav_width(self.ui().metrics()) } } + +#[cfg(test)] +mod tests { + //! **M301 的接线判据**:外壳这一帧采样 ⇒ 页面这一帧扣宽(同一个数、同一拍,含动效中间帧)。 + //! + //! 几何面(折叠 −64 / 展开 −88 / 差 24 / 门檻 / 唯一写入口)在 `pages/tests/rail_width.rs`; + //! 这里只守「外壳真的每帧都写、写了就立刻生效、不会粘住上一帧」。 + + use unlucky_design::color::SchemeFlavor; + use unlucky_design::motion::MotionScale; + use unlucky_presenter::mock::MockBackend; + use unlucky_presenter::settings_bridge::SettingsBridge; + + use crate::app::components::motion::{self, FRAME_MS}; + use crate::cli::Options; + use crate::pages::layout::{self, WidthClass}; + use crate::theme::Families; + + use super::*; + + const FAMILIES: Families = Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", + }; + + /// 出厂态 App(桥指向一个不存在的临时文件 = 出厂偏好)。 + fn app() -> App { + motion::reset(); + motion::configure_for_test(MotionScale::STANDARD, None); + let path = + std::env::temp_dir().join(format!("ull-m301-{}-{}.json", std::process::id(), line!())); + let mut app = App::with_presenter( + Options::default(), + FAMILIES, + SchemeFlavor::Light, + Box::new(MockBackend::new()), + SettingsBridge::with_path(path), + ); + app.window = Some(iced::window::Id::unique()); + app + } + + /// 验收窗宽(与 `pages/tests.rs` 同一个对照尺寸)。 + const WINDOW: f32 = 1180.0; + + /// 页面这一帧扣的宽度必须就是外壳这一帧采到的轨宽。 + fn assert_page_follows(app: &App) -> f32 { + let _ = app.rail_account(); + let rail = layout::rail_width(); + assert_eq!( + layout::available_width(WINDOW), + WINDOW - rail - 2.0 * WidthClass::Expanded.gutter(), + "页面扣的宽度必须等于外壳这一帧的轨宽({rail})" + ); + rail + } + + /// **折叠 → 采样 → 展开 → 再采样**:第二次必须回到 88(不粘滞)。 + /// + /// **抓得住**:①写入口只在折叠方向生效(展开时还停在 64);②写进去的值被页面忽略; + /// ③复位成常量(折叠也按 88 扣宽——这时折叠那条断言直接红)。 + /// **抓不住**:真画出来的像素(见 `DevDocs/artifacts/m301/`)。 + #[test] + fn the_shell_sample_follows_the_collapse_state_and_reverts() { + let mut app = app(); + let collapsed = layout::rail_width_collapsed(); + let expanded = layout::rail_width_expanded(); + + assert_eq!( + assert_page_follows(&app), + expanded, + "出厂展开 ⇒ 页面按 88 扣宽" + ); + + // 折叠:泵的目标与 prefs 不符 ⇒ `motion::nav(open)` 给终值 0.0 ⇒ 当帧就是 64(不靠动画)。 + app.state.prefs.nav_collapsed = true; + assert_eq!( + assert_page_follows(&app), + collapsed, + "折叠 ⇒ 当帧就按 64 扣宽" + ); + + // 展开回去:第二次采样必须回到 88(删掉写入 / 只在折叠时写 ⇒ 这里绿不了)。 + app.state.prefs.nav_collapsed = false; + assert_eq!( + assert_page_follows(&app), + expanded, + "第二帧必须回到 88,不许粘住 64" + ); + } + + /// **动效期间页面跟着插值**(任务判据④):中间帧既不等于 88 也不等于 64,且首尾是端值。 + /// + /// **抓得住**:页面只在端值上跳变(中间帧列表里没有严格递减的中间值)、或是拿端值当宽度画 + /// (那会让 `available_width` 与轨宽在这一帧不等);**抓不住**:25 帧的观感(那要连拍出图)。 + #[test] + fn the_page_width_interpolates_during_the_collapse_motion() { + let mut app = app(); + let collapsed = layout::rail_width_collapsed(); + let expanded = layout::rail_width_expanded(); + motion::snap_nav(true); + app.state.prefs.nav_collapsed = true; + assert!(motion::observe_nav(false), "折叠必须起跑一条宽度轨道"); + + let mut widths = vec![assert_page_follows(&app)]; + let mut guard = 0; + while motion::needs_frames() { + motion::advance_by(FRAME_MS); + widths.push(assert_page_follows(&app)); + guard += 1; + assert!(guard < 200, "左轨轨道没跑完"); + } + + assert_eq!( + widths.first().copied(), + Some(expanded), + "起跑那一帧还在展开档" + ); + assert_eq!( + widths.last().copied(), + Some(collapsed), + "终值必须是折叠档,逐位" + ); + assert!( + widths + .iter() + .any(|width| *width > collapsed && *width < expanded), + "必须有中间帧(页面跟插值走,不是端值跳变):{widths:?}" + ); + for pair in widths.windows(2) { + assert!(pair[1] <= pair[0], "折叠必须单调不增:{pair:?}"); + } + } +} diff --git a/crates/app-iced/src/pages/agent/metrics.rs b/crates/app-iced/src/pages/agent/metrics.rs index 144a002..50c467c 100644 --- a/crates/app-iced/src/pages/agent/metrics.rs +++ b/crates/app-iced/src/pages/agent/metrics.rs @@ -3,7 +3,7 @@ //! ## 一屏的横向预算 //! //! ```text -//! 窗口宽 = 左轨 88 + [中栗] + [拖拽缝 8(仅展开档)] + [右栗] +//! 窗口宽 = 左轨(**活值**:展开 88 / 折叠 64,见 [`layout::rail_width`])+ [中栗] + [拖拽缝 8(仅展开档)] + [右栗] //! ``` //! //! - 右栗宽度 = [`rail_width`]:折叠 48 / 展开 280~360(吸附值)/ 拆出 0; @@ -124,7 +124,7 @@ pub fn effective_state( pub struct RailLayout { /// 窗口宽度(输入)。 pub window: f32, - /// 左侧导航轨宽度(令牌 `nav_rail_width` = 88)。 + /// 左侧导航轨宽度(**这一帧的活值**:展开 88 / 折叠 64,见 [`layout::rail_width`])。 pub left: f32, /// 右栗面板宽度(0 / 48 / 280~360)。 pub rail: f32, diff --git a/crates/app-iced/src/pages/layout.rs b/crates/app-iced/src/pages/layout.rs index 4c55fed..c289b0c 100644 --- a/crates/app-iced/src/pages/layout.rs +++ b/crates/app-iced/src/pages/layout.rs @@ -27,10 +27,9 @@ //! | `SupportingPane` | 主列 + 窄侧列(侧列是辅助,主列是阅读对象) | 设置(分组导航 240 + 表单列) | `w ≥ 600` 且 `w ≥ 720` | `SingleColumn` | //! | `ListDetail` | 固定列表列(320~360,**独立滚动**)+ 详情列 | 实例 | `w ≥ 840` 且 `w ≥ 816` | `SingleColumn` | //! -//! 两个门槛都是**算出来的**,不是拍的(见 [`panes_min_window`]): -//! -//! - 设置:`240(drawer)+ 24(间隙)+ 320(表单列下限)+ 2×24(gutter)+ 88(rail)= 720`; -//! - 实例:`336(列表列)+ 24(间隙)+ 320(详情列下限)+ 2×24(gutter)+ 88(rail)= 816`。 +//! 两个门槛都是**算出来的**,不是拍的(见 [`panes_min_window`]):`240/336 + 24 + 320 + 2×24 + rail`。 +//! rail 是**活值**(展开 88 / 折叠 64,见 [`rail::rail_width`])⇒ 门槛跟着动:720/816 降到 +//! 696/792——折叠真的把「两列版面」的成立线往下推了 24px。 //! //! 实例的几何门槛 816 低于官方 expanded 断点 840 —— 所以**档位是更严的那一条**, //! 单测 `expanded_is_wide_enough_for_list_detail` 把这条「余量 24px」钉死。 @@ -94,6 +93,12 @@ use unlucky_design::typography::TypeRole; pub mod block; pub mod wrap; +/// 轨宽的**当帧活值**(M301:折叠后按 64 而不是 88 扣宽,见模块文档)。 +pub(crate) mod rail; + +pub(crate) use rail::note_rail_width; +pub use rail::{rail_width, rail_width_collapsed, rail_width_expanded}; + #[cfg(test)] pub(crate) mod source; @@ -153,11 +158,6 @@ pub const MAX_MEASURE_CHARS: f32 = 72.0; /// 行宽下限的字符数(再窄正文就碎成短行,45 是最低可读长度)。 pub const MIN_MEASURE_CHARS: f32 = 45.0; -/// 导航轨宽度(与 `shell::nav` 同一令牌值;布局系统只读它,不重定义)。 -pub const fn rail_width() -> f32 { - LayoutMetrics::DESKTOP.nav_rail_width -} - /// **逐行**安全宽度(px):折行模拟下「最长一行 ≤72 字符」的最大 4px 栅格列宽 = **424**。 /// /// 见模块文档 §3 的模拟表:428 会出现 73 字符行,426 起降到 71,424 给到 67(留 5 字符余量)。 @@ -268,12 +268,12 @@ pub fn panes_min_window(first: f32, second: f32) -> f32 { first + second + PANES_GAP + 2.0 * WidthClass::Expanded.gutter() + rail_width() } -/// `ListDetail` 的几何门槛:336 + 320 + 24 + 48 + 88 = **816**。 +/// `ListDetail` 的几何门槛:336 + 320 + 24 + 48 + 88 = **816**(折叠档 rail 64 ⇒ **792**)。 pub fn list_detail_min_window() -> f32 { panes_min_window(LIST_COLUMN_WIDTH, DETAIL_MIN_WIDTH) } -/// `SupportingPane` 的几何门槛:240 + 320 + 24 + 48 + 88 = **720**。 +/// `SupportingPane` 的几何门槛:240 + 320 + 24 + 48 + 88 = **720**(折叠档 rail 64 ⇒ **696**)。 pub fn supporting_pane_min_window() -> f32 { panes_min_window(SIDE_NAV_WIDTH, FORM_MIN_WIDTH) } @@ -298,7 +298,7 @@ pub fn resolve(kit: PaneKit, window_width: f32) -> PageLayout { } } -/// 页面可用宽度:窗口 − 导航轨 − 两侧页边距(下限 0)。 +/// 页面可用宽度:窗口 − **这一帧的轨宽**([`rail_width`]:展开 88 / 折叠 64)− 两侧页边距(下限 0)。 pub fn available_width(window_width: f32) -> f32 { let class = WidthClass::of(window_width); (window_width - rail_width() - 2.0 * class.gutter()).max(0.0) diff --git a/crates/app-iced/src/pages/layout/rail.rs b/crates/app-iced/src/pages/layout/rail.rs new file mode 100644 index 0000000..daf1e0b --- /dev/null +++ b/crates/app-iced/src/pages/layout/rail.rs @@ -0,0 +1,131 @@ +//! 轨宽的**当帧活值**(M301):`available_width` / `frame_metrics` 扣掉的那一段宽度。 +//! +//! ## 为什么不再是常量 +//! +//! M300 给了导航轨两个宽度档:展开 `LayoutMetrics::nav_rail_width` = **88**、折叠 +//! `nav_collapsed_width` = **64**,中间按 `medium1` / `short4` 插值。若这里仍是 +//! `const fn` 恒返回 88,用户折叠后页面照旧按 88 扣宽 ⇒ 内容区右侧留一条 **24px 空白带**, +//! 「折叠腾出空间」只完成一半(M300 登记的偏离,本条收口)。 +//! +//! ## 顺序契约(谁必须在谁之前) +//! +//! | 时刻 | 谁 | 做什么 | +//! |---|---|---| +//! | 每次 `App::view()` 开头 | 外壳 `app/view/rail.rs` 的 `rail_account()` | **写**:`note_rail_width(RailAccount::nav_width)` | +//! | 同一次 `view()` 里、之后 | 页面几何(`available_width` / `frame_metrics` / `pages/agent/metrics.rs`) | **读**:`rail_width()` | +//! +//! 「先写后读」是**代码顺序**保证的:`app/view.rs` 的 `let account = self.rail_account();` +//! 排在页面树(`page_with_settings_transition`)之前,所以页面扣的宽度与外壳画的轨**同一帧同一个数**。 +//! +//! ## 为什么这份采样不是「第二份状态」 +//! +//! 折叠的**决定**只有一份(presenter 的 `UiPrefs::nav_collapsed`,落盘 `ui.nav.collapsed`); +//! 过渡进度也只有一份(`components/motion` 的左轨轨道)。本模块存的是「这一帧外壳真画出来的那个 +//! 宽度」,是 [`super::super::layout`] 的**只读当帧输入**:没有任何代码把它写回状态、也不跨帧 +//! 做任何判断,所以两处不会漂。 +//! +//! ## 为什么是 thread-local +//! +//! 它是**当帧采样**,不是应用状态:没有业务语义、不需要跨线程或跨进程,用例天然隔离(一个用例 +//! 一个线程)。仓内同样的先例:`components/motion`(时间轴)、`pages/appearance/probe.rs`。 +//! +//! ## `None` 的语义:本线程还没有外壳采样过 ⇒ 回落展开档 +//! +//! 没有外壳的通道(`pages_demo`、组件库 harness、直接调 `frame_metrics` 的单测)里,轨本身是 +//! **组件版** `pages/nav.rs` 画的(只有 88 / 240 两态,没有折叠档),所以页面也必须按 88 扣宽。 +//! 于是读不到值时回落 `nav_rail_width`(不 panic、不 unwrap),这条由 +//! `pages/tests/rail_width.rs::no_shell_channel_falls_back_to_the_expanded_token` 钉住。 +//! +//! ## 为什么没有「一帧后自动失效」 +//! +//! 自动失效要先知道「哪一拍是新帧」,而帧边界是 `App::view()` 的开头——那里不在本条的 scope 内。 +//! 折中成两条可断言的性质:①外壳**每帧覆盖**(写入口只有一个,见 `rail_account()`); +//! ②没采过样的通道回落 88。副作用说明白:同一线程上先跑过 `App::view()`(折叠档)再跑无外壳 +//! 通道,会读到上一次采样——`#[cfg(test)]` 的 [`forget_rail_width`] 是给这种混跑场景的显式复位。 + +use std::cell::Cell; + +use unlucky_design::spacing::LayoutMetrics; + +/// 展开档(`nav_rail_width` = 88)。 +pub fn rail_width_expanded() -> f32 { + LayoutMetrics::DESKTOP.nav_rail_width +} + +/// 折叠档(`nav_collapsed_width` = 64)。算式与「为什么不是 48」见 `design/spacing.rs` 的令牌文档。 +pub fn rail_width_collapsed() -> f32 { + LayoutMetrics::DESKTOP.nav_collapsed_width +} + +thread_local! { + /// 这一帧的轨宽:`None` = 本线程还没有外壳采样过。 + /// + /// clippy 1.99 的 `missing_const_for_thread_local` 对**已经是** `const { … }` 的初始化也报 + /// (换过写法照报,属于误报;`components/motion` 等三处已有同样的 `allow`)。 + #[allow(clippy::missing_const_for_thread_local)] + static SAMPLE: Cell> = const { Cell::new(None) }; +} + +/// 这一帧的轨宽(逻辑 px)。未采样 ⇒ 回落展开档令牌 88。 +pub fn rail_width() -> f32 { + SAMPLE.with(Cell::get).unwrap_or_else(rail_width_expanded) +} + +/// 外壳每帧采一次(**唯一写入口**:`app/view/rail.rs` 的 `rail_account()`, +/// `pages/tests/rail_width.rs` 扫源码守这条)。 +/// +/// 非有限值(NaN / inf)与负值**不写入**:布局跟着一条坏数走会算出负的可用宽, +/// 而那比「这一帧沿用上一帧」糟得多。静默忽略而不是 `assert`——帧路径上不许 panic。 +pub(crate) fn note_rail_width(width: f32) { + if width.is_finite() && width >= 0.0 { + SAMPLE.with(|sample| sample.set(Some(width))); + } +} + +/// 丢掉本线程的采样(回到「未采样」= 回落 88)。 +/// +/// 产品路径不用它(外壳每帧覆盖);它给的是两条判据:①`None` 的回落行为可断言; +/// ②「先跑过一次外壳再跑无外壳通道」的单测可以显式复位,不必依赖线程调度。 +#[cfg(test)] +pub(crate) fn forget_rail_width() { + SAMPLE.with(|sample| sample.set(None)); +} + +#[cfg(test)] +mod tests { + //! 采样槽自身的三条判据(几何面在 `pages/tests/rail_width.rs`)。 + + use super::*; + + /// 未采样 ⇒ 回落 88,且**不 panic**(`None` 就是「本帧没有外壳」)。 + #[test] + fn an_unwritten_sample_falls_back_to_the_expanded_token() { + forget_rail_width(); + assert_eq!(rail_width(), rail_width_expanded()); + assert_eq!(rail_width(), 88.0, "回落值就是展开档令牌"); + } + + /// 写进去什么就读到什么;两个端值分别等于两个令牌(不是手抄的字面量)。 + #[test] + fn the_sample_is_read_back_verbatim() { + forget_rail_width(); + note_rail_width(rail_width_collapsed()); + assert_eq!(rail_width(), rail_width_collapsed()); + note_rail_width(rail_width_expanded()); + assert_eq!(rail_width(), rail_width_expanded()); + forget_rail_width(); + } + + /// 坏数不写:NaN / inf / 负数被忽略(沿用上一帧),而不是把几何拖成负宽。 + #[test] + fn a_non_finite_or_negative_sample_is_ignored() { + forget_rail_width(); + note_rail_width(f32::NAN); + assert_eq!(rail_width(), rail_width_expanded(), "NaN 不许写进去"); + note_rail_width(f32::INFINITY); + assert_eq!(rail_width(), rail_width_expanded(), "inf 不许写进去"); + note_rail_width(-8.0); + assert_eq!(rail_width(), rail_width_expanded(), "负宽不许写进去"); + forget_rail_width(); + } +} diff --git a/crates/app-iced/src/pages/tests.rs b/crates/app-iced/src/pages/tests.rs index a9f2a11..88262a3 100644 --- a/crates/app-iced/src/pages/tests.rs +++ b/crates/app-iced/src/pages/tests.rs @@ -3,11 +3,15 @@ //! 每条断言都注明了「抓得住什么 / 抓不住什么」——只测到纯函数,渲染真像素另见报告里的图与量测。 mod base_wiring; +mod demo_model; +/// M301:轨宽活值的几何判据(折叠 −64 / 展开 −88 / 差 24 + 唯一写入口) +mod rail_width; use super::layout::{self, frame_metrics, measure_cap_px, PaneKit, WidthClass}; use super::nav::NAV; use super::*; use unlucky_design::color::SchemeFlavor; +use unlucky_design::spacing::LayoutMetrics; use unlucky_design::typography::Platform; use unlucky_i18n::Lang; use unlucky_presenter::router::{NavSection, Route}; @@ -125,10 +129,20 @@ fn body_block_starts_at_the_header_title_and_fills_the_page() { assert!(metrics.column <= measure_cap_px() + f32::EPSILON); } - // 验收尺寸 1180:标题左缘 112、块宽 1044(旧版居中时正文左缘是 422) + // 验收尺寸 1180:标题左缘 = rail + gutter(展开档 88 + 24 = 112)、块宽 1044。 + // M301 的期望值从**令牌**算,不再手抄 88:`nav_rail_width + Expanded.gutter`。 let wide = frame_metrics(PaneKit::Single, 1180.0); - assert_eq!(layout::rail_width() + wide.gutter, 112.0, "页头标题左缘"); - assert_eq!(frame::body_width(wide), 1044.0, "正文块宽度"); + let expanded_title_x = LayoutMetrics::DESKTOP.nav_rail_width + WidthClass::Expanded.gutter(); + assert_eq!( + layout::rail_width() + wide.gutter, + expanded_title_x, + "页头标题左缘" + ); + assert_eq!( + frame::body_width(wide), + 1180.0 - expanded_title_x - WidthClass::Expanded.gutter(), + "正文块宽度 = 窗口 − 轨 − 两侧页边距(1180 − 88 − 48 = 1044)" + ); // 极宽窗:块宽封顶 1440(tower 拍板),仍然贴左 ⇒ 右侧留白而不是居中 let huge = frame_metrics(PaneKit::Single, 2560.0); @@ -140,7 +154,16 @@ fn body_block_starts_at_the_header_title_and_fills_the_page() { // 两列版面的块宽 = 主列 pane(实例页正文在 panes 的主列里,不铺整页) let two = frame_metrics(PaneKit::ListDetail, 1180.0); assert_eq!(frame::body_width(two), two.main_pane); - assert_eq!(two.main_pane, 1180.0 - 88.0 - 48.0 - 336.0 - 24.0); + let rail = LayoutMetrics::DESKTOP.nav_rail_width; + assert_eq!( + two.main_pane, + 1180.0 + - rail + - 2.0 * WidthClass::Expanded.gutter() + - layout::LIST_COLUMN_WIDTH + - layout::PANES_GAP, + "主列 pane = 窗口 − 轨 − 两侧页边距 − 列表列 − 间隙(1180 − 88 − 48 − 336 − 24)" + ); } /// **源码结构**:`frame::body` 的盒子必须从「块宽 + 块偏移 + 贴左」这三个数建。 @@ -284,15 +307,32 @@ fn every_page_any_width_stays_under_the_measure_cap() { } } -/// 断点 600 / 840 与 rail 88:M84 的三条硬值,接真组件后不许漂。 +/// 断点 600 / 840 与轨宽两档:M84 的三条硬值 + M301 的活值回落,接真组件后不许漂。 /// -/// **抓得住**:布局系统的常量与 `WidthClass::of` 的分档; -/// **抓不住**:导航轨真画出来是不是 88(那条要量像素;组件自己声明 88 见下一个断言)。 +/// **抓得住**:布局系统的常量与 `WidthClass::of` 的分档,以及「没有外壳采样时按展开档扣宽」; +/// **抓不住**:导航轨真画出来是不是 88(那条要量像素;组件自己声明 88 见下一个断言), +/// 也抓不住「折叠后是不是真的 64」——那是 `rail_width.rs` 那组判据 + 外壳接线的事。 #[test] fn the_three_frozen_numbers_are_unchanged() { + let lm = LayoutMetrics::DESKTOP; assert!((layout::BREAKPOINT_MEDIUM - 600.0).abs() < f32::EPSILON); assert!((layout::BREAKPOINT_EXPANDED - 840.0).abs() < f32::EPSILON); - assert!((layout::rail_width() - 88.0).abs() < f32::EPSILON); + // M301:`rail_width()` 不再是常量,断的是它的两个端点**令牌**与未采样时的回落值。 + assert_eq!( + layout::rail_width_expanded(), + lm.nav_rail_width, + "展开档令牌 88" + ); + assert_eq!( + layout::rail_width_collapsed(), + lm.nav_collapsed_width, + "折叠档令牌 64" + ); + assert_eq!( + layout::rail_width(), + lm.nav_rail_width, + "未采样 ⇒ 回落展开档" + ); assert_eq!(WidthClass::of(599.9), WidthClass::Compact); assert_eq!(WidthClass::of(600.0), WidthClass::Medium); @@ -300,29 +340,6 @@ fn the_three_frozen_numbers_are_unchanged() { assert_eq!(WidthClass::of(840.0), WidthClass::Expanded); } -/// **组件版**导航轨(M78 的 `NavigationRail`)声明的宽度必须与布局系统同值。 -/// -/// 注意这不是产品路径的实现(M90 r3 订正):产品页的 rail 由 `shell/nav.rs` 画,组件版只服务 -/// `pages::app`(harness 出口图)与单测。这条守的是「组件与布局系统没分叉」,**不是**「产品 rail -/// 没问题」——两套实现的漂移风险见 `pages/mod.rs` 偏离表 #10。 -/// -/// **抓得住**:`NavPaneGeometry::rail().width` == `layout::rail_width()` == 88; -/// **抓不住**:容器圆角/药丸摆放的像素级正确性(组件自己的 probe 管),也抓不住 `shell/nav.rs`。 -#[test] -fn the_real_rail_agrees_with_the_layout_system() { - let ui = ui(); - let geometry = crate::app::components::navigation::geometry::NavPaneGeometry::of( - &ui, - crate::app::components::navigation::NavLayout::Rail, - ); - assert!( - (geometry.width - layout::rail_width()).abs() < f32::EPSILON, - "真组件 rail 宽 {} 与布局系统的 {} 分叉了", - geometry.width, - layout::rail_width() - ); -} - /// 路由 → 页面:四个一级页各自落到自己那页,`Detail` 落在实例页,`Agent` 没有页面(app 层回退)。 #[test] fn routes_map_onto_pages() { @@ -366,24 +383,3 @@ fn every_rail_item_points_at_a_route_that_highlights_it() { assert_eq!(Screen::Instances.nav(), NavSection::Instances); assert_eq!(Screen::Settings.nav(), NavSection::Settings); } - -/// 演示模型:12 行 + 1 条选中详情(M84 的出图基线就靠这份数据)。 -#[test] -fn the_demo_model_carries_the_m84_baseline() { - let model = demo_model(Screen::Instances, 1180.0, 760.0); - assert_eq!(model.instances.rows.len(), 12); - let detail = model.instances.detail.as_ref().expect("演示数据有选中项"); - assert_eq!(detail.meta.len(), 6, "元信息 6 格"); - assert!(detail.note.is_some(), "演示数据带正文段"); - assert_eq!(model.active, Some(NavSection::Instances)); - - // 其余三页不带实例数据(占位视觉不读它) - for screen in [Screen::Download, Screen::Settings, Screen::Logs] { - let model = demo_model(screen, 1180.0, 760.0); - assert!( - model.instances.rows.is_empty(), - "{} 不该带实例数据", - screen.slug() - ); - } -} diff --git a/crates/app-iced/src/pages/tests/demo_model.rs b/crates/app-iced/src/pages/tests/demo_model.rs new file mode 100644 index 0000000..1f239b3 --- /dev/null +++ b/crates/app-iced/src/pages/tests/demo_model.rs @@ -0,0 +1,28 @@ +//! 演示模型(`pages::demo_model`)的基线判据(M84 出图基线)。 +//! +//! M301 从 `pages/tests.rs` 搬出来:那份文件要装轨宽活值的判据,按语义拆一块正好守 400 行门禁。 + +use unlucky_presenter::router::NavSection; + +use super::super::*; + +/// 演示模型:12 行 + 1 条选中详情(M84 的出图基线就靠这份数据)。 +#[test] +fn the_demo_model_carries_the_m84_baseline() { + let model = demo_model(Screen::Instances, 1180.0, 760.0); + assert_eq!(model.instances.rows.len(), 12); + let detail = model.instances.detail.as_ref().expect("演示数据有选中项"); + assert_eq!(detail.meta.len(), 6, "元信息 6 格"); + assert!(detail.note.is_some(), "演示数据带正文段"); + assert_eq!(model.active, Some(NavSection::Instances)); + + // 其余三页不带实例数据(占位视觉不读它) + for screen in [Screen::Download, Screen::Settings, Screen::Logs] { + let model = demo_model(screen, 1180.0, 760.0); + assert!( + model.instances.rows.is_empty(), + "{} 不该带实例数据", + screen.slug() + ); + } +} diff --git a/crates/app-iced/src/pages/tests/rail_width.rs b/crates/app-iced/src/pages/tests/rail_width.rs new file mode 100644 index 0000000..cb1deec --- /dev/null +++ b/crates/app-iced/src/pages/tests/rail_width.rs @@ -0,0 +1,289 @@ +//! **M301 的几何判据**:页面可用宽跟着**这一帧的轨宽**走(折叠 −64 / 展开 −88 / 差值正好 24)。 +//! +//! 这一组都打在「当帧采样」这一层(`layout::rail`),也就是产品里外壳写的那个值:采样写什么, +//! 页面就扣什么。四条任务判据的落点: +//! +//! | 判据 | 用例 | +//! |---|---| +//! | ①折叠态可用宽 = 窗口 − 64 ②展开态 = 窗口 − 88 ③差正好 24 | [`the_page_width_follows_the_collapsed_rail`] | +//! | 无外壳通道回落 88(harness / 组件 demo / 单测) | [`no_shell_channel_falls_back_to_the_expanded_token`] | +//! | 第二帧必须回到 88(不粘滞上一帧的采样) | [`the_sampled_width_is_not_sticky`] | +//! | 门槛跟着活值走(两列版面的成立线也降 24) | [`collapsing_lowers_the_pane_threshold_by_the_same_24`] | +//! | 唯一写入口 = 外壳,且排在页面树之前 | [`only_the_shell_samples_the_rail_width`]、[`the_shell_samples_before_the_page_tree_is_built`] | + +use std::path::{Path, PathBuf}; + +use unlucky_design::spacing::LayoutMetrics; + +use super::super::frame; +use super::super::layout::{self, block, frame_metrics, PageLayout, PaneKit, WidthClass}; + +/// 两个档位一律**从令牌取**(本文件不许再出现 88.0 / 64.0 这种字面量)。 +fn ends() -> (f32, f32) { + ( + layout::rail_width_collapsed(), + layout::rail_width_expanded(), + ) +} + +/// 采一次样、跑一段几何、复位(复位用 `forget`:回到「本线程未采样」,不把值留给后面的断言)。 +fn with_rail(width: f32, run: impl FnOnce() -> T) -> T { + layout::note_rail_width(width); + let out = run(); + layout::rail::forget_rail_width(); + out +} + +/// **判据③的对照组**:没有外壳的通道必须按展开档 88 扣宽。 +/// +/// **抓得住**:`None` 的回落值被改(比如回落成折叠档,或改成 panic); +/// **抓不住**:产品路径(外壳采样之后)的宽度——那是下面几条与外壳接线判据的事。 +#[test] +fn no_shell_channel_falls_back_to_the_expanded_token() { + let (_, expanded) = ends(); + layout::rail::forget_rail_width(); + assert_eq!(layout::rail_width(), expanded, "未采样 ⇒ 展开档"); + + let window = 1180.0; + let metrics = frame_metrics(PaneKit::Single, window); + let gutter = WidthClass::Expanded.gutter(); + assert_eq!( + metrics.available, + window - expanded - 2.0 * gutter, + "无外壳通道里页面按展开档扣宽(1180 − 88 − 48 = 1044)" + ); + assert_eq!( + metrics.available, + window - LayoutMetrics::DESKTOP.nav_rail_width - 2.0 * gutter, + "回落值就是 nav_rail_width 令牌" + ); +} + +/// **判据①②③**:折叠态 = 窗口 − 64、展开态 = 窗口 − 88、两者差**正好** 24。 +/// +/// 两侧都用同一个窗口(1180,验收尺寸),于是 diff 里窗口项与页边距项自己约掉——剩下的只能是 +/// 轨宽差。**抓得住**:有人把 `rail_width()` 改回常量 88(折叠态那条立刻红),或者把 +/// `available_width` 的减法换回硬编码;**抓不住**:外壳到底有没有真的写这个值(下一条 + 接线判据)、 +/// 真画出来的像素(`DevDocs/artifacts/m301/` 的折叠态全窗图)。 +#[test] +fn the_page_width_follows_the_collapsed_rail() { + let (collapsed, expanded) = ends(); + let window = 1180.0; + let gutter = WidthClass::Expanded.gutter(); + + let open = with_rail(expanded, || frame_metrics(PaneKit::Single, window)); + let folded = with_rail(collapsed, || frame_metrics(PaneKit::Single, window)); + + // ① 折叠态:窗口 − 64 − 两侧页边距 + assert_eq!( + folded.available, + window - collapsed - 2.0 * gutter, + "折叠态可用宽必须是窗口 − 64 − 48(1180 − 64 − 48 = 1068)" + ); + assert_ne!( + folded.available, + window - expanded - 2.0 * gutter, + "折叠态不许再按 88 扣宽——那正是 M300 留下的 24px 空白带" + ); + // ② 展开态:窗口 − 88 − 两侧页边距 + assert_eq!( + open.available, + window - expanded - 2.0 * gutter, + "展开态回到窗口 − 88 − 48(1044)" + ); + // ③ 差值正好 24 = 88 − 64 + assert_eq!(folded.available - open.available, 24.0, "两态差值正好 24"); + assert_eq!(expanded - collapsed, 24.0, "差值来自两个令牌本身"); + + // 折叠真的多腾出 24px:折宽 = 轨 + 左页边距 + 块宽(+偏移) + 右页边距,右缘贴死窗口 + assert_eq!( + collapsed + gutter + block::body_offset(folded) + frame::body_width(folded) + gutter, + window, + "折叠态正文块右缘必须贴到窗口右缘(右侧不许留 24px 空白带):64 + 24 + 1068 + 24 = 1180" + ); + assert_eq!( + frame::body_width(folded) - frame::body_width(open), + 24.0, + "块宽也必须跟着长 24(不是只有 available 这个字段变了)" + ); +} + +/// 采样**不粘滞**:写完折叠档、再写展开档,第二次必须回到 88。 +/// +/// **抓得住**:只有一个方向的写入(折叠写、展开不写);`forget` 之后回到回落值; +/// **抓不住**:外壳有没有每帧都调用写入口(那是 `only_the_shell_samples_the_rail_width` + +/// `the_shell_samples_before_the_page_tree_is_built` 的事)。 +#[test] +fn the_sampled_width_is_not_sticky() { + let (collapsed, expanded) = ends(); + layout::rail::forget_rail_width(); + + layout::note_rail_width(collapsed); + assert_eq!(layout::rail_width(), collapsed, "第一帧:折叠档"); + + layout::note_rail_width(expanded); + assert_eq!( + layout::rail_width(), + expanded, + "第二帧必须回到展开档,不许粘住 64" + ); + + layout::rail::forget_rail_width(); + assert_eq!( + layout::rail_width(), + expanded, + "复位后回到回落值(= 展开档)" + ); +} + +/// 两列版面的**门槛**也跟着活值走(少了 24 的轨宽,成立线也低 24)。 +/// +/// 设置页(supporting-pane):展开门槛 720 / 折叠 696 ⇒ 700 宽的窗口在展开态是单列、折叠态是两列。 +/// 实例页(list-detail)没有这条翻转带:它的几何门槛 816 低于官方 expanded 断点 840,档位是更严的 +/// 那一条——这条断言把「24px 改动不会让实例页在 840 线上反复翻版」写成判据。 +/// +/// **抓得住**:门槛被钉成常量(折叠后仍要求 720);**抓不住**:翻转瞬间的重排观感(要出动画)。 +#[test] +fn collapsing_lowers_the_pane_threshold_by_the_same_24() { + let (collapsed, expanded) = ends(); + let open_min = with_rail(expanded, layout::supporting_pane_min_window); + let folded_min = with_rail(collapsed, layout::supporting_pane_min_window); + assert_eq!( + open_min - folded_min, + 24.0, + "支撑栏门槛同样差 24(720 vs 696)" + ); + + // 700 宽:展开态够不到 720 ⇒ 单列;折叠态跨过 696 ⇒ 两列 + assert_eq!( + with_rail(expanded, || frame_metrics(PaneKit::Supporting, 700.0) + .layout), + PageLayout::SingleColumn + ); + assert_eq!( + with_rail(collapsed, || frame_metrics(PaneKit::Supporting, 700.0) + .layout), + PageLayout::SupportingPane, + "少了 24 的轨宽,700 宽的窗口就放得下两列了" + ); + + // 实例页:816 的几何门槛在 840 的档位之下,两态在 840 上都是 list-detail(没有翻转带) + for rail in [expanded, collapsed] { + assert_eq!( + with_rail(rail, || frame_metrics(PaneKit::ListDetail, 840.0).layout), + PageLayout::ListDetail, + "840 窗宽下两态都成立两列:档位(840)比几何门槛(816 / 792)更严" + ); + } +} + +/// **唯一写入口**:产品代码里只有外壳的 `app/view/rail.rs` 采样(其余是定义点与用例)。 +/// +/// 为什么扫源码:这条判据的本质是「不许有第二个写入口」——类型系统管不住一个 `pub(crate)` 函数。 +/// 仓内已有同样的先例(`layout::source`、`tests/no_hardcoded_tokens.rs` 都靠扫文本守形态)。 +/// +/// **抓得住**:谁在 update 侧 / 页面里偷写一份轨道宽度(那会与外壳漂成两个数); +/// **抓不住**:外壳是不是在**每一帧**都写(下一条盯顺序)。 +#[test] +fn only_the_shell_samples_the_rail_width() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut files = Vec::new(); + rust_files(&root, &mut files); + assert!( + files.len() > 50, + "只扫到 {} 个源文件:扫描逻辑坏了", + files.len() + ); + + let mut writers = Vec::new(); + for path in files { + let text = std::fs::read_to_string(&path).expect("读源文件"); + if !text.contains("note_rail_width(") { + continue; + } + let relative = path + .strip_prefix(&root) + .expect("相对路径") + .to_string_lossy() + .replace('\\', "/"); + // 定义点 + 它自己的单测;以及任何用例文件(用例**应该**能摆采样,那是它们的输入)。 + if relative == "pages/layout/rail.rs" || is_test_path(&relative) { + continue; + } + writers.push(relative); + } + + assert_eq!( + writers, + vec!["app/view/rail.rs".to_string()], + "轨宽的写入口只许有一个:外壳 `app/view/rail.rs` 的 `rail_account()`" + ); +} + +/// **顺序契约**:外壳的采样必须排在页面树之前(`app/view.rs` 里两条语句的先后)。 +/// +/// 为什么是源码判据而不是数值判据:iced 的 `Element` 没有几何内省接口,「同一帧里谁先跑」只能 +/// 从装配代码看。写反了 = 页面用上一帧的轨宽扣宽(动画最后一帧会停在 87 点几)。 +#[test] +fn the_shell_samples_before_the_page_tree_is_built() { + let view = include_str!("../../app/view.rs"); + let sampled = view + .find("let account = self.rail_account();") + .expect("`App::view` 里必须有外壳采样这一行"); + let page = view + .find("page_with_settings_transition(") + .expect("`App::view` 里必须建页面树"); + assert!( + sampled < page, + "外壳采样必须排在页面树之前:先写后读,页面才与轨同一拍" + ); +} + +/// 递归收集 `.rs`(与 `tests/no_hardcoded_tokens.rs` 同一手法)。 +fn rust_files(dir: &Path, collected: &mut Vec) { + let entries = + std::fs::read_dir(dir).unwrap_or_else(|error| panic!("读不动 {}:{error}", dir.display())); + for entry in entries { + let path = entry.expect("目录项读失败").path(); + if path.is_dir() { + rust_files(&path, collected); + } else if path.extension().is_some_and(|ext| ext == "rs") { + collected.push(path); + } + } +} + +/// 用例文件:`*/tests.rs` 或 `*/tests/*.rs`。 +fn is_test_path(relative: &str) -> bool { + relative.ends_with("/tests.rs") || relative.contains("/tests/") +} + +/// **组件版**导航轨(M78 的 `NavigationRail`)声明的宽度必须与布局系统同值。 +/// +/// 注意这不是产品路径的实现(M90 r3 订正):产品页的 rail 由 `shell/nav.rs` 画,组件版只服务 +/// `pages::app`(harness 出口图)与单测。这条守的是「组件与布局系统没分叉」,**不是**「产品 rail +/// 没问题」——两套实现的漂移风险见 `pages/mod.rs` 偏离表 #10。 +/// +/// M301:组件版只有 88 / 240 两态(`pages/nav.rs` 的模块文档写了为什么不做第三档),所以这里比 +/// 的是**展开档**;本线程没有外壳采样 ⇒ `rail_width()` 正是展开档。 +/// +/// **抓得住**:`NavPaneGeometry::rail().width` 与 `layout::rail_width_expanded()` 分叉; +/// **抓不住**:容器圆角/药丸摆放的像素级正确性(组件自己的 probe 管),也抓不住 `shell/nav.rs`。 +#[test] +fn the_real_rail_agrees_with_the_layout_system() { + let ui = super::ui(); + let geometry = crate::app::components::navigation::geometry::NavPaneGeometry::of( + &ui, + crate::app::components::navigation::NavLayout::Rail, + ); + let expanded = layout::rail_width_expanded(); + assert!( + (geometry.width - expanded).abs() < f32::EPSILON, + "真组件 rail 宽 {} 与布局系统的展开档 {expanded} 分叉了", + geometry.width + ); + assert!( + (layout::rail_width() - expanded).abs() < f32::EPSILON, + "没有外壳采样时,页面扣宽也必须等于展开档" + ); +}