fix(gate): M299 r1 —— tabs 判据改测闭包本身 + 反例判据抗重排

P2-1:tabs 的样式闭包抽成 tab_style(..)(返回闭包本身,照 chip_style / choice_style
的写法),单测喂的就是交给 button 的那个闭包。上一版只测闭包里的助手 tab_fill(..),
复审把 fill 挪到闭包外、闭包写回 |_theme, _status| 时用例仍全绿——本 mission 在 tabs 上
唯一的代码修复当时没有判据。现在同一变异红(hover 必须叠状态层)。

P2-2:反例判据改成「去空白 + 去花括号」后比对 .or_else(||Some(ui.color( 与
.unwrap_or_else(||ui.color(,换行 / 缩进 / 闭包加括号都躲不过;扫描跳过 .../tests/ 与
tests.rs;新增 KNOWN_COPIES 豁免表(slider handle,scope 外,已报 finding)与
every_known_copy_is_still_there(豁免不许挂空条目)。自证:名单外第 12 个站点用
换行+花括号写法 ⇒ 红(两种形态各一次),删掉后三条全绿。

行数:tab_style 拆到 components/navigation/tabs/style.rs(tabs.rs 398 → 370)。
像素:new 与 new2 两套 24 张逐张 md5 相同 ⇒ 行为中性。
This commit is contained in:
AskaEth committed 2026-11-13 15:24:44 +08:00
1 parent 8144714f30
commit fa36ccbb66
4 files changed
+214 -56

No files matched your search

@@ -40,11 +40,10 @@
use iced::widget::text::{LineHeight, Wrapping};
use iced::widget::{button, container, scrollable, space, text, Row, Stack};
use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Pixels, Shadow};
use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Pixels};
use unlucky_design::color::Role;
use unlucky_design::icons::IconKind;
use unlucky_design::shape::ShapeScale;
use unlucky_design::typography::TypeRole;
use crate::icons;
@@ -54,6 +53,9 @@ use super::super::interaction::{self, Base, Interaction};
use super::super::layout;
use super::geometry::{TabsGeometry, TABS_ICON_HEIGHT};
mod style;
use style::tab_style;
/// 两种 Tabs(`research/12b` §6;M3 的 `PrimaryTabRow` / `SecondaryTabRow`)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum TabsVariant {
@@ -215,11 +217,12 @@ fn secondary_line_color(ui: &Ui) -> Color {
}
/// 一行标签页(自带横向滚动)。
///
/// ## 状态层画在哪儿(M299)
///
/// tab 容器**没有**选中实底(选中的标记是下方那条指示条),所以选中与未选中都一样:状态层叠在
/// `base` 上(`Tabs::base(..)`,默认画布)。M299 之前这一层是在样式闭包**外**算的,真实
/// hover / pressed 一个像素都不变;现在由 [`tab_fill`] 在闭包里跟 `button::Status` 求值。
/// hover / pressed 一个像素都不变;现在由 [`style::tab_style`] 在闭包里跟 `button::Status` 求值。
pub fn tabs<'a, M: Clone + 'a>(ui: &'a Ui, tabs: Tabs<'a, M>) -> Element<'a, M> {
let geometry = TabsGeometry::of(ui);
let primary = tabs.variant.is_primary();
@@ -270,25 +273,6 @@ pub fn tabs<'a, M: Clone + 'a>(ui: &'a Ui, tabs: Tabs<'a, M>) -> Element<'a, M>
.into()
}
/// tab 容器的状态层颜色(**M299**:`status` 必须在样式闭包**里面**用掉)。
///
/// 修的是:此前 `tab_cell` 在闭包**外**把 `state` 算成 `forced.unwrap_or(Enabled)`、闭包又写成
/// `|_theme, _status|` ⇒ 真实指针的 hover / pressed **一个像素都不变**(只有 gallery 的
/// `Tab::state(..)` 强制态画得出来),全仓的 tab 一起中招。
///
/// 解析顺序:`forced`(取证 / 矩阵的 `.state(..)`)优先,否则 [`Interaction::from_status`]——
/// 与 `components/selection/chip.rs` 同一条。颜色经 [`interaction::layer_color`](唯一入口,A8 起
/// 带过渡时间轴):没有取样控件时**逐位**等于 `overlay_color`,取证图因此一字不变。
fn tab_fill(
ui: &Ui,
base: Base,
forced: Option<Interaction>,
status: button::Status,
) -> Option<Color> {
let state = forced.unwrap_or_else(|| Interaction::from_status(status));
interaction::layer_color(ui, state, Role::OnSurface, base.role())
}
/// 一个 tab 单元(内容 + 底部条)。
#[allow(clippy::too_many_arguments)]
fn tab_cell<'a, M: Clone + 'a>(
@@ -374,19 +358,7 @@ fn tab_cell<'a, M: Clone + 'a>(
let button = button(stack)
.height(Length::Fixed(height))
.padding(0)
.style(move |_theme, status| button::Style {
// M299:状态层在**闭包里**跟 `status` 求值(此前 `state` 在闭包外算、`status` 被丢掉,
// 真实 hover / pressed 因此一个像素都不变)。
background: tab_fill(ui, base, forced, status).map(Background::Color),
text_color: color,
// ContainerShape = CornerNone(tab 本身是直角;圆角只在指示条上)
border: Border {
radius: ui.radius_dp(ShapeScale::None),
..Border::default()
},
shadow: Shadow::default(),
snap: true,
});
.style(tab_style(ui, base, forced, color));
match tab.on_press {
Some(message) => button.on_press(message).into(),
@@ -0,0 +1,59 @@
//! tab 按钮的样式闭包(M299 r1:从 `tabs.rs` 拆出来)。
//!
//! ## 为什么拆文件
//!
//! 只有行数:`tabs.rs` 加了这个函数之后到 413 行(AGENTS.md §3-1 的单文件 400 行硬线)。拆法是
//! 「整段搬走 + 一条 `pub(super) use`」,行为零变化。
//!
//! ## 为什么返回**闭包**而不是普通函数
//!
//! 单测要拿到的必须是「交给 `button` 的那个闭包」,不是它里面的助手:M299 r1 复审把一个
//! `tab_fill(..)` 形状的助手留在闭包外先算、闭包写回 `|_theme, _status|`,那条只测助手的用例
//! **仍然全绿**——本 mission 在 tabs 上唯一的代码修复当时等于没有判据。照
//! `pages/settings/general/theme.rs` 的 `chip_style` / `appearance/section` 的 `choice_style`
//! 同一条写法,这里把闭包整个交出去,测试直接喂 `button::Status`。
//!
//! ## 修的是什么
//!
//! 此前 `tab_cell` 在闭包**外**把 `state` 算成 `forced.unwrap_or(Enabled)`、闭包又写成
//! `|_theme, _status|` ⇒ 真实指针的 hover / pressed **一个像素都不变**(只有 gallery 的
//! `Tab::state(..)` 强制态画得出来),全仓的 tab 一起中招。
//!
//! ## 解析顺序
//!
//! `forced`(取证 / 矩阵的 `.state(..)`)优先,否则 [`Interaction::from_status`]——与
//! `components/selection/chip.rs` 同一条。颜色经 [`interaction::layer_color`](唯一入口,A8 起
//! 带过渡时间轴):没有取样控件时**逐位**等于 `overlay_color`,取证图因此一字不变。
use iced::widget::button;
use iced::{Background, Border, Color, Shadow, Theme};
use unlucky_design::color::Role;
use unlucky_design::shape::ShapeScale;
use crate::app::components::interaction::{self, Base, Interaction};
use crate::theme::Ui;
/// tab 按钮的样式闭包(**M299**:状态层在闭包里跟 `status` 求值)。
pub(super) fn tab_style<'a>(
ui: &'a Ui,
base: Base,
forced: Option<Interaction>,
color: Color,
) -> impl Fn(&Theme, button::Status) -> button::Style + 'a {
move |_theme, status| {
let state = forced.unwrap_or_else(|| Interaction::from_status(status));
button::Style {
background: interaction::layer_color(ui, state, Role::OnSurface, base.role())
.map(Background::Color),
text_color: color,
// ContainerShape = CornerNone(tab 本身是直角;圆角只在指示条上)
border: Border {
radius: ui.radius_dp(ShapeScale::None),
..Border::default()
},
shadow: Shadow::default(),
snap: true,
}
}
}
@@ -134,18 +134,33 @@ fn icon_tabs_use_the_taller_container() {
assert!(TABS_ICON_HEIGHT > TabsGeometry::of(&ui).height);
}
/// **样式闭包必须吃 `status`**(M299):真实 hover / pressed 之前一个像素都不变。
/// **交给 `button` 的那个样式闭包必须吃 `status`**(M299)。
///
/// 抓得住:①回到「`state` 在闭包外算 + `|_theme, _status|`」⇒ hover / pressed 都等于静息(前三条
/// 红);②hover 与 pressed 用同一个 α ⇒ 第四条红;③`forced` 不再优先(取证图会跟着指针漂)⇒
/// 后两条红。
/// 这条用例调的是 [`tab_style`] 返回的**闭包本身**,不是它里面的助手——M299 r1 复审实测过:
/// 只测助手的那一版,把「闭包外先算 fill、闭包写回 `|_theme, _status|`」放回来仍然全绿,
/// 于是本次在 tabs 上唯一的代码修复等于没有判据(这就是 P2-1)。
///
/// 抓得住:①闭包里把 status 丢掉(`forced.unwrap_or(Enabled)` / 用 `Status::Active` 先算好)⇒
/// hover / pressed 都等于静息,前三条红;②hover 与 pressed 用同一个 α ⇒ 第四条红;③`forced`
/// 不再优先(取证图会跟着指针漂)⇒ 后两条红。
/// 抓不住:像素。gallery 的 tabs 五态行拍的是 **`forced` 支**(矩阵每格 `.state(..)`),
/// 它与本函数同源但不是 `status` 支;`status` 支的真像素要真实指针(取证通道开了指针穿透,
/// 本机无桌面会话 ⇒ 拍不到)。报告里把这条写成「图证明的是哪一层」。
#[test]
fn the_style_closure_consumes_the_live_status() {
let ui = ui();
let fill = |forced, status| tab_fill(&ui, Base::CANVAS, forced, status);
let theme = ui.theme();
// 这里拿到的是**闭包**(不是颜色):`tab_cell` 交给 `button` 的就是它。
let fill =
|forced, status| match tab_style(&ui, Base::CANVAS, forced, ui.color(Role::OnSurface))(
&theme, status,
)
.background
{
Some(Background::Color(color)) => Some(color),
Some(other) => panic!("背景必须是实色,得到 {other:?}"),
None => None,
};
let rest = fill(None, button::Status::Active);
let hover = fill(None, button::Status::Hovered);
@@ -180,6 +195,20 @@ fn the_style_closure_consumes_the_live_status() {
pressed,
"forced=pressed 时必须画出 pressed"
);
// 文字色是**静息内容色**(A1 的内容提档没做,见 findings),与 status 无关
let ink = ui.color(Role::OnSurface);
for status in [
button::Status::Active,
button::Status::Hovered,
button::Status::Pressed,
] {
assert_eq!(
tab_style(&ui, Base::CANVAS, None, ink)(&theme, status).text_color,
ink,
"text_color 必须原样透传({status:?})"
);
}
}
/// 3 个 tab 的树能建出来(含选中 / 禁用 / 带图标三条路径)。
@@ -13,23 +13,28 @@
//! M296 的变异实验就是这么发现外壳那两处的(改助手 ⇒ 外壳药丸不动)。所以这里量的是**接线**,
//! 不是值。
//!
//! ## 两条判据
//! ## 三条判据
//!
//! 1. **每个站点都必须出现 `selected_fill(`**(正例):删掉调用 = 退回私有拷贝,红;
//! 2. **除 `interaction/selected.rs` 之外,产品代码里不许出现 `or_else(|| Some(ui.color(`**
//! (反例):那正是旧私有拷贝的形态。整份源码里现在一处都没有,加回来就红。
//! 2. **产品代码里不许出现旧私有拷贝的两种形态**(反例):`.or_else(|| Some(ui.color(..))` 与
//! `.unwrap_or_else(|| ui.color(..))`。比对前把空白全部删掉,所以换行 / 缩进 / 加括号都躲不过
//! (M299 r1 P2-2:上一版是整串字面量,只换排版就能绕过);
//! 3. **[`KNOWN_COPIES`] 里的豁免必须还活着**(豁免不许挂空条目)。
//!
//! ## 抓得住 / 抓不住(据实写)
//!
//! 抓得住:①某个站点被改成 `Some(ui.color(Role::SecondaryContainer))`(用户点名的那条缺陷)——
//! 反例判据与正例判据至少一条红;②新的第十一处网点直接抄私有拷贝——反例判据红;
//! ③`selected_fill` 被改名 / 移走——全部正例红。
//! 抓不住:①**语义上正确但写法不同**的私有拷贝(比如 `match layer_color(..) { Some(c) => c,
//! None => ui.color(..) }`)——它不命中 `or_else` 形态,只能靠人读 diff;
//! 反例判据与正例判据至少一条红;②名单外的第十二个站点直接抄私有拷贝(含换行 / 加括号的写法)
//! ——反例判据红(M299 r1 实测过);③`selected_fill` 被改名 / 移走——全部正例红;
//! ④有人把某处收口了却忘了删豁免——判据 3 红。
//! 抓不住:①**语义上正确但形态不同**的私有拷贝(比如 `match layer_color(..) { Some(c) => c,
//! None => ui.color(..) }`,或先 `let Some(c) = .. else { return ui.color(..) }`)——它不是
//! `or_else` / `unwrap_or_else` 形态,只能靠人读 diff;
//! ②像素(那是 `selection/probe.rs` 与 `DevDocs/artifacts/m299/` 的图);
//! ③站点内部把 `selected_fill` 的返回值丢了不用(正例只证明「调了」)。
//! ③站点内部把 `selected_fill` 的返回值丢了不用(正例只证明「调了」);
//! ④字符串字面量里恰好含 `//` 的行会被截断(判定只会更松,不会误报)。
//!
//! 这三条盲区写在这里,是因为「门禁绿」最容易被读成「这事有人管」。
//! 这几条盲区写在这里,是因为「门禁绿」最容易被读成「这事有人管」。
use std::path::{Path, PathBuf};
@@ -63,10 +68,10 @@ fn product_part(text: &str) -> String {
let mut lines = text.lines();
while let Some(line) = lines.next() {
if !line.trim_start().starts_with("#[cfg(test)]") {
// 注释行不算代码:文档里解释「旧写法长什么样」不该被判成旧写法。
if !line.trim_start().starts_with("//") {
out.push_str(line);
}
// 注释不算代码:文档里解释「旧写法长什么样」不该被判成旧写法。整行注释与行尾注释
// 都剥掉(`//` 之后的半行同样不算;字符串里恰好有 `//` 只会让判定更松,不会误报)。
let code = line.split("//").next().unwrap_or_default();
out.push_str(code);
out.push('\n');
continue;
}
@@ -108,6 +113,53 @@ fn product_source(relative: &str) -> String {
product_part(&text)
}
/// 旧私有拷贝的**两种**形态(去掉空白后比对:换行 / 缩进 / 括号排版都躲不过)。
///
/// 第一种是「状态层有就给、没有就兜底实色」,第二种是 `selected_fill` 自己的函数体写法
/// (`unwrap_or_else(|| ui.color(container))`)——两者都是「选中项的底」那份拷贝。
const PRIVATE_COPY_NEEDLES: [&str; 2] =
[".or_else(||Some(ui.color(", ".unwrap_or_else(||ui.color("];
/// 把源码归一成「只留标识符与标点」的形态:删空白,**再删闭包的花括号**。
///
/// 于是下面三种写法归一成同一个字面量:
///
/// ```text
/// .or_else(|| Some(ui.color(..)))
/// .or_else(|| Some(ui.color(..))
/// .or_else(|| { Some(ui.color(..)) })
/// ```
///
/// 删花括号是必要的:M299 r1 P2-2 的绕过写法正是 `or_else(|| { Some(..) })`——只删空白时那个
/// `{` 会把针挡掉。针本身以 `||` 打头并紧贴 `Some(`/`ui.color(`,所以「中间隔着别的语句」不会误命中
/// (`;let y=` 之类的标记仍在)。
fn squeeze(text: &str) -> String {
text.split_whitespace()
.collect::<String>()
.replace(['{', '}'], "")
}
/// 命中哪一个针(没有就 `None`)。
fn private_copy(text: &str) -> Option<&'static str> {
let squeezed = squeeze(text);
PRIVATE_COPY_NEEDLES
.iter()
.copied()
.find(|needle| squeezed.contains(needle))
}
/// 这个文件是不是**只**在测试里编译。
///
/// 两种形态:`.../tests/xxx.rs`(父模块 `#[cfg(test)] mod tests;` 声明的目录)与 `xxx/tests.rs`
/// (同名子模块文件)。它们整体是测试代码——扫它们只会把测试助手(比如 `java/marker.rs` 里
/// 「容器为空时按 surface 算对比度」的兜底)当成私有拷贝。
fn is_test_only(path: &Path) -> bool {
path.file_name().is_some_and(|name| name == "tests.rs")
|| path
.components()
.any(|component| component.as_os_str() == "tests")
}
/// 十个「当前项」站点,全部必须经 [`selected_fill`] 拿底。
///
/// 前四行是 M296 收口的,后七行是 M299 收口的(`shell/nav.rs` 与 `shell/nav/account.rs` 各一处)。
@@ -144,6 +196,13 @@ fn every_selected_item_goes_through_the_single_entry() {
}
/// 判据 2(反例):旧私有拷贝的形态在产品代码里一处都不许留。
///
/// ## 为什么比对「去掉空白之后」的形态(M299 r1 P2-2)
///
/// 上一版拿整串 `"or_else(|| Some(ui.color("` 直接 `contains`:复审把同一形态写成
/// `or_else(|| { Some(ui.color(..)) })`(**只换排版**)就绕过去了——语义完全一样,字面量却不同。
/// 现在比对前把源文件里的空白全部删掉(`split_whitespace().collect()`),针也缩成无空白的形态;
/// 换行、缩进、括号里的换行都不再影响判定。
#[test]
fn the_private_copy_idiom_is_gone() {
let mut files = Vec::new();
@@ -170,13 +229,13 @@ fn the_private_copy_idiom_is_gone() {
let mut offenders = Vec::new();
for path in files {
if exempt.contains(&path) {
if exempt.contains(&path) || is_test_only(&path) || is_known_copy(&path) {
continue;
}
let text = std::fs::read_to_string(&path)
.unwrap_or_else(|error| panic!("读不动 {}:{error}", path.display()));
if product_part(&text).contains("or_else(|| Some(ui.color(") {
offenders.push(path.display().to_string());
if let Some(hit) = private_copy(&product_part(&text)) {
offenders.push(format!("{}(命中 {hit})", path.display()));
}
}
assert!(
@@ -186,3 +245,42 @@ fn the_private_copy_idiom_is_gone() {
静息它就是实色容器,叠层交给它。"
);
}
/// 已知的**同一形态、本次不动**的站点:`(相对路径, 为什么留着)`。
///
/// 单独列出来而不是「用更窄的针绕开」:绕开等于假装它不存在。这一处是滑块的 handle(静息底是
/// 实心 `Primary`,hover/focus/pressed 叠状态层 —— 与选中项**同一套语义**,换成
/// `selected_fill(ui, state, OnSurface, Primary)` 是逐位等价的)。
/// `components/inputs/**` 不在 M299 的 scope,已作为 finding 报给塔。
const KNOWN_COPIES: [(&str, &str); 1] = [(
"components/inputs/slider/paint.rs",
"滑块 handle 的静息底是实心 Primary,与选中项同一套语义;components/inputs/** 不在 M299 scope,已报 finding",
)];
/// 这个文件是不是 [`KNOWN_COPIES`] 里挂过豁免的那一个。
fn is_known_copy(path: &Path) -> bool {
let src = src_dir();
KNOWN_COPIES.iter().any(|(relative, _)| {
path.strip_prefix(&src)
.is_ok_and(|tail| tail == Path::new(relative))
})
}
/// 判据 3(豁免要**活着**):[`KNOWN_COPIES`] 里的文件必须**仍然**含那个形态。
///
/// 守的是「豁免越挂越多」:谁把那处收口了,这条立刻红,逼他回来删掉豁免行;谁想给自己文件
/// 挂个空豁免,也红。
#[test]
fn every_known_copy_is_still_there() {
let stale: Vec<&str> = KNOWN_COPIES
.iter()
.map(|(relative, _)| *relative)
.filter(|relative| private_copy(&product_source(relative)).is_none())
.collect();
assert!(
stale.is_empty(),
"这些豁免已经失效(文件里没有那个形态了):{stale:?}\n\
收口了就把它从 `KNOWN_COPIES` 里删掉——豁免表不许留空条目。"
);
}