Index: /trunk/src/VBox/ValidationKit/testmanager/core/failurecategory.py
===================================================================
--- /trunk/src/VBox/ValidationKit/testmanager/core/failurecategory.py	(revision 61269)
+++ /trunk/src/VBox/ValidationKit/testmanager/core/failurecategory.py	(revision 61270)
@@ -324,5 +324,5 @@
                                   'FROM     FailureCategories\n'
                                   'WHERE    idFailureCategory = %s\n'
-                                  'ORDER BY tsExpire\n'
+                                  'ORDER BY tsExpire DESC\n'
                                   'LIMIT 1\n'
                                   , (idFailureCategory, ));
Index: /trunk/src/VBox/ValidationKit/testmanager/core/failurereason.py
===================================================================
--- /trunk/src/VBox/ValidationKit/testmanager/core/failurereason.py	(revision 61269)
+++ /trunk/src/VBox/ValidationKit/testmanager/core/failurereason.py	(revision 61270)
@@ -423,5 +423,5 @@
                                   'FROM     FailureReasons\n'
                                   'WHERE    idFailureReason = %s\n'
-                                  'ORDER BY tsExpire\n'
+                                  'ORDER BY tsExpire DESC\n'
                                   'LIMIT 1\n'
                                   , (idFailureReason, ));
Index: /trunk/src/VBox/ValidationKit/testmanager/core/report.py
===================================================================
--- /trunk/src/VBox/ValidationKit/testmanager/core/report.py	(revision 61269)
+++ /trunk/src/VBox/ValidationKit/testmanager/core/report.py	(revision 61270)
@@ -36,4 +36,5 @@
 from testmanager.core.failurereason import FailureReasonLogic;
 from testmanager.core.testbox       import TestBoxData;
+from testmanager.core.testcase      import TestCaseLogic;
 from common                         import constants;
 
@@ -75,4 +76,5 @@
         # Public so the report generator can easily access them.
         self.tsNow           = tsNow;               # (Can be None.)
+        self.__tsNowDateTime = None;
         self.cPeriods        = cPeriods;
         self.cHoursPerPeriod = cHoursPerPeriod;
@@ -127,17 +129,25 @@
         return sWhere;
 
+    def getNowAsDateTime(self):
+        """ Returns a datetime instance corresponding to tsNow. """
+        if self.__tsNowDateTime is None:
+            if self.tsNow is None:
+                self.__tsNowDateTime = self._oDb.getCurrentTimestamp();
+            else:
+                self._oDb.execute('SELECT %s::TIMESTAMP WITH TIME ZONE', (self.tsNow,));
+                self.__tsNowDateTime = self._oDb.fetchOne()[0];
+        return self.__tsNowDateTime;
+
     def getPeriodStart(self, iPeriod):
         """ Gets the python timestamp for the start of the given period. """
         from datetime import timedelta;
-        tsNow = self.tsNow if self.tsNow is not None else self._oDb.getCurrentTimestamp();
         cHoursStart = (self.cPeriods - iPeriod    ) * self.cHoursPerPeriod;
-        return tsNow - timedelta(hours = cHoursStart);
+        return self.getNowAsDateTime() - timedelta(hours = cHoursStart);
 
     def getPeriodEnd(self, iPeriod):
         """ Gets the python timestamp for the end of the given period. """
         from datetime import timedelta;
-        tsNow = self.tsNow if self.tsNow is not None else self._oDb.getCurrentTimestamp();
         cHoursEnd   = (self.cPeriods - iPeriod - 1) * self.cHoursPerPeriod;
-        return tsNow - timedelta(hours = cHoursEnd);
+        return self.getNowAsDateTime() - timedelta(hours = cHoursEnd);
 
     def getExtraWhereExprForPeriod(self, iPeriod):
@@ -180,17 +190,11 @@
 
 
-class ReportFailureReasonRow(object):
-    """ Simpler to return this than muck about with stupid arrays. """
-    def __init__(self, aoRow, oReason):
-        self.idFailureReason    = aoRow[0];
-        self.cHits              = aoRow[1];
-        self.tsMin              = aoRow[2];
-        self.tsMax              = aoRow[3];
-        self.oReason            = oReason; # FailureReasonDataEx
-
-class ReportFailureReasonTransient(object):
-    """ Details the first or last occurence of a reason.  """
-    def __init__(self, idBuild, iRevision, sRepository, idTestSet, idTestResult, tsDone,  # pylint: disable=R0913
-                 oReason, iPeriod, fEnter):
+#
+# Data structures produced and returned by the ReportLazyModel.
+#
+
+class ReportTransientBase(object):
+    """ Details on the test where a problem was first/last seen.  """
+    def __init__(self, idBuild, iRevision, sRepository, idTestSet, idTestResult, tsDone, iPeriod, fEnter):
         self.idBuild            = idBuild;      # Build ID.
         self.iRevision          = iRevision;    # SVN revision for build.
@@ -199,37 +203,157 @@
         self.idTestResult       = idTestResult; # Test result.
         self.tsDone             = tsDone;       # When the test set was done.
-        self.oReason            = oReason;      # FailureReasonDataEx
         self.iPeriod            = iPeriod;      # Data set period.
         self.fEnter             = fEnter;       # True if enter event, False if leave event.
 
