use super::*;
use crate::common::datetime::Date;
use teksilo_core::signal::Signal;
use teksilo_core::widget_tree::WidgetTree;
fn light_tree() -> WidgetTree {
WidgetTree::new().with_theme(teksilo_core::presets::intui::light())
}
#[test]
fn single_calendar_builds_with_value() {
let mut tree = light_tree();
let date = Signal::new(Some(Date::constant(2026, 5, 2)));
let id = tree.add(Calendar::single(date));
tree.layout(SizeProposal {
width: Some(400.0),
height: None,
});
let bounds = tree.bounds(id);
assert!(bounds.width > 0.0);
assert!(bounds.height > 0.0);
}
#[test]
fn single_calendar_builds_with_none_value() {
let mut tree = light_tree();
let date = Signal::new(None::<Date>);
let id = tree.add(Calendar::single(date));
tree.layout(SizeProposal {
width: Some(400.0),
height: None,
});
let bounds = tree.bounds(id);
assert!(bounds.width > 0.0);
assert!(bounds.height > 0.0);
}
#[test]
fn range_calendar_builds() {
let mut tree = light_tree();
let range = Signal::new(None::<DateRange>);
let id = tree.add(Calendar::range(range));
tree.layout(SizeProposal {
width: Some(400.0),
height: None,
});
let bounds = tree.bounds(id);
assert!(bounds.width > 0.0);
}
#[test]
fn calendar_role_is_grid() {
let mut tree = light_tree();
let date = Signal::new(Some(Date::constant(2026, 5, 2)));
let id = tree.add(Calendar::single(date));
tree.layout(SizeProposal {
width: Some(400.0),
height: None,
});
let info = tree.accessibility_node(id);
assert_eq!(info.role(), teksilo_core::accesskit::Role::Grid);
}
#[test]
fn calendar_label_includes_month_and_year() {
let mut tree = light_tree();
let date = Signal::new(Some(Date::constant(2026, 5, 2)));
let id = tree.add(Calendar::single(date));
tree.layout(SizeProposal {
width: Some(400.0),
height: None,
});
let info = tree.accessibility_node(id);
let name = info.name().unwrap_or("");
assert!(
(name.contains("May") || name.contains("may")) && name.contains("2026"),
"got: {name}"
);
}
#[test]
fn calendar_value_emits_iso_string_in_single_mode() {
let mut tree = light_tree();
let date = Signal::new(Some(Date::constant(2026, 5, 2)));
let id = tree.add(Calendar::single(date));
tree.layout(SizeProposal {
width: Some(400.0),
height: None,
});
let update = tree.sync_accessibility();
let target_node_id = teksilo_core::accessibility::widget_id_to_node_id(id);
let (_, node) = update
.nodes
.iter()
.find(|(nid, _)| *nid == target_node_id)
.expect("calendar node present in AT update");
let value = node.value().unwrap_or_default();
assert!(
value.contains("2026-05-02"),
"expected ISO date in value, got: {value}"
);
assert!(
value.contains("selected"),
"expected `selected: ...` suffix when value is set, got: {value}"
);
}
#[test]
fn calendar_rebuilds_on_month_navigation() {
use crate::common::datetime::types::YearMonth;
let mut tree = light_tree();
let date = Signal::new(Some(Date::constant(2026, 5, 2)));
let calendar = Calendar::single(date);
let visible_month = calendar.visible_month_signal();
let id = tree.add(calendar);
tree.layout(SizeProposal {
width: Some(400.0),
height: None,
});
let initial_descendant_count = count_descendants(&tree, id);
visible_month.set(YearMonth::new(2026, 6));
tree.layout(SizeProposal {
width: Some(400.0),
height: None,
});
let after_nav_count = count_descendants(&tree, id);
assert!(
(initial_descendant_count as i64 - after_nav_count as i64).abs() <= 1,
"rebuild leaked nodes: {} → {}",
initial_descendant_count,
after_nav_count
);
assert!(
after_nav_count > 200 && after_nav_count < 800,
"expected calendar descendant count in 200..800, got {after_nav_count}"
);
}
fn count_descendants(tree: &WidgetTree, root: WidgetId) -> usize {
let mut count = 0;
let mut queue = vec![root];
while let Some(id) = queue.pop() {
count += 1;
queue.extend(tree.children(id));
}
count
}
fn find_day_cell(tree: &WidgetTree, root: WidgetId, day: u8) -> WidgetId {
let needle = format!(" {day}, ");
let mut queue = vec![root];
while let Some(id) = queue.pop() {
let node = tree.accessibility_node(id);
if node.role() == teksilo_core::accesskit::Role::GridCell
&& node.name().is_some_and(|n| n.contains(&needle))
{
return id;
}
queue.extend(tree.children(id));
}
panic!("no day cell for day {day}");
}
#[test]
fn access_click_on_day_cell_commits_the_date() {
let mut tree = light_tree();
let date = Signal::new(Some(Date::constant(2026, 5, 2)));
let cal = tree.add(Calendar::single(date.clone()));
tree.layout(SizeProposal {
width: Some(400.0),
height: None,
});
let cell = find_day_cell(&tree, cal, 17);
tree.dispatch_event(teksilo_core::event::WidgetEvent::AccessAction {
action: teksilo_core::accesskit::Action::Click,
target: Some(cell),
target_node: teksilo_core::accessibility::root_node_id(),
data: None,
});
assert_eq!(
date.get(),
Some(Date::constant(2026, 5, 17)),
"AT click on a day cell must commit that date"
);
}
#[test]
fn range_mode_first_commit_parks_anchor_second_commit_sets_value() {
use teksilo_core::event::{Key, Modifiers, WidgetEvent};
let mut tree = light_tree();
let value: Signal<Option<DateRange>> = Signal::new(None);
let id = tree.add(Calendar::range(value.clone()));
tree.layout(SizeProposal {
width: Some(400.0),
height: None,
});
assert_eq!(value.get(), None, "no value before any commit");
tree.focus(id);
let press = |tree: &mut WidgetTree, key: Key| {
tree.dispatch_event(WidgetEvent::KeyDown {
key,
modifiers: Modifiers::NONE,
text: None,
});
};
press(&mut tree, Key::Enter);
assert_eq!(
value.get(),
None,
"first commit must park the anchor without publishing a range"
);
press(&mut tree, Key::ArrowRight);
press(&mut tree, Key::Enter);
let committed = value.get();
assert!(
committed.is_some(),
"second commit must publish a range (also proves the keystrokes landed)"
);
let range = committed.unwrap();
assert!(
range.start < range.end,
"ArrowRight then commit should span two adjacent days, got {range:?}"
);
}
#[test]
fn date_range_invariant() {
let r = DateRange::new(Date::constant(2026, 5, 5), Date::constant(2026, 5, 1));
assert!(r.start <= r.end);
assert_eq!(r.start, Date::constant(2026, 5, 1));
assert_eq!(r.end, Date::constant(2026, 5, 5));
}
#[test]
fn date_range_contains_inclusive() {
let r = DateRange::new(Date::constant(2026, 5, 1), Date::constant(2026, 5, 5));
assert!(r.contains(Date::constant(2026, 5, 1)));
assert!(r.contains(Date::constant(2026, 5, 3)));
assert!(r.contains(Date::constant(2026, 5, 5)));
assert!(!r.contains(Date::constant(2026, 4, 30)));
assert!(!r.contains(Date::constant(2026, 5, 6)));
}
#[test]
fn calendar_mode_default_is_days() {
let date = Signal::new(Some(Date::constant(2026, 5, 2)));
let cal = Calendar::single(date);
assert_eq!(cal.mode_signal().get(), CalendarMode::Days);
}
#[test]
fn calendar_mode_demote_chain() {
assert_eq!(CalendarMode::Days.demote(), CalendarMode::Months);
assert_eq!(CalendarMode::Months.demote(), CalendarMode::Years);
assert_eq!(CalendarMode::Years.demote(), CalendarMode::Years);
}
#[test]
fn calendar_mode_signal_writable_for_programmatic_zoom() {
let date = Signal::new(Some(Date::constant(2026, 5, 2)));
let cal = Calendar::single(date);
let mode = cal.mode_signal();
mode.set(CalendarMode::Years);
assert_eq!(mode.get(), CalendarMode::Years);
let mut tree = light_tree();
let _id = tree.add(cal);
tree.layout(SizeProposal {
width: Some(400.0),
height: None,
});
assert_eq!(mode.get(), CalendarMode::Years);
}
#[test]
fn years_grid_decade_calculation() {
use crate::calendar::zoom_grid::YearsGrid;
assert_eq!(YearsGrid::decade_of(2026), 2020);
assert_eq!(YearsGrid::decade_of(2020), 2020);
assert_eq!(YearsGrid::decade_of(2029), 2020);
assert_eq!(YearsGrid::decade_of(2030), 2030);
assert_eq!(YearsGrid::decade_of(1999), 1990);
assert_eq!(YearsGrid::decade_of(0), 0);
}
#[test]
fn calendar_title_button_click_demotes_mode() {
use teksilo_canvas::Point;
use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
let date = Signal::new(Some(Date::constant(2026, 5, 2)));
let cal = Calendar::single(date);
let mode = cal.mode_signal();
let mut tree = light_tree();
let id = tree.add(cal);
tree.layout(SizeProposal {
width: Some(400.0),
height: None,
});
assert_eq!(mode.get(), CalendarMode::Days);
let bounds = tree.bounds(id);
let click_pos = Point::new(bounds.x + bounds.width / 2.0, bounds.y + 20.0);
tree.dispatch_event(WidgetEvent::PointerDown {
position: click_pos,
button: PointerButton::Primary,
modifiers: Modifiers::NONE,
});
tree.dispatch_event(WidgetEvent::PointerUp {
position: click_pos,
button: PointerButton::Primary,
modifiers: Modifiers::NONE,
});
assert_eq!(
mode.get(),
CalendarMode::Months,
"tap on header title should demote mode Days → Months; \
got {:?}. The TitleButton's on_tap is not firing.",
mode.get()
);
}
#[test]
fn calendar_months_body_does_not_collapse_to_left_edge() {
let date = Signal::new(Some(Date::constant(2026, 5, 2)));
let cal = Calendar::single(date);
let mode = cal.mode_signal();
mode.set(CalendarMode::Months);
let mut tree = light_tree();
let id = tree.add(cal);
tree.layout(SizeProposal {
width: Some(400.0),
height: None,
});
tree.layout(SizeProposal {
width: Some(400.0),
height: None,
});
let cal_bounds = tree.bounds(id);
let mut max_right: f32 = 0.0;
let mut stack = vec![id];
while let Some(node_id) = stack.pop() {
let b = tree.bounds(node_id);
if b.width > 0.0 {
max_right = max_right.max(b.right());
}
for child in tree.children(node_id) {
stack.push(child);
}
}
let half_width = cal_bounds.x + cal_bounds.width * 0.5;
assert!(
max_right > half_width,
"in Months mode the zoom body should distribute past the centre — \
max-right child x = {max_right}, calendar mid x = {half_width}, \
cal_bounds = {cal_bounds:?}"
);
}
#[test]
fn calendar_title_button_clickable_across_centered_band() {
use teksilo_canvas::Point;
use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
let date = Signal::new(Some(Date::constant(2026, 5, 2)));
let cal = Calendar::single(date);
let mode = cal.mode_signal();
let mut tree = light_tree();
let id = tree.add(cal);
tree.layout(SizeProposal {
width: Some(400.0),
height: None,
});
let bounds = tree.bounds(id);
let header_y = bounds.y + 20.0;
for pct in [0.30_f32, 0.50, 0.70] {
mode.set(CalendarMode::Days);
let click_pos = Point::new(bounds.x + bounds.width * pct, header_y);
tree.dispatch_event(WidgetEvent::PointerDown {
position: click_pos,
button: PointerButton::Primary,
modifiers: Modifiers::NONE,
});
tree.dispatch_event(WidgetEvent::PointerUp {
position: click_pos,
button: PointerButton::Primary,
modifiers: Modifiers::NONE,
});
assert_eq!(
mode.get(),
CalendarMode::Months,
"click at {pct:.0}% of header width should flip mode; \
title button bounds don't span the centred band."
);
}
}
fn first_weekday_header(tree: &mut WidgetTree, root: WidgetId) -> Option<String> {
fn collect(tree: &WidgetTree, id: WidgetId, out: &mut Vec<WidgetId>) {
out.push(id);
for c in tree.children(id) {
collect(tree, c, out);
}
}
let mut ids = Vec::new();
collect(tree, root, &mut ids);
let update = tree.sync_accessibility();
ids.iter().find_map(|id| {
let target = teksilo_core::accessibility::widget_id_to_node_id(*id);
update
.nodes
.iter()
.find(|(nid, _)| *nid == target)
.filter(|(_, n)| n.role() == teksilo_core::accesskit::Role::ColumnHeader)
.and_then(|(_, n)| n.label().map(|s| s.to_string()))
})
}
#[test]
fn calendar_re_derives_its_first_day_of_week_when_the_locale_switches() {
let mut tree = light_tree();
tree.set_locale("en-US".to_string());
let date = Signal::new(Some(Date::constant(2026, 5, 2)));
let id = tree.add(Calendar::single(date));
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(400.0),
});
let en = first_weekday_header(&mut tree, id).expect("weekday header");
assert!(
en.contains("sunday"),
"en-US should start the week on Sunday; got `{en}`"
);
tree.set_locale("fr-FR".to_string());
tree.layout(SizeProposal {
width: Some(400.0),
height: Some(400.0),
});
let fr = first_weekday_header(&mut tree, id).expect("weekday header");
assert!(
fr.contains("monday"),
"fr-FR should start the week on Monday after the switch; got `{fr}`"
);
}