Add a test freshness page.
authordewei_zhu@apple.com <dewei_zhu@apple.com@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Thu, 14 Dec 2017 09:49:37 +0000 (09:49 +0000)
committerdewei_zhu@apple.com <dewei_zhu@apple.com@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Thu, 14 Dec 2017 09:49:37 +0000 (09:49 +0000)
https://bugs.webkit.org/show_bug.cgi?id=180126

Reviewed by Ryosuke Niwa.

Added a page to show freshness of a test.
The test freshness page reports on the same set of tests as the one shown in the summary page.
Use a logistic function to evaluate the freshness of the data points.
This function has the desired property which increase dramatically when it close to the center of the graph.
'acceptableLastDataPointDurationInHour' configs the center of the graph.

* public/include/manifest-generator.php:
* public/v3/components/freshness-indicator.js: Added.
(FreshnessIndicator): A cell of the test freshness table, color will transit from green to red.
(FreshnessIndicator.prototype.update): Update the the data point information and triggers
the cell to re-render if anything changes.
(FreshnessIndicator.prototype._renderIndicator): Re-render the indicator.
(FreshnessIndicator.prototype.render): Render the box color base on a logistic function.
(FreshnessIndicator.prototype._createIndicator):
(FreshnessIndicator.htmlTemplate):
(FreshnessIndicator.cssTemplate):
* public/v3/index.html:
* public/v3/main.js: Added test freshness page.
(main):
* public/v3/models/build-request.js: Refactored waitingTime function to make it reusable.
(BuildRequest.formatTimeInterval): Format time interval in million seconds to more user friendly text.
(BuildRequest.prototype.waitingTime):
* public/v3/pages/test-freshness-page.js: Added.
(TestFreshnessPage):
(TestFreshnessPage.prototype.name):
(TestFreshnessPage.prototype._loadConfig): Load config from summary page configurations.
(TestFreshnessPage.prototype.open):
(TestFreshnessPage.prototype._fetchTestResults):
(TestFreshnessPage.prototype.render):
(TestFreshnessPage.prototype._renderTable):
(TestFreshnessPage.prototype._isValidPlatformMetricCombination): Return whether a platform
and metric combination is valid.
(TestFreshnessPage.prototype._constructTableCell):
(TestFreshnessPage.cssTemplate):
(TestFreshnessPage.prototype.routeName):
* server-tests/api-manifest-tests.js: Added 'warningHourBaseline' so that we can config the
parameter of logistic funciton.
* unit-tests/build-request-tests.js: Added unit tests for formatTimeInterval.

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

Websites/perf.webkit.org/ChangeLog
Websites/perf.webkit.org/public/include/manifest-generator.php
Websites/perf.webkit.org/public/v3/components/freshness-indicator.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/index.html
Websites/perf.webkit.org/public/v3/main.js
Websites/perf.webkit.org/public/v3/models/build-request.js
Websites/perf.webkit.org/public/v3/pages/test-freshness-page.js [new file with mode: 0644]
Websites/perf.webkit.org/server-tests/api-manifest-tests.js
Websites/perf.webkit.org/unit-tests/build-request-tests.js

