Skip to main content

datui_lib/widgets/
data_quality.rs

1use crate::analysis_modal::{AnalysisFocus, AnalysisTool, DetailScroll, EvidenceRead, SetupRow};
2use crate::config::Theme;
3use crate::data_quality::{
4    ColumnQualityProfile, CopyPlan, DataQualityPlan, DataQualityResults, IntervalClock,
5    IntervalFact, NoCopy, ObservationKind, QualityComparison, QualityCompute, QualityGrain,
6    QualityMetric, QualityPage, QualityPrecision, QualityScope, SegmentCount,
7    TemporalLatencyProfile, TemporalRole, interval_label, window_cadence,
8};
9use crate::glyphs;
10use crate::numfmt;
11use crate::quality_report::{
12    CHECKS_SHOWN, Check, Coverage, EvidenceRows, FindingOrder, FindingsView, Outcome,
13    QualityReport, Severity, advice, build_report, checks, coverage, describe, verdict,
14};
15use crate::quality_trends::{
16    GapCheck, GapKind, Gaps, TrendBar, TrendMeasure, TrendRow, TrendView, trend_view,
17};
18use crate::render::context::RenderContext;
19use crate::widgets::datatable::DataTableState;
20use crate::widgets::ui::{FormValue, Picker, Surface};
21use polars::prelude::{DataType, Schema};
22use ratatui::buffer::Buffer;
23use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
24use ratatui::style::{Modifier, Style};
25use ratatui::text::{Line, Span};
26use ratatui::widgets::{Cell, Paragraph, Row, StatefulWidget, Table, TableState, Widget, Wrap};
27
28/// What Setup says about the draft beside its rows, all of it known without a read.
29#[derive(Debug, Clone, Default)]
30pub struct SetupView<'a> {
31    /// The columns a time role can be given: date and time columns, then text.
32    pub time_candidates: &'a [String],
33    /// A run would start from the rows the last run read.
34    pub reuses_sample: bool,
35    /// A random sample reads seeded runs of one file instead of streaming it.
36    pub reads_blocks: bool,
37    /// Where a run gets exact segment totals: see [`SegmentCount`].
38    pub segment_count: SegmentCount,
39    /// The rows this draft's sample reads were read earlier and released to the
40    /// memory budget.
41    pub released: bool,
42    /// The session cache holds this draft's report.
43    pub cached: bool,
44    /// The report on screen was measured with exactly this draft.
45    pub unchanged: bool,
46    /// The draft differs from the report on screen only in the windows it expects or
47    /// what its segments are compared with: Run works both out from the report.
48    pub relabel_only: bool,
49    /// Setup holds edits Esc would discard.
50    pub edited: bool,
51    /// Why Enter did not run.
52    pub note: Option<&'a str>,
53    /// A cancelled run still going, while the screen should say so.
54    pub cancelling: Option<Cancelling>,
55    /// The rows runs kept for reuse, which `d` releases.
56    pub kept: Option<KeptRows>,
57    /// How a full scan of a remote source reads it: see [`CopyPlan`].
58    pub copy: CopyPlan,
59    /// This dataset's local copy was released, so a Run fetches it again.
60    pub copy_released: bool,
61}
62
63/// The rows Data Quality's sampled runs kept this session, for later runs to reuse.
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub struct KeptRows {
66    /// Samples kept: one per scope, method, size and seed a run read.
67    pub samples: usize,
68    pub rows: usize,
69    /// Near enough to budget by: [`crate::data_quality::QualitySample::estimated_bytes`].
70    pub bytes: usize,
71    /// Bytes on disk in a full scan's local copy of a remote source; 0 for none.
72    pub copy_bytes: u64,
73}
74
75impl KeptRows {
76    /// `100,000 rows kept · 12.4 MiB`, or `2 samples, 200,000 rows kept · …`, with
77    /// `, local copy · 17.3 MiB` after it when a full scan's copy is kept.
78    pub fn label(&self) -> String {
79        let middot = glyphs::get().middot;
80        let copy = format!(
81            "local copy {middot} {}",
82            crate::widgets::info::format_bytes(self.copy_bytes)
83        );
84        if self.samples == 0 {
85            return copy;
86        }
87        let rows = format!(
88            "{} {} kept {middot} {}",
89            numfmt::group_chrome(self.rows),
90            if self.rows == 1 { "row" } else { "rows" },
91            crate::widgets::info::format_bytes(self.bytes as u64)
92        );
93        let rows = if self.samples > 1 {
94            format!("{} samples, {rows}", numfmt::group_chrome(self.samples))
95        } else {
96            rows
97        };
98        if self.copy_bytes > 0 {
99            format!("{rows}, {copy}")
100        } else {
101            rows
102        }
103    }
104}
105
106/// A cancelled run that has not exited yet.
107#[derive(Debug, Clone, Copy)]
108pub struct Cancelling {
109    /// When the cancel came.
110    pub since: std::time::Instant,
111    /// The cancel came during a read of the source nothing can stop, which runs to
112    /// its end; otherwise the run is stopping and has outlasted its batch.
113    pub read_runs_out: bool,
114}
115
116impl Cancelling {
117    /// What is still going, after "Cancelling: " or "Run waits: ".
118    fn what(self) -> &'static str {
119        if self.read_runs_out {
120            "source read finishing"
121        } else {
122            "run stopping"
123        }
124    }
125}
126
127pub struct DataQualityWidgetConfig<'a> {
128    /// The clean entry's checks table shows every check, not the first few.
129    pub checks_expanded: bool,
130    pub state: &'a DataTableState,
131    pub plan: &'a DataQualityPlan,
132    /// The plan the result on screen was measured with; the header says this one
133    /// even while Setup edits another.
134    pub measured: &'a DataQualityPlan,
135    pub results: Option<&'a DataQualityResults>,
136    pub from_cache: bool,
137    pub metric: QualityMetric,
138    pub column_index: usize,
139    pub segment_index: usize,
140    /// The interval a detail shows.
141    pub interval_index: usize,
142    /// The Trends line a bar detail shows.
143    pub trend_line: usize,
144    /// The Expected editor, while it is open.
145    pub expected_form: Option<&'a crate::analysis_modal::ExpectedForm>,
146    pub segments_by_change: bool,
147    pub page: QualityPage,
148    pub setup: SetupView<'a>,
149    pub plan_field: usize,
150    pub show_access: bool,
151    pub observation_detail: bool,
152    /// How Overview narrows and orders the findings.
153    pub findings: &'a FindingsView,
154    /// The rows the report measured are still in memory: a finding's open from them.
155    pub rows_kept: bool,
156    /// A read of a finding's rows, waiting for Enter.
157    pub evidence_read: Option<&'a EvidenceRead>,
158    pub focus: AnalysisFocus,
159    pub theme: &'a Theme,
160    /// The dialogs' Surfaces and the table's number formatting.
161    pub ctx: &'a RenderContext,
162    /// One column's declared intent, being edited over the Column intent list.
163    pub intent_form: Option<&'a crate::intent_modal::IntentForm>,
164    /// The dialog writing the report on screen to a file.
165    pub export_form: Option<&'a crate::quality_export::ExportForm>,
166}
167
168/// The tool list's width: the width every analysis tool gives it, and none on a
169/// terminal too narrow to hold it beside the result (a picker pops up instead).
170fn data_quality_sidebar_width(width: u16) -> u16 {
171    if width >= 76 {
172        crate::widgets::analysis::sidebar_width(width)
173    } else {
174        0
175    }
176}
177
178/// Where the result goes: under the header and the page tabs, left of the tool list.
179/// The Sample form fills it before the first run.
180pub(crate) fn main_pane(area: Rect) -> Rect {
181    Rect {
182        y: area.y + 2,
183        height: area.height.saturating_sub(2),
184        width: area
185            .width
186            .saturating_sub(data_quality_sidebar_width(area.width)),
187        ..area
188    }
189}
190
191pub fn render(
192    config: DataQualityWidgetConfig<'_>,
193    table_state: &mut TableState,
194    sidebar_state: &mut TableState,
195    detail_scroll: &mut DetailScroll,
196    area: Rect,
197    buf: &mut Buffer,
198) {
199    let sidebar_width = data_quality_sidebar_width(area.width);
200    let vertical = Layout::default()
201        .direction(Direction::Vertical)
202        .constraints([
203            Constraint::Length(1),
204            Constraint::Length(1),
205            Constraint::Fill(1),
206        ])
207        .split(area);
208
209    render_header(&config, vertical[0], buf);
210    render_tabs(&config, vertical[1], buf);
211
212    let body = if sidebar_width > 0 {
213        let horizontal = Layout::default()
214            .direction(Direction::Horizontal)
215            .constraints([Constraint::Fill(1), Constraint::Length(sidebar_width)])
216            .split(vertical[2]);
217        render_sidebar(&config, sidebar_state, horizontal[1], buf);
218        horizontal[0]
219    } else {
220        vertical[2]
221    };
222
223    match config.page {
224        QualityPage::Setup => render_setup(&config, body, buf),
225        QualityPage::TimeRoles => render_time_roles(&config, table_state, body, buf),
226        QualityPage::IntervalPairs => render_interval_pairs(&config, table_state, body, buf),
227        QualityPage::Intent => {
228            crate::widgets::quality_intent::render_list(&config, table_state, body, buf)
229        }
230        QualityPage::Overview => render_overview(&config, table_state, body, buf),
231        QualityPage::Columns => render_columns(&config, table_state, body, buf),
232        QualityPage::Segments => render_segments(&config, table_state, body, buf),
233        QualityPage::SegmentDetail => {
234            render_segment_detail(&config, table_state, config.segment_index, body, buf)
235        }
236        QualityPage::Trends => render_trends(&config, table_state, body, buf),
237        QualityPage::TrendDetail => render_trend_detail(&config, table_state, body, buf),
238        QualityPage::Gaps => render_gaps(&config, table_state, body, buf),
239        QualityPage::ExpectedWindows => render_expected_windows(&config, body, buf),
240        QualityPage::Intervals => render_intervals(&config, table_state, body, buf),
241        QualityPage::IntervalDetail => render_interval_detail(&config, table_state, body, buf),
242        QualityPage::Detail => render_detail(&config, table_state, body, buf),
243    }
244
245    // An overlay owns the keys: what it covers takes no clicks.
246    if config.intent_form.is_some()
247        || config.export_form.is_some()
248        || config.show_access
249        || config.observation_detail
250    {
251        crate::pointer::record(area, crate::pointer::Hit::Modal);
252    }
253    if let Some(form) = config.intent_form {
254        crate::widgets::quality_intent::render_form(form, &config, area, buf);
255    } else if let Some(form) = config.export_form {
256        crate::widgets::quality_export::render(form, &config, area, buf);
257    } else if config.show_access {
258        render_access_plan(&config, area, buf);
259    } else if config.observation_detail {
260        render_finding_detail(&config, table_state, detail_scroll, area, buf);
261    } else if sidebar_width == 0 && config.focus == AnalysisFocus::Sidebar {
262        crate::pointer::record(area, crate::pointer::Hit::Modal);
263        render_narrow_tool_picker(&config, sidebar_state, area, buf);
264    }
265    // A staged read of rows sits over whatever asked for it: a finding or a count.
266    if let Some(read) = config.evidence_read {
267        crate::pointer::record(area, crate::pointer::Hit::Modal);
268        render_evidence_read(&config, read, area, buf);
269    }
270}
271
272/// One line, as every analysis tool heads its result: the tool, then what the
273/// numbers were measured on.
274fn render_header(config: &DataQualityWidgetConfig<'_>, area: Rect, buf: &mut Buffer) {
275    let middot = glyphs::get().middot;
276    let mut text = "Data Quality".to_string();
277    if let Some(results) = config.results.filter(|_| !config.page.is_setup()) {
278        let plan = config.measured;
279        text.push_str(&format!(" {middot} {}", measured_on(plan, results)));
280        if plan.grain != QualityGrain::Dataset {
281            text.push_str(&format!(" {middot} {}", plan.grain.label()));
282        }
283        if plan.comparison != QualityComparison::None {
284            text.push_str(&format!(
285                " {middot} compared with {}",
286                plan.comparison_label()
287            ));
288        }
289    }
290    let mut spans = vec![Span::raw(text)];
291    if config.from_cache && !config.page.is_setup() {
292        spans.push(Span::styled(
293            "  [session cache]",
294            Style::default().fg(config.theme.get("dimmed")),
295        ));
296    }
297    // State, not a message: it holds until the worker exits, on every page.
298    if let Some(cancelling) = config.setup.cancelling {
299        spans.push(Span::styled(
300            format!(
301                "  Cancelling: {} {} {}",
302                cancelling.what(),
303                glyphs::get().middot,
304                crate::render::analysis_view::elapsed(cancelling.since.elapsed())
305            ),
306            Style::default().fg(config.theme.get("warning")),
307        ));
308    }
309    Paragraph::new(Line::from(spans))
310        .style(crate::widgets::analysis::header_style(
311            config.theme,
312            "controls_bg",
313            "table_header",
314        ))
315        .render(area, buf);
316}
317
318/// The rows a result was measured on, in the words the other tools' headers use.
319fn measured_on(plan: &DataQualityPlan, results: &DataQualityResults) -> String {
320    let sample = plan.sample();
321    let scope = if plan.scope == QualityScope::CurrentView {
322        String::new()
323    } else {
324        format!(" {} {}", glyphs::get().middot, plan.scope.label())
325    };
326    match (results.precision, results.total_rows) {
327        (QualityPrecision::Metadata, _) => format!("file metadata only, no values read{scope}"),
328        (QualityPrecision::Exact, _) => sample.outcome(results.evaluated_rows, None, None),
329        (QualityPrecision::Sampled | QualityPrecision::Estimated, Some(total)) => {
330            sample.outcome(total, Some(results.evaluated_rows), results.per_value)
331        }
332        (QualityPrecision::Sampled | QualityPrecision::Estimated, None) => format!(
333            "sample of {} rows{scope}",
334            numfmt::group_chrome(results.evaluated_rows)
335        ),
336    }
337}
338
339/// The pages, with the one shown carrying the accent. `←→` walk them. Setup is not
340/// one of them: it names itself alone, since the report's pages do not apply to it.
341fn render_tabs(config: &DataQualityWidgetConfig<'_>, area: Rect, buf: &mut Buffer) {
342    if config.page.is_setup() {
343        Paragraph::new(Line::styled(
344            "Setup",
345            Style::default()
346                .fg(config.theme.get("accent"))
347                .add_modifier(Modifier::BOLD),
348        ))
349        .render(area, buf);
350        return;
351    }
352    let shown = config.page.tab();
353    let dimmed = Style::default().fg(config.theme.get("dimmed"));
354    let accent = Style::default()
355        .fg(config.theme.get("accent"))
356        .add_modifier(Modifier::BOLD);
357    // Each title padded a space each side, a space between: a click on one presses
358    // its number, as typed.
359    let mut spans = Vec::new();
360    let mut clicks = Vec::new();
361    for (i, page) in QualityPage::TABS.iter().enumerate() {
362        if i > 0 {
363            spans.push(Span::styled(" ", dimmed));
364        }
365        spans.push(Span::styled(" ", dimmed));
366        clicks.push((
367            spans.len(),
368            crate::pointer::Hit::Key(crossterm::event::KeyEvent::new(
369                crossterm::event::KeyCode::Char(char::from(b'1' + i as u8)),
370                crossterm::event::KeyModifiers::NONE,
371            )),
372        ));
373        spans.push(Span::styled(
374            page.title(),
375            if *page == shown { accent } else { dimmed },
376        ));
377        spans.push(Span::styled(" ", dimmed));
378    }
379    let line = Line::from(spans);
380    crate::pointer::record_spans(area, &line, clicks);
381    Paragraph::new(line).style(dimmed).render(area, buf);
382}
383/// One line of Setup, top to bottom.
384enum SetupLine {
385    Rule(&'static str, Option<String>),
386    Row(SetupRow),
387    Note(String, bool),
388}
389
390/// Data Quality Setup: every setting a run takes, in four sections, and what the
391/// run will read. Nothing here reads: the schema, the rows on screen and what
392/// earlier runs kept are all it knows. Staged edits wait for Enter.
393fn render_setup(config: &DataQualityWidgetConfig<'_>, area: Rect, buf: &mut Buffer) {
394    let plan = config.plan;
395    let view = &config.setup;
396    let ctx = config.ctx;
397    // A short terminal gives up the top margin before it gives up a row.
398    let top = u16::from(area.height >= 20);
399    let area = Rect {
400        x: area.x + 1,
401        y: area.y + top,
402        width: area.width.saturating_sub(2),
403        height: area.height.saturating_sub(top),
404    };
405    if area.height < 3 || area.width < 20 {
406        return;
407    }
408    let width = area.width as usize;
409    let schema = config.state.quality_schema(&plan.scope);
410
411    let mut lines = vec![
412        SetupLine::Rule("Rows & sample", None),
413        SetupLine::Row(SetupRow::Sample),
414    ];
415    let texts = config.state.quality_text_columns(&plan.scope).len();
416    let times = view.time_candidates.len().saturating_sub(texts);
417    let counts = [(times, "date or time"), (texts, "text")]
418        .into_iter()
419        .filter(|(count, _)| *count > 0)
420        .map(|(count, kind)| format!("{} {kind}", numfmt::group_chrome(count)))
421        .collect::<Vec<_>>()
422        .join(&format!(" {} ", glyphs::get().middot));
423    lines.push(SetupLine::Rule(
424        "Columns",
425        (!counts.is_empty()).then_some(counts),
426    ));
427    lines.push(SetupLine::Row(SetupRow::TextAsTime));
428    lines.push(SetupLine::Row(SetupRow::TimeRoles));
429    lines.push(SetupLine::Row(SetupRow::Intervals));
430    lines.push(SetupLine::Row(SetupRow::Intent));
431    for (note, warn) in column_notes(plan, schema) {
432        for line in crate::widgets::info::wrap_to(&dotted(&note), width.saturating_sub(2)) {
433            lines.push(SetupLine::Note(line, warn));
434        }
435    }
436    lines.push(SetupLine::Rule("Study", None));
437    for row in [
438        SetupRow::Grain,
439        SetupRow::Expected,
440        SetupRow::Compare,
441        SetupRow::Values,
442        SetupRow::Latency,
443        SetupRow::WindowBy,
444    ] {
445        lines.push(SetupLine::Row(row));
446    }
447    lines.push(SetupLine::Rule("Read", view.kept.map(|kept| kept.label())));
448    let read_start = lines.len();
449    for note in read_lines(config) {
450        for line in crate::widgets::info::wrap_to(&dotted(&note), width.saturating_sub(2)) {
451            lines.push(SetupLine::Note(line, false));
452        }
453    }
454
455    // The status line keeps the bottom row: why Run waits, or that it would
456    // replace the report on screen.
457    let status = setup_status(config).map(|(text, warn)| (dotted(&text), warn));
458    let body_height = area.height.saturating_sub(1) as usize;
459    // Scrolled only as far as the focused row needs; the read summary, last, is
460    // what gives way, and says how much of it is off screen.
461    let focused = lines
462        .iter()
463        .position(
464            |line| matches!(line, SetupLine::Row(row) if *row == SetupRow::at(config.plan_field)),
465        )
466        .unwrap_or(0);
467    let (offset, room) = if lines.len() <= body_height {
468        (0, body_height)
469    } else {
470        // One line counts what is cut, unless scrolling reaches the end anyway.
471        let room = body_height.saturating_sub(1);
472        let offset = (focused + 1).saturating_sub(room);
473        if offset + body_height >= lines.len() {
474            (lines.len() - body_height, body_height)
475        } else {
476            (offset, room)
477        }
478    };
479    let end = (offset + room).min(lines.len());
480    let cut = lines.len() - end;
481    let dimmed = Style::default().fg(ctx.dimmed);
482    let warning = Style::default().fg(ctx.warning);
483    if cut > 0 {
484        Paragraph::new(Line::styled(
485            format!(
486                "  {} {cut} more{}",
487                glyphs::get().ellipsis,
488                if end >= read_start { " (p)" } else { "" }
489            ),
490            dimmed,
491        ))
492        .render(
493            Rect {
494                y: area.y + room as u16,
495                height: 1,
496                ..area
497            },
498            buf,
499        );
500    }
501    for (index, line) in lines.iter().enumerate().take(end).skip(offset) {
502        let row_area = Rect {
503            y: area.y + (index - offset) as u16,
504            height: 1,
505            ..area
506        };
507        match line {
508            SetupLine::Rule(title, chip) => crate::widgets::ui::SectionRule {
509                title,
510                chip: chip.as_deref(),
511            }
512            .render(row_area, buf, ctx),
513            SetupLine::Row(row) => {
514                let (value, placeholder) = setup_value(config, *row);
515                let room = (row_area.width as usize).saturating_sub(1 + SETUP_LABEL_WIDTH as usize);
516                let value = fit(&value, room);
517                crate::widgets::ui::FormRow {
518                    label: row.label(),
519                    value: if placeholder {
520                        FormValue::Placeholder(&value)
521                    } else {
522                        FormValue::Choice(&value)
523                    },
524                    focused: config.focus == AnalysisFocus::Main
525                        && SetupRow::at(config.plan_field) == *row,
526                    label_width: SETUP_LABEL_WIDTH,
527                }
528                .render(row_area, buf, ctx);
529            }
530            SetupLine::Note(text, warn) => {
531                Paragraph::new(Line::styled(
532                    format!("  {text}"),
533                    if *warn { warning } else { dimmed },
534                ))
535                .render(row_area, buf);
536            }
537        }
538    }
539    if let Some((text, warn)) = status {
540        Paragraph::new(Line::styled(text, if warn { warning } else { dimmed })).render(
541            Rect {
542                y: area.y + area.height - 1,
543                height: 1,
544                ..area
545            },
546            buf,
547        );
548    }
549}
550
551/// Where Setup's values start, past the rail gutter and the longest label.
552const SETUP_LABEL_WIDTH: u16 = 15;
553
554/// A Setup row's value, and whether it is a placeholder: a choice not made, or one
555/// that has nothing to apply to yet.
556fn setup_value(config: &DataQualityWidgetConfig<'_>, row: SetupRow) -> (String, bool) {
557    let plan = config.plan;
558    match row {
559        SetupRow::Sample => {
560            // The view's rows, when counted: a sample never promises more.
561            let sample = plan.sample();
562            let known = (sample.scope == crate::data_quality::QualityScope::CurrentView)
563                .then(|| config.state.num_rows_if_valid())
564                .flatten();
565            (sample.summary_within(known), false)
566        }
567        SetupRow::TextAsTime if plan.time_formats.is_empty() => {
568            ("none: every text column is text".to_string(), true)
569        }
570        // The columns here, each one's format on its own line below.
571        SetupRow::TextAsTime => (
572            plan.time_formats
573                .iter()
574                .map(|format| format.column.as_str())
575                .collect::<Vec<_>>()
576                .join(", "),
577            false,
578        ),
579        SetupRow::TimeRoles if config.setup.time_candidates.is_empty() => {
580            ("none: no date, time or text columns".to_string(), true)
581        }
582        SetupRow::TimeRoles if plan.temporal_roles.is_empty() => ("none".to_string(), true),
583        SetupRow::TimeRoles => (
584            plan.temporal_roles
585                .iter()
586                .map(|role| format!("{} = {}", role.role.label(), role.column))
587                .collect::<Vec<_>>()
588                .join(", "),
589            false,
590        ),
591        SetupRow::Intervals if plan.candidate_pairs().is_empty() => {
592            ("needs two time roles".to_string(), true)
593        }
594        SetupRow::Intervals if plan.interval_pairs().is_empty() => ("none".to_string(), true),
595        SetupRow::Intervals => (
596            plan.interval_pairs()
597                .into_iter()
598                .map(interval_label)
599                .collect::<Vec<_>>()
600                .join(", "),
601            false,
602        ),
603        SetupRow::Intent if plan.intent.is_empty() => ("none".to_string(), true),
604        SetupRow::Intent => (plan.intent.summary(), false),
605        SetupRow::Grain => (plan.grain.label(), false),
606        SetupRow::Expected => match (&plan.grain, plan.expected.as_ref()) {
607            (QualityGrain::TimeWindows { every, .. }, Some(expected)) => (
608                format!(
609                    "{}, {}",
610                    expected.cadence_label(every),
611                    expected.range_label()
612                ),
613                false,
614            ),
615            (QualityGrain::TimeWindows { .. }, None) => {
616                ("none: no window is a gap".to_string(), true)
617            }
618            _ => ("needs a time-window grain".to_string(), true),
619        },
620        SetupRow::Compare => (
621            match (plan.comparison, plan.baseline_segment.as_deref()) {
622                (QualityComparison::Baseline, Some(segment)) => format!("baseline {segment}"),
623                (comparison, _) => comparison.choice_label().to_string(),
624            },
625            false,
626        ),
627        SetupRow::Values if plan.compute == QualityCompute::Metadata => {
628            ("file metadata only".to_string(), false)
629        }
630        SetupRow::Values => ("read".to_string(), false),
631        SetupRow::Latency if plan.interval_pairs().is_empty() => {
632            ("needs an interval to measure".to_string(), true)
633        }
634        SetupRow::Latency => (
635            crate::analysis_modal::threshold_label(plan.latency_threshold_seconds).to_string(),
636            plan.latency_threshold_seconds.is_none(),
637        ),
638        SetupRow::WindowBy => match (&plan.grain, plan.interval_clock) {
639            (QualityGrain::TimeWindows { .. }, _) if plan.interval_pairs().is_empty() => {
640                ("needs an interval".to_string(), true)
641            }
642            (QualityGrain::TimeWindows { column, .. }, IntervalClock::Grain) => {
643                (format!("the grain's column, {column}"), false)
644            }
645            (QualityGrain::TimeWindows { .. }, clock) => (clock.label().to_string(), false),
646            _ => ("needs a time-window grain".to_string(), true),
647        },
648    }
649}
650
651/// What the columns section says under its rows: text that needs a format before
652/// it can be read as time, and which intervals the roles measure, or why none.
653fn column_notes(plan: &DataQualityPlan, schema: &Schema) -> Vec<(String, bool)> {
654    let mut notes = plan
655        .time_formats
656        .iter()
657        .map(|format| {
658            (
659                format!("{} read as {}", format.column, format.label()),
660                false,
661            )
662        })
663        .collect::<Vec<_>>();
664    let mut unread = Vec::new();
665    let grain = match &plan.grain {
666        QualityGrain::TimeWindows { column, .. } => Some(column.as_str()),
667        _ => None,
668    };
669    for column in plan
670        .temporal_roles
671        .iter()
672        .map(|role| role.column.as_str())
673        .chain(grain)
674    {
675        if !plan.reads_as_time(column, schema) && !unread.contains(&column) {
676            unread.push(column);
677        }
678    }
679    for column in unread {
680        notes.push((
681            format!("{column}: text, no format · set Text as time"),
682            true,
683        ));
684    }
685    // A role that is in no interval measures nothing; say which, and where to fix it.
686    let unpaired = plan.unpaired_roles();
687    if plan.temporal_roles.len() == 1 {
688        notes.push((
689            "1 role, no interval · add one under Time roles".to_string(),
690            true,
691        ));
692    } else if !unpaired.is_empty() {
693        notes.push((
694            format!(
695                "In no interval: {} · set Intervals",
696                unpaired
697                    .iter()
698                    .map(|role| role.label())
699                    .collect::<Vec<_>>()
700                    .join(", ")
701            ),
702            true,
703        ));
704    }
705    // An interval from a time with no zone to an instant reads the first as UTC.
706    let mut naive = Vec::new();
707    for (start, end) in plan.interval_pairs() {
708        let (Some(start), Some(end)) = (plan.role_column(start), plan.role_column(end)) else {
709            continue;
710        };
711        for (column, other) in [(start, end), (end, start)] {
712            if plan.zoned(column, schema) == Some(false)
713                && plan.zoned(other, schema) == Some(true)
714                && !naive.contains(&column)
715            {
716                naive.push(column);
717            }
718        }
719    }
720    if !naive.is_empty() {
721        notes.push((
722            format!("No time zone, read as UTC: {}", naive.join(", ")),
723            false,
724        ));
725    }
726    // Intent on a column the scope does not have measures nothing.
727    let absent = plan
728        .intent
729        .declared_columns()
730        .into_iter()
731        .filter(|column| schema.get(column).is_none())
732        .collect::<Vec<_>>();
733    if !absent.is_empty() {
734        notes.push((
735            format!("Not in scope, not checked: {}", absent.join(", ")),
736            true,
737        ));
738    }
739    notes
740}
741
742/// What Run will read, in the order it happens, before it happens.
743fn read_lines(config: &DataQualityWidgetConfig<'_>) -> Vec<String> {
744    let plan = config.plan;
745    let view = &config.setup;
746    let state = config.state;
747    let mut lines = Vec::new();
748    if view.unchanged {
749        lines.push("Report on screen: this setup · no read".to_string());
750        return lines;
751    }
752    if view.relabel_only {
753        let compare = config.measured.compares_differently(plan);
754        let expected = config.measured.expected != plan.expected;
755        lines.push(
756            match (compare, expected) {
757                (true, true) => "Changed: Compare, Expected · no read",
758                (true, false) => "Changed: Compare · no read",
759                _ => "Changed: Expected · no read",
760            }
761            .to_string(),
762        );
763        return lines;
764    }
765    if view.cached {
766        lines.push("Session cache: this setup · no read".to_string());
767        return lines;
768    }
769    let scope_rows = planned_scope_rows(state, plan);
770    let n = numfmt::group_chrome(plan.dataset_rows);
771    match plan.compute {
772        QualityCompute::Metadata => {
773            lines.push("File metadata only: no values read".to_string());
774            return lines;
775        }
776        QualityCompute::Full => {
777            lines.extend(copy_lines(view, full_passes(config)));
778            // Each column an interval is windowed by is a grouping of its own.
779            let clocks =
780                crate::data_quality::interval_passes(plan, state.quality_schema(&plan.scope));
781            if clocks > 1 {
782                lines.push(format!(
783                    "Window by {}: {clocks} of those passes, 1 per column",
784                    plan.interval_clock.label()
785                ));
786            }
787        }
788        QualityCompute::Sample if view.reuses_sample => {
789            lines.push("Rows: from an earlier run · no source read".to_string());
790        }
791        QualityCompute::Sample => lines.push(match &plan.method {
792            crate::sampling::SampleMethod::FirstRows => {
793                format!("First {n} rows of the scope")
794            }
795            crate::sampling::SampleMethod::PerPartition { column } => {
796                format!("1 pass over every eligible row · {n} kept per {column}")
797            }
798            _ if scope_rows.is_some_and(|rows| rows <= plan.dataset_rows) => {
799                "Every row · scope no larger than the sample".to_string()
800            }
801            _ if view.reads_blocks => {
802                format!("Seeded runs of the file · about {n} rows, no full pass")
803            }
804            _ => format!("1 streaming pass over every eligible row · seeded {n} kept"),
805        }),
806    }
807    if plan.compute == QualityCompute::Sample && !view.reuses_sample && view.released {
808        lines.push("Released since last read · read again".to_string());
809    }
810    let exact = scope_rows.is_some_and(|rows| rows <= plan.dataset_rows);
811    if plan.compute == QualityCompute::Sample && !exact {
812        let column = match &plan.grain {
813            QualityGrain::Partition(column) | QualityGrain::TimeWindows { column, .. } => {
814                column.as_str()
815            }
816            _ => "",
817        };
818        match &view.segment_count {
819            SegmentCount::CountPass => {
820                lines.push(format!("+1 count of {column} · exact segment totals, kept"))
821            }
822            SegmentCount::InSamplePass => lines.push(format!(
823                "Segment totals: exact, counted by {column} in that pass"
824            )),
825            SegmentCount::Retained => {
826                lines.push("Segment totals: from an earlier count · no read".to_string())
827            }
828            SegmentCount::RolledUp(finer) => lines.push(format!(
829                "Segment totals: summed from earlier {} counts",
830                window_cadence(finer)
831            )),
832            SegmentCount::TooMany => lines.push(format!(
833                "Too many segments {} to count · choose a coarser grain",
834                plan.grain.label()
835            )),
836            SegmentCount::NotNeeded | SegmentCount::PerValue => {}
837        }
838    }
839    if plan.compute == QualityCompute::Sample {
840        lines.push("Measured in memory · no further reads".to_string());
841    }
842    lines.extend(intent_read_lines(plan, exact));
843    // Gaps come from the segment counts the run already takes, never a read of
844    // their own.
845    if plan.expected_windows().is_some() && plan.compute != QualityCompute::Metadata {
846        lines.push("Expected windows: from the segment counts · no read".to_string());
847    }
848    let rows = planned_rows(state, plan)
849        .map(numfmt::group_chrome)
850        .unwrap_or_else(|| "unknown".to_string());
851    let sampled = plan.compute == QualityCompute::Sample;
852    let read = if sampled && view.reuses_sample && view.segment_count.reads() && !exact {
853        "the grain's column, for the count".to_string()
854    } else if sampled && view.reuses_sample {
855        "none".to_string()
856    } else if let Some(transfer) = copy_transfer(view.copy, state) {
857        transfer
858    } else if state.is_remote_source() {
859        "unknown".to_string()
860    } else if sampled
861        && view.reads_blocks
862        && !exact
863        && matches!(plan.method, crate::sampling::SampleMethod::Spread)
864    {
865        // A ceiling of the whole file would say more than the runs read.
866        "the row groups the runs fall in".to_string()
867    } else {
868        planned_read_label(state, plan)
869    };
870    lines.push(format!(
871        "Rows evaluated {rows} {} {} {read}",
872        glyphs::get().middot,
873        if state.is_remote_source() {
874            "remote transfer"
875        } else {
876            "local read"
877        }
878    ));
879    lines
880}
881
882/// A full scan's first lines in Read: one pass per check, over the source, over a
883/// local copy fetched first, or over one fetched earlier, and why no copy where none.
884fn copy_lines(view: &SetupView<'_>, passes: usize) -> Vec<String> {
885    let bytes = crate::widgets::info::format_bytes;
886    let over_source =
887        format!("Every eligible row · up to {passes} passes over the source, 1 per check");
888    match view.copy {
889        CopyPlan::NotApplicable => vec![format!(
890            "Every eligible row · up to {passes} passes over the scope, 1 per check"
891        )],
892        CopyPlan::Fetch {
893            bytes: size,
894            objects,
895        } => {
896            let mut lines = vec![
897                format!(
898                    "1 fetch of {} ({size}) to a local copy · up to {passes} passes over it",
899                    objects_label(objects),
900                    size = bytes(size)
901                ),
902                "Local copy kept for later full scans · d releases".to_string(),
903            ];
904            if view.copy_released {
905                lines.push("Released since last copy · fetched again".to_string());
906            }
907            lines
908        }
909        CopyPlan::Kept { bytes: size, .. } => vec![format!(
910            "Every eligible row · up to {passes} passes over the local copy ({}) · no source read",
911            bytes(size)
912        )],
913        CopyPlan::Passes(why) => vec![
914            over_source,
915            match why {
916                NoCopy::Off => "No local copy: quality_local_copy = 0".to_string(),
917                NoCopy::SizeUnknown => "No local copy: object sizes unknown".to_string(),
918                NoCopy::Unusable => "No local copy: copy did not read as the source".to_string(),
919                NoCopy::PartOfTheSource => {
920                    "No local copy: scope reads part of the source".to_string()
921                }
922                NoCopy::TooLarge { bytes: size, limit } => format!(
923                    "No local copy: {} over the {} limit",
924                    bytes(size),
925                    bytes(limit)
926                ),
927                NoCopy::NoRoom {
928                    bytes: size,
929                    free: Some(free),
930                } => format!(
931                    "No local copy: {} needed, {} free on disk",
932                    bytes(size),
933                    bytes(free)
934                ),
935                NoCopy::NoRoom { free: None, .. } => {
936                    "No local copy: free disk space unknown".to_string()
937                }
938            },
939        ],
940    }
941}
942
943fn objects_label(objects: usize) -> String {
944    if objects == 1 {
945        "1 object".to_string()
946    } else {
947        format!("{} objects", numfmt::group_chrome(objects))
948    }
949}
950
951/// What a full scan takes from a remote source when it reads a local copy: the
952/// objects once, or nothing. `None` when its passes read the source.
953fn copy_transfer(copy: CopyPlan, state: &DataTableState) -> Option<String> {
954    let conflicts = if state.quality_conflict_reads() > 0 {
955        ", plus the conflict reads"
956    } else {
957        ""
958    };
959    match copy {
960        CopyPlan::Fetch { bytes, .. } => Some(format!(
961            "{}, one request per object{conflicts}",
962            crate::widgets::info::format_bytes(bytes)
963        )),
964        CopyPlan::Kept { .. } if conflicts.is_empty() => Some("none".to_string()),
965        CopyPlan::Kept { .. } => Some("only the conflict reads".to_string()),
966        _ => None,
967    }
968}
969
970/// What the declared intent costs, said before Run: nothing past the rows read, but
971/// a key on a sample speaks only for the sampled rows, and on a full scan it is a
972/// grouping of its own.
973fn intent_read_lines(plan: &DataQualityPlan, every_row: bool) -> Vec<String> {
974    if plan.intent.is_empty() {
975        return Vec::new();
976    }
977    let key = !plan.intent.key.is_empty();
978    match plan.compute {
979        QualityCompute::Metadata => vec!["Column intent: not checked, needs values".to_string()],
980        QualityCompute::Full if key => {
981            vec!["Column intent: in the profile pass · key adds 1 pass".to_string()]
982        }
983        QualityCompute::Full => {
984            vec!["Column intent: in the profile pass · no extra pass".to_string()]
985        }
986        QualityCompute::Sample => {
987            let mut lines = vec!["Column intent: on the rows read · no extra read".to_string()];
988            if key && !every_row {
989                lines.push(format!(
990                    "Key: repeats among the {} sampled rows only · Every row checks all",
991                    numfmt::group_chrome(plan.dataset_rows)
992                ));
993            }
994            lines
995        }
996    }
997}
998
999/// Collects a full run makes over its scope: one per check, and a count first when
1000/// the scope's size is not known. How much each reads again depends on the
1001/// source; that there are this many is the plan's.
1002fn full_passes(config: &DataQualityWidgetConfig<'_>) -> usize {
1003    let plan = config.plan;
1004    let state = config.state;
1005    let texts = state
1006        .quality_schema(&plan.scope)
1007        .iter()
1008        .filter(|(_, dtype)| matches!(dtype, DataType::String | DataType::Categorical(..)))
1009        .count();
1010    // Profile, most common values, duplicates, and at most one for shared nulls.
1011    let mut passes = 4 + texts;
1012    if !matches!(plan.grain, QualityGrain::Dataset) {
1013        passes += 1;
1014    }
1015    passes += crate::data_quality::interval_passes(plan, state.quality_schema(&plan.scope));
1016    // The declared key is a grouping of its columns: one pass of its own.
1017    passes += usize::from(!plan.intent.key.is_empty());
1018    if planned_scope_rows(state, plan).is_none() {
1019        passes += 1;
1020    }
1021    passes + state.quality_conflict_reads()
1022}
1023
1024/// Setup's bottom line: a cancelled read still running, why Enter did not run, or
1025/// that the draft differs from the report it would replace.
1026fn setup_status(config: &DataQualityWidgetConfig<'_>) -> Option<(String, bool)> {
1027    let view = &config.setup;
1028    if let Some(cancelling) = view.cancelling {
1029        // Said as a reason once Enter has been refused for it, short enough to keep
1030        // its clock beside the tool list at 80 columns.
1031        return Some((
1032            format!(
1033                "{}{} {} {}",
1034                if view.note.is_some() {
1035                    "Run waits: "
1036                } else {
1037                    "Cancelling: "
1038                },
1039                cancelling.what(),
1040                glyphs::get().middot,
1041                crate::render::analysis_view::elapsed(cancelling.since.elapsed())
1042            ),
1043            true,
1044        ));
1045    }
1046    if let Some(note) = view.note {
1047        return Some((note.to_string(), true));
1048    }
1049    if view.edited {
1050        return Some(("Edited · Enter runs, Esc discards".to_string(), false));
1051    }
1052    None
1053}
1054
1055fn render_overview(
1056    config: &DataQualityWidgetConfig<'_>,
1057    table_state: &mut TableState,
1058    area: Rect,
1059    buf: &mut Buffer,
1060) {
1061    let Some(results) = config.results else {
1062        render_run_prompt(area, config.theme, buf);
1063        return;
1064    };
1065    let report = build_report(results);
1066    let all_checks = checks(results, &report);
1067    let notes = config.state.notes();
1068    let notes_height = if notes.is_empty() {
1069        0
1070    } else {
1071        (notes.len() as u16).min(3) + 2
1072    };
1073    // Coverage sits under the verdict on every report, clean or not: four lines
1074    // where there is room, two on a short terminal.
1075    let coverage = coverage_lines(
1076        &coverage(results, &all_checks, config.measured),
1077        area.width.saturating_sub(2),
1078        if area.height >= 16 { 4 } else { 2 },
1079        config.theme,
1080    );
1081    let sections = Layout::default()
1082        .direction(Direction::Vertical)
1083        .constraints([
1084            Constraint::Length(2 + coverage.len() as u16),
1085            Constraint::Length(notes_height),
1086            Constraint::Fill(1),
1087        ])
1088        .horizontal_margin(1)
1089        .vertical_margin(1)
1090        .split(area);
1091    render_verdict(config, &report, sections[0], buf);
1092    Paragraph::new(coverage).render(
1093        Rect {
1094            y: sections[0].y + 1,
1095            height: sections[0].height.saturating_sub(1),
1096            ..sections[0]
1097        },
1098        buf,
1099    );
1100    if !notes.is_empty() {
1101        let mut lines = vec![rule_line(
1102            "Dataset notes",
1103            Some(&numfmt::group_chrome(notes.len())),
1104            sections[1].width,
1105            config.theme,
1106        )];
1107        lines.extend(notes.iter().take(3).map(|note| {
1108            Line::raw(format!(
1109                "{} {} {}",
1110                note.summary,
1111                glyphs::get().dash,
1112                note.scope
1113            ))
1114        }));
1115        Paragraph::new(lines).render(sections[1], buf);
1116    }
1117
1118    let mut list = sections[2];
1119    if report.findings.is_empty() {
1120        let message = if report.metadata_only {
1121            "No values read · e Setup"
1122        } else if report.no_rows {
1123            "No rows in scope · e Setup"
1124        } else {
1125            "No columns to check"
1126        };
1127        Paragraph::new(dotted(message))
1128            .wrap(Wrap { trim: true })
1129            .style(Style::default().fg(config.theme.get("dimmed")))
1130            .render(list, buf);
1131        return;
1132    }
1133    let shown = config.findings.shown(&report);
1134    // Narrowed or reordered, the list says so above itself, and what it holds.
1135    let facets = config.findings.describe();
1136    if !facets.is_empty() && list.height > 1 {
1137        let total = report
1138            .findings
1139            .iter()
1140            .filter(|finding| finding.kind.is_some())
1141            .count();
1142        let listed = shown
1143            .iter()
1144            .filter(|index| report.findings[**index].kind.is_some())
1145            .count();
1146        let middot = glyphs::get().middot;
1147        let mut text = if config.findings.narrowed() {
1148            format!("{listed} of {total} findings")
1149        } else {
1150            format!(
1151                "{total} {}",
1152                if total == 1 { "finding" } else { "findings" }
1153            )
1154        };
1155        for facet in &facets {
1156            text.push_str(&format!(" {middot} {facet}"));
1157        }
1158        Paragraph::new(Line::styled(
1159            fit(&text, list.width as usize),
1160            Style::default().fg(config.theme.get("dimmed")),
1161        ))
1162        .render(Rect { height: 1, ..list }, buf);
1163        list.y += 1;
1164        list.height -= 1;
1165    }
1166    normalize_selection(table_state, shown.len());
1167    if shown.is_empty() {
1168        Paragraph::new(dotted("No findings match · Esc shows all"))
1169            .wrap(Wrap { trim: true })
1170            .style(Style::default().fg(config.theme.get("dimmed")))
1171            .render(list, buf);
1172        return;
1173    }
1174    if report.problems == 0 && report.notes == 0 && !report.metadata_only && !report.no_rows {
1175        // Nothing to fix: what was checked is the answer, so it is on the page
1176        // rather than behind the clean entry.
1177        let parts = Layout::default()
1178            .direction(Direction::Vertical)
1179            .constraints([Constraint::Length(3), Constraint::Fill(1)])
1180            .split(list);
1181        render_findings(config, &report, table_state, parts[0], buf);
1182        Paragraph::new(check_lines(&all_checks, parts[1].width, None, config.theme))
1183            .render(parts[1], buf);
1184        return;
1185    }
1186    render_findings(config, &report, table_state, list, buf);
1187}
1188
1189/// The answer before the evidence: a mark and the counts. What they were measured on
1190/// is the strip above.
1191fn render_verdict(
1192    config: &DataQualityWidgetConfig<'_>,
1193    report: &QualityReport,
1194    area: Rect,
1195    buf: &mut Buffer,
1196) {
1197    let g = glyphs::get();
1198    let theme = config.theme;
1199    let (mark, tone) = if report.problems > 0 {
1200        (g.warning, theme.get("warning"))
1201    } else if report.metadata_only || report.no_rows {
1202        (g.middot, theme.get("dimmed"))
1203    } else {
1204        (g.check, theme.get("success"))
1205    };
1206    Paragraph::new(Line::from(vec![
1207        Span::styled(format!("{mark} "), Style::default().fg(tone)),
1208        Span::styled(
1209            verdict(report),
1210            Style::default().fg(tone).add_modifier(Modifier::BOLD),
1211        ),
1212    ]))
1213    .render(area, buf);
1214}
1215
1216/// The label gutter of the coverage lines, as wide as its longest label and a gap.
1217const COVERAGE_LABEL: usize = 8;
1218
1219/// The coverage under the verdict: checks, rows and limits, each a labeled line of
1220/// facts that wraps between facts. Within `max_lines` the rows give way first, since
1221/// the header states them too, and a section cut short counts what it left out.
1222fn coverage_lines(
1223    coverage: &Coverage,
1224    width: u16,
1225    max_lines: usize,
1226    theme: &Theme,
1227) -> Vec<Line<'static>> {
1228    let mut sections = [
1229        ("Checks", coverage.checks()),
1230        ("Rows", coverage.rows.clone()),
1231        ("Limits", coverage.limits()),
1232    ]
1233    .into_iter()
1234    .filter(|(_, facts)| !facts.is_empty())
1235    .collect::<Vec<_>>();
1236    if sections.len() > max_lines {
1237        sections.retain(|(label, _)| *label != "Rows");
1238    }
1239    sections.truncate(max_lines);
1240    let text_width = (width as usize).saturating_sub(COVERAGE_LABEL).max(1);
1241    let dimmed = Style::default().fg(theme.get("dimmed"));
1242    let plain = Style::default().fg(theme.get("text_primary"));
1243    let mut lines = Vec::new();
1244    for (index, (label, facts)) in sections.iter().enumerate() {
1245        // Every later section keeps one line; this one may take the rest.
1246        let later = sections.len() - index - 1;
1247        let room = max_lines.saturating_sub(lines.len() + later).max(1);
1248        for (row, text) in pack_facts(facts, text_width, room).into_iter().enumerate() {
1249            let gutter = if row == 0 {
1250                format!("{label:<COVERAGE_LABEL$}")
1251            } else {
1252                " ".repeat(COVERAGE_LABEL)
1253            };
1254            lines.push(Line::from(vec![
1255                Span::styled(gutter, dimmed),
1256                Span::styled(text, plain),
1257            ]));
1258        }
1259    }
1260    lines
1261}
1262
1263/// `facts` joined by middots into at most `room` lines of `width`, breaking between
1264/// facts. What does not fit is counted at the end of the last line: "+2 more".
1265fn pack_facts(facts: &[String], width: usize, room: usize) -> Vec<String> {
1266    let sep = format!(" {} ", glyphs::get().middot);
1267    let join = |line: &[String]| line.join(&sep);
1268    let mut lines: Vec<Vec<String>> = vec![Vec::new()];
1269    let mut placed = 0;
1270    for fact in facts {
1271        let fact = fit(fact, width);
1272        let current = lines.last_mut().expect("one line at least");
1273        let mut joined = current.clone();
1274        joined.push(fact.clone());
1275        if current.is_empty() || glyphs::display_width(&join(&joined)) <= width {
1276            *current = joined;
1277        } else if lines.len() < room {
1278            lines.push(vec![fact]);
1279        } else {
1280            break;
1281        }
1282        placed += 1;
1283    }
1284    let left = facts.len() - placed;
1285    let last = lines.last_mut().expect("one line at least");
1286    if left > 0 {
1287        // Make room for the count by giving up facts from the end of the line.
1288        let mut dropped = left;
1289        loop {
1290            let more = format!("+{dropped} more");
1291            let mut line = last.clone();
1292            line.push(more);
1293            if glyphs::display_width(&join(&line)) <= width || last.is_empty() {
1294                *last = line;
1295                break;
1296            }
1297            // A line's only fact is cut short rather than given up, so a line
1298            // never says only how much it left out.
1299            if let [only] = last.as_mut_slice() {
1300                let room =
1301                    width.saturating_sub(glyphs::display_width(&format!("{sep}+{dropped} more")));
1302                if room >= 8 {
1303                    *only = fit(only, room);
1304                    continue;
1305                }
1306            }
1307            last.pop();
1308            dropped += 1;
1309        }
1310    }
1311    lines.iter().map(|line| join(line)).collect()
1312}
1313
1314/// A title on a rule with a flat count chip, as `SectionRule` draws it, from the
1315/// theme this widget is handed.
1316pub(crate) fn rule_line(
1317    title: &str,
1318    chip: Option<&str>,
1319    width: u16,
1320    theme: &Theme,
1321) -> Line<'static> {
1322    let mut spans = vec![
1323        Span::styled(
1324            title.to_string(),
1325            Style::default()
1326                .fg(theme.get("accent"))
1327                .add_modifier(Modifier::BOLD),
1328        ),
1329        Span::raw(" "),
1330    ];
1331    let mut used = glyphs::display_width(title) + 1;
1332    if let Some(chip) = chip {
1333        let chip = format!(" {chip} ");
1334        used += glyphs::display_width(&chip) + 1;
1335        spans.push(Span::styled(
1336            chip,
1337            Style::default()
1338                .bg(theme.get("controls_bg"))
1339                .fg(theme.get("text_primary")),
1340        ));
1341        spans.push(Span::raw(" "));
1342    }
1343    spans.push(Span::styled(
1344        glyphs::get()
1345            .rule_h
1346            .repeat((width as usize).saturating_sub(used)),
1347        Style::default().fg(theme.get("table_column_separator")),
1348    ));
1349    Line::from(spans)
1350}
1351
1352fn severity_mark(severity: Severity, theme: &Theme) -> Span<'static> {
1353    let g = glyphs::get();
1354    match severity {
1355        Severity::Problem => Span::styled(g.warning, Style::default().fg(theme.get("warning"))),
1356        Severity::Note => Span::styled(g.middot, Style::default().fg(theme.get("text_primary"))),
1357        Severity::Clean => Span::styled(g.check, Style::default().fg(theme.get("success"))),
1358    }
1359}
1360
1361/// Findings under one rule per severity. The selection lives in finding space, so
1362/// the keys never land on a rule; the scroll offset lives in line space.
1363fn render_findings(
1364    config: &DataQualityWidgetConfig<'_>,
1365    report: &QualityReport,
1366    table_state: &mut TableState,
1367    area: Rect,
1368    buf: &mut Buffer,
1369) {
1370    enum Item {
1371        Rule(Severity, usize),
1372        Gap,
1373        Finding(usize),
1374    }
1375    // Positions in the list as shown, which is what the selection counts; each
1376    // severity's rule counts what is shown under it.
1377    let shown = config.findings.shown(report);
1378    let mut items = Vec::new();
1379    let mut current = None;
1380    for (position, index) in shown.iter().enumerate() {
1381        let finding = &report.findings[*index];
1382        if current != Some(finding.severity) {
1383            if current.is_some() {
1384                items.push(Item::Gap);
1385            }
1386            let count = shown
1387                .iter()
1388                .filter(|other| report.findings[**other].severity == finding.severity)
1389                .count();
1390            let count = match finding.severity {
1391                // The clean entry is one row naming many columns; count the columns.
1392                Severity::Clean => report.clean_columns,
1393                _ => count,
1394            };
1395            items.push(Item::Rule(finding.severity, count));
1396            current = Some(finding.severity);
1397        }
1398        items.push(Item::Finding(position));
1399    }
1400
1401    let height = area.height as usize;
1402    if height == 0 {
1403        return;
1404    }
1405    let selected = table_state.selected().unwrap_or(0);
1406    let selected_line = items
1407        .iter()
1408        .position(|item| matches!(item, Item::Finding(index) if *index == selected))
1409        .unwrap_or(0);
1410    let mut offset = table_state.offset().min(items.len().saturating_sub(1));
1411    if selected_line < offset {
1412        // Bring the section's rule along when the selection is its first row.
1413        offset = if selected_line > 0 && matches!(items[selected_line - 1], Item::Rule(..)) {
1414            selected_line - 1
1415        } else {
1416            selected_line
1417        };
1418    } else if selected_line >= offset + height {
1419        offset = selected_line + 1 - height;
1420    }
1421    *table_state.offset_mut() = offset;
1422
1423    // Mark, title, columns, summary. The title column fits the longest title and a
1424    // gap; the columns take a share of what is left, so the summary keeps the rest.
1425    let title_width = report
1426        .findings
1427        .iter()
1428        .map(|finding| glyphs::display_width(finding.title))
1429        .max()
1430        .unwrap_or(0)
1431        + 2;
1432    let width = area.width as usize;
1433    let lead = 4; // rail + space + mark + space
1434    let rest = width.saturating_sub(lead + title_width);
1435    let columns_width = (rest * 2 / 5).clamp(10.min(rest), 32);
1436    // Ordered by a number, the number is on every row, right-aligned, so the order
1437    // can be read; the summary gives up the room.
1438    let ordered = config.findings.order != FindingOrder::Ranked;
1439    let numbers = |finding: &crate::quality_report::Finding| {
1440        if finding.kind.is_none() {
1441            return (String::new(), String::new());
1442        }
1443        (
1444            numfmt::group_chrome(finding.affected_rows),
1445            crate::quality_report::percent(finding.affected_rows, finding.evaluated_rows),
1446        )
1447    };
1448    let widest = |pick: fn((String, String)) -> String| {
1449        shown
1450            .iter()
1451            .map(|index| glyphs::display_width(&pick(numbers(&report.findings[*index]))))
1452            .max()
1453            .unwrap_or(0)
1454    };
1455    let (count_width, rate_width) = (widest(|(count, _)| count), widest(|(_, rate)| rate));
1456    // The number the list is ordered by leads, each in its own aligned column.
1457    let affected = |finding: &crate::quality_report::Finding| {
1458        let (count, rate) = numbers(finding);
1459        match config.findings.order {
1460            FindingOrder::Rate => format!("{rate:>rate_width$}  {count:>count_width$}"),
1461            _ => format!("{count:>count_width$}  {rate:>rate_width$}"),
1462        }
1463    };
1464    let affected_width = if ordered {
1465        count_width + rate_width + 3
1466    } else {
1467        0
1468    };
1469    let summary_width = rest.saturating_sub(columns_width + 1 + affected_width);
1470    // Too narrow to say anything, the summary gives its room to the numbers.
1471    let (summary_width, affected_width) = if ordered && summary_width < 12 {
1472        (
1473            0,
1474            rest.saturating_sub(columns_width + 1).max(affected_width),
1475        )
1476    } else {
1477        (summary_width, affected_width)
1478    };
1479    let g = glyphs::get();
1480    let theme = config.theme;
1481    let focused = config.focus == AnalysisFocus::Main;
1482    for (row, item) in items.iter().skip(offset).take(height).enumerate() {
1483        let line_area = Rect {
1484            y: area.y + row as u16,
1485            height: 1,
1486            ..area
1487        };
1488        let line = match item {
1489            Item::Gap => continue,
1490            Item::Rule(severity, count) => rule_line(
1491                severity.heading(),
1492                Some(&numfmt::group_chrome(*count)),
1493                area.width,
1494                theme,
1495            ),
1496            Item::Finding(position) => {
1497                let finding = &report.findings[shown[*position]];
1498                let is_selected = *position == selected;
1499                let columns = fit(&finding.columns_label(columns_width), columns_width);
1500                let summary = fit(&finding.summary, summary_width);
1501                let mut spans = vec![
1502                    Span::styled(
1503                        if is_selected { g.rail } else { " " },
1504                        Style::default().fg(theme.get("accent")),
1505                    ),
1506                    Span::raw(" "),
1507                    severity_mark(finding.severity, theme),
1508                    Span::raw(" "),
1509                    Span::styled(
1510                        format!("{:<title_width$}", finding.title),
1511                        Style::default().fg(theme.get("text_primary")),
1512                    ),
1513                    Span::styled(
1514                        format!("{columns:<columns_width$} "),
1515                        Style::default().fg(theme.get("text_primary")),
1516                    ),
1517                    Span::styled(
1518                        if ordered {
1519                            format!("{summary:<summary_width$}")
1520                        } else {
1521                            summary
1522                        },
1523                        Style::default().fg(theme.get("dimmed")),
1524                    ),
1525                ];
1526                if ordered {
1527                    spans.push(Span::styled(
1528                        format!("{:>affected_width$}", affected(finding)),
1529                        Style::default().fg(theme.get("text_primary")),
1530                    ));
1531                }
1532                let line = Line::from(spans);
1533                if is_selected {
1534                    let style = if focused {
1535                        theme.highlight_style()
1536                    } else {
1537                        Style::default()
1538                    };
1539                    buf.set_style(line_area, style);
1540                }
1541                line
1542            }
1543        };
1544        line.render(line_area, buf);
1545    }
1546}
1547
1548/// The checks the run made, one per line: mark, name, reach, outcome, and what each
1549/// looks for when there is room. `limit` keeps the first few; the rest are counted.
1550fn check_lines(
1551    checks: &[Check],
1552    width: u16,
1553    limit: Option<usize>,
1554    theme: &Theme,
1555) -> Vec<Line<'static>> {
1556    let width = width as usize;
1557    let dimmed = Style::default().fg(theme.get("dimmed"));
1558    let g = glyphs::get();
1559    let shown = limit.unwrap_or(checks.len()).min(checks.len());
1560    let outcome = |check: &Check| match &check.outcome {
1561        Outcome::Passed => "passed".to_string(),
1562        Outcome::Found { detail, .. } => format!("{detail} flagged"),
1563        Outcome::Skipped(reason) => format!("skipped: {reason}"),
1564        Outcome::Unavailable(reason) => format!("unavailable: {reason}"),
1565    };
1566    // Each column as wide as its widest entry across every check, so showing all
1567    // of them moves nothing; what a check looks for takes the rest, and wraps under
1568    // itself rather than being cut.
1569    let column = |text: &dyn Fn(&Check) -> String| {
1570        checks
1571            .iter()
1572            .map(|check| glyphs::display_width(&text(check)))
1573            .max()
1574            .unwrap_or(0)
1575            + 2
1576    };
1577    let name_width = column(&|check| check.name.to_string());
1578    let reach_width = column(&|check| check.applies_to.clone());
1579    let outcome_width = column(&outcome);
1580    let lead = 2 + name_width + reach_width + outcome_width;
1581    // Too narrow for a readable fourth column: it goes on the lines below instead.
1582    let (looks_indent, looks_width) = if width >= lead + 24 {
1583        (lead, width - lead)
1584    } else {
1585        (2, width.saturating_sub(2).max(1))
1586    };
1587    let mut lines = vec![rule_line(
1588        "Checks",
1589        Some(&numfmt::group_chrome(checks.len())),
1590        width as u16,
1591        theme,
1592    )];
1593    for check in &checks[..shown] {
1594        let (mark, style) = match &check.outcome {
1595            Outcome::Passed => (
1596                Span::styled(g.check, Style::default().fg(theme.get("success"))),
1597                Style::default().fg(theme.get("text_primary")),
1598            ),
1599            Outcome::Found { tier, .. } => (
1600                severity_mark(*tier, theme),
1601                Style::default().fg(theme.get("text_primary")),
1602            ),
1603            Outcome::Skipped(_) | Outcome::Unavailable(_) => (Span::styled(g.dash, dimmed), dimmed),
1604        };
1605        let beside = looks_indent == lead;
1606        let mut spans = vec![
1607            mark,
1608            Span::raw(" "),
1609            Span::styled(format!("{:<name_width$}", check.name), style),
1610            Span::styled(format!("{:<reach_width$}", check.applies_to), dimmed),
1611            // Padded only when something follows it on the line: trailing spaces
1612            // past the frame would wrap into a blank line.
1613            Span::styled(
1614                if beside {
1615                    format!("{:<outcome_width$}", outcome(check))
1616                } else {
1617                    outcome(check)
1618                },
1619                style,
1620            ),
1621        ];
1622        let mut looks = crate::widgets::info::wrap_to(check.looks_for, looks_width).into_iter();
1623        if beside {
1624            spans.extend(looks.next().map(|text| Span::styled(text, dimmed)));
1625        }
1626        lines.push(Line::from(spans));
1627        lines.extend(
1628            looks.map(|text| Line::styled(format!("{}{text}", " ".repeat(looks_indent)), dimmed)),
1629        );
1630    }
1631    if shown < checks.len() {
1632        lines.push(Line::styled(
1633            format!(
1634                "  {} more {}",
1635                checks.len() - shown,
1636                if checks.len() - shown == 1 {
1637                    "check"
1638                } else {
1639                    "checks"
1640                }
1641            ),
1642            dimmed,
1643        ));
1644    }
1645    lines
1646}
1647
1648/// `text` cut to `width` display columns, with the ellipsis glyph when anything was
1649/// cut, so a truncated count never reads as a smaller one.
1650/// A segment's label as the terminal draws it: the `∅` naming rows with no value
1651/// is the null glyph, which `LANG=C` swaps for its ASCII twin. The label itself
1652/// stays as it is, since evidence finds a segment's rows by it.
1653fn segment_text(label: &str) -> String {
1654    label.replace('∅', glyphs::get().null)
1655}
1656
1657/// Text written with `·` between its parts, in the glyph set's middot: `-` on an
1658/// ASCII terminal.
1659pub(crate) fn dotted(text: &str) -> String {
1660    text.replace('·', glyphs::get().middot)
1661}
1662
1663pub(crate) fn fit(text: &str, width: usize) -> String {
1664    if glyphs::display_width(text) <= width {
1665        return text.to_string();
1666    }
1667    if width == 0 {
1668        return String::new();
1669    }
1670    let ellipsis = glyphs::get().ellipsis;
1671    let keep = width.saturating_sub(glyphs::display_width(ellipsis));
1672    format!("{}{ellipsis}", glyphs::take_columns(text, keep))
1673}
1674
1675/// The finding itself: what it is in one sentence with its numbers, why it matters,
1676/// what to check, and the evidence, in a frame on top of the list.
1677fn render_finding_detail(
1678    config: &DataQualityWidgetConfig<'_>,
1679    table_state: &TableState,
1680    scroll: &mut DetailScroll,
1681    area: Rect,
1682    buf: &mut Buffer,
1683) {
1684    let Some(results) = config.results else {
1685        return;
1686    };
1687    let report = build_report(results);
1688    let Some(finding) = table_state
1689        .selected()
1690        .and_then(|position| config.findings.selected(&report, position))
1691    else {
1692        return;
1693    };
1694    let theme = config.theme;
1695    let dimmed = Style::default().fg(theme.get("dimmed"));
1696    let (headline, evidence) = describe(finding, results);
1697    // A reading surface: cap the measure on a wide terminal. The checks table on
1698    // the clean entry is a table, and may use more of the width.
1699    let width = area
1700        .width
1701        .saturating_sub(4)
1702        .min(if finding.kind.is_none() { 118 } else { 84 });
1703    let inner = width.saturating_sub(4).max(1);
1704    // The name and the columns on the frame; the facts as a list under it.
1705    let title = if finding.kind.is_none() {
1706        finding.title.to_string()
1707    } else {
1708        let name = format!("{} {} ", finding.title, glyphs::get().middot);
1709        let room = (width as usize).saturating_sub(glyphs::display_width(&name) + 4);
1710        format!("{name}{}", finding.columns_label(room))
1711    };
1712    let bullet = |text: String| {
1713        Line::from(vec![
1714            Span::styled(format!("{} ", glyphs::get().middot), dimmed),
1715            Span::raw(text),
1716        ])
1717    };
1718    let mut lines = Vec::new();
1719    // The frame cut the list short: the whole of it, unless the evidence lists
1720    // every column with its rate already.
1721    if finding.kind.is_some()
1722        && finding.columns.len() > 1
1723        && !finding.lists_columns()
1724        && finding.columns_label(usize::MAX) != finding.columns_label(inner as usize)
1725    {
1726        lines.push(bullet(finding.columns.join(", ")));
1727    }
1728    lines.push(bullet(headline));
1729    lines.extend(
1730        evidence
1731            .into_iter()
1732            .map(|line| Line::raw(format!("  {line}"))),
1733    );
1734    lines.extend(advice(finding).into_iter().map(bullet));
1735    if finding.kind.is_none() {
1736        lines.push(Line::raw(""));
1737        lines.extend(check_lines(
1738            &checks(results, &report),
1739            inner,
1740            (!config.checks_expanded).then_some(CHECKS_SHOWN),
1741            theme,
1742        ));
1743    }
1744    if finding.kind.is_some() {
1745        lines.push(Line::raw(""));
1746        lines.push(Line::styled(
1747            evidence_line(config, finding, results),
1748            dimmed,
1749        ));
1750    }
1751    // Grow with the text up to the screen, then scroll inside the frame; the last
1752    // row counts what is below.
1753    let rows = lines
1754        .iter()
1755        .map(|line| crate::render::home_view::wrapped_rows(line, inner as usize))
1756        .sum::<usize>()
1757        .min(u16::MAX as usize) as u16;
1758    let height = (rows + 2).min(area.height.saturating_sub(2));
1759    let room = height.saturating_sub(2);
1760    scroll.max = rows.saturating_sub(room);
1761    scroll.offset = scroll.offset.min(scroll.max);
1762    // Short of the end, the last row counts what is below instead of showing text.
1763    let shown = if scroll.offset < scroll.max {
1764        room.saturating_sub(1)
1765    } else {
1766        room
1767    };
1768    let below = rows.saturating_sub(scroll.offset + shown);
1769    let popup = centered_rect(width, height, area);
1770    let content = Surface::new(&title)
1771        .border_style(Style::default().fg(config.ctx.modal_border_active))
1772        .render(popup, buf, config.ctx);
1773    Paragraph::new(lines)
1774        .wrap(Wrap { trim: false })
1775        .scroll((scroll.offset, 0))
1776        .render(
1777            Rect {
1778                height: shown.min(content.height),
1779                ..content
1780            },
1781            buf,
1782        );
1783    if below > 0 && content.height > shown {
1784        Paragraph::new(Line::styled(
1785            format!("{} {below} more", glyphs::get().ellipsis),
1786            dimmed,
1787        ))
1788        .alignment(Alignment::Right)
1789        .render(
1790            Rect {
1791                y: content.y + shown,
1792                height: 1,
1793                ..content
1794            },
1795            buf,
1796        );
1797    }
1798}
1799
1800/// What Enter does with a finding's rows, in one sentence: shows the ones the run
1801/// kept, reads ones it did not keep once asked, or says why there are none.
1802fn evidence_line(
1803    config: &DataQualityWidgetConfig<'_>,
1804    finding: &crate::quality_report::Finding,
1805    results: &DataQualityResults,
1806) -> String {
1807    let rows = match finding.evidence(results) {
1808        Ok(rows) => rows,
1809        Err(reason) => return reason,
1810    };
1811    if let EvidenceRows::Files(QualityScope::SourceFiles(files)) = &rows {
1812        return format!(
1813            "Enter: rows of the {} named {} (asks first)",
1814            files.len(),
1815            if files.len() == 1 { "file" } else { "files" }
1816        );
1817    }
1818    let sampled = results.precision == QualityPrecision::Sampled;
1819    let noun = |count: usize| {
1820        format!(
1821            "{} {}{}",
1822            numfmt::group_chrome(count),
1823            if sampled { "sampled " } else { "" },
1824            if count == 1 { "row" } else { "rows" }
1825        )
1826    };
1827    let what = match (finding.kind, finding.evidence_count(results)) {
1828        // The measurement counts rows beyond one per value; the rows that share a
1829        // value are always more.
1830        (Some(ObservationKind::KeyLike), _) => format!(
1831            "every {}row that shares a repeated value",
1832            if sampled { "sampled " } else { "" }
1833        ),
1834        (Some(ObservationKind::DuplicateRows), Some(count)) => {
1835            format!("the {} that have a copy", noun(count))
1836        }
1837        (Some(ObservationKind::ParseableText), Some(count)) => {
1838            format!("the {} that do not parse", noun(count))
1839        }
1840        (_, Some(count)) => format!("the {}", noun(count)),
1841        // Grouped columns: a row missing in any of them; the table counts them.
1842        (_, None) => format!(
1843            "the {}rows with any of them",
1844            if sampled { "sampled " } else { "" }
1845        ),
1846    };
1847    if config.rows_kept {
1848        let together = if matches!(rows, EvidenceRows::Duplicates) {
1849            ", copies together"
1850        } else {
1851            ""
1852        };
1853        format!("Enter: {what}{together}")
1854    } else if sampled {
1855        format!("Enter: {what} (sample released, asks first)")
1856    } else if config.measured.compute == QualityCompute::Full {
1857        format!("Enter: {what} (full scan keeps none, asks first)")
1858    } else {
1859        format!("Enter: {what} (rows released, asks first)")
1860    }
1861}
1862
1863/// A read of a finding's rows, before it reads: what, why, how much, from where.
1864fn render_evidence_read(
1865    config: &DataQualityWidgetConfig<'_>,
1866    read: &EvidenceRead,
1867    area: Rect,
1868    buf: &mut Buffer,
1869) {
1870    let rows = read
1871        .summary
1872        .iter()
1873        .map(|(label, value)| FieldRow {
1874            mark: None,
1875            label: label.to_string(),
1876            value: value.clone(),
1877        })
1878        .collect::<Vec<_>>();
1879    let width = 72.min(area.width.saturating_sub(2));
1880    let label_width = rows
1881        .iter()
1882        .map(|row| glyphs::display_width(&row.label))
1883        .max()
1884        .unwrap_or(0)
1885        + 2;
1886    let lines = field_lines(&rows, label_width, width.saturating_sub(4) as usize, false);
1887    let popup = centered_rect(width, lines.len() as u16 + 2, area);
1888    let content = Surface::new("Read Rows")
1889        .border_style(Style::default().fg(config.ctx.modal_border_active))
1890        .render(popup, buf, config.ctx);
1891    render_counted(lines, content, config.theme, buf);
1892}
1893
1894fn render_time_roles(
1895    config: &DataQualityWidgetConfig<'_>,
1896    table_state: &mut TableState,
1897    area: Rect,
1898    buf: &mut Buffer,
1899) {
1900    let theme = config.theme;
1901    let dimmed = Style::default().fg(theme.get("dimmed"));
1902    let accent = Style::default().fg(theme.get("accent"));
1903    let columns = config.setup.time_candidates;
1904    let roles = TemporalRole::ALL.len() as u16;
1905    let sections = Layout::default()
1906        .direction(Direction::Vertical)
1907        .constraints([
1908            Constraint::Length(2),
1909            Constraint::Length(roles + 1),
1910            Constraint::Length(1),
1911            Constraint::Length(2),
1912            Constraint::Fill(1),
1913        ])
1914        .margin(1)
1915        .split(area);
1916    Paragraph::new(rule_line("Time roles", None, sections[0].width, theme))
1917        .render(sections[0], buf);
1918
1919    let focused_role = TemporalRole::ALL[config.plan_field.min(TemporalRole::ALL.len() - 1)];
1920    let assigned = |role: TemporalRole| {
1921        config
1922            .plan
1923            .temporal_roles
1924            .iter()
1925            .find(|assignment| assignment.role == role)
1926            .map(|assignment| assignment.column.clone())
1927    };
1928    let rows = TemporalRole::ALL.iter().map(|role| {
1929        Row::new(vec![
1930            Cell::from(role.label()),
1931            match assigned(*role) {
1932                Some(column) => Cell::from(column),
1933                None => Cell::from(Span::styled("unassigned", dimmed)),
1934            },
1935        ])
1936    });
1937    table_state.select(Some(config.plan_field.min(TemporalRole::ALL.len() - 1)));
1938    let table = Table::new(rows, [Constraint::Length(20), Constraint::Fill(1)])
1939        .header(Row::new(["Role", "Column"]).style(dimmed))
1940        .row_highlight_style(theme.highlight_style())
1941        .highlight_symbol(glyphs::get().selector);
1942    StatefulWidget::render(table, sections[1], buf, table_state);
1943
1944    // The candidates, with a few of their values from the rows already on screen,
1945    // so choosing which column is "received" is choosing among things seen.
1946    Paragraph::new(rule_line(
1947        "Date, time and text columns",
1948        Some(&numfmt::group_chrome(columns.len())),
1949        sections[3].width,
1950        theme,
1951    ))
1952    .render(sections[3], buf);
1953    if columns.is_empty() {
1954        Paragraph::new(Span::styled("None in this data", dimmed)).render(sections[4], buf);
1955        return;
1956    }
1957    let name_width = columns
1958        .iter()
1959        .map(|column| glyphs::display_width(column))
1960        .max()
1961        .unwrap_or(0)
1962        + 2;
1963    let types = columns
1964        .iter()
1965        .map(|column| {
1966            // Text says how it is read, so a role on it is not mistaken for a cast.
1967            match (
1968                config.plan.time_format(column),
1969                config.state.quality_schema(&config.plan.scope).get(column),
1970            ) {
1971                (Some(format), _) => format!("text as {}", format.kind.label()),
1972                (None, Some(DataType::String | DataType::Categorical(..))) => {
1973                    "text, no format".to_string()
1974                }
1975                (None, dtype) => dtype.map(|dtype| dtype.to_string()).unwrap_or_default(),
1976            }
1977        })
1978        .collect::<Vec<_>>();
1979    let type_width = types
1980        .iter()
1981        .map(|dtype| glyphs::display_width(dtype))
1982        .max()
1983        .unwrap_or(0)
1984        + 2;
1985    let chosen = assigned(focused_role);
1986    let values_width = (sections[4].width as usize).saturating_sub(2 + name_width + type_width);
1987    let lines = columns
1988        .iter()
1989        .zip(&types)
1990        .map(|(column, dtype)| {
1991            let is_chosen = chosen.as_deref() == Some(column.as_str());
1992            let values = config.state.buffered_values(column, 3);
1993            let values = if values.is_empty() {
1994                "not in the rows on screen".to_string()
1995            } else {
1996                values.join("   ")
1997            };
1998            Line::from(vec![
1999                Span::styled(if is_chosen { glyphs::get().rail } else { " " }, accent),
2000                Span::raw(" "),
2001                Span::styled(
2002                    format!("{column:<name_width$}"),
2003                    if is_chosen {
2004                        accent
2005                    } else {
2006                        Style::default().fg(theme.get("text_primary"))
2007                    },
2008                ),
2009                Span::styled(format!("{dtype:<type_width$}"), dimmed),
2010                Span::raw(fit(&values, values_width)),
2011            ])
2012        })
2013        .collect::<Vec<_>>();
2014    Paragraph::new(lines).render(sections[4], buf);
2015}
2016
2017fn render_columns(
2018    config: &DataQualityWidgetConfig<'_>,
2019    table_state: &mut TableState,
2020    area: Rect,
2021    buf: &mut Buffer,
2022) {
2023    let Some(results) = config.results else {
2024        render_run_prompt(area, config.theme, buf);
2025        return;
2026    };
2027    let report = build_report(results);
2028    let sections = Layout::default()
2029        .direction(Direction::Vertical)
2030        .constraints([Constraint::Length(2), Constraint::Fill(1)])
2031        .margin(1)
2032        .split(area);
2033    let title = rule_line(
2034        "Columns",
2035        Some(&numfmt::group_chrome(results.columns.len())),
2036        sections[0].width,
2037        config.theme,
2038    );
2039    Paragraph::new(title).render(sections[0], buf);
2040    let layout = if sections[1].width >= 120 {
2041        2
2042    } else if sections[1].width >= 72 {
2043        1
2044    } else {
2045        0
2046    };
2047    let dimmed = Style::default().fg(config.theme.get("dimmed"));
2048    let rows = results.columns.iter().enumerate().map(|(index, profile)| {
2049        let severity = report
2050            .column_status
2051            .get(index)
2052            .copied()
2053            .unwrap_or(Severity::Clean);
2054        // With no values read, a column with no finding is unknown, not clean.
2055        let unknown = severity == Severity::Clean && (report.no_rows || report.metadata_only);
2056        let mark = if unknown {
2057            Cell::from("")
2058        } else {
2059            Cell::from(Line::from(severity_mark(severity, config.theme)))
2060        };
2061        let findings = report
2062            .column_findings
2063            .get(index)
2064            .filter(|titles| !titles.is_empty())
2065            .map(|titles| Cell::from(titles.join(", ")))
2066            .unwrap_or_else(|| Cell::from(Span::styled("none", dimmed)));
2067        let most_common = profile
2068            .dominant_value
2069            .as_ref()
2070            .zip(profile.dominant_count)
2071            .map(|(value, count)| {
2072                format!(
2073                    "{value} ({})",
2074                    crate::quality_report::percent(count, profile.non_null_rows())
2075                )
2076            })
2077            .unwrap_or_else(|| "-".to_string());
2078        let missing = if profile.null_count == 0 {
2079            Cell::from(Span::styled("0", dimmed))
2080        } else {
2081            Cell::from(format!(
2082                "{} ({})",
2083                numfmt::group_chrome(profile.null_count),
2084                crate::quality_report::percent(profile.null_count, profile.evaluated_rows)
2085            ))
2086        };
2087        let distinct = count_label(profile.distinct_count);
2088        Row::new(match layout {
2089            2 => vec![
2090                mark,
2091                Cell::from(profile.name.clone()),
2092                Cell::from(profile.dtype.to_string()),
2093                missing,
2094                Cell::from(distinct),
2095                Cell::from(most_common),
2096                findings,
2097            ],
2098            1 => vec![
2099                mark,
2100                Cell::from(profile.name.clone()),
2101                Cell::from(profile.dtype.to_string()),
2102                missing,
2103                findings,
2104            ],
2105            _ => vec![mark, Cell::from(profile.name.clone()), findings],
2106        })
2107    });
2108    normalize_selection(table_state, results.columns.len());
2109    let (headers, widths) = match layout {
2110        2 => (
2111            vec![
2112                "",
2113                "Column",
2114                "Type",
2115                "Missing",
2116                "Distinct",
2117                "Most common",
2118                "Findings",
2119            ],
2120            vec![
2121                Constraint::Length(1),
2122                Constraint::Length(22),
2123                Constraint::Length(10),
2124                Constraint::Length(16),
2125                Constraint::Length(10),
2126                Constraint::Length(22),
2127                Constraint::Fill(1),
2128            ],
2129        ),
2130        1 => (
2131            vec!["", "Column", "Type", "Missing", "Findings"],
2132            vec![
2133                Constraint::Length(1),
2134                Constraint::Length(20),
2135                Constraint::Length(8),
2136                Constraint::Length(16),
2137                Constraint::Fill(1),
2138            ],
2139        ),
2140        _ => (
2141            vec!["", "Column", "Findings"],
2142            vec![
2143                Constraint::Length(1),
2144                Constraint::Length(16),
2145                Constraint::Fill(1),
2146            ],
2147        ),
2148    };
2149    let table = Table::new(rows, widths)
2150        .header(Row::new(headers).style(dimmed))
2151        .row_highlight_style(config.theme.highlight_style())
2152        .highlight_symbol(glyphs::get().selector);
2153    StatefulWidget::render(table, sections[1], buf, table_state);
2154}
2155
2156fn render_segments(
2157    config: &DataQualityWidgetConfig<'_>,
2158    table_state: &mut TableState,
2159    area: Rect,
2160    buf: &mut Buffer,
2161) {
2162    let Some(results) = config.results else {
2163        render_run_prompt(area, config.theme, buf);
2164        return;
2165    };
2166    let theme = config.theme;
2167    let dimmed = Style::default().fg(theme.get("dimmed"));
2168    let sections = Layout::default()
2169        .direction(Direction::Vertical)
2170        .constraints([Constraint::Length(2), Constraint::Fill(1)])
2171        .margin(1)
2172        .split(area);
2173    if config.plan.grain == QualityGrain::Dataset {
2174        Paragraph::new(rule_line("Segments", Some("1"), sections[0].width, theme))
2175            .render(sections[0], buf);
2176        Paragraph::new(
2177            "The rows are one segment. Set Grain in Setup (e) to split them by file, \
2178             partition, row chunk or time window, and compare the parts.",
2179        )
2180        .wrap(Wrap { trim: true })
2181        .style(Style::default().fg(theme.get("text_primary")))
2182        .render(sections[1], buf);
2183        return;
2184    }
2185    Paragraph::new(rule_line(
2186        if config.segments_by_change {
2187            "Segments, largest change first"
2188        } else {
2189            "Segments"
2190        },
2191        Some(&numfmt::group_chrome(results.segments.len())),
2192        sections[0].width,
2193        theme,
2194    ))
2195    .render(sections[0], buf);
2196    // A thin sample per segment can only name large moves; say how thin.
2197    if results.precision == QualityPrecision::Sampled && !results.segments.is_empty() {
2198        let typical = {
2199            let mut rows = results
2200                .segments
2201                .iter()
2202                .map(|segment| segment.evaluated_rows)
2203                .collect::<Vec<_>>();
2204            rows.sort_unstable();
2205            rows[rows.len() / 2]
2206        };
2207        Paragraph::new(Line::styled(
2208            format!(
2209                "About {} sampled rows a segment: a change is named only when it is past \
2210                 sampling noise. Trends pools segments to show smaller ones",
2211                numfmt::group_chrome(typical)
2212            ),
2213            dimmed,
2214        ))
2215        .render(
2216            Rect {
2217                y: sections[0].y + 1,
2218                height: 1,
2219                ..sections[0]
2220            },
2221            buf,
2222        );
2223    }
2224
2225    // What there is for every segment without choosing anything: its rows, how
2226    // much of it is empty, and the one change against its comparison that moved
2227    // most. Enter shows the rest.
2228    let rows_label = |segment: &crate::data_quality::SegmentQualityProfile| match segment.total_rows
2229    {
2230        Some(total) if total != segment.evaluated_rows => format!(
2231            "{} of {}",
2232            numfmt::group_chrome(segment.evaluated_rows),
2233            numfmt::group_chrome(total)
2234        ),
2235        _ => numfmt::group_chrome(segment.evaluated_rows),
2236    };
2237    let label_width = results
2238        .segments
2239        .iter()
2240        .map(|segment| glyphs::display_width(&segment_text(&segment.label)))
2241        .max()
2242        .unwrap_or(0)
2243        .clamp(7, 40) as u16
2244        + 2;
2245    let rows_width = results
2246        .segments
2247        .iter()
2248        .map(|segment| glyphs::display_width(&rows_label(segment)))
2249        .max()
2250        .unwrap_or(0)
2251        .max(4) as u16
2252        + 2;
2253    let compared = config.plan.comparison != QualityComparison::None;
2254    let order = crate::data_quality::segment_order(results, config.segments_by_change);
2255    let rows = order
2256        .iter()
2257        .map(|&index| &results.segments[index])
2258        .map(|segment| {
2259            let mut cells = vec![
2260                Cell::from(segment_text(&segment.label)),
2261                Cell::from(rows_label(segment)),
2262                Cell::from(format!("{:.1}%", segment.null_rate * 100.0)),
2263            ];
2264            if compared {
2265                cells.push(Cell::from(
2266                    segment.largest_change.clone().unwrap_or_default(),
2267                ));
2268            }
2269            Row::new(cells)
2270        });
2271    let mut headers = vec!["Segment", "Rows", "Null cells"];
2272    let mut widths = vec![
2273        Constraint::Length(label_width),
2274        Constraint::Length(rows_width),
2275        Constraint::Length(12),
2276    ];
2277    if compared {
2278        headers.push("Largest change");
2279        widths.push(Constraint::Fill(1));
2280    }
2281    normalize_selection(table_state, results.segments.len());
2282    let table = Table::new(rows, widths)
2283        .header(Row::new(headers).style(dimmed))
2284        .row_highlight_style(theme.highlight_style())
2285        .highlight_symbol(glyphs::get().selector);
2286    StatefulWidget::render(table, sections[1], buf, table_state);
2287}
2288
2289/// A rate as the report writes one: two places below 1% so a small share never
2290/// reads as none.
2291fn rate_label(value: f64) -> String {
2292    let percent = value * 100.0;
2293    if percent > 0.0 && percent < 1.0 {
2294        format!("{percent:.2}%")
2295    } else {
2296        format!("{percent:.1}%")
2297    }
2298}
2299
2300/// One segment's columns, every measure beside the segment it is compared with,
2301/// the largest move first: the answer to "what changed here" without choosing a
2302/// column or a measure first.
2303fn render_segment_detail(
2304    config: &DataQualityWidgetConfig<'_>,
2305    table_state: &mut TableState,
2306    segment_index: usize,
2307    area: Rect,
2308    buf: &mut Buffer,
2309) {
2310    let Some(results) = config.results else {
2311        render_run_prompt(area, config.theme, buf);
2312        return;
2313    };
2314    let Some(segment) = results.segments.get(segment_index) else {
2315        return;
2316    };
2317    let theme = config.theme;
2318    let dimmed = Style::default().fg(theme.get("dimmed"));
2319    let changes = crate::data_quality::segment_changes(results, segment_index);
2320    let sections = Layout::default()
2321        .direction(Direction::Vertical)
2322        .constraints([Constraint::Length(2), Constraint::Fill(1)])
2323        .margin(1)
2324        .split(area);
2325    let other = segment.compared_with.as_deref();
2326    let title = match other {
2327        Some(other) => format!(
2328            "{} vs {}",
2329            segment_text(&segment.label),
2330            segment_text(other)
2331        ),
2332        None => segment_text(&segment.label),
2333    };
2334    Paragraph::new(rule_line(
2335        &title,
2336        Some(&numfmt::group_chrome(changes.len())),
2337        sections[0].width,
2338        theme,
2339    ))
2340    .render(sections[0], buf);
2341    if changes.is_empty() {
2342        Paragraph::new(Span::styled("Nothing measured is above zero here", dimmed))
2343            .render(sections[1], buf);
2344        return;
2345    }
2346    let name_width = changes
2347        .iter()
2348        .map(|change| glyphs::display_width(&change.column))
2349        .max()
2350        .unwrap_or(0)
2351        .clamp(6, 32) as u16
2352        + 2;
2353    let value_width = |label: &str| (glyphs::display_width(label).clamp(8, 24) + 2) as u16;
2354    let rows = changes.iter().map(|change| {
2355        let style = if change.clear || other.is_none() {
2356            Style::default()
2357        } else {
2358            dimmed
2359        };
2360        let mut cells = vec![
2361            Cell::from(Span::styled(change.column.clone(), style)),
2362            Cell::from(Span::styled(change.metric.short_label(), dimmed)),
2363        ];
2364        if other.is_some() {
2365            cells.push(Cell::from(
2366                change
2367                    .before
2368                    .map(rate_label)
2369                    .unwrap_or_else(|| "-".to_string()),
2370            ));
2371        }
2372        cells.push(Cell::from(rate_label(change.now)));
2373        if other.is_some() {
2374            cells.push(match change.change() {
2375                Some(points) => Cell::from(Span::styled(format!("{points:+.1} pp"), style)),
2376                None => Cell::from(""),
2377            });
2378        }
2379        Row::new(cells)
2380    });
2381    let mut headers = vec!["Column".to_string(), "Measure".to_string()];
2382    let mut widths = vec![Constraint::Length(name_width), Constraint::Length(10)];
2383    if let Some(other) = other {
2384        headers.push(segment_text(other));
2385        widths.push(Constraint::Length(value_width(&segment_text(other))));
2386    }
2387    headers.push(segment_text(&segment.label));
2388    widths.push(Constraint::Length(value_width(&segment_text(
2389        &segment.label,
2390    ))));
2391    if other.is_some() {
2392        headers.push("Change".to_string());
2393        widths.push(Constraint::Fill(1));
2394    }
2395    normalize_selection(table_state, changes.len());
2396    let table = Table::new(rows, widths)
2397        .header(Row::new(headers).style(dimmed))
2398        .row_highlight_style(theme.highlight_style())
2399        .highlight_symbol(glyphs::get().selector);
2400    StatefulWidget::render(table, sections[1], buf, table_state);
2401}
2402
2403fn render_trends(
2404    config: &DataQualityWidgetConfig<'_>,
2405    table_state: &mut TableState,
2406    area: Rect,
2407    buf: &mut Buffer,
2408) {
2409    let Some(results) = config.results else {
2410        render_run_prompt(area, config.theme, buf);
2411        return;
2412    };
2413    let [area] = Layout::default()
2414        .direction(Direction::Vertical)
2415        .constraints([Constraint::Fill(1)])
2416        .margin(1)
2417        .areas(area);
2418    if crate::data_quality::shows_trend(config.plan, results) {
2419        render_trend_table(config, results, table_state, area, buf);
2420        return;
2421    }
2422    let [title, body] = Layout::default()
2423        .direction(Direction::Vertical)
2424        .constraints([Constraint::Length(2), Constraint::Fill(1)])
2425        .areas(area);
2426    Paragraph::new(rule_line(
2427        "Across segments",
2428        None,
2429        title.width,
2430        config.theme,
2431    ))
2432    .render(title, buf);
2433    let mut text = vec![Line::raw(
2434        "Set Grain in Setup (e) to a partition column, to days, weeks or months of a \
2435         date, or to chunks of rows, to follow each column from one to the next.",
2436    )];
2437    // One window found is no trend, but the windows expected around it still are.
2438    if let Some(gaps) = crate::quality_trends::expected_gaps(config.plan, results) {
2439        text.extend([Line::raw(""), Line::raw(gaps_summary(config.plan, &gaps))]);
2440    }
2441    Paragraph::new(text)
2442        .wrap(Wrap { trim: true })
2443        .style(Style::default().fg(config.theme.get("text_primary")))
2444        .render(body, buf);
2445}
2446
2447/// `count` of `of`, with its rate when there is one: `2 of 5 (40.0%)`.
2448fn share(count: usize, of: usize) -> String {
2449    if of == 0 || count == 0 {
2450        format!(
2451            "{} of {}",
2452            numfmt::group_chrome(count),
2453            numfmt::group_chrome(of)
2454        )
2455    } else {
2456        format!(
2457            "{} of {} ({})",
2458            numfmt::group_chrome(count),
2459            numfmt::group_chrome(of),
2460            rate_label(count as f64 / of as f64)
2461        )
2462    }
2463}
2464
2465/// The rate of `count` in `of`, or a dash with nothing to take it over.
2466fn share_rate(count: usize, of: usize) -> String {
2467    // A dash as `duration_label` has it, beside which it sits.
2468    if of == 0 {
2469        "-".to_string()
2470    } else {
2471        rate_label(count as f64 / of as f64)
2472    }
2473}
2474
2475/// Each interval in each segment, one row each: which, where, and the counts a
2476/// glance needs. Enter opens every count it took, so nothing is lost to a narrow
2477/// terminal.
2478fn render_intervals(
2479    config: &DataQualityWidgetConfig<'_>,
2480    table_state: &mut TableState,
2481    area: Rect,
2482    buf: &mut Buffer,
2483) {
2484    let Some(results) = config.results else {
2485        render_run_prompt(area, config.theme, buf);
2486        return;
2487    };
2488    let theme = config.theme;
2489    let plan = config.plan;
2490    let dimmed = Style::default().fg(theme.get("dimmed"));
2491    let [title, note, body] = Layout::default()
2492        .direction(Direction::Vertical)
2493        .constraints([
2494            Constraint::Length(1),
2495            Constraint::Length(1),
2496            Constraint::Fill(1),
2497        ])
2498        .margin(1)
2499        .areas(area);
2500    let profiles = &results.temporal;
2501    let chip = (!profiles.is_empty()).then(|| numfmt::group_chrome(profiles.len()));
2502    Paragraph::new(rule_line(
2503        "Time between dates",
2504        chip.as_deref(),
2505        title.width,
2506        theme,
2507    ))
2508    .render(title, buf);
2509    if profiles.is_empty() {
2510        let schema = config.state.quality_schema(&plan.scope);
2511        let message = if config.setup.time_candidates.is_empty() {
2512            "No date, time or text columns"
2513        } else if plan.compute == QualityCompute::Metadata && !plan.interval_pairs().is_empty() {
2514            "File metadata only · no values read · e Setup"
2515        } else if plan.interval_pairs().iter().any(|(start, end)| {
2516            [start, end].into_iter().any(|role| {
2517                plan.role_column(*role)
2518                    .is_some_and(|column| !plan.reads_as_time(column, schema))
2519            })
2520        }) {
2521            "Interval column is text with no format · Text as time in Setup (e)"
2522        } else if !plan.interval_pairs().is_empty() {
2523            "No rows to measure"
2524        } else if !plan.candidate_pairs().is_empty() {
2525            "Time roles make no interval · Intervals in Setup (e)"
2526        } else {
2527            "No time roles · Time roles in Setup (e)"
2528        };
2529        Paragraph::new(dotted(message))
2530            .wrap(Wrap { trim: true })
2531            .style(Style::default().fg(theme.get("text_primary")))
2532            .render(
2533                Rect {
2534                    height: body.height + 1,
2535                    ..note
2536                },
2537                buf,
2538            );
2539        return;
2540    }
2541    // What the last column counts, and out of what, said once above it.
2542    let threshold = plan.latency_threshold_seconds;
2543    let over = match threshold {
2544        Some(_) => format!(
2545            "Over: duration > {}, of rows with both ends",
2546            crate::analysis_modal::threshold_label(threshold)
2547        ),
2548        None => "Negative: end before start, of rows with both ends".to_string(),
2549    };
2550    Paragraph::new(Line::styled(fit(&over, note.width as usize), dimmed)).render(note, buf);
2551
2552    let segmented = profiles
2553        .iter()
2554        .any(|profile| profile.segment != profiles[0].segment);
2555    let segment_width = profiles
2556        .iter()
2557        .map(|profile| glyphs::display_width(&segment_text(&profile.segment)))
2558        .max()
2559        .unwrap_or(0)
2560        .clamp(8, 22) as u16;
2561    let last = |profile: &TemporalLatencyProfile, counted: bool| {
2562        let count = match profile.above_threshold_count {
2563            Some(count) if threshold.is_some() => count,
2564            _ => profile.negative_count,
2565        };
2566        let rate = share_rate(count, profile.paired_rows);
2567        if counted && profile.paired_rows > 0 {
2568            format!("{} ({rate})", numfmt::group_chrome(count))
2569        } else {
2570            rate
2571        }
2572    };
2573    let last_header = if threshold.is_some() {
2574        "Over"
2575    } else {
2576        "Negative"
2577    };
2578    let wide = body.width >= 92;
2579    let medium = !wide && body.width >= 44;
2580    let mut headers = vec!["Interval"];
2581    let mut fixed = vec![];
2582    if segmented && (wide || medium) {
2583        headers.push("Segment");
2584        fixed.push(segment_width);
2585    }
2586    if wide {
2587        headers.extend(["Both ends", "Missing s/e", "p50", "p95"]);
2588        fixed.extend([9, 12, 7, 7]);
2589    } else {
2590        headers.push("p50");
2591        fixed.push(7);
2592    }
2593    headers.push(last_header);
2594    fixed.push(if wide { 15 } else { 9 });
2595    // What the interval's name has left: a long one ends in an ellipsis rather
2596    // than losing its last letters unmarked, and the detail says it in full.
2597    let interval_width = (body.width as usize).saturating_sub(
2598        glyphs::display_width(glyphs::get().selector)
2599            + fixed.iter().map(|width| *width as usize + 1).sum::<usize>(),
2600    );
2601    let rows = profiles.iter().map(|profile| {
2602        let mut cells = vec![fit(&profile.label(), interval_width)];
2603        if segmented && (wide || medium) {
2604            cells.push(fit(&segment_text(&profile.segment), segment_width as usize));
2605        }
2606        if wide {
2607            cells.extend([
2608                numfmt::group_chrome(profile.paired_rows),
2609                format!(
2610                    "{} / {}",
2611                    numfmt::group_chrome(profile.missing_start),
2612                    numfmt::group_chrome(profile.missing_end)
2613                ),
2614                duration_label(profile.p50_seconds),
2615                duration_label(profile.p95_seconds),
2616            ]);
2617        } else {
2618            cells.push(duration_label(profile.p50_seconds));
2619        }
2620        cells.push(last(profile, wide));
2621        Row::new(cells)
2622    });
2623    let widths = std::iter::once(Constraint::Fill(1))
2624        .chain(fixed.into_iter().map(Constraint::Length))
2625        .collect::<Vec<_>>();
2626    normalize_selection(table_state, profiles.len());
2627    let table = Table::new(rows, widths)
2628        .header(Row::new(headers).style(dimmed))
2629        .row_highlight_style(theme.highlight_style())
2630        .highlight_symbol(glyphs::get().selector);
2631    StatefulWidget::render(table, body, buf, table_state);
2632}
2633
2634/// One interval in one segment: its endpoints and every count it took, each out
2635/// of what it is out of. Missing ends, text a format did not read, and negative
2636/// durations are separate rows, never folded together. The counts with rows
2637/// behind them take the cursor; Enter opens those rows. From the measurements the
2638/// report holds: nothing here reads.
2639fn render_interval_detail(
2640    config: &DataQualityWidgetConfig<'_>,
2641    table_state: &mut TableState,
2642    area: Rect,
2643    buf: &mut Buffer,
2644) {
2645    let Some(results) = config.results else {
2646        render_run_prompt(area, config.theme, buf);
2647        return;
2648    };
2649    let Some(profile) = results.temporal.get(config.interval_index) else {
2650        return;
2651    };
2652    let theme = config.theme;
2653    let plan = config.plan;
2654    let [title, body] = Layout::default()
2655        .direction(Direction::Vertical)
2656        .constraints([Constraint::Length(2), Constraint::Fill(1)])
2657        .margin(1)
2658        .areas(area);
2659    Paragraph::new(rule_line(
2660        &profile.label(),
2661        Some(&segment_text(&profile.segment)),
2662        title.width,
2663        theme,
2664    ))
2665    .render(title, buf);
2666
2667    let endpoint = |role: TemporalRole, column: &str| match plan.time_format(column) {
2668        Some(format) if format.zoned() => format!("{}: {column}, text with offset", role.label()),
2669        Some(format) => format!(
2670            "{}: {column}, text as {}",
2671            role.label(),
2672            format.kind.label()
2673        ),
2674        None => format!("{}: {column}", role.label()),
2675    };
2676    let grain = plan.interval_grain(&profile.start_column, &profile.end_column);
2677    let facts = IntervalFact::ALL
2678        .into_iter()
2679        .filter_map(|fact| profile.count(fact, plan).map(|count| (fact, count)))
2680        .collect::<Vec<_>>();
2681    let selected = facts
2682        .get(
2683            table_state
2684                .selected()
2685                .unwrap_or(0)
2686                .min(facts.len().saturating_sub(1)),
2687        )
2688        .map(|(fact, _)| *fact);
2689    let plain = |label: &str, value: String| {
2690        (
2691            FieldRow {
2692                mark: None,
2693                label: label.to_string(),
2694                value,
2695            },
2696            None,
2697        )
2698    };
2699    let fact_row = |fact: IntervalFact| {
2700        let (count, of) = profile.count(fact, plan)?;
2701        let value = match fact {
2702            IntervalFact::Negative | IntervalFact::Zero | IntervalFact::OverThreshold => {
2703                format!("{} with both ends", share(count, of))
2704            }
2705            _ => share(count, of),
2706        };
2707        let mark = (selected == Some(fact))
2708            .then(|| Span::styled(glyphs::get().rail, Style::default().fg(theme.get("accent"))));
2709        Some((
2710            FieldRow {
2711                mark,
2712                label: fact.label(profile),
2713                value,
2714            },
2715            Some(fact),
2716        ))
2717    };
2718    let mut rows = vec![
2719        plain("Start", endpoint(profile.start_role, &profile.start_column)),
2720        plain("End", endpoint(profile.end_role, &profile.end_column)),
2721    ];
2722    if grain != QualityGrain::Dataset {
2723        rows.push(plain(
2724            "Segment",
2725            format!("{}, {}", segment_text(&profile.segment), grain.label()),
2726        ));
2727    }
2728    rows.push(plain("Rows", numfmt::group_chrome(profile.evaluated_rows)));
2729    rows.push(plain(
2730        "Both ends",
2731        share(profile.paired_rows, profile.evaluated_rows),
2732    ));
2733    rows.extend(
2734        [
2735            IntervalFact::MissingStart,
2736            IntervalFact::MissingEnd,
2737            IntervalFact::UnparsedStart,
2738            IntervalFact::UnparsedEnd,
2739            IntervalFact::Negative,
2740            IntervalFact::Zero,
2741        ]
2742        .into_iter()
2743        .filter_map(fact_row),
2744    );
2745    let pair = |left: Option<i64>, right: Option<i64>| {
2746        format!("{}, {}", duration_label(left), duration_label(right))
2747    };
2748    rows.push(plain(
2749        "p50, p90",
2750        pair(profile.p50_seconds, profile.p90_seconds),
2751    ));
2752    rows.push(plain(
2753        "p95, p99",
2754        pair(profile.p95_seconds, profile.p99_seconds),
2755    ));
2756    rows.push(plain("Maximum", duration_label(profile.max_seconds)));
2757    rows.push(plain(
2758        "Threshold",
2759        match profile.threshold_seconds {
2760            Some(_) => format!(
2761                "duration > {}, strictly",
2762                crate::analysis_modal::threshold_label(profile.threshold_seconds)
2763            ),
2764            None => "none: set Latency over in Setup".to_string(),
2765        },
2766    ));
2767    rows.extend(fact_row(IntervalFact::OverThreshold));
2768
2769    let label_width = rows
2770        .iter()
2771        .map(|(row, _)| glyphs::display_width(&row.label))
2772        .max()
2773        .unwrap_or(0)
2774        + 2;
2775    let width = (body.width as usize).min(DETAIL_MEASURE);
2776    // Each row's lines, and where the selected count's start, so a short terminal
2777    // scrolls to keep it in view.
2778    let mut lines = Vec::new();
2779    let mut focus = 0;
2780    // Enter opens nothing in a row chunk or a file, so say why under the segment
2781    // rather than leave it to be found out.
2782    let closed = (!profile.segment_opens(plan)).then(|| {
2783        let what = match grain {
2784            QualityGrain::File => "a file",
2785            _ => "a row chunk",
2786        };
2787        Line::styled(
2788            fit(
2789                &format!("  Rows do not open: {what} is not a filter value"),
2790                width,
2791            ),
2792            Style::default().fg(theme.get("dimmed")),
2793        )
2794    });
2795    for (row, fact) in &rows {
2796        if fact.is_some() && *fact == selected {
2797            focus = lines.len();
2798        }
2799        let mut row_lines = field_lines(std::slice::from_ref(row), label_width, width, true);
2800        if fact.is_some() && *fact == selected {
2801            for line in &mut row_lines {
2802                if let Some(label) = line.spans.get_mut(2) {
2803                    label.style = Style::default().fg(theme.get("accent"));
2804                }
2805            }
2806        }
2807        lines.extend(row_lines);
2808        if row.label == "Segment" {
2809            lines.extend(closed.clone());
2810        }
2811    }
2812    let height = body.height as usize;
2813    let offset = if lines.len() > height {
2814        // Room for the count of what is below, and for the focused row; never
2815        // past the last line, which would leave the bottom blank.
2816        (focus + 2)
2817            .saturating_sub(height.saturating_sub(1))
2818            .min(lines.len() - height)
2819    } else {
2820        0
2821    };
2822    render_counted(lines.split_off(offset), body, theme, buf);
2823}
2824
2825/// Which starts and ends are measured, chosen from every pair the assigned roles
2826/// make, the suggested ones first. Space turns the pair under the cursor on or off.
2827fn render_interval_pairs(
2828    config: &DataQualityWidgetConfig<'_>,
2829    table_state: &mut TableState,
2830    area: Rect,
2831    buf: &mut Buffer,
2832) {
2833    let theme = config.theme;
2834    let plan = config.plan;
2835    let dimmed = Style::default().fg(theme.get("dimmed"));
2836    let [title, body] = Layout::default()
2837        .direction(Direction::Vertical)
2838        .constraints([Constraint::Length(2), Constraint::Fill(1)])
2839        .margin(1)
2840        .areas(area);
2841    let candidates = plan.candidate_pairs();
2842    let chosen = plan.interval_pairs();
2843    Paragraph::new(rule_line(
2844        "Intervals",
2845        Some(&format!(
2846            "{} of {}",
2847            numfmt::group_chrome(chosen.len()),
2848            numfmt::group_chrome(candidates.len())
2849        )),
2850        title.width,
2851        theme,
2852    ))
2853    .render(title, buf);
2854    if candidates.is_empty() {
2855        Paragraph::new(Span::styled(
2856            dotted("Needs 2 time roles · Time roles in Setup (e)"),
2857            dimmed,
2858        ))
2859        .wrap(Wrap { trim: true })
2860        .render(body, buf);
2861        return;
2862    }
2863    let g = glyphs::get();
2864    let label_width = candidates
2865        .iter()
2866        .map(|pair| glyphs::display_width(&interval_label(*pair)))
2867        .max()
2868        .unwrap_or(0) as u16
2869        + 2;
2870    // The selector, the box, the label and the gaps between them come first.
2871    let columns_width = (body.width as usize)
2872        .saturating_sub(glyphs::display_width(g.selector) + 1 + label_width as usize + 2);
2873    let rows = candidates.iter().map(|pair| {
2874        let on = chosen.contains(pair);
2875        let columns = format!(
2876            "{} to {}",
2877            plan.role_column(pair.0).unwrap_or_default(),
2878            plan.role_column(pair.1).unwrap_or_default()
2879        );
2880        Row::new(vec![
2881            Cell::from(if on { g.checkbox_on } else { g.checkbox_off }),
2882            Cell::from(interval_label(*pair)),
2883            Cell::from(Span::styled(fit(&columns, columns_width), dimmed)),
2884        ])
2885    });
2886    table_state.select(Some(
2887        config.plan_field.min(candidates.len().saturating_sub(1)),
2888    ));
2889    let table = Table::new(
2890        rows,
2891        [
2892            Constraint::Length(glyphs::display_width(g.checkbox_on).max(1) as u16),
2893            Constraint::Length(label_width),
2894            Constraint::Fill(1),
2895        ],
2896    )
2897    .row_highlight_style(theme.highlight_style())
2898    .highlight_symbol(g.selector);
2899    StatefulWidget::render(table, body, buf, table_state);
2900}
2901
2902/// The width of the Trends table's range column.
2903const TREND_RANGE: u16 = 20;
2904
2905/// The Trends table's name column and how many bars fit beside it and the range,
2906/// in `width`, past the selector and the gaps between columns. The table and a
2907/// bar's detail lay out the same, so a bar there is the bar the table drew.
2908fn trend_layout(results: &DataQualityResults, width: u16) -> (u16, usize) {
2909    let name_width = results
2910        .columns
2911        .iter()
2912        .map(|profile| glyphs::display_width(&profile.name))
2913        .max()
2914        .unwrap_or(0)
2915        .clamp(12, 28) as u16
2916        + 2;
2917    let bars = width.saturating_sub(trend_lead(name_width)).max(8) as usize;
2918    (name_width, bars)
2919}
2920
2921/// Where a Trends line's first bar sits: past the selector, the name, the range
2922/// and the space after each.
2923fn trend_lead(name_width: u16) -> u16 {
2924    glyphs::display_width(glyphs::get().selector) as u16 + name_width + TREND_RANGE + 2
2925}
2926
2927/// What a segment of `grain` is called, many of them.
2928fn trend_unit(grain: &QualityGrain) -> &'static str {
2929    match grain {
2930        QualityGrain::TimeWindows { every, .. } => match every.as_str() {
2931            "1h" => "hours",
2932            "1d" => "days",
2933            "1w" => "weeks",
2934            _ => "months",
2935        },
2936        QualityGrain::Partition(_) => "partitions",
2937        QualityGrain::File => "files",
2938        _ => "chunks",
2939    }
2940}
2941
2942/// One of `unit`, or many.
2943fn counted_unit(count: usize, unit: &str) -> String {
2944    let unit = if count == 1 {
2945        unit.strip_suffix('s').unwrap_or(unit)
2946    } else {
2947        unit
2948    };
2949    format!("{} {unit}", numfmt::group_chrome(count))
2950}
2951
2952/// A bar's mark in `line`: its level, the unsampled mark where the sample drew no
2953/// row (so a bar of missed segments is never a short bar or a blank), and a blank
2954/// where the measure has nothing to apply to. The exact rows line has a level for
2955/// every bar.
2956fn bar_mark(line: &TrendRow, bar: &TrendBar, index: usize, g: &glyphs::Glyphs) -> &'static str {
2957    if bar.evaluated == 0 && line.measure != TrendMeasure::Rows {
2958        return g.unsampled;
2959    }
2960    match line.bars.get(index).copied().flatten() {
2961        Some(value) if line.high > 0.0 => {
2962            g.mini_bars[((value / line.high) * 7.0).round().clamp(0.0, 7.0) as usize]
2963        }
2964        Some(_) => g.mini_bars[0],
2965        None => " ",
2966    }
2967}
2968
2969/// A line's lowest and highest bar, as the Range column says them.
2970fn trend_range(line: &TrendRow) -> String {
2971    if line.rows() {
2972        format!(
2973            "{} to {}",
2974            numfmt::group_chrome(line.low.round() as usize),
2975            numfmt::group_chrome(line.high.round() as usize)
2976        )
2977    } else if (line.high - line.low).abs() < 1e-9 {
2978        rate_label(line.high)
2979    } else {
2980        format!("{} to {}", rate_label(line.low), rate_label(line.high))
2981    }
2982}
2983
2984/// The coarser grain `w` stages, in words: "weekly".
2985fn coarser_label(grain: &QualityGrain) -> String {
2986    match grain {
2987        QualityGrain::TimeWindows { every, .. } => window_cadence(every).to_string(),
2988        other => other.label(),
2989    }
2990}
2991
2992/// What the Trends page says under its title: the span, how much of it the sample
2993/// reached, and the expected windows with no rows. Each fact a line.
2994fn trend_notes(
2995    config: &DataQualityWidgetConfig<'_>,
2996    results: &DataQualityResults,
2997    view: &TrendView<'_>,
2998) -> Vec<String> {
2999    let plan = config.plan;
3000    let unit = trend_unit(&plan.grain);
3001    let middot = glyphs::get().middot;
3002    let first = view.slots.first().map(|slot| slot.label).unwrap_or("");
3003    let last = view.slots.last().map(|slot| slot.label).unwrap_or("");
3004    let mut facts = vec![format!(
3005        "{} to {}, {}",
3006        segment_text(first),
3007        segment_text(last),
3008        counted_unit(view.slots.len(), unit)
3009    )];
3010    if view.per_bar > 1 {
3011        facts.push(format!("each bar {}", counted_unit(view.per_bar, unit)));
3012    }
3013    if view.sampled {
3014        facts.push("a bar's rate pools its sampled rows".to_string());
3015    }
3016    let mut notes = vec![facts.join(&format!(" {middot} "))];
3017    let (unsampled, thin) = view.coverage();
3018    if view.sampled && unsampled + thin > 0 {
3019        let mut reach = Vec::new();
3020        if unsampled > 0 {
3021            reach.push(format!(
3022                "{} of {} not sampled ({})",
3023                numfmt::group_chrome(unsampled),
3024                counted_unit(view.slots.len(), unit),
3025                glyphs::get().unsampled
3026            ));
3027        }
3028        if thin > 0 {
3029            reach.push(format!(
3030                "{} under {} sampled rows",
3031                numfmt::group_chrome(thin),
3032                crate::quality_report::THIN_SEGMENT_ROWS
3033            ));
3034        }
3035        let mut note = reach.join(", ");
3036        if let Some(coarser) = plan.coarser_grain() {
3037            note.push_str(&format!("; w stages {}", coarser_label(&coarser)));
3038        }
3039        notes.push(note);
3040    }
3041    if let Some(gaps) = crate::quality_trends::expected_gaps(plan, results) {
3042        notes.push(gaps_summary(plan, &gaps));
3043    }
3044    notes
3045}
3046
3047/// The expected windows in one line, for the Trends page.
3048fn gaps_summary(plan: &DataQualityPlan, gaps: &Gaps) -> String {
3049    let every = match &plan.grain {
3050        QualityGrain::TimeWindows { every, .. } => every.as_str(),
3051        _ => "",
3052    };
3053    let cadence = plan
3054        .expected
3055        .as_ref()
3056        .map(|expected| expected.cadence_label(every))
3057        .unwrap_or_default();
3058    let unit = trend_unit(&plan.grain);
3059    match gaps {
3060        Gaps::NoValues => format!("Expected {cadence}: file metadata only, no windows counted"),
3061        Gaps::NoWindows => {
3062            format!("Expected {cadence}: no window found, no range set")
3063        }
3064        Gaps::TooMany { windows } => format!(
3065            "Expected {cadence}: {} in range, over {} {dot} narrow in Setup (e)",
3066            counted_unit(*windows, unit),
3067            numfmt::group_chrome(crate::quality_trends::MAX_EXPECTED_WINDOWS),
3068            dot = glyphs::get().middot,
3069        ),
3070        // From typed past the last window found, with Before blank.
3071        Gaps::Checked(check) if check.expected == 0 => {
3072            format!("Expected {cadence}: no window in range")
3073        }
3074        Gaps::Checked(check) if check.gaps() == 0 => format!(
3075            "Expected {cadence}: all {} have rows",
3076            counted_unit(check.expected, unit)
3077        ),
3078        Gaps::Checked(check) => format!(
3079            "Expected {cadence}, {}: {} {} g lists",
3080            counted_unit(check.expected, unit),
3081            gap_counts(check),
3082            glyphs::get().middot,
3083        ),
3084    }
3085}
3086
3087/// "5 empty, 2 not sampled": each kind of gap there is, with its count.
3088fn gap_counts(check: &GapCheck) -> String {
3089    [
3090        (check.empty, GapKind::Empty),
3091        (check.unsampled, GapKind::Unsampled),
3092        (check.out_of_scope, GapKind::OutOfScope),
3093    ]
3094    .into_iter()
3095    .filter(|(count, _)| *count > 0)
3096    .map(|(count, kind)| format!("{} {}", numfmt::group_chrome(count), kind.label()))
3097    .collect::<Vec<_>>()
3098    .join(", ")
3099}
3100
3101/// Each column's measure across the segments as a line of bars, the rows each
3102/// segment holds first. The whole range fits the width: a bar pools as many
3103/// consecutive segments as it takes. Segments the sample missed are bars of their
3104/// own mark, and the notes say how many, and what expected windows have no rows.
3105fn render_trend_table(
3106    config: &DataQualityWidgetConfig<'_>,
3107    results: &DataQualityResults,
3108    table_state: &mut TableState,
3109    area: Rect,
3110    buf: &mut Buffer,
3111) {
3112    let theme = config.theme;
3113    let dimmed = Style::default().fg(theme.get("dimmed"));
3114    let (name_width, bars) = trend_layout(results, area.width);
3115    let view = trend_view(results, config.metric, bars);
3116    let notes = trend_notes(config, results, &view)
3117        .iter()
3118        .flat_map(|note| crate::widgets::info::wrap_to(note, area.width as usize))
3119        .collect::<Vec<_>>();
3120    // A short terminal keeps the table: the notes give way first.
3121    let room = (area.height as usize).saturating_sub(5).max(1);
3122    let notes = notes.into_iter().take(room).collect::<Vec<_>>();
3123    let [title, note, table_area] = Layout::default()
3124        .direction(Direction::Vertical)
3125        .constraints([
3126            Constraint::Length(1),
3127            Constraint::Length(notes.len() as u16 + 1),
3128            Constraint::Fill(1),
3129        ])
3130        .areas(area);
3131    Paragraph::new(rule_line(
3132        &format!("{} over time", config.metric.label()),
3133        Some(&numfmt::group_chrome(
3134            view.lines
3135                .iter()
3136                .filter(|row| !row.rows())
3137                .map(|row| row.names.len())
3138                .sum::<usize>(),
3139        )),
3140        title.width,
3141        theme,
3142    ))
3143    .render(title, buf);
3144    Paragraph::new(
3145        notes
3146            .iter()
3147            .map(|text| Line::styled(fit(text, note.width as usize), dimmed))
3148            .collect::<Vec<_>>(),
3149    )
3150    .render(note, buf);
3151    let g = glyphs::get();
3152    let accent = Style::default().fg(theme.get("accent"));
3153    let lines = view.lines.iter().map(|row| {
3154        let spark = view
3155            .bars
3156            .iter()
3157            .enumerate()
3158            .map(|(index, bar)| {
3159                let mark = bar_mark(row, bar, index, g);
3160                Span::styled(mark, if mark == g.unsampled { dimmed } else { accent })
3161            })
3162            .collect::<Vec<_>>();
3163        let name = crate::quality_report::columns_label(&row.names, name_width as usize - 2);
3164        Row::new(vec![
3165            Cell::from(if row.rows() {
3166                Span::styled(name, dimmed)
3167            } else {
3168                Span::raw(name)
3169            }),
3170            Cell::from(Span::styled(trend_range(row), dimmed)),
3171            Cell::from(Line::from(spark)),
3172        ])
3173    });
3174    normalize_selection(table_state, view.lines.len());
3175    let table = Table::new(
3176        lines,
3177        [
3178            Constraint::Length(name_width),
3179            Constraint::Length(TREND_RANGE),
3180            Constraint::Fill(1),
3181        ],
3182    )
3183    .header(Row::new(["Column", "Range", "Trend"]).style(dimmed))
3184    .row_highlight_style(theme.highlight_style())
3185    .highlight_symbol(g.selector);
3186    StatefulWidget::render(table, table_area, buf, table_state);
3187}
3188
3189/// One bar of one Trends line, the line drawn above it with a pointer under the
3190/// bar: what it spans, how many segments it pools and how much of them the run
3191/// read, its value with what it is out of, how sure a sample is of it, and how it
3192/// stands against the bar it is compared with. From the report's measurements:
3193/// nothing here reads. ↑↓ walk the bars.
3194fn render_trend_detail(
3195    config: &DataQualityWidgetConfig<'_>,
3196    table_state: &mut TableState,
3197    area: Rect,
3198    buf: &mut Buffer,
3199) {
3200    let Some(results) = config.results else {
3201        render_run_prompt(area, config.theme, buf);
3202        return;
3203    };
3204    let theme = config.theme;
3205    let g = glyphs::get();
3206    let dimmed = Style::default().fg(theme.get("dimmed"));
3207    let accent = Style::default().fg(theme.get("accent"));
3208    let [area] = Layout::default()
3209        .direction(Direction::Vertical)
3210        .constraints([Constraint::Fill(1)])
3211        .margin(1)
3212        .areas(area);
3213    let (name_width, bars) = trend_layout(results, area.width);
3214    let view = trend_view(results, config.metric, bars);
3215    let (Some(line), false) = (view.lines.get(config.trend_line), view.bars.is_empty()) else {
3216        Paragraph::new(dotted("No bars · Esc Trends"))
3217            .style(Style::default().fg(theme.get("text_primary")))
3218            .render(area, buf);
3219        return;
3220    };
3221    // Held to the last bar: the keys only know an upper bound, the width decides.
3222    let index = table_state.selected().unwrap_or(0).min(view.bars.len() - 1);
3223    table_state.select(Some(index));
3224    let [title, spark, pointer, _, body] = Layout::default()
3225        .direction(Direction::Vertical)
3226        .constraints([
3227            Constraint::Length(1),
3228            Constraint::Length(1),
3229            Constraint::Length(1),
3230            Constraint::Length(1),
3231            Constraint::Fill(1),
3232        ])
3233        .areas(area);
3234    let measure = match line.measure {
3235        TrendMeasure::Rows => "Rows per segment".to_string(),
3236        TrendMeasure::SampledRows => "Sampled rows per segment".to_string(),
3237        TrendMeasure::Column(_) => config.metric.label().to_string(),
3238    };
3239    Paragraph::new(rule_line(
3240        &measure,
3241        Some(&format!(
3242            "bar {} of {}",
3243            numfmt::group_chrome(index + 1),
3244            numfmt::group_chrome(view.bars.len())
3245        )),
3246        title.width,
3247        theme,
3248    ))
3249    .render(title, buf);
3250
3251    // The line as Trends drew it, its selected bar pointed at from below.
3252    let name = crate::quality_report::columns_label(&line.names, name_width as usize - 2);
3253    let lead = |text: String, width: u16| {
3254        format!(
3255            "{text}{}",
3256            " ".repeat((width as usize).saturating_sub(glyphs::display_width(&text)))
3257        )
3258    };
3259    let mut spans = vec![
3260        Span::raw(g.selector_blank),
3261        Span::raw(lead(name, name_width + 1)),
3262        Span::styled(lead(trend_range(line), TREND_RANGE + 1), dimmed),
3263    ];
3264    spans.extend(view.bars.iter().enumerate().map(|(at, candidate)| {
3265        let mark = bar_mark(line, candidate, at, g);
3266        let style = if mark == g.unsampled { dimmed } else { accent };
3267        Span::styled(
3268            mark,
3269            if at == index {
3270                style.add_modifier(Modifier::BOLD)
3271            } else {
3272                style
3273            },
3274        )
3275    }));
3276    Paragraph::new(Line::from(spans)).render(spark, buf);
3277    Paragraph::new(Line::from(vec![
3278        Span::raw(" ".repeat(trend_lead(name_width) as usize + index)),
3279        Span::styled(g.pointer, accent),
3280    ]))
3281    .render(pointer, buf);
3282
3283    let rows = trend_bar_fields(config, results, &view, line, index);
3284    let label_width = rows
3285        .iter()
3286        .map(|row| glyphs::display_width(&row.label))
3287        .max()
3288        .unwrap_or(0)
3289        + 2;
3290    let width = (body.width as usize).min(DETAIL_MEASURE);
3291    render_counted(
3292        field_lines(&rows, label_width, width, false),
3293        body,
3294        theme,
3295        buf,
3296    );
3297}
3298
3299/// A bar's facts as label and value rows.
3300fn trend_bar_fields(
3301    config: &DataQualityWidgetConfig<'_>,
3302    results: &DataQualityResults,
3303    view: &TrendView<'_>,
3304    line: &TrendRow,
3305    index: usize,
3306) -> Vec<FieldRow> {
3307    let plan = config.plan;
3308    let bar = &view.bars[index];
3309    let unit = trend_unit(&plan.grain);
3310    let row = |label: &str, value: String| FieldRow {
3311        mark: None,
3312        label: label.to_string(),
3313        value,
3314    };
3315    let mut rows = vec![row(
3316        "Span",
3317        segment_text(&crate::quality_trends::bar_span(view, bar, &plan.grain)),
3318    )];
3319    // How much of the bar the run reached, before any number taken from it.
3320    let mut reach = vec![counted_unit(bar.segments(), unit)];
3321    if view.sampled {
3322        if bar.unsampled > 0 {
3323            reach.push(format!(
3324                "{} not sampled",
3325                numfmt::group_chrome(bar.unsampled)
3326            ));
3327        }
3328        if bar.thin > 0 {
3329            reach.push(format!(
3330                "{} under {} sampled rows",
3331                numfmt::group_chrome(bar.thin),
3332                crate::quality_report::THIN_SEGMENT_ROWS
3333            ));
3334        }
3335        if bar.unsampled + bar.thin == 0 {
3336            reach.push("each sampled".to_string());
3337        }
3338    }
3339    rows.push(row("Segments", reach.join(", ")));
3340    let every_row = !view.sampled || bar.eligible == Some(bar.evaluated);
3341    rows.push(row(
3342        "Rows",
3343        match (view.sampled, bar.eligible) {
3344            (false, Some(eligible)) => {
3345                format!("{}, every row read", numfmt::group_chrome(eligible))
3346            }
3347            (false, None) => format!("{}, every row read", numfmt::group_chrome(bar.evaluated)),
3348            (true, Some(eligible)) => format!(
3349                "{} sampled of {} ({})",
3350                numfmt::group_chrome(bar.evaluated),
3351                numfmt::group_chrome(eligible),
3352                share_rate(bar.evaluated, eligible)
3353            ),
3354            (true, None) => format!(
3355                "{} sampled, total not counted",
3356                numfmt::group_chrome(bar.evaluated)
3357            ),
3358        },
3359    ));
3360    let compared = crate::quality_trends::compared_bar(view, index, plan);
3361    let against = if plan.comparison == QualityComparison::Baseline {
3362        "Baseline bar"
3363    } else {
3364        "Previous bar"
3365    };
3366    if line.rows() {
3367        // Rows per segment: the mean, and the smallest and largest segment.
3368        let counts = view.slots[bar.slots.clone()]
3369            .iter()
3370            .map(|slot| match line.measure {
3371                TrendMeasure::Rows => slot.total.unwrap_or(0),
3372                _ => slot.evaluated,
3373            })
3374            .collect::<Vec<_>>();
3375        let (low, high) = (
3376            counts.iter().min().copied().unwrap_or(0),
3377            counts.iter().max().copied().unwrap_or(0),
3378        );
3379        let mean = line.bars[index].unwrap_or(0.0);
3380        rows.push(row(
3381            "Per segment",
3382            if low == high {
3383                numfmt::group_chrome(low)
3384            } else {
3385                format!(
3386                    "{} on average, {} to {}",
3387                    numfmt::group_chrome(mean.round() as usize),
3388                    numfmt::group_chrome(low),
3389                    numfmt::group_chrome(high)
3390                )
3391            },
3392        ));
3393        if let Some(other) = compared {
3394            let before = line.bars[other].unwrap_or(0.0);
3395            let moved = if before > 0.0 {
3396                format!(" ({:+.1}%)", (mean - before) / before * 100.0)
3397            } else {
3398                String::new()
3399            };
3400            rows.push(row(
3401                against,
3402                format!(
3403                    "{} to {} per segment{moved}",
3404                    numfmt::group_chrome(before.round() as usize),
3405                    numfmt::group_chrome(mean.round() as usize)
3406                ),
3407            ));
3408        }
3409        return rows;
3410    }
3411    let (count, of) = line.parts[index];
3412    let noun = match config.metric {
3413        QualityMetric::DistinctShare
3414        | QualityMetric::IntegerParseShare
3415        | QualityMetric::DecimalParseShare => "values",
3416        _ => "rows",
3417    };
3418    rows.push(row(
3419        config.metric.label(),
3420        if of > 0.0 {
3421            format!(
3422                "{} of {} {noun} ({})",
3423                numfmt::group_chrome(count.round() as usize),
3424                numfmt::group_chrome(of.round() as usize),
3425                rate_label(count / of)
3426            )
3427        } else if bar.evaluated == 0 {
3428            "none: no row sampled".to_string()
3429        } else {
3430            format!("none: no {noun} to measure")
3431        },
3432    ));
3433    // How sure a sample is of the rate: never a point, and never for a distinct
3434    // share, which does not stand for the whole.
3435    let interval = if of <= 0.0 {
3436        None
3437    } else if every_row {
3438        Some("none needed: every row read".to_string())
3439    } else if config.metric == QualityMetric::DistinctShare {
3440        Some("none: a distinct share does not stand for the whole".to_string())
3441    } else {
3442        crate::quality_trends::wilson_interval(count, of).map(|(low, high)| {
3443            let mut text = format!("{} to {}", rate_label(low), rate_label(high));
3444            if (of as usize) < crate::quality_report::THIN_SEGMENT_ROWS {
3445                text.push_str(&format!(
3446                    ", from under {} {noun}",
3447                    crate::quality_report::THIN_SEGMENT_ROWS
3448                ));
3449            }
3450            text
3451        })
3452    };
3453    if let Some(interval) = interval {
3454        rows.push(row("95% interval", interval));
3455    }
3456    if let Some(other) = compared {
3457        let other_bar = &view.bars[other];
3458        let exact = results.precision == QualityPrecision::Exact
3459            || (every_row && other_bar.eligible == Some(other_bar.evaluated));
3460        rows.push(row(
3461            against,
3462            match crate::quality_trends::bar_change(line, index, other, exact) {
3463                Some(change) => format!(
3464                    "{} to {}, {:+.1} points: {}",
3465                    rate_label(change.before),
3466                    rate_label(change.now),
3467                    change.points(),
3468                    // Segments never judge a distinct share: it falls as a segment
3469                    // grows, so bars of different sizes differ by it whatever the data.
3470                    if config.metric == QualityMetric::DistinctShare {
3471                        "not judged for a distinct share"
3472                    } else if change.clear {
3473                        "a clear change"
3474                    } else if change.points().abs() < crate::data_quality::MATERIAL_CHANGE_PP {
3475                        "under a point"
3476                    } else {
3477                        "within sampling noise"
3478                    }
3479                ),
3480                None => "nothing to compare: one side has no rate".to_string(),
3481            },
3482        ));
3483    }
3484    rows
3485}
3486
3487/// The expected windows with no rows, a line per run of them: why, which windows,
3488/// and the rows a sample missed. Bounded: the runs past the cap are counted.
3489fn render_gaps(
3490    config: &DataQualityWidgetConfig<'_>,
3491    table_state: &mut TableState,
3492    area: Rect,
3493    buf: &mut Buffer,
3494) {
3495    let Some(results) = config.results else {
3496        render_run_prompt(area, config.theme, buf);
3497        return;
3498    };
3499    let theme = config.theme;
3500    let plan = config.plan;
3501    let dimmed = Style::default().fg(theme.get("dimmed"));
3502    let [area] = Layout::default()
3503        .direction(Direction::Vertical)
3504        .constraints([Constraint::Fill(1)])
3505        .margin(1)
3506        .areas(area);
3507    let Some(gaps) = crate::quality_trends::expected_gaps(plan, results) else {
3508        Paragraph::new(
3509            "No expected windows: set Expected in Setup (e) to say which windows rows \
3510             belong in.",
3511        )
3512        .wrap(Wrap { trim: true })
3513        .style(Style::default().fg(theme.get("text_primary")))
3514        .render(area, buf);
3515        return;
3516    };
3517    let check = match &gaps {
3518        Gaps::Checked(check) if check.expected > 0 => check,
3519        _ => {
3520            Paragraph::new(gaps_summary(plan, &gaps))
3521                .wrap(Wrap { trim: true })
3522                .style(Style::default().fg(theme.get("text_primary")))
3523                .render(area, buf);
3524            return;
3525        }
3526    };
3527    let unit = trend_unit(&plan.grain);
3528    let every = check.every.as_str();
3529    let sampled = matches!(
3530        results.precision,
3531        QualityPrecision::Sampled | QualityPrecision::Estimated
3532    );
3533    let mut notes = vec![format!(
3534        "{}, {}: {} of {} {}",
3535        check.column,
3536        crate::quality_trends::calendar_span(
3537            check.from,
3538            crate::quality_trends::floor_window(
3539                check.before - chrono::Duration::microseconds(1),
3540                every
3541            ),
3542            every
3543        ),
3544        numfmt::group_chrome(check.with_rows),
3545        counted_unit(check.expected, unit),
3546        if sampled { "sampled" } else { "with rows" }
3547    )];
3548    if check.weekend > 0 {
3549        notes.push(format!(
3550            "{} on weekends, not expected",
3551            counted_unit(check.weekend, unit)
3552        ));
3553    }
3554    // What each kind means, for the kinds listed: empty only on an exact count.
3555    let scope = plan.scope.label();
3556    for (count, text) in [
3557        (
3558            check.empty,
3559            format!("Empty: no rows in {scope}, by exact count"),
3560        ),
3561        (
3562            check.unsampled,
3563            match missed_rows(check) {
3564                Some(rows) if check.counted => format!(
3565                    "Not sampled: {} rows there, none drawn",
3566                    numfmt::group_chrome(rows)
3567                ),
3568                None if check.counted => "Not sampled: rows there, none drawn".to_string(),
3569                _ => "Not sampled: none drawn, rows not counted".to_string(),
3570            },
3571        ),
3572        (
3573            check.out_of_scope,
3574            "Out of scope: outside the time range the scope reads".to_string(),
3575        ),
3576    ] {
3577        if count > 0 {
3578            notes.push(text);
3579        }
3580    }
3581    let notes = notes
3582        .iter()
3583        .flat_map(|note| crate::widgets::info::wrap_to(note, area.width as usize))
3584        .take((area.height as usize).saturating_sub(5).max(1))
3585        .collect::<Vec<_>>();
3586    let [title, note, body] = Layout::default()
3587        .direction(Direction::Vertical)
3588        .constraints([
3589            Constraint::Length(1),
3590            Constraint::Length(notes.len() as u16 + 1),
3591            Constraint::Fill(1),
3592        ])
3593        .areas(area);
3594    Paragraph::new(rule_line(
3595        &format!(
3596            "Expected {}",
3597            plan.expected
3598                .as_ref()
3599                .map(|expected| expected.cadence_label(every))
3600                .unwrap_or_default()
3601        ),
3602        Some(&format!(
3603            "{} {}",
3604            numfmt::group_chrome(check.gaps()),
3605            if check.gaps() == 1 { "gap" } else { "gaps" }
3606        )),
3607        title.width,
3608        theme,
3609    ))
3610    .render(title, buf);
3611    Paragraph::new(
3612        notes
3613            .iter()
3614            .map(|text| Line::styled(fit(text, note.width as usize), dimmed))
3615            .collect::<Vec<_>>(),
3616    )
3617    .render(note, buf);
3618    if check.runs.is_empty() {
3619        Paragraph::new("Every expected window has rows")
3620            .style(Style::default().fg(theme.get("text_primary")))
3621            .render(body, buf);
3622        return;
3623    }
3624    // The windows first, which is what a gap is; the rows a sample missed where
3625    // there is room for them.
3626    let span_width = if every == "1h" { 35 } else { 24 };
3627    let wide = body.width as usize >= span_width + 2 + 12 + 10 + 14 + 4;
3628    let rows = check
3629        .runs
3630        .iter()
3631        .map(|run| {
3632            let mut cells = vec![
3633                Cell::from(crate::quality_trends::calendar_span(
3634                    run.first, run.last, every,
3635                )),
3636                Cell::from(run.kind.label()),
3637                Cell::from(Span::styled(counted_unit(run.windows, unit), dimmed)),
3638            ];
3639            if wide {
3640                cells.push(Cell::from(Span::styled(
3641                    run.rows
3642                        .map(|rows| format!("{} rows", numfmt::group_chrome(rows)))
3643                        .unwrap_or_default(),
3644                    dimmed,
3645                )));
3646            }
3647            Row::new(cells)
3648        })
3649        .chain((check.more_runs > 0).then(|| {
3650            Row::new(vec![Cell::from(Span::styled(
3651                format!(
3652                    "{} {} more",
3653                    glyphs::get().ellipsis,
3654                    numfmt::group_chrome(check.more_runs)
3655                ),
3656                dimmed,
3657            ))])
3658        }))
3659        .collect::<Vec<_>>();
3660    normalize_selection(table_state, check.runs.len());
3661    let mut widths = vec![
3662        Constraint::Length(span_width as u16),
3663        Constraint::Length(12),
3664        Constraint::Length(10),
3665    ];
3666    let mut headers = vec!["Windows", "Gap", "Length"];
3667    if wide {
3668        widths.push(Constraint::Length(14));
3669        headers.push("Rows");
3670    }
3671    let table = Table::new(rows, widths)
3672        .header(Row::new(headers).style(dimmed))
3673        .row_highlight_style(theme.highlight_style())
3674        .highlight_symbol(glyphs::get().selector);
3675    StatefulWidget::render(table, body, buf, table_state);
3676}
3677
3678/// The rows in the windows a sample missed, when every one was counted.
3679fn missed_rows(check: &GapCheck) -> Option<usize> {
3680    check
3681        .runs
3682        .iter()
3683        .filter(|run| run.kind == GapKind::Unsampled)
3684        .map(|run| run.rows)
3685        .sum::<Option<usize>>()
3686        .filter(|_| check.more_runs == 0)
3687}
3688
3689/// Setup's Expected editor: which windows rows are expected in, and from and before
3690/// when. Nothing here reads; Enter writes it into the draft.
3691fn render_expected_windows(config: &DataQualityWidgetConfig<'_>, area: Rect, buf: &mut Buffer) {
3692    let ctx = config.ctx;
3693    let theme = config.theme;
3694    let Some(form) = config.expected_form else {
3695        return;
3696    };
3697    let [area] = Layout::default()
3698        .direction(Direction::Vertical)
3699        .constraints([Constraint::Fill(1)])
3700        .margin(1)
3701        .areas(area);
3702    let every = match &config.plan.grain {
3703        QualityGrain::TimeWindows { every, .. } => every.as_str(),
3704        _ => "1d",
3705    };
3706    let notes = [
3707        format!("Grain: {}", config.plan.grain.label()),
3708        "From, Before: a date or a UTC timestamp, such as 2024-01-01 or \
3709         2024-01-01T09:00:00Z. Blank: the first or last window found."
3710            .to_string(),
3711        "A window in range with no rows is listed under Trends (g); a weekend only \
3712         when every day is expected."
3713            .to_string(),
3714    ];
3715    let width = (area.width as usize).min(DETAIL_MEASURE);
3716    let mut y = area.y;
3717    put_line(
3718        rule_line("Expected Windows", None, area.width, theme),
3719        area,
3720        &mut y,
3721        buf,
3722    );
3723    put_line(Line::raw(""), area, &mut y, buf);
3724    let cadence = form.cadence_label(every);
3725    for (field, label) in crate::analysis_modal::EXPECTED_ROWS.iter().enumerate() {
3726        let row_area = Rect {
3727            y,
3728            height: 1,
3729            ..area
3730        };
3731        if y < area.y + area.height {
3732            crate::widgets::ui::FormRow {
3733                label,
3734                value: match field {
3735                    0 => FormValue::Choice(&cadence),
3736                    1 => FormValue::Input(&form.from),
3737                    _ => FormValue::Input(&form.before),
3738                },
3739                focused: form.field == field,
3740                label_width: 10,
3741            }
3742            .render(row_area, buf, ctx);
3743            crate::pointer::record_field::<crate::analysis_modal::ExpectedForm>(row_area, field);
3744        }
3745        y += 1;
3746    }
3747    put_line(Line::raw(""), area, &mut y, buf);
3748    let dimmed = Style::default().fg(theme.get("dimmed"));
3749    for note in &notes {
3750        for text in crate::widgets::info::wrap_to(note, width.saturating_sub(2)) {
3751            put_line(Line::styled(format!("  {text}"), dimmed), area, &mut y, buf);
3752        }
3753    }
3754    if let Some(error) = &form.error {
3755        put_line(Line::raw(""), area, &mut y, buf);
3756        put_line(
3757            Line::styled(fit(error, width), Style::default().fg(theme.get("warning"))),
3758            area,
3759            &mut y,
3760            buf,
3761        );
3762    }
3763}
3764
3765/// `line` on row `y` of `area` when it is inside it, and the next row after.
3766fn put_line(line: Line<'static>, area: Rect, y: &mut u16, buf: &mut Buffer) {
3767    if *y < area.y + area.height {
3768        Paragraph::new(line).render(
3769            Rect {
3770                y: *y,
3771                height: 1,
3772                ..area
3773            },
3774            buf,
3775        );
3776    }
3777    *y += 1;
3778}
3779
3780fn duration_label(seconds: Option<i64>) -> String {
3781    let Some(seconds) = seconds else {
3782        return "-".to_string();
3783    };
3784    let sign = if seconds < 0 { "-" } else { "" };
3785    let seconds = seconds.unsigned_abs();
3786    if seconds >= 86_400 {
3787        format!("{sign}{:.1}d", seconds as f64 / 86_400.0)
3788    } else if seconds >= 3_600 {
3789        format!("{sign}{:.1}h", seconds as f64 / 3_600.0)
3790    } else if seconds >= 60 {
3791        format!("{sign}{:.1}m", seconds as f64 / 60.0)
3792    } else {
3793        format!("{sign}{seconds}s")
3794    }
3795}
3796
3797/// One column's findings, then its measurements, as aligned label and value rows
3798/// on the page, the way Setup lays out its rows.
3799fn render_detail(
3800    config: &DataQualityWidgetConfig<'_>,
3801    table_state: &mut TableState,
3802    area: Rect,
3803    buf: &mut Buffer,
3804) {
3805    let Some(results) = config.results else {
3806        render_run_prompt(area, config.theme, buf);
3807        return;
3808    };
3809    let index = table_state.selected().unwrap_or(0);
3810    let Some(profile) = results.columns.get(index) else {
3811        Paragraph::new("No column selected")
3812            .alignment(Alignment::Center)
3813            .render(area, buf);
3814        return;
3815    };
3816    let theme = config.theme;
3817    let [title, body] = Layout::default()
3818        .direction(Direction::Vertical)
3819        .constraints([Constraint::Length(2), Constraint::Fill(1)])
3820        .margin(1)
3821        .areas(area);
3822    Paragraph::new(rule_line(&profile.name, None, title.width, theme)).render(title, buf);
3823
3824    // The column's findings first, in the report's own words; the measurements
3825    // under them are the evidence.
3826    let report = build_report(results);
3827    let mut findings = report
3828        .findings
3829        .iter()
3830        .filter(|finding| finding.kind.is_some() && finding.columns.contains(&profile.name))
3831        .map(|finding| FieldRow {
3832            mark: Some(severity_mark(finding.severity, theme)),
3833            label: finding.title.to_string(),
3834            value: finding.summary.clone(),
3835        })
3836        .collect::<Vec<_>>();
3837    if findings.is_empty() {
3838        findings.push(FieldRow {
3839            mark: Some(severity_mark(Severity::Clean, theme)),
3840            label: "No findings".to_string(),
3841            value: String::new(),
3842        });
3843    }
3844    let measurements = detail_measurements(config.ctx, results, profile);
3845    let label_width = findings
3846        .iter()
3847        .chain(&measurements)
3848        .map(|row| glyphs::display_width(&row.label))
3849        .max()
3850        .unwrap_or(0)
3851        + 2;
3852    // A reading surface: values wrap at a comfortable measure on a wide terminal.
3853    let width = (body.width as usize).min(DETAIL_MEASURE);
3854    let mut lines = field_lines(&findings, label_width, width, true);
3855    lines.push(Line::raw(""));
3856    lines.extend(field_lines(&measurements, label_width, width, true));
3857    render_counted(lines, body, theme, buf);
3858}
3859
3860/// The widest a reading surface's text runs, however wide the terminal.
3861const DETAIL_MEASURE: usize = 100;
3862
3863/// The most of one value the Detail page shows: a range's end, the most common
3864/// value, a spelling.
3865const END_WIDTH: usize = 32;
3866
3867/// A label and its value on one row, with an optional mark in the lead.
3868struct FieldRow {
3869    mark: Option<Span<'static>>,
3870    label: String,
3871    value: String,
3872}
3873
3874/// Rows in two aligned columns: the label padded to `label_width`, and the value
3875/// in what is left, wrapped under itself; a value of several lines keeps each on
3876/// its own. With `marks`, a two-column lead holds each row's mark, or nothing.
3877fn field_lines(
3878    rows: &[FieldRow],
3879    label_width: usize,
3880    width: usize,
3881    marks: bool,
3882) -> Vec<Line<'static>> {
3883    let lead = if marks { 2 } else { 0 };
3884    let value_width = width.saturating_sub(lead + label_width).max(8);
3885    let mut lines = Vec::new();
3886    for row in rows {
3887        let mut values = row
3888            .value
3889            .lines()
3890            // A value that fits is kept as written: wrapping splits on whitespace
3891            // and would drop the leading, trailing or doubled spaces a value holds.
3892            .flat_map(|line| {
3893                if glyphs::display_width(line) <= value_width {
3894                    vec![line.to_string()]
3895                } else {
3896                    crate::widgets::info::wrap_to(line, value_width)
3897                }
3898            })
3899            .collect::<Vec<_>>()
3900            .into_iter();
3901        let mut spans = Vec::new();
3902        if marks {
3903            spans.push(row.mark.clone().unwrap_or_else(|| Span::raw(" ")));
3904            spans.push(Span::raw(" "));
3905        }
3906        spans.push(Span::raw(format!(
3907            "{}{}",
3908            row.label,
3909            " ".repeat(label_width.saturating_sub(glyphs::display_width(&row.label)))
3910        )));
3911        spans.extend(
3912            values
3913                .next()
3914                .map(|value| Span::raw(fit(&value, value_width))),
3915        );
3916        lines.push(Line::from(spans));
3917        lines.extend(values.map(|value| {
3918            Line::raw(format!(
3919                "{}{}",
3920                " ".repeat(lead + label_width),
3921                fit(&value, value_width)
3922            ))
3923        }));
3924    }
3925    lines
3926}
3927
3928/// `lines` in `area`; when they run past it, the last row counts what is below
3929/// rather than showing half of it.
3930fn render_counted(mut lines: Vec<Line<'static>>, area: Rect, theme: &Theme, buf: &mut Buffer) {
3931    let height = area.height as usize;
3932    if lines.len() > height && height > 0 {
3933        let below = lines.len() - (height - 1);
3934        lines.truncate(height - 1);
3935        lines.push(Line::styled(
3936            format!("  {} {below} more", glyphs::get().ellipsis),
3937            Style::default().fg(theme.get("dimmed")),
3938        ));
3939    }
3940    Paragraph::new(lines).render(area, buf);
3941}
3942
3943/// What was measured on a column, in the table's own formatting. A measurement
3944/// that does not apply to its type is left out rather than shown as a dash, and
3945/// what the header says (the rows checked, whether sampled) is not repeated.
3946fn detail_measurements(
3947    ctx: &RenderContext,
3948    results: &DataQualityResults,
3949    profile: &ColumnQualityProfile,
3950) -> Vec<FieldRow> {
3951    // Long text is cut, so both ends of a range and a count after a value stay
3952    // in view.
3953    let value = |text: &str| fit(&table_value(ctx, profile, text), END_WIDTH);
3954    let row = |label: &str, value: String| FieldRow {
3955        mark: None,
3956        label: label.to_string(),
3957        value,
3958    };
3959    let mut rows = vec![
3960        row("Type", profile.dtype.to_string()),
3961        row(
3962            "Missing",
3963            if profile.null_count == 0 {
3964                "0".to_string()
3965            } else {
3966                format!(
3967                    "{} ({})",
3968                    numfmt::group_chrome(profile.null_count),
3969                    crate::quality_report::percent(profile.null_count, profile.evaluated_rows)
3970                )
3971            },
3972        ),
3973    ];
3974    for (label, measured) in [
3975        ("Distinct", profile.distinct_count),
3976        ("Empty text", profile.empty_count),
3977        ("Blank text", profile.whitespace_count),
3978        ("NaN", profile.nan_count),
3979    ] {
3980        if let Some(measured) = measured {
3981            rows.push(row(label, numfmt::group_chrome(measured)));
3982        }
3983    }
3984    if profile.positive_infinity_count.is_some() || profile.negative_infinity_count.is_some() {
3985        rows.push(row(
3986            "Infinite",
3987            format!(
3988                "{} positive, {} negative",
3989                count_label(profile.positive_infinity_count),
3990                count_label(profile.negative_infinity_count)
3991            ),
3992        ));
3993    }
3994    if profile.min.is_some() || profile.max.is_some() {
3995        rows.push(row(
3996            "Range",
3997            format!(
3998                "{} to {}",
3999                profile.min.as_deref().map(value).unwrap_or_default(),
4000                profile.max.as_deref().map(value).unwrap_or_default()
4001            ),
4002        ));
4003    }
4004    if let Some((dominant, count)) = profile.dominant_value.as_ref().zip(profile.dominant_count) {
4005        rows.push(row(
4006            "Most common",
4007            format!(
4008                "{}, {} {}",
4009                value(dominant),
4010                numfmt::group_chrome(count),
4011                if count == 1 { "row" } else { "rows" }
4012            ),
4013        ));
4014    }
4015    if profile.min_length.is_some() || profile.max_length.is_some() {
4016        rows.push(row(
4017            if matches!(profile.dtype, polars::prelude::DataType::List(_)) {
4018                "List length"
4019            } else {
4020                "Text length"
4021            },
4022            format!(
4023                "{} to {}",
4024                count_label(profile.min_length),
4025                count_label(profile.max_length)
4026            ),
4027        ));
4028    }
4029    // Only the parsers that accepted something: four zeros say less than "none".
4030    let parses = [
4031        ("integer", profile.integer_parse_count),
4032        ("decimal", profile.decimal_parse_count),
4033        ("date", profile.date_parse_count),
4034        ("datetime", profile.datetime_parse_count),
4035    ];
4036    if parses.iter().any(|(_, count)| count.is_some()) {
4037        let found = parses
4038            .iter()
4039            .filter_map(|(parser, count)| {
4040                count
4041                    .filter(|count| *count > 0)
4042                    .map(|count| format!("{parser} {}", numfmt::group_chrome(count)))
4043            })
4044            .collect::<Vec<_>>();
4045        rows.push(row(
4046            "Parses as",
4047            if found.is_empty() {
4048                "none".to_string()
4049            } else {
4050                found.join(", ")
4051            },
4052        ));
4053    }
4054    for group in results
4055        .category_variants
4056        .iter()
4057        .filter(|group| group.column == profile.name)
4058        .take(3)
4059    {
4060        // Spellings differ by case and by spaces the table does not show, so they are
4061        // quoted as the finding quotes them: "West " and "West" read apart.
4062        rows.push(row(
4063            "Spellings",
4064            group
4065                .variants
4066                .iter()
4067                .map(|(variant, count)| {
4068                    format!(
4069                        "{} ({})",
4070                        crate::quality_report::quoted(variant, END_WIDTH),
4071                        numfmt::group_chrome(*count)
4072                    )
4073                })
4074                .collect::<Vec<_>>()
4075                .join("\n"),
4076        ));
4077    }
4078    rows
4079}
4080
4081/// A value the profile holds as text, formatted as the table formats its column:
4082/// grouped, or to fixed places, when the number format says so; as-is otherwise.
4083fn table_value(ctx: &RenderContext, profile: &ColumnQualityProfile, text: &str) -> String {
4084    use polars::prelude::{AnyValue, DataType};
4085    let formatter = ctx
4086        .number_format
4087        .formatter_for(&profile.name, &profile.dtype);
4088    if formatter.is_passthrough() {
4089        return text.to_string();
4090    }
4091    let parsed = match profile.dtype {
4092        DataType::Float32 | DataType::Float64 => text.parse::<f64>().ok().map(AnyValue::Float64),
4093        DataType::UInt8 | DataType::UInt16 | DataType::UInt32 | DataType::UInt64 => {
4094            text.parse::<u64>().ok().map(AnyValue::UInt64)
4095        }
4096        _ => text.parse::<i64>().ok().map(AnyValue::Int64),
4097    };
4098    parsed
4099        .map(|parsed| {
4100            numfmt::format_any_value(&formatter, &parsed, &mut String::new()).into_owned()
4101        })
4102        .unwrap_or_else(|| text.to_string())
4103}
4104
4105fn count_label(value: Option<usize>) -> String {
4106    value
4107        .map(numfmt::group_chrome)
4108        .unwrap_or_else(|| "-".to_string())
4109}
4110
4111fn render_sidebar(
4112    config: &DataQualityWidgetConfig<'_>,
4113    sidebar_state: &mut TableState,
4114    area: Rect,
4115    buf: &mut Buffer,
4116) {
4117    crate::widgets::analysis::render_sidebar(
4118        area,
4119        buf,
4120        sidebar_state,
4121        Some(AnalysisTool::DataQuality),
4122        config.focus,
4123        config.theme,
4124    );
4125}
4126
4127/// The tool list, on a terminal too narrow to keep it beside the result.
4128fn render_narrow_tool_picker(
4129    config: &DataQualityWidgetConfig<'_>,
4130    sidebar_state: &mut TableState,
4131    area: Rect,
4132    buf: &mut Buffer,
4133) {
4134    let tools = vec![
4135        "Describe",
4136        "Distribution Analysis",
4137        "Correlation Matrix",
4138        "Data Quality",
4139    ];
4140    let popup = centered_rect(28, tools.len() as u16 + 2, area);
4141    let content = Surface::new("Analysis Tools").render(popup, buf, config.ctx);
4142    Picker::new(tools, sidebar_state.selected(), true)
4143        .on_click(crate::widgets::ui::Clicks::Tool)
4144        .render(content, buf, config.ctx);
4145}
4146
4147/// What a run of the plan will read and write, before it runs.
4148fn render_access_plan(config: &DataQualityWidgetConfig<'_>, area: Rect, buf: &mut Buffer) {
4149    let state = config.state;
4150    let plan = config.plan;
4151    let remote = state.is_remote_source();
4152    let copy = config.setup.copy;
4153    let format_bytes = crate::widgets::info::format_bytes;
4154    let row = |label: &str, value: String| FieldRow {
4155        mark: None,
4156        label: label.to_string(),
4157        value,
4158    };
4159    let source_files = if plan.scope.uses_source() {
4160        Some(state.quality_source_file_count())
4161            .filter(|count| *count > 0)
4162            .or_else(|| state.source_file_count())
4163    } else {
4164        state.source_file_count()
4165    };
4166    let rows = [
4167        row(
4168            "Source",
4169            if remote { "remote" } else { "local" }.to_string(),
4170        ),
4171        row("Scope", plan.scope.label()),
4172        row("Grain", plan.grain.label()),
4173        row("Sample", compute_label(plan)),
4174        row(
4175            "Rows evaluated",
4176            planned_rows(state, plan)
4177                .map(numfmt::group_chrome)
4178                .unwrap_or_else(|| "unknown".to_string()),
4179        ),
4180        row(
4181            "Value reads",
4182            match copy {
4183                CopyPlan::Fetch { bytes, .. } => format!(
4184                    "{}, fetched once · passes read the copy",
4185                    format_bytes(bytes)
4186                ),
4187                CopyPlan::Kept { bytes, .. } => {
4188                    format!(
4189                        "none: every pass reads the local copy ({})",
4190                        format_bytes(bytes)
4191                    )
4192                }
4193                _ if remote => "unknown".to_string(),
4194                _ => planned_read_label(state, plan),
4195            },
4196        ),
4197        row(
4198            "Requests",
4199            match copy {
4200                CopyPlan::Fetch { objects, .. } => format!("{objects}, one per object"),
4201                CopyPlan::Kept { .. } => "none".to_string(),
4202                _ if remote => "unknown".to_string(),
4203                _ => "none".to_string(),
4204            },
4205        ),
4206        row(
4207            "Known source files",
4208            source_files
4209                .map(numfmt::group_chrome)
4210                .unwrap_or_else(|| "unknown".to_string()),
4211        ),
4212        row(
4213            "Conflict values",
4214            match state.quality_conflict_reads() {
4215                0 => "none: no file holds a column in an unreadable type".to_string(),
4216                reads if plan.compute == QualityCompute::Full => {
4217                    format!("{reads} extra one-column file reads")
4218                }
4219                reads => format!("not read · full scan adds {reads} one-column file reads"),
4220            },
4221        ),
4222        row("Remote writes", "none".to_string()),
4223        row(
4224            "Local file writes",
4225            match copy {
4226                CopyPlan::Fetch { bytes, .. } => format!(
4227                    "{} copy in the cache directory · kept until d, reopen or exit",
4228                    format_bytes(bytes)
4229                ),
4230                _ => "none".to_string(),
4231            },
4232        ),
4233        row("Passes", passes_label(config)),
4234        row("Column intent", {
4235            let every_row =
4236                planned_scope_rows(state, plan).is_some_and(|rows| rows <= plan.dataset_rows);
4237            let lines = intent_read_lines(plan, every_row);
4238            if lines.is_empty() {
4239                "none declared".to_string()
4240            } else {
4241                // The row names it; the lines say what it costs.
4242                lines
4243                    .iter()
4244                    .map(|line| line.strip_prefix("Column intent: ").unwrap_or(line))
4245                    .collect::<Vec<_>>()
4246                    .join(" · ")
4247            }
4248        }),
4249        row(
4250            "Estimate basis",
4251            match plan.compute {
4252                QualityCompute::Metadata => "file footers read when the data opened".to_string(),
4253                QualityCompute::Sample => {
4254                    "rows: the sample; bytes: a ceiling, the whole scope".to_string()
4255                }
4256                QualityCompute::Full if matches!(copy, CopyPlan::Fetch { .. }) => {
4257                    "rows: every eligible row; bytes: the object sizes listed when it opened"
4258                        .to_string()
4259                }
4260                QualityCompute::Full => {
4261                    "rows: every eligible row; bytes: unknown, per source and its caches"
4262                        .to_string()
4263                }
4264            },
4265        ),
4266    ];
4267    let rows = rows.map(|row| FieldRow {
4268        value: dotted(&row.value),
4269        ..row
4270    });
4271    let width = 72.min(area.width.saturating_sub(2));
4272    let label_width = rows
4273        .iter()
4274        .map(|row| glyphs::display_width(&row.label))
4275        .max()
4276        .unwrap_or(0)
4277        + 2;
4278    // The frame and its gutters take four columns of the width.
4279    let lines = field_lines(&rows, label_width, width.saturating_sub(4) as usize, false);
4280    let popup = centered_rect(width, lines.len() as u16 + 2, area);
4281    let content = Surface::new("Access Plan").render(popup, buf, config.ctx);
4282    render_counted(lines, content, config.theme, buf);
4283}
4284
4285/// The reads a run makes over its scope, counted from the plan: honest about the
4286/// passes even where their bytes are unknown.
4287fn passes_label(config: &DataQualityWidgetConfig<'_>) -> String {
4288    let plan = config.plan;
4289    let view = &config.setup;
4290    match plan.compute {
4291        _ if view.unchanged || view.cached => "none: the report is already here".to_string(),
4292        QualityCompute::Metadata => "none".to_string(),
4293        QualityCompute::Full => match view.copy {
4294            CopyPlan::Fetch { .. } => format!(
4295                "one fetch, then up to {} over the copy, one per check",
4296                full_passes(config)
4297            ),
4298            CopyPlan::Kept { .. } => {
4299                format!(
4300                    "up to {} over the local copy, one per check",
4301                    full_passes(config)
4302                )
4303            }
4304            _ => format!("up to {}, one per check", full_passes(config)),
4305        },
4306        QualityCompute::Sample => {
4307            let sample = if view.reuses_sample {
4308                "none for the sample: rows already read"
4309            } else {
4310                "one sampling pass"
4311            };
4312            match view.segment_count {
4313                SegmentCount::CountPass => {
4314                    format!("{sample}, then one count of the grain's column")
4315                }
4316                SegmentCount::InSamplePass => format!("{sample}, counting the grain's rows"),
4317                _ => sample.to_string(),
4318            }
4319        }
4320    }
4321}
4322
4323fn render_run_prompt(area: Rect, theme: &Theme, buf: &mut Buffer) {
4324    Paragraph::new(dotted("No report · e Setup, then Enter"))
4325        .alignment(Alignment::Center)
4326        .style(Style::default().fg(theme.get("text_primary")))
4327        .render(area, buf);
4328}
4329
4330fn normalize_selection(state: &mut TableState, len: usize) {
4331    if len == 0 {
4332        state.select(None);
4333    } else if state.selected().is_none_or(|selected| selected >= len) {
4334        state.select(Some(0));
4335    }
4336}
4337
4338fn planned_rows(state: &DataTableState, plan: &DataQualityPlan) -> Option<usize> {
4339    if plan.compute == QualityCompute::Metadata {
4340        return Some(0);
4341    }
4342    let total = match &plan.scope {
4343        QualityScope::CurrentView => state.num_rows_if_valid()?,
4344        QualityScope::FirstRows(rows) => state.num_rows_if_valid()?.min(*rows),
4345        QualityScope::ViewRows { start, end } => {
4346            let rows = state.num_rows_if_valid()?;
4347            rows.min(*end).saturating_sub(start.saturating_sub(1))
4348        }
4349        _ => return None,
4350    };
4351    match plan.compute {
4352        QualityCompute::Metadata => unreachable!(),
4353        QualityCompute::Sample => Some(total.min(dataset_rows(plan))),
4354        QualityCompute::Full => Some(total),
4355    }
4356}
4357
4358fn planned_read_bytes(state: &DataTableState, plan: &DataQualityPlan) -> Option<usize> {
4359    if plan.scope.uses_source() {
4360        return None;
4361    }
4362    let rows = match plan.compute {
4363        QualityCompute::Metadata => 0,
4364        // The head reads what it keeps. Anything else is a ceiling: a spread sample
4365        // is one Parquet or IPC file's few dozen short runs or one stream of the
4366        // scope, and a per-partition one streams the scope.
4367        QualityCompute::Sample if plan.method == crate::sampling::SampleMethod::FirstRows => {
4368            planned_scope_rows(state, plan)?.min(dataset_rows(plan))
4369        }
4370        QualityCompute::Sample => planned_scope_rows(state, plan)?,
4371        QualityCompute::Full => return None,
4372    };
4373    Some(rows.saturating_mul(state.estimated_row_bytes()))
4374}
4375
4376fn planned_scope_rows(state: &DataTableState, plan: &DataQualityPlan) -> Option<usize> {
4377    let rows = state.num_rows_if_valid()?;
4378    Some(match &plan.scope {
4379        QualityScope::FirstRows(limit) => rows.min(*limit),
4380        QualityScope::ViewRows { start, end } => {
4381            rows.min(*end).saturating_sub(start.saturating_sub(1))
4382        }
4383        QualityScope::CurrentView => rows,
4384        _ => return None,
4385    })
4386}
4387
4388/// The planned read in words: a sample of a scope larger than itself reads at most
4389/// the scope, and how much less depends on the source.
4390pub(crate) fn planned_read_label(state: &DataTableState, plan: &DataQualityPlan) -> String {
4391    let bytes = planned_read_bytes(state, plan);
4392    let sampled = plan.compute == QualityCompute::Sample
4393        && plan.method != crate::sampling::SampleMethod::FirstRows
4394        && planned_scope_rows(state, plan).is_some_and(|rows| rows > dataset_rows(plan));
4395    match bytes {
4396        Some(bytes) if sampled => format!(
4397            "up to {}",
4398            approximate_bytes(bytes).trim_start_matches("about ")
4399        ),
4400        other => approximate_bytes_option(other),
4401    }
4402}
4403
4404/// What reading every row of `plan`'s scope again costs, as far as it is known: a
4405/// ceiling from the rows and their width, and unknown for a remote or source scope.
4406pub(crate) fn scope_read_label(state: &DataTableState, plan: &DataQualityPlan) -> String {
4407    if state.is_remote_source() {
4408        return "unknown".to_string();
4409    }
4410    match planned_scope_rows(state, plan) {
4411        Some(rows) => format!(
4412            "up to {}",
4413            approximate_bytes(rows.saturating_mul(state.estimated_row_bytes()))
4414                .trim_start_matches("about ")
4415        ),
4416        None => "unknown".to_string(),
4417    }
4418}
4419
4420fn approximate_bytes_option(bytes: Option<usize>) -> String {
4421    bytes
4422        .map(approximate_bytes)
4423        .unwrap_or_else(|| "unknown".to_string())
4424}
4425
4426/// Rows a dataset-grain run keeps: the shared sample's size.
4427fn dataset_rows(plan: &DataQualityPlan) -> usize {
4428    plan.dataset_rows
4429}
4430
4431pub(crate) fn compute_label(plan: &DataQualityPlan) -> String {
4432    match plan.compute {
4433        QualityCompute::Metadata => "metadata only".to_string(),
4434        QualityCompute::Sample => format!(
4435            "{} rows {} / seed {}",
4436            numfmt::group_chrome(dataset_rows(plan)),
4437            plan.method.label().to_lowercase(),
4438            plan.sample_seed
4439        ),
4440        QualityCompute::Full => "full scan".to_string(),
4441    }
4442}
4443
4444fn approximate_bytes(bytes: usize) -> String {
4445    const KIB: f64 = 1024.0;
4446    const MIB: f64 = KIB * 1024.0;
4447    const GIB: f64 = MIB * 1024.0;
4448    let bytes = bytes as f64;
4449    if bytes >= GIB {
4450        format!("about {:.1} GiB", bytes / GIB)
4451    } else if bytes >= MIB {
4452        format!("about {:.1} MiB", bytes / MIB)
4453    } else if bytes >= KIB {
4454        format!("about {:.1} KiB", bytes / KIB)
4455    } else {
4456        format!("about {} B", bytes as usize)
4457    }
4458}
4459
4460pub(crate) fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
4461    let width = width.min(area.width.saturating_sub(2)).max(1);
4462    let height = height.min(area.height.saturating_sub(2)).max(1);
4463    Rect {
4464        x: area.x + area.width.saturating_sub(width) / 2,
4465        y: area.y + area.height.saturating_sub(height) / 2,
4466        width,
4467        height,
4468    }
4469}
4470
4471#[cfg(test)]
4472mod tests {
4473    use super::*;
4474
4475    #[test]
4476    fn byte_estimates_use_binary_units() {
4477        assert_eq!(approximate_bytes(512), "about 512 B");
4478        assert_eq!(approximate_bytes(2 * 1024), "about 2.0 KiB");
4479        assert_eq!(approximate_bytes(3 * 1024 * 1024), "about 3.0 MiB");
4480    }
4481
4482    /// A value that fits keeps its spaces; one that does not wraps under itself.
4483    #[test]
4484    fn field_values_keep_their_spaces_and_wrap_under_themselves() {
4485        let row = |value: &str| FieldRow {
4486            mark: None,
4487            label: "Range".to_string(),
4488            value: value.to_string(),
4489        };
4490        let text = |lines: Vec<Line<'static>>| {
4491            lines
4492                .iter()
4493                .map(|line| line.to_string())
4494                .collect::<Vec<_>>()
4495        };
4496        assert_eq!(
4497            text(field_lines(&[row(" South to New  York")], 7, 40, false)),
4498            ["Range   South to New  York"]
4499        );
4500        assert_eq!(
4501            text(field_lines(&[row("one two three four")], 7, 17, false)),
4502            ["Range  one two", "       three four"]
4503        );
4504    }
4505
4506    use crate::data_quality::compute_data_quality;
4507    use polars::prelude::*;
4508
4509    fn fixture() -> LazyFrame {
4510        df!(
4511            "id" => &[1i64, 2, 3, 4, 5, 6, 7, 8],
4512            "region" => &[
4513                Some("West"), Some("west"), Some("West"), Some("West "),
4514                Some("East"), None, Some("North"), Some("West"),
4515            ],
4516            "amount" => &[1.5f64, 2.0, 3.25, 4.0, 5.0, 6.0, 7.0, 8.0],
4517        )
4518        .unwrap()
4519        .lazy()
4520    }
4521
4522    struct Screen {
4523        state: DataTableState,
4524        plan: DataQualityPlan,
4525        results: DataQualityResults,
4526        findings: FindingsView,
4527        theme: Theme,
4528        ctx: RenderContext,
4529    }
4530
4531    impl Screen {
4532        fn new() -> Self {
4533            let lf = fixture();
4534            let schema = Arc::new((*lf.clone().collect_schema().unwrap()).clone());
4535            let state = DataTableState::from_schema_and_lazyframe(
4536                schema,
4537                lf.clone(),
4538                &crate::OpenOptions::default(),
4539                None,
4540            )
4541            .unwrap();
4542            let plan = DataQualityPlan {
4543                compute: QualityCompute::Full,
4544                ..DataQualityPlan::default()
4545            };
4546            let results = compute_data_quality(&lf, Some(8), &plan, None, false).unwrap();
4547            Self {
4548                state,
4549                plan,
4550                results,
4551                findings: FindingsView::default(),
4552                theme: Theme::from_config(&crate::config::ThemeConfig::default()).unwrap(),
4553                ctx: RenderContext::for_test(),
4554            }
4555        }
4556
4557        fn config(&self, page: QualityPage) -> DataQualityWidgetConfig<'_> {
4558            DataQualityWidgetConfig {
4559                checks_expanded: false,
4560                state: &self.state,
4561                plan: &self.plan,
4562                measured: &self.plan,
4563                results: Some(&self.results),
4564                from_cache: false,
4565                metric: QualityMetric::NullRate,
4566                column_index: 0,
4567                segment_index: 0,
4568                interval_index: 0,
4569                trend_line: 0,
4570                expected_form: None,
4571                segments_by_change: false,
4572                page,
4573                setup: SetupView::default(),
4574                plan_field: 0,
4575                show_access: false,
4576                observation_detail: false,
4577                findings: &self.findings,
4578                rows_kept: false,
4579                evidence_read: None,
4580                focus: AnalysisFocus::Main,
4581                theme: &self.theme,
4582                ctx: &self.ctx,
4583                intent_form: None,
4584                export_form: None,
4585            }
4586        }
4587
4588        fn draw(
4589            &self,
4590            config: DataQualityWidgetConfig<'_>,
4591            selected: usize,
4592            width: u16,
4593            height: u16,
4594        ) -> Vec<String> {
4595            let area = Rect::new(0, 0, width, height);
4596            let mut buf = Buffer::empty(area);
4597            let mut table = TableState::default();
4598            table.select(Some(selected));
4599            let mut sidebar = TableState::default();
4600            sidebar.select(Some(3));
4601            render(
4602                config,
4603                &mut table,
4604                &mut sidebar,
4605                &mut DetailScroll::default(),
4606                area,
4607                &mut buf,
4608            );
4609            (0..height)
4610                .map(|y| {
4611                    (0..width)
4612                        .map(|x| buf[(x, y)].symbol().to_string())
4613                        .collect::<String>()
4614                })
4615                .collect()
4616        }
4617    }
4618
4619    /// The column Detail page: the column on a rule, its findings, then its
4620    /// measurements as label and value rows whose values start in one column.
4621    /// No frame, no Rust debug quotes, nothing the header already says.
4622    #[test]
4623    fn column_detail_is_aligned_rows_without_a_box() {
4624        let screen = Screen::new();
4625        let region = screen
4626            .results
4627            .columns
4628            .iter()
4629            .position(|column| column.name == "region")
4630            .unwrap();
4631        // Narrow enough that the tool list gives way; the page is the whole width.
4632        let rows = screen.draw(screen.config(QualityPage::Detail), region, 70, 24);
4633        let text = rows.join("\n");
4634        let g = glyphs::get();
4635        let b = g.border;
4636        for corner in [
4637            b.top_left,
4638            b.top_right,
4639            b.bottom_left,
4640            b.bottom_right,
4641            b.vertical_left,
4642        ] {
4643            assert!(!text.contains(corner), "no box on the page:\n{text}");
4644        }
4645        let rule = format!("region {}", g.rule_h);
4646        assert!(
4647            rows.iter().any(|row| row.trim_start().starts_with(&rule)),
4648            "the column is named on a rule:\n{text}"
4649        );
4650        assert!(
4651            text.contains("Mixed spellings"),
4652            "the column's findings are on the page:\n{text}"
4653        );
4654        let findings_end = rows
4655            .iter()
4656            .position(|row| row.contains("Mixed spellings"))
4657            .unwrap();
4658        let type_row = rows.iter().position(|row| row.contains("Type")).unwrap();
4659        assert!(findings_end < type_row, "findings first:\n{text}");
4660
4661        // Every measurement's value starts where the first one's does.
4662        let value_column = |label: &str| {
4663            let row = rows
4664                .iter()
4665                .find(|row| row.trim_start().starts_with(label))
4666                .unwrap_or_else(|| panic!("{label} row:\n{text}"));
4667            let after = row.find(label).unwrap() + label.len();
4668            after + row[after..].len() - row[after..].trim_start().len()
4669        };
4670        let column = value_column("Type");
4671        for label in ["Missing", "Distinct", "Range", "Most common", "Spellings"] {
4672            assert_eq!(value_column(label), column, "{label} aligned:\n{text}");
4673        }
4674
4675        let spellings = rows
4676            .iter()
4677            .position(|row| row.contains("Spellings"))
4678            .unwrap();
4679        let measurements = rows[type_row..spellings].join("\n");
4680        assert!(
4681            !measurements.contains('"'),
4682            "values as the table shows them, not debug quoted:\n{text}"
4683        );
4684        assert!(measurements.contains("West, 3 rows"), "{text}");
4685        // Spellings differ by what the table cannot show; quoted, they read apart.
4686        let spellings = rows[spellings..].join("\n");
4687        for spelling in ["\"West\" (3)", "\"West \" (1)", "\"west\" (1)"] {
4688            assert!(spellings.contains(spelling), "{spelling}:\n{text}");
4689        }
4690        assert!(
4691            !text.contains("Evaluated") && !text.contains("sampled"),
4692            "the header says what was evaluated:\n{text}"
4693        );
4694    }
4695
4696    /// Section titles are words on a rule, never SCREAMING.
4697    #[test]
4698    fn no_page_has_an_uppercase_title() {
4699        let screen = Screen::new();
4700        for page in [
4701            QualityPage::Setup,
4702            QualityPage::Overview,
4703            QualityPage::Columns,
4704            QualityPage::Segments,
4705            QualityPage::Trends,
4706            QualityPage::Detail,
4707        ] {
4708            for (width, height) in [(120, 32), (80, 24), (60, 20)] {
4709                let rows = screen.draw(screen.config(page), 1, width, height);
4710                for row in &rows {
4711                    let shouting = row.split(|c: char| !c.is_alphabetic()).find(|word| {
4712                        word.chars().count() >= 4 && word.chars().all(|c| c.is_uppercase())
4713                    });
4714                    assert!(
4715                        shouting.is_none(),
4716                        "{page:?} at {width}x{height} shouts {shouting:?}: {row:?}"
4717                    );
4718                }
4719            }
4720        }
4721    }
4722
4723    /// Each dialog is one Surface: one frame, its title on it, nothing boxed
4724    /// inside, at the smallest size datui supports.
4725    #[test]
4726    fn dialogs_are_one_surface() {
4727        let screen = Screen::new();
4728        for title in ["Access Plan", "Mixed spellings", "Analysis Tools"] {
4729            for (width, height) in [(80, 24), (60, 20)] {
4730                let mut config = screen.config(QualityPage::Setup);
4731                match title {
4732                    "Access Plan" => config.show_access = true,
4733                    // The first finding on the Overview.
4734                    "Mixed spellings" => {
4735                        config.page = QualityPage::Overview;
4736                        config.observation_detail = true;
4737                    }
4738                    _ => config.focus = AnalysisFocus::Sidebar,
4739                }
4740                let rows = screen.draw(config, 0, width, height);
4741                let text = rows.join("\n");
4742                let corners = glyphs::frame_corners(&rows).len();
4743                // At 80 columns the tool list keeps its own frame beside the page.
4744                let expected = if width >= 76 && title != "Analysis Tools" {
4745                    2
4746                } else {
4747                    1
4748                };
4749                assert_eq!(corners, expected, "{title} at {width}x{height}:\n{text}");
4750                let border = glyphs::get().border;
4751                let titled = format!("{}{title}", border.top_left);
4752                let frame = rows
4753                    .iter()
4754                    .find(|row| row.contains(&titled))
4755                    .unwrap_or_else(|| panic!("{title} on its frame:\n{text}"));
4756                let after = &frame[frame.find(&titled).unwrap() + titled.len()..];
4757                assert!(after.contains(border.top_right), "{title}:\n{text}");
4758            }
4759        }
4760    }
4761
4762    /// Coverage sits under the verdict whether the report found problems or none, at
4763    /// the baseline size and the smallest, with the findings still on screen below
4764    /// it and every character outside ASCII a glyph slot.
4765    #[test]
4766    fn coverage_accompanies_every_verdict_at_80x24_and_60x20() {
4767        let screen = Screen::new();
4768        let g = glyphs::get();
4769        let slots = [
4770            g.rail, g.rule_h, g.middot, g.ellipsis, g.warning, g.check, g.dash,
4771        ]
4772        .concat();
4773        // The same rows sampled, and found clean: a report with nothing to fix.
4774        let mut clean = screen.results.clone();
4775        clean.observations.clear();
4776        clean.precision = QualityPrecision::Sampled;
4777        clean.total_rows = Some(80);
4778        clean.reads = Some(crate::data_quality::ObservedReads {
4779            reads: 1,
4780            counted: 1,
4781            rows: 80,
4782            copy: None,
4783        });
4784        for (results, verdict) in [(&screen.results, "problem"), (&clean, "No problems found")] {
4785            for (width, height) in [(80, 24), (60, 20)] {
4786                let mut config = screen.config(QualityPage::Overview);
4787                config.results = Some(results);
4788                let rows = screen.draw(config, 0, width, height);
4789                let text = rows.join("\n");
4790                let at = rows
4791                    .iter()
4792                    .position(|row| row.contains(verdict))
4793                    .unwrap_or_else(|| panic!("verdict at {width}x{height}:\n{text}"));
4794                assert!(
4795                    rows[at + 1].trim_start().starts_with("Checks"),
4796                    "coverage under the verdict at {width}x{height}:\n{text}"
4797                );
4798                assert!(
4799                    text.contains("Problems") || text.contains("Clean"),
4800                    "findings still on screen at {width}x{height}:\n{text}"
4801                );
4802                if results.precision == QualityPrecision::Exact {
4803                    assert!(
4804                        rows[at + 1].contains("exact") && text.contains("all 8 read, exact"),
4805                        "{text}"
4806                    );
4807                } else {
4808                    // Clean, and not everything could be looked at: it says so.
4809                    assert!(rows[at + 1].contains("1 unavailable"), "{text}");
4810                    assert!(text.contains("8 of 80 sampled (10.0%)"), "{text}");
4811                    assert!(
4812                        text.contains("Nearly unique: needs every row checked"),
4813                        "{text}"
4814                    );
4815                }
4816                for c in text.chars().filter(|c| !c.is_ascii()) {
4817                    assert!(
4818                        slots.contains(c) || "╭╮╰╯│─".contains(c),
4819                        "{c:?} is not a glyph slot at {width}x{height}:\n{text}"
4820                    );
4821                }
4822            }
4823        }
4824    }
4825
4826    /// A finding says where its rows come from before Enter: the rows the run kept,
4827    /// a sample no longer kept, or a full scan that kept none. A staged read lists
4828    /// what it reads, at the baseline size and the smallest, in glyph slots.
4829    #[test]
4830    fn evidence_says_where_its_rows_come_from() {
4831        let screen = Screen::new();
4832        let mut sampled = screen.results.clone();
4833        sampled.precision = QualityPrecision::Sampled;
4834        let report = build_report(&sampled);
4835        let position = FindingsView::default()
4836            .shown(&report)
4837            .iter()
4838            .position(|index| report.findings[*index].title == "Missing values")
4839            .unwrap();
4840        let detail = |results: &DataQualityResults, kept: bool, width, height| {
4841            let mut config = screen.config(QualityPage::Overview);
4842            config.results = Some(results);
4843            config.observation_detail = true;
4844            config.rows_kept = kept;
4845            screen.draw(config, position, width, height).join("\n")
4846        };
4847        assert!(detail(&sampled, true, 80, 24).contains("Enter: the 1 sampled row"));
4848        assert!(detail(&sampled, false, 80, 24).contains("sample released, asks first"));
4849        assert!(
4850            detail(&screen.results, false, 80, 24).contains("full scan keeps none, asks first")
4851        );
4852        // A sample that held every row is exact, but it was no full scan.
4853        let whole_sample = DataQualityPlan {
4854            compute: QualityCompute::Sample,
4855            ..screen.plan.clone()
4856        };
4857        let mut config = screen.config(QualityPage::Overview);
4858        config.measured = &whole_sample;
4859        config.observation_detail = true;
4860        let text = screen.draw(config, position, 80, 24).join("\n");
4861        assert!(text.contains("rows released, asks first"), "{text}");
4862
4863        let read = EvidenceRead {
4864            rows: EvidenceRows::Matching(polars::prelude::lit(true)),
4865            label: String::new(),
4866            sample: None,
4867            scope: QualityScope::CurrentView,
4868            summary: vec![
4869                ("Rows", "Missing values · region".to_string()),
4870                ("Why", "a full scan keeps no rows".to_string()),
4871                (
4872                    "Reads",
4873                    "current view, as far as the table scrolls".to_string(),
4874                ),
4875                ("Shows", "1 row".to_string()),
4876                ("Source", "local, read only".to_string()),
4877            ],
4878        };
4879        let g = glyphs::get();
4880        let slots = [g.rail, g.rule_h, g.middot, g.ellipsis, g.warning, g.check].concat();
4881        for (width, height) in [(80, 24), (60, 20)] {
4882            let mut config = screen.config(QualityPage::Overview);
4883            config.observation_detail = true;
4884            config.evidence_read = Some(&read);
4885            let text = screen.draw(config, position, width, height).join("\n");
4886            for label in ["Read Rows", "Why", "Reads", "Shows", "read only"] {
4887                assert!(text.contains(label), "{label} at {width}x{height}:\n{text}");
4888            }
4889            for c in text.chars().filter(|c| !c.is_ascii()) {
4890                assert!(
4891                    slots.contains(c) || "╭╮╰╯│─·".contains(c),
4892                    "{c:?} at {width}x{height}:\n{text}"
4893                );
4894            }
4895        }
4896    }
4897
4898    /// A line with room for one fact keeps it, cut short, beside the count of the
4899    /// rest, rather than saying only how much it left out.
4900    #[test]
4901    fn a_lone_fact_is_cut_rather_than_dropped() {
4902        let facts = [
4903            "Nearly unique: needs every row checked".to_string(),
4904            "key repeats among 10,000 sampled rows only".to_string(),
4905        ];
4906        let lines = pack_facts(&facts, 37, 1);
4907        assert_eq!(lines.len(), 1);
4908        assert!(lines[0].starts_with("Nearly unique"), "{lines:?}");
4909        assert!(lines[0].ends_with("+1 more"), "{lines:?}");
4910        assert!(glyphs::display_width(&lines[0]) <= 37, "{lines:?}");
4911    }
4912
4913    /// Facts wrap between facts, never inside one, and what does not fit is counted.
4914    #[test]
4915    fn coverage_facts_wrap_whole_and_count_the_rest() {
4916        let facts = ["one fact", "another fact", "a third fact", "a fourth"]
4917            .map(String::from)
4918            .to_vec();
4919        let dot = glyphs::get().middot;
4920        assert_eq!(
4921            pack_facts(&facts, 24, 2),
4922            [
4923                format!("one fact {dot} another fact"),
4924                format!("a third fact {dot} a fourth")
4925            ]
4926        );
4927        assert_eq!(
4928            pack_facts(&facts, 24, 1),
4929            [format!("one fact {dot} +3 more")]
4930        );
4931    }
4932
4933    /// Setup at the baseline size and the smallest: every row in its section, the
4934    /// focused one on screen with the rail whichever it is, and every character
4935    /// outside ASCII a glyph slot, which has an ASCII twin under `LANG=C`.
4936    #[test]
4937    fn setup_fits_80x24_and_60x20_in_glyph_slots() {
4938        let screen = Screen::new();
4939        let g = glyphs::get();
4940        let slots = [
4941            g.rail,
4942            g.rule_h,
4943            g.rule_h_focused,
4944            g.middot,
4945            g.ellipsis,
4946            g.selector,
4947        ]
4948        .concat();
4949        for (width, height) in [(80, 24), (60, 20)] {
4950            for row in SetupRow::ALL {
4951                let mut config = screen.config(QualityPage::Setup);
4952                config.plan_field = row.index();
4953                let rows = screen.draw(config, 0, width, height);
4954                let text = rows.join("\n");
4955                assert!(
4956                    rows.iter()
4957                        .any(|line| line.contains(&format!("{}{}", g.rail, row.label()))),
4958                    "{row:?} focused at {width}x{height}:\n{text}"
4959                );
4960                for other in SetupRow::ALL {
4961                    assert!(
4962                        text.contains(other.label()),
4963                        "{other:?} on screen at {width}x{height}:\n{text}"
4964                    );
4965                }
4966                for section in ["Rows & sample", "Columns", "Study", "Read"] {
4967                    assert!(
4968                        text.contains(section),
4969                        "{section} at {width}x{height}:\n{text}"
4970                    );
4971                }
4972                for c in text.chars().filter(|c| !c.is_ascii()) {
4973                    assert!(
4974                        slots.contains(c) || "╭╮╰╯│─".contains(c),
4975                        "{c:?} is not a glyph slot at {width}x{height}:\n{text}"
4976                    );
4977                }
4978            }
4979        }
4980    }
4981
4982    /// Setup says what a run will read before it runs, and says it honestly: a full
4983    /// run counts its passes rather than promising one read, and Setup's own line
4984    /// says why Enter waits.
4985    #[test]
4986    fn setup_states_the_read_and_why_run_waits() {
4987        let screen = Screen::new();
4988        let mut config = screen.config(QualityPage::Setup);
4989        config.show_access = true;
4990        let text = screen.draw(config, 0, 100, 30).join("\n");
4991        assert!(!text.contains("at most one read"), "{text}");
4992        assert!(text.contains("one per check"), "{text}");
4993
4994        let config = screen.config(QualityPage::Setup);
4995        let text = screen.draw(config, 0, 100, 30).join("\n");
4996        assert!(text.contains("passes over the scope"), "{text}");
4997
4998        let mut config = screen.config(QualityPage::Setup);
4999        config.setup.cancelling = Some(Cancelling {
5000            since: std::time::Instant::now(),
5001            read_runs_out: true,
5002        });
5003        config.setup.note = Some("Run waits: the cancelled run is still stopping");
5004        let rows = screen.draw(config, 0, 100, 30);
5005        let text = rows.join("\n");
5006        assert!(
5007            rows[0].contains("Cancelling: source read finishing"),
5008            "the header keeps the state: {text}"
5009        );
5010        assert!(
5011            text.contains("Run waits: source read finishing"),
5012            "Setup says why Enter did not run: {text}"
5013        );
5014
5015        // A run that should have stopped at its next batch and has not says so,
5016        // without claiming a read it cannot stop.
5017        let mut config = screen.config(QualityPage::Setup);
5018        config.setup.cancelling = Some(Cancelling {
5019            since: std::time::Instant::now(),
5020            read_runs_out: false,
5021        });
5022        let rows = screen.draw(config, 0, 100, 30);
5023        assert!(
5024            rows[0].contains("Cancelling: run stopping"),
5025            "{}",
5026            rows.join("\n")
5027        );
5028        assert!(!rows.join("\n").contains("source read finishing"));
5029    }
5030}
5031
5032/// Intervals on screen: the report's list and one interval's detail, and what
5033/// Setup says about roles and zones before a run.
5034#[cfg(test)]
5035mod interval_tests {
5036    use super::*;
5037    use crate::data_quality::{TemporalRoleAssignment, TimeInterpretation, TimeKind};
5038    use polars::prelude::*;
5039
5040    const HOUR: i64 = 3_600_000_000;
5041
5042    /// Two days of sends and receipts, with a receipt missing, one early and one
5043    /// over an hour; and validity periods, one open and one that ends first.
5044    fn frame() -> LazyFrame {
5045        let day = 1_704_067_200_000_000i64; // 2024-01-01
5046        let sent = (0..8)
5047            .map(|row| Some(day + (row / 4) * 24 * HOUR + row * HOUR))
5048            .collect::<Vec<_>>();
5049        let seen = sent
5050            .iter()
5051            .enumerate()
5052            .map(|(row, at)| match row {
5053                1 => None,
5054                2 => at.map(|at| at - 60_000_000),
5055                5 => at.map(|at| at + 2 * HOUR),
5056                _ => at.map(|at| at + 600_000_000),
5057            })
5058            .collect::<Vec<_>>();
5059        let datetimes = |name: &str, values: Vec<Option<i64>>| -> Column {
5060            Series::new(name.into(), values)
5061                .cast(&DataType::Datetime(TimeUnit::Microseconds, None))
5062                .unwrap()
5063                .into()
5064        };
5065        let days = |name: &str, values: [Option<i32>; 8]| -> Column {
5066            Series::new(name.into(), values)
5067                .cast(&DataType::Date)
5068                .unwrap()
5069                .into()
5070        };
5071        DataFrame::new(
5072            8,
5073            vec![
5074                datetimes("sent", sent),
5075                datetimes("seen", seen),
5076                Column::new("stamp".into(), vec!["2024-01-01T00:10:00Z"; 8]),
5077                days("from", [Some(19_723); 8]),
5078                days(
5079                    "to",
5080                    [
5081                        Some(19_730),
5082                        None,
5083                        Some(19_720),
5084                        Some(19_730),
5085                        Some(19_730),
5086                        Some(19_730),
5087                        Some(19_730),
5088                        Some(19_723),
5089                    ],
5090                ),
5091            ],
5092        )
5093        .unwrap()
5094        .lazy()
5095    }
5096
5097    fn role(role: TemporalRole, column: &str) -> TemporalRoleAssignment {
5098        TemporalRoleAssignment {
5099            role,
5100            column: column.to_string(),
5101            timezone: None,
5102        }
5103    }
5104
5105    struct Screen {
5106        state: DataTableState,
5107        plan: DataQualityPlan,
5108        /// What Setup offers roles: the frame's date and time columns, and text.
5109        candidates: Vec<String>,
5110        results: DataQualityResults,
5111        theme: Theme,
5112        ctx: RenderContext,
5113    }
5114
5115    impl Screen {
5116        fn new(plan: DataQualityPlan) -> Self {
5117            let lf = frame();
5118            let schema = Arc::new((*lf.clone().collect_schema().unwrap()).clone());
5119            let state = DataTableState::from_schema_and_lazyframe(
5120                schema,
5121                lf.clone(),
5122                &crate::OpenOptions::default(),
5123                None,
5124            )
5125            .unwrap();
5126            let results =
5127                crate::data_quality::compute_data_quality(&lf, Some(8), &plan, None, false)
5128                    .unwrap();
5129            Self {
5130                state,
5131                plan,
5132                candidates: ["sent", "seen", "from", "to", "stamp"]
5133                    .map(String::from)
5134                    .to_vec(),
5135                results,
5136                theme: Theme::from_config(&crate::config::ThemeConfig::default()).unwrap(),
5137                ctx: RenderContext::for_test(),
5138            }
5139        }
5140
5141        fn studied() -> Self {
5142            Self::new(DataQualityPlan {
5143                compute: QualityCompute::Full,
5144                temporal_roles: vec![
5145                    role(TemporalRole::Event, "sent"),
5146                    role(TemporalRole::Received, "seen"),
5147                    role(TemporalRole::ValidFrom, "from"),
5148                    role(TemporalRole::ValidTo, "to"),
5149                ],
5150                latency_threshold_seconds: Some(3_600),
5151                grain: QualityGrain::TimeWindows {
5152                    column: "sent".to_string(),
5153                    every: "1d".to_string(),
5154                },
5155                ..DataQualityPlan::default()
5156            })
5157        }
5158
5159        fn draw(
5160            &self,
5161            page: QualityPage,
5162            interval: usize,
5163            selected: usize,
5164            size: (u16, u16),
5165        ) -> String {
5166            let config = DataQualityWidgetConfig {
5167                checks_expanded: false,
5168                state: &self.state,
5169                plan: &self.plan,
5170                measured: &self.plan,
5171                results: Some(&self.results),
5172                from_cache: false,
5173                metric: QualityMetric::NullRate,
5174                column_index: 0,
5175                segment_index: 0,
5176                interval_index: interval,
5177                trend_line: 0,
5178                expected_form: None,
5179                segments_by_change: false,
5180                page,
5181                setup: SetupView {
5182                    time_candidates: &self.candidates,
5183                    ..SetupView::default()
5184                },
5185                plan_field: selected,
5186                show_access: false,
5187                observation_detail: false,
5188                findings: &FindingsView {
5189                    column: None,
5190                    check: None,
5191                    order: FindingOrder::Ranked,
5192                },
5193                rows_kept: false,
5194                evidence_read: None,
5195                focus: AnalysisFocus::Main,
5196                theme: &self.theme,
5197                ctx: &self.ctx,
5198                intent_form: None,
5199                export_form: None,
5200            };
5201            let (width, height) = size;
5202            let area = Rect::new(0, 0, width, height);
5203            let mut buf = Buffer::empty(area);
5204            let mut table = TableState::default();
5205            table.select(Some(selected));
5206            let mut sidebar = TableState::default();
5207            render(
5208                config,
5209                &mut table,
5210                &mut sidebar,
5211                &mut DetailScroll::default(),
5212                area,
5213                &mut buf,
5214            );
5215            (0..height)
5216                .map(|y| {
5217                    (0..width)
5218                        .map(|x| buf[(x, y)].symbol().to_string())
5219                        .collect::<String>()
5220                })
5221                .collect::<Vec<_>>()
5222                .join("\n")
5223        }
5224    }
5225
5226    /// Every character outside ASCII is a glyph slot, which `LANG=C` swaps for its
5227    /// ASCII twin.
5228    fn assert_glyph_slots(text: &str) {
5229        let g = glyphs::get();
5230        let slots = [
5231            g.rail,
5232            g.rule_h,
5233            g.rule_h_focused,
5234            g.middot,
5235            g.ellipsis,
5236            g.selector,
5237            g.checkbox_on,
5238            g.checkbox_off,
5239        ]
5240        .concat();
5241        for c in text.chars().filter(|c| !c.is_ascii()) {
5242            assert!(
5243                slots.contains(c) || "╭╮╰╯│─".contains(c),
5244                "{c:?} is not a glyph slot:\n{text}"
5245            );
5246        }
5247    }
5248
5249    /// The list says what its last column counts and out of what; each row names
5250    /// its interval and segment where there is room, and the selected row has the
5251    /// selector at every size.
5252    #[test]
5253    fn the_interval_list_fits_80x24_and_60x20() {
5254        let screen = Screen::studied();
5255        let intervals = &screen.results.temporal;
5256        assert_eq!(intervals.len(), 4, "two intervals over two days");
5257        for size in [(120, 32), (80, 24), (60, 20)] {
5258            for (selected, interval) in intervals.iter().enumerate() {
5259                let text = screen.draw(QualityPage::Intervals, 0, selected, size);
5260                assert!(text.contains("Time between dates"), "{text}");
5261                assert!(
5262                    text.contains("Over: duration > 1 hour, of rows with both ends"),
5263                    "{size:?}: {text}"
5264                );
5265                let row = text
5266                    .lines()
5267                    // Leading: the ASCII selector, `> `, is also in "duration > 1 hour".
5268                    .find(|line| line.trim_start().starts_with(glyphs::get().selector))
5269                    .unwrap_or_else(|| panic!("a selected row at {size:?}:\n{text}"));
5270                // In full, or cut with an ellipsis where the width runs out.
5271                let label = interval.label();
5272                assert!(
5273                    row.contains(&label)
5274                        || row.contains(&label[..12]) && row.contains(glyphs::get().ellipsis),
5275                    "{size:?}: {row}"
5276                );
5277                if size.0 >= 80 {
5278                    assert!(row.contains(&interval.segment), "{row}");
5279                }
5280                assert_glyph_slots(&text);
5281            }
5282        }
5283        // Wide enough, the list adds the denominator and each end's missing count.
5284        let wide = screen.draw(QualityPage::Intervals, 0, 0, (160, 32));
5285        assert!(
5286            wide.contains("Both ends") && wide.contains("Missing s/e"),
5287            "{wide}"
5288        );
5289    }
5290
5291    /// An interval's detail at 80x24 holds every count at once; at 60x20 it scrolls
5292    /// to the count under the cursor. Missing ends, unread text and negative
5293    /// durations are rows of their own, and each count says what it is out of.
5294    #[test]
5295    fn an_interval_detail_keeps_every_count_at_80x24_and_60x20() {
5296        let screen = Screen::studied();
5297        let first = &screen.results.temporal[0];
5298        assert_eq!(first.label(), "event to received");
5299        assert_eq!(
5300            (first.evaluated_rows, first.paired_rows, first.missing_end),
5301            (4, 3, 1)
5302        );
5303        let labels = [
5304            "Start",
5305            "End",
5306            "Segment",
5307            "Rows",
5308            "Both ends",
5309            "Missing start",
5310            "Missing end",
5311            "Negative",
5312            "Zero",
5313            "p50, p90",
5314            "p95, p99",
5315            "Maximum",
5316            "Threshold",
5317            "Over 1 hour",
5318        ];
5319        let text = screen.draw(QualityPage::IntervalDetail, 0, 0, (80, 24));
5320        for label in labels {
5321            assert!(text.contains(label), "{label}:\n{text}");
5322        }
5323        assert!(
5324            text.contains("3 of 4 (75.0%)"),
5325            "both ends out of rows:\n{text}"
5326        );
5327        assert!(
5328            text.contains("1 of 3 (33.3%) with both ends"),
5329            "negative out of both ends:\n{text}"
5330        );
5331        assert!(text.contains("duration > 1 hour, strictly"), "{text}");
5332        assert!(!text.contains("more"), "nothing cut at 80x24:\n{text}");
5333        let facts = IntervalFact::ALL
5334            .into_iter()
5335            .filter(|fact| first.count(*fact, &screen.plan).is_some())
5336            .collect::<Vec<_>>();
5337        // 60x17 is what a 60x20 terminal leaves the page under its bars.
5338        for size in [(80, 24), (60, 20), (60, 17)] {
5339            for (selected, fact) in facts.iter().enumerate() {
5340                let text = screen.draw(QualityPage::IntervalDetail, 0, selected, size);
5341                let rail = format!("{} {}", glyphs::get().rail, fact.label(first));
5342                assert!(
5343                    text.contains(&rail),
5344                    "{fact:?} under the cursor at {size:?}:\n{text}"
5345                );
5346                assert_glyph_slots(&text);
5347                // Scrolled, the page ends on its last line or the count of what
5348                // is below, never on blank lines.
5349                if !text.contains("Start") {
5350                    let lines = text.lines().collect::<Vec<_>>();
5351                    let last = lines.iter().rposition(|line| !line.trim().is_empty());
5352                    assert_eq!(last, Some(lines.len() - 2), "{fact:?}:\n{text}");
5353                }
5354            }
5355        }
5356    }
5357
5358    /// Where Enter cannot help, the page says why: a report of file metadata reads
5359    /// no times, and a row chunk's rows are not a value a count can open.
5360    #[test]
5361    fn intervals_say_why_nothing_opens() {
5362        let roles = vec![
5363            role(TemporalRole::Event, "sent"),
5364            role(TemporalRole::Received, "seen"),
5365        ];
5366        let screen = Screen::new(DataQualityPlan {
5367            compute: QualityCompute::Metadata,
5368            temporal_roles: roles.clone(),
5369            ..DataQualityPlan::default()
5370        });
5371        assert!(screen.results.temporal.is_empty());
5372        let text = screen.draw(QualityPage::Intervals, 0, 0, (80, 24));
5373        assert!(
5374            text.contains("File metadata only · no values read"),
5375            "{text}"
5376        );
5377
5378        let screen = Screen::new(DataQualityPlan {
5379            compute: QualityCompute::Full,
5380            temporal_roles: roles,
5381            grain: QualityGrain::RowChunks(4),
5382            ..DataQualityPlan::default()
5383        });
5384        for size in [(80, 24), (60, 20)] {
5385            let text = screen.draw(QualityPage::IntervalDetail, 0, 0, size);
5386            assert!(text.contains("Rows do not open: a row chunk"), "{text}");
5387        }
5388    }
5389
5390    /// Windowing intervals by their ends groups once per end column, and on a full
5391    /// scan Setup says how many of its passes that is before Run.
5392    #[test]
5393    fn setup_counts_the_passes_a_window_clock_takes() {
5394        let mut plan = DataQualityPlan {
5395            compute: QualityCompute::Full,
5396            temporal_roles: vec![
5397                role(TemporalRole::Event, "sent"),
5398                role(TemporalRole::Received, "seen"),
5399                role(TemporalRole::Processed, "to"),
5400            ],
5401            grain: QualityGrain::TimeWindows {
5402                column: "sent".to_string(),
5403                every: "1d".to_string(),
5404            },
5405            interval_clock: IntervalClock::End,
5406            ..DataQualityPlan::default()
5407        };
5408        let text = Screen::new(plan.clone()).draw(QualityPage::Setup, 0, 0, (120, 40));
5409        assert!(
5410            text.contains("Window by each interval's end: 2 of those passes, 1 per column"),
5411            "{text}"
5412        );
5413        plan.interval_clock = IntervalClock::Grain;
5414        let text = Screen::new(plan).draw(QualityPage::Setup, 0, 0, (120, 40));
5415        assert!(!text.contains("of those passes"), "{text}");
5416    }
5417
5418    /// A full scan's Read says how it gets the rows of a remote source before Run:
5419    /// one fetch into a copy, a copy fetched earlier, or the source in every pass
5420    /// with the reason there is no copy.
5421    #[test]
5422    fn setup_says_how_a_full_scan_reads_a_remote_source() {
5423        const MIB: u64 = 1024 * 1024;
5424        let lines = |copy: CopyPlan, copy_released: bool| {
5425            let view = SetupView {
5426                copy,
5427                copy_released,
5428                ..SetupView::default()
5429            };
5430            copy_lines(&view, 7).join("\n")
5431        };
5432        let fetch = CopyPlan::Fetch {
5433            bytes: 17 * MIB,
5434            objects: 8,
5435        };
5436        let text = lines(fetch, false);
5437        assert!(
5438            text.starts_with(
5439                "1 fetch of 8 objects (17.0 MiB) to a local copy · up to 7 passes over it"
5440            ),
5441            "{text}"
5442        );
5443        assert!(
5444            text.contains("Local copy kept for later full scans · d releases"),
5445            "{text}"
5446        );
5447        assert!(!text.contains("Released since"), "{text}");
5448        assert!(lines(fetch, true).contains("Released since last copy · fetched again"));
5449        let one = CopyPlan::Fetch {
5450            bytes: MIB,
5451            objects: 1,
5452        };
5453        assert!(lines(one, false).starts_with("1 fetch of 1 object (1.0 MiB)"));
5454        assert_eq!(
5455            lines(
5456                CopyPlan::Kept {
5457                    bytes: 17 * MIB,
5458                    objects: 8
5459                },
5460                false
5461            ),
5462            "Every eligible row · up to 7 passes over the local copy (17.0 MiB) · no source read"
5463        );
5464        assert!(lines(CopyPlan::NotApplicable, false).contains("passes over the scope"));
5465        for (why, says) in [
5466            (NoCopy::Off, "No local copy: quality_local_copy = 0"),
5467            (NoCopy::SizeUnknown, "No local copy: object sizes unknown"),
5468            (
5469                NoCopy::Unusable,
5470                "No local copy: copy did not read as the source",
5471            ),
5472            (
5473                NoCopy::PartOfTheSource,
5474                "No local copy: scope reads part of the source",
5475            ),
5476            (
5477                NoCopy::TooLarge {
5478                    bytes: 3 * 1024 * MIB,
5479                    limit: 2 * 1024 * MIB,
5480                },
5481                "No local copy: 3.0 GiB over the 2.0 GiB limit",
5482            ),
5483            (
5484                NoCopy::NoRoom {
5485                    bytes: 17 * MIB,
5486                    free: Some(MIB),
5487                },
5488                "No local copy: 17.0 MiB needed, 1.0 MiB free on disk",
5489            ),
5490            (
5491                NoCopy::NoRoom {
5492                    bytes: 17 * MIB,
5493                    free: None,
5494                },
5495                "No local copy: free disk space unknown",
5496            ),
5497        ] {
5498            let text = lines(CopyPlan::Passes(why), false);
5499            assert!(
5500                text.starts_with("Every eligible row · up to 7 passes over the source"),
5501                "{text}"
5502            );
5503            assert!(text.ends_with(says), "{text}");
5504        }
5505    }
5506
5507    /// The Read rule names a kept copy beside kept rows, or alone.
5508    #[test]
5509    fn the_read_rule_names_a_kept_copy() {
5510        let rows = KeptRows {
5511            samples: 1,
5512            rows: 100_000,
5513            bytes: 13 * 1024 * 1024,
5514            copy_bytes: 0,
5515        };
5516        let middot = glyphs::get().middot;
5517        assert_eq!(rows.label(), format!("100,000 rows kept {middot} 13.0 MiB"));
5518        let both = KeptRows {
5519            copy_bytes: 17 * 1024 * 1024,
5520            ..rows
5521        };
5522        assert_eq!(
5523            both.label(),
5524            format!("100,000 rows kept {middot} 13.0 MiB, local copy {middot} 17.0 MiB")
5525        );
5526        let copy = KeptRows {
5527            samples: 0,
5528            rows: 0,
5529            bytes: 0,
5530            ..both
5531        };
5532        assert_eq!(copy.label(), format!("local copy {middot} 17.0 MiB"));
5533    }
5534
5535    /// Valid from to valid to is a validity period: no end is open, an end before
5536    /// the start ends first.
5537    #[test]
5538    fn a_validity_period_reads_as_one() {
5539        let screen = Screen::studied();
5540        let index = screen
5541            .results
5542            .temporal
5543            .iter()
5544            .position(|interval| interval.is_validity())
5545            .unwrap();
5546        let text = screen.draw(QualityPage::IntervalDetail, index, 0, (80, 24));
5547        assert!(text.contains("Open, no end"), "{text}");
5548        assert!(text.contains("Ends first"), "{text}");
5549        assert!(!text.contains("Negative"), "{text}");
5550    }
5551
5552    /// Before a run, Setup names roles no interval uses and says how a time with
5553    /// no zone meets one with an offset; the pairs editor lists every start and end.
5554    #[test]
5555    fn setup_names_unpaired_roles_and_how_zones_compare() {
5556        let screen = Screen::new(DataQualityPlan {
5557            temporal_roles: vec![
5558                role(TemporalRole::Event, "sent"),
5559                role(TemporalRole::Received, "stamp"),
5560                role(TemporalRole::Created, "from"),
5561            ],
5562            time_formats: vec![TimeInterpretation {
5563                column: "stamp".to_string(),
5564                kind: TimeKind::Datetime,
5565                format: "%Y-%m-%dT%H:%M:%S%.f%#z".to_string(),
5566            }],
5567            ..DataQualityPlan::default()
5568        });
5569        let text = screen.draw(QualityPage::Setup, 0, 0, (120, 32));
5570        assert!(
5571            text.contains("In no interval: created · set Intervals"),
5572            "{text}"
5573        );
5574        assert!(text.contains("No time zone, read as UTC: sent"), "{text}");
5575        assert!(text.contains("event to received"), "{text}");
5576
5577        let text = screen.draw(QualityPage::IntervalPairs, 0, 0, (80, 24));
5578        assert!(text.contains("Intervals  1 of 6"), "{text}");
5579        let g = glyphs::get();
5580        assert!(
5581            text.contains(&format!("{} event to received", g.checkbox_on)),
5582            "{text}"
5583        );
5584        assert!(
5585            text.contains(&format!("{} created to event", g.checkbox_off)),
5586            "{text}"
5587        );
5588        assert_glyph_slots(&text);
5589    }
5590}
5591
5592#[cfg(test)]
5593mod trend_tests {
5594    use super::*;
5595    use crate::analysis_modal::ExpectedForm;
5596    use crate::data_quality::{ExpectedWindows, compute_data_quality};
5597    use polars::prelude::{DataType, IntoLazy, LazyFrame, col, df};
5598    use ratatui::style::Color;
5599    use std::sync::Arc;
5600
5601    /// Weekday rows over eight weeks, forty a day, the second week missing and a
5602    /// third of the amounts null.
5603    fn frame() -> LazyFrame {
5604        let days = (0..56)
5605            .filter(|day| day % 7 < 5 && !(7..14).contains(day))
5606            .collect::<Vec<i32>>();
5607        let day = days
5608            .iter()
5609            .flat_map(|day| std::iter::repeat_n(19_723 + day, 40))
5610            .collect::<Vec<_>>();
5611        let rows = day.len();
5612        df!(
5613            "day" => day,
5614            "amount" => (0..rows).map(|row| (row % 3 != 0).then_some(row as f64)).collect::<Vec<_>>(),
5615        )
5616        .unwrap()
5617        .lazy()
5618        .with_column(col("day").cast(DataType::Date))
5619    }
5620
5621    struct Screen {
5622        state: DataTableState,
5623        plan: DataQualityPlan,
5624        results: DataQualityResults,
5625        metric: QualityMetric,
5626        theme: Theme,
5627        ctx: RenderContext,
5628    }
5629
5630    impl Screen {
5631        /// A daily study of a 30-row sample, weekdays expected through March 3.
5632        fn sampled() -> Self {
5633            let lf = frame();
5634            let schema = Arc::new((*lf.clone().collect_schema().unwrap()).clone());
5635            let state = DataTableState::from_schema_and_lazyframe(
5636                schema,
5637                lf.clone(),
5638                &crate::OpenOptions::default(),
5639                None,
5640            )
5641            .unwrap();
5642            let plan = DataQualityPlan {
5643                dataset_rows: 30,
5644                sample_seed: 415,
5645                grain: QualityGrain::TimeWindows {
5646                    column: "day".to_string(),
5647                    every: "1d".to_string(),
5648                },
5649                expected: Some(ExpectedWindows {
5650                    weekdays: true,
5651                    from: Some("2024-01-01".to_string()),
5652                    before: Some("2024-03-04".to_string()),
5653                }),
5654                ..DataQualityPlan::default()
5655            };
5656            let results = compute_data_quality(&lf, Some(1_400), &plan, None, false).unwrap();
5657            assert!(!results.unsampled_segments.is_empty());
5658            Self {
5659                state,
5660                plan,
5661                results,
5662                metric: QualityMetric::NullRate,
5663                theme: Theme::from_config(&crate::config::ThemeConfig::default()).unwrap(),
5664                ctx: RenderContext::for_test(),
5665            }
5666        }
5667
5668        fn draw_with(
5669            &self,
5670            theme: &Theme,
5671            page: QualityPage,
5672            line: usize,
5673            selected: usize,
5674            form: Option<&ExpectedForm>,
5675            (width, height): (u16, u16),
5676        ) -> Buffer {
5677            let config = DataQualityWidgetConfig {
5678                checks_expanded: false,
5679                state: &self.state,
5680                plan: &self.plan,
5681                measured: &self.plan,
5682                results: Some(&self.results),
5683                from_cache: false,
5684                metric: self.metric,
5685                column_index: 0,
5686                segment_index: 0,
5687                interval_index: 0,
5688                trend_line: line,
5689                expected_form: form,
5690                segments_by_change: false,
5691                page,
5692                setup: SetupView::default(),
5693                plan_field: SetupRow::Expected.index(),
5694                show_access: false,
5695                observation_detail: false,
5696                focus: AnalysisFocus::Main,
5697                theme,
5698                ctx: &self.ctx,
5699                findings: &FindingsView::default(),
5700                rows_kept: false,
5701                evidence_read: None,
5702                intent_form: None,
5703                export_form: None,
5704            };
5705            let area = Rect::new(0, 0, width, height);
5706            let mut buf = Buffer::empty(area);
5707            let mut table = TableState::default();
5708            table.select(Some(selected));
5709            render(
5710                config,
5711                &mut table,
5712                &mut TableState::default(),
5713                &mut DetailScroll::default(),
5714                area,
5715                &mut buf,
5716            );
5717            buf
5718        }
5719
5720        fn draw(
5721            &self,
5722            page: QualityPage,
5723            line: usize,
5724            selected: usize,
5725            size: (u16, u16),
5726        ) -> String {
5727            text(&self.draw_with(&self.theme, page, line, selected, None, size))
5728        }
5729
5730        /// The Trends line of the amount column.
5731        fn amount(&self) -> usize {
5732            trend_view(&self.results, self.metric, 1)
5733                .lines
5734                .iter()
5735                .position(|line| line.names == ["amount"])
5736                .unwrap()
5737        }
5738    }
5739
5740    fn text(buf: &Buffer) -> String {
5741        let area = buf.area;
5742        (0..area.height)
5743            .map(|y| {
5744                (0..area.width)
5745                    .map(|x| buf[(x, y)].symbol().to_string())
5746                    .collect::<String>()
5747            })
5748            .collect::<Vec<_>>()
5749            .join("\n")
5750    }
5751
5752    /// Every character outside ASCII is a glyph slot, which `LANG=C` swaps for its
5753    /// ASCII twin.
5754    fn assert_glyph_slots(text: &str) {
5755        let g = glyphs::get();
5756        let slots = [
5757            g.rail,
5758            g.rule_h,
5759            g.middot,
5760            g.ellipsis,
5761            g.selector,
5762            g.unsampled,
5763            g.pointer,
5764            g.updown,
5765        ]
5766        .concat()
5767            + &g.mini_bars.concat();
5768        for c in text.chars().filter(|c| !c.is_ascii()) {
5769            assert!(
5770                slots.contains(c) || "╭╮╰╯│─".contains(c),
5771                "{c:?} is not a glyph slot:\n{text}"
5772            );
5773        }
5774    }
5775
5776    /// A bar the sample drew nothing from has a mark of its own in both glyph sets:
5777    /// not a bar level, and not the blank of a measure with nothing to apply to. So
5778    /// neither a C locale nor a 16-color terminal loses it.
5779    #[test]
5780    fn a_missed_bar_has_its_own_mark_in_both_glyph_sets() {
5781        let screen = Screen::sampled();
5782        let view = trend_view(&screen.results, QualityMetric::NullRate, 100);
5783        let missed = view
5784            .bars
5785            .iter()
5786            .position(|bar| bar.evaluated == 0)
5787            .expect("a day with no sampled row");
5788        let amount = &view.lines[screen.amount()];
5789        for g in [glyphs::unicode(), glyphs::ascii()] {
5790            let mark = bar_mark(amount, &view.bars[missed], missed, g);
5791            assert_eq!(mark, g.unsampled);
5792            assert!(!g.mini_bars.contains(&mark) && mark != " ", "{mark:?}");
5793            assert_ne!(g.pointer, " ");
5794            assert_eq!(glyphs::display_width(g.unsampled), 1);
5795            assert_eq!(glyphs::display_width(g.pointer), 1);
5796        }
5797        // The exact count still has every day's rows.
5798        let rows = &view.lines[0];
5799        assert_eq!(rows.names, ["rows"]);
5800        assert_eq!(rows.bars[missed], Some(40.0));
5801    }
5802
5803    /// Trends says how much of the scope the sample reached, offers a coarser window,
5804    /// and sums up the expected windows, at 80x24 and 60x20.
5805    #[test]
5806    fn trends_say_their_coverage_and_gaps_at_80x24_and_60x20() {
5807        let screen = Screen::sampled();
5808        for size in [(80, 24), (60, 20)] {
5809            let text = screen.draw(QualityPage::Trends, 0, 0, size);
5810            assert!(text.contains("not sampled"), "{size:?}:\n{text}");
5811            assert!(text.contains("stages weekly"), "{size:?}:\n{text}");
5812            assert!(text.contains("Expected weekdays"), "{size:?}:\n{text}");
5813            assert!(text.contains("sampled rows"), "{size:?}:\n{text}");
5814            assert!(text.contains("amount"), "{size:?}:\n{text}");
5815            assert_glyph_slots(&text);
5816        }
5817    }
5818
5819    /// A bar's detail holds every fact at 80x24 and 60x20, the pointer under the bar
5820    /// selected, and walks to the last bar without running past it.
5821    #[test]
5822    fn a_trend_bar_states_its_facts_at_80x24_and_60x20() {
5823        let screen = Screen::sampled();
5824        let amount = screen.amount();
5825        for size in [(80, 24), (60, 20)] {
5826            let first = screen.draw(QualityPage::TrendDetail, amount, 0, size);
5827            for label in ["Span", "Segments", "Rows", "Null rate", "95% interval"] {
5828                assert!(first.contains(label), "{label} at {size:?}:\n{first}");
5829            }
5830            assert!(first.contains("bar 1 of"), "{first}");
5831            assert!(!first.contains("Previous bar"), "nothing before the first");
5832            let second = screen.draw(QualityPage::TrendDetail, amount, 1, size);
5833            assert!(second.contains("Previous bar"), "{size:?}:\n{second}");
5834            // Past the end, the last bar; the pointer sits on the spark's last mark.
5835            let last = screen.draw(QualityPage::TrendDetail, amount, 10_000, size);
5836            let lines = last.lines().collect::<Vec<_>>();
5837            let spark = lines
5838                .iter()
5839                .position(|line| line.contains("amount"))
5840                .unwrap();
5841            let pointer = lines[spark + 1]
5842                .find(glyphs::get().pointer)
5843                .expect("a pointer");
5844            // Up to the tool list's frame, where there is one.
5845            let side = format!(" {}", glyphs::get().border.vertical_left);
5846            let marks =
5847                lines[spark][..lines[spark].find(&side).unwrap_or(lines[spark].len())].trim_end();
5848            assert_eq!(
5849                glyphs::display_width(marks) - 1,
5850                glyphs::display_width(&lines[spark + 1][..pointer]),
5851                "{last}"
5852            );
5853            assert_glyph_slots(&last);
5854        }
5855        // A rows line says rows per segment, not a rate.
5856        let rows = screen.draw(QualityPage::TrendDetail, 0, 0, (80, 24));
5857        assert!(rows.contains("Rows per segment"), "{rows}");
5858        assert!(rows.contains("Per segment"), "{rows}");
5859        assert!(!rows.contains("95% interval"), "{rows}");
5860    }
5861
5862    /// Nothing on these pages is said by color alone: drawn with every color gone,
5863    /// and with the 16 a basic terminal has, every cell holds the same symbol.
5864    #[test]
5865    fn trends_and_gaps_read_the_same_without_color() {
5866        let screen = Screen::sampled();
5867        let mono = Theme {
5868            colors: screen
5869                .theme
5870                .colors
5871                .keys()
5872                .map(|key| (key.clone(), Color::Reset))
5873                .collect(),
5874        };
5875        let sixteen = Theme {
5876            colors: screen
5877                .theme
5878                .colors
5879                .iter()
5880                .map(|(key, color)| {
5881                    let color = match *color {
5882                        Color::Rgb(r, g, b) => crate::config::rgb_to_basic_ansi(r, g, b),
5883                        Color::Indexed(index) => Color::Indexed(index % 16),
5884                        other => other,
5885                    };
5886                    (key.clone(), color)
5887                })
5888                .collect(),
5889        };
5890        for (page, line) in [
5891            (QualityPage::Trends, 0),
5892            (QualityPage::TrendDetail, screen.amount()),
5893            (QualityPage::Gaps, 0),
5894        ] {
5895            let full = text(&screen.draw_with(&screen.theme, page, line, 1, None, (80, 24)));
5896            for theme in [&mono, &sixteen] {
5897                assert_eq!(
5898                    text(&screen.draw_with(theme, page, line, 1, None, (80, 24))),
5899                    full,
5900                    "{page:?}"
5901                );
5902            }
5903        }
5904    }
5905
5906    /// Gaps list each run of windows by why it has no rows: empty by the exact
5907    /// count, not sampled, with the weekends not expected said apart.
5908    #[test]
5909    fn gaps_list_each_kind_at_80x24_and_60x20() {
5910        let screen = Screen::sampled();
5911        for size in [(80, 24), (60, 20)] {
5912            let text = screen.draw(QualityPage::Gaps, 0, 0, size);
5913            assert!(text.contains("Expected weekdays"), "{size:?}:\n{text}");
5914            assert!(text.contains("2024-01-08 to 2024-01-12 empty"), "{text}");
5915            assert!(text.contains("not sampled"), "{text}");
5916            assert!(text.contains("on weekends, not expected"), "{text}");
5917            assert!(text.contains("gaps"), "{text}");
5918            assert_glyph_slots(&text);
5919        }
5920        // Wide enough, each missed run says the rows the scope holds there.
5921        let wide = screen.draw(QualityPage::Gaps, 0, 0, (120, 30));
5922        assert!(wide.contains("40 rows"), "{wide}");
5923    }
5924
5925    /// Setup's Expected row says what is stated, and its editor lays out at 60x20.
5926    #[test]
5927    fn setup_states_the_expected_windows() {
5928        let mut screen = Screen::sampled();
5929        let setup = screen.draw(QualityPage::Setup, 0, 0, (100, 30));
5930        assert!(
5931            setup.contains("Expected       weekdays, 2024-01-01 to before 2024-03-04"),
5932            "{setup}"
5933        );
5934        assert!(
5935            setup.contains("Expected windows: from the segment counts · no read"),
5936            "{setup}"
5937        );
5938        let form = ExpectedForm::new(&screen.plan, &screen.theme);
5939        let editor = text(&screen.draw_with(
5940            &screen.theme,
5941            QualityPage::ExpectedWindows,
5942            0,
5943            0,
5944            Some(&form),
5945            (60, 20),
5946        ));
5947        for text in [
5948            "Windows",
5949            "weekdays, Monday to Friday",
5950            "From",
5951            "2024-01-01",
5952            "Before",
5953        ] {
5954            assert!(editor.contains(text), "{text}:\n{editor}");
5955        }
5956        screen.plan.expected = None;
5957        let none = screen.draw(QualityPage::Setup, 0, 0, (100, 30));
5958        assert!(none.contains("none: no window is a gap"), "{none}");
5959        screen.plan.grain = QualityGrain::Dataset;
5960        let none = screen.draw(QualityPage::Setup, 0, 0, (100, 30));
5961        assert!(
5962            none.contains("Expected       needs a time-window grain"),
5963            "{none}"
5964        );
5965    }
5966
5967    /// A distinct share is shown against the bar before but never judged, as
5968    /// Segments never judges one: it falls as a segment grows.
5969    #[test]
5970    fn a_distinct_share_is_not_judged_between_bars() {
5971        let mut screen = Screen::sampled();
5972        screen.metric = QualityMetric::DistinctShare;
5973        let second = screen.draw(QualityPage::TrendDetail, screen.amount(), 1, (100, 30));
5974        assert!(second.contains("Previous bar"), "{second}");
5975        assert!(second.contains("+0.0 points: not judged"), "{second}");
5976        assert!(second.contains("none: a distinct share"), "{second}");
5977    }
5978
5979    /// One day found is no trend, but the week expected around it still has its
5980    /// gaps: Trends sums them up beside the way to a grain.
5981    #[test]
5982    fn one_window_found_still_sums_up_the_expected_ones() {
5983        let mut screen = Screen::sampled();
5984        screen.plan = DataQualityPlan {
5985            compute: crate::data_quality::QualityCompute::Full,
5986            expected: Some(ExpectedWindows {
5987                weekdays: false,
5988                from: Some("2024-01-01".to_string()),
5989                before: Some("2024-01-08".to_string()),
5990            }),
5991            ..screen.plan.clone()
5992        };
5993        let one_day = frame().filter(
5994            col("day")
5995                .cast(DataType::Int32)
5996                .eq(polars::prelude::lit(19_724)),
5997        );
5998        screen.results = compute_data_quality(&one_day, None, &screen.plan, None, false).unwrap();
5999        assert!(!crate::data_quality::shows_trend(
6000            &screen.plan,
6001            &screen.results
6002        ));
6003        let text = screen.draw(QualityPage::Trends, 0, 0, (80, 24));
6004        assert!(text.contains("Set Grain"), "{text}");
6005        assert!(
6006            text.contains("Expected every day, 7 days: 6 empty"),
6007            "{text}"
6008        );
6009    }
6010
6011    /// From past the last window found, Before blank: no window is in range, and
6012    /// Trends says that rather than that every window has rows.
6013    #[test]
6014    fn a_range_with_no_window_says_so() {
6015        let mut screen = Screen::sampled();
6016        screen.plan.expected = Some(ExpectedWindows {
6017            weekdays: false,
6018            from: Some("2025-01-01".to_string()),
6019            before: None,
6020        });
6021        for page in [QualityPage::Trends, QualityPage::Gaps] {
6022            let text = screen.draw(page, 0, 0, (80, 24));
6023            assert!(
6024                text.contains("Expected every day: no window in range"),
6025                "{page:?}:\n{text}"
6026            );
6027        }
6028    }
6029}