Make it possible to view results for sub tests and metrics in A/B testing
authorrniwa@webkit.org <rniwa@webkit.org@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Fri, 21 Apr 2017 20:20:05 +0000 (20:20 +0000)
committerrniwa@webkit.org <rniwa@webkit.org@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Fri, 21 Apr 2017 20:20:05 +0000 (20:20 +0000)
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.

git-svn-id: http://svn.webkit.org/repository/webkit/trunk@215633 268f45cc-cd09-0410-ab3c-d52691b4dbfc

19 files changed:
Websites/perf.webkit.org/ChangeLog
Websites/perf.webkit.org/browser-tests/index.html
Websites/perf.webkit.org/public/v3/components/analysis-results-viewer.js
Websites/perf.webkit.org/public/v3/components/bar-graph-group.js
Websites/perf.webkit.org/public/v3/components/base.js
Websites/perf.webkit.org/public/v3/components/results-table.js
Websites/perf.webkit.org/public/v3/components/test-group-results-table.js [deleted file]
Websites/perf.webkit.org/public/v3/components/test-group-results-viewer.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/components/test-group-revision-table.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/index.html
Websites/perf.webkit.org/public/v3/models/analysis-results.js
Websites/perf.webkit.org/public/v3/models/metric.js
Websites/perf.webkit.org/public/v3/models/test-group.js
Websites/perf.webkit.org/public/v3/models/test.js
Websites/perf.webkit.org/public/v3/models/uploaded-file.js
Websites/perf.webkit.org/public/v3/pages/analysis-task-page.js
Websites/perf.webkit.org/public/v3/pages/create-analysis-task-page.js
Websites/perf.webkit.org/tools/js/v3-models.js
Websites/perf.webkit.org/unit-tests/test-model-tests.js [new file with mode: 0644]

index c6fa3fc..a628357 100644 (file)
@@ -1,3 +1,145 @@
+2017-04-21  Ryosuke Niwa  <rniwa@webkit.org>
+
+        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  <rniwa@webkit.org>
 
         Another build fix after r215061. Clear TriggerableRepositoryGroup's static map in each iteration.
