Make baseline data points selectable
authorrniwa@webkit.org <rniwa@webkit.org@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Thu, 2 Mar 2017 21:23:07 +0000 (21:23 +0000)
committerrniwa@webkit.org <rniwa@webkit.org@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Thu, 2 Mar 2017 21:23:07 +0000 (21:23 +0000)
https://bugs.webkit.org/show_bug.cgi?id=169069
<rdar://problem/29209427>

Reviewed by Antti Koivisto.

Add the capability to select data points other than "current" configuration type.

This patch refactors the way the "chart status" is computed. Before this patch, ChartStatusView was
responsible for determining two data points for which to compute the status, and computing the status
between two data points. ChartPaneStatusView which inherits from ChartStatusView and used in the charts
page relied upon ChartStatusView to compute these values, and computed the list of revision ranges for
each relevant repository between the data points. ChartPane then had callbacks on ChartPaneStatusView
to know whenever these values changed. Because of this entangled mess, ChartStatusView had to be aware
of InteractiveTimeSeriesChart even though only ChartPaneStatusView could be used with it.

This patch dramatically simplifies the situation by adding referencePoints() on TimeSeriesChart and
InteractiveTimeSeriesChart which returns the current point, the previous point if there is any, and
their time series view. It also extracts ChartStatusEvaluator which computes the current status values
and ChartRevisionRange which computes a list of revision differences both based on the referencePoints.
As a result, ChartPaneStatusView no longer inherits from ChartStatusView, and ChartStatusView has been
renamed to DashboardChartStatusView to reflect its purpose. Furthermore, ChartPane which used to rely on
ChartPaneStatusView's revisionCallback to update the commit log viewer simply uses another instance of
ChartRevisionRange, eliminating the need for the callback.

To implement these classes easily, this patch also introduces a new class, LazilyEvaluatedFunction to
memoize the return value of a function when called with the same arguments. Delaying the computation of
a value and avoiding the work when the values are the same is a very common pattern in the perf dashboard
so I expect this class would be used in a lot more places in the future.

* browser-tests/chart-revision-range-tests.js: Added. Tests for ChartRevisionRange.
* browser-tests/chart-status-evaluator-tests.js: Added. Tests for ChartStatusEvaluator.

* browser-tests/index.html:
(BrowsingContext):
(BrowsingContext.importScripts): Fixed the bug that calling importScripts twice results in MockRemoteAPI
being loaded twice.
(ChartTest.importChartScripts): Import more model objects.
(ChartTest.sampleCluster): Made this a getter.
(ChartTest.makeModelObjectsForSampleCluster):
(ChartTest.makeSampleCluster): Added. Cutomizes the valus of baseline / target based on options.
(ChartTest.respondWithSampleCluster): Now takes an options argument for makeSampleCluster.

* public/v3/components/chart-pane-base.js:
(ChartPaneBase): Added _openRepository to keep track of the currently open repository instead of relying
on _mainChartStatus or _commitLogViewer to keep track of it.
(ChartPaneBase.prototype.configure):  The callback for when the user clicked on a repository name in
ChartPaneStatusView has been replaced by "openRepository" action.
(ChartPaneBase.prototype.setOpenRepository): Moved from ChartPane.
(ChartPaneBase.prototype._mainSelectionDidChange):
(ChartPaneBase.prototype._indicatorDidChange):
(ChartPaneBase.prototype._didFetchData):
(ChartPaneBase.prototype._updateCommitLogViewer): Renamed from _updateStatus.
(ChartPaneBase.prototype.openNewRepository): Renamed from _requestOpeningCommitViewer. Fixed a bug that
clicking on the repository name inside ChartPaneStatusView would not focus the pane, which resulted in
arrow keys to be ignored instead of moving the main chart's indicator or the currently open repository.
(ChartPaneBase.prototype._keyup):
(ChartPaneBase.prototype._moveOpenRepository): Moved from ChartPaneStatusView's
moveRepositoryWithNotification. Used when changing the open repository by up/down arrow keys.

* public/v3/components/chart-revision-range.js: Added. Extracted from ChartPaneStatusView.
(ChartRevisionRange): Added.
(ChartRevisionRange.prototype.revisionList): Added.
(ChartRevisionRange.prototype.rangeForRepository): Added.
(ChartRevisionRange._revisionForPoint): Added. Extracted from ChartPaneStatusView's
_updateRevisionListForNewCurrentRepository.
(ChartRevisionRange._computeRevisionList): Ditto from computeChartStatusLabels.

* public/v3/components/chart-status-evaluator.js: Added.
(ChartStatusEvaluator): Added.
(ChartStatusEvaluator.prototype.status): Added.
(ChartStatusEvaluator.computeChartStatus): Added. Extracted from ChartStatusView's updateStatusIfNeeded.

* public/v3/components/chart-status-view.js: Removed.
(ChartStatusView): Deleted. Split into ChartStatusEvaluator and DashboardChartStatusView.

* public/v3/components/chart-styles.js:
(ChartStyles.baselineStyle): Make baseline data points interactive. This single line change is what
enables the user to interact with the data points. The rest of changes in this patch mostly deals with
the status text such as "5% worse than baseline" and the list of revisions shown in the commit log viewer
which would have shown the wrong range without these changes.

* public/v3/components/dashboard-chart-status-view.js: Added. Extracted from ChartStatusView.
(DashboardChartStatusView): Added.
(DashboardChartStatusView.prototype.render): Added.
(DashboardChartStatusView.htmlTemplate): Added.
(DashboardChartStatusView.cssTemplate): Added.

* public/v3/components/interactive-time-series-chart.js:
(InteractiveTimeSeriesChart.prototype.referencePoints): Added. Return the first point and the last point
as the reference points when there is a selection. Only report the previous point if they are distinct as
showing a range of revisions from a data point to itself makes no sense. When there is a indicator simply
return it and its previous point as reference points. Otherwise return null unlike TimeSeriesChart's
referencePoints which always returns the latest point as the reference point.

* public/v3/components/time-series-chart.js:
(TimeSeriesChart.prototype.referencePoints): Added. Return the latest point as the reference point. It
never returns the previous point even if there were more data points as there is no way for the user to
specify which data points to compare.

* public/v3/index.html: Include newly added files.

* public/v3/lazily-evaluated-function.js: Added.
(LazilyEvaluatedFunction): Added.
(LazilyEvaluatedFunction.prototype.evaluate): Added.

* public/v3/models/commit-log.js:
(CommitLog.prototype.diff): Fixed a bug that computing the diff of two Subversion-like revisions results
in "from" field to be unexpectedly an integer instead of a string.

* public/v3/models/metric.js:
(Metric): Moved the code to compute the unit from the metric name from v2's RunsData class. This makes
writing tests easier since it eliminates the need to load v2's data.js.
(Metric.prototype.unit):
(Metric.prototype.isSmallerBetter): Ditto for determining whether the unit is smaller-is-better.

* public/v3/pages/analysis-task-page.js:
(AnalysisTaskChartPane.prototype._updateStatus): Deleted the unused code.

* public/v3/pages/chart-pane-status-view.js:
(ChartPaneStatusView): No longer inherits from ChartStatusView. Uses ChartStatusEvaluator and
ChartRevisionRange to to compute the chart status and the list of revision changes.
(ChartPaneStatusView.prototype.pointsRangeForAnalysis): Deleted.
(ChartPaneStatusView.prototype.render): Split it into _renderStatus and _renderBuildRevisionTable using
LazilyEvaluatedFunction.
(ChartPaneStatusView.prototype._renderStatus): Added.
(ChartPaneStatusView.prototype._renderBuildRevisionTable): Added.
(ChartPaneStatusView.prototype.setCurrentRepository): _updateRevisionListForNewCurrentRepository has been
moved into ChartRevisionRange. Just enqueue itself to re-render.
(ChartPaneStatusView.prototype._setRevisionRange): Deleted.
(ChartPaneStatusView.prototype.moveRepositoryWithNotification): Deleted.
(ChartPaneStatusView.prototype.updateRevisionList): Deleted.
(ChartPaneStatusView.prototype._updateRevisionListForNewCurrentRepository): Deleted.
(ChartPaneStatusView.prototype.computeChartStatusLabels): Deleted.
(ChartPaneStatusView.htmlTemplate):
(ChartPaneStatusView.cssTemplate):

* public/v3/pages/chart-pane.js:
(ChartPane.prototype.openNewRepository): Overrides the one in ChartPaneBase, which has been renamed from
_requestOpeningCommitViewer.
(ChartPane.prototype._analyzeRange):
(ChartPane.prototype._renderActionToolbar): Use the main chart's selection directly to determine whether
an analysis task can be created for the currenty selected range.

* public/v3/pages/dashboard-page.js:
(DashboardPage.prototype._createChartForCell):

* unit-tests/lazily-evaluated-function-tests.js: Added. Tests for LazilyEvaluatedFunction.

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

21 files changed:
Websites/perf.webkit.org/ChangeLog
Websites/perf.webkit.org/browser-tests/chart-revision-range-tests.js [new file with mode: 0644]
Websites/perf.webkit.org/browser-tests/chart-status-evaluator-tests.js [new file with mode: 0644]
Websites/perf.webkit.org/browser-tests/index.html
Websites/perf.webkit.org/public/v3/components/chart-pane-base.js
Websites/perf.webkit.org/public/v3/components/chart-revision-range.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/components/chart-status-evaluator.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/components/chart-status-view.js [deleted file]
Websites/perf.webkit.org/public/v3/components/chart-styles.js
Websites/perf.webkit.org/public/v3/components/dashboard-chart-status-view.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/components/interactive-time-series-chart.js
Websites/perf.webkit.org/public/v3/components/time-series-chart.js
Websites/perf.webkit.org/public/v3/index.html
Websites/perf.webkit.org/public/v3/lazily-evaluated-function.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/models/commit-log.js
Websites/perf.webkit.org/public/v3/models/metric.js
Websites/perf.webkit.org/public/v3/pages/analysis-task-page.js
Websites/perf.webkit.org/public/v3/pages/chart-pane-status-view.js
Websites/perf.webkit.org/public/v3/pages/chart-pane.js
Websites/perf.webkit.org/public/v3/pages/dashboard-page.js
Websites/perf.webkit.org/unit-tests/lazily-evaluated-function-tests.js [new file with mode: 0644]

