fix(iced/feedback): 不定态两条 p1 —— 线性先排序合并(P1-A)+ 环形补全局旋转(P1-B)

复审 r1 的两条 p1 都是「报告写了 ✓、图上没有」,逐条修:

P1-A(视觉)线性不定态把两段画成一段:
- 根因:`phase_segments` 的两段**不保证有序**(第一线前缘抢跑 250ms、第二线从 0 起),
  `linear()` 却按返回顺序单调铺 `FillPortion`,且份额之和在 34/100 相位上 >1(最大 1.99),
  iced 按总和归一 ⇒ phase 0.55 的两段被压成一段 100px 台阶。
- 修:新增 `layout_spans`(排序 + 合并重叠 + 显式铺间隙)与 `portion_shares`(累计舍入,
  份额之和**恒为 1000**),`linear()` 只消费这两者。
- 断言:`raw_segments_are_unordered_and_layout_sorts_them`(原始输出不有序、排序后位置对得上
  A1 keyframes)、`portion_shares_always_sum_to_the_full_track`(200 个相位全 = 1000)、
  以及渲染层 `12.4:phase 0.55 必须数出两段`(旧实现会数到 1 段)。

P1-B(代码)环形不定态少一条全局旋转 + keyframes 首点语义:
- 官方是两条旋转相加:`CircularGlobalRotationDegreesTarget = 1080f`(`tween(6000, LinearEasing)`,
  javap 偏移 5125)与 8 关键帧旋转(0→360°),绘制时 `fadd` 后 rotate ⇒ 每周期 1440°(4 圈)。
  我们只做了 360°,转速是官方的 1/4。
- `ROTATION` 首点在 300ms/90°:Compose 会自己补 t=0(值 = 动画初值 0),我们没补 ⇒ `[0,0.05)`
  恒 90°,且周期末尾 360° → 下周期 90° 瞬时倒跳 270°。现在显式补 `KeyPoint{ms:0, value:0}`
  并在 `Keyframes::sample` 加 `debug_assert!(首点 ms == 0)`,同一类坑不再靠自觉。
- `CircularGeometry::indeterminate` 改返回 `(弧长, 圈数)`,圈数 = 3.0×phase + 关键帧(可为 >1),
  `progress.rs` 直接乘 360、不做 rem_euclid。
- 断言:`circular_indeterminate_rotation_is_four_turns_and_continuous`(每周期 4 圈、
  跨周期跳变 ≤ 一帧 = 4/60 圈,修前是 0.75 圈 = 270°、旋转单调)。

probe 拆成 `probe.rs` + `probe/circular.rs`(新断言会把单文件顶过 400 行)。
This commit is contained in:
AskaEth committed 2026-11-02 15:56:43 +08:00
1 parent 36f58d7904
commit e9af27c8f1
6 files changed
+494 -202

No files matched your search