index a3260ad..b0b148b 100644 (file)
@@ -207,6 +207,7 @@ const ChartTest = {
     {
         return context.importScripts([
             '../shared/statistics.js',
+            'lazily-evaluated-function.js',
             'instrumentation.js',
             'models/data-model.js',
             'models/time-series.js',
index 214cf9f..e3c4314 100644 (file)
@@ -487,7 +487,7 @@ AnalysisResultsViewer.TestGroupStackingBlock = class {
     _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 @@ AnalysisResultsViewer.TestGroupStackingBlock = class {
         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};
     }
 }
index 92d264c..ddeedac 100644 (file)
 
 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();
+    }
+
+    range()
+    {
+        return this._computeRangeLazily.evaluate(...this._bars);
+    }
 
-        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);
+    _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 {
-                position: relative;
-                display: block;
-                background: #eee;
-                height: 100%;
+            :host {
+                display: block !important;
                 overflow: hidden;
-                text-decoration: none;
-                color: black;
-            }
-            .single-bar-graph .percentage {
-                position: absolute;
-                top: 1px;
-                left: 1px;
-                background: #ccc;
-                height: calc(100% - 2px);
+                position: relative;
             }
-            .single-bar-graph .label {
+            .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);
index 7f456db..c288fef 100644 (file)
@@ -16,6 +16,7 @@ class ComponentBase {
         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 @@ class ComponentBase {
     {
         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 @@ class ComponentBase {
         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();
             });
         }
index 176fc55..473e5a2 100644 (file)
@@ -19,7 +19,8 @@ class ResultsTable extends ComponentBase {
 
         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 @@ class ResultsTable extends ComponentBase {
                 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 @@ class ResultsTable extends ComponentBase {
 
         this.renderReplace(this.content('constant-commits'), constantCommits.map((commit) => element('li', commit.title())));
 
-        barGraphGroup.updateGroupRendering();
-
         Instrumentation.endMeasuringTime('ResultsTable', 'renderTable');
     }
 
@@ -201,10 +200,10 @@ class ResultsTable extends ComponentBase {
                 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 @@ class ResultsTableRow {
     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;
     }
 }
diff --git a/Websites/perf.webkit.org/public/v3/components/test-group-results-table.js b/Websites/perf.webkit.org/public/v3/components/test-group-results-table.js
deleted file mode 100644 (file)
index 9d3d28b..0000000
+++ /dev/null
@@ -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);
diff --git a/Websites/perf.webkit.org/public/v3/components/test-group-results-viewer.js b/Websites/perf.webkit.org/public/v3/components/test-group-results-viewer.js
new file mode 100644 (file)
index 0000000..10be0f2
--- /dev/null
@@ -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);
diff --git a/Websites/perf.webkit.org/public/v3/components/test-group-revision-table.js b/Websites/perf.webkit.org/public/v3/components/test-group-revision-table.js
new file mode 100644 (file)
index 0000000..c759c97
--- /dev/null
@@ -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);
index a37e345..7cd5113 100644 (file)
@@ -84,7 +84,8 @@ Run tools/bundle-v3-scripts to speed up the load time for production.`);
         <script src="components/bar-graph-group.js"></script>
         <script src="components/results-table.js"></script>
         <script src="components/analysis-results-viewer.js"></script>
-        <script src="components/test-group-results-table.js"></script>
+        <script src="components/test-group-results-viewer.js"></script>
+        <script src="components/test-group-revision-table.js"></script>
         <script src="components/test-group-form.js"></script>
         <script src="components/customizable-test-group-form.js"></script>
         <script src="components/chart-styles.js"></script>
index bb08590..0bb165c 100644 (file)
@@ -3,9 +3,11 @@ class AnalysisResults {
     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 @@ class AnalysisResults {
         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 @@ class AnalysisResultsView {
 
     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());
     }
 }
index a97745a..e43af2c 100644 (file)
@@ -48,26 +48,33 @@ class Metric extends LabeledObject {
 
     fullName() { return this._test.fullName() + ' : ' + this.label(); }
 
-    label()
+    relativeName(path)
+    {
+        const relativeTestName = this._test.relativeName(path);
+        if (relativeTestName == null)
+            return this.label();
+        return relativeTestName + ' : ' + this.label();
+    }
+
+    aggregatorLabel()
     {
-        var suffix = '';
         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; }
@@ -80,7 +87,7 @@ class Metric extends LabeledObject {
 
     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') {
index c5f6091..1e8140b 100644 (file)
@@ -109,14 +109,12 @@ class TestGroup extends LabeledObject {
         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();
index d3a41fd..4c78195 100644 (file)
@@ -8,6 +8,7 @@ class Test extends LabeledObject {
         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 @@ class Test extends LabeledObject {
 
     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,7 +57,23 @@ class Test extends LabeledObject {
         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; }
 
index 1909eba..45b6537 100644 (file)
@@ -5,6 +5,7 @@ class UploadedFile extends DataModelObject {
     {
         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 @@ class UploadedFile extends DataModelObject {
     }
 
     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)
     {
index 2091319..30221a5 100644 (file)
@@ -161,13 +161,15 @@ class AnalysisTaskTestGroupPane extends ComponentBase {
         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 @@ class AnalysisTaskTestGroupPane extends ComponentBase {
         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 @@ class AnalysisTaskTestGroupPane extends ComponentBase {
         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,6 +252,15 @@ class AnalysisTaskTestGroupPane extends ComponentBase {
         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 {
@@ -490,7 +500,7 @@ class AnalysisTaskPage extends PageWithHeading {
             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;
index a5f064d..9ecff03 100644 (file)
@@ -45,9 +45,7 @@ class CreateAnalysisTaskPage extends PageWithHeading {
         const commitSets = configurator.commitSets();
 
         TestGroup.createWithTask(taskName, platform, tests[0], testGroupName, iterationCount, commitSets).then((task) => {
-            console.log('yay?', task);
             const url = this.router().url(`analysis/task/${task.id()}`);
-            console.log('moving to ' + url);
             location.href = this.router().url(`analysis/task/${task.id()}`);
         }, (error) => {
             alert('Failed to create a new test group: ' + error);
index 76b4e37..05307e5 100644 (file)
@@ -35,5 +35,6 @@ importFromV3('models/uploaded-file.js', 'UploadedFile');
 
 importFromV3('privileged-api.js', 'PrivilegedAPI');
 importFromV3('instrumentation.js', 'Instrumentation');
+importFromV3('lazily-evaluated-function.js', 'LazilyEvaluatedFunction');
 
 global.Statistics = require('../../public/shared/statistics.js');
diff --git a/Websites/perf.webkit.org/unit-tests/test-model-tests.js b/Websites/perf.webkit.org/unit-tests/test-model-tests.js
new file mode 100644 (file)
index 0000000..6854def
--- /dev/null
@@ -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