index 1abc4a98a5febeff79dba62e57530627a4fc9c89..41bb956d1d93facfc9c22e3b5f69006ed7dd53c6 100644 (file)
@@ -1,3 +1,154 @@
+2017-03-02  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Make baseline data points selectable
+        https://bugs.webkit.org/show_bug.cgi?id=169069
+        <rdar://problem/29209427>
+
+        Reviewed by Antti Koivisto.
+
+        Add the capability to select data points other than "current" configuration type.
+
+        This patch refactors the way the "chart status" is computed. Before this patch, ChartStatusView was
+        responsible for determining two data points for which to compute the status, and computing the status
+        between two data points. ChartPaneStatusView which inherits from ChartStatusView and used in the charts
+        page relied upon ChartStatusView to compute these values, and computed the list of revision ranges for
+        each relevant repository between the data points. ChartPane then had callbacks on ChartPaneStatusView
+        to know whenever these values changed. Because of this entangled mess, ChartStatusView had to be aware
+        of InteractiveTimeSeriesChart even though only ChartPaneStatusView could be used with it.
+
+        This patch dramatically simplifies the situation by adding referencePoints() on TimeSeriesChart and
+        InteractiveTimeSeriesChart which returns the current point, the previous point if there is any, and
+        their time series view. It also extracts ChartStatusEvaluator which computes the current status values
+        and ChartRevisionRange which computes a list of revision differences both based on the referencePoints.
+        As a result, ChartPaneStatusView no longer inherits from ChartStatusView, and ChartStatusView has been
+        renamed to DashboardChartStatusView to reflect its purpose. Furthermore, ChartPane which used to rely on
+        ChartPaneStatusView's revisionCallback to update the commit log viewer simply uses another instance of
+        ChartRevisionRange, eliminating the need for the callback.
+
+        To implement these classes easily, this patch also introduces a new class, LazilyEvaluatedFunction to
+        memoize the return value of a function when called with the same arguments. Delaying the computation of
+        a value and avoiding the work when the values are the same is a very common pattern in the perf dashboard
+        so I expect this class would be used in a lot more places in the future.
+
+        * browser-tests/chart-revision-range-tests.js: Added. Tests for ChartRevisionRange.
+        * browser-tests/chart-status-evaluator-tests.js: Added. Tests for ChartStatusEvaluator.
+
+        * browser-tests/index.html:
+        (BrowsingContext):
+        (BrowsingContext.importScripts): Fixed the bug that calling importScripts twice results in MockRemoteAPI
+        being loaded twice.
+        (ChartTest.importChartScripts): Import more model objects.
+        (ChartTest.sampleCluster): Made this a getter.
+        (ChartTest.makeModelObjectsForSampleCluster):
+        (ChartTest.makeSampleCluster): Added. Cutomizes the valus of baseline / target based on options.
+        (ChartTest.respondWithSampleCluster): Now takes an options argument for makeSampleCluster.
+
+        * public/v3/components/chart-pane-base.js:
+        (ChartPaneBase): Added _openRepository to keep track of the currently open repository instead of relying
+        on _mainChartStatus or _commitLogViewer to keep track of it.
+        (ChartPaneBase.prototype.configure):  The callback for when the user clicked on a repository name in
+        ChartPaneStatusView has been replaced by "openRepository" action.
+        (ChartPaneBase.prototype.setOpenRepository): Moved from ChartPane.
+        (ChartPaneBase.prototype._mainSelectionDidChange):
+        (ChartPaneBase.prototype._indicatorDidChange):
+        (ChartPaneBase.prototype._didFetchData):
+        (ChartPaneBase.prototype._updateCommitLogViewer): Renamed from _updateStatus.
+        (ChartPaneBase.prototype.openNewRepository): Renamed from _requestOpeningCommitViewer. Fixed a bug that
+        clicking on the repository name inside ChartPaneStatusView would not focus the pane, which resulted in
+        arrow keys to be ignored instead of moving the main chart's indicator or the currently open repository.
+        (ChartPaneBase.prototype._keyup):
+        (ChartPaneBase.prototype._moveOpenRepository): Moved from ChartPaneStatusView's
+        moveRepositoryWithNotification. Used when changing the open repository by up/down arrow keys.
+
+        * public/v3/components/chart-revision-range.js: Added. Extracted from ChartPaneStatusView.
+        (ChartRevisionRange): Added.
+        (ChartRevisionRange.prototype.revisionList): Added.
+        (ChartRevisionRange.prototype.rangeForRepository): Added.
+        (ChartRevisionRange._revisionForPoint): Added. Extracted from ChartPaneStatusView's
+        _updateRevisionListForNewCurrentRepository.
+        (ChartRevisionRange._computeRevisionList): Ditto from computeChartStatusLabels.
+
+        * public/v3/components/chart-status-evaluator.js: Added.
+        (ChartStatusEvaluator): Added.
+        (ChartStatusEvaluator.prototype.status): Added.
+        (ChartStatusEvaluator.computeChartStatus): Added. Extracted from ChartStatusView's updateStatusIfNeeded.
+
+        * public/v3/components/chart-status-view.js: Removed.
+        (ChartStatusView): Deleted. Split into ChartStatusEvaluator and DashboardChartStatusView.
+
+        * public/v3/components/chart-styles.js:
+        (ChartStyles.baselineStyle): Make baseline data points interactive. This single line change is what
+        enables the user to interact with the data points. The rest of changes in this patch mostly deals with
+        the status text such as "5% worse than baseline" and the list of revisions shown in the commit log viewer
+        which would have shown the wrong range without these changes.
+
+        * public/v3/components/dashboard-chart-status-view.js: Added. Extracted from ChartStatusView.
+        (DashboardChartStatusView): Added.
+        (DashboardChartStatusView.prototype.render): Added.
+        (DashboardChartStatusView.htmlTemplate): Added.
+        (DashboardChartStatusView.cssTemplate): Added.
+
+        * public/v3/components/interactive-time-series-chart.js:
+        (InteractiveTimeSeriesChart.prototype.referencePoints): Added. Return the first point and the last point
+        as the reference points when there is a selection. Only report the previous point if they are distinct as
+        showing a range of revisions from a data point to itself makes no sense. When there is a indicator simply
+        return it and its previous point as reference points. Otherwise return null unlike TimeSeriesChart's
+        referencePoints which always returns the latest point as the reference point.
+
+        * public/v3/components/time-series-chart.js:
+        (TimeSeriesChart.prototype.referencePoints): Added. Return the latest point as the reference point. It
+        never returns the previous point even if there were more data points as there is no way for the user to
+        specify which data points to compare.
+
+        * public/v3/index.html: Include newly added files.
+
+        * public/v3/lazily-evaluated-function.js: Added.
+        (LazilyEvaluatedFunction): Added.
+        (LazilyEvaluatedFunction.prototype.evaluate): Added.
+
+        * public/v3/models/commit-log.js:
+        (CommitLog.prototype.diff): Fixed a bug that computing the diff of two Subversion-like revisions results
+        in "from" field to be unexpectedly an integer instead of a string.
+
+        * public/v3/models/metric.js:
+        (Metric): Moved the code to compute the unit from the metric name from v2's RunsData class. This makes
+        writing tests easier since it eliminates the need to load v2's data.js.
+        (Metric.prototype.unit):
+        (Metric.prototype.isSmallerBetter): Ditto for determining whether the unit is smaller-is-better.
+
+        * public/v3/pages/analysis-task-page.js:
+        (AnalysisTaskChartPane.prototype._updateStatus): Deleted the unused code.
+
+        * public/v3/pages/chart-pane-status-view.js:
+        (ChartPaneStatusView): No longer inherits from ChartStatusView. Uses ChartStatusEvaluator and
+        ChartRevisionRange to to compute the chart status and the list of revision changes.
+        (ChartPaneStatusView.prototype.pointsRangeForAnalysis): Deleted.
+        (ChartPaneStatusView.prototype.render): Split it into _renderStatus and _renderBuildRevisionTable using
+        LazilyEvaluatedFunction.
+        (ChartPaneStatusView.prototype._renderStatus): Added.
+        (ChartPaneStatusView.prototype._renderBuildRevisionTable): Added.
+        (ChartPaneStatusView.prototype.setCurrentRepository): _updateRevisionListForNewCurrentRepository has been
+        moved into ChartRevisionRange. Just enqueue itself to re-render.
+        (ChartPaneStatusView.prototype._setRevisionRange): Deleted.
+        (ChartPaneStatusView.prototype.moveRepositoryWithNotification): Deleted.
+        (ChartPaneStatusView.prototype.updateRevisionList): Deleted.
+        (ChartPaneStatusView.prototype._updateRevisionListForNewCurrentRepository): Deleted.
+        (ChartPaneStatusView.prototype.computeChartStatusLabels): Deleted.
+        (ChartPaneStatusView.htmlTemplate):
+        (ChartPaneStatusView.cssTemplate):
+
+        * public/v3/pages/chart-pane.js:
+        (ChartPane.prototype.openNewRepository): Overrides the one in ChartPaneBase, which has been renamed from
+        _requestOpeningCommitViewer.
+        (ChartPane.prototype._analyzeRange):
+        (ChartPane.prototype._renderActionToolbar): Use the main chart's selection directly to determine whether
+        an analysis task can be created for the currenty selected range.
+
+        * public/v3/pages/dashboard-page.js:
+        (DashboardPage.prototype._createChartForCell):
+
+        * unit-tests/lazily-evaluated-function-tests.js: Added. Tests for LazilyEvaluatedFunction.
+
 2017-03-01  Ryosuke Niwa  <rniwa@webkit.org>
 
         Build fix after r212853. Make creating an analysis task work again.
