Title: [215633] trunk/Websites/perf.webkit.org
Revision
215633
Author
[email protected]
Date
2017-04-21 13:20:05 -0700 (Fri, 21 Apr 2017)

Log Message

Make it possible to view results for sub tests and metrics in A/B testing
https://bugs.webkit.org/show_bug.cgi?id=170975

Reviewed by Chris Dumez.

Replaced TestGroupResultsTable, which was a single table that presented the test results with a set of revisions
each build request used, with TestGroupResultsViewer and TestGroupRevisionTable. TestGroupResultsViewer provides
an UI to look the results of sub-tests and sub-metrics and TestGroupRevisionTable provides an UI to display
the set of revisions each build request used. TestGroupRevisionTable can also show the list of custom roots now
that we've added UI to schedule an analysis task with a custom test group.

This patch extends BarGraphGroup to show multiple bars per SingleBarGraph using a canvas with bars indicating
their mean and confidence interval.

* browser-tests/index.html:
(ChartTest.importChartScripts): Include lazily-evaluated-function.js now that Test model object uses
LazilyEvaluatedFunction.

* public/v3/components/analysis-results-viewer.js:
(AnalysisResultsViewer.TestGroupStackingBlock.prototype._valuesForCommitSet):
(AnalysisResultsViewer.TestGroupStackingBlock.prototype._computeTestGroupStatus):

* public/v3/components/bar-graph-group.js:
(BarGraphGroup): No longer takes formatter. Added _computeRangeLazily and _showLabels as instance variables.
(BarGraphGroup.prototype.addBar): Now takes a list of values, their labels, mean, confidence interval, and
the colors of bar graphs shown for each value and the mean indicator.
(BarGraphGroup.prototype.showLabels): Added.
(BarGraphGroup.prototype.setShowLabels): Added.
(BarGraphGroup.prototype.range): Added.
(BarGraphGroup.prototype._computeRange): Renamed from updateGroupRendering. Now returns the range instead off
setting it to each bar, and each SingleBarGraph's render function uses the value via BarGraphGroup's range.
(BarGraph): Renamed from SingleBarGraph. Added various arguments introduced in addBar, and now stores various
lazily evaluated functions used for rendering.
(BarGraph.prototype.render): Rewritten to use canvas to draw bar graphs and show a label when group's
showLabels() returns true.
(BarGraph.prototype._resizeCanvas): Added.
(BarGraph.prototype._renderCanvas): Added.
(BarGraph.prototype._renderLabels): Added. We align the top of each label to the middle of each bar and shift it
back up by half the height of the label (0.4rem) using margin-top in css.
(BarGraph.htmlTemplate): Uses a canvas now.
(BarGraph.cssTemplate):

* public/v3/components/results-table.js:
(ResultsTable.prototype.renderTable): Updated per code changes to BarGraphGroup.
(ResultsTableRow.prototype.resultContent): Ditto.

* public/v3/components/test-group-results-table.js: Removed.
* public/v3/components/test-group-results-viewer.js: Added.
(TestGroupResultsViewer): Added. Shows a list of test results with bar graphs with mean and confidence interval
indicators. The results of sub tests and metrics can be expanded via "(Breakdown)" link shown below each test. 
(TestGroupResultsViewer.prototype.setTestGroup): Added.
(TestGroupResultsViewer.prototype.setAnalysisResults): Added.
(TestGroupResultsViewer.prototype.render): Added.
(TestGroupResultsViewer.prototype._renderResultsTable): Compute the depth of the test tree we show, and construct
the header rows. Each sub test is "indented" by a new column.
(TestGroupResultsViewer.prototype._buildRowForTest): Added. Build rows for metrics of the given test. Expand the
list of its child tests if it's in expandedTests. Otherwise add a link to "Breakdown" if it has any child tests.
(TestGroupResultsViewer.prototype._buildRowForMetric): Added. Builds rows of table cells to show the results for
the given metric for each configuration.
(TestGroupResultsViewer.prototype._buildRowForMetric.createConfigurationRow): Added. A helper to build cells for
a given configuration represented by a requested commit set.
(TestGroupResultsViewer.prototype._buildValueMap): Added. Creates a mappting between a requested commit set, and
the list of values, mean, etc... associated with the results for the commit set.
(TestGroupResultsViewer.prototype._buildEmptyCells): Added. A helper to create empty cells to indent sub tests.
(TestGroupResultsViewer.prototype._expandCurrentMetrics): Added. Highlights the current metrics and renders the
label for each bar in the results.
(TestGroupResultsViewer.htmlTemplate): Added.
(TestGroupResultsViewer.cssTemplate): Added.

* public/v3/components/test-group-revision-table.js: Added.
(TestGroupRevisionTable): Added. Renders the list of revisions requested for each test configuration as well as
ones used in actual testing, and additional repositories being reported (e.g. repositories for helper scripts).
(TestGroupRevisionTable.prototype.setTestGroup): Added.
(TestGroupRevisionTable.prototype.setAnalysisResults): Added.
(TestGroupRevisionTable.prototype.render): Added.
(TestGroupRevisionTable.prototype._renderTable): Added. The basic algorithm here is to first create a row entry
object for each build request, merge cells that use the same revision of the same repository, and then render
the entire table.
(TestGroupRevisionTable.prototype._buildCommitCell): Added.
(TestGroupRevisionTable.prototype._buildCustomRootsCell): Added.
(TestGroupRevisionTable.prototype._mergeCellsWithSameCommitsAcrossRows): Added. Compute rowspan for each cell
by traversing the rows that use the same revision per repository, and store it in rowCountByRepository while
adding the repository to each succeeding row's repositoriesToSkip.
(TestGroupRevisionTable.htmlTemplate): Added.
(TestGroupRevisionTable.cssTemplate): Added.

* public/v3/index.html:
* public/v3/models/analysis-results.js:
(AnalysisResults): Added _metricIds and _lazilyComputedHighestTests as instance variables.
(AnalysisResults.prototype.findResult): Renamed from find.
(AnalysisResults.prototype.highestTests): Added.
(AnalysisResults.prototype._computeHighestTests): Added. Finds the root tests for this analysis result.
(AnalysisResults.prototype.add): Update _metricIds.
(AnalysisResults.prototype.commitSetForRequest): Added. Returns the reported commit set for the build request.
This commit set contains the set of revisions reported to /api/report by A/B testers.
(AnalysisResultsView.prototype.resultForRequest): Renamed from resultForBuildId.

* public/v3/models/metric.js:
(Metric.prototype.relativeName): Added. Computes the relative name given the test/metric path. This function is
used to determine the label for each test/metric in TestGroupResultsViewer.
(Metric.prototype.aggregatorLabel): Extracted from label.
(Metric.prototype.label):
(Metric.makeFormatter): Added the default value of false to alwaysShowSign.

* public/v3/models/test-group.js:
(TestGroup.prototype.compareTestResults): Now takes a metric instead of retrieving it from the analysis task
since a custom analysis task may not have a metric associated with it.

* public/v3/models/test.js:
(Test): Added _computePathLazily as an instance variable.
(Test.prototype.path): Lazily computes the path now that this function can be called on the same test for many
times in TestGroupResultsViewer while computing relative names of tests and metrics.
(Test.prototype._computePath): Extracted path.
(Test.prototype.fullName): Modernized the code.
(Test.prototype.relativeName): Added.

* public/v3/models/uploaded-file.js:
(UploadedFile):
(UploadedFile.prototype.deletedAt): Added.
(UploadedFile.prototype.label): Added.
(UploadedFile.prototype.url): Added.

* public/v3/pages/analysis-task-page.js:
(AnalysisTaskTestGroupPane.prototype.setTestGroups):
(AnalysisTaskTestGroupPane.prototype.setAnalysisResults): Replaced setAnalysisResultsView. Now takes an
analysisResults instead of its view.
(AnalysisTaskTestGroupPane.prototype.render): No longer enqueues the results table and the retry form to render
since the results table no longer exists, and the retry form re-renders itself as needed.
(AnalysisTaskTestGroupPane.htmlTemplate): Now uses test-group-results-viewer and test-group-revision-table
instead of test-group-results-table, which has been removed.
(AnalysisTaskTestGroupPane.cssTemplate):
(AnalysisTaskPage.prototype._assignTestResultsIfPossible):

* public/v3/pages/create-analysis-task-page.js:
(CreateAnalysisTaskPage.prototype._createAnalysisTaskWithGroup): Removed superflous console.log's.

* tools/js/v3-models.js: Import LazilyEvaluatedFunction now that it's used in the Test model.

* unit-tests/test-model-tests.js: Added.

Modified Paths

Added Paths

Removed Paths

Diff

Modified: trunk/Websites/perf.webkit.org/ChangeLog (215632 => 215633)