-class ReportFailureReasonPeriod(object):
+class ReportFailureReasonTransient(ReportTransientBase):
+    """ Details on the test where a failure reason was first/last seen.  """
+    def __init__(self, idBuild, iRevision, sRepository, idTestSet, idTestResult, tsDone,  # pylint: disable=R0913
+                 iPeriod, fEnter, oReason):
+        ReportTransientBase.__init__(self, idBuild, iRevision, sRepository, idTestSet, idTestResult, tsDone, iPeriod, fEnter);
+        self.oReason            = oReason;      # FailureReasonDataEx
+
+class ReportTestCaseFailureTransient(ReportTransientBase):
+    """ Details on the test where a test case was first/last seen.  """
+    def __init__(self, idBuild, iRevision, sRepository, idTestSet, idTestResult, tsDone,  # pylint: disable=R0913
+                 iPeriod, fEnter, oTestCase):
+        ReportTransientBase.__init__(self, idBuild, iRevision, sRepository, idTestSet, idTestResult, tsDone, iPeriod, fEnter);
+        self.oTestCase          = oTestCase;      # TestCaseDataEx
+
+
+class ReportHitRowBase(object):
+    """ A row in a period. """
+    def __init__(self, cHits, tsMin = None, tsMax = None):
+        self.cHits              = cHits;
+        self.tsMin              = tsMin;
+        self.tsMax              = tsMax;
+
+class ReportFailureReasonRow(ReportHitRowBase):
+    """ The account of one failure reason for a period. """
+    def __init__(self, aoRow, oReason):
+        ReportHitRowBase.__init__(self, aoRow[1], aoRow[2], aoRow[3]);
+        self.idFailureReason    = aoRow[0];
+        self.oReason            = oReason;      # FailureReasonDataEx
+
+class ReportTestCaseFailureRow(ReportHitRowBase):
+    """ The account of one test case for a period. """
+    def __init__(self, aoRow, aoTestCase):
+        ReportHitRowBase.__init__(self, aoRow[1], aoRow[2], aoRow[3]);
+        self.idTestCase         = aoRow[0];
+        self.aoTestCase         = aoTestCase;   # TestCaseDataEx
+
+
+class ReportPeriodBase(object):
     """ A period in ReportFailureReasonSet. """
     def __init__(self, oSet, iPeriod, sDesc, tsFrom, tsTo):
-        self.oSet               = oSet          # Reference to the parent ReportFailureReasonSet.
-        self.iPeriod            = iPeriod;
-        self.sDesc              = sDesc;
-        self.aoRows             = [];           # Rows in order the database returned them.
-        self.dById              = {};           # Same as aoRows but indexed by idFailureReason.
-        self.cHits              = 0;            # Total number of hits.
-        self.dFirst             = {};           # The reasons seen for the first time (idFailureReason key).
-        self.dLast              = {};           # The reasons seen for the last time (idFailureReason key).
-        self.tsStart            = tsFrom;
-        self.tsEnd              = tsTo;
-        self.tsMin              = tsTo;
-        self.tsMax              = tsFrom;
+        self.oSet               = oSet          # Reference to the parent ReportSetBase derived object.
+        self.iPeriod            = iPeriod;      # Period number in the set.
+        self.sDesc              = sDesc;        # Short period description.
+        self.tsStart            = tsFrom;       # Start of the period.
+        self.tsEnd              = tsTo;         # End of the period (exclusive).
+        self.tsMin              = tsTo;         # The earlierst hit of the period (only valid for cHits > 0).
+        self.tsMax              = tsFrom;       # The latest hit of the period (only valid for cHits > 0).
+        self.aoRows             = [];           # Rows in order the database returned them (ReportHitRowBase descendant).
+        self.dRowsById          = {};           # Same as aoRows but indexed by object ID (see ReportSetBase::sIdAttr).
+        self.dFirst             = {};           # The subjects seen for the first time - data object, keyed by ID.
+        self.dLast              = {};           # The subjects seen for the last  time - data object, keyed by ID.
+        self.cHits              = 0;            # Total number of hits in this period.
+        self.cMaxHits           = 0;            # Max hits in a row.
+        self.cMinHits           = 99999999;     # Min hits in a row (only valid for cHits > 0).
+
+    def appendRow(self, oRow, idRow, oData):
+        """ Adds a row. """
+        assert isinstance(oRow, ReportHitRowBase);
+        self.aoRows.append(oRow);
+        self.dRowsById[idRow] = oRow;
+
+        if oRow.tsMin is not None and oRow.tsMin < self.tsMin:
+            self.tsMin = oRow.tsMin;
+        if oRow.tsMax is not None and oRow.tsMax < self.tsMax:
+            self.tsMax = oRow.tsMax;
+
+        self.cHits += oRow.cHits;
+        if oRow.cHits > self.cMaxHits:
+            self.cMaxHits = oRow.cHits;
+        if oRow.cHits < self.cMinHits:
+            self.cMinHits = oRow.cHits;
+
+        if idRow in self.oSet.dcTotalsPerId:
+            self.oSet.dcTotalsPerId[idRow] += oRow.cHits;
+        else:
+            self.dFirst[idRow]              = oData;
+            self.oSet.dSubjects[idRow]      = oData;
+            self.oSet.dcTotalsPerId[idRow]  = oRow.cHits;
+            self.oSet.diPeriodFirst[idRow]  = self.iPeriod;
+        self.oSet.diPeriodLast[idRow]       = self.iPeriod;
+
+class ReportFailureReasonPeriod(ReportPeriodBase):
+    """ A period in ReportFailureReasonSet. """
+    def __init__(self, oSet, iPeriod, sDesc, tsFrom, tsTo):
+        ReportPeriodBase.__init__(self, oSet, iPeriod, sDesc, tsFrom, tsTo);
         self.cWithoutReason     = 0;            # Number of failed test sets without any assigned reason.
 
