278 lines
12 KiB
Rust
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");
|
|
}
|
|
}
|