1use crate::control::composed_button;
4use crate::handlers::SharedHandler;
5use crate::motion::{MotionTokens, swap_progress};
6use crate::theme::SemanticStyledExt as _;
7use gpui::{
8 App, ClickEvent, Div, ElementId, InteractiveElement as _, IntoElement, ParentElement as _,
9 RenderOnce, Role, SharedString, Stateful, StatefulInteractiveElement as _, StyleRefinement,
10 Styled, Window, div, prelude::FluentBuilder as _,
11};
12use gpui_base::Button;
13use gpui_base::motion::{Transition, transition};
14use gpui_component::{
15 ActiveTheme as _, StyledExt as _, chart::LineChart, h_flex, text::TextView, v_flex,
16};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum InsightTrend {
21 Up,
23 Down,
25 Flat,
27}
28
29impl InsightTrend {
30 fn accessibility_label(self) -> &'static str {
31 match self {
32 Self::Up => "increasing",
33 Self::Down => "decreasing",
34 Self::Flat => "steady",
35 }
36 }
37
38 fn visible_label(self) -> &'static str {
39 match self {
40 Self::Up => "↑ Increasing",
41 Self::Down => "↓ Decreasing",
42 Self::Flat => "→ Steady",
43 }
44 }
45}
46
47#[derive(Debug, Clone)]
49pub struct InsightMetric {
50 id: SharedString,
51 label: SharedString,
52 value: SharedString,
53 change: Option<SharedString>,
54 trend: InsightTrend,
55}
56
57impl InsightMetric {
58 pub fn new(
60 id: impl Into<SharedString>,
61 label: impl Into<SharedString>,
62 value: impl Into<SharedString>,
63 ) -> Self {
64 Self {
65 id: id.into(),
66 label: label.into(),
67 value: value.into(),
68 change: None,
69 trend: InsightTrend::Flat,
70 }
71 }
72
73 pub fn change(mut self, change: impl Into<SharedString>, trend: InsightTrend) -> Self {
75 self.change = Some(change.into());
76 self.trend = trend;
77 self
78 }
79
80 fn accessibility_name(&self) -> SharedString {
81 match &self.change {
82 Some(change) => format!(
83 "{}, {}, {}, {}",
84 self.label,
85 self.value,
86 change,
87 self.trend.accessibility_label()
88 )
89 .into(),
90 None => format!("{}, {}", self.label, self.value).into(),
91 }
92 }
93}
94
95#[derive(Debug, Clone)]
97pub struct InsightPoint {
98 label: SharedString,
99 value: f64,
100}
101
102impl InsightPoint {
103 pub fn new(label: impl Into<SharedString>, value: f64) -> Self {
105 Self {
106 label: label.into(),
107 value,
108 }
109 }
110}
111
112#[derive(Debug, Clone, PartialEq, Eq)]
114pub enum InsightEvent {
115 Previous {
117 id: SharedString,
119 },
120 Next {
122 id: SharedString,
124 },
125 FollowUp {
127 id: SharedString,
129 prompt: SharedString,
131 },
132}
133
134#[derive(Clone, Copy)]
135enum InsightControlStyle {
136 Paging,
137 FollowUp,
138}
139
140struct InsightControl {
141 card_id: SharedString,
142 local_id: &'static str,
143 label: SharedString,
144 disabled: bool,
145 event: InsightEvent,
146 style: InsightControlStyle,
147}
148
149impl InsightControl {
150 fn styled_button(self, handler: Option<SharedHandler<InsightEvent>>, cx: &mut App) -> Button {
151 let tokens = cx.theme().semantic_tokens();
152 let (background, foreground, border, hover, active) = match self.style {
153 InsightControlStyle::Paging => (
154 cx.theme().transparent,
155 cx.theme().foreground,
156 cx.theme().transparent,
157 cx.theme().button_hover,
158 cx.theme().button_active,
159 ),
160 InsightControlStyle::FollowUp => (
161 cx.theme().button_primary,
162 cx.theme().button_primary_foreground,
163 cx.theme().primary,
164 cx.theme().button_primary_hover,
165 cx.theme().button_primary_active,
166 ),
167 };
168 let disabled = self.disabled;
169 let label = self.label.clone();
170 composed_button((ElementId::from(self.card_id), self.local_id), self.label)
171 .disabled(disabled)
172 .flex()
173 .items_center()
174 .justify_center()
175 .px(tokens.spacing.sm)
176 .py(tokens.spacing.xxs)
177 .border_1()
178 .border_color(border)
179 .rounded(tokens.radius.sm)
180 .bg(background)
181 .text_token(tokens.typography.xs)
182 .text_color(foreground)
183 .when(!disabled, |button| {
184 button
185 .hover(|style| style.bg(hover))
186 .active(|style| style.bg(active))
187 })
188 .focus(|style| style.border_color(cx.theme().ring))
189 .focus_visible(|style| style.border_color(cx.theme().ring))
190 .styles(|styles| {
191 styles.disabled(|style| {
192 style
193 .bg(cx.theme().muted)
194 .text_color(cx.theme().muted_foreground)
195 .border_color(cx.theme().border)
196 })
197 })
198 .child(div().child(label))
199 .when_some(handler, |button, handler| {
200 let event = self.event;
201 button.on_click(move |_: &ClickEvent, window, cx| handler(&event, window, cx))
202 })
203 }
204}
205
206#[derive(IntoElement)]
225pub struct InsightCard {
226 id: SharedString,
227 style: StyleRefinement,
228 title: SharedString,
229 body: Option<SharedString>,
230 current_page: usize,
231 total_pages: usize,
232 metrics: Vec<InsightMetric>,
233 series_name: SharedString,
234 points: Vec<InsightPoint>,
235 chart_summary: Option<SharedString>,
236 follow_up: Option<SharedString>,
237 on_event: Option<SharedHandler<InsightEvent>>,
238}
239
240impl InsightCard {
241 pub fn new(id: impl Into<SharedString>, title: impl Into<SharedString>) -> Self {
243 Self {
244 id: id.into(),
245 style: StyleRefinement::default(),
246 title: title.into(),
247 body: None,
248 current_page: 1,
249 total_pages: 1,
250 metrics: Vec::new(),
251 series_name: "Series".into(),
252 points: Vec::new(),
253 chart_summary: None,
254 follow_up: None,
255 on_event: None,
256 }
257 }
258
259 pub fn body(mut self, body: impl Into<SharedString>) -> Self {
261 self.body = Some(body.into());
262 self
263 }
264
265 pub fn page(mut self, current: usize, total: usize) -> Self {
267 self.total_pages = total.max(1);
268 self.current_page = current.clamp(1, self.total_pages);
269 self
270 }
271
272 pub fn metrics(mut self, metrics: impl IntoIterator<Item = InsightMetric>) -> Self {
274 self.metrics = metrics.into_iter().collect();
275 self
276 }
277
278 pub fn series(
280 mut self,
281 name: impl Into<SharedString>,
282 points: impl IntoIterator<Item = InsightPoint>,
283 ) -> Self {
284 self.series_name = name.into();
285 self.points = points.into_iter().collect();
286 self
287 }
288
289 pub fn chart_summary(mut self, summary: impl Into<SharedString>) -> Self {
291 self.chart_summary = Some(summary.into());
292 self
293 }
294
295 pub fn follow_up(mut self, prompt: impl Into<SharedString>) -> Self {
297 self.follow_up = Some(prompt.into());
298 self
299 }
300
301 pub fn on_event(
303 mut self,
304 handler: impl Fn(&InsightEvent, &mut Window, &mut App) + 'static,
305 ) -> Self {
306 self.on_event = Some(std::rc::Rc::new(handler));
307 self
308 }
309
310 fn page_label(&self) -> SharedString {
311 format!("{} of {}", self.current_page, self.total_pages).into()
312 }
313
314 fn previous_event(&self) -> InsightEvent {
315 InsightEvent::Previous {
316 id: self.id.clone(),
317 }
318 }
319
320 fn next_event(&self) -> InsightEvent {
321 InsightEvent::Next {
322 id: self.id.clone(),
323 }
324 }
325
326 fn resolved_chart_summary(&self) -> Option<SharedString> {
327 self.chart_summary.clone().or_else(|| {
328 if self.points.is_empty() {
329 return None;
330 }
331
332 let values = self
333 .points
334 .iter()
335 .map(|point| format!("{} {}", point.label, point.value))
336 .collect::<Vec<_>>()
337 .join(", ");
338 Some(format!("{}: {values}.", self.series_name).into())
339 })
340 }
341
342 fn previous_control(&self) -> InsightControl {
343 InsightControl {
344 card_id: self.id.clone(),
345 local_id: "previous",
346 label: "Previous".into(),
347 disabled: self.current_page == 1 || self.on_event.is_none(),
348 event: self.previous_event(),
349 style: InsightControlStyle::Paging,
350 }
351 }
352
353 fn previous_button(&self, cx: &mut App) -> Button {
354 self.previous_control()
355 .styled_button(self.on_event.clone(), cx)
356 }
357
358 fn next_control(&self) -> InsightControl {
359 InsightControl {
360 card_id: self.id.clone(),
361 local_id: "next",
362 label: "Next".into(),
363 disabled: self.current_page == self.total_pages || self.on_event.is_none(),
364 event: self.next_event(),
365 style: InsightControlStyle::Paging,
366 }
367 }
368
369 fn next_button(&self, cx: &mut App) -> Button {
370 self.next_control().styled_button(self.on_event.clone(), cx)
371 }
372
373 fn follow_up_control(&self) -> Option<InsightControl> {
374 self.follow_up.clone().map(|prompt| InsightControl {
375 card_id: self.id.clone(),
376 local_id: "follow-up",
377 label: prompt.clone(),
378 disabled: self.on_event.is_none(),
379 event: InsightEvent::FollowUp {
380 id: self.id.clone(),
381 prompt,
382 },
383 style: InsightControlStyle::FollowUp,
384 })
385 }
386
387 fn follow_up_button(&self, cx: &mut App) -> Option<Button> {
388 self.follow_up_control()
389 .map(|control| control.styled_button(self.on_event.clone(), cx))
390 }
391}
392
393fn metrics_group(card_id: &SharedString, cx: &mut App) -> Stateful<Div> {
394 let tokens = cx.theme().semantic_tokens();
395 h_flex()
396 .id(format!("{card_id}-metrics"))
397 .role(Role::List)
398 .aria_label("Insight metrics")
399 .flex_wrap()
400 .gap(tokens.spacing.sm)
401}
402
403fn metric_item(card_id: &SharedString, metric: InsightMetric, cx: &mut App) -> Stateful<Div> {
404 let tokens = cx.theme().semantic_tokens();
405 let accessibility_name = metric.accessibility_name();
406 let trend = metric.trend;
407 let trend_color = match trend {
408 InsightTrend::Up => cx.theme().success,
409 InsightTrend::Down => cx.theme().danger,
410 InsightTrend::Flat => cx.theme().muted_foreground,
411 };
412
413 v_flex()
414 .id((
415 ElementId::from((ElementId::from(card_id.clone()), "metric")),
416 metric.id.clone(),
417 ))
418 .role(Role::ListItem)
419 .aria_label(accessibility_name)
420 .flex_1()
421 .gap(tokens.spacing.xxs)
422 .p(tokens.spacing.sm)
423 .bg(cx.theme().muted)
424 .rounded(tokens.radius.sm)
425 .child(crate::surface::hint(metric.label, cx))
426 .child(
427 div()
428 .text_token(tokens.typography.md)
429 .text_color(cx.theme().foreground)
430 .child(metric.value),
431 )
432 .when_some(metric.change, |this, change| {
433 this.child(
434 h_flex()
435 .gap(tokens.spacing.xs)
436 .text_token(tokens.typography.xs)
437 .text_color(trend_color)
438 .child(change)
439 .child(trend.visible_label()),
440 )
441 })
442}
443
444fn chart_group(
445 card_id: &SharedString,
446 label: SharedString,
447 summary: SharedString,
448 page: usize,
449 points: Vec<InsightPoint>,
450 window: &mut Window,
451 cx: &mut App,
452) -> Stateful<Div> {
453 let tokens = cx.theme().semantic_tokens();
454 let standard = MotionTokens::read(cx).standard();
460 let points = points
461 .into_iter()
462 .map(|point| {
463 let drawn = transition(
464 ElementId::Name(SharedString::from(format!(
465 "{card_id}-pt-{page}-{}",
466 point.label
467 ))),
468 point.value as f32,
469 Transition::new(crate::motion::retarget_duration(cx, standard))
470 .ease(crate::motion::ease_in_out_quart),
471 window,
472 cx,
473 );
474 InsightPoint {
475 label: point.label,
476 value: drawn as f64,
477 }
478 })
479 .collect::<Vec<_>>();
480 v_flex()
481 .id((ElementId::from(card_id.clone()), "chart-group"))
482 .role(Role::Group)
483 .aria_label(label.clone())
484 .aria_description(summary)
485 .h(tokens.spacing.xxl + tokens.spacing.xxl + tokens.spacing.xxl + tokens.spacing.xxl)
486 .child(
487 LineChart::new(points)
488 .x(|point| point.label.clone())
489 .y(|point| point.value)
490 .natural()
491 .grid(false)
492 .x_axis(false)
493 .stroke(cx.theme().chart_2)
494 .name(label)
495 .id((ElementId::from(card_id.clone()), "chart")),
496 )
497}
498
499impl Styled for InsightCard {
500 fn style(&mut self) -> &mut StyleRefinement {
501 &mut self.style
502 }
503}
504
505impl RenderOnce for InsightCard {
506 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
507 let tokens = cx.theme().semantic_tokens();
508 let page_memory = window.use_keyed_state(
513 (ElementId::from(self.id.clone()), "page-memory"),
514 cx,
515 |_, _| (self.current_page, 0u64, true),
516 );
517 let (previous_page, mut page_generation, mut entering_forward) = *page_memory.read(cx);
518 if previous_page != self.current_page {
519 page_generation = page_generation.wrapping_add(1);
520 entering_forward = self.current_page > previous_page;
521 let stamp = (self.current_page, page_generation, entering_forward);
522 page_memory.update(cx, |memory, _| *memory = stamp);
523 }
524 let page_entrance = if page_generation == 0 {
527 1.0
528 } else {
529 swap_progress(
530 ElementId::Name(SharedString::from(format!(
531 "{}-page-enter-{page_generation}",
532 self.id
533 ))),
534 window,
535 cx,
536 )
537 };
538 let page_travel = tokens.spacing.xxs * (1.0 - page_entrance) * crate::motion::travel(cx);
539 let page_offset = if entering_forward {
540 page_travel
541 } else {
542 -page_travel
543 };
544 let page_label = self.page_label();
545 let page_text: SharedString = format!("Insights · {page_label}").into();
546 let resolved_chart_summary = self.resolved_chart_summary();
547 let accessibility_description: SharedString = match &resolved_chart_summary {
548 Some(summary) => format!("{page_text}. {summary}").into(),
549 None => page_text.clone(),
550 };
551 let paging_buttons =
552 (self.total_pages > 1).then(|| (self.previous_button(cx), self.next_button(cx)));
553 let follow_up_button = self.follow_up_button(cx);
554 let chart_label = self.series_name.clone();
555 let card_id = self.id.clone();
556 let chart = if self.points.is_empty() {
557 None
558 } else {
559 resolved_chart_summary.map(|summary| {
560 chart_group(
561 &card_id,
562 chart_label,
563 summary,
564 self.current_page,
565 self.points,
566 window,
567 cx,
568 )
569 })
570 };
571
572 v_flex()
573 .id(self.id.clone())
574 .role(Role::Group)
575 .aria_label(self.title.clone())
576 .aria_description(accessibility_description)
577 .gap(tokens.spacing.md)
578 .p(tokens.spacing.lg)
579 .bg(cx.theme().background)
580 .border_1()
581 .border_color(cx.theme().border)
582 .rounded(tokens.radius.md)
583 .child(
584 v_flex()
585 .gap(tokens.spacing.md)
586 .relative()
587 .left(page_offset)
588 .opacity(page_entrance)
589 .child(
590 v_flex()
591 .gap(tokens.spacing.xs)
592 .child(crate::surface::hint(page_text, cx))
593 .child(
594 div()
595 .text_token(tokens.typography.lg)
596 .text_color(cx.theme().foreground)
597 .child(self.title),
598 ),
599 )
600 .when_some(self.body, |this, body| {
601 this.child(
602 TextView::markdown((ElementId::from(card_id.clone()), "body"), body)
603 .selectable(true),
604 )
605 })
606 .when(!self.metrics.is_empty(), |this| {
607 this.child(
608 metrics_group(&card_id, cx).children(
609 self.metrics
610 .into_iter()
611 .map(|metric| metric_item(&card_id, metric, cx)),
612 ),
613 )
614 })
615 .when_some(chart, |this, chart| this.child(chart)),
616 )
617 .when(self.total_pages > 1 || self.follow_up.is_some(), |this| {
618 this.child(
619 h_flex()
620 .flex_wrap()
621 .items_center()
622 .gap(tokens.spacing.sm)
623 .when_some(paging_buttons, |this, (previous, next)| {
624 this.child(
625 div().flex_1().child(
626 h_flex().gap(tokens.spacing.xs).child(previous).child(next),
627 ),
628 )
629 })
630 .when_some(follow_up_button, |this, button| this.child(button)),
631 )
632 })
633 .refine_style(&self.style)
634 }
635}
636
637#[cfg(test)]
638mod tests {
639 use super::{
640 InsightCard, InsightEvent, InsightMetric, InsightPoint, InsightTrend, chart_group,
641 metric_item, metrics_group,
642 };
643
644 mod paging_motion {
645 use super::super::{InsightCard, InsightMetric};
646 use gpui::{
647 Context, IntoElement, ParentElement as _, Render, Styled as _, TestAppContext,
648 VisualTestContext, Window, div, px,
649 };
650
651 struct PagedProbe {
652 page: usize,
653 }
654
655 impl Render for PagedProbe {
656 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
657 div().w(px(420.)).h(px(360.)).child(
658 InsightCard::new("probe-insight", "Weekly demand")
659 .page(self.page, 3)
660 .metrics([InsightMetric::new("mint", "Mint Chip", "$2,377.66")]),
661 )
662 }
663 }
664
665 fn draw(cx: &mut VisualTestContext) {
666 cx.update(|window, cx| window.draw(cx).clear(cx));
667 }
668
669 #[gpui::test]
670 fn a_mounted_page_is_presented_and_navigation_enters(cx: &mut TestAppContext) {
671 cx.update(crate::init);
672 let (probe, cx) = cx.add_window_view(|_, _| PagedProbe { page: 1 });
673 let cx: &mut VisualTestContext = cx;
674 draw(cx);
675 crate::motion::take_reveal_frame_requests();
676 draw(cx);
677 assert_eq!(
678 crate::motion::take_reveal_frame_requests(),
679 0,
680 "the first page is presented, not entered"
681 );
682
683 probe.update(cx, |probe, cx| {
684 probe.page = 2;
685 cx.notify();
686 });
687 draw(cx);
688 assert!(
689 crate::motion::take_reveal_frame_requests() > 0,
690 "navigating must enter the new page"
691 );
692
693 cx.executor()
694 .advance_clock(std::time::Duration::from_secs(2));
695 draw(cx);
696 crate::motion::take_reveal_frame_requests();
697 probe.update(cx, |probe, cx| {
698 probe.page = 1;
699 cx.notify();
700 });
701 draw(cx);
702 assert!(
703 crate::motion::take_reveal_frame_requests() > 0,
704 "returning to a page replays its entrance"
705 );
706 }
707 }
708 use gpui::{
709 Element as _, IntoElement as _, Render, RenderOnce as _, Role, TestAppContext, Window,
710 accesskit, canvas,
711 };
712 use std::sync::{Arc, Mutex};
713
714 #[derive(Clone, Copy)]
715 enum ChildProbeKind {
716 Metrics,
717 Metric,
718 Chart,
719 Previous,
720 Next,
721 FollowUp,
722 }
723
724 struct CapturedChild {
725 role: Option<Role>,
726 node: accesskit::Node,
727 }
728
729 struct ChildProbe {
730 kind: ChildProbeKind,
731 captured: Arc<Mutex<Option<CapturedChild>>>,
732 }
733
734 impl Render for ChildProbe {
735 fn render(
736 &mut self,
737 _: &mut Window,
738 _: &mut gpui::Context<Self>,
739 ) -> impl gpui::IntoElement {
740 let kind = self.kind;
741 let captured = self.captured.clone();
742 canvas(
743 move |_, window, cx| {
744 let card_id: gpui::SharedString = "insight".into();
745 let mut card = Some(
746 InsightCard::new(card_id.clone(), "Demand changed")
747 .page(1, 3)
748 .follow_up("Should I rebalance flavors?")
749 .on_event(|_, _, _| {}),
750 );
751 macro_rules! capture_element {
752 ($element:expr) => {{
753 let element = $element.into_element();
754 let role = element.a11y_role();
755 let mut node = accesskit::Node::new(role.unwrap_or(Role::Unknown));
756 element.write_a11y_info(&mut node);
757 CapturedChild { role, node }
758 }};
759 }
760 let child = match kind {
761 ChildProbeKind::Metrics => {
762 capture_element!(metrics_group(&card_id, cx))
763 }
764 ChildProbeKind::Metric => capture_element!(metric_item(
765 &card_id,
766 InsightMetric::new("mint", "Mint Chip", "$2,377.66")
767 .change("down 4.41%", InsightTrend::Down),
768 cx,
769 )),
770 ChildProbeKind::Chart => capture_element!(chart_group(
771 &card_id,
772 "Trend snapshot".into(),
773 "Trend snapshot: Week 1 18, Week 2 24.".into(),
774 1,
775 vec![
776 InsightPoint::new("Week 1", 18.0),
777 InsightPoint::new("Week 2", 24.0),
778 ],
779 window,
780 cx,
781 )),
782 ChildProbeKind::Previous => capture_element!(
783 card.take()
784 .expect("probe card should be available")
785 .previous_button(cx)
786 .render(window, cx)
787 ),
788 ChildProbeKind::Next => capture_element!(
789 card.take()
790 .expect("probe card should be available")
791 .next_button(cx)
792 .render(window, cx)
793 ),
794 ChildProbeKind::FollowUp => capture_element!(
795 card.take()
796 .expect("probe card should be available")
797 .follow_up_button(cx)
798 .expect("probe card should have a follow-up")
799 .render(window, cx)
800 ),
801 };
802 *captured.lock().expect("capture mutex should be available") = Some(child);
803 },
804 |_, _, _, _| {},
805 )
806 }
807 }
808
809 fn capture_child(kind: ChildProbeKind, cx: &mut TestAppContext) -> CapturedChild {
810 cx.update(crate::init);
811 let captured = Arc::new(Mutex::new(None));
812 let result = captured.clone();
813 let (_, cx) = cx.add_window_view(move |_, _| ChildProbe { kind, captured });
814 cx.update(|window, cx| window.draw(cx).clear(cx));
815 result
816 .lock()
817 .expect("capture mutex should be available")
818 .take()
819 .expect("semantic child should be captured")
820 }
821
822 #[test]
823 fn page_position_is_one_based_and_clamped() {
824 assert_eq!(
825 InsightCard::new("insight", "Demand changed")
826 .page(0, 0)
827 .page_label(),
828 "1 of 1"
829 );
830 assert_eq!(
831 InsightCard::new("insight", "Demand changed")
832 .page(7, 3)
833 .page_label(),
834 "3 of 3"
835 );
836 assert_eq!(
837 InsightCard::new("insight", "Demand changed")
838 .page(2, 3)
839 .page_label(),
840 "2 of 3"
841 );
842 }
843
844 #[test]
845 fn metric_accessibility_name_never_relies_on_color() {
846 let metric = InsightMetric::new("mint", "Mint Chip", "$2,377.66")
847 .change("down 4.41%", InsightTrend::Down);
848 assert_eq!(
849 metric.accessibility_name(),
850 "Mint Chip, $2,377.66, down 4.41%, decreasing"
851 );
852 }
853
854 #[test]
855 fn events_preserve_the_stable_insight_identity() {
856 let card = InsightCard::new("insight-17", "Demand changed").follow_up("Rebalance flavors");
857 assert_eq!(
858 card.previous_event(),
859 InsightEvent::Previous {
860 id: "insight-17".into()
861 }
862 );
863 assert_eq!(
864 card.next_event(),
865 InsightEvent::Next {
866 id: "insight-17".into()
867 }
868 );
869 assert_eq!(
870 card.follow_up_control().map(|control| control.event),
871 Some(InsightEvent::FollowUp {
872 id: "insight-17".into(),
873 prompt: "Rebalance flavors".into(),
874 })
875 );
876 }
877
878 #[test]
879 fn chart_summary_falls_back_to_series_and_labeled_values() {
880 let card = InsightCard::new("insight", "Demand changed").series(
881 "Trend snapshot",
882 [
883 InsightPoint::new("Week 1", 18.0),
884 InsightPoint::new("Week 2", 24.0),
885 ],
886 );
887
888 assert_eq!(
889 card.resolved_chart_summary(),
890 Some("Trend snapshot: Week 1 18, Week 2 24.".into())
891 );
892 assert_eq!(
893 card.chart_summary("Demand grew overall")
894 .resolved_chart_summary(),
895 Some("Demand grew overall".into())
896 );
897 }
898
899 #[gpui::test]
900 fn metric_and_chart_children_expose_direct_semantics(cx: &mut TestAppContext) {
901 let metrics = capture_child(ChildProbeKind::Metrics, cx);
902 assert_eq!(metrics.role, Some(Role::List));
903 assert_eq!(metrics.node.label(), Some("Insight metrics"));
904
905 let metric = capture_child(ChildProbeKind::Metric, cx);
906 assert_eq!(metric.role, Some(Role::ListItem));
907 assert_eq!(
908 metric.node.label(),
909 Some("Mint Chip, $2,377.66, down 4.41%, decreasing")
910 );
911
912 let chart = capture_child(ChildProbeKind::Chart, cx);
913 assert_eq!(chart.role, Some(Role::Group));
914 assert_eq!(chart.node.label(), Some("Trend snapshot"));
915 assert_eq!(
916 chart.node.description(),
917 Some("Trend snapshot: Week 1 18, Week 2 24.")
918 );
919 }
920
921 #[gpui::test]
922 fn insight_controls_expose_click_actions_and_disabled_boundaries(cx: &mut TestAppContext) {
923 let previous = capture_child(ChildProbeKind::Previous, cx);
924 let next = capture_child(ChildProbeKind::Next, cx);
925 let follow_up = capture_child(ChildProbeKind::FollowUp, cx);
926 assert_eq!(previous.role, Some(Role::Button));
927 assert_eq!(previous.node.label(), Some("Previous"));
928 assert!(!previous.node.supports_action(accesskit::Action::Click));
929
930 assert_eq!(next.role, Some(Role::Button));
931 assert_eq!(next.node.label(), Some("Next"));
932 assert!(next.node.supports_action(accesskit::Action::Click));
933
934 assert_eq!(follow_up.role, Some(Role::Button));
935 assert_eq!(follow_up.node.label(), Some("Should I rebalance flavors?"));
936 assert!(follow_up.node.supports_action(accesskit::Action::Click));
937 }
938}