-class ReportFailureReasonSet(object):
+class ReportTestCaseFailurePeriod(ReportPeriodBase):
+    """ A period in ReportTestCaseFailureSet. """
+    def __init__(self, oSet, iPeriod, sDesc, tsFrom, tsTo):
+        ReportPeriodBase.__init__(self, oSet, iPeriod, sDesc, tsFrom, tsTo);
+
+
+class ReportPeriodSetBase(object):
+    """ Period data set base class. """
+    def __init__(self, sIdAttr):
+        self.sIdAttr            = sIdAttr;      # The name of the key attribute.  Mainly for documentation purposes.
+        self.aoPeriods          = [];           # Periods (ReportPeriodBase descendant) in ascending order (time wise).
+        self.dcTotalsPerId      = {};           # Totals per subject ID (key).
+        self.dSubjects          = {};           # The subject data objects, keyed by the subject ID.
+        self.cHits              = 0;            # Total number of hits in all periods and all reasons.
+        self.cMaxHits           = 0;            # Max hits in a row.
+        self.cMinHits           = 0;            # Min hits in a row.
+        self.cMaxRows           = 0;            # Max number of rows in a period.
+        self.cMinRows           = 0;            # Min number of rows in a period.
+        self.diPeriodFirst      = {};           # The period number a reason was first seen (keyed by subject ID).
+        self.diPeriodLast       = {};           # The period number a reason was last seen (keyed by subject ID).
+        self.aoEnterInfo        = [];           # Array of ReportTransientBase children order by iRevision.  Excludes
+                                                # the first period of course.  (Child class populates this.)
+        self.aoLeaveInfo        = [];           # Array of ReportTransientBase children order in descending order by
+                                                # iRevision. Excludes the last priod.  (Child class populates this.)
+
+    def appendPeriod(self, oPeriod):
+        """ Appends a period to the set. """
+        assert isinstance(oPeriod, ReportPeriodBase);
+        self.aoPeriods.append(oPeriod);
+
+        self.cHits += oPeriod.cHits;
+        if oPeriod.cHits > self.cMaxHits:
+            self.cMaxHits = oPeriod.cHits;
+        if oPeriod.cHits < self.cMinHits:
+            self.cMinHits = oPeriod.cHits;
+
+        if len(oPeriod.aoRows) > self.cMaxHits:
+            self.cMaxHits = len(oPeriod.aoRows);
+        if len(oPeriod.aoRows) < self.cMinHits:
+            self.cMinHits = len(oPeriod.aoRows);
+
+    def finalizePass1(self):
+        """ Finished all but aoEnterInfo and aoLeaveInfo. """
+        # All we need to do here is to populate the dLast members.
+        for idKey, iPeriod in self.diPeriodLast.items():
+            self.aoPeriods[iPeriod].dLast[idKey] = self.dSubjects[idKey];
+        return self;
+
+    def finalizePass2(self):
+        """ Called after aoEnterInfo and aoLeaveInfo has been populated to sort them. """
+        self.aoEnterInfo = sorted(self.aoEnterInfo, key = lambda oTrans: oTrans.iRevision);
+        self.aoLeaveInfo = sorted(self.aoLeaveInfo, key = lambda oTrans: oTrans.iRevision, reverse = True);
+        return self;
+
+class ReportFailureReasonSet(ReportPeriodSetBase):
     """ What ReportLazyModel.getFailureReasons returns. """
     def __init__(self):
-        self.aoPeriods   = [];  # Periods in ascending order (time wise).
-        self.dReasons    = {};  # FailureReasonDataEx objected indexted by idFailureReason
-        self.dTotals     = {};  # Totals per reason, indexed by idFailureReason.
-        self.cHits       = 0;   # Total number of hits in all periods and all reasons.
-        self.cMaxRowHits = 0;   # Max hits in a row.
-        self.diFirst     = {};  # The period number a reason was first seen (idFailureReason key).
-        self.diLast      = {};  # The period number a reason was last seen (idFailureReason key).
-        self.aoEnterInfo = [];  # Array of ReportFailureReasonTransient order by iRevision. Excludes the first period.
-        self.aoLeaveInfo = [];  # Array of ReportFailureReasonTransient order in descending order by iRevision.  Excludes last.
+        ReportPeriodSetBase.__init__(self, 'idFailureReason');
+
+class ReportTestCaseFailureSet(ReportPeriodSetBase):
+    """ What ReportLazyModel.getTestCaseFailures returns. """
+    def __init__(self):
+        ReportPeriodSetBase.__init__(self, 'idTestCase');
 
 
@@ -300,13 +424,8 @@
         Gets the failure reasons of the subject in the specified period.
 
