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