Files
effectcraft/crates/ui-egui/tests/viewer_geometry.rs
skylar taylor-barrick 3dba472943 UI.9: share docking transactions and preserve viewer lifecycle
Preserve legacy selection repair, modifier routing, viewer lifecycle and workspace persistence through the shared docking integration. Qualify the complete workspace gate by refreshing the 18 stale Audio Spectrum hashes for the existing linear renderer, with an independent synthetic-tone regression.
2026-10-10 17:14:12 -06:00

278 lines
12 KiB
Rust

//! Inspect the actual viewer's painted colors and pointer-driven control geometry.
use effectcraft_engine::Session;
use effectcraft_ui_egui::{
EffectcraftApp,
panels::viewer,
theme::{self, ThemeKind},
};
use egui::{Color32, Event, Rect, Shape, pos2, vec2};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use serde_json::json;
fn harness(width: f32, kind: ThemeKind) -> Harness<'static, EffectcraftApp> {
harness_at_scale(width, kind, 1.0)
}
fn harness_at_scale(width: f32, kind: ThemeKind, scale: f32) -> Harness<'static, EffectcraftApp> {
let mut session = Session::default();
session.execute("comp.new", json!({"name":"Geometry validation", "width":640, "height":360, "duration":4})).unwrap();
let mut h = Harness::builder().with_size(vec2(width, 360.0)).with_pixels_per_point(scale).build_ui_state(
|ui, app: &mut EffectcraftApp| {
app.auto.begin_frame();
*app.frames.last_ms.lock().unwrap() = 0.0;
viewer::show(app, ui, ui.max_rect());
},
EffectcraftApp::new(session),
);
let ctx = h.ctx.clone();
h.state_mut().set_theme(&ctx, kind);
theme::install(&ctx, &h.state().tokens, "en");
h.run_steps(4);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
while h.state().frames.urgent_pending() && std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(1));
}
assert!(!h.state().frames.urgent_pending(), "synthetic viewer frame must finish rendering");
h.run_steps(3);
h
}
fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect {
let e = h.state().auto.elements.iter().find(|e| e.id == id).unwrap_or_else(|| panic!("missing {id}"));
Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3]))
}
fn luminance(color: Color32) -> f32 {
let linear = |v: u8| {
let v = f32::from(v) / 255.0;
if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
};
0.2126 * linear(color.r()) + 0.7152 * linear(color.g()) + 0.0722 * linear(color.b())
}
#[test]
fn active_breadcrumb_text_contrasts_with_its_actual_painted_background() {
let mut failures = Vec::new();
for kind in [ThemeKind::Light, ThemeKind::Dark, ThemeKind::Darker] {
for scale in [1.0, 2.0] {
let h = harness_at_scale(300.0, kind, scale);
let text = h
.output()
.shapes
.iter()
.find_map(|s| match &s.shape {
Shape::Text(text) if text.galley.text() == "Geometry validation" => Some(text),
_ => None,
})
.unwrap();
let fill = h
.output()
.shapes
.iter()
.rev()
.find_map(|s| match &s.shape {
Shape::Rect(r) if r.rect.height() == 18.0 && r.rect.contains(text.visual_bounding_rect().center()) => Some(r.fill),
_ => None,
})
.unwrap();
let color = text.override_text_color.unwrap_or(text.fallback_color);
let foreground = luminance(color);
let background = luminance(fill);
let contrast = (foreground.max(background) + 0.05) / (foreground.min(background) + 0.05);
eprintln!("viewer breadcrumb: {kind:?} {scale}x, text {color:?}, background {fill:?}, contrast {contrast:.2}");
if contrast < 4.5 {
failures.push(format!("{kind:?} {scale}x: actual breadcrumb contrast is {contrast:.2}"));
}
}
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
#[test]
fn compact_control_row_scrolls_and_preserves_interactions() {
let mut h = harness(300.0, ThemeKind::Light);
assert!(rect(&h, "viewer.timecode").right() > 300.0);
h.event(Event::PointerMoved(pos2(150.0, 345.0)));
h.event(Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(-1500.0, 0.0), modifiers: Default::default(), phase: egui::TouchPhase::Move });
h.run_steps(30);
let timecode = rect(&h, "viewer.timecode");
let row = rect(&h, "viewer.controls");
assert!(timecode.left() >= row.left() && timecode.right() <= row.right(), "last viewer control must scroll into view: {timecode:?}");
let exposure = rect(&h, "viewer.resetExposure");
assert!(exposure.left() >= row.left() && exposure.right() <= row.right());
h.state_mut().session.execute("view.exposure", json!({"stops":2.0})).unwrap();
h.run_steps(2);
let point = rect(&h, "viewer.resetExposure").center();
h.event(Event::PointerMoved(point));
for pressed in [true, false] {
h.event(Event::PointerButton { pos: point, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() });
h.step();
}
h.run_steps(3);
assert_eq!(h.state().session.state.viewer.exposure, 0.0, "a scrolled control must still run its original command");
let exposure = rect(&h, "viewer.exposure").center();
h.event(Event::PointerMoved(exposure));
h.event(Event::PointerButton { pos: exposure, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
h.step();
for x in [10.0, 20.0, 30.0] {
h.event(Event::PointerMoved(exposure + vec2(x, 0.0)));
h.step();
}
h.event(Event::PointerButton { pos: exposure + vec2(30.0, 0.0), button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
h.run_steps(3);
assert!(h.state().session.state.viewer.exposure > 0.0, "the scrolled exposure control must retain dragging");
click_control(&mut h, "viewer.snapshot");
assert!(h.state().session.snapshot.is_some(), "the scrolled snapshot button must capture");
let snapshot = rect(&h, "viewer.showSnapshot").center();
h.event(Event::PointerMoved(snapshot));
h.event(Event::PointerButton { pos: snapshot, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
h.run_steps(3);
assert!(h.state().session.state.viewer.show_snapshot, "the scrolled snapshot control must retain holding");
h.event(Event::PointerButton { pos: snapshot, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
h.run_steps(3);
assert!(!h.state().session.state.viewer.show_snapshot, "releasing the snapshot control must return to the current frame");
click_control(&mut h, "viewer.fastPreviews");
click_control(&mut h, "viewer.fastPreviewsItem.1");
assert_eq!(h.state().session.state.viewer.fast_previews.id(), "adaptive", "a scrolled dropdown must retain its menu command");
}
fn click_control(h: &mut Harness<'_, EffectcraftApp>, id: &str) {
let control = rect(h, id);
let point = control.center();
let row = rect(h, "viewer.controls");
if !id.contains("Item.") {
assert!(control.left() >= row.left() && control.right() <= row.right(), "{id} must be fully visible before clicking");
}
h.event(Event::PointerMoved(point));
h.step();
for pressed in [true, false] {
h.event(Event::PointerButton { pos: point, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() });
h.step();
}
h.run_steps(3);
}
#[test]
fn tab_traversal_reveals_the_last_control() {
let mut h = harness(300.0, ThemeKind::Dark);
let last = egui::Id::new("vw-timecode");
for _ in 0..80 {
h.key_press(egui::Key::Tab);
h.run_steps(3);
if h.ctx.memory(|memory| memory.focused()) == Some(last) {
break;
}
}
assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(last), "Tab must reach the actual timecode control");
h.run_steps(12);
let response = h.ctx.read_response(last).unwrap();
let row = rect(&h, "viewer.controls");
assert!(response.rect.left() >= row.left() && response.rect.right() <= row.right(), "focused control must be visible: {:?}", response.rect);
assert!(
h.output().shapes.iter().any(|shape| matches!(&shape.shape, Shape::Rect(rect)
if rect.rect == response.rect.shrink(1.0) && rect.stroke.color == h.state().tokens.focus)),
"focused control must have a visible outline"
);
}
#[test]
fn compact_scrollbar_thumb_is_distinct_and_tracks_the_scrolled_position() {
for kind in [ThemeKind::Dark, ThemeKind::Darker, ThemeKind::Light] {
for scale in [1.0, 2.0] {
let mut h = harness_at_scale(300.0, kind, scale);
let (initial_track, initial_thumb) = painted_scrollbar(&h, kind, scale, "initial");
scroll_controls_to_end(&mut h);
let (scrolled_track, scrolled_thumb) = painted_scrollbar(&h, kind, scale, "scrolled");
assert_eq!(initial_track, scrolled_track, "scrolling must keep the track fixed");
assert!(scrolled_thumb.left() > initial_thumb.left() + 1.0, "the painted thumb must move with horizontal scrolling");
}
}
}
fn painted_scrollbar(h: &Harness<'_, EffectcraftApp>, kind: ThemeKind, scale: f32, state: &str) -> (Rect, Rect) {
let scrollbar = h.get_by_role(egui::accesskit::Role::ScrollBar).rect();
let row = rect(h, "viewer.controls");
assert!(row.contains_rect(scrollbar), "scrollbar {scrollbar:?} must stay inside {row:?}");
let mut painted: Vec<_> = h
.output()
.shapes
.iter()
.filter_map(|shape| match &shape.shape {
Shape::Rect(rect)
if rect.rect.width() > 20.0
&& rect.rect.height() <= 8.0
&& scrollbar.contains_rect(rect.rect)
&& rect.fill.a() > 0
&& shape.clip_rect.contains_rect(rect.rect) =>
{
Some(rect)
}
_ => None,
})
.collect();
painted.sort_by(|a, b| a.rect.width().total_cmp(&b.rect.width()));
assert_eq!(painted.len(), 2, "the track and thumb must each be painted without clipping");
let (thumb, track) = (painted[0], painted[1]);
assert!(thumb.rect.width() < track.rect.width() - 1.0 && track.rect.contains_rect(thumb.rect), "the overflow thumb must be narrower than its track");
let foreground = luminance(thumb.fill);
let background = luminance(track.fill);
let contrast = (foreground.max(background) + 0.05) / (foreground.min(background) + 0.05);
eprintln!(
"viewer scrollbar: {kind:?} {scale}x {state}, thumb {:?} {:?}, track {:?} {:?}, contrast {contrast:.2}",
thumb.rect, thumb.fill, track.rect, track.fill
);
assert!(contrast >= 3.0, "{kind:?} {scale}x {state}: actual thumb/track contrast {contrast:.2} is too low");
(track.rect, thumb.rect)
}
fn scroll_controls_to_end(h: &mut Harness<'_, EffectcraftApp>) {
h.event(Event::PointerMoved(pos2(150.0, 345.0)));
h.event(Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(-1500.0, 0.0), modifiers: Default::default(), phase: egui::TouchPhase::Move });
h.run_steps(30);
h.event(Event::PointerMoved(pos2(150.0, 180.0)));
h.run_steps(3);
}
#[test]
fn viewer_theme_and_width_visual_matrix() {
let Ok(directory) = std::env::var("CRAFT_UI_VISUAL_DIR") else { return };
std::fs::create_dir_all(&directory).unwrap();
for kind in [ThemeKind::Dark, ThemeKind::Darker, ThemeKind::Light] {
for width in [900.0, 300.0] {
for scale in [1.0, 2.0] {
let mut h = harness_at_scale(width, kind, scale);
save_stable_snapshot(&mut h, &format!("{directory}/effect-viewer-{kind:?}-{width}-{scale}x.png"), width);
if width == 300.0 {
scroll_controls_to_end(&mut h);
save_stable_snapshot(&mut h, &format!("{directory}/effect-viewer-{kind:?}-{width}-{scale}x-scrolled.png"), width);
}
}
}
}
}
fn save_stable_snapshot(h: &mut Harness<'_, EffectcraftApp>, path: &str, width: f32) {
let image = h.render().unwrap();
image.save(path).unwrap();
h.run_steps(3);
let repeated = h.render().unwrap();
assert!(image == repeated, "settled synthetic viewer pixels must repeat exactly: {path}");
if width == 900.0 {
let times: Vec<_> = h
.output()
.shapes
.iter()
.filter_map(|shape| match &shape.shape {
Shape::Text(text) if text.galley.text().ends_with(" ms") => Some(text.galley.text()),
_ => None,
})
.collect();
assert_eq!(times, ["0 ms"], "cached rendering must preserve the deterministic timing input");
}
}