-        Returns an array of data per period (0 is the oldes, self.cPeriods-1 is
-        the latest) where each entry is a dicationary using failure reason ID as
-        key.  The dictionary contains a tuple where the first element is the
-        number of occurences and the second is the corresponding
-        FailureReasonDataEx object from the cache.
-
-        Note that reason IDs may not be present in every period, we only return
-        those with actual occurences.
-        """
+        Returns a ReportFailureReasonSet instance.
+        """
+
+        oFailureReasonLogic = FailureReasonLogic(self._oDb);
 
         # Create a temporary table
@@ -337,7 +456,5 @@
         self._oDb.execute('SELECT idFailureReason FROM TmpReasons;');
 
-
         # Retrieve the period results.
-        oFailureReasonLogic = FailureReasonLogic(self._oDb);
         oSet = ReportFailureReasonSet();
         for iPeriod in xrange(self.cPeriods):
@@ -354,25 +471,9 @@
             oPeriod = ReportFailureReasonPeriod(oSet, iPeriod, self.getStraightPeriodDesc(iPeriod),
                                                 self.getPeriodStart(iPeriod), self.getPeriodEnd(iPeriod));
-            oSet.aoPeriods.append(oPeriod);
+
             for aoRow in aaoRows:
                 oReason = oFailureReasonLogic.cachedLookup(aoRow[0]);
                 oPeriodRow = ReportFailureReasonRow(aoRow, oReason);
-                oPeriod.aoRows.append(oPeriodRow);
-                oPeriod.dById[oPeriodRow.idFailureReason] = oPeriodRow;
-                oPeriod.cHits += oPeriodRow.cHits;
-                if oPeriodRow.idFailureReason in oSet.dReasons:
-                    oSet.dTotals[oPeriodRow.idFailureReason]  += oPeriodRow.cHits;
-                else:
-                    oSet.dTotals[oPeriodRow.idFailureReason]   = oPeriodRow.cHits;
-                    oSet.dReasons[oPeriodRow.idFailureReason]  = oReason;
-                    oSet.diFirst[oPeriodRow.idFailureReason]   = iPeriod;
-                    oPeriod.dFirst[oPeriodRow.idFailureReason] = oReason;
-                if oPeriodRow.cHits > oSet.cMaxRowHits:
-                    oSet.cMaxRowHits = oPeriodRow.cHits;
-                if oPeriodRow.tsMin < oPeriod.tsMin:
-                    oPeriod.tsMin = oPeriodRow.tsMin;
-                if oPeriodRow.tsMax > oPeriod.tsMax:
-                    oPeriod.tsMax = oPeriodRow.tsMax;
-            oSet.cHits += oPeriod.cHits;
+                oPeriod.appendRow(oPeriodRow, oReason.idFailureReason, oReason);
 
             # Count how many test sets we've got without any reason associated with them.
@@ -388,14 +489,6 @@
             oPeriod.cWithoutReason = self._oDb.fetchOne()[0];
 
-
-        #
-        # construct the diLast and dLast bits.
-        #
-        for iPeriod in xrange(self.cPeriods - 1, 0, -1):
-            oPeriod = oSet.aoPeriods[iPeriod];
-            for oRow in oPeriod.aoRows:
-                if oRow.idFailureReason not in oSet.diLast:
-                    oSet.diLast[oRow.idFailureReason]   = iPeriod;
-                    oPeriod.dLast[oRow.idFailureReason] = oRow.oReason;
+            oSet.appendPeriod(oPeriod);
+
 
         #
@@ -403,9 +496,10 @@
         # test set it first occured with.
         #
+        oSet.finalizePass1();
+
         for iPeriod in xrange(1, self.cPeriods):
             oPeriod = oSet.aoPeriods[iPeriod];
             for oReason in oPeriod.dFirst.values():
                 oSet.aoEnterInfo.append(self._getEdgeFailureReasonOccurence(oReason, iPeriod, fEnter = True));
-        oSet.aoEnterInfo = sorted(oSet.aoEnterInfo, key = lambda oTrans: oTrans.iRevision);
 
         # Ditto for reasons leaving before the last.
@@ -414,5 +508,6 @@
             for oReason in oPeriod.dLast.values():
                 oSet.aoLeaveInfo.append(self._getEdgeFailureReasonOccurence(oReason, iPeriod, fEnter = False));
-        oSet.aoLeaveInfo = sorted(oSet.aoLeaveInfo, key = lambda oTrans: oTrans.iRevision, reverse = True);
+
+        oSet.finalizePass2();
 
         self._oDb.execute('DROP TABLE TmpReasons\n');
