use crate::harness;
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use gpui::{
canvas, point, prelude::*, px, AbsoluteLength, AnyElement, Bounds, Modifiers, Pixels,
TestAppContext, VisualTestContext,
};
use harness::{click, events, open_host, press, Events};
use herogpui_components::{
clear_toasts, dismiss_toast, pause_toasts, toast_store, Alert, Avatar, AvatarVariant, Badge,
BadgeAnchor, BadgeLabel, BadgePlacement, BadgeVariant, Color, Meter, NumberFormat, ProgressBar,
ProgressCircle, Size, Skeleton, Spinner, SpinnerSize, Toast, ToastData, ToastPlacement,
ToastViewport,
};
use herogpui_theme::SkeletonAnimation;
fn still() {
harness::still();
}
fn flush_frame(cx: &mut VisualTestContext) {
cx.update(|window, _| window.refresh());
}
fn leave_stack(cx: &mut VisualTestContext) {
cx.simulate_mouse_move(point(px(-100.), px(-100.)), None, Modifiers::none());
flush_frame(cx);
}
fn last_string(seen: &Events) -> String {
seen.borrow()
.last()
.expect("the closure must have run at least once")
.clone()
}
#[gpui::test]
fn toast_start_end_placements_move_the_close_target(cx: &mut TestAppContext) {
still();
cx.update(|cx| {
Toast::new("A").timeout(Duration::ZERO).push(None, cx);
});
let placement = Rc::new(RefCell::new(ToastPlacement::BottomEnd));
let holder = placement.clone();
let cx = open_host(cx, move || {
ToastViewport::new()
.placement(*holder.borrow())
.into_any_element()
});
click(cx, 1898., 1026.);
cx.update(|_window, cx| {
assert!(
toast_store(cx).read(cx).toasts().is_empty(),
"at BottomEnd the close button must hug the bottom-right corner"
);
});
*placement.borrow_mut() = ToastPlacement::BottomStart;
cx.update(|_window, cx| {
Toast::new("B").timeout(Duration::ZERO).push(None, cx);
});
flush_frame(cx);
click(cx, 1898., 1026.);
cx.update(|_window, cx| {
assert_eq!(
toast_store(cx).read(cx).toasts().len(),
1,
"moving to BottomStart must take the card off the bottom-right \
point: the old coordinate now hits nothing"
);
});
click(cx, 470., 1026.);
cx.update(|_window, cx| {
assert!(
toast_store(cx).read(cx).toasts().is_empty(),
"at BottomStart the close button must hug the bottom-left corner"
);
});
*placement.borrow_mut() = ToastPlacement::TopEnd;
cx.update(|_window, cx| {
Toast::new("C").timeout(Duration::ZERO).push(None, cx);
});
flush_frame(cx);
click(cx, 470., 1026.);
cx.update(|_window, cx| {
assert_eq!(
toast_store(cx).read(cx).toasts().len(),
1,
"at TopEnd the card must have left the bottom-left corner"
);
});
click(cx, 1898., 22.);
cx.update(|_window, cx| {
assert!(
toast_store(cx).read(cx).toasts().is_empty(),
"at TopEnd the close button must hug the top-right corner"
);
});
}
#[gpui::test]
fn toast_custom_indicator_content_renders_from_the_queue(cx: &mut TestAppContext) {
still();
let id = cx.update(|cx| {
Toast::new("Custom indicator")
.timeout(Duration::ZERO)
.indicator_content(|_| {
gpui::svg()
.size(px(12.))
.path(herogpui_components::icons::INFO_CIRCLE)
})
.push(None, cx)
});
let cx = open_host(cx, || ToastViewport::new().into_any_element());
cx.update(|_window, cx| {
let toast = toast_store(cx)
.read(cx)
.toasts()
.iter()
.find(|toast| toast.id == id)
.expect("the custom toast remains queued");
assert!(toast.indicator_content.is_some());
});
}
#[gpui::test]
fn toast_close_click_dismisses_without_running_the_action(cx: &mut TestAppContext) {
still();
let actions = events();
let rec = actions.clone();
cx.update(move |cx| {
Toast::new("Has an action")
.timeout(Duration::ZERO)
.action("Undo", move |_| rec.borrow_mut().push("undo".into()))
.push(None, cx)
});
let cx = open_host(cx, || ToastViewport::new().into_any_element());
click(cx, 1184., 1014.);
assert_eq!(
actions.borrow().as_slice(),
[] as [&str; 0],
"clicking the close must dismiss without running the action handler"
);
cx.update(|_window, cx| {
assert!(
toast_store(cx).read(cx).toasts().is_empty(),
"the close click must dismiss the toast"
);
});
}
#[gpui::test]
fn toast_close_button_is_keyboard_reachable(cx: &mut TestAppContext) {
still();
let actions = events();
let rec = actions.clone();
cx.update(move |cx| {
Toast::new("Has an action")
.timeout(Duration::ZERO)
.action("Undo", move |_| rec.borrow_mut().push("undo".into()))
.push(None, cx)
});
let cx = open_host(cx, || ToastViewport::new().into_any_element());
press(cx, "tab");
press(cx, "tab");
press(cx, "enter");
assert_eq!(
actions.borrow().as_slice(),
[] as [&str; 0],
"Enter on the close button must dismiss without running the action"
);
cx.update(|_window, cx| {
assert!(
toast_store(cx).read(cx).toasts().is_empty(),
"Enter on the close button must dismiss the toast"
);
});
}
#[gpui::test]
fn toast_pause_keeps_the_rendered_card_alive_and_dismissable(cx: &mut TestAppContext) {
still();
cx.update(|cx| {
pause_toasts(true, cx);
Toast::new("Paused")
.timeout(Duration::from_millis(300))
.push(None, cx)
});
let cx = open_host(cx, || ToastViewport::new().into_any_element());
cx.executor().advance_clock(Duration::from_millis(1500));
cx.update(|_window, cx| {
assert_eq!(
toast_store(cx).read(cx).toasts().len(),
1,
"a paused queue must not dismiss a toast whose timeout has passed"
);
});
click(cx, 1184., 1026.);
cx.update(|_window, cx| {
assert!(
toast_store(cx).read(cx).toasts().is_empty(),
"a rendered paused toast must still be dismissable by hand"
);
});
leave_stack(cx);
cx.update(|_window, cx| {
pause_toasts(false, cx);
Toast::new("Post-resume")
.timeout(Duration::from_millis(300))
.push(None, cx);
});
flush_frame(cx);
cx.executor().advance_clock(Duration::from_millis(500));
cx.update(|_window, cx| {
assert!(
toast_store(cx).read(cx).toasts().is_empty(),
"after resume the same viewport must let the clock dismiss again"
);
});
}
#[gpui::test]
fn toast_viewport_reveals_the_newest_next_as_toasts_leave(cx: &mut TestAppContext) {
still();
let (a, b, c, d) = cx.update(|cx| {
(
Toast::new("T1").timeout(Duration::ZERO).push(None, cx),
Toast::new("T2").timeout(Duration::ZERO).push(None, cx),
Toast::new("T3").timeout(Duration::ZERO).push(None, cx),
Toast::new("T4").timeout(Duration::ZERO).push(None, cx),
)
});
let cx = open_host(cx, || {
ToastViewport::new()
.max_visible_toasts(1)
.scale_factor(0.0)
.into_any_element()
});
cx.update(|_window, cx| {
let ids: Vec<u64> = toast_store(cx)
.read(cx)
.toasts()
.iter()
.map(|t| t.id)
.collect();
assert_eq!(
ids,
[d, c, b, a],
"the fourth push must retain every toast in newest-first order"
);
assert!(a != b && a != c && a != d, "the ids must be distinct");
});
flush_frame(cx);
click(cx, 1184., 1026.);
cx.update(|_window, cx| {
let ids: Vec<u64> = toast_store(cx)
.read(cx)
.toasts()
.iter()
.map(|t| t.id)
.collect();
assert_eq!(
ids,
[c, b, a],
"the first click must dismiss the newest drawn (D)"
);
});
flush_frame(cx);
click(cx, 1184., 1026.);
cx.update(|_window, cx| {
let ids: Vec<u64> = toast_store(cx)
.read(cx)
.toasts()
.iter()
.map(|t| t.id)
.collect();
assert_eq!(
ids,
[b, a],
"the second click must dismiss the next-newest (C)"
);
});
flush_frame(cx);
click(cx, 1184., 1026.);
cx.update(|_window, cx| {
assert_eq!(
toast_store(cx).read(cx).toasts()[0].id,
a,
"the third click must reveal the initially hidden oldest toast"
);
});
flush_frame(cx);
click(cx, 1184., 1026.);
cx.update(|_window, cx| {
assert!(
toast_store(cx).read(cx).toasts().is_empty(),
"the fourth click must dismiss the final queued toast"
);
});
}
#[gpui::test]
fn toast_overflow_auto_dismisses_while_visually_hidden(cx: &mut TestAppContext) {
still();
let (hidden, front) = cx.update(|cx| {
let hidden = Toast::new("Hidden")
.timeout(Duration::from_millis(300))
.push(None, cx);
let front = Toast::new("Front").timeout(Duration::ZERO).push(None, cx);
(hidden, front)
});
let cx = open_host(cx, || {
ToastViewport::new()
.max_visible_toasts(1)
.scale_factor(0.0)
.into_any_element()
});
cx.executor().advance_clock(Duration::from_millis(1500));
cx.update(|_window, cx| {
let store = toast_store(cx).read(cx);
let ids = store
.toasts()
.iter()
.map(|toast| toast.id)
.collect::<Vec<_>>();
assert_eq!(
ids,
[front],
"maxVisibleToasts must not pause a visually hidden toast's clock"
);
assert_eq!(store.visible_toasts(1)[0].id, front);
assert!(store.toasts().iter().all(|toast| toast.id != hidden));
});
}
#[gpui::test]
fn toast_on_close_fires_once_for_each_dismissal_path(cx: &mut TestAppContext) {
still();
let hand_dismissed = Rc::new(RefCell::new(0usize));
let timed_out = Rc::new(RefCell::new(0usize));
let hand = hand_dismissed.clone();
let timed = timed_out.clone();
cx.update(move |cx| {
Toast::new("Times out")
.timeout(Duration::from_millis(300))
.on_close(move |_| *timed.borrow_mut() += 1)
.push(None, cx);
Toast::new("Stays")
.timeout(Duration::ZERO)
.on_close(move |_| *hand.borrow_mut() += 1)
.push(None, cx);
});
let cx = open_host(cx, || ToastViewport::new().into_any_element());
click(cx, 1184., 1026.);
assert_eq!(
*hand_dismissed.borrow(),
1,
"hand dismissal must report the close exactly once"
);
assert_eq!(
*timed_out.borrow(),
0,
"the sibling's onClose must not fire"
);
leave_stack(cx);
cx.executor().advance_clock(Duration::from_millis(500));
assert_eq!(
*timed_out.borrow(),
1,
"a timed-out toast must report its close exactly once"
);
assert_eq!(
*hand_dismissed.borrow(),
1,
"the timer must not re-report the hand-dismissed toast"
);
}
#[gpui::test]
fn toast_on_close_fires_once_when_a_timed_toast_is_dismissed_by_hand(cx: &mut TestAppContext) {
still();
let closed = Rc::new(RefCell::new(0usize));
let count = closed.clone();
cx.update(move |cx| {
Toast::new("Timed")
.timeout(Duration::from_millis(300))
.on_close(move |_| *count.borrow_mut() += 1)
.push(None, cx)
});
let cx = open_host(cx, || ToastViewport::new().into_any_element());
click(cx, 1184., 1026.);
cx.update(|_window, cx| {
assert!(
toast_store(cx).read(cx).toasts().is_empty(),
"the click must dismiss the toast"
);
});
assert_eq!(
*closed.borrow(),
1,
"the hand dismissal itself must report exactly one close"
);
cx.executor().advance_clock(Duration::from_millis(500));
assert_eq!(
*closed.borrow(),
1,
"the dormant timer must not report a second close"
);
}
#[gpui::test]
fn toast_clear_does_not_report_timed_toasts_as_individually_closed(cx: &mut TestAppContext) {
still();
let closed = Rc::new(RefCell::new(0usize));
let count = closed.clone();
cx.update(move |cx| {
Toast::new("Cleared")
.timeout(Duration::from_millis(300))
.on_close(move |_| *count.borrow_mut() += 1)
.push(None, cx);
});
let cx = open_host(cx, || ToastViewport::new().into_any_element());
cx.update(|_window, cx| clear_toasts(cx));
cx.update(|_window, cx| {
assert!(toast_store(cx).read(cx).toasts().is_empty());
});
cx.executor().advance_clock(Duration::from_millis(500));
assert_eq!(
*closed.borrow(),
0,
"clearing the queue must not later invoke an individual onClose"
);
}
#[gpui::test]
fn toast_reused_id_gets_a_fresh_close_lifecycle(cx: &mut TestAppContext) {
let id = cx.update(|cx| Toast::new("Old").timeout(Duration::ZERO).push(None, cx));
cx.update(clear_toasts);
let closed = std::sync::Arc::new(AtomicUsize::new(0));
let count = closed.clone();
cx.update(move |cx| {
toast_store(cx).update(cx, |store, cx| {
store.insert(ToastData {
id,
color: Color::Accent,
title: "Replacement".into(),
description: None,
closable: true,
indicator: None,
indicator_content: None,
indicator_set: false,
is_loading: false,
action: None,
on_close: Some(std::sync::Arc::new(move |_| {
count.fetch_add(1, Ordering::Relaxed);
})),
close_hover_bg: None,
padding: None,
radius: None,
});
cx.notify();
});
});
cx.update(|cx| dismiss_toast(id, cx));
assert_eq!(
closed.load(Ordering::Relaxed),
1,
"reusing a cleared id must not suppress the replacement's onClose"
);
}
#[gpui::test]
fn toast_store_close_reports_on_close_once(cx: &mut TestAppContext) {
let closed = Rc::new(RefCell::new(0usize));
let count = closed.clone();
let id = cx.update(move |cx| {
Toast::new("Queue close")
.timeout(Duration::from_millis(300))
.on_close(move |_| *count.borrow_mut() += 1)
.push(None, cx)
});
cx.update(|cx| {
let store = toast_store(cx);
herogpui_components::ToastStore::close(&store, id, cx);
});
assert_eq!(*closed.borrow(), 1);
cx.executor().advance_clock(Duration::from_millis(500));
assert_eq!(
*closed.borrow(),
1,
"the retired timer must not duplicate ToastStore::close"
);
}
#[gpui::test]
fn toast_store_add_keeps_generated_keys_unique(cx: &mut TestAppContext) {
let explicit = cx.update(|cx| {
let store = toast_store(cx);
herogpui_components::ToastStore::add(
&store,
ToastData {
id: 7,
color: Color::Default,
title: "Explicit".into(),
description: None,
closable: true,
indicator: None,
indicator_content: None,
indicator_set: false,
is_loading: false,
action: None,
on_close: None,
close_hover_bg: None,
padding: None,
radius: None,
},
cx,
)
});
let generated = cx.update(|cx| {
Toast::new("Generated")
.timeout(Duration::ZERO)
.push(None, cx)
});
assert_eq!((explicit, generated), (7, 8));
}
#[gpui::test]
fn toast_stack_keyboard_reaches_only_the_frontmost_close(cx: &mut TestAppContext) {
still();
let (older, front) = cx.update(|cx| {
let older = Toast::new("Older").timeout(Duration::ZERO).push(None, cx);
let front = Toast::new("Front").timeout(Duration::ZERO).push(None, cx);
(older, front)
});
let cx = open_host(cx, || ToastViewport::new().into_any_element());
press(cx, "tab");
press(cx, "enter");
cx.update(|_window, cx| {
let ids = toast_store(cx)
.read(cx)
.toasts()
.iter()
.map(|toast| toast.id)
.collect::<Vec<_>>();
assert_eq!(
ids,
[older],
"the first reachable close action must belong to the frontmost toast"
);
assert!(ids.iter().all(|id| *id != front));
});
}
#[gpui::test]
fn toast_new_uses_the_default_variant(cx: &mut TestAppContext) {
let id = cx.update(|cx| Toast::new("Plain").timeout(Duration::ZERO).push(None, cx));
cx.update(|cx| {
let store = toast_store(cx);
let toast = store
.read(cx)
.toasts()
.iter()
.find(|toast| toast.id == id)
.expect("new toast is queued");
assert_eq!(
toast.color,
Color::Default,
"Toast::new must match the pinned helper's default variant"
);
});
}
#[gpui::test]
fn alert_every_status_renders_without_panicking(cx: &mut TestAppContext) {
let cx = open_host(cx, || {
gpui::div()
.flex()
.flex_col()
.gap(px(8.))
.child(Alert::new("Default status"))
.child(Alert::new("Accent status").status(Color::Accent))
.child(Alert::new("Success status").status(Color::Success))
.child(Alert::new("Warning status").status(Color::Warning))
.child(Alert::new("Danger status").status(Color::Danger))
.into_any_element()
});
click(cx, 30., 25.);
click(cx, 1900., 75.);
click(cx, 100., 125.);
}
#[gpui::test]
fn alert_without_close_has_nothing_to_press_at_the_close_spot(cx: &mut TestAppContext) {
let recorded = events();
let probe = recorded.clone();
let cx = open_host(cx, move || {
let probe = probe.clone();
gpui::div()
.flex()
.flex_col()
.gap(px(10.))
.child(Alert::new("Unsaved changes").description("No close here"))
.child(
gpui::div()
.id("fb-alert-probe")
.w(px(40.))
.h(px(20.))
.cursor_pointer()
.on_click(move |_, _, _| probe.borrow_mut().push("probe".into())),
)
.into_any_element()
});
click(cx, 1897., 19.);
assert!(
recorded.borrow().is_empty(),
"a non-closable alert must have nothing at the close position"
);
click(cx, 20., 86.);
assert_eq!(
recorded.borrow().as_slice(),
["probe"],
"the probe below must confirm the click machinery works"
);
}
#[gpui::test]
fn progress_bar_clamps_value_outside_the_range(cx: &mut TestAppContext) {
let seen = events();
let record = seen.clone();
let value = Rc::new(RefCell::new(150.0f32));
let for_view = value.clone();
let cx = open_host(cx, move || {
let record = record.clone();
let now = *for_view.borrow();
ProgressBar::new("feedback-progress-clamp")
.value(now)
.show_value_label(true)
.value_content(move |percentage, text, _| {
record.borrow_mut().push(format!("{percentage:.0}|{text}"));
gpui::div().child(text.to_owned()).into_any_element()
})
.into_any_element()
});
assert_eq!(
last_string(&seen),
"100|100%",
"a value of 150 against max 100 must clamp to 100%, not overflow"
);
*value.borrow_mut() = -20.0;
flush_frame(cx);
assert_eq!(
last_string(&seen),
"0|0%",
"a value below min must clamp to 0%"
);
*value.borrow_mut() = 0.0;
flush_frame(cx);
assert_eq!(last_string(&seen), "0|0%", "the floor of the range is 0%");
*value.borrow_mut() = 100.0;
flush_frame(cx);
assert_eq!(
last_string(&seen),
"100|100%",
"the ceiling of the range is 100%"
);
}
#[gpui::test]
fn progress_bar_format_options_change_the_value_text(cx: &mut TestAppContext) {
let seen = events();
let record = seen.clone();
let value = Rc::new(RefCell::new(750.0f32));
let for_view = value.clone();
let cx = open_host(cx, move || {
let record = record.clone();
let now = *for_view.borrow();
ProgressBar::new("feedback-progress-format")
.value(now)
.max_value(1000.0)
.show_value_label(true)
.format_options(NumberFormat::currency("USD"))
.value_content(move |percentage, text, _| {
record.borrow_mut().push(format!("{percentage:.0}|{text}"));
gpui::div().child(text.to_owned()).into_any_element()
})
.into_any_element()
});
assert_eq!(
last_string(&seen),
"75|$750.00",
"750 of 1000 must report 75% and the currency-formatted value"
);
*value.borrow_mut() = 500.0;
flush_frame(cx);
assert_eq!(
last_string(&seen),
"50|$500.00",
"the formatted text must follow the value"
);
}
#[gpui::test]
fn meter_custom_range_formats_and_clamps(cx: &mut TestAppContext) {
let seen = events();
let record = seen.clone();
let value = Rc::new(RefCell::new(750.0f32));
let for_view = value.clone();
let cx = open_host(cx, move || {
let record = record.clone();
let now = *for_view.borrow();
Meter::new("feedback-meter-format", now)
.min_value(500.0)
.max_value(1000.0)
.show_value(true)
.format_options(NumberFormat::currency("USD"))
.value_content(move |percentage, text| {
record.borrow_mut().push(format!("{percentage:.0}|{text}"));
gpui::div().child(text.to_owned()).into_any_element()
})
.into_any_element()
});
assert_eq!(
last_string(&seen),
"50|$750.00",
"750 across 500..1000 spans half the range, not three quarters"
);
*value.borrow_mut() = 500.0;
flush_frame(cx);
assert_eq!(
last_string(&seen),
"0|$500.00",
"the custom minimum must be the 0% edge"
);
*value.borrow_mut() = 1000.0;
flush_frame(cx);
assert_eq!(
last_string(&seen),
"100|$1,000.00",
"the custom maximum must be the 100% edge, grouped and formatted"
);
}
#[gpui::test]
fn progress_circle_clamps_value_outside_the_range(cx: &mut TestAppContext) {
let seen = events();
let record = seen.clone();
let value = Rc::new(RefCell::new(150.0f32));
let for_view = value.clone();
let cx = open_host(cx, move || {
let record = record.clone();
let now = *for_view.borrow();
ProgressCircle::new()
.value(now)
.show_value_label(true)
.value_content(move |percentage, text, _| {
record.borrow_mut().push(format!("{percentage:.0}|{text}"));
gpui::div().child(text.to_owned()).into_any_element()
})
.into_any_element()
});
assert_eq!(
last_string(&seen),
"100|100%",
"a ring value of 150 must clamp to 100%"
);
*value.borrow_mut() = -5.0;
flush_frame(cx);
assert_eq!(
last_string(&seen),
"0|0%",
"a ring value below 0 must clamp"
);
}
#[gpui::test]
fn progress_circle_format_options_change_the_value_text(cx: &mut TestAppContext) {
let seen = events();
let record = seen.clone();
let value = Rc::new(RefCell::new(60.0f32));
let for_view = value.clone();
let cx = open_host(cx, move || {
let record = record.clone();
let now = *for_view.borrow();
ProgressCircle::new()
.value(now)
.show_value_label(true)
.format_options(NumberFormat::currency("USD"))
.value_content(move |percentage, text, _| {
record.borrow_mut().push(format!("{percentage:.0}|{text}"));
gpui::div().child(text.to_owned()).into_any_element()
})
.into_any_element()
});
assert_eq!(
last_string(&seen),
"60|$60.00",
"60 of 100 must report 60% and the currency text"
);
*value.borrow_mut() = 30.0;
flush_frame(cx);
assert_eq!(
last_string(&seen),
"30|$30.00",
"the circle's text must follow its value too"
);
}
#[gpui::test]
fn progress_bar_indeterminate_reports_no_value_text(cx: &mut TestAppContext) {
let seen = events();
let record = seen.clone();
open_host(cx, move || {
let record = record.clone();
ProgressBar::new("feedback-progress-indeterminate")
.value(73.0)
.value_label("must stay hidden")
.is_indeterminate(true)
.show_value_label(true)
.value_content(move |percentage, text, is_indeterminate| {
record.borrow_mut().push(format!("{percentage:.0}|{text}"));
assert!(is_indeterminate);
gpui::div().child(text.to_owned()).into_any_element()
})
.into_any_element()
});
assert_eq!(
last_string(&seen),
"0|",
"an indeterminate bar must report no percentage text, only the empty label"
);
}
#[gpui::test]
fn meter_value_text_uses_the_clamped_value_for_custom_formats(cx: &mut TestAppContext) {
let seen = events();
let record = seen.clone();
let value = Rc::new(RefCell::new(1500.0f32));
let for_view = value.clone();
let cx = open_host(cx, move || {
let record = record.clone();
let now = *for_view.borrow();
Meter::new("feedback-meter-clamp", now)
.min_value(500.0)
.max_value(1000.0)
.show_value(true)
.format_options(NumberFormat::currency("USD"))
.value_content(move |percentage, text| {
record.borrow_mut().push(format!("{percentage:.0}|{text}"));
gpui::div().child(text.to_owned()).into_any_element()
})
.into_any_element()
});
assert_eq!(
last_string(&seen),
"100|$1,000.00",
"an over-max value must clamp both the percentage and the formatted label"
);
*value.borrow_mut() = 400.0;
flush_frame(cx);
assert_eq!(
last_string(&seen),
"0|$500.00",
"a below-min value must clamp both the percentage and the formatted label"
);
}
#[gpui::test]
fn progress_circle_indeterminate_reports_no_value_text(cx: &mut TestAppContext) {
let seen = events();
let record = seen.clone();
open_host(cx, move || {
let record = record.clone();
ProgressCircle::new()
.is_indeterminate(true)
.show_value_label(true)
.value_content(move |percentage, text, is_indeterminate| {
record.borrow_mut().push(format!("{percentage:.0}|{text}"));
assert!(is_indeterminate);
gpui::div().child(text.to_owned()).into_any_element()
})
.into_any_element()
});
assert_eq!(
last_string(&seen),
"0|",
"an indeterminate ring must report no percentage text, only the empty label"
);
}
#[gpui::test]
fn spinner_renders_every_documented_size_and_color(cx: &mut TestAppContext) {
open_host(cx, || {
let sizes = [
SpinnerSize::Sm,
SpinnerSize::Md,
SpinnerSize::Lg,
SpinnerSize::Xl,
];
let colors = [
Color::Default,
Color::Accent,
Color::Success,
Color::Warning,
Color::Danger,
];
let mut row = gpui::div().flex().flex_wrap().gap(px(8.));
for (i, size) in sizes.into_iter().enumerate() {
for (j, color) in colors.into_iter().enumerate() {
row = row.child(
Spinner::new(gpui::ElementId::Name(format!("fb-spinner-{i}-{j}").into()))
.size(size)
.color(color),
);
}
}
row.child(Spinner::new("fb-spinner-slow").duration_ms(2000))
.into_any_element()
});
}
#[gpui::test]
fn spinner_stops_scheduling_frames_when_motion_is_disabled(cx: &mut TestAppContext) {
still();
let cx = open_host(cx, || Spinner::new("motion-spinner").into_any_element());
assert_eq!(cx.update(|window, cx| window.simulate_next_frame(cx)), 0);
cx.update(|_, cx| herogpui_theme::set_reduce_motion(false, cx));
flush_frame(cx);
assert!(cx.update(|window, cx| window.simulate_next_frame(cx)) > 0);
cx.update(|_, cx| herogpui_theme::set_reduce_motion(true, cx));
flush_frame(cx);
cx.update(|window, cx| window.simulate_next_frame(cx));
assert_eq!(cx.update(|window, cx| window.simulate_next_frame(cx)), 0);
}
#[gpui::test]
fn spinner_keeps_its_diameter_when_a_flex_parent_is_smaller(cx: &mut TestAppContext) {
for vertical in [false, true] {
let measured = Rc::new(RefCell::new(None));
let captured = measured.clone();
open_host(cx, move || {
let captured = captured.clone();
gpui::div()
.flex()
.when(vertical, |row| row.flex_col())
.size(px(8.))
.child(Spinner::new("constrained-spinner"))
.child(
canvas(
move |bounds, _, _| *captured.borrow_mut() = Some(bounds),
|_, _, _, _| {},
)
.size(px(1.))
.flex_shrink_0(),
)
.into_any_element()
});
let bounds = measured.borrow().expect("following marker must render");
let offset = if vertical {
bounds.origin.y
} else {
bounds.origin.x
};
assert_eq!(offset, px(24.), "Spinner must keep its 24px diameter");
}
}
fn boxed_spinner(selector: &'static str, spinner: Spinner) -> AnyElement {
gpui::div()
.flex()
.debug_selector(move || selector.to_owned())
.child(spinner)
.into_any_element()
}
#[gpui::test]
fn spinner_size_px_overrides_the_derived_diameter(cx: &mut TestAppContext) {
let cx = open_host(cx, || {
gpui::div()
.flex()
.flex_col()
.items_start()
.gap(px(8.))
.child(boxed_spinner(
"spin-px-14",
Spinner::new("spin-px-14-spinner").size_px(px(14.)),
))
.child(boxed_spinner(
"spin-px-after-size",
Spinner::new("spin-px-after-size-spinner")
.size(SpinnerSize::Xl)
.size_px(px(14.)),
))
.child(boxed_spinner(
"spin-px-before-size",
Spinner::new("spin-px-before-size-spinner")
.size_px(px(14.))
.size(SpinnerSize::Xl),
))
.child(boxed_spinner(
"spin-default",
Spinner::new("spin-default-spinner"),
))
.into_any_element()
});
for (selector, expected) in [
("spin-px-14", 14.),
("spin-px-after-size", 14.),
("spin-px-before-size", 14.),
("spin-default", 24.),
] {
let bounds = cx
.debug_bounds(selector)
.unwrap_or_else(|| panic!("{selector} box must paint"));
assert_eq!(bounds.size.width, px(expected), "{selector} width");
assert_eq!(bounds.size.height, px(expected), "{selector} height");
}
}
#[gpui::test]
fn spinner_glyph_replaces_the_arc_and_keeps_spinning(cx: &mut TestAppContext) {
let cx = open_host(cx, || {
gpui::div()
.flex()
.child(
boxed_spinner(
"spin-glyph",
Spinner::new("spin-glyph-spinner")
.size_px(px(14.))
.duration_ms(1500)
.glyph(
gpui::svg()
.size(px(10.))
.path(herogpui_components::icons::PLUS)
.debug_selector(|| "spin-glyph-svg".into()),
),
)
.into_any_element(),
)
.into_any_element()
});
let box_bounds = cx
.debug_bounds("spin-glyph")
.expect("spinner box must paint");
assert_eq!(
box_bounds.size.width,
px(14.),
"the box keeps size_px's diameter"
);
let glyph_bounds = cx
.debug_bounds("spin-glyph-svg")
.expect("the caller glyph must render in place of the arc");
assert_eq!(
glyph_bounds.size.width,
px(10.),
"the glyph keeps its own 10px layout box"
);
assert_eq!(
glyph_bounds.origin.x - box_bounds.origin.x,
px(2.),
"the glyph is centered in the diameter box"
);
assert!(
cx.update(|window, cx| window.simulate_next_frame(cx)) > 0,
"a glyph spinner must keep scheduling rotation frames"
);
}
#[gpui::test]
fn spinner_glyph_stops_scheduling_when_motion_is_disabled(cx: &mut TestAppContext) {
still();
let cx = open_host(cx, || {
gpui::div()
.flex()
.child(
boxed_spinner(
"spin-glyph-still",
Spinner::new("spin-glyph-still-spinner").glyph(
gpui::div()
.size(px(12.))
.bg(gpui::black())
.debug_selector(|| "spin-glyph-still-div".into()),
),
)
.into_any_element(),
)
.into_any_element()
});
let box_bounds = cx
.debug_bounds("spin-glyph-still")
.expect("spinner box must paint");
assert_eq!(box_bounds.size.width, px(24.), "the default diameter holds");
let glyph_bounds = cx
.debug_bounds("spin-glyph-still-div")
.expect("the caller glyph must render in place of the arc");
assert_eq!(glyph_bounds.size.width, px(12.));
assert_eq!(
cx.update(|window, cx| window.simulate_next_frame(cx)),
0,
"reduced motion must leave a glyph spinner static"
);
}
#[gpui::test]
fn skeleton_renders_every_animation_type(cx: &mut TestAppContext) {
open_host(cx, || {
gpui::div()
.flex()
.flex_col()
.gap(px(8.))
.child(
Skeleton::new()
.id("fb-skeleton-none")
.w(px(120.))
.h(px(24.))
.animation_type(SkeletonAnimation::None),
)
.child(
Skeleton::new()
.id("fb-skeleton-pulse")
.w(px(120.))
.h(px(24.))
.animation_type(SkeletonAnimation::Pulse),
)
.child(
Skeleton::new()
.id("fb-skeleton-shimmer")
.w(px(120.))
.h(px(24.))
.animation_type(SkeletonAnimation::Shimmer),
)
.child(
Skeleton::new()
.id("fb-skeleton-default")
.w(px(120.))
.h(px(24.)),
)
.child(
Skeleton::new()
.id("fb-skeleton-child")
.w(px(120.))
.h(px(24.))
.child(gpui::div().child("x")),
)
.into_any_element()
});
}
#[gpui::test]
fn badge_renders_every_variant_size_placement_and_dot(cx: &mut TestAppContext) {
open_host(cx, || {
let mut col = gpui::div().flex().flex_col().gap(px(16.));
for (i, variant) in BadgeVariant::ALL.into_iter().enumerate() {
for (j, size) in [Size::Sm, Size::Md, Size::Lg].into_iter().enumerate() {
col = col.child(
BadgeAnchor::new()
.child(gpui::div().w(px(40.)).h(px(40.)))
.child(
Badge::new()
.variant(variant)
.size(size)
.color(Color::Accent)
.child(BadgeLabel::new().child((i * 3 + j).to_string())),
),
);
}
}
for placement in [
BadgePlacement::TopLeft,
BadgePlacement::TopRight,
BadgePlacement::BottomLeft,
BadgePlacement::BottomRight,
] {
col = col.child(
BadgeAnchor::new()
.child(gpui::div().w(px(40.)).h(px(40.)))
.child(
Badge::new()
.placement(placement)
.child(BadgeLabel::new().child("5")),
),
);
}
for size in [Size::Sm, Size::Md, Size::Lg] {
col = col.child(
BadgeAnchor::new()
.child(gpui::div().w(px(40.)).h(px(40.)))
.child(Badge::new().size(size).color(Color::Success)),
);
}
col.into_any_element()
});
}
fn badge_box(cx: &mut VisualTestContext) -> Bounds<Pixels> {
cx.debug_bounds("badge")
.unwrap_or_else(|| panic!("badge must paint"))
}
fn bounds_str(b: &Bounds<Pixels>) -> String {
format!(
"{:.1}..{:.1} x {:.1}..{:.1}",
f32::from(b.origin.x),
f32::from(b.origin.x + b.size.width),
f32::from(b.origin.y),
f32::from(b.origin.y + b.size.height)
)
}
fn anchored_badge(
placement: BadgePlacement,
content: Option<&'static str>,
size: Size,
) -> AnyElement {
let mut badge = Badge::new().size(size).placement(placement);
if let Some(text) = content {
badge = badge.child(BadgeLabel::new().child(text));
}
gpui::div()
.pt(px(40.))
.pl(px(100.))
.flex()
.child(
BadgeAnchor::new()
.child(gpui::div().w(px(64.)).h(px(64.)))
.child(badge),
)
.into_any_element()
}
#[gpui::test]
fn badge_overhangs_the_anchor_by_a_quarter_of_its_box(cx: &mut TestAppContext) {
{
let cx = open_host(cx, || {
anchored_badge(BadgePlacement::TopRight, None, Size::Md)
});
assert_eq!(
bounds_str(&badge_box(cx)),
"143.0..171.0 x 33.0..61.0",
"the md top-right dot must overhang 7px past the anchor's corner"
);
}
{
let cx = open_host(cx, || {
anchored_badge(BadgePlacement::TopLeft, None, Size::Lg)
});
assert_eq!(
bounds_str(&badge_box(cx)),
"92.0..124.0 x 32.0..64.0",
"the lg top-left dot must overhang 8px past the anchor's corner"
);
}
{
let cx = open_host(cx, || {
anchored_badge(BadgePlacement::BottomLeft, None, Size::Sm)
});
assert_eq!(
bounds_str(&badge_box(cx)),
"96.0..112.0 x 92.0..108.0",
"the sm bottom-left dot must overhang 4px past the anchor's corner"
);
}
{
let cx = open_host(cx, || {
anchored_badge(BadgePlacement::TopRight, Some("5"), Size::Md)
});
assert_eq!(
bounds_str(&badge_box(cx)),
"143.0..171.0 x 33.0..61.0",
"the md top-right label badge must overhang 7px past the anchor's corner"
);
}
}
#[gpui::test]
fn badge_leading_matches_the_pinned_css(cx: &mut TestAppContext) {
for (size, expected) in [
(Size::Sm, "10.00/13.40"),
(Size::Md, "12.00/16.08"),
(Size::Lg, "14.00/20.02"),
] {
let seen = Rc::new(RefCell::new(None::<(f32, f32)>));
{
open_host(cx, {
let seen = seen.clone();
move || {
gpui::div()
.pt(px(40.))
.pl(px(100.))
.flex()
.child(
BadgeAnchor::new()
.child(gpui::div().w(px(64.)).h(px(64.)))
.child(
Badge::new().size(size).child(
canvas(|_, _, _| {}, {
let seen = seen.clone();
move |_, _, window, _| {
let style = window.text_style();
let rem = window.rem_size();
let font = style.font_size.to_pixels(rem);
*seen.borrow_mut() = Some((
f32::from(font),
f32::from(style.line_height.to_pixels(
AbsoluteLength::Pixels(font),
rem,
)),
));
}
})
.size_0(),
),
),
)
.into_any_element()
}
});
}
let (font, leading) = seen.borrow().unwrap_or_else(|| panic!("probe must paint"));
assert_eq!(
format!("{font:.2}/{leading:.2}"),
expected,
"{size:?} badge must carry its pinned text size and leading"
);
}
}
#[gpui::test]
fn badge_anchor_keeps_its_width_in_an_overflowing_row(cx: &mut TestAppContext) {
let cx = open_host(cx, || {
gpui::div()
.pt(px(20.))
.flex()
.w(px(80.))
.child(
BadgeAnchor::new()
.child(gpui::div().w(px(64.)).h(px(16.)))
.child(Badge::new().child(BadgeLabel::new().child("5"))),
)
.child(gpui::div().w(px(64.)).h(px(16.)))
.into_any_element()
});
let anchor = cx
.debug_bounds("badge-anchor")
.unwrap_or_else(|| panic!("badge anchor must paint"));
assert_eq!(
format!("{:.1}", f32::from(anchor.size.width)),
"64.0",
"the badge anchor must not shrink below its content in an overflowing row"
);
}
#[gpui::test]
fn avatar_renders_every_variant_with_and_without_src(cx: &mut TestAppContext) {
open_host(cx, || {
let fallback = Avatar::new("fallback").name("Jane Doe");
let soft = Avatar::new("soft")
.name("Jane Doe")
.variant(AvatarVariant::Soft)
.color(Color::Accent);
let large = Avatar::new("large")
.name("Jane Doe")
.size(Size::Lg)
.color(Color::Success);
let with_src = Avatar::new("with-src")
.name("Jane Doe")
.src("fb-avatar-missing.png")
.color(Color::Warning);
let small_src = Avatar::new("small-src")
.name("Jane Doe")
.size(Size::Sm)
.src("fb-avatar-missing.png");
gpui::div()
.flex()
.flex_wrap()
.gap(px(8.))
.child(fallback)
.child(soft)
.child(large)
.child(with_src)
.child(small_src)
.into_any_element()
});
}
#[gpui::test]
fn progress_and_meter_text_keep_twenty_pixel_lines(cx: &mut TestAppContext) {
for leading in [None, Some(48.)] {
for meter in [false, true] {
for label_only in [false, true] {
let cx = open_host(cx, move || {
let content = if meter {
Meter::new("leading-meter", 50.)
.label(if label_only { "First\nSecond" } else { "Label" })
.show_value(!label_only)
.value_content(|_, _| {
gpui::div().child("50\npercent").into_any_element()
})
.into_any_element()
} else {
ProgressBar::new("leading-progress")
.value(50.)
.label(if label_only { "First\nSecond" } else { "Label" })
.show_value_label(!label_only)
.value_content(|_, _, _| {
gpui::div().child("50\npercent").into_any_element()
})
.into_any_element()
};
gpui::div()
.w(px(300.))
.when_some(leading, |el, leading| el.line_height(px(leading)))
.child(
gpui::div()
.debug_selector(|| "leading-bar".into())
.child(content),
)
.into_any_element()
});
let bounds = cx.debug_bounds("leading-bar").expect("bar must paint");
assert_eq!(bounds.size.height, px(52.), "two 20px lines, 4px gap and 8px track; meter={meter}, label_only={label_only}, host={leading:?}");
}
}
}
}