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#[derive(Debug, Clone, Default)]
30pub struct SetupView<'a> {
31 pub time_candidates: &'a [String],
33 pub reuses_sample: bool,
35 pub reads_blocks: bool,
37 pub segment_count: SegmentCount,
39 pub released: bool,
42 pub cached: bool,
44 pub unchanged: bool,
46 pub relabel_only: bool,
49 pub edited: bool,
51 pub note: Option<&'a str>,
53 pub cancelling: Option<Cancelling>,
55 pub kept: Option<KeptRows>,
57 pub copy: CopyPlan,
59 pub copy_released: bool,
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub struct KeptRows {
66 pub samples: usize,
68 pub rows: usize,
69 pub bytes: usize,
71 pub copy_bytes: u64,
73}
74
75impl KeptRows {
76 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#[derive(Debug, Clone, Copy)]
108pub struct Cancelling {
109 pub since: std::time::Instant,
111 pub read_runs_out: bool,
114}
115
116impl Cancelling {
117 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 pub checks_expanded: bool,
130 pub state: &'a DataTableState,
131 pub plan: &'a DataQualityPlan,
132 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 pub interval_index: usize,
142 pub trend_line: usize,
144 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 pub findings: &'a FindingsView,
154 pub rows_kept: bool,
156 pub evidence_read: Option<&'a EvidenceRead>,
158 pub focus: AnalysisFocus,
159 pub theme: &'a Theme,
160 pub ctx: &'a RenderContext,
162 pub intent_form: Option<&'a crate::intent_modal::IntentForm>,
164 pub export_form: Option<&'a crate::quality_export::ExportForm>,
166}
167
168fn 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
178pub(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 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 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
272fn 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 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
318fn 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
339fn 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 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}
383enum SetupLine {
385 Rule(&'static str, Option<String>),
386 Row(SetupRow),
387 Note(String, bool),
388}
389
390fn 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 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(¬e), 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(¬e), width.saturating_sub(2)) {
451 lines.push(SetupLine::Note(line, false));
452 }
453 }
454
455 let status = setup_status(config).map(|(text, warn)| (dotted(&text), warn));
458 let body_height = area.height.saturating_sub(1) as usize;
459 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 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
551const SETUP_LABEL_WIDTH: u16 = 15;
553
554fn setup_value(config: &DataQualityWidgetConfig<'_>, row: SetupRow) -> (String, bool) {
557 let plan = config.plan;
558 match row {
559 SetupRow::Sample => {
560 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 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
651fn 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 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 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 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
742fn 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 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 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 "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
882fn 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
951fn 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
970fn 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
999fn 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 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 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
1024fn setup_status(config: &DataQualityWidgetConfig<'_>) -> Option<(String, bool)> {
1027 let view = &config.setup;
1028 if let Some(cancelling) = view.cancelling {
1029 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 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 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 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
1189fn 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
1216const COVERAGE_LABEL: usize = 8;
1218
1219fn 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 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
1263fn 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 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 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
1314pub(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
1361fn 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 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 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 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 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; let rest = width.saturating_sub(lead + title_width);
1435 let columns_width = (rest * 2 / 5).clamp(10.min(rest), 32);
1436 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 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 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
1548fn 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 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 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 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
1648fn segment_text(label: &str) -> String {
1654 label.replace('∅', glyphs::get().null)
1655}
1656
1657pub(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
1675fn 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 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 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 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 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 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
1800fn 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 (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 (_, 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
1863fn 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 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 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 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 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 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
2289fn 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
2300fn 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 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
2447fn 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
2465fn share_rate(count: usize, of: usize) -> String {
2467 if of == 0 {
2469 "-".to_string()
2470 } else {
2471 rate_label(count as f64 / of as f64)
2472 }
2473}
2474
2475fn 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 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 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
2634fn 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 let mut lines = Vec::new();
2779 let mut focus = 0;
2780 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 (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
2825fn 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 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
2902const TREND_RANGE: u16 = 20;
2904
2905fn 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
2921fn trend_lead(name_width: u16) -> u16 {
2924 glyphs::display_width(glyphs::get().selector) as u16 + name_width + TREND_RANGE + 2
2925}
2926
2927fn 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
2942fn 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
2952fn 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
2969fn 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
2984fn coarser_label(grain: &QualityGrain) -> String {
2986 match grain {
2987 QualityGrain::TimeWindows { every, .. } => window_cadence(every).to_string(),
2988 other => other.label(),
2989 }
2990}
2991
2992fn 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
3047fn 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 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
3087fn 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
3101fn 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 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
3189fn 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 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 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
3299fn 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 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 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 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 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
3487fn 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 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 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
3678fn 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
3689fn 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 ¬es {
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
3765fn 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
3797fn 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 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 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
3860const DETAIL_MEASURE: usize = 100;
3862
3863const END_WIDTH: usize = 32;
3866
3867struct FieldRow {
3869 mark: Option<Span<'static>>,
3870 label: String,
3871 value: String,
3872}
3873
3874fn 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 .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
3928fn 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
3943fn detail_measurements(
3947 ctx: &RenderContext,
3948 results: &DataQualityResults,
3949 profile: &ColumnQualityProfile,
3950) -> Vec<FieldRow> {
3951 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 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 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
4081fn 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
4127fn 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
4147fn 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 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 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
4285fn 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 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
4388pub(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
4404pub(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
4426fn 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 #[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 #[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 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 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 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 #[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 #[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 "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 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 #[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 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 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 #[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 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 #[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 #[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 #[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 #[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 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#[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 fn frame() -> LazyFrame {
5045 let day = 1_704_067_200_000_000i64; 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 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 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 #[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 .find(|line| line.trim_start().starts_with(glyphs::get().selector))
5269 .unwrap_or_else(|| panic!("a selected row at {size:?}:\n{text}"));
5270 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 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 #[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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 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 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 #[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 let rows = &view.lines[0];
5799 assert_eq!(rows.names, ["rows"]);
5800 assert_eq!(rows.bars[missed], Some(40.0));
5801 }
5802
5803 #[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 #[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 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 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 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 #[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 #[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 let wide = screen.draw(QualityPage::Gaps, 0, 0, (120, 30));
5922 assert!(wide.contains("40 rows"), "{wide}");
5923 }
5924
5925 #[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 #[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 #[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 #[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}