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