[current results ui] Add summary counts of failing and flaky tests

Change-Id: I9ed901863cb3b4125445eda93fd64dec2cc70189
Reviewed-on: https://dart-review.googlesource.com/c/dart_ci/+/164820
Reviewed-by: Alexander Thomas <athom@google.com>
diff --git a/current_results_ui/lib/main.dart b/current_results_ui/lib/main.dart
index 25058d1..4306d9b 100644
--- a/current_results_ui/lib/main.dart
+++ b/current_results_ui/lib/main.dart
@@ -84,6 +84,8 @@
           ),
         ),
         persistentFooterButtons: [
+          const TestSummary(),
+          const ResultsSummary(),
           const ApiPortalLink(),
           const JsonLink(),
           const TextPopup(),
diff --git a/current_results_ui/lib/query.dart b/current_results_ui/lib/query.dart
index ee66120..9acc661 100644
--- a/current_results_ui/lib/query.dart
+++ b/current_results_ui/lib/query.dart
@@ -22,8 +22,10 @@
   final Filter filter;
   StreamSubscription<GetResultsResponse> fetcher;
   List<String> names = [];
-  Map<String, Map<String, int>> counts = {};
+  Map<String, Counts> counts = {};
   Map<String, Map<ChangeInResult, List<Result>>> grouped = {};
+  TestCounts testCounts = TestCounts();
+  Counts resultCounts = Counts();
   int fetchedResultsCount = 0;
   bool get noQuery => filter.terms.isEmpty;
 
@@ -39,11 +41,14 @@
 
   void fetchCurrentResults() async {
     fetcher?.cancel();
+    fetcher = null;
     names = [];
     counts = {};
     grouped = {};
+    testCounts = TestCounts();
+    resultCounts = Counts();
     fetchedResultsCount = 0;
-
+    if (noQuery) return;
     fetcher = fetchResults(filter).listen(onResults, onDone: onDone);
   }
 
@@ -60,10 +65,9 @@
           .putIfAbsent(result.name, () => <ChangeInResult, List<Result>>{})
           .putIfAbsent(change, () => <Result>[])
           .add(result);
-      counts
-          .putIfAbsent(result.name, () => <String, int>{})
-          .putIfAbsent(change.kind, () => 0);
-      ++counts[result.name][change.kind];
+      counts.putIfAbsent(result.name, () => Counts()).addResult(change, result);
+      testCounts.addResult(change, result);
+      resultCounts.addResult(change, result);
     }
     names = grouped.keys.toList()..sort();
     notifyListeners();
@@ -129,3 +133,49 @@
     ].join(',');
 
 String resultTextHeader = "name,configuration,result,expected,flaky,timeMs";
+
+class Counts {
+  int count = 0;
+  int countFailing = 0;
+  int countFlaky = 0;
+
+  int get countPassing => count - countFailing - countFlaky;
+
+  void addResult(ChangeInResult change, Result result) {
+    count++;
+    if (change.flaky) {
+      countFlaky++;
+    } else if (!change.matches) {
+      countFailing++;
+    }
+  }
+}
+
+class TestCounts extends Counts {
+  String currentTest = '';
+  bool currentFailing = false;
+  bool currentFlaky = false;
+
+  void addResult(ChangeInResult change, Result result) {
+    if (currentTest != result.name) {
+      if (currentTest.compareTo(result.name) > 0) {
+        print('Results are not sorted by test name: '
+            '$currentTest, ${result.name}');
+        return;
+      }
+      currentFlaky = false;
+      currentFailing = false;
+      currentTest = result.name;
+      count++;
+    }
+    if (change.flaky) {
+      if (!currentFlaky) {
+        currentFlaky = true;
+        countFlaky++;
+      }
+    } else if (!change.matches && !currentFailing) {
+      currentFailing = true;
+      countFailing++;
+    }
+  }
+}
diff --git a/current_results_ui/lib/results.dart b/current_results_ui/lib/results.dart
index e07dd46..902f371 100644
--- a/current_results_ui/lib/results.dart
+++ b/current_results_ui/lib/results.dart
@@ -7,7 +7,9 @@
 import 'package:flutter/gestures.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/rendering.dart';
+import 'package:provider/provider.dart';
 
+import 'src/generated/query.pb.dart';
 import 'query.dart';
 
 const Color lightCoral = Color.fromARGB(255, 240, 128, 128);
