Repository: Nitrate/Nitrate

Issue:
Refactor stats_caseruns_status to make it more reusable
Points to refactor:

* Remove argument case_run_statuses
* Accept multiple run id to return subtotal result for those runs

AC:

* Update tests
* Update code using that function


Discussion:

Resolving patch:
diff --git a/src/tcms/core/db.py b/src/tcms/core/db.py
--- a/src/tcms/core/db.py
+++ b/src/tcms/core/db.py
@@ -331,3 +331,39 @@ def _rows_generator():
                 yield _key_conv(key), value
 
     return GroupByResult(_rows_generator(), total_name=_rollup_name)
+
+
+class CaseRunStatusGroupByResult(GroupByResult):
+    """Specific for group by result from TestCaseRun"""
+
+    @property
+    def complete_count(self):
+        return (self.get('PASSED', 0) +
+                self.get('ERROR', 0) +
+                self.get('FAILED', 0) +
+                self.get('WAIVED', 0))
+
+    @property
+    def failure_count(self):
+        return self.get('ERROR', 0) + self.get('FAILED', 0)
+
+    @property
+    def complete_percent(self):
+        if self.total:
+            return self.complete_count * 100.0 / self.total
+        else:
+            return .0
+
+    @property
+    def failure_percent_in_complete(self):
+        if self.complete_count:
+            return self.failure_count * 100.0 / self.complete_count
+        else:
+            return .0
+
+    @property
+    def failure_percent_in_total(self):
+        if self.total:
+            return self.failure_count * 100.0 / self.total
+        else:
+            return .0
diff --git a/src/tcms/testruns/data.py b/src/tcms/testruns/data.py
--- a/src/tcms/testruns/data.py
+++ b/src/tcms/testruns/data.py
@@ -1,78 +1,57 @@
 # -*- coding: utf-8 -*-
 
-from collections import namedtuple
 from itertools import groupby
 from operator import itemgetter
+from typing import Dict, List
 
 from django.conf import settings
 from django.db.models import Count, F
 
+from tcms.core.db import CaseRunStatusGroupByResult
 from tcms.testruns.models import TestCaseRun
 from tcms.testruns.models import TestCaseRunStatus
 
 
-TestCaseRunStatusSubtotal = namedtuple('TestCaseRunStatusSubtotal',
-                                       'StatusSubtotal '
-                                       'CaseRunsTotalCount '
-                                       'CompletedPercentage '
-                                       'FailurePercentage')
-
-
-def stats_caseruns_status(run_id, case_run_statuss):
+def stats_case_runs_status(run_ids: List[int]) -> Dict[int, CaseRunStatusGroupByResult]:
     """Get statistics based on case runs' status
 
-    @param run_id: id of test run from where to get statistics
-    @type run_id: int
-    @param case_run_statuss: iterable object containing TestCaseRunStatus
-        objects representing PASS, FAIL, WAIVED, etc.
-    @type case_run_statuss: iterable object
-    @return: the statistics including the number of each status mapping,
+    :param list[int] run_ids: id of test run from where to get statistics
+    :return: the statistics including the number of each status mapping,
         total number of case runs, complete percent, and failure percent.
-    @rtype: namedtuple
+    :rtype: dict[int, CaseRunStatusGroupByResult]
     """