+74 -175
View File
@@ -1,18 +1,21 @@
//! 反馈家族的**渲染层**几何断言:对着真截图量像素(M68 的教训:纯函数对、图可能是错的)。
//!
//! 为什么住在 `feedback/` 而不是 `gallery/`:`gallery/feedback.rs` 只负责把矩阵摆进 harness,
//! 断言加上去会把它顶到 400 行红线(与 `selection/probe.rs` 同一处置)。像素工具在同级
//! [`super::pixel`](`cfg(test)`)。
//! 断言加上去会把它顶到 400 行红线。像素工具在 [`super::pixel`],环形与两个「面」的判据
//! 拆在同级子模块 [`circular`] / [`panels`](`inputs/probe/` 的同一先例)。
//!
//! ## 量什么(每条对一个具体错误,打红它的是「变异」那一列)
//!
//! | 类 | 判据 | 打红它的变异 |
//! |---|---|---|
//! | 线性厚度 / 圆角 | 轨道带**恰好 4 行**;条的四角**透出底色** | 厚度 4→6 / `Full`→`None` |
//! | Snackbar 宽上下限 | 宿主 800→**600**、344→**344**、300→**300**(无下限 G17-d) | 上限 600→560 |
//! | 线性不定态 | phase 0.55 列必须**数出两段**(P1-A 回归) | 段不排序(旧实现:两段压成一段) |
//! | 环形 | 环 40 且四角无墨(真圆) | 直径 40→48 |
//! | Snackbar 宽上下限 | 宿主 800→**600**、368→**344**、324→**300**(无下限 G17-d) | 上限 600→560 |
//! | Snackbar 高 | 单行带 **48** / 双行带 **68** | 48→40 |
//! | Dialog 宽 / 内距 | 最宽行 **480**(在 280..560 内)· 标题墨迹左缩 **≈24** | 480→560 / pad 24→16 |
//! | 环形 / 空态块 / scrim | 环 40 且四角无墨 · 块 **96×96** · 遮罩带颜色 = 预合成实色 | 40→48 / 96→88 |
//! | Dialog 宽 / 内距 | 最宽行 **480**(在 280..560 内)· 标题墨迹左缩 **≈24**、顶缩 **≈26** | 480→488 / pad 24→16 |
//! | 空态块 / 间距 / CTA | 块 **96×96** · 块↔标题 **≈16**+bearing · 条目总高 **≈232** · CTA **40** | 96→88 / spacing 双算回来 |
//! | scrim | 一块 ≥700 宽的遮罩带,颜色 = 预合成实色 | 遮罩改成 surface |
//!
//! ## 复现(worktree 根目录;四个部分各一张图,浅/深各一轮)
//!
@@ -26,15 +29,13 @@
//! done
//! ```
mod circular;
mod panels;
use unlucky_design::color::{Role, SchemeFlavor};
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::typography::Platform;
use super::dialog::scrim;
use super::pixel::{
band_width, bands, blobs, dims, ink_inside, matches, near, rgb, row_ink, runs, shot_path, BBox,
Shot,
};
use super::pixel::{band_width, bands, matches, near, rgb, runs, shot_path, Shot};
use crate::theme::{Families, Ui};
const FAMILIES: Families = Families {
@@ -47,6 +48,11 @@ fn ui(flavor: SchemeFlavor) -> Ui {
Ui::with_platform(flavor, Platform::Windows, FAMILIES)
}
/// 第三条(phase 0.55)线性轨道的左缘与宽度(gallery 的宿主尺寸:页内边距 24 + 2×332)。
const TRACK3_X0: usize = 688;
/// 单条轨道宽(gallery 的 `TRACK_HOST`)。
const TRACK3_W: usize = 320;
/// 一条测试覆盖当前截图对应的那一段(段由 `ULL_FEEDBACK_PART` 指定,默认 `all`)。
#[test]
fn feedback_geometry_matches_the_reference() {
@@ -77,13 +83,16 @@ fn feedback_geometry_matches_the_reference() {
let part = part.trim().to_ascii_lowercase();
match part.as_str() {
"progress" => check_progress(&shot, &ui),
"progress" => {
check_linear(&shot, &ui);
circular::check(&shot, &ui);
}
"snackbar" => check_snackbar(&shot, &ui),
"dialog" => check_dialog(&shot, &ui),
"empty" => check_empty(&shot, &ui),
"dialog" => panels::check_dialog(&shot, &ui),
"empty" => panels::check_empty(&shot, &ui),
other => {
// 整屏时只查最不容易被折叠线切掉的那一类;分图才是逐项判据的场合。
check_progress(&shot, &ui);
check_linear(&shot, &ui);
eprintln!("[feedback-probe] part={other:?} 只查了 progress 那一类;其余请在分图里查");
}
}
@@ -91,10 +100,10 @@ fn feedback_geometry_matches_the_reference() {
eprintln!("[feedback-probe] OK flavor={flavor:?} part={part}");
}
/// §12:线性厚度 4 / 圆角 full;环形 40 且真圆。
fn check_progress(shot: &Shot, ui: &Ui) {
/// §12.1 + §12.4:线性厚度 / 圆角 / **不定态两段**。
fn check_linear(shot: &Shot, ui: &Ui) {
// 参考值写字面量(A1):**不能**从 `LinearGeometry` 读回期望值——那样把 4 改成 6 之后
// 「实测 6 == 期望 6」会照样绿,等于没有断言(复审 P2-4 的同一类坑)。
// 「实测 6 == 期望 6」会照样绿,等于没有断言。
let surface = ui.color(Role::Surface);
let track = ui.color(Role::SecondaryContainer);
let active = ui.color(Role::Primary);
@@ -128,41 +137,65 @@ fn check_progress(shot: &Shot, ui: &Ui) {
"条的中间必须有墨(否则是没画出来,不是圆角)"
);
// ---- 12.3 环形:40 的外接框,且四角无墨(真圆)------------------------------
let rings: Vec<BBox> = blobs(shot, active, 30)
// ---- 12.4 不定态:phase 0.55 那一列必须**数出两段**(复审 P1-A 的回归断言)----
//
// 旧实现把两段按返回顺序铺 `FillPortion`(第一线在右、第二线在左),并且份额之和
// 在某 34/100 相位上 >1,被 iced 归一 ⇒ 图上只剩一段 100px 的台阶(x 749..848)。
let row = indeterminate_row(shot, active, surface);
let found: Vec<(usize, usize)> = runs(shot, row, active, surface)
.into_iter()
.filter(|bbox| near(bbox.width(), 40) && near(bbox.height(), 40))
.filter(|(start, len)| {
*start >= TRACK3_X0 && start + len <= TRACK3_X0 + TRACK3_W + 2 && *len >= 8
})
.collect();
assert!(
!rings.is_empty(),
"12.3:没找到 40(±1) 的环形激活层;候选:{}",
dims(&blobs(shot, active, 10))
assert_eq!(
found.len(),
2,
"12.4:phase 0.55 必须是**两段**(P1-A 修完才成立),实测 {found:?} @ 行 {row}"
);
for ring in &rings {
for (x, y) in [
(ring.left, ring.top),
(ring.right, ring.top),
(ring.left, ring.bottom),
(ring.right, ring.bottom),
] {
assert!(
!matches(shot.at(x, y), rgb(active)),
"12.3:环必须是真圆(外接框四角不该有墨)@ ({x},{y})"
);
}
// A1 keyframes 手算:第二线 0.003..0.079、第一线 0.355..0.871(EmphasizedAccelerate)
let expected = [(0.003_f32, 0.079_f32), (0.355, 0.871)];
for (index, (start, len)) in found.iter().enumerate() {
let want_start = TRACK3_X0 as f32 + expected[index].0 * TRACK3_W as f32;
let want_end = TRACK3_X0 as f32 + expected[index].1 * TRACK3_W as f32;
assert!(
(*start as f32 - want_start).abs() <= 4.0,
"12.4 第 {} 段左端应在 {want_start:.1}(实测 {start})",
index + 1
);
assert!(
((start + len) as f32 - want_end).abs() <= 4.0,
"12.4 第 {} 段右端应在 {want_end:.1}(实测 {})",
index + 1,
start + len
);
}
eprintln!(
"[feedback-probe] progress:轨道带 {} 条(高 {:?})· 环 {} 个 40(±1)",
"[feedback-probe] progress:轨道带 {} 条(高 {:?})· phase 0.55 两段 {found:?}",
bar_bands.len(),
bar_bands
.iter()
.map(|(top, bottom)| bottom - top + 1)
.collect::<Vec<_>>(),
rings.len()
);
}
/// 线性不定态那一行的 y:主色墨迹在**第一条轨道**里出现、且不在确定态行的最上一组。
///
/// 不想硬编码坐标(gallery 一改间距就失准),所以按「主色横带 + 排除最高那条带」找。
fn indeterminate_row(shot: &Shot, active: iced::Color, surface: iced::Color) -> usize {
let found = bands(shot, active, surface, 20);
assert!(
found.len() >= 2,
"至少要找到确定态行与不定态行两组主色横带,实测 {found:?}"
);
// 第一组是确定态(0% 无墨、50%/100% 有墨),第二组是不定态
let (top, bottom) = found[1];
(top + bottom) / 2
}
/// §14:宽度上下限 + 单/双行高。
fn check_snackbar(shot: &Shot, ui: &Ui) {
// 参考值写字面量(A1):600 = SnackbarKt.ContainerMaxWidth,48/68 = SnackbarTokens。
@@ -182,8 +215,6 @@ fn check_snackbar(shot: &Shot, ui: &Ui) {
);
// 宽度集 = 每条带里最宽的那一行(留白行没有文字,量得到全宽)。
// **注意**:并排的两条窄条落在同一组行里,所以这一屏的宽度集只能证「存在 344 与 300」;
// 要把其中一条单独改坏,变异必须同时改所有实例(报告「变异」一节里写明)。
let mut widths: Vec<usize> = found
.iter()
.map(|band| band_width(shot, *band, bar, surface))
@@ -200,16 +231,14 @@ fn check_snackbar(shot: &Shot, ui: &Ui) {
widths.iter().any(|w| near(*w, 600)),
"上限宽必须是 600(SnackbarKt.ContainerMaxWidth,宿主 800 时夹取),实测宽度集 {widths:?}"
);
// 最小宽的取值口径 344,以及「没有下限」的反证 300
assert!(
widths.iter().any(|w| near(*w, 344)),
"宿区 344 那格必须量到 344(G17-d [估] 最小宽口径),实测 {widths:?}"
"宿区 368 那格必须量到 344(G17-d [估] 最小宽口径),实测 {widths:?}"
);
assert!(
widths.iter().any(|w| near(*w, 300)),
"宿区 300 那格必须量到 300(组件没有 min-width,G17-d),实测 {widths:?}"
"宿区 324 那格必须量到 300(组件没有 min-width,G17-d),实测 {widths:?}"
);
// 高度 48 / 68
assert!(
heights.iter().any(|h| near(*h, 48)),
"14.3:单行高 48,实测高度集 {heights:?}"
@@ -237,133 +266,3 @@ fn check_snackbar(shot: &Shot, ui: &Ui) {
"[feedback-probe] snackbar:宽度集 {widths:?} · 高度集 {heights:?} · InversePrimary {action_pixels}px"
);
}
/// §7:卡片宽 480(官方 280..560 内)+ 内距 24 + scrim 毯。
fn check_dialog(shot: &Shot, ui: &Ui) {
// 参考值写字面量(A1 AlertDialogKt):280 / 560 / 480 / pad 24。
let surface = ui.color(Role::Surface);
let card = ui.layer(MaterialLayer::Overlay);
// ---- 7.4 宽:所有行里最宽的一段(卡片上下留白那几行没有文字,量得到全宽)------
let mut widest = (0usize, 0usize, 0usize); // (len, x, y)
for y in 0..shot.height {
for (x, len) in runs(shot, y, card, surface) {
if len > widest.0 {
widest = (len, x, y);
}
}
}
let (width, card_left, card_row) = widest;
assert!(
near(width, 480),
"7.4:对话框宽必须是 480(G17-e 项目取值),实测 {width}"
);
// 该行上下扩成卡片带(同一张卡片的连续行;两张卡并排时各自成带)
let mut top = card_row;
while top > 0 && row_ink(shot, top - 1, card, surface) >= width / 2 {
top -= 1;
}
let mut bottom = card_row;
while bottom + 1 < shot.height && row_ink(shot, bottom + 1, card, surface) >= width / 2 {
bottom += 1;
}
// ---- 7.3 左右内距:标题墨迹距卡片左缘 ≈ 24(外加 24px 字形的 bearing,22..28)----
let bbox = BBox {
left: card_left,
right: card_left + width - 1,
top,
bottom,
};
let title =
ink_inside(shot, &bbox, ui.color(Role::OnSurface), card, 2).expect("卡片里必须有标题墨迹");
let inset = title.left.saturating_sub(card_left);
assert!(
(22..=28).contains(&inset),
"7.3:四边内距 24 ⇒ 标题墨迹左缩 ≈24(含 bearing 22..28),实测 {inset}"
);
let top_inset = title.top.saturating_sub(top);
assert!(
(24..=40).contains(&top_inset),
"7.3:上内距 24 ⇒ 标题墨迹顶缩 ≈24+行内 bearing,实测 {top_inset}"
);
// ---- 7.7 scrim:一块 ≥700 宽的遮罩带,颜色 = 预合成实色 ----------------------
let backdrop = scrim(ui);
let scrim_bands = bands(shot, backdrop, surface, 700);
assert!(
!scrim_bands.is_empty(),
"7.7:没找到 ≥700 宽的遮罩带(宿主 800 宽那格)"
);
// ---- 7.6 动作色:Dialog 动作是 `Primary`(不是 OnSurfaceVariant)-------------
// 按「更靠近 Primary 而不是卡片」判,不按精确色:14px 的中文字形只有边缘几个像素能命中
// 精确色(实测 0),而 Primary 与卡片色的距离很大,这个判据又稳又不误判。
let action = rgb(ui.color(Role::Primary));
let card_rgb = rgb(card);
let action_pixels = (top..=bottom)
.map(|y| {
(card_left..(card_left + width).min(shot.width))
.filter(|x| {
let pixel = shot.at(*x, y);
let to_action: u32 = pixel
.iter()
.zip(action)
.map(|(a, b)| u32::from(a.abs_diff(b)))
.sum();
let to_card: u32 = pixel
.iter()
.zip(card_rgb)
.map(|(a, b)| u32::from(a.abs_diff(b)))
.sum();
to_action < to_card
})
.count()
})
.sum::<usize>();
assert!(
action_pixels > 40,
"7.6:动作文本应是 Primary(实测该色只有 {action_pixels} px)"
);
eprintln!(
"[feedback-probe] dialog:宽 {width} · 标题左缩 {inset} · 顶缩 {top_inset} · \
卡片 y {top}..{bottom} · scrim 带 {} 条 · Primary {action_pixels}px",
scrim_bands.len()
);
}
/// §15:空态图标块 96(`[估]`)+ CTA 40 高。
fn check_empty(shot: &Shot, ui: &Ui) {
// 参考值写字面量:96/16/32 全是 `[估]`(项目自定,官方无此组件)。
let surface = ui.color(Role::Surface);
let block = ui.layer(MaterialLayer::Card);
// 下限 40 而不是 88:块只有 96 宽,中间的图标(32)会把那几行的块色墨量压到 ≈64。
let found = bands(shot, block, surface, 40);
let (top, bottom) = found
.iter()
.copied()
.max_by_key(|(top, bottom)| bottom - top)
.filter(|(top, bottom)| near(bottom - top + 1, 96))
.unwrap_or_else(|| {
panic!("15.1:没找到高 96 的图标块(`[估]` 项目自定值),实测量到的带:{found:?}")
});
let width = band_width(shot, (top, bottom), block, surface);
assert!(near(width, 96), "15.1:图标块必须 96×96,实测宽 {width}");
// 15.6 CTA:标准 Filled 按钮 40 高(`Primary` 实心带)。
// 下限 20 而不是 60:按钮约 100 宽,文字那几行只剩 ≈44 个实心像素(文字不是实心色)。
let cta = bands(shot, ui.color(Role::Primary), surface, 20);
assert!(
cta.iter().any(|(top, bottom)| near(bottom - top + 1, 40)),
"15.6:CTA 应是标准按钮(可见高 40),实测带 {cta:?}"
);
eprintln!(
"[feedback-probe] empty:块 y {top}..{bottom}(高 {})· 宽 {width} · CTA 带 {cta:?}",
bottom - top + 1
);
}