Merge #2182: Regression tests for #354, #382, #979 and #1497 (@skylartaylor)

This commit is contained in:
aiongg
2026-10-23 23:32:52 +00:00
4 changed files with 180 additions and 0 deletions
+42
View File
@@ -1065,6 +1065,48 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
// Regression test for #382 (#274): resampling footage audio divided by the requested mix
// rate, so a rate of 0 panicked (division by zero); the read must come back instead.
#[test]
fn decoding_audio_at_a_zero_mix_rate_does_not_divide_by_zero() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-zero-rate");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
// One second of a 440 Hz tone, 44.1 kHz mono 16-bit PCM: not the 48 kHz mix rate.
let native = 44_100u32;
let pcm: Vec<u8> =
(0..native).flat_map(|n| (((n as f32 * 440.0 * std::f32::consts::TAU / native as f32).sin() * 8000.0) as i16).to_le_bytes()).collect();
let mut wav = b"RIFF".to_vec();
wav.extend((36 + pcm.len() as u32).to_le_bytes());
wav.extend(b"WAVEfmt ");
wav.extend(16u32.to_le_bytes());
wav.extend([1u16, 1].iter().flat_map(|v| v.to_le_bytes()));
wav.extend(native.to_le_bytes());
wav.extend((native * 2).to_le_bytes());
wav.extend([2u16, 16].iter().flat_map(|v| v.to_le_bytes()));
wav.extend(b"data");
wav.extend((pcm.len() as u32).to_le_bytes());
wav.extend(&pcm);
let path = dir.join("tone.wav");
std::fs::write(&path, &wav).unwrap();
let footage = Footage {
path: path.to_string_lossy().into(),
kind: FootageKind::Audio,
duration: Tick::from_seconds_f64(1.0),
has_audio: true,
..Default::default()
};
let pool = MediaPool::new();
// The file really is read, and resampled, at a normal rate...
let tone = pool.decode_audio(&footage, Tick::from_seconds_f64(0.1), 1024, 48_000);
assert!(tone.iter().any(|v| v.abs() > 0.05), "the tone is audible at 48 kHz");
// ...and a mix rate of 0 (which `audio_samples` itself turns away) returns a read of the
// requested length instead of dividing by it.
let degenerate = pool.decode_audio(&footage, Tick::from_seconds_f64(0.1), 64, 0);
assert_eq!(degenerate.len(), 128);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn frame_index_loops_and_clamps() {
let r = FrameRate::FPS_30;
@@ -778,6 +778,23 @@ fn effect_control_name_can_be_renamed_from_its_header() {
assert_eq!(current_name(&h), "Point Control");
}
// Regression test for #1497: double-clicking an effect parameter's name in Effect Controls did
// nothing; it should reveal that property in the Timeline, as After Effects does.
#[test]
fn double_clicking_an_effect_property_name_reveals_it_in_the_timeline() {
let (app, x) = app();
let mut h = Harness::builder().with_size(egui::vec2(1700.0, 1100.0)).with_step_dt(1.0 / 60.0).build_eframe(|_| app);
settle(&mut h);
let row = format!("timeline.prop.{}.name", x.point);
assert!(!ids(&h).contains(&row), "the effect's property is not in the Timeline before the double-click");
assert!(!h.state().ui.timeline.reveal_props.contains(&x.point));
let point = rect_of(&h, &format!("effectControls.prop.{}.name", x.point)).center();
double_click(&mut h, point);
h.run_steps(4);
assert!(h.state().ui.timeline.reveal_props.contains(&x.point), "the property is marked revealed in the Timeline");
assert!(ids(&h).contains(&row), "the Timeline shows the property's row");
}
#[test]
fn effect_rename_escape_keeps_the_name() {
let (app, x) = app();
+44
View File
@@ -187,3 +187,47 @@ fn double_clicking_an_effect_in_the_timeline_opens_effect_controls() {
assert_eq!(st.selected_props.iter().map(|(l, u)| (l.0, *u)).collect::<Vec<_>>(), vec![(red, uid)], "with the effect selected");
assert!(!h.state().ui.timeline.open_groups.contains(&uid), "the effect did not twirl open");
}
// Regression test for #354: the focused panel's blue outline was painted before the panel body,
// so the body's background and widgets painted over it. It must be drawn after every panel body.
#[test]
fn focused_panel_outline_is_painted_over_the_panel_bodies() {
fn flatten<'a>(shape: &'a egui::Shape, clip: egui::Rect, out: &mut Vec<(egui::Rect, &'a egui::epaint::RectShape)>) {
match shape {
egui::Shape::Rect(r) => out.push((clip, r)),
egui::Shape::Vec(v) => v.iter().for_each(|s| flatten(s, clip, out)),
_ => {}
}
}
let (mut h, _, _) = harness();
for panel in [PanelKind::Timeline, PanelKind::Project] {
h.state_mut().ui.focused = panel;
h.run_steps(3);
let focus = h.state().tokens.focus;
let mut rects = vec![];
h.output().shapes.iter().for_each(|c| flatten(&c.shape, c.clip_rect, &mut rects));
// The one-pixel, unfilled outline round the focused panel's frame.
let body = rect(&h, &format!("panel.{}", panel.id()));
let outlines: Vec<usize> = rects
.iter()
.enumerate()
.filter(|(_, (_, r))| r.fill.a() == 0 && r.stroke.color == focus && r.stroke.width == 1.0 && r.rect.contains_rect(body))
.map(|(i, _)| i)
.collect();
let Some(&at) = outlines.last() else { panic!("{panel:?}: no focus outline painted") };
let outline = rects[at].1.rect;
// Nothing painted after it reaches the one-pixel ring it draws, where a panel's
// background or rows would hide it.
let ring = [
egui::Rect::from_min_max(outline.min, egui::pos2(outline.right(), outline.top() + 1.0)),
egui::Rect::from_min_max(egui::pos2(outline.left(), outline.bottom() - 1.0), outline.max),
egui::Rect::from_min_max(outline.min, egui::pos2(outline.left() + 1.0, outline.bottom())),
egui::Rect::from_min_max(egui::pos2(outline.right() - 1.0, outline.top()), outline.max),
];
for (clip, r) in &rects[at + 1..] {
let painted = r.rect.intersect(*clip);
let over = ring.iter().any(|edge| edge.intersect(painted).area() > 0.0);
assert!(r.fill.a() == 0 || !over, "{panel:?}: a filled rect {:?} (fill {:?}) is painted over the focus outline {outline:?}", r.rect, r.fill);
}
}
}
@@ -637,3 +637,80 @@ fn timeline_context_menu_creates_shapes_and_masks_from_text() {
assert!(h.query_by_label("Create Shapes from Text").is_none(), "no text conversion for a solid");
assert!(h.query_by_label("Create Masks from Text").is_none(), "no text conversion for a solid");
}
// Regression test for #979 (#958): right-clicking a vector footage layer (SVG) in the Timeline
// or the Composition viewer offered no Create Shapes from Vector Layer; raster layers must not.
#[test]
fn layer_context_menus_offer_create_shapes_from_vector_layer_for_vector_footage() {
use effectcraft_engine::project::LayerSource;
let dir = std::env::temp_dir().join(format!("ec-ui-vector-menu-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let svg = dir.join("logo.svg");
std::fs::write(
&svg,
r##"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20" viewBox="0 0 40 20"><rect width="40" height="20" fill="#ff0000"/></svg>"##,
)
.unwrap();
let png = dir.join("photo.png");
image::RgbaImage::from_pixel(16, 16, image::Rgba([0, 128, 255, 255])).save(&png).unwrap();
let mut s = effectcraft_host::session();
let item = s.execute("file.import", json!({"paths": [svg.to_string_lossy()]})).unwrap()["items"][0].as_u64().unwrap();
let raster = s.execute("file.import", json!({"paths": [png.to_string_lossy()]})).unwrap()["items"][0].as_u64().unwrap();
s.execute("comp.new", json!({"name":"Vec","width":320,"height":180,"duration":4})).unwrap();
// The raster layer sits below the SVG layer, so the viewer click below lands on the SVG.
let photo = s.execute("layer.addItem", json!({"item": raster})).unwrap()["layer"].as_u64().unwrap();
let logo = s.execute("layer.addItem", json!({"item": item})).unwrap()["layer"].as_u64().unwrap();
let plate = s.execute("layer.newSolid", json!({"name":"Plate","width":40,"height":40,"color":"#406080"})).unwrap()["layer"].as_u64().unwrap();
s.execute("edit.deselectAll", json!({})).unwrap();
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
h.run_steps(3);
let right_click = |h: &mut Harness<'_, EffectcraftApp>, p: Pos2| {
for pressed in [true, false] {
h.input_mut().events.push(Event::PointerMoved(p));
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE });
h.step();
}
h.run_steps(3);
};
let row = |h: &Harness<'_, EffectcraftApp>, id: u64| {
let r = rect(h, &format!("timeline.layer.{id}.row"));
pos2(r.min.x + 210.0, r.center().y)
};
let label = "Create Shapes from Vector Layer";
let shapes =
|h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layers.iter().filter(|l| matches!(l.source, LayerSource::Shape)).count();
// A raster-less solid does not offer the conversion.
h.state_mut().session.execute("layer.select", json!({"layers": [plate]})).unwrap();
h.run_steps(3);
let at = row(&h, plate);
right_click(&mut h, at);
assert!(h.query_by_label(label).is_none(), "no vector conversion for a solid");
h.key_press(egui::Key::Escape);
h.run_steps(3);
// Nor does raster footage.
h.state_mut().session.execute("layer.select", json!({"layers": [photo]})).unwrap();
h.run_steps(3);
let at = row(&h, photo);
right_click(&mut h, at);
assert!(h.query_by_label(label).is_none(), "no vector conversion for raster footage");
h.key_press(egui::Key::Escape);
h.run_steps(3);
// The Timeline row of the SVG layer offers it, and it converts the layer.
h.state_mut().session.execute("layer.select", json!({"layers": [logo]})).unwrap();
h.run_steps(3);
let at = row(&h, logo);
right_click(&mut h, at);
h.get_by_label(label).click();
h.run_steps(3);
assert_eq!(shapes(&h), 1, "a shape layer was created from the SVG layer");
// The Composition viewer's menu offers it for the SVG layer under the pointer too.
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
h.state_mut().session.execute("prop.set", json!({"layer": plate, "path": "transform/position", "value": [40.0, 40.0]})).unwrap();
// The solid stays selected: the menu must follow the layer under the pointer, not the selection.
h.state_mut().session.execute("layer.select", json!({"layers": [plate]})).unwrap();
h.run_steps(3);
let comp = rect(&h, "viewer.comp");
right_click(&mut h, comp.center());
assert!(h.query_by_label(label).is_some(), "the viewer's context menu offers the vector conversion");
let _ = std::fs::remove_dir_all(&dir);
}