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:
1 parent
36f58d7904
commit
e9af27c8f1
6 files changed
+494
-202
No files matched your search
@@ -146,6 +146,13 @@ const CIRCULAR_MIN: f32 = 0.1;
|
|||||||
const CIRCULAR_MAX: f32 = 0.87;
|
const CIRCULAR_MAX: f32 = 0.87;
|
||||||
/// 环形不定态周期(`CircularAnimationProgressDuration = 6000`)。
|
/// 环形不定态周期(`CircularAnimationProgressDuration = 6000`)。
|
||||||
const CIRCULAR_CYCLE_MS: u32 = 6000;
|
const CIRCULAR_CYCLE_MS: u32 = 6000;
|
||||||
|
/// 环形的**全局旋转**:`CircularGlobalRotationDegreesTarget = 1080f`,每周期 3 圈。
|
||||||
|
///
|
||||||
|
/// `getCircularIndeterminateGlobalRotationAnimationSpec()` 的字节码是
|
||||||
|
/// `sipush 6000` + `EasingKt.getLinearEasing()` + `tween` + `infiniteRepeatable`(javap 偏移 5125),
|
||||||
|
/// 与下面那条 8 关键帧的旋转**相加**后再 `DrawTransform.rotate`(`State.getValue`×2 → `fadd` → `rotate`)。
|
||||||
|
/// 所以官方 6s 一共转 1080 + 360 = **1440°(4 圈)**;早先只实现了 360°,转速是官方的 1/4(P1-B)。
|
||||||
|
const CIRCULAR_GLOBAL_TURNS: f32 = 3.0;
|
||||||
|
|
||||||
/// 弧长:0.1 → 0.87(3000ms,`CircularProgressEasing`=Standard),再回 0.1(3000ms)。
|
/// 弧长:0.1 → 0.87(3000ms,`CircularProgressEasing`=Standard),再回 0.1(3000ms)。
|
||||||
const ARC_SWEEP: Keyframes = Keyframes {
|
const ARC_SWEEP: Keyframes = Keyframes {
|
||||||
@@ -170,9 +177,17 @@ const ARC_SWEEP: Keyframes = Keyframes {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/// 旋转(`_get_circularIndeterminateRotationAnimationSpec` 的 8 个关键帧,占周期 0..360°)。
|
/// 旋转(`_get_circularIndeterminateRotationAnimationSpec` 的 8 个关键帧,占周期 0..360°)。
|
||||||
|
///
|
||||||
|
/// **首点 0ms/0° 是必须写的**:Compose 会自己补这个点(值 = 动画初值 0),我们显式写出来,
|
||||||
|
/// 否则 `[0, 0.05)` 会恒为 90°、并且周期末尾 360° → 下周期 90° 瞬时倒跳 270°(P1-B)。
|
||||||
const ROTATION: Keyframes = Keyframes {
|
const ROTATION: Keyframes = Keyframes {
|
||||||
cycle_ms: CIRCULAR_CYCLE_MS,
|
cycle_ms: CIRCULAR_CYCLE_MS,
|
||||||
points: &[
|
points: &[
|
||||||
|
KeyPoint {
|
||||||
|
ms: 0,
|
||||||
|
value: 0.0,
|
||||||
|
easing: Easing::Linear,
|
||||||
|
},
|
||||||
KeyPoint {
|
KeyPoint {
|
||||||
ms: 300,
|
ms: 300,
|
||||||
value: 0.25,
|
value: 0.25,
|
||||||
@@ -248,8 +263,13 @@ impl CircularGeometry {
|
|||||||
(self.diameter - self.thickness) / 2.0
|
(self.diameter - self.thickness) / 2.0
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 不定态:相位 → `(弧长占比, 起始旋转占整圈的比例)`。
|
/// 不定态:相位 → `(弧长占比, 旋转圈数)`。
|
||||||
|
///
|
||||||
|
/// **圈数允许 > 1**(每周期 4 圈):全局 1080°(线性,3 圈)+ 关键帧 0→360°(1 圈)相加,
|
||||||
|
/// 与官方 `fadd` 口径一致。调用方**不要**先 `rem_euclid(1.0)` 再乘 360——那会把 3 圈丢掉,
|
||||||
|
/// 又变成 P1-B 那个「转速只有官方 1/4」的版本。
|
||||||
pub fn indeterminate(&self, phase: f32) -> (f32, f32) {
|
pub fn indeterminate(&self, phase: f32) -> (f32, f32) {
|
||||||
(ARC_SWEEP.sample(phase), ROTATION.sample(phase))
|
let global = CIRCULAR_GLOBAL_TURNS * phase.rem_euclid(1.0);
|
||||||
|
(ARC_SWEEP.sample(phase), global + ROTATION.sample(phase))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -15,8 +15,16 @@
|
|||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! Compose 的 `KeyframesSpec.at(value, time).using(easing)` 是「**从该点出发的那一段**用这条缓动」,
|
//! Compose 的 `KeyframesSpec.at(value, time).using(easing)` 是「**从该点出发的那一段**用这条缓动」,
|
||||||
//! 所以 [`KeyPoint::easing`] 的语义就是段起点的缓动;第一点之前 / 最后一点之后一律**保持端点值**
|
//! 所以 [`KeyPoint::easing`] 的语义就是段起点的缓动;最后一点之后一律**保持末值**
|
||||||
//! (Compose 对未覆盖的区间就是保持,这也是「段跑到头以后停在满格」的来源)。
|
//! (Compose 对未覆盖的区间就是保持,这也是「段跑到头以后停在满格」的来源)。
|
||||||
|
//!
|
||||||
|
//! ## 首点必须在 `ms = 0`(复审 P1-B 的教训)
|
||||||
|
//!
|
||||||
|
//! Compose 在 keyframes **缺 0ms 关键帧**时会自己补一个 `t = 0`、值 = 动画**初值**的点
|
||||||
|
//! (`VectorizedKeyframesSpec` 构造里 `Map.containsKey(0) == false` 就 `timestamps.add(0, 0)`,
|
||||||
|
//! 且 `playTime <= 0 → startValue`)。本求值器不猜:首点不在 0 就 `debug_assert` 炸,
|
||||||
|
//! 逼调用方把那个点显式写出来(环形旋转就漏过这一点:`ROTATION` 首点是 300ms/90°,
|
||||||
|
//! 于是 `[0, 0.05)` 被恒定为 90°,周期末尾 360° → 下周期 90° 还会瞬时倒跳 270°)。
|
||||||
|
|
||||||
use unlucky_design::motion::Easing;
|
use unlucky_design::motion::Easing;
|
||||||
|
|
||||||
@@ -53,6 +61,10 @@ impl Keyframes {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let cycle = self.cycle_ms as f32;
|
let cycle = self.cycle_ms as f32;
|
||||||
|
debug_assert_eq!(
|
||||||
|
first.ms, 0,
|
||||||
|
"首点必须在 0ms:Compose 会补一个 t=0 的初值点,我们不猜,请显式写出来"
|
||||||
|
);
|
||||||
let ms = phase.rem_euclid(1.0) * cycle;
|
let ms = phase.rem_euclid(1.0) * cycle;
|
||||||
if ms <= first.ms as f32 {
|
if ms <= first.ms as f32 {
|
||||||
return first.value;
|
return first.value;
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
//! 反馈家族的**渲染层**几何断言:对着真截图量像素(M68 的教训:纯函数对、图可能是错的)。
|
//! 反馈家族的**渲染层**几何断言:对着真截图量像素(M68 的教训:纯函数对、图可能是错的)。
|
||||||
//!
|
//!
|
||||||
//! 为什么住在 `feedback/` 而不是 `gallery/`:`gallery/feedback.rs` 只负责把矩阵摆进 harness,
|
//! 为什么住在 `feedback/` 而不是 `gallery/`:`gallery/feedback.rs` 只负责把矩阵摆进 harness,
|
||||||
//! 断言加上去会把它顶到 400 行红线(与 `selection/probe.rs` 同一处置)。像素工具在同级
|
//! 断言加上去会把它顶到 400 行红线。像素工具在 [`super::pixel`],环形与两个「面」的判据
|
||||||
//! [`super::pixel`](`cfg(test)`)。
|
//! 拆在同级子模块 [`circular`] / [`panels`](`inputs/probe/` 的同一先例)。
|
||||||
//!
|
//!
|
||||||
//! ## 量什么(每条对一个具体错误,打红它的是「变异」那一列)
|
//! ## 量什么(每条对一个具体错误,打红它的是「变异」那一列)
|
||||||
//!
|
//!
|
||||||
//! | 类 | 判据 | 打红它的变异 |
|
//! | 类 | 判据 | 打红它的变异 |
|
||||||
//! |---|---|---|
|
//! |---|---|---|
|
||||||
//! | 线性厚度 / 圆角 | 轨道带**恰好 4 行**;条的四角**透出底色** | 厚度 4→6 / `Full`→`None` |
|
//! | 线性厚度 / 圆角 | 轨道带**恰好 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 |
|
//! | Snackbar 高 | 单行带 **48** / 双行带 **68** | 48→40 |
|
||||||
//! | Dialog 宽 / 内距 | 最宽行 **480**(在 280..560 内)· 标题墨迹左缩 **≈24** | 480→560 / pad 24→16 |
|
//! | Dialog 宽 / 内距 | 最宽行 **480**(在 280..560 内)· 标题墨迹左缩 **≈24**、顶缩 **≈26** | 480→488 / pad 24→16 |
|
||||||
//! | 环形 / 空态块 / scrim | 环 40 且四角无墨 · 块 **96×96** · 遮罩带颜色 = 预合成实色 | 40→48 / 96→88 |
|
//! | 空态块 / 间距 / CTA | 块 **96×96** · 块↔标题 **≈16**+bearing · 条目总高 **≈232** · CTA **40** | 96→88 / spacing 双算回来 |
|
||||||
|
//! | scrim | 一块 ≥700 宽的遮罩带,颜色 = 预合成实色 | 遮罩改成 surface |
|
||||||
//!
|
//!
|
||||||
//! ## 复现(worktree 根目录;四个部分各一张图,浅/深各一轮)
|
//! ## 复现(worktree 根目录;四个部分各一张图,浅/深各一轮)
|
||||||
//!
|
//!
|
||||||
@@ -26,15 +29,13 @@
|
|||||||
//! done
|
//! done
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
|
mod circular;
|
||||||
|
mod panels;
|
||||||
|
|
||||||
use unlucky_design::color::{Role, SchemeFlavor};
|
use unlucky_design::color::{Role, SchemeFlavor};
|
||||||
use unlucky_design::elevation::MaterialLayer;
|
|
||||||
use unlucky_design::typography::Platform;
|
use unlucky_design::typography::Platform;
|
||||||
|
|
||||||
use super::dialog::scrim;
|
use super::pixel::{band_width, bands, matches, near, rgb, runs, shot_path, Shot};
|
||||||
use super::pixel::{
|
|
||||||
band_width, bands, blobs, dims, ink_inside, matches, near, rgb, row_ink, runs, shot_path, BBox,
|
|
||||||
Shot,
|
|
||||||
};
|
|
||||||
use crate::theme::{Families, Ui};
|
use crate::theme::{Families, Ui};
|
||||||
|
|
||||||
const FAMILIES: Families = Families {
|
const FAMILIES: Families = Families {
|
||||||
@@ -47,6 +48,11 @@ fn ui(flavor: SchemeFlavor) -> Ui {
|
|||||||
Ui::with_platform(flavor, Platform::Windows, FAMILIES)
|
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`)。
|
/// 一条测试覆盖当前截图对应的那一段(段由 `ULL_FEEDBACK_PART` 指定,默认 `all`)。
|
||||||
#[test]
|
#[test]
|
||||||
fn feedback_geometry_matches_the_reference() {
|
fn feedback_geometry_matches_the_reference() {
|
||||||
@@ -77,13 +83,16 @@ fn feedback_geometry_matches_the_reference() {
|
|||||||
let part = part.trim().to_ascii_lowercase();
|
let part = part.trim().to_ascii_lowercase();
|
||||||
|
|
||||||
match part.as_str() {
|
match part.as_str() {
|
||||||
"progress" => check_progress(&shot, &ui),
|
"progress" => {
|
||||||
|
check_linear(&shot, &ui);
|
||||||
|
circular::check(&shot, &ui);
|
||||||
|
}
|
||||||
"snackbar" => check_snackbar(&shot, &ui),
|
"snackbar" => check_snackbar(&shot, &ui),
|
||||||
"dialog" => check_dialog(&shot, &ui),
|
"dialog" => panels::check_dialog(&shot, &ui),
|
||||||
"empty" => check_empty(&shot, &ui),
|
"empty" => panels::check_empty(&shot, &ui),
|
||||||
other => {
|
other => {
|
||||||
// 整屏时只查最不容易被折叠线切掉的那一类;分图才是逐项判据的场合。
|
// 整屏时只查最不容易被折叠线切掉的那一类;分图才是逐项判据的场合。
|
||||||
check_progress(&shot, &ui);
|
check_linear(&shot, &ui);
|
||||||
eprintln!("[feedback-probe] part={other:?} 只查了 progress 那一类;其余请在分图里查");
|
eprintln!("[feedback-probe] part={other:?} 只查了 progress 那一类;其余请在分图里查");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,10 +100,10 @@ fn feedback_geometry_matches_the_reference() {
|
|||||||
eprintln!("[feedback-probe] OK flavor={flavor:?} part={part}");
|
eprintln!("[feedback-probe] OK flavor={flavor:?} part={part}");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// §12:线性厚度 4 / 圆角 full;环形 40 且真圆。
|
/// §12.1 + §12.4:线性厚度 / 圆角 / **不定态两段**。
|
||||||
fn check_progress(shot: &Shot, ui: &Ui) {
|
fn check_linear(shot: &Shot, ui: &Ui) {
|
||||||
// 参考值写字面量(A1):**不能**从 `LinearGeometry` 读回期望值——那样把 4 改成 6 之后
|
// 参考值写字面量(A1):**不能**从 `LinearGeometry` 读回期望值——那样把 4 改成 6 之后
|
||||||
// 「实测 6 == 期望 6」会照样绿,等于没有断言(复审 P2-4 的同一类坑)。
|
// 「实测 6 == 期望 6」会照样绿,等于没有断言。
|
||||||
let surface = ui.color(Role::Surface);
|
let surface = ui.color(Role::Surface);
|
||||||
let track = ui.color(Role::SecondaryContainer);
|
let track = ui.color(Role::SecondaryContainer);
|
||||||
let active = ui.color(Role::Primary);
|
let active = ui.color(Role::Primary);
|
||||||
@@ -128,41 +137,65 @@ fn check_progress(shot: &Shot, ui: &Ui) {
|
|||||||
"条的中间必须有墨(否则是没画出来,不是圆角)"
|
"条的中间必须有墨(否则是没画出来,不是圆角)"
|
||||||
);
|
);
|
||||||
|
|
||||||
// ---- 12.3 环形:40 的外接框,且四角无墨(真圆)------------------------------
|
// ---- 12.4 不定态:phase 0.55 那一列必须**数出两段**(复审 P1-A 的回归断言)----
|
||||||
let rings: Vec<BBox> = blobs(shot, active, 30)
|
//
|
||||||
|
// 旧实现把两段按返回顺序铺 `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()
|
.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();
|
.collect();
|
||||||
assert!(
|
assert_eq!(
|
||||||
!rings.is_empty(),
|
found.len(),
|
||||||
"12.3:没找到 40(±1) 的环形激活层;候选:{}",
|
2,
|
||||||
dims(&blobs(shot, active, 10))
|
"12.4:phase 0.55 必须是**两段**(P1-A 修完才成立),实测 {found:?} @ 行 {row}"
|
||||||
);
|
);
|
||||||
for ring in &rings {
|
|
||||||
for (x, y) in [
|
// A1 keyframes 手算:第二线 0.003..0.079、第一线 0.355..0.871(EmphasizedAccelerate)
|
||||||
(ring.left, ring.top),
|
let expected = [(0.003_f32, 0.079_f32), (0.355, 0.871)];
|
||||||
(ring.right, ring.top),
|
for (index, (start, len)) in found.iter().enumerate() {
|
||||||
(ring.left, ring.bottom),
|
let want_start = TRACK3_X0 as f32 + expected[index].0 * TRACK3_W as f32;
|
||||||
(ring.right, ring.bottom),
|
let want_end = TRACK3_X0 as f32 + expected[index].1 * TRACK3_W as f32;
|
||||||
] {
|
assert!(
|
||||||
assert!(
|
(*start as f32 - want_start).abs() <= 4.0,
|
||||||
!matches(shot.at(x, y), rgb(active)),
|
"12.4 第 {} 段左端应在 {want_start:.1}(实测 {start})",
|
||||||
"12.3:环必须是真圆(外接框四角不该有墨)@ ({x},{y})"
|
index + 1
|
||||||
);
|
);
|
||||||
}
|
assert!(
|
||||||
|
((start + len) as f32 - want_end).abs() <= 4.0,
|
||||||
|
"12.4 第 {} 段右端应在 {want_end:.1}(实测 {})",
|
||||||
|
index + 1,
|
||||||
|
start + len
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"[feedback-probe] progress:轨道带 {} 条(高 {:?})· 环 {} 个 40(±1)",
|
"[feedback-probe] progress:轨道带 {} 条(高 {:?})· phase 0.55 两段 {found:?}",
|
||||||
bar_bands.len(),
|
bar_bands.len(),
|
||||||
bar_bands
|
bar_bands
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(top, bottom)| bottom - top + 1)
|
.map(|(top, bottom)| bottom - top + 1)
|
||||||
.collect::<Vec<_>>(),
|
.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:宽度上下限 + 单/双行高。
|
/// §14:宽度上下限 + 单/双行高。
|
||||||
fn check_snackbar(shot: &Shot, ui: &Ui) {
|
fn check_snackbar(shot: &Shot, ui: &Ui) {
|
||||||
// 参考值写字面量(A1):600 = SnackbarKt.ContainerMaxWidth,48/68 = SnackbarTokens。
|
// 参考值写字面量(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
|
let mut widths: Vec<usize> = found
|
||||||
.iter()
|
.iter()
|
||||||
.map(|band| band_width(shot, *band, bar, surface))
|
.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)),
|
widths.iter().any(|w| near(*w, 600)),
|
||||||
"上限宽必须是 600(SnackbarKt.ContainerMaxWidth,宿主 800 时夹取),实测宽度集 {widths:?}"
|
"上限宽必须是 600(SnackbarKt.ContainerMaxWidth,宿主 800 时夹取),实测宽度集 {widths:?}"
|
||||||
);
|
);
|
||||||
// 最小宽的取值口径 344,以及「没有下限」的反证 300
|
|
||||||
assert!(
|
assert!(
|
||||||
widths.iter().any(|w| near(*w, 344)),
|
widths.iter().any(|w| near(*w, 344)),
|
||||||
"宿区 344 那格必须量到 344(G17-d [估] 最小宽口径),实测 {widths:?}"
|
"宿区 368 那格必须量到 344(G17-d [估] 最小宽口径),实测 {widths:?}"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
widths.iter().any(|w| near(*w, 300)),
|
widths.iter().any(|w| near(*w, 300)),
|
||||||
"宿区 300 那格必须量到 300(组件没有 min-width,G17-d),实测 {widths:?}"
|
"宿区 324 那格必须量到 300(组件没有 min-width,G17-d),实测 {widths:?}"
|
||||||
);
|
);
|
||||||
// 高度 48 / 68
|
|
||||||
assert!(
|
assert!(
|
||||||
heights.iter().any(|h| near(*h, 48)),
|
heights.iter().any(|h| near(*h, 48)),
|
||||||
"14.3:单行高 48,实测高度集 {heights:?}"
|
"14.3:单行高 48,实测高度集 {heights:?}"
|
||||||
@@ -237,133 +266,3 @@ fn check_snackbar(shot: &Shot, ui: &Ui) {
|
|||||||
"[feedback-probe] snackbar:宽度集 {widths:?} · 高度集 {heights:?} · InversePrimary {action_pixels}px"
|
"[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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
//! 环形进度的渲染层判据(`probe` 的子模块,`inputs/probe/` 的同一先例)。
|
||||||
|
//!
|
||||||
|
//! 量两件事(`research/12b` §12.3):
|
||||||
|
//!
|
||||||
|
//! - 环的**外接框 40(±1)** —— 直径是 A1 `CircularProgressIndicatorTokens.Size`;
|
||||||
|
//! - 环的外接框**四角无墨** —— 真圆,不是圆角方。
|
||||||
|
//!
|
||||||
|
//! 不定态的**节奏**(4 圈/周期、跨周期不跳变)不在这里量:那是时间的函数,截图只有一帧,
|
||||||
|
//! 判据在 `tests.rs::circular_indeterminate_rotation_is_four_turns_and_continuous`。
|
||||||
|
|
||||||
|
use unlucky_design::color::Role;
|
||||||
|
|
||||||
|
use super::super::pixel::{blobs, dims, matches, near, rgb, Shot};
|
||||||
|
use crate::theme::Ui;
|
||||||
|
|
||||||
|
/// 环直径(A1 `CircularProgressIndicatorTokens.Size = 40`)。
|
||||||
|
const DIAMETER: usize = 40;
|
||||||
|
|
||||||
|
/// 环形那一排的判据。
|
||||||
|
pub(super) fn check(shot: &Shot, ui: &Ui) {
|
||||||
|
let active = ui.color(Role::Primary);
|
||||||
|
let rings: Vec<_> = blobs(shot, active, 30)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|bbox| near(bbox.width(), DIAMETER) && near(bbox.height(), DIAMETER))
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
!rings.is_empty(),
|
||||||
|
"12.3:没找到 40(±1) 的环形激活层;候选:{}",
|
||||||
|
dims(&blobs(shot, active, 10))
|
||||||
|
);
|
||||||
|
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})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
eprintln!(
|
||||||
|
"[feedback-probe] circular:环 {} 个 {}",
|
||||||
|
rings.len(),
|
||||||
|
dims(&rings)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -93,6 +93,71 @@ impl Progress {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 把 0..1 的区间**排序 + 合并重叠**,摊成「间隙 / 指示条」交替的列表(总长恒为 1.0)。
|
||||||
|
///
|
||||||
|
/// ## 为什么必须排序(复审 P1-A,视觉 r1 的 p1)
|
||||||
|
///
|
||||||
|
/// [`phase_segments`] 返回的两段**不保证有序**:第一线的前缘抢跑 250ms,所以它的区间在右,
|
||||||
|
/// 第二线从 0 起跑的区间在左。照顺序铺 `FillPortion` 会把左段接到右段**之后**,并且
|
||||||
|
/// `FillPortion` 之和一旦 ≠ 1000,iced 就按总和归一 —— 位置和宽度一起被压扁。
|
||||||
|
/// 实测 phase 0.55:本应两段 `[0.003,0.079]` + `[0.355,0.860]`(x 689..713 / 802..963),
|
||||||
|
/// 实际被画成一段 100px 的台阶(x 749..848),且 **34/100 个相位**上总和 >1(最大 1.99)。
|
||||||
|
///
|
||||||
|
/// 返回的每项是 `(是不是指示条, 占比)`;间隙项也显式给出来,才能断言「总和 = 1.0」。
|
||||||
|
pub fn layout_spans(spans: &[(f32, f32)]) -> Vec<(bool, f32)> {
|
||||||
|
let mut clipped: Vec<(f32, f32)> = spans
|
||||||
|
.iter()
|
||||||
|
.map(|(start, end)| (start.clamp(0.0, 1.0), end.clamp(0.0, 1.0)))
|
||||||
|
.filter(|(start, end)| end > start)
|
||||||
|
.collect();
|
||||||
|
clipped.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||||
|
|
||||||
|
let mut merged: Vec<(f32, f32)> = Vec::new();
|
||||||
|
for (start, end) in clipped {
|
||||||
|
match merged.last_mut() {
|
||||||
|
Some(last) if start <= last.1 => last.1 = last.1.max(end),
|
||||||
|
_ => merged.push((start, end)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut items = Vec::new();
|
||||||
|
let mut cursor = 0.0_f32;
|
||||||
|
for (start, end) in merged {
|
||||||
|
if start > cursor {
|
||||||
|
items.push((false, start - cursor));
|
||||||
|
}
|
||||||
|
items.push((true, end - start));
|
||||||
|
cursor = end;
|
||||||
|
}
|
||||||
|
if cursor < 1.0 {
|
||||||
|
items.push((false, 1.0 - cursor));
|
||||||
|
}
|
||||||
|
items
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 占比 → `FillPortion` 整数份额,**总和恒为 `PORTIONS`**(累计舍入,最后一项吃掉残差)。
|
||||||
|
///
|
||||||
|
/// 为什么不用「每项各自 round」:各项独立取整后总和可能是 999 或 1001,iced 按总和归一,
|
||||||
|
/// 就又回到 P1-A 那个「整条被压扁」的坑。这里让每个**累计边界**取整,最后一项补足,
|
||||||
|
/// 于是任意相位下份额之和都精确等于 1000。
|
||||||
|
pub fn portion_shares(items: &[(bool, f32)]) -> Vec<(bool, u16)> {
|
||||||
|
let mut shares = Vec::with_capacity(items.len());
|
||||||
|
let mut accumulated = 0.0_f32;
|
||||||
|
let mut previous = 0_u16;
|
||||||
|
for (index, (is_bar, fraction)) in items.iter().enumerate() {
|
||||||
|
accumulated += fraction;
|
||||||
|
let target = if index + 1 == items.len() {
|
||||||
|
PORTIONS as u16
|
||||||
|
} else {
|
||||||
|
(accumulated * PORTIONS).round() as u16
|
||||||
|
};
|
||||||
|
let share = target.saturating_sub(previous);
|
||||||
|
previous = target;
|
||||||
|
shares.push((*is_bar, share));
|
||||||
|
}
|
||||||
|
shares
|
||||||
|
}
|
||||||
|
|
||||||
/// 线性进度条(轨道 + 指示条,厚 4、圆角 full)。
|
/// 线性进度条(轨道 + 指示条,厚 4、圆角 full)。
|
||||||
///
|
///
|
||||||
/// 轨道宽 `Fill`(由宿主决定),指示条按占比分到 `FillPortion` 上——**不需要知道轨道像素宽**,
|
/// 轨道宽 `Fill`(由宿主决定),指示条按占比分到 `FillPortion` 上——**不需要知道轨道像素宽**,
|
||||||
@@ -105,18 +170,13 @@ pub fn linear<'a, M: 'a>(ui: &'a Ui, progress: Progress) -> Element<'a, M> {
|
|||||||
let mut bar = row![]
|
let mut bar = row![]
|
||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
.height(Length::Fixed(geometry.thickness));
|
.height(Length::Fixed(geometry.thickness));
|
||||||
let mut cursor = 0.0_f32;
|
for (is_bar, share) in portion_shares(&layout_spans(&progress.spans())) {
|
||||||
for (start, end) in progress.spans() {
|
if share == 0 {
|
||||||
let (start, end) = (start.clamp(0.0, 1.0), end.clamp(0.0, 1.0));
|
|
||||||
if end <= start {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if start > cursor {
|
bar = bar.push(if is_bar {
|
||||||
bar = bar.push(spacer(start - cursor));
|
|
||||||
}
|
|
||||||
bar = bar.push(
|
|
||||||
container(Space::new().width(Length::Fill).height(Length::Fill))
|
container(Space::new().width(Length::Fill).height(Length::Fill))
|
||||||
.width(Length::FillPortion(portion(end - start)))
|
.width(Length::FillPortion(share))
|
||||||
.height(Length::Fill)
|
.height(Length::Fill)
|
||||||
.style(move |_theme| container::Style {
|
.style(move |_theme| container::Style {
|
||||||
background: Some(Background::Color(active_color)),
|
background: Some(Background::Color(active_color)),
|
||||||
@@ -125,12 +185,11 @@ pub fn linear<'a, M: 'a>(ui: &'a Ui, progress: Progress) -> Element<'a, M> {
|
|||||||
..Border::default()
|
..Border::default()
|
||||||
},
|
},
|
||||||
..container::Style::default()
|
..container::Style::default()
|
||||||
}),
|
})
|
||||||
);
|
.into()
|
||||||
cursor = end;
|
} else {
|
||||||
}
|
spacer(share)
|
||||||
if cursor < 1.0 {
|
});
|
||||||
bar = bar.push(spacer(1.0 - cursor));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
container(bar)
|
container(bar)
|
||||||
@@ -153,8 +212,9 @@ pub fn circular<'a, M: 'a>(ui: &'a Ui, progress: Progress) -> Element<'a, M> {
|
|||||||
let (sweep, rotation) = match progress.amount {
|
let (sweep, rotation) = match progress.amount {
|
||||||
Amount::Determinate(value) => (value.clamp(0.0, 1.0) * 360.0, 0.0),
|
Amount::Determinate(value) => (value.clamp(0.0, 1.0) * 360.0, 0.0),
|
||||||
Amount::Indeterminate(phase) => {
|
Amount::Indeterminate(phase) => {
|
||||||
let (sweep_fraction, rotation_fraction) = geometry.indeterminate(phase);
|
// 圈数直接乘 360(不做 rem_euclid):每周期 4 圈是官方口径,截断就丢转速。
|
||||||
(sweep_fraction * 360.0, rotation_fraction * 360.0)
|
let (sweep_fraction, turns) = geometry.indeterminate(phase);
|
||||||
|
(sweep_fraction * 360.0, turns * 360.0)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -193,18 +253,13 @@ pub fn circular<'a, M: 'a>(ui: &'a Ui, progress: Progress) -> Element<'a, M> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 两段之间/两端的透明占位。
|
/// 两段之间/两端的透明占位。
|
||||||
fn spacer<'a, M: 'a>(fraction: f32) -> Element<'a, M> {
|
fn spacer<'a, M: 'a>(share: u16) -> Element<'a, M> {
|
||||||
Space::new()
|
Space::new()
|
||||||
.width(Length::FillPortion(portion(fraction)))
|
.width(Length::FillPortion(share))
|
||||||
.height(Length::Fill)
|
.height(Length::Fill)
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 0..1 的占比 → `FillPortion`(≥1,避免 0 段;调用方已过滤空段)。
|
|
||||||
fn portion(fraction: f32) -> u16 {
|
|
||||||
(fraction.clamp(0.0, 1.0) * PORTIONS).round().max(1.0) as u16
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 一层几何图元(固定尺寸 + `currentColor` 上色)。
|
/// 一层几何图元(固定尺寸 + `currentColor` 上色)。
|
||||||
fn glyph<'a, M: 'a>(
|
fn glyph<'a, M: 'a>(
|
||||||
geometry: &CircularGeometry,
|
geometry: &CircularGeometry,
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
//! 进度两式的纯函数断言(`tests` 的子模块,从 `tests.rs` 拆出来控行数)。
|
||||||
|
//!
|
||||||
|
//! 三条回归都住在这里:
|
||||||
|
//! - [`raw_segments_are_unordered_and_layout_sorts_them`]:`phase_segments` 的原始输出**不有序**
|
||||||
|
//! (视觉 r1 P1-A 就是把两段按顺序铺 `FillPortion`,图上被压成一段);
|
||||||
|
//! - [`portion_shares_always_sum_to_the_full_track`]:份额之和恒为 1000(旧实现 34/100 相位 >1);
|
||||||
|
//! - [`circular_indeterminate_rotation_is_four_turns_and_continuous`]:每周期 4 圈、跨周期不跳变
|
||||||
|
//! (代码 r1 P1-B:旧实现只有 1 圈,且末尾倒跳 270°)。
|
||||||
|
|
||||||
|
use unlucky_design::color::SchemeFlavor;
|
||||||
|
|
||||||
|
use super::super::geometry::{phase_segments, CircularGeometry, LinearGeometry};
|
||||||
|
use super::super::keyframes::{KeyPoint, Keyframes};
|
||||||
|
use super::super::progress::{layout_spans, portion_shares, Amount, Progress};
|
||||||
|
use super::ui;
|
||||||
|
|
||||||
|
// ---- §12 进度 ---------------------------------------------------------------
|
||||||
|
|
||||||
|
/// 12.1:线性厚度 **4**、圆角 full ⇒ 2(`ShapeScale::Full.radius_px(4)`)。
|
||||||
|
#[test]
|
||||||
|
fn linear_thickness_and_radius_come_from_the_tokens() {
|
||||||
|
let geometry = LinearGeometry::of(&ui(SchemeFlavor::Light));
|
||||||
|
assert_eq!(geometry.thickness, 4.0, "12.1 线性厚度 = 4(A1 Height)");
|
||||||
|
assert_eq!(
|
||||||
|
geometry.radius,
|
||||||
|
ShapeScale::Full.radius_px(4.0),
|
||||||
|
"12.1 圆角 full = 厚的一半"
|
||||||
|
);
|
||||||
|
assert_eq!(geometry.radius, 2.0);
|
||||||
|
assert_eq!(
|
||||||
|
geometry.cycle_ms, 1750,
|
||||||
|
"不定态周期 = LinearAnimationDuration"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 12.3:环形 **40 / 4**,环中线半径 (40 − 4) / 2 = **18**。
|
||||||
|
#[test]
|
||||||
|
fn circular_geometry_matches_the_tokens() {
|
||||||
|
let geometry = CircularGeometry::of(&ui(SchemeFlavor::Dark));
|
||||||
|
assert_eq!(
|
||||||
|
geometry.diameter, 40.0,
|
||||||
|
"A1 CircularProgressIndicatorTokens.Size"
|
||||||
|
);
|
||||||
|
assert_eq!(geometry.thickness, 4.0, "A1 ActiveThickness");
|
||||||
|
assert_eq!(geometry.radius, 20.0, "圆角 full = 直径的一半");
|
||||||
|
assert_eq!(geometry.arc_radius(), 18.0);
|
||||||
|
assert_eq!(
|
||||||
|
geometry.cycle_ms, 6000,
|
||||||
|
"A1 CircularAnimationProgressDuration"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 12.4:不定态两段**逐段非负**——这条只证「单段自身合法」,**不证两段的先后**
|
||||||
|
/// (视觉 r1 P1-A 就是从这里漏过去的:函数名曾经叫 `..._stay_ordered`,其实没验排序)。
|
||||||
|
#[test]
|
||||||
|
fn indeterminate_segments_are_each_well_formed() {
|
||||||
|
let mut travelled = false;
|
||||||
|
let mut previous_end = 0.0;
|
||||||
|
for step in 0..=200 {
|
||||||
|
let phase = step as f32 / 200.0;
|
||||||
|
for (start, end) in phase_segments(phase) {
|
||||||
|
assert!(
|
||||||
|
start <= end + f32::EPSILON,
|
||||||
|
"相位 {phase:.3}:段起点 {start} 大于终点 {end}(前缘/后缘写反就会这样)"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(0.0..=1.0).contains(&start) && (0.0..=1.0).contains(&end),
|
||||||
|
"相位 {phase:.3}:段越出轨道 {start}..{end}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let first = phase_segments(phase)[0].1;
|
||||||
|
if phase > 0.0 && phase < 0.5 && first > previous_end {
|
||||||
|
travelled = true;
|
||||||
|
}
|
||||||
|
previous_end = first;
|
||||||
|
}
|
||||||
|
assert!(travelled, "段前沿必须随相位右移(否则就是一根不动的条)");
|
||||||
|
assert_eq!(phase_segments(0.0), [(0.0, 0.0), (0.0, 0.0)]);
|
||||||
|
let (_, head) = phase_segments(1000.0 / 1750.0)[0];
|
||||||
|
assert!((head - 1.0).abs() < 1.0e-5, "第一线前沿应在 1000ms 到 1.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **复审 P1-A 的回归测试**:`phase_segments` 的原始输出**不有序**(第一线的前缘在右、第二线在左),
|
||||||
|
/// 所以铺布局前必须排序 + 合并;排序后的两段位置要与 A1 keyframes 对得上。
|
||||||
|
#[test]
|
||||||
|
fn raw_segments_are_unordered_and_layout_sorts_them() {
|
||||||
|
// 原始顺序:第一线(右,0.355..0.87)在前,第二线(左,0.003..0.079)在后
|
||||||
|
let raw = phase_segments(0.55);
|
||||||
|
assert!(
|
||||||
|
raw[0].0 > raw[1].0,
|
||||||
|
"phase 0.55 的原始两段应当是「右段在前、左段在后」,实际 {raw:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let laid = layout_spans(&raw);
|
||||||
|
// 期望:间隙 / 左段 / 间隙 / 右段 / 间隙(5 项,总长 1.0)
|
||||||
|
assert_eq!(laid.len(), 5, "两段应摊成 5 项:{laid:?}");
|
||||||
|
assert!(!laid[0].0 && laid[1].0 && !laid[2].0 && laid[3].0 && !laid[4].0);
|
||||||
|
let left = laid[1].1;
|
||||||
|
let right = laid[3].1;
|
||||||
|
let left_start: f32 = laid[0].1;
|
||||||
|
let right_start: f32 = laid[0].1 + left + laid[2].1;
|
||||||
|
// A1 keyframes 的手算值:第二线 0.003..0.079、第一线 0.355..0.871(容差 0.01)
|
||||||
|
assert!((left_start - 0.003).abs() < 0.01, "左段起点 {left_start}");
|
||||||
|
assert!((left - 0.076).abs() < 0.01, "左段宽 {left}");
|
||||||
|
assert!((right_start - 0.355).abs() < 0.01, "右段起点 {right_start}");
|
||||||
|
assert!((right - 0.516).abs() < 0.01, "右段宽 {right}");
|
||||||
|
|
||||||
|
let total: f32 = laid.iter().map(|(_, fraction)| fraction).sum();
|
||||||
|
assert!(
|
||||||
|
(total - 1.0).abs() < 1.0e-5,
|
||||||
|
"铺开的占比之和必须 = 1.0,实测 {total}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 任意相位下 `FillPortion` 份额之和**恒为 1000**(iced 按总和归一 ⇒ 不等于 1000 就会压扁)。
|
||||||
|
///
|
||||||
|
/// 复审 P1-A 的实测反例:旧实现 **34/100 个相位**总和 >1(最大 1.99)。
|
||||||
|
#[test]
|
||||||
|
fn portion_shares_always_sum_to_the_full_track() {
|
||||||
|
for step in 0..=200 {
|
||||||
|
let phase = step as f32 / 200.0;
|
||||||
|
let items = layout_spans(&phase_segments(phase));
|
||||||
|
let shares = portion_shares(&items);
|
||||||
|
let total: u32 = shares.iter().map(|(_, share)| u32::from(*share)).sum();
|
||||||
|
assert_eq!(total, 1000, "相位 {phase:.3} 的份额之和是 {total}");
|
||||||
|
assert!(
|
||||||
|
shares.iter().filter(|(is_bar, _)| *is_bar).count() <= 2,
|
||||||
|
"最多两段指示条,相位 {phase:.3}"
|
||||||
|
);
|
||||||
|
// 指示条之间必须留间隙(除非两段相接,那时会被合并成一段)
|
||||||
|
let mut previous_bar = false;
|
||||||
|
for (is_bar, share) in &shares {
|
||||||
|
if *is_bar && previous_bar && *share > 0 {
|
||||||
|
panic!("相位 {phase:.3}:两段指示条相邻但没合并");
|
||||||
|
}
|
||||||
|
previous_bar = *is_bar;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 环形不定态:弧长在 0.1..0.87 之间(A1 min/max),旋转走满一圈。
|
||||||
|
#[test]
|
||||||
|
fn circular_indeterminate_sweeps_the_official_range() {
|
||||||
|
let geometry = CircularGeometry::of(&ui(SchemeFlavor::Light));
|
||||||
|
let mut min = f32::MAX;
|
||||||
|
let mut max = f32::MIN;
|
||||||
|
let mut rotation = f32::MIN;
|
||||||
|
for step in 0..=600 {
|
||||||
|
let (sweep, spin) = geometry.indeterminate(step as f32 / 600.0);
|
||||||
|
min = min.min(sweep);
|
||||||
|
max = max.max(sweep);
|
||||||
|
rotation = rotation.max(spin);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
(min - 0.1).abs() < 1.0e-3,
|
||||||
|
"弧长下限 0.1(CircularIndeterminateMinProgress),实测 {min}"
|
||||||
|
);
|
||||||
|
assert!((max - 0.87).abs() < 1.0e-3, "弧长上限 0.87,实测 {max}");
|
||||||
|
assert!(
|
||||||
|
(rotation - 1.0).abs() < 1.0e-3,
|
||||||
|
"旋转应走满一整圈,实测 {rotation}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **复审 P1-B 的回归测试**:环形不定态每周期 4 圈(官方 1440°),且跨周期**不跳变**。
|
||||||
|
///
|
||||||
|
/// 修之前:`ROTATION` 首点在 300ms/90°,且没有全局旋转 ⇒
|
||||||
|
/// `turns(0.9999) = 1.0`、`turns(0.0) = 0.25`,跨周期差 0.75 圈 = **270° 瞬时倒跳**。
|
||||||
|
#[test]
|
||||||
|
fn circular_indeterminate_rotation_is_four_turns_and_continuous() {
|
||||||
|
let geometry = CircularGeometry::of(&ui(SchemeFlavor::Light));
|
||||||
|
|
||||||
|
// 每周期 4 圈 = 1440°(1080° 全局 + 360° 关键帧)
|
||||||
|
let start = geometry.indeterminate(0.0).1;
|
||||||
|
let end = geometry.indeterminate(1.0 - 1.0e-6).1;
|
||||||
|
assert!(
|
||||||
|
((end - start) - 4.0).abs() < 1.0e-3,
|
||||||
|
"每周期应为 4 圈(1440°),实测 {} 圈",
|
||||||
|
end - start
|
||||||
|
);
|
||||||
|
|
||||||
|
// 跨周期连续性:按「圈」取最短距离,必须 ≤ 一帧的位移(60fps ⇒ 4/60 ≈ 0.067 圈)
|
||||||
|
let before = geometry.indeterminate(1.0 - 1.0e-4).1;
|
||||||
|
let after = geometry.indeterminate(0.0).1;
|
||||||
|
let mut jump = (before - after).rem_euclid(1.0);
|
||||||
|
if jump > 0.5 {
|
||||||
|
jump = 1.0 - jump;
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
jump <= 4.0 / 60.0,
|
||||||
|
"跨周期旋转跳变 {jump} 圈(应 ≤ 4/60;修之前是 0.75 圈 = 270°)"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 旋转必须单调(相位 0 → 1 逐帧不回退,按圈算)
|
||||||
|
let mut previous = geometry.indeterminate(0.0).1;
|
||||||
|
for step in 1..=600 {
|
||||||
|
let phase = step as f32 / 600.0;
|
||||||
|
let current = geometry.indeterminate(phase).1;
|
||||||
|
assert!(
|
||||||
|
current >= previous - 1.0e-6,
|
||||||
|
"相位 {phase:.3}:旋转从 {previous} 回退到 {current}"
|
||||||
|
);
|
||||||
|
previous = current;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 确定态:越界值夹到 0..1(0% 只画轨道,100% 画满)。
|
||||||
|
#[test]
|
||||||
|
fn determinate_values_are_clamped() {
|
||||||
|
assert_eq!(
|
||||||
|
Progress::determinate(1.5).amount(),
|
||||||
|
Amount::Determinate(1.0)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Progress::determinate(-2.0).amount(),
|
||||||
|
Amount::Determinate(0.0)
|
||||||
|
);
|
||||||
|
assert_eq!(Progress::determinate(0.5).spans(), vec![(0.0, 0.5)]);
|
||||||
|
assert_eq!(Progress::determinate(0.0).spans(), vec![(0.0, 0.0)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// keyframes 求值器:端点保持、相位循环、越界相位按周期取模。
|
||||||
|
#[test]
|
||||||
|
fn keyframes_hold_ends_and_wrap() {
|
||||||
|
const LINE: Keyframes = Keyframes {
|
||||||
|
cycle_ms: 1000,
|
||||||
|
points: &[
|
||||||
|
KeyPoint {
|
||||||
|
ms: 250,
|
||||||
|
value: 0.0,
|
||||||
|
easing: unlucky_design::motion::Easing::Linear,
|
||||||
|
},
|
||||||
|
KeyPoint {
|
||||||
|
ms: 750,
|
||||||
|
value: 1.0,
|
||||||
|
easing: unlucky_design::motion::Easing::Linear,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
assert_eq!(LINE.sample(0.0), 0.0, "第一点之前保持首值");
|
||||||
|
assert_eq!(LINE.sample(0.25), 0.0, "正好在第一点");
|
||||||
|
assert!((LINE.sample(0.5) - 0.5).abs() < 1.0e-5, "中点线性插值");
|
||||||
|
assert_eq!(LINE.sample(0.75), 1.0);
|
||||||
|
assert_eq!(LINE.sample(0.9), 1.0, "最后一点之后保持末值");
|
||||||
|
assert_eq!(LINE.sample(1.25), 0.0, "相位 1.25 = 0.25(循环)");
|
||||||
|
assert_eq!(
|
||||||
|
LINE.sample(-0.75),
|
||||||
|
0.0,
|
||||||
|
"负相位按周期折返:-0.75 → 0.25 相位 = 250ms = 首点"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(LINE.sample(-0.6) - 0.3).abs() < 1.0e-5,
|
||||||
|
"-0.6 → 0.4 相位 = 400ms = 30%"
|
||||||
|
);
|
||||||
|
assert_eq!(LINE.sample(f32::NAN), 0.0, "NaN 不 panic,给首值");
|
||||||
|
}
|
||||||
Reference in new issue
Block a user