blob: 3339cad4a9e1844b2c73a4944e377ff8c14646df [file] [log] [blame]
/*
* Loader:
* Reads GM result reports written out by results.py, and imports
* them into $scope.categories and $scope.testData .
*/
var Loader = angular.module(
'Loader',
[]
);
// TODO(epoger): Combine ALL of our filtering operations (including
// truncation) into this one filter, so that runs most efficiently?
// (We would have to make sure truncation still took place after
// sorting, though.)
Loader.filter(
'removeHiddenItems',
function() {
return function(unfilteredItems, hiddenResultTypes, hiddenConfigs,
builderSubstring, testSubstring, viewingTab) {
var filteredItems = [];
for (var i = 0; i < unfilteredItems.length; i++) {
var item = unfilteredItems[i];
// For performance, we examine the "set" objects directly rather
// than calling $scope.isValueInSet().
// Besides, I don't think we have access to $scope in here...
if (!(true == hiddenResultTypes[item.resultType]) &&
!(true == hiddenConfigs[item.config]) &&
!(-1 == item.builder.indexOf(builderSubstring)) &&
!(-1 == item.test.indexOf(testSubstring)) &&
(viewingTab == item.tab)) {
filteredItems.push(item);
}
}
return filteredItems;
};
}
);
Loader.controller(
'Loader.Controller',
function($scope, $http, $filter, $location, $timeout) {
$scope.windowTitle = "Loading GM Results...";
$scope.resultsToLoad = $location.search().resultsToLoad;
$scope.loadingMessage = "Loading results of type '" + $scope.resultsToLoad +
"', please wait...";
/**
* On initial page load, load a full dictionary of results.
* Once the dictionary is loaded, unhide the page elements so they can
* render the data.
*/
$http.get("/results/" + $scope.resultsToLoad).success(
function(data, status, header, config) {
if (data.header.resultsStillLoading) {
$scope.loadingMessage =
"Server is still loading results; will retry at " +
$scope.localTimeString(data.header.timeNextUpdateAvailable);
$timeout(
function(){location.reload();},
(data.header.timeNextUpdateAvailable * 1000) - new Date().getTime());
} else {
$scope.loadingMessage = "Processing data, please wait...";
$scope.header = data.header;
$scope.categories = data.categories;
$scope.testData = data.testData;
$scope.sortColumn = 'weightedDiffMeasure';
$scope.showTodos = false;
$scope.showSubmitAdvancedSettings = false;
$scope.submitAdvancedSettings = {};
$scope.submitAdvancedSettings['reviewed-by-human'] = true;
$scope.submitAdvancedSettings['ignore-failure'] = false;
$scope.submitAdvancedSettings['bug'] = '';
// Create the list of tabs (lists into which the user can file each
// test). This may vary, depending on isEditable.
$scope.tabs = [
'Unfiled', 'Hidden'
];
if (data.header.isEditable) {
$scope.tabs = $scope.tabs.concat(
['Pending Approval']);
}
$scope.defaultTab = $scope.tabs[0];
$scope.viewingTab = $scope.defaultTab;
// Track the number of results on each tab.
$scope.numResultsPerTab = {};
for (var i = 0; i < $scope.tabs.length; i++) {
$scope.numResultsPerTab[$scope.tabs[i]] = 0;
}
$scope.numResultsPerTab[$scope.defaultTab] = $scope.testData.length;
// Add index and tab fields to all records.
for (var i = 0; i < $scope.testData.length; i++) {
$scope.testData[i].index = i;
$scope.testData[i].tab = $scope.defaultTab;
}
// Arrays within which the user can toggle individual elements.
$scope.selectedItems = [];
// Sets within which the user can toggle individual elements.
$scope.hiddenResultTypes = {
'failure-ignored': true,
'no-comparison': true,
'succeeded': true,
};
$scope.allResultTypes = Object.keys(data.categories['resultType']);
$scope.hiddenConfigs = {};
$scope.allConfigs = Object.keys(data.categories['config']);
// Associative array of partial string matches per category.
$scope.categoryValueMatch = {};
$scope.categoryValueMatch.builder = "";
$scope.categoryValueMatch.test = "";
// If any defaults were overridden in the URL, get them now.
$scope.queryParameters.load();
$scope.updateResults();
$scope.loadingMessage = "";
$scope.windowTitle = "Current GM Results";
}
}
).error(
function(data, status, header, config) {
$scope.loadingMessage = "Failed to load results of type '"
+ $scope.resultsToLoad + "'";
$scope.windowTitle = "Failed to Load GM Results";
}
);
//
// Select/Clear/Toggle all tests.
//
/**
* Select all currently showing tests.
*/
$scope.selectAllItems = function() {
var numItemsShowing = $scope.limitedTestData.length;
for (var i = 0; i < numItemsShowing; i++) {
var index = $scope.limitedTestData[i].index;
if (!$scope.isValueInArray(index, $scope.selectedItems)) {
$scope.toggleValueInArray(index, $scope.selectedItems);
}
}
}
/**
* Deselect all currently showing tests.
*/
$scope.clearAllItems = function() {
var numItemsShowing = $scope.limitedTestData.length;
for (var i = 0; i < numItemsShowing; i++) {
var index = $scope.limitedTestData[i].index;
if ($scope.isValueInArray(index, $scope.selectedItems)) {
$scope.toggleValueInArray(index, $scope.selectedItems);
}
}
}
/**
* Toggle selection of all currently showing tests.
*/
$scope.toggleAllItems = function() {
var numItemsShowing = $scope.limitedTestData.length;
for (var i = 0; i < numItemsShowing; i++) {
var index = $scope.limitedTestData[i].index;
$scope.toggleValueInArray(index, $scope.selectedItems);
}
}
//
// Tab operations.
//
/**
* Change the selected tab.
*
* @param tab (string): name of the tab to select
*/
$scope.setViewingTab = function(tab) {
$scope.viewingTab = tab;
$scope.updateResults();
}
/**
* Move the items in $scope.selectedItems to a different tab,
* and then clear $scope.selectedItems.
*
* @param newTab (string): name of the tab to move the tests to
*/
$scope.moveSelectedItemsToTab = function(newTab) {
$scope.moveItemsToTab($scope.selectedItems, newTab);
$scope.selectedItems = [];
$scope.updateResults();
}
/**
* Move a subset of $scope.testData to a different tab.
*
* @param itemIndices (array of ints): indices into $scope.testData
* indicating which test results to move
* @param newTab (string): name of the tab to move the tests to
*/
$scope.moveItemsToTab = function(itemIndices, newTab) {
var itemIndex;
var numItems = itemIndices.length;
for (var i = 0; i < numItems; i++) {
itemIndex = itemIndices[i];
$scope.numResultsPerTab[$scope.testData[itemIndex].tab]--;
$scope.testData[itemIndex].tab = newTab;
}
$scope.numResultsPerTab[newTab] += numItems;
}
//
// $scope.queryParameters:
// Transfer parameter values between $scope and the URL query string.
//
$scope.queryParameters = {};
// load and save functions for parameters of each type
// (load a parameter value into $scope from nameValuePairs,
// save a parameter value from $scope into nameValuePairs)
$scope.queryParameters.copiers = {
'simple': {
'load': function(nameValuePairs, name) {
var value = nameValuePairs[name];
if (value) {
$scope[name] = value;
}
},
'save': function(nameValuePairs, name) {
nameValuePairs[name] = $scope[name];
}
},
'categoryValueMatch': {
'load': function(nameValuePairs, name) {
var value = nameValuePairs[name];
if (value) {
$scope.categoryValueMatch[name] = value;
}
},
'save': function(nameValuePairs, name) {
nameValuePairs[name] = $scope.categoryValueMatch[name];
}
},
'set': {
'load': function(nameValuePairs, name) {
var value = nameValuePairs[name];
if (value) {
var valueArray = value.split(',');
$scope[name] = {};
$scope.toggleValuesInSet(valueArray, $scope[name]);
}
},
'save': function(nameValuePairs, name) {
nameValuePairs[name] = Object.keys($scope[name]).join(',');
}
},
};
// parameter name -> copier objects to load/save parameter value
$scope.queryParameters.map = {
'resultsToLoad': $scope.queryParameters.copiers.simple,
'displayLimitPending': $scope.queryParameters.copiers.simple,
'imageSizePending': $scope.queryParameters.copiers.simple,
'sortColumn': $scope.queryParameters.copiers.simple,
'builder': $scope.queryParameters.copiers.categoryValueMatch,
'test': $scope.queryParameters.copiers.categoryValueMatch,
'hiddenResultTypes': $scope.queryParameters.copiers.set,
'hiddenConfigs': $scope.queryParameters.copiers.set,
};
// Loads all parameters into $scope from the URL query string;
// any which are not found within the URL will keep their current value.
$scope.queryParameters.load = function() {
var nameValuePairs = $location.search();
angular.forEach($scope.queryParameters.map,
function(copier, paramName) {
copier.load(nameValuePairs, paramName);
}
);
};
// Saves all parameters from $scope into the URL query string.
$scope.queryParameters.save = function() {
var nameValuePairs = {};
angular.forEach($scope.queryParameters.map,
function(copier, paramName) {
copier.save(nameValuePairs, paramName);
}
);
$location.search(nameValuePairs);
};
//
// updateResults() and friends.
//
/**
* Set $scope.areUpdatesPending (to enable/disable the Update Results
* button).
*
* TODO(epoger): We could reduce the amount of code by just setting the
* variable directly (from, e.g., a button's ng-click handler). But when
* I tried that, the HTML elements depending on the variable did not get
* updated.
* It turns out that this is due to variable scoping within an ng-repeat
* element; see http://stackoverflow.com/questions/15388344/behavior-of-assignment-expression-invoked-by-ng-click-within-ng-repeat
*
* @param val boolean value to set $scope.areUpdatesPending to
*/
$scope.setUpdatesPending = function(val) {
$scope.areUpdatesPending = val;
}
/**
* Update the displayed results, based on filters/settings,
* and call $scope.queryParameters.save() so that the new filter results
* can be bookmarked.
*/
$scope.updateResults = function() {
$scope.displayLimit = $scope.displayLimitPending;
// TODO(epoger): Every time we apply a filter, AngularJS creates
// another copy of the array. Is there a way we can filter out
// the items as they are displayed, rather than storing multiple
// array copies? (For better performance.)
if ($scope.viewingTab == $scope.defaultTab) {
// TODO(epoger): Until we allow the user to reverse sort order,
// there are certain columns we want to sort in a different order.
var doReverse = (
($scope.sortColumn == 'percentDifferingPixels') ||
($scope.sortColumn == 'weightedDiffMeasure'));
$scope.filteredTestData =
$filter("orderBy")(
$filter("removeHiddenItems")(
$scope.testData,
$scope.hiddenResultTypes,
$scope.hiddenConfigs,
$scope.categoryValueMatch.builder,
$scope.categoryValueMatch.test,
$scope.viewingTab
),
$scope.sortColumn, doReverse);
$scope.limitedTestData = $filter("limitTo")(
$scope.filteredTestData, $scope.displayLimit);
} else {
$scope.filteredTestData =
$filter("orderBy")(
$filter("filter")(
$scope.testData,
{tab: $scope.viewingTab},
true
),
$scope.sortColumn);
$scope.limitedTestData = $scope.filteredTestData;
}
$scope.imageSize = $scope.imageSizePending;
$scope.setUpdatesPending(false);
$scope.queryParameters.save();
}
/**
* Re-sort the displayed results.
*
* @param sortColumn (string): name of the column to sort on
*/
$scope.sortResultsBy = function(sortColumn) {
$scope.sortColumn = sortColumn;
$scope.updateResults();
}
/**
* Set $scope.categoryValueMatch[name] = value, and update results.
*
* @param name
* @param value
*/
$scope.setCategoryValueMatch = function(name, value) {
$scope.categoryValueMatch[name] = value;
$scope.updateResults();
}
/**
* Update $scope.hiddenResultTypes so that ONLY this resultType is showing,
* and update the visible results.
*
* @param resultType
*/
$scope.showOnlyResultType = function(resultType) {
$scope.hiddenResultTypes = {};
// TODO(epoger): Maybe change $scope.allResultTypes to be a Set like
// $scope.hiddenResultTypes (rather than an array), so this operation is
// simpler (just assign or add allResultTypes to hiddenResultTypes).
$scope.toggleValuesInSet($scope.allResultTypes, $scope.hiddenResultTypes);
$scope.toggleValueInSet(resultType, $scope.hiddenResultTypes);
$scope.updateResults();
}
/**
* Update $scope.hiddenConfigs so that ONLY this config is showing,
* and update the visible results.
*
* @param config
*/
$scope.showOnlyConfig = function(config) {
$scope.hiddenConfigs = {};
$scope.toggleValuesInSet($scope.allConfigs, $scope.hiddenConfigs);
$scope.toggleValueInSet(config, $scope.hiddenConfigs);
$scope.updateResults();
}
//
// Operations for sending info back to the server.
//
/**
* Tell the server that the actual results of these particular tests
* are acceptable.
*
* @param testDataSubset an array of test results, most likely a subset of
* $scope.testData (perhaps with some modifications)
*/
$scope.submitApprovals = function(testDataSubset) {
$scope.submitPending = true;
// Convert bug text field to null or 1-item array.
var bugs = null;
var bugNumber = parseInt($scope.submitAdvancedSettings['bug']);
if (!isNaN(bugNumber)) {
bugs = [bugNumber];
}
// TODO(epoger): This is a suboptimal way to prevent users from
// rebaselining failures in alternative renderModes, but it does work.
// For a better solution, see
// https://code.google.com/p/skia/issues/detail?id=1748 ('gm: add new
// result type, RenderModeMismatch')
var encounteredComparisonConfig = false;
var newResults = [];
for (var i = 0; i < testDataSubset.length; i++) {
var actualResult = testDataSubset[i];
var expectedResult = {
builder: actualResult['builder'],
test: actualResult['test'],
config: actualResult['config'],
expectedHashType: actualResult['actualHashType'],
expectedHashDigest: actualResult['actualHashDigest'],
};
if (0 == expectedResult.config.indexOf('comparison-')) {
encounteredComparisonConfig = true;
}
// Advanced settings...
expectedResult['reviewed-by-human'] =
$scope.submitAdvancedSettings['reviewed-by-human'];
if (true == $scope.submitAdvancedSettings['ignore-failure']) {
// if it's false, don't send it at all (just keep the default)
expectedResult['ignore-failure'] = true;
}
expectedResult['bugs'] = bugs;
newResults.push(expectedResult);
}
if (encounteredComparisonConfig) {
alert("Approval failed -- you cannot approve results with config " +
"type comparison-*");
$scope.submitPending = false;
return;
}
$http({
method: "POST",
url: "/edits",
data: {
oldResultsType: $scope.header.type,
oldResultsHash: $scope.header.dataHash,
modifications: newResults
}
}).success(function(data, status, headers, config) {
var itemIndicesToMove = [];
for (var i = 0; i < testDataSubset.length; i++) {
itemIndicesToMove.push(testDataSubset[i].index);
}
$scope.moveItemsToTab(itemIndicesToMove,
"HackToMakeSureThisItemDisappears");
$scope.updateResults();
alert("New baselines submitted successfully!\n\n" +
"You still need to commit the updated expectations files on " +
"the server side to the Skia repo.\n\n" +
"When you click OK, your web UI will reload; after that " +
"completes, you will see the updated data (once the server has " +
"finished loading the update results into memory!) and you can " +
"submit more baselines if you want.");
// I don't know why, but if I just call reload() here it doesn't work.
// Making a timer call it fixes the problem.
$timeout(function(){location.reload();}, 1);
}).error(function(data, status, headers, config) {
alert("There was an error submitting your baselines.\n\n" +
"Please see server-side log for details.");
$scope.submitPending = false;
});
}
//
// Operations we use to mimic Set semantics, in such a way that
// checking for presence within the Set is as fast as possible.
// But getting a list of all values within the Set is not necessarily
// possible.
// TODO(epoger): move into a separate .js file?
//
/**
* Returns true if value "value" is present within set "set".
*
* @param value a value of any type
* @param set an Object which we use to mimic set semantics
* (this should make isValueInSet faster than if we used an Array)
*/
$scope.isValueInSet = function(value, set) {
return (true == set[value]);
}
/**
* If value "value" is already in set "set", remove it; otherwise, add it.
*
* @param value a value of any type
* @param set an Object which we use to mimic set semantics
*/
$scope.toggleValueInSet = function(value, set) {
if (true == set[value]) {
delete set[value];
} else {
set[value] = true;
}
}
/**
* For each value in valueArray, call toggleValueInSet(value, set).
*
* @param valueArray
* @param set
*/
$scope.toggleValuesInSet = function(valueArray, set) {
var arrayLength = valueArray.length;
for (var i = 0; i < arrayLength; i++) {
$scope.toggleValueInSet(valueArray[i], set);
}
}
//
// Array operations; similar to our Set operations, but operate on a
// Javascript Array so we *can* easily get a list of all values in the Set.
// TODO(epoger): move into a separate .js file?
//
/**
* Returns true if value "value" is present within array "array".
*
* @param value a value of any type
* @param array a Javascript Array
*/
$scope.isValueInArray = function(value, array) {
return (-1 != array.indexOf(value));
}
/**
* If value "value" is already in array "array", remove it; otherwise,
* add it.
*
* @param value a value of any type
* @param array a Javascript Array
*/
$scope.toggleValueInArray = function(value, array) {
var i = array.indexOf(value);
if (-1 == i) {
array.push(value);
} else {
array.splice(i, 1);
}
}
//
// Miscellaneous utility functions.
// TODO(epoger): move into a separate .js file?
//
/**
* Returns a human-readable (in local time zone) time string for a
* particular moment in time.
*
* @param secondsPastEpoch (numeric): seconds past epoch in UTC
*/
$scope.localTimeString = function(secondsPastEpoch) {
var d = new Date(secondsPastEpoch * 1000);
return d.toString();
}
}
);