Skip to main content

datui_lib/analysis/
sample_modal.rs

1//! The Sample form: the one place an analysis's rows are chosen, for every tool.
2//! It edits a copy of the shared [`Sample`]; Enter applies it, Esc discards it.
3//!
4//! Every row is a setting whose value names itself: which rows ("All rows (36.8M)",
5//! "Partitions", "Files", ...), how they are picked, how many, and the seed. Picking
6//! a kind of rows shows only that kind's inputs, with the context they need (the
7//! partition values that exist, the numbered files) and nothing else.
8
9use crate::analysis::data_quality::QualityScope;
10use crate::analysis::sampling::{Sample, SampleMethod};
11use crate::widgets::text_input::TextInput;
12
13/// Which rows the sample is drawn from.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum RowsKind {
16    /// The table as shown, query and filters applied.
17    All,
18    /// The loaded source, ignoring the query and filters. Offered only when those
19    /// make it differ from the table as shown.
20    Source,
21    Partitions,
22    Files,
23    Range,
24    Time,
25}
26
27/// A row of the form. Only the rows the chosen kind and method need are shown.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum SampleField {
30    Rows,
31    PartitionColumn,
32    PartitionValues,
33    Files,
34    RangeFrom,
35    RangeTo,
36    TimeColumn,
37    TimeFrom,
38    TimeBefore,
39    Method,
40    By,
41    Size,
42    Seed,
43}
44
45impl SampleField {
46    pub fn label(self) -> &'static str {
47        match self {
48            Self::Rows => "Rows from:",
49            Self::PartitionColumn => "Partition:",
50            Self::PartitionValues => "Values:",
51            Self::Files => "Files:",
52            Self::RangeFrom => "From row:",
53            Self::RangeTo => "To row:",
54            Self::TimeColumn => "Column:",
55            Self::TimeFrom => "From:",
56            Self::TimeBefore => "Before:",
57            Self::Method => "Method:",
58            Self::By => "Per value of:",
59            Self::Size => "Sample size:",
60            Self::Seed => "Random seed:",
61        }
62    }
63
64    /// Whether the row is typed into rather than stepped with ←/→.
65    pub fn is_text(self) -> bool {
66        matches!(
67            self,
68            Self::PartitionValues
69                | Self::Files
70                | Self::RangeFrom
71                | Self::RangeTo
72                | Self::TimeFrom
73                | Self::TimeBefore
74                | Self::Size
75                | Self::Seed
76        )
77    }
78}
79
80/// What the table knows that the form's choices depend on.
81#[derive(Debug, Clone, Default)]
82pub struct SampleContext {
83    /// Rows in the table as shown, when counted.
84    pub view_rows: Option<usize>,
85    /// Whether a query, filter or reshape makes the table differ from its source.
86    pub filtered: bool,
87    /// The source's files, in inventory order.
88    pub files: Vec<String>,
89    pub partition_columns: Vec<String>,
90    /// Values a partition column is known to hold, from the source's directory
91    /// names, when there is no file inventory to read them from.
92    pub partition_values: Vec<(String, Vec<String>)>,
93    pub time_columns: Vec<String>,
94    /// Columns an equal-per-value sample can split by.
95    pub value_columns: Vec<String>,
96}
97
98pub struct SampleForm {
99    /// Before a tool's first run the form sits in its empty pane, and Enter is what
100    /// runs it; otherwise it floats over a result, and Enter applies a change.
101    pub inline: bool,
102    /// The form edits the view's sample, the step under its query: Every row is
103    /// "No sample" there, which takes the sample away.
104    pub view: bool,
105    /// Bytes a row of the view takes, every column, as the table measured them:
106    /// what the Size row's estimate of a sample of the view is worked out from.
107    pub bytes_per_row: Option<usize>,
108    /// The same of a row of the source, every column it has: a sample of the
109    /// source (partitions, files, a time range, the source unfiltered) reads them.
110    pub source_bytes_per_row: Option<usize>,
111    /// The memory warning has been shown for the form as it stands: Enter again
112    /// draws anyway. Any edit takes it back.
113    pub anyway: bool,
114    pub draft: Sample,
115    pub kind: RowsKind,
116    pub field: SampleField,
117    pub context: SampleContext,
118    pub partition_column: usize,
119    pub partition_values: TextInput,
120    pub files: TextInput,
121    pub range_from: TextInput,
122    pub range_to: TextInput,
123    pub time_column: usize,
124    pub time_from: TextInput,
125    pub time_before: TextInput,
126    /// The sample size as typed: `100,000`, `50k`, `2m`. Read on Enter.
127    pub size: TextInput,
128    /// Any number: the same seed draws the same rows, so 0 or 1 is a sample anyone
129    /// can repeat.
130    pub seed: TextInput,
131    pub error: Option<String>,
132    /// First source file shown in the numbered list under the Files row.
133    pub file_offset: usize,
134}
135
136impl SampleForm {
137    pub fn new(sample: &Sample, context: SampleContext, theme: &crate::config::Theme) -> Self {
138        let input = || TextInput::new().with_theme(theme);
139        let mut form = Self {
140            inline: false,
141            view: false,
142            bytes_per_row: None,
143            source_bytes_per_row: None,
144            anyway: false,
145            draft: sample.clone(),
146            kind: RowsKind::All,
147            field: SampleField::Rows,
148            context,
149            partition_column: 0,
150            partition_values: input(),
151            files: input(),
152            range_from: input(),
153            range_to: input(),
154            time_column: 0,
155            time_from: input(),
156            time_before: input(),
157            size: input(),
158            seed: input(),
159            error: None,
160            file_offset: 0,
161        };
162        form.set_scope(&sample.scope);
163        // Most seeds come from the clock, and a seed is a token, not text to
164        // extend: typing one means a new one.
165        form.seed.suggest(sample.seed.to_string());
166        // A size is replaced more often than edited, as the seed is.
167        form.size.suggest(crate::numfmt::group_chrome(sample.rows));
168        form.sync_focus(true);
169        form
170    }
171
172    /// Load a scope into the form's rows: its kind, and that kind's inputs.
173    pub fn set_scope(&mut self, scope: &QualityScope) {
174        self.kind = match scope {
175            QualityScope::CurrentView => RowsKind::All,
176            QualityScope::WholeSource => RowsKind::Source,
177            QualityScope::FirstRows(end) => {
178                self.range_from.set_value("1");
179                self.range_to.set_value(end.to_string());
180                RowsKind::Range
181            }
182            QualityScope::ViewRows { start, end } => {
183                self.range_from.set_value(start.to_string());
184                self.range_to.set_value(end.to_string());
185                RowsKind::Range
186            }
187            QualityScope::SourceFiles(files) => {
188                self.files.set_value(
189                    files
190                        .iter()
191                        .map(usize::to_string)
192                        .collect::<Vec<_>>()
193                        .join(","),
194                );
195                RowsKind::Files
196            }
197            QualityScope::SourcePartition { column, value } => {
198                if !self.context.partition_columns.contains(column) {
199                    self.context.partition_columns.insert(0, column.clone());
200                }
201                self.partition_column = self
202                    .context
203                    .partition_columns
204                    .iter()
205                    .position(|name| name == column)
206                    .unwrap_or(0);
207                self.partition_values.set_value(value);
208                RowsKind::Partitions
209            }
210            QualityScope::SourceTimeRange { column, start, end } => {
211                if !self.context.time_columns.contains(column) {
212                    self.context.time_columns.insert(0, column.clone());
213                }
214                self.time_column = self
215                    .context
216                    .time_columns
217                    .iter()
218                    .position(|name| name == column)
219                    .unwrap_or(0);
220                self.time_from.set_value(start);
221                self.time_before.set_value(end);
222                RowsKind::Time
223            }
224        };
225    }
226
227    /// The kinds of rows this table offers, in the order ←/→ steps them.
228    pub fn kinds(&self) -> Vec<RowsKind> {
229        let mut kinds = vec![RowsKind::All];
230        if self.context.filtered {
231            kinds.push(RowsKind::Source);
232        }
233        if !self.context.partition_columns.is_empty() {
234            kinds.push(RowsKind::Partitions);
235        }
236        if self.context.files.len() > 1 {
237            kinds.push(RowsKind::Files);
238        }
239        kinds.push(RowsKind::Range);
240        if !self.context.time_columns.is_empty() {
241            kinds.push(RowsKind::Time);
242        }
243        if !kinds.contains(&self.kind) {
244            kinds.push(self.kind);
245        }
246        kinds
247    }
248
249    /// The rows on screen, top to bottom.
250    pub fn fields(&self) -> Vec<SampleField> {
251        // No sample has no rows to choose.
252        if self.no_sample() {
253            return vec![SampleField::Method];
254        }
255        let mut fields = vec![SampleField::Rows];
256        fields.extend(match self.kind {
257            RowsKind::All | RowsKind::Source => vec![],
258            RowsKind::Partitions => {
259                vec![SampleField::PartitionColumn, SampleField::PartitionValues]
260            }
261            RowsKind::Files => vec![SampleField::Files],
262            RowsKind::Range => vec![SampleField::RangeFrom, SampleField::RangeTo],
263            RowsKind::Time => vec![
264                SampleField::TimeColumn,
265                SampleField::TimeFrom,
266                SampleField::TimeBefore,
267            ],
268        });
269        fields.push(SampleField::Method);
270        if matches!(self.draft.method, SampleMethod::PerPartition { .. }) {
271            fields.push(SampleField::By);
272        }
273        if self.draft.method != SampleMethod::EveryRow {
274            fields.push(SampleField::Size);
275        }
276        if matches!(
277            self.draft.method,
278            SampleMethod::Spread | SampleMethod::PerPartition { .. }
279        ) {
280            fields.push(SampleField::Seed);
281        }
282        fields
283    }
284
285    pub fn input(&self, field: SampleField) -> Option<&TextInput> {
286        Some(match field {
287            SampleField::PartitionValues => &self.partition_values,
288            SampleField::Files => &self.files,
289            SampleField::RangeFrom => &self.range_from,
290            SampleField::RangeTo => &self.range_to,
291            SampleField::TimeFrom => &self.time_from,
292            SampleField::TimeBefore => &self.time_before,
293            SampleField::Size => &self.size,
294            SampleField::Seed => &self.seed,
295            _ => return None,
296        })
297    }
298
299    pub fn input_mut(&mut self, field: SampleField) -> Option<&mut TextInput> {
300        Some(match field {
301            SampleField::PartitionValues => &mut self.partition_values,
302            SampleField::Files => &mut self.files,
303            SampleField::RangeFrom => &mut self.range_from,
304            SampleField::RangeTo => &mut self.range_to,
305            SampleField::TimeFrom => &mut self.time_from,
306            SampleField::TimeBefore => &mut self.time_before,
307            SampleField::Size => &mut self.size,
308            SampleField::Seed => &mut self.seed,
309            _ => return None,
310        })
311    }
312
313    /// Show the text cursor in the focused text row only, and only while the form
314    /// has the cursor at all.
315    pub fn sync_focus(&mut self, form_focused: bool) {
316        let field = self.field;
317        for candidate in [
318            SampleField::PartitionValues,
319            SampleField::Files,
320            SampleField::RangeFrom,
321            SampleField::RangeTo,
322            SampleField::TimeFrom,
323            SampleField::TimeBefore,
324            SampleField::Size,
325            SampleField::Seed,
326        ] {
327            if let Some(input) = self.input_mut(candidate) {
328                input.set_focused(form_focused && candidate == field);
329            }
330        }
331    }
332
333    /// ←/→ on the focused row. Each ring steps both ways, so Left undoes Right.
334    pub fn adjust(&mut self, forward: bool) {
335        let step = |len: usize, at: usize| {
336            if forward {
337                (at + 1) % len
338            } else {
339                (at + len - 1) % len
340            }
341        };
342        match self.field {
343            SampleField::Rows => {
344                let kinds = self.kinds();
345                let at = kinds
346                    .iter()
347                    .position(|kind| *kind == self.kind)
348                    .unwrap_or(0);
349                self.kind = kinds[step(kinds.len(), at)];
350                // A range starts as the whole table, so its rows say what they cover.
351                if self.kind == RowsKind::Range && self.range_to.value().is_empty() {
352                    self.range_from.suggest("1");
353                    if let Some(rows) = self.context.view_rows {
354                        self.range_to.suggest(rows.to_string());
355                    }
356                }
357            }
358            SampleField::PartitionColumn if !self.context.partition_columns.is_empty() => {
359                self.partition_column =
360                    step(self.context.partition_columns.len(), self.partition_column);
361            }
362            SampleField::TimeColumn if !self.context.time_columns.is_empty() => {
363                self.time_column = step(self.context.time_columns.len(), self.time_column);
364            }
365            SampleField::Method => {
366                let by = match &self.draft.method {
367                    SampleMethod::PerPartition { column } => Some(column.clone()),
368                    _ => None,
369                }
370                .or_else(|| self.context.value_columns.first().cloned());
371                let mut methods = vec![SampleMethod::Spread];
372                if let Some(column) = by {
373                    methods.push(SampleMethod::PerPartition { column });
374                }
375                methods.push(SampleMethod::FirstRows);
376                methods.push(SampleMethod::EveryRow);
377                let at = methods
378                    .iter()
379                    .position(|method| {
380                        std::mem::discriminant(method) == std::mem::discriminant(&self.draft.method)
381                    })
382                    .unwrap_or(0);
383                self.draft.method = methods[step(methods.len(), at)].clone();
384                // Rows per value, not in all: a size meant for the whole table would
385                // keep nearly every row of every value.
386                if matches!(self.draft.method, SampleMethod::PerPartition { .. })
387                    && self.typed_size().unwrap_or(self.draft.rows) > PER_VALUE_ROWS
388                {
389                    self.draft.rows = PER_VALUE_ROWS;
390                    self.size
391                        .suggest(crate::numfmt::group_chrome(PER_VALUE_ROWS));
392                }
393            }
394            SampleField::By => {
395                let columns = &self.context.value_columns;
396                if let SampleMethod::PerPartition { column } = &self.draft.method
397                    && !columns.is_empty()
398                {
399                    let at = columns.iter().position(|name| name == column).unwrap_or(0);
400                    self.draft.method = SampleMethod::PerPartition {
401                        column: columns[step(columns.len(), at)].clone(),
402                    };
403                }
404            }
405            _ => {}
406        }
407    }
408
409    /// The choice a ←/→ row shows, in words.
410    pub fn choice(&self, field: SampleField) -> String {
411        match field {
412            SampleField::Rows => match self.kind {
413                RowsKind::All => {
414                    let count = self
415                        .context
416                        .view_rows
417                        .map(|rows| format!(" ({})", crate::home::discover::format_rows(rows)))
418                        .unwrap_or_default();
419                    if self.context.filtered {
420                        format!("All rows as filtered{count}")
421                    } else {
422                        format!("All rows{count}")
423                    }
424                }
425                RowsKind::Source => "The source, unfiltered".to_string(),
426                RowsKind::Partitions => "Partitions".to_string(),
427                RowsKind::Files => "Files".to_string(),
428                RowsKind::Range => "Row range".to_string(),
429                RowsKind::Time => "Time range".to_string(),
430            },
431            SampleField::PartitionColumn => self
432                .context
433                .partition_columns
434                .get(self.partition_column)
435                .cloned()
436                .unwrap_or_default(),
437            SampleField::TimeColumn => self
438                .context
439                .time_columns
440                .get(self.time_column)
441                .cloned()
442                .unwrap_or_default(),
443            SampleField::Method => match &self.draft.method {
444                SampleMethod::EveryRow if self.view => "No sample".to_string(),
445                method => method.name().to_string(),
446            },
447            SampleField::By => match &self.draft.method {
448                SampleMethod::PerPartition { column } => column.clone(),
449                _ => String::new(),
450            },
451            SampleField::Seed => self.draft.seed.to_string(),
452            _ => String::new(),
453        }
454    }
455
456    /// The values of the chosen partition column that the source's files hold, from
457    /// their `column=value` path segments, in order.
458    pub fn partition_values_known(&self) -> Vec<String> {
459        let Some(column) = self.context.partition_columns.get(self.partition_column) else {
460            return Vec::new();
461        };
462        if let Some((_, values)) = self
463            .context
464            .partition_values
465            .iter()
466            .find(|(name, _)| name == column)
467        {
468            return values.clone();
469        }
470        let prefix = format!("{column}=");
471        let mut values: Vec<String> = Vec::new();
472        for file in &self.context.files {
473            for segment in file.split(['/', '\\']) {
474                if let Some(value) = segment.strip_prefix(&prefix)
475                    && !values.iter().any(|known| known == value)
476                {
477                    values.push(value.to_string());
478                }
479            }
480        }
481        values
482    }
483
484    /// Refuse partition values the source does not hold, when it is known what it
485    /// holds: a typo would otherwise match no rows and sample nothing, silently. A
486    /// range's ends need only be the same kind of value (2015..2030 is fine for
487    /// years), since a range is compared, not matched.
488    fn check_partition_values(&self, column: &str, values: &str) -> Result<(), String> {
489        let known = self.partition_values_known();
490        if known.is_empty() {
491            return Ok(());
492        }
493        let holds = match known.as_slice() {
494            [only] => only.clone(),
495            [first, .., last] => format!("{first} {} {last}", crate::glyphs::get().ellipsis),
496            [] => String::new(),
497        };
498        if let Some((start, end)) = values.split_once("..") {
499            let numeric = known.iter().all(|value| value.parse::<f64>().is_ok());
500            let dated = known
501                .iter()
502                .all(|value| chrono::NaiveDate::parse_from_str(value, "%Y-%m-%d").is_ok());
503            let fits = |end: &str| {
504                let end = end.trim();
505                !end.is_empty()
506                    && (!numeric || end.parse::<f64>().is_ok())
507                    && (!dated || chrono::NaiveDate::parse_from_str(end, "%Y-%m-%d").is_ok())
508            };
509            if !fits(start) || !fits(end) {
510                return Err(format!(
511                    "A {column} range runs between two of its values, like {}..{}; it holds {holds}",
512                    known[0],
513                    known[known.len() - 1]
514                ));
515            }
516            return Ok(());
517        }
518        for value in values.split(',').map(str::trim).filter(|v| !v.is_empty()) {
519            if value != "∅" && !known.iter().any(|known| known == value) {
520                return Err(format!(
521                    "{column} has no partition {value:?}; it holds {holds}"
522                ));
523            }
524        }
525        Ok(())
526    }
527
528    /// The form takes the view's sample away: No sample is chosen.
529    pub fn no_sample(&self) -> bool {
530        self.view && self.draft.method == SampleMethod::EveryRow
531    }
532
533    /// Rows the sample will hold, when that can be told before it is drawn: the
534    /// size, or the rows there are when fewer. Not for an equal-per-value sample,
535    /// whose size is per value.
536    pub fn rows_expected(&self) -> Option<usize> {
537        let asked = match self.draft.method {
538            SampleMethod::Spread | SampleMethod::FirstRows => self.typed_size()?,
539            SampleMethod::EveryRow => self.context.view_rows?,
540            SampleMethod::PerPartition { .. } => return None,
541        };
542        let there = match self.kind {
543            RowsKind::All => self.context.view_rows,
544            _ => None,
545        };
546        Some(there.map_or(asked, |rows| asked.min(rows)))
547    }
548
549    /// The bytes the sample will take, from the bytes per row of what it reads.
550    pub fn estimate(&self) -> Option<u64> {
551        let rows = self.rows_expected()? as u64;
552        let per_row = match self.kind {
553            RowsKind::All | RowsKind::Range => self.bytes_per_row?,
554            _ => self.source_bytes_per_row?,
555        };
556        Some(rows.saturating_mul(per_row as u64))
557    }
558
559    /// An edit: what was said about the form as it stood no longer stands.
560    pub fn edited(&mut self) {
561        self.error = None;
562        self.anyway = false;
563    }
564
565    /// The sample size typed, when it reads as one.
566    fn typed_size(&self) -> Option<usize> {
567        crate::analysis::sampling::parse_size(self.size.value()).ok()
568    }
569
570    /// The file numbers typed so far, for marking the list under the Files row.
571    pub fn files_chosen(&self) -> Vec<usize> {
572        self.files
573            .value()
574            .split(',')
575            .filter_map(|part| part.trim().parse().ok())
576            .collect()
577    }
578
579    /// The draft with the rows the form describes, or why it cannot say which.
580    pub fn finish(&self) -> Result<Sample, String> {
581        let scope = match self.kind {
582            RowsKind::All => QualityScope::CurrentView,
583            RowsKind::Source => QualityScope::WholeSource,
584            RowsKind::Range => {
585                let number = |input: &TextInput| {
586                    input
587                        .value()
588                        .replace([',', '_', ' '], "")
589                        .parse::<usize>()
590                        .ok()
591                };
592                match (number(&self.range_from), number(&self.range_to)) {
593                    (Some(from), Some(to)) if from >= 1 && to >= from => {
594                        QualityScope::parse_command(&format!("rows {from}..{to}"))
595                            .map_err(|e| e.to_string())?
596                    }
597                    _ => {
598                        return Err(
599                            "From row and To row are row numbers, From at least 1 and not past To"
600                                .to_string(),
601                        );
602                    }
603                }
604            }
605            RowsKind::Files => {
606                let chosen = self.files_chosen();
607                let count = self.context.files.len();
608                if chosen.is_empty() {
609                    return Err("Type the numbers of the files to read, like 1,3".to_string());
610                }
611                if let Some(file) = chosen.iter().find(|file| **file == 0 || **file > count) {
612                    return Err(format!(
613                        "There is no file {file}; the files are 1 to {count}"
614                    ));
615                }
616                QualityScope::parse_command(&format!(
617                    "files {}",
618                    chosen
619                        .iter()
620                        .map(usize::to_string)
621                        .collect::<Vec<_>>()
622                        .join(",")
623                ))
624                .map_err(|e| e.to_string())?
625            }
626            RowsKind::Partitions => {
627                let column = self.choice(SampleField::PartitionColumn);
628                let values = self.partition_values.value().trim();
629                if values.is_empty() {
630                    return Err(format!("Type the {column} values to read"));
631                }
632                self.check_partition_values(&column, values)?;
633                QualityScope::parse_command(&format!("partition {column}={values}"))
634                    .map_err(|e| e.to_string())?
635            }
636            RowsKind::Time => {
637                let column = self.choice(SampleField::TimeColumn);
638                let (from, before) = (
639                    self.time_from.value().trim(),
640                    self.time_before.value().trim(),
641                );
642                QualityScope::parse_command(&format!("time {column}={from}..{before}")).map_err(
643                    |_| {
644                        "From and Before are dates (2024-01-31) or timestamps, From first"
645                            .to_string()
646                    },
647                )?
648            }
649        };
650        let mut sample = Sample {
651            scope,
652            ..self.draft.clone()
653        };
654        if self.fields().contains(&SampleField::Size) {
655            sample.rows = crate::analysis::sampling::parse_size(self.size.value())
656                .map_err(|e| e.to_string())?
657                .min(u32::MAX as usize);
658        }
659        if self.fields().contains(&SampleField::Seed) {
660            sample.seed = self
661                .seed
662                .value()
663                .replace([',', '_', ' '], "")
664                .parse()
665                .map_err(|_| "Random seed is a whole number, like 0 or 42".to_string())?;
666        }
667        Ok(sample)
668    }
669}
670
671/// Rows per value an equal-per-value sample starts at.
672const PER_VALUE_ROWS: usize = 1_000;
673
674/// A seed from the clock: another sample each time it is asked for. Six digits are
675/// plenty to tell samples apart and short enough to read back or type into a note.
676pub fn new_seed() -> u64 {
677    std::time::SystemTime::now()
678        .duration_since(std::time::UNIX_EPOCH)
679        .unwrap_or_default()
680        .as_nanos() as u64
681        % 1_000_000
682}
683
684impl crate::app::form::Form for SampleForm {
685    type Field = SampleField;
686
687    fn fields(&self) -> Vec<(SampleField, crate::app::form::FieldKind)> {
688        use crate::app::form::FieldKind;
689        SampleForm::fields(self)
690            .into_iter()
691            .map(|field| {
692                let kind = if field.is_text() {
693                    FieldKind::Text
694                } else {
695                    FieldKind::Choice
696                };
697                (field, kind)
698            })
699            .collect()
700    }
701
702    fn focused(&self) -> SampleField {
703        self.field
704    }
705
706    fn set_focused(&mut self, field: SampleField) {
707        self.field = field;
708        // Arriving on the seed selects it, so typing 1 makes it 1 rather than
709        // appending to the number already there.
710        if field == SampleField::Seed {
711            self.seed.select_all();
712        }
713        if field == SampleField::Size {
714            self.size.select_all();
715        }
716        self.sync_focus(true);
717    }
718}
719
720#[cfg(test)]
721mod tests {
722    use super::*;
723
724    fn form() -> SampleForm {
725        SampleForm::new(
726            &Sample::default(),
727            SampleContext {
728                view_rows: Some(36_800_000),
729                filtered: false,
730                files: (2016..2019)
731                    .map(|year| format!("/data/weather/year={year}/0.parquet"))
732                    .collect(),
733                partition_columns: vec!["year".to_string()],
734                partition_values: Vec::new(),
735                time_columns: vec!["date".to_string()],
736                value_columns: vec!["station".to_string()],
737            },
738            &crate::config::Theme::from_config(&crate::config::ThemeConfig::default()).unwrap(),
739        )
740    }
741
742    /// The first row says what it will read in words, and each kind brings only its
743    /// own inputs.
744    #[test]
745    fn each_kind_of_rows_shows_only_its_inputs() {
746        let mut form = form();
747        assert_eq!(form.choice(SampleField::Rows), "All rows (36.8M)");
748        assert_eq!(form.fields()[1], SampleField::Method);
749        form.adjust(true);
750        assert_eq!(form.kind, RowsKind::Partitions);
751        assert_eq!(
752            form.fields()[1..3],
753            [SampleField::PartitionColumn, SampleField::PartitionValues]
754        );
755        assert_eq!(form.partition_values_known(), vec!["2016", "2017", "2018"]);
756        form.adjust(true);
757        assert_eq!(form.kind, RowsKind::Files);
758        form.adjust(true);
759        assert_eq!(form.kind, RowsKind::Range);
760        assert_eq!(
761            (form.range_from.value(), form.range_to.value()),
762            ("1", "36800000"),
763            "a range starts as the whole table"
764        );
765        form.adjust(false);
766        assert_eq!(form.kind, RowsKind::Files, "Left undoes Right");
767        assert!(
768            !form.kinds().contains(&RowsKind::Source),
769            "unfiltered is the same as all"
770        );
771    }
772
773    #[test]
774    fn method_steps_both_ways_and_rows_appear_with_it() {
775        let mut form = form();
776        form.field = SampleField::Method;
777        form.adjust(true);
778        assert_eq!(
779            form.draft.method,
780            SampleMethod::PerPartition {
781                column: "station".to_string()
782            }
783        );
784        assert_eq!(form.size.value(), "1,000");
785        assert!(form.fields().contains(&SampleField::By));
786        form.adjust(true);
787        assert_eq!(form.draft.method, SampleMethod::FirstRows);
788        assert!(
789            !form.fields().contains(&SampleField::Seed),
790            "the head has no seed"
791        );
792        form.adjust(true);
793        assert_eq!(form.draft.method, SampleMethod::EveryRow);
794        assert!(!form.fields().contains(&SampleField::Size));
795        form.adjust(false);
796        assert_eq!(form.draft.method, SampleMethod::FirstRows);
797    }
798
799    /// The Files row is typed into, so its PgUp/PgDn come back as its own keys:
800    /// the handler pages the numbered file list on `Text(Files)`, never `Other`.
801    #[test]
802    fn the_files_row_keeps_its_paging_keys() {
803        use crate::app::form::{Form, FormKey};
804        use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
805        let mut form = form();
806        while form.kind != RowsKind::Files {
807            form.adjust(true);
808        }
809        assert!(form.focus(SampleField::Files));
810        for code in [KeyCode::PageDown, KeyCode::PageUp] {
811            assert_eq!(
812                crate::app::form::key(&mut form, &KeyEvent::new(code, KeyModifiers::NONE)),
813                FormKey::Text(SampleField::Files)
814            );
815        }
816    }
817
818    /// The size is typed, in shorthand or in full, and read on Enter; a size it
819    /// cannot read says why and is not applied.
820    #[test]
821    fn the_size_is_typed() {
822        let mut form = form();
823        assert_eq!(form.size.value(), "100,000");
824        while form.field != SampleField::Size {
825            crate::app::form::Form::focus_next(&mut form);
826        }
827        let key = |c| {
828            crossterm::event::KeyEvent::new(
829                crossterm::event::KeyCode::Char(c),
830                crossterm::event::KeyModifiers::NONE,
831            )
832        };
833        for c in "250k".chars() {
834            form.size.handle_key(&key(c), None);
835        }
836        assert_eq!(form.size.value(), "250k", "typing replaces the size");
837        assert_eq!(form.finish().unwrap().rows, 250_000);
838        form.size.set_value("2M");
839        assert_eq!(form.finish().unwrap().rows, 2_000_000);
840        form.size.set_value("lots");
841        assert!(form.finish().unwrap_err().contains("50k"));
842        form.size.set_value("0");
843        assert!(form.finish().is_err());
844        // Every row has no size to read.
845        form.field = SampleField::Method;
846        while form.draft.method != SampleMethod::EveryRow {
847            form.adjust(true);
848        }
849        assert!(form.finish().is_ok());
850    }
851
852    /// Any number is a seed, typed over the one there; the same seed is the same
853    /// sample, so 0 is one anyone can repeat.
854    #[test]
855    fn the_seed_is_typed() {
856        let mut form = form();
857        while form.field != SampleField::Seed {
858            crate::app::form::Form::focus_next(&mut form);
859        }
860        assert!(form.field.is_text());
861        let zero = crossterm::event::KeyEvent::new(
862            crossterm::event::KeyCode::Char('0'),
863            crossterm::event::KeyModifiers::NONE,
864        );
865        form.seed.handle_key(&zero, None);
866        assert_eq!(form.finish().unwrap().seed, 0);
867        form.seed.set_value("seven");
868        assert!(form.finish().unwrap_err().contains("whole number"));
869    }
870
871    #[test]
872    fn the_rows_described_become_a_scope() {
873        let mut form = form();
874        form.set_scope(&QualityScope::parse_command("partition year=2016..2017").unwrap());
875        assert_eq!(form.kind, RowsKind::Partitions);
876        assert_eq!(
877            form.finish().unwrap().scope,
878            QualityScope::SourcePartition {
879                column: "year".to_string(),
880                value: "2016..2017".to_string()
881            }
882        );
883        form.kind = RowsKind::Range;
884        form.range_from.set_value("2");
885        form.range_to.set_value("5,000");
886        assert_eq!(
887            form.finish().unwrap().scope,
888            QualityScope::ViewRows {
889                start: 2,
890                end: 5_000
891            }
892        );
893        form.kind = RowsKind::Files;
894        form.files.set_value("1, 4");
895        assert!(form.finish().unwrap_err().contains("no file 4"));
896    }
897
898    /// A value the source does not hold is a typo, not an empty sample.
899    #[test]
900    fn partition_values_the_source_does_not_hold_are_refused() {
901        let mut form = form();
902        form.kind = RowsKind::Partitions;
903        for garbage in ["asdf", "2016,20x7", "a..b", "2016.."] {
904            form.partition_values.set_value(garbage);
905            let error = form.finish().unwrap_err();
906            assert!(error.contains("2016"), "{garbage}: {error}");
907        }
908        for fine in ["2017", "2016,2018", "2015..2030"] {
909            form.partition_values.set_value(fine);
910            assert!(form.finish().is_ok(), "{fine}");
911        }
912    }
913}