diff --git a/crates/gpui_widgets/src/audio_meter.rs b/crates/gpui_widgets/src/audio_meter.rs index 837633e976..da8a43a197 100644 --- a/crates/gpui_widgets/src/audio_meter.rs +++ b/crates/gpui_widgets/src/audio_meter.rs @@ -45,7 +45,7 @@ impl AudioLevelMeter { } /// The current per-channel levels. - pub fn levels(&self, cx: &Context) -> Vec { + pub fn levels(&self, cx: &App) -> Vec { self.data.read(cx).levels() } @@ -118,10 +118,52 @@ impl Render for AudioLevelMeter { 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); + impl AudioMeterDataSource for MockAudio { + fn levels(&self) -> Vec { + self.0.clone() + } + } + + struct Host { + meter: Entity>, + } + impl Render for Host { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> 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); + } } diff --git a/crates/gpui_widgets/src/dialog/file_dialog.rs b/crates/gpui_widgets/src/dialog/file_dialog.rs index 1f1d4e9e18..5bdeecfbdd 100644 --- a/crates/gpui_widgets/src/dialog/file_dialog.rs +++ b/crates/gpui_widgets/src/dialog/file_dialog.rs @@ -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, + } + impl Render for Host { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> 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(); + }); + } } diff --git a/crates/gpui_widgets/src/dialog/progress.rs b/crates/gpui_widgets/src/dialog/progress.rs index 6c43106f1a..d4827d0541 100644 --- a/crates/gpui_widgets/src/dialog/progress.rs +++ b/crates/gpui_widgets/src/dialog/progress.rs @@ -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, + } + impl Render for Host { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> 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); + } } diff --git a/crates/gpui_widgets/src/keyable.rs b/crates/gpui_widgets/src/keyable.rs index 6ed55f95a9..e6f61196b3 100644 --- a/crates/gpui_widgets/src/keyable.rs +++ b/crates/gpui_widgets/src/keyable.rs @@ -138,6 +138,33 @@ fn paint_diamond(bounds: Bounds, 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) -> 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() { diff --git a/crates/gpui_widgets/src/menu/mod.rs b/crates/gpui_widgets/src/menu/mod.rs index 3ab2a63150..08f8df2465 100644 --- a/crates/gpui_widgets/src/menu/mod.rs +++ b/crates/gpui_widgets/src/menu/mod.rs @@ -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 { + 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, + events: Vec, + } + impl Render for Host { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div().size_full().child(self.menu_bar.clone()) + } + } + + fn make_bar(cx: &mut TestAppContext) -> (&'static mut VisualTestContext, Entity) { + 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, + event: &MenuBarEvent, + _cx: &mut Context| { + 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); + } } diff --git a/crates/gpui_widgets/src/project_explorer.rs b/crates/gpui_widgets/src/project_explorer.rs index b9eaf3986e..aba4ca26ee 100644 --- a/crates/gpui_widgets/src/project_explorer.rs +++ b/crates/gpui_widgets/src/project_explorer.rs @@ -255,8 +255,12 @@ impl Render for ProjectExplorer { 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 { + vec![ + ProjectEntry::new(1, "Footage", true), + ProjectEntry::new(2, "Notes.md", false), + ] + } + fn children(&self, parent_id: u64) -> Vec { + if parent_id == 1 { + vec![ProjectEntry::new(10, "a.mov", false)] + } else { + Vec::new() + } + } + } + + struct Host { + explorer: Entity>, + events: Vec, + } + impl Render for Host { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div().size_full().child(self.explorer.clone()) + } + } + + fn make_explorer(cx: &mut TestAppContext) -> (&'static mut VisualTestContext, Entity) { + 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>, + event: &ProjectExplorerEvent, + _cx: &mut Context| { + 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"); + } } diff --git a/crates/gpui_widgets/src/scopes/mod.rs b/crates/gpui_widgets/src/scopes/mod.rs index 7cde8b4ef0..e0ebb07a76 100644 --- a/crates/gpui_widgets/src/scopes/mod.rs +++ b/crates/gpui_widgets/src/scopes/mod.rs @@ -47,7 +47,7 @@ impl Histogram { } /// The current histogram bins (for tests and hosts). - pub fn bins(&self, cx: &Context) -> Vec { + pub fn bins(&self, cx: &App) -> Vec { histogram_bins(&self.data.read(cx).luma_samples(), 64) } } @@ -104,7 +104,7 @@ impl Waveform { } /// The current envelope columns. - pub fn envelope(&self, cx: &Context) -> 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 Vectorscope { } /// The projected chroma points. - pub fn points(&self, cx: &Context) -> 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 Render for Vectorscope { .size_full() } } + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{Entity, Render, TestAppContext, Window, div, px, size}; + + struct MockLuma(Vec); + impl LumaDataSource for MockLuma { + fn luma_samples(&self) -> Vec { + 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>, + waveform: Entity>, + vectorscope: Entity>, + } + impl Render for Host { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> 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); + } +} diff --git a/crates/gpui_widgets/src/viewer/mod.rs b/crates/gpui_widgets/src/viewer/mod.rs index 10788f1408..ab20480718 100644 --- a/crates/gpui_widgets/src/viewer/mod.rs +++ b/crates/gpui_widgets/src/viewer/mod.rs @@ -354,18 +354,42 @@ mod tests { } } + struct Host { + viewer: Entity>, + events: Vec, + } + impl Render for Host { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> 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>, - events: Vec, - } - impl Render for Host { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> 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) { 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"); } }