# -*- coding: utf-8 -*- # $Id: wuigraphwiz.py $ """ Test Manager WUI - Graph Wizard """ __copyright__ = \ """ Copyright (C) 2012-2023 Oracle and/or its affiliates. This file is part of VirtualBox base platform packages, as available from https://www.virtualbox.org. This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, in version 3 of the License. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program; if not, see . The contents of this file may alternatively be used under the terms of the Common Development and Distribution License Version 1.0 (CDDL), a copy of it is provided in the "COPYING.CDDL" file included in the VirtualBox distribution, in which case the provisions of the CDDL are applicable instead of those of the GPL. You may elect to license modified versions of this file under the terms and conditions of either the GPL or the CDDL or both. SPDX-License-Identifier: GPL-3.0-only OR CDDL-1.0 """ __version__ = "$Revision: 155244 $" # Python imports. import functools; # Validation Kit imports. from testmanager.webui.wuimain import WuiMain; from testmanager.webui.wuihlpgraph import WuiHlpLineGraphErrorbarY, WuiHlpGraphDataTableEx; from testmanager.webui.wuireport import WuiReportBase; from common import utils, webutils; from common import constants; class WuiGraphWiz(WuiReportBase): """Construct a graph for analyzing test results (values) across builds and testboxes.""" ## @name Series name parts. ## @{ kfSeriesName_TestBox = 1; kfSeriesName_Product = 2; kfSeriesName_Branch = 4; kfSeriesName_BuildType = 8; kfSeriesName_OsArchs = 16; kfSeriesName_TestCase = 32; kfSeriesName_TestCaseArgs = 64; kfSeriesName_All = 127; ## @} def __init__(self, oModel, dParams, fSubReport = False, fnDPrint = None, oDisp = None): WuiReportBase.__init__(self, oModel, dParams, fSubReport = fSubReport, fnDPrint = fnDPrint, oDisp = oDisp); # Select graph implementation. if dParams[WuiMain.ksParamGraphWizImpl] == 'charts': from testmanager.webui.wuihlpgraphgooglechart import WuiHlpLineGraphErrorbarY as MyGraph; self.oGraphClass = MyGraph; elif dParams[WuiMain.ksParamGraphWizImpl] == 'matplotlib': from testmanager.webui.wuihlpgraphmatplotlib import WuiHlpLineGraphErrorbarY as MyGraph; self.oGraphClass = MyGraph; else: self.oGraphClass = WuiHlpLineGraphErrorbarY; # def _figureSeriesNameBits(self, aoSeries): """ Figures out the method (bitmask) to use when naming series. """ if len(aoSeries) <= 1: return WuiGraphWiz.kfSeriesName_TestBox; # Start with all and drop unnecessary specs one-by-one. fRet = WuiGraphWiz.kfSeriesName_All; if [oSrs.idTestBox for oSrs in aoSeries].count(aoSeries[0].idTestBox) == len(aoSeries): fRet &= ~WuiGraphWiz.kfSeriesName_TestBox; if [oSrs.idBuildCategory for oSrs in aoSeries].count(aoSeries[0].idBuildCategory) == len(aoSeries): fRet &= ~WuiGraphWiz.kfSeriesName_Product; fRet &= ~WuiGraphWiz.kfSeriesName_Branch; fRet &= ~WuiGraphWiz.kfSeriesName_BuildType; fRet &= ~WuiGraphWiz.kfSeriesName_OsArchs; else: if [oSrs.oBuildCategory.sProduct for oSrs in aoSeries].count(aoSeries[0].oBuildCategory.sProduct) == len(aoSeries): fRet &= ~WuiGraphWiz.kfSeriesName_Product; if [oSrs.oBuildCategory.sBranch for oSrs in aoSeries].count(aoSeries[0].oBuildCategory.sBranch) == len(aoSeries): fRet &= ~WuiGraphWiz.kfSeriesName_Branch; if [oSrs.oBuildCategory.sType for oSrs in aoSeries].count(aoSeries[0].oBuildCategory.sType) == len(aoSeries): fRet &= ~WuiGraphWiz.kfSeriesName_BuildType; # Complicated. fRet &= ~WuiGraphWiz.kfSeriesName_OsArchs; daTestBoxes = {}; for oSeries in aoSeries: if oSeries.idTestBox in daTestBoxes: daTestBoxes[oSeries.idTestBox].append(oSeries); else: daTestBoxes[oSeries.idTestBox] = [oSeries,]; for aoSeriesPerTestBox in daTestBoxes.values(): if len(aoSeriesPerTestBox) >= 0: asOsArches = aoSeriesPerTestBox[0].oBuildCategory.asOsArches; for i in range(1, len(aoSeriesPerTestBox)): if aoSeriesPerTestBox[i].oBuildCategory.asOsArches != asOsArches: fRet |= WuiGraphWiz.kfSeriesName_OsArchs; break; if aoSeries[0].oTestCaseArgs is None: fRet &= ~WuiGraphWiz.kfSeriesName_TestCaseArgs; if [oSrs.idTestCase for oSrs in aoSeries].count(aoSeries[0].idTestCase) == len(aoSeries): fRet &= ~WuiGraphWiz.kfSeriesName_TestCase; else: fRet &= ~WuiGraphWiz.kfSeriesName_TestCase; if [oSrs.idTestCaseArgs for oSrs in aoSeries].count(aoSeries[0].idTestCaseArgs) == len(aoSeries): fRet &= ~WuiGraphWiz.kfSeriesName_TestCaseArgs; return fRet; def _getSeriesNameFromBits(self, oSeries, fBits): """ Creates a series name from bits (kfSeriesName_xxx). """ assert fBits != 0; sName = ''; if fBits & WuiGraphWiz.kfSeriesName_Product: if sName: sName += ' / '; sName += oSeries.oBuildCategory.sProduct; if fBits & WuiGraphWiz.kfSeriesName_Branch: if sName: sName += ' / '; sName += oSeries.oBuildCategory.sBranch; if fBits & WuiGraphWiz.kfSeriesName_BuildType: if sName: sName += ' / '; sName += oSeries.oBuildCategory.sType; if fBits & WuiGraphWiz.kfSeriesName_OsArchs: if sName: sName += ' / '; sName += ' & '.join(oSeries.oBuildCategory.asOsArches); if fBits & WuiGraphWiz.kfSeriesName_TestCaseArgs: if sName: sName += ' / '; if oSeries.idTestCaseArgs is not None: sName += oSeries.oTestCase.sName + ':#' + str(oSeries.idTestCaseArgs); else: sName += oSeries.oTestCase.sName; elif fBits & WuiGraphWiz.kfSeriesName_TestCase: if sName: sName += ' / '; sName += oSeries.oTestCase.sName; if fBits & WuiGraphWiz.kfSeriesName_TestBox: if sName: sName += ' / '; sName += oSeries.oTestBox.sName; return sName; def _calcGraphName(self, oSeries, fSeriesName, sSampleName): """ Constructs a name for the graph. """ fGraphName = ~fSeriesName & ( WuiGraphWiz.kfSeriesName_TestBox | WuiGraphWiz.kfSeriesName_Product | WuiGraphWiz.kfSeriesName_Branch | WuiGraphWiz.kfSeriesName_BuildType ); sName = self._getSeriesNameFromBits(oSeries, fGraphName); if sName: sName += ' - '; sName += sSampleName; return sName; def _calcSampleName(self, oCollection): """ Constructs a name for a sample source (collection). """ if oCollection.sValue is not None: asSampleName = [oCollection.sValue, 'in',]; elif oCollection.sType == self._oModel.ksTypeElapsed: asSampleName = ['Elapsed time', 'for', ]; elif oCollection.sType == self._oModel.ksTypeResult: asSampleName = ['Error count', 'for',]; else: return 'Invalid collection type: "%s"' % (oCollection.sType,); sTestName = ', '.join(oCollection.asTests if oCollection.asTests[0] else oCollection.asTests[1:]); if sTestName == '': # Use the testcase name if there is only one for all series. if not oCollection.aoSeries: return asSampleName[0]; if len(oCollection.aoSeries) > 1: idTestCase = oCollection.aoSeries[0].idTestCase; for oSeries in oCollection.aoSeries: if oSeries.idTestCase != idTestCase: return asSampleName[0]; sTestName = oCollection.aoSeries[0].oTestCase.sName; return ' '.join(asSampleName) + ' ' + sTestName; def _splitSeries(self, aoSeries): """ Splits the data series (ReportGraphModel.DataSeries) into one or more graphs. Returns an array of data series arrays. """ # Must be at least two series for something to be splittable. if len(aoSeries) <= 1: if not aoSeries: return []; return [aoSeries,]; # Split on unit. dUnitSeries = {}; for oSeries in aoSeries: if oSeries.iUnit not in dUnitSeries: dUnitSeries[oSeries.iUnit] = []; dUnitSeries[oSeries.iUnit].append(oSeries); # Sort the per-unit series since the build category was only sorted by ID. for iUnit in dUnitSeries: def mycmp(oSelf, oOther): """ __cmp__ like function. """ iCmp = utils.stricmp(oSelf.oBuildCategory.sProduct, oOther.oBuildCategory.sProduct); if iCmp != 0: return iCmp; iCmp = utils.stricmp(oSelf.oBuildCategory.sBranch, oOther.oBuildCategory.sBranch); if iCmp != 0: return iCmp; iCmp = utils.stricmp(oSelf.oBuildCategory.sType, oOther.oBuildCategory.sType); if iCmp != 0: return iCmp; iCmp = utils.stricmp(oSelf.oTestBox.sName, oOther.oTestBox.sName); if iCmp != 0: return iCmp; return 0; dUnitSeries[iUnit] = sorted(dUnitSeries[iUnit], key = functools.cmp_to_key(mycmp)); # Split the per-unit series up if necessary. cMaxPerGraph = self._dParams[WuiMain.ksParamGraphWizMaxPerGraph]; aaoRet = []; for aoUnitSeries in dUnitSeries.values(): while len(aoUnitSeries) > cMaxPerGraph: aaoRet.append(aoUnitSeries[:cMaxPerGraph]); aoUnitSeries = aoUnitSeries[cMaxPerGraph:]; if aoUnitSeries: aaoRet.append(aoUnitSeries); return aaoRet; def _configureGraph(self, oGraph): """ Configures oGraph according to user parameters and other config settings. Returns oGraph. """ oGraph.setWidth(self._dParams[WuiMain.ksParamGraphWizWidth]) oGraph.setHeight(self._dParams[WuiMain.ksParamGraphWizHeight]) oGraph.setDpi(self._dParams[WuiMain.ksParamGraphWizDpi]) oGraph.setErrorBarY(self._dParams[WuiMain.ksParamGraphWizErrorBarY]); oGraph.setFontSize(self._dParams[WuiMain.ksParamGraphWizFontSize]); if hasattr(oGraph, 'setXkcdStyle'): oGraph.setXkcdStyle(self._dParams[WuiMain.ksParamGraphWizXkcdStyle]); return oGraph; def _generateInteractiveForm(self): """ Generates the HTML for the interactive form. Returns (sTopOfForm, sEndOfForm) """ # # The top of the form. # sTop = '
\n' \ ' \n' \ ' \n' \ % ( WuiMain.ksParamAction, WuiMain.ksActionGraphWiz, WuiMain.ksParamGraphWizSrcTestSetId, self._dParams[WuiMain.ksParamGraphWizSrcTestSetId], ); sTop += '
\n'; sTop += ' \n' \ % ( WuiMain.ksParamGraphWizWidth, ); # # Top: First row. # sTop += '
\n'; # time. sNow = self._dParams[WuiMain.ksParamEffectiveDate]; if sNow is None: sNow = ''; sTop += '
\n'; sTop += ' \n' \ ' \n' \ % ( WuiMain.ksParamEffectiveDate, WuiMain.ksParamEffectiveDate, WuiMain.ksParamEffectiveDate, sNow, ); sTop += ' \n' % ( WuiMain.ksParamReportPeriods, 1, ); sTop += '
\n'; # Graph options top row. sTop += '
\n'; # graph type. sTop += ' \n' \ ' \n'; # graph size. sTop += ' \n' \ ' x\n' \ ' \n' \ ' '\ ' \n' \ ' \n' \ % ( WuiMain.ksParamGraphWizWidth, WuiMain.ksParamGraphWizWidth, WuiMain.ksParamGraphWizWidth, self._dParams[WuiMain.ksParamGraphWizWidth], WuiMain.ksParamGraphWizHeight, WuiMain.ksParamGraphWizHeight, self._dParams[WuiMain.ksParamGraphWizHeight], WuiMain.ksParamGraphWizDpi, WuiMain.ksParamGraphWizDpi, WuiMain.ksParamGraphWizDpi, self._dParams[WuiMain.ksParamGraphWizDpi], webutils.escapeAttr('return graphwizSetDefaultSizeValues("graphwiz-nav", "%s", "%s", "%s");' % ( WuiMain.ksParamGraphWizWidth, WuiMain.ksParamGraphWizHeight, WuiMain.ksParamGraphWizDpi )), ); sTop += '
\n'; # (options row 1) sTop += '
\n'; # (end of row 1) # # Top: Second row. # sTop += '
\n'; # Submit sFormButton = '\n'; sTop += '
' + sFormButton + '
\n'; # Options. sTop += '
\n'; sTop += ' \n' \ ' \n' \ % ( WuiMain.ksParamGraphWizTabular, WuiMain.ksParamGraphWizTabular, ' checked' if self._dParams[WuiMain.ksParamGraphWizTabular] else '', WuiMain.ksParamGraphWizTabular); if hasattr(self.oGraphClass, 'setXkcdStyle'): sTop += ' \n' \ ' \n' \ % ( WuiMain.ksParamGraphWizXkcdStyle, WuiMain.ksParamGraphWizXkcdStyle, ' checked' if self._dParams[WuiMain.ksParamGraphWizXkcdStyle] else '', WuiMain.ksParamGraphWizXkcdStyle); elif self._dParams[WuiMain.ksParamGraphWizXkcdStyle]: sTop += ' \n' \ % ( WuiMain.ksParamGraphWizXkcdStyle, WuiMain.ksParamGraphWizXkcdStyle, ); if not hasattr(self.oGraphClass, 'kfNoErrorBarsSupport'): sTop += ' \n' \ ' \n' \ ' \n' \ ' \n' \ % ( WuiMain.ksParamGraphWizErrorBarY, WuiMain.ksParamGraphWizErrorBarY, ' checked' if self._dParams[WuiMain.ksParamGraphWizErrorBarY] else '', 'Error bars shows some of the max and min results on the Y-axis.', WuiMain.ksParamGraphWizErrorBarY, WuiMain.ksParamGraphWizMaxErrorBarY, WuiMain.ksParamGraphWizMaxErrorBarY, WuiMain.ksParamGraphWizMaxErrorBarY, self._dParams[WuiMain.ksParamGraphWizMaxErrorBarY], 'Maximum number of Y-axis error bar per graph. (Too many makes it unreadable.)' ); else: if self._dParams[WuiMain.ksParamGraphWizErrorBarY]: sTop += '\n' \ % ( WuiMain.ksParamGraphWizErrorBarY, WuiMain.ksParamGraphWizErrorBarY, ); sTop += '\n' \ % ( WuiMain.ksParamGraphWizMaxErrorBarY, WuiMain.ksParamGraphWizMaxErrorBarY, self._dParams[WuiMain.ksParamGraphWizMaxErrorBarY], ); sTop += ' \n' \ ' \n' \ % ( WuiMain.ksParamGraphWizFontSize, WuiMain.ksParamGraphWizFontSize, WuiMain.ksParamGraphWizFontSize, self._dParams[WuiMain.ksParamGraphWizFontSize], ); sTop += ' \n' \ ' \n' \ % ( WuiMain.ksParamGraphWizMaxPerGraph, WuiMain.ksParamGraphWizMaxPerGraph, WuiMain.ksParamGraphWizMaxPerGraph, self._dParams[WuiMain.ksParamGraphWizMaxPerGraph], 'Max data series per graph.' ); sTop += '
\n'; # (options row 2) sTop += '
\n'; # (end of row 2) sTop += '
\n'; # end of top. # # The end of the page selection. # sEnd = '
\n'; # # Testbox selection # aidTestBoxes = list(self._dParams[WuiMain.ksParamGraphWizTestBoxIds]); sEnd += '
\n' \ '