--- trunk/Websites/perf.webkit.org/ChangeLog	2017-04-21 20:07:07 UTC (rev 215632)
+++ trunk/Websites/perf.webkit.org/ChangeLog	2017-04-21 20:20:05 UTC (rev 215633)
@@ -1,3 +1,145 @@
+2017-04-21  Ryosuke Niwa  <[email protected]>
+
+        Make it possible to view results for sub tests and metrics in A/B testing
+        https://bugs.webkit.org/show_bug.cgi?id=170975
+
+        Reviewed by Chris Dumez.
+
+        Replaced TestGroupResultsTable, which was a single table that presented the test results with a set of revisions
+        each build request used, with TestGroupResultsViewer and TestGroupRevisionTable. TestGroupResultsViewer provides
+        an UI to look the results of sub-tests and sub-metrics and TestGroupRevisionTable provides an UI to display
+        the set of revisions each build request used. TestGroupRevisionTable can also show the list of custom roots now
+        that we've added UI to schedule an analysis task with a custom test group.
+
+        This patch extends BarGraphGroup to show multiple bars per SingleBarGraph using a canvas with bars indicating
+        their mean and confidence interval.
+
+        * browser-tests/index.html:
+        (ChartTest.importChartScripts): Include lazily-evaluated-function.js now that Test model object uses
+        LazilyEvaluatedFunction.
+
+        * public/v3/components/analysis-results-viewer.js:
+        (AnalysisResultsViewer.TestGroupStackingBlock.prototype._valuesForCommitSet):
+        (AnalysisResultsViewer.TestGroupStackingBlock.prototype._computeTestGroupStatus):
+
+        * public/v3/components/bar-graph-group.js:
+        (BarGraphGroup): No longer takes formatter. Added _computeRangeLazily and _showLabels as instance variables.
+        (BarGraphGroup.prototype.addBar): Now takes a list of values, their labels, mean, confidence interval, and
+        the colors of bar graphs shown for each value and the mean indicator.
+        (BarGraphGroup.prototype.showLabels): Added.
+        (BarGraphGroup.prototype.setShowLabels): Added.
+        (BarGraphGroup.prototype.range): Added.
+        (BarGraphGroup.prototype._computeRange): Renamed from updateGroupRendering. Now returns the range instead off
+        setting it to each bar, and each SingleBarGraph's render function uses the value via BarGraphGroup's range.
+        (BarGraph): Renamed from SingleBarGraph. Added various arguments introduced in addBar, and now stores various
+        lazily evaluated functions used for rendering.
+        (BarGraph.prototype.render): Rewritten to use canvas to draw bar graphs and show a label when group's
+        showLabels() returns true.
+        (BarGraph.prototype._resizeCanvas): Added.
+        (BarGraph.prototype._renderCanvas): Added.
+        (BarGraph.prototype._renderLabels): Added. We align the top of each label to the middle of each bar and shift it
+        back up by half the height of the label (0.4rem) using margin-top in css.
+        (BarGraph.htmlTemplate): Uses a canvas now.
+        (BarGraph.cssTemplate):
+
+        * public/v3/components/results-table.js:
+        (ResultsTable.prototype.renderTable): Updated per code changes to BarGraphGroup.
+        (ResultsTableRow.prototype.resultContent): Ditto.
+
+        * public/v3/components/test-group-results-table.js: Removed.
+        * public/v3/components/test-group-results-viewer.js: Added.
+        (TestGroupResultsViewer): Added. Shows a list of test results with bar graphs with mean and confidence interval
+        indicators. The results of sub tests and metrics can be expanded via "(Breakdown)" link shown below each test. 
+        (TestGroupResultsViewer.prototype.setTestGroup): Added.
+        (TestGroupResultsViewer.prototype.setAnalysisResults): Added.
+        (TestGroupResultsViewer.prototype.render): Added.
+        (TestGroupResultsViewer.prototype._renderResultsTable): Compute the depth of the test tree we show, and construct
+        the header rows. Each sub test is "indented" by a new column.
+        (TestGroupResultsViewer.prototype._buildRowForTest): Added. Build rows for metrics of the given test. Expand the
+        list of its child tests if it's in expandedTests. Otherwise add a link to "Breakdown" if it has any child tests.
+        (TestGroupResultsViewer.prototype._buildRowForMetric): Added. Builds rows of table cells to show the results for
+        the given metric for each configuration.
+        (TestGroupResultsViewer.prototype._buildRowForMetric.createConfigurationRow): Added. A helper to build cells for
+        a given configuration represented by a requested commit set.
+        (TestGroupResultsViewer.prototype._buildValueMap): Added. Creates a mappting between a requested commit set, and
+        the list of values, mean, etc... associated with the results for the commit set.
+        (TestGroupResultsViewer.prototype._buildEmptyCells): Added. A helper to create empty cells to indent sub tests.
+        (TestGroupResultsViewer.prototype._expandCurrentMetrics): Added. Highlights the current metrics and renders the
+        label for each bar in the results.
+        (TestGroupResultsViewer.htmlTemplate): Added.
+        (TestGroupResultsViewer.cssTemplate): Added.
+
+        * public/v3/components/test-group-revision-table.js: Added.
+        (TestGroupRevisionTable): Added. Renders the list of revisions requested for each test configuration as well as
+        ones used in actual testing, and additional repositories being reported (e.g. repositories for helper scripts).
+        (TestGroupRevisionTable.prototype.setTestGroup): Added.
+        (TestGroupRevisionTable.prototype.setAnalysisResults): Added.
+        (TestGroupRevisionTable.prototype.render): Added.
+        (TestGroupRevisionTable.prototype._renderTable): Added. The basic algorithm here is to first create a row entry
+        object for each build request, merge cells that use the same revision of the same repository, and then render
+        the entire table.
+        (TestGroupRevisionTable.prototype._buildCommitCell): Added.
+        (TestGroupRevisionTable.prototype._buildCustomRootsCell): Added.
+        (TestGroupRevisionTable.prototype._mergeCellsWithSameCommitsAcrossRows): Added. Compute rowspan for each cell
+        by traversing the rows that use the same revision per repository, and store it in rowCountByRepository while
+        adding the repository to each succeeding row's repositoriesToSkip.
+        (TestGroupRevisionTable.htmlTemplate): Added.
+        (TestGroupRevisionTable.cssTemplate): Added.
+
+        * public/v3/index.html:
+        * public/v3/models/analysis-results.js:
+        (AnalysisResults): Added _metricIds and _lazilyComputedHighestTests as instance variables.
+        (AnalysisResults.prototype.findResult): Renamed from find.
+        (AnalysisResults.prototype.highestTests): Added.
+        (AnalysisResults.prototype._computeHighestTests): Added. Finds the root tests for this analysis result.
+        (AnalysisResults.prototype.add): Update _metricIds.
+        (AnalysisResults.prototype.commitSetForRequest): Added. Returns the reported commit set for the build request.
+        This commit set contains the set of revisions reported to /api/report by A/B testers.
+        (AnalysisResultsView.prototype.resultForRequest): Renamed from resultForBuildId.
+
+        * public/v3/models/metric.js:
+        (Metric.prototype.relativeName): Added. Computes the relative name given the test/metric path. This function is
+        used to determine the label for each test/metric in TestGroupResultsViewer.
+        (Metric.prototype.aggregatorLabel): Extracted from label.
+        (Metric.prototype.label):
+        (Metric.makeFormatter): Added the default value of false to alwaysShowSign.
+
+        * public/v3/models/test-group.js:
+        (TestGroup.prototype.compareTestResults): Now takes a metric instead of retrieving it from the analysis task
+        since a custom analysis task may not have a metric associated with it.
+
+        * public/v3/models/test.js:
+        (Test): Added _computePathLazily as an instance variable.
+        (Test.prototype.path): Lazily computes the path now that this function can be called on the same test for many
+        times in TestGroupResultsViewer while computing relative names of tests and metrics.
+        (Test.prototype._computePath): Extracted path.
+        (Test.prototype.fullName): Modernized the code.
+        (Test.prototype.relativeName): Added.
+
+        * public/v3/models/uploaded-file.js:
+        (UploadedFile):
+        (UploadedFile.prototype.deletedAt): Added.
+        (UploadedFile.prototype.label): Added.
+        (UploadedFile.prototype.url): Added.
+
+        * public/v3/pages/analysis-task-page.js:
+        (AnalysisTaskTestGroupPane.prototype.setTestGroups):
+        (AnalysisTaskTestGroupPane.prototype.setAnalysisResults): Replaced setAnalysisResultsView. Now takes an
+        analysisResults instead of its view.
+        (AnalysisTaskTestGroupPane.prototype.render): No longer enqueues the results table and the retry form to render
+        since the results table no longer exists, and the retry form re-renders itself as needed.
+        (AnalysisTaskTestGroupPane.htmlTemplate): Now uses test-group-results-viewer and test-group-revision-table
+        instead of test-group-results-table, which has been removed.
+        (AnalysisTaskTestGroupPane.cssTemplate):
+        (AnalysisTaskPage.prototype._assignTestResultsIfPossible):
+
+        * public/v3/pages/create-analysis-task-page.js:
+        (CreateAnalysisTaskPage.prototype._createAnalysisTaskWithGroup): Removed superflous console.log's.
+
+        * tools/js/v3-models.js: Import LazilyEvaluatedFunction now that it's used in the Test model.
+
+        * unit-tests/test-model-tests.js: Added.
+
 2017-04-19  Ryosuke Niwa  <[email protected]>
 
         Another build fix after r215061. Clear TriggerableRepositoryGroup's static map in each iteration.

Modified: trunk/Websites/perf.webkit.org/browser-tests/index.html (215632 => 215633)


