1use chrono::NaiveDateTime;
2use diesel::dsl::sql;
3use diesel::*;
4use regex::Regex;
5use std::collections::HashMap;
6use std::sync::{LazyLock, Mutex};
7use std::time::{Duration, Instant};
8
9use super::rollup;
10use crate::frontend::helpers::severity_highlight;
11use crate::helpers::TaskStatus;
12use crate::models::{
13 Corpus, DiffStatusFilter, DiffStatusRow, HistoricalTask, LogError, LogFatal, LogInfo, LogInvalid,
14 LogRecord, LogWarning, Service, Task, TaskRunMetadata,
15};
16use crate::reports::{AggregateReport, TaskDetailReport};
17use crate::schema::tasks;
18
19static TASK_REPORT_NAME_REGEX: LazyLock<Regex> =
20 LazyLock::new(|| Regex::new(r"^.+/(.+)\..+$").unwrap());
21
22#[derive(Clone, Debug, Default)]
27pub struct LiveRunDiff {
28 pub improved: i64,
30 pub regressed: i64,
33 pub unchanged: i64,
35 pub reclassified: i64,
39}
40impl LiveRunDiff {
41 pub fn compared(&self) -> i64 {
43 self.improved + self.regressed + self.unchanged + self.reclassified
44 }
45}
46
47#[derive(QueryableByName)]
48struct DiffRow {
49 #[diesel(sql_type = diesel::sql_types::BigInt)]
50 improved: i64,
51 #[diesel(sql_type = diesel::sql_types::BigInt)]
52 regressed: i64,
53 #[diesel(sql_type = diesel::sql_types::BigInt)]
54 unchanged: i64,
55 #[diesel(sql_type = diesel::sql_types::BigInt)]
56 reclassified: i64,
57}
58
59type LiveDiffCache = Mutex<HashMap<(i32, i32), (Instant, LiveRunDiff)>>;
68static LIVE_DIFF_CACHE: LazyLock<LiveDiffCache> = LazyLock::new(|| Mutex::new(HashMap::new()));
69const LIVE_DIFF_TTL: Duration = Duration::from_secs(5);
70
71pub fn live_run_diff(
73 connection: &mut PgConnection,
74 corpus_id: i32,
75 service_id: i32,
76) -> LiveRunDiff {
77 let key = (corpus_id, service_id);
78 {
79 let cache = LIVE_DIFF_CACHE.lock().unwrap_or_else(|e| e.into_inner());
81 match cache.get(&key) {
82 Some((at, diff)) if at.elapsed() < LIVE_DIFF_TTL => return diff.clone(),
83 _ => {},
84 }
85 }
87 let diff = compute_live_run_diff(connection, corpus_id, service_id);
88 let mut cache = LIVE_DIFF_CACHE.lock().unwrap_or_else(|e| e.into_inner());
89 cache.insert(key, (Instant::now(), diff.clone()));
90 diff
91}
92
93fn compute_live_run_diff(
114 connection: &mut PgConnection,
115 corpus_id: i32,
116 service_id: i32,
117) -> LiveRunDiff {
118 let row: Option<DiffRow> = sql_query(
122 "SELECT \
123 count(*) FILTER (WHERE t.status <> b.status AND t.status <> -5 AND b.status <> -5 AND t.status > b.status)::bigint AS improved, \
124 count(*) FILTER (WHERE t.status <> b.status AND t.status <> -5 AND b.status <> -5 AND t.status < b.status)::bigint AS regressed, \
125 count(*) FILTER (WHERE t.status = b.status)::bigint AS unchanged, \
126 count(*) FILTER (WHERE t.status <> b.status AND (t.status = -5 OR b.status = -5))::bigint AS reclassified \
127 FROM tasks t \
128 JOIN ( \
129 SELECT DISTINCT ON (h.task_id) h.task_id, h.status \
130 FROM historical_tasks h JOIN tasks tt ON tt.id = h.task_id \
131 WHERE tt.corpus_id = $1 AND tt.service_id = $2 \
132 AND h.saved_at < COALESCE( \
133 (SELECT max(start_time) FROM historical_runs \
134 WHERE corpus_id = $1 AND service_id = $2), 'infinity'::timestamp) \
135 ORDER BY h.task_id, h.saved_at DESC \
136 ) b ON b.task_id = t.id \
137 WHERE t.corpus_id = $1 AND t.service_id = $2 AND t.status < 0",
138 )
139 .bind::<diesel::sql_types::Integer, _>(corpus_id)
140 .bind::<diesel::sql_types::Integer, _>(service_id)
141 .get_result(connection)
142 .optional()
143 .ok()
144 .flatten();
145 match row {
146 Some(r) => LiveRunDiff {
147 improved: r.improved,
148 regressed: r.regressed,
149 unchanged: r.unchanged,
150 reclassified: r.reclassified,
151 },
152 None => LiveRunDiff::default(),
153 }
154}
155
156pub const DOCUMENT_MESSAGE_CAP: i64 = 1000;
163
164#[derive(Debug, Default, Clone, Copy)]
167pub struct MessageCounts {
168 pub info: i64,
170 pub warning: i64,
172 pub error: i64,
174 pub fatal: i64,
176 pub invalid: i64,
178}
179impl MessageCounts {
180 pub fn total(&self) -> i64 { self.info + self.warning + self.error + self.fatal + self.invalid }
182}
183
184pub fn task_messages(
197 connection: &mut PgConnection,
198 task: &Task,
199) -> (Vec<Box<dyn LogRecord>>, MessageCounts) {
200 let mut messages: Vec<Box<dyn LogRecord>> = Vec::new();
201 let mut counts = MessageCounts::default();
202 macro_rules! collect {
203 ($row:ty, $field:ident) => {
204 counts.$field = <$row>::belonging_to(task)
205 .count()
206 .get_result(connection)
207 .unwrap_or(0);
208 if let Ok(rows) = <$row>::belonging_to(task)
209 .limit(DOCUMENT_MESSAGE_CAP)
210 .load::<$row>(connection)
211 {
212 messages.extend(
213 rows
214 .into_iter()
215 .map(|row| Box::new(row) as Box<dyn LogRecord>),
216 );
217 }
218 };
219 }
220 collect!(LogInfo, info);
221 collect!(LogWarning, warning);
222 collect!(LogError, error);
223 collect!(LogFatal, fatal);
224 collect!(LogInvalid, invalid);
225 (messages, counts)
226}
227
228#[derive(Debug, Clone)]
230pub struct TaskReportOptions<'a> {
231 pub corpus: &'a Corpus,
233 pub service: &'a Service,
235 pub severity_opt: Option<String>,
237 pub category_opt: Option<String>,
239 pub what_opt: Option<String>,
241 pub all_messages: bool,
243 pub offset: i64,
245 pub page_size: i64,
247}
248
249pub(crate) fn progress_report(
250 connection: &mut PgConnection,
251 corpus: i32,
252 service: i32,
253) -> HashMap<String, f64> {
254 use crate::schema::tasks::{corpus_id, service_id, status};
255 use diesel::sql_types::BigInt;
256
257 let mut stats_hash: HashMap<String, f64> = HashMap::new();
258 for status_key in TaskStatus::keys() {
259 stats_hash.insert(status_key, 0.0);
260 }
261 stats_hash.insert("total".to_string(), 0.0);
262 let rows: Vec<(i32, i64)> = tasks::table
263 .select((status, sql::<BigInt>("count(*) AS status_count")))
264 .filter(service_id.eq(service))
265 .filter(corpus_id.eq(corpus))
266 .group_by(tasks::status)
267 .order(sql::<BigInt>("status_count").desc())
268 .load(connection)
269 .unwrap_or_default();
270 for &(raw_status, count) in &rows {
271 let task_status = TaskStatus::from_raw(raw_status);
272 let status_key = task_status.to_key();
273 {
274 let status_frequency = stats_hash.entry(status_key).or_insert(0.0);
275 *status_frequency += count as f64;
276 }
277 if task_status != TaskStatus::Invalid {
278 let total_frequency = stats_hash.entry("total".to_string()).or_insert(0.0);
280 *total_frequency += count as f64;
281 }
282 }
283 aux_stats_compute_percentages(&mut stats_hash, None);
284 stats_hash
285}
286
287pub(crate) fn task_report(
299 connection: &mut PgConnection,
300 options: TaskReportOptions,
301) -> Vec<HashMap<String, String>> {
302 if report_uses_rollup(
305 options.severity_opt.as_deref(),
306 options.category_opt.as_deref(),
307 options.what_opt.as_deref(),
308 options.all_messages,
309 ) {
310 if let Some(severity) = options.severity_opt.clone() {
314 match (options.category_opt.as_deref(), options.what_opt.as_deref()) {
315 (None, None) => {
317 let severity_tasks =
318 severity_task_count(connection, options.corpus, options.service, &severity);
319 return category_grain_from_rollup(
320 connection,
321 options.corpus,
322 options.service,
323 &severity,
324 severity_tasks,
325 options.page_size,
326 options.offset,
327 );
328 },
329 (Some(category), None) => {
331 return what_grain_from_rollup(
332 connection,
333 options.corpus,
334 options.service,
335 &severity,
336 category,
337 options.page_size,
338 options.offset,
339 );
340 },
341 _ => {},
342 }
343 }
344 }
345 task_report_live(connection, options)
346}
347
348pub fn report_uses_rollup(
360 severity_opt: Option<&str>,
361 category_opt: Option<&str>,
362 what_opt: Option<&str>,
363 all_messages: bool,
364) -> bool {
365 if all_messages {
373 return false;
374 }
375 let Some(severity) = severity_opt else {
376 return false;
377 };
378 if !matches!(severity, "warning" | "error" | "fatal" | "invalid" | "info") {
379 return false;
380 }
381 match (category_opt, what_opt) {
382 (None, None) => true,
383 (Some(category), None) => category != "no_messages",
384 _ => false,
385 }
386}
387
388fn total_valid_task_count(
391 connection: &mut PgConnection,
392 corpus: &Corpus,
393 service: &Service,
394) -> i64 {
395 use crate::schema::tasks::dsl::{corpus_id, service_id, status};
396 let total: i64 = tasks::table
397 .filter(service_id.eq(service.id))
398 .filter(corpus_id.eq(corpus.id))
399 .count()
400 .get_result(connection)
401 .unwrap_or(0);
402 let invalid: i64 = tasks::table
403 .filter(service_id.eq(service.id))
404 .filter(corpus_id.eq(corpus.id))
405 .filter(status.eq(TaskStatus::Invalid.raw()))
406 .count()
407 .get_result(connection)
408 .unwrap_or(0);
409 total - invalid
410}
411
412fn count_in_status(
414 connection: &mut PgConnection,
415 corpus: &Corpus,
416 service: &Service,
417 raw_status: i32,
418) -> i64 {
419 use crate::schema::tasks::dsl::{corpus_id, service_id, status};
420 tasks::table
421 .filter(service_id.eq(service.id))
422 .filter(corpus_id.eq(corpus.id))
423 .filter(status.eq(raw_status))
424 .count()
425 .get_result(connection)
426 .unwrap_or(0)
427}
428
429fn severity_task_count(
434 connection: &mut PgConnection,
435 corpus: &Corpus,
436 service: &Service,
437 severity: &str,
438) -> i64 {
439 let raw_status = match severity {
440 "warning" => TaskStatus::Warning.raw(),
441 "error" => TaskStatus::Error.raw(),
442 "fatal" => TaskStatus::Fatal.raw(),
443 "invalid" => TaskStatus::Invalid.raw(),
444 _ => return total_task_count(connection, corpus, service),
446 };
447 count_in_status(connection, corpus, service, raw_status)
448}
449
450fn total_task_count(connection: &mut PgConnection, corpus: &Corpus, service: &Service) -> i64 {
453 use crate::schema::tasks::dsl::{corpus_id, service_id};
454 tasks::table
455 .filter(service_id.eq(service.id))
456 .filter(corpus_id.eq(corpus.id))
457 .count()
458 .get_result(connection)
459 .unwrap_or(0)
460}
461
462fn category_grain_from_rollup(
465 connection: &mut PgConnection,
466 corpus: &Corpus,
467 service: &Service,
468 severity: &str,
469 severity_tasks: i64,
470 limit: i64,
471 offset: i64,
472) -> Vec<HashMap<String, String>> {
473 let category_rows =
474 rollup::category_rollup(connection, corpus.id, service.id, severity, limit, offset)
475 .unwrap_or_default();
476 let grand_total =
477 rollup::severity_total(connection, corpus.id, service.id, severity).unwrap_or_default();
478 let total_valid_count = total_valid_task_count(connection, corpus, service);
479 let logged_task_count = grand_total.as_ref().map_or(0, |g| g.task_count);
481 let logged_message_count = grand_total.as_ref().map_or(0, |g| g.message_count);
482 let silent_task_count = if logged_task_count >= severity_tasks {
483 None
484 } else {
485 Some(severity_tasks - logged_task_count)
486 };
487 let report_rows = rows_to_aggregates(category_rows, |row| row.category);
488 aux_task_rows_stats(
489 &report_rows,
490 total_valid_count,
491 severity_tasks,
492 logged_message_count,
493 silent_task_count,
494 )
495}
496
497fn what_grain_from_rollup(
500 connection: &mut PgConnection,
501 corpus: &Corpus,
502 service: &Service,
503 severity: &str,
504 category: &str,
505 limit: i64,
506 offset: i64,
507) -> Vec<HashMap<String, String>> {
508 let what_rows = rollup::what_rollup(
509 connection, corpus.id, service.id, severity, category, limit, offset,
510 )
511 .unwrap_or_default();
512 let category_total =
513 rollup::category_total(connection, corpus.id, service.id, severity, category)
514 .unwrap_or_default();
515 let total_valid_count = total_valid_task_count(connection, corpus, service);
516 let (category_tasks, category_messages) = category_total
517 .as_ref()
518 .map_or((0, 0), |c| (c.task_count, c.message_count));
519 let report_rows = rows_to_aggregates(what_rows, |row| row.what.unwrap_or_default());
520 aux_task_rows_stats(
521 &report_rows,
522 total_valid_count,
523 category_tasks,
524 category_messages,
525 None,
526 )
527}
528
529fn rows_to_aggregates(
532 rows: Vec<rollup::ReportSummaryRow>,
533 name_of: impl Fn(rollup::ReportSummaryRow) -> String,
534) -> Vec<AggregateReport> {
535 rows
536 .into_iter()
537 .map(|row| {
538 let task_count = row.task_count;
539 let message_count = row.message_count;
540 AggregateReport {
541 report_name: Some(name_of(row)),
542 task_count,
543 message_count,
544 }
545 })
546 .collect()
547}
548
549pub(crate) fn task_report_live(
553 connection: &mut PgConnection,
554 options: TaskReportOptions,
555) -> Vec<HashMap<String, String>> {
556 use crate::schema::tasks::dsl::{corpus_id, service_id, status};
557 use diesel::sql_types::{BigInt, Text};
558 let TaskReportOptions {
560 corpus,
561 service,
562 severity_opt,
563 category_opt,
564 what_opt,
565 mut all_messages,
566 offset,
567 page_size,
568 } = options;
569 let mut report = Vec::new();
571
572 if let Some(severity_name) = severity_opt {
573 let task_status = TaskStatus::from_key(&severity_name);
574 if task_status == Some(TaskStatus::NoProblem) {
577 let entry_rows: Vec<(String, i64)> = tasks::table
578 .select((tasks::entry, tasks::id))
579 .filter(service_id.eq(service.id))
580 .filter(corpus_id.eq(corpus.id))
581 .filter(status.eq(TaskStatus::NoProblem.raw()))
584 .order(tasks::entry.asc())
585 .offset(offset)
586 .limit(page_size)
587 .load(connection)
588 .unwrap_or_default();
589 for &(ref entry_fixedwidth, entry_taskid) in &entry_rows {
590 let mut entry_map = HashMap::new();
591 let entry_trimmed = entry_fixedwidth.trim_end().to_string();
592 let entry_name = TASK_REPORT_NAME_REGEX
593 .replace(&entry_trimmed, "$1")
594 .to_string();
595
596 entry_map.insert("entry".to_string(), entry_trimmed);
597 entry_map.insert("entry_name".to_string(), entry_name);
598 entry_map.insert("entry_taskid".to_string(), entry_taskid.to_string());
599 entry_map.insert("details".to_string(), "OK".to_string());
600 report.push(entry_map);
601 }
602 } else {
603 let total_count: i64 = tasks::table
611 .filter(service_id.eq(service.id))
612 .filter(corpus_id.eq(corpus.id))
613 .count()
614 .get_result(connection)
615 .unwrap_or(0);
616 let invalid_count: i64 = tasks::table
617 .filter(service_id.eq(service.id))
618 .filter(corpus_id.eq(corpus.id))
619 .filter(status.eq(TaskStatus::Invalid.raw()))
620 .count()
621 .get_result(connection)
622 .unwrap_or(0);
623 let total_valid_count = (total_count - invalid_count).max(0);
624
625 let log_table = match task_status {
626 Some(ref ts) => ts.to_table(),
627 None => {
628 all_messages = true;
629 "log_infos".to_string()
630 },
631 };
632
633 let task_status_raw = task_status.unwrap_or(TaskStatus::NoProblem).raw();
634 let (status_clause, bind_status) = if !all_messages {
635 (String::from("status=$3 "), task_status_raw)
636 } else {
637 (
638 String::from("status < $3 and status > ") + &TaskStatus::Invalid.raw().to_string(),
639 0,
640 ) };
642 match category_opt {
643 None => {
644 let category_report_string =
647 "SELECT category as report_name, count(*) as task_count, COALESCE(SUM(total_counts::integer),0) as message_count FROM (".to_string()+
648 "SELECT "+&log_table+".category, "+&log_table+".task_id, count(*) as total_counts FROM "+
649 "tasks LEFT OUTER JOIN "+&log_table+" ON (tasks.id="+&log_table+".task_id) WHERE service_id=$1 and corpus_id=$2 and "+ &status_clause +
650 " GROUP BY "+&log_table+".category, "+&log_table+".task_id) as tmp "+
651 "GROUP BY category ORDER BY task_count desc";
652 let category_report_query = sql_query(category_report_string);
653 let category_report_rows: Vec<AggregateReport> = category_report_query
654 .bind::<BigInt, i64>(i64::from(service.id))
655 .bind::<BigInt, i64>(i64::from(corpus.id))
656 .bind::<BigInt, i64>(i64::from(bind_status))
657 .load(connection)
658 .unwrap_or_default();
659
660 let severity_tasks: i64 = if !all_messages {
662 tasks::table
663 .filter(service_id.eq(service.id))
664 .filter(corpus_id.eq(corpus.id))
665 .filter(status.eq(task_status_raw))
666 .count()
667 .get_result(connection)
668 .unwrap_or(-1)
669 } else {
670 tasks::table
671 .filter(service_id.eq(service.id))
672 .filter(corpus_id.eq(corpus.id))
673 .count()
674 .get_result(connection)
675 .unwrap_or(-1)
676 };
677 let status_report_query_string =
678 "SELECT NULL as report_name, count(*) as task_count, COALESCE(SUM(inner_message_count::integer),0) as message_count FROM ( ".to_string()+
679 "SELECT tasks.id, count(*) as inner_message_count FROM "+
680 "tasks, "+&log_table+" where tasks.id="+&log_table+".task_id and "+
681 "service_id=$1 and corpus_id=$2 and "+&status_clause+" group by tasks.id) as tmp";
682 let status_report_query = sql_query(status_report_query_string)
683 .bind::<BigInt, i64>(i64::from(service.id))
684 .bind::<BigInt, i64>(i64::from(corpus.id))
685 .bind::<BigInt, i64>(i64::from(bind_status));
686 let status_report_rows: AggregateReport = status_report_query
687 .get_result(connection)
688 .unwrap_or_default();
689
690 let logged_task_count: i64 = status_report_rows.task_count;
691 let logged_message_count: i64 = status_report_rows.message_count;
692 let silent_task_count = if logged_task_count >= severity_tasks {
693 None
694 } else {
695 Some(severity_tasks - logged_task_count)
696 };
697 report = aux_task_rows_stats(
698 &category_report_rows,
699 total_valid_count,
700 severity_tasks,
701 logged_message_count,
702 silent_task_count,
703 )
704 },
705 Some(category_name) => {
706 if category_name == "no_messages" {
707 let no_messages_query_string = "SELECT * FROM tasks t WHERE ".to_string()
708 + "service_id=$1 and corpus_id=$2 and "
709 + &status_clause
710 + " and "
711 + "NOT EXISTS (SELECT null FROM "
712 + &log_table
713 + " where "
714 + &log_table
715 + ".task_id=t.id) limit 100";
716 let no_messages_query = sql_query(no_messages_query_string)
717 .bind::<BigInt, i64>(i64::from(service.id))
718 .bind::<BigInt, i64>(i64::from(corpus.id))
719 .bind::<BigInt, i64>(i64::from(bind_status))
720 .bind::<BigInt, i64>(i64::from(task_status_raw));
721 let no_message_tasks: Vec<Task> = no_messages_query
722 .get_results(connection)
723 .unwrap_or_default();
724
725 for task in &no_message_tasks {
726 let mut entry_map = HashMap::new();
727 let entry = task.entry.trim_end().to_string();
728 let entry_name = TASK_REPORT_NAME_REGEX.replace(&entry, "$1").to_string();
729
730 entry_map.insert("entry".to_string(), entry);
731 entry_map.insert("entry_name".to_string(), entry_name);
732 entry_map.insert("entry_taskid".to_string(), task.id.to_string());
733 entry_map.insert("details".to_string(), "OK".to_string());
734 report.push(entry_map);
735 }
736 } else {
737 match what_opt {
738 None => {
739 let what_report_query_string =
740 "SELECT what as report_name, count(*) as task_count, COALESCE(SUM(total_counts::integer),0) as message_count FROM ( ".to_string() +
741 "SELECT "+&log_table+".what, "+&log_table+".task_id, count(*) as total_counts FROM "+
742 "tasks LEFT OUTER JOIN "+&log_table+" ON (tasks.id="+&log_table+".task_id) "+
743 "WHERE service_id=$1 and corpus_id=$2 and "+&status_clause+" and category=$4 "+
744 "GROUP BY "+&log_table+".what, "+&log_table+".task_id) as tmp GROUP BY what ORDER BY task_count desc";
745 let what_report_query = sql_query(what_report_query_string)
746 .bind::<BigInt, i64>(i64::from(service.id))
747 .bind::<BigInt, i64>(i64::from(corpus.id))
748 .bind::<BigInt, i64>(i64::from(bind_status))
749 .bind::<Text, _>(category_name.clone());
750 let what_report: Vec<AggregateReport> = what_report_query
751 .get_results(connection)
752 .unwrap_or_default();
753 let this_category_report_query_string = "SELECT NULL as report_name, count(*) as task_count, COALESCE(SUM(inner_message_count::integer),0) as message_count FROM".to_string() +
755 " (SELECT tasks.id, count(*) as inner_message_count "+
756 "FROM tasks, "+&log_table+" WHERE tasks.id="+&log_table+".task_id and "+
757 "service_id=$1 and corpus_id=$2 and "+&status_clause+" and category=$4 group by tasks.id) as tmp";
758 let this_category_report_query = sql_query(this_category_report_query_string)
759 .bind::<BigInt, i64>(i64::from(service.id))
760 .bind::<BigInt, i64>(i64::from(corpus.id))
761 .bind::<BigInt, i64>(i64::from(bind_status))
762 .bind::<Text, _>(category_name);
763 let this_category_report: AggregateReport = this_category_report_query
764 .get_result(connection)
765 .unwrap_or_default();
766
767 report = aux_task_rows_stats(
768 &what_report,
769 total_valid_count,
770 this_category_report.task_count,
771 this_category_report.message_count,
772 None,
773 )
774 },
775 Some(what_name) => {
776 let details_report_query_string = "SELECT tasks.id, tasks.entry, ".to_string()
777 + &log_table
778 + ".details from tasks, "
779 + &log_table
780 + " WHERE tasks.id="
781 + &log_table
782 + ".task_id and service_id=$1 and corpus_id=$2 and "
783 + &status_clause
784 + " and category=$4 and what=$5 ORDER BY tasks.entry ASC offset $6 limit $7";
785
786 let details_report_query = sql_query(details_report_query_string)
787 .bind::<BigInt, i64>(i64::from(service.id))
788 .bind::<BigInt, i64>(i64::from(corpus.id))
789 .bind::<BigInt, i64>(i64::from(bind_status))
790 .bind::<Text, _>(category_name)
791 .bind::<Text, _>(what_name)
792 .bind::<BigInt, i64>(offset)
793 .bind::<BigInt, i64>(page_size);
794 let details_report: Vec<TaskDetailReport> = details_report_query
795 .get_results(connection)
796 .unwrap_or_default();
797 for details_row in details_report {
798 let mut entry_map = HashMap::new();
799 let entry = details_row.entry.trim_end().to_string();
800 let entry_name = TASK_REPORT_NAME_REGEX.replace(&entry, "$1").to_string();
801 entry_map.insert("entry".to_string(), entry);
803 entry_map.insert("entry_name".to_string(), entry_name);
804 entry_map.insert("entry_taskid".to_string(), details_row.id.to_string());
805 entry_map.insert("details".to_string(), details_row.details);
806 report.push(entry_map);
807 }
808 },
809 }
810 }
811 },
812 }
813 }
814 }
815 report
816}
817
818fn aux_stats_compute_percentages(stats_hash: &mut HashMap<String, f64>, total_given: Option<f64>) {
819 let total: f64 = 1.0_f64.max(match total_given {
821 None => stats_hash.get("total").copied().unwrap_or(0.0),
824 Some(total_num) => total_num,
825 });
826 let stats_keys = stats_hash.keys().cloned().collect::<Vec<_>>();
827 for stats_key in stats_keys {
828 {
829 let key_percent_value: f64 = 100.0 * (*stats_hash.get_mut(&stats_key).unwrap() / total);
830 let key_percent_rounded: f64 = (key_percent_value * 100.0).round() / 100.0;
831 let key_percent_name = stats_key + "_percent";
832 stats_hash.insert(key_percent_name, key_percent_rounded);
833 }
834 }
835}
836
837fn aux_task_rows_stats(
838 report_rows: &[AggregateReport],
839 mut total_valid_tasks: i64,
840 these_tasks: i64,
841 mut these_messages: i64,
842 these_silent: Option<i64>,
843) -> Vec<HashMap<String, String>> {
844 let mut report = Vec::new();
845 if total_valid_tasks <= 0 {
847 total_valid_tasks = 1;
848 }
849 if these_messages <= 0 {
850 these_messages = 1;
851 }
852
853 for row in report_rows {
854 let stat_type: String = match row.report_name {
855 Some(ref name) => name.trim_end().to_string(),
856 None => String::new(),
857 };
858 if stat_type.is_empty() {
859 continue;
860 } let stat_tasks: i64 = row.task_count;
862 let stat_messages: i64 = row.message_count;
863 let mut stats_hash: HashMap<String, String> = HashMap::new();
864 stats_hash.insert("name".to_string(), stat_type);
865 stats_hash.insert("tasks".to_string(), stat_tasks.to_string());
866 stats_hash.insert("messages".to_string(), stat_messages.to_string());
867
868 let tasks_percent_value: f64 = 100.0 * (stat_tasks as f64 / total_valid_tasks as f64);
869 let tasks_percent_rounded: f64 = (tasks_percent_value * 100.0).round() / 100.0;
870 stats_hash.insert(
871 "tasks_percent".to_string(),
872 tasks_percent_rounded.to_string(),
873 );
874 let messages_percent_value: f64 = 100.0 * (stat_messages as f64 / these_messages as f64);
875 let messages_percent_rounded: f64 = (messages_percent_value * 100.0).round() / 100.0;
876 stats_hash.insert(
877 "messages_percent".to_string(),
878 messages_percent_rounded.to_string(),
879 );
880
881 report.push(stats_hash);
882 }
883
884 let these_tasks_percent_value: f64 = 100.0 * (these_tasks as f64 / total_valid_tasks as f64);
885 let these_tasks_percent_rounded: f64 = (these_tasks_percent_value * 100.0).round() / 100.0;
886 let mut total_hash = HashMap::new();
888 total_hash.insert("name".to_string(), "total".to_string());
889 match these_silent {
890 None => {},
891 Some(silent_count) => {
892 let mut no_messages_hash: HashMap<String, String> = HashMap::new();
893 no_messages_hash.insert("name".to_string(), "no_messages".to_string());
894 no_messages_hash.insert("tasks".to_string(), silent_count.to_string());
895 let silent_tasks_percent_value: f64 =
896 100.0 * (silent_count as f64 / total_valid_tasks as f64);
897 let silent_tasks_percent_rounded: f64 = (silent_tasks_percent_value * 100.0).round() / 100.0;
898 no_messages_hash.insert(
899 "tasks_percent".to_string(),
900 silent_tasks_percent_rounded.to_string(),
901 );
902 no_messages_hash.insert("messages".to_string(), "0".to_string());
903 no_messages_hash.insert("messages_percent".to_string(), "0".to_string());
904 report.push(no_messages_hash);
905 },
906 };
907 total_hash.insert("tasks".to_string(), these_tasks.to_string());
908 total_hash.insert(
909 "tasks_percent".to_string(),
910 these_tasks_percent_rounded.to_string(),
911 );
912 total_hash.insert("messages".to_string(), these_messages.to_string());
913 total_hash.insert("messages_percent".to_string(), "100".to_string());
914 report.push(total_hash);
915 report
916}
917
918pub(crate) fn list_tasks(
919 connection: &mut PgConnection,
920 corpus: &Corpus,
921 service: &Service,
922 task_status: &TaskStatus,
923) -> Vec<Task> {
924 use crate::schema::tasks::dsl::{corpus_id, service_id, status};
925 tasks::table
926 .filter(service_id.eq(service.id))
927 .filter(corpus_id.eq(corpus.id))
928 .filter(status.eq(task_status.raw()))
929 .load(connection)
930 .unwrap_or_default()
931}
932
933pub(crate) fn list_entries(
934 connection: &mut PgConnection,
935 corpus: &Corpus,
936 service: &Service,
937 task_status: &TaskStatus,
938) -> Vec<String> {
939 list_tasks(connection, corpus, service, task_status)
940 .into_iter()
941 .map(|task| {
942 if service.name == "import" {
943 task.entry.trim_end().to_string()
944 } else {
945 crate::helpers::result_archive_path(&task.entry, &service.name, corpus.sandbox_id())
946 .map(|p| p.to_string_lossy().into_owned())
947 .unwrap_or_default()
948 }
949 })
950 .collect()
951}
952
953pub fn list_task_diffs(
955 connection: &mut PgConnection,
956 corpus: &Corpus,
957 service: &Service,
958 filters: DiffStatusFilter,
959) -> Vec<TaskRunMetadata> {
960 match HistoricalTask::report_for(corpus, service, Some(filters), connection) {
961 Ok((_dates, report)) => report
962 .into_iter()
963 .map(|row| {
964 let previous_status = TaskStatus::from_raw(row.0.status).to_key();
965 let current_status = TaskStatus::from_raw(row.1.status).to_key();
966 let previous_highlight = severity_highlight(&previous_status).to_owned();
967 let current_highlight = severity_highlight(¤t_status).to_owned();
968 TaskRunMetadata {
969 task_id: row.0.task_id.to_string(),
970 entry: TASK_REPORT_NAME_REGEX
971 .replace(&row.0.entry, "$1")
972 .to_string(),
973 previous_status,
974 current_status,
975 previous_highlight,
976 current_highlight,
977 previous_saved_at: row.0.saved_at.format("%Y-%m-%d").to_string(),
978 current_saved_at: row.1.saved_at.format("%Y-%m-%d").to_string(),
979 }
980 })
981 .collect(),
982 _ => Vec::new(),
983 }
984}
985
986pub fn summary_task_diffs(
988 connection: &mut PgConnection,
989 corpus: &Corpus,
990 service: &Service,
991 previous_date: Option<NaiveDateTime>,
992 current_date: Option<NaiveDateTime>,
993) -> (Vec<String>, Vec<DiffStatusRow>) {
994 let (dates, matrix) =
1000 HistoricalTask::status_change_matrix(corpus, service, previous_date, current_date, connection)
1001 .unwrap_or_default();
1002 let mut summary: HashMap<i32, HashMap<i32, i64>> = HashMap::new();
1003 for (prev_status, current_status, count) in matrix {
1004 summary
1005 .entry(prev_status)
1006 .or_default()
1007 .insert(current_status, count);
1008 }
1009 use TaskStatus::*;
1012 let mut tabular = Vec::new();
1013 for prev in [NoProblem, Warning, Error, Fatal].iter() {
1014 for current in [NoProblem, Warning, Error, Fatal].iter() {
1015 let previous_status = prev.to_key();
1016 let current_status = current.to_key();
1017 let previous_highlight = severity_highlight(&previous_status).to_owned();
1018 let current_highlight = severity_highlight(¤t_status).to_owned();
1019 let task_count = summary
1020 .get(&prev.raw())
1021 .and_then(|row| row.get(¤t.raw()))
1022 .copied()
1023 .unwrap_or(0) as usize;
1024 tabular.push(DiffStatusRow {
1025 previous_status,
1026 current_status,
1027 previous_highlight,
1028 current_highlight,
1029 task_count,
1030 });
1031 }
1032 }
1033 (dates, tabular)
1034}
1035
1036#[cfg(test)]
1037mod rollup_equivalence_tests {
1038 use super::{TaskReportOptions, rollup, task_report, task_report_live};
1042 use crate::backend;
1043 use crate::helpers::TaskStatus;
1044 use crate::models::{Corpus, NewCorpus, NewService, Service};
1045 use crate::schema::{corpora, log_errors, log_infos, log_warnings, services, tasks};
1046 use diesel::prelude::*;
1047 use std::collections::HashMap;
1048
1049 const CORPUS_NAME: &str = "rollup-equivalence corpus";
1050 const SERVICE_NAME: &str = "rollup_equiv_svc";
1051
1052 fn add_task(conn: &mut PgConnection, entry: &str, service: i32, corpus: i32, status: i32) -> i64 {
1053 diesel::insert_into(tasks::table)
1054 .values((
1055 tasks::entry.eq(entry),
1056 tasks::service_id.eq(service),
1057 tasks::corpus_id.eq(corpus),
1058 tasks::status.eq(status),
1059 ))
1060 .returning(tasks::id)
1061 .get_result(conn)
1062 .expect("insert task")
1063 }
1064
1065 fn add_warning(conn: &mut PgConnection, task_id: i64, category: &str, what: &str) {
1066 diesel::insert_into(log_warnings::table)
1067 .values((
1068 log_warnings::task_id.eq(task_id),
1069 log_warnings::category.eq(category),
1070 log_warnings::what.eq(what),
1071 log_warnings::details.eq(""),
1072 ))
1073 .execute(conn)
1074 .expect("insert log_warning");
1075 }
1076
1077 fn add_error(conn: &mut PgConnection, task_id: i64, category: &str, what: &str) {
1078 diesel::insert_into(log_errors::table)
1079 .values((
1080 log_errors::task_id.eq(task_id),
1081 log_errors::category.eq(category),
1082 log_errors::what.eq(what),
1083 log_errors::details.eq(""),
1084 ))
1085 .execute(conn)
1086 .expect("insert log_error");
1087 }
1088
1089 fn add_info(conn: &mut PgConnection, task_id: i64, category: &str, what: &str) {
1090 diesel::insert_into(log_infos::table)
1091 .values((
1092 log_infos::task_id.eq(task_id),
1093 log_infos::category.eq(category),
1094 log_infos::what.eq(what),
1095 log_infos::details.eq(""),
1096 ))
1097 .execute(conn)
1098 .expect("insert log_info");
1099 }
1100
1101 fn by_name(rows: Vec<HashMap<String, String>>) -> HashMap<String, HashMap<String, String>> {
1103 rows
1104 .into_iter()
1105 .map(|row| (row.get("name").cloned().unwrap_or_default(), row))
1106 .collect()
1107 }
1108
1109 fn options_paged<'a>(
1110 corpus: &'a Corpus,
1111 service: &'a Service,
1112 severity: &str,
1113 category: Option<&str>,
1114 page_size: i64,
1115 offset: i64,
1116 ) -> TaskReportOptions<'a> {
1117 TaskReportOptions {
1118 corpus,
1119 service,
1120 severity_opt: Some(severity.to_string()),
1121 category_opt: category.map(str::to_string),
1122 what_opt: None,
1123 all_messages: false,
1124 offset,
1125 page_size,
1126 }
1127 }
1128
1129 fn options<'a>(
1130 corpus: &'a Corpus,
1131 service: &'a Service,
1132 severity: &str,
1133 category: Option<&str>,
1134 ) -> TaskReportOptions<'a> {
1135 options_paged(corpus, service, severity, category, 100, 0)
1136 }
1137
1138 #[test]
1139 fn live_report_grains_are_correct() {
1140 let mut backend = backend::testdb();
1141
1142 if let Ok(existing) = Corpus::find_by_name(CORPUS_NAME, &mut backend.connection) {
1144 let ids: Vec<i64> = tasks::table
1145 .filter(tasks::corpus_id.eq(existing.id))
1146 .select(tasks::id)
1147 .load(&mut backend.connection)
1148 .unwrap_or_default();
1149 diesel::delete(log_warnings::table.filter(log_warnings::task_id.eq_any(&ids)))
1150 .execute(&mut backend.connection)
1151 .ok();
1152 diesel::delete(log_errors::table.filter(log_errors::task_id.eq_any(&ids)))
1153 .execute(&mut backend.connection)
1154 .ok();
1155 diesel::delete(tasks::table.filter(tasks::corpus_id.eq(existing.id)))
1156 .execute(&mut backend.connection)
1157 .ok();
1158 diesel::delete(corpora::table.filter(corpora::id.eq(existing.id)))
1159 .execute(&mut backend.connection)
1160 .ok();
1161 }
1162 diesel::delete(services::table.filter(services::name.eq(SERVICE_NAME)))
1163 .execute(&mut backend.connection)
1164 .ok();
1165
1166 backend
1168 .add(&NewCorpus {
1169 name: CORPUS_NAME.to_string(),
1170 path: "/tmp/rollup-equivalence".to_string(),
1171 complex: true,
1172 description: String::new(),
1173 })
1174 .expect("add corpus");
1175 let corpus = Corpus::find_by_name(CORPUS_NAME, &mut backend.connection).expect("corpus");
1176 backend
1177 .add(&NewService {
1178 name: SERVICE_NAME.to_string(),
1179 version: 0.1,
1180 inputformat: "tex".to_string(),
1181 outputformat: "html".to_string(),
1182 inputconverter: Some("import".to_string()),
1183 complex: true,
1184 description: String::from("rollup equivalence service"),
1185 })
1186 .expect("add service");
1187 let service = Service::find_by_name(SERVICE_NAME, &mut backend.connection).expect("service");
1188
1189 let warning = TaskStatus::Warning.raw();
1190 let error = TaskStatus::Error.raw();
1191 let conn = &mut backend.connection;
1192
1193 let a = add_task(conn, "/eq/a", service.id, corpus.id, warning);
1196 let b = add_task(conn, "/eq/b", service.id, corpus.id, warning);
1197 let c = add_task(conn, "/eq/c", service.id, corpus.id, warning);
1198 let _silent = add_task(conn, "/eq/d", service.id, corpus.id, warning);
1199 add_warning(conn, a, "math", "undefined_x");
1200 add_warning(conn, a, "math", "undefined_y");
1201 add_warning(conn, b, "math", "undefined_x");
1202 add_warning(conn, c, "font", "missing");
1203
1204 let e = add_task(conn, "/eq/e", service.id, corpus.id, error);
1206 let f = add_task(conn, "/eq/f", service.id, corpus.id, error);
1207 add_error(conn, e, "tex", "err1");
1208 add_error(conn, f, "tex", "err1");
1209 add_error(conn, f, "tex", "err2");
1210
1211 let g = add_task(
1215 conn,
1216 "/eq/g",
1217 service.id,
1218 corpus.id,
1219 TaskStatus::NoProblem.raw(),
1220 );
1221 add_info(conn, a, "load", "package");
1222 add_info(conn, e, "load", "package");
1223 add_info(conn, g, "load", "class");
1224
1225 let warning_cats = rollup::category_rollup(conn, corpus.id, service.id, "warning", 100, 0)
1228 .expect("category_rollup");
1229 assert_eq!(warning_cats.len(), 2, "warning categories: math, font");
1230 assert_eq!(warning_cats[0].category, "math", "busiest category first");
1231 assert_eq!(warning_cats[0].task_count, 2, "math distinct tasks A,B");
1232 assert_eq!(
1233 warning_cats[0].message_count, 3,
1234 "math messages: 2 (A) + 1 (B)"
1235 );
1236 assert_eq!(warning_cats[1].category, "font");
1237 assert_eq!(warning_cats[1].task_count, 1);
1238 let math_whats = rollup::what_rollup(conn, corpus.id, service.id, "warning", "math", 100, 0)
1239 .expect("what_rollup");
1240 assert_eq!(math_whats.len(), 2, "math whats: undefined_x, undefined_y");
1241 let warning_total = rollup::severity_total(conn, corpus.id, service.id, "warning")
1242 .expect("severity_total query")
1243 .expect("warning has messages");
1244 assert_eq!(
1245 warning_total.task_count, 3,
1246 "distinct warning-logged tasks A,B,C (silent D has no logs)"
1247 );
1248 assert_eq!(warning_total.message_count, 4, "total warning messages");
1249 assert!(
1250 rollup::severity_total(conn, corpus.id, service.id, "fatal")
1251 .expect("severity_total query")
1252 .is_none(),
1253 "a severity with no messages yields None"
1254 );
1255
1256 let cases = [
1258 ("warning", None),
1259 ("warning", Some("math")),
1260 ("warning", Some("font")),
1261 ("error", None),
1262 ("error", Some("tex")),
1263 ("info", None),
1266 ("info", Some("load")),
1267 ];
1268 for (severity, category) in cases {
1269 let fast = by_name(task_report(
1270 conn,
1271 options(&corpus, &service, severity, category),
1272 ));
1273 let live = by_name(task_report_live(
1274 conn,
1275 options(&corpus, &service, severity, category),
1276 ));
1277 assert_eq!(
1278 fast, live,
1279 "task_report (live) vs task_report_live mismatch for severity={severity} category={category:?}"
1280 );
1281 assert!(
1282 !fast.is_empty(),
1283 "live path produced an empty report for severity={severity} category={category:?}"
1284 );
1285 }
1286
1287 let warning_cat = by_name(task_report(
1290 conn,
1291 options(&corpus, &service, "warning", None),
1292 ));
1293 assert_eq!(
1294 warning_cat["math"]["tasks"], "2",
1295 "math: distinct tasks A,B"
1296 );
1297 assert_eq!(warning_cat["math"]["messages"], "3", "math: 2 (A) + 1 (B)");
1298 assert_eq!(warning_cat["font"]["tasks"], "1");
1299 assert_eq!(
1300 warning_cat["no_messages"]["tasks"], "1",
1301 "one silent task D"
1302 );
1303 assert_eq!(warning_cat["total"]["tasks"], "4", "A,B,C,D");
1304
1305 let fatal_fast = by_name(task_report(conn, options(&corpus, &service, "fatal", None)));
1308 assert!(
1309 fatal_fast.contains_key("total"),
1310 "an empty-severity report must still carry a total row"
1311 );
1312 assert_eq!(
1313 fatal_fast["total"]["tasks"], "0",
1314 "no fatal tasks -> 0 total"
1315 );
1316 let fatal_live = by_name(task_report_live(
1317 conn,
1318 options(&corpus, &service, "fatal", None),
1319 ));
1320 assert_eq!(
1321 fatal_fast, fatal_live,
1322 "empty severity: rollup vs live mismatch"
1323 );
1324
1325 let page0 = by_name(task_report(
1328 conn,
1329 options_paged(&corpus, &service, "warning", None, 1, 0),
1330 ));
1331 let page1 = by_name(task_report(
1332 conn,
1333 options_paged(&corpus, &service, "warning", None, 1, 1),
1334 ));
1335 assert!(
1336 page0.contains_key("math") && !page0.contains_key("font"),
1337 "page 0 (busiest first) = math only"
1338 );
1339 assert!(
1340 page1.contains_key("font") && !page1.contains_key("math"),
1341 "page 1 = font only"
1342 );
1343 assert_eq!(
1345 page0["total"]["tasks"], "4",
1346 "total is whole-severity on page 0"
1347 );
1348 assert_eq!(
1349 page1["total"]["tasks"], "4",
1350 "total is whole-severity on page 1"
1351 );
1352 }
1353
1354 #[test]
1355 fn report_uses_rollup_routes_aggregate_grains_to_cache() {
1356 use super::report_uses_rollup;
1357 for sev in ["warning", "error", "fatal", "invalid", "info"] {
1362 assert!(
1363 report_uses_rollup(Some(sev), None, None, false),
1364 "{sev} category report should hit the cache"
1365 );
1366 assert!(
1367 report_uses_rollup(Some(sev), Some("cat"), None, false),
1368 "{sev} what drill-down should hit the cache"
1369 );
1370 assert!(!report_uses_rollup(
1372 Some(sev),
1373 Some("cat"),
1374 Some("w"),
1375 false
1376 ));
1377 }
1378 assert!(!report_uses_rollup(
1380 Some("warning"),
1381 Some("no_messages"),
1382 None,
1383 false
1384 ));
1385 assert!(!report_uses_rollup(Some("no_problem"), None, None, false));
1387 assert!(!report_uses_rollup(Some("todo"), None, None, false));
1388 assert!(!report_uses_rollup(Some("warning"), None, None, true));
1390 assert!(!report_uses_rollup(None, None, None, false));
1391 }
1392}
1393
1394#[cfg(test)]
1395mod live_run_diff_baseline_tests {
1396 use super::compute_live_run_diff;
1402 use crate::backend;
1403 use crate::helpers::TaskStatus;
1404 use crate::models::{Corpus, NewCorpus, NewService, Service};
1405 use crate::schema::{corpora, historical_runs, historical_tasks, services, tasks};
1406 use chrono::{NaiveDate, NaiveDateTime};
1407 use diesel::prelude::*;
1408
1409 const CORPUS_NAME: &str = "live-diff-baseline corpus";
1410 const SERVICE_NAME: &str = "live_diff_baseline_svc";
1411
1412 fn ts(year: i32, month: u32, day: u32) -> NaiveDateTime {
1413 NaiveDate::from_ymd_opt(year, month, day)
1414 .unwrap()
1415 .and_hms_opt(0, 0, 0)
1416 .unwrap()
1417 }
1418
1419 fn add_task(conn: &mut PgConnection, entry: &str, service: i32, corpus: i32, status: i32) -> i64 {
1420 diesel::insert_into(tasks::table)
1421 .values((
1422 tasks::entry.eq(entry),
1423 tasks::service_id.eq(service),
1424 tasks::corpus_id.eq(corpus),
1425 tasks::status.eq(status),
1426 ))
1427 .returning(tasks::id)
1428 .get_result(conn)
1429 .expect("insert task")
1430 }
1431
1432 fn add_snapshot(conn: &mut PgConnection, task_id: i64, status: i32, saved_at: NaiveDateTime) {
1433 diesel::insert_into(historical_tasks::table)
1434 .values((
1435 historical_tasks::task_id.eq(task_id),
1436 historical_tasks::status.eq(status),
1437 historical_tasks::saved_at.eq(saved_at),
1438 ))
1439 .execute(conn)
1440 .expect("insert historical snapshot");
1441 }
1442
1443 #[test]
1444 fn baseline_excludes_the_current_runs_own_self_snapshot() {
1445 let mut backend = backend::testdb();
1446
1447 if let Ok(existing) = Corpus::find_by_name(CORPUS_NAME, &mut backend.connection) {
1449 diesel::delete(historical_runs::table.filter(historical_runs::corpus_id.eq(existing.id)))
1451 .execute(&mut backend.connection)
1452 .ok();
1453 diesel::delete(tasks::table.filter(tasks::corpus_id.eq(existing.id)))
1454 .execute(&mut backend.connection)
1455 .ok();
1456 diesel::delete(corpora::table.filter(corpora::id.eq(existing.id)))
1457 .execute(&mut backend.connection)
1458 .ok();
1459 }
1460 diesel::delete(services::table.filter(services::name.eq(SERVICE_NAME)))
1461 .execute(&mut backend.connection)
1462 .ok();
1463
1464 backend
1466 .add(&NewCorpus {
1467 name: CORPUS_NAME.to_string(),
1468 path: "/tmp/live-diff-baseline".to_string(),
1469 complex: true,
1470 description: String::new(),
1471 })
1472 .expect("add corpus");
1473 let corpus = Corpus::find_by_name(CORPUS_NAME, &mut backend.connection).expect("corpus");
1474 backend
1475 .add(&NewService {
1476 name: SERVICE_NAME.to_string(),
1477 version: 0.1,
1478 inputformat: "tex".to_string(),
1479 outputformat: "html".to_string(),
1480 inputconverter: Some("import".to_string()),
1481 complex: true,
1482 description: String::from("live-diff baseline service"),
1483 })
1484 .expect("add service");
1485 let service = Service::find_by_name(SERVICE_NAME, &mut backend.connection).expect("service");
1486
1487 let no_problem = TaskStatus::NoProblem.raw(); let warning = TaskStatus::Warning.raw(); let error = TaskStatus::Error.raw(); let conn = &mut backend.connection;
1491
1492 let t1 = add_task(conn, "/d/1", service.id, corpus.id, no_problem);
1494 let t2 = add_task(conn, "/d/2", service.id, corpus.id, no_problem);
1495 let t3 = add_task(conn, "/d/3", service.id, corpus.id, error);
1496
1497 let t_prior = ts(2025, 1, 1); let t_mid = ts(2025, 6, 1); let t_self = ts(2025, 12, 1); diesel::insert_into(historical_runs::table)
1502 .values((
1503 historical_runs::corpus_id.eq(corpus.id),
1504 historical_runs::service_id.eq(service.id),
1505 historical_runs::owner.eq("tester"),
1506 historical_runs::start_time.eq(t_mid),
1507 historical_runs::end_time.eq(t_self),
1508 ))
1509 .execute(conn)
1510 .expect("insert historical_run");
1511
1512 add_snapshot(conn, t1, warning, t_prior);
1514 add_snapshot(conn, t2, no_problem, t_prior);
1515 add_snapshot(conn, t3, error, t_prior);
1516
1517 add_snapshot(conn, t1, no_problem, t_self);
1520 add_snapshot(conn, t2, no_problem, t_self);
1521 add_snapshot(conn, t3, error, t_self);
1522
1523 let diff = compute_live_run_diff(conn, corpus.id, service.id);
1524
1525 assert_eq!(
1530 diff.improved, 1,
1531 "t1's Warning→NoProblem must register as an improvement vs the PRIOR run's snapshot"
1532 );
1533 assert_eq!(diff.regressed, 0, "no task regressed");
1534 assert_eq!(
1535 diff.unchanged, 2,
1536 "t2 and t3 are unchanged; the count must NOT be 3 (which is the self-comparison bug)"
1537 );
1538 assert_eq!(diff.reclassified, 0, "no Invalid transitions");
1539 assert_eq!(
1540 diff.compared(),
1541 3,
1542 "all three completed tasks had a baseline"
1543 );
1544
1545 diesel::delete(historical_runs::table.filter(historical_runs::corpus_id.eq(corpus.id)))
1547 .execute(conn)
1548 .ok();
1549 diesel::delete(tasks::table.filter(tasks::corpus_id.eq(corpus.id)))
1550 .execute(conn)
1551 .ok();
1552 diesel::delete(corpora::table.filter(corpora::id.eq(corpus.id)))
1553 .execute(conn)
1554 .ok();
1555 diesel::delete(services::table.filter(services::name.eq(SERVICE_NAME)))
1556 .execute(conn)
1557 .ok();
1558 }
1559}