@@ -431,4 +526,6 @@
 
         """
+
+
         sSorting = 'ASC' if fEnter else 'DESC';
         self._oDb.execute('SELECT   TmpReasons.idTestResult,\n'
@@ -452,9 +549,104 @@
         aoRow = self._oDb.fetchOne();
         if aoRow is None:
-            return ReportFailureReasonTransient(-1, -1, 'internal-error', -1, -1,
-                                                self._oDb.getCurrentTimestamp(), oReason, iPeriod, fEnter);
+            return ReportFailureReasonTransient(-1, -1, 'internal-error', -1, -1, self._oDb.getCurrentTimestamp(),
+                                                iPeriod, fEnter, oReason);
         return ReportFailureReasonTransient(idBuild = aoRow[3], iRevision = aoRow[4], sRepository = aoRow[5],
                                             idTestSet = aoRow[1], idTestResult = aoRow[0], tsDone = aoRow[2],
-                                            oReason = oReason, iPeriod = iPeriod, fEnter = fEnter);
+                                            iPeriod = iPeriod, fEnter = fEnter, oReason = oReason);
+
+
+    def getTestCaseFailures(self):
+        """
+        Gets the test case failures of the subject in the specified period.
+
+        Returns a ReportTestCaseFailureSet instance.
+
+        """
+
+        oTestCaseLogic = TestCaseLogic(self._oDb);
+
+        # Retrieve the period results.
+        oSet = ReportTestCaseFailureSet();
+        for iPeriod in xrange(self.cPeriods):
+            self._oDb.execute('SELECT   idTestCase,\n'
+                              '         COUNT(idTestResult),\n'
+                              '         MIN(tsDone),\n'
+                              '         MAX(tsDone)\n'
+                              'FROM     TestSets\n'
+                              'WHERE    TRUE\n'
+                              + self.getExtraWhereExprForPeriod(iPeriod) +
+                              'GROUP BY idTestCase\n');
+            aaoRows = self._oDb.fetchAll()
+
+            oPeriod = ReportTestCaseFailurePeriod(oSet, iPeriod, self.getStraightPeriodDesc(iPeriod),
+                                                  self.getPeriodStart(iPeriod), self.getPeriodEnd(iPeriod));
+
+            for aoRow in aaoRows:
+                oTestCase = oTestCaseLogic.cachedLookup(aoRow[0]);
+                oPeriodRow = ReportTestCaseFailureRow(aoRow, oTestCase);
+                oPeriod.appendRow(oPeriodRow, oTestCase.idTestCase, oTestCase);
+
+            oSet.appendPeriod(oPeriod);
+
+
+        #
+        # For reasons entering after the first period, look up the build and
+        # test set it first occured with.
+        #
+        oSet.finalizePass1();
+
+        for iPeriod in xrange(1, self.cPeriods):
+            oPeriod = oSet.aoPeriods[iPeriod];
+            for oTestCase in oPeriod.dFirst.values():
+                oSet.aoEnterInfo.append(self._getEdgeTestCaseFailureOccurence(oTestCase, iPeriod, fEnter = True));
+
+        # Ditto for reasons leaving before the last.
+        for iPeriod in xrange(self.cPeriods - 1):
+            oPeriod = oSet.aoPeriods[iPeriod];
+            for oTestCase in oPeriod.dLast.values():
+                oSet.aoLeaveInfo.append(self._getEdgeTestCaseFailureOccurence(oTestCase, iPeriod, fEnter = False));
+
+        oSet.finalizePass2();
+
+        return oSet;
+
+
+    def _getEdgeTestCaseFailureOccurence(self, oTestCase, iPeriod, fEnter = True):
+        """
+        Helper for the failure reason report that finds the oldest or newest build
+        (SVN rev) and test set (start time) it occured with.
+
+        If fEnter is set the oldest occurence is return, if fEnter clear the newest
+        is is returned.
+
+        Returns ReportFailureReasonTransient instant.
+
+        """
+        sSorting = 'ASC' if fEnter else 'DESC';
+        self._oDb.execute('SELECT   TestSets.idTestResult,\n'
+                          '         TestSets.idTestSet,\n'
+                          '         TestSets.tsDone,\n'
+                          '         TestSets.idBuild,\n'
+                          '         Builds.iRevision,\n'
+                          '         BuildCategories.sRepository\n'
+                          'FROM     TestSets,\n'
+                          '         Builds,\n'
+                          '         BuildCategories\n'
+                          'WHERE    TestSets.idTestCase       = %s\n'
+                          '     AND TestSets.idBuild          = Builds.idBuild\n'
+                          '     AND Builds.tsExpire             > TestSets.tsCreated\n'
+                          '     AND Builds.tsEffective         <= TestSets.tsCreated\n'
+                          '     AND Builds.idBuildCategory      = BuildCategories.idBuildCategory\n'
+                          'ORDER BY Builds.iRevision ' + sSorting + ',\n'
+                          '         TestSets.tsCreated ' + sSorting + '\n'
+                          'LIMIT 1\n'
+                          , ( oTestCase.idTestCase, ));
+        aoRow = self._oDb.fetchOne();
+        if aoRow is None:
+            return ReportTestCaseFailureTransient(-1, -1, 'internal-error', -1, -1,
+                                                  self._oDb.getCurrentTimestamp(), oTestCase, iPeriod, fEnter);
+        return ReportTestCaseFailureTransient(idBuild = aoRow[3], iRevision = aoRow[4], sRepository = aoRow[5],
+                                              idTestSet = aoRow[1], idTestResult = aoRow[0], tsDone = aoRow[2],
+                                              oTestCase = oTestCase, iPeriod = iPeriod, fEnter = fEnter);
 
 
Index: /trunk/src/VBox/ValidationKit/testmanager/core/testcase.py
===================================================================
--- /trunk/src/VBox/ValidationKit/testmanager/core/testcase.py	(revision 61269)
+++ /trunk/src/VBox/ValidationKit/testmanager/core/testcase.py	(revision 61270)
@@ -934,4 +934,8 @@
     Test case management logic.
     """
