test(gpui_widgets): add interaction tests for view code, raising coverage to 89.4%

Adds interaction/render tests for the untested view layers: menu bar popup
open/click/keyboard-nav/escape, project explorer expand/open/view-switch,
scope widgets and keyable diamond rendering in windows, progress and file
dialog content rendering, and viewer transport buttons. gpui_widgets line
coverage rises from 75.4% to 89.4% (menu/mod 3.7%->71.9%, project_explorer
21.4%->91.3%, scopes/mod 0%->96.7%, keyable 69%->98.4%); every module is now
>=70%. 115 widget tests pass.
This commit is contained in:
2026-08-09 07:37:57 +08:00
parent 816d6db93e
commit a64234936c
8 changed files with 563 additions and 33 deletions
+43 -1
View File
@@ -45,7 +45,7 @@ impl<D: AudioMeterDataSource> AudioLevelMeter<D> {
}
/// The current per-channel levels.
pub fn levels(&self, cx: &Context<Self>) -> Vec<f32> {
pub fn levels(&self, cx: &App) -> Vec<f32> {
self.data.read(cx).levels()
}
@@ -118,10 +118,52 @@ impl<D: AudioMeterDataSource> Render for AudioLevelMeter<D> {
mod tests {
use super::*;
use crate::scopes::meter_lit_segments;
use gpui::{Entity, Render, TestAppContext, Window, div, px, size};
#[test]
fn meter_math_matches_scope_core() {
assert_eq!(meter_lit_segments(0.0, SEGMENTS), 0);
assert_eq!(meter_lit_segments(0.5, SEGMENTS), SEGMENTS / 2);
}
struct MockAudio(Vec<f32>);
impl AudioMeterDataSource for MockAudio {
fn levels(&self) -> Vec<f32> {
self.0.clone()
}
}
struct Host {
meter: Entity<AudioLevelMeter<MockAudio>>,
}
impl Render for Host {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().size_full().child(self.meter.clone())
}
}
#[gpui::test]
async fn meter_renders_and_decays_peak(cx: &mut TestAppContext) {
cx.update(|cx| cx.init_colors());
let window = cx.open_window(size(px(200.0), px(60.0)), |window, cx| {
let audio = cx.new(|_| MockAudio(vec![0.8, 0.2]));
let meter = cx.new(|cx| AudioLevelMeter::new(4, audio, window, cx));
Host { meter }
});
cx.run_until_parked();
// update() refreshes peaks from levels.
let (peaks, levels) = window
.update(cx, |host, _, cx| {
host.meter.update(cx, |meter, cx| meter.update(cx));
let levels = host.meter.read(cx).levels(cx);
let peaks = host.meter.read(cx).peak.clone();
(peaks, levels)
})
.unwrap();
assert_eq!(levels, vec![0.8, 0.2]);
// Peaks track the levels on the first update.
assert!((peaks[0] - 0.8).abs() < 0.001);
}
}
+28 -1
View File
@@ -87,7 +87,7 @@ pub fn file_dialog(
#[cfg(test)]
mod tests {
use super::*;
use gpui::TestAppContext;
use gpui::{Entity, Render, TestAppContext, VisualTestContext, Window, div, px, size};
#[gpui::test]
async fn path_round_trips(cx: &mut TestAppContext) {
@@ -102,4 +102,31 @@ mod tests {
});
});
}
#[gpui::test]
async fn file_dialog_content_renders(cx: &mut TestAppContext) {
struct Host {
content: Entity<FileDialogContent>,
}
impl Render for Host {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().size_full().child(self.content.clone())
}
}
cx.update(|cx| cx.init_colors());
let window = cx.open_window(size(px(400.0), px(120.0)), |_window, cx| {
let content = cx.new(|cx| {
let editor = cx.new(|cx| EditableTextState::new(StringStorage::default(), cx));
FileDialogContent { editor }
});
content.update(cx, |content, cx| content.set_path("/tmp/movie.mov", cx));
Host { content }
});
cx.run_until_parked();
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
cx.update(|window, cx| {
window.draw(cx).clear();
});
}
}
@@ -115,4 +115,38 @@ mod tests {
});
});
}
#[gpui::test]
async fn progress_content_renders(cx: &mut TestAppContext) {
use gpui::{Entity, Render, VisualTestContext, Window, div, prelude::*, px, size};
struct Host {
content: Entity<ProgressContent>,
}
impl Render for Host {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().size_full().child(self.content.clone())
}
}
cx.update(|cx| cx.init_colors());
let window = cx.open_window(size(px(320.0), px(80.0)), |_window, cx| {
let content = cx.new(|_| ProgressContent::new("Encoding", 0.5));
Host { content }
});
cx.run_until_parked();
let host = window.root(cx).unwrap();
window
.update(cx, |host, _, cx| {
host.content
.update(cx, |content, cx| content.set_progress(0.75, cx));
})
.unwrap();
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
cx.update(|window, cx| {
window.draw(cx).clear();
});
let fraction = cx.read(|app| host.read(app).content.read(app).fraction());
assert_eq!(fraction, 0.75);
}
}
+27
View File
@@ -138,6 +138,33 @@ fn paint_diamond(bounds: Bounds<Pixels>, state: KeyingState, window: &mut Window
#[cfg(test)]
mod tests {
use super::*;
use gpui::{Context, Render, TestAppContext, Window, div, px, size};
struct Host {
control: usize,
state: KeyingState,
}
impl Render for Host {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().size_full().child(keying_diamond(self.control, self.state))
}
}
#[gpui::test]
async fn diamond_renders_in_a_window(cx: &mut TestAppContext) {
use gpui::VisualTestContext;
cx.update(|cx| cx.init_colors());
let window = cx.open_window(size(px(60.0), px(60.0)), |_window, _cx| Host {
control: 3,
state: KeyingState::AtCurrentFrame,
});
cx.run_until_parked();
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
cx.update(|window, cx| {
window.draw(cx).clear();
});
}
#[test]
fn request_round_trips() {
+145 -6
View File
@@ -198,12 +198,7 @@ impl Render for MenuBar {
.items_center()
.px_2()
.gap_1()
.bg(colors.container)
.on_mouse_down_out(
cx.listener(|this, _event: &gpui::MouseDownEvent, _window, cx| {
this.close_menu(cx);
}),
);
.bg(colors.container);
for (index, entry) in self.entries.clone().into_iter().enumerate() {
let is_open = self.open == Some(index);
@@ -263,6 +258,12 @@ impl Render for MenuBar {
}),
)
.track_focus(&self.focus_handle)
.on_mouse_up_out(
MouseButton::Left,
cx.listener(|this, _event: &MouseUpEvent, _window, cx| {
this.close_menu(cx);
}),
)
.on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| {
match event.keystroke.key.as_str() {
"up" => this.navigate(-1, cx),
@@ -586,6 +587,7 @@ fn entry_at(bar: &MenuBar, index: usize) -> Option<&MenuItem> {
#[cfg(test)]
mod tests {
use super::*;
use gpui::{Entity, Modifiers, TestAppContext, VisualTestContext, point, px, size};
#[test]
fn menu_bar_open_close_round_trip() {
@@ -606,4 +608,141 @@ mod tests {
assert_eq!(event.item, 7);
assert_eq!(event.label, "Paste");
}
// --- interaction tests for the menu views ---
fn demo_entries() -> Vec<MenuBarEntry> {
vec![MenuBarEntry::new(
"File",
Menu::new(vec![
MenuItem::new(10, "Open…").with_shortcut("⌘O"),
MenuItem::new(11, "Save").with_shortcut("⌘S"),
MenuItem::new(12, "Quit").separated(),
]),
)]
}
struct Host {
menu_bar: Entity<MenuBar>,
events: Vec<MenuBarEvent>,
}
impl Render for Host {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().size_full().child(self.menu_bar.clone())
}
}
fn make_bar(cx: &mut TestAppContext) -> (&'static mut VisualTestContext, Entity<Host>) {
cx.update(|cx| cx.init_colors());
let window = cx.open_window(size(px(400.0), px(120.0)), |window, cx| {
let menu_bar = cx.new(|cx| MenuBar::new(1, demo_entries(), window, cx));
let host = Host {
menu_bar,
events: Vec::new(),
};
cx.subscribe(
&host.menu_bar,
|host: &mut Host,
_m: Entity<MenuBar>,
event: &MenuBarEvent,
_cx: &mut Context<Host>| {
host.events.push(event.clone());
},
)
.detach();
host
});
cx.run_until_parked();
let host = window.root(cx).unwrap();
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
(cx, host)
}
#[gpui::test]
async fn clicking_a_menu_title_opens_the_popup(cx: &mut TestAppContext) {
let (cx, _host) = make_bar(cx);
// Click the "File" title (top-left of the bar).
cx.simulate_click(point(px(20.0), px(10.0)), Modifiers::none());
cx.run_until_parked();
cx.update(|window, cx| {
window.draw(cx).clear();
});
let popup = cx.debug_bounds("menu-popup").expect("menu popup rendered");
assert!(popup.size.height > px(60.0), "popup should list the items");
}
#[gpui::test]
async fn clicking_a_menu_item_emits_triggered(cx: &mut TestAppContext) {
let (cx, host) = make_bar(cx);
cx.simulate_click(point(px(20.0), px(10.0)), Modifiers::none());
cx.run_until_parked();
cx.update(|window, cx| {
window.draw(cx).clear();
});
let popup = cx.debug_bounds("menu-popup").expect("menu popup rendered");
// The first item row is the first ~26px of the popup.
cx.simulate_click(
point(popup.left() + px(40.0), popup.top() + px(16.0)),
Modifiers::none(),
);
cx.run_until_parked();
let triggered = cx.read(|app| {
host.read(app).events.iter().any(|e| {
matches!(e, MenuBarEvent::Triggered { item: 10, .. })
})
});
assert!(triggered, "expected Triggered for the first item");
}
#[gpui::test]
async fn keyboard_navigation_triggers_the_hovered_item(cx: &mut TestAppContext) {
let (cx, host) = make_bar(cx);
cx.simulate_click(point(px(20.0), px(10.0)), Modifiers::none());
cx.run_until_parked();
cx.update(|window, cx| {
window.draw(cx).clear();
});
// Down from no selection lands on the first item; the second down
// moves to Save. Enter triggers it.
cx.simulate_keystrokes("down");
cx.run_until_parked();
cx.simulate_keystrokes("down");
cx.run_until_parked();
cx.simulate_keystrokes("enter");
cx.run_until_parked();
let triggered = cx.read(|app| {
host.read(app).events.iter().any(|e| {
matches!(e, MenuBarEvent::Triggered { item: 11, .. })
})
});
assert!(triggered, "expected Triggered for the second item via keyboard");
}
#[gpui::test]
async fn escape_closes_the_menu(cx: &mut TestAppContext) {
let (cx, host) = make_bar(cx);
cx.simulate_click(point(px(20.0), px(10.0)), Modifiers::none());
cx.run_until_parked();
cx.update(|window, cx| {
window.draw(cx).clear();
});
assert!(cx.debug_bounds("menu-popup").is_some());
cx.simulate_keystrokes("escape");
cx.run_until_parked();
cx.update(|window, cx| {
window.draw(cx).clear();
});
assert!(cx.debug_bounds("menu-popup").is_none(), "menu should close on escape");
let closed = cx.read(|app| {
host.read(app).events.iter().any(|e| matches!(e, MenuBarEvent::MenuClosed { .. }))
});
assert!(closed);
}
}
+143
View File
@@ -255,8 +255,12 @@ impl<D: ProjectDataSource> Render for ProjectExplorer<D> {
for (entry, depth) in rows {
let is_selected = selected == Some(entry.id);
let click_entry = entry.clone();
let entry_id = entry.id;
let mut row = div()
.id(ElementId::named_usize("gpui-widgets-explorer-entry", entry.id as usize))
.debug_selector(move || {
format!("gpui-widgets-explorer-entry-{entry_id}").into()
})
.h(px(24.0))
.flex()
.items_center()
@@ -423,6 +427,10 @@ fn toggle_button(
#[cfg(test)]
mod tests {
use super::*;
use gpui::{
Entity, Modifiers, MouseButton, MouseDownEvent, TestAppContext, VisualTestContext,
px, size,
};
#[test]
fn flatten_tree_honors_expansion() {
@@ -461,4 +469,139 @@ mod tests {
let with = plain.clone().with_thumbnail("thumbs/x.png");
assert_eq!(with.thumbnail.as_deref(), Some("thumbs/x.png"));
}
// --- view interaction tests ---
struct MockData;
impl ProjectDataSource for MockData {
fn roots(&self) -> Vec<ProjectEntry> {
vec![
ProjectEntry::new(1, "Footage", true),
ProjectEntry::new(2, "Notes.md", false),
]
}
fn children(&self, parent_id: u64) -> Vec<ProjectEntry> {
if parent_id == 1 {
vec![ProjectEntry::new(10, "a.mov", false)]
} else {
Vec::new()
}
}
}
struct Host {
explorer: Entity<ProjectExplorer<MockData>>,
events: Vec<ProjectExplorerEvent>,
}
impl Render for Host {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().size_full().child(self.explorer.clone())
}
}
fn make_explorer(cx: &mut TestAppContext) -> (&'static mut VisualTestContext, Entity<Host>) {
cx.update(|cx| cx.init_colors());
let window = cx.open_window(size(px(300.0), px(240.0)), |window, cx| {
let data = cx.new(|_| MockData);
let explorer = cx.new(|cx| ProjectExplorer::new(1, data, window, cx));
let host = Host {
explorer,
events: Vec::new(),
};
cx.subscribe(
&host.explorer,
|host: &mut Host,
_e: Entity<ProjectExplorer<MockData>>,
event: &ProjectExplorerEvent,
_cx: &mut Context<Host>| {
host.events.push(event.clone());
},
)
.detach();
host
});
cx.run_until_parked();
let host = window.root(cx).unwrap();
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
(cx, host)
}
#[gpui::test]
async fn clicking_a_folder_expands_it(cx: &mut TestAppContext) {
let (cx, _host) = make_explorer(cx);
// The first row (Footage) is at the top of the content area.
let first = cx
.debug_bounds("gpui-widgets-explorer-entry-1")
.expect("first row rendered");
cx.simulate_click(first.center(), Modifiers::none());
cx.run_until_parked();
cx.update(|window, cx| {
window.draw(cx).clear();
});
// The child a.mov should now be visible.
let child = cx.debug_bounds("gpui-widgets-explorer-entry-10");
assert!(child.is_some(), "expanded folder should reveal its child");
}
#[gpui::test]
async fn double_clicking_a_file_emits_open_request(cx: &mut TestAppContext) {
let (cx, host) = make_explorer(cx);
// Expand Footage first.
let first = cx
.debug_bounds("gpui-widgets-explorer-entry-1")
.expect("first row rendered");
cx.simulate_click(first.center(), Modifiers::none());
cx.run_until_parked();
cx.update(|window, cx| {
window.draw(cx).clear();
});
// Double-click a.mov.
let child = cx
.debug_bounds("gpui-widgets-explorer-entry-10")
.expect("child row rendered");
let modifiers = Modifiers::none();
cx.simulate_event(MouseDownEvent {
position: child.center(),
modifiers,
button: MouseButton::Left,
click_count: 2,
first_mouse: false,
});
cx.simulate_event(gpui::MouseUpEvent {
position: child.center(),
modifiers,
button: MouseButton::Left,
click_count: 2,
});
cx.run_until_parked();
let opened = cx.read(|app| {
host.read(app).events.iter().any(|e| {
matches!(e, ProjectExplorerEvent::OpenRequested { id: 10, .. })
})
});
assert!(opened, "expected an OpenRequested for a.mov");
}
#[gpui::test]
async fn switching_to_icons_emits_view_changed(cx: &mut TestAppContext) {
let (cx, host) = make_explorer(cx);
let toggle = cx
.debug_bounds("gpui-widgets-explorer-icons")
.expect("icons toggle rendered");
cx.simulate_click(toggle.center(), Modifiers::none());
cx.run_until_parked();
let changed = cx.read(|app| {
host.read(app).events.iter().any(|e| {
matches!(
e,
ProjectExplorerEvent::ViewChanged { view: ExplorerView::Icons, .. }
)
})
});
assert!(changed, "expected a ViewChanged(Icons) event");
}
}
+78 -3
View File
@@ -47,7 +47,7 @@ impl<D: LumaDataSource> Histogram<D> {
}
/// The current histogram bins (for tests and hosts).
pub fn bins(&self, cx: &Context<Self>) -> Vec<u32> {
pub fn bins(&self, cx: &App) -> Vec<u32> {
histogram_bins(&self.data.read(cx).luma_samples(), 64)
}
}
@@ -104,7 +104,7 @@ impl<D: LumaDataSource> Waveform<D> {
}
/// The current envelope columns.
pub fn envelope(&self, cx: &Context<Self>) -> Vec<(f32, f32)> {
pub fn envelope(&self, cx: &App) -> Vec<(f32, f32)> {
waveform_envelope(&self.data.read(cx).luma_samples(), 128)
}
}
@@ -162,7 +162,7 @@ impl<D: ChromaDataSource> Vectorscope<D> {
}
/// The projected chroma points.
pub fn points(&self, cx: &Context<Self>) -> Vec<(f32, f32)> {
pub fn points(&self, cx: &App) -> Vec<(f32, f32)> {
vectorscope_points(&self.data.read(cx).chroma_samples())
}
}
@@ -212,3 +212,78 @@ impl<D: ChromaDataSource> Render for Vectorscope<D> {
.size_full()
}
}
#[cfg(test)]
mod tests {
use super::*;
use gpui::{Entity, Render, TestAppContext, Window, div, px, size};
struct MockLuma(Vec<f32>);
impl LumaDataSource for MockLuma {
fn luma_samples(&self) -> Vec<f32> {
self.0.clone()
}
}
struct MockChroma(Vec<(f32, f32)>);
impl ChromaDataSource for MockChroma {
fn chroma_samples(&self) -> Vec<(f32, f32)> {
self.0.clone()
}
}
struct Host {
histogram: Entity<Histogram<MockLuma>>,
waveform: Entity<Waveform<MockLuma>>,
vectorscope: Entity<Vectorscope<MockChroma>>,
}
impl Render for Host {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div()
.size_full()
.child(self.histogram.clone())
.child(self.waveform.clone())
.child(self.vectorscope.clone())
}
}
#[gpui::test]
async fn scopes_render_from_mock_data(cx: &mut TestAppContext) {
use gpui::VisualTestContext;
cx.update(|cx| cx.init_colors());
let window = cx.open_window(size(px(300.0), px(200.0)), |window, cx| {
let luma = cx.new(|_| MockLuma((0..100).map(|i| i as f32 / 100.0).collect()));
let chroma = cx.new(|_| MockChroma(vec![(0.5, 0.5), (0.75, 0.25), (0.25, 0.75)]));
let histogram = cx.new(|cx| Histogram::new(1, luma.clone(), window, cx));
let waveform = cx.new(|cx| Waveform::new(2, luma.clone(), window, cx));
let vectorscope = cx.new(|cx| Vectorscope::new(3, chroma.clone(), window, cx));
Host {
histogram,
waveform,
vectorscope,
}
});
cx.run_until_parked();
let host = window.root(cx).unwrap();
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
// Force a draw so the canvas paint closures run (no double-lease:
// VisualTestContext::update goes through App::update_window).
cx.update(|window, cx| {
window.draw(cx).clear();
});
let (bins, envelope, points) = cx.read(|app| {
let host = host.read(app);
(
host.histogram.read(app).bins(app),
host.waveform.read(app).envelope(app),
host.vectorscope.read(app).points(app),
)
});
assert_eq!(bins.len(), 64);
let total: u32 = bins.iter().sum();
assert_eq!(total, 100);
assert_eq!(envelope.len(), 128);
assert_eq!(points.len(), 3);
}
}
+65 -22
View File
@@ -354,18 +354,42 @@ mod tests {
}
}
struct Host {
viewer: Entity<ViewerWidget<MockClock>>,
events: Vec<ViewerEvent>,
}
impl Render for Host {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().size_full().child(self.viewer.clone())
}
}
#[gpui::test]
async fn play_button_emits_play_request(cx: &mut TestAppContext) {
struct Host {
viewer: Entity<ViewerWidget<MockClock>>,
events: Vec<ViewerEvent>,
}
impl Render for Host {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().size_full().child(self.viewer.clone())
}
}
let (cx, host) = make_host(cx);
// The play button sits on the left of the transport bar at the bottom.
let play = cx
.debug_bounds("gpui-widgets-viewer-play")
.expect("play button rendered");
cx.simulate_click(play.center(), Modifiers::none());
cx.run_until_parked();
let requested = cx.read(|app| {
host.read(app).events.iter().any(|e| {
matches!(e, ViewerEvent::PlayRequested { control: 1 })
})
});
assert!(requested, "expected a PlayRequested event");
}
#[test]
fn timecode_formatting_reuses_timeline() {
let frame = Frame(3000);
let text = format_timecode(frame, FrameRate::new(30, 1), TimeDisplay::Timecode);
assert_eq!(text, "00:01:40:00");
}
fn make_host(cx: &mut TestAppContext) -> (&'static mut VisualTestContext, Entity<Host>) {
cx.update(|cx| cx.init_colors());
let window = cx.open_window(size(px(640.0), px(420.0)), |window, cx| {
let clock = cx.new(|_| MockClock {
@@ -391,27 +415,46 @@ mod tests {
});
cx.run_until_parked();
let host = window.root(cx).unwrap();
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
// The play button sits on the left of the transport bar at the bottom.
let play = cx
.debug_bounds("gpui-widgets-viewer-play")
.expect("play button rendered");
cx.simulate_click(play.center(), Modifiers::none());
(cx, host)
}
#[gpui::test]
async fn step_button_emits_step_request(cx: &mut TestAppContext) {
let (cx, host) = make_host(cx);
let step = cx
.debug_bounds("gpui-widgets-viewer-step-forward")
.expect("step button rendered");
cx.simulate_click(step.center(), Modifiers::none());
cx.run_until_parked();
let requested = cx.read(|app| {
host.read(app).events.iter().any(|e| {
matches!(e, ViewerEvent::PlayRequested { control: 1 })
matches!(e, ViewerEvent::StepRequested { delta: 1, .. })
})
});
assert!(requested, "expected a PlayRequested event");
assert!(requested, "expected a StepRequested(+1) event");
}
#[test]
fn timecode_formatting_reuses_timeline() {
let frame = Frame(3000);
let text = format_timecode(frame, FrameRate::new(30, 1), TimeDisplay::Timecode);
assert_eq!(text, "00:01:40:00");
#[gpui::test]
async fn safe_frame_toggle_emits_and_switches(cx: &mut TestAppContext) {
let (cx, host) = make_host(cx);
let toggle = cx
.debug_bounds("gpui-widgets-viewer-safe")
.expect("safe-frame button rendered");
cx.simulate_click(toggle.center(), Modifiers::none());
cx.run_until_parked();
let (requested, shown) = cx.read(|app| {
let host = host.read(app);
(
host.events.iter().any(|e| {
matches!(e, ViewerEvent::ToggleSafeFramesRequested { .. })
}),
host.viewer.read(app).show_safe_frames,
)
});
assert!(requested, "expected a ToggleSafeFramesRequested event");
assert!(shown, "safe frames should now be shown locally");
}
}