Skip to main content

datui_lib/
analysis_modal.rs

1use crate::data_quality::{
2    DataQualityPlan, DataQualityResults, IntervalClock, IntervalFact, QUALITY_WINDOW_WIDTHS,
3    QualityComparison, QualityCompute, QualityGrain, QualityMetric, QualityPage, TIME_FORMATS,
4    TemporalRole, TemporalRoleAssignment, TimeInterpretation, TimeKind,
5};
6use crate::quality_report::{EvidenceRows, Finding, FindingsView, QualityReport};
7use crate::statistics::{AnalysisResults, DistributionType};
8use ratatui::widgets::TableState;
9
10/// The rows of Data Quality Setup, top to bottom: the rows read, what the columns
11/// mean, and how the study splits and compares them.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum SetupRow {
14    Sample,
15    TextAsTime,
16    TimeRoles,
17    Intervals,
18    /// What columns must hold, declared: the key and each column's rules.
19    Intent,
20    Grain,
21    Expected,
22    Compare,
23    Values,
24    Latency,
25    WindowBy,
26}
27
28impl SetupRow {
29    pub const ALL: [Self; 11] = [
30        Self::Sample,
31        Self::TextAsTime,
32        Self::TimeRoles,
33        Self::Intervals,
34        Self::Intent,
35        Self::Grain,
36        Self::Expected,
37        Self::Compare,
38        Self::Values,
39        Self::Latency,
40        Self::WindowBy,
41    ];
42
43    pub fn label(self) -> &'static str {
44        match self {
45            Self::Sample => "Sample",
46            Self::TextAsTime => "Text as time",
47            Self::TimeRoles => "Time roles",
48            Self::Intervals => "Intervals",
49            Self::Intent => "Column intent",
50            Self::Grain => "Grain",
51            Self::Expected => "Expected",
52            Self::Compare => "Compare",
53            Self::Values => "Values",
54            Self::Latency => "Latency over",
55            Self::WindowBy => "Window by",
56        }
57    }
58
59    /// The row at `index`, the last one past the end.
60    pub fn at(index: usize) -> Self {
61        Self::ALL[index.min(Self::ALL.len() - 1)]
62    }
63
64    pub fn index(self) -> usize {
65        Self::ALL.iter().position(|row| *row == self).unwrap_or(0)
66    }
67}
68
69/// What a Setup row's picker sets.
70#[derive(Debug, Clone, PartialEq)]
71pub enum PlanChoice {
72    Grain(QualityGrain),
73    Values(QualityCompute),
74    Compare(QualityComparison),
75    Latency(Option<i64>),
76    /// Which time puts an interval in a window.
77    Clock(IntervalClock),
78    /// A text column to read as time; choosing it asks for the format next.
79    TextColumn(String),
80    /// How a text column is read as time, or `None` to read it as text again.
81    Format(String, Option<(TimeKind, &'static str)>),
82    /// Only the findings that name this column, or all of them.
83    FindingColumn(Option<String>),
84    /// Only the findings this check made, or all of them.
85    FindingCheck(Option<&'static str>),
86}
87
88impl PlanChoice {
89    fn is_current(&self, plan: &DataQualityPlan) -> bool {
90        match self {
91            Self::Grain(grain) => &plan.grain == grain,
92            Self::Values(QualityCompute::Metadata) => plan.compute == QualityCompute::Metadata,
93            Self::Values(_) => plan.compute != QualityCompute::Metadata,
94            Self::Compare(comparison) => &plan.comparison == comparison,
95            Self::Latency(seconds) => &plan.latency_threshold_seconds == seconds,
96            Self::Clock(clock) => plan.interval_clock == *clock,
97            Self::TextColumn(column) => plan.time_format(column).is_some(),
98            Self::Format(column, format) => {
99                plan.time_format(column)
100                    .map(|current| (current.kind, current.format.as_str()))
101                    == format.map(|(kind, format)| (kind, format))
102            }
103            // The findings list is not the plan: its picker selects its own current.
104            Self::FindingColumn(_) | Self::FindingCheck(_) => false,
105        }
106    }
107}
108
109/// A Setup row's choices, or the findings list's narrowing, open as a list.
110#[derive(Debug, Clone)]
111pub struct PlanPicker {
112    /// The Setup row the choices set; `None` for the findings list's.
113    pub row: Option<SetupRow>,
114    pub title: String,
115    pub choices: Vec<PlanChoice>,
116    pub state: crate::widgets::ui::PickerState,
117}
118
119/// What the data offers Setup's choices: partition columns, the columns a time
120/// window can split by (and whether they hold times of day), whether there are
121/// files to split by, and the text columns that could be read as time, each with
122/// a few of its values from the rows on screen.
123#[derive(Debug, Clone, Default)]
124pub struct PlanContext {
125    pub partitions: Vec<String>,
126    pub time_columns: Vec<(String, bool)>,
127    pub files: bool,
128    pub text_columns: Vec<(String, Vec<String>)>,
129}
130
131/// Rows a finding names that the run did not keep, waiting for Enter to read them.
132/// Nothing reads until then, and Esc drops it.
133#[derive(Debug, Clone)]
134pub struct EvidenceRead {
135    pub rows: EvidenceRows,
136    /// The table's label once the rows are shown.
137    pub label: String,
138    /// Draw this sample again from its seed, rather than read the scope.
139    pub sample: Option<crate::sampling::Sample>,
140    /// The scope the rows are read from.
141    pub scope: crate::data_quality::QualityScope,
142    /// What the read is, as the dialog says it: label, value.
143    pub summary: Vec<(&'static str, String)>,
144}
145
146/// A latency threshold as Setup offers it.
147pub fn threshold_label(seconds: Option<i64>) -> &'static str {
148    match seconds {
149        None => "none",
150        Some(3_600) => "1 hour",
151        Some(86_400) => "1 day",
152        Some(604_800) => "1 week",
153        Some(_) => "custom",
154    }
155}
156
157/// Which windows the Expected editor's first row says rows are expected in.
158#[derive(Debug, Clone, Copy, PartialEq, Eq)]
159pub enum ExpectedCadence {
160    /// None stated: no window is a gap.
161    None,
162    /// Every window of the grain.
163    Every,
164    /// Monday to Friday's hours or days.
165    Weekdays,
166}
167
168/// The rows of the Expected editor.
169pub const EXPECTED_ROWS: [&str; 3] = ["Windows", "From", "Before"];
170
171/// The Expected editor while it is open: the cadence, and the range as typed. Its
172/// Enter writes them into the draft, and its Esc leaves the draft as it was.
173#[derive(Debug, Clone)]
174pub struct ExpectedForm {
175    /// The row under the cursor, in [`EXPECTED_ROWS`].
176    pub field: usize,
177    pub cadence: ExpectedCadence,
178    pub from: crate::widgets::text_input::TextInput,
179    pub before: crate::widgets::text_input::TextInput,
180    /// Why Enter did not apply, until the next edit.
181    pub error: Option<String>,
182}
183
184impl ExpectedForm {
185    pub fn new(plan: &DataQualityPlan, theme: &crate::config::Theme) -> Self {
186        let input = || crate::widgets::text_input::TextInput::new().with_theme(theme);
187        let (mut from, mut before) = (input(), input());
188        let cadence = match &plan.expected {
189            None => ExpectedCadence::None,
190            Some(expected) => {
191                from.set_value(expected.from.as_deref().unwrap_or_default());
192                before.set_value(expected.before.as_deref().unwrap_or_default());
193                // Weekdays stated for days read as every window once the grain is
194                // weeks or months, as Setup and the check read it.
195                let every = match &plan.grain {
196                    crate::data_quality::QualityGrain::TimeWindows { every, .. } => every.as_str(),
197                    _ => "",
198                };
199                if expected.weekdays && crate::data_quality::ExpectedWindows::weekdays_apply(every)
200                {
201                    ExpectedCadence::Weekdays
202                } else {
203                    ExpectedCadence::Every
204                }
205            }
206        };
207        let mut form = Self {
208            field: 0,
209            cadence,
210            from,
211            before,
212            error: None,
213        };
214        form.sync_focus();
215        form
216    }
217
218    /// The choices the Windows row cycles through for windows `every` wide.
219    pub fn cadences(every: &str) -> Vec<ExpectedCadence> {
220        let mut cadences = vec![ExpectedCadence::None, ExpectedCadence::Every];
221        if crate::data_quality::ExpectedWindows::weekdays_apply(every) {
222            cadences.push(ExpectedCadence::Weekdays);
223        }
224        cadences
225    }
226
227    /// The cadence in the editor's words.
228    pub fn cadence_label(&self, every: &str) -> String {
229        match self.cadence {
230            ExpectedCadence::None => "none: no window is a gap".to_string(),
231            ExpectedCadence::Every => {
232                crate::data_quality::ExpectedWindows::default().cadence_label(every)
233            }
234            ExpectedCadence::Weekdays => "weekdays, Monday to Friday".to_string(),
235        }
236    }
237
238    pub fn cycle(&mut self, every: &str, forward: bool) {
239        let cadences = Self::cadences(every);
240        let at = cadences
241            .iter()
242            .position(|cadence| *cadence == self.cadence)
243            .unwrap_or(0);
244        let next = if forward {
245            (at + 1) % cadences.len()
246        } else {
247            (at + cadences.len() - 1) % cadences.len()
248        };
249        self.cadence = cadences[next];
250        self.error = None;
251    }
252
253    /// Whether the row under the cursor is one typed into.
254    pub fn typing(&self) -> bool {
255        self.field > 0
256    }
257
258    pub fn input_mut(&mut self) -> Option<&mut crate::widgets::text_input::TextInput> {
259        match self.field {
260            1 => Some(&mut self.from),
261            2 => Some(&mut self.before),
262            _ => None,
263        }
264    }
265
266    pub fn sync_focus(&mut self) {
267        self.from.set_focused(self.field == 1);
268        self.before.set_focused(self.field == 2);
269    }
270
271    /// What the editor states, or why it cannot be read.
272    pub fn expected(&self) -> Result<Option<crate::data_quality::ExpectedWindows>, String> {
273        if self.cadence == ExpectedCadence::None {
274            return Ok(None);
275        }
276        let typed = |input: &crate::widgets::text_input::TextInput| {
277            let text = input.value().trim();
278            (!text.is_empty()).then(|| text.to_string())
279        };
280        let expected = crate::data_quality::ExpectedWindows {
281            weekdays: self.cadence == ExpectedCadence::Weekdays,
282            from: typed(&self.from),
283            before: typed(&self.before),
284        };
285        match expected.problem() {
286            Some(problem) => Err(problem),
287            None => Ok(Some(expected)),
288        }
289    }
290}
291
292impl crate::form::Form for ExpectedForm {
293    /// The row, in [`EXPECTED_ROWS`].
294    type Field = usize;
295
296    fn fields(&self) -> Vec<(usize, crate::form::FieldKind)> {
297        use crate::form::FieldKind;
298        (0..EXPECTED_ROWS.len())
299            .map(|row| {
300                let kind = if row == 0 {
301                    FieldKind::Choice
302                } else {
303                    FieldKind::Text
304                };
305                (row, kind)
306            })
307            .collect()
308    }
309
310    fn focused(&self) -> usize {
311        self.field
312    }
313
314    fn set_focused(&mut self, field: usize) {
315        self.field = field;
316        self.sync_focus();
317    }
318}
319
320/// Read `column` as time through `format`, or as text again with `None`. A time
321/// window on a column that is text again has no clock, so the grain goes back to
322/// the whole dataset rather than failing the run.
323pub fn set_time_format(plan: &mut DataQualityPlan, column: &str, format: Option<(TimeKind, &str)>) {
324    plan.time_formats
325        .retain(|interpretation| interpretation.column != column);
326    match format {
327        Some((kind, format)) => plan.time_formats.push(TimeInterpretation {
328            column: column.to_string(),
329            kind,
330            format: format.to_string(),
331        }),
332        None => {
333            if matches!(&plan.grain, QualityGrain::TimeWindows { column: on, .. } if on == column) {
334                plan.grain = QualityGrain::Dataset;
335                plan.baseline_segment = None;
336            }
337        }
338    }
339}
340
341#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
342pub enum AnalysisView {
343    #[default]
344    Main, // Main tool view
345    DistributionDetail, // Full-screen distribution detail view
346    CorrelationDetail,  // Full-screen correlation pair detail view
347}
348
349#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
350pub enum AnalysisTool {
351    #[default]
352    Describe, // Column describe table
353    DistributionAnalysis, // Distribution analysis table
354    CorrelationMatrix,    // Correlation matrix
355    DataQuality,          // Multi-scale quality profile
356}
357
358impl AnalysisTool {
359    /// The tools in the order the sidebar lists them.
360    pub const ALL: [Self; 4] = [
361        Self::Describe,
362        Self::DistributionAnalysis,
363        Self::CorrelationMatrix,
364        Self::DataQuality,
365    ];
366
367    /// The tool's row in the sidebar.
368    pub fn index(self) -> usize {
369        Self::ALL.iter().position(|tool| *tool == self).unwrap_or(0)
370    }
371}
372
373/// Progress state for the analysis progress overlay (display only).
374#[derive(Debug, Clone)]
375pub struct AnalysisProgress {
376    pub phase: String,
377    /// When the run began, for the elapsed time on screen. A run has no total to
378    /// count against, so time is the progress there is to show.
379    pub started: std::time::Instant,
380    /// Whether the stage now running reads the source or works on rows already
381    /// read. `None` until a Data Quality run names its first stage.
382    pub reads_source: Option<bool>,
383    /// Whether a cancel stops the stage now running partway. `None` until a Data
384    /// Quality run names its first stage.
385    pub interruptible: Option<bool>,
386    /// The sampler's count of rows seen, where the read can count them.
387    pub read: Option<crate::sampling::ReadWatch>,
388    /// What the run starts from, when it reuses something: said before it starts.
389    pub reuse: Option<String>,
390}
391
392impl AnalysisProgress {
393    pub fn new(phase: &str) -> Self {
394        Self {
395            phase: phase.to_string(),
396            started: std::time::Instant::now(),
397            reads_source: None,
398            interruptible: None,
399            read: None,
400            reuse: None,
401        }
402    }
403
404    /// The stage now running reads the source in one collect nothing can stop: a
405    /// cancel waits for its end.
406    pub fn read_runs_out(&self) -> bool {
407        self.reads_source == Some(true) && self.interruptible == Some(false)
408    }
409}
410
411#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
412pub enum AnalysisFocus {
413    #[default]
414    Main, // Focus on main area (tool view)
415    Sidebar,              // Focus on sidebar (tool list)
416    DistributionSelector, // Focus on distribution selector in detail view
417}
418
419/// A popup taller than the screen scrolls: the offset, and the most it can be.
420#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
421pub struct DetailScroll {
422    pub offset: u16,
423    pub max: u16,
424}
425
426/// A result table wider than its pane scrolls by statistic: the first one shown,
427/// and the furthest that first can go, which is where the last statistic comes
428/// into view. The table sets `max` as it draws, so the keys stop where it does.
429#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
430pub struct ColumnScroll {
431    pub offset: usize,
432    pub max: usize,
433}
434
435impl ColumnScroll {
436    pub fn left(&mut self) {
437        self.offset = self.offset.min(self.max).saturating_sub(1);
438    }
439
440    pub fn right(&mut self) {
441        if self.offset < self.max {
442            self.offset += 1;
443        }
444    }
445}
446
447#[derive(Default)]
448pub struct AnalysisModal {
449    pub active: bool,
450    pub scroll_position: usize,
451    pub selected_column: Option<usize>,
452    pub describe_columns: ColumnScroll,
453    pub distribution_columns: ColumnScroll,
454    /// Kept on the selected cell by the matrix as it draws.
455    pub correlation_columns: ColumnScroll,
456    /// The rows every tool reads: one scope, method, size and seed for all of them,
457    /// so switching tools compares like with like. Kept across opens; `s` edits it.
458    pub sample: crate::sampling::Sample,
459    /// The dataset the sample's scope was chosen for. A scope naming a partition or a
460    /// file of one dataset means nothing on the next.
461    pub sample_dataset: Option<u64>,
462    /// The dataset a sample was last run on. Once one has been, a tool picked with
463    /// no result runs on it at once; before, the Sample form asks first.
464    pub sample_run_for: Option<u64>,
465    /// The Sample form, while it is open.
466    pub sample_form: Option<crate::sample_modal::SampleForm>,
467    /// The tools' own sample, put aside while the view has a sample of its own:
468    /// every tool then reads the view's rows whole.
469    pub own_sample: Option<crate::sampling::Sample>,
470    pub table_state: TableState,              // For describe table
471    pub distribution_table_state: TableState, // For distribution table
472    pub correlation_table_state: TableState,  // For correlation matrix
473    pub sidebar_state: TableState,            // For sidebar tool list
474    /// Cached results per tool; each tool computes and stores its own state independently.
475    pub describe_results: Option<AnalysisResults>,
476    pub distribution_results: Option<AnalysisResults>,
477    pub correlation_results: Option<AnalysisResults>,
478    pub data_quality_results: Option<DataQualityResults>,
479    /// When Some, show progress overlay (phase, current/total); in-progress data lives in App.
480    pub computing: Option<AnalysisProgress>,
481    pub view: AnalysisView,
482    pub focus: AnalysisFocus,
483    /// None = no tool selected yet (show instructions); Some(tool) = user chose a tool (may be computing or showing results).
484    pub selected_tool: Option<AnalysisTool>,
485    pub selected_distribution: Option<usize>, // Selected row in distribution table
486    pub selected_correlation: Option<(usize, usize)>, // Selected cell in correlation matrix (row, col)
487    /// The coefficient the matrix and the pair detail show; both are computed.
488    pub correlation_method: crate::statistics::CorrelationMethod,
489    pub selected_theoretical_distribution: DistributionType, // Selected theoretical distribution for Q-Q plot
490    pub distribution_selector_state: TableState,             // For distribution selector list
491    pub histogram_scale: HistogramScale,                     // Scale for histogram (linear or log)
492    pub data_quality_page: QualityPage,
493    /// The plan: the one the last Run committed, and while Setup is open, the draft
494    /// being staged for the next.
495    pub data_quality_plan: DataQualityPlan,
496    /// The plan as it stood when Setup opened: Esc puts it back. `None` while Setup
497    /// is closed.
498    pub data_quality_setup_before: Option<DataQualityPlan>,
499    /// The report page Setup was opened from, and goes back to.
500    pub data_quality_setup_return: QualityPage,
501    /// Why Enter did not run, said on Setup's own line until the next edit.
502    pub data_quality_setup_note: Option<String>,
503    pub data_quality_table_state: TableState,
504    /// The list a Setup row's choices open in, while it is open.
505    pub data_quality_picker: Option<PlanPicker>,
506    /// The Setup row under the cursor, or the role in the time roles editor.
507    pub data_quality_plan_field: usize,
508    pub data_quality_show_access: bool,
509    pub data_quality_observation_detail: bool,
510    /// Where the finding popup is scrolled to, and how far it can go (set as it draws).
511    pub data_quality_detail_scroll: DetailScroll,
512    /// The segment a drill-in shows, and the one Segments selects on the way back.
513    pub data_quality_segment_index: usize,
514    /// Segments listed clearest change first rather than in their own order.
515    pub data_quality_segments_by_change: bool,
516    /// The clean entry's popup lists every check rather than the most important.
517    pub data_quality_checks_expanded: bool,
518    /// A full scan is being asked about in the confirmation; Yes runs Setup.
519    pub data_quality_confirm_run: bool,
520    pub data_quality_plan_before_edit: Option<DataQualityPlan>,
521    pub data_quality_last_plan: Option<DataQualityPlan>,
522    pub data_quality_from_cache: bool,
523    pub data_quality_metric: QualityMetric,
524    pub data_quality_column_index: usize,
525    /// The interval a detail shows, and the one Intervals selects on the way back.
526    pub data_quality_interval_index: usize,
527    /// How Overview narrows and orders its findings; the report is not measured again.
528    pub data_quality_findings: FindingsView,
529    /// A read for a finding's rows, shown with what it reads until Enter or Esc.
530    pub data_quality_evidence_read: Option<EvidenceRead>,
531    /// The Trends line a bar detail shows, and the one Trends selects on the way back.
532    pub data_quality_trend_line: usize,
533    /// The Expected editor, while it is open.
534    pub data_quality_expected_form: Option<ExpectedForm>,
535    /// One column's declared intent, being edited over the Column intent list.
536    pub data_quality_intent_form: Option<crate::intent_modal::IntentForm>,
537    /// The dialog that writes the report on screen to a file.
538    pub data_quality_export: Option<crate::quality_export::ExportForm>,
539    /// The view (`DataTableState::len_generation`) the screen was opened on, which
540    /// its results are of.
541    pub results_view: Option<u64>,
542    /// What a close put down: the tools' results and where they were, taken back
543    /// by the next open on the same view.
544    kept: Option<Kept>,
545}
546
547/// The tools' results as a close left them, and the cursor in each.
548struct Kept {
549    view: Option<u64>,
550    tool: Option<AnalysisTool>,
551    describe: Option<AnalysisResults>,
552    distribution: Option<AnalysisResults>,
553    correlation: Option<AnalysisResults>,
554    row: Option<usize>,
555    distribution_row: Option<usize>,
556    cell: Option<(usize, usize)>,
557}
558
559#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
560pub enum HistogramScale {
561    #[default]
562    Linear,
563    Log,
564}
565
566impl AnalysisModal {
567    pub fn new() -> Self {
568        Self::default()
569    }
570
571    /// A modal whose shared sample starts at the configured size: `[performance]
572    /// analysis_sample_rows`, where 0 means every row.
573    pub fn with_sample_rows(rows: usize) -> Self {
574        let mut modal = Self::default();
575        modal.sample.seed = crate::sample_modal::new_seed();
576        if rows == 0 {
577            modal.sample.method = crate::sampling::SampleMethod::EveryRow;
578        } else {
579            modal.sample.rows = rows;
580        }
581        modal
582    }
583
584    /// Open the screen on `view`. On the view a close left results of, they come
585    /// back as they were, with the tool on screen; on any other, every tool starts
586    /// empty.
587    pub fn open(&mut self, view: Option<u64>) {
588        let kept = self
589            .kept
590            .take()
591            .filter(|kept| view.is_some() && kept.view == view);
592        self.results_view = view;
593        self.active = true;
594        self.scroll_position = 0;
595        self.selected_column = None;
596        self.describe_columns = ColumnScroll::default();
597        self.distribution_columns = ColumnScroll::default();
598        self.correlation_columns = ColumnScroll::default();
599        self.table_state.select(Some(0));
600        self.distribution_table_state.select(Some(0));
601        self.correlation_table_state.select(Some(0));
602        self.sidebar_state.select(Some(0)); // Highlight first tool; user must press Enter to select
603        self.view = AnalysisView::Main;
604        self.focus = AnalysisFocus::Sidebar; // Sidebar focused by default when no tool selected
605        self.selected_tool = None; // No tool until user selects from sidebar
606        self.selected_distribution = Some(0);
607        self.selected_correlation = None;
608        self.computing = None;
609        self.describe_results = None;
610        self.distribution_results = None;
611        self.correlation_results = None;
612        self.data_quality_results = None;
613        self.data_quality_page = QualityPage::Setup;
614        self.data_quality_plan = DataQualityPlan::default();
615        self.data_quality_setup_before = None;
616        self.data_quality_setup_return = QualityPage::Overview;
617        self.data_quality_setup_note = None;
618        self.data_quality_table_state.select(Some(0));
619        self.data_quality_picker = None;
620        self.data_quality_plan_field = 0;
621        self.data_quality_show_access = false;
622        self.data_quality_observation_detail = false;
623        self.data_quality_confirm_run = false;
624        self.data_quality_plan_before_edit = None;
625        self.data_quality_last_plan = None;
626        self.data_quality_from_cache = false;
627        self.data_quality_metric = QualityMetric::NullRate;
628        self.data_quality_column_index = 0;
629        self.data_quality_interval_index = 0;
630        self.data_quality_trend_line = 0;
631        self.data_quality_expected_form = None;
632        self.data_quality_intent_form = None;
633        self.data_quality_export = None;
634        self.sample_form = None;
635        if let Some(kept) = kept {
636            self.describe_results = kept.describe;
637            self.distribution_results = kept.distribution;
638            self.correlation_results = kept.correlation;
639            self.selected_tool = kept.tool;
640            if self.current_results().is_none() {
641                self.selected_tool = None;
642            }
643            if let Some(tool) = self.selected_tool {
644                self.sidebar_state.select(Some(tool.index()));
645            }
646            self.table_state.select(kept.row.or(Some(0)));
647            self.distribution_table_state
648                .select(kept.distribution_row.or(Some(0)));
649            self.selected_distribution = kept.distribution_row.or(Some(0));
650            if let Some(cell) = kept.cell {
651                self.selected_correlation = Some(cell);
652                self.correlation_table_state.select(Some(cell.0));
653            }
654        }
655        if self.correlation_results.is_none() {
656            self.selected_correlation = None;
657        }
658    }
659
660    /// Close the screen. The tools' results are put down rather than dropped: the
661    /// next open on the same view shows them again.
662    pub fn close(&mut self) {
663        self.kept = Some(Kept {
664            view: self.results_view.take(),
665            tool: self
666                .selected_tool
667                .filter(|tool| *tool != AnalysisTool::DataQuality),
668            describe: self.describe_results.take(),
669            distribution: self.distribution_results.take(),
670            correlation: self.correlation_results.take(),
671            row: self.table_state.selected(),
672            distribution_row: self.distribution_table_state.selected(),
673            cell: self.selected_correlation,
674        });
675        self.active = false;
676        self.scroll_position = 0;
677        self.selected_column = None;
678        self.describe_columns = ColumnScroll::default();
679        self.distribution_columns = ColumnScroll::default();
680        self.correlation_columns = ColumnScroll::default();
681        self.view = AnalysisView::Main;
682        self.focus = AnalysisFocus::Main;
683        self.selected_tool = None;
684        self.selected_distribution = None;
685        self.selected_correlation = None;
686        self.computing = None;
687        self.describe_results = None;
688        self.distribution_results = None;
689        self.correlation_results = None;
690        self.data_quality_results = None;
691        self.data_quality_page = QualityPage::Setup;
692        self.data_quality_setup_before = None;
693        self.data_quality_setup_note = None;
694        self.data_quality_picker = None;
695        self.data_quality_show_access = false;
696        self.data_quality_observation_detail = false;
697        self.data_quality_confirm_run = false;
698        self.data_quality_plan_before_edit = None;
699        self.data_quality_last_plan = None;
700        self.data_quality_from_cache = false;
701        self.data_quality_metric = QualityMetric::NullRate;
702        self.data_quality_column_index = 0;
703        self.data_quality_interval_index = 0;
704        self.data_quality_trend_line = 0;
705        self.data_quality_expected_form = None;
706        self.data_quality_intent_form = None;
707        self.data_quality_export = None;
708    }
709
710    /// Returns the cached results for the currently selected tool, if any.
711    pub fn current_results(&self) -> Option<&AnalysisResults> {
712        match self.selected_tool {
713            Some(AnalysisTool::Describe) => self.describe_results.as_ref(),
714            Some(AnalysisTool::DistributionAnalysis) => self.distribution_results.as_ref(),
715            Some(AnalysisTool::CorrelationMatrix) => self.correlation_results.as_ref(),
716            Some(AnalysisTool::DataQuality) => None,
717            None => None,
718        }
719    }
720
721    /// Tab on the main view: the tool list and the result trade focus. The detail
722    /// views have one focusable thing, so Tab is not offered there; nor is it
723    /// before a tool is chosen, when the pane beside the list is empty.
724    pub fn switch_focus(&mut self) {
725        self.focus = match self.focus {
726            AnalysisFocus::Sidebar if self.selected_tool.is_some() => AnalysisFocus::Main,
727            _ => AnalysisFocus::Sidebar,
728        };
729    }
730
731    /// The tool under the sidebar cursor.
732    pub fn highlighted_tool(&self) -> Option<AnalysisTool> {
733        AnalysisTool::ALL
734            .get(self.sidebar_state.selected()?)
735            .copied()
736    }
737
738    /// Select the tool under the sidebar cursor. Where the cursor goes is the
739    /// caller's: Enter on a tool takes it into the tool's pane.
740    pub fn select_tool(&mut self) {
741        if self.sidebar_state.selected().is_some() {
742            self.selected_tool = Some(self.highlighted_tool().unwrap_or_default());
743        }
744    }
745
746    pub fn next_tool(&mut self) {
747        if let Some(current) = self.sidebar_state.selected() {
748            let next = (current + 1).min(AnalysisTool::ALL.len() - 1);
749            self.sidebar_state.select(Some(next));
750        }
751    }
752
753    pub fn previous_tool(&mut self) {
754        if let Some(current) = self.sidebar_state.selected()
755            && current > 0
756        {
757            self.sidebar_state.select(Some(current - 1));
758        }
759    }
760
761    pub fn open_distribution_detail(&mut self) {
762        if self.focus == AnalysisFocus::Main
763            && self.selected_tool == Some(AnalysisTool::DistributionAnalysis)
764            && let Some(idx) = self.distribution_table_state.selected()
765        {
766            if let Some(results) = &self.distribution_results
767                && let Some(dist_analysis) = results.distribution_analyses.get(idx)
768            {
769                self.selected_theoretical_distribution = dist_analysis.distribution_type;
770            }
771            self.view = AnalysisView::DistributionDetail;
772            self.focus = AnalysisFocus::DistributionSelector;
773            if self.selected_theoretical_distribution == DistributionType::Unknown {
774                self.selected_theoretical_distribution = DistributionType::Normal;
775            }
776            self.distribution_selector_state.select(None);
777        }
778    }
779
780    pub fn open_correlation_detail(&mut self) {
781        if self.focus == AnalysisFocus::Main
782            && self.selected_tool == Some(AnalysisTool::CorrelationMatrix)
783            && let Some((row, col)) = self.selected_correlation
784            && row != col
785        {
786            self.view = AnalysisView::CorrelationDetail;
787        }
788    }
789
790    pub fn close_detail(&mut self) {
791        self.view = AnalysisView::Main;
792        self.focus = AnalysisFocus::Main;
793    }
794
795    pub fn scroll_left(&mut self) {
796        if let Some(columns) = self.column_scroll_mut() {
797            columns.left();
798        }
799    }
800
801    pub fn scroll_right(&mut self) {
802        if let Some(columns) = self.column_scroll_mut() {
803            columns.right();
804        }
805    }
806
807    /// The selected tool's statistic scroll, for the tools that scroll by statistic.
808    pub fn column_scroll(&self) -> Option<&ColumnScroll> {
809        match self.selected_tool {
810            Some(AnalysisTool::Describe) => Some(&self.describe_columns),
811            Some(AnalysisTool::DistributionAnalysis) => Some(&self.distribution_columns),
812            _ => None,
813        }
814    }
815
816    fn column_scroll_mut(&mut self) -> Option<&mut ColumnScroll> {
817        match self.selected_tool {
818            Some(AnalysisTool::Describe) => Some(&mut self.describe_columns),
819            Some(AnalysisTool::DistributionAnalysis) => Some(&mut self.distribution_columns),
820            _ => None,
821        }
822    }
823
824    /// Read the view's sample whole while it has one (`sampled`), and the tools' own
825    /// sample again once it has none.
826    pub fn follow_view_sample(&mut self, sampled: bool) {
827        match (sampled, self.own_sample.is_some()) {
828            (true, false) => {
829                let every = crate::sampling::Sample {
830                    scope: crate::data_quality::QualityScope::CurrentView,
831                    method: crate::sampling::SampleMethod::EveryRow,
832                    ..self.sample.clone()
833                };
834                self.own_sample = Some(std::mem::replace(&mut self.sample, every));
835            }
836            (false, true) => {
837                if let Some(own) = self.own_sample.take() {
838                    self.sample = own;
839                }
840            }
841            _ => {}
842        }
843    }
844
845    /// Another sample: a new seed for the shared sample.
846    pub fn recalculate(&mut self) {
847        self.sample.seed = crate::sample_modal::new_seed();
848    }
849
850    pub fn quality_row_count(&self) -> usize {
851        let Some(results) = self.data_quality_results.as_ref() else {
852            return 0;
853        };
854        match self.data_quality_page {
855            QualityPage::Setup => SetupRow::ALL.len(),
856            QualityPage::TimeRoles => TemporalRole::ALL.len(),
857            QualityPage::IntervalPairs => self.data_quality_plan.candidate_pairs().len(),
858            // The scope's columns, which the modal does not hold: the list's keys move
859            // by them in `App`.
860            QualityPage::Intent => 0,
861            QualityPage::Intervals => results.temporal.len(),
862            QualityPage::IntervalDetail => self.interval_facts().len(),
863            QualityPage::Overview => self
864                .data_quality_findings
865                .shown(&crate::quality_report::build_report(results))
866                .len(),
867            QualityPage::Columns | QualityPage::Detail => results.columns.len(),
868            QualityPage::Segments => results.segments.len(),
869            QualityPage::SegmentDetail => {
870                crate::data_quality::segment_changes(results, self.data_quality_segment_index).len()
871            }
872            // The trend table's lines; the width only changes how many bars.
873            QualityPage::Trends => {
874                crate::quality_trends::trend_view(results, self.data_quality_metric, 1)
875                    .lines
876                    .len()
877            }
878            // As many bars as there are segments, at most: the width decides how many
879            // there are, and the page holds the cursor to the last as it draws.
880            QualityPage::TrendDetail => crate::quality_trends::trend_slots(results).len(),
881            QualityPage::Gaps => {
882                match crate::quality_trends::expected_gaps(self.quality_result_plan(), results) {
883                    Some(crate::quality_trends::Gaps::Checked(check)) => check.runs.len(),
884                    _ => 0,
885                }
886            }
887            QualityPage::ExpectedWindows => EXPECTED_ROWS.len(),
888        }
889    }
890
891    /// Whether the highlighted Overview entry is the clean-columns entry, which opens
892    /// no rows and instead lists the checks.
893    pub fn quality_selected_is_clean(&self) -> bool {
894        self.data_quality_page == QualityPage::Overview
895            && self
896                .selected_finding()
897                .is_some_and(|(_, finding)| finding.kind.is_none())
898    }
899
900    /// The report on screen and the finding under the cursor, as Overview lists
901    /// them: narrowed and ordered.
902    pub fn selected_finding(&self) -> Option<(QualityReport, Finding)> {
903        let results = self.data_quality_results.as_ref()?;
904        let report = crate::quality_report::build_report(results);
905        let finding = self
906            .data_quality_findings
907            .selected(&report, self.data_quality_table_state.selected()?)?
908            .clone();
909        Some((report, finding))
910    }
911
912    /// Narrow the findings to a column (`by_column`) or a check, from a list of
913    /// those the report has, the current one selected.
914    pub fn open_findings_picker(&mut self, by_column: bool) {
915        let Some(results) = self.data_quality_results.as_ref() else {
916            return;
917        };
918        let report = crate::quality_report::build_report(results);
919        let view = &self.data_quality_findings;
920        let findings = |count: usize| match count {
921            0 => "none".to_string(),
922            1 => "1 finding".to_string(),
923            count => format!("{} findings", crate::numfmt::group_chrome(count)),
924        };
925        let (title, choices, current) = if by_column {
926            let columns = crate::quality_report::column_choices(&report, results);
927            let width = columns
928                .iter()
929                .map(|(name, _)| crate::glyphs::display_width(name))
930                .max()
931                .unwrap_or(0);
932            let current = view
933                .column
934                .as_ref()
935                .and_then(|column| columns.iter().position(|(name, _)| name == column))
936                .map_or(0, |position| position + 1);
937            let mut choices = vec![("All columns".to_string(), PlanChoice::FindingColumn(None))];
938            choices.extend(columns.into_iter().map(|(name, count)| {
939                let pad = width.saturating_sub(crate::glyphs::display_width(&name));
940                (
941                    format!("{name}{}  {}", " ".repeat(pad), findings(count)),
942                    PlanChoice::FindingColumn(Some(name)),
943                )
944            }));
945            ("Findings by Column", choices, current)
946        } else {
947            let checks = crate::quality_report::check_choices(&report);
948            let width = checks
949                .iter()
950                .map(|(name, _)| crate::glyphs::display_width(name))
951                .max()
952                .unwrap_or(0);
953            let current = view
954                .check
955                .and_then(|check| checks.iter().position(|(name, _)| *name == check))
956                .map_or(0, |position| position + 1);
957            let mut choices = vec![("All types".to_string(), PlanChoice::FindingCheck(None))];
958            choices.extend(checks.into_iter().map(|(name, count)| {
959                let pad = width.saturating_sub(crate::glyphs::display_width(name));
960                (
961                    format!("{name}{}  {}", " ".repeat(pad), findings(count)),
962                    PlanChoice::FindingCheck(Some(name)),
963                )
964            }));
965            ("Findings by Type", choices, current)
966        };
967        let (labels, choices): (Vec<_>, Vec<_>) = choices.into_iter().unzip();
968        let mut state = crate::widgets::ui::PickerState::new(labels);
969        state.select_original(current);
970        self.data_quality_picker = Some(PlanPicker {
971            row: None,
972            title: title.to_string(),
973            choices,
974            state,
975        });
976    }
977
978    /// The next order for the findings, keeping the finding under the cursor under it.
979    pub fn cycle_findings_order(&mut self) {
980        let selected = self.selected_finding();
981        self.data_quality_findings.order = self.data_quality_findings.order.next();
982        self.reselect_finding(selected.map(|(_, finding)| finding));
983    }
984
985    /// Show every finding again, in the order chosen.
986    pub fn clear_findings_narrowing(&mut self) {
987        let selected = self.selected_finding();
988        self.data_quality_findings.column = None;
989        self.data_quality_findings.check = None;
990        self.reselect_finding(selected.map(|(_, finding)| finding));
991    }
992
993    /// Put the cursor on `finding` where the list now shows it, or on the first.
994    fn reselect_finding(&mut self, finding: Option<Finding>) {
995        let position = self.data_quality_results.as_ref().and_then(|results| {
996            let report = crate::quality_report::build_report(results);
997            let finding = finding?;
998            let shown = self.data_quality_findings.shown(&report);
999            shown.iter().position(|index| {
1000                let listed = &report.findings[*index];
1001                listed.title == finding.title && listed.columns == finding.columns
1002            })
1003        });
1004        self.data_quality_table_state
1005            .select(Some(position.unwrap_or(0)));
1006        *self.data_quality_table_state.offset_mut() = 0;
1007    }
1008
1009    /// Whether `s` opens the Sample form here: on a tool's main view, with nothing
1010    /// else holding the keys — no run, no popup, no editor, no text field.
1011    pub fn sample_key_opens_form(&self) -> bool {
1012        self.active
1013            && self.view == AnalysisView::Main
1014            && self.selected_tool.is_some()
1015            && self.computing.is_none()
1016            && self.data_quality_picker.is_none()
1017            && !matches!(
1018                self.data_quality_page,
1019                QualityPage::TimeRoles
1020                    | QualityPage::IntervalPairs
1021                    | QualityPage::ExpectedWindows
1022                    | QualityPage::Intent
1023            )
1024            && !self.data_quality_show_access
1025            && !self.data_quality_observation_detail
1026            && self.data_quality_evidence_read.is_none()
1027            && self.data_quality_export.is_none()
1028            && self.data_quality_intent_form.is_none()
1029            && self.sample_form.is_none()
1030    }
1031
1032    /// Whether the export dialog's path owns typed characters.
1033    pub fn export_typing(&self) -> bool {
1034        self.active
1035            && self
1036                .data_quality_export
1037                .as_ref()
1038                .is_some_and(|form| !form.on_format)
1039    }
1040
1041    /// Whether a Column intent field owns typed characters, so Ctrl-C and `?` are
1042    /// text there as in any field.
1043    pub fn intent_typing(&self) -> bool {
1044        self.active
1045            && self
1046                .data_quality_intent_form
1047                .as_ref()
1048                .is_some_and(crate::intent_modal::IntentForm::typing)
1049    }
1050
1051    /// Whether the Sample form's scope field owns typed characters, so Ctrl-C and `?`
1052    /// are text there as in any field.
1053    pub fn sample_scope_typing(&self) -> bool {
1054        self.active
1055            && self.sample_form.as_ref().is_some_and(|form| {
1056                form.field.is_text() && (!form.inline || self.focus == AnalysisFocus::Main)
1057            })
1058    }
1059
1060    /// Whether the Expected editor's From or Before has the cursor, so every key
1061    /// but its own types there.
1062    pub fn quality_expected_typing(&self) -> bool {
1063        self.active
1064            && self.data_quality_page == QualityPage::ExpectedWindows
1065            && self
1066                .data_quality_expected_form
1067                .as_ref()
1068                .is_some_and(ExpectedForm::typing)
1069    }
1070
1071    /// Open the bars of the Trends line under the cursor, the first bar selected.
1072    pub fn open_trend_detail(&mut self) {
1073        let line = self.data_quality_table_state.selected().unwrap_or(0);
1074        self.data_quality_trend_line = line;
1075        self.set_quality_page(QualityPage::TrendDetail);
1076    }
1077
1078    /// The next measure in a bar's detail, on the same column's line. A column with
1079    /// nothing to draw in it has no line, and Trends lists what does.
1080    pub fn cycle_trend_detail_metric(&mut self) {
1081        let Some(results) = self.data_quality_results.as_ref() else {
1082            return;
1083        };
1084        let view = crate::quality_trends::trend_view(results, self.data_quality_metric, 1);
1085        let line = view
1086            .lines
1087            .get(self.data_quality_trend_line)
1088            .map(|line| (line.measure, line.names.clone()));
1089        self.cycle_quality_metric();
1090        let Some(results) = self.data_quality_results.as_ref() else {
1091            return;
1092        };
1093        let view = crate::quality_trends::trend_view(results, self.data_quality_metric, 1);
1094        let found = line.and_then(|(measure, names)| {
1095            view.lines.iter().position(|candidate| {
1096                candidate.measure == measure
1097                    || candidate
1098                        .names
1099                        .iter()
1100                        .any(|name| !candidate.rows() && names.contains(name))
1101            })
1102        });
1103        match found {
1104            Some(index) => self.data_quality_trend_line = index,
1105            None => {
1106                self.data_quality_trend_line = 0;
1107                self.close_to_trends();
1108            }
1109        }
1110    }
1111
1112    /// Back to Trends from a bar detail or the gaps, the line still selected.
1113    pub fn close_to_trends(&mut self) {
1114        let line = (self.data_quality_page == QualityPage::TrendDetail)
1115            .then_some(self.data_quality_trend_line);
1116        self.set_quality_page(QualityPage::Trends);
1117        self.data_quality_table_state
1118            .select(Some(line.unwrap_or(0)));
1119    }
1120
1121    pub fn set_quality_page(&mut self, page: QualityPage) {
1122        self.data_quality_page = page;
1123        self.data_quality_observation_detail = false;
1124        self.data_quality_evidence_read = None;
1125        self.data_quality_table_state.select(Some(0));
1126    }
1127
1128    /// Move between the column lens and its detail without losing which column
1129    /// the user was looking at.
1130    pub fn set_quality_column_page(&mut self, page: QualityPage) {
1131        // Detail moves the same selection with Up/Down, so capture it from there
1132        // too or navigating inside Detail is lost on the way back.
1133        if matches!(
1134            self.data_quality_page,
1135            QualityPage::Columns | QualityPage::Detail
1136        ) {
1137            self.data_quality_column_index = self.data_quality_table_state.selected().unwrap_or(0);
1138        }
1139        self.set_quality_page(page);
1140        self.data_quality_table_state
1141            .select(Some(self.data_quality_column_index));
1142    }
1143
1144    /// Open the highlighted segment's columns, or go back to the list with the
1145    /// segment still selected.
1146    pub fn open_segment_detail(&mut self) {
1147        if let Some(segment) = self.selected_segment() {
1148            self.data_quality_segment_index = segment;
1149            self.set_quality_page(QualityPage::SegmentDetail);
1150        }
1151    }
1152
1153    pub fn close_segment_detail(&mut self) {
1154        self.set_quality_page(QualityPage::Segments);
1155        let position = self
1156            .segment_order()
1157            .iter()
1158            .position(|segment| *segment == self.data_quality_segment_index);
1159        self.data_quality_table_state
1160            .select(Some(position.unwrap_or(0)));
1161    }
1162
1163    /// The order Segments lists its rows in.
1164    pub fn segment_order(&self) -> Vec<usize> {
1165        self.data_quality_results
1166            .as_ref()
1167            .map(|results| {
1168                crate::data_quality::segment_order(results, self.data_quality_segments_by_change)
1169            })
1170            .unwrap_or_default()
1171    }
1172
1173    /// The segment under the cursor on Segments, whichever order it is listed in.
1174    pub fn selected_segment(&self) -> Option<usize> {
1175        let position = self.data_quality_table_state.selected()?;
1176        self.segment_order().get(position).copied()
1177    }
1178
1179    /// List segments in their own order or clearest change first, keeping the one
1180    /// under the cursor under it.
1181    pub fn toggle_segment_order(&mut self) {
1182        let segment = self.selected_segment();
1183        self.data_quality_segments_by_change = !self.data_quality_segments_by_change;
1184        let position = segment
1185            .and_then(|segment| self.segment_order().iter().position(|s| *s == segment))
1186            .unwrap_or(0);
1187        self.data_quality_table_state.select(Some(position));
1188    }
1189
1190    /// Open the highlighted interval's detail, its first count under the cursor.
1191    pub fn open_interval_detail(&mut self) {
1192        let Some(index) = self.data_quality_table_state.selected() else {
1193            return;
1194        };
1195        if self
1196            .data_quality_results
1197            .as_ref()
1198            .is_some_and(|results| index < results.temporal.len())
1199        {
1200            self.data_quality_interval_index = index;
1201            self.set_quality_page(QualityPage::IntervalDetail);
1202        }
1203    }
1204
1205    /// Back to the list, the interval still selected.
1206    pub fn close_interval_detail(&mut self) {
1207        self.set_quality_page(QualityPage::Intervals);
1208        self.data_quality_table_state
1209            .select(Some(self.data_quality_interval_index));
1210    }
1211
1212    /// The counts an interval's detail lists, each with rows it can open: those
1213    /// the interval measured, in the order the detail shows them.
1214    pub fn interval_facts(&self) -> Vec<IntervalFact> {
1215        let plan = self.quality_result_plan();
1216        self.data_quality_results
1217            .as_ref()
1218            .and_then(|results| results.temporal.get(self.data_quality_interval_index))
1219            .map(|profile| {
1220                IntervalFact::ALL
1221                    .into_iter()
1222                    .filter(|fact| profile.count(*fact, plan).is_some())
1223                    .collect()
1224            })
1225            .unwrap_or_default()
1226    }
1227
1228    /// The count under the cursor in an interval's detail.
1229    pub fn selected_interval_fact(&self) -> Option<IntervalFact> {
1230        let facts = self.interval_facts();
1231        facts
1232            .get(self.data_quality_table_state.selected().unwrap_or(0))
1233            .copied()
1234    }
1235
1236    /// The rows behind the count under the cursor in an interval's detail, when it
1237    /// has any and they can be told by their values: a predicate over the rows the
1238    /// run read, with what to call them.
1239    /// `schema`, the data's where known, lets a partition segment compare in its
1240    /// column's type.
1241    pub fn interval_evidence(
1242        &self,
1243        schema: Option<&polars::prelude::Schema>,
1244    ) -> Option<(polars::prelude::Expr, String, usize)> {
1245        let results = self.data_quality_results.as_ref()?;
1246        if !matches!(
1247            results.precision,
1248            crate::data_quality::QualityPrecision::Exact
1249                | crate::data_quality::QualityPrecision::Sampled
1250        ) {
1251            return None;
1252        }
1253        let plan = self.quality_result_plan();
1254        let profile = results.temporal.get(self.data_quality_interval_index)?;
1255        let fact = self.selected_interval_fact()?;
1256        let (count, _) = profile.count(fact, plan)?;
1257        if count == 0 {
1258            return None;
1259        }
1260        let predicate = profile.evidence_predicate(fact, plan, schema)?;
1261        Some((
1262            predicate,
1263            format!(
1264                "Data Quality / {} / {} / {}",
1265                profile.label(),
1266                profile.segment,
1267                fact.short()
1268            ),
1269            count,
1270        ))
1271    }
1272
1273    /// Move the finding popup by `rows`, within what it last drew.
1274    pub fn scroll_quality_detail(&mut self, rows: i32) {
1275        let scroll = &mut self.data_quality_detail_scroll;
1276        scroll.offset = (scroll.offset as i32 + rows).clamp(0, scroll.max as i32) as u16;
1277    }
1278
1279    /// Show a tab, keeping the column in view across Columns, Segments and Trends.
1280    pub fn show_quality_tab(&mut self, page: QualityPage) {
1281        if matches!(
1282            self.data_quality_page,
1283            QualityPage::Columns | QualityPage::Detail
1284        ) {
1285            self.data_quality_column_index = self.data_quality_table_state.selected().unwrap_or(0);
1286        }
1287        self.set_quality_page(page);
1288        if page == QualityPage::Columns {
1289            self.data_quality_table_state
1290                .select(Some(self.data_quality_column_index));
1291        }
1292    }
1293
1294    /// The next or previous tab, stopping at either end.
1295    pub fn step_quality_tab(&mut self, forward: bool) {
1296        let tabs = QualityPage::TABS;
1297        let Some(at) = tabs
1298            .iter()
1299            .position(|page| *page == self.data_quality_page.tab())
1300        else {
1301            return;
1302        };
1303        let next = if forward {
1304            (at + 1).min(tabs.len() - 1)
1305        } else {
1306            at.saturating_sub(1)
1307        };
1308        if next != at {
1309            self.show_quality_tab(tabs[next]);
1310        }
1311    }
1312
1313    pub fn cycle_quality_metric(&mut self) {
1314        let current = QualityMetric::ALL
1315            .iter()
1316            .position(|metric| *metric == self.data_quality_metric)
1317            .unwrap_or(0);
1318        self.data_quality_metric = QualityMetric::ALL[(current + 1) % QualityMetric::ALL.len()];
1319    }
1320
1321    /// The plan the result on screen was measured with; the plan until a run
1322    /// exists. Result pages read this one, so a draft never relabels what was
1323    /// measured.
1324    pub fn quality_result_plan(&self) -> &DataQualityPlan {
1325        self.data_quality_last_plan
1326            .as_ref()
1327            .unwrap_or(&self.data_quality_plan)
1328    }
1329
1330    /// The plan differs from the one the result on screen was measured with.
1331    pub fn quality_plan_pending(&self) -> bool {
1332        self.data_quality_results.is_some()
1333            && self.data_quality_last_plan.as_ref() != Some(&self.data_quality_plan)
1334    }
1335
1336    /// The Setup row under the cursor.
1337    pub fn setup_row(&self) -> SetupRow {
1338        SetupRow::at(self.data_quality_plan_field)
1339    }
1340
1341    /// Whether Setup holds staged changes Esc would discard.
1342    pub fn setup_edited(&self) -> bool {
1343        self.data_quality_setup_before
1344            .as_ref()
1345            .is_some_and(|before| *before != self.data_quality_plan)
1346    }
1347
1348    /// The choices a Setup row offers, in the words the header uses.
1349    pub fn plan_choices(&self, row: SetupRow, context: &PlanContext) -> Vec<(String, PlanChoice)> {
1350        match row {
1351            SetupRow::Grain => {
1352                let mut grains = vec![QualityGrain::Dataset];
1353                if context.files {
1354                    grains.push(QualityGrain::File);
1355                }
1356                grains.extend(
1357                    context
1358                        .partitions
1359                        .iter()
1360                        .cloned()
1361                        .map(QualityGrain::Partition),
1362                );
1363                // Any date column can split the rows by day, week or month; no role
1364                // has to be invented for it first. Hours only where there are times.
1365                for (column, has_time) in &context.time_columns {
1366                    for every in QUALITY_WINDOW_WIDTHS {
1367                        if every == "1h" && !has_time {
1368                            continue;
1369                        }
1370                        grains.push(QualityGrain::TimeWindows {
1371                            column: column.clone(),
1372                            every: every.to_string(),
1373                        });
1374                    }
1375                }
1376                grains.extend([100_000, 1_000_000].map(QualityGrain::RowChunks));
1377                if !grains.contains(&self.data_quality_plan.grain) {
1378                    grains.insert(0, self.data_quality_plan.grain.clone());
1379                }
1380                grains
1381                    .into_iter()
1382                    .map(|grain| (grain.label(), PlanChoice::Grain(grain)))
1383                    .collect()
1384            }
1385            SetupRow::Values => {
1386                // The draft's sample says which kind of read it is.
1387                let read =
1388                    if self.data_quality_plan.method == crate::sampling::SampleMethod::EveryRow {
1389                        QualityCompute::Full
1390                    } else {
1391                        QualityCompute::Sample
1392                    };
1393                vec![
1394                    ("read".to_string(), PlanChoice::Values(read)),
1395                    (
1396                        "file metadata only".to_string(),
1397                        PlanChoice::Values(QualityCompute::Metadata),
1398                    ),
1399                ]
1400            }
1401            SetupRow::Compare => [
1402                QualityComparison::None,
1403                QualityComparison::Previous,
1404                QualityComparison::Baseline,
1405            ]
1406            .into_iter()
1407            .map(|comparison| {
1408                (
1409                    comparison.choice_label().to_string(),
1410                    PlanChoice::Compare(comparison),
1411                )
1412            })
1413            .collect(),
1414            SetupRow::WindowBy if !self.data_quality_plan.windows_intervals() => Vec::new(),
1415            SetupRow::WindowBy => IntervalClock::ALL
1416                .into_iter()
1417                .map(|clock| (clock.label().to_string(), PlanChoice::Clock(clock)))
1418                .collect(),
1419            SetupRow::Latency if self.data_quality_plan.interval_pairs().is_empty() => Vec::new(),
1420            SetupRow::Latency => [None, Some(3_600), Some(86_400), Some(604_800)]
1421                .into_iter()
1422                .map(|seconds| {
1423                    (
1424                        threshold_label(seconds).to_string(),
1425                        PlanChoice::Latency(seconds),
1426                    )
1427                })
1428                .collect(),
1429            // Each text column with the first value on screen, so choosing which one
1430            // holds times is choosing among things seen.
1431            SetupRow::TextAsTime => context
1432                .text_columns
1433                .iter()
1434                .map(|(column, examples)| {
1435                    let label = match (self.data_quality_plan.time_format(column), examples.first())
1436                    {
1437                        (Some(format), _) => format!("{column}  as {}", format.label()),
1438                        (None, Some(example)) => format!("{column}  {example}"),
1439                        (None, None) => column.clone(),
1440                    };
1441                    (label, PlanChoice::TextColumn(column.clone()))
1442                })
1443                .collect(),
1444            SetupRow::Sample
1445            | SetupRow::TimeRoles
1446            | SetupRow::Intervals
1447            | SetupRow::Expected
1448            | SetupRow::Intent => Vec::new(),
1449        }
1450    }
1451
1452    /// Open the focused Setup row's choices, the current one selected.
1453    pub fn open_plan_picker(&mut self, row: SetupRow, context: &PlanContext) {
1454        let choices = self.plan_choices(row, context);
1455        self.data_quality_plan_field = row.index();
1456        self.show_picker(row, row.label().to_string(), choices);
1457    }
1458
1459    fn show_picker(&mut self, row: SetupRow, title: String, choices: Vec<(String, PlanChoice)>) {
1460        if choices.is_empty() {
1461            return;
1462        }
1463        let current = choices
1464            .iter()
1465            .position(|(_, choice)| choice.is_current(&self.data_quality_plan))
1466            .unwrap_or(0);
1467        let (labels, choices): (Vec<_>, Vec<_>) = choices.into_iter().unzip();
1468        let mut state = crate::widgets::ui::PickerState::new(labels);
1469        state.select_original(current);
1470        self.data_quality_picker = Some(PlanPicker {
1471            row: Some(row),
1472            title,
1473            choices,
1474            state,
1475        });
1476    }
1477
1478    /// How `column` can be read as time: each format with how many of `examples`,
1479    /// values on screen, it reads, the formats that read the most first; and, when
1480    /// it has a format, reading it as text again.
1481    pub fn open_format_picker(&mut self, column: &str, examples: &[String]) {
1482        let mut formats = TIME_FORMATS
1483            .iter()
1484            .enumerate()
1485            .map(|(order, (kind, format))| {
1486                let interpretation = TimeInterpretation {
1487                    column: column.to_string(),
1488                    kind: *kind,
1489                    format: format.to_string(),
1490                };
1491                let read = examples
1492                    .iter()
1493                    .filter(|value| interpretation.reads(value))
1494                    .count();
1495                (read, order, *kind, *format)
1496            })
1497            .collect::<Vec<_>>();
1498        // Most read first; the offered order among equals, so the list is stable.
1499        formats.sort_by_key(|(read, order, _, _)| (std::cmp::Reverse(*read), *order));
1500        let mut choices = formats
1501            .into_iter()
1502            .map(|(read, _, kind, format)| {
1503                // The count first, so a narrow list clips the format, not the
1504                // evidence for choosing it.
1505                let label = if examples.is_empty() {
1506                    format!("{} {format}", kind.label())
1507                } else {
1508                    format!(
1509                        "reads {read} of {}  {} {format}",
1510                        examples.len(),
1511                        kind.label()
1512                    )
1513                };
1514                (
1515                    label,
1516                    PlanChoice::Format(column.to_string(), Some((kind, format))),
1517                )
1518            })
1519            .collect::<Vec<_>>();
1520        if self.data_quality_plan.time_format(column).is_some() {
1521            choices.push((
1522                "text, not a time".to_string(),
1523                PlanChoice::Format(column.to_string(), None),
1524            ));
1525        }
1526        self.show_picker(SetupRow::TextAsTime, format!("Read {column} As"), choices);
1527    }
1528
1529    /// Take the picker's selection into the plan and close it. A text column chosen
1530    /// to read as time comes back: its format is the next choice.
1531    pub fn choose_plan_picker(&mut self) -> Option<String> {
1532        let picker = self.data_quality_picker.take()?;
1533        let choice = picker
1534            .state
1535            .selected_original()
1536            .and_then(|index| picker.choices.get(index))?;
1537        let plan = &mut self.data_quality_plan;
1538        match choice.clone() {
1539            PlanChoice::Grain(grain) => {
1540                if plan.grain != grain {
1541                    plan.baseline_segment = None;
1542                }
1543                plan.grain = grain;
1544            }
1545            PlanChoice::Values(compute) => plan.compute = compute,
1546            PlanChoice::Compare(comparison) => {
1547                plan.comparison = comparison;
1548                if comparison != QualityComparison::Baseline {
1549                    plan.baseline_segment = None;
1550                }
1551            }
1552            PlanChoice::Latency(seconds) => plan.latency_threshold_seconds = seconds,
1553            PlanChoice::Clock(clock) => plan.interval_clock = clock,
1554            PlanChoice::TextColumn(column) => return Some(column),
1555            PlanChoice::Format(column, format) => set_time_format(plan, &column, format),
1556            PlanChoice::FindingColumn(column) => {
1557                let selected = self.selected_finding();
1558                self.data_quality_findings.column = column;
1559                self.reselect_finding(selected.map(|(_, finding)| finding));
1560            }
1561            PlanChoice::FindingCheck(check) => {
1562                let selected = self.selected_finding();
1563                self.data_quality_findings.check = check;
1564                self.reselect_finding(selected.map(|(_, finding)| finding));
1565            }
1566        }
1567        None
1568    }
1569
1570    /// The next or previous choice of a Setup row whose choices are a short list,
1571    /// in place: ←→ on Grain, Compare, Values, Latency and Window by.
1572    pub fn cycle_setup_choice(&mut self, row: SetupRow, context: &PlanContext, forward: bool) {
1573        let choices = self.plan_choices(row, context);
1574        if choices.is_empty() || matches!(row, SetupRow::TextAsTime) {
1575            return;
1576        }
1577        let current = choices
1578            .iter()
1579            .position(|(_, choice)| choice.is_current(&self.data_quality_plan))
1580            .unwrap_or(0);
1581        let next = if forward {
1582            (current + 1).min(choices.len() - 1)
1583        } else {
1584            current.saturating_sub(1)
1585        };
1586        let (labels, choices): (Vec<_>, Vec<_>) = choices.into_iter().unzip();
1587        let mut state = crate::widgets::ui::PickerState::new(labels);
1588        state.select_original(next);
1589        self.data_quality_picker = Some(PlanPicker {
1590            row: Some(row),
1591            title: row.label().to_string(),
1592            choices,
1593            state,
1594        });
1595        self.choose_plan_picker();
1596    }
1597
1598    pub fn cycle_quality_time_role(
1599        &mut self,
1600        role_index: usize,
1601        columns: &[String],
1602        forward: bool,
1603    ) {
1604        let Some(role) = TemporalRole::ALL.get(role_index).copied() else {
1605            return;
1606        };
1607        let current = self
1608            .data_quality_plan
1609            .temporal_roles
1610            .iter()
1611            .find(|assignment| assignment.role == role)
1612            .and_then(|assignment| columns.iter().position(|name| name == &assignment.column))
1613            .map(|index| index + 1)
1614            .unwrap_or(0);
1615        let choices = columns.len() + 1;
1616        let next = if forward {
1617            (current + 1) % choices
1618        } else if current == 0 {
1619            choices - 1
1620        } else {
1621            current - 1
1622        };
1623        self.data_quality_plan
1624            .temporal_roles
1625            .retain(|assignment| assignment.role != role);
1626        if next > 0 {
1627            self.data_quality_plan
1628                .temporal_roles
1629                .push(TemporalRoleAssignment {
1630                    role,
1631                    column: columns[next - 1].clone(),
1632                    timezone: None,
1633                });
1634        }
1635    }
1636
1637    pub fn next_row(&mut self, max_rows: usize) {
1638        if self.focus == AnalysisFocus::Sidebar {
1639            self.next_tool();
1640            return;
1641        }
1642        match self.selected_tool {
1643            Some(AnalysisTool::Describe) => {
1644                if let Some(current) = self.table_state.selected() {
1645                    let next = (current + 1).min(max_rows.saturating_sub(1));
1646                    self.table_state.select(Some(next));
1647                } else {
1648                    self.table_state.select(Some(0));
1649                }
1650            }
1651            Some(AnalysisTool::DistributionAnalysis) => {
1652                if let Some(current) = self.distribution_table_state.selected() {
1653                    let next = (current + 1).min(max_rows.saturating_sub(1));
1654                    self.distribution_table_state.select(Some(next));
1655                    self.selected_distribution = Some(next);
1656                } else {
1657                    self.distribution_table_state.select(Some(0));
1658                    self.selected_distribution = Some(0);
1659                }
1660            }
1661            Some(AnalysisTool::CorrelationMatrix) => {
1662                if let Some((row, col)) = self.selected_correlation {
1663                    let next_row = (row + 1).min(max_rows.saturating_sub(1));
1664                    self.selected_correlation = Some((next_row, col));
1665                    self.correlation_table_state.select(Some(next_row));
1666                }
1667            }
1668            Some(AnalysisTool::DataQuality) => {
1669                let current = self.data_quality_table_state.selected().unwrap_or(0);
1670                self.data_quality_table_state
1671                    .select(Some((current + 1).min(max_rows.saturating_sub(1))));
1672            }
1673            None => {}
1674        }
1675    }
1676
1677    pub fn previous_row(&mut self) {
1678        if self.focus == AnalysisFocus::Sidebar {
1679            self.previous_tool();
1680            return;
1681        }
1682        match self.selected_tool {
1683            Some(AnalysisTool::Describe) => {
1684                if let Some(current) = self.table_state.selected()
1685                    && current > 0
1686                {
1687                    self.table_state.select(Some(current - 1));
1688                }
1689            }
1690            Some(AnalysisTool::DistributionAnalysis) => {
1691                if let Some(current) = self.distribution_table_state.selected()
1692                    && current > 0
1693                {
1694                    let prev = current - 1;
1695                    self.distribution_table_state.select(Some(prev));
1696                    self.selected_distribution = Some(prev);
1697                }
1698            }
1699            Some(AnalysisTool::CorrelationMatrix) => {
1700                if let Some((row, col)) = self.selected_correlation
1701                    && row > 0
1702                {
1703                    let prev_row = row - 1;
1704                    self.selected_correlation = Some((prev_row, col));
1705                    self.correlation_table_state.select(Some(prev_row));
1706                }
1707            }
1708            Some(AnalysisTool::DataQuality) => {
1709                let current = self.data_quality_table_state.selected().unwrap_or(0);
1710                self.data_quality_table_state
1711                    .select(Some(current.saturating_sub(1)));
1712            }
1713            None => {}
1714        }
1715    }
1716
1717    pub fn page_down(&mut self, max_rows: usize, page_size: usize) {
1718        if self.focus == AnalysisFocus::Sidebar {
1719            return;
1720        }
1721
1722        match self.selected_tool {
1723            Some(AnalysisTool::Describe) => {
1724                if let Some(current) = self.table_state.selected() {
1725                    let next = (current + page_size).min(max_rows.saturating_sub(1));
1726                    self.table_state.select(Some(next));
1727                }
1728            }
1729            Some(AnalysisTool::DistributionAnalysis) => {
1730                if let Some(current) = self.distribution_table_state.selected() {
1731                    let next = (current + page_size).min(max_rows.saturating_sub(1));
1732                    self.distribution_table_state.select(Some(next));
1733                    self.selected_distribution = Some(next);
1734                }
1735            }
1736            Some(AnalysisTool::CorrelationMatrix) => {
1737                if let Some((row, col)) = self.selected_correlation {
1738                    let next_row = (row + page_size).min(max_rows.saturating_sub(1));
1739                    self.selected_correlation = Some((next_row, col));
1740                    self.correlation_table_state.select(Some(next_row));
1741                }
1742            }
1743            Some(AnalysisTool::DataQuality) => {
1744                let current = self.data_quality_table_state.selected().unwrap_or(0);
1745                self.data_quality_table_state
1746                    .select(Some((current + page_size).min(max_rows.saturating_sub(1))));
1747            }
1748            None => {}
1749        }
1750    }
1751
1752    pub fn page_up(&mut self, page_size: usize) {
1753        if self.focus == AnalysisFocus::Sidebar {
1754            return;
1755        }
1756
1757        match self.selected_tool {
1758            Some(AnalysisTool::Describe) => {
1759                if let Some(current) = self.table_state.selected() {
1760                    let next = current.saturating_sub(page_size);
1761                    self.table_state.select(Some(next));
1762                }
1763            }
1764            Some(AnalysisTool::DistributionAnalysis) => {
1765                if let Some(current) = self.distribution_table_state.selected() {
1766                    let next = current.saturating_sub(page_size);
1767                    self.distribution_table_state.select(Some(next));
1768                    self.selected_distribution = Some(next);
1769                }
1770            }
1771            Some(AnalysisTool::CorrelationMatrix) => {
1772                if let Some((row, col)) = self.selected_correlation {
1773                    let prev_row = row.saturating_sub(page_size);
1774                    self.selected_correlation = Some((prev_row, col));
1775                    self.correlation_table_state.select(Some(prev_row));
1776                }
1777            }
1778            Some(AnalysisTool::DataQuality) => {
1779                let current = self.data_quality_table_state.selected().unwrap_or(0);
1780                self.data_quality_table_state
1781                    .select(Some(current.saturating_sub(page_size)));
1782            }
1783            None => {}
1784        }
1785    }
1786
1787    /// The matrix a run read. The cursor stays where it was when it is still a cell
1788    /// of it; otherwise it starts on the first pair, off the diagonal, where Enter
1789    /// has a detail to open.
1790    pub fn install_correlations(&mut self, results: AnalysisResults) {
1791        let n = results
1792            .correlation_matrix
1793            .as_ref()
1794            .map_or(0, |matrix| matrix.columns.len());
1795        self.correlation_results = Some(results);
1796        let cell = match self.selected_correlation {
1797            Some((row, col)) if row < n && col < n => (row, col),
1798            _ if n >= 2 => (0, 1),
1799            _ => (0, 0),
1800        };
1801        self.selected_correlation = Some(cell);
1802        self.correlation_table_state.select(Some(cell.0));
1803    }
1804
1805    /// The number of columns in the correlation matrix on screen.
1806    pub fn correlation_size(&self) -> usize {
1807        self.correlation_results
1808            .as_ref()
1809            .and_then(|results| results.correlation_matrix.as_ref())
1810            .map_or(0, |matrix| matrix.columns.len())
1811    }
1812
1813    /// The families the distribution detail's selector lists for the column under
1814    /// the cursor.
1815    pub fn distribution_choices(&self) -> usize {
1816        let row = self.distribution_table_state.selected().unwrap_or(0);
1817        self.distribution_results
1818            .as_ref()
1819            .and_then(|results| results.distribution_analyses.get(row))
1820            .map_or(0, |analysis| {
1821                crate::distribution_fit::listing_order(&analysis.fits).len()
1822            })
1823    }
1824
1825    /// Move the correlation cursor by `(rows, columns)`, stopping at the edges. The
1826    /// matrix scrolls to keep the cell in view as it draws, from the width it has.
1827    pub fn move_correlation_cell(&mut self, (rows, cols): (isize, isize)) {
1828        let n = self.correlation_size();
1829        if n == 0 {
1830            return;
1831        }
1832        if let Some((row, col)) = self.selected_correlation {
1833            let row = row.saturating_add_signed(rows).min(n - 1);
1834            let col = col.saturating_add_signed(cols).min(n - 1);
1835            self.selected_correlation = Some((row, col));
1836            self.correlation_table_state.select(Some(row));
1837        }
1838    }
1839
1840    pub fn next_distribution(&mut self) {
1841        let max_idx = self.distribution_choices().saturating_sub(1);
1842
1843        if let Some(current) = self.distribution_selector_state.selected() {
1844            let next = (current + 1).min(max_idx);
1845            self.distribution_selector_state.select(Some(next));
1846            self.select_distribution();
1847        } else {
1848            self.distribution_selector_state.select(Some(0));
1849            self.select_distribution();
1850        }
1851    }
1852
1853    pub fn previous_distribution(&mut self) {
1854        if let Some(current) = self.distribution_selector_state.selected() {
1855            if current > 0 {
1856                self.distribution_selector_state.select(Some(current - 1));
1857                self.select_distribution();
1858            }
1859        } else {
1860            self.distribution_selector_state.select(Some(0));
1861            self.select_distribution();
1862        }
1863    }
1864
1865    pub fn select_distribution(&mut self) {
1866        if let Some(idx) = self.distribution_selector_state.selected()
1867            && let Some(results) = &self.distribution_results
1868        {
1869            let dist_analysis_idx = self.distribution_table_state.selected().unwrap_or(0);
1870            if let Some(dist_analysis) = results.distribution_analyses.get(dist_analysis_idx) {
1871                // The order the selector lists them in.
1872                let distribution_scores =
1873                    crate::distribution_fit::listing_order(&dist_analysis.fits);
1874                let valid_idx = idx.min(distribution_scores.len().saturating_sub(1));
1875                if let Some(dist_type) = distribution_scores.get(valid_idx) {
1876                    self.selected_theoretical_distribution = *dist_type;
1877                    if idx != valid_idx {
1878                        self.distribution_selector_state.select(Some(valid_idx));
1879                    }
1880                }
1881            }
1882        }
1883    }
1884}
1885
1886#[cfg(test)]
1887mod quality_scope_tests {
1888    use super::*;
1889
1890    /// Weekdays stated for days, then a coarser grain: the editor offers what the
1891    /// grain allows and shows what Setup and the check read, every week.
1892    #[test]
1893    fn expected_weekdays_read_as_every_window_on_weeks() {
1894        let theme =
1895            crate::config::Theme::from_config(&crate::config::ThemeConfig::default()).unwrap();
1896        let mut plan = DataQualityPlan {
1897            grain: QualityGrain::TimeWindows {
1898                column: "day".to_string(),
1899                every: "1d".to_string(),
1900            },
1901            expected: Some(crate::data_quality::ExpectedWindows {
1902                weekdays: true,
1903                ..Default::default()
1904            }),
1905            ..DataQualityPlan::default()
1906        };
1907        assert_eq!(
1908            ExpectedForm::new(&plan, &theme).cadence,
1909            ExpectedCadence::Weekdays
1910        );
1911        plan.grain = QualityGrain::TimeWindows {
1912            column: "day".to_string(),
1913            every: "1w".to_string(),
1914        };
1915        let form = ExpectedForm::new(&plan, &theme);
1916        assert_eq!(form.cadence, ExpectedCadence::Every);
1917        assert_eq!(form.cadence_label("1w"), "every week");
1918        assert!(!form.expected().unwrap().unwrap().weekdays);
1919    }
1920
1921    /// Grain choices come from the data: files, partition columns, and a day, week
1922    /// or month of any date column (hours only where there are times), then chunks.
1923    #[test]
1924    fn grain_choices_come_from_the_data() {
1925        let mut modal = AnalysisModal::new();
1926        let context = PlanContext {
1927            partitions: vec!["year".to_string()],
1928            time_columns: vec![("date".to_string(), false), ("stamp".to_string(), true)],
1929            files: true,
1930            text_columns: Vec::new(),
1931        };
1932        let labels = modal
1933            .plan_choices(SetupRow::Grain, &context)
1934            .into_iter()
1935            .map(|(label, _)| label)
1936            .collect::<Vec<_>>();
1937        assert_eq!(
1938            labels,
1939            [
1940                "whole dataset",
1941                "by file",
1942                "by year",
1943                "by day of date",
1944                "by week of date",
1945                "by month of date",
1946                "by hour of stamp",
1947                "by day of stamp",
1948                "by week of stamp",
1949                "by month of stamp",
1950                "in chunks of 100,000 rows",
1951                "in chunks of 1,000,000 rows",
1952            ]
1953        );
1954        // Choosing opens the list on the current value and sets the one chosen.
1955        modal.open_plan_picker(SetupRow::Grain, &context);
1956        let picker = modal.data_quality_picker.as_mut().unwrap();
1957        assert_eq!(picker.state.selected_original(), Some(0));
1958        picker.state.move_down();
1959        picker.state.move_down();
1960        picker.state.move_down();
1961        modal.choose_plan_picker();
1962        assert!(modal.data_quality_picker.is_none());
1963        assert_eq!(
1964            modal.data_quality_plan.grain,
1965            QualityGrain::TimeWindows {
1966                column: "date".to_string(),
1967                every: "1d".to_string()
1968            }
1969        );
1970    }
1971
1972    /// Values: read or metadata only; which of sample and full scan a read is, the
1973    /// shared sample says.
1974    #[test]
1975    fn a_read_is_the_samples_kind() {
1976        let mut modal = AnalysisModal::new();
1977        let context = PlanContext::default();
1978        modal.data_quality_plan.compute = QualityCompute::Metadata;
1979        modal.open_plan_picker(SetupRow::Values, &context);
1980        modal
1981            .data_quality_picker
1982            .as_mut()
1983            .unwrap()
1984            .state
1985            .select_original(0);
1986        modal.choose_plan_picker();
1987        assert_eq!(modal.data_quality_plan.compute, QualityCompute::Sample);
1988        modal.data_quality_plan.method = crate::sampling::SampleMethod::EveryRow;
1989        modal.open_plan_picker(SetupRow::Values, &context);
1990        modal.choose_plan_picker();
1991        assert_eq!(modal.data_quality_plan.compute, QualityCompute::Full);
1992        modal.open_plan_picker(SetupRow::Latency, &context);
1993        assert!(
1994            modal.data_quality_picker.is_none(),
1995            "no threshold without an interval to measure"
1996        );
1997    }
1998
1999    /// A text column is read as time in two choices, the column and then its
2000    /// format, the formats that read the values on screen first; reading it as text
2001    /// again takes back a time window that needed it.
2002    #[test]
2003    fn text_is_read_as_time_through_a_chosen_format() {
2004        let mut modal = AnalysisModal::new();
2005        let context = PlanContext {
2006            text_columns: vec![(
2007                "created".to_string(),
2008                vec!["2024-01-31 08:15:00".to_string()],
2009            )],
2010            ..PlanContext::default()
2011        };
2012        modal.open_plan_picker(SetupRow::TextAsTime, &context);
2013        assert_eq!(modal.choose_plan_picker(), Some("created".to_string()));
2014        modal.open_format_picker("created", &["2024-01-31 08:15:00".to_string()]);
2015        let picker = modal.data_quality_picker.as_ref().unwrap();
2016        assert_eq!(picker.title, "Read created As");
2017        let first = picker.state.filtered()[0].1.to_string();
2018        assert_eq!(first, "reads 1 of 1  datetime %Y-%m-%d %H:%M:%S");
2019        assert_eq!(modal.choose_plan_picker(), None);
2020        let format = modal.data_quality_plan.time_format("created").unwrap();
2021        assert_eq!(format.kind, TimeKind::Datetime);
2022
2023        // Now a time window can split by it; as text again, the window goes.
2024        let windows = PlanContext {
2025            time_columns: vec![("created".to_string(), true)],
2026            ..context.clone()
2027        };
2028        modal.open_plan_picker(SetupRow::Grain, &windows);
2029        let picker = modal.data_quality_picker.as_mut().unwrap();
2030        picker.state.move_down();
2031        picker.state.move_down();
2032        modal.choose_plan_picker();
2033        assert!(matches!(
2034            modal.data_quality_plan.grain,
2035            QualityGrain::TimeWindows { .. }
2036        ));
2037        modal.open_format_picker("created", &[]);
2038        let picker = modal.data_quality_picker.as_mut().unwrap();
2039        let text = picker
2040            .state
2041            .filtered()
2042            .iter()
2043            .position(|(_, label)| *label == "text, not a time")
2044            .unwrap();
2045        for _ in 0..text {
2046            picker.state.move_down();
2047        }
2048        modal.choose_plan_picker();
2049        assert!(modal.data_quality_plan.time_formats.is_empty());
2050        assert_eq!(modal.data_quality_plan.grain, QualityGrain::Dataset);
2051    }
2052}