TestBox Selection:

\n' \ '
    \n'; # Get a list of eligible testboxes from the DB. for oTestBox in self._oModel.getEligibleTestBoxes(): try: aidTestBoxes.remove(oTestBox.idTestBox); except: sChecked = ''; else: sChecked = ' checked'; sEnd += '
  1. ' \ '
  2. \n' \ % ( WuiMain.ksParamGraphWizTestBoxIds, oTestBox.idTestBox, oTestBox.idTestBox, sChecked, oTestBox.idTestBox, oTestBox.sName); # List testboxes that have been checked in a different period or something. for idTestBox in aidTestBoxes: oTestBox = self._oModel.oCache.getTestBox(idTestBox); sEnd += '
  3. ' \ '
  4. \n' \ % ( WuiMain.ksParamGraphWizTestBoxIds, oTestBox.idTestBox, oTestBox.idTestBox, oTestBox.idTestBox, oTestBox.sName); sEnd += '
\n' \ '
\n'; # # Build category selection. # aidBuildCategories = list(self._dParams[WuiMain.ksParamGraphWizBuildCatIds]); sEnd += '
\n' \ '

Build Category Selection:

\n' \ '
    \n'; for oBuildCat in self._oModel.getEligibleBuildCategories(): try: aidBuildCategories.remove(oBuildCat.idBuildCategory); except: sChecked = ''; else: sChecked = ' checked'; sEnd += '
  1. ' \ '
  2. \n' \ % ( WuiMain.ksParamGraphWizBuildCatIds, oBuildCat.idBuildCategory, oBuildCat.idBuildCategory, sChecked, oBuildCat.idBuildCategory, oBuildCat.sProduct, oBuildCat.sBranch, oBuildCat.sType, ' & '.join(oBuildCat.asOsArches) ); assert not aidBuildCategories; # SQL should return all currently selected. sEnd += '