-    rows = TestCaseRun.objects.filter(
-        run=run_id
-    ).values(
-        'case_run_status'
-    ).annotate(status_count=Count('case_run_status'))
-
-    caserun_statuss_subtotal = {
-        status.pk: [0, status] for status in case_run_statuss
-    }
-
-    for row in rows:
-        status_pk = row['case_run_status']
-        caserun_statuss_subtotal[status_pk][0] = row['status_count']
-
-    complete_count = 0
-    failure_count = 0
-    caseruns_total_count = 0
-    status_complete_names = TestCaseRunStatus.complete_status_names
-    status_failure_names = TestCaseRunStatus.failure_status_names
-
-    for status_pk, total_info in caserun_statuss_subtotal.items():
-        status_caseruns_count, caserun_status = total_info
-        status_name = caserun_status.name
-
-        caseruns_total_count += status_caseruns_count
-
-        if status_name in status_complete_names:
-            complete_count += status_caseruns_count
-        if status_name in status_failure_names:
-            failure_count += status_caseruns_count
-
-    # Final calculation
-    complete_percent = .0
-    if caseruns_total_count:
-        complete_percent = complete_count * 100.0 / caseruns_total_count
-    failure_percent = .0
-    if complete_count:
-        failure_percent = failure_count * 100.0 / complete_count
-
-    return TestCaseRunStatusSubtotal(caserun_statuss_subtotal,
-                                     caseruns_total_count,
-                                     complete_percent,
-                                     failure_percent)
+    result = (TestCaseRun.objects
+              .filter(run__in=run_ids)
+              .values('run_id', status_name=F('case_run_status__name'))
+              .annotate(count=Count('pk'))
+              .order_by('run_id', 'status_name'))
+
+    # Example of final result: {
+    #     # run_id: {status_1: count, ...}
+    #     1: {'PASSED': 1, 'FAILED': 2, 'IDLE': 3, ...},
+    #     2: {'PASSED': 1, 'ERROR': 2, 'IDLE': 3, ...},
+    #     3: {'PASSED': 1, 'FAILED': 2, 'IDLE': 3, 'WAIVED': 4, ...},
+    # }
+
+    if result:
+        subtotal = {}
+        for item in result:
+            run_id = item['run_id']
+            status_subtotal = subtotal.setdefault(run_id, CaseRunStatusGroupByResult())
+            status_subtotal[item['status_name']] = item['count']
+    else:
+        subtotal = {run_id: CaseRunStatusGroupByResult() for run_id in run_ids}
+
+    stock_status_names = TestCaseRunStatus.objects.values_list('name', flat=True)
+
+    # Not all case runs of a test run are not in all the status, hence some
+    # status could be missed in the subtotal result.
+    for item in subtotal.values():
+        for name in stock_status_names:
+            if name not in item:
+                item[name] = 0
+
+    return subtotal
 
 
 class TestCaseRunDataMixin:
diff --git a/src/tcms/testruns/views.py b/src/tcms/testruns/views.py
--- a/src/tcms/testruns/views.py
+++ b/src/tcms/testruns/views.py
@@ -55,7 +55,7 @@
 from tcms.testcases.models import TestCasePlan, TestCaseStatus
 from tcms.testcases.views import get_selected_testcases
 from tcms.testplans.models import TestPlan
-from tcms.testruns.data import stats_caseruns_status
+from tcms.testruns.data import stats_case_runs_status
 from tcms.testruns.data import TestCaseRunDataMixin
 from tcms.testruns.forms import MulitpleRunsCloneForm, PlanFilterRunForm, RunAndEnvValueForm, ChangeRunEnvValueForm
 from tcms.testruns.forms import NewRunForm, SearchRunForm, EditRunForm, RunCloneForm
@@ -602,12 +602,13 @@ def get(request, run_id, template_name='run/get.html'):
     # 2. get test run's all case runs
     tcrs = open_run_get_case_runs(request, tr)
 
-    case_run_statuss = TestCaseRunStatus.objects.only('pk', 'name')
-    case_run_statuss = case_run_statuss.order_by('pk')
+    case_run_statuss = (TestCaseRunStatus.objects
+                        .only('pk', 'name')
+                        .order_by('pk'))
 
     # Count the status
     # 3. calculate number of case runs of each status
-    status_stats_result = stats_caseruns_status(run_id, case_run_statuss)
+    status_stats_result = stats_case_runs_status([run_id])[run_id]
 
     # Get the test case run bugs summary
     # 6. get the number of bugs of this run


Test patch:
diff --git a/src/tests/core/test_core.py b/src/tests/core/test_core.py
--- a/src/tests/core/test_core.py
+++ b/src/tests/core/test_core.py
@@ -11,7 +11,7 @@
 from django.core import mail
 
 from tcms.core import responses
-from tcms.core.db import GroupByResult
+from tcms.core.db import GroupByResult, CaseRunStatusGroupByResult
 from tcms.core.utils import string_to_list
 from tcms.core.mailto import mailto
 from tcms.core.task import AsyncTask
@@ -407,3 +407,49 @@ def test_log_action(self):
             self.assertEqual(new_value, log.new_value)
             self.assertEqual(field or '', log.field)
             self.assertEqual(original_value or '', log.original_value)