diff --git a/Websites/perf.webkit.org/browser-tests/chart-revision-range-tests.js b/Websites/perf.webkit.org/browser-tests/chart-revision-range-tests.js
new file mode 100644 (file)
index 0000000..3e70323
--- /dev/null
@@ -0,0 +1,165 @@
+
+describe('ChartRevisionRange', () => {
+
+    function importRevisionList(context)
+    {
+        return ChartTest.importChartScripts(context).then(() => {
+            ChartTest.makeModelObjectsForSampleCluster(context);
+            return context.importScripts(['lazily-evaluated-function.js', 'components/chart-revision-range.js'], 'ChartRevisionRange');
+        });
+    }
+
+    describe('revisionList on a non-interactive chart', () => {
+        it('should report the list of revision for the latest point', () => {
+            const context = new BrowsingContext();
+            let evaluator;
+            return importRevisionList(context).then((ChartRevisionRange) => {
+                const chart = ChartTest.createChartWithSampleCluster(context);
+                chart.setDomain(ChartTest.sampleCluster.startTime, ChartTest.sampleCluster.endTime);
+                chart.fetchMeasurementSets();
+
+                const requests = context.symbols.MockRemoteAPI.requests;
+                expect(requests.length).to.be(1);
+                ChartTest.respondWithSampleCluster(requests[0]);
+
+                evaluator = new ChartRevisionRange(chart);
+                expect(evaluator.revisionList()).to.be(null);
+
+                return waitForComponentsToRender(context);
+            }).then(() => {
+                const revisionList = evaluator.revisionList();
+                expect(revisionList).to.not.be(null);
+                expect(revisionList.length).to.be(2);
+
+                expect(revisionList[0].repository.label()).to.be('SomeApp');
+                expect(revisionList[0].label).to.be('r4006');
+                expect(revisionList[0].from).to.be(null);
+                expect(revisionList[0].to).to.be('4006');
+
+                expect(revisionList[1].repository.label()).to.be('macOS');
+                expect(revisionList[1].label).to.be('15C50');
+                expect(revisionList[1].from).to.be(null);
+                expect(revisionList[1].to).to.be('15C50');
+            })
+        });
+    });
+
+
+    describe('revisionList on an interactive chart', () => {
+
+        it('should not report the list of revision for the latest point when there is no selection or indicator', () => {
+            const context = new BrowsingContext();
+            let evaluator;
+            return importRevisionList(context).then((ChartRevisionRange) => {
+                const chart = ChartTest.createInteractiveChartWithSampleCluster(context);
+                chart.setDomain(ChartTest.sampleCluster.startTime, ChartTest.sampleCluster.endTime);
+                chart.fetchMeasurementSets();
+
+                const requests = context.symbols.MockRemoteAPI.requests;
+                expect(requests.length).to.be(1);
+                ChartTest.respondWithSampleCluster(requests[0]);
+
+                evaluator = new ChartRevisionRange(chart);
+                expect(evaluator.revisionList()).to.be(null);
+
+                return waitForComponentsToRender(context);
+            }).then(() => {
+                expect(evaluator.revisionList()).to.be(null);
+            })
+        });
+
+        it('should report the list of revision for the locked indicator with differences to the previous point', () => {
+            const context = new BrowsingContext();
+            let chart;
+            let evaluator;
+            return importRevisionList(context).then((ChartRevisionRange) => {
+                chart = ChartTest.createInteractiveChartWithSampleCluster(context);
+                chart.setDomain(ChartTest.sampleCluster.startTime, ChartTest.sampleCluster.endTime);
+                chart.fetchMeasurementSets();
+
+                const requests = context.symbols.MockRemoteAPI.requests;
+                expect(requests.length).to.be(1);
+                ChartTest.respondWithSampleCluster(requests[0]);
+
+                evaluator = new ChartRevisionRange(chart);
+                expect(evaluator.revisionList()).to.be(null);
+
+                return waitForComponentsToRender(context);
+            }).then(() => {
+                expect(evaluator.revisionList()).to.be(null);
+
+                const currentView = chart.sampledTimeSeriesData('current');
+                chart.setIndicator(currentView.lastPoint().id, true);
+
+                let revisionList = evaluator.revisionList();
+                expect(revisionList).to.not.be(null);
+                expect(revisionList.length).to.be(2);
+
+                expect(revisionList[0].repository.label()).to.be('SomeApp');
+                expect(revisionList[0].label).to.be('r4005-r4006');
+                expect(revisionList[0].from).to.be('4005');
+                expect(revisionList[0].to).to.be('4006');
+
+                expect(revisionList[1].repository.label()).to.be('macOS');
+                expect(revisionList[1].label).to.be('15C50');
+                expect(revisionList[1].from).to.be(null);
+                expect(revisionList[1].to).to.be('15C50');
+
+                chart.setIndicator(1004, true); // Across macOS change.
+
+                revisionList = evaluator.revisionList();
+                expect(revisionList.length).to.be(2);
+
+                expect(revisionList[0].repository.label()).to.be('SomeApp');
+                expect(revisionList[0].label).to.be('r4004-r4004');
+                expect(revisionList[0].from).to.be('4004');
+                expect(revisionList[0].to).to.be('4004');
+
+                expect(revisionList[1].repository.label()).to.be('macOS');
+                expect(revisionList[1].label).to.be('15B42 - 15C50');
+                expect(revisionList[1].from).to.be('15B42');
+                expect(revisionList[1].to).to.be('15C50');
+            });
+        });
+
+        it('should report the list of revision for the selected range', () => {
+            const context = new BrowsingContext();
+            let chart;
+            let evaluator;
+            return importRevisionList(context).then((ChartRevisionRange) => {
+                chart = ChartTest.createInteractiveChartWithSampleCluster(context);
+                chart.setDomain(ChartTest.sampleCluster.startTime, ChartTest.sampleCluster.endTime);
+                chart.fetchMeasurementSets();
+
+                const requests = context.symbols.MockRemoteAPI.requests;
+                expect(requests.length).to.be(1);
+                ChartTest.respondWithSampleCluster(requests[0]);
+
+                evaluator = new ChartRevisionRange(chart);
+                expect(evaluator.revisionList()).to.be(null);
+
+                return waitForComponentsToRender(context);
+            }).then(() => {
+                expect(evaluator.revisionList()).to.be(null);
+
+                const currentView = chart.sampledTimeSeriesData('current');
+                chart.setSelection([currentView.firstPoint().time + 1, currentView.lastPoint().time - 1]);
+
+                let revisionList = evaluator.revisionList();
+                expect(revisionList).to.not.be(null);
+                expect(revisionList.length).to.be(2);
+
+                expect(revisionList[0].repository.label()).to.be('SomeApp');
+                expect(revisionList[0].label).to.be('r4003-r4004'); // 4002 and 4005 are outliers and skipped.
+                expect(revisionList[0].from).to.be('4003');
+                expect(revisionList[0].to).to.be('4004');
+
+                expect(revisionList[1].repository.label()).to.be('macOS');
+                expect(revisionList[1].label).to.be('15B42 - 15C50');
+                expect(revisionList[1].from).to.be('15B42');
+                expect(revisionList[1].to).to.be('15C50');
+            });
+        });
+    });
+
+});
diff --git a/Websites/perf.webkit.org/browser-tests/chart-status-evaluator-tests.js b/Websites/perf.webkit.org/browser-tests/chart-status-evaluator-tests.js
new file mode 100644 (file)
index 0000000..aeb7a80
--- /dev/null
@@ -0,0 +1,477 @@
+
+describe('ChartStatusEvaluator', () => {
+
+    function importEvaluator(context)
+    {
+        const scripts = [
+            'lazily-evaluated-function.js',
+            'components/chart-status-evaluator.js'];
+
+        return ChartTest.importChartScripts(context).then(() => {
+            return context.importScripts(scripts, 'Test', 'Metric', 'ChartStatusEvaluator');
+        }).then(() => {
+            return context.symbols.ChartStatusEvaluator;
+        });
+    }
+
+    function makeMetric(context, name) {
+        const Test = context.symbols.Test;
+        const Metric = context.symbols.Metric;
+
+        const test = new Test(10, {name: 'SomeTest'});
+        const metric = new Metric(1, {name: name, test: test});
+
+        return metric;
+    }
+
+    describe('status on a non-interactive chart', () => {
+
+        it('should report the current value of the latest data point', () => {
+            const context = new BrowsingContext();
+            let evaluator;
+            return importEvaluator(context).then((ChartStatusEvaluator) => {
+                const chart = ChartTest.createChartWithSampleCluster(context);
+                chart.setDomain(ChartTest.sampleCluster.startTime, ChartTest.sampleCluster.endTime);
+                chart.fetchMeasurementSets();
+
+                const requests = context.symbols.MockRemoteAPI.requests;
+                expect(requests.length).to.be(1);
+                ChartTest.respondWithSampleCluster(requests[0]);
+
+                const metric = makeMetric(context, 'Time');
+                evaluator = new ChartStatusEvaluator(chart, metric);
+                expect(evaluator.status()).to.be(null);
+
+                return waitForComponentsToRender(context);
+            }).then(() => {
+                const status = evaluator.status();
+                expect(status).to.not.be(null);
+                expect(status.comparison).to.be(null);
+                expect(status.label).to.be(null);
+                expect(status.currentValue).to.be('116 ms');
+                expect(status.relativeDelta).to.be(null);
+            })
+        });
+
+        it('should compare the latest current data point to the baseline when for a smaller-is-better unit', () => {
+            const context = new BrowsingContext();
+            let evaluator;
+            return importEvaluator(context).then((ChartStatusEvaluator) => {
+                const chart = ChartTest.createChartWithSampleCluster(context, [{type: 'current'}, {type: 'baseline'}]);
+                chart.setDomain(ChartTest.sampleCluster.startTime, ChartTest.sampleCluster.endTime);
+                chart.fetchMeasurementSets();
+
+                const requests = context.symbols.MockRemoteAPI.requests;
+                expect(requests.length).to.be(1);
+                ChartTest.respondWithSampleCluster(requests[0]);
+
+                const metric = makeMetric(context, 'Time');
+                evaluator = new ChartStatusEvaluator(chart, metric);
+                expect(evaluator.status()).to.be(null);
+
+                return waitForComponentsToRender(context);
+            }).then(() => {
+                const status = evaluator.status();
+                expect(status).to.not.be(null);
+                expect(status.comparison).to.be('better');
+                expect(status.label).to.be('11.5% better than baseline (131 ms)');
+                expect(status.currentValue).to.be('116 ms');
+                expect(status.relativeDelta).to.be(null);
+            })
+        });
+
+        it('should compare the latest current data point to the baseline when for a bigger-is-better unit', () => {
+            const context = new BrowsingContext();
+            let evaluator;
+            return importEvaluator(context).then((ChartStatusEvaluator) => {
+                const chart = ChartTest.createChartWithSampleCluster(context, [{type: 'current'}, {type: 'baseline'}]);
+                chart.setDomain(ChartTest.sampleCluster.startTime, ChartTest.sampleCluster.endTime);
+                chart.fetchMeasurementSets();
+
+                const requests = context.symbols.MockRemoteAPI.requests;
+                expect(requests.length).to.be(1);
+                ChartTest.respondWithSampleCluster(requests[0]);
+
+                const metric = makeMetric(context, 'Score');
+                evaluator = new ChartStatusEvaluator(chart, metric);
+                expect(evaluator.status()).to.be(null);
+
+                return waitForComponentsToRender(context);
+            }).then(() => {
+                const status = evaluator.status();
+                expect(status).to.not.be(null);
+                expect(status.comparison).to.be('worse');
+                expect(status.label).to.be('11.5% worse than baseline (131 pt)');
+                expect(status.currentValue).to.be('116 pt');
+                expect(status.relativeDelta).to.be(null);
+            })
+        });
+
+        it('should compare the latest current data point to the target when for a smaller-is-better unit', () => {
+            const context = new BrowsingContext();
+            let evaluator;
+            return importEvaluator(context).then((ChartStatusEvaluator) => {
+                const chart = ChartTest.createChartWithSampleCluster(context, [{type: 'current'}, {type: 'target'}]);
+                chart.setDomain(ChartTest.sampleCluster.startTime, ChartTest.sampleCluster.endTime);
+                chart.fetchMeasurementSets();
+
+                const requests = context.symbols.MockRemoteAPI.requests;
+                expect(requests.length).to.be(1);
+                ChartTest.respondWithSampleCluster(requests[0]);
+
+                const metric = makeMetric(context, 'Time');
+                evaluator = new ChartStatusEvaluator(chart, metric);
+                expect(evaluator.status()).to.be(null);
+
+                return waitForComponentsToRender(context);
+            }).then(() => {
+                const status = evaluator.status();
+                expect(status).to.not.be(null);
+                expect(status.comparison).to.be('worse');
+                expect(status.label).to.be('27.5% until target (91.0 ms)');
+                expect(status.currentValue).to.be('116 ms');
+                expect(status.relativeDelta).to.be(null);
+            })
+        });
+
+        it('should compare the latest current data point to the target when for a bigger-is-better unit', () => {
+            const context = new BrowsingContext();
+            let evaluator;
+            return importEvaluator(context).then((ChartStatusEvaluator) => {
+                const chart = ChartTest.createChartWithSampleCluster(context, [{type: 'current'}, {type: 'target'}]);
+                chart.setDomain(ChartTest.sampleCluster.startTime, ChartTest.sampleCluster.endTime);
+                chart.fetchMeasurementSets();
+
+                const requests = context.symbols.MockRemoteAPI.requests;
+                expect(requests.length).to.be(1);
+                ChartTest.respondWithSampleCluster(requests[0]);
+
+                const metric = makeMetric(context, 'Score');
+                evaluator = new ChartStatusEvaluator(chart, metric);
+                expect(evaluator.status()).to.be(null);
+
+                return waitForComponentsToRender(context);
+            }).then(() => {
+                const status = evaluator.status();
+                expect(status).to.not.be(null);
+                expect(status.comparison).to.be('better');
+                expect(status.label).to.be('27.5% until target (91.0 pt)');
+                expect(status.currentValue).to.be('116 pt');
+                expect(status.relativeDelta).to.be(null);
+            })
+        });
+
+        it('should compare the latest current data point to the target when it is smaller than the baseline for a smaller-is-better unit', () => {
+            const context = new BrowsingContext();
+            let evaluator;
+            return importEvaluator(context).then((ChartStatusEvaluator) => {
+                const chart = ChartTest.createChartWithSampleCluster(context, [{type: 'current'}, {type: 'target'}, {type: 'baseline'}]);
+                chart.setDomain(ChartTest.sampleCluster.startTime, ChartTest.sampleCluster.endTime);
+                chart.fetchMeasurementSets();
+
+                const requests = context.symbols.MockRemoteAPI.requests;
+                expect(requests.length).to.be(1);
+                ChartTest.respondWithSampleCluster(requests[0]);
+
+                const metric = makeMetric(context, 'Time');
+                evaluator = new ChartStatusEvaluator(chart, metric);
+                expect(evaluator.status()).to.be(null);
+
+                return waitForComponentsToRender(context);
+            }).then(() => {
+                const status = evaluator.status();
+                expect(status).to.not.be(null);
+                expect(status.comparison).to.be(null);
+                expect(status.label).to.be('27.5% until target (91.0 ms)');
+                expect(status.currentValue).to.be('116 ms');
+                expect(status.relativeDelta).to.be(null);
+            });
+        });
+
+        it('should compare the latest current data point to the baseline when it is smaller than the baseline for a bigger-is-better unit', () => {
+            const context = new BrowsingContext();
+            let evaluator;
+            return importEvaluator(context).then((ChartStatusEvaluator) => {
+                const chart = ChartTest.createChartWithSampleCluster(context, [{type: 'current'}, {type: 'target'}, {type: 'baseline'}]);
+                chart.setDomain(ChartTest.sampleCluster.startTime, ChartTest.sampleCluster.endTime);
+                chart.fetchMeasurementSets();
+
+                const requests = context.symbols.MockRemoteAPI.requests;
+                expect(requests.length).to.be(1);
+                ChartTest.respondWithSampleCluster(requests[0]);
+
+                const metric = makeMetric(context, 'Score');
+                evaluator = new ChartStatusEvaluator(chart, metric);
+                expect(evaluator.status()).to.be(null);
+
+                return waitForComponentsToRender(context);
+            }).then(() => {
+                const status = evaluator.status();
+                expect(status).to.not.be(null);
+                expect(status.comparison).to.be('worse');
+                expect(status.label).to.be('11.5% worse than baseline (131 pt)');
+                expect(status.currentValue).to.be('116 pt');
+                expect(status.relativeDelta).to.be(null);
+            });
+        });
+
+        it('should compare the latest current data point to the baseline when it is bigger than the baseline and the target for a smaller-is-better unit', () => {
+            const context = new BrowsingContext();
+            let evaluator;
+            return importEvaluator(context).then((ChartStatusEvaluator) => {
+                const chart = ChartTest.createChartWithSampleCluster(context, [{type: 'current'}, {type: 'target'}, {type: 'baseline'}]);
+                chart.setDomain(ChartTest.sampleCluster.startTime, ChartTest.sampleCluster.endTime);
+                chart.fetchMeasurementSets();
+
+                const requests = context.symbols.MockRemoteAPI.requests;
+                expect(requests.length).to.be(1);
+                ChartTest.respondWithSampleCluster(requests[0], {baselineIsSmaller: true});
+
+                const metric = makeMetric(context, 'Time');
+                evaluator = new ChartStatusEvaluator(chart, metric);
+                expect(evaluator.status()).to.be(null);
+
+                return waitForComponentsToRender(context);
+            }).then(() => {
+                const status = evaluator.status();
+                expect(status).to.not.be(null);
+                expect(status.comparison).to.be('worse');
+                expect(status.label).to.be('274.2% worse than baseline (31.0 ms)');
+                expect(status.currentValue).to.be('116 ms');
+                expect(status.relativeDelta).to.be(null);
+            });
+        });
+
+        it('should compare the latest current data point to the target when it is bigger than the baseline but smaller than the target for a bigger-is-better unit', () => {
+            const context = new BrowsingContext();
+            let evaluator;
+            return importEvaluator(context).then((ChartStatusEvaluator) => {
+                const chart = ChartTest.createChartWithSampleCluster(context, [{type: 'current'}, {type: 'target'}, {type: 'baseline'}]);
+                chart.setDomain(ChartTest.sampleCluster.startTime, ChartTest.sampleCluster.endTime);
+                chart.fetchMeasurementSets();
+
+                const requests = context.symbols.MockRemoteAPI.requests;
+                expect(requests.length).to.be(1);
+                ChartTest.respondWithSampleCluster(requests[0], {baselineIsSmaller: true, targetIsBigger: true});
+
+                const metric = makeMetric(context, 'Score');
+                evaluator = new ChartStatusEvaluator(chart, metric);
+                expect(evaluator.status()).to.be(null);
+
+                return waitForComponentsToRender(context);
+            }).then(() => {
+                const status = evaluator.status();
+                expect(status).to.not.be(null);
+                expect(status.comparison).to.be(null);
+                expect(status.label).to.be('4.1% until target (121 pt)');
+                expect(status.currentValue).to.be('116 pt');
+                expect(status.relativeDelta).to.be(null);
+            });
+        });
+
+        it('should compare the latest current data point to the target when it is smaller than the target for a smaller-is-better unit', () => {
+            const context = new BrowsingContext();
+            let evaluator;
+            return importEvaluator(context).then((ChartStatusEvaluator) => {
+                const chart = ChartTest.createChartWithSampleCluster(context, [{type: 'current'}, {type: 'target'}, {type: 'baseline'}]);
+                chart.setDomain(ChartTest.sampleCluster.startTime, ChartTest.sampleCluster.endTime);
+                chart.fetchMeasurementSets();
+
+                const requests = context.symbols.MockRemoteAPI.requests;
+                expect(requests.length).to.be(1);
+                ChartTest.respondWithSampleCluster(requests[0], {targetIsBigger: true});
+
+                const metric = makeMetric(context, 'Time');
+                evaluator = new ChartStatusEvaluator(chart, metric);
+                expect(evaluator.status()).to.be(null);
+
+                return waitForComponentsToRender(context);
+            }).then(() => {
+                const status = evaluator.status();
+                expect(status).to.not.be(null);
+                expect(status.comparison).to.be('better');
+                expect(status.label).to.be('4.1% better than target (121 ms)');
+                expect(status.currentValue).to.be('116 ms');
+                expect(status.relativeDelta).to.be(null);
+            });
+        });
+
+        it('should compare the latest current data point to the baseline when it is smaller than the target but bigger than the baseline for a bigger-is-better unit', () => {
+            const context = new BrowsingContext();
+            let evaluator;
+            return importEvaluator(context).then((ChartStatusEvaluator) => {
+                const chart = ChartTest.createChartWithSampleCluster(context, [{type: 'current'}, {type: 'target'}, {type: 'baseline'}]);
+                chart.setDomain(ChartTest.sampleCluster.startTime, ChartTest.sampleCluster.endTime);
+                chart.fetchMeasurementSets();
+
+                const requests = context.symbols.MockRemoteAPI.requests;
+                expect(requests.length).to.be(1);
+                ChartTest.respondWithSampleCluster(requests[0], {baselineIsSmaller: true, targetIsBigger: true});
+
+                const metric = makeMetric(context, 'Score');
+                evaluator = new ChartStatusEvaluator(chart, metric);
+                expect(evaluator.status()).to.be(null);
+
+                return waitForComponentsToRender(context);
+            }).then(() => {
+                const status = evaluator.status();
+                expect(status).to.not.be(null);
+                expect(status.comparison).to.be(null);
+                expect(status.label).to.be('4.1% until target (121 pt)');
+                expect(status.currentValue).to.be('116 pt');
+                expect(status.relativeDelta).to.be(null);
+            });
+        });
+    });
+
+    describe('status on an interactive chart', () => {
+
+        it('should not report the current value of the latest data point', () => {
+            const context = new BrowsingContext();
+            let evaluator;
+            return importEvaluator(context).then((ChartStatusEvaluator) => {
+                const chart = ChartTest.createInteractiveChartWithSampleCluster(context);
+                chart.setDomain(ChartTest.sampleCluster.startTime, ChartTest.sampleCluster.endTime);
+                chart.fetchMeasurementSets();
+
+                const requests = context.symbols.MockRemoteAPI.requests;
+                expect(requests.length).to.be(1);
+                ChartTest.respondWithSampleCluster(requests[0]);
+
+                const metric = makeMetric(context, 'Time');
+                evaluator = new ChartStatusEvaluator(chart, metric);
+                expect(evaluator.status()).to.be(null);
+
+                return waitForComponentsToRender(context);
+            }).then(() => {
+                expect(evaluator.status()).to.be(null);
+            })
+        });
+
+        it('should report the current value and the relative delta when there is a locked indicator', () => {
+            const context = new BrowsingContext();
+            let chart;
+            let evaluator;
+            return importEvaluator(context).then((ChartStatusEvaluator) => {
+                chart = ChartTest.createInteractiveChartWithSampleCluster(context);
+                chart.setDomain(ChartTest.sampleCluster.startTime, ChartTest.sampleCluster.endTime);
+                chart.fetchMeasurementSets();
+
+                const requests = context.symbols.MockRemoteAPI.requests;
+                expect(requests.length).to.be(1);
+                ChartTest.respondWithSampleCluster(requests[0]);
+
+                const metric = makeMetric(context, 'Time');
+                evaluator = new ChartStatusEvaluator(chart, metric);
+                expect(evaluator.status()).to.be(null);
+
+                return waitForComponentsToRender(context);
+            }).then(() => {
+                expect(evaluator.status()).to.be(null);
+
+                const currentView = chart.sampledTimeSeriesData('current');
+                chart.setIndicator(currentView.lastPoint().id, true);
+
+                let status = evaluator.status();
+                expect(status).to.not.be(null);
+                expect(status.comparison).to.be(null);
+                expect(status.label).to.be(null);
+                expect(status.currentValue).to.be('116 ms');
+                expect(status.relativeDelta).to.be('-6%');
+
+                chart.setIndicator(currentView.previousPoint(currentView.lastPoint()).id, true);
+                status = evaluator.status();
+                expect(status).to.not.be(null);
+                expect(status.comparison).to.be(null);
+                expect(status.label).to.be(null);
+                expect(status.currentValue).to.be('124 ms');
+                expect(status.relativeDelta).to.be('10%');
+
+                chart.setIndicator(currentView.firstPoint().id, true);
+                status = evaluator.status();
+                expect(status).to.not.be(null);
+                expect(status.comparison).to.be(null);
+                expect(status.label).to.be(null);
+                expect(status.currentValue).to.be('100 ms');
+                expect(status.relativeDelta).to.be(null);
+
+                return waitForComponentsToRender(context);
+            });
+        });
+
+        it('should report the current value and the relative delta when there is a selection with at least two points', () => {
+            const context = new BrowsingContext();
+            let chart;
+            let evaluator;
+            return importEvaluator(context).then((ChartStatusEvaluator) => {
+                chart = ChartTest.createInteractiveChartWithSampleCluster(context);
+                chart.setDomain(ChartTest.sampleCluster.startTime, ChartTest.sampleCluster.endTime);
+                chart.fetchMeasurementSets();
+
+                const requests = context.symbols.MockRemoteAPI.requests;
+                expect(requests.length).to.be(1);
+                ChartTest.respondWithSampleCluster(requests[0]);
+
+                const metric = makeMetric(context, 'Time');
+                evaluator = new ChartStatusEvaluator(chart, metric);
+                expect(evaluator.status()).to.be(null);
+
+                return waitForComponentsToRender(context);
+            }).then(() => {
+                expect(evaluator.status()).to.be(null);
+
+                const currentView = chart.sampledTimeSeriesData('current');
+                const firstPoint = currentView.firstPoint();
+                const lastPoint = currentView.lastPoint();
+                chart.setSelection([firstPoint.time + 1, lastPoint.time - 1]);
+
+                let status = evaluator.status();
+                expect(status).to.not.be(null);
+                expect(status.comparison).to.be(null);
+                expect(status.label).to.be(null);
+                expect(status.currentValue).to.be('124 ms');
+                expect(status.relativeDelta).to.be('2%');
+
+                return waitForComponentsToRender(context);
+            });
+        });
+
+        it('should report the current value but not the relative delta when there is a selection with exaclyt one point', () => {
+            const context = new BrowsingContext();
+            let chart;
+            let evaluator;
+            return importEvaluator(context).then((ChartStatusEvaluator) => {
+                chart = ChartTest.createInteractiveChartWithSampleCluster(context);
+                chart.setDomain(ChartTest.sampleCluster.startTime, ChartTest.sampleCluster.endTime);
+                chart.fetchMeasurementSets();
+
+                const requests = context.symbols.MockRemoteAPI.requests;
+                expect(requests.length).to.be(1);
+                ChartTest.respondWithSampleCluster(requests[0]);
+
+                const metric = makeMetric(context, 'Time');
+                evaluator = new ChartStatusEvaluator(chart, metric);
+                expect(evaluator.status()).to.be(null);
+
+                return waitForComponentsToRender(context);
+            }).then(() => {
+                expect(evaluator.status()).to.be(null);
+
+                const currentView = chart.sampledTimeSeriesData('current');
+                const firstPoint = currentView.firstPoint();
+                chart.setSelection([firstPoint.time + 1, currentView.nextPoint(firstPoint).time + 1]);
+
+                let status = evaluator.status();
+                expect(status).to.not.be(null);
+                expect(status.comparison).to.be(null);
+                expect(status.label).to.be(null);
+                expect(status.currentValue).to.be('122 ms');
+                expect(status.relativeDelta).to.be(null);
+
+                return waitForComponentsToRender(context);
+            });
+        });
+
+    });
+
+});
index 0dad4b584824c1f7d37e3a5b316ad1eec316ebe6..ce71bd6206d445cb8c759e758433fd1754bf875c 100644 (file)
@@ -19,6 +19,8 @@ mocha.setup('bdd');
 <script src="editable-text-tests.js"></script>
 <script src="time-series-chart-tests.js"></script>
 <script src="interactive-time-series-chart-tests.js"></script>
