use super::*;
use crate::geometry::{Constraints, Rect, Vec2};
use crate::phase::Phase;
use crate::placement::RasterPlacement;
use crate::raster::{PixelFormat, RasterComponent, RasterImage, RasterResidency, Resolution};
use crate::render_context::{CachePolicy, PassThrough, RenderContext};
use crate::time::{LocalTime, Time, TimelineTime};
use std::sync::atomic::{AtomicU8, Ordering};
#[derive(Clone, PartialEq, Hash)]
struct Dot;
impl RasterComponent for Dot {
fn layout(&self, _constraints: Constraints) -> Vec2 {
Vec2(1.0, 1.0)
}
fn render(
&self,
_size: Vec2,
_target: Resolution,
_residency: RasterResidency,
_ctx: &mut dyn RenderContext,
) -> RasterImage {
RasterImage::cpu(1, 1, PixelFormat::Rgba8, vec![0u8, 0, 0, 0])
}
}
#[crate::component(raster)]
fn Badge(#[builder(into)] tag: String) -> impl RasterComponent {
let _ = &tag;
Dot
}
#[crate::component(raster)]
fn PositionedBadge() -> impl RasterComponent {
Dot.place_at(Vec2::ZERO)
}
#[crate::component(raster)]
fn AvailablePositionedBadge(#[available] _available: Vec2) -> impl RasterComponent {
Dot.place_at(Vec2::ZERO)
}
#[test]
fn raster_component_macro_name_flows_through_the_blanket() {
let badge = Badge::builder().tag("hello").build();
assert_eq!(badge.arrangement_name().as_deref(), Some("Badge"));
let boxed: Box<dyn TimelineComponent + Send> = Box::new(badge);
assert_eq!(boxed.arrangement(0.0).name.as_deref(), Some("Badge"));
}
#[test]
fn boxed_timeline_component_clone_is_dyn_equal() {
let original: Box<dyn TimelineComponent> = Box::new(Dot);
let cloned = original.clone();
assert!(crate::dyn_compare::DynEq::dyn_eq(
original.as_ref(),
cloned.as_ref().as_any(),
));
let original: Box<dyn TimelineComponent + Send> = Box::new(Dot);
let cloned = original.clone();
assert!(crate::dyn_compare::DynEq::dyn_eq(
original.as_ref(),
cloned.as_ref().as_any(),
));
}
#[test]
fn raster_component_macro_routes_cache_policy_by_build_mode() {
assert_eq!(
PositionedBadge::builder().build().cache_policy(),
CachePolicy::Transparent,
);
assert_eq!(
Badge::builder().tag("memoized").build().cache_policy(),
CachePolicy::Memoize,
);
assert_eq!(
AvailablePositionedBadge::builder().build().cache_policy(),
CachePolicy::Transparent,
);
}
static LAST_TIMELINE_RESIDENCY: AtomicU8 = AtomicU8::new(0);
#[derive(Clone, PartialEq, Hash)]
struct ResidencyProbe;
impl TimelineComponent for ResidencyProbe {
fn frame(
&self,
_clock: Clock<'_>,
_canvas: Vec2,
_target: Resolution,
residency: RasterResidency,
_ctx: &mut dyn RenderContext,
) -> Option<RasterImage> {
let value = match residency {
RasterResidency::Cpu => 1,
RasterResidency::Gpu => 2,
};
LAST_TIMELINE_RESIDENCY.store(value, Ordering::Relaxed);
None
}
fn arrangement(&self, offset: f64) -> Arrangement {
Arrangement {
kind: NodeKind::Video,
label: String::new(),
name: None,
source: None,
start: offset,
end: offset,
trim: None,
triggers: Vec::new(),
children: Vec::new(),
}
}
}
#[crate::component(timeline)]
fn ResidencyForwarder() -> impl TimelineComponent {
ResidencyProbe
}
#[test]
fn timeline_component_macro_forwards_requested_residency() {
LAST_TIMELINE_RESIDENCY.store(0, Ordering::Relaxed);
let component = ResidencyForwarder::builder().build();
let table = TriggerTable::new();
let clock = Clock::new(TimelineTime::new(0.0), LocalTime::new(0.0), &table);
let mut ctx = PassThrough;
let _ = component.frame(
clock,
Vec2(1.0, 1.0),
Resolution::new(1, 1),
RasterResidency::Gpu,
&mut ctx,
);
assert_eq!(LAST_TIMELINE_RESIDENCY.load(Ordering::Relaxed), 2);
}
#[test]
fn raster_component_is_a_timeline_component_via_blanket() {
let boxed: Box<dyn TimelineComponent + Send> = Box::new(Dot);
assert_eq!(boxed.duration(), None);
assert_eq!(boxed.measure(), None);
assert_eq!(boxed.cues(0.0), Vec::new());
assert_eq!(boxed.arrangement(0.0).kind, NodeKind::Video);
assert_eq!(boxed.arrangement(0.0).name, None);
}
#[test]
fn blanket_frame_routes_through_ctx_render() {
let dot = Dot;
let table = TriggerTable::new();
let clock = Clock::new(TimelineTime::new(0.0), LocalTime::new(0.0), &table);
let mut ctx = PassThrough;
let frame = dot.frame(
clock,
Vec2(4.0, 4.0),
Resolution::new(4, 4),
RasterResidency::Cpu,
&mut ctx,
);
assert!(frame.is_some());
}
#[derive(Clone, PartialEq, Hash)]
struct Square;
impl Square {
const BOX: Vec2 = Vec2(400.0, 100.0);
const SIDE: f32 = 50.0;
}
impl RasterComponent for Square {
fn layout(&self, _constraints: Constraints) -> Vec2 {
Self::BOX
}
fn render(
&self,
size: Vec2,
target: Resolution,
_residency: RasterResidency,
_ctx: &mut dyn RenderContext,
) -> RasterImage {
let scale_x = target.width as f32 / size.0;
let scale_y = target.height as f32 / size.1;
let half = Self::SIDE * 0.5;
let cx = size.0 * 0.5;
let cy = size.1 * 0.5;
let px0 = ((cx - half) * scale_x).round() as i64;
let px1 = ((cx + half) * scale_x).round() as i64;
let py0 = ((cy - half) * scale_y).round() as i64;
let py1 = ((cy + half) * scale_y).round() as i64;
let w = target.width as usize;
let h = target.height as usize;
let mut pixels = vec![0u8; w * h * 4];
for y in 0..h as i64 {
for x in 0..w as i64 {
if (px0..px1).contains(&x) && (py0..py1).contains(&y) {
let i = ((y as usize) * w + (x as usize)) * 4;
pixels[i..i + 4].copy_from_slice(&[255, 255, 255, 255]);
}
}
}
RasterImage::cpu(target.width, target.height, PixelFormat::Rgba8, pixels)
}
}
fn opaque_span(image: &crate::raster::CpuRasterImage) -> (u32, u32) {
let w = image.width as usize;
let h = image.height as usize;
let mut min_x = w;
let mut max_x = 0usize;
let mut min_y = h;
let mut max_y = 0usize;
for y in 0..h {
for x in 0..w {
if image.pixels[(y * w + x) * 4 + 3] != 0 {
min_x = min_x.min(x);
max_x = max_x.max(x);
min_y = min_y.min(y);
max_y = max_y.max(y);
}
}
}
((max_x + 1 - min_x) as u32, (max_y + 1 - min_y) as u32)
}
#[test]
fn blanket_frame_lays_out_against_the_canvas_not_the_intrinsic_box() {
let table = TriggerTable::new();
let clock = Clock::new(TimelineTime::new(0.0), LocalTime::new(0.0), &table);
let mut ctx = PassThrough;
let target = Resolution::new(1280, 720); let canvas = Vec2(target.width as f32, target.height as f32);
let frame = Square
.frame(clock, canvas, target, RasterResidency::Cpu, &mut ctx)
.expect("a visual contributes a frame");
let cpu = frame.as_cpu().expect("cpu image");
let (span_w, span_h) = opaque_span(cpu);
let diff = (span_w as i32 - span_h as i32).abs();
assert!(
diff <= 2,
"square must stay square (no aspect stretch): {span_w}x{span_h}",
);
}
#[derive(Clone, PartialEq, Hash)]
struct Margined;
impl Margined {
const MARGIN: f32 = 4.0;
}
impl RasterComponent for Margined {
fn layout(&self, constraints: Constraints) -> Vec2 {
constraints.constrain(Vec2(8.0, 8.0))
}
fn paint_bounds(&self, size: Vec2) -> Rect {
Rect {
origin: Vec2(-Self::MARGIN, -Self::MARGIN),
size: Vec2(size.0 + 2.0 * Self::MARGIN, size.1 + 2.0 * Self::MARGIN),
}
}
fn render(
&self,
size: Vec2,
target: Resolution,
_residency: RasterResidency,
_ctx: &mut dyn RenderContext,
) -> RasterImage {
let bounds = self.paint_bounds(size);
let sx = target.width as f32 / bounds.size.0;
let sy = target.height as f32 / bounds.size.1;
let px = ((0.0 - bounds.origin.0) * sx).round() as usize;
let py = ((0.0 - bounds.origin.1) * sy).round() as usize;
let w = target.width as usize;
let h = target.height as usize;
let mut pixels = vec![0u8; w * h * 4];
let i = (py.min(h - 1) * w + px.min(w - 1)) * 4;
pixels[i..i + 4].copy_from_slice(&[255, 255, 255, 255]);
RasterImage::cpu(target.width, target.height, PixelFormat::Rgba8, pixels)
}
}
#[test]
fn blanket_frame_gives_paint_bounds_pixels_and_composites_at_the_painted_origin() {
let table = TriggerTable::new();
let clock = Clock::new(TimelineTime::new(0.0), LocalTime::new(0.0), &table);
let mut ctx = PassThrough;
let target = Resolution::new(8, 8);
let canvas = Vec2(8.0, 8.0);
let frame = Margined
.frame(clock, canvas, target, RasterResidency::Cpu, &mut ctx)
.expect("a visual contributes a frame");
let cpu = frame.as_cpu().expect("cpu image");
assert_eq!(
(cpu.width, cpu.height),
(8, 8),
"the frame stays target-sized regardless of the visual's paint bounds",
);
assert_eq!(cpu.pixels[3], 255, "logical (0,0) lands at frame (0,0)");
let squeezed = (2 * 8 + 2) * 4 + 3;
assert_eq!(
cpu.pixels[squeezed], 0,
"content must not be squeezed toward the center",
);
}
#[test]
fn timed_and_triggers_blankets_apply_to_a_visual() {
let placed = Dot.at(0.0..2.0);
assert_eq!(placed.duration(), Some(2.0));
assert_eq!(placed.measure(), Some(2.0));
let e = Event::new();
let triggered = Dot.trigger_at_start(e);
assert_eq!(triggered.event(), e);
}
#[test]
fn event_new_gives_distinct_ids() {
let a = Event::new();
let b = Event::new();
let c = Event::new();
assert_ne!(a, b);
assert_ne!(b, c);
assert_ne!(a, c);
}
#[test]
fn trigger_table_earliest_wins_and_absent_is_infinity() {
let e = Event::new();
let mut table = TriggerTable::new();
assert!(table.get(e.id()).seconds().is_infinite());
table.record(e, 5.0);
table.record(e, 8.0);
assert_eq!(table.get(e.id()).seconds(), 5.0);
table.record(e, 2.0);
assert_eq!(table.get(e.id()).seconds(), 2.0);
}
#[test]
fn unfired_event_phase_is_zero_not_nan() {
let e = Event::new();
let table = TriggerTable::new();
let clock = Clock::new(TimelineTime::new(3.0), LocalTime::new(3.0), &table);
assert_eq!(e.phase(&clock, 0.0, 0.5), Phase::ZERO);
assert!(e.is_before(&clock));
assert!(!e.is_after(&clock));
}
#[test]
fn fired_event_phase_tracks_the_global_clock() {
let e = Event::new();
let mut table = TriggerTable::new();
table.record(e, 2.0);
let clock = Clock::new(TimelineTime::new(2.25), LocalTime::new(0.0), &table);
assert_eq!(e.phase(&clock, 0.0, 0.5).get(), 0.5);
assert!(e.is_after(&clock));
}
#[test]
fn event_elapsed_tracks_the_global_clock() {
let e = Event::new();
let mut table = TriggerTable::new();
table.record(e, 2.0);
let before = Clock::new(TimelineTime::new(1.5), LocalTime::new(99.0), &table);
assert_eq!(e.elapsed(&before), 0.0);
let after = Clock::new(TimelineTime::new(2.25), LocalTime::new(0.0), &table);
assert_eq!(e.elapsed(&after), 0.25);
}
#[test]
fn unfired_event_elapsed_is_zero() {
let e = Event::new();
let table = TriggerTable::new();
let clock = Clock::new(TimelineTime::new(3.0), LocalTime::new(3.0), &table);
assert_eq!(e.elapsed(&clock), 0.0);
}
#[test]
fn event_window_unfired_reports_the_before_state_with_a_stable_key() {
let e = Event::new();
let table = TriggerTable::new();
let early = Clock::new(TimelineTime::new(3.0), LocalTime::new(3.0), &table);
let late = Clock::new(TimelineTime::new(50.0), LocalTime::new(50.0), &table);
let w_early = e.window(&early, 0.2..0.9);
let w_late = e.window(&late, 0.2..0.9);
assert_eq!(w_early, w_late);
assert_eq!(w_early.phase().get(), 0.0);
assert_eq!(w_early.envelope(0.1, 0.1).get(), 0.0);
}
#[test]
fn event_window_before_the_trigger_is_clamped_to_start() {
let e = Event::new();
let mut table = TriggerTable::new();
table.record(e, 5.0);
let clock = Clock::new(TimelineTime::new(4.0), LocalTime::new(0.0), &table);
let w = e.window(&clock, 0.5..1.5);
assert_eq!(w.phase().get(), 0.0);
assert_eq!(w.envelope(0.2, 0.2).get(), 0.0);
}
#[test]
fn event_window_inside_tracks_the_trigger_relative_cursor() {
let e = Event::new();
let mut table = TriggerTable::new();
table.record(e, 5.0);
let clock = Clock::new(TimelineTime::new(6.0), LocalTime::new(0.0), &table);
let w = e.window(&clock, 0.5..1.5);
assert!((w.phase().get() - 0.5).abs() < 1e-6);
}
#[test]
fn event_window_after_is_clamped_to_end_with_a_stable_key() {
let e = Event::new();
let mut table = TriggerTable::new();
table.record(e, 5.0);
let just_after = Clock::new(TimelineTime::new(7.0), LocalTime::new(0.0), &table);
let long_after = Clock::new(TimelineTime::new(500.0), LocalTime::new(0.0), &table);
let w_just_after = e.window(&just_after, 0.5..1.5);
let w_long_after = e.window(&long_after, 0.5..1.5);
assert_eq!(w_just_after, w_long_after);
assert_eq!(w_just_after.phase().get(), 1.0);
}
#[test]
#[should_panic(expected = "Event::window requires a finite range with end > start")]
fn event_window_rejects_empty_range() {
let e = Event::new();
let table = TriggerTable::new();
let clock = Clock::new(TimelineTime::new(0.0), LocalTime::new(0.0), &table);
let _ = e.window(&clock, 1.0..1.0);
}
#[test]
fn triggered_resolve_records_start_time() {
let e = Event::new();
let triggered = Dot.trigger_at_start(e);
let mut ctx = ResolveCtx::new();
triggered.resolve(4.0, &mut ctx);
let table = ctx.into_triggers();
assert_eq!(table.get(e.id()).seconds(), 4.0);
}
#[crate::component(timeline)]
fn Beat(start: f64) -> impl TimelineComponent {
Dot.at(start..(start + 2.0))
}
#[crate::component(timeline)]
fn Pulse(#[clock] clock: Clock, start: f64) -> impl TimelineComponent {
let _ = clock.local().seconds() + clock.global().seconds();
Dot.at(start..(start + 1.0))
}
#[crate::component(timeline, name = "Shot {take}: {label}")]
fn NamedBeat(#[builder(into)] label: String, take: u32, start: f64) -> impl TimelineComponent {
let _ = (&label, take);
Dot.at(start..(start + 2.0))
}
fn assert_timeline_component<T: TimelineComponent>(_: &T) {}
fn assert_timeline_builder<B: TimelineBuilder>(_: &B) {}
fn assert_boxable<T: TimelineComponent + Send + 'static>(
value: T,
) -> Box<dyn TimelineComponent + Send> {
Box::new(value)
}
#[test]
fn timeline_component_without_clock_delegates_full_query_set() {
let beat = Beat::builder().start(3.0).build();
assert_timeline_component(&beat);
assert_eq!(beat.duration(), Some(2.0));
assert_eq!(beat.measure(), Some(5.0));
assert_eq!(beat.cues(0.0), Vec::new());
let node = beat.arrangement(0.0);
assert_eq!(node.kind, NodeKind::Video);
assert_eq!(node.name.as_deref(), Some("Beat"));
assert_eq!(node.label, String::new());
let mut ctx = ResolveCtx::new();
assert_eq!(beat.resolve(0.0, &mut ctx), 2.0);
let builder = Beat::builder().start(3.0);
assert_timeline_builder(&builder);
let _boxed: Box<dyn TimelineComponent + Send> = assert_boxable(beat);
}
#[test]
fn timeline_component_name_template_interpolates_stored_fields() {
let a = NamedBeat::builder()
.label("intro")
.take(1)
.start(0.0)
.build();
let b = NamedBeat::builder()
.label("outro")
.take(7)
.start(0.0)
.build();
assert_eq!(a.arrangement(0.0).name.as_deref(), Some("Shot 1: intro"));
assert_eq!(b.arrangement(0.0).name.as_deref(), Some("Shot 7: outro"));
assert_eq!(a.arrangement(0.0).kind, NodeKind::Video);
assert!(a.arrangement(0.0).children.is_empty());
}
#[test]
fn timeline_component_with_clock_forwards_the_real_clock() {
let pulse = Pulse::builder().start(1.0).build();
assert_timeline_component(&pulse);
let table = TriggerTable::new();
let clock = Clock::new(TimelineTime::new(1.25), LocalTime::new(1.25), &table);
let mut ctx = PassThrough;
let frame = pulse.frame(
clock,
Vec2(4.0, 4.0),
Resolution::new(4, 4),
RasterResidency::Cpu,
&mut ctx,
);
assert!(frame.is_some());
assert_eq!(pulse.duration(), Some(1.0));
let builder = Pulse::builder().start(1.0);
assert_timeline_builder(&builder);
let _boxed: Box<dyn TimelineComponent + Send> = assert_boxable(pulse);
}
#[test]
fn clock_window_surfaces_the_local_interval() {
let table = TriggerTable::new();
let base = Clock::new(TimelineTime::new(0.0), LocalTime::new(0.0), &table);
let open = base.with_local_window(LocalTime::new(5.0), None);
assert!(open.window().is_none());
let at = |t: f64| base.with_local_window(LocalTime::new(t), Some(3.0));
let w = at(1.0).window().expect("windowed");
assert_eq!(w.start(), 0.0);
assert_eq!(w.end(), 3.0);
assert_eq!(w.current(), 1.0);
assert!((w.remaining() - 2.0).abs() < 1e-6);
assert_eq!(at(4.0).window().unwrap().remaining(), 0.0);
assert_eq!(at(0.0).window().unwrap().envelope(0.5, 0.5).get(), 0.0);
assert!(
(at(1.5).window().unwrap().envelope(0.5, 0.5).get() - 1.0).abs() < 1e-6,
"held at full between the fades"
);
}
#[test]
fn timeline_clock_is_excluded_from_the_cache_key() {
let a = Pulse::builder().start(1.0).build();
let b = Pulse::builder().start(1.0).build();
assert!(a == b);
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut ha = DefaultHasher::new();
let mut hb = DefaultHasher::new();
a.hash(&mut ha);
b.hash(&mut hb);
assert_eq!(ha.finish(), hb.finish());
}
#[test]
fn timeline_complete_builder_boxes_via_from() {
let boxed: Box<dyn TimelineComponent + Send> = Beat::builder().start(0.0).into();
assert_eq!(boxed.duration(), Some(2.0));
}
#[test]
fn resolve_visual_in_a_window_gives_window_duration() {
let resolved = resolve(Dot.at(0.0..3.0)).expect("a windowed visual is not timeless");
assert_eq!(resolved.duration(), 3.0);
assert_eq!(resolved.source().duration(), Some(3.0));
assert!(resolved.warnings().is_empty());
}
#[test]
fn resolve_bare_timeless_visual_is_an_error() {
let err = resolve(Dot).expect_err("a bare visual has no intrinsic length");
assert!(matches!(err, ResolveError::Timeless));
}
#[test]
fn resolve_records_trigger_absolute_start() {
let e = Event::new();
let resolved =
resolve(Dot.at(5.0..8.0).trigger_at_start(e)).expect("windowed, so not timeless");
assert_eq!(resolved.triggers().get(e.id()).seconds(), 0.0);
assert_eq!(resolved.duration(), 8.0);
}
#[test]
fn resolve_scales_interior_trigger_under_window_stretch() {
let e = Event::new();
let resolved = resolve(
Beat::builder()
.start(0.0)
.build()
.trigger_at(1.0, e)
.at(0.0..1.0),
)
.expect("windowed timed child is not timeless");
assert_eq!(resolved.duration(), 1.0);
assert!(
(resolved.triggers().get(e.id()).seconds() - 0.5).abs() < 1e-6,
"interior trigger should compress to 0.5s, got {}",
resolved.triggers().get(e.id()).seconds()
);
}
#[test]
fn resolve_zero_length_window_is_an_error() {
let err = resolve(Dot.at(2.0..2.0)).expect_err("zero-length window is invalid");
assert!(matches!(err, ResolveError::Invalid(_)));
}
#[test]
fn resolve_records_trigger_at_end_uses_resolved_length() {
let e = Event::new();
let resolved = resolve(Dot.at(5.0..8.0).trigger_at_end(e)).expect("windowed, so not timeless");
assert_eq!(resolved.triggers().get(e.id()).seconds(), 3.0);
}
#[test]
fn resolve_trigger_earliest_wins_across_two_writers() {
let e = Event::new();
let mut ctx = ResolveCtx::new();
Dot.at(0.0..2.0).trigger_at_start(e).resolve(7.0, &mut ctx);
Dot.at(0.0..2.0).trigger_at_start(e).resolve(2.0, &mut ctx);
let table = ctx.into_triggers();
assert_eq!(table.get(e.id()).seconds(), 2.0);
}
#[test]
fn resolved_timeline_is_send() {
fn assert_send<T: Send>(_: &T) {}
let resolved = resolve(Dot.at(0.0..1.0)).unwrap();
assert_send(&resolved);
}