+
+
+class TestCaseRunStatusGroupbyResult(test.TestCase):
+    """Test CaseRunStatusGroupByResult"""
+
+    def setUp(self):
+        self.result = CaseRunStatusGroupByResult({
+            'PASSED': 20, 'ERROR': 0, 'FAILED': 10, 'IDLE': 50
+        })
+        self.empty_result = CaseRunStatusGroupByResult()
+
+    def test_complete_count(self):
+        r = self.result
+        self.assertEqual(r['PASSED'] + r['ERROR'] + r['FAILED'],
+                         r.complete_count)
+        self.assertEqual(0, self.empty_result.complete_count)
+
+    def test_failure_count(self):
+        r = self.result
+        self.assertEqual(r['ERROR'] + r['FAILED'], r.failure_count)
+        self.assertEqual(0, self.empty_result.failure_count)
+
+    def test_complete_percent(self):
+        r = self.result
+        self.assertEqual(
+            (r['PASSED'] + r['ERROR'] + r['FAILED']) * 1.0 / r.total * 100,
+            self.result.complete_percent
+        )
+        self.assertEqual(.0, self.empty_result.complete_percent)
+
+    def test_failure_percent_in_complete(self):
+        r = self.result
+        # It is not stable to compare the equality of two float numbers.
+        self.assertEqual(
+            round((r['ERROR'] + r['FAILED']) * 1.0 / r.complete_count * 100, 1),
+            round(self.result.failure_percent_in_complete, 1)
+        )
+        self.assertEqual(.0, self.empty_result.failure_percent_in_complete)
+
+    def test_failure_percent_in_total(self):
+        r = self.result
+        self.assertEqual(
+            (r['ERROR'] + r['FAILED']) * 1.0 / r.total * 100,
+            self.result.failure_percent_in_total
+        )
+        self.assertEqual(.0, self.empty_result.failure_percent_in_total)
diff --git a/src/tests/testruns/test_data.py b/src/tests/testruns/test_data.py
--- a/src/tests/testruns/test_data.py
+++ b/src/tests/testruns/test_data.py
@@ -7,7 +7,7 @@
 
 from tcms.comments.models import add_comment
 from tcms.testruns.data import TestCaseRunDataMixin
-from tcms.testruns.data import stats_caseruns_status
+from tcms.testruns.data import stats_case_runs_status
 from tests import factories as f
 from tests import BaseCaseRun
 from tests import BasePlanCase
@@ -24,18 +24,13 @@ def setUpTestData(cls):
             default_tester=cls.tester,
             plan=cls.plan)
 
-        cls.case_run_statuss = f.TestCaseRunStatus.objects.all().order_by('pk')
-
     def test_get_from_empty_case_runs(self):
-        data = stats_caseruns_status(self.empty_test_run.pk,
-                                     self.case_run_statuss)
-
-        subtotal = {status.pk: [0, status] for status in self.case_run_statuss}
+        pk = self.empty_test_run.pk
+        data = stats_case_runs_status([pk])[pk]
 
-        self.assertEqual(subtotal, data.StatusSubtotal)
-        self.assertEqual(0, data.CaseRunsTotalCount)
-        self.assertEqual(.0, data.CompletedPercentage)
-        self.assertEqual(.0, data.FailurePercentage)
+        self.assertEqual(0, data.total)
+        self.assertEqual(.0, data.complete_percent)
+        self.assertEqual(.0, data.failure_percent_in_complete)
 
 
 class TestGetCaseRunsStatsByStatus(BasePlanCase):
@@ -44,8 +39,6 @@ class TestGetCaseRunsStatsByStatus(BasePlanCase):
     def setUpTestData(cls):
         super().setUpTestData()
 
-        cls.case_run_statuss = f.TestCaseRunStatus.objects.all().order_by('pk')
-
         get_status = f.TestCaseRunStatus.objects.get
         cls.case_run_status_idle = get_status(name='IDLE')
         cls.case_run_status_failed = get_status(name='FAILED')
@@ -71,20 +64,15 @@ def setUpTestData(cls):
                 case_run_status=status)
 
     def test_get_stats(self):
-        data = stats_caseruns_status(self.test_run.pk, self.case_run_statuss)
-
-        subtotal = {status.pk: [0, status] for status in self.case_run_statuss}
-        subtotal[self.case_run_status_idle.pk][0] = 1
-        subtotal[self.case_run_status_failed.pk][0] = 2
-        subtotal[self.case_run_status_waived.pk][0] = 3
+        pk = self.test_run.pk
+        data = stats_case_runs_status([pk])[pk]
 
         expected_completed_percentage = 5.0 * 100 / 6
         expected_failure_percentage = 2.0 * 100 / 5
 
-        self.assertEqual(subtotal, data.StatusSubtotal)
-        self.assertEqual(6, data.CaseRunsTotalCount)
-        self.assertEqual(expected_completed_percentage, data.CompletedPercentage)
-        self.assertEqual(expected_failure_percentage, data.FailurePercentage)
+        self.assertEqual(6, data.total)
+        self.assertEqual(expected_completed_percentage, data.complete_percent)
+        self.assertEqual(expected_failure_percentage, data.failure_percent_in_complete)
 
 
 class TestGetCaseRunsComments(BaseCaseRun):