+
+    def __init__(self, oDb):
+        ModelLogicBase.__init__(self, oDb)
+        self.ahCache = None;
 
     def getAll(self):
@@ -1349,4 +1353,45 @@
 
 
+    def cachedLookup(self, idTestCase):
+        """
+        Looks up the most recent TestCaseDataEx object for uid idTestCase
+        an object cache.
+
+        Returns a shared TestCaseDataEx object.  None if not found.
+        Raises exception on DB error.
+        """
+        if self.ahCache is None:
+            self.ahCache = self._oDb.getCache('TestCaseDataEx');
+        oEntry = self.ahCache.get(idTestCase, None);
+        if oEntry is None:
+            ##fNeedTsNow = False;
+            fNeedTsNow = True;
+            self._oDb.execute('SELECT   *\n'
+                              'FROM     TestCases\n'
+                              'WHERE    idTestCase = %s\n'
+                              '     AND tsExpire   = \'infinity\'::TIMESTAMP\n'
+                              , (idTestCase, ));
+            if self._oDb.getRowCount() == 0:
+                # Maybe it was deleted, try get the last entry.
+                self._oDb.execute('SELECT   *\n'
+                                  'FROM     TestCases\n'
+                                  'WHERE    idTestCase = %s\n'
+                                  'ORDER BY tsExpire DESC\n'
+                                  'LIMIT 1\n'
+                                  , (idTestCase, ));
+                fNeedTsNow = True;
+            elif self._oDb.getRowCount() > 1:
+                raise self._oDb.integrityException('%s infinity rows for %s' % (self._oDb.getRowCount(), idTestCase));
+
+            if self._oDb.getRowCount() == 1:
+                aaoRow = self._oDb.fetchOne();
+                oEntry = TestCaseDataEx();
+                tsNow  = oEntry.initFromDbRow(aaoRow).tsEffective if fNeedTsNow else None;
+                oEntry.initFromDbRowEx(aaoRow, self._oDb, tsNow);
+                self.ahCache[idTestCase] = oEntry;
+        return oEntry;
+
+
+
 #
 # Unit testing.
Index: /trunk/src/VBox/ValidationKit/testmanager/core/useraccount.py
===================================================================
--- /trunk/src/VBox/ValidationKit/testmanager/core/useraccount.py	(revision 61269)
+++ /trunk/src/VBox/ValidationKit/testmanager/core/useraccount.py	(revision 61270)
@@ -247,5 +247,5 @@
                                   'FROM     Users\n'
                                   'WHERE    uid = %s\n'
-                                  'ORDER BY tsExpire\n'
+                                  'ORDER BY tsExpire DESC\n'
                                   'LIMIT 1\n'
                                   , (uid, ));
Index: /trunk/src/VBox/ValidationKit/testmanager/webui/wuimain.py
===================================================================
--- /trunk/src/VBox/ValidationKit/testmanager/webui/wuimain.py	(revision 61269)
+++ /trunk/src/VBox/ValidationKit/testmanager/webui/wuimain.py	(revision 61270)
@@ -80,4 +80,5 @@
     ksActionReportSummary               = 'ReportSummary';
     ksActionReportRate                  = 'ReportRate';
+    ksActionReportTestCaseFailures      = 'ReportTestCaseFailures';
     ksActionReportFailureReasons        = 'ReportFailureReasons';
     ksActionGraphWiz                    = 'GraphWiz';
@@ -248,13 +249,17 @@
         d[self.ksActionViewLog]                     = self.actionViewLog;
         d[self.ksActionGetFile]                     = self.actionGetFile;
-        from testmanager.webui.wuireport import WuiReportSummary, WuiReportSuccessRate, WuiReportFailureReasons;
-        d[self.ksActionReportSummary]               = lambda: self._actionGenericReport(ReportLazyModel, WuiReportSummary);
-        d[self.ksActionReportRate]                  = lambda: self._actionGenericReport(ReportLazyModel, WuiReportSuccessRate);
-        d[self.ksActionReportFailureReasons]        = lambda: self._actionGenericReport(ReportLazyModel, WuiReportFailureReasons);
-        d[self.ksActionGraphWiz]                    = self._actionGraphWiz;
-        d[self.ksActionVcsHistoryTooltip]           = self._actionVcsHistoryTooltip;
+
+        from testmanager.webui.wuireport import WuiReportSummary, WuiReportSuccessRate, WuiReportTestCaseFailures, \
+                                                WuiReportFailureReasons;
+        d[self.ksActionReportSummary]          = lambda: self._actionGenericReport(ReportLazyModel, WuiReportSummary);
+        d[self.ksActionReportRate]             = lambda: self._actionGenericReport(ReportLazyModel, WuiReportSuccessRate);
+        d[self.ksActionReportTestCaseFailures] = lambda: self._actionGenericReport(ReportLazyModel, WuiReportTestCaseFailures);
+        d[self.ksActionReportFailureReasons]   = lambda: self._actionGenericReport(ReportLazyModel, WuiReportFailureReasons);
+        d[self.ksActionGraphWiz]               = self._actionGraphWiz;
+
+        d[self.ksActionVcsHistoryTooltip]      = self._actionVcsHistoryTooltip;
 
         # Legacy.
