loader.js revision ceba079e3b5b8698fbd6651ae6cd30448991dd63
1/*
2 * Loader:
3 * Reads GM result reports written out by results.py, and imports
4 * them into $scope.categories and $scope.testData .
5 */
6var Loader = angular.module(
7    'Loader',
8    ['diff_viewer']
9);
10
11
12// TODO(epoger): Combine ALL of our filtering operations (including
13// truncation) into this one filter, so that runs most efficiently?
14// (We would have to make sure truncation still took place after
15// sorting, though.)
16Loader.filter(
17  'removeHiddenItems',
18  function() {
19    return function(unfilteredItems, hiddenResultTypes, hiddenConfigs,
20                    builderSubstring, testSubstring, viewingTab) {
21      var filteredItems = [];
22      for (var i = 0; i < unfilteredItems.length; i++) {
23        var item = unfilteredItems[i];
24        // For performance, we examine the "set" objects directly rather
25        // than calling $scope.isValueInSet().
26        // Besides, I don't think we have access to $scope in here...
27        if (!(true == hiddenResultTypes[item.resultType]) &&
28            !(true == hiddenConfigs[item.config]) &&
29            !(-1 == item.builder.indexOf(builderSubstring)) &&
30            !(-1 == item.test.indexOf(testSubstring)) &&
31            (viewingTab == item.tab)) {
32          filteredItems.push(item);
33        }
34      }
35      return filteredItems;
36    };
37  }
38);
39
40
41Loader.controller(
42  'Loader.Controller',
43    function($scope, $http, $filter, $location, $timeout) {
44    $scope.windowTitle = "Loading GM Results...";
45    $scope.resultsToLoad = $location.search().resultsToLoad;
46    $scope.loadingMessage = "Loading results of type '" + $scope.resultsToLoad +
47        "', please wait...";
48
49    /**
50     * On initial page load, load a full dictionary of results.
51     * Once the dictionary is loaded, unhide the page elements so they can
52     * render the data.
53     */
54    $http.get("/results/" + $scope.resultsToLoad).success(
55      function(data, status, header, config) {
56        if (data.header.resultsStillLoading) {
57          $scope.loadingMessage =
58              "Server is still loading results; will retry at " +
59              $scope.localTimeString(data.header.timeNextUpdateAvailable);
60          $timeout(
61              function(){location.reload();},
62              (data.header.timeNextUpdateAvailable * 1000) - new Date().getTime());
63        } else {
64          $scope.loadingMessage = "Processing data, please wait...";
65
66          $scope.header = data.header;
67          $scope.categories = data.categories;
68          $scope.testData = data.testData;
69          $scope.sortColumn = 'weightedDiffMeasure';
70          $scope.showTodos = false;
71
72          $scope.showSubmitAdvancedSettings = false;
73          $scope.submitAdvancedSettings = {};
74          $scope.submitAdvancedSettings['reviewed-by-human'] = true;
75          $scope.submitAdvancedSettings['ignore-failure'] = false;
76          $scope.submitAdvancedSettings['bug'] = '';
77
78          // Create the list of tabs (lists into which the user can file each
79          // test).  This may vary, depending on isEditable.
80          $scope.tabs = [
81            'Unfiled', 'Hidden'
82          ];
83          if (data.header.isEditable) {
84            $scope.tabs = $scope.tabs.concat(
85                ['Pending Approval']);
86          }
87          $scope.defaultTab = $scope.tabs[0];
88          $scope.viewingTab = $scope.defaultTab;
89
90          // Track the number of results on each tab.
91          $scope.numResultsPerTab = {};
92          for (var i = 0; i < $scope.tabs.length; i++) {
93            $scope.numResultsPerTab[$scope.tabs[i]] = 0;
94          }
95          $scope.numResultsPerTab[$scope.defaultTab] = $scope.testData.length;
96
97          // Add index and tab fields to all records.
98          for (var i = 0; i < $scope.testData.length; i++) {
99            $scope.testData[i].index = i;
100            $scope.testData[i].tab = $scope.defaultTab;
101          }
102
103          // Arrays within which the user can toggle individual elements.
104          $scope.selectedItems = [];
105
106          // Sets within which the user can toggle individual elements.
107          $scope.hiddenResultTypes = {
108            'failure-ignored': true,
109            'no-comparison': true,
110            'succeeded': true,
111          };
112          $scope.allResultTypes = Object.keys(data.categories['resultType']);
113          $scope.hiddenConfigs = {};
114          $scope.allConfigs = Object.keys(data.categories['config']);
115
116          // Associative array of partial string matches per category.
117          $scope.categoryValueMatch = {};
118          $scope.categoryValueMatch.builder = "";
119          $scope.categoryValueMatch.test = "";
120
121          // If any defaults were overridden in the URL, get them now.
122          $scope.queryParameters.load();
123
124          $scope.updateResults();
125          $scope.loadingMessage = "";
126          $scope.windowTitle = "Current GM Results";
127        }
128      }
129    ).error(
130      function(data, status, header, config) {
131        $scope.loadingMessage = "Failed to load results of type '"
132            + $scope.resultsToLoad + "'";
133        $scope.windowTitle = "Failed to Load GM Results";
134      }
135    );
136
137
138    //
139    // Select/Clear/Toggle all tests.
140    //
141
142    /**
143     * Select all currently showing tests.
144     */
145    $scope.selectAllItems = function() {
146      var numItemsShowing = $scope.limitedTestData.length;
147      for (var i = 0; i < numItemsShowing; i++) {
148        var index = $scope.limitedTestData[i].index;
149        if (!$scope.isValueInArray(index, $scope.selectedItems)) {
150          $scope.toggleValueInArray(index, $scope.selectedItems);
151        }
152      }
153    }
154
155    /**
156     * Deselect all currently showing tests.
157     */
158    $scope.clearAllItems = function() {
159      var numItemsShowing = $scope.limitedTestData.length;
160      for (var i = 0; i < numItemsShowing; i++) {
161        var index = $scope.limitedTestData[i].index;
162        if ($scope.isValueInArray(index, $scope.selectedItems)) {
163          $scope.toggleValueInArray(index, $scope.selectedItems);
164        }
165      }
166    }
167
168    /**
169     * Toggle selection of all currently showing tests.
170     */
171    $scope.toggleAllItems = function() {
172      var numItemsShowing = $scope.limitedTestData.length;
173      for (var i = 0; i < numItemsShowing; i++) {
174        var index = $scope.limitedTestData[i].index;
175        $scope.toggleValueInArray(index, $scope.selectedItems);
176      }
177    }
178
179
180    //
181    // Tab operations.
182    //
183
184    /**
185     * Change the selected tab.
186     *
187     * @param tab (string): name of the tab to select
188     */
189    $scope.setViewingTab = function(tab) {
190      $scope.viewingTab = tab;
191      $scope.updateResults();
192    }
193
194    /**
195     * Move the items in $scope.selectedItems to a different tab,
196     * and then clear $scope.selectedItems.
197     *
198     * @param newTab (string): name of the tab to move the tests to
199     */
200    $scope.moveSelectedItemsToTab = function(newTab) {
201      $scope.moveItemsToTab($scope.selectedItems, newTab);
202      $scope.selectedItems = [];
203      $scope.updateResults();
204    }
205
206    /**
207     * Move a subset of $scope.testData to a different tab.
208     *
209     * @param itemIndices (array of ints): indices into $scope.testData
210     *        indicating which test results to move
211     * @param newTab (string): name of the tab to move the tests to
212     */
213    $scope.moveItemsToTab = function(itemIndices, newTab) {
214      var itemIndex;
215      var numItems = itemIndices.length;
216      for (var i = 0; i < numItems; i++) {
217        itemIndex = itemIndices[i];
218        $scope.numResultsPerTab[$scope.testData[itemIndex].tab]--;
219        $scope.testData[itemIndex].tab = newTab;
220      }
221      $scope.numResultsPerTab[newTab] += numItems;
222    }
223
224
225    //
226    // $scope.queryParameters:
227    // Transfer parameter values between $scope and the URL query string.
228    //
229    $scope.queryParameters = {};
230
231    // load and save functions for parameters of each type
232    // (load a parameter value into $scope from nameValuePairs,
233    //  save a parameter value from $scope into nameValuePairs)
234    $scope.queryParameters.copiers = {
235      'simple': {
236        'load': function(nameValuePairs, name) {
237          var value = nameValuePairs[name];
238          if (value) {
239            $scope[name] = value;
240          }
241        },
242        'save': function(nameValuePairs, name) {
243          nameValuePairs[name] = $scope[name];
244        }
245      },
246
247      'categoryValueMatch': {
248        'load': function(nameValuePairs, name) {
249          var value = nameValuePairs[name];
250          if (value) {
251            $scope.categoryValueMatch[name] = value;
252          }
253        },
254        'save': function(nameValuePairs, name) {
255          nameValuePairs[name] = $scope.categoryValueMatch[name];
256        }
257      },
258
259      'set': {
260        'load': function(nameValuePairs, name) {
261          var value = nameValuePairs[name];
262          if (value) {
263            var valueArray = value.split(',');
264            $scope[name] = {};
265            $scope.toggleValuesInSet(valueArray, $scope[name]);
266          }
267        },
268        'save': function(nameValuePairs, name) {
269          nameValuePairs[name] = Object.keys($scope[name]).join(',');
270        }
271      },
272
273    };
274
275    // parameter name -> copier objects to load/save parameter value
276    $scope.queryParameters.map = {
277      'resultsToLoad':       $scope.queryParameters.copiers.simple,
278      'displayLimitPending': $scope.queryParameters.copiers.simple,
279      'imageSizePending':    $scope.queryParameters.copiers.simple,
280      'sortColumn':          $scope.queryParameters.copiers.simple,
281
282      'builder': $scope.queryParameters.copiers.categoryValueMatch,
283      'test':    $scope.queryParameters.copiers.categoryValueMatch,
284
285      'hiddenResultTypes': $scope.queryParameters.copiers.set,
286      'hiddenConfigs':     $scope.queryParameters.copiers.set,
287    };
288
289    // Loads all parameters into $scope from the URL query string;
290    // any which are not found within the URL will keep their current value.
291    $scope.queryParameters.load = function() {
292      var nameValuePairs = $location.search();
293      angular.forEach($scope.queryParameters.map,
294                      function(copier, paramName) {
295                        copier.load(nameValuePairs, paramName);
296                      }
297                     );
298    };
299
300    // Saves all parameters from $scope into the URL query string.
301    $scope.queryParameters.save = function() {
302      var nameValuePairs = {};
303      angular.forEach($scope.queryParameters.map,
304                      function(copier, paramName) {
305                        copier.save(nameValuePairs, paramName);
306                      }
307                     );
308      $location.search(nameValuePairs);
309    };
310
311
312    //
313    // updateResults() and friends.
314    //
315
316    /**
317     * Set $scope.areUpdatesPending (to enable/disable the Update Results
318     * button).
319     *
320     * TODO(epoger): We could reduce the amount of code by just setting the
321     * variable directly (from, e.g., a button's ng-click handler).  But when
322     * I tried that, the HTML elements depending on the variable did not get
323     * updated.
324     * It turns out that this is due to variable scoping within an ng-repeat
325     * element; see http://stackoverflow.com/questions/15388344/behavior-of-assignment-expression-invoked-by-ng-click-within-ng-repeat
326     *
327     * @param val boolean value to set $scope.areUpdatesPending to
328     */
329    $scope.setUpdatesPending = function(val) {
330      $scope.areUpdatesPending = val;
331    }
332
333    /**
334     * Update the displayed results, based on filters/settings,
335     * and call $scope.queryParameters.save() so that the new filter results
336     * can be bookmarked.
337     */
338    $scope.updateResults = function() {
339      $scope.displayLimit = $scope.displayLimitPending;
340      // TODO(epoger): Every time we apply a filter, AngularJS creates
341      // another copy of the array.  Is there a way we can filter out
342      // the items as they are displayed, rather than storing multiple
343      // array copies?  (For better performance.)
344
345      if ($scope.viewingTab == $scope.defaultTab) {
346
347        // TODO(epoger): Until we allow the user to reverse sort order,
348        // there are certain columns we want to sort in a different order.
349        var doReverse = (
350            ($scope.sortColumn == 'percentDifferingPixels') ||
351            ($scope.sortColumn == 'weightedDiffMeasure'));
352
353        $scope.filteredTestData =
354            $filter("orderBy")(
355                $filter("removeHiddenItems")(
356                    $scope.testData,
357                    $scope.hiddenResultTypes,
358                    $scope.hiddenConfigs,
359                    $scope.categoryValueMatch.builder,
360                    $scope.categoryValueMatch.test,
361                    $scope.viewingTab
362                ),
363                $scope.sortColumn, doReverse);
364        $scope.limitedTestData = $filter("limitTo")(
365            $scope.filteredTestData, $scope.displayLimit);
366      } else {
367        $scope.filteredTestData =
368            $filter("orderBy")(
369                $filter("filter")(
370                    $scope.testData,
371                    {tab: $scope.viewingTab},
372                    true
373                ),
374                $scope.sortColumn);
375        $scope.limitedTestData = $scope.filteredTestData;
376      }
377      $scope.imageSize = $scope.imageSizePending;
378      $scope.setUpdatesPending(false);
379      $scope.queryParameters.save();
380    }
381
382    /**
383     * Re-sort the displayed results.
384     *
385     * @param sortColumn (string): name of the column to sort on
386     */
387    $scope.sortResultsBy = function(sortColumn) {
388      $scope.sortColumn = sortColumn;
389      $scope.updateResults();
390    }
391
392    /**
393     * Set $scope.categoryValueMatch[name] = value, and update results.
394     *
395     * @param name
396     * @param value
397     */
398    $scope.setCategoryValueMatch = function(name, value) {
399      $scope.categoryValueMatch[name] = value;
400      $scope.updateResults();
401    }
402
403    /**
404     * Update $scope.hiddenResultTypes so that ONLY this resultType is showing,
405     * and update the visible results.
406     *
407     * @param resultType
408     */
409    $scope.showOnlyResultType = function(resultType) {
410      $scope.hiddenResultTypes = {};
411      // TODO(epoger): Maybe change $scope.allResultTypes to be a Set like
412      // $scope.hiddenResultTypes (rather than an array), so this operation is
413      // simpler (just assign or add allResultTypes to hiddenResultTypes).
414      $scope.toggleValuesInSet($scope.allResultTypes, $scope.hiddenResultTypes);
415      $scope.toggleValueInSet(resultType, $scope.hiddenResultTypes);
416      $scope.updateResults();
417    }
418
419    /**
420     * Update $scope.hiddenConfigs so that ONLY this config is showing,
421     * and update the visible results.
422     *
423     * @param config
424     */
425    $scope.showOnlyConfig = function(config) {
426      $scope.hiddenConfigs = {};
427      $scope.toggleValuesInSet($scope.allConfigs, $scope.hiddenConfigs);
428      $scope.toggleValueInSet(config, $scope.hiddenConfigs);
429      $scope.updateResults();
430    }
431
432
433    //
434    // Operations for sending info back to the server.
435    //
436
437    /**
438     * Tell the server that the actual results of these particular tests
439     * are acceptable.
440     *
441     * @param testDataSubset an array of test results, most likely a subset of
442     *        $scope.testData (perhaps with some modifications)
443     */
444    $scope.submitApprovals = function(testDataSubset) {
445      $scope.submitPending = true;
446
447      // Convert bug text field to null or 1-item array.
448      var bugs = null;
449      var bugNumber = parseInt($scope.submitAdvancedSettings['bug']);
450      if (!isNaN(bugNumber)) {
451        bugs = [bugNumber];
452      }
453
454      // TODO(epoger): This is a suboptimal way to prevent users from
455      // rebaselining failures in alternative renderModes, but it does work.
456      // For a better solution, see
457      // https://code.google.com/p/skia/issues/detail?id=1748 ('gm: add new
458      // result type, RenderModeMismatch')
459      var encounteredComparisonConfig = false;
460
461      var newResults = [];
462      for (var i = 0; i < testDataSubset.length; i++) {
463        var actualResult = testDataSubset[i];
464        var expectedResult = {
465          builder: actualResult['builder'],
466          test: actualResult['test'],
467          config: actualResult['config'],
468          expectedHashType: actualResult['actualHashType'],
469          expectedHashDigest: actualResult['actualHashDigest'],
470        };
471        if (0 == expectedResult.config.indexOf('comparison-')) {
472          encounteredComparisonConfig = true;
473        }
474
475        // Advanced settings...
476        expectedResult['reviewed-by-human'] =
477            $scope.submitAdvancedSettings['reviewed-by-human'];
478        if (true == $scope.submitAdvancedSettings['ignore-failure']) {
479          // if it's false, don't send it at all (just keep the default)
480          expectedResult['ignore-failure'] = true;
481        }
482        expectedResult['bugs'] = bugs;
483
484        newResults.push(expectedResult);
485      }
486      if (encounteredComparisonConfig) {
487        alert("Approval failed -- you cannot approve results with config " +
488            "type comparison-*");
489        $scope.submitPending = false;
490        return;
491      }
492      $http({
493        method: "POST",
494        url: "/edits",
495        data: {
496          oldResultsType: $scope.header.type,
497          oldResultsHash: $scope.header.dataHash,
498          modifications: newResults
499        }
500      }).success(function(data, status, headers, config) {
501        var itemIndicesToMove = [];
502        for (var i = 0; i < testDataSubset.length; i++) {
503          itemIndicesToMove.push(testDataSubset[i].index);
504        }
505        $scope.moveItemsToTab(itemIndicesToMove,
506                              "HackToMakeSureThisItemDisappears");
507        $scope.updateResults();
508        alert("New baselines submitted successfully!\n\n" +
509            "You still need to commit the updated expectations files on " +
510            "the server side to the Skia repo.\n\n" +
511            "When you click OK, your web UI will reload; after that " +
512            "completes, you will see the updated data (once the server has " +
513            "finished loading the update results into memory!) and you can " +
514            "submit more baselines if you want.");
515        // I don't know why, but if I just call reload() here it doesn't work.
516        // Making a timer call it fixes the problem.
517        $timeout(function(){location.reload();}, 1);
518      }).error(function(data, status, headers, config) {
519        alert("There was an error submitting your baselines.\n\n" +
520            "Please see server-side log for details.");
521        $scope.submitPending = false;
522      });
523    }
524
525
526    //
527    // Operations we use to mimic Set semantics, in such a way that
528    // checking for presence within the Set is as fast as possible.
529    // But getting a list of all values within the Set is not necessarily
530    // possible.
531    // TODO(epoger): move into a separate .js file?
532    //
533
534    /**
535     * Returns true if value "value" is present within set "set".
536     *
537     * @param value a value of any type
538     * @param set an Object which we use to mimic set semantics
539     *        (this should make isValueInSet faster than if we used an Array)
540     */
541    $scope.isValueInSet = function(value, set) {
542      return (true == set[value]);
543    }
544
545    /**
546     * If value "value" is already in set "set", remove it; otherwise, add it.
547     *
548     * @param value a value of any type
549     * @param set an Object which we use to mimic set semantics
550     */
551    $scope.toggleValueInSet = function(value, set) {
552      if (true == set[value]) {
553        delete set[value];
554      } else {
555        set[value] = true;
556      }
557    }
558
559    /**
560     * For each value in valueArray, call toggleValueInSet(value, set).
561     *
562     * @param valueArray
563     * @param set
564     */
565    $scope.toggleValuesInSet = function(valueArray, set) {
566      var arrayLength = valueArray.length;
567      for (var i = 0; i < arrayLength; i++) {
568        $scope.toggleValueInSet(valueArray[i], set);
569      }
570    }
571
572
573    //
574    // Array operations; similar to our Set operations, but operate on a
575    // Javascript Array so we *can* easily get a list of all values in the Set.
576    // TODO(epoger): move into a separate .js file?
577    //
578
579    /**
580     * Returns true if value "value" is present within array "array".
581     *
582     * @param value a value of any type
583     * @param array a Javascript Array
584     */
585    $scope.isValueInArray = function(value, array) {
586      return (-1 != array.indexOf(value));
587    }
588
589    /**
590     * If value "value" is already in array "array", remove it; otherwise,
591     * add it.
592     *
593     * @param value a value of any type
594     * @param array a Javascript Array
595     */
596    $scope.toggleValueInArray = function(value, array) {
597      var i = array.indexOf(value);
598      if (-1 == i) {
599        array.push(value);
600      } else {
601        array.splice(i, 1);
602      }
603    }
604
605
606    //
607    // Miscellaneous utility functions.
608    // TODO(epoger): move into a separate .js file?
609    //
610
611    /**
612     * Returns a human-readable (in local time zone) time string for a
613     * particular moment in time.
614     *
615     * @param secondsPastEpoch (numeric): seconds past epoch in UTC
616     */
617    $scope.localTimeString = function(secondsPastEpoch) {
618      var d = new Date(secondsPastEpoch * 1000);
619      return d.toString();
620    }
621
622    /**
623     * Returns a hex color string (such as "#aabbcc") for the given RGB values.
624     *
625     * @param r (numeric): red channel value, 0-255
626     * @param g (numeric): green channel value, 0-255
627     * @param b (numeric): blue channel value, 0-255
628     */
629    $scope.hexColorString = function(r, g, b) {
630      var rString = r.toString(16);
631      if (r < 16) {
632        rString = "0" + rString;
633      }
634      var gString = g.toString(16);
635      if (g < 16) {
636        gString = "0" + gString;
637      }
638      var bString = b.toString(16);
639      if (b < 16) {
640        bString = "0" + bString;
641      }
642      return '#' + rString + gString + bString;
643    }
644
645    /**
646     * Returns a hex color string (such as "#aabbcc") for the given brightness.
647     *
648     * @param brightnessString (string): 0-255, 0 is completely black
649     *
650     * TODO(epoger): It might be nice to tint the color when it's not completely
651     * black or completely white.
652     */
653    $scope.brightnessStringToHexColor = function(brightnessString) {
654      var v = parseInt(brightnessString);
655      return $scope.hexColorString(v, v, v);
656    }
657
658  }
659);
660