1use presentar_core::{
13 widget::{AccessibleRole, LayoutResult},
14 Brick, BrickAssertion, BrickBudget, BrickVerification, Canvas, Color, Constraints, Event,
15 Point, Rect, Size, TypeId, Widget,
16};
17use std::any::Any;
18use std::time::Duration;
19
20pub const DEFAULT_MAX_POINTS: usize = 1024;
22
23fn sanitize(sample: f32) -> f32 {
25 if sample.is_finite() {
26 sample.clamp(-1.0, 1.0)
27 } else {
28 0.0
29 }
30}
31
32const COORD_LIMIT: f32 = 1.0e15;
35
36fn sane(value: f32) -> f32 {
38 if value.is_finite() {
39 value.clamp(-COORD_LIMIT, COORD_LIMIT)
40 } else {
41 0.0
42 }
43}
44
45fn sane_bounds(bounds: Rect) -> Rect {
49 Rect::new(
50 sane(bounds.x),
51 sane(bounds.y),
52 sane(bounds.width).max(0.0),
53 sane(bounds.height).max(0.0),
54 )
55}
56
57fn valid_bounds(bounds: Rect) -> bool {
59 [bounds.x, bounds.y, bounds.width, bounds.height]
60 .iter()
61 .all(|v| v.is_finite())
62 && bounds.width >= 0.0
63 && bounds.height >= 0.0
64}
65
66fn sane_extent(value: f32) -> Option<f32> {
69 value.is_finite().then(|| value.clamp(0.0, COORD_LIMIT))
70}
71
72fn fit(value: f32, min: f32, max: f32) -> f32 {
76 sane(value.max(min).min(max)).max(0.0)
77}
78
79fn larger_magnitude(best: f32, candidate: f32) -> f32 {
81 if candidate.abs() > best.abs() {
82 candidate
83 } else {
84 best
85 }
86}
87
88fn bucket_edge(bucket: usize, len: usize, target: usize) -> usize {
91 let edge = bucket_edge_wide(bucket as u64, len as u64, target as u64);
92 usize::try_from(edge).map_or(len, |e| e.min(len))
93}
94
95fn bucket_edge_wide(bucket: u64, len: u64, target: u64) -> u64 {
97 let edge = (u128::from(bucket) * u128::from(len)) / u128::from(target);
98 u64::try_from(edge).map_or(len, |e| e.min(len))
99}
100
101fn reduce_levels(samples: &[f32], max_points: usize) -> Vec<f32> {
107 let target = max_points.max(2);
108 let len = samples.len();
109 let levels: Vec<f32> = if len <= target {
110 samples.iter().copied().map(sanitize).collect()
111 } else {
112 (0..target)
113 .map(|bucket| {
114 let start = bucket_edge(bucket, len, target);
115 let end = bucket_edge(bucket + 1, len, target);
116 samples
117 .get(start..end)
118 .unwrap_or_default()
119 .iter()
120 .copied()
121 .map(sanitize)
122 .fold(0.0, larger_magnitude)
123 })
124 .collect()
125 };
126 if levels.len() < 2 {
127 let level = levels.first().copied().unwrap_or(0.0);
128 return vec![level, level];
129 }
130 levels
131}
132
133#[must_use]
151pub fn waveform_points(samples: &[f32], bounds: Rect, max_points: usize) -> Vec<Point> {
152 let levels = reduce_levels(samples, max_points);
153 let safe = sane_bounds(bounds);
154 let (x0, y0, width, height) = (safe.x, safe.y, safe.width, safe.height);
155 let y1 = y0 + height;
156 let half = height / 2.0;
157 let centre = y0 + half;
158 let span = levels.len().saturating_sub(1).max(1) as f32;
159
160 levels
161 .iter()
162 .enumerate()
163 .map(|(index, &level)| {
164 let t = index as f32 / span;
165 let x = t.mul_add(width, x0);
167 let y = (-level).mul_add(half, centre).clamp(y0, y1);
168 Point::new(x, y)
169 })
170 .collect()
171}
172
173fn escape_attr(text: &str) -> String {
175 text.replace('&', "&")
176 .replace('"', """)
177 .replace('<', "<")
178 .replace('>', ">")
179}
180
181#[derive(Debug, Clone)]
183pub struct Waveform {
184 samples: Vec<f32>,
186 color: Color,
188 line_width: f32,
190 max_points: usize,
192 width: Option<f32>,
194 height: Option<f32>,
196 accessible_name_value: Option<String>,
198 test_id_value: Option<String>,
200 bounds: Rect,
202}
203
204impl Default for Waveform {
205 fn default() -> Self {
206 Self {
207 samples: Vec::new(),
208 color: Color::new(0.1, 0.6, 0.9, 1.0),
209 line_width: 1.5,
210 max_points: DEFAULT_MAX_POINTS,
211 width: None,
212 height: None,
213 accessible_name_value: None,
214 test_id_value: None,
215 bounds: Rect::default(),
216 }
217 }
218}
219
220impl Waveform {
221 #[must_use]
223 pub fn new() -> Self {
224 Self::default()
225 }
226
227 #[must_use]
229 pub fn samples(mut self, samples: Vec<f32>) -> Self {
230 self.samples = samples;
231 self
232 }
233
234 pub fn set_samples(&mut self, samples: &[f32]) {
236 self.samples.clear();
237 self.samples.extend_from_slice(samples);
238 }
239
240 #[must_use]
242 pub fn color(mut self, color: Color) -> Self {
243 self.color = color;
244 self
245 }
246
247 #[must_use]
250 pub fn line_width(mut self, width: f32) -> Self {
251 self.line_width = sane(width).max(0.0);
252 self
253 }
254
255 #[must_use]
257 pub fn max_points(mut self, max_points: usize) -> Self {
258 self.max_points = max_points;
259 self
260 }
261
262 #[must_use]
268 pub fn width(mut self, width: f32) -> Self {
269 self.width = sane_extent(width);
270 self
271 }
272
273 #[must_use]
275 pub fn height(mut self, height: f32) -> Self {
276 self.height = sane_extent(height);
277 self
278 }
279
280 #[must_use]
282 pub fn size(self, width: f32, height: f32) -> Self {
283 self.width(width).height(height)
284 }
285
286 #[must_use]
288 pub fn accessible_name(mut self, name: impl Into<String>) -> Self {
289 self.accessible_name_value = Some(name.into());
290 self
291 }
292
293 #[must_use]
295 pub fn test_id(mut self, id: impl Into<String>) -> Self {
296 self.test_id_value = Some(id.into());
297 self
298 }
299
300 #[must_use]
302 pub fn get_samples(&self) -> &[f32] {
303 &self.samples
304 }
305
306 #[must_use]
308 pub const fn get_color(&self) -> Color {
309 self.color
310 }
311
312 #[must_use]
314 pub fn get_line_width(&self) -> f32 {
315 sane(self.line_width).max(0.0)
316 }
317
318 #[must_use]
320 pub const fn get_max_points(&self) -> usize {
321 self.max_points
322 }
323
324 #[must_use]
326 pub fn peak(&self) -> f32 {
327 self.samples
328 .iter()
329 .copied()
330 .map(sanitize)
331 .fold(0.0, |best, v| best.max(v.abs()))
332 }
333
334 #[must_use]
336 pub fn rms(&self) -> f32 {
337 if self.samples.is_empty() {
338 return 0.0;
339 }
340 let sum: f64 = self
341 .samples
342 .iter()
343 .copied()
344 .map(sanitize)
345 .map(|v| f64::from(v) * f64::from(v))
346 .sum();
347 (sum / self.samples.len() as f64).sqrt() as f32
348 }
349
350 fn has_accessible_name(&self) -> bool {
352 self.accessible_name_value
353 .as_deref()
354 .is_some_and(|name| !name.trim().is_empty())
355 }
356}
357
358impl Widget for Waveform {
359 fn type_id(&self) -> TypeId {
360 TypeId::of::<Self>()
361 }
362
363 fn measure(&self, constraints: Constraints) -> Size {
364 Size::new(
367 fit(
368 self.width.unwrap_or(200.0),
369 constraints.min_width,
370 constraints.max_width,
371 ),
372 fit(
373 self.height.unwrap_or(80.0),
374 constraints.min_height,
375 constraints.max_height,
376 ),
377 )
378 }
379
380 fn layout(&mut self, bounds: Rect) -> LayoutResult {
381 self.bounds = bounds;
382 LayoutResult {
383 size: sane_bounds(bounds).size(),
384 }
385 }
386
387 fn paint(&self, canvas: &mut dyn Canvas) {
388 let points = waveform_points(&self.samples, self.bounds, self.max_points);
389 canvas.draw_path(&points, self.color, self.get_line_width());
390 }
391
392 fn event(&mut self, _event: &Event) -> Option<Box<dyn Any + Send>> {
393 None
394 }
395
396 fn children(&self) -> &[Box<dyn Widget>] {
397 &[]
398 }
399
400 fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
401 &mut []
402 }
403
404 fn accessible_name(&self) -> Option<&str> {
405 self.accessible_name_value.as_deref()
406 }
407
408 fn accessible_role(&self) -> AccessibleRole {
409 AccessibleRole::Image
410 }
411
412 fn test_id(&self) -> Option<&str> {
413 self.test_id_value.as_deref()
414 }
415
416 fn bounds(&self) -> Rect {
417 self.bounds
418 }
419}
420
421impl Brick for Waveform {
423 fn brick_name(&self) -> &'static str {
424 "Waveform"
425 }
426
427 fn assertions(&self) -> &[BrickAssertion] {
428 &[BrickAssertion::MaxLatencyMs(16)]
429 }
430
431 fn budget(&self) -> BrickBudget {
432 BrickBudget::uniform(16)
433 }
434
435 fn verify(&self) -> BrickVerification {
436 let mut passed = self.assertions().to_vec();
437 let mut failed = Vec::new();
438
439 let named = BrickAssertion::ElementPresent("accessible-name".to_string());
441 if self.has_accessible_name() {
442 passed.push(named);
443 } else {
444 failed.push((named, "Waveform has no accessible name".to_string()));
445 }
446
447 let bounds_ok = BrickAssertion::ElementPresent("valid-bounds".to_string());
449 if valid_bounds(self.bounds) {
450 passed.push(bounds_ok);
451 } else {
452 failed.push((
453 bounds_ok,
454 "Waveform bounds are not finite and >= 0".to_string(),
455 ));
456 }
457
458 BrickVerification {
459 passed,
460 failed,
461 verification_time: Duration::from_micros(10),
462 }
463 }
464
465 fn to_html(&self) -> String {
466 let label = escape_attr(self.accessible_name_value.as_deref().unwrap_or(""));
467 format!(r#"<canvas class="brick-waveform" role="img" aria-label="{label}"></canvas>"#)
468 }
469
470 fn to_css(&self) -> String {
471 ".brick-waveform { display: block; }".to_string()
472 }
473
474 fn test_id(&self) -> Option<&str> {
475 self.test_id_value.as_deref()
476 }
477}
478
479#[cfg(test)]
480#[allow(clippy::unwrap_used, clippy::disallowed_methods)]
481mod tests {
482 use super::*;
483 use presentar_core::draw::DrawCommand;
484 use presentar_core::RecordingCanvas;
485 use proptest::prelude::*;
486
487 const B: Rect = Rect {
488 x: 10.0,
489 y: 20.0,
490 width: 100.0,
491 height: 40.0,
492 };
493
494 fn centre_y(b: Rect) -> f32 {
495 b.y + b.height / 2.0
496 }
497
498 fn inside(p: &Point, b: Rect) -> bool {
499 p.x >= b.x && p.x <= b.x + b.width && p.y >= b.y && p.y <= b.y + b.height
500 }
501
502 #[test]
505 fn points_empty_is_centre_line() {
506 let pts = waveform_points(&[], B, 64);
507 assert_eq!(pts.len(), 2);
508 assert_eq!(pts[0], Point::new(10.0, 40.0));
509 assert_eq!(pts[1], Point::new(110.0, 40.0));
510 }
511
512 #[test]
513 fn points_all_zero_on_centre() {
514 let pts = waveform_points(&[0.0; 50], B, 64);
515 assert_eq!(pts.len(), 50);
516 assert!(pts.iter().all(|p| p.y == centre_y(B)));
517 }
518
519 #[test]
520 fn points_map_extremes_to_edges() {
521 let pts = waveform_points(&[1.0, 0.0, -1.0], B, 64);
522 assert_eq!(pts[0].y, 20.0); assert_eq!(pts[1].y, 40.0); assert_eq!(pts[2].y, 60.0); }
526
527 #[test]
528 fn points_x_spans_bounds() {
529 let pts = waveform_points(&[0.1, 0.2, 0.3, 0.4, 0.5], B, 64);
530 assert_eq!(pts[0].x, 10.0);
531 assert_eq!(pts[4].x, 110.0);
532 assert_eq!(pts[2].x, 60.0);
533 }
534
535 #[test]
536 fn points_clamp_out_of_range() {
537 let pts = waveform_points(&[5.0, -5.0, f32::MAX, f32::MIN], B, 64);
538 assert_eq!(pts[0].y, 20.0);
539 assert_eq!(pts[1].y, 60.0);
540 assert_eq!(pts[2].y, 20.0);
541 assert_eq!(pts[3].y, 60.0);
542 }
543
544 #[test]
545 fn points_nonfinite_is_silence() {
546 let pts = waveform_points(&[f32::NAN, f32::INFINITY, f32::NEG_INFINITY], B, 64);
547 assert!(pts.iter().all(|p| p.y == centre_y(B)));
548 }
549
550 #[test]
551 fn points_downsample_keeps_peaks() {
552 let mut samples = vec![0.1_f32; 100];
553 samples[37] = -0.9;
554 let bounds = Rect::new(0.0, 0.0, 100.0, 20.0);
555 let pts = waveform_points(&samples, bounds, 10);
556 assert_eq!(pts.len(), 10);
557 assert!(pts.iter().any(|p| (p.y - 19.0).abs() < 1e-4));
559 assert!((pts[3].y - 19.0).abs() < 1e-4);
561 assert!((pts[2].y - 9.0).abs() < 1e-4);
562 }
563
564 #[test]
565 fn points_downsample_largest_magnitude_ignores_sign() {
566 let pts = waveform_points(&[-0.5, 0.8, 0.8, -0.5, 0.0, 0.0], B, 3);
569 assert_eq!(pts.len(), 3);
570 let top = centre_y(B) - 0.8 * 20.0;
571 assert!((pts[0].y - top).abs() < 1e-4);
572 assert!((pts[1].y - top).abs() < 1e-4);
573 assert_eq!(pts[2].y, centre_y(B));
574 }
575
576 #[test]
577 fn points_max_points_zero_and_one_do_not_panic() {
578 for max in [0_usize, 1] {
579 let pts = waveform_points(&[0.5, -0.5, 0.25, 0.0], B, max);
580 assert_eq!(pts.len(), 2);
581 assert!(pts.iter().all(|p| inside(p, B)));
582 let none = waveform_points(&[], B, max);
583 assert_eq!(none.len(), 2);
584 }
585 }
586
587 #[test]
588 fn points_huge_max_points_is_all_samples() {
589 let pts = waveform_points(&[0.1, 0.2, 0.3], B, usize::MAX);
590 assert_eq!(pts.len(), 3);
591 }
592
593 #[test]
594 fn points_single_sample_is_flat_line() {
595 let pts = waveform_points(&[0.5], B, 64);
596 assert_eq!(pts.len(), 2);
597 assert_eq!(pts[0].y, pts[1].y);
598 assert_eq!(pts[0].x, 10.0);
599 assert_eq!(pts[1].x, 110.0);
600 }
601
602 #[test]
603 fn points_degenerate_bounds_stay_inside() {
604 for b in [
605 Rect::new(5.0, 7.0, 0.0, 0.0),
606 Rect::new(5.0, 7.0, 0.0, 30.0),
607 Rect::new(5.0, 7.0, 30.0, 0.0),
608 Rect::new(5.0, 7.0, -3.0, -3.0),
609 Rect::new(f32::NAN, f32::INFINITY, f32::NAN, f32::INFINITY),
610 ] {
611 let pts = waveform_points(&[1.0, -1.0, 0.3], b, 8);
612 assert!(finite_and_inside(&pts, b), "{b:?}");
613 }
614 }
615
616 #[test]
617 fn points_table_of_generated_inputs_stay_inside() {
618 let mut state = 0x2545_F491_4F6C_DD1D_u64;
620 let mut next = move || {
621 state ^= state << 13;
622 state ^= state >> 7;
623 state ^= state << 17;
624 state
625 };
626 let specials = [
627 f32::NAN,
628 f32::INFINITY,
629 f32::NEG_INFINITY,
630 f32::MAX,
631 f32::MIN,
632 ];
633 for _ in 0..200 {
634 let n = (next() % 300) as usize;
635 let samples: Vec<f32> = (0..n)
636 .map(|_| {
637 let r = next();
638 if r % 11 == 0 {
639 specials[(r >> 8) as usize % specials.len()]
640 } else {
641 ((r >> 16) % 4001) as f32 / 1000.0 - 2.0
642 }
643 })
644 .collect();
645 let b = Rect::new(
646 (next() % 2000) as f32 - 1000.0,
647 (next() % 2000) as f32 - 1000.0,
648 (next() % 500) as f32,
649 (next() % 500) as f32,
650 );
651 let max = (next() % 70) as usize;
652 let pts = waveform_points(&samples, b, max);
653 assert!(pts.len() >= 2 && pts.len() <= max.max(2));
654 assert!(pts.iter().all(|p| inside(p, b)));
655 assert!(pts.windows(2).all(|w| w[0].x <= w[1].x));
656 }
657 }
658
659 fn bound_coord() -> impl Strategy<Value = f32> {
661 prop_oneof![-1.0e6_f32..1.0e6, proptest::num::f32::ANY]
662 }
663
664 proptest! {
665 #[test]
666 fn prop_points_inside_bounds(
667 samples in proptest::collection::vec(proptest::num::f32::ANY, 0..200),
668 x in bound_coord(),
669 y in bound_coord(),
670 w in bound_coord(),
671 h in bound_coord(),
672 max in 0_usize..300,
673 ) {
674 let b = Rect::new(x, y, w, h);
675 let pts = waveform_points(&samples, b, max);
676 prop_assert!(finite_and_inside(&pts, b), "{:?} -> {:?}", b, pts);
677 }
678
679 #[test]
680 fn prop_points_len_bounded(
681 samples in proptest::collection::vec(proptest::num::f32::ANY, 0..200),
682 max in 0_usize..300,
683 ) {
684 let pts = waveform_points(&samples, B, max);
685 prop_assert!(pts.len() >= 2);
686 prop_assert!(pts.len() <= max.max(2));
687 }
688
689 #[test]
690 fn prop_points_x_non_decreasing(
691 samples in proptest::collection::vec(proptest::num::f32::ANY, 0..200),
692 x in -1.0e6_f32..1.0e6,
693 w in 0.0_f32..1.0e6,
694 max in 0_usize..300,
695 ) {
696 let pts = waveform_points(&samples, Rect::new(x, 0.0, w, 10.0), max);
697 prop_assert!(pts.windows(2).all(|p| p[0].x <= p[1].x));
698 }
699
700 #[test]
701 fn prop_silence_is_centre_line(
702 n in 0_usize..200,
703 y in -1.0e6_f32..1.0e6,
704 h in 0.0_f32..1.0e6,
705 max in 0_usize..300,
706 ) {
707 let b = Rect::new(0.0, y, 50.0, h);
708 let pts = waveform_points(&vec![0.0; n], b, max);
709 let c = y + h / 2.0;
710 prop_assert!(pts.iter().all(|p| p.y == c));
711 }
712 }
713
714 const LIMIT: f32 = 1.0e15;
718
719 fn oracle_coord(value: f32) -> f32 {
720 if value.is_finite() {
721 value.clamp(-LIMIT, LIMIT)
722 } else {
723 0.0
724 }
725 }
726
727 fn oracle_bounds(b: Rect) -> Rect {
729 Rect::new(
730 oracle_coord(b.x),
731 oracle_coord(b.y),
732 oracle_coord(b.width).max(0.0),
733 oracle_coord(b.height).max(0.0),
734 )
735 }
736
737 fn finite_and_inside(pts: &[Point], b: Rect) -> bool {
738 let eff = oracle_bounds(b);
739 pts.iter()
740 .all(|p| p.x.is_finite() && p.y.is_finite() && inside(p, eff))
741 }
742
743 #[test]
744 fn points_reviewer_repro_f32_max_bounds_are_finite_and_inside() {
745 let b = Rect::new(f32::MAX, f32::MAX, f32::MAX, f32::MAX);
746 let pts = waveform_points(&[0.0, 1.0, -1.0], b, 4);
747 assert_eq!(pts.len(), 3);
748 assert!(finite_and_inside(&pts, b), "{pts:?}");
749 let near = |actual: f32, want: f32| (actual - want).abs() <= 4.0e8;
752 assert!(near(pts[0].x, LIMIT) && near(pts[0].y, 1.5 * LIMIT));
753 assert!(near(pts[1].x, 1.5 * LIMIT) && near(pts[1].y, LIMIT));
754 assert!(near(pts[2].x, 2.0 * LIMIT) && near(pts[2].y, 2.0 * LIMIT));
755 }
756
757 #[test]
758 fn points_extreme_bounds_table_stays_finite_and_inside() {
759 let extremes = [
760 f32::MAX,
761 f32::MIN,
762 f32::INFINITY,
763 f32::NEG_INFINITY,
764 f32::NAN,
765 f32::MIN_POSITIVE,
766 -0.0,
767 LIMIT,
768 -LIMIT,
769 3.0e15,
770 ];
771 for &x in &extremes {
772 for &w in &extremes {
773 let b = Rect::new(x, f32::MAX, w, w);
774 let pts = waveform_points(&[1.0, -1.0, 0.25, f32::NAN], b, 8);
775 assert!(finite_and_inside(&pts, b), "{b:?} -> {pts:?}");
776 let b = Rect::new(f32::MIN, x, w, f32::MAX);
777 let pts = waveform_points(&[1.0, -1.0, 0.25, f32::NAN], b, 8);
778 assert!(finite_and_inside(&pts, b), "{b:?} -> {pts:?}");
779 }
780 }
781 }
782
783 #[test]
784 fn points_negative_extents_collapse_to_the_origin_point() {
785 let b = Rect::new(10.0, 20.0, -50.0, -40.0);
786 let pts = waveform_points(&[1.0, -1.0, 0.5], b, 8);
787 assert!(pts.iter().all(|p| *p == Point::new(10.0, 20.0)), "{pts:?}");
788 }
789
790 proptest! {
791 #[test]
792 fn prop_points_finite_and_inside_any_f32_bounds(
793 samples in proptest::collection::vec(proptest::num::f32::ANY, 0..100),
794 x in proptest::num::f32::ANY,
795 y in proptest::num::f32::ANY,
796 w in proptest::num::f32::ANY,
797 h in proptest::num::f32::ANY,
798 max in 0_usize..100,
799 ) {
800 let b = Rect::new(x, y, w, h);
801 let pts = waveform_points(&samples, b, max);
802 prop_assert!(finite_and_inside(&pts, b), "{:?} -> {:?}", b, pts);
803 }
804 }
805
806 #[test]
809 fn non_finite_preferred_size_counts_as_absent() {
810 let unbounded = Constraints::unbounded();
811 for bad in [f32::INFINITY, f32::NEG_INFINITY, f32::NAN] {
812 let w = Waveform::new().size(bad, bad);
813 assert_eq!(w.measure(unbounded), Size::new(200.0, 80.0), "{bad}");
814 }
815 }
816
817 #[test]
818 fn huge_preferred_size_is_clamped_to_the_coordinate_limit() {
819 let w = Waveform::new().size(f32::MAX, f32::MAX);
820 assert_eq!(w.measure(Constraints::unbounded()), Size::new(LIMIT, LIMIT));
821 }
822
823 #[test]
824 fn measure_is_finite_for_degenerate_constraints() {
825 let w = Waveform::new().size(f32::MAX, 10.0);
826 for c in [
827 Constraints::unbounded(),
828 Constraints::new(f32::INFINITY, f32::INFINITY, 0.0, 5.0),
829 Constraints::new(0.0, f32::NAN, f32::NAN, f32::INFINITY),
830 Constraints::new(9.0, 3.0, 9.0, 3.0),
831 ] {
832 let s = w.measure(c);
833 assert!(
834 s.width.is_finite() && s.height.is_finite(),
835 "{c:?} -> {s:?}"
836 );
837 }
838 }
839
840 proptest! {
841 #[test]
842 fn prop_measure_finite_for_any_constraints(
843 a in proptest::num::f32::ANY,
844 b in proptest::num::f32::ANY,
845 c in proptest::num::f32::ANY,
846 d in proptest::num::f32::ANY,
847 ) {
848 let s = Waveform::new().measure(Constraints::new(a, b, c, d));
849 prop_assert!(s.width.is_finite() && s.height.is_finite());
850 }
851 }
852
853 #[test]
856 fn brick_verify_flags_invalid_bounds() {
857 for b in [
858 Rect::new(f32::NAN, 0.0, 10.0, 10.0),
859 Rect::new(0.0, f32::INFINITY, 10.0, 10.0),
860 Rect::new(0.0, 0.0, -1.0, 10.0),
861 Rect::new(0.0, 0.0, 10.0, -1.0),
862 Rect::new(0.0, 0.0, f32::NAN, 10.0),
863 Rect::new(0.0, 0.0, 10.0, f32::NEG_INFINITY),
864 ] {
865 let mut w = Waveform::new().accessible_name("Mic level");
866 w.layout(b);
867 let v = w.verify();
868 assert!(!v.is_valid(), "{b:?}");
869 assert_eq!(v.failed.len(), 1, "{b:?}");
870 assert_eq!(
871 v.failed[0].0,
872 BrickAssertion::ElementPresent("valid-bounds".to_string())
873 );
874 assert!(v.failed[0].1.contains("bounds"));
875 }
876 }
877
878 #[test]
879 fn brick_verify_accepts_valid_laid_out_bounds() {
880 let mut w = Waveform::new().accessible_name("Mic level");
881 w.layout(B);
882 let v = w.verify();
883 assert!(v.is_valid());
884 assert!(v
885 .passed
886 .contains(&BrickAssertion::ElementPresent("valid-bounds".to_string())));
887 }
888
889 #[test]
892 fn peak_is_max_clamped_magnitude() {
893 assert_eq!(Waveform::new().peak(), 0.0);
894 assert_eq!(Waveform::new().samples(vec![0.2, -0.7, 0.5]).peak(), 0.7);
895 assert_eq!(Waveform::new().samples(vec![9.0, -3.0]).peak(), 1.0);
896 assert_eq!(
897 Waveform::new()
898 .samples(vec![f32::NAN, f32::INFINITY])
899 .peak(),
900 0.0
901 );
902 }
903
904 #[test]
905 fn rms_of_known_signals() {
906 assert_eq!(Waveform::new().rms(), 0.0);
907 assert_eq!(
908 Waveform::new().samples(vec![1.0, -1.0, 1.0, -1.0]).rms(),
909 1.0
910 );
911 let half = Waveform::new().samples(vec![0.5; 4]).rms();
912 assert!((half - 0.5).abs() < 1e-6);
913 let clamped = Waveform::new().samples(vec![3.0, f32::NAN]).rms();
915 assert!((clamped - 0.5_f32.sqrt()).abs() < 1e-6);
916 }
917
918 #[test]
921 fn widget_defaults_and_builders() {
922 let w = Waveform::new();
923 assert!(w.get_samples().is_empty());
924 assert_eq!(w.get_max_points(), DEFAULT_MAX_POINTS);
925 assert_eq!(w.get_line_width(), 1.5);
926
927 let red = Color::new(1.0, 0.0, 0.0, 1.0);
928 let w = Waveform::new()
929 .samples(vec![0.1, 0.2])
930 .color(red)
931 .line_width(3.0)
932 .max_points(64)
933 .size(300.0, 90.0)
934 .accessible_name("Microphone level")
935 .test_id("wave");
936 assert_eq!(w.get_samples(), &[0.1, 0.2]);
937 assert_eq!(w.get_color(), red);
938 assert_eq!(w.get_line_width(), 3.0);
939 assert_eq!(w.get_max_points(), 64);
940 assert_eq!(Widget::accessible_name(&w), Some("Microphone level"));
941 assert_eq!(Widget::test_id(&w), Some("wave"));
942 assert_eq!(Brick::test_id(&w), Some("wave"));
943 }
944
945 #[test]
946 fn line_width_and_size_never_negative() {
947 let w = Waveform::new().line_width(-2.0).width(-1.0).height(-1.0);
948 assert_eq!(w.get_line_width(), 0.0);
949 assert_eq!(
950 w.measure(Constraints::loose(Size::new(500.0, 500.0))),
951 Size::new(0.0, 0.0)
952 );
953 }
954
955 #[test]
956 fn set_samples_replaces_buffer() {
957 let mut w = Waveform::new().samples(vec![0.9; 10]);
958 w.set_samples(&[0.1, 0.2, 0.3]);
959 assert_eq!(w.get_samples(), &[0.1, 0.2, 0.3]);
960 w.set_samples(&[]);
961 assert!(w.get_samples().is_empty());
962 }
963
964 #[test]
967 fn measure_defaults_and_preferred() {
968 let loose = Constraints::loose(Size::new(500.0, 500.0));
969 assert_eq!(Waveform::new().measure(loose), Size::new(200.0, 80.0));
970 assert_eq!(
971 Waveform::new().size(320.0, 60.0).measure(loose),
972 Size::new(320.0, 60.0)
973 );
974 }
975
976 #[test]
977 fn layout_caches_bounds() {
978 let mut w = Waveform::new();
979 let result = w.layout(B);
980 assert_eq!(result.size, Size::new(100.0, 40.0));
981 assert_eq!(Widget::bounds(&w), B);
982 }
983
984 #[test]
985 fn widget_trait_surface() {
986 let mut w = Waveform::new();
987 assert_eq!(Widget::type_id(&w), TypeId::of::<Waveform>());
988 assert!(w
989 .event(&Event::MouseMove {
990 position: Point::ORIGIN
991 })
992 .is_none());
993 assert!(w.children().is_empty());
994 assert!(w.children_mut().is_empty());
995 assert!(!w.is_interactive());
996 assert!(!w.is_focusable());
997 assert_eq!(w.accessible_role(), AccessibleRole::Image);
998 assert_eq!(Widget::accessible_name(&w), None);
999 assert_eq!(Widget::test_id(&w), None);
1000 }
1001
1002 #[test]
1003 fn paint_draws_one_path_from_waveform_points() {
1004 let red = Color::new(1.0, 0.0, 0.0, 1.0);
1005 let mut w = Waveform::new()
1006 .samples(vec![0.0, 1.0, -1.0])
1007 .color(red)
1008 .line_width(2.0);
1009 w.layout(B);
1010 let mut canvas = RecordingCanvas::new();
1011 w.paint(&mut canvas);
1012 let cmds = canvas.take_commands();
1013 assert_eq!(cmds.len(), 1);
1014 let expected = waveform_points(&[0.0, 1.0, -1.0], B, DEFAULT_MAX_POINTS);
1015 assert!(matches!(
1016 &cmds[0],
1017 DrawCommand::Path { points, closed, style }
1018 if *points == expected && !closed && style.color == red && style.width == 2.0
1019 ));
1020 }
1021
1022 #[test]
1023 fn paint_honours_max_points() {
1024 let samples: Vec<f32> = (0..100).map(|i| (i as f32 / 50.0) - 1.0).collect();
1025 let mut w = Waveform::new().samples(samples.clone()).max_points(4);
1026 w.layout(B);
1027 let mut canvas = RecordingCanvas::new();
1028 w.paint(&mut canvas);
1029 let expected = waveform_points(&samples, B, 4);
1030 assert_eq!(expected.len(), 4);
1031 assert!(matches!(
1032 &canvas.take_commands()[0],
1033 DrawCommand::Path { points, .. } if *points == expected
1034 ));
1035 }
1036
1037 #[test]
1038 fn paint_without_samples_draws_centre_line() {
1039 let mut w = Waveform::new();
1040 w.layout(B);
1041 let mut canvas = RecordingCanvas::new();
1042 w.paint(&mut canvas);
1043 let expected = vec![Point::new(10.0, 40.0), Point::new(110.0, 40.0)];
1044 assert!(matches!(
1045 &canvas.take_commands()[0],
1046 DrawCommand::Path { points, .. } if *points == expected
1047 ));
1048 }
1049
1050 #[test]
1053 fn brick_verify_passes_with_accessible_name() {
1054 let w = Waveform::new().accessible_name("Mic level");
1055 let v = w.verify();
1056 assert!(v.is_valid());
1057 assert!(v.failed.is_empty());
1058 assert!(w.can_render());
1059 assert_eq!(w.brick_name(), "Waveform");
1060 assert_eq!(w.assertions(), &[BrickAssertion::MaxLatencyMs(16)]);
1061 assert_eq!(w.budget(), BrickBudget::uniform(16));
1062 }
1063
1064 #[test]
1065 fn brick_verify_fails_without_accessible_name() {
1066 for w in [
1067 Waveform::new(),
1068 Waveform::new().accessible_name(""),
1069 Waveform::new().accessible_name(" "),
1070 ] {
1071 let v = w.verify();
1072 assert!(!v.is_valid());
1073 assert!(!w.can_render());
1074 assert_eq!(v.failed.len(), 1);
1075 assert!(matches!(v.failed[0].0, BrickAssertion::ElementPresent(_)));
1076 assert!(v.failed[0].1.contains("accessible name"));
1077 }
1078 }
1079
1080 #[test]
1081 fn brick_html_and_css() {
1082 let w = Waveform::new().accessible_name(r#"Mic <"A"> & B"#);
1083 let html = w.to_html();
1084 assert!(html.contains("brick-waveform"));
1085 assert!(html.contains("Mic <"A"> & B"));
1086 assert!(Waveform::new().to_html().contains(r#"aria-label="""#));
1087 assert!(w.to_css().contains(".brick-waveform"));
1088 }
1089
1090 fn wild() -> impl Strategy<Value = f32> {
1107 prop_oneof![
1108 proptest::num::f32::ANY,
1109 Just(f32::NAN),
1110 Just(f32::INFINITY),
1111 Just(f32::NEG_INFINITY),
1112 Just(f32::MAX),
1113 Just(f32::MIN),
1114 Just(f32::MIN_POSITIVE),
1115 Just(1.0e-40_f32),
1116 Just(-0.0_f32),
1117 Just(-10.0_f32),
1118 -1000.0_f32..1000.0,
1119 ]
1120 }
1121
1122 fn any_max_points() -> impl Strategy<Value = usize> {
1123 prop_oneof![0_usize..300, Just(usize::MAX), any::<usize>()]
1124 }
1125
1126 fn finite_non_negative(v: f32) -> bool {
1127 v.is_finite() && v >= 0.0
1128 }
1129
1130 fn size_sane(s: Size) -> bool {
1131 finite_non_negative(s.width) && finite_non_negative(s.height)
1132 }
1133
1134 fn command_widths_sane(cmd: &DrawCommand) -> bool {
1136 match cmd {
1137 DrawCommand::Path { points, style, .. } => {
1138 finite_non_negative(style.width)
1139 && style.dash.iter().copied().all(finite_non_negative)
1140 && points.iter().all(|p| p.x.is_finite() && p.y.is_finite())
1141 }
1142 _ => false,
1143 }
1144 }
1145
1146 fn raw_bounds_valid(b: Rect) -> bool {
1147 [b.x, b.y, b.width, b.height].iter().all(|v| v.is_finite())
1148 && b.width >= 0.0
1149 && b.height >= 0.0
1150 }
1151
1152 fn bits(r: Rect) -> [u32; 4] {
1153 [
1154 r.x.to_bits(),
1155 r.y.to_bits(),
1156 r.width.to_bits(),
1157 r.height.to_bits(),
1158 ]
1159 }
1160
1161 fn painted(w: &Waveform) -> Vec<DrawCommand> {
1162 let mut canvas = RecordingCanvas::new();
1163 w.paint(&mut canvas);
1164 canvas.take_commands()
1165 }
1166
1167 #[test]
1168 fn reviewer_repro_waveform_measure_negative_upper_limits_is_not_negative() {
1169 let size = Waveform::new().measure(Constraints::new(0.0, -10.0, 0.0, -10.0));
1170 assert!(size_sane(size), "{size:?}");
1171 assert_eq!(size, Size::new(0.0, 0.0));
1172 }
1173
1174 #[test]
1175 fn reviewer_repro_waveform_infinite_line_width_paints_a_finite_stroke() {
1176 for width in [f32::INFINITY, f32::MAX, 3.0e15, f32::NAN, f32::NEG_INFINITY] {
1177 let mut wf = Waveform::new().samples(vec![0.5, -0.5]).line_width(width);
1178 assert!(finite_non_negative(wf.get_line_width()), "{width}");
1179 wf.layout(Rect::new(0.0, 0.0, 100.0, 50.0));
1180 let cmds = painted(&wf);
1181 assert_eq!(cmds.len(), 1);
1182 assert!(cmds.iter().all(command_widths_sane), "{width}: {cmds:?}");
1183 }
1184 assert_eq!(Waveform::new().line_width(2.5).get_line_width(), 2.5);
1186 }
1187
1188 #[test]
1189 fn class_waveform_helpers_reject_what_they_should() {
1190 let wide = DrawCommand::Path {
1191 points: vec![Point::new(0.0, 0.0)],
1192 closed: false,
1193 style: presentar_core::draw::StrokeStyle {
1194 width: f32::INFINITY,
1195 ..presentar_core::draw::StrokeStyle::default()
1196 },
1197 };
1198 assert!(!command_widths_sane(&wide));
1199 let rect = DrawCommand::filled_rect(B, Color::WHITE);
1200 assert!(!command_widths_sane(&rect));
1201 assert!(!raw_bounds_valid(Rect::new(0.0, 0.0, -1.0, 1.0)));
1202 assert!(raw_bounds_valid(B));
1203 }
1204
1205 proptest! {
1206 #[test]
1207 fn class_waveform_points_are_finite_inside_and_bounded(
1208 samples in proptest::collection::vec(wild(), 0..120),
1209 x in wild(),
1210 y in wild(),
1211 w in wild(),
1212 h in wild(),
1213 max in any_max_points(),
1214 ) {
1215 let b = Rect::new(x, y, w, h);
1216 let pts = waveform_points(&samples, b, max);
1217 prop_assert!(finite_and_inside(&pts, b), "{:?} -> {:?}", b, pts);
1218 prop_assert!(pts.len() >= 2 && pts.len() <= max.max(2));
1219 prop_assert!(pts.windows(2).all(|p| p[0].x <= p[1].x));
1220 }
1221
1222 #[test]
1223 fn class_waveform_builders_and_getters_are_sane(
1224 samples in proptest::collection::vec(wild(), 0..80),
1225 line in wild(),
1226 w in wild(),
1227 h in wild(),
1228 max in any_max_points(),
1229 ) {
1230 let wf = Waveform::new()
1231 .samples(samples.clone())
1232 .color(Color::new(0.1, 0.2, 0.3, 1.0))
1233 .line_width(line)
1234 .max_points(max)
1235 .width(w)
1236 .height(h)
1237 .accessible_name("Mic")
1238 .test_id("wave");
1239 prop_assert!(finite_non_negative(wf.get_line_width()), "{}", wf.get_line_width());
1240 prop_assert_eq!(wf.get_max_points(), max);
1241 prop_assert_eq!(wf.get_samples().len(), samples.len());
1242 prop_assert!(finite_non_negative(wf.peak()) && wf.peak() <= 1.0);
1243 prop_assert!(finite_non_negative(wf.rms()) && wf.rms() <= 1.0 + 1.0e-3);
1244 let mut in_place = Waveform::new();
1245 in_place.set_samples(&samples);
1246 prop_assert_eq!(in_place.peak().to_bits(), wf.peak().to_bits());
1247 prop_assert_eq!(in_place.rms().to_bits(), wf.rms().to_bits());
1248 prop_assert!(!wf.to_html().is_empty());
1249 }
1250
1251 #[test]
1252 fn class_waveform_measure_is_finite_and_not_negative(
1253 w in wild(),
1254 h in wild(),
1255 a in wild(),
1256 b in wild(),
1257 c in wild(),
1258 d in wild(),
1259 ) {
1260 let constraints = Constraints::new(a, b, c, d);
1261 for wf in [
1262 Waveform::new(),
1263 Waveform::new().size(w, h),
1264 Waveform::new().width(w),
1265 Waveform::new().height(h),
1266 ] {
1267 let s = wf.measure(constraints);
1268 prop_assert!(size_sane(s), "{constraints:?} -> {s:?}");
1269 }
1270 }
1271
1272 #[test]
1273 fn class_waveform_layout_size_is_sane_and_verify_judges_raw_bounds(
1274 x in wild(),
1275 y in wild(),
1276 w in wild(),
1277 h in wild(),
1278 ) {
1279 let bounds = Rect::new(x, y, w, h);
1280 let mut wf = Waveform::new().accessible_name("Mic level");
1281 let result = wf.layout(bounds);
1282 prop_assert!(size_sane(result.size), "{:?}", result.size);
1283 prop_assert_eq!(result.size, oracle_bounds(bounds).size());
1284 prop_assert_eq!(bits(Widget::bounds(&wf)), bits(bounds));
1285 prop_assert_eq!(wf.verify().is_valid(), raw_bounds_valid(bounds));
1286 }
1287
1288 #[test]
1289 fn class_waveform_paint_is_sane_for_any_input(
1290 samples in proptest::collection::vec(wild(), 0..120),
1291 line in wild(),
1292 x in wild(),
1293 y in wild(),
1294 w in wild(),
1295 h in wild(),
1296 max in any_max_points(),
1297 ) {
1298 let bounds = Rect::new(x, y, w, h);
1299 let mut wf = Waveform::new().samples(samples).line_width(line).max_points(max);
1300 wf.layout(bounds);
1301 let cmds = painted(&wf);
1302 prop_assert_eq!(cmds.len(), 1);
1303 prop_assert!(cmds.iter().all(command_widths_sane), "{cmds:?}");
1304 let expected = oracle_bounds(bounds);
1305 for cmd in &cmds {
1306 let DrawCommand::Path { points, .. } = cmd else {
1307 return Err(TestCaseError::fail("not a path"));
1308 };
1309 prop_assert!(points.len() >= 2 && points.len() <= max.max(2));
1310 prop_assert!(points.iter().all(|p| inside(p, expected)));
1311 }
1312 }
1313
1314 #[test]
1315 fn class_waveform_event_is_total(px in wild(), py in wild()) {
1316 let mut wf = Waveform::new();
1317 wf.layout(B);
1318 let at = Point::new(px, py);
1319 let reply = wf.event(&Event::MouseMove { position: at });
1320 prop_assert!(reply.is_none());
1321 }
1322 }
1323
1324 #[test]
1325 fn bucket_edge_does_not_saturate_at_32_bit_limits() {
1326 let (bucket, len, target) = (1023u64, 5_000_000u64, 1024u64);
1329 let saturated32 = u64::from((bucket as u32).saturating_mul(len as u32)) / target;
1330 let exact = bucket_edge_wide(bucket, len, target);
1331 assert_ne!(exact, saturated32);
1332 assert_eq!(exact, 4_995_117);
1333 assert_eq!(bucket_edge_wide(1024, len, target), len);
1334 assert_eq!(bucket_edge(usize::MAX, 4, 2), 4);
1335 }
1336
1337 #[test]
1338 fn empty_waveform_rms_is_positive_zero() {
1339 let w = Waveform::new();
1340 assert!(w.rms() == 0.0 && w.rms().is_sign_positive());
1341 }
1342}