index 201e4b4..d13e27a 100644 (file)
@@ -1,3 +1,49 @@
+2017-12-13  Dewei Zhu  <dewei_zhu@apple.com>
+
+        Add a test freshness page.
+        https://bugs.webkit.org/show_bug.cgi?id=180126
+
+        Reviewed by Ryosuke Niwa.
+
+        Added a page to show freshness of a test.
+        The test freshness page reports on the same set of tests as the one shown in the summary page.
+        Use a logistic function to evaluate the freshness of the data points.
+        This function has the desired property which increase dramatically when it close to the center of the graph.
+        'acceptableLastDataPointDurationInHour' configs the center of the graph.
+
+        * public/include/manifest-generator.php:
+        * public/v3/components/freshness-indicator.js: Added.
+        (FreshnessIndicator): A cell of the test freshness table, color will transit from green to red.
+        (FreshnessIndicator.prototype.update): Update the the data point information and triggers
+        the cell to re-render if anything changes.
+        (FreshnessIndicator.prototype._renderIndicator): Re-render the indicator.
+        (FreshnessIndicator.prototype.render): Render the box color base on a logistic function.
+        (FreshnessIndicator.prototype._createIndicator):
+        (FreshnessIndicator.htmlTemplate):
+        (FreshnessIndicator.cssTemplate):
+        * public/v3/index.html:
+        * public/v3/main.js: Added test freshness page.
+        (main):
+        * public/v3/models/build-request.js: Refactored waitingTime function to make it reusable.
+        (BuildRequest.formatTimeInterval): Format time interval in million seconds to more user friendly text.
+        (BuildRequest.prototype.waitingTime):
+        * public/v3/pages/test-freshness-page.js: Added.
+        (TestFreshnessPage):
+        (TestFreshnessPage.prototype.name):
+        (TestFreshnessPage.prototype._loadConfig): Load config from summary page configurations.
+        (TestFreshnessPage.prototype.open):
+        (TestFreshnessPage.prototype._fetchTestResults):
+        (TestFreshnessPage.prototype.render):
+        (TestFreshnessPage.prototype._renderTable):
+        (TestFreshnessPage.prototype._isValidPlatformMetricCombination): Return whether a platform
+        and metric combination is valid.
+        (TestFreshnessPage.prototype._constructTableCell):
+        (TestFreshnessPage.cssTemplate):
+        (TestFreshnessPage.prototype.routeName):
+        * server-tests/api-manifest-tests.js: Added 'warningHourBaseline' so that we can config the
+        parameter of logistic funciton.
+        * unit-tests/build-request-tests.js: Added unit tests for formatTimeInterval.
+
 2017-11-02  Dewei Zhu  <dewei_zhu@apple.com>
 
         Add platform argument for syncing script.
index 80a622e..e771419 100644 (file)
@@ -44,6 +44,7 @@ class ManifestGenerator {
             'dashboards' => (object)config('dashboards'),
             'summaryPages' => config('summaryPages'),
             'fileUploadSizeLimit' => config('uploadFileLimitInMB', 0) * 1024 * 1024,
+            'testAgeToleranceInHours' => config('testAgeToleranceInHours'),
         );
 
         $this->manifest['elapsedTime'] = (microtime(true) - $start_time) * 1000;
diff --git a/Websites/perf.webkit.org/public/v3/components/freshness-indicator.js b/Websites/perf.webkit.org/public/v3/components/freshness-indicator.js
new file mode 100644 (file)
index 0000000..40dc264
--- /dev/null
@@ -0,0 +1,79 @@
+class FreshnessIndicator extends ComponentBase {
+    constructor(lastDataPointDuration, testAgeTolerance, summary, url)
+    {
+        super('freshness-indicator');
+        this._lastDataPointDuration = lastDataPointDuration;
+        this._summary = summary;
+        this._testAgeTolerance = testAgeTolerance;
+        this._url = url;
+
+        this._renderIndicatorLazily = new LazilyEvaluatedFunction(this._renderIndicator.bind(this));
+    }
+
+    update(lastDataPointDuration, testAgeTolerance, summary, url)
+    {
+        this._lastDataPointDuration = lastDataPointDuration;
+        this._summary = summary;
+        this._testAgeTolerance = testAgeTolerance;
+        this._url = url;
+        this.enqueueToRender();
+    }
+
+    render()
+    {
+        super.render();
+        this._renderIndicatorLazily.evaluate(this._lastDataPointDuration, this._testAgeTolerance, this._summary, this._url);
+
+    }
+
+    _renderIndicator(lastDataPointDuration, testAgeTolerance, summary, url)
+    {
+        const element = ComponentBase.createElement;
+        if (!lastDataPointDuration) {
+            this.renderReplace(this.content('container'), new SpinnerIcon);
+            return;
+        }
+
+        const hoursSinceLastDataPoint = this._lastDataPointDuration / 3600 / 1000;
+        const testAgeToleranceInHours = testAgeTolerance / 3600 / 1000;
+        const rating = 1 / (1 + Math.exp(Math.log(1.2) * (hoursSinceLastDataPoint - testAgeToleranceInHours)));
+        const hue = Math.round(120 * rating);
+        const brightness = Math.round(30 + 50 * rating);
+        const indicator = element('a', {id: 'cell', title: summary, href: url});
+
+        indicator.style.backgroundColor = `hsl(${hue}, 100%, ${brightness}%)`;
+        this.renderReplace(this.content('container'), indicator);
+    }
+
+    static htmlTemplate()
+    {
+        return `<div id='container'></div>`;
+    }
+
+    static  cssTemplate()
+    {
+        return `
+            div {
+                height: 1.8rem;
+                width: 1.8rem;
+                padding-top: 0.1rem;
+            }
+            a {
+                display: block;
+                height:1.6rem;
+                width:1.6rem;
+                margin: 0.1rem;
+                padding: 0;
+            }
+
+            a:hover {
+                height: 1.8rem;
+                width: 1.8rem;
+                margin: 0rem;
+                padding: 0;
+            }`;
+    }
+}
+
+
+ComponentBase.defineElement('freshness-indicator', FreshnessIndicator);
\ No newline at end of file
index 46e6adc..1a21c0f 100644 (file)
@@ -98,6 +98,7 @@ Run tools/bundle-v3-scripts to speed up the load time for production.`);
         <script src="components/custom-analysis-task-configurator.js"></script>
         <script src="components/custom-configuration-test-group-form.js"></script>
         <script src="components/instant-file-uploader.js"></script>
+        <script src="components/freshness-indicator.js"></script>
 
         <script src="pages/page.js"></script>
         <script src="pages/page-router.js"></script>
@@ -117,6 +118,7 @@ Run tools/bundle-v3-scripts to speed up the load time for production.`);
         <script src="pages/create-analysis-task-page.js"></script>
         <script src="pages/build-request-queue-page.js"></script>
         <script src="pages/summary-page.js"></script>