--- trunk/Websites/perf.webkit.org/browser-tests/index.html	2017-04-21 20:07:07 UTC (rev 215632)
+++ trunk/Websites/perf.webkit.org/browser-tests/index.html	2017-04-21 20:20:05 UTC (rev 215633)
@@ -207,6 +207,7 @@
     {
         return context.importScripts([
             '../shared/statistics.js',
+            'lazily-evaluated-function.js',
             'instrumentation.js',
             'models/data-model.js',
             'models/time-series.js',

Modified: trunk/Websites/perf.webkit.org/public/v3/components/analysis-results-viewer.js (215632 => 215633)


--- trunk/Websites/perf.webkit.org/public/v3/components/analysis-results-viewer.js	2017-04-21 20:07:07 UTC (rev 215632)
+++ trunk/Websites/perf.webkit.org/public/v3/components/analysis-results-viewer.js	2017-04-21 20:20:05 UTC (rev 215633)
@@ -487,7 +487,7 @@
     _valuesForCommitSet(testGroup, commitSet)
     {
         return testGroup.requestsForCommitSet(commitSet).map((request) => {
-            return this._analysisResultsView.resultForBuildId(request.buildId());
+            return this._analysisResultsView.resultForRequest(request);
         }).filter((result) => !!result).map((result) => result.value);
     }
 
@@ -498,7 +498,7 @@
         console.assert(this._commitSetIndexRowIndexMap.length <= 2); // FIXME: Support having more root sets.
         const startValues = this._valuesForCommitSet(this._testGroup, this._commitSetIndexRowIndexMap[0].commitSet);
         const endValues = this._valuesForCommitSet(this._testGroup, this._commitSetIndexRowIndexMap[1].commitSet);
-        const result = this._testGroup.compareTestResults(startValues, endValues);
+        const result = this._testGroup.compareTestResults(this._analysisResultsView.metric(), startValues, endValues);
         return {label: result.label, title: result.fullLabel, status: result.status};
     }
 }

Modified: trunk/Websites/perf.webkit.org/public/v3/components/bar-graph-group.js (215632 => 215633)


--- trunk/Websites/perf.webkit.org/public/v3/components/bar-graph-group.js	2017-04-21 20:07:07 UTC (rev 215632)
+++ trunk/Websites/perf.webkit.org/public/v3/components/bar-graph-group.js	2017-04-21 20:20:05 UTC (rev 215633)
@@ -1,104 +1,205 @@
 
 class BarGraphGroup {
-    constructor(formatter)
+    constructor()
     {
         this._bars = [];
-        this._formatter = formatter;
+        this._computeRangeLazily = new LazilyEvaluatedFunction(this._computeRange.bind(this));
+        this._showLabels = false;
     }
 
-    addBar(value, interval)
+    addBar(values, valueLabels, mean, interval, barColor, meanIndicatorColor)
     {
-        var newBar = new SingleBarGraph(this);
-        this._bars.push({bar: newBar, value: value, interval: interval});
-        return newBar;
+        const bar = new BarGraph(this, values, valueLabels, mean, interval, barColor, meanIndicatorColor);
+        this._bars.push({bar, values, interval});
+        return bar;
     }
 
-    updateGroupRendering()
+    showLabels() { return this._showLabels; }
+    setShowLabels(showLabels)
     {
-        Instrumentation.startMeasuringTime('BarGraphGroup', 'updateGroupRendering');
+        this._showLabels = showLabels;
+        for (const entry of this._bars)
+            entry.bar.enqueueToRender();
+    }
 
-        var min = Infinity;
-        var max = -Infinity;
-        for (var entry of this._bars) {
-            min = Math.min(min, entry.interval ? entry.interval[0] : entry.value);
-            max = Math.max(max, entry.interval ? entry.interval[1] : entry.value);
+    range()
+    {
+        return this._computeRangeLazily.evaluate(...this._bars);
+    }
+
+    _computeRange(...bars)
+    {
+        Instrumentation.startMeasuringTime('BarGraphGroup', 'updateGroup');
+
+        let min = Infinity;
+        let max = -Infinity;
+        for (const entry of bars) {
+            for (const value of entry.values) {
+                if (isNaN(value))
+                    continue;
+                min = Math.min(min, value);
+                max = Math.max(max, value);
+            }
+            if (entry.interval) {
+                for (const value of entry.interval) {
+                    min = Math.min(min, value);
+                    max = Math.max(max, value);
+                }
+            }
         }
 
-        for (var entry of this._bars) {
-            var value = entry.value;
-            var formattedValue = this._formatter(value);
-            if (entry.interval)
-                formattedValue += ' \u00B1' + ((value - entry.interval[0]) * 100 / value).toFixed(2) + '%';
+        const diff = max - min;
+        min -= diff * 0.1;
+        max += diff * 0.1;
 
-            var diff = (max - min);
-            var range = diff * 1.2;
-            var start = min - (range - diff) / 2;
+        const xForValue = (value, width) => (value - min) / (max - min) * width;
+        const barRangeForValue = (value, width) => [0, (value - min) / (max - min) * width];
 
-            entry.bar.update((value - start) / range, formattedValue);
-            entry.bar.enqueueToRender();
-        }
+        Instrumentation.endMeasuringTime('BarGraphGroup', 'updateGroup');
 
-        Instrumentation.endMeasuringTime('BarGraphGroup', 'updateGroupRendering');
+        return {min, max, xForValue, barRangeForValue};
     }
 }
 
-class SingleBarGraph extends ComponentBase {
+class BarGraph extends ComponentBase {
+    constructor(group, values, valueLabels, mean, interval, barColor, meanIndicatorColor)
+    {
+        super('bar-graph');
+        this._group = group;
+        this._values = values;
+        this._valueLabels = valueLabels;
+        this._mean = mean;
+        this._interval = interval;
+        this._barColor = barColor;
+        this._meanIndicatorColor = meanIndicatorColor;
+        this._resizeCanvasLazily = new LazilyEvaluatedFunction(this._resizeCanvas.bind(this));
+        this._renderCanvasLazily = new LazilyEvaluatedFunction(this._renderCanvas.bind(this));
+        this._renderLabelsLazily = new LazilyEvaluatedFunction(this._renderLabels.bind(this));
+    }
 
-    constructor(group)
+    render()
     {
-        console.assert(group instanceof BarGraphGroup);
-        super('single-bar-graph');
-        this._percentage = 0;
-        this._label = null;
+        Instrumentation.startMeasuringTime('SingleBarGraph', 'render');
+
+        if (!this._values)
+            return false;
+
+        const range = this._group.range();
+        const showLabels = this._group.showLabels();
+
+        const canvas = this.content('graph');
+        const element = this.element();
+        const width = element.offsetWidth;
+        const height = element.offsetHeight;
+        const scale = this._resizeCanvasLazily.evaluate(canvas, width, height);
+
+        const step = this._renderCanvasLazily.evaluate(canvas, width, height, scale, this._values,
+            showLabels ? null : this._mean, showLabels ? null : this._interval, range);
+
+        this._renderLabelsLazily.evaluate(canvas, step, showLabels ? this._valueLabels : null);
+
+        Instrumentation.endMeasuringTime('SingleBarGraph', 'render');
     }
 
-    update(percentage, label)
+    _resizeCanvas(canvas, width, height)
     {
-        this._percentage = percentage;
-        this._label = label;
+        const scale = window.devicePixelRatio;
+        canvas.width = width * scale;
+        canvas.height = height * scale;
+        canvas.style.width = width + 'px';
+        canvas.style.height = height + 'px';
+        return scale;
     }
 
-    render()
+    _renderCanvas(canvas, width, height, scale, values, mean, interval, range)
     {
-        this.content().querySelector('.percentage').style.width = `calc(${this._percentage * 100}% - 2px)`;
-        this.content().querySelector('.label').textContent = this._label;
+        const context = canvas.getContext('2d');
+        context.scale(scale, scale);
+        context.clearRect(0, 0, width, height);
+
+        context.fillStyle = this._barColor;
+        context.strokeStyle = this._meanIndicatorColor;
+        context.lineWidth = 1;
+
+        const step = Math.floor(height / values.length);
+        for (let i = 0; i < values.length; i++) {
+            const value = values[i];
+            if (isNaN(value))
+                continue;
+            const barWidth = range.xForValue(value, width);
+            const barRange = range.barRangeForValue(value, width);
+            const y = i * step;
+            context.fillRect(0, y, barWidth, step - 1);
+        }
+
+        const filteredValues = values.filter((value) => !isNaN(value));
+        if (mean) {
+            const x = range.xForValue(mean, width);
+            context.beginPath();
+            context.moveTo(x, 0);
+            context.lineTo(x, height);
+            context.stroke();
+        }
+
+        if (interval) {
+            const x1 = range.xForValue(interval[0], width);
+            const x2 = range.xForValue(interval[1], width);
+
+            const errorBarHeight = 10;
+            const endBarY1 = height / 2 - errorBarHeight / 2;
+            const endBarY2 = height / 2 + errorBarHeight / 2;
+
+            context.beginPath();
+            context.moveTo(x1, endBarY1);
+            context.lineTo(x1, endBarY2);
+            context.moveTo(x1, height / 2);
+            context.lineTo(x2, height / 2);
+            context.moveTo(x2, endBarY1);
+            context.lineTo(x2, endBarY2);
+            context.stroke();
+        }
+
+        return step;
     }
 
+    _renderLabels(canvas, step, valueLabels)
+    {
+        if (!valueLabels)
+            valueLabels = [];
+
+        const element = ComponentBase.createElement;
+        this.renderReplace(this.content('labels'), valueLabels.map((label, i) => {
+            const labelElement = element('div', {class: 'label'}, label);
+            labelElement.style.top = (i + 0.5) * step + 'px';
+            return labelElement;
+        }));
+        canvas.style.opacity = valueLabels.length ? 0.5 : 1;
+    }
+
     static htmlTemplate()
     {
-        return `<div class="single-bar-graph"><div class="percentage"></div><div class="label">-</div></div>`;
+        return `<canvas id="graph"></canvas><div id="labels"></div>`;
     }
 
     static cssTemplate()
     {
         return `
-            .single-bar-graph {
+            :host {
+                display: block !important;
+                overflow: hidden;
                 position: relative;
-                display: block;
-                background: #eee;
-                height: 100%;
-                overflow: hidden;
-                text-decoration: none;
-                color: black;
             }
-            .single-bar-graph .percentage {
+            .label {
                 position: absolute;
-                top: 1px;
-                left: 1px;
-                background: #ccc;
-                height: calc(100% - 2px);
-            }
-            .single-bar-graph .label {
-                position: absolute;
-                top: calc(50% - 0.35rem);
                 left: 0;
                 width: 100%;
-                height: 100%;
+                text-align: center;
                 font-size: 0.8rem;
                 line-height: 0.8rem;
-                text-align: center;
+                margin-top: -0.4rem;
             }
         `;
     }
+}
 
-}
+ComponentBase.defineElement('bar-graph', BarGraph);

Modified: trunk/Websites/perf.webkit.org/public/v3/components/base.js (215632 => 215633)


--- trunk/Websites/perf.webkit.org/public/v3/components/base.js	2017-04-21 20:07:07 UTC (rev 215632)
+++ trunk/Websites/perf.webkit.org/public/v3/components/base.js	2017-04-21 20:20:05 UTC (rev 215633)
@@ -16,6 +16,7 @@
         this._element = element;
         this._shadow = null;
         this._actionCallbacks = new Map;
+        this._oldSizeToCheckForResize = null;
 
         if (!ComponentBase.useNativeCustomElements)
             element.addEventListener('DOMNodeInsertedIntoDocument', () => this.enqueueToRender());
@@ -74,18 +75,55 @@
     {
         Instrumentation.startMeasuringTime('ComponentBase', 'renderingTimerDidFire');
 
+        const componentsToRender = ComponentBase._componentsToRender;
+        this._renderLoop();
+        if (ComponentBase._componentsToRenderOnResize) {
+            const resizedComponents = this._resizedComponents(ComponentBase._componentsToRenderOnResize);
+            if (resizedComponents.length) {
+                ComponentBase._componentsToRender = new Set(resizedComponents);
+                this._renderLoop();
+            }
+        }
+
+        Instrumentation.endMeasuringTime('ComponentBase', 'renderingTimerDidFire');
+    }
+
+    static _renderLoop()
+    {
+        const componentsToRender = ComponentBase._componentsToRender;
         do {
-            const currentSet = [...ComponentBase._componentsToRender];
-            ComponentBase._componentsToRender.clear();
+            const currentSet = [...componentsToRender];
+            componentsToRender.clear();
+            const resizeSet = ComponentBase._componentsToRenderOnResize;
             for (let component of currentSet) {
                 Instrumentation.startMeasuringTime('ComponentBase', 'renderingTimerDidFire.render');
                 component.render();
+                if (resizeSet && resizeSet.has(component)) {
+                    const element = component.element();
+                    component._oldSizeToCheckForResize = {width: element.offsetWidth, height: element.offsetHeight};
+                }
                 Instrumentation.endMeasuringTime('ComponentBase', 'renderingTimerDidFire.render');
             }
-        } while (ComponentBase._componentsToRender.size);
+        } while (componentsToRender.size);
         ComponentBase._componentsToRender = null;
+    }
 
-        Instrumentation.endMeasuringTime('ComponentBase', 'renderingTimerDidFire');
+    static _resizedComponents(componentSet)
+    {
+        if (!componentSet)
+            return [];
+
+        const resizedList = [];
+        for (let component of componentSet) {
+            const element = component.element();
+            const width = element.offsetWidth;
+            const height = element.offsetHeight;
+            const oldSize = component._oldSizeToCheckForResize;
+            if (oldSize && oldSize.width == width && oldSize.height == height)
+                continue;
+            resizedList.push(component);
+        }
+        return resizedList;
     }
 
     static _connectedComponentToRenderOnResize(component)
@@ -93,7 +131,8 @@
         if (!ComponentBase._componentsToRenderOnResize) {
             ComponentBase._componentsToRenderOnResize = new Set;
             window.addEventListener('resize', () => {
-                for (let component of ComponentBase._componentsToRenderOnResize)
+                const resized = this._resizedComponents(ComponentBase._componentsToRenderOnResize);
+                for (const component of resized)
                     component.enqueueToRender();
             });
         }

Modified: trunk/Websites/perf.webkit.org/public/v3/components/results-table.js (215632 => 215633)


--- trunk/Websites/perf.webkit.org/public/v3/components/results-table.js	2017-04-21 20:07:07 UTC (rev 215632)
+++ trunk/Websites/perf.webkit.org/public/v3/components/results-table.js	2017-04-21 20:20:05 UTC (rev 215633)
@@ -19,7 +19,8 @@
 
         const [repositoryList, constantCommits] = this._computeRepositoryList(rowGroups);
 
-        const barGraphGroup = new BarGraphGroup(valueFormatter);
+        const barGraphGroup = new BarGraphGroup();
+        barGraphGroup.setShowLabels(true);
         const element = ComponentBase.createElement;
         let hasGroupHeading = false;
         const tableBodies = rowGroups.map((group) => {
@@ -37,7 +38,7 @@
                 if (row.labelForWholeRow())
                     cells.push(element('td', {class: 'whole-row-label', colspan: repositoryList.length + 1}, row.labelForWholeRow()));
                 else {
-                    cells.push(element('td', row.resultContent(barGraphGroup)));
+                    cells.push(element('td', row.resultContent(valueFormatter, barGraphGroup)));
                     cells.push(this._createRevisionListCells(repositoryList, revisionSupressionCount, group, row.commitSet(), rowIndex));
                 }
 
@@ -58,8 +59,6 @@
 
         this.renderReplace(this.content('constant-commits'), constantCommits.map((commit) => element('li', commit.title())));
 
-        barGraphGroup.updateGroupRendering();
-
         Instrumentation.endMeasuringTime('ResultsTable', 'renderTable');
     }
 
@@ -201,10 +200,10 @@
                 border-top: solid 1px #ccc;
             }
 
-            .results-table single-bar-graph {
+            .results-table bar-graph {
                 display: block;
                 width: 7rem;
-                height: 1.2rem;
+                height: 1rem;
             }
 
             #constant-commits {
@@ -252,9 +251,17 @@
     setLabelForWholeRow(label) { this._labelForWholeRow = label; }
     labelForWholeRow() { return this._labelForWholeRow; }
 
-    resultContent(barGraphGroup)
+    resultContent(valueFormatter, barGraphGroup)
     {
-        var resultContent = this._result ? barGraphGroup.addBar(this._result.value, this._result.interval) : this._label;
+        let resultContent = this._label;
+        if (this._result) {
+            const value = this._result.value;
+            const interval = this._result.interval;
+            let label = valueFormatter(value);
+            if (interval)
+                label += ' \u00B1' + ((value - interval[0]) * 100 / value).toFixed(2) + '%';
+            resultContent = barGraphGroup.addBar([value], [label], null, null, '#ccc', null);
+        }
         return this._link ? ComponentBase.createLink(resultContent, this._label, this._link) : resultContent;
     }
 }

Deleted: trunk/Websites/perf.webkit.org/public/v3/components/test-group-results-table.js (215632 => 215633)


--- trunk/Websites/perf.webkit.org/public/v3/components/test-group-results-table.js	2017-04-21 20:07:07 UTC (rev 215632)
+++ trunk/Websites/perf.webkit.org/public/v3/components/test-group-results-table.js	2017-04-21 20:20:05 UTC (rev 215633)
@@ -1,139 +0,0 @@
-
-class TestGroupResultsTable extends ResultsTable {
-    constructor()
-    {
-        super('test-group-results-table');
-        this._testGroup = null;
-        this._renderTestGroupLazily = new LazilyEvaluatedFunction(this._renderTestGroup.bind(this));
-    }
-
-    setTestGroup(testGroup)
-    {
-        this._testGroup = testGroup;
-        this.enqueueToRender();
-    }
-
-    render()
-    {
-        super.render();
-        this._renderTestGroupLazily.evaluate(this._testGroup, this._analysisResultsView);
-    }
-
-    _renderTestGroup(testGroup, analysisResults)
-    {
-        if (!analysisResults)
-            return;
-        const rowGroups = this._buildRowGroups();
-        this.renderTable(
-            analysisResults.metric().makeFormatter(4),
-            rowGroups,
-            'Configuration');
-    }
-
-    _buildRowGroups()
-    {
-        const testGroup = this._testGroup;
-        if (!testGroup)
-            return [];
-
-        const commitSets = this._testGroup.requestedCommitSets();
-        const resultsByCommitSet = new Map;
-        const groups = commitSets.map((commitSet) => {
-            const group = this._buildRowGroupForCommitSet(testGroup, commitSet, resultsByCommitSet);
-            resultsByCommitSet.set(commitSet, group.results);
-            return group;
-        });
-
-        const comparisonRows = [];
-        for (let i = 0; i < commitSets.length - 1; i++) {
-            const startCommit = commitSets[i];
-            for (let j = i + 1; j < commitSets.length; j++) {
-                const endCommit = commitSets[j];
-                const startResults = resultsByCommitSet.get(startCommit) || [];
-                const endResults = resultsByCommitSet.get(endCommit) || [];
-                const row = this._buildComparisonRow(testGroup, startCommit, startResults, endCommit, endResults);
-                if (!row)
-                    continue;
-                comparisonRows.push(row);
-            }
-        }
-
-        groups.unshift({heading: '', rows: comparisonRows});
-
-        return groups;
-    }
-
-    _buildRowGroupForCommitSet(testGroup, commitSet)
-    {
-        const rows = [new ResultsTableRow('Mean', commitSet)];
-        const results = [];
-
-        for (const request of testGroup.requestsForCommitSet(commitSet)) {
-            const result = this._analysisResultsView.resultForBuildId(request.buildId());
-            // Call result.commitSet() for each result since the set of revisions used in testing maybe different from requested ones.
-            const row = new ResultsTableRow(1 + +request.order(), result ? result.commitSet() : null);
-            rows.push(row);
-            if (result) {
-                row.setLink(result.build().url(), result.build().label());
-                row.setResult(result);
-                results.push(result);
-            } else
-                row.setLink(request.statusUrl(), request.statusLabel());
-        }
-
-        const aggregatedResult = MeasurementAdaptor.aggregateAnalysisResults(results);
-        if (!isNaN(aggregatedResult.value))
-            rows[0].setResult(aggregatedResult);
-
-        return {heading: testGroup.labelForCommitSet(commitSet), rows, results};
-    }
-
-    _buildComparisonRow(testGroup, startCommit, startResults, endCommit, endResults)
-    {
-        const startConfig = testGroup.labelForCommitSet(startCommit);
-        const endConfig = testGroup.labelForCommitSet(endCommit);
-
-        const result = this._testGroup.compareTestResults(
-            startResults.map((result) => result.value), endResults.map((result) => result.value));
-        if (result.changeType == null)
-            return null;
-
-        const row = new ResultsTableRow(`${startConfig} to ${endConfig}`, null);
-        const element = ComponentBase.createElement;
-        row.setLabelForWholeRow(element('span',
-            {class: 'results-label ' + result.status}, `${endConfig} is ${result.fullLabel} than ${startConfig}`));
-        return row;
-    }
-
-    static cssTemplate()
-    {
-        return super.cssTemplate() + `
-            .results-label {
-                padding: 0.1rem;
-                width: 100%;
-                height: 100%;
-            }
-
-            th {
-                vertical-align: top;
-            }
-
-            .failed {
-                color: rgb(128, 51, 128);
-            }
-            .unchanged {
-                color: rgb(128, 128, 128);
-            }
-            .worse {
-                color: rgb(255, 102, 102);
-                font-weight: bold;
-            }
-            .better {
-                color: rgb(102, 102, 255);
-                font-weight: bold;
-            }
-        `;
-    }
-}
-
-ComponentBase.defineElement('test-group-results-table', TestGroupResultsTable);

Added: trunk/Websites/perf.webkit.org/public/v3/components/test-group-results-viewer.js (0 => 215633)


--- trunk/Websites/perf.webkit.org/public/v3/components/test-group-results-viewer.js	                        (rev 0)
+++ trunk/Websites/perf.webkit.org/public/v3/components/test-group-results-viewer.js	2017-04-21 20:20:05 UTC (rev 215633)
@@ -0,0 +1,298 @@
+
+class TestGroupResultsViewer extends ComponentBase {
+    constructor()
+    {
+        super('test-group-results-table');
+        this._analysisResults = null;
+        this._testGroup = null;
+        this._startPoint = null;
+        this._endPoint = null;
+        this._currentMetric = null;
+        this._expandedTests = new Set;
+        this._barGraphCellMap = new Map;
+        this._renderResultsTableLazily = new LazilyEvaluatedFunction(this._renderResultsTable.bind(this));
+        this._renderCurrentMetricsLazily = new LazilyEvaluatedFunction(this._renderCurrentMetrics.bind(this));
+    }
+
+    setTestGroup(currentTestGroup)
+    {
+        this._testGroup = currentTestGroup;
+        this.enqueueToRender();
+    }
+
+    setAnalysisResults(analysisResults, metric)
+    {
+        this._analysisResults = analysisResults;
+        this._currentMetric = metric;
+        this.enqueueToRender();
+    }
+
+    render()
+    {
+        if (!this._testGroup || !this._analysisResults)
+            return;
+
+        this._renderResultsTableLazily.evaluate(this._testGroup, this._expandedTests, ...this._analysisResults.highestTests());
+        this._renderCurrentMetricsLazily.evaluate(this._currentMetric);
+    }
+
+    _renderResultsTable(testGroup, expandedTests, ...tests)
+    {
+        let maxDepth = 0;
+        for (const test of expandedTests)
+            maxDepth = Math.max(maxDepth, test.path().length);
+
+        const element = ComponentBase.createElement;
+        this.renderReplace(this.content('results'), [
+            element('thead', [
+                element('tr', [
+                    element('th', {colspan: maxDepth + 1}, 'Test'),
+                    element('th', {class: 'metric-direction'}, ''),
+                    element('th', {colspan: 2}, 'Results'),
+                    element('th', 'Averages'),
+                    element('th', 'Comparison'),
+                ]),
+            ]),
+            tests.map((test) => this._buildRowsForTest(testGroup, expandedTests, test, [], maxDepth, 0))]);
+    }
+
+    _buildRowsForTest(testGroup, expandedTests, test, sharedPath, maxDepth, depth)
+    {
+        const element = ComponentBase.createElement;
+        const rows = element('tbody', test.metrics().map((metric) => this._buildRowForMetric(testGroup, metric, sharedPath, maxDepth, depth)));
+
+        if (expandedTests.has(test)) {
+            return [rows, test.childTests().map((childTest) => {
+                return this._buildRowsForTest(testGroup, expandedTests, childTest, test.path(), maxDepth, depth + 1);
+            })];
+        }
+
+        if (test.childTests().length) {
+            const link = ComponentBase.createLink;
+            return [rows, element('tr', {class: 'breakdown'}, [
+                element('td', {colspan: maxDepth + 1}, link('(Breakdown)', () => {
+                    this._expandedTests = new Set([...expandedTests, test]);
+                    this.enqueueToRender();
+                })),
+                element('td', {colspan: 3}),
+            ])];
+        }
+
+        return rows;
+    }
+
+    _buildRowForMetric(testGroup, metric, sharedPath, maxDepth, depth)
+    {
+        const commitSets = testGroup.requestedCommitSets();
+        const valueMap = this._buildValueMap(testGroup, this._analysisResults.viewForMetric(metric));
+
+        const formatter = metric.makeFormatter(4);
+        const deltaFormatter = metric.makeFormatter(2, false);
+        const formatValue = (value, interval) => {
+            const delta = interval ? (interval[1] - interval[0]) / 2 : null;
+            return value == null || isNaN(value) ? '-' : `${formatter(value)} \u00b1 ${deltaFormatter(delta)}`;
+        }
+
+        const barGroup = new BarGraphGroup();
+        const barCells = [];
+        const createConfigurationRow = (commitSet, previousCommitSet, barColor, meanIndicatorColor) => {
+            const entry = valueMap.get(commitSet);
+            const previousEntry = valueMap.get(previousCommitSet);
+
+            const comparison = entry && previousEntry ? testGroup.compareTestResults(metric, previousEntry.filteredValues, entry.filteredValues) : null;
+            const valueLabels = entry.measurements.map((measurement) => measurement ?  formatValue(measurement.value, measurement.interval) : '-');
+
+            const barCell = element('td', {class: 'plot-bar'},
+                element('div', barGroup.addBar(entry.allValues, valueLabels, entry.mean, entry.interval, barColor, meanIndicatorColor)));
+            barCell.expandedHeight = +valueLabels.length + 'rem';
+            barCells.push(barCell);
+
+            const significance = comparison && comparison.isStatisticallySignificant ? 'significant' : 'negligible';
+            const changeType = comparison ? comparison.changeType : null;
+            return [
+                element('th', testGroup.labelForCommitSet(commitSet)),
+                barCell,
+                element('td', formatValue(entry.mean, entry.interval)),
+                element('td', {class: `comparison ${changeType} ${significance}`}, comparison ? comparison.fullLabel : ''),
+            ];
+        }
+
+        this._barGraphCellMap.set(metric, {barGroup, barCells});
+
+        const rowspan = commitSets.length;
+        const element = ComponentBase.createElement;
+        const link = ComponentBase.createLink;
+        const metricName = metric.test().metrics().length == 1 ? metric.test().relativeName(sharedPath) : metric.relativeName(sharedPath);
+        const _onclick_ = this.createEventHandler((event) => {
+            if (this._currentMetric == metric) {
+                if (event.target.localName == 'bar-graph')
+                    return;
+                this._currentMetric = null;
+            } else
+                this._currentMetric = metric;
+            this.enqueueToRender();
+        });
+        return [
+            element('tr', {onclick}, [
+                this._buildEmptyCells(depth, rowspan),
+                element('th', {colspan: maxDepth - depth + 1, rowspan}, link(metricName, onclick)),
+                element('td', {class: 'metric-direction', rowspan}, metric.isSmallerBetter() ? '\u21A4' : '\u21A6'),
+                createConfigurationRow(commitSets[0], null, '#ddd', '#333')
+            ]),
+            commitSets.slice(1).map((commitSet, setIndex) => {
+                return element('tr', {onclick},
+                    createConfigurationRow(commitSet, commitSets[setIndex], '#aaa', '#000'));
+            })
+        ];
+    }
+
+    _buildValueMap(testGroup, resultsView)
+    {
+        const commitSets = testGroup.requestedCommitSets();
+        const map = new Map;
+        for (const commitSet of commitSets) {
+            const requests = testGroup.requestsForCommitSet(commitSet);
+            const measurements = requests.map((request) => resultsView.resultForRequest(request));
+            const filteredValues = measurements.filter((result) => !!result).map((measurement) => measurement.value);
+            const allValues = measurements.map((result) => result != null ? result.value : NaN);
+            const interval = Statistics.confidenceInterval(filteredValues);
+            map.set(commitSet, {requests, measurements, filteredValues, allValues, mean: Statistics.mean(filteredValues), interval});
+        }
+        return map;
+    }
+
+    _buildEmptyCells(count, rowspan)
+    {
+        const element = ComponentBase.createElement;
+        const emptyCells = [];
+        for (let i = 0; i < count; i++)
+            emptyCells.push(element('td', {rowspan}, ''));
+        return emptyCells;
+    }
+
+    _renderCurrentMetrics(currentMetric)
+    {
+        for (const entry of this._barGraphCellMap.values()) {
+            for (const cell of entry.barCells) {
+                cell.style.height = null;
+                cell.parentNode.className = null;
+            }
+            entry.barGroup.setShowLabels(false);
+        }
+
+        const entry = this._barGraphCellMap.get(currentMetric);
+        if (entry) {
+            for (const cell of entry.barCells) {
+                cell.style.height = cell.expandedHeight;
+                cell.parentNode.className = 'selected';
+            }
+            entry.barGroup.setShowLabels(true);
+        }
+    }
+
+    static htmlTemplate()
+    {
+        return `<table id="results"></table>`;
+    }
+
+    static cssTemplate()
+    {
+        return `
+            table {
+                border-collapse: collapse;
+                margin: 0;
+                padding: 0;
+            }
+            td, th {
+                border: none;
+                padding: 0;
+                margin: 0;
+                white-space: nowrap;
+            }
+            td:not(.metric-direction),
+            th:not(.metric-direction) {
+                padding: 0.1rem 0.5rem;
+            }
+            td:not(.metric-direction) {
+                min-width: 2rem;
+            }
+            td.metric-direction {
+                font-size: large;
+            }
+            bar-graph {
+                width: 7rem;
+                height: 1rem;
+            }
+            th {
+                font-weight: inherit;
+            }
+            thead th {
+                font-weight: inherit;
+                color: #c93;
+            }
+
+            tr.selected > td,
+            tr.selected > th {
+                background: rgba(204, 153, 51, 0.05);
+            }
+
+            tr:first-child > td,
+            tr:first-child > th {
+                border-top: solid 1px #eee;
+            }
+
+            tbody th {
+                text-align: left;
+            }
+            tbody th,
+            tbody td {
+                cursor: pointer;
+            }
+            a {
+                color: inherit;
+                text-decoration: inherit;
+            }
+            bar-graph {
+                width: 100%;
+                height: 100%;
+            }
+            td.plot-bar {
+                position: relative;
+                min-width: 7rem;
+            }
+            td.plot-bar > * {
+                display: block;
+                position: absolute;
+                width: 100%;
+                height: 100%;
+                top: 0;
+                left: 0;
+            }
+            .comparison {
+                text-align: left;
+            }
+            .negligible {
+                color: #999;
+            }
+            .significant.worse {
+                color: #c33;
+            }
+            .significant.better {
+                color: #33c;
+            }
+            tr.breakdown td {
+                padding: 0;
+                font-size: small;
+                text-align: center;
+            }
+            tr.breakdown a {
+                display: inline-block;
+                text-decoration: none;
+                color: #999;
+                margin-bottom: 0.2rem;
+            }
+        `;
+    }
+}
+
+ComponentBase.defineElement('test-group-results-viewer', TestGroupResultsViewer);

Added: trunk/Websites/perf.webkit.org/public/v3/components/test-group-revision-table.js (0 => 215633)


--- trunk/Websites/perf.webkit.org/public/v3/components/test-group-revision-table.js	                        (rev 0)
+++ trunk/Websites/perf.webkit.org/public/v3/components/test-group-revision-table.js	2017-04-21 20:20:05 UTC (rev 215633)
@@ -0,0 +1,203 @@
+
+class TestGroupRevisionTable extends ComponentBase {
+    constructor()
+    {
+        super('test-group-revision-table');
+        this._testGroup = null;
+        this._analysisResults = null;
+        this._renderTableLazily = new LazilyEvaluatedFunction(this._renderTable.bind(this));
+    }
+
+    setTestGroup(testGroup)
+    {
+        this._testGroup = testGroup;
+        this.enqueueToRender();
+    }
+
+    setAnalysisResults(analysisResults)
+    {
+        this._analysisResults = analysisResults;
+        this.enqueueToRender();
+    }
+
+    render()
+    {
+        this._renderTableLazily.evaluate(this._testGroup, this._analysisResults);
+    }
+
+    _renderTable(testGroup, analysisResults)
+    {
+        if (!testGroup)
+            return;
+
+        const commitSets = testGroup.requestedCommitSets();
+
+        const requestedRepositorySet = new Set;
+        const additionalRepositorySet = new Set;
+        let hasCustomRoots = false;
+        for (const commitSet of commitSets) {
+            if (commitSet.customRoots().length)
+                hasCustomRoots = true;
+            for (const repository of commitSet.repositories())
+                requestedRepositorySet.add(repository);
+        }
+
+        const rowEntries = [];
+        commitSets.forEach((commitSet, commitSetIndex) => {
+            const setLabel = testGroup.labelForCommitSet(commitSet);
+            const buildRequests = testGroup.requestsForCommitSet(commitSet);
+            buildRequests.forEach((request, i) => {
+                const resultCommitSet = analysisResults ? analysisResults.commitSetForRequest(request) : null;
+                if (resultCommitSet) {
+                    for (const repository of resultCommitSet.repositories()) {
+                        if (!requestedRepositorySet.has(repository))
+                            additionalRepositorySet.add(repository);
+                    }
+                }
+                rowEntries.push({
+                    groupHeader: !i ? setLabel : null,
+                    groupRowCount: buildRequests.length,
+                    label: (1 + +request.order()).toString(),
+                    commitSet: resultCommitSet || commitSet,
+                    customRoots: commitSet.customRoots(), // FIXME: resultCommitSet should also report roots that got installed.
+                    rowCountByRepository: new Map,
+                    repositoriesToSkip: new Set,
+                    customRootsRowCount: 1,
+                    request,
+                });
+            });
+        });
+
+        this._mergeCellsWithSameCommitsAcrossRows(rowEntries);
+
+        const requestedRepositoryList = Repository.sortByNamePreferringOnesWithURL([...requestedRepositorySet]);
+        const additionalRepositoryList = Repository.sortByNamePreferringOnesWithURL([...additionalRepositorySet]);
+
+        const element = ComponentBase.createElement;
+        const link = ComponentBase.createLink;
+        this.renderReplace(this.content('revision-table'), [
+            element('thead', [
+                element('th', 'Configuration'),
+                requestedRepositoryList.map((repository) => element('th', repository.name())),
+                hasCustomRoots ? element('th', 'Roots') : [],
+                element('th', 'Order'),
+                element('th', 'Status'),
+                additionalRepositoryList.map((repository) => element('th', repository.name())),
+            ]),
+            element('tbody', rowEntries.map((entry) => {
+                const request = entry.request;
+                return element('tr', [
+                    entry.groupHeader ? element('td', {rowspan: entry.groupRowCount}, entry.groupHeader) : [],
+                    requestedRepositoryList.map((repository) => this._buildCommitCell(entry, repository)),
+                    hasCustomRoots ? this._buildCustomRootsCell(entry) : [],
+                    element('td', 1 + +request.order()),
+                    element('td', request.statusUrl() ? link(request.statusLabel(), request.statusUrl()) : request.statusLabel()),
+                    additionalRepositoryList.map((repository) => this._buildCommitCell(entry, repository)),
+                ]);
+            }))]);
+    }
+
+    _buildCommitCell(entry, repository)
+    {
+        if (entry.repositoriesToSkip.has(repository))
+            return [];
+        const commit = entry.commitSet.commitForRepository(repository);
+        return ComponentBase.createElement('td', {rowspan: entry.rowCountByRepository.get(repository)}, commit ? commit.label() : '');
+    }
+
+    _buildCustomRootsCell(entry)
+    {
+        const rowspan = entry.customRootsRowCount;
+        if (!rowspan)
+            return [];
+        const element = ComponentBase.createElement;
+        const link = ComponentBase.createLink;
+
+        if (!entry.customRoots.length)
+            return element('td', {class: 'roots', rowspan});
+
+        return element('td', {class: 'roots', rowspan},
+            element('ul', entry.customRoots.map((customRoot) => {
+                if (customRoot.deletedAt())
+                    return [customRoot.label(), ' ', element('span', {class: 'purged'}, '(Purged)')];
+                return link(customRoot.label(), customRoot.filename(), customRoot.url());
+            }).map((content) => element('li', content))));
+    }
+
+    _mergeCellsWithSameCommitsAcrossRows(rowEntries)
+    {
+        for (let rowIndex = 0; rowIndex < rowEntries.length; rowIndex++) {
+            const entry = rowEntries[rowIndex];
+            for (const repository of entry.commitSet.repositories()) {
+                if (entry.repositoriesToSkip.has(repository))
+                    continue;
+                const commit = entry.commitSet.commitForRepository(repository);
+                let rowCount = 1;
+                for (let otherRowIndex = rowIndex + 1; otherRowIndex < rowEntries.length; otherRowIndex++) {
+                    const otherEntry = rowEntries[otherRowIndex];
+                    const otherCommit = otherEntry.commitSet.commitForRepository(repository);
+                    if (commit != otherCommit)
+                        break;
+                    otherEntry.repositoriesToSkip.add(repository);
+                    rowCount++;
+                }
+                entry.rowCountByRepository.set(repository, rowCount);
+            }
+            if (entry.customRootsRowCount) {
+                let rowCount = 1;
+                for (let otherRowIndex = rowIndex + 1; otherRowIndex < rowEntries.length; otherRowIndex++) {
+                    const otherEntry = rowEntries[otherRowIndex];
+                    if (!CommitSet.areCustomRootsEqual(entry.customRoots, otherEntry.customRoots))
+                        break;
+                    otherEntry.customRootsRowCount = 0;
+                    rowCount++;
+                }
+                entry.customRootsRowCount = rowCount;
+            }
+        }
+    }
+
+    static htmlTemplate()
+    {
+        return `<table id="revision-table"></table>`;
+    }
+
+    static cssTemplate()
+    {
+        return `
+            table {
+                border-collapse: collapse;
+            }
+            th, td {
+                text-align: center;
+                padding: 0.2rem 0.8rem;
+            }
+            tbody th,
+            tbody td {
+                border-top: solid 1px #eee;
+                border-bottom: solid 1px #eee;
+            }
+            th {
+                font-weight: inherit;
+            }
+            .roots {
+                max-width: 20rem;
+            }
+            .purged {
+                color: #999;
+            }
+            .roots ul,
+            .roots li {
+                list-style: none;
+                margin: 0;
+                padding: 0;
+            }
+            .roots li {
+                margin-top: 0.4rem;
+                margin-bottom: 0.4rem;
+            }
+        `;
+    }
+}
+
+ComponentBase.defineElement('test-group-revision-table', TestGroupRevisionTable);

Modified: trunk/Websites/perf.webkit.org/public/v3/index.html (215632 => 215633)


--- trunk/Websites/perf.webkit.org/public/v3/index.html	2017-04-21 20:07:07 UTC (rev 215632)
+++ trunk/Websites/perf.webkit.org/public/v3/index.html	2017-04-21 20:20:05 UTC (rev 215633)
@@ -84,7 +84,8 @@
         <script src=""
         <script src=""
         <script src=""
-        <script src=""
+        <script src=""
+        <script src=""
         <script src=""
         <script src=""
         <script src=""

Modified: trunk/Websites/perf.webkit.org/public/v3/models/analysis-results.js (215632 => 215633)


--- trunk/Websites/perf.webkit.org/public/v3/models/analysis-results.js	2017-04-21 20:07:07 UTC (rev 215632)
+++ trunk/Websites/perf.webkit.org/public/v3/models/analysis-results.js	2017-04-21 20:20:05 UTC (rev 215633)
@@ -3,9 +3,11 @@
     constructor()
     {
         this._metricToBuildMap = {};
+        this._metricIds = [];
+        this._lazilyComputedHighestTests = new LazilyEvaluatedFunction(this._computeHighestTests);
     }
 
-    find(buildId, metricId)
+    findResult(buildId, metricId)
     {
         const map = this._metricToBuildMap[metricId];
         if (!map)
@@ -13,17 +15,37 @@
         return map[buildId];
     }
 
+    highestTests() { return this._lazilyComputedHighestTests.evaluate(this._metricIds); }
+
+    _computeHighestTests(metricIds)
+    {
+        const testsInResults = new Set(metricIds.map((metricId) => Metric.findById(metricId).test()));
+        return [...testsInResults].filter((test) => !testsInResults.has(test.parentTest()));
+    }
+
     add(measurement)
     {
         console.assert(measurement.configType == 'current');
         const metricId = measurement.metricId;
-        if (!(metricId in this._metricToBuildMap))
+        if (!(metricId in this._metricToBuildMap)) {
             this._metricToBuildMap[metricId] = {};
-        var map = this._metricToBuildMap[metricId];
+            this._metricIds = Object.keys(this._metricToBuildMap);
+        }
+        const map = this._metricToBuildMap[metricId];
         console.assert(!map[measurement.buildId]);
         map[measurement.buildId] = measurement;
     }
 
+    commitSetForRequest(buildRequest)
+    {
+        if (!this._metricIds.length)
+            return null;
+        const result = this.findResult(buildRequest.buildId(), this._metricIds[0]);
+        if (!result)
+            return null;
+        return result.commitSet();
+    }
+
     viewForMetric(metric)
     {
         console.assert(metric instanceof Metric);
@@ -60,8 +82,8 @@
 
     metric() { return this._metric; }
 
-    resultForBuildId(buildId)
+    resultForRequest(buildRequest)
     {
-        return this._results.find(buildId, this._metric.id());
+        return this._results.findResult(buildRequest.buildId(), this._metric.id());
     }
 }

Modified: trunk/Websites/perf.webkit.org/public/v3/models/metric.js (215632 => 215633)


--- trunk/Websites/perf.webkit.org/public/v3/models/metric.js	2017-04-21 20:07:07 UTC (rev 215632)
+++ trunk/Websites/perf.webkit.org/public/v3/models/metric.js	2017-04-21 20:20:05 UTC (rev 215633)
@@ -48,28 +48,35 @@
 
     fullName() { return this._test.fullName() + ' : ' + this.label(); }
 
-    label()
+    relativeName(path)
     {
-        var suffix = '';
+        const relativeTestName = this._test.relativeName(path);
+        if (relativeTestName == null)
+            return this.label();
+        return relativeTestName + ' : ' + this.label();
+    }
+
+    aggregatorLabel()
+    {
         switch (this._aggregatorName) {
-        case null:
-            break;
         case 'Arithmetic':
-            suffix = ' : Arithmetic mean';
-            break;
+            return 'Arithmetic mean';
         case 'Geometric':
-            suffix = ' : Geometric mean';
-            break;
+            return 'Geometric mean';
         case 'Harmonic':
-            suffix = ' : Harmonic mean';
-            break;
+            return 'Harmonic mean';
         case 'Total':
-        default:
-            suffix = ' : ' + this._aggregatorName;
+            return 'Total';
         }
-        return this.name() + suffix;
+        return null;
     }
 
+    label()
+    {
+        const aggregatorLabel = this.aggregatorLabel();
+        return this.name() + (aggregatorLabel ? ` : ${aggregatorLabel}` : '');
+    }
+
     unit() { return this._unit; }
 
     isSmallerBetter()
@@ -80,7 +87,7 @@
 
     makeFormatter(sigFig, alwaysShowSign) { return Metric.makeFormatter(this.unit(), sigFig, alwaysShowSign); }
 
-    static makeFormatter(unit, sigFig = 2, alwaysShowSign)
+    static makeFormatter(unit, sigFig = 2, alwaysShowSign = false)
     {
         let isMiliseconds = false;
         if (unit == 'ms') {

Modified: trunk/Websites/perf.webkit.org/public/v3/models/test-group.js (215632 => 215633)


--- trunk/Websites/perf.webkit.org/public/v3/models/test-group.js	2017-04-21 20:07:07 UTC (rev 215632)
+++ trunk/Websites/perf.webkit.org/public/v3/models/test-group.js	2017-04-21 20:20:05 UTC (rev 215633)
@@ -109,14 +109,12 @@
         return this._buildRequests.some(function (request) { return request.isPending(); });
     }
 
-    compareTestResults(beforeValues, afterValues)
+    compareTestResults(metric, beforeValues, afterValues)
     {
+        console.assert(metric);
         const beforeMean = Statistics.sum(beforeValues) / beforeValues.length;
         const afterMean = Statistics.sum(afterValues) / afterValues.length;
 
-        var metric = AnalysisTask.findById(this._taskId).metric();
-        console.assert(metric);
-
         var result = {changeType: null, status: 'failed', label: 'Failed', fullLabel: 'Failed', isStatisticallySignificant: false};
 
         var hasCompleted = this.hasFinished();

Modified: trunk/Websites/perf.webkit.org/public/v3/models/test.js (215632 => 215633)


--- trunk/Websites/perf.webkit.org/public/v3/models/test.js	2017-04-21 20:07:07 UTC (rev 215632)
+++ trunk/Websites/perf.webkit.org/public/v3/models/test.js	2017-04-21 20:20:05 UTC (rev 215633)
@@ -8,6 +8,7 @@
         this._parentId = object.parentId;
         this._childTests = [];
         this._metrics = [];
+        this._computePathLazily = new LazilyEvaluatedFunction(this._computePath.bind(this));
 
         if (!this._parentId)
             this.ensureNamedStaticMap('topLevelTests')[id] = this;
@@ -43,10 +44,12 @@
 
     parentTest() { return Test.findById(this._parentId); }
 
-    path()
+    path() { return this._computePathLazily.evaluate(); }
+
+    _computePath()
     {
-        var path = [];
-        var currentTest = this;
+        const path = [];
+        let currentTest = this;
         while (currentTest) {
             path.unshift(currentTest);
             currentTest = currentTest.parentTest();
@@ -54,8 +57,24 @@
         return path;
     }
 
-    fullName() { return this.path().map(function (test) { return test.label(); }).join(' \u220B '); }
+    fullName() { return this.path().map((test) => test.label()).join(' \u220B '); }
 
+    relativeName(sharedPath)
+    {
+        const path = this.path();
+        const partialName = (index) => path.slice(index).map((test) => test.label()).join(' \u220B ');
+        if (!sharedPath || !sharedPath.length)
+            return partialName(0);
+        let i = 0;
+        for (; i < path.length && i < sharedPath.length; i++) {
+            if (sharedPath[i] != path[i])
+                return partialName(i);
+        }
+        if (i < path.length)
+            return partialName(i);
+        return null;
+    }
+
     onlyContainsSingleMetric() { return !this.childTests().length && this._metrics.length == 1; }
 
     childTests()

Modified: trunk/Websites/perf.webkit.org/public/v3/models/uploaded-file.js (215632 => 215633)


--- trunk/Websites/perf.webkit.org/public/v3/models/uploaded-file.js	2017-04-21 20:07:07 UTC (rev 215632)
+++ trunk/Websites/perf.webkit.org/public/v3/models/uploaded-file.js	2017-04-21 20:20:05 UTC (rev 215633)
@@ -5,6 +5,7 @@
     {
         super(id, object);
         this._createdAt = new Date(object.createdAt);
+        this._deletedAt = new Date(object.deletedAt);
         this._filename = object.filename;
         this._author = object.author;
         this._size = object.size;
@@ -13,9 +14,12 @@
     }
 
     createdAt() { return this._createdAt; }
+    deletedAt() { return this._deletedAt; }
     filename() { return this._filename; }
     author() { return this._author; }
     size() { return this._size; }
+    label() { return this.filename(); }
+    url() { return `/api/uploaded-file/${this.id()}`; }
 
     static uploadFile(file, uploadProgressCallback = null)
     {

Modified: trunk/Websites/perf.webkit.org/public/v3/pages/analysis-task-page.js (215632 => 215633)


--- trunk/Websites/perf.webkit.org/public/v3/pages/analysis-task-page.js	2017-04-21 20:07:07 UTC (rev 215632)
+++ trunk/Websites/perf.webkit.org/public/v3/pages/analysis-task-page.js	2017-04-21 20:20:05 UTC (rev 215633)
@@ -161,13 +161,15 @@
         this._testGroups = testGroups;
         this._currentTestGroup = currentTestGroup;
         this._showHiddenGroups = showHiddenGroups;
-        this.part('results-table').setTestGroup(currentTestGroup);
+        this.part('revision-table').setTestGroup(currentTestGroup);
+        this.part('results-viewer').setTestGroup(currentTestGroup);
         this.enqueueToRender();
     }
 
-    setAnalysisResultsView(analysisResultsView)
+    setAnalysisResults(analysisResults, metric)
     {
-        this.part('results-table').setAnalysisResultsView(analysisResultsView);
+        this.part('revision-table').setAnalysisResults(analysisResults);
+        this.part('results-viewer').setAnalysisResults(analysisResults, metric);
         this.enqueueToRender();
     }
 
@@ -177,8 +179,6 @@
         this._renderTestGroupVisibilityLazily.evaluate(...this._testGroups.map((group) => group.isHidden() ? 'hidden' : 'visible'));
         this._renderTestGroupNamesLazily.evaluate(...this._testGroups.map((group) => group.label()));
         this._renderCurrentTestGroup(this._currentTestGroup);
-        this.part('results-table').enqueueToRender();
-        this.part('retry-form').enqueueToRender();
     }
 
     _renderTestGroups(showHiddenGroups, ...testGroups)
@@ -239,7 +239,8 @@
         return `
             <ul id="test-group-list"></ul>
             <div id="test-group-details">
-                <test-group-results-table id="results-table"></test-group-results-table>
+                <test-group-results-viewer id="results-viewer"></test-group-results-viewer>
+                <test-group-revision-table id="revision-table"></test-group-revision-table>
                 <test-group-form id="retry-form">Retry</test-group-form>
                 <button id="hide-button">Hide</button>
                 <span id="pending-request-cancel-warning">(cancels pending requests)</span>
@@ -251,8 +252,17 @@
         return `
             :host {
                 display: flex !important;
+                font-size: 0.9rem;
             }
 
+            #new-container {
+                display: flex;
+            }
+
+            #new-container test-group-revision-table {
+                margin-left: 2rem;
+            }
+
             #test-group-list {
                 margin: 0;
                 padding: 0.2rem 0;
@@ -490,7 +500,7 @@
             return false;
 
         const view = this._analysisResults.viewForMetric(this._metric);
-        this.part('group-pane').setAnalysisResultsView(view);
+        this.part('group-pane').setAnalysisResults(this._analysisResults, this._metric);
         this.part('results-pane').setAnalysisResultsView(view);
 
         return true;

Modified: trunk/Websites/perf.webkit.org/public/v3/pages/create-analysis-task-page.js (215632 => 215633)


--- trunk/Websites/perf.webkit.org/public/v3/pages/create-analysis-task-page.js	2017-04-21 20:07:07 UTC (rev 215632)
+++ trunk/Websites/perf.webkit.org/public/v3/pages/create-analysis-task-page.js	2017-04-21 20:20:05 UTC (rev 215633)
@@ -45,9 +45,7 @@
         const commitSets = configurator.commitSets();
 
         TestGroup.createWithTask(taskName, platform, tests[0], testGroupName, iterationCount, commitSets).then((task) => {
-            console.log('yay?', task);
             const url = ""
-            console.log('moving to ' + url);
             location.href = ""
         }, (error) => {
             alert('Failed to create a new test group: ' + error);

Modified: trunk/Websites/perf.webkit.org/tools/js/v3-models.js (215632 => 215633)


--- trunk/Websites/perf.webkit.org/tools/js/v3-models.js	2017-04-21 20:07:07 UTC (rev 215632)
+++ trunk/Websites/perf.webkit.org/tools/js/v3-models.js	2017-04-21 20:20:05 UTC (rev 215633)
@@ -35,5 +35,6 @@
 
 importFromV3('privileged-api.js', 'PrivilegedAPI');
 importFromV3('instrumentation.js', 'Instrumentation');
+importFromV3('lazily-evaluated-function.js', 'LazilyEvaluatedFunction');
 
 global.Statistics = require('../../public/shared/statistics.js');

Added: trunk/Websites/perf.webkit.org/unit-tests/test-model-tests.js (0 => 215633)


--- trunk/Websites/perf.webkit.org/unit-tests/test-model-tests.js	                        (rev 0)
+++ trunk/Websites/perf.webkit.org/unit-tests/test-model-tests.js	2017-04-21 20:20:05 UTC (rev 215633)
@@ -0,0 +1,179 @@
+'use strict';
+
+const assert = require('assert');
+require('../tools/js/v3-models.js');
+
+describe('Test', function () {
+    beforeEach(() => {
+        Test.clearStaticMap();
+    });
+
+    describe('topLevelTests', () => {
+        it('should contain the tests without a parent test', () => {
+            assert.deepEqual(Test.topLevelTests(), []);
+            const someTest = new Test(1, {id: 1, name: 'some test', parentId: null});
+            assert.deepEqual(Test.topLevelTests(), [someTest]);
+        });
+
+        it('should not contain the tests with a parent test', () => {
+            assert.deepEqual(Test.topLevelTests(), []);
+            const someTest = new Test(1, {id: 1, name: 'some test', parentId: null});
+            const childTest = new Test(2, {id: 2, name: 'child test', parentId: 1});
+            assert.equal(childTest.parentTest(), someTest);
+            assert.deepEqual(Test.topLevelTests(), [someTest]);
+        });
+    });
+
+    describe('childTests', () => {
+        it('must return the list of the child tests', () => {
+            const someTest = new Test(1, {id: 1, name: 'some test', parentId: null});
+            const childTest = new Test(2, {id: 2, name: 'child test', parentId: 1});
+            const otherChildTest = new Test(3, {id: 3, name: 'other child test', parentId: 1});
+            assert.deepEqual(someTest.childTests(), [childTest, otherChildTest]);
+        });
+
+        it('must not return a list that contains a grand child test', () => {
+            const someTest = new Test(1, {id: 1, name: 'some test', parentId: null});
+            const childTest = new Test(2, {id: 2, name: 'child test', parentId: 1});
+            const grandChildTest = new Test(3, {id: 3, name: 'grand child test', parentId: 2});
+            assert.deepEqual(someTest.childTests(), [childTest]);
+            assert.deepEqual(childTest.childTests(), [grandChildTest]);
+        });
+    });
+
+    describe('parentTest', () => {
+        it('must return null for a test without a parent test', () => {
+            const someTest = new Test(1, {id: 1, name: 'some test', parentId: null});
+            assert.equal(someTest.parentTest(), null);
+        });
+
+        it('must return the parent test', () => {
+            const someTest = new Test(1, {id: 1, name: 'some test', parentId: null});
+            const childTest = new Test(2, {id: 2, name: 'child test', parentId: 1});
+            const grandChildTest = new Test(3, {id: 3, name: 'grand child test', parentId: 2});
+            assert.equal(childTest.parentTest(), someTest);
+            assert.equal(grandChildTest.parentTest(), childTest);
+        });
+    });
+
+    describe('path', () => {
+        it('must return an array containing itself for a test without a parent', () => {
+            const someTest = new Test(1, {id: 1, name: 'some test', parentId: null});
+            assert.deepEqual(someTest.path(), [someTest]);
+        });
+
+        it('must return an array containing every ancestor and itself for a test with a parent', () => {
+            const someTest = new Test(1, {id: 1, name: 'some test', parentId: null});
+            const childTest = new Test(2, {id: 2, name: 'child test', parentId: 1});
+            const grandChildTest = new Test(3, {id: 3, name: 'grand child test', parentId: 2});
+            assert.deepEqual(childTest.path(), [someTest, childTest]);
+            assert.deepEqual(grandChildTest.path(), [someTest, childTest, grandChildTest]);
+        });
+    });
+
+    describe('findByPath', () => {
+        it('must return null when there are no tests', () => {
+            assert.equal(Test.findByPath(['some test']), null);
+        });
+
+        it('must be able to find top-level tests', () => {
+            const someTest = new Test(1, {id: 1, name: 'some test', parentId: null});
+            const otherTest = new Test(2, {id: 2, name: 'other test', parentId: null});
+            assert.equal(Test.findByPath(['some test']), someTest);
+        });
+
+        it('must be able to find second-level tests', () => {
+            const parent = new Test(1, {id: 1, name: 'parent', parentId: null});
+            const someChild = new Test(2, {id: 2, name: 'some', parentId: 1});
+            const otherChild = new Test(3, {id: 3, name: 'other', parentId: 1});
+            assert.equal(Test.findByPath(['some']), null);
+            assert.equal(Test.findByPath(['other']), null);
+            assert.equal(Test.findByPath(['parent', 'some']), someChild);
+            assert.equal(Test.findByPath(['parent', 'other']), otherChild);
+        });
+
+        it('must be able to find third-level tests', () => {
+            const parent = new Test(1, {id: 1, name: 'parent', parentId: null});
+            const child = new Test(2, {id: 2, name: 'child', parentId: 1});
+            const grandChild = new Test(3, {id: 3, name: 'grandChild', parentId: 2});
+            assert.equal(Test.findByPath(['child']), null);
+            assert.equal(Test.findByPath(['grandChild']), null);
+            assert.equal(Test.findByPath(['child', 'grandChild']), null);
+            assert.equal(Test.findByPath(['parent', 'grandChild']), null);
+            assert.equal(Test.findByPath(['parent', 'child']), child);
+            assert.equal(Test.findByPath(['parent', 'child', 'grandChild']), grandChild);
+        });
+    });
+
+    describe('fullName', () => {
+        it('must return the name of a top-level test', () => {
+            const someTest = new Test(1, {id: 1, name: 'some test', parentId: null});
+            assert.equal(someTest.fullName(), 'some test');
+        });
+
+        it('must return the name of a second-level test and the name of its parent concatenated with \u220B', () => {
+            const parent = new Test(1, {id: 1, name: 'parent', parentId: null});
+            const child = new Test(2, {id: 2, name: 'child', parentId: 1});
+            assert.equal(child.fullName(), 'parent \u220B child');
+        });
+
+        it('must return the name of a third-level test concatenated with the names of its ancestor tests with \u220B', () => {
+            const parent = new Test(1, {id: 1, name: 'parent', parentId: null});
+            const child = new Test(2, {id: 2, name: 'child', parentId: 1});
+            const grandChild = new Test(3, {id: 3, name: 'grandChild', parentId: 2});
+            assert.equal(grandChild.fullName(), 'parent \u220B child \u220B grandChild');
+        });
+    });
+
+    describe('relativeName', () => {
+        it('must return the full name of a test when the shared path is null', () => {
+            const someTest = new Test(1, {id: 1, name: 'some test', parentId: null});
+            const childTest = new Test(2, {id: 2, name: 'child test', parentId: 1});
+            assert.equal(someTest.relativeName(null), someTest.fullName());
+            assert.equal(childTest.relativeName(null), childTest.fullName());
+        });
+
+        it('must return the full name of a test when the shared path is empty', () => {
+            const someTest = new Test(1, {id: 1, name: 'some test', parentId: null});
+            const childTest = new Test(2, {id: 2, name: 'child test', parentId: 1});
+            assert.equal(someTest.relativeName([]), someTest.fullName());
+            assert.equal(childTest.relativeName([]), childTest.fullName());
+        });
+
+        it('must return null when the shared path is identical to the path of the test', () => {
+            const someTest = new Test(1, {id: 1, name: 'some test', parentId: null});
+            const childTest = new Test(2, {id: 2, name: 'child test', parentId: 1});
+            assert.equal(someTest.relativeName(someTest.path()), null);
+            assert.equal(childTest.relativeName(childTest.path()), null);
+        });
+
+        it('must return the full name of a test when the first part in the path differs', () => {
+            const someTest = new Test(1, {id: 1, name: 'some test', parentId: null});
+            const otherTest = new Test(2, {id: 1, name: 'some test', parentId: null});
+            const childTest = new Test(3, {id: 3, name: 'child test', parentId: 1});
+            assert.equal(someTest.relativeName([otherTest]), someTest.fullName());
+            assert.equal(childTest.relativeName([otherTest]), childTest.fullName());
+        });
+
+        it('must return the name relative to its parent when the shared path is of the parent', () => {
+            const parent = new Test(1, {id: 1, name: 'parent', parentId: null});
+            const child = new Test(2, {id: 2, name: 'child', parentId: 1});
+            assert.equal(child.relativeName([parent]), 'child');
+        });
+
+        it('must return the name relative to its grand parent when the shared path is of the grand parent', () => {
+            const grandParent = new Test(1, {id: 1, name: 'grandParent', parentId: null});
+            const parent = new Test(2, {id: 2, name: 'parent', parentId: 1});
+            const self = new Test(3, {id: 3, name: 'self', parentId: 2});
+            assert.equal(self.relativeName([grandParent]), 'parent \u220B self');
+        });
+
+        it('must return the name relative to its parent when the shared path is of the parent even if it had a grandparent', () => {
+            const grandParent = new Test(1, {id: 1, name: 'grandParent', parentId: null});
+            const parent = new Test(2, {id: 2, name: 'parent', parentId: 1});
+            const self = new Test(3, {id: 3, name: 'self', parentId: 2});
+            assert.equal(self.relativeName([grandParent, parent]), 'self');
+        });
+    });
+
+});
\ No newline at end of file
_______________________________________________
webkit-changes mailing list
[email protected]
https://lists.webkit.org/mailman/listinfo/webkit-changes

Reply via email to