From f7cfc8ded6e3e877cffc3c1d58fb1d3e449ab8bb Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 11 Nov 2026 18:04:03 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat(app-iced):=20=E5=8D=95=E8=A1=8C?= =?UTF-8?q?=E8=BE=93=E5=85=A5=E6=A1=86=E9=80=8F=E5=87=BA=20on=5Fsubmit?= =?UTF-8?q?=EF=BC=8C=E5=9B=9E=E8=BD=A6=E6=8E=A5=E4=B8=8A=E5=AD=98=E6=A1=A3?= =?UTF-8?q?=E6=94=B9=E5=90=8D=E4=B8=8E=E5=BC=BA=E8=B0=83=E8=89=B2=E6=8F=90?= =?UTF-8?q?=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 债务台账 P18(M250 + M247 的共同缺口):iced 0.14 的 text_input 有 on_submit、 没有 on_unfocus(builder 逐条核过:on_input/on_input_maybe/on_submit/on_submit_maybe/ on_paste/on_paste_maybe,on_unfocus 零命中),而本仓的 text_field 只透出 on_input ⇒ 行内重命名只能点按钮、强调色拿不到「用户确认」这一下。 - field.rs:Field 变 `Field<'a, M>`(M 只被新加的 on_submit 用到,调用点的类型由 text_field 的 on_input 一并推出,一行都不用改),回车消息唯一从 submit_message() 取,`on_submit_maybe(None)` 是默认档 ⇒ 既有调用点逐位不变;禁用态与 on_input 同一 条规矩被摘掉。 - 存档改名:回车与「改名」按钮共用 commit_rename() 一个消息产地,空名/原名不变/ 非法字符那几道校验因此对回车同样成立。 - 强调色:回车发 Change::AccentCommit(不是又一次 Accent(原文))⇒ 立即 commit(), 把「打了一半就放着」的 #12 亮成红框 + 错行;逐键不报短错的规矩不变,落盘仍取 上一次合法值。 - 判据:submit_is_opt_in(未接就是 None)、text_field_forwards_the_submit_callback、 submit_is_wired_only_at_the_two_call_sites(白名单扫源码)、 enter_takes_the_same_message_as_the_rename_button、the_rename_field_wires_enter、 the_accent_field_wires_enter_to_commit、enter_commits_a_half_typed_value_and_ persists_only_legal_ones。真键盘回车量不到(Element 无内省、造不出 iced::Renderer 的测试替身),报告里单列。 --- .../app-iced/src/components/inputs/field.rs | 55 ++++++++- .../src/components/inputs/field/tests.rs | 110 +++++++++++++++++- crates/app-iced/src/components/inputs/mod.rs | 3 + .../app-iced/src/pages/appearance/accent.rs | 6 +- crates/app-iced/src/pages/appearance/mod.rs | 12 ++ .../src/pages/appearance/section/accent.rs | 47 +++++++- crates/app-iced/src/pages/appearance/state.rs | 10 +- crates/app-iced/src/pages/appearance/tests.rs | 11 ++ .../src/pages/appearance/tests/accent.rs | 67 +++++++++++ crates/app-iced/src/pages/instances/saves.rs | 3 + .../src/pages/instances/saves/rows.rs | 27 +++-- crates/app-iced/src/pages/instances/tests.rs | 3 + .../src/pages/instances/tests/rename.rs | 52 +++++++++ 13 files changed, 384 insertions(+), 22 deletions(-) create mode 100644 crates/app-iced/src/pages/instances/tests/rename.rs diff --git a/crates/app-iced/src/components/inputs/field.rs b/crates/app-iced/src/components/inputs/field.rs index d9602d6..e4e9145 100644 --- a/crates/app-iced/src/components/inputs/field.rs +++ b/crates/app-iced/src/components/inputs/field.rs @@ -17,6 +17,22 @@ //! //! 1. **状态差异落在指示线/描边/标签上,容器底色不叠状态层**(A1 的口径,与按钮家族的 8%/10% 状态层不同);press 无独立 token,按 hover 处理(D11)。 //! 2. 标签是「**常浮起**」的两段式(占上面 16px,不做上浮动画,登记 D12);缺省宽度 `Fill`(最小宽 280 无法在 iced 表达,G21)。 +//! +//! ## 回车与失焦(P18 / M252):只有回车是真的事件 +//! +//! `iced_widget-0.14.2/src/text_input.rs` 的 builder 一共这几条(**逐条从源码抄的**,别凭记忆): +//! +//! | 方法 | 行 | 产物 | +//! |---|---|---| +//! | `on_input` / `on_input_maybe` | 172 / 184 | 每次改动后发一条消息 | +//! | `on_submit` / `on_submit_maybe` | 194 / 201 | **焦点在框内时**按回车发**一条**消息(`:1049-1053`:一个 `KeyPressed` 事件只 `publish` 一次;不在焦点内的回车走不到这里) | +//! | `on_paste` / `on_paste_maybe` | 208 / 218 | 粘贴 | +//! | ~~`on_unfocus`~~ | **整个文件 0 命中** | 失焦**没有**消息通道:`State::unfocus()`(`:1524`)与 `Focusable for State`(`:1563`)只改控件内部状态,不发消息 | +//! +//! 所以本封装的取舍是:**把有的事件透出去**([`Field::on_submit`]),失焦继续用代理近似 +//! (回车 + 点到别的控件),**不为它去 fork iced**。`on_submit` 是**可选**的:不调 +//! [`Field::on_submit`] 时 `text_input` 的 `on_submit` 保持空(`on_submit_maybe(None)`), +//! 回车**什么消息都不发**——全站既有调用点因此逐位不变(回归见 `tests.rs`)。 use iced::widget::text::LineHeight; use iced::widget::{column, container, row, text, text_input}; @@ -62,8 +78,10 @@ impl FieldVariant { } /// 一个文本框的装配描述(不持有 `String`:受控组件只借值,契约 §2)。 -#[derive(Debug, Clone)] -pub struct Field<'a> { +/// +/// `M` 是**消息类型**,只被 [`Field::on_submit`] 用到:不给提交回调时它跟 `on_input` 的消息同型, +/// 调用点照旧写 `Field::new(value)…`(类型由 `text_field` 的 `on_input` 一并推出,不用标注)。 +pub struct Field<'a, M> { variant: FieldVariant, value: &'a str, label: Option<&'a str>, @@ -77,9 +95,11 @@ pub struct Field<'a> { secure: bool, /// 脚下那一层的底(M234,禁用态合成用)。 base: Base, + /// 回车要发的那条消息(P18;`None` = 回车什么都不发生,既有调用点全是这一档)。 + on_submit: Option M + 'a>>, } -impl<'a> Field<'a> { +impl<'a, M> Field<'a, M> { /// 一个 Filled 文本框。 pub fn new(value: &'a str) -> Self { Self { @@ -95,6 +115,7 @@ impl<'a> Field<'a> { width: Length::Fill, secure: false, base: Base::CANVAS, + on_submit: None, } } @@ -166,21 +187,46 @@ impl<'a> Field<'a> { self.secure = secure; self } + + /// **回车提交**(P18 / M252):焦点在框内时按回车,发这一条消息。 + /// + /// 不调就是「回车什么都不发生」——这是默认档,也是全站既有调用点的现状。 + /// 禁用态([`Field::state`] = `Disabled`)里回车**不发**(与 `on_input` 同一条规矩: + /// 点不动的框也不该能提交)。 + pub fn on_submit(mut self, on_submit: impl Fn() -> M + 'a) -> Self { + self.on_submit = Some(Box::new(on_submit)); + self + } + + /// 回车那条消息(`None` = 没接 [`Field::on_submit`])。**唯一的读取口**: + /// `text_field` 只从这里取,于是「接没接」与「发不发」是同一个事实。 + fn submit_message(&self) -> Option { + self.on_submit.as_ref().map(|on_submit| on_submit()) + } } /// 一个文本框(Filled / Outlined 由 [`Field::variant`] 选)。 /// /// 受控:值由外部持有,输入经 `on_input` 回传;`state` 强制成 `Disabled` 时输入被摘掉 /// (`on_input_maybe(None)`),所以禁用字段真的点不动,不只是变灰。 +/// 回车(P18):只在 [`Field::on_submit`] 被调过时才接(`on_submit_maybe`),没接就是 +/// 「回车什么都不发生」。 pub fn text_field<'a, M: Clone + 'a>( ui: &'a Ui, - field: Field<'a>, + field: Field<'a, M>, on_input: impl Fn(String) -> M + 'a, ) -> Element<'a, M> { let geometry = FieldGeometry::of(ui, field.label.is_some(), field.leading); let state = field.state.unwrap_or(Interaction::Enabled); let colors = palette(ui, field.base, field.variant, state, field.error); + // 提交消息只在**可交互**时接:禁用字段与 `on_input` 一样被摘掉。 + let submit = if state.is_disabled() { + None + } else { + field.submit_message() + }; + let input_style = ui.style(TypeRole::BodyLarge); let label_style = ui.style(TypeRole::BodySmall); @@ -190,6 +236,7 @@ pub fn text_field<'a, M: Clone + 'a>( } else { Some(on_input) }) + .on_submit_maybe(submit) .secure(field.secure) .size(input_style.size_px) .line_height(LineHeight::Absolute(Pixels(input_style.line_height_px))) diff --git a/crates/app-iced/src/components/inputs/field/tests.rs b/crates/app-iced/src/components/inputs/field/tests.rs index 64302bd..1f5a78d 100644 --- a/crates/app-iced/src/components/inputs/field/tests.rs +++ b/crates/app-iced/src/components/inputs/field/tests.rs @@ -17,7 +17,7 @@ fn ui(flavor: SchemeFlavor) -> Ui { Ui::with_platform(flavor, Platform::Windows, FAMILIES) } -fn build(field: Field<'_>, flavor: SchemeFlavor) -> Element<'_, u8> { +fn build(field: Field<'_, u8>, flavor: SchemeFlavor) -> Element<'_, u8> { let ui = Box::leak(Box::new(ui(flavor))); text_field(ui, field, |_| 0_u8) } @@ -163,3 +163,111 @@ fn filled_carries_the_container_role() { ); assert_eq!(FieldGeometry::of(&ui, true, None).shape.dp(), 4.0); } + +/// 本文件所在模块的源码(源码结构断言用,`pages::layout::source` 的同一条纪律)。 +const FIELD_SOURCE: &str = include_str!("../field.rs"); + +/// P18 / M252 判据 ④-a:`on_submit` 是**可选**的——不接就没有提交消息。 +/// +/// 抓得住:`Field::new(..)` 这条链(其余构造器全接上)给出 `None` ⇒ `text_field` 交给 +/// `text_input` 的是 `on_submit_maybe(None)`,回车**什么消息都不发**(全站既有调用点因此逐位 +/// 不变);接了之后取到的正是那条消息(回调没被构造器吞掉)。 +/// 抓不住:回车键真的走到控件(iced 的键路径在 `Widget::update` 里,`Element` 没有内省接口, +/// 见 `pages::layout::source` 的模块文档;本仓也造不出 `iced::Renderer` 的测试替身)。 +#[test] +fn submit_is_opt_in() { + let plain: Field<'_, u8> = Field::new("实例一") + .variant(FieldVariant::Outlined) + .label("名称") + .placeholder("请输入") + .supporting("辅助说明") + .leading(IconKind::Folder) + .trailing(IconKind::Close) + .error() + .state(Interaction::Enabled) + .secure(false) + .base(Base::CANVAS); + assert_eq!( + plain.submit_message(), + None, + "没调 `.on_submit` ⇒ 回车不发消息(既有调用点的行为)" + ); + + let wired: Field<'_, u8> = Field::new("实例一").on_submit(|| 7_u8); + assert_eq!(wired.submit_message(), Some(7), "接上的回调要被原样取出来"); +} + +/// 回归(T27 反例 ①):`text_field` 必须把 `Field` 上的提交回调**转发**给 `text_input`。 +/// +/// iced 的 `Element` 没有内省接口,所以这条守的是**源码结构**(本仓既有同一条纪律,见 +/// `pages::layout::source` 的模块文档)。两条断言合起来才等于「转发」:消息只从 +/// `Field::submit_message()` 取(唯一读取口),且它被交给 `on_submit_maybe`。 +/// +/// 抓得住:删掉 `.on_submit_maybe(..)` 那一行 / 改成 `on_submit_maybe(None)` / 把 +/// `Field::on_submit` 存着不读(变异实测见 M252 报告)——本条立刻红。 +/// 抓不住:`text_input` 内部的键处理(那是 iced 的实现,不在这里)。 +#[test] +fn text_field_forwards_the_submit_callback() { + let body = crate::pages::layout::source::function_body(FIELD_SOURCE, "pub fn text_field"); + assert!( + body.contains("submit_message()"), + "提交消息必须从 `Field::submit_message()` 取(唯一读取口),实际函数体:\n{body}" + ); + assert!( + body.contains(".on_submit_maybe("), + "取到之后必须交给 `text_input` 的 `on_submit_maybe`,实际函数体:\n{body}" + ); +} + +/// 回归(T27 反例 ②):全站**只有那两处**(存档改名 / 强调色)接 `Field::on_submit`。 +/// +/// 为什么要扫源码:`text_field` 是全站共用控件,加了一个可选参数之后最容易出的错是 +/// **顺手把别的调用点也接上**——那会让一个「回车本来什么都不做」的输入框开始提交。 +/// 这条不靠「我看了一遍所有调用点」:白名单对不上就红;新增一处接线必须来这里登记, +/// 这份摩擦是故意的。 +/// +/// 抓得住:任何一处**新增/删除**接在构建链上的 `.on_submit(..)`(本仓的链一律一行一条)。 +/// 抓不住:写在别处的同名文本(注释/字符串里的例子)、写成同一行的接线,也不管那两处接的 +/// 消息对不对(由 `pages` 侧各自的用例钉)。 +#[test] +fn submit_is_wired_only_at_the_two_call_sites() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut wired = Vec::new(); + scan(&root, &root, &mut wired); + wired.sort(); + assert_eq!( + wired, + vec![ + "pages/appearance/section/accent.rs".to_owned(), + "pages/instances/saves/rows.rs".to_owned(), + ], + "`Field::on_submit` 的调用点白名单变了(新接线要在这里登记)" + ); +} + +/// 递归找 `dir` 下**有一行以 `.on_submit(` 开头**的 `.rs`,记下相对 `root` 的路径(`/` 分隔)。 +/// +/// 为什么按「行首」而不是整篇 `contains`:注释与字符串里出现的 `.on_submit(` 例子(包括本文件 +/// 自己)会假红。代价是**写成同一行的接线扫不到**——本仓的构建链一律一行一条,这条取舍记在上面。 +fn scan(root: &std::path::Path, dir: &std::path::Path, found: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + scan(root, &path, found); + } else if path.extension().is_some_and(|ext| ext == "rs") { + let Ok(text) = std::fs::read_to_string(&path) else { + continue; + }; + if text + .lines() + .any(|line| line.trim_start().starts_with(".on_submit(")) + { + let rel = path.strip_prefix(root).unwrap_or(&path); + found.push(rel.to_string_lossy().replace('\\', "/")); + } + } + } +} diff --git a/crates/app-iced/src/components/inputs/mod.rs b/crates/app-iced/src/components/inputs/mod.rs index 5e880ba..9b14b4e 100644 --- a/crates/app-iced/src/components/inputs/mod.rs +++ b/crates/app-iced/src/components/inputs/mod.rs @@ -26,6 +26,9 @@ //! 前后槽位 × 错误/辅助文本),所以照按钮家族的先例把「部件参数」收进构造器结构体: //! `text_field(ui, Field::new(value).label("名称"), on_input)`。**泛型纪律一字不改**, //! 差别只是位置参数变命名参数(按钮家族已登记同一决定,见 `buttons` 模块文档)。 +//! +//! 回车提交(P18)也是命名参数:`Field::new(value).on_submit(|| Msg::Commit)`。**不接就没有** +//! ——`Field` 的 `M` 由 `text_field` 的 `on_input` 推出,既有调用点一个都不用改。 pub mod geometry; diff --git a/crates/app-iced/src/pages/appearance/accent.rs b/crates/app-iced/src/pages/appearance/accent.rs index 59039b2..3a63084 100644 --- a/crates/app-iced/src/pages/appearance/accent.rs +++ b/crates/app-iced/src/pages/appearance/accent.rs @@ -21,7 +21,8 @@ //! //! 1. **不可能再靠打字变合法**:出现非十六进制字符(`Digit`)或超过 6 位(`Long`)——任何继续 //! 输入都只会更错,立刻亮; -//! 2. **提交**([`Accent::commit`]):离开这一格、点到别的外观控件、或配置读回。 +//! 2. **提交**([`Accent::commit`]):离开这一格、点到别的外观控件、**按回车**(P18,M252 接的 +//! `Change::AccentCommit`)、或配置读回。 //! //! 配置读回走 [`Accent::new`](`commit` 语义):文件里存着坏值必须**当场**看得见,不能等用户来点。 //! @@ -81,6 +82,9 @@ impl Accent { } /// 提交:把「输入中暂不报」的错亮出来。离开这一格就该走它(见 [`Accent::pending`])。 + /// + /// 三个触发点:点到别的外观控件 / 按回车(P18 `Change::AccentCommit`)/ 配置读回。 + /// 幂等:已经亮着错再提交一次什么都不做。 pub fn commit(&mut self) { if self.error.is_none() { self.error = self.pending.take(); diff --git a/crates/app-iced/src/pages/appearance/mod.rs b/crates/app-iced/src/pages/appearance/mod.rs index da1da56..bf4f52d 100644 --- a/crates/app-iced/src/pages/appearance/mod.rs +++ b/crates/app-iced/src/pages/appearance/mod.rs @@ -132,6 +132,13 @@ pub enum Change { Corner(unlucky_presenter::prefs::WindowCorner), /// 自定义强调色输入(M250:原文;空串 = 恢复出厂板)。 Accent(String), + /// **强调色回车提交**(P18 / M252):不带值,只表示「用户按了回车,把打了一半的东西定下来」。 + /// + /// 为什么不复用 `Accent(原文)`:那个是**每一次按键**(`Accent::set`,输入中不报短错), + /// 而回车是 [`Accent::commit`](super::accent::Accent::commit) 的第三个触发点(另两个是 + /// 「点到别的外观控件」与「配置读回」)——把 `#12` 这种打了一半就放着的值亮成红框, + /// 同时**不写盘**(落盘永远取上一次合法值)。 + AccentCommit, /// 设置页切分组(左列点选)。 SelectGroup(usize), } @@ -150,6 +157,9 @@ impl Change { Self::PaletteSource(_) => unlucky_presenter::prefs::keys::PALETTE_SOURCE, Self::Corner(_) => unlucky_presenter::prefs::keys::WINDOW_CORNER, Self::Accent(_) => unlucky_presenter::prefs::keys::ACCENT_COLOR, + // 回车提交不产生新值,但**属于**强调色那一格 ⇒ 仍然点名到这个键(走 bridge 再写一次 + // 上一次合法值,见 `App::on_appearance`)。 + Self::AccentCommit => unlucky_presenter::prefs::keys::ACCENT_COLOR, Self::SelectGroup(_) => return None, }) } @@ -173,6 +183,8 @@ impl Change { Self::PaletteSource(source) => source.as_str().to_owned(), Self::Corner(corner) => corner.as_str().to_owned(), Self::Accent(text) => text.clone(), + // 回车**不改值**:真正的落盘值一直是 `current_values()` 里的上一次合法值。 + Self::AccentCommit => return None, Self::SelectGroup(_) => return None, }) } diff --git a/crates/app-iced/src/pages/appearance/section/accent.rs b/crates/app-iced/src/pages/appearance/section/accent.rs index 3944737..8d5363e 100644 --- a/crates/app-iced/src/pages/appearance/section/accent.rs +++ b/crates/app-iced/src/pages/appearance/section/accent.rs @@ -51,7 +51,10 @@ pub(super) fn push<'a>( // 受控输入框 + 失败态:`Field::error()` 让框本身变成错误色,错行再补「错在哪」。 let mut field = Field::new(accent.text()) .label(t(Key::SettingsAppearanceAccent, lang)) - .placeholder("#RRGGBB"); + .placeholder("#RRGGBB") + // P18:回车 = 立即提交(把「打了一半就放着」的 `#12` 亮成红框 + 错行)。 + // **不是**逐键报错:敲字那条路(`Change::Accent`)照旧只在「不可能再变合法」时才亮红。 + .on_submit(accent_commit); if accent.error().is_some() { field = field.error(); } @@ -81,6 +84,15 @@ pub(super) fn push<'a>( )); } +/// 回车那一下要发的那条消息——**唯一产地**(M252 / P18)。 +/// +/// 抽成函数是为了让「回车 = 提交」这件事在源码里只有一个写法:`section/accent.rs` 把它交给 +/// `Field::on_submit`,测试按它钉住「回车走的是 `AccentCommit` 而不是又一条 `Accent(原文)`」 +/// (后者是**逐键**路径,会把已经亮出来的错重新吞回去)。 +fn accent_commit() -> Message { + Message::Appearance(Change::AccentCommit) +} + /// 「预设」两行:先「标签 + 出厂」,再一排 8 颗色块。 /// /// 为什么分两行:一行放 9 个控件在设置卡片的正文宽度里会被挤成「7 + 1」折行,最后一颗色块 @@ -217,6 +229,39 @@ fn visible(ch: char) -> String { mod tests { use super::visible; + /// 本文件源码(源码结构断言用,与 `pages/layout/source.rs` 同一条纪律)。 + const SOURCE: &str = include_str!("accent.rs"); + + /// **M252 / P18 回车接线的两条断言**:输入框接了 `on_submit`,且接的是「提交」那条消息。 + /// + /// iced 的 `Element` 没有内省接口(见 `pages::layout::source` 的模块文档),所以「回车真的 + /// 按下去」在这里量不到——能钉住的是**它接的是哪条消息**: + /// + /// - 抓得住:`.on_submit(..)` 被删掉(T27 反例);接成 `Change::Accent(原文)`(那是**逐键** + /// 路径,`retry(false)` 会把已经亮起来的错吞回去,等于回车把错误擦掉);接成别的控件消息。 + /// - 抓不住:按键真的到达控件(iced 的 `Widget::update`)、以及红框/错行画没画(出图负责)。 + #[test] + fn the_accent_field_wires_enter_to_commit() { + let body = crate::pages::layout::source::function_body(SOURCE, "fn push"); + assert!( + body.contains(".on_submit("), + "强调色输入框没接回车(去掉接线这条必须红),实际函数体:\n{body}" + ); + assert!( + body.contains("accent_commit"), + "回车必须接 `accent_commit`(唯一产地),实际函数体:\n{body}" + ); + // 那条产地发的到底是什么:`Message` 没有 `PartialEq`,比 `Debug` 文本(变体名 + 字段)。 + assert_eq!( + format!("{:?}", super::accent_commit()), + format!( + "{:?}", + crate::pages::Message::Appearance(super::Change::AccentCommit) + ), + "回车那条消息必须是 `AccentCommit`(提交),不是 `Accent(原文)`(敲字)" + ); + } + /// 错行里的字符必须**看得见**:不可见字符写成 `U+XXXX`,可见字符原样(r1 小事 2)。 /// /// 抓得住:把 `visible` 改回 `ch.to_string()`——`U+00A0` / `U+200B` 那两条立刻红(错行会变成 diff --git a/crates/app-iced/src/pages/appearance/state.rs b/crates/app-iced/src/pages/appearance/state.rs index 80a2595..024ce32 100644 --- a/crates/app-iced/src/pages/appearance/state.rs +++ b/crates/app-iced/src/pages/appearance/state.rs @@ -111,10 +111,9 @@ impl State { state } - /// 截图/真机取证用:把 `ULL_APPEARANCE_*` 灌进来(不设 = 不动)。 - /// - /// 为什么用环境变量而不是 CLI 参数:`crates/app-iced/src/cli.rs` 不在本 mission 的 scope。 - /// 语义与既有取证开关(`ULL_TICK_TRACE`)一致:**只影响这次进程**,不落盘。 + /// 截图/真机取证用:把 `ULL_APPEARANCE_*` 灌进来(不设 = 不动)。为什么用环境变量而不是 CLI + /// 参数:`crates/app-iced/src/cli.rs` 不在本 mission 的 scope。语义与既有取证开关(`ULL_TICK_TRACE`) + /// 一致:**只影响这次进程**,不落盘。 pub fn apply_env(&mut self) { if let Some(value) = env_f32(ENV_WINDOW_ALPHA) { self.window_opacity = value; @@ -244,7 +243,7 @@ impl State { /// 应用一条改动(设置页 → 状态)。返回**是否需要立刻下发整窗 α**。 pub fn apply(&mut self, change: super::Change) -> bool { use super::Change; - // M250 p2-3:其余外观改动都意味着用户离开了强调色那一格 ⇒ 提交(理由见 `accent` 模块文档)。 + // M250 p2-3 / P18:除「敲字」(`Accent`)以外都意味着这一格定下来了 ⇒ 提交;回车 `AccentCommit` 同路。 if !matches!(change, Change::Accent(_)) { self.accent.commit(); } @@ -291,6 +290,7 @@ impl State { self.accent.set(&text); false } + Change::AccentCommit => false, // P18 回车:语义就是「提交」,上面那条规则已 `commit()` Change::SelectGroup(index) => { self.selected_group = index; false diff --git a/crates/app-iced/src/pages/appearance/tests.rs b/crates/app-iced/src/pages/appearance/tests.rs index 34bb04d..22e4603 100644 --- a/crates/app-iced/src/pages/appearance/tests.rs +++ b/crates/app-iced/src/pages/appearance/tests.rs @@ -50,6 +50,17 @@ fn every_change_names_its_own_schema_key() { } assert_eq!(Change::SelectGroup(2).key(), None, "切分组不落盘"); assert_eq!(Change::SelectGroup(2).value(), None); + // P18:回车提交不产生值,但它是强调色那一格的改动(`on_appearance` 据此再写一次上一次合法值) + assert_eq!( + Change::AccentCommit.key(), + Some(keys::ACCENT_COLOR), + "回车提交也要点名到自己那个 schema 键" + ); + assert_eq!( + Change::AccentCommit.value(), + None, + "回车不产生新值(落盘一直取上一次合法值)" + ); } /// 抓得住:落盘字符串必须是 schema 认识的词表(枚举档写错就整条配置读回默认值)。 diff --git a/crates/app-iced/src/pages/appearance/tests/accent.rs b/crates/app-iced/src/pages/appearance/tests/accent.rs index 17c2d4c..75ea2aa 100644 --- a/crates/app-iced/src/pages/appearance/tests/accent.rs +++ b/crates/app-iced/src/pages/appearance/tests/accent.rs @@ -252,3 +252,70 @@ fn accent_errors_only_appear_when_they_are_actionable() { ); assert!(loaded.accent.color().is_none(), "坏值不生效,走出厂板"); } + +/// **P18 / M252 判据 ③**:回车 = 立即提交(`Change::AccentCommit`)。 +/// +/// 抓得住:① 把回车实现成「再走一次 `Accent(原文)`」——`retry(false)` 会把已经亮起来的错**吞回去**, +/// 第一段当场红;② 把 `State::apply` 开头那条「非敲字即提交」的规则删掉(回车不再提交), +/// 第一段同样红;③ 回车把非法值写进配置(`persist_value` 变了),第二、三段红。 +/// 抓不住:真按下回车(iced 的键路径,见 `section/accent.rs` 的接线用例),也抓不住写盘那一下 +/// (`SettingsBridge::persist` 在 `app` 侧)——这里钉的是 `current_values().accent_color`, +/// 也就是**要写出去的那个值**。 +#[test] +fn enter_commits_a_half_typed_value_and_persists_only_legal_ones() { + use crate::cli::Backend; + use unlucky_design::color::SchemeFlavor; + use unlucky_presenter::prefs::{AccentError, AppearanceBase, AppearancePrefs}; + + use super::super::State; + + let mut state = State::new( + &AppearancePrefs::factory(), + &AppearanceBase::factory(), + Backend::Gpu, + SchemeFlavor::Light, + ); + + // ① 打了一半就放着:输入中**不**亮(逐键报错的规矩不许被回车改回来)…… + state.apply(Change::Accent("#12".to_owned())); + assert_eq!( + state.accent.error(), + None, + "输入中先不亮(手打一个合法色不该闪好几次)" + ); + // ……但回车必须亮,而且**不写盘**(落盘仍是上一次合法值 = 空串 = 出厂板) + state.apply(Change::AccentCommit); + assert_eq!( + state.accent.error(), + Some(AccentError::Short { digits: 2 }), + "回车之后 #12 必须亮红(打了一半放着也能被用户主动确认)" + ); + assert_eq!( + state.accent.persist_value(), + "", + "#12: 回车不许把非法值写盘" + ); + assert!(state.accent.color().is_none(), "#12: 也不许改生效色"); + assert_eq!( + state.current_values().accent_color, + "", + "写回配置的值仍是上一次合法值" + ); + + // ② 合法值回车:不报错、生效、写盘值就是规范化 hex + state.apply(Change::Accent(TEAL.to_hex().to_lowercase())); + state.apply(Change::AccentCommit); + assert_eq!(state.accent.error(), None, "合法值回车不该冒出错误"); + assert!(state.accent.color().is_some(), "合法值回车后仍生效"); + assert_eq!( + state.current_values().accent_color, + TEAL.to_hex(), + "合法值回车后写回配置的是规范化 hex" + ); + + // ③ 幂等:同一个值连按两次回车不出错、不改值 + state.apply(Change::AccentCommit); + state.apply(Change::AccentCommit); + assert_eq!(state.accent.error(), None); + assert_eq!(state.current_values().accent_color, TEAL.to_hex()); +} diff --git a/crates/app-iced/src/pages/instances/saves.rs b/crates/app-iced/src/pages/instances/saves.rs index b10b7cd..81e3af1 100644 --- a/crates/app-iced/src/pages/instances/saves.rs +++ b/crates/app-iced/src/pages/instances/saves.rs @@ -91,6 +91,9 @@ mod wall; #[cfg(test)] pub(super) use model::clear_handles; pub use model::{failure_line, from_state, Row, Saves, Shot, ShotPreview}; +/// 「改名」那条消息的产地(实现在 [`rows`] 里)——只给用例用,与 `op_line` 同一条口径。 +#[cfg(test)] +pub(super) use rows::commit_rename; /// 写操作成功那句话的产地(实现在 [`rows`] 里)。 /// /// 生产代码走 `model::from_state → rows::op_line`;这条 re-export 只给**用例**用 diff --git a/crates/app-iced/src/pages/instances/saves/rows.rs b/crates/app-iced/src/pages/instances/saves/rows.rs index 2d37638..1c33646 100644 --- a/crates/app-iced/src/pages/instances/saves/rows.rs +++ b/crates/app-iced/src/pages/instances/saves/rows.rs @@ -14,9 +14,10 @@ //! 三枚按钮与「打开存档文件夹」同一条口径:`buttons::text` + `frame::BASE`(M237:卡片里的控件 //! 把底传成卡底,hover/禁用状态才叠在卡实色上),出图取证时整行一起跟 `--force-state`。 //! -//! 输入框用 `components::inputs::text_field`(与启动参数区同一个控件)。**回车即提交**这件事 -//! 由 `text_input` 的 `on_submit` 给不了(`Field` 没有开这个口子),所以提交靠右边的「改名」按钮 -//! ——少一个键,但多一条谁都能看见的路。 +//! 输入框用 `components::inputs::text_field`(与启动参数区同一个控件)。**回车 = 点「改名」** +//! (M252 / P18):`Field::on_submit` 与右边那枚按钮共用 [`commit_rename`] 这一个消息产地, +//! 所以空名 / 原名不变 / 非法字符走的仍是原来那条校验(`app/update/instances.rs` 的 +//! 空名与「没改」两条短路 + presenter 那边的目录名规则),**回车没有绕开任何一道边界**。 use iced::widget::{column, row}; use iced::{Alignment, Element, Length}; @@ -98,17 +99,14 @@ fn rename_row<'a>( // M237:输入框住在卡里 ⇒ 状态层与描边叠卡底。 .base(frame::BASE) .placeholder(texts.saves_rename_placeholder()) - .width(Length::Fill), + .width(Length::Fill) + // P18:回车与右枚按钮**同一个**消息产地(见 `commit_rename` 的文档)。 + .on_submit(|| Message::Instances(commit_rename())), |value| Message::Instances(PageMessage::RenameWorldChanged(value)), ); row![ field, - action( - ui, - texts.saves_rename_confirm(), - PageMessage::CommitRenameWorld, - forced - ), + action(ui, texts.saves_rename_confirm(), commit_rename(), forced), action( ui, texts.saves_cancel(), @@ -122,6 +120,15 @@ fn rename_row<'a>( .into() } +/// 「改名」那一下要发的那条页面意图——**按钮与回车共用的唯一产地**(M252 / P18)。 +/// +/// 为什么抽成一个函数:「回车走的是不是同一条路」在 iced 里量不到(`Element` 没有内省接口, +/// 见 `pages::layout::source` 的模块文档),能钉住的只有**两条路拿的是同一个 Message**—— +/// 于是「空名 / 原名不变 / 非法字符不许提交」那几道校验自动对回车同样成立。 +pub(crate) fn commit_rename() -> PageMessage { + PageMessage::CommitRenameWorld +} + /// 名字 + 副文案(两行)。 fn labels<'a>(ui: &'a Ui, item: &'a Row, color: Role) -> iced::widget::Column<'a, Message> { column![ diff --git a/crates/app-iced/src/pages/instances/tests.rs b/crates/app-iced/src/pages/instances/tests.rs index 0fb85e0..26519d2 100644 --- a/crates/app-iced/src/pages/instances/tests.rs +++ b/crates/app-iced/src/pages/instances/tests.rs @@ -10,6 +10,9 @@ mod launch_args; /// M235 的存档区模型判据(快照 → 行 / 计数 / 失败线 / UTC 时间)。 mod saves; +/// M252 / P18 的**回车**接线(行内重命名:回车 = 点「改名」)。 +mod rename; + use super::super::layout::source; use super::super::layout::{frame_metrics, measure_cap_px, PaneKit, WidthClass}; diff --git a/crates/app-iced/src/pages/instances/tests/rename.rs b/crates/app-iced/src/pages/instances/tests/rename.rs new file mode 100644 index 0000000..35c9243 --- /dev/null +++ b/crates/app-iced/src/pages/instances/tests/rename.rs @@ -0,0 +1,52 @@ +//! M252 / P18:行内重命名的**回车**接线。 +//! +//! iced 的 `Element` 没有内省接口(见 `pages::layout::source` 的模块文档),所以「真按下回车会 +//! 发生什么」在单测里量不到。这里能钉住的两件事: +//! +//! | 断言 | 抓得住 | 抓不住 | +//! |---|---|---| +//! | [`enter_takes_the_same_message_as_the_rename_button`] | 「回车」与「点改名」共用的那条消息被换成别的(取消 / 重开…);空名 / 原名不变 / 非法字符那几道校验因此**自动**对回车成立——它们挂在同一个 Message 后面 | 校验本身对不对(在 `app/update/instances.rs` 与 presenter,本 mission 一字未改) | +//! | [`the_rename_field_wires_enter`] | `rename_row` 里那枚输入框的 `.on_submit(..)` 被删掉 / 接成别的(T27 反例) | 回车键真的到达控件(那是 iced 的 `Widget::update`) | +//! +//! 也就是说:判据①「回车真的提交一次」里,**「提交的是什么」被钉住了,「按下去会不会到这儿」没有** +//! (理由与替代验证写在 M252 报告的「没验的部分」一节)。 + +use crate::pages::layout::source; + +use super::super::saves::commit_rename; +use super::super::state::Message as PageMessage; + +/// 回车与「改名」按钮共用同一个消息产地 ⇒ 走同一套校验。 +/// +/// `PageMessage` 只有 `Debug + Clone`(没有 `PartialEq`,那些枚举不在本 mission 的 scope), +/// 所以这里比 `Debug` 文本:枚举的 `Debug` 打印变体名与字段,变体不同必然文本不同。 +#[test] +fn enter_takes_the_same_message_as_the_rename_button() { + assert_eq!( + format!("{:?}", commit_rename()), + format!("{:?}", PageMessage::CommitRenameWorld), + "回车必须发「提交重命名」,不能是取消/重开/逐键输入" + ); + assert!( + !format!("{:?}", commit_rename()).contains("Cancel"), + "回车不许接到取消上(那会让用户按回车丢掉草稿)" + ); +} + +/// `rename_row` 里那枚输入框真的接了 `on_submit`,且接的是 `commit_rename`(与按钮同一个)。 +/// +/// 抓得住(T27 反例):把 `.on_submit(..)` 那一行删掉 → 第一条断言红;把它接成 +/// `CancelRenameWorld` → 第二条断言红。 +/// 抓不住:回车键真的到达控件(iced 的键路径,见模块文档)。 +#[test] +fn the_rename_field_wires_enter() { + let body = source::function_body(include_str!("../saves/rows.rs"), "fn rename_row"); + assert!( + body.contains(".on_submit("), + "输入框没接回车(去掉接线这条必须红),实际函数体:\n{body}" + ); + assert!( + body.matches("commit_rename()").count() >= 2, + "回车与「改名」按钮必须共用 `commit_rename()`(两条路同一个 Message),实际函数体:\n{body}" + ); +} From ebce41b2c5a37b4c0d311dbba67fdd19622035ab Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 11 Nov 2026 18:13:31 +0800 Subject: [PATCH 2/5] =?UTF-8?q?test(app-iced)+docs(appearance):=20?= =?UTF-8?q?=E5=BC=BA=E8=B0=83=E8=89=B2=E3=80=8C=E5=B7=B2=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=E3=80=8D=E9=82=A3=E4=B8=80=E6=A1=A3=E7=9A=84=E5=87=BA=E5=9B=BE?= =?UTF-8?q?=E5=BC=80=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D30 要拍「回车之后」的样子,但出图通道没有键盘(渲染第 3~4 帧就落盘)。所以把强调色 的两档取证开关(ULL_APPEARANCE_ACCENT / …_COMMIT)收进 accent 自己的模块:第二档在灌值 之后补一次 Accent::commit(),它做的正是回车那条消息(Change::AccentCommit)在状态层做的事 (State::apply 开头的 commit)。拍的因此是同一个状态,不是另一条渲染分支。 顺带两处口径订正: - state.rs 少了 3 行(它贴 400 行硬线),强调色的 env 开关不再散在 state 里; - rows.rs 的模块文档把「名字规则」指到 core 的 game/saves/write/boundary::check_name(原来写的是 presenter)。 --- .../app-iced/src/pages/appearance/accent.rs | 22 +++++++++++++++++++ crates/app-iced/src/pages/appearance/state.rs | 9 ++++---- .../src/pages/instances/saves/rows.rs | 5 +++-- 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/crates/app-iced/src/pages/appearance/accent.rs b/crates/app-iced/src/pages/appearance/accent.rs index 3a63084..61b6984 100644 --- a/crates/app-iced/src/pages/appearance/accent.rs +++ b/crates/app-iced/src/pages/appearance/accent.rs @@ -38,6 +38,14 @@ use unlucky_presenter::prefs::{AccentChoice, AccentError}; /// 属于强调色自己,跟 `Accent` 放一起也更好找。语义与其它 `ULL_APPEARANCE_*` 一致——只影响这次进程。 pub const ENV_ACCENT: &str = "ULL_APPEARANCE_ACCENT"; +/// 出图矩阵的第二档(`ULL_APPEARANCE_ACCENT_COMMIT=1`):把 [`ENV_ACCENT`] 灌进来的值当作 +/// **已提交**([`Accent::commit`])。 +/// +/// P18 / M252:「打了一半就放着」的 `#12` 平时**不**亮红,按回车才亮。出图通道没有键盘,所以用这个 +/// 开关把那一帧拍下来——它拍的是 `commit()` 之后的**同一个状态**,不是另一条渲染分支。 +/// 语义同族:只影响这次进程,产品路径不读它。 +pub const ENV_ACCENT_COMMIT: &str = "ULL_APPEARANCE_ACCENT_COMMIT"; + /// 强调色的运行期状态(`App` 经 `pages::appearance::State` 持有一份)。 #[derive(Debug, Clone, PartialEq)] pub struct Accent { @@ -91,6 +99,20 @@ impl Accent { } } + /// 把 `ULL_APPEARANCE_ACCENT` / `ULL_APPEARANCE_ACCENT_COMMIT` 灌进来(不设 = 不动)。 + /// + /// 第一档 = 「像刚敲进去的」(走输入中规则);再给它 `…_COMMIT=1` 就等于「刚按下回车」。 + /// 两档合起来才拍得出 P18 的对照:同一条 `#12`,回车前不亮、回车后亮红。 + pub fn apply_env(&mut self) { + let Ok(value) = std::env::var(ENV_ACCENT) else { + return; + }; + self.set(&value); + if std::env::var_os(ENV_ACCENT_COMMIT).is_some_and(|flag| flag != "0") { + self.commit(); + } + } + /// 输入框原文。 #[must_use] pub fn text(&self) -> &str { diff --git a/crates/app-iced/src/pages/appearance/state.rs b/crates/app-iced/src/pages/appearance/state.rs index 024ce32..2ceebef 100644 --- a/crates/app-iced/src/pages/appearance/state.rs +++ b/crates/app-iced/src/pages/appearance/state.rs @@ -24,7 +24,7 @@ use unlucky_presenter::prefs::{ use crate::Backend; -use super::accent::{Accent, ENV_ACCENT}; +use super::accent::Accent; use super::alpha::{self, effective_glass, effective_panel_alpha, panel_is_forced_opaque}; use super::background::{self, Image, Note}; use super::palette::{self, Resolved, Source}; @@ -135,10 +135,9 @@ impl State { if let Some(value) = std::env::var_os(ENV_PALETTE) { self.palette_source = PaletteSource::from_key_value(&value.to_string_lossy()); } - if let Some(value) = std::env::var_os(ENV_ACCENT) { - // 取证开关 = 「像刚敲进去的」(走输入中规则),不是配置读回。 - self.accent.set(&value.to_string_lossy()); - } + // 强调色的两档开关(`ULL_APPEARANCE_ACCENT` / `…_COMMIT`)收在 `accent` 自己的模块里: + // 本文件贴 400 行硬线,而那两个开关与 `Accent` 的状态机是一件事(见 `Accent::apply_env`)。 + self.accent.apply_env(); self.refresh_background(); self.refresh_palette(); } diff --git a/crates/app-iced/src/pages/instances/saves/rows.rs b/crates/app-iced/src/pages/instances/saves/rows.rs index 1c33646..1fb041d 100644 --- a/crates/app-iced/src/pages/instances/saves/rows.rs +++ b/crates/app-iced/src/pages/instances/saves/rows.rs @@ -16,8 +16,9 @@ //! //! 输入框用 `components::inputs::text_field`(与启动参数区同一个控件)。**回车 = 点「改名」** //! (M252 / P18):`Field::on_submit` 与右边那枚按钮共用 [`commit_rename`] 这一个消息产地, -//! 所以空名 / 原名不变 / 非法字符走的仍是原来那条校验(`app/update/instances.rs` 的 -//! 空名与「没改」两条短路 + presenter 那边的目录名规则),**回车没有绕开任何一道边界**。 +//! 所以空名 / 原名不变 / 非法字符走的仍是原来那条校验(`app/update/instances.rs` 的空名与 +//! 「没改」两条短路 + `core::game::saves::write::boundary::check_name` 的路径规则), +//! **回车没有绕开任何一道边界**。 use iced::widget::{column, row}; use iced::{Alignment, Element, Length}; From 92a3c1bbc8f36b3ce6aeca5e95b91e825e8eaa47 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 11 Nov 2026 18:21:02 +0800 Subject: [PATCH 3/5] =?UTF-8?q?refactor(app-iced):=20=E3=80=8C=E7=A6=81?= =?UTF-8?q?=E7=94=A8=E4=B8=8D=E6=8F=90=E4=BA=A4=E3=80=8D=E7=9A=84=E8=A7=84?= =?UTF-8?q?=E5=88=99=E6=94=B6=E8=BF=9B=20Field::submit=5Fmessage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原来 text_field 里内联着 `if state.is_disabled() { None } else { field.submit_message() }`, 那条分支在 Element 上量不到(没有内省接口)⇒ 等于没验。现在把状态作为参数交给 Field::submit_message(state),规则本身可单测:submit_is_opt_in 多了 Disabled 那一档断言 (接了回调 + 禁用 ⇒ None),text_field 相应少 3 行。 --- .../app-iced/src/components/inputs/field.rs | 19 ++++++++++--------- .../src/components/inputs/field/tests.rs | 16 +++++++++++++--- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/crates/app-iced/src/components/inputs/field.rs b/crates/app-iced/src/components/inputs/field.rs index e4e9145..b8a4eec 100644 --- a/crates/app-iced/src/components/inputs/field.rs +++ b/crates/app-iced/src/components/inputs/field.rs @@ -198,9 +198,14 @@ impl<'a, M> Field<'a, M> { self } - /// 回车那条消息(`None` = 没接 [`Field::on_submit`])。**唯一的读取口**: - /// `text_field` 只从这里取,于是「接没接」与「发不发」是同一个事实。 - fn submit_message(&self) -> Option { + /// 回车那条消息(`None` = 没接 [`Field::on_submit`],或这个状态不能交互)。 + /// + /// **唯一的读取口**:`text_field` 只从这里取,于是「接没接」「点不点得动」与「发不发」 + /// 是同一个事实——禁用态里回车不提交(与 `on_input` 同一条规矩)。 + fn submit_message(&self, state: Interaction) -> Option { + if state.is_disabled() { + return None; + } self.on_submit.as_ref().map(|on_submit| on_submit()) } } @@ -220,12 +225,8 @@ pub fn text_field<'a, M: Clone + 'a>( let state = field.state.unwrap_or(Interaction::Enabled); let colors = palette(ui, field.base, field.variant, state, field.error); - // 提交消息只在**可交互**时接:禁用字段与 `on_input` 一样被摘掉。 - let submit = if state.is_disabled() { - None - } else { - field.submit_message() - }; + // 提交消息只在**可交互**时接(禁用规则住在 `Field::submit_message` 里,可单测)。 + let submit = field.submit_message(state); let input_style = ui.style(TypeRole::BodyLarge); let label_style = ui.style(TypeRole::BodySmall); diff --git a/crates/app-iced/src/components/inputs/field/tests.rs b/crates/app-iced/src/components/inputs/field/tests.rs index 1f5a78d..21b0c3f 100644 --- a/crates/app-iced/src/components/inputs/field/tests.rs +++ b/crates/app-iced/src/components/inputs/field/tests.rs @@ -188,13 +188,23 @@ fn submit_is_opt_in() { .secure(false) .base(Base::CANVAS); assert_eq!( - plain.submit_message(), + plain.submit_message(Interaction::Enabled), None, "没调 `.on_submit` ⇒ 回车不发消息(既有调用点的行为)" ); let wired: Field<'_, u8> = Field::new("实例一").on_submit(|| 7_u8); - assert_eq!(wired.submit_message(), Some(7), "接上的回调要被原样取出来"); + assert_eq!( + wired.submit_message(Interaction::Enabled), + Some(7), + "接上的回调要被原样取出来" + ); + // 禁用态里回车不提交(与 `on_input` 同一条规矩:点不动的框也不该能提交) + assert_eq!( + wired.submit_message(Interaction::Disabled), + None, + "禁用字段不许回车提交" + ); } /// 回归(T27 反例 ①):`text_field` 必须把 `Field` 上的提交回调**转发**给 `text_input`。 @@ -210,7 +220,7 @@ fn submit_is_opt_in() { fn text_field_forwards_the_submit_callback() { let body = crate::pages::layout::source::function_body(FIELD_SOURCE, "pub fn text_field"); assert!( - body.contains("submit_message()"), + body.contains("submit_message("), "提交消息必须从 `Field::submit_message()` 取(唯一读取口),实际函数体:\n{body}" ); assert!( From a17f6fb29fe5ace4849324540bdcef7180e512ca Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 11 Nov 2026 19:05:52 +0800 Subject: [PATCH 4/5] =?UTF-8?q?test(app-iced):=20=E6=8E=A5=E7=BA=BF?= =?UTF-8?q?=E5=AE=88=E5=8D=AB=E6=94=B9=E5=88=A4=E3=80=8C=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E8=A1=8C=E3=80=8D=EF=BC=8C=E6=B3=A8=E9=87=8A=E6=8E=89=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E7=AE=97=E6=8E=A5=E7=BA=BF=EF=BC=88M252=20r1=20P1-1?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审实测:三处接线守卫都是纯文本 `contains`,把真接线注释掉(行注释,或 `/*` 独占一行的 块注释)之后 943 条用例一条不红;`field.rs` 那处连 rustc 的 unused-variable 兜底也能用 `let _submit` 一起绕掉 —— 功能整条静默失效时,测试与 lint 两条线同时不响。 - `pages/layout/source/code.rs`(新):`code_lines()` 剥 `//` 与跨行/可嵌套 `/* … */`, 字符串与字符字面量整段保留(`"http://x"` 里的 `//` 不剥);`code_line()` / `has_code_line()` 给「某行代码的行首就是那个调用」这条谓词。边界写在模块文档:原始字符串里出现 `"` 是唯一 可能朝松方向错的形态(本仓 src 现无此写法),其余偏差都是假红。 - 三处守卫改用它:`text_field_forwards_the_submit_callback`(行首是 `on_submit_maybe(` 且实参不是 `None`、消息来自 `submit_message`)、`the_rename_field_wires_enter`(回车与 「改名」按钮的行都含 `commit_rename()`,且都不含第二种页面意图 —— 一并关掉 r1 P2-1 「Debug 自证撑不住判据」)、`the_accent_field_wires_enter_to_commit`(含产地那条代码行)。 - `submit_is_wired_only_at_the_two_call_sites` 的 `scan()` 换成同一谓词并跳过测试文件 (守卫自己的 fixture 里也有 `.on_submit(` 样本)。 - T29 顺手收敛 scope 内的同族旧断言:`pages/instances/tests.rs` 的行距/散文接线判据与 `pages/instances/tests/i18n.rs` 的五条取词判据改走 `code_text()`(一行改动、方向不变)。 --- .../src/components/inputs/field/tests.rs | 61 ++++-- crates/app-iced/src/pages/instances/tests.rs | 25 ++- .../src/pages/instances/tests/i18n.rs | 20 +- .../src/pages/instances/tests/rename.rs | 48 ++++- crates/app-iced/src/pages/layout/source.rs | 10 + .../app-iced/src/pages/layout/source/code.rs | 181 ++++++++++++++++++ .../src/pages/layout/source/code/tests.rs | 164 ++++++++++++++++ 7 files changed, 464 insertions(+), 45 deletions(-) create mode 100644 crates/app-iced/src/pages/layout/source/code.rs create mode 100644 crates/app-iced/src/pages/layout/source/code/tests.rs diff --git a/crates/app-iced/src/components/inputs/field/tests.rs b/crates/app-iced/src/components/inputs/field/tests.rs index 21b0c3f..9964492 100644 --- a/crates/app-iced/src/components/inputs/field/tests.rs +++ b/crates/app-iced/src/components/inputs/field/tests.rs @@ -210,22 +210,34 @@ fn submit_is_opt_in() { /// 回归(T27 反例 ①):`text_field` 必须把 `Field` 上的提交回调**转发**给 `text_input`。 /// /// iced 的 `Element` 没有内省接口,所以这条守的是**源码结构**(本仓既有同一条纪律,见 -/// `pages::layout::source` 的模块文档)。两条断言合起来才等于「转发」:消息只从 -/// `Field::submit_message()` 取(唯一读取口),且它被交给 `on_submit_maybe`。 +/// `pages::layout::source` 的模块文档)。三条断言合起来才等于「转发」:有一**行代码**在调 +/// `on_submit_maybe`、它的实参不是写死的 `None`、消息确实来自 `Field::submit_message`(唯一读取口)。 /// -/// 抓得住:删掉 `.on_submit_maybe(..)` 那一行 / 改成 `on_submit_maybe(None)` / 把 -/// `Field::on_submit` 存着不读(变异实测见 M252 报告)——本条立刻红。 -/// 抓不住:`text_input` 内部的键处理(那是 iced 的实现,不在这里)。 +/// **判据是「代码行」,不是「文本里还有那个字符串」**(M252 r1 的 P1-1):断言走 +/// `source::code_line`,注释先被剥掉。所以下面这些改法都会红: +/// - 删掉 `.on_submit_maybe(..)` 那一行; +/// - 把它**注释掉**(`//` 行注释,或 `/*` 独占一行的块注释); +/// - 改成 `.on_submit_maybe(None)` ——**即使同时**把 `let submit` 写成 `let _submit` 把 rustc 的 +/// unused-variable 兜底也绕掉(复审实测过:那样测试与 lint 两条线会同时静默)。 +/// +/// 抓不住:`text_input` 内部的键处理(iced 的实现);等价重构(把值先赋给别的名字再传)—— +/// 那要同时改 needle,见 `pages::layout::source::code` 的边界一节。 #[test] fn text_field_forwards_the_submit_callback() { let body = crate::pages::layout::source::function_body(FIELD_SOURCE, "pub fn text_field"); + let call = + crate::pages::layout::source::code_line(body, ".on_submit_maybe(").unwrap_or_else(|| { + panic!( + "`text_field` 里没有一行**代码**在调 `on_submit_maybe`(注释掉也算没有):\n{body}" + ) + }); assert!( - body.contains("submit_message("), - "提交消息必须从 `Field::submit_message()` 取(唯一读取口),实际函数体:\n{body}" + !call.contains("None"), + "提交消息被写死成 `None`(回车什么都不发,且功能静默失效):{call}" ); assert!( - body.contains(".on_submit_maybe("), - "取到之后必须交给 `text_input` 的 `on_submit_maybe`,实际函数体:\n{body}" + crate::pages::layout::source::code_text(body).contains("submit_message("), + "提交消息必须从 `Field::submit_message()` 取(唯一读取口;注释里写不算),实际函数体:\n{body}" ); } @@ -236,7 +248,8 @@ fn text_field_forwards_the_submit_callback() { /// 这条不靠「我看了一遍所有调用点」:白名单对不上就红;新增一处接线必须来这里登记, /// 这份摩擦是故意的。 /// -/// 抓得住:任何一处**新增/删除**接在构建链上的 `.on_submit(..)`(本仓的链一律一行一条)。 +/// 抓得住:任何一处**新增/删除/注释掉**接在构建链上的 `.on_submit(..)`(本仓的链一律一行一条; +/// 注释先被 [`scan`] 剥掉,所以 `/*` 独占一行的块注释也算删除)。 /// 抓不住:写在别处的同名文本(注释/字符串里的例子)、写成同一行的接线,也不管那两处接的 /// 消息对不对(由 `pages` 侧各自的用例钉)。 #[test] @@ -255,10 +268,13 @@ fn submit_is_wired_only_at_the_two_call_sites() { ); } -/// 递归找 `dir` 下**有一行以 `.on_submit(` 开头**的 `.rs`,记下相对 `root` 的路径(`/` 分隔)。 +/// 递归找 `dir` 下**有一行代码以 `.on_submit(` 开头**的 `.rs`,记下相对 `root` 的路径(`/` 分隔)。 /// -/// 为什么按「行首」而不是整篇 `contains`:注释与字符串里出现的 `.on_submit(` 例子(包括本文件 -/// 自己)会假红。代价是**写成同一行的接线扫不到**——本仓的构建链一律一行一条,这条取舍记在上面。 +/// 谓词与三处接线守卫同一个(`source::has_code_line`):注释与字符串里出现的 `.on_submit(` +/// 例子不算接线。代价是**写成同一行的接线扫不到**——本仓的构建链一律一行一条。 +/// +/// **测试文件跳过**([`is_test_path`]):守卫自己的 fixture 里有 `.on_submit(` 的样本(本文件、 +/// `pages/layout/source/code/tests.rs`),那不是调用点;测试也不会去接生产 UI。 fn scan(root: &std::path::Path, dir: &std::path::Path, found: &mut Vec) { let Ok(entries) = std::fs::read_dir(dir) else { return; @@ -267,17 +283,26 @@ fn scan(root: &std::path::Path, dir: &std::path::Path, found: &mut Vec) let path = entry.path(); if path.is_dir() { scan(root, &path, found); - } else if path.extension().is_some_and(|ext| ext == "rs") { + } else if path.extension().is_some_and(|ext| ext == "rs") && !is_test_path(&path) { let Ok(text) = std::fs::read_to_string(&path) else { continue; }; - if text - .lines() - .any(|line| line.trim_start().starts_with(".on_submit(")) - { + if crate::pages::layout::source::has_code_line(&text, ".on_submit(") { let rel = path.strip_prefix(root).unwrap_or(&path); found.push(rel.to_string_lossy().replace('\\', "/")); } } } } + +/// 这个路径是不是**测试模块**的文件:任一组件叫 `tests` / `tests.rs`,或文件名以 `_tests.rs` 收尾。 +/// +/// 本仓的测试文件就这几种形状(`*/tests.rs`、`*/tests/*.rs`、`*_tests.rs`),全部由 +/// `#[cfg(test)]` 或 `tests/` 目标挂载;生产文件的 inline `mod tests` 不在这里判(那些文件本身 +/// 也可能是真调用点,例如 `section/accent.rs`)。 +fn is_test_path(path: &std::path::Path) -> bool { + path.components().any(|component| { + let name = component.as_os_str().to_string_lossy(); + name == "tests" || name == "tests.rs" || name.ends_with("_tests.rs") + }) +} diff --git a/crates/app-iced/src/pages/instances/tests.rs b/crates/app-iced/src/pages/instances/tests.rs index 26519d2..7a09588 100644 --- a/crates/app-iced/src/pages/instances/tests.rs +++ b/crates/app-iced/src/pages/instances/tests.rs @@ -98,27 +98,32 @@ fn list_column_spaces_rows_only_from_the_shared_constant() { let rows_chain = source::statement(file, "let mut rows = column("); assert!( - body.contains("ROW_SPACING"), - "列表行距必须从 ROW_SPACING 取,不许另写:\n{body}" + source::code_text(&body).contains("ROW_SPACING"), + "列表行距必须从 ROW_SPACING 取(注释里写不算),不许另写:\n{body}" ); assert!( - body.contains("LIST_BOTTOM_INSET"), - "底部内缩必须从 LIST_BOTTOM_INSET 取:\n{body}" + source::code_text(&body).contains("LIST_BOTTOM_INSET"), + "底部内缩必须从 LIST_BOTTOM_INSET 取(注释里写不算):\n{body}" ); - // ① 行容器的构建链上不许叠间距(`column![head, scroller]` 上的合法间距不在这条链里) + // ① 行容器的构建链上不许叠间距(`column![head, scroller]` 上的合法间距不在这条链里)。 + // 判据走「代码行」(`source::code_text` 剥注释):注释掉的 `.spacing(..)` 等于没有间距。 assert!( - !rows_chain.contains(".spacing("), + !source::code_text(rows_chain).contains(".spacing("), "行容器的构建链上又出现了间距(M102 ② 的页级附加量):\n{rows_chain}" ); // ② 语句序列:每一处 `rows.spacing(…)` 的实参都必须**顺绑定追回 `ROW_SPACING`** // (直接用它、或 `let spacing = ROW_SPACING` 再中转都算;先 `let extra = Space::X1;` // 再 `rows.spacing(ui.space(extra))` 则红——r4 复审的变异 M4) + // + // 判据走**代码文本**(`source::code_text` 剥注释):注释掉的 `rows.spacing(..)` 不算「应用了间距」, + // 否则把那一行注释掉整条用例会绿着通过(T29 的同族退化)。 + let body = source::code_text(&body); let applied = source::call_arguments(&body, "rows.spacing("); assert!( !applied.is_empty(), - "`list_column()` 里没有把 ROW_SPACING 应用到行容器的调用:\n{body}" + "`list_column()` 里没有把 ROW_SPACING 应用到行容器的调用(注释掉也算没有):\n{body}" ); for argument in &applied { let ident = argument @@ -155,11 +160,11 @@ fn the_detail_note_goes_through_the_prose_box() { let body = source::function_body(include_str!("../instances/detail.rs"), "fn push_text"); assert!( - body.contains("frame::prose("), - "正文段没有走 frame::prose:\n{body}" + source::code_text(body).contains("frame::prose("), + "正文段没有走 frame::prose(注释里写不算):\n{body}" ); assert!( - !body.contains("frame::body("), + !source::code_text(body).contains("frame::body("), "正文段被塞进了整页块(会失去 72 字符行宽承诺):\n{body}" ); } diff --git a/crates/app-iced/src/pages/instances/tests/i18n.rs b/crates/app-iced/src/pages/instances/tests/i18n.rs index 2f289a4..106aec0 100644 --- a/crates/app-iced/src/pages/instances/tests/i18n.rs +++ b/crates/app-iced/src/pages/instances/tests/i18n.rs @@ -20,6 +20,8 @@ use unlucky_i18n::catalog::MISSING; use unlucky_i18n::{Catalog, Key, Lang}; +use crate::pages::layout::source; + use super::super::Texts; /// 中文界面里允许出现的 ASCII 词:**品牌名与技术标识**。 @@ -341,24 +343,24 @@ fn every_english_literal_in_the_page_sources_is_whitelisted() { .unwrap_or_else(|| panic!("PAGE_SOURCES 里没有 {name}:拆文件之后忘了登记?")) }; assert!( - source("instances.rs").contains("Texts::of("), - "实例页没有从 Texts 取词" + source::code_text(source("instances.rs")).contains("Texts::of("), + "实例页没有从 Texts 取词(注释里写不算)" ); assert!( - source("instances.rs").contains("Header::new(texts.title())"), + source::code_text(source("instances.rs")).contains("Header::new(texts.title())"), "页头标题必须是词表里的那条(`Header::new` 里写死字符串 = 中文界面上顶着英文标题)" ); assert!( - source("instances/text.rs").contains("Key::Instances"), - "text.rs 没有引用本族的 key" + source::code_text(source("instances/text.rs")).contains("Key::Instances"), + "text.rs 没有引用本族的 key(注释里写不算)" ); assert!( - source("app/view/instances.rs").contains("Key::InstancesStage"), - "阶段标签没有走目录" + source::code_text(source("app/view/instances.rs")).contains("Key::InstancesStage"), + "阶段标签没有走目录(注释里写不算)" ); assert!( - source("instances/launch_args.rs").contains("texts.launch_title()"), - "启动参数区没有走目录" + source::code_text(source("instances/launch_args.rs")).contains("texts.launch_title()"), + "启动参数区没有走目录(注释里写不算)" ); } diff --git a/crates/app-iced/src/pages/instances/tests/rename.rs b/crates/app-iced/src/pages/instances/tests/rename.rs index 35c9243..4cb32b4 100644 --- a/crates/app-iced/src/pages/instances/tests/rename.rs +++ b/crates/app-iced/src/pages/instances/tests/rename.rs @@ -20,6 +20,9 @@ use super::super::state::Message as PageMessage; /// /// `PageMessage` 只有 `Debug + Clone`(没有 `PartialEq`,那些枚举不在本 mission 的 scope), /// 所以这里比 `Debug` 文本:枚举的 `Debug` 打印变体名与字段,变体不同必然文本不同。 +/// +/// 这条只钉「产地生产什么」;**「两条路都用了这个产地」由 [`the_rename_field_wires_enter`] 钉** +/// (r1 复审 P2-1:上一版把两件事写进这里,而实际覆盖只在文本型断言里)。 #[test] fn enter_takes_the_same_message_as_the_rename_button() { assert_eq!( @@ -33,20 +36,49 @@ fn enter_takes_the_same_message_as_the_rename_button() { ); } -/// `rename_row` 里那枚输入框真的接了 `on_submit`,且接的是 `commit_rename`(与按钮同一个)。 +/// 回车与「改名」按钮**在源码里**都接 `commit_rename()`,且都没接第二种页面意图。 /// -/// 抓得住(T27 反例):把 `.on_submit(..)` 那一行删掉 → 第一条断言红;把它接成 -/// `CancelRenameWorld` → 第二条断言红。 -/// 抓不住:回车键真的到达控件(iced 的键路径,见模块文档)。 +/// 判据是**代码行**(`source::code_line` 先剥注释),所以下面这些改法都会红: +/// - 把 `.on_submit(..)` 那一行删掉,或注释掉(`//` / `/*` 独占一行的块注释); +/// - 把它接成别的页面意图(取消 / 重开 / 逐键输入)——按钮那一行同样不许; +/// - 让两条路走**不同**的产地(例如按钮仍写 `PageMessage::CommitRenameWorld` 字面量): +/// 那时按钮那行不含 `commit_rename()`,第二条断言红。 +/// +/// 抓不住:回车键真的到达控件(那是 iced 的 `Widget::update`,见模块文档);同一行里写完的 +/// 等价接线(本仓构建链一律一行一条)。 #[test] fn the_rename_field_wires_enter() { let body = source::function_body(include_str!("../saves/rows.rs"), "fn rename_row"); + let enter = source::code_line(body, ".on_submit(").unwrap_or_else(|| { + panic!("`rename_row` 里没有一行**代码**接 `on_submit`(注释掉也算没有):\n{body}") + }); assert!( - body.contains(".on_submit("), - "输入框没接回车(去掉接线这条必须红),实际函数体:\n{body}" + enter.contains("commit_rename()"), + "回车必须接 `commit_rename()`(与「改名」按钮同一个产地):{enter}" ); + + let button = source::code_lines(body) + .into_iter() + .find(|line| line.contains("saves_rename_confirm()")) + .unwrap_or_else(|| panic!("找不到「改名」按钮那一行:\n{body}")); assert!( - body.matches("commit_rename()").count() >= 2, - "回车与「改名」按钮必须共用 `commit_rename()`(两条路同一个 Message),实际函数体:\n{body}" + button.contains("commit_rename()"), + "「改名」按钮必须用同一个产地(RenameWorld 只有一处写法):{button}" ); + + // P2-1:两条路都只许这一个页面意图——接成别的(取消/重开/逐键输入)就是行为变了。 + for line in [&enter, &button] { + for other in [ + "CancelRenameWorld", + "StartRenameWorld", + "RenameWorldChanged", + "BackupWorld", + "RequestDeleteWorld", + ] { + assert!( + !line.contains(other), + "回车/按钮接成了别的页面意图({other}):{line}" + ); + } + } } diff --git a/crates/app-iced/src/pages/layout/source.rs b/crates/app-iced/src/pages/layout/source.rs index 28e74f7..a3ad75d 100644 --- a/crates/app-iced/src/pages/layout/source.rs +++ b/crates/app-iced/src/pages/layout/source.rs @@ -18,6 +18,16 @@ //! 所以它只配一条**明确的契约**用:`frame::body` / `frame::prose` / `list_column` 这几处的 //! 写法本身就是契约的一部分(它们各自只有 5~10 行),不是为了抠实现细节。 //! 真要守像素,还是得靠软件渲染金图(`shot::diff`,绑 Windows + 字体栈,属另一个决定)。 +//! +//! ## 「接线在不在」必须走 [`code_line`](T29,M252 r1 的 P1) +//! +//! 文本判据有个致命退化:把真接线**注释掉**、字符串留在注释里,`contains(..)` 照样绿。 +//! 所以断言「某处接了 `X`」时不许直接 `contains`,统一走 [`code_line`] / [`has_code_line`] +//! (注释剥掉、按行首判,边界见 [`code`] 的模块文档)。 + +mod code; + +pub(crate) use code::{code_line, code_lines, code_text, has_code_line}; /// 把 `source` 里第一个 `signature` 起始、到该函数**顶层**右花括号为止的文本切出来。 /// diff --git a/crates/app-iced/src/pages/layout/source/code.rs b/crates/app-iced/src/pages/layout/source/code.rs new file mode 100644 index 0000000..b55979c --- /dev/null +++ b/crates/app-iced/src/pages/layout/source/code.rs @@ -0,0 +1,181 @@ +//! **剥注释**的行扫描:接线守卫的判据只能看「代码行」,不能看「文本里还有没有那个字符串」。 +//! +//! ## 为什么要有这个文件(T29 的第三次复发) +//! +//! 接线守卫是拿源码文本当判据的(`Element` 没有内省接口,见 [`super`] 的模块文档)。用 +//! `text.contains(".on_submit(")` 写时,把真接线**注释掉**就整条骗过:字符串还在,功能没了。 +//! M252 复审实测了四种注释型变异(行注释 / `/*` 独占一行的块注释 / `.on_submit_maybe(None)` +//! 再加 `let _submit` 绕掉 rustc 的 unused 兜底),**943 条用例一条不红**;同一批「真删除」 +//! 变异则每条都红。⇒ 守卫不是没用,是**只认一种改法**。 +//! +//! 所以:接线类断言一律先过 [`code_lines`](注释剥掉、只留代码),再按**行首**判 [`code_line`]。 +//! +//! ## 剥什么(三条都要记) +//! +//! | 形态 | 处理 | +//! |---|---| +//! | `// …` / `/// …` / `//! …` | 剥到行尾 | +//! | `/* … */`,**跨行、可嵌套**(Rust 允许嵌套块注释) | 整段剥掉;行结构保留(只被注释占满的行变空串) | +//! | 字符串字面量里的 `//` / `/*`(`"http://x"`、`"a /* b"`) | **不剥**:字面量整段原样搬过去 | +//! | 字符字面量 `'"'` / `'\''` | 整段搬过去,不会误开字符串 | +//! +//! ## 剥不了什么(边界,改守卫前先读这一节) +//! +//! 1. **原始字符串**没有被特判,只是**偶然正确**:`r"…"` 与 `r#"…"#` 里的那个 `"` 会被当普通 +//! 字符串起点,于是内容(含 `//`)原样保留。真正会出错的是**原始字符串里出现 `"` 或 `\"`** +//! (`r#"a " b // c"#`):扫描器会提前收尾,后面那段按代码处理 ⇒ 若那段正好是注释就会**漏剥** +//! (方向变成「假绿」)。这是本模块唯一能朝松方向错的形态,登记在此。本仓 `src/**` 现在没有 +//! 这种写法(8 处 `r#"` 全是 JSON 字面量,无内嵌引号/反斜杠)。 +//! 2. **跨行普通字符串**(行尾 `\` 续行)**不识别**:字符串状态每行复位,续行的下一行会当代码。 +//! 后果是**假红**(把字符串里的一段当代码看见,反而更啰嗦)。 +//! 3. 它**不判语义**:注释剥掉之后,「`.on_submit(..)` 在不在」仍然只是文本事实。等价重构 +//! (先赋值再调用)可能绕过——所以接线守卫的 needle 与写法要一起维护(见各守卫的 docstring)。 +//! +//! 除第 1 条那个狭窄形态外,其余偏差都是**假红**:宁可假红,也不要「注释掉仍绿」。 + +/// 逐行的代码文本(注释已剥)。**行数与输入一一对应**:只被注释占满的行变成空串。 +/// +/// 保留行结构是为了让失败信息能直接对回源码行,也让「行首」这条谓词有意义。 +#[must_use] +pub(crate) fn code_lines(source: &str) -> Vec { + let mut lines = Vec::with_capacity(source.lines().count()); + // 块注释的嵌套深度(0 = 不在注释里)。跨行靠它带着走。 + let mut depth = 0_usize; + + for line in source.lines() { + let chars: Vec = line.chars().collect(); + let mut code = String::with_capacity(chars.len()); + let mut index = 0; + + while index < chars.len() { + let ch = chars[index]; + + if depth > 0 { + if ch == '/' && chars.get(index + 1) == Some(&'*') { + depth += 1; + index += 2; + } else if ch == '*' && chars.get(index + 1) == Some(&'/') { + depth -= 1; + index += 2; + } else { + index += 1; + } + continue; + } + + if ch == '/' && chars.get(index + 1) == Some(&'/') { + break; + } + if ch == '/' && chars.get(index + 1) == Some(&'*') { + depth = 1; + index += 2; + continue; + } + + if ch == '"' { + index = copy_string_literal(&chars, index, &mut code); + continue; + } + if ch == '\'' { + index = copy_char_literal(&chars, index, &mut code); + continue; + } + + code.push(ch); + index += 1; + } + + lines.push(code.trim_end().to_owned()); + } + + lines +} + +/// 把从 `start`(一个 `"`)起的字符串字面量原样搬进 `code`,返回下一个未处理的下标。 +/// +/// 「原样」= 里面的 `//`、`/*` 都不当注释(这条正是本模块存在的理由之一)。 +/// 行内没有闭合引号时搬到最后(跨行字符串见模块文档第 2 条边界)。 +fn copy_string_literal(chars: &[char], start: usize, code: &mut String) -> usize { + code.push(chars[start]); + let mut index = start + 1; + + while index < chars.len() { + let ch = chars[index]; + code.push(ch); + index += 1; + + match ch { + // 转义:把被转义的字符一起搬走(`\"` 不会提前收尾) + '\\' => { + if index < chars.len() { + code.push(chars[index]); + index += 1; + } + } + '"' => break, + _ => {} + } + } + + index +} + +/// 把从 `start`(一个 `'`)起的**字符字面量**原样搬过去;生命周期(`'a`)只搬那个引号。 +/// +/// 为什么值得单独判:`'"'` 会让「见到 `"` 就当字符串开头」的扫描器错位,于是整行的注释剥错。 +fn copy_char_literal(chars: &[char], start: usize, code: &mut String) -> usize { + code.push(chars[start]); + let index = start + 1; + + // `'\n'` / `'\''`:反斜杠 + 一个字符 + 收尾引号 + if chars.get(index) == Some(&'\\') && chars.get(index + 2) == Some(&'\'') { + for offset in index..index + 3 { + if let Some(ch) = chars.get(offset) { + code.push(*ch); + } + } + return index + 3; + } + + // `'x'`:一个字符 + 收尾引号 + if chars.get(index + 1) == Some(&'\'') { + if let Some(ch) = chars.get(index) { + code.push(*ch); + } + code.push('\''); + return index + 2; + } + + // 生命周期 / 未闭合:只搬引号,余下的照常扫 + index +} + +/// `source` 里第一条**去注释后行首是 `prefix`** 的代码行(`None` = 没有这样一行)。 +/// +/// 接线守卫统一用这个谓词:`code_line(rows_rs, ".on_submit(")` —— 那行被注释掉就查不到。 +#[must_use] +pub(crate) fn code_line(source: &str, prefix: &str) -> Option { + code_lines(source) + .into_iter() + .find(|line| line.trim_start().starts_with(prefix)) +} + +/// [`code_line`] 的布尔形态(全文件扫描那种只关心「有没有」的场合)。 +#[must_use] +pub(crate) fn has_code_line(source: &str, prefix: &str) -> bool { + code_line(source, prefix).is_some() +} + +/// 整份源码剥注释后的文本(行结构保留)。 +/// +/// **T29 的统一收敛口**:仓库里还有一批 `source.contains("…")` 形式的接线断言(见 M252 r1 +/// 报告的清单)。它们在问的是同一件事——「这段接线在不在」——只是写法更早。把 +/// `source.contains(..)` 换成 `code_text(&source).contains(..)` 是一行改动:判据方向不变, +/// 但「注释掉仍绿」这条退化被堵上。新写的断言优先用 [`code_line`](能钉「行首是哪个调用」)。 +#[must_use] +pub(crate) fn code_text(source: &str) -> String { + code_lines(source).join("\n") +} + +#[cfg(test)] +mod tests; diff --git a/crates/app-iced/src/pages/layout/source/code/tests.rs b/crates/app-iced/src/pages/layout/source/code/tests.rs new file mode 100644 index 0000000..23291a7 --- /dev/null +++ b/crates/app-iced/src/pages/layout/source/code/tests.rs @@ -0,0 +1,164 @@ +//! [`code_lines`] 自己的断言(门禁的门禁,T28:守卫守不到自己就等于没有)。 +//! +//! 这一族用例的形状统一:**喂一段源码文本 → 断言剥完剩下什么**。它们不读真文件,所以不会因为 +//! 别人改页面而假红;真文件上的效果由三处接线守卫自己证。 + +use super::{code_line, code_lines, has_code_line}; + +/// 行结构必须保留:只被注释占满的行是空串,代码行原样(去掉行尾空白)。 +#[test] +fn it_strips_line_comments_and_keeps_the_line_count() { + let source = "\ +let a = 1; // 尾注释 +// 整行注释 +/// 文档注释 +let b = 2; +/* 单行块注释 */ +let c = 3; +"; + let lines = code_lines(source); + assert_eq!(lines.len(), 6, "行数必须一一对应:{lines:?}"); + assert_eq!(lines[0], "let a = 1;"); + assert_eq!(lines[1], ""); + assert_eq!(lines[2], ""); + assert_eq!(lines[3], "let b = 2;"); + assert_eq!(lines[4], ""); + assert_eq!(lines[5], "let c = 3;"); +} + +/// 跨行 / 嵌套块注释:整段剥掉,注释结束处的代码要接着留下来。 +#[test] +fn it_strips_block_comments_across_lines_and_nested() { + let source = "\ +let a = 1; +/* 开头 /* 嵌套 */ 还在注释里 +.on_submit(comment_only()) <- 这一行整个在注释里 +*/ let b = 2; +let c = /* 行内 */ 3; +"; + let lines = code_lines(source); + assert_eq!(lines[0], "let a = 1;"); + assert_eq!(lines[1], ""); + assert_eq!(lines[2], "", "块注释里的「接线」不许当成代码"); + assert_eq!(lines[3], " let b = 2;", "注释结束后同一行的代码要留下"); + assert_eq!(lines[4], "let c = 3;"); +} + +/// 字符串字面量里的 `//` 与 `/*` **不剥**(否则会把后半行代码剥掉 ⇒ 假红方向)。 +#[test] +fn it_keeps_slashes_inside_string_literals() { + let source = r##" +let url = "http://example.com/a//b"; let after = 1; +let odd = "a /* not a comment */ b"; let tail = 2; +let raw = r#"a // b"#; let tail2 = 3; +"##; + let lines = code_lines(source); + assert!( + lines[1].contains("http://example.com/a//b"), + "{:?}", + lines[1] + ); + assert!( + lines[1].contains("let after = 1;"), + "字符串里的 `//` 不该吃掉后半行:{:?}", + lines[1] + ); + assert!(lines[2].contains("let tail = 2;"), "{:?}", lines[2]); + // 原始字符串**没有特判**:靠其中那个 `"` 偶然按普通字符串处理(模块文档边界第 1 条)。 + assert!(lines[3].contains("a // b"), "{:?}", lines[3]); + assert!(lines[3].contains("let tail2 = 3;"), "{:?}", lines[3]); +} + +/// 已知缺口(模块文档边界第 1 条):原始字符串里出现 `"` 时扫描器提前收尾,后面那段按代码看。 +/// +/// 这条用例把那个**朝松方向**的形态钉住:不是「希望这样」,是「哪天修了要知道」。 +#[test] +fn raw_strings_with_inner_quotes_are_a_known_gap() { + let source = "let s = r#\"a \" b // c\"#; let after = 1;\n"; + let lines = code_lines(source); + assert!( + !lines[0].contains("let after = 1;"), + "已知缺口:内嵌引号的原始字符串会让后半行被当注释剥掉(这里只钉现状):{:?}", + lines[0] + ); +} + +/// 字符字面量里的引号不会让扫描器错位(`'"'` 之后还有注释与代码)。 +#[test] +fn char_literals_do_not_open_a_string() { + let source = "\ +let quote = '\"'; let after = 1; // 尾注释 +let nl = '\\n'; let more = 2; +let lifetime: &'a str = \"x\"; +"; + let lines = code_lines(source); + assert!(lines[0].contains("let after = 1;"), "{:?}", lines[0]); + assert!(!lines[0].contains("尾注释"), "{:?}", lines[0]); + assert!(lines[1].contains("let more = 2;"), "{:?}", lines[1]); + assert!(lines[2].contains("&'a str = \"x\""), "{:?}", lines[2]); +} + +/// 行首谓词:注释里的「接线」查不到,代码里的查得到(本模块的全部意义)。 +#[test] +fn the_line_predicate_only_sees_code() { + let commented = "\ +let field = text_field( + ui, + Field::new(text) + // .on_submit(|| Message::Instances(commit_rename())), + , + on_input, +); +"; + assert!( + !has_code_line(commented, ".on_submit("), + "注释掉的那一行不许被当成交接" + ); + + let blocked = "\ +let field = text_field( + ui, + Field::new(text) + /* + .on_submit(|| Message::Instances(commit_rename())), + */ + , + on_input, +); +"; + assert!( + !has_code_line(blocked, ".on_submit("), + "块注释掉的那一行不许被当成交接" + ); + + let wired = "\ +let field = text_field( + ui, + Field::new(text) + .on_submit(|| Message::Instances(commit_rename())), + on_input, +); +"; + let line = code_line(wired, ".on_submit(").expect("接在代码里就该查得到"); + assert!(line.contains("commit_rename()"), "{line}"); +} + +/// needle 带实参时才分得清「转发」与「写死 None」——这正是 M252 复审的第二格。 +#[test] +fn the_predicate_tells_a_wired_argument_from_a_hardcoded_none() { + let wired = " .on_submit_maybe(submit)\n"; + let none = " .on_submit_maybe(None)\n"; + assert!(has_code_line(wired, ".on_submit_maybe(")); + assert!( + has_code_line(none, ".on_submit_maybe("), + "两条都「有这一行」" + ); + + let picked = |source: &str| { + code_line(source, ".on_submit_maybe(") + .expect("有那一行") + .contains("None") + }; + assert!(!picked(wired), "传的是值,不是 None"); + assert!(picked(none), "写死 None 必须被认出来"); +} From 9ce54a8119debd45616bd20e0469c67b484f0823 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 11 Nov 2026 19:37:28 +0800 Subject: [PATCH 5/5] =?UTF-8?q?test(app-iced):=20accent=20=E5=9B=9E?= =?UTF-8?q?=E8=BD=A6=E5=AE=88=E5=8D=AB=E6=94=B9=E5=88=A4=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E8=A1=8C=E5=B9=B6=E6=95=B4=E5=8F=96=E5=AE=9E=E5=8F=82=EF=BC=88?= =?UTF-8?q?M252=20r2=20P2-1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一轮(e2a9692)声称三处守卫都改了,实际只有 field.rs 与 rows.rs 进了提交: `section/accent.rs` 的改写留在工作树里,被我自己重放 C4 变异时的 `git checkout --` 还原掉了 —— 提交面因此看不到它,那条守卫仍是 `body.contains(..)`,注释掉接线后它照样绿 (只有白名单顺手红)。先修过程、再修判据: - `the_accent_field_wires_enter_to_commit` 改成 `code_line(body, ".on_submit(")` 取**代码行**, 再用 `call_arguments` **整取**实参并断言等于 `["accent_commit"]` —— 顺带堵上 「`accent_commit_none` 这类同前缀产地静默通过」。 - `code_lines()` 的边界文档换成实测口径(`app-iced/src` 9 处 `r#"`、8 处内容含引号; 全仓 `crates/**.rs` 291 处),并写清两个方向:内嵌引号 ⇒ 尾部注释漏剥 ⇒ **假绿**; JSON 原始字符串同行有 `//` ⇒ 多剥 ⇒ **假红**。两种都由 `code/tests.rs` 钉住。 过程纪律(写进报告):**变异重放只在已提交的树上做**,否则 `git checkout --` 会把未提交的 修复一起扔掉,而「声称改了、diff 没动」正是这次被复审抓到的形状。 --- .../src/pages/appearance/section/accent.rs | 34 +++++++++++++------ .../app-iced/src/pages/layout/source/code.rs | 24 +++++++++---- .../src/pages/layout/source/code/tests.rs | 27 +++++++++++---- 3 files changed, 62 insertions(+), 23 deletions(-) diff --git a/crates/app-iced/src/pages/appearance/section/accent.rs b/crates/app-iced/src/pages/appearance/section/accent.rs index 8d5363e..da81fff 100644 --- a/crates/app-iced/src/pages/appearance/section/accent.rs +++ b/crates/app-iced/src/pages/appearance/section/accent.rs @@ -232,26 +232,40 @@ mod tests { /// 本文件源码(源码结构断言用,与 `pages/layout/source.rs` 同一条纪律)。 const SOURCE: &str = include_str!("accent.rs"); - /// **M252 / P18 回车接线的两条断言**:输入框接了 `on_submit`,且接的是「提交」那条消息。 + /// **M252 / P18 回车接线的判据**:`push` 里有一**行代码**接了 `on_submit`,实参整取等于 + /// `accent_commit`,且那个产地发的确实是 `AccentCommit`(提交)而不是 `Accent(原文)`(敲字)。 /// /// iced 的 `Element` 没有内省接口(见 `pages::layout::source` 的模块文档),所以「回车真的 - /// 按下去」在这里量不到——能钉住的是**它接的是哪条消息**: + /// 按下去」在这里量不到——能钉住的是**它接的是哪条消息**。 /// - /// - 抓得住:`.on_submit(..)` 被删掉(T27 反例);接成 `Change::Accent(原文)`(那是**逐键** - /// 路径,`retry(false)` 会把已经亮起来的错吞回去,等于回车把错误擦掉);接成别的控件消息。 + /// - 抓得住:`.on_submit(..)` 被删掉 **或注释掉**(`source::code_line` 先剥注释,判的是**代码行**); + /// 实参被换成同前缀的别的产地(`accent_commit_none`——`call_arguments` 是**整取**,不是子串); + /// 接成 `Change::Accent(原文)`(那是**逐键**路径,`retry(false)` 会把已经亮起来的错吞回去)。 /// - 抓不住:按键真的到达控件(iced 的 `Widget::update`)、以及红框/错行画没画(出图负责)。 #[test] fn the_accent_field_wires_enter_to_commit() { let body = crate::pages::layout::source::function_body(SOURCE, "fn push"); - assert!( - body.contains(".on_submit("), - "强调色输入框没接回车(去掉接线这条必须红),实际函数体:\n{body}" + let line = + crate::pages::layout::source::code_line(body, ".on_submit(").unwrap_or_else(|| { + panic!("`push` 里没有一行**代码**接 `on_submit`(注释掉也算没有):\n{body}") + }); + + // 实参**整取**(不是子串):`accent_commit_none` 这种同前缀产地不许静默通过。 + assert_eq!( + crate::pages::layout::source::call_arguments(&line, ".on_submit("), + vec!["accent_commit"], + "回车必须接 `accent_commit`(唯一产地),实际接线:{line}" ); + + // 产地本身发什么:先看**代码行**(注释里的字不算),再跑一次拿 `Debug` 文本核形态 + // (`Message` 没有 `PartialEq`)。 + let factory = crate::pages::layout::source::function_body(SOURCE, "fn accent_commit"); assert!( - body.contains("accent_commit"), - "回车必须接 `accent_commit`(唯一产地),实际函数体:\n{body}" + crate::pages::layout::source::code_lines(factory) + .iter() + .any(|line| line.contains("Change::AccentCommit")), + "产地必须发 `Change::AccentCommit`(提交),实际函数体:\n{factory}" ); - // 那条产地发的到底是什么:`Message` 没有 `PartialEq`,比 `Debug` 文本(变体名 + 字段)。 assert_eq!( format!("{:?}", super::accent_commit()), format!( diff --git a/crates/app-iced/src/pages/layout/source/code.rs b/crates/app-iced/src/pages/layout/source/code.rs index b55979c..e3f1c6a 100644 --- a/crates/app-iced/src/pages/layout/source/code.rs +++ b/crates/app-iced/src/pages/layout/source/code.rs @@ -21,17 +21,29 @@ //! //! ## 剥不了什么(边界,改守卫前先读这一节) //! -//! 1. **原始字符串**没有被特判,只是**偶然正确**:`r"…"` 与 `r#"…"#` 里的那个 `"` 会被当普通 -//! 字符串起点,于是内容(含 `//`)原样保留。真正会出错的是**原始字符串里出现 `"` 或 `\"`** -//! (`r#"a " b // c"#`):扫描器会提前收尾,后面那段按代码处理 ⇒ 若那段正好是注释就会**漏剥** -//! (方向变成「假绿」)。这是本模块唯一能朝松方向错的形态,登记在此。本仓 `src/**` 现在没有 -//! 这种写法(8 处 `r#"` 全是 JSON 字面量,无内嵌引号/反斜杠)。 +//! 1. **原始字符串没有被特判**:`r"…"` / `r#"…"#` 里那个 `"` 被当成普通字符串起点,于是 +//! 内容(含 `//`)原样保留——对**不含**引号的内容(`r"a // b"`)恰好是对的。含引号时会错位, +//! 而**含引号是常态不是例外**(JSON 字面量必然含 `"`)。实测(`git ls-files` + 逐字面量扫 +//! `r(#+)"`):`crates/app-iced/src` 下 **9 处** `r#"`、其中 **8 处内容含 `"`**; +//! 全仓 `crates/**.rs` **291 处**。 +//! +//! 两个方向都要知道(都由 `code/tests.rs` 的用例钉住): +//! +//! | 写法 | 偏差 | 方向 | +//! |---|---|---| +//! | `r#"a " b"#; // .on_submit(fake())` | 提前收尾后又开了一个「字符串」⇒ **尾部注释不被剥** | **假绿**(`code_text(..).contains(..)` 形式的断言会被骗) | +//! | `r#"{"url":"http://x"}"#; // …` | 截断到 `http:` 之后 ⇒ **多剥** | **假红** | +//! +//! **对本仓的三处接线守卫无影响**:它们判「行首就是那个调用」([`code_line`]),而行首是 +//! `r#"…` 的行永远匹配不上;受影响的是 `code_text(..).contains(..)` 形式的旧断言 +//! (T29 清单里还有约 90 处)。三处被守卫的文件(`field.rs` / `saves/rows.rs` / +//! `section/accent.rs`)`r#"` 计数都是 0。 //! 2. **跨行普通字符串**(行尾 `\` 续行)**不识别**:字符串状态每行复位,续行的下一行会当代码。 //! 后果是**假红**(把字符串里的一段当代码看见,反而更啰嗦)。 //! 3. 它**不判语义**:注释剥掉之后,「`.on_submit(..)` 在不在」仍然只是文本事实。等价重构 //! (先赋值再调用)可能绕过——所以接线守卫的 needle 与写法要一起维护(见各守卫的 docstring)。 //! -//! 除第 1 条那个狭窄形态外,其余偏差都是**假红**:宁可假红,也不要「注释掉仍绿」。 +//! 除第 1 条里「内嵌引号」那个形态外,其余偏差都是**假红**:宁可假红,也不要「注释掉仍绿」。 /// 逐行的代码文本(注释已剥)。**行数与输入一一对应**:只被注释占满的行变成空串。 /// diff --git a/crates/app-iced/src/pages/layout/source/code/tests.rs b/crates/app-iced/src/pages/layout/source/code/tests.rs index 23291a7..aebafb4 100644 --- a/crates/app-iced/src/pages/layout/source/code/tests.rs +++ b/crates/app-iced/src/pages/layout/source/code/tests.rs @@ -69,17 +69,30 @@ let raw = r#"a // b"#; let tail2 = 3; assert!(lines[3].contains("let tail2 = 3;"), "{:?}", lines[3]); } -/// 已知缺口(模块文档边界第 1 条):原始字符串里出现 `"` 时扫描器提前收尾,后面那段按代码看。 +/// 已知缺口(模块文档边界第 1 条):原始字符串含内嵌引号时扫描器会错位,**两个方向都钉住**。 /// -/// 这条用例把那个**朝松方向**的形态钉住:不是「希望这样」,是「哪天修了要知道」。 +/// 不是「希望这样」,是「哪天修了要知道」;也是 `code_text(..).contains(..)` 形式旧断言的已知上限。 #[test] fn raw_strings_with_inner_quotes_are_a_known_gap() { - let source = "let s = r#\"a \" b // c\"#; let after = 1;\n"; - let lines = code_lines(source); + // ① 多剥 ⇒ 假红:`r#"{"url":"http://x"}"#` 那一行会被截断(字符串里的 `//` 当成注释)。 + let json = "let s = r#\"{\"url\":\"http://x\"}\"#; let after = 1;\n"; assert!( - !lines[0].contains("let after = 1;"), - "已知缺口:内嵌引号的原始字符串会让后半行被当注释剥掉(这里只钉现状):{:?}", - lines[0] + !code_lines(json)[0].contains("let after = 1;"), + "已知缺口(假红方向):JSON 原始字符串那行的后半行被剥掉了", + ); + + // ② 漏剥 ⇒ 假绿:内嵌引号之后扫描器又开了一个「字符串」,**尾部注释不被剥**, + // 于是注释里的 `.on_submit(fake())` 会留在「代码」里 —— `code_text(..).contains(..)` + // 形式的断言会被它骗过(三处接线守卫按行首判,不受影响)。 + let sneaky = "let s = r#\"a \" b\"#; // .on_submit(fake())\n"; + let line = &code_lines(sneaky)[0]; + assert!( + line.contains(".on_submit(fake())"), + "已知缺口(假绿方向):内嵌引号的原始字符串会让尾部注释留下,实际:{line:?}" + ); + assert!( + !has_code_line(sneaky, ".on_submit("), + "行首判不受这条影响:那一行的行首是 `let s = r#\"`,永远匹配不上" ); }