1use ratatui::style::{Color, Modifier, Style};
45use ratatui::text::{Line, Span};
46
47use crate::analytics::chart::{ChartExport, ChartProjection};
48use crate::analytics::events::{AnalyticsScope, Period};
49use crate::analytics::series::{Coverage, CoverageSummary, DailyPoint, Mode};
50use crate::keybindings::TuiAction;
51use crate::ui::detail::dash_number;
52
53const BAR_CELLS: usize = 20;
59
60const BAR: &str = "█";
62
63const NO_BAR: &str = "-";
65
66const ZERO_BAR: &str = "·";
72
73const DAY_COLUMNS: [&str; 7] = [
75 "DATE",
76 "COVERAGE",
77 "REMAINING",
78 "SCOPE",
79 "DONE",
80 "+ADD",
81 "-DONE",
82];
83
84const DAY_WIDTHS: [usize; 7] = [10, 16, 9, 9, 9, 9, 9];
90
91const fn day_text_column(index: usize) -> bool {
93 index < 2
94}
95
96pub const HELP_ACTIONS: &[TuiAction] = &[
103 TuiAction::MoveUp,
104 TuiAction::MoveDown,
105 TuiAction::Next,
106 TuiAction::Previous,
107 TuiAction::Help,
108 TuiAction::Close,
109];
110
111pub const HELP_RAW: &[(&str, &str)] = &[
119 ("[ / ]", "previous / next scope"),
120 ("p", "period source (planning / observed)"),
121 ("t", "mode (count / estimate)"),
122 ("u", "next estimate unit"),
123 ("v", "ask for the forecast"),
124 ("d / D", "forecast origin (earlier / later)"),
125];
126
127pub const FOOTER_ACTIONS: &[TuiAction] = HELP_ACTIONS;
135
136#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
144pub enum BurndownPeriod {
145 #[default]
147 Planning,
148 Observed,
150}
151
152impl BurndownPeriod {
153 pub const ALL: [Self; 2] = [Self::Planning, Self::Observed];
155
156 pub const fn label(self) -> &'static str {
158 match self {
159 Self::Planning => "planning",
160 Self::Observed => "observed",
161 }
162 }
163
164 pub const fn description(self) -> &'static str {
166 match self {
167 Self::Planning => "the scope's own planning period",
168 Self::Observed => "the days the record's own events span",
169 }
170 }
171
172 pub fn next(self) -> Self {
174 match self {
175 Self::Planning => Self::Observed,
176 Self::Observed => Self::Planning,
177 }
178 }
179}
180
181#[derive(Debug, Clone, PartialEq)]
193pub struct BurndownState {
194 pub choices: Vec<AnalyticsScope>,
196 pub choice: usize,
198 pub period: BurndownPeriod,
200 pub mode: Mode,
202 pub units: Vec<String>,
204 pub unit: usize,
207 pub forecast: bool,
209 pub origin_back: usize,
211 pub cursor: usize,
213 pub document: Option<ChartExport>,
215 pub problem: Option<String>,
218}
219
220impl BurndownState {
221 pub fn new(choices: Vec<AnalyticsScope>, units: Vec<String>) -> Self {
224 Self {
225 choices,
226 choice: 0,
227 period: BurndownPeriod::Planning,
228 mode: Mode::Count,
229 units,
230 unit: 0,
231 forecast: false,
232 origin_back: 0,
233 cursor: 0,
234 document: None,
235 problem: None,
236 }
237 }
238
239 pub fn selected(&self) -> Option<&AnalyticsScope> {
241 self.choices.get(self.choice)
242 }
243
244 pub fn scope(&self) -> Option<AnalyticsScope> {
250 let mut scope = self.selected()?.clone();
251 if self.period == BurndownPeriod::Observed {
252 scope.period = Period::unbounded();
253 }
254 Some(scope)
255 }
256
257 pub fn unit(&self) -> Option<&str> {
260 if self.mode != Mode::Estimate {
261 return None;
262 }
263 self.units.get(self.unit).map(String::as_str)
264 }
265
266 pub fn days(&self) -> usize {
268 self.document
269 .as_ref()
270 .map_or(0, |document| document.series.points.len())
271 }
272
273 pub fn origin(&self) -> Option<&DailyPoint> {
275 let points = &self.document.as_ref()?.series.points;
276 points.get(points.len().checked_sub(self.origin_back + 1)?)
277 }
278
279 pub fn projection(&self) -> Option<(String, String)> {
286 if !self.forecast {
287 return None;
288 }
289 let origin = self.origin()?;
290 let end = self.scope()?.period_end().map(str::to_string)?;
291 Some((origin.date.clone(), end))
292 }
293
294 pub fn shift_scope(&mut self, delta: isize) {
297 if self.choices.is_empty() {
298 self.choice = 0;
299 return;
300 }
301 let len = self.choices.len() as isize;
302 self.choice = ((self.choice as isize + delta).rem_euclid(len)) as usize;
303 self.origin_back = 0;
307 self.cursor = 0;
308 }
309
310 pub fn cycle_period(&mut self) {
312 self.period = self.period.next();
313 }
314
315 pub fn cycle_mode(&mut self) {
317 self.mode = match self.mode {
318 Mode::Count => Mode::Estimate,
319 Mode::Estimate => Mode::Count,
320 };
321 }
322
323 pub fn cycle_unit(&mut self) {
325 if !self.units.is_empty() {
326 self.unit = (self.unit + 1) % self.units.len();
327 }
328 }
329
330 pub fn toggle_forecast(&mut self) {
332 self.forecast = !self.forecast;
333 if !self.forecast {
334 self.origin_back = 0;
335 }
336 }
337
338 pub fn origin_earlier(&mut self) {
340 let last = self.days().saturating_sub(1);
341 self.origin_back = (self.origin_back + 1).min(last);
342 }
343
344 pub fn origin_later(&mut self) {
346 self.origin_back = self.origin_back.saturating_sub(1);
347 }
348
349 pub fn move_cursor(&mut self, delta: isize) {
351 let last = self.days().saturating_sub(1);
352 let next = if delta.is_negative() {
353 self.cursor.saturating_sub(delta.unsigned_abs())
354 } else {
355 self.cursor.saturating_add(delta as usize)
356 };
357 self.cursor = next.min(last);
358 }
359
360 pub fn clamp(&mut self) {
363 if self.choice >= self.choices.len() {
364 self.choice = 0;
365 }
366 if self.unit >= self.units.len() {
367 self.unit = 0;
368 }
369 let last = self.days().saturating_sub(1);
370 self.origin_back = self.origin_back.min(last);
371 self.cursor = self.cursor.min(last);
372 }
373}
374
375#[derive(Debug, Clone, PartialEq)]
380pub struct BurndownFrame {
381 pub lines: Vec<Line<'static>>,
383 pub cursor_line: usize,
385}
386
387pub fn frame(state: &BurndownState) -> BurndownFrame {
394 let mut lines: Vec<Line<'static>> = Vec::new();
395 let mut cursor_line = 0usize;
396
397 if state.choices.is_empty() {
398 lines.extend(no_scope_lines());
399 return BurndownFrame { lines, cursor_line };
400 }
401
402 lines.extend(scope_lines(state, state.document.as_ref()));
403 lines.push(Line::from(""));
404
405 let Some(document) = state.document.as_ref() else {
406 lines.extend(problem_lines(state));
407 return BurndownFrame { lines, cursor_line };
408 };
409
410 lines.extend(fold_lines(document));
411 lines.push(Line::from(""));
412
413 if document.is_empty() {
414 lines.extend(no_day_lines(document));
415 } else {
416 lines.push(head_line());
417 for (index, point) in document.series.points.iter().enumerate() {
418 let selected = index == state.cursor;
419 if selected {
420 cursor_line = lines.len();
421 }
422 lines.push(day_line(point, selected));
423 }
424 }
425
426 lines.push(Line::from(""));
427 lines.extend(projection_lines(state, document));
428 lines.push(Line::from(""));
429 lines.extend(diagnostic_lines(document));
430 BurndownFrame { lines, cursor_line }
431}
432
433fn dim() -> Style {
434 Style::default().fg(Color::DarkGray)
435}
436
437fn warning() -> Style {
438 Style::default()
439 .fg(Color::Yellow)
440 .add_modifier(Modifier::BOLD)
441}
442
443fn field(label: &str, value: &str) -> Line<'static> {
445 Line::from(vec![
446 Span::styled(format!("{label}: "), dim()),
447 Span::raw(value.to_string()),
448 ])
449}
450
451fn scope_lines(state: &BurndownState, document: Option<&ChartExport>) -> Vec<Line<'static>> {
459 let mut lines = Vec::new();
460 let Some(scope) = state.scope() else {
461 return lines;
462 };
463 let period = format!(
464 "{}..{}",
465 bound_text(scope.period_start()),
466 bound_text(scope.period_end())
467 );
468 lines.push(field("scope", scope.kind.as_str()));
469 lines.push(field("id", &scope.id));
470 lines.push(field("period", &period));
471 lines.push(field(
472 "period source",
473 &format!("{} ({})", state.period.label(), state.period.description()),
474 ));
475 lines.push(field(
476 "snapshot",
477 scope.snapshot_id.as_deref().unwrap_or("-"),
478 ));
479 lines.push(field("mode", state.mode.as_str()));
480 let unit = document
483 .and_then(|document| document.unit.as_deref())
484 .or_else(|| state.unit())
485 .unwrap_or("-");
486 lines.push(field("unit", unit));
487 lines.push(field(
488 "selected",
489 &format!("{} of {}", state.choice + 1, state.choices.len()),
490 ));
491 lines
492}
493
494fn bound_text(bound: Option<&str>) -> &str {
497 bound.unwrap_or("open")
498}
499
500fn fold_lines(document: &ChartExport) -> Vec<Line<'static>> {
504 let coverage: CoverageSummary = document.coverage;
505 let covered = document
506 .series
507 .points
508 .first()
509 .zip(document.series.points.last())
510 .map_or_else(
511 || "-".to_string(),
512 |(first, last)| format!("{}..{}", first.date, last.date),
513 );
514 vec![
515 field("formula", &document.formula_version),
516 Line::from(vec![
517 Span::styled("days: ", dim()),
518 Span::raw(document.series.points.len().to_string()),
519 Span::styled(" covered: ", dim()),
520 Span::raw(covered),
521 ]),
522 Line::from(vec![
523 Span::styled("coverage: ", dim()),
524 Span::raw(format!(
525 "known={} unknown={} coverage_limited={}",
526 coverage.known, coverage.unknown, coverage.coverage_limited
527 )),
528 ]),
529 ]
530}
531
532fn finding_count(document: &ChartExport) -> usize {
535 document.series.diagnostics.len()
536 + document
537 .projection
538 .as_ref()
539 .map_or(0, |projection| projection.diagnostics.len())
540 + document.diagnostics.len()
541}
542
543fn head_line() -> Line<'static> {
545 let mut text = String::from(" ");
546 for (index, column) in DAY_COLUMNS.iter().enumerate() {
547 if index > 0 {
548 text.push_str(" ");
549 }
550 let width = DAY_WIDTHS[index];
551 if day_text_column(index) {
552 text.push_str(&format!("{column:<width$}"));
553 } else {
554 text.push_str(&format!("{column:>width$}"));
555 }
556 }
557 text.push_str(" CHART");
558 Line::from(Span::styled(text, dim()))
559}
560
561fn day_line(point: &DailyPoint, selected: bool) -> Line<'static> {
568 let cells = [
569 point.date.clone(),
570 point.coverage.as_str().to_string(),
571 dash_number(point.remaining),
572 dash_number(point.scope_total),
573 dash_number(point.completed),
574 dash_number(point.scope_added),
575 dash_number(point.completions),
576 ];
577 let mut text = if selected {
578 String::from("> ")
579 } else {
580 String::from(" ")
581 };
582 for (index, cell) in cells.iter().enumerate() {
583 if index > 0 {
584 text.push_str(" ");
585 }
586 let width = DAY_WIDTHS[index];
587 if day_text_column(index) {
588 text.push_str(&format!("{cell:<width$}"));
589 } else {
590 text.push_str(&format!("{cell:>width$}"));
591 }
592 }
593 text.push_str(" ");
594 text.push_str(&bar_text(point.remaining));
595 Line::from(Span::styled(text, day_style(point.coverage, selected)))
596}
597
598fn day_style(coverage: Coverage, selected: bool) -> Style {
601 let base = match coverage {
602 Coverage::Known => Style::default(),
603 Coverage::CoverageLimited => Style::default().fg(Color::Yellow),
604 Coverage::Unknown => dim(),
605 };
606 if selected {
607 base.add_modifier(Modifier::BOLD)
608 } else {
609 base
610 }
611}
612
613fn bar_text(remaining: Option<i64>) -> String {
616 let Some(remaining) = remaining else {
617 return NO_BAR.to_string();
618 };
619 let cells = usize::try_from(remaining).unwrap_or(0);
620 if cells == 0 {
621 return ZERO_BAR.to_string();
622 }
623 let mut bar = BAR.repeat(cells.min(BAR_CELLS));
624 if cells > BAR_CELLS {
625 bar.push('…');
626 }
627 bar
628}
629
630fn projection_lines(state: &BurndownState, document: &ChartExport) -> Vec<Line<'static>> {
633 let mut lines = Vec::new();
634 match document.projection.as_ref() {
635 Some(projection) => lines.extend(forecast_lines(projection)),
636 None if !state.forecast => {
637 lines.push(field("projection", "off (press `v` to ask for it)"));
638 }
639 None => {
640 lines.push(field("projection", "unavailable"));
641 match state.scope().filter(|scope| scope.period_end().is_none()) {
642 Some(scope) => {
643 lines.push(Line::from(format!(
647 " the {} scope `{}` declares no planning end date, so a forecast has \
648 no boundary to be drawn against",
649 scope.kind, scope.id
650 )));
651 lines.push(Line::from(
652 " remedy: give the scope an end — the cycle's `due_date` or the \
653 milestone's target date",
654 ));
655 }
656 None => {
657 lines.push(Line::from(
658 " the record holds no covered day to stand the forecast on",
659 ));
660 lines.push(Line::from(
661 " remedy: widen the series' period (press `p`) so it covers a \
662 recorded day",
663 ));
664 }
665 }
666 }
667 }
668 lines
669}
670
671fn forecast_lines(projection: &ChartProjection) -> Vec<Line<'static>> {
677 vec![
678 field("projection", projection.status.as_str()),
679 field(" as_of", &projection.as_of),
680 field(" end_date", &projection.end_date),
681 field(" remaining", &dash_number(projection.remaining)),
682 field(" observed_rate", &dash_number(projection.observed_rate)),
683 field(
684 " projected_completion",
685 projection.projected_completion.as_deref().unwrap_or("-"),
686 ),
687 field(" meets_end_date", &dash_number(projection.meets_end_date)),
688 field(" formula", &projection.formula_version),
689 ]
690}
691
692fn diagnostic_lines(document: &ChartExport) -> Vec<Line<'static>> {
699 let mut lines = vec![field("diagnostics", &finding_count(document).to_string())];
700 for diagnostic in &document.series.diagnostics {
701 lines.extend(finding_lines(diagnostic));
702 }
703 if let Some(projection) = document.projection.as_ref() {
704 for diagnostic in &projection.diagnostics {
705 lines.extend(finding_lines(diagnostic));
706 }
707 }
708 for diagnostic in &document.diagnostics {
709 lines.push(Line::from(format!(
710 " {} {} {}",
711 diagnostic.kind,
712 diagnostic
713 .scope
714 .as_deref()
715 .or(diagnostic.target.as_deref())
716 .unwrap_or("-"),
717 diagnostic.reason
718 )));
719 if let Some(remedy) = diagnostic.remedy.as_deref() {
720 lines.push(Line::from(Span::styled(
721 format!(" remedy: {remedy}"),
722 dim(),
723 )));
724 }
725 }
726 if document.series.diagnostics.is_empty()
727 && document.diagnostics.is_empty()
728 && document
729 .projection
730 .as_ref()
731 .is_none_or(|projection| projection.diagnostics.is_empty())
732 {
733 lines.push(Line::from(Span::styled(
734 " none: the fold answered every day it covers",
735 dim(),
736 )));
737 }
738 lines
739}
740
741fn finding_lines(
745 diagnostic: &crate::analytics::diagnostics::AnalyticsDiagnostic,
746) -> Vec<Line<'static>> {
747 let mut place = diagnostic.scope.clone();
748 if let Some(date) = diagnostic.date.as_deref() {
749 place.push_str(&format!(" {date}"));
750 }
751 if let Some(event_id) = diagnostic.event_id.as_deref() {
752 place.push_str(&format!(" {event_id}"));
753 }
754 let mut lines = vec![Line::from(format!(
755 " {} {place} {}",
756 diagnostic.kind.as_str(),
757 diagnostic.message
758 ))];
759 if let Some(remedy) = diagnostic.remedy.as_deref() {
760 lines.push(Line::from(Span::styled(
761 format!(" remedy: {remedy}"),
762 dim(),
763 )));
764 }
765 lines
766}
767
768fn no_scope_lines() -> Vec<Line<'static>> {
770 vec![
771 Line::from(Span::styled(
772 "No cycle or milestone is recorded.",
773 warning(),
774 )),
775 Line::from("The fold reports one scope at a time, and the record names none to report."),
776 Line::from("Define one with `tix cycle add <name>` or `tix milestone create <name>`,"),
777 Line::from("then open this tab again."),
778 ]
779}
780
781fn no_day_lines(document: &ChartExport) -> Vec<Line<'static>> {
783 let findings = document.series.diagnostics.len();
784 let mut lines = vec![
785 Line::from(Span::styled(
786 "The fold answered no day for this scope.",
787 warning(),
788 )),
789 Line::from(format!(
790 "It reported {findings} finding(s) — below — rather than an empty series."
791 )),
792 ];
793 if document.series.diagnostics.is_empty() {
794 lines.push(Line::from(
795 "The period could not be enumerated: press `p` for the period the record's own",
796 ));
797 lines.push(Line::from(
798 "events span, or choose a scope whose record declares its dates.",
799 ));
800 }
801 lines
802}
803
804fn problem_lines(state: &BurndownState) -> Vec<Line<'static>> {
807 let mut lines = vec![Line::from(Span::styled(
808 "The fold produced no document.",
809 warning(),
810 ))];
811 match state.problem.as_deref() {
812 Some(problem) => lines.push(Line::from(problem.to_string())),
813 None => lines.push(Line::from(
814 "The scope, period, mode or unit selects no series this build can fold.",
815 )),
816 }
817 lines.push(Line::from(""));
818 lines.push(Line::from(
819 "press `t` for count mode, `p` for the period the record's own events span,",
820 ));
821 lines.push(Line::from("or `[`/`]` for another scope"));
822 lines
823}
824
825#[cfg(test)]
826mod tests {
827 #[allow(clippy::wildcard_imports)]
828 use super::*;
829
830 fn render(lines: &[Line<'static>]) -> String {
832 lines
833 .iter()
834 .map(|line| {
835 line.spans
836 .iter()
837 .map(|span| span.content.as_ref())
838 .collect::<String>()
839 })
840 .collect::<Vec<_>>()
841 .join("\n")
842 }
843
844 fn unknown_day() -> DailyPoint {
845 DailyPoint {
846 date: "2026-01-05".to_string(),
847 scope_total: None,
848 remaining: None,
849 completed: None,
850 scope_added: None,
851 completions: None,
852 coverage: Coverage::Unknown,
853 event_ids: Vec::new(),
854 }
855 }
856
857 fn quiet_day() -> DailyPoint {
858 DailyPoint {
859 date: "2026-01-06".to_string(),
860 scope_total: Some(0),
861 remaining: Some(0),
862 completed: Some(0),
863 scope_added: Some(0),
864 completions: Some(0),
865 coverage: Coverage::Known,
866 event_ids: Vec::new(),
867 }
868 }
869
870 fn limited_day() -> DailyPoint {
871 DailyPoint {
872 date: "2026-01-07".to_string(),
873 scope_total: Some(2),
874 remaining: Some(1),
875 completed: None,
876 scope_added: None,
877 completions: None,
878 coverage: Coverage::CoverageLimited,
879 event_ids: Vec::new(),
880 }
881 }
882
883 fn text(line: &Line<'_>) -> String {
884 line.spans
885 .iter()
886 .map(|span| span.content.as_ref())
887 .collect::<String>()
888 }
889
890 #[test]
893 fn an_unanswerable_day_is_a_dash_and_a_read_quiet_day_is_a_zero() {
894 let unobserved = text(&day_line(&unknown_day(), false));
895 assert!(unobserved.contains("unknown"), "{unobserved}");
896 assert!(
897 unobserved.contains("2026-01-05 unknown"),
898 "the coverage marker travels beside the values it qualifies: {unobserved}"
899 );
900 let cells: Vec<&str> = unobserved.split_whitespace().collect();
901 assert_eq!(cells[0], "2026-01-05", "{cells:?}");
902 assert_eq!(cells[1], "unknown", "{cells:?}");
903 assert_eq!(
904 cells[2..7].to_vec(),
905 vec!["-"; 5],
906 "no numeric cell of an unanswerable day reads as a recorded zero: {cells:?}"
907 );
908 assert_eq!(
909 cells[7], "-",
910 "and the chart column draws the absent marker, not a zero-length bar: {cells:?}"
911 );
912
913 let observed = text(&day_line(&quiet_day(), false));
914 assert!(observed.contains("known"), "{observed}");
915 assert!(
916 observed.contains(" 0"),
917 "an observed quiet day's zero is a value: {observed}"
918 );
919 assert!(
920 observed.trim_end().ends_with(ZERO_BAR),
921 "and its chart column says a known zero, not an unknown one: {observed}"
922 );
923 }
924
925 #[test]
930 fn an_unbounded_scope_under_a_forecast_names_the_missing_planning_end() {
931 let unbounded = AnalyticsScope {
932 kind: crate::analytics::events::ScopeKind::Cycle,
933 id: "Open Cycle".to_string(),
934 period: crate::analytics::events::Period {
935 start: Some("2026-01-05".to_string()),
936 end: None,
937 },
938 snapshot_id: None,
939 };
940 let series = crate::analytics::series::BurndownSeries {
941 scope: unbounded.clone(),
942 mode: Mode::Count,
943 unit: None,
944 points: vec![quiet_day()],
945 diagnostics: Vec::new(),
946 formula_version: "burndown.count.v1".to_string(),
947 };
948 let document = ChartExport::build(&series, None, &[]);
949 let mut state = BurndownState::new(vec![unbounded.clone()], Vec::new());
950 state.forecast = true;
951
952 let rendered = render(&projection_lines(&state, &document));
953 assert!(
954 rendered.contains("declares no planning end date"),
955 "the missing planning end is named: {rendered}"
956 );
957 assert!(
958 !rendered.contains("no covered day"),
959 "the scope has a covered day, so that sentence is false here: {rendered}"
960 );
961 assert!(
962 rendered.contains("due_date"),
963 "and the remedy names the record's own field: {rendered}"
964 );
965
966 let bounded = AnalyticsScope {
968 period: crate::analytics::events::Period {
969 start: Some("2026-01-05".to_string()),
970 end: Some("2026-01-09".to_string()),
971 },
972 ..unbounded.clone()
973 };
974 let mut state = BurndownState::new(vec![bounded], Vec::new());
975 state.forecast = true;
976 let empty = ChartExport::build(
977 &crate::analytics::series::BurndownSeries {
978 points: Vec::new(),
979 ..series
980 },
981 None,
982 &[],
983 );
984 let rendered = render(&projection_lines(&state, &empty));
985 assert!(rendered.contains("no covered day"), "{rendered}");
986 assert!(!rendered.contains("planning end date"), "{rendered}");
987 }
988
989 #[test]
992 fn a_coverage_limited_day_draws_the_level_it_holds() {
993 let row = text(&day_line(&limited_day(), true));
994 assert!(row.starts_with("> "), "the cursor marks the row: {row}");
995 assert!(row.contains("coverage_limited"), "{row}");
996 assert!(row.contains('█'), "{row}");
997 }
998
999 #[test]
1002 fn the_chart_bar_draws_the_value_it_stands_for() {
1003 assert_eq!(bar_text(None), "-", "an unanswerable value draws no bar");
1004 assert_eq!(
1005 bar_text(Some(0)),
1006 "·",
1007 "a known zero is drawn as a zero, not as the absent marker"
1008 );
1009 assert_eq!(bar_text(Some(3)), "███");
1010 assert_eq!(bar_text(Some(20)), "█".repeat(20));
1011 assert_eq!(bar_text(Some(21)), format!("{}…", "█".repeat(20)));
1012 }
1013
1014 #[test]
1016 fn the_burndown_rows_name_only_this_tabs_keys() {
1017 let bindings =
1018 crate::keybindings::KeyBindings::resolve(&crate::keybindings::KeyBindings::default())
1019 .expect("the built-in bindings resolve");
1020 let context = crate::keybindings::InputContext::Stats;
1021 for action in HELP_ACTIONS {
1022 assert!(
1023 bindings.bound_actions(context).contains(action),
1024 "`{}` is not bound in the Stats context",
1025 action.as_str()
1026 );
1027 }
1028 for absent in [
1029 TuiAction::TimeAdd,
1030 TuiAction::TimeEdit,
1031 TuiAction::TimeDelete,
1032 TuiAction::TimeFilter,
1033 TuiAction::TimeBreakdown,
1034 TuiAction::TimeHistory,
1035 TuiAction::Submit,
1037 ] {
1038 assert!(
1039 !HELP_ACTIONS.contains(&absent),
1040 "`{}` acts on nothing on the Burndown tab",
1041 absent.as_str()
1042 );
1043 assert!(
1044 !FOOTER_ACTIONS.contains(&absent),
1045 "`{}` acts on nothing on the Burndown tab",
1046 absent.as_str()
1047 );
1048 }
1049 let advertised: Vec<&str> = HELP_RAW.iter().map(|(key, _)| *key).collect();
1050 for key in ["[ / ]", "p", "t", "u", "v", "d / D"] {
1051 assert!(
1052 advertised.contains(&key),
1053 "the tab names `{key}`: {advertised:?}"
1054 );
1055 }
1056 }
1057
1058 #[test]
1060 fn a_tab_with_no_scope_says_so() {
1061 let state = BurndownState::new(Vec::new(), Vec::new());
1062 let frame = frame(&state);
1063 let text: Vec<String> = frame.lines.iter().map(text).collect();
1064 assert!(
1065 text.iter()
1066 .any(|line| line.contains("No cycle or milestone is recorded")),
1067 "{text:?}"
1068 );
1069 assert_eq!(frame.cursor_line, 0);
1070 }
1071
1072 #[test]
1075 fn the_controls_move_between_the_records_own_choices() {
1076 let mut state = BurndownState::new(
1077 vec![
1078 AnalyticsScope::cycle("Sprint 7", Period::unbounded()).unwrap(),
1079 AnalyticsScope::milestone("v1", Period::unbounded()).unwrap(),
1080 ],
1081 vec!["points".to_string(), "hours".to_string()],
1082 );
1083 assert_eq!(state.mode, Mode::Count);
1084 assert_eq!(state.unit(), None, "a count is unitless");
1085 state.cycle_mode();
1086 assert_eq!(state.mode, Mode::Estimate);
1087 assert_eq!(state.unit(), Some("points"));
1088 state.cycle_unit();
1089 assert_eq!(state.unit(), Some("hours"));
1090 state.cycle_unit();
1091 assert_eq!(state.unit(), Some("points"), "the unit list wraps");
1092 state.cycle_period();
1093 assert_eq!(state.period, BurndownPeriod::Observed);
1094 assert_eq!(
1095 state.scope().unwrap().period,
1096 Period::unbounded(),
1097 "the observed source releases both bounds"
1098 );
1099 state.shift_scope(1);
1100 assert_eq!(state.selected().unwrap().id, "v1");
1101 state.shift_scope(1);
1102 assert_eq!(state.selected().unwrap().id, "Sprint 7", "the ring wraps");
1103 state.shift_scope(-1);
1104 assert_eq!(state.selected().unwrap().id, "v1");
1105 }
1106}