+<script src="chart-status-evaluator-tests.js"></script>
+<script src="chart-revision-range-tests.js"></script>
 <script>
 
 afterEach(() => {
@@ -40,6 +42,7 @@ class BrowsingContext {
         this.symbols = {};
         this.global = this.iframe.contentWindow;
         this.document = this.iframe.contentDocument;
+        this._didLoadMockRemote = false;
     }
 
     importScripts(pathList, ...symbolList)
@@ -48,8 +51,12 @@ class BrowsingContext {
         const global = this.iframe.contentWindow;
 
         pathList = pathList.map((path) => `../public/v3/${path}`);
+        if (!this._didLoadMockRemote) {
+            this._didLoadMockRemote = true;
+            pathList.unshift('../unit-tests/resources/mock-remote-api.js');
+        }
 
-        return Promise.all(['../unit-tests/resources/mock-remote-api.js', ...pathList].map((path) => {
+        return Promise.all(pathList.map((path) => {
             return new Promise((resolve, reject) => {
                 let script = doc.createElement('script');
                 script.addEventListener('load', resolve);
@@ -196,22 +203,45 @@ const ChartTest = {
             '../shared/statistics.js',
             'instrumentation.js',
             'models/data-model.js',
-            'models/metric.js',
             'models/time-series.js',
             'models/measurement-set.js',
             'models/measurement-cluster.js',
             'models/measurement-adaptor.js',
+            'models/repository.js',
+            'models/platform.js',
+            'models/test.js',
+            'models/metric.js',
+            'models/root-set.js',
+            'models/commit-log.js',
             'components/base.js',
             'components/time-series-chart.js',
             'components/interactive-time-series-chart.js'],
-            'ComponentBase', 'TimeSeriesChart', 'InteractiveTimeSeriesChart', 'Metric', 'MeasurementSet', 'MockRemoteAPI').then(() => {
+            'ComponentBase', 'TimeSeriesChart', 'InteractiveTimeSeriesChart',
+            'Platform', 'Metric', 'Test', 'Repository', 'MeasurementSet', 'MockRemoteAPI').then(() => {
                 return context.symbols.TimeSeriesChart;
             })
     },
 
     posixTime: posixTime,
 
-    sampleCluster: {
+    get sampleCluster() { return this.makeSampleCluster(); },
+
+    makeModelObjectsForSampleCluster(context)
+    {
+        const test = context.symbols.Test.ensureSingleton(2, {name: 'Test'});
+        const metric = context.symbols.Metric.ensureSingleton(1, {name: 'Time', test})
+        const platform = context.symbols.Platform.ensureSingleton(1,
+            {name: 'SomePlatform', metrics: [metric], lastModifiedByMetric: [posixTime('2016-01-18T00:00:00Z')]});
+        metric.addPlatform(platform);
+        context.symbols.Repository.ensureSingleton(1, {name: 'SomeApp'});
+        context.symbols.Repository.ensureSingleton(2, {name: 'macOS'});
+    },
+
+    makeSampleCluster(options = {})
+    {
+        const baselineStart = options.baselineIsSmaller ? 30 : 130;
+        const targetStart = options.targetIsBigger ? 120 : 90;
+        return {
         "clusterStart": posixTime('2016-01-01T00:00:00Z'),
         "clusterSize": 7 * dayInMilliseconds,
         "startTime": posixTime('2016-01-01T00:00:00Z'),
@@ -228,41 +258,65 @@ const ChartTest = {
             "current": [
                 [
                     1000, 100, 1, 100, 100 * 100, false,
-                    [ [ 2000, 1, "4000", posixTime('2016-01-05T17:35:00Z')] ],
+                    [ [2000, 1, "4000", posixTime('2016-01-05T17:35:00Z')], [3000, 2, "15B42", 0] ],
                     posixTime('2016-01-05T17:35:00Z'), 5000, posixTime('2016-01-05T19:23:00Z'), "10", 7
                 ],
                 [
                     1001, 131, 1, 131, 131 * 131, true,
-                    [ [ 2001, 1, "4001", posixTime('2016-01-05T18:43:01Z')] ],
+                    [ [2001, 1, "4001", posixTime('2016-01-05T18:43:01Z')], [3000, 2, "15B42", 0] ],
                     posixTime('2016-01-05T18:43:01Z'), 5001, posixTime('2016-01-05T20:58:01Z'), "11", 7
                 ],
                 [
                     1002, 122, 1, 122, 122 * 122, false,
-                    [ [ 2002, 1, "4002", posixTime('2016-01-05T20:01:02Z') ] ],
+                    [ [2002, 1, "4002", posixTime('2016-01-05T20:01:02Z')], [3000, 2, "15B42", 0] ],
                     posixTime('2016-01-05T20:01:02Z'), 5002, posixTime('2016-01-05T22:37:02Z'), "12", 7
                 ],
                 [
                     1003, 113, 1, 113, 113 * 113, false,
-                    [ [ 2003, 1, "4003", posixTime('2016-01-05T23:19:03Z') ] ],
+                    [ [2003, 1, "4003", posixTime('2016-01-05T23:19:03Z')], [3000, 2, "15B42", 0] ],
                     posixTime('2016-01-05T23:19:03Z'), 5003, posixTime('2016-01-06T23:19:03Z'), "13", 7
                 ],
                 [
                     1004, 124, 1, 124, 124 * 124, false,
-                    [ [ 2004, 1, "4004", posixTime('2016-01-06T01:52:04Z') ] ],
+                    [ [2004, 1, "4004", posixTime('2016-01-06T01:52:04Z')], [3001, 2, "15C50", 0] ],
                     posixTime('2016-01-06T01:52:04Z'), 5004, posixTime('2016-01-06T02:42:04Z'), "14", 7
                 ],
                 [
                     1005, 115, 1, 115, 115 * 115, true,
-                    [ [ 2005, 1, "4005", posixTime('2016-01-06T03:22:05Z') ] ],
+                    [ [2005, 1, "4005", posixTime('2016-01-06T03:22:05Z')], [3001, 2, "15C50", 0] ],
                     posixTime('2016-01-06T03:22:05Z'), 5005, posixTime('2016-01-06T06:01:05Z'), "15", 7
                 ],
                 [
                     1006, 116, 1, 116, 116 * 116, false,
-                    [ [ 2006, 1, "4006", posixTime('2016-01-06T05:59:06Z') ] ],
+                    [ [2006, 1, "4006", posixTime('2016-01-06T05:59:06Z')], [3001, 2, "15C50", 0] ],
                     posixTime('2016-01-06T05:59:06Z'), 5006, posixTime('2016-01-06T08:34:06Z'), "16", 7
                 ]
+            ],
+            "baseline": [
+                [
+                    7000, baselineStart, 1, baselineStart, baselineStart * baselineStart, false,
+                    [ ],
+                    posixTime('2016-01-05T12:00:30Z'), 5030, posixTime('2016-01-05T12:00:30Z'), "30", 7
+                ],
+                [
+                    7001, baselineStart + 1, 1, baselineStart + 1, Math.pow(baselineStart + 1, 2), false,
+                    [ ],
+                    posixTime('2016-01-06T00:00:31Z'), 5031, posixTime('2016-01-06T00:00:31Z'), "31", 7
+                ],
+            ],
+            "target": [
+                [
+                    8000, targetStart, 1, targetStart, targetStart * targetStart, false,
+                    [ ],
+                    posixTime('2016-01-05T12:00:30Z'), 5030, posixTime('2016-01-05T12:00:30Z'), "90", 7
+                ],
+                [
+                    8001, targetStart + 1, 1, targetStart + 1, Math.pow(targetStart + 1, 2), false,
+                    [ ],
+                    posixTime('2016-01-06T00:00:31Z'), 5031, posixTime('2016-01-06T00:00:31Z'), "91", 7
+                ],
             ]
-        },
+        }};
     },
 
     createChartWithSampleCluster(context, sourceList = null, chartOptions = {}, className = 'TimeSeriesChart')
@@ -296,11 +350,11 @@ const ChartTest = {
         return this.createChartWithSampleCluster(context, sourceList, chartOptions, 'InteractiveTimeSeriesChart');
     },
 
-    respondWithSampleCluster(request)
+    respondWithSampleCluster(request, options)
     {
         expect(request.url).to.be('../data/measurement-set-1-1.json');
         expect(request.method).to.be('GET');
-        request.resolve(this.sampleCluster);
+        request.resolve(this.makeSampleCluster(options));
     },
 };
 
index 4fd332919eb97eeed75fd6bec13b4255231b2a24..5743bab563bd0b6282c35d88563533d3d8f641d6 100644 (file)
@@ -12,6 +12,7 @@ class ChartPaneBase extends ComponentBase {
         this._metric = null;
         this._disableSampling = false;
         this._showOutliers = false;
+        this._openRepository = null;
 
         this._overviewChart = null;
         this._mainChart = null;
@@ -53,10 +54,13 @@ class ChartPaneBase extends ComponentBase {
         this._mainChart.listenToAction('annotationClick', this._openAnalysisTask.bind(this));
         this.renderReplace(this.content().querySelector('.chart-pane-main'), this._mainChart);
 
-        this._mainChartStatus = new ChartPaneStatusView(result.metric, this._mainChart, this._requestOpeningCommitViewer.bind(this));
+        this._revisionRange = new ChartRevisionRange(this._mainChart);
+
+        this._mainChartStatus = new ChartPaneStatusView(result.metric, this._mainChart);
+        this._mainChartStatus.listenToAction('openRepository', this.openNewRepository.bind(this));
         this.renderReplace(this.content().querySelector('.chart-pane-details'), this._mainChartStatus);
 
-        this.content().querySelector('.chart-pane').addEventListener('keyup', this._keyup.bind(this));
+        this.content().querySelector('.chart-pane').addEventListener('keydown', this._keyup.bind(this));
 
         this.fetchAnalysisTasks(false);
     }
@@ -125,11 +129,18 @@ class ChartPaneBase extends ComponentBase {
             this._mainChart.setSelection(selection);
     }
 
+    setOpenRepository(repository)
+    {
+        this._openRepository = repository;
+        this._mainChartStatus.setCurrentRepository(repository);
+        this._updateCommitLogViewer();
+    }
+
     _overviewSelectionDidChange(domain, didEndDrag) { }
 
     _mainSelectionDidChange(selection, didEndDrag)
     {
-        this._updateStatus();
+        this._updateCommitLogViewer();
     }
 
     _mainSelectionDidZoom(selection)
@@ -141,19 +152,19 @@ class ChartPaneBase extends ComponentBase {
 
     _indicatorDidChange(indicatorID, isLocked)
     {
-        this._updateStatus();
+        this._updateCommitLogViewer();
     }
 
     _didFetchData()
     {
-        this._updateStatus();
+        this._updateCommitLogViewer();
     }
 
-    _updateStatus()
+    _updateCommitLogViewer()
     {
-        var range = this._mainChartStatus.updateRevisionList();
+        const range = this._revisionRange.rangeForRepository(this._openRepository);
         const updateRendering = () => { this.enqueueToRender(); };
-        this._commitLogViewer.view(range.repository, range.from, range.to).then(updateRendering);
+        this._commitLogViewer.view(this._openRepository, range.from, range.to).then(updateRendering);
         updateRendering();
     }
 
@@ -166,12 +177,10 @@ class ChartPaneBase extends ComponentBase {
 
     router() { return null; }
 
-    _requestOpeningCommitViewer(repository, from, to)
+    openNewRepository(repository)
     {
-        this._mainChartStatus.setCurrentRepository(repository);
-        const updateRendering = () => { this.enqueueToRender(); };
-        this._commitLogViewer.view(repository, from, to).then(updateRendering);
-        updateRendering();
+        this.content().querySelector('.chart-pane').focus();
+        this.setOpenRepository(repository);
     }
 
     _keyup(event)
@@ -186,11 +195,13 @@ class ChartPaneBase extends ComponentBase {
                 return;
             break;
         case 38: // Up
-            if (!this._mainChartStatus.moveRepositoryWithNotification(false))
+            if (!this._moveOpenRepository(false))
                 return;
+            break;
         case 40: // Down
-            if (!this._mainChartStatus.moveRepositoryWithNotification(true))
+            if (!this._moveOpenRepository(true))
                 return;
+            break;
         default:
             return;
         }
@@ -201,6 +212,28 @@ class ChartPaneBase extends ComponentBase {
         event.stopPropagation();
     }
 
+    _moveOpenRepository(forward)
+    {
+        const openRepository = this._openRepository;
+        if (!openRepository)
+            return false;
+
+        const revisionList = this._revisionRange.revisionList();
+        if (!revisionList)
+            return false;
+
+        const currentIndex = revisionList.findIndex((info) => info.repository == openRepository);
+        console.assert(currentIndex >= 0);
+
+        const newIndex = currentIndex + (forward ? 1 : -1);
+        if (newIndex < 0 || newIndex >= revisionList.length)
+            return false;
+
+        this.openNewRepository(revisionList[newIndex].repository);
+
+        return true;
+    }
+
     render()
     {
         Instrumentation.startMeasuringTime('ChartPane', 'render');
diff --git a/Websites/perf.webkit.org/public/v3/components/chart-revision-range.js b/Websites/perf.webkit.org/public/v3/components/chart-revision-range.js
new file mode 100644 (file)
index 0000000..c054f0a
--- /dev/null
@@ -0,0 +1,68 @@
+
+class ChartRevisionRange {
+
+    constructor(chart, metric)
+    {
+        this._chart = chart;
+
+        const thisClass = new.target;
+        this._computeRevisionList = new LazilyEvaluatedFunction((currentPoint, prevoiusPoint) => {
+            return thisClass._computeRevisionList(currentPoint, prevoiusPoint);
+        });
+
+        this._computeRevisionRange = new LazilyEvaluatedFunction((repository, currentPoint, previousPoint) => {
+            return {
+                repository,
+                from: thisClass._revisionForPoint(repository, previousPoint),
+                to: thisClass._revisionForPoint(repository, currentPoint)};
+        });
+    }
+
+    revisionList()
+    {
+        const referencePoints = this._chart.referencePoints('current');
+        const currentPoint = referencePoints ? referencePoints.currentPoint : null;
+        const previousPoint = referencePoints ? referencePoints.previousPoint : null;
+        return this._computeRevisionList.evaluate(currentPoint, previousPoint);
+    }
+
+    rangeForRepository(repository)
+    {
+        const referencePoints = this._chart.referencePoints('current');
+        const currentPoint = referencePoints ? referencePoints.currentPoint : null;
+        const previousPoint = referencePoints ? referencePoints.previousPoint : null;
+        return this._computeRevisionRange.evaluate(repository, currentPoint, previousPoint);
+    }
+
+    static _revisionForPoint(repository, point)
+    {
+        if (!point || !repository)
+            return null;
+        const rootSet = point.rootSet();
+        if (!rootSet)
+            return null;
+        const commit = rootSet.commitForRepository(repository);
+        if (!commit)
+            return null;
+        return commit.revision();
+    }
+
+    static _computeRevisionList(currentPoint, previousPoint)
+    {
+        if (!currentPoint)
+            return null;
+
+        const currentRootSet = currentPoint.rootSet();
+        const previousRootSet = previousPoint ? previousPoint.rootSet() : null;
+
+        const repositoriesInCurrentRootSet = Repository.sortByNamePreferringOnesWithURL(currentRootSet.repositories());
+        const revisionList = [];
+        for (let repository of repositoriesInCurrentRootSet) {
+            let currentCommit = currentRootSet.commitForRepository(repository);
+            let previousCommit = previousRootSet ? previousRootSet.commitForRepository(repository) : null;
+            revisionList.push(currentCommit.diff(previousCommit));
+        }
+        return revisionList;
+    }
+
+}
diff --git a/Websites/perf.webkit.org/public/v3/components/chart-status-evaluator.js b/Websites/perf.webkit.org/public/v3/components/chart-status-evaluator.js
new file mode 100644 (file)
index 0000000..52453c7
--- /dev/null
@@ -0,0 +1,79 @@
+
+class ChartStatusEvaluator {
+
+    constructor(chart, metric)
+    {
+        this._chart = chart;
+        this._computeStatus = new LazilyEvaluatedFunction((currentPoint, previousPoint, view) => {
+            if (!currentPoint)
+                return null;
+
+            const baselineView = this._chart.sampledTimeSeriesData('baseline');
+            const targetView = this._chart.sampledTimeSeriesData('target');
+            return ChartStatusEvaluator.computeChartStatus(metric, currentPoint, previousPoint, view, baselineView, targetView);
+        });
+    }
+
+    status()
+    {
+        const referencePoints = this._chart.referencePoints('current');
+        const currentPoint = referencePoints ? referencePoints.currentPoint : null;
+        const previousPoint = referencePoints ? referencePoints.previousPoint : null;
+        const view = referencePoints ? referencePoints.view : null;
+        return this._computeStatus.evaluate(currentPoint, previousPoint, view);
+    }
+
+    static computeChartStatus(metric, currentPoint, previousPoint, currentView, baselineView, targetView)
+    {
+        const formatter = metric.makeFormatter(3);
+        const deltaFormatter = metric.makeFormatter(2, true);
+        const smallerIsBetter = metric.isSmallerBetter();
+
+        const labelForDiff = (diff, referencePoint, name, comparison) => {
+            const relativeDiff = Math.abs(diff * 100).toFixed(1);
+            const referenceValue = referencePoint ? ` (${formatter(referencePoint.value)})` : '';
+            if (comparison != 'until')
+                comparison += ' than';
+            return `${relativeDiff}% ${comparison} ${name}${referenceValue}`;
+        }
+
+        const pointIsInCurrentSeries = baselineView != currentView && targetView != currentView;
+
+        const baselinePoint = pointIsInCurrentSeries && baselineView ? baselineView.lastPointInTimeRange(0, currentPoint.time) : null;
+        const targetPoint = pointIsInCurrentSeries && targetView ? targetView.lastPointInTimeRange(0, currentPoint.time) : null;
+
+        const diffFromBaseline = baselinePoint ? (currentPoint.value - baselinePoint.value) / baselinePoint.value : undefined;
+        const diffFromTarget = targetPoint ? (currentPoint.value - targetPoint.value) / targetPoint.value : undefined;
+
+        let label = null;
+        let comparison = null;
+
+        if (diffFromBaseline !== undefined && diffFromTarget !== undefined) {
+            if (diffFromBaseline > 0 == smallerIsBetter) {
+                comparison = 'worse';
+                label = labelForDiff(diffFromBaseline, baselinePoint, 'baseline', comparison);
+            } else if (diffFromTarget < 0 == smallerIsBetter) {
+                comparison = 'better';
+                label = labelForDiff(diffFromTarget, targetPoint, 'target', comparison);
+            } else
+                label = labelForDiff(diffFromTarget, targetPoint, 'target', 'until');
+        } else if (diffFromBaseline !== undefined) {
+            comparison = diffFromBaseline > 0 == smallerIsBetter ? 'worse' : 'better';
+            label = labelForDiff(diffFromBaseline, baselinePoint, 'baseline', comparison);
+        } else if (diffFromTarget !== undefined) {
+            comparison = diffFromTarget < 0 == smallerIsBetter ? 'better' : 'worse';
+            label = labelForDiff(diffFromTarget, targetPoint, 'target', 'until');
+        }
+
+        let valueDelta = null;
+        let relativeDelta = null;
+        if (previousPoint) {
+            valueDelta = deltaFormatter(currentPoint.value - previousPoint.value);
+            relativeDelta = (currentPoint.value - previousPoint.value) / previousPoint.value;
+            relativeDelta = (relativeDelta * 100).toFixed(0) + '%';
+        }
+
+        return {comparison, label, currentValue: formatter(currentPoint.value), valueDelta, relativeDelta};
+    }
+
+}
diff --git a/Websites/perf.webkit.org/public/v3/components/chart-status-view.js b/Websites/perf.webkit.org/public/v3/components/chart-status-view.js
deleted file mode 100644 (file)
index 1ebbe8a..0000000
+++ /dev/null
@@ -1,181 +0,0 @@
-
-class ChartStatusView extends ComponentBase {
-
-    constructor(metric, chart)
-    {
-        super('chart-status');
-        this._metric = metric;
-        this._chart = chart;
-
-        this._usedSelection = null;
-        this._usedCurrentPoint = null;
-        this._usedPreviousPoint = null;
-
-        this._currentValue = null;
-        this._comparisonClass = null;
-        this._comparisonLabel = null;
-
-        this._renderedCurrentValue = null;
-        this._renderedComparisonClass = null;
-        this._renderedComparisonLabel = null;
-    }
-
-    render()
-    {
-        this.updateStatusIfNeeded();
-
-        if (this._renderedCurrentValue == this._currentValue
-            && this._renderedComparisonClass == this._comparisonClass
-            && this._renderedComparisonLabel == this._comparisonLabel)
-            return;
-
-        this._renderedCurrentValue = this._currentValue;
-        this._renderedComparisonClass = this._comparisonClass;
-        this._renderedComparisonLabel = this._comparisonLabel;
-
-        this.content().querySelector('.chart-status-current-value').textContent = this._currentValue || '';
-        var comparison = this.content().querySelector('.chart-status-comparison');
-        comparison.className = 'chart-status-comparison ' + (this._comparisonClass || '');
-        comparison.textContent = this._comparisonLabel;
-    }
-
-    updateStatusIfNeeded()
-    {
-        var currentPoint;
-        var previousPoint;
-
-        if (this._chart instanceof InteractiveTimeSeriesChart) {
-            var selection = this._chart.currentSelection();
-            if (selection && this._usedSelection == selection)
-                return false;
-
-            if (selection) {
-                const view = this._chart.selectedPoints('current');
-                if (!view)
-                    return false;
-
-                if (view && view.length() > 1) {
-                    this._usedSelection = selection;
-                    currentPoint = view.lastPoint();
-                    previousPoint = view.firstPoint();
-                }
-            } else  {
-                const indicator = this._chart.currentIndicator();
-                if (indicator) {
-                    currentPoint = indicator.point;
-                    previousPoint = indicator.view.previousPoint(currentPoint);
-                }
-            }
-        } else {
-            var data = this._chart.sampledTimeSeriesData('current');
-            if (!data)
-                return false;
-            if (data.length())
-                currentPoint = data.lastPoint();
-        }
-
-        if (currentPoint == this._usedCurrentPoint && previousPoint == this._usedPreviousPoint)
-            return false;
-
-        this._usedCurrentPoint = currentPoint;
-        this._usedPreviousPoint = previousPoint;
-
-        this.computeChartStatusLabels(currentPoint, previousPoint);
-
-        return true;
-    }
-
-    computeChartStatusLabels(currentPoint, previousPoint)
-    {
-        var status = currentPoint ? this._computeChartStatus(this._metric, this._chart, currentPoint, previousPoint) : null;
-        if (status) {
-            this._currentValue = status.currentValue;
-            if (previousPoint)
-                this._currentValue += ` (${status.valueDelta} / ${status.relativeDelta})`;
-            this._comparisonClass = status.className;
-            this._comparisonLabel = status.label;
-        } else {
-            this._currentValue = null;
-            this._comparisonClass = null;
-            this._comparisonLabel = null;
-        }
-    }
-
-    _computeChartStatus(metric, chart, currentPoint, previousPoint)
-    {
-        console.assert(currentPoint);
-        const baselineView = chart.sampledTimeSeriesData('baseline');
-        const targetView = chart.sampledTimeSeriesData('target');
-
-        const formatter = metric.makeFormatter(3);
-        const deltaFormatter = metric.makeFormatter(2, true);
-        const smallerIsBetter = metric.isSmallerBetter();
-
-        const labelForDiff = (diff, referencePoint, name, comparison) => {
-            const relativeDiff = Math.abs(diff * 100).toFixed(1);
-            const referenceValue = referencePoint ? ` (${formatter(referencePoint.value)})` : '';
-            return `${relativeDiff}% ${comparison} than ${name}${referenceValue}`;
-        };
-
-        const baselinePoint = baselineView ? baselineView.lastPointInTimeRange(0, currentPoint.time) : null;
-        const targetPoint = targetView ? targetView.lastPointInTimeRange(0, currentPoint.time) : null;
-
-        const diffFromBaseline = baselinePoint ? (currentPoint.value - baselinePoint.value) / baselinePoint.value : undefined;
-        const diffFromTarget = targetPoint ? (currentPoint.value - targetPoint.value) / targetPoint.value : undefined;
-
-        let label = null;
-        let comparison = null;
-
-        if (diffFromBaseline !== undefined && diffFromTarget !== undefined) {
-            if (diffFromBaseline > 0 == smallerIsBetter) {
-                comparison = 'worse';
-                label = labelForDiff(diffFromBaseline, baselinePoint, 'baseline', comparison);
-            } else if (diffFromTarget < 0 == smallerIsBetter) {
-                comparison = 'better';
-                label = labelForDiff(diffFromTarget, targetPoint, 'target', comparison);
-            } else
-                label = labelForDiff(diffFromTarget, targetPoint, 'target', 'until');
-        } else if (diffFromBaseline !== undefined) {
-            comparison = diffFromBaseline > 0 == smallerIsBetter ? 'worse' : 'better';
-            label = labelForDiff(diffFromBaseline, baselinePoint, 'baseline', comparison);
-        } else if (diffFromTarget !== undefined) {
-            comparison = diffFromTarget < 0 == smallerIsBetter ? 'better' : 'worse';
-            label = labelForDiff(diffFromTarget, targetPoint, 'target', comparison);
-        }
-
-        let valueDelta = null;
-        let relativeDelta = null;
-        if (previousPoint) {
-            valueDelta = deltaFormatter(currentPoint.value - previousPoint.value);
-            relativeDelta = (currentPoint.value - previousPoint.value) / previousPoint.value;
-            relativeDelta = (relativeDelta * 100).toFixed(0) + '%';
-        }
-
-        return {className: comparison, label, currentValue: formatter(currentPoint.value), valueDelta, relativeDelta};
-    }
-
-    static htmlTemplate()
-    {
-        return `
-            <div>
-                <span class="chart-status-current-value"></span>
-                <span class="chart-status-comparison"></span>
-            </div>`;
-    }
-
-    static cssTemplate()
-    {
-        return `
-            .chart-status-current-value {
-                padding-right: 0.5rem;
-            }
-
-            .chart-status-comparison.worse {
-                color: #c33;
-            }
-
-            .chart-status-comparison.better {
-                color: #33c;
-            }`;
-    }
-}
\ No newline at end of file
index b0306fddba61e7707eb9688377d2d1626832c25f..cd130780e57f5e963652899fbb9166c2d94bf3bf 100644 (file)
@@ -52,6 +52,7 @@ class ChartStyles {
             backgroundIntervalStyle: 'rgba(255, 153, 153, 0.1)',
             backgroundPointStyle: '#f99',
             backgroundLineStyle: '#fcc',
+            interactive: true,
         };
     }
 
diff --git a/Websites/perf.webkit.org/public/v3/components/dashboard-chart-status-view.js b/Websites/perf.webkit.org/public/v3/components/dashboard-chart-status-view.js
new file mode 100644 (file)
index 0000000..a19685f
--- /dev/null
@@ -0,0 +1,45 @@
+
+class DashboardChartStatusView extends ComponentBase {
+
+    constructor(metric, chart)
+    {
+        super('chart-status-view');
+        this._statusEvaluator = new ChartStatusEvaluator(chart, metric);
+        this._renderLazily = new LazilyEvaluatedFunction((status) => {
+            status = status || {};
+            this.content('current-value').textContent = status.currentValue || '';
+            this.content('comparison').textContent = status.label || '';
+            this.content('comparison').className = status.comparison || '';
+        });
+    }
+
+    render()
+    {
+        this._renderLazily.evaluate(this._statusEvaluator.status());
+    }
+
+    static htmlTemplate()
+    {
+        return `<span id="current-value"></span> <span id="comparison"></span>`;
+    }
+
+    static cssTemplate()
+    {
+        return `
+            :host {
+                display: block;
+            }
+
+            #comparison {
+                padding-left: 0.5rem;
+            }
+
+            #comparison.worse {
+                color: #c33;
+            }
+
+            #comparison.better {
+                color: #33c;
+            }`;
+    }
+}
index ddf6da69ae5f3f51d86a34b0e78073f0c5691624..cf4aef13f482d11cf395aadb9b493b947fdc8eb3 100644 (file)
@@ -52,6 +52,27 @@ class InteractiveTimeSeriesChart extends TimeSeriesChart {
         return selection && data ? data.firstPointInTimeRange(selection[0], selection[1]) : null;
     }
 
+    referencePoints(type)
+    {
+        const selection = this.currentSelection();
+        if (selection) {
+            const view = this.selectedPoints('current');
+            if (!view)
+                return null;
+            const firstPoint = view.lastPoint();
+            const lastPoint = view.firstPoint();
+            if (!firstPoint)
+                return null;
+            return {view, currentPoint: firstPoint, previousPoint: firstPoint != lastPoint ? lastPoint : null};
+        } else  {
+            const indicator = this.currentIndicator();
+            if (!indicator)
+                return null;
+            return {view: indicator.view, currentPoint: indicator.point, previousPoint: indicator.view.previousPoint(indicator.point)};
+        }
+        return null;
+    }
+
     setIndicator(id, shouldLock)
     {
         var selectionDidChange = !!this._sampledTimeSeriesData;
index ac6a28fcf2abae6aa1faa9fa044778f8ee2539f7..e9ef0f9c354ab01347b7adc5df67a81f5aac1453 100644 (file)
@@ -131,6 +131,17 @@ class TimeSeriesChart extends ComponentBase {
         return null;
     }
 
+    referencePoints(type)
+    {
+        const view = this.sampledTimeSeriesData(type);
+        if (!view || !this._startTime || !this._endTime)
+            return null;
+        const point = view.lastPointInTimeRange(this._startTime, this._endTime);
+        if (!point)
+            return null;
+        return {view, currentPoint: point, previousPoint: null};
+    }
+
     setAnnotations(annotations)
     {
         this._annotations = annotations;
index 22310ed0d3817f0790b30424c3119be29bd1f9ee..03cbf9ea1ab9669ca55e900e2eb37f4d81c68aaa 100644 (file)
@@ -38,12 +38,12 @@ Run tools/bundle-v3-scripts to speed up the load time for production.`);
 
     <template id="unbundled-scripts">
         <script src="../shared/statistics.js"></script>
-        <script src="../v2/data.js"></script>
 
         <script src="instrumentation.js"></script>
         <script src="remote.js"></script>
         <script src="privileged-api.js"></script>
         <script src="async-task.js"></script>
+        <script src="lazily-evaluated-function.js"></script>
 
         <script src="models/time-series.js"></script>
         <script src="models/measurement-adaptor.js"></script>
@@ -75,7 +75,7 @@ Run tools/bundle-v3-scripts to speed up the load time for production.`);
         <script src="components/editable-text.js"></script>
         <script src="components/time-series-chart.js"></script>
         <script src="components/interactive-time-series-chart.js"></script>
-        <script src="components/chart-status-view.js"></script>
+        <script src="components/dashboard-chart-status-view.js"></script>
         <script src="components/pane-selector.js"></script>
         <script src="components/bar-graph-group.js"></script>
         <script src="components/results-table.js"></script>
@@ -84,6 +84,8 @@ Run tools/bundle-v3-scripts to speed up the load time for production.`);
         <script src="components/test-group-form.js"></script>
         <script src="components/customizable-test-group-form.js"></script>
         <script src="components/chart-styles.js"></script>
+        <script src="components/chart-status-evaluator.js"></script>
+        <script src="components/chart-revision-range.js"></script>
         <script src="components/chart-pane-base.js"></script>
         <script src="components/mutable-list-view.js"></script>
         <script src="components/ratio-bar-graph.js"></script>
diff --git a/Websites/perf.webkit.org/public/v3/lazily-evaluated-function.js b/Websites/perf.webkit.org/public/v3/lazily-evaluated-function.js
new file mode 100644 (file)
index 0000000..aca3e1c
--- /dev/null
@@ -0,0 +1,27 @@
+class LazilyEvaluatedFunction {
+    constructor(callback, ...observedPropertiesList)
+    {
+        console.assert(typeof(callback) == 'function');
+        this._callback = callback;
+        this._observedPropertiesList = observedPropertiesList;
+        this._cachedArguments = null;
+        this._cachedResult = undefined;
+    }
+
+    evaluate(...args)
+    {
+        if (this._cachedArguments) {
+            const length = this._cachedArguments.length;
+            if (args.length == length && (!length || this._cachedArguments.every((cached, i) => cached === args[i])))
+                return this._cachedResult;
+        }
+
+        this._cachedArguments = args;
+        this._cachedResult = this._callback.apply(null, args);
+
+        return this._cachedResult;
+    }
+}
+
+if (typeof module != 'undefined')
+    module.exports.LazilyEvaluatedFunction = LazilyEvaluatedFunction;
index 9fe9c6ac3249bc2f5da6cf427855e43ebb01bf2a..4eba7e1012ae01d9e512f9064887c7c06a514659 100644 (file)
@@ -57,7 +57,7 @@ class CommitLog extends DataModelObject {
         var from = previousCommit.revision();
         var label = null;
         if (parseInt(to) == to) { // e.g. r12345.
-            from = parseInt(from) + 1;
+            from = (parseInt(from) + 1).toString();
             label = `r${from}-r${this.revision()}`;
         } else if (to.length == 40) { // e.g. git hash
             label = `${from.substring(0, 8)}..${to.substring(0, 8)}`;
index 66965c1d9a8615dd465af3657d936990dacad283..9dc43c17e8d6da791cca6b701cf88063c0858c90 100644 (file)
@@ -8,6 +8,19 @@ class Metric extends LabeledObject {
         object.test.addMetric(this);
         this._test = object.test;
         this._platforms = [];
+
+        const suffix = this.name().match('([A-z][a-z]+|FrameRate)$')[0];
+        this._unit = {
+            'FrameRate': 'fps',
+            'Runs': '/s',
+            'Time': 'ms',
+            'Duration': 'ms',
+            'Malloc': 'B',
+            'Heap': 'B',
+            'Allocations': 'B',
+            'Size': 'B',
+            'Score': 'pt',
+        }[suffix];
     }
 
     aggregatorName() { return this._aggregatorName; }
@@ -57,8 +70,13 @@ class Metric extends LabeledObject {
         return this.name() + suffix;
     }
 
-    unit() { return RunsData.unitFromMetricName(this.name()); }
-    isSmallerBetter() { return RunsData.isSmallerBetter(this.unit()); }
+    unit() { return this._unit; }
+
+    isSmallerBetter()
+    {
+        const unit = this._unit;
+        return unit != 'fps' && unit != '/s' && unit != 'pt';
+    }
 
     makeFormatter(sigFig, alwaysShowSign) { return Metric.makeFormatter(this.unit(), sigFig, alwaysShowSign); }
 
index 33096469b70b5b2a046e8ad3c3372c423207f088..ba94165ee96223b54292b7e167a3e0b57fa68765 100644 (file)
@@ -16,12 +16,6 @@ class AnalysisTaskChartPane extends ChartPaneBase {
             this._page._chartSelectionDidChange();
     }
 
-    _updateStatus()
-    {
-        super._updateStatus();
-        this._page.enqueueToRender();
-    }
-
     selectedPoints()
     {
         return this._mainChart ? this._mainChart.selectedPoints('current') : null;
index 59ce2feb4bbb75b2b892842b422edfbcff4ddfd0..07e9ea25b3034bd620b4f7a5b2cee8ead3c44c20 100644 (file)
 
-class ChartPaneStatusView extends ChartStatusView {
-    
-    constructor(metric, chart, revisionCallback)
+class ChartPaneStatusView extends ComponentBase {
+    constructor(metric, chart)
     {
-        super(metric, chart);
+        super('chart-pane-status-view');
 
-        this._buildLabel = null;
-        this._buildUrl = null;
-
-        this._revisionList = [];
+        this._chart = chart;
+        this._status = new ChartStatusEvaluator(chart, metric);
+        this._revisionRange = new ChartRevisionRange(chart);
         this._currentRepository = null;
-        this._revisionCallback = revisionCallback;
-        this._pointsRangeForAnalysis = null;
-
-        this._renderedRevisionList = null;
-        this._renderedRepository = null;
 
-        this._usedRevisionRange = [null, null, null];
+        this._renderStatusLazily = new LazilyEvaluatedFunction(this._renderStatus.bind(this));
+        this._renderBuildRevisionTableLazily = new LazilyEvaluatedFunction(this._renderBuildRevisionTable.bind(this));
     }
 
-    pointsRangeForAnalysis() { return this._pointsRangeForAnalysis; }
-
     render()
     {
         super.render();
 
-        if (this._renderedRevisionList == this._revisionList && this._renderedRepository == this._currentRepository)
-            return;
-        this._renderedRevisionList = this._revisionList;    
-        this._renderedRepository = this._currentRepository;
-
-        var element = ComponentBase.createElement;
-        var link = ComponentBase.createLink;
-        var self = this;
-        var buildInfo = this._buildInfo;
-        var tableContent = this._revisionList.map(function (info, rowIndex) {
-            var selected = info.repository == self._currentRepository;
-            var action = function () {
-                if (self._currentRepository == info.repository)
-                    self._setRevisionRange(true, null, null, null);
-                else
-                    self._setRevisionRange(true, info.repository, info.from, info.to);
-            };
-
-            return element('tr', {class: selected ? 'selected' : ''}, [
-                element('td', info.repository.name()),
-                element('td', info.url ? link(info.label, info.label, info.url, true) : info.label),
-                element('td', {class: 'commit-viewer-opener'}, link('\u00BB', action)),
-            ]);
-        });
-
-        if (this._buildInfo) {
-            var build = this._buildInfo;
-            var number = build.buildNumber();
-            var buildTime = this._formatTime(build.buildTime());
-            var url = build.url();
-
-            tableContent.unshift(element('tr', [
-                element('td', 'Build'),
-                element('td', {colspan: 2}, [url ? link(number, build.label(), url, true) : number, ` (${buildTime})`]),
-            ]));
-        }
-
-        this.renderReplace(this.content().querySelector('.chart-pane-revisions'), tableContent);
-    }
-
-    _formatTime(date)
-    {
-        console.assert(date instanceof Date);
-        return date.toISOString().replace('T', ' ').replace(/\.\d+Z$/, '');
-    }
+        this._renderStatusLazily.evaluate(this._status.status());
 
-    setCurrentRepository(repository)
-    {
-        this._currentRepository = repository;
-        return this._updateRevisionListForNewCurrentRepository();
+        const indicator = this._chart.currentIndicator();
+        const build = indicator ? indicator.point.build() : null;
+        this._renderBuildRevisionTableLazily.evaluate(build, this._currentRepository, this._revisionRange.revisionList());
     }
 
-    _setRevisionRange(shouldNotify, repository, from, to)
+    _renderStatus(status)
     {
-        if (this._usedRevisionRange[0] == repository
-            && this._usedRevisionRange[1] == from && this._usedRevisionRange[2] == to)
-            return;
-        this._usedRevisionRange = [repository, from, to];
-        if (shouldNotify)
-            this._revisionCallback(repository, from, to);
+        status = status || {};
+        let currentValue = status.currentValue || '';
+        if (currentValue)
+            currentValue += ` (${status.valueDelta} / ${status.relativeDelta})`;
+
+        this.content('current-value').textContent = currentValue;
+        this.content('comparison').textContent = status.label || '';
+        this.content('comparison').className = status.comparison || '';
     }
 
-    moveRepositoryWithNotification(forward)
+    _renderBuildRevisionTable(build, currentRepository, revisionList)
     {
-        var currentRepository = this._currentRepository;
-        if (!currentRepository)
-            return false;
-        var index = this._revisionList.findIndex(function (info) { return info.repository == currentRepository; });
-        console.assert(index >= 0);
-
-        var newIndex = index + (forward ? 1 : -1);
-        newIndex = Math.min(this._revisionList.length - 1, Math.max(0, newIndex));
-        if (newIndex == index)
-            return false;
-
-        var item = this._revisionList[newIndex];
-        this.setCurrentRepository(item ? item.repository : null);
+        const element = ComponentBase.createElement;
+        const link = ComponentBase.createLink;
+        let tableContent = [];
+
+        if (build) {
+            const url = build.url();
+            const buildNumber = build.buildNumber();
+            tableContent.push(element('tr', [
+                element('td', 'Build'),
+                element('td', {colspan: 2}, [
+                    url ? link(buildNumber, build.label(), url, true) : buildNumber,
+                    ` (${this._formatTime(build.buildTime())})`
+                ]),
+            ]));
+        }
 
-        return true;
-    }
+        if (revisionList) {
+            for (let info of revisionList) {
+                const selected = info.repository == this._currentRepository;
+                const action = () => {
+                    this.dispatchAction('openRepository', this._currentRepository == info.repository ? null : info.repository);
+                };
+
+                tableContent.push(element('tr', {class: selected ? 'selected' : ''}, [
+                    element('td', info.repository.name()),
+                    element('td', info.url ? link(info.label, info.label, info.url, true) : info.label),
+                    element('td', {class: 'commit-viewer-opener'}, link('\u00BB', action)),
+                ]));
+            }
+        }
 
-    updateRevisionList()
-    {
-        if (!this._currentRepository)
-            return {repository: null, from: null, to: null};
-        return this._updateRevisionListForNewCurrentRepository();
+        this.renderReplace(this.content('build-revision'), tableContent);
     }
 
-    _updateRevisionListForNewCurrentRepository()
+    _formatTime(date)
     {
-        this.updateStatusIfNeeded();
-
-        for (var info of this._revisionList) {
-            if (info.repository == this._currentRepository) {
-                this._setRevisionRange(false, info.repository, info.from, info.to);
-                return {repository: info.repository, from: info.from, to: info.to};
-            }
-        }
-        this._setRevisionRange(false, null, null, null);
-        return {repository: this._currentRepository, from: null, to: null};
+        console.assert(date instanceof Date);
+        return date.toISOString().replace('T', ' ').replace(/\.\d+Z$/, '');
     }
 
-    computeChartStatusLabels(currentPoint, previousPoint)
+    setCurrentRepository(repository)
     {
-        super.computeChartStatusLabels(currentPoint, previousPoint);
-
-        this._buildInfo = null;
-        this._revisionList = [];
-        this._pointsRangeForAnalysis = null;
-
-        if (!currentPoint)
-            return;
-
-        if (!this._chart.currentSelection())
-            this._buildInfo = currentPoint.build();
-
-        if (currentPoint && previousPoint && this._chart.currentSelection()) {
-            this._pointsRangeForAnalysis = {
-                startPointId: previousPoint.id,
-                endPointId: currentPoint.id,
-            };
-        }
-
-        // FIXME: Rewrite the interface to obtain the list of revision changes.
-        var currentRootSet = currentPoint.rootSet();
-        var previousRootSet = previousPoint ? previousPoint.rootSet() : null;
-
-        var repositoriesInCurrentRootSet = Repository.sortByNamePreferringOnesWithURL(currentRootSet.repositories());
-        var revisionList = [];
-        for (var repository of repositoriesInCurrentRootSet) {
-            var currentCommit = currentRootSet.commitForRepository(repository);
-            var previousCommit = previousRootSet ? previousRootSet.commitForRepository(repository) : null;
-            revisionList.push(currentCommit.diff(previousCommit));
-        }
-
-        this._revisionList = revisionList;
+        this._currentRepository = repository;
+        this.enqueueToRender();
     }
 
     static htmlTemplate()
     {
         return `
-            <div class="chart-pane-status">
-                <h3 class="chart-status-current-value"></h3>
-                <span class="chart-status-comparison"></span>
+            <div id="chart-pane-status">
+                <h3 id="current-value"></h3>
+                <span id="comparison"></span>
             </div>
-            <table class="chart-pane-revisions"></table>
+            <table id="build-revision"></table>
         `;
     }
 
     static cssTemplate()
     {
-        return Toolbar.cssTemplate() + ChartStatusView.cssTemplate() + `
-            .chart-pane-status {
+        return Toolbar.cssTemplate() + `
+            #chart-pane-status {
                 display: block;
                 text-align: center;
             }
 
-            .chart-pane-status .chart-status-current-value,
-            .chart-pane-status .chart-status-comparison {
+            #current-value,
+            #comparison {
                 display: block;
                 margin: 0;
                 padding: 0;
@@ -191,7 +112,15 @@ class ChartPaneStatusView extends ChartStatusView {
                 font-size: 1rem;
             }
 
-            .chart-pane-revisions {
+            #comparison.worse {
+                color: #c33;
+            }
+
+            #comparison.better {
+                color: #33c;
+            }
+
+            #build-revision {
                 line-height: 1rem;
                 font-size: 0.9rem;
                 font-weight: normal;
@@ -203,23 +132,23 @@ class ChartPaneStatusView extends ChartStatusView {
                 width: 100%;
             }
 
-            .chart-pane-revisions th,
-            .chart-pane-revisions td {
+            #build-revision th,
+            #build-revision td {
                 font-weight: inherit;
                 border-top: solid 1px #ccc;
                 padding: 0.2rem 0.2rem;
             }
             
-            .chart-pane-revisions .selected > th,
-            .chart-pane-revisions .selected > td {
+            #build-revision .selected > th,
+            #build-revision .selected > td {
                 background: rgba(204, 153, 51, 0.1);
             }
 
-            .chart-pane-revisions .commit-viewer-opener {
+            #build-revision .commit-viewer-opener {
                 width: 1rem;
             }
 
-            .chart-pane-revisions .commit-viewer-opener a {
+            #build-revision .commit-viewer-opener a {
                 text-decoration: none;
                 color: inherit;
                 font-weight: inherit;
index 2cb9bce2f73c7b7cd932d95cf79d9207c1faf9a5..586cfe6ccf0cefbd3e15fafa2785fb9d8523cd17 100644 (file)
@@ -181,21 +181,12 @@ class ChartPane extends ChartPaneBase {
 
     router() { return this._chartsPage.router(); }
 
-    _requestOpeningCommitViewer(repository, from, to)
+    openNewRepository(repository)
     {
-        super._requestOpeningCommitViewer(repository, from, to);
+        this.content().querySelector('.chart-pane').focus();
         this._chartsPage.setOpenRepository(repository);
     }
 
-    setOpenRepository(repository)
-    {
-        if (repository != this._commitLogViewer.currentRepository()) {
-            var range = this._mainChartStatus.setCurrentRepository(repository);
-            this._commitLogViewer.view(repository, range.from, range.to).then(() => { this.enqueueToRender(); });
-            this.enqueueToRender();
-        }
-    }
-
     _indicatorDidChange(indicatorID, isLocked)
     {
         this._chartsPage.mainChartIndicatorDidChange(this, isLocked != this._mainChartIndicatorWasLocked);
@@ -203,17 +194,16 @@ class ChartPane extends ChartPaneBase {
         super._indicatorDidChange(indicatorID, isLocked);
     }
 
-    _analyzeRange(pointsRangeForAnalysis)
+    _analyzeRange(startPoint, endPoint)
     {
         var router = this._chartsPage.router();
         var newWindow = window.open(router.url('analysis/task/create'), '_blank');
 
         var analyzePopover = this.content().querySelector('.chart-pane-analyze-popover');
         var name = analyzePopover.querySelector('input').value;
-        var self = this;
-        AnalysisTask.create(name, pointsRangeForAnalysis.startPointId, pointsRangeForAnalysis.endPointId).then(function (data) {
+        AnalysisTask.create(name, startPoint.id, endPoint.id).then((data) => {
             newWindow.location.href = router.url('analysis/task/' + data['taskId']);
-            self.fetchAnalysisTasks(true);
+            this.fetchAnalysisTasks(true);
         }, function (error) {
             newWindow.location.href = router.url('analysis/task/create', {error: error});
         });
@@ -279,16 +269,17 @@ class ChartPane extends ChartPaneBase {
             platformPopover.style.display = 'none';
 
         var analyzePopover = this.content().querySelector('.chart-pane-analyze-popover');
-        var pointsRangeForAnalysis = this._mainChartStatus.pointsRangeForAnalysis();
-        if (pointsRangeForAnalysis) {
+        const selectedPoints = this._mainChart.selectedPoints('current');
+        const hasSelectedPoints = selectedPoints && selectedPoints.length();
+        if (hasSelectedPoints) {
             actions.push(this._makePopoverActionItem(analyzePopover, 'Analyze', false));
-            analyzePopover.onsubmit = function (event) {
-                event.preventDefault();
-                self._analyzeRange(pointsRangeForAnalysis);
-            }
+            analyzePopover.onsubmit = this.createEventHandler(() => {
+                console.log(selectedPoints.length());
+                this._analyzeRange(selectedPoints.firstPoint(), selectedPoints.lastPoint());
+            });
         } else {
             analyzePopover.style.display = 'none';
-            analyzePopover.onsubmit = function (event) { event.preventDefault(); }
+            analyzePopover.onsubmit = this.createEventHandler(() => {});
         }
 
         var filteringOptions = this.content().querySelector('.chart-pane-filtering-options');
index 1b48c4cb34bc5e3ac9f0d29275dd018f0aa236f0..3bb54bd7f0f546bd7388dd5650e7a5b8f8a53011 100644 (file)
@@ -137,7 +137,7 @@ class DashboardPage extends PageWithHeading {
         chart.listenToAction('dataChange', () => this._fetchedData())
         this._charts.push(chart);
 
-        var statusView = new ChartStatusView(result.metric, chart);
+        var statusView = new DashboardChartStatusView(result.metric, chart);
         this._statusViews.push(statusView);
 
         return {
diff --git a/Websites/perf.webkit.org/unit-tests/lazily-evaluated-function-tests.js b/Websites/perf.webkit.org/unit-tests/lazily-evaluated-function-tests.js
new file mode 100644 (file)
index 0000000..b14115e
--- /dev/null
@@ -0,0 +1,187 @@
+
+const assert = require('assert');
+const LazilyEvaluatedFunction = require('../public/v3/lazily-evaluated-function.js').LazilyEvaluatedFunction;
+
+describe('LazilyEvaluatedFunction', () => {
+
+    describe('evaluate', () => {
+        it('should invoke the callback on the very first call with no arguments', () => {
+            const calls = [];
+            const lazyFunction = new LazilyEvaluatedFunction((...args) => calls.push(args));
+
+            assert.deepEqual(calls, []);
+            lazyFunction.evaluate();
+            assert.deepEqual(calls, [[]]);
+        });
+
+        it('should retrun the cached results without invoking the callback on the second call with no arguments', () => {
+            const calls = [];
+            const lazyFunction = new LazilyEvaluatedFunction((...args) => calls.push(args));
+
+            assert.deepEqual(calls, []);
+            lazyFunction.evaluate();
+            assert.deepEqual(calls, [[]]);
+            lazyFunction.evaluate();
+            assert.deepEqual(calls, [[]]);
+        });
+
+        it('should invoke the callback when calld with an argument after being called with no argument', () => {
+            const calls = [];
+            const lazyFunction = new LazilyEvaluatedFunction((...args) => calls.push(args));
+
+            assert.deepEqual(calls, []);
+            lazyFunction.evaluate();
+            assert.deepEqual(calls, [[]]);
+            lazyFunction.evaluate(1);
+            assert.deepEqual(calls, [[], [1]]);
+        });
+
+        it('should invoke the callback when calld with no arguments after being called with an argument', () => {
+            const calls = [];
+            const lazyFunction = new LazilyEvaluatedFunction((...args) => calls.push(args));
+
+            assert.deepEqual(calls, []);
+            lazyFunction.evaluate('foo');
+            assert.deepEqual(calls, [['foo']]);
+            lazyFunction.evaluate();
+            assert.deepEqual(calls, [['foo'], []]);
+        });
+
+        it('should invoke the callback when calld with null after being called with undefined', () => {
+            const calls = [];
+            const lazyFunction = new LazilyEvaluatedFunction((...args) => calls.push(args));
+
+            assert.deepEqual(calls, []);
+            lazyFunction.evaluate(undefined);
+            assert.deepEqual(calls, [[undefined]]);
+            lazyFunction.evaluate(null);
+            assert.deepEqual(calls, [[undefined], [null]]);
+        });
+
+        it('should invoke the callback when calld with 0 after being called with "0"', () => {
+            const calls = [];
+            const lazyFunction = new LazilyEvaluatedFunction((...args) => calls.push(args));
+
+            assert.deepEqual(calls, []);
+            lazyFunction.evaluate(0);
+            assert.deepEqual(calls, [[0]]);
+            lazyFunction.evaluate("0");
+            assert.deepEqual(calls, [[0], ["0"]]);
+        });
+
+        it('should invoke the callback when calld with an object after being called with another object with the same set of properties', () => {
+            const calls = [];
+            const lazyFunction = new LazilyEvaluatedFunction((...args) => calls.push(args));
+
+            assert.deepEqual(calls, []);
+            const x = {};
+            const y = {};
+            lazyFunction.evaluate(x);
+            assert.deepEqual(calls, [[x]]);
+            lazyFunction.evaluate(y);
+            assert.deepEqual(calls, [[x], [y]]);
+        });
+
+        it('should return the cached result without invoking the callback when calld with a string after being called with the same string', () => {
+            const calls = [];
+            const lazyFunction = new LazilyEvaluatedFunction((...args) => calls.push(args));
+
+            assert.deepEqual(calls, []);
+            lazyFunction.evaluate("foo");
+            assert.deepEqual(calls, [["foo"]]);
+            lazyFunction.evaluate("foo");
+            assert.deepEqual(calls, [["foo"]]);
+        });
+
+        it('should invoke the callback when calld with a string after being called with another string', () => {
+            const calls = [];
+            const lazyFunction = new LazilyEvaluatedFunction((...args) => calls.push(args));
+
+            assert.deepEqual(calls, []);
+            lazyFunction.evaluate("foo");
+            assert.deepEqual(calls, [["foo"]]);
+            lazyFunction.evaluate("bar");
+            assert.deepEqual(calls, [["foo"], ["bar"]]);
+        });
+
+        it('should return the cached result without invoking the callback when calld with a number after being called with the same number', () => {
+            const calls = [];
+            const lazyFunction = new LazilyEvaluatedFunction((...args) => calls.push(args));
+
+            assert.deepEqual(calls, []);
+            lazyFunction.evaluate(8);
+            assert.deepEqual(calls, [[8]]);
+            lazyFunction.evaluate(8);
+            assert.deepEqual(calls, [[8]]);
+        });
+
+        it('should invoke the callback when calld with a number after being called with another number', () => {
+            const calls = [];
+            const lazyFunction = new LazilyEvaluatedFunction((...args) => calls.push(args));
+
+            assert.deepEqual(calls, []);
+            lazyFunction.evaluate(4);
+            assert.deepEqual(calls, [[4]]);
+            lazyFunction.evaluate(2);
+            assert.deepEqual(calls, [[4], [2]]);
+        });
+
+        it('should return the cached result without invoking the callback when calld with ["hello", 3, "world"] for the second time', () => {
+            const calls = [];
+            const lazyFunction = new LazilyEvaluatedFunction((...args) => calls.push(args));
+
+            assert.deepEqual(calls, []);
+            lazyFunction.evaluate("hello", 3, "world");
+            assert.deepEqual(calls, [["hello", 3, "world"]]);
+            lazyFunction.evaluate("hello", 3, "world");
+            assert.deepEqual(calls, [["hello", 3, "world"]]);
+        });
+
+        it('should invoke the callback when calld with ["hello", 3, "world"] after being called with ["hello", 4, "world"]', () => {
+            const calls = [];
+            const lazyFunction = new LazilyEvaluatedFunction((...args) => calls.push(args));
+
+            assert.deepEqual(calls, []);
+            lazyFunction.evaluate("hello", 3, "world");
+            assert.deepEqual(calls, [["hello", 3, "world"]]);
+            lazyFunction.evaluate("hello", 4, "world");
+            assert.deepEqual(calls, [["hello", 3, "world"], ["hello", 4, "world"]]);
+        });
+
+        it('should return the cached result without invoking the callback when called with [null, null] for the second time', () => {
+            const calls = [];
+            const lazyFunction = new LazilyEvaluatedFunction((...args) => calls.push(args));
+
+            assert.deepEqual(calls, []);
+            lazyFunction.evaluate(null, null);
+            assert.deepEqual(calls, [[null, null]]);
+            lazyFunction.evaluate(null, null);
+            assert.deepEqual(calls, [[null, null]]);
+        });
+
+        it('should invoke the callback when calld with [null] after being called with [null, null]', () => {
+            const calls = [];
+            const lazyFunction = new LazilyEvaluatedFunction((...args) => calls.push(args));
+
+            assert.deepEqual(calls, []);
+            lazyFunction.evaluate(null, null);
+            assert.deepEqual(calls, [[null, null]]);
+            lazyFunction.evaluate(null);
+            assert.deepEqual(calls, [[null, null], [null]]);
+        });
+
+        it('should invoke the callback when calld with [null, 4] after being called with [null]', () => {
+            const calls = [];
+            const lazyFunction = new LazilyEvaluatedFunction((...args) => calls.push(args));
+
+            assert.deepEqual(calls, []);
+            lazyFunction.evaluate(null, 4);
+            assert.deepEqual(calls, [[null, 4]]);
+            lazyFunction.evaluate(null);
+            assert.deepEqual(calls, [[null, 4], [null]]);
+        });
+
+    });
+
+});
+