-        d['TestResultDetails']                      = d[self.ksActionTestSetDetails];
+        d['TestResultDetails']                 = d[self.ksActionTestSetDetails];
 
 
@@ -295,4 +300,5 @@
                     [ 'Summary',                  sActUrlBase + self.ksActionReportSummary ],
                     [ 'Success Rate',             sActUrlBase + self.ksActionReportRate ],
+                    [ 'Test Case Failures',       sActUrlBase + self.ksActionReportTestCaseFailures ],
                     [ 'Failure Reasons',          sActUrlBase + self.ksActionReportFailureReasons ],
                 ]
Index: /trunk/src/VBox/ValidationKit/testmanager/webui/wuireport.py
===================================================================
--- /trunk/src/VBox/ValidationKit/testmanager/webui/wuireport.py	(revision 61269)
+++ /trunk/src/VBox/ValidationKit/testmanager/webui/wuireport.py	(revision 61270)
@@ -128,13 +128,22 @@
 
 
-class WuiReportFailureReasons(WuiReportBase):
-    """
-    Generates a report displaying the failure reasons over time.
-    """
+class WuiReportFailuresBase(WuiReportBase):
+    """
+    Common parent of WuiReportFailureReasons and WuiReportTestCaseFailures.
+    """
+
+    def _formatEdgeOccurenceSubject(self, oTransient):
+        """
+        Worker for _formatEdgeOccurence that child classes overrides to format
+        their type of subject data in the best possible way.
+        """
+        _ = oTransient;
+        assert False;
+        return '';
 
     def _formatEdgeOccurence(self, oTransient):
         """
         Helper for formatting the transients.
-        oTransient is of type ReportFailureReasonTransient.
+        oTransient is of type ReportFailureReasonTransient or ReportTestCaseFailureTransient.
         """
         sHtml = u'<li>';
@@ -143,42 +152,16 @@
         sHtml += WuiSvnLinkWithTooltip(oTransient.iRevision, oTransient.sRepository, fBracketed = 'False').toHtml();
         sHtml += u', %s: ' % (self.formatTsShort(oTransient.tsDone),);
-        sHtml += u'%s / %s' % (webutils.escapeElem(oTransient.oReason.oCategory.sShort),
-                               webutils.escapeElem(oTransient.oReason.sShort),);
+        sHtml += self._formatEdgeOccurenceSubject(oTransient);
         sHtml += u'</li>\n';
-
-        return sHtml;
-
-
-    def generateReportBody(self):
-        self._sTitle = 'Failure reasons';
-
-        sHtml = u'';
-
-        #
-        # The array of periods we get have the oldest period first [0].
-        #
-        oSet = self._oModel.getFailureReasons();
-
-        #
-        # List failure reasons starting or stopping to appear within the data set.
-        #
-        dtFirstLast = {};
-        for iPeriod, oPeriod in enumerate(oSet.aoPeriods):
-            for oRow in oPeriod.aoRows:
-                tIt = dtFirstLast.get(oRow.idFailureReason, (iPeriod, iPeriod));
-                #sHtml += u'<!-- %d: %d,%d -- %d -->\n' % (oRow.idFailureReason, tIt[0], tIt[1], iPeriod);
-                dtFirstLast[oRow.idFailureReason] = (tIt[0], iPeriod);
-
-        sHtml += '<!-- \n';
-        for iPeriod, oPeriod in enumerate(oSet.aoPeriods):
-            sHtml += ' iPeriod=%d tsStart=%s tsEnd=%s\n' % (iPeriod, oPeriod.tsStart, oPeriod.tsEnd,);
-            sHtml += '             tsMin=%s tsMax=%s\n' % (oPeriod.tsMin, oPeriod.tsMax,);
-            sHtml += '              %d / %s\n' % (oPeriod.iPeriod, oPeriod.sDesc,)
-        sHtml += '-->\n';
-
-        sHtml += u'<h4>Changes:</h4>\n' \
+        return sHtml;
+
+    def _generateTransitionList(self, oSet):
+        """
+        Generates the enter and leave lists.
+        """
+        sHtml  = u'<h4>Movements:</h4>\n' \
                  u'<ul>\n';
         if len(oSet.aoEnterInfo) == 0 and len(oSet.aoLeaveInfo) == 0:
-            sHtml += u'<li> No changes</li>\n';
+            sHtml += u'<li>No changes</li>\n';
         else:
             for oTransient in oSet.aoEnterInfo:
@@ -188,4 +171,27 @@
         sHtml += u'</ul>\n';
 
+        return sHtml;
+
+
+
+class WuiReportFailureReasons(WuiReportFailuresBase):
+    """
+    Generates a report displaying the failure reasons over time.
+    """
+
+    def _formatEdgeOccurenceSubject(self, oTransient):
+        return u'%s / %s' % ( webutils.escapeElem(oTransient.oReason.oCategory.sShort),
+                              webutils.escapeElem(oTransient.oReason.sShort),);
+
+
+    def generateReportBody(self):
+        self._sTitle = 'Failure reasons';
+
+        #
+        # Get the data and generate transition list.
+        #
+        oSet = self._oModel.getFailureReasons();
+        sHtml = self._generateTransitionList(oSet);
+
         #
         # Check if most of the stuff is without any assign reason, if so, skip
