Home | History | Annotate | Download | only in static
      1 /*
      2  * Loader:
      3  * Reads GM result reports written out by results.py, and imports
      4  * them into $scope.extraColumnHeaders and $scope.imagePairs .
      5  */
      6 var Loader = angular.module(
      7     'Loader',
      8     ['ConstantsModule']
      9 );
     10 
     11 Loader.directive(
     12   'resultsUpdatedCallbackDirective',
     13   ['$timeout',
     14    function($timeout) {
     15      return function(scope, element, attrs) {
     16        if (scope.$last) {
     17          $timeout(function() {
     18            scope.resultsUpdatedCallback();
     19          });
     20        }
     21      };
     22    }
     23   ]
     24 );
     25 
     26 // TODO(epoger): Combine ALL of our filtering operations (including
     27 // truncation) into this one filter, so that runs most efficiently?
     28 // (We would have to make sure truncation still took place after
     29 // sorting, though.)
     30 Loader.filter(
     31   'removeHiddenImagePairs',
     32   function(constants) {
     33     return function(unfilteredImagePairs, filterableColumnNames, showingColumnValues,
     34                     viewingTab) {
     35       var filteredImagePairs = [];
     36       for (var i = 0; i < unfilteredImagePairs.length; i++) {
     37         var imagePair = unfilteredImagePairs[i];
     38         var extraColumnValues = imagePair[constants.KEY__IMAGEPAIRS__EXTRACOLUMNS];
     39         var allColumnValuesAreVisible = true;
     40         // Loop over all columns, and if any of them contain values not found in
     41         // showingColumnValues[columnName], don't include this imagePair.
     42         //
     43         // We use this same filtering mechanism regardless of whether each column
     44         // has USE_FREEFORM_FILTER set or not; if that flag is set, then we will
     45         // have already used the freeform text entry block to populate
     46         // showingColumnValues[columnName].
     47         for (var j = 0; j < filterableColumnNames.length; j++) {
     48           var columnName = filterableColumnNames[j];
     49           var columnValue = extraColumnValues[columnName];
     50           if (!showingColumnValues[columnName][columnValue]) {
     51             allColumnValuesAreVisible = false;
     52             break;
     53           }
     54         }
     55         if (allColumnValuesAreVisible && (viewingTab == imagePair.tab)) {
     56           filteredImagePairs.push(imagePair);
     57         }
     58       }
     59       return filteredImagePairs;
     60     };
     61   }
     62 );
     63 
     64 /**
     65  * Limit the input imagePairs to some max number, and merge identical rows
     66  * (adjacent rows which have the same (imageA, imageB) pair).
     67  *
     68  * @param unfilteredImagePairs imagePairs to filter
     69  * @param maxPairs maximum number of pairs to output, or <0 for no limit
     70  * @param mergeIdenticalRows if true, merge identical rows by setting
     71  *     ROWSPAN>1 on the first merged row, and ROWSPAN=0 for the rest
     72  */
     73 Loader.filter(
     74   'mergeAndLimit',
     75   function(constants) {
     76     return function(unfilteredImagePairs, maxPairs, mergeIdenticalRows) {
     77       var numPairs = unfilteredImagePairs.length;
     78       if ((maxPairs > 0) && (maxPairs < numPairs)) {
     79         numPairs = maxPairs;
     80       }
     81       var filteredImagePairs = [];
     82       if (!mergeIdenticalRows || (numPairs == 1)) {
     83         // Take a shortcut if we're not merging identical rows.
     84         // We still need to set ROWSPAN to 1 for each row, for the HTML viewer.
     85         for (var i = numPairs-1; i >= 0; i--) {
     86           var imagePair = unfilteredImagePairs[i];
     87           imagePair[constants.KEY__IMAGEPAIRS__ROWSPAN] = 1;
     88           filteredImagePairs[i] = imagePair;
     89         }
     90       } else if (numPairs > 1) {
     91         // General case--there are at least 2 rows, so we may need to merge some.
     92         // Work from the bottom up, so we can keep a running total of how many
     93         // rows should be merged, and set ROWSPAN of the top row accordingly.
     94         var imagePair = unfilteredImagePairs[numPairs-1];
     95         var nextRowImageAUrl = imagePair[constants.KEY__IMAGEPAIRS__IMAGE_A_URL];
     96         var nextRowImageBUrl = imagePair[constants.KEY__IMAGEPAIRS__IMAGE_B_URL];
     97         imagePair[constants.KEY__IMAGEPAIRS__ROWSPAN] = 1;
     98         filteredImagePairs[numPairs-1] = imagePair;
     99         for (var i = numPairs-2; i >= 0; i--) {
    100           imagePair = unfilteredImagePairs[i];
    101           var thisRowImageAUrl = imagePair[constants.KEY__IMAGEPAIRS__IMAGE_A_URL];
    102           var thisRowImageBUrl = imagePair[constants.KEY__IMAGEPAIRS__IMAGE_B_URL];
    103           if ((thisRowImageAUrl == nextRowImageAUrl) &&
    104               (thisRowImageBUrl == nextRowImageBUrl)) {
    105             imagePair[constants.KEY__IMAGEPAIRS__ROWSPAN] =
    106                 filteredImagePairs[i+1][constants.KEY__IMAGEPAIRS__ROWSPAN] + 1;
    107             filteredImagePairs[i+1][constants.KEY__IMAGEPAIRS__ROWSPAN] = 0;
    108           } else {
    109             imagePair[constants.KEY__IMAGEPAIRS__ROWSPAN] = 1;
    110             nextRowImageAUrl = thisRowImageAUrl;
    111             nextRowImageBUrl = thisRowImageBUrl;
    112           }
    113           filteredImagePairs[i] = imagePair;
    114         }
    115       } else {
    116         // No results.
    117       }
    118       return filteredImagePairs;
    119     };
    120   }
    121 );
    122 
    123 
    124 Loader.controller(
    125   'Loader.Controller',
    126     function($scope, $http, $filter, $location, $log, $timeout, constants) {
    127     $scope.readyToDisplay = false;
    128     $scope.constants = constants;
    129     $scope.windowTitle = "Loading GM Results...";
    130     $scope.resultsToLoad = $location.search().resultsToLoad;
    131     $scope.loadingMessage = "please wait...";
    132 
    133     var currSortAsc = true;
    134 
    135 
    136     /**
    137      * On initial page load, load a full dictionary of results.
    138      * Once the dictionary is loaded, unhide the page elements so they can
    139      * render the data.
    140      */
    141     $http.get($scope.resultsToLoad).success(
    142       function(data, status, header, config) {
    143         var dataHeader = data[constants.KEY__ROOT__HEADER];
    144         if (dataHeader[constants.KEY__HEADER__SCHEMA_VERSION] !=
    145             constants.VALUE__HEADER__SCHEMA_VERSION) {
    146           $scope.loadingMessage = "ERROR: Got JSON file with schema version "
    147               + dataHeader[constants.KEY__HEADER__SCHEMA_VERSION]
    148               + " but expected schema version "
    149               + constants.VALUE__HEADER__SCHEMA_VERSION;
    150         } else if (dataHeader[constants.KEY__HEADER__IS_STILL_LOADING]) {
    151           // Apply the server's requested reload delay to local time,
    152           // so we will wait the right number of seconds regardless of clock
    153           // skew between client and server.
    154           var reloadDelayInSeconds =
    155               dataHeader[constants.KEY__HEADER__TIME_NEXT_UPDATE_AVAILABLE] -
    156               dataHeader[constants.KEY__HEADER__TIME_UPDATED];
    157           var timeNow = new Date().getTime();
    158           var timeToReload = timeNow + reloadDelayInSeconds * 1000;
    159           $scope.loadingMessage =
    160               "server is still loading results; will retry at " +
    161               $scope.localTimeString(timeToReload / 1000);
    162           $timeout(
    163               function(){location.reload();},
    164               timeToReload - timeNow);
    165         } else {
    166           $scope.loadingMessage = "processing data, please wait...";
    167 
    168           $scope.header = dataHeader;
    169           $scope.extraColumnHeaders = data[constants.KEY__ROOT__EXTRACOLUMNHEADERS];
    170           $scope.orderedColumnNames = data[constants.KEY__ROOT__EXTRACOLUMNORDER];
    171           $scope.imagePairs = data[constants.KEY__ROOT__IMAGEPAIRS];
    172           $scope.imageSets = data[constants.KEY__ROOT__IMAGESETS];
    173 
    174           // set the default sort column and make it ascending.
    175           $scope.sortColumnSubdict = constants.KEY__IMAGEPAIRS__DIFFERENCES;
    176           $scope.sortColumnKey = constants.KEY__DIFFERENCES__PERCEPTUAL_DIFF;
    177           currSortAsc = true;
    178 
    179           $scope.showSubmitAdvancedSettings = false;
    180           $scope.submitAdvancedSettings = {};
    181           $scope.submitAdvancedSettings[
    182               constants.KEY__EXPECTATIONS__REVIEWED] = true;
    183           $scope.submitAdvancedSettings[
    184               constants.KEY__EXPECTATIONS__IGNOREFAILURE] = false;
    185           $scope.submitAdvancedSettings['bug'] = '';
    186 
    187           // Create the list of tabs (lists into which the user can file each
    188           // test).  This may vary, depending on isEditable.
    189           $scope.tabs = [
    190             'Unfiled', 'Hidden'
    191           ];
    192           if (dataHeader[constants.KEY__HEADER__IS_EDITABLE]) {
    193             $scope.tabs = $scope.tabs.concat(
    194                 ['Pending Approval']);
    195           }
    196           $scope.defaultTab = $scope.tabs[0];
    197           $scope.viewingTab = $scope.defaultTab;
    198 
    199           // Track the number of results on each tab.
    200           $scope.numResultsPerTab = {};
    201           for (var i = 0; i < $scope.tabs.length; i++) {
    202             $scope.numResultsPerTab[$scope.tabs[i]] = 0;
    203           }
    204           $scope.numResultsPerTab[$scope.defaultTab] = $scope.imagePairs.length;
    205 
    206           // Add index and tab fields to all records.
    207           for (var i = 0; i < $scope.imagePairs.length; i++) {
    208             $scope.imagePairs[i].index = i;
    209             $scope.imagePairs[i].tab = $scope.defaultTab;
    210           }
    211 
    212           // Arrays within which the user can toggle individual elements.
    213           $scope.selectedImagePairs = [];
    214 
    215           // Set up filters.
    216           //
    217           // filterableColumnNames is a list of all column names we can filter on.
    218           // allColumnValues[columnName] is a list of all known values
    219           // for a given column.
    220           // showingColumnValues[columnName] is a set indicating which values
    221           // in a given column would cause us to show a row, rather than hiding it.
    222           //
    223           // columnStringMatch[columnName] is a string used as a pattern to generate
    224           // showingColumnValues[columnName] for columns we filter using free-form text.
    225           // It is ignored for any columns with USE_FREEFORM_FILTER == false.
    226           $scope.filterableColumnNames = [];
    227           $scope.allColumnValues = {};
    228           $scope.showingColumnValues = {};
    229           $scope.columnStringMatch = {};
    230 
    231           angular.forEach(
    232             Object.keys($scope.extraColumnHeaders),
    233             function(columnName) {
    234               var columnHeader = $scope.extraColumnHeaders[columnName];
    235               if (columnHeader[constants.KEY__EXTRACOLUMNHEADERS__IS_FILTERABLE]) {
    236                 $scope.filterableColumnNames.push(columnName);
    237                 $scope.allColumnValues[columnName] = $scope.columnSliceOf2DArray(
    238                     columnHeader[constants.KEY__EXTRACOLUMNHEADERS__VALUES_AND_COUNTS], 0);
    239                 $scope.showingColumnValues[columnName] = {};
    240                 $scope.toggleValuesInSet($scope.allColumnValues[columnName],
    241                                          $scope.showingColumnValues[columnName]);
    242                 $scope.columnStringMatch[columnName] = "";
    243               }
    244             }
    245           );
    246 
    247           // TODO(epoger): Special handling for RESULT_TYPE column:
    248           // by default, show only KEY__RESULT_TYPE__FAILED results
    249           $scope.showingColumnValues[constants.KEY__EXTRACOLUMNS__RESULT_TYPE] = {};
    250           $scope.showingColumnValues[constants.KEY__EXTRACOLUMNS__RESULT_TYPE][
    251               constants.KEY__RESULT_TYPE__FAILED] = true;
    252 
    253           // Set up mapping for URL parameters.
    254           // parameter name -> copier object to load/save parameter value
    255           $scope.queryParameters.map = {
    256             'resultsToLoad':         $scope.queryParameters.copiers.simple,
    257             'displayLimitPending':   $scope.queryParameters.copiers.simple,
    258             'showThumbnailsPending': $scope.queryParameters.copiers.simple,
    259             'mergeIdenticalRowsPending': $scope.queryParameters.copiers.simple,
    260             'imageSizePending':      $scope.queryParameters.copiers.simple,
    261             'sortColumnSubdict':     $scope.queryParameters.copiers.simple,
    262             'sortColumnKey':         $scope.queryParameters.copiers.simple,
    263           };
    264           // Some parameters are handled differently based on whether they USE_FREEFORM_FILTER.
    265           angular.forEach(
    266             $scope.filterableColumnNames,
    267             function(columnName) {
    268               if ($scope.extraColumnHeaders[columnName]
    269                   [constants.KEY__EXTRACOLUMNHEADERS__USE_FREEFORM_FILTER]) {
    270                 $scope.queryParameters.map[columnName] =
    271                     $scope.queryParameters.copiers.columnStringMatch;
    272               } else {
    273                 $scope.queryParameters.map[columnName] =
    274                     $scope.queryParameters.copiers.showingColumnValuesSet;
    275               }
    276             }
    277           );
    278 
    279           // If any defaults were overridden in the URL, get them now.
    280           $scope.queryParameters.load();
    281 
    282           // Any image URLs which are relative should be relative to the JSON
    283           // file's source directory; absolute URLs should be left alone.
    284           var baseUrlKey = constants.KEY__IMAGESETS__FIELD__BASE_URL;
    285           angular.forEach(
    286             $scope.imageSets,
    287             function(imageSet) {
    288               var baseUrl = imageSet[baseUrlKey];
    289               if ((baseUrl.substring(0, 1) != '/') &&
    290                   (baseUrl.indexOf('://') == -1)) {
    291                 imageSet[baseUrlKey] = $scope.resultsToLoad + '/../' + baseUrl;
    292               }
    293             }
    294           );
    295 
    296           $scope.readyToDisplay = true;
    297           $scope.updateResults();
    298           $scope.loadingMessage = "";
    299           $scope.windowTitle = "Current GM Results";
    300 
    301           $timeout( function() {
    302             make_results_header_sticky();
    303           });
    304         }
    305       }
    306     ).error(
    307       function(data, status, header, config) {
    308         $scope.loadingMessage = "FAILED to load.";
    309         $scope.windowTitle = "Failed to Load GM Results";
    310       }
    311     );
    312 
    313 
    314     //
    315     // Select/Clear/Toggle all tests.
    316     //
    317 
    318     /**
    319      * Select all currently showing tests.
    320      */
    321     $scope.selectAllImagePairs = function() {
    322       var numImagePairsShowing = $scope.limitedImagePairs.length;
    323       for (var i = 0; i < numImagePairsShowing; i++) {
    324         var index = $scope.limitedImagePairs[i].index;
    325         if (!$scope.isValueInArray(index, $scope.selectedImagePairs)) {
    326           $scope.toggleValueInArray(index, $scope.selectedImagePairs);
    327         }
    328       }
    329     };
    330 
    331     /**
    332      * Deselect all currently showing tests.
    333      */
    334     $scope.clearAllImagePairs = function() {
    335       var numImagePairsShowing = $scope.limitedImagePairs.length;
    336       for (var i = 0; i < numImagePairsShowing; i++) {
    337         var index = $scope.limitedImagePairs[i].index;
    338         if ($scope.isValueInArray(index, $scope.selectedImagePairs)) {
    339           $scope.toggleValueInArray(index, $scope.selectedImagePairs);
    340         }
    341       }
    342     };
    343 
    344     /**
    345      * Toggle selection of all currently showing tests.
    346      */
    347     $scope.toggleAllImagePairs = function() {
    348       var numImagePairsShowing = $scope.limitedImagePairs.length;
    349       for (var i = 0; i < numImagePairsShowing; i++) {
    350         var index = $scope.limitedImagePairs[i].index;
    351         $scope.toggleValueInArray(index, $scope.selectedImagePairs);
    352       }
    353     };
    354 
    355     /**
    356      * Toggle selection state of a subset of the currently showing tests.
    357      *
    358      * @param startIndex index within $scope.limitedImagePairs of the first
    359      *     test to toggle selection state of
    360      * @param num number of tests (in a contiguous block) to toggle
    361      */
    362     $scope.toggleSomeImagePairs = function(startIndex, num) {
    363       var numImagePairsShowing = $scope.limitedImagePairs.length;
    364       for (var i = startIndex; i < startIndex + num; i++) {
    365         var index = $scope.limitedImagePairs[i].index;
    366         $scope.toggleValueInArray(index, $scope.selectedImagePairs);
    367       }
    368     };
    369 
    370 
    371     //
    372     // Tab operations.
    373     //
    374 
    375     /**
    376      * Change the selected tab.
    377      *
    378      * @param tab (string): name of the tab to select
    379      */
    380     $scope.setViewingTab = function(tab) {
    381       $scope.viewingTab = tab;
    382       $scope.updateResults();
    383     };
    384 
    385     /**
    386      * Move the imagePairs in $scope.selectedImagePairs to a different tab,
    387      * and then clear $scope.selectedImagePairs.
    388      *
    389      * @param newTab (string): name of the tab to move the tests to
    390      */
    391     $scope.moveSelectedImagePairsToTab = function(newTab) {
    392       $scope.moveImagePairsToTab($scope.selectedImagePairs, newTab);
    393       $scope.selectedImagePairs = [];
    394       $scope.updateResults();
    395     };
    396 
    397     /**
    398      * Move a subset of $scope.imagePairs to a different tab.
    399      *
    400      * @param imagePairIndices (array of ints): indices into $scope.imagePairs
    401      *        indicating which test results to move
    402      * @param newTab (string): name of the tab to move the tests to
    403      */
    404     $scope.moveImagePairsToTab = function(imagePairIndices, newTab) {
    405       var imagePairIndex;
    406       var numImagePairs = imagePairIndices.length;
    407       for (var i = 0; i < numImagePairs; i++) {
    408         imagePairIndex = imagePairIndices[i];
    409         $scope.numResultsPerTab[$scope.imagePairs[imagePairIndex].tab]--;
    410         $scope.imagePairs[imagePairIndex].tab = newTab;
    411       }
    412       $scope.numResultsPerTab[newTab] += numImagePairs;
    413     };
    414 
    415 
    416     //
    417     // $scope.queryParameters:
    418     // Transfer parameter values between $scope and the URL query string.
    419     //
    420     $scope.queryParameters = {};
    421 
    422     // load and save functions for parameters of each type
    423     // (load a parameter value into $scope from nameValuePairs,
    424     //  save a parameter value from $scope into nameValuePairs)
    425     $scope.queryParameters.copiers = {
    426       'simple': {
    427         'load': function(nameValuePairs, name) {
    428           var value = nameValuePairs[name];
    429           if (value) {
    430             $scope[name] = value;
    431           }
    432         },
    433         'save': function(nameValuePairs, name) {
    434           nameValuePairs[name] = $scope[name];
    435         }
    436       },
    437 
    438       'columnStringMatch': {
    439         'load': function(nameValuePairs, name) {
    440           var value = nameValuePairs[name];
    441           if (value) {
    442             $scope.columnStringMatch[name] = value;
    443           }
    444         },
    445         'save': function(nameValuePairs, name) {
    446           nameValuePairs[name] = $scope.columnStringMatch[name];
    447         }
    448       },
    449 
    450       'showingColumnValuesSet': {
    451         'load': function(nameValuePairs, name) {
    452           var value = nameValuePairs[name];
    453           if (value) {
    454             var valueArray = value.split(',');
    455             $scope.showingColumnValues[name] = {};
    456             $scope.toggleValuesInSet(valueArray, $scope.showingColumnValues[name]);
    457           }
    458         },
    459         'save': function(nameValuePairs, name) {
    460           nameValuePairs[name] = Object.keys($scope.showingColumnValues[name]).join(',');
    461         }
    462       },
    463 
    464     };
    465 
    466     // Loads all parameters into $scope from the URL query string;
    467     // any which are not found within the URL will keep their current value.
    468     $scope.queryParameters.load = function() {
    469       var nameValuePairs = $location.search();
    470 
    471       // If urlSchemaVersion is not specified, we assume the current version.
    472       var urlSchemaVersion = constants.URL_VALUE__SCHEMA_VERSION__CURRENT;
    473       if (constants.URL_KEY__SCHEMA_VERSION in nameValuePairs) {
    474         urlSchemaVersion = nameValuePairs[constants.URL_KEY__SCHEMA_VERSION];
    475       } else if ('hiddenResultTypes' in nameValuePairs) {
    476         // The combination of:
    477         // - absence of an explicit urlSchemaVersion, and
    478         // - presence of the old 'hiddenResultTypes' field
    479         // tells us that the URL is from the original urlSchemaVersion.
    480         // See https://codereview.chromium.org/367173002/
    481         urlSchemaVersion = 0;
    482       }
    483       $scope.urlSchemaVersionLoaded = urlSchemaVersion;
    484 
    485       if (urlSchemaVersion != constants.URL_VALUE__SCHEMA_VERSION__CURRENT) {
    486         nameValuePairs = $scope.upconvertUrlNameValuePairs(nameValuePairs, urlSchemaVersion);
    487       }
    488       angular.forEach($scope.queryParameters.map,
    489                       function(copier, paramName) {
    490                         copier.load(nameValuePairs, paramName);
    491                       }
    492                      );
    493     };
    494 
    495     // Saves all parameters from $scope into the URL query string.
    496     $scope.queryParameters.save = function() {
    497       var nameValuePairs = {};
    498       nameValuePairs[constants.URL_KEY__SCHEMA_VERSION] = constants.URL_VALUE__SCHEMA_VERSION__CURRENT;
    499       angular.forEach($scope.queryParameters.map,
    500                       function(copier, paramName) {
    501                         copier.save(nameValuePairs, paramName);
    502                       }
    503                      );
    504       $location.search(nameValuePairs);
    505     };
    506 
    507     /**
    508      * Converts URL name/value pairs that were stored by a previous urlSchemaVersion
    509      * to the currently needed format.
    510      *
    511      * @param oldNValuePairs name/value pairs found in the loaded URL
    512      * @param oldUrlSchemaVersion which version of the schema was used to generate that URL
    513      *
    514      * @returns nameValuePairs as needed by the current URL parser
    515      */
    516     $scope.upconvertUrlNameValuePairs = function(oldNameValuePairs, oldUrlSchemaVersion) {
    517       var newNameValuePairs = {};
    518       angular.forEach(oldNameValuePairs,
    519                       function(value, name) {
    520                         if (oldUrlSchemaVersion < 1) {
    521                           if ('hiddenConfigs' == name) {
    522                             name = 'config';
    523                             var valueSet = {};
    524                             $scope.toggleValuesInSet(value.split(','), valueSet);
    525                             $scope.toggleValuesInSet(
    526                                 $scope.allColumnValues[constants.KEY__EXTRACOLUMNS__CONFIG],
    527                                 valueSet);
    528                             value = Object.keys(valueSet).join(',');
    529                           } else if ('hiddenResultTypes' == name) {
    530                             name = 'resultType';
    531                             var valueSet = {};
    532                             $scope.toggleValuesInSet(value.split(','), valueSet);
    533                             $scope.toggleValuesInSet(
    534                                 $scope.allColumnValues[constants.KEY__EXTRACOLUMNS__RESULT_TYPE],
    535                                 valueSet);
    536                             value = Object.keys(valueSet).join(',');
    537                           }
    538                         }
    539 
    540                         newNameValuePairs[name] = value;
    541                       }
    542                      );
    543       return newNameValuePairs;
    544     }
    545 
    546 
    547     //
    548     // updateResults() and friends.
    549     //
    550 
    551     /**
    552      * Set $scope.areUpdatesPending (to enable/disable the Update Results
    553      * button).
    554      *
    555      * TODO(epoger): We could reduce the amount of code by just setting the
    556      * variable directly (from, e.g., a button's ng-click handler).  But when
    557      * I tried that, the HTML elements depending on the variable did not get
    558      * updated.
    559      * It turns out that this is due to variable scoping within an ng-repeat
    560      * element; see http://stackoverflow.com/questions/15388344/behavior-of-assignment-expression-invoked-by-ng-click-within-ng-repeat
    561      *
    562      * @param val boolean value to set $scope.areUpdatesPending to
    563      */
    564     $scope.setUpdatesPending = function(val) {
    565       $scope.areUpdatesPending = val;
    566     }
    567 
    568     /**
    569      * Update the displayed results, based on filters/settings,
    570      * and call $scope.queryParameters.save() so that the new filter results
    571      * can be bookmarked.
    572      */
    573     $scope.updateResults = function() {
    574       $scope.renderStartTime = window.performance.now();
    575       $log.debug("renderStartTime: " + $scope.renderStartTime);
    576       $scope.displayLimit = $scope.displayLimitPending;
    577       $scope.mergeIdenticalRows = $scope.mergeIdenticalRowsPending;
    578 
    579       // For each USE_FREEFORM_FILTER column, populate showingColumnValues.
    580       // This is more efficient than applying the freeform filter within the
    581       // tight loop in removeHiddenImagePairs.
    582       angular.forEach(
    583         $scope.filterableColumnNames,
    584         function(columnName) {
    585           var columnHeader = $scope.extraColumnHeaders[columnName];
    586           if (columnHeader[constants.KEY__EXTRACOLUMNHEADERS__USE_FREEFORM_FILTER]) {
    587             var columnStringMatch = $scope.columnStringMatch[columnName];
    588             var showingColumnValues = {};
    589             angular.forEach(
    590               $scope.allColumnValues[columnName],
    591               function(columnValue) {
    592                 if (-1 != columnValue.indexOf(columnStringMatch)) {
    593                   showingColumnValues[columnValue] = true;
    594                 }
    595               }
    596             );
    597             $scope.showingColumnValues[columnName] = showingColumnValues;
    598           }
    599         }
    600       );
    601 
    602       // TODO(epoger): Every time we apply a filter, AngularJS creates
    603       // another copy of the array.  Is there a way we can filter out
    604       // the imagePairs as they are displayed, rather than storing multiple
    605       // array copies?  (For better performance.)
    606       if ($scope.viewingTab == $scope.defaultTab) {
    607         var doReverse = !currSortAsc;
    608 
    609         $scope.filteredImagePairs =
    610             $filter("orderBy")(
    611                 $filter("removeHiddenImagePairs")(
    612                     $scope.imagePairs,
    613                     $scope.filterableColumnNames,
    614                     $scope.showingColumnValues,
    615                     $scope.viewingTab
    616                 ),
    617                 // [$scope.getSortColumnValue, $scope.getSecondOrderSortValue],
    618                 $scope.getSortColumnValue,
    619                 doReverse);
    620         $scope.limitedImagePairs = $filter("mergeAndLimit")(
    621             $scope.filteredImagePairs, $scope.displayLimit, $scope.mergeIdenticalRows);
    622       } else {
    623         $scope.filteredImagePairs =
    624             $filter("orderBy")(
    625                 $filter("filter")(
    626                     $scope.imagePairs,
    627                     {tab: $scope.viewingTab},
    628                     true
    629                 ),
    630                 // [$scope.getSortColumnValue, $scope.getSecondOrderSortValue]);
    631                 $scope.getSortColumnValue);
    632         $scope.limitedImagePairs = $filter("mergeAndLimit")(
    633             $scope.filteredImagePairs, -1, $scope.mergeIdenticalRows);
    634       }
    635       $scope.showThumbnails = $scope.showThumbnailsPending;
    636       $scope.imageSize = $scope.imageSizePending;
    637       $scope.setUpdatesPending(false);
    638       $scope.queryParameters.save();
    639     }
    640 
    641     /**
    642      * This function is called when the results have been completely rendered
    643      * after updateResults().
    644      */
    645     $scope.resultsUpdatedCallback = function() {
    646       $scope.renderEndTime = window.performance.now();
    647       $log.debug("renderEndTime: " + $scope.renderEndTime);
    648     };
    649 
    650     /**
    651      * Re-sort the displayed results.
    652      *
    653      * @param subdict (string): which KEY__IMAGEPAIRS__* subdictionary
    654      *     the sort column key is within, or 'none' if the sort column
    655      *     key is one of KEY__IMAGEPAIRS__*
    656      * @param key (string): sort by value associated with this key in subdict
    657      */
    658     $scope.sortResultsBy = function(subdict, key) {
    659       // if we are already sorting by this column then toggle between asc/desc
    660       if ((subdict === $scope.sortColumnSubdict) && ($scope.sortColumnKey === key)) {
    661         currSortAsc = !currSortAsc;
    662       } else {
    663         $scope.sortColumnSubdict = subdict;
    664         $scope.sortColumnKey = key;
    665         currSortAsc = true;
    666       }
    667       $scope.updateResults();
    668     };
    669 
    670     /**
    671      * Returns ASC or DESC (from constants) if currently the data
    672      * is sorted by the provided column.
    673      *
    674      * @param colName: name of the column for which we need to get the class.
    675      */
    676 
    677     $scope.sortedByColumnsCls = function (colName) {
    678       if ($scope.sortColumnKey !== colName) {
    679         return '';
    680       }
    681 
    682       var result = (currSortAsc) ? constants.ASC : constants.DESC;
    683       console.log("sort class:", result);
    684       return result;
    685     };
    686 
    687     /**
    688      * For a particular ImagePair, return the value of the column we are
    689      * sorting on (according to $scope.sortColumnSubdict and
    690      * $scope.sortColumnKey).
    691      *
    692      * @param imagePair: imagePair to get a column value out of.
    693      */
    694     $scope.getSortColumnValue = function(imagePair) {
    695       if ($scope.sortColumnSubdict in imagePair) {
    696         return imagePair[$scope.sortColumnSubdict][$scope.sortColumnKey];
    697       } else if ($scope.sortColumnKey in imagePair) {
    698         return imagePair[$scope.sortColumnKey];
    699       } else {
    700         return undefined;
    701       }
    702     };
    703 
    704     /**
    705      * For a particular ImagePair, return the value we use for the
    706      * second-order sort (tiebreaker when multiple rows have
    707      * the same getSortColumnValue()).
    708      *
    709      * We join the imageA and imageB urls for this value, so that we merge
    710      * adjacent rows as much as possible.
    711      *
    712      * @param imagePair: imagePair to get a column value out of.
    713      */
    714     $scope.getSecondOrderSortValue = function(imagePair) {
    715       return imagePair[constants.KEY__IMAGEPAIRS__IMAGE_A_URL] + "-vs-" +
    716           imagePair[constants.KEY__IMAGEPAIRS__IMAGE_B_URL];
    717     };
    718 
    719     /**
    720      * Set $scope.columnStringMatch[name] = value, and update results.
    721      *
    722      * @param name
    723      * @param value
    724      */
    725     $scope.setColumnStringMatch = function(name, value) {
    726       $scope.columnStringMatch[name] = value;
    727       $scope.updateResults();
    728     };
    729 
    730     /**
    731      * Update $scope.showingColumnValues[columnName] and $scope.columnStringMatch[columnName]
    732      * so that ONLY entries with this columnValue are showing, and update the visible results.
    733      * (We update both of those, so we cover both freeform and checkbox filtered columns.)
    734      *
    735      * @param columnName
    736      * @param columnValue
    737      */
    738     $scope.showOnlyColumnValue = function(columnName, columnValue) {
    739       $scope.columnStringMatch[columnName] = columnValue;
    740       $scope.showingColumnValues[columnName] = {};
    741       $scope.toggleValueInSet(columnValue, $scope.showingColumnValues[columnName]);
    742       $scope.updateResults();
    743     };
    744 
    745     /**
    746      * Update $scope.showingColumnValues[columnName] and $scope.columnStringMatch[columnName]
    747      * so that ALL entries are showing, and update the visible results.
    748      * (We update both of those, so we cover both freeform and checkbox filtered columns.)
    749      *
    750      * @param columnName
    751      */
    752     $scope.showAllColumnValues = function(columnName) {
    753       $scope.columnStringMatch[columnName] = "";
    754       $scope.showingColumnValues[columnName] = {};
    755       $scope.toggleValuesInSet($scope.allColumnValues[columnName],
    756                                $scope.showingColumnValues[columnName]);
    757       $scope.updateResults();
    758     };
    759 
    760 
    761     //
    762     // Operations for sending info back to the server.
    763     //
    764 
    765     /**
    766      * Tell the server that the actual results of these particular tests
    767      * are acceptable.
    768      *
    769      * TODO(epoger): This assumes that the original expectations are in
    770      * imageSetA, and the actuals are in imageSetB.
    771      *
    772      * @param imagePairsSubset an array of test results, most likely a subset of
    773      *        $scope.imagePairs (perhaps with some modifications)
    774      */
    775     $scope.submitApprovals = function(imagePairsSubset) {
    776       $scope.submitPending = true;
    777 
    778       // Convert bug text field to null or 1-item array.
    779       var bugs = null;
    780       var bugNumber = parseInt($scope.submitAdvancedSettings['bug']);
    781       if (!isNaN(bugNumber)) {
    782         bugs = [bugNumber];
    783       }
    784 
    785       // TODO(epoger): This is a suboptimal way to prevent users from
    786       // rebaselining failures in alternative renderModes, but it does work.
    787       // For a better solution, see
    788       // https://code.google.com/p/skia/issues/detail?id=1748 ('gm: add new
    789       // result type, RenderModeMismatch')
    790       var encounteredComparisonConfig = false;
    791 
    792       var updatedExpectations = [];
    793       for (var i = 0; i < imagePairsSubset.length; i++) {
    794         var imagePair = imagePairsSubset[i];
    795         var updatedExpectation = {};
    796         updatedExpectation[constants.KEY__IMAGEPAIRS__EXPECTATIONS] =
    797             imagePair[constants.KEY__IMAGEPAIRS__EXPECTATIONS];
    798         updatedExpectation[constants.KEY__IMAGEPAIRS__EXTRACOLUMNS] =
    799             imagePair[constants.KEY__IMAGEPAIRS__EXTRACOLUMNS];
    800         // IMAGE_B_URL contains the actual image (which is now the expectation)
    801         updatedExpectation[constants.KEY__IMAGEPAIRS__IMAGE_B_URL] =
    802             imagePair[constants.KEY__IMAGEPAIRS__IMAGE_B_URL];
    803         if (0 == updatedExpectation[constants.KEY__IMAGEPAIRS__EXTRACOLUMNS]
    804                                    [constants.KEY__EXTRACOLUMNS__CONFIG]
    805                                    .indexOf('comparison-')) {
    806           encounteredComparisonConfig = true;
    807         }
    808 
    809         // Advanced settings...
    810         if (null == updatedExpectation[constants.KEY__IMAGEPAIRS__EXPECTATIONS]) {
    811           updatedExpectation[constants.KEY__IMAGEPAIRS__EXPECTATIONS] = {};
    812         }
    813         updatedExpectation[constants.KEY__IMAGEPAIRS__EXPECTATIONS]
    814                           [constants.KEY__EXPECTATIONS__REVIEWED] =
    815             $scope.submitAdvancedSettings[
    816                 constants.KEY__EXPECTATIONS__REVIEWED];
    817         if (true == $scope.submitAdvancedSettings[
    818             constants.KEY__EXPECTATIONS__IGNOREFAILURE]) {
    819           // if it's false, don't send it at all (just keep the default)
    820           updatedExpectation[constants.KEY__IMAGEPAIRS__EXPECTATIONS]
    821                             [constants.KEY__EXPECTATIONS__IGNOREFAILURE] = true;
    822         }
    823         updatedExpectation[constants.KEY__IMAGEPAIRS__EXPECTATIONS]
    824                           [constants.KEY__EXPECTATIONS__BUGS] = bugs;
    825 
    826         updatedExpectations.push(updatedExpectation);
    827       }
    828       if (encounteredComparisonConfig) {
    829         alert("Approval failed -- you cannot approve results with config " +
    830             "type comparison-*");
    831         $scope.submitPending = false;
    832         return;
    833       }
    834       var modificationData = {};
    835       modificationData[constants.KEY__EDITS__MODIFICATIONS] =
    836           updatedExpectations;
    837       modificationData[constants.KEY__EDITS__OLD_RESULTS_HASH] =
    838           $scope.header[constants.KEY__HEADER__DATAHASH];
    839       modificationData[constants.KEY__EDITS__OLD_RESULTS_TYPE] =
    840           $scope.header[constants.KEY__HEADER__TYPE];
    841       $http({
    842         method: "POST",
    843         url: "/edits",
    844         data: modificationData
    845       }).success(function(data, status, headers, config) {
    846         var imagePairIndicesToMove = [];
    847         for (var i = 0; i < imagePairsSubset.length; i++) {
    848           imagePairIndicesToMove.push(imagePairsSubset[i].index);
    849         }
    850         $scope.moveImagePairsToTab(imagePairIndicesToMove,
    851                                    "HackToMakeSureThisImagePairDisappears");
    852         $scope.updateResults();
    853         alert("New baselines submitted successfully!\n\n" +
    854             "You still need to commit the updated expectations files on " +
    855             "the server side to the Skia repo.\n\n" +
    856             "When you click OK, your web UI will reload; after that " +
    857             "completes, you will see the updated data (once the server has " +
    858             "finished loading the update results into memory!) and you can " +
    859             "submit more baselines if you want.");
    860         // I don't know why, but if I just call reload() here it doesn't work.
    861         // Making a timer call it fixes the problem.
    862         $timeout(function(){location.reload();}, 1);
    863       }).error(function(data, status, headers, config) {
    864         alert("There was an error submitting your baselines.\n\n" +
    865             "Please see server-side log for details.");
    866         $scope.submitPending = false;
    867       });
    868     };
    869 
    870 
    871     //
    872     // Operations we use to mimic Set semantics, in such a way that
    873     // checking for presence within the Set is as fast as possible.
    874     // But getting a list of all values within the Set is not necessarily
    875     // possible.
    876     // TODO(epoger): move into a separate .js file?
    877     //
    878 
    879     /**
    880      * Returns the number of values present within set "set".
    881      *
    882      * @param set an Object which we use to mimic set semantics
    883      */
    884     $scope.setSize = function(set) {
    885       return Object.keys(set).length;
    886     };
    887 
    888     /**
    889      * Returns true if value "value" is present within set "set".
    890      *
    891      * @param value a value of any type
    892      * @param set an Object which we use to mimic set semantics
    893      *        (this should make isValueInSet faster than if we used an Array)
    894      */
    895     $scope.isValueInSet = function(value, set) {
    896       return (true == set[value]);
    897     };
    898 
    899     /**
    900      * If value "value" is already in set "set", remove it; otherwise, add it.
    901      *
    902      * @param value a value of any type
    903      * @param set an Object which we use to mimic set semantics
    904      */
    905     $scope.toggleValueInSet = function(value, set) {
    906       if (true == set[value]) {
    907         delete set[value];
    908       } else {
    909         set[value] = true;
    910       }
    911     };
    912 
    913     /**
    914      * For each value in valueArray, call toggleValueInSet(value, set).
    915      *
    916      * @param valueArray
    917      * @param set
    918      */
    919     $scope.toggleValuesInSet = function(valueArray, set) {
    920       var arrayLength = valueArray.length;
    921       for (var i = 0; i < arrayLength; i++) {
    922         $scope.toggleValueInSet(valueArray[i], set);
    923       }
    924     };
    925 
    926 
    927     //
    928     // Array operations; similar to our Set operations, but operate on a
    929     // Javascript Array so we *can* easily get a list of all values in the Set.
    930     // TODO(epoger): move into a separate .js file?
    931     //
    932 
    933     /**
    934      * Returns true if value "value" is present within array "array".
    935      *
    936      * @param value a value of any type
    937      * @param array a Javascript Array
    938      */
    939     $scope.isValueInArray = function(value, array) {
    940       return (-1 != array.indexOf(value));
    941     };
    942 
    943     /**
    944      * If value "value" is already in array "array", remove it; otherwise,
    945      * add it.
    946      *
    947      * @param value a value of any type
    948      * @param array a Javascript Array
    949      */
    950     $scope.toggleValueInArray = function(value, array) {
    951       var i = array.indexOf(value);
    952       if (-1 == i) {
    953         array.push(value);
    954       } else {
    955         array.splice(i, 1);
    956       }
    957     };
    958 
    959 
    960     //
    961     // Miscellaneous utility functions.
    962     // TODO(epoger): move into a separate .js file?
    963     //
    964 
    965     /**
    966      * Returns a single "column slice" of a 2D array.
    967      *
    968      * For example, if array is:
    969      * [[A0, A1],
    970      *  [B0, B1],
    971      *  [C0, C1]]
    972      * and index is 0, this this will return:
    973      * [A0, B0, C0]
    974      *
    975      * @param array a Javascript Array
    976      * @param column (numeric): index within each row array
    977      */
    978     $scope.columnSliceOf2DArray = function(array, column) {
    979       var slice = [];
    980       var numRows = array.length;
    981       for (var row = 0; row < numRows; row++) {
    982         slice.push(array[row][column]);
    983       }
    984       return slice;
    985     };
    986 
    987     /**
    988      * Returns a human-readable (in local time zone) time string for a
    989      * particular moment in time.
    990      *
    991      * @param secondsPastEpoch (numeric): seconds past epoch in UTC
    992      */
    993     $scope.localTimeString = function(secondsPastEpoch) {
    994       var d = new Date(secondsPastEpoch * 1000);
    995       return d.toString();
    996     };
    997 
    998     /**
    999      * Returns a hex color string (such as "#aabbcc") for the given RGB values.
   1000      *
   1001      * @param r (numeric): red channel value, 0-255
   1002      * @param g (numeric): green channel value, 0-255
   1003      * @param b (numeric): blue channel value, 0-255
   1004      */
   1005     $scope.hexColorString = function(r, g, b) {
   1006       var rString = r.toString(16);
   1007       if (r < 16) {
   1008         rString = "0" + rString;
   1009       }
   1010       var gString = g.toString(16);
   1011       if (g < 16) {
   1012         gString = "0" + gString;
   1013       }
   1014       var bString = b.toString(16);
   1015       if (b < 16) {
   1016         bString = "0" + bString;
   1017       }
   1018       return '#' + rString + gString + bString;
   1019     };
   1020 
   1021     /**
   1022      * Returns a hex color string (such as "#aabbcc") for the given brightness.
   1023      *
   1024      * @param brightnessString (string): 0-255, 0 is completely black
   1025      *
   1026      * TODO(epoger): It might be nice to tint the color when it's not completely
   1027      * black or completely white.
   1028      */
   1029     $scope.brightnessStringToHexColor = function(brightnessString) {
   1030       var v = parseInt(brightnessString);
   1031       return $scope.hexColorString(v, v, v);
   1032     };
   1033 
   1034   }
   1035 );
   1036