@@ -17,7 +19,6 @@
   'flaky': gold,
   'fail': lightCoral,
 };
-const kinds = ['pass', 'fail', 'flaky'];
 
 class ResultsPanel extends StatelessWidget {
   final QueryResults queryResults;
@@ -49,8 +50,8 @@
 
 class ExpandableResult extends StatefulWidget {
   final String name;
-  final changeGroups;
-  final counts;
+  final Map<ChangeInResult, List<Result>> changeGroups;
+  final Counts counts;
   final bool showAll;
 
   ExpandableResult(this.name, this.changeGroups, this.counts, this.showAll)
@@ -60,6 +61,26 @@
   _ExpandableResultState createState() => _ExpandableResultState();
 }
 
+class CountItem {
+  String text;
+  Color color;
+
+  CountItem(int count, this.color) {
+    if (count > 0) {
+      text = count.toString();
+    } else {
+      color = Colors.transparent;
+      text = '';
+    }
+  }
+}
+
+List<CountItem> countItems(Counts counts) => [
+      CountItem(counts.countPassing, resultColors['pass']),
+      CountItem(counts.countFailing, resultColors['fail']),
+      CountItem(counts.countFlaky, resultColors['flaky'])
+    ];
+
 class _ExpandableResultState extends State<ExpandableResult> {
   bool expanded = false;
 
@@ -80,19 +101,16 @@
                 icon: Icon(expanded ? Icons.expand_less : Icons.expand_more),
                 onPressed: () => setState(() => expanded = !expanded),
               ),
-              for (final kind in kinds)
+              for (final item in countItems(widget.counts))
                 Container(
                   width: 24,
                   alignment: Alignment.center,
                   margin: EdgeInsets.symmetric(horizontal: 1.0),
                   decoration: BoxDecoration(
-                    color: widget.counts.containsKey(kind)
-                        ? resultColors[kind]
-                        : Colors.transparent,
+                    color: item.color,
                     shape: BoxShape.circle,
                   ),
-                  child: Text('${widget.counts[kind] ?? ''}',
-                      style: TextStyle(fontSize: 14.0)),
+                  child: Text(item.text, style: TextStyle(fontSize: 14.0)),
                 ),
               Expanded(
                 flex: 1,
@@ -204,3 +222,81 @@
     ]);
   }
 }
+
+class ResultsSummary extends StatelessWidget {
+  const ResultsSummary() : super();
+
+  @override
+  Widget build(BuildContext context) {
+    return Consumer<QueryResults>(
+      builder: (context, results, child) =>
+          Summary("results", results.resultCounts),
+    );
+  }
+}
+
+class TestSummary extends StatelessWidget {
+  const TestSummary() : super();
+
+  @override
+  Widget build(BuildContext context) {
+    return Consumer<QueryResults>(
+      builder: (context, results, child) =>
+          Summary("tests", results.testCounts),
+    );
+  }
+}
+
+class Summary extends StatelessWidget {
+  final String typeText;
+  final Counts counts;
+
+  Summary(this.typeText, this.counts);
+
+  @override
+  Widget build(BuildContext context) {
+    return Container(
+      alignment: AlignmentDirectional.centerStart,
+      width: 200.0,
+      height: 36.0,
+      child: Row(
+        children: [
+          Text(
+            typeText,
+            style: TextStyle(fontWeight: FontWeight.bold),
+          ),
+          Pill(Colors.black26, counts.count, 'total'),
+          Pill(resultColors['fail'], counts.countFailing, 'failing'),
+          Pill(resultColors['flaky'], counts.countFlaky, 'flaky'),
+        ],
+      ),
+    );
+  }
+}
+
+class Pill extends StatelessWidget {
+  final Color color;
+  final int count;
+  final String tooltip;
+
+  Pill(this.color, this.count, this.tooltip);
+
+  @override
+  Widget build(BuildContext context) {
+    return Tooltip(
+      message: tooltip,
+      child: Container(
+        //width: 24,
+        height: 24,
+        alignment: Alignment.center,
+        margin: EdgeInsets.symmetric(horizontal: 4.0),
+        padding: EdgeInsets.symmetric(horizontal: 8.0),
+        decoration: BoxDecoration(
+          color: color,
+          borderRadius: BorderRadius.circular(14.0),
+        ),
+        child: Text(count.toString(), style: TextStyle(fontSize: 14.0)),
+      ),
+    );
+  }
+}