@@ -194,5 +200,5 @@
         fIncludeWithoutReason = True;
         for oPeriod in reversed(oSet.aoPeriods):
-            if oPeriod.cWithoutReason > oSet.cMaxRowHits * 4:
+            if oPeriod.cWithoutReason > oSet.cMaxHits * 4:
                 fIncludeWithoutReason = False;
                 sHtml += '<p>Warning: Many failures without assigned reason!</p>\n';
@@ -200,14 +206,11 @@
 
         #
-        # Graph.
-        #
-        if True: # pylint: disable=W0125
-            aidSorted = sorted(oSet.dReasons, key = lambda idReason: oSet.dTotals[idReason], reverse = True);
-        else:
-            aidSorted = sorted(oSet.dReasons, key = lambda idReason: '%s / %s' % ( oSet.dReasons[idReason].oCategory.sShort,
-                                                                                   oSet.dReasons[idReason].sShort, ));
+        # Generate the graph.
+        #
+        aidSorted = sorted(oSet.dSubjects, key = lambda idReason: oSet.dcTotalsPerId[idReason], reverse = True);
+
         asNames = [];
         for idReason in aidSorted:
-            oReason = oSet.dReasons[idReason];
+            oReason = oSet.dSubjects[idReason];
             asNames.append('%s / %s' % (oReason.oCategory.sShort, oReason.sShort,) )
         if fIncludeWithoutReason:
@@ -216,10 +219,10 @@
         oTable = WuiHlpGraphDataTable('Period', asNames);
 
-        cMax = oSet.cMaxRowHits;
-        for iPeriod, oPeriod in enumerate(reversed(oSet.aoPeriods)):
+        cMax = oSet.cMaxHits;
+        for _, oPeriod in enumerate(reversed(oSet.aoPeriods)):
             aiValues = [];
 
             for idReason in aidSorted:
-                oRow = oPeriod.dById.get(idReason, None);
+                oRow = oPeriod.dRowsById.get(idReason, None);
                 iValue = oRow.cHits if oRow is not None else 0;
                 aiValues.append(iValue);
@@ -244,4 +247,52 @@
 
 
+class WuiReportTestCaseFailures(WuiReportFailuresBase):
+    """
+    Generates a report displaying the failure reasons over time.
+    """
+
+    def _formatEdgeOccurenceSubject(self, oTransient):
+        return u'%s' % ( webutils.escapeElem(oTransient.oTestCase.sName),);
+
+
+    def generateReportBody(self):
+        self._sTitle = 'Test Case Failures';
+
+        #
+        # Get the data and generate transition list.
+        #
+        oSet = self._oModel.getTestCaseFailures();
+        sHtml = self._generateTransitionList(oSet);
+
+        #
+        # Generate the graph.
+        #
+        aidSorted = sorted(oSet.dSubjects, key = lambda idTestCase: oSet.dcTotalsPerId[idTestCase], reverse = True);
+
+        asNames = [];
+        for idKey in aidSorted:
+            oSubject = oSet.dSubjects[idKey];
+            asNames.append(oSubject.sName);
+
+        oTable = WuiHlpGraphDataTable('Period', asNames);
+
+        cMax = oSet.cMaxHits;
+        for _, oPeriod in enumerate(reversed(oSet.aoPeriods)):
+            aiValues = [];
+
+            for idKey in aidSorted:
+                oRow = oPeriod.dRowsById.get(idKey, None);
+                iValue = oRow.cHits if oRow is not None else 0;
+                aiValues.append(iValue);
+
+            oTable.addRow(oPeriod.sDesc, aiValues);
+
+        oGraph = WuiHlpBarGraph('testcase-failures', oTable, self._oDisp);
+        oGraph.setRangeMax(max(cMax + 1, 3));
+        sHtml += oGraph.renderGraph();
+
+        return sHtml;
+
+
 class WuiReportSummary(WuiReportBase):
     """
@@ -254,12 +305,11 @@
              % (self._oModel.sSubject, self._oModel.aidSubjects,);
 
-        oSuccessRate = WuiReportSuccessRate(self._oModel, self._dParams, fSubReport = True,
-                                            fnDPrint = self._fnDPrint, oDisp = self._oDisp);
-
-
-
-        oFailureReasons = WuiReportFailureReasons(self._oModel, self._dParams, fSubReport = True,
-                                                  fnDPrint = self._fnDPrint, oDisp = self._oDisp);
-        for oReport in [oSuccessRate, oFailureReasons, ]:
+        oSuccessRate      = WuiReportSuccessRate(     self._oModel, self._dParams, fSubReport = True,
+                                                      fnDPrint = self._fnDPrint, oDisp = self._oDisp);
+        oTestCaseFailures = WuiReportTestCaseFailures(self._oModel, self._dParams, fSubReport = True,
+                                                      fnDPrint = self._fnDPrint, oDisp = self._oDisp);
+        oFailureReasons   = WuiReportFailureReasons(  self._oModel, self._dParams, fSubReport = True,
+                                                      fnDPrint = self._fnDPrint, oDisp = self._oDisp);
+        for oReport in [oSuccessRate, oTestCaseFailures, oFailureReasons, ]:
             (sTitle, sContent) = oReport.show();
             sHtml += '<br>'; # drop this layout hack
