use crate::data_quality::{
DataQualityPlan, DataQualityResults, IntervalClock, IntervalFact, QUALITY_WINDOW_WIDTHS,
QualityComparison, QualityCompute, QualityGrain, QualityMetric, QualityPage, TIME_FORMATS,
TemporalRole, TemporalRoleAssignment, TimeInterpretation, TimeKind,
};
use crate::quality_report::{EvidenceRows, Finding, FindingsView, QualityReport};
use crate::statistics::{AnalysisResults, DistributionType};
use ratatui::widgets::TableState;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SetupRow {
Sample,
TextAsTime,
TimeRoles,
Intervals,
Intent,
Grain,
Expected,
Compare,
Values,
Latency,
WindowBy,
}
impl SetupRow {
pub const ALL: [Self; 11] = [
Self::Sample,
Self::TextAsTime,
Self::TimeRoles,
Self::Intervals,
Self::Intent,
Self::Grain,
Self::Expected,
Self::Compare,
Self::Values,
Self::Latency,
Self::WindowBy,
];
pub fn label(self) -> &'static str {
match self {
Self::Sample => "Sample",
Self::TextAsTime => "Text as time",
Self::TimeRoles => "Time roles",
Self::Intervals => "Intervals",
Self::Intent => "Column intent",
Self::Grain => "Grain",
Self::Expected => "Expected",
Self::Compare => "Compare",
Self::Values => "Values",
Self::Latency => "Latency over",
Self::WindowBy => "Window by",
}
}
pub fn at(index: usize) -> Self {
Self::ALL[index.min(Self::ALL.len() - 1)]
}
pub fn index(self) -> usize {
Self::ALL.iter().position(|row| *row == self).unwrap_or(0)
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum PlanChoice {
Grain(QualityGrain),
Values(QualityCompute),
Compare(QualityComparison),
Latency(Option<i64>),
Clock(IntervalClock),
TextColumn(String),
Format(String, Option<(TimeKind, &'static str)>),
FindingColumn(Option<String>),
FindingCheck(Option<&'static str>),
}
impl PlanChoice {
fn is_current(&self, plan: &DataQualityPlan) -> bool {
match self {
Self::Grain(grain) => &plan.grain == grain,
Self::Values(QualityCompute::Metadata) => plan.compute == QualityCompute::Metadata,
Self::Values(_) => plan.compute != QualityCompute::Metadata,
Self::Compare(comparison) => &plan.comparison == comparison,
Self::Latency(seconds) => &plan.latency_threshold_seconds == seconds,
Self::Clock(clock) => plan.interval_clock == *clock,
Self::TextColumn(column) => plan.time_format(column).is_some(),
Self::Format(column, format) => {
plan.time_format(column)
.map(|current| (current.kind, current.format.as_str()))
== format.map(|(kind, format)| (kind, format))
}
Self::FindingColumn(_) | Self::FindingCheck(_) => false,
}
}
}
#[derive(Debug, Clone)]
pub struct PlanPicker {
pub row: Option<SetupRow>,
pub title: String,
pub choices: Vec<PlanChoice>,
pub state: crate::widgets::ui::PickerState,
}
#[derive(Debug, Clone, Default)]
pub struct PlanContext {
pub partitions: Vec<String>,
pub time_columns: Vec<(String, bool)>,
pub files: bool,
pub text_columns: Vec<(String, Vec<String>)>,
}
#[derive(Debug, Clone)]
pub struct EvidenceRead {
pub rows: EvidenceRows,
pub label: String,
pub sample: Option<crate::sampling::Sample>,
pub scope: crate::data_quality::QualityScope,
pub summary: Vec<(&'static str, String)>,
}
pub fn threshold_label(seconds: Option<i64>) -> &'static str {
match seconds {
None => "none",
Some(3_600) => "1 hour",
Some(86_400) => "1 day",
Some(604_800) => "1 week",
Some(_) => "custom",
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExpectedCadence {
None,
Every,
Weekdays,
}
pub const EXPECTED_ROWS: [&str; 3] = ["Windows", "From", "Before"];
#[derive(Debug, Clone)]
pub struct ExpectedForm {
pub field: usize,
pub cadence: ExpectedCadence,
pub from: crate::widgets::text_input::TextInput,
pub before: crate::widgets::text_input::TextInput,
pub error: Option<String>,
}
impl ExpectedForm {
pub fn new(plan: &DataQualityPlan, theme: &crate::config::Theme) -> Self {
let input = || crate::widgets::text_input::TextInput::new().with_theme(theme);
let (mut from, mut before) = (input(), input());
let cadence = match &plan.expected {
None => ExpectedCadence::None,
Some(expected) => {
from.set_value(expected.from.as_deref().unwrap_or_default());
before.set_value(expected.before.as_deref().unwrap_or_default());
let every = match &plan.grain {
crate::data_quality::QualityGrain::TimeWindows { every, .. } => every.as_str(),
_ => "",
};
if expected.weekdays && crate::data_quality::ExpectedWindows::weekdays_apply(every)
{
ExpectedCadence::Weekdays
} else {
ExpectedCadence::Every
}
}
};
let mut form = Self {
field: 0,
cadence,
from,
before,
error: None,
};
form.sync_focus();
form
}
pub fn cadences(every: &str) -> Vec<ExpectedCadence> {
let mut cadences = vec![ExpectedCadence::None, ExpectedCadence::Every];
if crate::data_quality::ExpectedWindows::weekdays_apply(every) {
cadences.push(ExpectedCadence::Weekdays);
}
cadences
}
pub fn cadence_label(&self, every: &str) -> String {
match self.cadence {
ExpectedCadence::None => "none: no window is a gap".to_string(),
ExpectedCadence::Every => {
crate::data_quality::ExpectedWindows::default().cadence_label(every)
}
ExpectedCadence::Weekdays => "weekdays, Monday to Friday".to_string(),
}
}
pub fn cycle(&mut self, every: &str, forward: bool) {
let cadences = Self::cadences(every);
let at = cadences
.iter()
.position(|cadence| *cadence == self.cadence)
.unwrap_or(0);
let next = if forward {
(at + 1) % cadences.len()
} else {
(at + cadences.len() - 1) % cadences.len()
};
self.cadence = cadences[next];
self.error = None;
}
pub fn typing(&self) -> bool {
self.field > 0
}
pub fn input_mut(&mut self) -> Option<&mut crate::widgets::text_input::TextInput> {
match self.field {
1 => Some(&mut self.from),
2 => Some(&mut self.before),
_ => None,
}
}
pub fn sync_focus(&mut self) {
self.from.set_focused(self.field == 1);
self.before.set_focused(self.field == 2);
}
pub fn expected(&self) -> Result<Option<crate::data_quality::ExpectedWindows>, String> {
if self.cadence == ExpectedCadence::None {
return Ok(None);
}
let typed = |input: &crate::widgets::text_input::TextInput| {
let text = input.value().trim();
(!text.is_empty()).then(|| text.to_string())
};
let expected = crate::data_quality::ExpectedWindows {
weekdays: self.cadence == ExpectedCadence::Weekdays,
from: typed(&self.from),
before: typed(&self.before),
};
match expected.problem() {
Some(problem) => Err(problem),
None => Ok(Some(expected)),
}
}
}
impl crate::form::Form for ExpectedForm {
type Field = usize;
fn fields(&self) -> Vec<(usize, crate::form::FieldKind)> {
use crate::form::FieldKind;
(0..EXPECTED_ROWS.len())
.map(|row| {
let kind = if row == 0 {
FieldKind::Choice
} else {
FieldKind::Text
};
(row, kind)
})
.collect()
}
fn focused(&self) -> usize {
self.field
}
fn set_focused(&mut self, field: usize) {
self.field = field;
self.sync_focus();
}
}
pub fn set_time_format(plan: &mut DataQualityPlan, column: &str, format: Option<(TimeKind, &str)>) {
plan.time_formats
.retain(|interpretation| interpretation.column != column);
match format {
Some((kind, format)) => plan.time_formats.push(TimeInterpretation {
column: column.to_string(),
kind,
format: format.to_string(),
}),
None => {
if matches!(&plan.grain, QualityGrain::TimeWindows { column: on, .. } if on == column) {
plan.grain = QualityGrain::Dataset;
plan.baseline_segment = None;
}
}
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum AnalysisView {
#[default]
Main, DistributionDetail, CorrelationDetail, }
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum AnalysisTool {
#[default]
Describe, DistributionAnalysis, CorrelationMatrix, DataQuality, }
impl AnalysisTool {
pub const ALL: [Self; 4] = [
Self::Describe,
Self::DistributionAnalysis,
Self::CorrelationMatrix,
Self::DataQuality,
];
pub fn index(self) -> usize {
Self::ALL.iter().position(|tool| *tool == self).unwrap_or(0)
}
}
#[derive(Debug, Clone)]
pub struct AnalysisProgress {
pub phase: String,
pub started: std::time::Instant,
pub reads_source: Option<bool>,
pub interruptible: Option<bool>,
pub read: Option<crate::sampling::ReadWatch>,
pub reuse: Option<String>,
}
impl AnalysisProgress {
pub fn new(phase: &str) -> Self {
Self {
phase: phase.to_string(),
started: std::time::Instant::now(),
reads_source: None,
interruptible: None,
read: None,
reuse: None,
}
}
pub fn read_runs_out(&self) -> bool {
self.reads_source == Some(true) && self.interruptible == Some(false)
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum AnalysisFocus {
#[default]
Main, Sidebar, DistributionSelector, }
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct DetailScroll {
pub offset: u16,
pub max: u16,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ColumnScroll {
pub offset: usize,
pub max: usize,
}
impl ColumnScroll {
pub fn left(&mut self) {
self.offset = self.offset.min(self.max).saturating_sub(1);
}
pub fn right(&mut self) {
if self.offset < self.max {
self.offset += 1;
}
}
}
#[derive(Default)]
pub struct AnalysisModal {
pub active: bool,
pub scroll_position: usize,
pub selected_column: Option<usize>,
pub describe_columns: ColumnScroll,
pub distribution_columns: ColumnScroll,
pub correlation_columns: ColumnScroll,
pub sample: crate::sampling::Sample,
pub sample_dataset: Option<u64>,
pub sample_run_for: Option<u64>,
pub sample_form: Option<crate::sample_modal::SampleForm>,
pub own_sample: Option<crate::sampling::Sample>,
pub table_state: TableState, pub distribution_table_state: TableState, pub correlation_table_state: TableState, pub sidebar_state: TableState, pub describe_results: Option<AnalysisResults>,
pub distribution_results: Option<AnalysisResults>,
pub correlation_results: Option<AnalysisResults>,
pub data_quality_results: Option<DataQualityResults>,
pub computing: Option<AnalysisProgress>,
pub view: AnalysisView,
pub focus: AnalysisFocus,
pub selected_tool: Option<AnalysisTool>,
pub selected_distribution: Option<usize>, pub selected_correlation: Option<(usize, usize)>, pub correlation_method: crate::statistics::CorrelationMethod,
pub selected_theoretical_distribution: DistributionType, pub distribution_selector_state: TableState, pub histogram_scale: HistogramScale, pub data_quality_page: QualityPage,
pub data_quality_plan: DataQualityPlan,
pub data_quality_setup_before: Option<DataQualityPlan>,
pub data_quality_setup_return: QualityPage,
pub data_quality_setup_note: Option<String>,
pub data_quality_table_state: TableState,
pub data_quality_picker: Option<PlanPicker>,
pub data_quality_plan_field: usize,
pub data_quality_show_access: bool,
pub data_quality_observation_detail: bool,
pub data_quality_detail_scroll: DetailScroll,
pub data_quality_segment_index: usize,
pub data_quality_segments_by_change: bool,
pub data_quality_checks_expanded: bool,
pub data_quality_confirm_run: bool,
pub data_quality_plan_before_edit: Option<DataQualityPlan>,
pub data_quality_last_plan: Option<DataQualityPlan>,
pub data_quality_from_cache: bool,
pub data_quality_metric: QualityMetric,
pub data_quality_column_index: usize,
pub data_quality_interval_index: usize,
pub data_quality_findings: FindingsView,
pub data_quality_evidence_read: Option<EvidenceRead>,
pub data_quality_trend_line: usize,
pub data_quality_expected_form: Option<ExpectedForm>,
pub data_quality_intent_form: Option<crate::intent_modal::IntentForm>,
pub data_quality_export: Option<crate::quality_export::ExportForm>,
pub results_view: Option<u64>,
kept: Option<Kept>,
}
struct Kept {
view: Option<u64>,
tool: Option<AnalysisTool>,
describe: Option<AnalysisResults>,
distribution: Option<AnalysisResults>,
correlation: Option<AnalysisResults>,
row: Option<usize>,
distribution_row: Option<usize>,
cell: Option<(usize, usize)>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum HistogramScale {
#[default]
Linear,
Log,
}
impl AnalysisModal {
pub fn new() -> Self {
Self::default()
}
pub fn with_sample_rows(rows: usize) -> Self {
let mut modal = Self::default();
modal.sample.seed = crate::sample_modal::new_seed();
if rows == 0 {
modal.sample.method = crate::sampling::SampleMethod::EveryRow;
} else {
modal.sample.rows = rows;
}
modal
}
pub fn open(&mut self, view: Option<u64>) {
let kept = self
.kept
.take()
.filter(|kept| view.is_some() && kept.view == view);
self.results_view = view;
self.active = true;
self.scroll_position = 0;
self.selected_column = None;
self.describe_columns = ColumnScroll::default();
self.distribution_columns = ColumnScroll::default();
self.correlation_columns = ColumnScroll::default();
self.table_state.select(Some(0));
self.distribution_table_state.select(Some(0));
self.correlation_table_state.select(Some(0));
self.sidebar_state.select(Some(0)); self.view = AnalysisView::Main;
self.focus = AnalysisFocus::Sidebar; self.selected_tool = None; self.selected_distribution = Some(0);
self.selected_correlation = None;
self.computing = None;
self.describe_results = None;
self.distribution_results = None;
self.correlation_results = None;
self.data_quality_results = None;
self.data_quality_page = QualityPage::Setup;
self.data_quality_plan = DataQualityPlan::default();
self.data_quality_setup_before = None;
self.data_quality_setup_return = QualityPage::Overview;
self.data_quality_setup_note = None;
self.data_quality_table_state.select(Some(0));
self.data_quality_picker = None;
self.data_quality_plan_field = 0;
self.data_quality_show_access = false;
self.data_quality_observation_detail = false;
self.data_quality_confirm_run = false;
self.data_quality_plan_before_edit = None;
self.data_quality_last_plan = None;
self.data_quality_from_cache = false;
self.data_quality_metric = QualityMetric::NullRate;
self.data_quality_column_index = 0;
self.data_quality_interval_index = 0;
self.data_quality_trend_line = 0;
self.data_quality_expected_form = None;
self.data_quality_intent_form = None;
self.data_quality_export = None;
self.sample_form = None;
if let Some(kept) = kept {
self.describe_results = kept.describe;
self.distribution_results = kept.distribution;
self.correlation_results = kept.correlation;
self.selected_tool = kept.tool;
if self.current_results().is_none() {
self.selected_tool = None;
}
if let Some(tool) = self.selected_tool {
self.sidebar_state.select(Some(tool.index()));
}
self.table_state.select(kept.row.or(Some(0)));
self.distribution_table_state
.select(kept.distribution_row.or(Some(0)));
self.selected_distribution = kept.distribution_row.or(Some(0));
if let Some(cell) = kept.cell {
self.selected_correlation = Some(cell);
self.correlation_table_state.select(Some(cell.0));
}
}
if self.correlation_results.is_none() {
self.selected_correlation = None;
}
}
pub fn close(&mut self) {
self.kept = Some(Kept {
view: self.results_view.take(),
tool: self
.selected_tool
.filter(|tool| *tool != AnalysisTool::DataQuality),
describe: self.describe_results.take(),
distribution: self.distribution_results.take(),
correlation: self.correlation_results.take(),
row: self.table_state.selected(),
distribution_row: self.distribution_table_state.selected(),
cell: self.selected_correlation,
});
self.active = false;
self.scroll_position = 0;
self.selected_column = None;
self.describe_columns = ColumnScroll::default();
self.distribution_columns = ColumnScroll::default();
self.correlation_columns = ColumnScroll::default();
self.view = AnalysisView::Main;
self.focus = AnalysisFocus::Main;
self.selected_tool = None;
self.selected_distribution = None;
self.selected_correlation = None;
self.computing = None;
self.describe_results = None;
self.distribution_results = None;
self.correlation_results = None;
self.data_quality_results = None;
self.data_quality_page = QualityPage::Setup;
self.data_quality_setup_before = None;
self.data_quality_setup_note = None;
self.data_quality_picker = None;
self.data_quality_show_access = false;
self.data_quality_observation_detail = false;
self.data_quality_confirm_run = false;
self.data_quality_plan_before_edit = None;
self.data_quality_last_plan = None;
self.data_quality_from_cache = false;
self.data_quality_metric = QualityMetric::NullRate;
self.data_quality_column_index = 0;
self.data_quality_interval_index = 0;
self.data_quality_trend_line = 0;
self.data_quality_expected_form = None;
self.data_quality_intent_form = None;
self.data_quality_export = None;
}
pub fn current_results(&self) -> Option<&AnalysisResults> {
match self.selected_tool {
Some(AnalysisTool::Describe) => self.describe_results.as_ref(),
Some(AnalysisTool::DistributionAnalysis) => self.distribution_results.as_ref(),
Some(AnalysisTool::CorrelationMatrix) => self.correlation_results.as_ref(),
Some(AnalysisTool::DataQuality) => None,
None => None,
}
}
pub fn switch_focus(&mut self) {
self.focus = match self.focus {
AnalysisFocus::Sidebar if self.selected_tool.is_some() => AnalysisFocus::Main,
_ => AnalysisFocus::Sidebar,
};
}
pub fn highlighted_tool(&self) -> Option<AnalysisTool> {
AnalysisTool::ALL
.get(self.sidebar_state.selected()?)
.copied()
}
pub fn select_tool(&mut self) {
if self.sidebar_state.selected().is_some() {
self.selected_tool = Some(self.highlighted_tool().unwrap_or_default());
}
}
pub fn next_tool(&mut self) {
if let Some(current) = self.sidebar_state.selected() {
let next = (current + 1).min(AnalysisTool::ALL.len() - 1);
self.sidebar_state.select(Some(next));
}
}
pub fn previous_tool(&mut self) {
if let Some(current) = self.sidebar_state.selected()
&& current > 0
{
self.sidebar_state.select(Some(current - 1));
}
}
pub fn open_distribution_detail(&mut self) {
if self.focus == AnalysisFocus::Main
&& self.selected_tool == Some(AnalysisTool::DistributionAnalysis)
&& let Some(idx) = self.distribution_table_state.selected()
{
if let Some(results) = &self.distribution_results
&& let Some(dist_analysis) = results.distribution_analyses.get(idx)
{
self.selected_theoretical_distribution = dist_analysis.distribution_type;
}
self.view = AnalysisView::DistributionDetail;
self.focus = AnalysisFocus::DistributionSelector;
if self.selected_theoretical_distribution == DistributionType::Unknown {
self.selected_theoretical_distribution = DistributionType::Normal;
}
self.distribution_selector_state.select(None);
}
}
pub fn open_correlation_detail(&mut self) {
if self.focus == AnalysisFocus::Main
&& self.selected_tool == Some(AnalysisTool::CorrelationMatrix)
&& let Some((row, col)) = self.selected_correlation
&& row != col
{
self.view = AnalysisView::CorrelationDetail;
}
}
pub fn close_detail(&mut self) {
self.view = AnalysisView::Main;
self.focus = AnalysisFocus::Main;
}
pub fn scroll_left(&mut self) {
if let Some(columns) = self.column_scroll_mut() {
columns.left();
}
}
pub fn scroll_right(&mut self) {
if let Some(columns) = self.column_scroll_mut() {
columns.right();
}
}
pub fn column_scroll(&self) -> Option<&ColumnScroll> {
match self.selected_tool {
Some(AnalysisTool::Describe) => Some(&self.describe_columns),
Some(AnalysisTool::DistributionAnalysis) => Some(&self.distribution_columns),
_ => None,
}
}
fn column_scroll_mut(&mut self) -> Option<&mut ColumnScroll> {
match self.selected_tool {
Some(AnalysisTool::Describe) => Some(&mut self.describe_columns),
Some(AnalysisTool::DistributionAnalysis) => Some(&mut self.distribution_columns),
_ => None,
}
}
pub fn follow_view_sample(&mut self, sampled: bool) {
match (sampled, self.own_sample.is_some()) {
(true, false) => {
let every = crate::sampling::Sample {
scope: crate::data_quality::QualityScope::CurrentView,
method: crate::sampling::SampleMethod::EveryRow,
..self.sample.clone()
};
self.own_sample = Some(std::mem::replace(&mut self.sample, every));
}
(false, true) => {
if let Some(own) = self.own_sample.take() {
self.sample = own;
}
}
_ => {}
}
}
pub fn recalculate(&mut self) {
self.sample.seed = crate::sample_modal::new_seed();
}
pub fn quality_row_count(&self) -> usize {
let Some(results) = self.data_quality_results.as_ref() else {
return 0;
};
match self.data_quality_page {
QualityPage::Setup => SetupRow::ALL.len(),
QualityPage::TimeRoles => TemporalRole::ALL.len(),
QualityPage::IntervalPairs => self.data_quality_plan.candidate_pairs().len(),
QualityPage::Intent => 0,
QualityPage::Intervals => results.temporal.len(),
QualityPage::IntervalDetail => self.interval_facts().len(),
QualityPage::Overview => self
.data_quality_findings
.shown(&crate::quality_report::build_report(results))
.len(),
QualityPage::Columns | QualityPage::Detail => results.columns.len(),
QualityPage::Segments => results.segments.len(),
QualityPage::SegmentDetail => {
crate::data_quality::segment_changes(results, self.data_quality_segment_index).len()
}
QualityPage::Trends => {
crate::quality_trends::trend_view(results, self.data_quality_metric, 1)
.lines
.len()
}
QualityPage::TrendDetail => crate::quality_trends::trend_slots(results).len(),
QualityPage::Gaps => {
match crate::quality_trends::expected_gaps(self.quality_result_plan(), results) {
Some(crate::quality_trends::Gaps::Checked(check)) => check.runs.len(),
_ => 0,
}
}
QualityPage::ExpectedWindows => EXPECTED_ROWS.len(),
}
}
pub fn quality_selected_is_clean(&self) -> bool {
self.data_quality_page == QualityPage::Overview
&& self
.selected_finding()
.is_some_and(|(_, finding)| finding.kind.is_none())
}
pub fn selected_finding(&self) -> Option<(QualityReport, Finding)> {
let results = self.data_quality_results.as_ref()?;
let report = crate::quality_report::build_report(results);
let finding = self
.data_quality_findings
.selected(&report, self.data_quality_table_state.selected()?)?
.clone();
Some((report, finding))
}
pub fn open_findings_picker(&mut self, by_column: bool) {
let Some(results) = self.data_quality_results.as_ref() else {
return;
};
let report = crate::quality_report::build_report(results);
let view = &self.data_quality_findings;
let findings = |count: usize| match count {
0 => "none".to_string(),
1 => "1 finding".to_string(),
count => format!("{} findings", crate::numfmt::group_chrome(count)),
};
let (title, choices, current) = if by_column {
let columns = crate::quality_report::column_choices(&report, results);
let width = columns
.iter()
.map(|(name, _)| crate::glyphs::display_width(name))
.max()
.unwrap_or(0);
let current = view
.column
.as_ref()
.and_then(|column| columns.iter().position(|(name, _)| name == column))
.map_or(0, |position| position + 1);
let mut choices = vec![("All columns".to_string(), PlanChoice::FindingColumn(None))];
choices.extend(columns.into_iter().map(|(name, count)| {
let pad = width.saturating_sub(crate::glyphs::display_width(&name));
(
format!("{name}{} {}", " ".repeat(pad), findings(count)),
PlanChoice::FindingColumn(Some(name)),
)
}));
("Findings by Column", choices, current)
} else {
let checks = crate::quality_report::check_choices(&report);
let width = checks
.iter()
.map(|(name, _)| crate::glyphs::display_width(name))
.max()
.unwrap_or(0);
let current = view
.check
.and_then(|check| checks.iter().position(|(name, _)| *name == check))
.map_or(0, |position| position + 1);
let mut choices = vec![("All types".to_string(), PlanChoice::FindingCheck(None))];
choices.extend(checks.into_iter().map(|(name, count)| {
let pad = width.saturating_sub(crate::glyphs::display_width(name));
(
format!("{name}{} {}", " ".repeat(pad), findings(count)),
PlanChoice::FindingCheck(Some(name)),
)
}));
("Findings by Type", choices, current)
};
let (labels, choices): (Vec<_>, Vec<_>) = choices.into_iter().unzip();
let mut state = crate::widgets::ui::PickerState::new(labels);
state.select_original(current);
self.data_quality_picker = Some(PlanPicker {
row: None,
title: title.to_string(),
choices,
state,
});
}
pub fn cycle_findings_order(&mut self) {
let selected = self.selected_finding();
self.data_quality_findings.order = self.data_quality_findings.order.next();
self.reselect_finding(selected.map(|(_, finding)| finding));
}
pub fn clear_findings_narrowing(&mut self) {
let selected = self.selected_finding();
self.data_quality_findings.column = None;
self.data_quality_findings.check = None;
self.reselect_finding(selected.map(|(_, finding)| finding));
}
fn reselect_finding(&mut self, finding: Option<Finding>) {
let position = self.data_quality_results.as_ref().and_then(|results| {
let report = crate::quality_report::build_report(results);
let finding = finding?;
let shown = self.data_quality_findings.shown(&report);
shown.iter().position(|index| {
let listed = &report.findings[*index];
listed.title == finding.title && listed.columns == finding.columns
})
});
self.data_quality_table_state
.select(Some(position.unwrap_or(0)));
*self.data_quality_table_state.offset_mut() = 0;
}
pub fn sample_key_opens_form(&self) -> bool {
self.active
&& self.view == AnalysisView::Main
&& self.selected_tool.is_some()
&& self.computing.is_none()
&& self.data_quality_picker.is_none()
&& !matches!(
self.data_quality_page,
QualityPage::TimeRoles
| QualityPage::IntervalPairs
| QualityPage::ExpectedWindows
| QualityPage::Intent
)
&& !self.data_quality_show_access
&& !self.data_quality_observation_detail
&& self.data_quality_evidence_read.is_none()
&& self.data_quality_export.is_none()
&& self.data_quality_intent_form.is_none()
&& self.sample_form.is_none()
}
pub fn export_typing(&self) -> bool {
self.active
&& self
.data_quality_export
.as_ref()
.is_some_and(|form| !form.on_format)
}
pub fn intent_typing(&self) -> bool {
self.active
&& self
.data_quality_intent_form
.as_ref()
.is_some_and(crate::intent_modal::IntentForm::typing)
}
pub fn sample_scope_typing(&self) -> bool {
self.active
&& self.sample_form.as_ref().is_some_and(|form| {
form.field.is_text() && (!form.inline || self.focus == AnalysisFocus::Main)
})
}
pub fn quality_expected_typing(&self) -> bool {
self.active
&& self.data_quality_page == QualityPage::ExpectedWindows
&& self
.data_quality_expected_form
.as_ref()
.is_some_and(ExpectedForm::typing)
}
pub fn open_trend_detail(&mut self) {
let line = self.data_quality_table_state.selected().unwrap_or(0);
self.data_quality_trend_line = line;
self.set_quality_page(QualityPage::TrendDetail);
}
pub fn cycle_trend_detail_metric(&mut self) {
let Some(results) = self.data_quality_results.as_ref() else {
return;
};
let view = crate::quality_trends::trend_view(results, self.data_quality_metric, 1);
let line = view
.lines
.get(self.data_quality_trend_line)
.map(|line| (line.measure, line.names.clone()));
self.cycle_quality_metric();
let Some(results) = self.data_quality_results.as_ref() else {
return;
};
let view = crate::quality_trends::trend_view(results, self.data_quality_metric, 1);
let found = line.and_then(|(measure, names)| {
view.lines.iter().position(|candidate| {
candidate.measure == measure
|| candidate
.names
.iter()
.any(|name| !candidate.rows() && names.contains(name))
})
});
match found {
Some(index) => self.data_quality_trend_line = index,
None => {
self.data_quality_trend_line = 0;
self.close_to_trends();
}
}
}
pub fn close_to_trends(&mut self) {
let line = (self.data_quality_page == QualityPage::TrendDetail)
.then_some(self.data_quality_trend_line);
self.set_quality_page(QualityPage::Trends);
self.data_quality_table_state
.select(Some(line.unwrap_or(0)));
}
pub fn set_quality_page(&mut self, page: QualityPage) {
self.data_quality_page = page;
self.data_quality_observation_detail = false;
self.data_quality_evidence_read = None;
self.data_quality_table_state.select(Some(0));
}
pub fn set_quality_column_page(&mut self, page: QualityPage) {
if matches!(
self.data_quality_page,
QualityPage::Columns | QualityPage::Detail
) {
self.data_quality_column_index = self.data_quality_table_state.selected().unwrap_or(0);
}
self.set_quality_page(page);
self.data_quality_table_state
.select(Some(self.data_quality_column_index));
}
pub fn open_segment_detail(&mut self) {
if let Some(segment) = self.selected_segment() {
self.data_quality_segment_index = segment;
self.set_quality_page(QualityPage::SegmentDetail);
}
}
pub fn close_segment_detail(&mut self) {
self.set_quality_page(QualityPage::Segments);
let position = self
.segment_order()
.iter()
.position(|segment| *segment == self.data_quality_segment_index);
self.data_quality_table_state
.select(Some(position.unwrap_or(0)));
}
pub fn segment_order(&self) -> Vec<usize> {
self.data_quality_results
.as_ref()
.map(|results| {
crate::data_quality::segment_order(results, self.data_quality_segments_by_change)
})
.unwrap_or_default()
}
pub fn selected_segment(&self) -> Option<usize> {
let position = self.data_quality_table_state.selected()?;
self.segment_order().get(position).copied()
}
pub fn toggle_segment_order(&mut self) {
let segment = self.selected_segment();
self.data_quality_segments_by_change = !self.data_quality_segments_by_change;
let position = segment
.and_then(|segment| self.segment_order().iter().position(|s| *s == segment))
.unwrap_or(0);
self.data_quality_table_state.select(Some(position));
}
pub fn open_interval_detail(&mut self) {
let Some(index) = self.data_quality_table_state.selected() else {
return;
};
if self
.data_quality_results
.as_ref()
.is_some_and(|results| index < results.temporal.len())
{
self.data_quality_interval_index = index;
self.set_quality_page(QualityPage::IntervalDetail);
}
}
pub fn close_interval_detail(&mut self) {
self.set_quality_page(QualityPage::Intervals);
self.data_quality_table_state
.select(Some(self.data_quality_interval_index));
}
pub fn interval_facts(&self) -> Vec<IntervalFact> {
let plan = self.quality_result_plan();
self.data_quality_results
.as_ref()
.and_then(|results| results.temporal.get(self.data_quality_interval_index))
.map(|profile| {
IntervalFact::ALL
.into_iter()
.filter(|fact| profile.count(*fact, plan).is_some())
.collect()
})
.unwrap_or_default()
}
pub fn selected_interval_fact(&self) -> Option<IntervalFact> {
let facts = self.interval_facts();
facts
.get(self.data_quality_table_state.selected().unwrap_or(0))
.copied()
}
pub fn interval_evidence(
&self,
schema: Option<&polars::prelude::Schema>,
) -> Option<(polars::prelude::Expr, String, usize)> {
let results = self.data_quality_results.as_ref()?;
if !matches!(
results.precision,
crate::data_quality::QualityPrecision::Exact
| crate::data_quality::QualityPrecision::Sampled
) {
return None;
}
let plan = self.quality_result_plan();
let profile = results.temporal.get(self.data_quality_interval_index)?;
let fact = self.selected_interval_fact()?;
let (count, _) = profile.count(fact, plan)?;
if count == 0 {
return None;
}
let predicate = profile.evidence_predicate(fact, plan, schema)?;
Some((
predicate,
format!(
"Data Quality / {} / {} / {}",
profile.label(),
profile.segment,
fact.short()
),
count,
))
}
pub fn scroll_quality_detail(&mut self, rows: i32) {
let scroll = &mut self.data_quality_detail_scroll;
scroll.offset = (scroll.offset as i32 + rows).clamp(0, scroll.max as i32) as u16;
}
pub fn show_quality_tab(&mut self, page: QualityPage) {
if matches!(
self.data_quality_page,
QualityPage::Columns | QualityPage::Detail
) {
self.data_quality_column_index = self.data_quality_table_state.selected().unwrap_or(0);
}
self.set_quality_page(page);
if page == QualityPage::Columns {
self.data_quality_table_state
.select(Some(self.data_quality_column_index));
}
}
pub fn step_quality_tab(&mut self, forward: bool) {
let tabs = QualityPage::TABS;
let Some(at) = tabs
.iter()
.position(|page| *page == self.data_quality_page.tab())
else {
return;
};
let next = if forward {
(at + 1).min(tabs.len() - 1)
} else {
at.saturating_sub(1)
};
if next != at {
self.show_quality_tab(tabs[next]);
}
}
pub fn cycle_quality_metric(&mut self) {
let current = QualityMetric::ALL
.iter()
.position(|metric| *metric == self.data_quality_metric)
.unwrap_or(0);
self.data_quality_metric = QualityMetric::ALL[(current + 1) % QualityMetric::ALL.len()];
}
pub fn quality_result_plan(&self) -> &DataQualityPlan {
self.data_quality_last_plan
.as_ref()
.unwrap_or(&self.data_quality_plan)
}
pub fn quality_plan_pending(&self) -> bool {
self.data_quality_results.is_some()
&& self.data_quality_last_plan.as_ref() != Some(&self.data_quality_plan)
}
pub fn setup_row(&self) -> SetupRow {
SetupRow::at(self.data_quality_plan_field)
}
pub fn setup_edited(&self) -> bool {
self.data_quality_setup_before
.as_ref()
.is_some_and(|before| *before != self.data_quality_plan)
}
pub fn plan_choices(&self, row: SetupRow, context: &PlanContext) -> Vec<(String, PlanChoice)> {
match row {
SetupRow::Grain => {
let mut grains = vec![QualityGrain::Dataset];
if context.files {
grains.push(QualityGrain::File);
}
grains.extend(
context
.partitions
.iter()
.cloned()
.map(QualityGrain::Partition),
);
for (column, has_time) in &context.time_columns {
for every in QUALITY_WINDOW_WIDTHS {
if every == "1h" && !has_time {
continue;
}
grains.push(QualityGrain::TimeWindows {
column: column.clone(),
every: every.to_string(),
});
}
}
grains.extend([100_000, 1_000_000].map(QualityGrain::RowChunks));
if !grains.contains(&self.data_quality_plan.grain) {
grains.insert(0, self.data_quality_plan.grain.clone());
}
grains
.into_iter()
.map(|grain| (grain.label(), PlanChoice::Grain(grain)))
.collect()
}
SetupRow::Values => {
let read =
if self.data_quality_plan.method == crate::sampling::SampleMethod::EveryRow {
QualityCompute::Full
} else {
QualityCompute::Sample
};
vec![
("read".to_string(), PlanChoice::Values(read)),
(
"file metadata only".to_string(),
PlanChoice::Values(QualityCompute::Metadata),
),
]
}
SetupRow::Compare => [
QualityComparison::None,
QualityComparison::Previous,
QualityComparison::Baseline,
]
.into_iter()
.map(|comparison| {
(
comparison.choice_label().to_string(),
PlanChoice::Compare(comparison),
)
})
.collect(),
SetupRow::WindowBy if !self.data_quality_plan.windows_intervals() => Vec::new(),
SetupRow::WindowBy => IntervalClock::ALL
.into_iter()
.map(|clock| (clock.label().to_string(), PlanChoice::Clock(clock)))
.collect(),
SetupRow::Latency if self.data_quality_plan.interval_pairs().is_empty() => Vec::new(),
SetupRow::Latency => [None, Some(3_600), Some(86_400), Some(604_800)]
.into_iter()
.map(|seconds| {
(
threshold_label(seconds).to_string(),
PlanChoice::Latency(seconds),
)
})
.collect(),
SetupRow::TextAsTime => context
.text_columns
.iter()
.map(|(column, examples)| {
let label = match (self.data_quality_plan.time_format(column), examples.first())
{
(Some(format), _) => format!("{column} as {}", format.label()),
(None, Some(example)) => format!("{column} {example}"),
(None, None) => column.clone(),
};
(label, PlanChoice::TextColumn(column.clone()))
})
.collect(),
SetupRow::Sample
| SetupRow::TimeRoles
| SetupRow::Intervals
| SetupRow::Expected
| SetupRow::Intent => Vec::new(),
}
}
pub fn open_plan_picker(&mut self, row: SetupRow, context: &PlanContext) {
let choices = self.plan_choices(row, context);
self.data_quality_plan_field = row.index();
self.show_picker(row, row.label().to_string(), choices);
}
fn show_picker(&mut self, row: SetupRow, title: String, choices: Vec<(String, PlanChoice)>) {
if choices.is_empty() {
return;
}
let current = choices
.iter()
.position(|(_, choice)| choice.is_current(&self.data_quality_plan))
.unwrap_or(0);
let (labels, choices): (Vec<_>, Vec<_>) = choices.into_iter().unzip();
let mut state = crate::widgets::ui::PickerState::new(labels);
state.select_original(current);
self.data_quality_picker = Some(PlanPicker {
row: Some(row),
title,
choices,
state,
});
}
pub fn open_format_picker(&mut self, column: &str, examples: &[String]) {
let mut formats = TIME_FORMATS
.iter()
.enumerate()
.map(|(order, (kind, format))| {
let interpretation = TimeInterpretation {
column: column.to_string(),
kind: *kind,
format: format.to_string(),
};
let read = examples
.iter()
.filter(|value| interpretation.reads(value))
.count();
(read, order, *kind, *format)
})
.collect::<Vec<_>>();
formats.sort_by_key(|(read, order, _, _)| (std::cmp::Reverse(*read), *order));
let mut choices = formats
.into_iter()
.map(|(read, _, kind, format)| {
let label = if examples.is_empty() {
format!("{} {format}", kind.label())
} else {
format!(
"reads {read} of {} {} {format}",
examples.len(),
kind.label()
)
};
(
label,
PlanChoice::Format(column.to_string(), Some((kind, format))),
)
})
.collect::<Vec<_>>();
if self.data_quality_plan.time_format(column).is_some() {
choices.push((
"text, not a time".to_string(),
PlanChoice::Format(column.to_string(), None),
));
}
self.show_picker(SetupRow::TextAsTime, format!("Read {column} As"), choices);
}
pub fn choose_plan_picker(&mut self) -> Option<String> {
let picker = self.data_quality_picker.take()?;
let choice = picker
.state
.selected_original()
.and_then(|index| picker.choices.get(index))?;
let plan = &mut self.data_quality_plan;
match choice.clone() {
PlanChoice::Grain(grain) => {
if plan.grain != grain {
plan.baseline_segment = None;
}
plan.grain = grain;
}
PlanChoice::Values(compute) => plan.compute = compute,
PlanChoice::Compare(comparison) => {
plan.comparison = comparison;
if comparison != QualityComparison::Baseline {
plan.baseline_segment = None;
}
}
PlanChoice::Latency(seconds) => plan.latency_threshold_seconds = seconds,
PlanChoice::Clock(clock) => plan.interval_clock = clock,
PlanChoice::TextColumn(column) => return Some(column),
PlanChoice::Format(column, format) => set_time_format(plan, &column, format),
PlanChoice::FindingColumn(column) => {
let selected = self.selected_finding();
self.data_quality_findings.column = column;
self.reselect_finding(selected.map(|(_, finding)| finding));
}
PlanChoice::FindingCheck(check) => {
let selected = self.selected_finding();
self.data_quality_findings.check = check;
self.reselect_finding(selected.map(|(_, finding)| finding));
}
}
None
}
pub fn cycle_setup_choice(&mut self, row: SetupRow, context: &PlanContext, forward: bool) {
let choices = self.plan_choices(row, context);
if choices.is_empty() || matches!(row, SetupRow::TextAsTime) {
return;
}
let current = choices
.iter()
.position(|(_, choice)| choice.is_current(&self.data_quality_plan))
.unwrap_or(0);
let next = if forward {
(current + 1).min(choices.len() - 1)
} else {
current.saturating_sub(1)
};
let (labels, choices): (Vec<_>, Vec<_>) = choices.into_iter().unzip();
let mut state = crate::widgets::ui::PickerState::new(labels);
state.select_original(next);
self.data_quality_picker = Some(PlanPicker {
row: Some(row),
title: row.label().to_string(),
choices,
state,
});
self.choose_plan_picker();
}
pub fn cycle_quality_time_role(
&mut self,
role_index: usize,
columns: &[String],
forward: bool,
) {
let Some(role) = TemporalRole::ALL.get(role_index).copied() else {
return;
};
let current = self
.data_quality_plan
.temporal_roles
.iter()
.find(|assignment| assignment.role == role)
.and_then(|assignment| columns.iter().position(|name| name == &assignment.column))
.map(|index| index + 1)
.unwrap_or(0);
let choices = columns.len() + 1;
let next = if forward {
(current + 1) % choices
} else if current == 0 {
choices - 1
} else {
current - 1
};
self.data_quality_plan
.temporal_roles
.retain(|assignment| assignment.role != role);
if next > 0 {
self.data_quality_plan
.temporal_roles
.push(TemporalRoleAssignment {
role,
column: columns[next - 1].clone(),
timezone: None,
});
}
}
pub fn next_row(&mut self, max_rows: usize) {
if self.focus == AnalysisFocus::Sidebar {
self.next_tool();
return;
}
match self.selected_tool {
Some(AnalysisTool::Describe) => {
if let Some(current) = self.table_state.selected() {
let next = (current + 1).min(max_rows.saturating_sub(1));
self.table_state.select(Some(next));
} else {
self.table_state.select(Some(0));
}
}
Some(AnalysisTool::DistributionAnalysis) => {
if let Some(current) = self.distribution_table_state.selected() {
let next = (current + 1).min(max_rows.saturating_sub(1));
self.distribution_table_state.select(Some(next));
self.selected_distribution = Some(next);
} else {
self.distribution_table_state.select(Some(0));
self.selected_distribution = Some(0);
}
}
Some(AnalysisTool::CorrelationMatrix) => {
if let Some((row, col)) = self.selected_correlation {
let next_row = (row + 1).min(max_rows.saturating_sub(1));
self.selected_correlation = Some((next_row, col));
self.correlation_table_state.select(Some(next_row));
}
}
Some(AnalysisTool::DataQuality) => {
let current = self.data_quality_table_state.selected().unwrap_or(0);
self.data_quality_table_state
.select(Some((current + 1).min(max_rows.saturating_sub(1))));
}
None => {}
}
}
pub fn previous_row(&mut self) {
if self.focus == AnalysisFocus::Sidebar {
self.previous_tool();
return;
}
match self.selected_tool {
Some(AnalysisTool::Describe) => {
if let Some(current) = self.table_state.selected()
&& current > 0
{
self.table_state.select(Some(current - 1));
}
}
Some(AnalysisTool::DistributionAnalysis) => {
if let Some(current) = self.distribution_table_state.selected()
&& current > 0
{
let prev = current - 1;
self.distribution_table_state.select(Some(prev));
self.selected_distribution = Some(prev);
}
}
Some(AnalysisTool::CorrelationMatrix) => {
if let Some((row, col)) = self.selected_correlation
&& row > 0
{
let prev_row = row - 1;
self.selected_correlation = Some((prev_row, col));
self.correlation_table_state.select(Some(prev_row));
}
}
Some(AnalysisTool::DataQuality) => {
let current = self.data_quality_table_state.selected().unwrap_or(0);
self.data_quality_table_state
.select(Some(current.saturating_sub(1)));
}
None => {}
}
}
pub fn page_down(&mut self, max_rows: usize, page_size: usize) {
if self.focus == AnalysisFocus::Sidebar {
return;
}
match self.selected_tool {
Some(AnalysisTool::Describe) => {
if let Some(current) = self.table_state.selected() {
let next = (current + page_size).min(max_rows.saturating_sub(1));
self.table_state.select(Some(next));
}
}
Some(AnalysisTool::DistributionAnalysis) => {
if let Some(current) = self.distribution_table_state.selected() {
let next = (current + page_size).min(max_rows.saturating_sub(1));
self.distribution_table_state.select(Some(next));
self.selected_distribution = Some(next);
}
}
Some(AnalysisTool::CorrelationMatrix) => {
if let Some((row, col)) = self.selected_correlation {
let next_row = (row + page_size).min(max_rows.saturating_sub(1));
self.selected_correlation = Some((next_row, col));
self.correlation_table_state.select(Some(next_row));
}
}
Some(AnalysisTool::DataQuality) => {
let current = self.data_quality_table_state.selected().unwrap_or(0);
self.data_quality_table_state
.select(Some((current + page_size).min(max_rows.saturating_sub(1))));
}
None => {}
}
}
pub fn page_up(&mut self, page_size: usize) {
if self.focus == AnalysisFocus::Sidebar {
return;
}
match self.selected_tool {
Some(AnalysisTool::Describe) => {
if let Some(current) = self.table_state.selected() {
let next = current.saturating_sub(page_size);
self.table_state.select(Some(next));
}
}
Some(AnalysisTool::DistributionAnalysis) => {
if let Some(current) = self.distribution_table_state.selected() {
let next = current.saturating_sub(page_size);
self.distribution_table_state.select(Some(next));
self.selected_distribution = Some(next);
}
}
Some(AnalysisTool::CorrelationMatrix) => {
if let Some((row, col)) = self.selected_correlation {
let prev_row = row.saturating_sub(page_size);
self.selected_correlation = Some((prev_row, col));
self.correlation_table_state.select(Some(prev_row));
}
}
Some(AnalysisTool::DataQuality) => {
let current = self.data_quality_table_state.selected().unwrap_or(0);
self.data_quality_table_state
.select(Some(current.saturating_sub(page_size)));
}
None => {}
}
}
pub fn install_correlations(&mut self, results: AnalysisResults) {
let n = results
.correlation_matrix
.as_ref()
.map_or(0, |matrix| matrix.columns.len());
self.correlation_results = Some(results);
let cell = match self.selected_correlation {
Some((row, col)) if row < n && col < n => (row, col),
_ if n >= 2 => (0, 1),
_ => (0, 0),
};
self.selected_correlation = Some(cell);
self.correlation_table_state.select(Some(cell.0));
}
pub fn correlation_size(&self) -> usize {
self.correlation_results
.as_ref()
.and_then(|results| results.correlation_matrix.as_ref())
.map_or(0, |matrix| matrix.columns.len())
}
pub fn distribution_choices(&self) -> usize {
let row = self.distribution_table_state.selected().unwrap_or(0);
self.distribution_results
.as_ref()
.and_then(|results| results.distribution_analyses.get(row))
.map_or(0, |analysis| {
crate::distribution_fit::listing_order(&analysis.fits).len()
})
}
pub fn move_correlation_cell(&mut self, (rows, cols): (isize, isize)) {
let n = self.correlation_size();
if n == 0 {
return;
}
if let Some((row, col)) = self.selected_correlation {
let row = row.saturating_add_signed(rows).min(n - 1);
let col = col.saturating_add_signed(cols).min(n - 1);
self.selected_correlation = Some((row, col));
self.correlation_table_state.select(Some(row));
}
}
pub fn next_distribution(&mut self) {
let max_idx = self.distribution_choices().saturating_sub(1);
if let Some(current) = self.distribution_selector_state.selected() {
let next = (current + 1).min(max_idx);
self.distribution_selector_state.select(Some(next));
self.select_distribution();
} else {
self.distribution_selector_state.select(Some(0));
self.select_distribution();
}
}
pub fn previous_distribution(&mut self) {
if let Some(current) = self.distribution_selector_state.selected() {
if current > 0 {
self.distribution_selector_state.select(Some(current - 1));
self.select_distribution();
}
} else {
self.distribution_selector_state.select(Some(0));
self.select_distribution();
}
}
pub fn select_distribution(&mut self) {
if let Some(idx) = self.distribution_selector_state.selected()
&& let Some(results) = &self.distribution_results
{
let dist_analysis_idx = self.distribution_table_state.selected().unwrap_or(0);
if let Some(dist_analysis) = results.distribution_analyses.get(dist_analysis_idx) {
let distribution_scores =
crate::distribution_fit::listing_order(&dist_analysis.fits);
let valid_idx = idx.min(distribution_scores.len().saturating_sub(1));
if let Some(dist_type) = distribution_scores.get(valid_idx) {
self.selected_theoretical_distribution = *dist_type;
if idx != valid_idx {
self.distribution_selector_state.select(Some(valid_idx));
}
}
}
}
}
}
#[cfg(test)]
mod quality_scope_tests {
use super::*;
#[test]
fn expected_weekdays_read_as_every_window_on_weeks() {
let theme =
crate::config::Theme::from_config(&crate::config::ThemeConfig::default()).unwrap();
let mut plan = DataQualityPlan {
grain: QualityGrain::TimeWindows {
column: "day".to_string(),
every: "1d".to_string(),
},
expected: Some(crate::data_quality::ExpectedWindows {
weekdays: true,
..Default::default()
}),
..DataQualityPlan::default()
};
assert_eq!(
ExpectedForm::new(&plan, &theme).cadence,
ExpectedCadence::Weekdays
);
plan.grain = QualityGrain::TimeWindows {
column: "day".to_string(),
every: "1w".to_string(),
};
let form = ExpectedForm::new(&plan, &theme);
assert_eq!(form.cadence, ExpectedCadence::Every);
assert_eq!(form.cadence_label("1w"), "every week");
assert!(!form.expected().unwrap().unwrap().weekdays);
}
#[test]
fn grain_choices_come_from_the_data() {
let mut modal = AnalysisModal::new();
let context = PlanContext {
partitions: vec!["year".to_string()],
time_columns: vec![("date".to_string(), false), ("stamp".to_string(), true)],
files: true,
text_columns: Vec::new(),
};
let labels = modal
.plan_choices(SetupRow::Grain, &context)
.into_iter()
.map(|(label, _)| label)
.collect::<Vec<_>>();
assert_eq!(
labels,
[
"whole dataset",
"by file",
"by year",
"by day of date",
"by week of date",
"by month of date",
"by hour of stamp",
"by day of stamp",
"by week of stamp",
"by month of stamp",
"in chunks of 100,000 rows",
"in chunks of 1,000,000 rows",
]
);
modal.open_plan_picker(SetupRow::Grain, &context);
let picker = modal.data_quality_picker.as_mut().unwrap();
assert_eq!(picker.state.selected_original(), Some(0));
picker.state.move_down();
picker.state.move_down();
picker.state.move_down();
modal.choose_plan_picker();
assert!(modal.data_quality_picker.is_none());
assert_eq!(
modal.data_quality_plan.grain,
QualityGrain::TimeWindows {
column: "date".to_string(),
every: "1d".to_string()
}
);
}
#[test]
fn a_read_is_the_samples_kind() {
let mut modal = AnalysisModal::new();
let context = PlanContext::default();
modal.data_quality_plan.compute = QualityCompute::Metadata;
modal.open_plan_picker(SetupRow::Values, &context);
modal
.data_quality_picker
.as_mut()
.unwrap()
.state
.select_original(0);
modal.choose_plan_picker();
assert_eq!(modal.data_quality_plan.compute, QualityCompute::Sample);
modal.data_quality_plan.method = crate::sampling::SampleMethod::EveryRow;
modal.open_plan_picker(SetupRow::Values, &context);
modal.choose_plan_picker();
assert_eq!(modal.data_quality_plan.compute, QualityCompute::Full);
modal.open_plan_picker(SetupRow::Latency, &context);
assert!(
modal.data_quality_picker.is_none(),
"no threshold without an interval to measure"
);
}
#[test]
fn text_is_read_as_time_through_a_chosen_format() {
let mut modal = AnalysisModal::new();
let context = PlanContext {
text_columns: vec![(
"created".to_string(),
vec!["2024-01-31 08:15:00".to_string()],
)],
..PlanContext::default()
};
modal.open_plan_picker(SetupRow::TextAsTime, &context);
assert_eq!(modal.choose_plan_picker(), Some("created".to_string()));
modal.open_format_picker("created", &["2024-01-31 08:15:00".to_string()]);
let picker = modal.data_quality_picker.as_ref().unwrap();
assert_eq!(picker.title, "Read created As");
let first = picker.state.filtered()[0].1.to_string();
assert_eq!(first, "reads 1 of 1 datetime %Y-%m-%d %H:%M:%S");
assert_eq!(modal.choose_plan_picker(), None);
let format = modal.data_quality_plan.time_format("created").unwrap();
assert_eq!(format.kind, TimeKind::Datetime);
let windows = PlanContext {
time_columns: vec![("created".to_string(), true)],
..context.clone()
};
modal.open_plan_picker(SetupRow::Grain, &windows);
let picker = modal.data_quality_picker.as_mut().unwrap();
picker.state.move_down();
picker.state.move_down();
modal.choose_plan_picker();
assert!(matches!(
modal.data_quality_plan.grain,
QualityGrain::TimeWindows { .. }
));
modal.open_format_picker("created", &[]);
let picker = modal.data_quality_picker.as_mut().unwrap();
let text = picker
.state
.filtered()
.iter()
.position(|(_, label)| *label == "text, not a time")
.unwrap();
for _ in 0..text {
picker.state.move_down();
}
modal.choose_plan_picker();
assert!(modal.data_quality_plan.time_formats.is_empty());
assert_eq!(modal.data_quality_plan.grain, QualityGrain::Dataset);
}
}