Files
Unlucky-Launcher/crates/app-iced/src/components/feedback/tests/progress.rs
T
AskaEth e9af27c8f1 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 行)。
2026-11-02 15:56:43 +08:00

257 lines
10 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 进度两式的纯函数断言(`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,给首值");
}