\n' \ '
\n'; # # Testcase variations. # sEnd += '
\n' \ '

Miscellaneous:

\n' \ '
    '; sEnd += '
  1. \n' \ ' \n' \ ' \n' \ '
  2. \n' \ % ( WuiMain.ksParamGraphWizSepTestVars, WuiMain.ksParamGraphWizSepTestVars, ' checked' if self._dParams[WuiMain.ksParamGraphWizSepTestVars] else '', WuiMain.ksParamGraphWizSepTestVars ); sEnd += '
  3. \n' \ ' Test case ID:\n' \ ' \n' \ '
  4. \n' \ % ( WuiMain.ksParamGraphWizTestCaseIds, WuiMain.ksParamGraphWizTestCaseIds, WuiMain.ksParamGraphWizTestCaseIds, ','.join([str(i) for i in self._dParams[WuiMain.ksParamGraphWizTestCaseIds]]), ); sEnd += '
\n' \ '
\n'; #sEnd += '

 

\n'; # # Finish up the form. # sEnd += '

' + sFormButton + '

\n'; sEnd += '
\n' \ '
\n'; return (sTop, sEnd); def generateReportBody(self): fInteractive = not self._fSubReport; # Quick mockup. self._sTitle = 'Graph Wizzard'; sHtml = ''; sHtml += '