+        <script src="pages/test-freshness-page.js"></script>
 
         <script src="main.js"></script>
     </template>
index 4075c9c..a545d7a 100644 (file)
@@ -47,8 +47,10 @@ function main() {
         var buildRequestQueuePage = new BuildRequestQueuePage();
         buildRequestQueuePage.setParentPage(analysisCategoryPage);
 
+        const testHealthPage = new TestFreshnessPage(manifest.summaryPages, manifest.testAgeToleranceInHours);
+
         var heading = new Heading(manifest.siteTitle);
-        heading.addPageGroup(summaryPages.concat([chartsPage, analysisCategoryPage]));
+        heading.addPageGroup(summaryPages.concat([chartsPage, analysisCategoryPage, testHealthPage]));
 
         heading.setTitle(manifest.siteTitle);
         heading.addPageGroup(dashboardPages);
@@ -61,6 +63,7 @@ function main() {
         router.addPage(analysisTaskPage);
         router.addPage(buildRequestQueuePage);
         router.addPage(analysisCategoryPage);
+        router.addPage(testHealthPage);
         for (var page of dashboardPages)
             router.addPage(page);
 
index 06e92d8..cc8609b 100644 (file)
@@ -84,43 +84,47 @@ class BuildRequest extends DataModelObject {
     buildId() { return this._buildId; }
     createdAt() { return this._createdAt; }
 
-    waitingTime(referenceTime)
-    {
-        var units = [
+    static formatTimeInterval(intervalInMillionSeconds) {
+        let intervalInSeconds = intervalInMillionSeconds / 1000;
+        const units = [
             {unit: 'week', length: 7 * 24 * 3600},
             {unit: 'day', length: 24 * 3600},
             {unit: 'hour', length: 3600},
             {unit: 'minute', length: 60},
         ];
 
-        var diff = (referenceTime - this.createdAt()) / 1000;
 
-        var indexOfFirstSmallEnoughUnit = units.length - 1;
-        for (var i = 0; i < units.length; i++) {
-            if (diff > 1.5 * units[i].length) {
+        let indexOfFirstSmallEnoughUnit = units.length - 1;
+        for (let i = 0; i < units.length; i++) {
+            if (intervalInSeconds > 1.5 * units[i].length) {
                 indexOfFirstSmallEnoughUnit = i;
                 break;
             }
         }
 
-        var label = '';
-        var lastUnit = false;
-        for (var i = indexOfFirstSmallEnoughUnit; !lastUnit; i++) {
+        let label = '';
+        let lastUnit = false;
+        for (let i = indexOfFirstSmallEnoughUnit; !lastUnit; i++) {
             lastUnit = i == indexOfFirstSmallEnoughUnit + 1 || i == units.length - 1;
-            var length = units[i].length;
-            var valueForUnit = lastUnit ? Math.round(diff / length) : Math.floor(diff / length);
+            const length = units[i].length;
+            const valueForUnit = lastUnit ? Math.round(intervalInSeconds / length) : Math.floor(intervalInSeconds / length);
 
-            var unit = units[i].unit + (valueForUnit == 1 ? '' : 's');
+            const unit = units[i].unit + (valueForUnit == 1 ? '' : 's');
             if (label)
                 label += ' ';
             label += `${valueForUnit} ${unit}`;
 
-            diff = diff - valueForUnit * length;
+            intervalInSeconds = intervalInSeconds - valueForUnit * length;
         }
 
         return label;
     }
 
+    waitingTime(referenceTime)
+    {
+        return BuildRequest.formatTimeInterval(referenceTime - this.createdAt());
+    }
+
     static fetchForTriggerable(triggerable)
     {
         return RemoteAPI.getJSONWithStatus('/api/build-requests/' + triggerable).then(function (data) {
diff --git a/Websites/perf.webkit.org/public/v3/pages/test-freshness-page.js b/Websites/perf.webkit.org/public/v3/pages/test-freshness-page.js
new file mode 100644 (file)
index 0000000..781c3f2
--- /dev/null
@@ -0,0 +1,230 @@
+
+class TestFreshnessPage extends PageWithHeading {
+    constructor(summaryPageConfiguration, testAgeToleranceInHours)
+    {
+        super('test-freshness', null);
+        this._testAgeTolerance = (testAgeToleranceInHours || 24) * 3600 * 1000;
+        this._timeDuration = this._testAgeTolerance * 2;
+        this._excludedConfigurations = {};
+        this._lastDataPointByConfiguration = null;
+        this._indicatorByConfiguration = null;
+        this._renderTableLazily = new LazilyEvaluatedFunction(this._renderTable.bind(this));
+
+        this._loadConfig(summaryPageConfiguration);
+    }
+
+    name() { return 'Test-Freshness'; }
+
+    _loadConfig(summaryPageConfiguration)
+    {
+        const platformIdSet = new Set;
+        const metricIdSet = new Set;
+
+        for (const config of summaryPageConfiguration) {
+            for (const platformGroup of config.platformGroups) {
+                for (const platformId of platformGroup.platforms)
+                    platformIdSet.add(platformId);
+            }
+
+            for (const metricGroup of config.metricGroups) {
+                for (const subgroup of metricGroup.subgroups) {
+                    for (const metricId of subgroup.metrics)
+                        metricIdSet.add(metricId);
+                }
+            }
+
+            const excludedConfigs = config.excludedConfigurations;
+            for (const platform in excludedConfigs) {
+                if (platform in this._excludedConfigurations)
+                    this._excludedConfigurations[platform] = this._excludedConfigurations[platform].concat(excludedConfigs[platform]);
+                else
+                    this._excludedConfigurations[platform] = excludedConfigs[platform];
+            }
+        }
+        this._platforms = [...platformIdSet].map((platformId) => Platform.findById(platformId));
+        this._metrics = [...metricIdSet].map((metricId) => Metric.findById(metricId));
+    }
+
+    open(state)
+    {
+        this._fetchTestResults();
+        super.open(state);
+    }
+
+    _fetchTestResults()
+    {
+        this._measurementSetFetchTime = Date.now();
+        this._lastDataPointByConfiguration = new Map;
+
+        const startTime = this._measurementSetFetchTime - this._timeDuration;
+
+        for (const platform of this._platforms) {
+            const lastDataPointByMetric = new Map;
+            this._lastDataPointByConfiguration.set(platform, lastDataPointByMetric);
+
+            for (const metric of this._metrics) {
+                if (!this._isValidPlatformMetricCombination(platform, metric, this._excludedConfigurations))
+                    continue;
+
+                const measurementSet = MeasurementSet.findSet(platform.id(), metric.id(), platform.lastModified(metric));
+                measurementSet.fetchBetween(startTime, this._measurementSetFetchTime).then(() => {
+                    const currentTimeSeries = measurementSet.fetchedTimeSeries('current', false, false);
+
+                    let timeForLastDataPoint = startTime;
+                    if (currentTimeSeries.lastPoint())
+                        timeForLastDataPoint = currentTimeSeries.lastPoint().time;
+
+                    lastDataPointByMetric.set(metric, {time: timeForLastDataPoint, hasCurrentDataPoint: !!currentTimeSeries.lastPoint()});
+                    this.enqueueToRender();
+                });
+            }
+        }
+    }
+
+    render()
+    {
+        super.render();
+
+        this._renderTableLazily.evaluate(this._platforms, this._metrics);
+
+        for (const [platform, lastDataPointByMetric] of this._lastDataPointByConfiguration.entries()) {
+            for (const [metric, lastDataPoint] of lastDataPointByMetric.entries()) {
+                const timeDuration = this._measurementSetFetchTime - lastDataPoint.time;
+                const timeDurationSummaryPrefix = lastDataPoint.hasCurrentDataPoint ? '' : 'More than ';
+                const timeDurationSummary = BuildRequest.formatTimeInterval(timeDuration);
+                const testLabel = `"${metric.test().fullName()}" for "${platform.name()}"`;
+                const summary = `${timeDurationSummaryPrefix}${timeDurationSummary} since last data point on ${testLabel}`;
+                const url = this._router.url('charts', ChartsPage.createStateForDashboardItem(platform.id(), metric.id(),
+                    this._measurementSetFetchTime - this._timeDuration));
+
+                const indicator = this._indicatorByConfiguration.get(platform).get(metric);
+                indicator.update(timeDuration, this._testAgeTolerance, summary, url);
+            }
+        }
+    }
+
+    _renderTable(platforms, metrics)
+    {
+        const element = ComponentBase.createElement;
+        const tableBodyElement = [];
+        const tableHeadElements = [element('th',  {class: 'table-corner'}, 'Platform \\ Test')];
+
+        for (const metric of metrics)
+            tableHeadElements.push(element('th', {class: 'diagonal-header'}, element('div', metric.test().fullName())));
+
+        this._indicatorByConfiguration = new Map;
+        for (const platform of platforms) {
+            const indicatorByMetric = new Map;
+            this._indicatorByConfiguration.set(platform, indicatorByMetric);
+            tableBodyElement.push(element('tr',
+                [element('th', platform.label()), ...metrics.map((metric) => this._constructTableCell(platform, metric, indicatorByMetric))]));
+        }
+
+        this.renderReplace(this.content('test-health'), [element('thead', tableHeadElements), element('tbody', tableBodyElement)]);
+    }
+
+    _isValidPlatformMetricCombination(platform, metric)
+    {
+        return !(this._excludedConfigurations && this._excludedConfigurations[platform.id()]
+            && this._excludedConfigurations[platform.id()].some((metricId) => metricId == metric.id()))
+            && platform.hasMetric(metric);
+    }
+
+    _constructTableCell(platform, metric, indicatorByMetric)
+    {
+        const element = ComponentBase.createElement;
+
+        if (!this._isValidPlatformMetricCombination(platform, metric))
+            return element('td', {class: 'blank-cell'}, element('div'));
+
+        const indicator = new FreshnessIndicator;
+        indicatorByMetric.set(metric, indicator);
+        return element('td', {class: 'status-cell'}, indicator);
+    }
+
+    static htmlTemplate()
+    {
+        return `<section class="page-with-heading"><table id="test-health"></table></section>`;
+    }
+
+    static cssTemplate()
+    {
+        return `
+            .page-with-heading {
+                display: flex;
+                justify-content: center;
+            }
+            #test-health {
+                font-size: 1rem;
+            }
+            #test-health th.table-corner {
+                text-align: right;
+                vertical-align: bottom;
+            }
+            #test-health th {
+                text-align: left;
+                border-bottom: 0.1rem solid #ccc;
+                font-weight: normal;
+            }
+            #test-health th.diagonal-header {
+                white-space: nowrap;
+                height: 16rem;
+                border-bottom: 0rem;
+            }
+            #test-health th.diagonal-header > div {
+                transform: translate(1rem, 7rem) rotate(315deg);
+                width: 2rem;
+                border: 0rem;
+            }
+            #test-health td.status-cell {
+                margin: 0;
+                padding: 0;
+                max-width: 2.2rem;
+                max-height: 2.2rem;
+                min-width: 2.2rem;
+                min-height: 2.2rem;
+            }
+            #test-health td.blank-cell {
+                margin: 0;
+                padding: 0;
+                max-width: 2.2rem;
+                max-height: 2.2rem;
+                min-width: 2.2rem;
+                min-height: 2.2rem;
+            }
+            #test-health td.blank-cell > div  {
+                background-color: #F9F9F9;
+                height: 1.6rem;
+                width: 1.6rem;
+                margin: 0.1rem;
+                padding: 0;
+                position: relative;
+                overflow: hidden;
+            }
+            #test-health td.blank-cell > div::before {
+              content: "";
+              position: absolute;
+              top: -1px;
+              left: -1px;
+              display: block;
+              width: 0px;
+              height: 0px;
+              border-right: calc(1.6rem + 1px) solid #ddd;
+              border-top: calc(1.6rem + 1px) solid transparent;
+            }
+            #test-health td.blank-cell > div::after {
+              content: "";
+              display: block;
+              position: absolute;
+              top: 1px;
+              left: 1px;
+              width: 0px;
+              height: 0px;
+              border-right: calc(1.6rem - 1px) solid #F9F9F9;
+              border-top: calc(1.6rem - 1px) solid transparent;
+            }
+        `;
+    }
+
+    routeName() { return 'test-freshness'; }
+}
\ No newline at end of file
index f0687e3..20af381 100644 (file)
@@ -14,7 +14,7 @@ describe('/api/manifest', function () {
     it("should generate an empty manifest when database is empty", () => {
         return TestServer.remoteAPI().getJSON('/api/manifest').then((manifest) => {
             assert.deepEqual(Object.keys(manifest).sort(), ['all', 'bugTrackers', 'builders', 'dashboard', 'dashboards',
-                'elapsedTime', 'fileUploadSizeLimit', 'metrics', 'repositories', 'siteTitle', 'status', 'summaryPages', 'tests', 'triggerables']);
+                'elapsedTime', 'fileUploadSizeLimit', 'metrics', 'repositories', 'siteTitle', 'status', 'summaryPages', 'testAgeToleranceInHours', 'tests', 'triggerables']);
 
             assert.equal(typeof(manifest.elapsedTime), 'number');
             delete manifest.elapsedTime;
@@ -29,6 +29,7 @@ describe('/api/manifest', function () {
                 fileUploadSizeLimit: 2097152, // 2MB during testing.
                 metrics: {},
                 repositories: {},
+                testAgeToleranceInHours: null,
                 tests: {},
                 triggerables: {},
                 summaryPages: [],
index 56cd807..f092955 100644 (file)
@@ -158,4 +158,54 @@ describe('BuildRequest', function () {
 
     });
 
+    describe('formatTimeInterval', () => {
+        it('should return "0 minutes" when formatting for 0 second in million seconds', () => {
+            assert.equal(BuildRequest.formatTimeInterval(0), '0 minutes');
+        });
+
+        it('should return "1 minute" when formatting for 60 seconds in million seconds', () => {
+            assert.equal(BuildRequest.formatTimeInterval(60 * 1000), '1 minute');
+        });
+
+        it('should return "1 minute" when formatting for  75 seconds in million seconds', () => {
+            assert.equal(BuildRequest.formatTimeInterval(75 * 1000), '1 minute');
+        });
+
+        it('should return "2 minutes" when formatting for 118 seconds in million seconds', () => {
+            assert.equal(BuildRequest.formatTimeInterval(118 * 1000), '2 minutes');
+        });
+
+        it('should return "75 minutes" when formatting for 75 minutes in million seconds', () => {
+            assert.equal(BuildRequest.formatTimeInterval(75 * 60 * 1000), '75 minutes');
+        });
+
+        it('should return "1 hour 58 minutes" when formatting for 118 minutes in million seconds', () => {
+            assert.equal(BuildRequest.formatTimeInterval(118 * 60 * 1000), '1 hour 58 minutes');
+        });
+
+        it('should return "3 hours 2 minutes" when formatting for 182 minutes in million seconds', () => {
+            assert.equal(BuildRequest.formatTimeInterval(182 * 60 * 1000), '3 hours 2 minutes');
+        });
+
+        it('should return "27 hours 14 minutes" when formatting for 27 hours 14 minutes in million seconds', () => {
+            assert.equal(BuildRequest.formatTimeInterval((27 * 3600 + 14 * 60) * 1000), '27 hours 14 minutes');
+        });
+
+        it('should return "2 days 3 hours" when formatting for 51 hours 14 minutes in million seconds', () => {
+            assert.equal(BuildRequest.formatTimeInterval((51 * 3600 + 14 * 60) * 1000), '2 days 3 hours');
+        });
+
+        it('should return "2 days 0 hours" when formatting for 48 hours 1 minutes in million seconds', () => {
+            assert.equal(BuildRequest.formatTimeInterval((48 * 3600 + 1 * 60) * 1000), '2 days 0 hours');
+        });
+
+        it('should return "2 days 2 hours" when formatting for 49 hours 59 minutes in million seconds', () => {
+            assert.equal(BuildRequest.formatTimeInterval((49 * 3600 + 59 * 60) * 1000), '2 days 2 hours');
+        });
+
+        it('should return "2 weeks 6 days" when formatting for 20 days 5 hours 21 minutes in million seconds', () => {
+            assert.equal(BuildRequest.formatTimeInterval(((20 * 24 + 5) * 3600 + 21 * 60) * 1000), '2 weeks 6 days');
+        });
+    });
+
 });
\ No newline at end of file