use crate::harness;
use std::{cell::RefCell, collections::HashSet, rc::Rc};
use gpui::{point, prelude::*, px, Modifiers, SharedString, TestAppContext, VisualTestContext};
use herogpui_components::{
Button, Checkbox, CheckboxGroup, CheckboxOption, CheckboxState, Form, FormData, FormField,
Input, InputOTP, InputState, NumberField, NumberState, OtpState, RadioGroup, RadioOption,
Switch, SwitchGroup, Time, TimeField, TimeState,
};
use harness::{click, events, open_host, press, still, Events};
fn sorted_join(keys: &HashSet<SharedString>) -> String {
let mut keys: Vec<String> = keys.iter().map(ToString::to_string).collect();
keys.sort();
keys.join(",")
}
fn record_checkbox_state(states: &Events, state: CheckboxState) {
states.borrow_mut().push(format!(
"{}|{}|{}|{}|{}|{}",
state.is_selected,
state.is_indeterminate,
state.is_disabled,
state.is_read_only,
state.is_invalid,
state.is_required
));
}
fn record_data(data: &FormData) -> String {
data.iter()
.map(|(name, value)| format!("{name}={}", value.as_text()))
.collect::<Vec<_>>()
.join(",")
}
#[gpui::test]
fn checkbox_group_toggles_two_keys(cx: &mut TestAppContext) {
let changes = events();
let recorded = changes.clone();
let cx = open_host(cx, move || {
let changes = changes.clone();
CheckboxGroup::new(
"cg-two",
vec![
CheckboxOption::new("alpha", "Alpha"),
CheckboxOption::new("beta", "Beta"),
CheckboxOption::new("gamma", "Gamma"),
],
)
.default_value(Vec::<SharedString>::new())
.on_change(move |set, _, _| changes.borrow_mut().push(sorted_join(set)))
.into_any_element()
});
click(cx, 8., 11.);
assert_eq!(
recorded.borrow().as_slice(),
["alpha"],
"clicking the first box must select its key"
);
click(cx, 8., 46.);
assert_eq!(
recorded.borrow().as_slice(),
["alpha", "alpha,beta"],
"clicking the second box must keep the first pick"
);
click(cx, 8., 11.);
assert_eq!(
recorded.borrow().as_slice(),
["alpha", "alpha,beta", "beta"],
"re-clicking a picked box must remove it, which requires the group \
to remember the earlier picks"
);
}
#[gpui::test]
fn checkbox_indeterminate_does_not_block_toggle(cx: &mut TestAppContext) {
let changes = events();
let recorded = changes.clone();
let cx = open_host(cx, move || {
let changes = changes.clone();
Checkbox::new("cb-indeterminate")
.label("Indeterminate")
.is_indeterminate(true)
.on_change(move |checked, _, _| {
changes.borrow_mut().push(format!("change:{checked}"));
})
.into_any_element()
});
click(cx, 8., 11.);
assert_eq!(
recorded.borrow().as_slice(),
["change:true"],
"an indeterminate box must still report a change when clicked"
);
}
#[gpui::test]
fn checkbox_indicator_receives_distinct_field_state(cx: &mut TestAppContext) {
let states = events();
let recorded = states.clone();
open_host(cx, move || {
let indeterminate_states = states.clone();
let selected_states = states.clone();
gpui::div()
.child(
Checkbox::new("cb-indicator-state")
.is_indeterminate(true)
.is_disabled(true)
.is_read_only(true)
.is_invalid(true)
.is_required(true)
.indicator(move |state| {
record_checkbox_state(&indeterminate_states, state);
gpui::div().into_any_element()
}),
)
.child(
Checkbox::new("cb-indicator-selected")
.default_selected(true)
.indicator(move |state| {
record_checkbox_state(&selected_states, state);
gpui::div().into_any_element()
}),
)
.into_any_element()
});
let recorded = recorded.borrow();
assert!(
recorded
.iter()
.any(|state| state == "false|true|true|true|true|true"),
"an indeterminate indicator must receive the full field state without inventing selection"
);
assert!(
recorded
.iter()
.any(|state| state == "true|false|false|false|false|false"),
"a selected indicator must receive selection without inventing indeterminate state"
);
}
#[gpui::test]
fn checkbox_content_receives_live_field_state(cx: &mut TestAppContext) {
let states = events();
let recorded = states.clone();
let cx = open_host(cx, move || {
let states = states.clone();
Checkbox::new("cb-content-state")
.is_indeterminate(true)
.is_invalid(true)
.is_required(true)
.label(
gpui::div()
.debug_selector(|| "cb-static-label".to_owned())
.child("Static label"),
)
.content(move |state| {
record_checkbox_state(&states, state);
gpui::div()
.debug_selector(|| "cb-live-content".to_owned())
.child("Live state")
.into_any_element()
})
.into_any_element()
});
click(cx, 8., 11.);
assert!(
cx.debug_bounds("cb-static-label").is_none(),
"functional root content must replace static children"
);
assert!(
cx.debug_bounds("cb-live-content").is_some(),
"the replacement assertion requires painted functional content"
);
let recorded = recorded.borrow();
assert!(
recorded
.iter()
.any(|state| state == "false|true|false|false|true|true"),
"root content must receive the initial field state"
);
assert!(
recorded
.iter()
.any(|state| state == "true|true|false|false|true|true"),
"uncontrolled selection must rerender root content with the live state"
);
}
#[gpui::test]
fn checkbox_controlled_content_rerenders_after_owner_accepts_change(cx: &mut TestAppContext) {
let for_view_selected = Rc::new(RefCell::new(false));
let states = events();
let recorded = states.clone();
let cx = open_host(cx, move || {
let selected_value = *for_view_selected.borrow();
let selected = for_view_selected.clone();
let states = states.clone();
Checkbox::new("cb-controlled-content-state")
.is_selected(selected_value)
.on_change(move |next, window, _| {
*selected.borrow_mut() = *next;
window.refresh();
})
.content(move |state| {
record_checkbox_state(&states, state);
gpui::div().child("Controlled state").into_any_element()
})
.into_any_element()
});
click(cx, 8., 11.);
let recorded = recorded.borrow();
assert!(
recorded
.iter()
.any(|state| state == "false|false|false|false|false|false"),
"controlled content must receive the owner's initial value"
);
assert!(
recorded
.iter()
.any(|state| state == "true|false|false|false|false|false"),
"controlled content must rerender after the owner accepts the reported change"
);
}
#[gpui::test]
fn radio_group_arrows_move_and_select(cx: &mut TestAppContext) {
let picks = events();
let picked = picks.clone();
let switches = events();
let switched = switches.clone();
let cx = open_host(cx, move || {
let picks = picks.clone();
let switches = switches.clone();
gpui::div()
.flex()
.flex_col()
.gap(px(16.))
.child(
RadioGroup::new(
"rg-roving",
vec!["One".into(), "Two".into(), "Three".into()],
)
.default_value("One")
.on_change(move |value, _, _| picks.borrow_mut().push(value.to_string())),
)
.child(
Switch::new("rg-exit-probe").on_change(move |checked, _, _| {
switches.borrow_mut().push(format!("change:{checked}"));
}),
)
.into_any_element()
});
press(cx, "tab");
press(cx, "down");
press(cx, "down");
press(cx, "up");
assert_eq!(
picked.borrow().as_slice(),
["Two", "Three", "Two"],
"Down Down Up must move AND select within the one tab stop"
);
press(cx, "tab");
press(cx, "down");
assert_eq!(
picked.borrow().as_slice(),
["Two", "Three", "Two"],
"the group must not answer the arrows after Tab leaves it"
);
press(cx, "space");
assert_eq!(
switched.borrow().as_slice(),
["change:true"],
"Tab must land on the next tab stop — the switch"
);
}
#[gpui::test]
fn radio_group_disabled_option_is_skipped(cx: &mut TestAppContext) {
let picks = events();
let picked = picks.clone();
let cx = open_host(cx, move || {
let picks = picks.clone();
RadioGroup::new(
"rg-disabled-opt",
vec![
"One".into(),
RadioOption::new("Two").is_disabled(true),
"Three".into(),
],
)
.default_value("One")
.on_change(move |value, _, _| picks.borrow_mut().push(value.to_string()))
.into_any_element()
});
press(cx, "tab");
press(cx, "down");
press(cx, "up");
assert_eq!(
picked.borrow().as_slice(),
["Three", "One"],
"Down and Up must skip the disabled option and land on the enabled \
ones around it"
);
click(cx, 8., 46.);
assert_eq!(
picked.borrow().as_slice(),
["Three", "One"],
"the disabled option must not answer a click"
);
click(cx, 8., 11.);
assert_eq!(
picked.borrow().as_slice(),
["Three", "One", "One"],
"an enabled option beside a disabled one must still answer a click"
);
}
#[gpui::test]
fn radio_group_first_option_disabled_stays_reachable(cx: &mut TestAppContext) {
let picks = events();
let picked = picks.clone();
let cx = open_host(cx, move || {
let picks = picks.clone();
RadioGroup::new(
"rg-first-disabled",
vec![
RadioOption::new("One").is_disabled(true),
"Two".into(),
"Three".into(),
],
)
.default_value("")
.on_change(move |value, _, _| picks.borrow_mut().push(value.to_string()))
.into_any_element()
});
press(cx, "tab");
press(cx, "down");
assert_eq!(
picked.borrow().as_slice(),
["Three"],
"Tab must reach the group with the first option disabled, and the \
first Down must advance from the focused option"
);
}
#[gpui::test]
fn switch_group_reports_each_switch(cx: &mut TestAppContext) {
let first = events();
let first_rec = first.clone();
let second = events();
let second_rec = second.clone();
let cx = open_host(cx, move || {
let first = first.clone();
let second = second.clone();
SwitchGroup::new()
.child(
Switch::new("sg-1")
.label("One")
.on_change(move |checked, _, _| {
first.borrow_mut().push(format!("change:{checked}"));
}),
)
.child(
Switch::new("sg-2")
.label("Two")
.on_change(move |checked, _, _| {
second.borrow_mut().push(format!("change:{checked}"));
}),
)
.into_any_element()
});
click(cx, 20., 13.);
click(cx, 20., 52.);
assert_eq!(
first_rec.borrow().as_slice(),
["change:true"],
"clicking the first track must report that switch's change"
);
assert_eq!(
second_rec.borrow().as_slice(),
["change:true"],
"clicking the second track must report its own change, not the first's"
);
}
#[gpui::test]
fn input_typing_and_clear(cx: &mut TestAppContext) {
let changes = events();
let recorded = changes.clone();
let state = cx.new(|cx| InputState::new(cx));
let state_for_view = state.clone();
let cx = open_host(cx, move || {
let changes = changes.clone();
let state = state_for_view.clone();
Input::new(state)
.is_clearable(true)
.on_change(move |text, _, _| changes.borrow_mut().push(text.to_owned()))
.into_any_element()
});
click(cx, 60., 18.);
cx.simulate_input("hello");
assert_eq!(
recorded.borrow().as_slice(),
["h", "he", "hel", "hell", "hello"],
"typing must report the growing value on every keystroke"
);
let value = cx.update(|_, cx| state.read(cx).value().to_owned());
assert_eq!(value, "hello", "the InputState must hold what was typed");
click(cx, 298., 18.);
assert_eq!(
recorded.borrow().as_slice(),
["h", "he", "hel", "hell", "hello", ""],
"the clear button must report an empty value"
);
let cleared = cx.update(|_, cx| state.read(cx).value().to_owned());
assert_eq!(cleared, "", "clearing must empty the InputState");
}
#[gpui::test]
fn number_field_steppers_and_bounds(cx: &mut TestAppContext) {
let changes = events();
let recorded = changes.clone();
let state = cx.new(|cx| NumberState::new(cx, 90.0));
let state_for_view = state.clone();
let cx = open_host(cx, move || {
let changes = changes.clone();
let state = state_for_view.clone();
NumberField::new(state)
.min_value(0.0)
.max_value(100.0)
.step(10.0)
.on_change(move |value, _, _| changes.borrow_mut().push(format!("{value}")))
.into_any_element()
});
click(cx, 200., 18.);
click(cx, 200., 18.);
assert_eq!(
recorded.borrow().as_slice(),
["100"],
"a bound no-op must not synthesize a duplicate on_change callback"
);
let value = cx.update(|_, cx| state.read(cx).value().to_string());
assert_eq!(value, "100", "the NumberState must hold the clamped value");
}
#[gpui::test]
fn number_field_vertical_steppers_drive_both_directions(cx: &mut TestAppContext) {
let changes = events();
let recorded = changes.clone();
let state = cx.new(|cx| NumberState::new(cx, 10.0));
let state_for_view = state.clone();
let cx = open_host(cx, move || {
let changes = changes.clone();
NumberField::new(state_for_view.clone())
.step(2.0)
.vertical_steppers(true)
.increment_icon(gpui::div())
.decrement_icon(gpui::div())
.on_change(move |value, _, _| changes.borrow_mut().push(format!("{value}")))
.into_any_element()
});
click(cx, 208., 9.);
click(cx, 208., 27.);
assert_eq!(
recorded.borrow().as_slice(),
["12", "10"],
"both custom vertical stepper cells must retain their press behaviour"
);
let value = cx.update(|_, cx| state.read(cx).value());
assert_eq!(value.to_bits(), 10.0_f64.to_bits());
}
#[gpui::test]
fn time_field_segments_answer_arrows(cx: &mut TestAppContext) {
let changes = events();
let recorded = changes.clone();
let state = cx.new(|cx| TimeState::with_value(cx, Time::new(9, 30)));
let state_for_view = state;
let cx = open_host(cx, move || {
let changes = changes.clone();
TimeField::new(state_for_view.clone())
.on_change(move |time, _, _| {
let text = time
.map(|t| format!("{:02}:{:02}", t.hour, t.minute))
.unwrap_or_default();
changes.borrow_mut().push(text);
})
.into_any_element()
});
press(cx, "tab");
press(cx, "up");
assert_eq!(
recorded.borrow().as_slice(),
["10:30"],
"Up must step the focused hour segment by one hour"
);
press(cx, "right");
press(cx, "up");
assert_eq!(
recorded.borrow().as_slice(),
["10:30", "10:31"],
"Right must move to the minute segment and Up must step it"
);
}
#[gpui::test]
fn form_submit_reports_named_fields(cx: &mut TestAppContext) {
let submits = events();
let submitted = submits.clone();
let invalids = events();
let invalid = invalids.clone();
let name_state = cx.new(|cx| InputState::new(cx));
let email_state = cx.new(|cx| InputState::new(cx));
let name_for_view = name_state;
let email_for_view = email_state;
let cx = open_host(cx, move || {
let submits = submits.clone();
let invalids = invalids.clone();
let name_state = name_for_view.clone();
let email_state = email_for_view.clone();
let form = Form::new()
.field(FormField::text(name_state.clone()).is_required(true))
.field(FormField::text(email_state.clone()))
.on_submit(move |data: &FormData, _, _| {
submits.borrow_mut().push(record_data(data));
})
.on_invalid(move |data: &FormData, _, _| {
invalids.borrow_mut().push(record_data(data));
});
let submit = form.submit_handler();
form.child(Input::new(name_state).name("name"))
.child(Input::new(email_state).name("email"))
.child(
gpui::div().flex().gap(px(8.)).child(
Button::new("form-submit")
.label("Submit")
.on_press(move |_, window, cx| submit(window, cx)),
),
)
.into_any_element()
});
click(cx, 60., 18.);
cx.simulate_input("ada");
click(cx, 60., 70.);
cx.simulate_input("ada@example.com");
click(cx, 60., 122.);
assert_eq!(
submitted.borrow().as_slice(),
["name=ada,email=ada@example.com"],
"submitting must report every named field, in registration order"
);
assert!(
invalid.borrow().is_empty(),
"a filled required field must not block the submission"
);
}
#[gpui::test]
fn form_validation_blocks_submit(cx: &mut TestAppContext) {
let submits = events();
let submitted = submits.clone();
let invalids = events();
let missing = invalids.clone();
let name_state = cx.new(|cx| InputState::new(cx));
let email_state = cx.new(|cx| InputState::new(cx));
let name_for_view = name_state;
let email_for_view = email_state;
let cx = open_host(cx, move || {
let submits = submits.clone();
let invalids = invalids.clone();
let name_state = name_for_view.clone();
let email_state = email_for_view.clone();
let form = Form::new()
.field(FormField::text(name_state.clone()).is_required(true))
.field(FormField::text(email_state.clone()))
.on_submit(move |data: &FormData, _, _| {
submits.borrow_mut().push(record_data(data));
})
.on_invalid(move |data: &FormData, _, _| {
let names = data
.missing_required(&[SharedString::from("name")])
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(",");
invalids.borrow_mut().push(format!("missing:{names}"));
});
let submit = form.submit_handler();
form.child(Input::new(name_state).name("name"))
.child(Input::new(email_state).name("email"))
.child(
gpui::div().flex().gap(px(8.)).child(
Button::new("form-submit")
.label("Submit")
.on_press(move |_, window, cx| submit(window, cx)),
),
)
.into_any_element()
});
click(cx, 60., 70.);
cx.simulate_input("ada@example.com");
click(cx, 60., 122.);
assert_eq!(
missing.borrow().as_slice(),
["missing:name"],
"an empty required field must route the submission to on_invalid \
with that name reported"
);
assert!(
submitted.borrow().is_empty(),
"onSubmit must not fire while a required field is empty"
);
}
fn pending_frames(cx: &mut VisualTestContext) -> usize {
cx.update(|window, cx| window.simulate_next_frame(cx))
}
fn flush_frame(cx: &mut VisualTestContext) {
cx.update(|window, _| window.refresh());
}
fn settle(cx: &mut VisualTestContext) {
for _ in 0..200 {
if pending_frames(cx) == 0 {
return;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
panic!("field chrome animations never settled");
}
const MID_FLIGHT_MS: u64 = 60;
fn sleep_mid_flight() {
std::thread::sleep(std::time::Duration::from_millis(MID_FLIGHT_MS));
}
#[gpui::test]
fn number_field_chrome_mounts_settled_and_interpolates_focus(cx: &mut TestAppContext) {
let state = cx.new(|cx| NumberState::new(cx, 5.0));
let state_for_view = state;
let cx = open_host(cx, move || {
gpui::div()
.mt(px(200.))
.child(NumberField::new(state_for_view.clone()))
.into_any_element()
});
assert_eq!(
pending_frames(cx),
0,
"an unfocused NumberField must mount settled"
);
flush_frame(cx);
assert_eq!(pending_frames(cx), 0);
press(cx, "tab");
flush_frame(cx);
sleep_mid_flight();
assert!(
pending_frames(cx) > 0,
"taking focus must animate the focus ring, not swap it in"
);
cx.update(|window, cx| window.blur(cx));
flush_frame(cx);
sleep_mid_flight();
assert!(
pending_frames(cx) > 0,
"losing focus must animate the ring out, not swap it off"
);
settle(cx);
assert_eq!(pending_frames(cx), 0);
}
#[gpui::test]
fn number_field_focus_snaps_under_reduced_motion(cx: &mut TestAppContext) {
still();
let state = cx.new(|cx| NumberState::new(cx, 5.0));
let state_for_view = state;
let cx = open_host(cx, move || {
NumberField::new(state_for_view.clone()).into_any_element()
});
press(cx, "tab");
flush_frame(cx);
assert_eq!(
pending_frames(cx),
0,
"reduced motion must snap the focus chrome"
);
flush_frame(cx);
assert_eq!(pending_frames(cx), 0);
}
#[gpui::test]
fn number_field_invalid_chrome_interpolates(cx: &mut TestAppContext) {
let invalid = Rc::new(std::cell::Cell::new(false));
let seed = invalid.clone();
let state = cx.new(|cx| NumberState::new(cx, 5.0));
let state_for_view = state;
let cx = open_host(cx, move || {
let invalid = seed.clone();
NumberField::new(state_for_view.clone())
.is_invalid(invalid.get())
.into_any_element()
});
settle(cx);
invalid.set(true);
flush_frame(cx);
sleep_mid_flight();
assert!(
pending_frames(cx) > 0,
"becoming invalid must animate the danger outline in"
);
settle(cx);
invalid.set(false);
flush_frame(cx);
sleep_mid_flight();
assert!(
pending_frames(cx) > 0,
"recovering from invalid must animate the danger outline out"
);
settle(cx);
assert_eq!(pending_frames(cx), 0);
}
#[gpui::test]
fn number_field_hover_interpolates_the_group_surface(cx: &mut TestAppContext) {
let state = cx.new(|cx| NumberState::new(cx, 5.0));
let state_for_view = state;
let cx = open_host(cx, move || {
NumberField::new(state_for_view.clone()).into_any_element()
});
settle(cx);
cx.simulate_mouse_move(point(px(110.), px(18.)), None, Modifiers::none());
flush_frame(cx);
sleep_mid_flight();
assert!(
pending_frames(cx) > 0,
"hovering the group must ease the hover surface in"
);
settle(cx);
cx.simulate_mouse_move(point(px(-100.), px(-100.)), None, Modifiers::none());
flush_frame(cx);
sleep_mid_flight();
assert!(
pending_frames(cx) > 0,
"leaving the group must ease the hover surface out"
);
settle(cx);
assert_eq!(pending_frames(cx), 0);
}
#[gpui::test]
fn input_otp_invalid_slots_interpolate_the_danger_outline(cx: &mut TestAppContext) {
let invalid = Rc::new(std::cell::Cell::new(false));
let seed = invalid.clone();
let state = cx.new(|cx| OtpState::with_length(cx, 4));
let state_for_view = state;
let cx = open_host(cx, move || {
let invalid = seed.clone();
InputOTP::new(state_for_view.clone())
.is_invalid(invalid.get())
.into_any_element()
});
settle(cx);
invalid.set(true);
flush_frame(cx);
sleep_mid_flight();
assert!(
pending_frames(cx) > 0,
"becoming invalid must animate the slots' danger outlines in"
);
settle(cx);
invalid.set(false);
flush_frame(cx);
sleep_mid_flight();
assert!(
pending_frames(cx) > 0,
"recovering from invalid must animate the slots' danger outlines out"
);
settle(cx);
assert_eq!(pending_frames(cx), 0);
}
#[gpui::test]
fn input_otp_hover_interpolates_the_slot_surface(cx: &mut TestAppContext) {
let state = cx.new(|cx| OtpState::with_length(cx, 4));
let state_for_view = state;
let cx = open_host(cx, move || {
InputOTP::new(state_for_view.clone()).into_any_element()
});
settle(cx);
cx.simulate_mouse_move(point(px(27.), px(20.)), None, Modifiers::none());
flush_frame(cx);
sleep_mid_flight();
assert!(
pending_frames(cx) > 0,
"hovering a slot must ease its hover chrome in"
);
settle(cx);
cx.simulate_mouse_move(point(px(-100.), px(-100.)), None, Modifiers::none());
flush_frame(cx);
sleep_mid_flight();
assert!(
pending_frames(cx) > 0,
"leaving a slot must ease its hover chrome out"
);
settle(cx);
assert_eq!(pending_frames(cx), 0);
}