Incomplete code - no complaints yet, thank you!!

\n'; # # Create a form for altering the data we're working with. # if fInteractive: (sTopOfForm, sEndOfForm) = self._generateInteractiveForm(); sHtml += sTopOfForm; del sTopOfForm; # # Emit the graphs. At least one per sample source. # sHtml += '
\n'; iGraph = 0; aoCollections = self._oModel.fetchGraphData(); for iCollection, oCollection in enumerate(aoCollections): # Name the graph and add a checkbox for removing it. sSampleName = self._calcSampleName(oCollection); sHtml += '
\n' % (iCollection,); if fInteractive: sHtml += '
\n' \ ' \n' \ ' \n' \ '
\n' \ % ( WuiMain.ksParamReportSubjectIds, WuiMain.ksParamReportSubjectIds, oCollection.sType, ':'.join([str(idStr) for idStr in oCollection.aidStrTests]), ':%u' % oCollection.idStrValue if oCollection.idStrValue else '', WuiMain.ksParamReportSubjectIds, sSampleName ); if oCollection.aoSeries: # # Split the series into sub-graphs as needed and produce SVGs. # aaoSeries = self._splitSeries(oCollection.aoSeries); for aoSeries in aaoSeries: # Gather the data for this graph. (Most big stuff is passed by # reference, so there shouldn't be any large memory penalty for # repacking the data here.) sYUnit = None; if aoSeries[0].iUnit < len(constants.valueunit.g_asNames) and aoSeries[0].iUnit > 0: sYUnit = constants.valueunit.g_asNames[aoSeries[0].iUnit]; oData = WuiHlpGraphDataTableEx(sXUnit = 'Build revision', sYUnit = sYUnit); fSeriesName = self._figureSeriesNameBits(aoSeries); for oSeries in aoSeries: sSeriesName = self._getSeriesNameFromBits(oSeries, fSeriesName); asHtmlTooltips = None; if len(oSeries.aoRevInfo) == len(oSeries.aiRevisions): asHtmlTooltips = []; for i, oRevInfo in enumerate(oSeries.aoRevInfo): sPlusMinus = ''; if oSeries.acSamples[i] > 1: sPlusMinus = ' (+%s/-%s; %u samples)' \ % ( utils.formatNumber(oSeries.aiErrorBarAbove[i]), utils.formatNumber(oSeries.aiErrorBarBelow[i]), oSeries.acSamples[i]) sTooltip = ''\ '' \ % ( sSeriesName, utils.formatNumber(oSeries.aiValues[i]), sYUnit, sPlusMinus, oSeries.aiRevisions[i], ); if oRevInfo.sAuthor is not None: sMsg = oRevInfo.sMessage[:80].strip(); #if sMsg.find('\n') >= 0: # sMsg = sMsg[:sMsg.find('\n')].strip(); sTooltip += '' \ '' \ '' \ % ( oRevInfo.sAuthor, self.formatTsShort(oRevInfo.tsCreated), sMsg, '...' if len(oRevInfo.sMessage) > len(sMsg) else ''); sTooltip += '
%s:%s %s %s
Rev:r%s
Author:%s
Date:%s
Message:%s%s
'; asHtmlTooltips.append(sTooltip); oData.addDataSeries(sSeriesName, oSeries.aiRevisions, oSeries.aiValues, asHtmlTooltips, oSeries.aiErrorBarBelow, oSeries.aiErrorBarAbove); # Render the data into a graph. oGraph = self.oGraphClass('tmgraph-%u' % (iGraph,), oData, self._oDisp); self._configureGraph(oGraph); oGraph.setTitle(self._calcGraphName(aoSeries[0], fSeriesName, sSampleName)); sHtml += '
\n' % (iGraph,); sHtml += oGraph.renderGraph(); sHtml += '\n
\n'; iGraph += 1; # # Emit raw tabular data if requested. # if self._dParams[WuiMain.ksParamGraphWizTabular]: sHtml += '
\n' \ ' \n' \ % (iCollection, ); for aoSeries in aaoSeries: if aoSeries[0].iUnit < len(constants.valueunit.g_asNames) and aoSeries[0].iUnit > 0: sUnit = constants.valueunit.g_asNames[aoSeries[0].iUnit]; else: sUnit = str(aoSeries[0].iUnit); for iSeries, oSeries in enumerate(aoSeries): sColor = self.oGraphClass.calcSeriesColor(iSeries); sHtml += '\n' \ ' \n' \ ' \n' \ ' \n' \ ' \n' \ ' ' \ '\n' \ ' \n' \ '\n' \ % ( sColor, self._getSeriesNameFromBits(oSeries, self.kfSeriesName_All & ~self.kfSeriesName_OsArchs), sUnit ); for i, iRevision in enumerate(oSeries.aiRevisions): sHtml += ' \n' \ % ( 'tmodd' if i & 1 else 'tmeven', iRevision, oSeries.aiValues[i], oSeries.aiErrorBarAbove[i], oSeries.aiErrorBarBelow[i], oSeries.acSamples[i]); sHtml += '
   %s
RevisionValue (%s)ΔmaxΔminSamples
r%s%s+%s-%s%s
\n' \ '
\n'; else: sHtml += 'No results.\n'; sHtml += '
\n' sHtml += '
\n'; # # Finish the form. # if fInteractive: sHtml += sEndOfForm; return sHtml;