Add UI for A/B testing on owned commits.
authordewei_zhu@apple.com <dewei_zhu@apple.com@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Fri, 22 Dec 2017 06:25:24 +0000 (06:25 +0000)
committerdewei_zhu@apple.com <dewei_zhu@apple.com@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Fri, 22 Dec 2017 06:25:24 +0000 (06:25 +0000)
https://bugs.webkit.org/show_bug.cgi?id=177993

Reviewed by Ryosuke Niwa.

Customizable test group form should support specifying and A/B testing owned commits.
Introduce 'IntermediateCommitSet' to achieve the goal of specifying owned commits for A/B test.
In order to support configure A/B testing that may need to add/remove owned commits, CommitSet may be the
closest thing we can get. However, it is a subclass of DataModelObject, which means CommitSet is a representation
of 'commit_sets' table and can only be updated from server data. Thus, we want something like CustomCommitSet that
is not a representation of database table, but unlike CustomCommitSet, it should store information about commits
rather than a revision. As a result, IntermediateCommitSet is introduced. For a longer term, we may replace
CustomCommitSet with IntermediateCommitSet as it carries more information and could potentially simplify some
CustomCommitSet related APIs by using commit id instead of commit revision.
Extend ButtonBase class so that we can enable/disable a button.

* public/v3/components/button-base.js:
(ButtonBase):
(ButtonBase.prototype.setDisabled): Enable/disable a button.
(ButtonBase.prototype.render):
(ButtonBase.cssTemplate): Added css rule for disabled button.
* public/v3/components/combo-box.js: Added.
(ComboBox):
(ComboBox.prototype.didConstructShadowTree): Setup text field.
(ComboBox.prototype.render):
(ComboBox.prototype._candidateNameForCurrentIndex): Returns candidate name based on current index.
(ComboBox.prototype._candidateElementForCurrentIndex): Returns a list element based on current index.
(ComboBox.prototype._autoCompleteIfOnlyOneMatchingItem): Supports auto completion.
(ComboBox.prototype._moveCandidate): Supports arrow up/down.
(ComboBox.prototype._updateCandidateList): Hide/unhide candidate list and high-light selected candidate.
(ComboBox.prototype._renderCandidateList): Render candidate list base on value on text field.
(ComboBox.htmlTemplate):
(ComboBox.cssTemplate):
* public/v3/components/customizable-test-group-form.js:
(CustomizableTestGroupForm):
(CustomizableTestGroupForm.prototype.didConstructShadowTree): Only fetch the full commits when we about to create a customized A/B tests.
(CustomizableTestGroupForm.prototype._computeCommitSetMap): Compute the CustomCommitSet based on IntermediateCommitSet and
other revision related information in some map.
(CustomizableTestGroupForm.prototype.render):
(CustomizableTestGroupForm.prototype._renderCustomRevisionTable):
(CustomizableTestGroupForm.prototype._constructTableBodyList): This function builds table body for each highest level repository.
It will also include the owned repository rows in the same table body if the commits for highest level repository owns other commits.
(CustomizableTestGroupForm.prototype._constructTableRowForCommitsWithoutOwner): Build a table row for a highest level repository.
(CustomizableTestGroupForm.prototype._constructTableRowForCommitsWithOwner): Build a table row for repository with owner.
(CustomizableTestGroupForm.prototype._constructTableRowForIncompleteOwnedCommits): Build a table row for an unspecified repository.
(CustomizableTestGroupForm.prototype._constructRevisionRadioButtons): Update the logic to support build radio buttons for the owned repository rows.
(CustomizableTestGroupForm.cssTemplate):
* public/v3/components/minus-button.js: Added.
(MinusButton):
(MinusButton.buttonContent):
* public/v3/components/owned-commit-viewer.js:
(OwnedCommitViewer.prototype._renderOwnedCommitTable):
* public/v3/components/plus-button.js: Added.
(PlusButton):
(PlusButton.buttonContent):
* public/v3/index.html: Added new components.
* public/v3/models/commit-log.js: Added owner and owned commit information.
(CommitLog):
(CommitLog.prototype.ownedCommits): Returns a list of commits owned by current commit.
(CommitLog.prototype.ownerCommit): Return owner commit of current commit.
(CommitLog.prototype.setOwnerCommits): Set owner commit of current commit.
(CommitLog.prototype.label): Remove unnecessary 'else'.
(CommitLog.prototype.diff): Remove unused 'fromRevisionForURL' and tiny code cleanup.
(CommitLog.prototype.ownedCommitForOwnedRepository):
(CommitLog.prototype.fetchOwnedCommits): Sets the owner for those owned commits. The owner of a commit with multiple owner
commits will be overwritten by each time this function is called.
(CommitLog.ownedCommitDifferenceForOwnerCommits): A more generic version of diffOwnedCommits. diffOwnedCommits only accepts 2 commits,
but ownedCommitDifferenceForOwnerCommits supports multiple commits.
(CommitLog.diffOwnedCommits): Deleted and should use 'CommitLog.ownedCommitDifferenceForOwnerCommits' instead.
* public/v3/models/commit-set.js:
(CommitSet.prototype.topLevelRepositories):
(CommitSet.prototype.commitForRepository):
(IntermediateCommitSet): Take CommitSet as argument, note the commit from CommitSet doesn't contains full information of the commit.
Always call 'fetchFullCommits' once before any further usages.
(IntermediateCommitSet.prototype.fetchCommitLogs): Fetch all commits information in current commit set.
(IntermediateCommitSet.prototype._fetchCommitLogAndOwnedCommits): Fetch commit log and owned commits if necessary.
(IntermediateCommitSet.prototype.updateRevisionForOwnerRepository): Updates a commit for a repository by given a revision of the repository.
(IntermediateCommitSet.prototype.setCommitForRepository): Sets a commit for a repository in commit set.
(IntermediateCommitSet.prototype.removeCommitForRepository): Removes a commit for a repository in commit set.
(IntermediateCommitSet.prototype.ownsCommitsForRepository): Returns whether the commit for repository owns commits.
(IntermediateCommitSet.prototype.repositories): Returns all repositories in the commit set.
(IntermediateCommitSet.prototype.highestLevelRepositories): Returns all repositories those don't have an owner.
(IntermediateCommitSet.prototype.commitForRepository): Returns a commit for a given repository.
(IntermediateCommitSet.prototype.ownedRepositoriesForOwnerRepository): Returns all repositories owned by a given repository in current commit set.
(IntermediateCommitSet.prototype.ownerCommitForRepository): Returns owner commit for a given owned repository.
* tools/js/v3-models.js: Added import for 'IntermediateCommitSet'.
* unit-tests/commit-log-tests.js: Updated unittest which tests 'ownerCommit' function.
* unit-tests/commit-set-tests.js: Added unit tests for IntermediateCommitSet.

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

13 files changed:
Websites/perf.webkit.org/ChangeLog
Websites/perf.webkit.org/public/v3/components/button-base.js
Websites/perf.webkit.org/public/v3/components/combo-box.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/components/customizable-test-group-form.js
Websites/perf.webkit.org/public/v3/components/minus-button.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/components/owned-commit-viewer.js
Websites/perf.webkit.org/public/v3/components/plus-button.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/index.html
Websites/perf.webkit.org/public/v3/models/commit-log.js
Websites/perf.webkit.org/public/v3/models/commit-set.js
Websites/perf.webkit.org/tools/js/v3-models.js
Websites/perf.webkit.org/unit-tests/commit-log-tests.js
Websites/perf.webkit.org/unit-tests/commit-set-tests.js

index d13e27ad90b1bf69b5c660c4f091c6f4d574eb8e..7fcb997c2b2a624b901a5c5255e04e5e149c1e8b 100644 (file)
@@ -1,3 +1,94 @@
+2017-12-21  Dewei Zhu  <dewei_zhu@apple.com>
+
+        Add UI for A/B testing on owned commits.
+        https://bugs.webkit.org/show_bug.cgi?id=177993
+
+        Reviewed by Ryosuke Niwa.
+
+        Customizable test group form should support specifying and A/B testing owned commits.
+        Introduce 'IntermediateCommitSet' to achieve the goal of specifying owned commits for A/B test.
+        In order to support configure A/B testing that may need to add/remove owned commits, CommitSet may be the
+        closest thing we can get. However, it is a subclass of DataModelObject, which means CommitSet is a representation
+        of 'commit_sets' table and can only be updated from server data. Thus, we want something like CustomCommitSet that
+        is not a representation of database table, but unlike CustomCommitSet, it should store information about commits
+        rather than a revision. As a result, IntermediateCommitSet is introduced. For a longer term, we may replace
+        CustomCommitSet with IntermediateCommitSet as it carries more information and could potentially simplify some
+        CustomCommitSet related APIs by using commit id instead of commit revision.
+        Extend ButtonBase class so that we can enable/disable a button.
+
+        * public/v3/components/button-base.js:
+        (ButtonBase):
+        (ButtonBase.prototype.setDisabled): Enable/disable a button.
+        (ButtonBase.prototype.render):
+        (ButtonBase.cssTemplate): Added css rule for disabled button.
+        * public/v3/components/combo-box.js: Added.
+        (ComboBox):
+        (ComboBox.prototype.didConstructShadowTree): Setup text field.
+        (ComboBox.prototype.render):
+        (ComboBox.prototype._candidateNameForCurrentIndex): Returns candidate name based on current index.
+        (ComboBox.prototype._candidateElementForCurrentIndex): Returns a list element based on current index.
+        (ComboBox.prototype._autoCompleteIfOnlyOneMatchingItem): Supports auto completion.
+        (ComboBox.prototype._moveCandidate): Supports arrow up/down.
+        (ComboBox.prototype._updateCandidateList): Hide/unhide candidate list and high-light selected candidate.
+        (ComboBox.prototype._renderCandidateList): Render candidate list base on value on text field.
+        (ComboBox.htmlTemplate):
+        (ComboBox.cssTemplate):
+        * public/v3/components/customizable-test-group-form.js:
+        (CustomizableTestGroupForm):
+        (CustomizableTestGroupForm.prototype.didConstructShadowTree): Only fetch the full commits when we about to create a customized A/B tests.
+        (CustomizableTestGroupForm.prototype._computeCommitSetMap): Compute the CustomCommitSet based on IntermediateCommitSet and
+        other revision related information in some map.
+        (CustomizableTestGroupForm.prototype.render):
+        (CustomizableTestGroupForm.prototype._renderCustomRevisionTable):
+        (CustomizableTestGroupForm.prototype._constructTableBodyList): This function builds table body for each highest level repository.
+        It will also include the owned repository rows in the same table body if the commits for highest level repository owns other commits.
+        (CustomizableTestGroupForm.prototype._constructTableRowForCommitsWithoutOwner): Build a table row for a highest level repository.
+        (CustomizableTestGroupForm.prototype._constructTableRowForCommitsWithOwner): Build a table row for repository with owner.
+        (CustomizableTestGroupForm.prototype._constructTableRowForIncompleteOwnedCommits): Build a table row for an unspecified repository.
+        (CustomizableTestGroupForm.prototype._constructRevisionRadioButtons): Update the logic to support build radio buttons for the owned repository rows.
+        (CustomizableTestGroupForm.cssTemplate):
+        * public/v3/components/minus-button.js: Added.
+        (MinusButton):
+        (MinusButton.buttonContent):
+        * public/v3/components/owned-commit-viewer.js:
+        (OwnedCommitViewer.prototype._renderOwnedCommitTable):
+        * public/v3/components/plus-button.js: Added.
+        (PlusButton):
+        (PlusButton.buttonContent):
+        * public/v3/index.html: Added new components.
+        * public/v3/models/commit-log.js: Added owner and owned commit information.
+        (CommitLog):
+        (CommitLog.prototype.ownedCommits): Returns a list of commits owned by current commit.
+        (CommitLog.prototype.ownerCommit): Return owner commit of current commit.
+        (CommitLog.prototype.setOwnerCommits): Set owner commit of current commit.
+        (CommitLog.prototype.label): Remove unnecessary 'else'.
+        (CommitLog.prototype.diff): Remove unused 'fromRevisionForURL' and tiny code cleanup.
+        (CommitLog.prototype.ownedCommitForOwnedRepository):
+        (CommitLog.prototype.fetchOwnedCommits): Sets the owner for those owned commits. The owner of a commit with multiple owner
+        commits will be overwritten by each time this function is called.
+        (CommitLog.ownedCommitDifferenceForOwnerCommits): A more generic version of diffOwnedCommits. diffOwnedCommits only accepts 2 commits,
+        but ownedCommitDifferenceForOwnerCommits supports multiple commits.
+        (CommitLog.diffOwnedCommits): Deleted and should use 'CommitLog.ownedCommitDifferenceForOwnerCommits' instead.
+        * public/v3/models/commit-set.js:
+        (CommitSet.prototype.topLevelRepositories):
+        (CommitSet.prototype.commitForRepository):
+        (IntermediateCommitSet): Take CommitSet as argument, note the commit from CommitSet doesn't contains full information of the commit.
+        Always call 'fetchFullCommits' once before any further usages.
+        (IntermediateCommitSet.prototype.fetchCommitLogs): Fetch all commits information in current commit set.
+        (IntermediateCommitSet.prototype._fetchCommitLogAndOwnedCommits): Fetch commit log and owned commits if necessary.
+        (IntermediateCommitSet.prototype.updateRevisionForOwnerRepository): Updates a commit for a repository by given a revision of the repository.
+        (IntermediateCommitSet.prototype.setCommitForRepository): Sets a commit for a repository in commit set.
+        (IntermediateCommitSet.prototype.removeCommitForRepository): Removes a commit for a repository in commit set.
+        (IntermediateCommitSet.prototype.ownsCommitsForRepository): Returns whether the commit for repository owns commits.
+        (IntermediateCommitSet.prototype.repositories): Returns all repositories in the commit set.
+        (IntermediateCommitSet.prototype.highestLevelRepositories): Returns all repositories those don't have an owner.
+        (IntermediateCommitSet.prototype.commitForRepository): Returns a commit for a given repository.
+        (IntermediateCommitSet.prototype.ownedRepositoriesForOwnerRepository): Returns all repositories owned by a given repository in current commit set.
+        (IntermediateCommitSet.prototype.ownerCommitForRepository): Returns owner commit for a given owned repository.
+        * tools/js/v3-models.js: Added import for 'IntermediateCommitSet'.
+        * unit-tests/commit-log-tests.js: Updated unittest which tests 'ownerCommit' function.
+        * unit-tests/commit-set-tests.js: Added unit tests for IntermediateCommitSet.
+
 2017-12-13  Dewei Zhu  <dewei_zhu@apple.com>
 
         Add a test freshness page.
index 36439fce816bc3032175e9a5a223ad85cfe506db..4c0e34bc31f7d01ce5dcd0d7d89d0eb39043f3d2 100644 (file)
@@ -1,5 +1,18 @@
 
 class ButtonBase extends ComponentBase {
+
+    constructor(name)
+    {
+        super(name);
+        this._disabled = false;
+    }
+
+    setDisabled(disabled)
+    {
+        this._disabled = disabled;
+        this.enqueueToRender();
+    }
+
     didConstructShadowTree()
     {
         this.content('button').addEventListener('click', this.createEventHandler(() => {
@@ -7,6 +20,15 @@ class ButtonBase extends ComponentBase {
         }));
     }
 
+    render()
+    {
+        super.render();
+        if (this._disabled)
+            this.content('button').setAttribute('disabled', '');
+        else
+            this.content('button').removeAttribute('disabled');
+    }
+
     static htmlTemplate()
     {
         return `<a id="button" href="#"><svg viewBox="0 0 100 100">${this.buttonContent()}</svg></a>`;
@@ -35,6 +57,12 @@ class ButtonBase extends ComponentBase {
                 opacity: 0.6;
             }
 
+            a[disabled] {
+                pointer-events: none;
+                cursor: default;
+                opacity: 0.2;
+            }
+
             svg {
                 display: block;
             }
diff --git a/Websites/perf.webkit.org/public/v3/components/combo-box.js b/Websites/perf.webkit.org/public/v3/components/combo-box.js
new file mode 100644 (file)
index 0000000..4e58528
--- /dev/null
@@ -0,0 +1,205 @@
+class ComboBox extends ComponentBase {
+
+    constructor(candidates, maxCandidateListLength)
+    {
+        super('combo-box');
+        this._candidates = candidates;
+        this._maxCandidateListLength = maxCandidateListLength || 1000;
+        this._candidateList = [];
+        this._currentCandidateIndex = null;
+        this._showCandidateList = false;
+        this._renderCandidateListLazily = new LazilyEvaluatedFunction(this._renderCandidateList.bind(this));
+        this._updateCandidateListLazily = new LazilyEvaluatedFunction(this._updateCandidateList.bind(this));
+    }
+
+    didConstructShadowTree()
+    {
+        super.didConstructShadowTree();
+        const textField = this.content('text-field');
+        textField.addEventListener('input', () => {
+            this._showCandidateList = true;
+            this._currentCandidateIndex = null;
+            this.enqueueToRender();
+        });
+        textField.addEventListener('change', () => {
+            this._autoCompleteIfOnlyOneMatchingItem();
+            this._currentCandidateIndex = null;
+        });
+        textField.addEventListener('blur', () => {
+            this._showCandidateList = false;
+            this._autoCompleteIfOnlyOneMatchingItem();
+            this.enqueueToRender();
+        });
+        textField.addEventListener('focus', () => {
+            this._showCandidateList = true;
+            this.enqueueToRender();
+        });
+        textField.addEventListener('keydown', (event) => {
+            if (event.key === 'ArrowDown' || event.key === 'ArrowUp')
+                this._moveCandidate(event.key === 'ArrowDown');
+            else if (event.key === 'Tab' || event.key === 'Enter') {
+                let candidate = this._currentCandidateIndex === null ? null : this._candidateList[this._currentCandidateIndex];
+                if (!candidate && this._candidateList.length === 1)
+                    candidate = this._candidateList[0];
+                if (candidate)
+                    this.dispatchAction('update', candidate);
+            }
+        });
+    }
+
+    render()
+    {
+        super.render();
+
+        const candidateElementList = this._renderCandidateListLazily.evaluate(this._candidates, this.content('text-field').value);
+
+        console.assert(this._currentCandidateIndex === null || (this._currentCandidateIndex >= 0 && this._currentCandidateIndex < candidateElementList.length));
+        const selectedCandidateElement = this._currentCandidateIndex === null ? null : candidateElementList[this._currentCandidateIndex];
+        this._updateCandidateListLazily.evaluate(selectedCandidateElement, this._showCandidateList);
+    }
+
+    _autoCompleteIfOnlyOneMatchingItem()
+    {
+        const textFieldValueInLowerCase = this.content('text-field').value.toLowerCase();
+        if (!textFieldValueInLowerCase.length)
+            return;
+
+        let matchingCandidateCount = 0;
+        let matchingCandidate = null;
+        for (const candidate of this._candidates) {
+            if (candidate.toLowerCase().includes(textFieldValueInLowerCase)) {
+                matchingCandidateCount += 1;
+                matchingCandidate = candidate;
+            }
+            if (matchingCandidateCount > 1)
+                break;
+        }
+        if (matchingCandidateCount === 1)
+            this.dispatchAction('update', matchingCandidate);
+    }
+
+    _moveCandidate(forward)
+    {
+        const candidateListLength = this._candidateList.length;
+        if (!candidateListLength)
+            return;
+        let newIndex = this._currentCandidateIndex;
+        if (newIndex === null)
+            newIndex = forward ? 0 : candidateListLength - 1;
+        else {
+            newIndex += forward ? 1 : -1;
+            if (newIndex >= this._candidateList.length)
+                newIndex = this._candidateList.length - 1;
+            if (newIndex < 0)
+                newIndex = 0;
+        }
+        this._currentCandidateIndex = newIndex;
+        this.enqueueToRender();
+    }
+
+    _renderCandidateList(candidates, key)
+    {
+        const element = ComponentBase.createElement;
+        const link = ComponentBase.createLink;
+        const candidatesStartingWithKey = [];
+        const candidatesContainingKey = [];
+        for (const candidate of candidates) {
+            const searchResult = candidate.toLowerCase().indexOf(key.toLowerCase());
+            if (key && searchResult < 0)
+                continue;
+            if (!searchResult)
+                candidatesStartingWithKey.push(candidate);
+            else
+                candidatesContainingKey.push(candidate);
+        }
+        this._candidateList = candidatesStartingWithKey.concat(candidatesContainingKey).slice(0, this._maxCandidateListLength);
+        const candidateElementList = this._candidateList.map((candidate) => {
+            const item = link(candidate, () => null);
+            // FIXME: We should use 'onlick' callback provided by 'ComponentBase.createLink'. However, in this case,
+            // 'blur' event will be triggered before 'onlick', we have to use 'mousedown' instead.
+            item.addEventListener('mousedown', () => {
+                this.dispatchAction('update', candidate);
+                this.enqueueToRender();
+            });
+            return element('li', item);
+        });
+
+        this.renderReplace(this.content('candidate-list'), candidateElementList);
+        return candidateElementList;
+    }
+
+    _updateCandidateList(selectedCandidateElement, showCandidateList)
+    {
+
+        const candidateList = this.content('candidate-list');
+        candidateList.style.display = showCandidateList ? null : 'none';
+
+        const previouslySelectedCandidateElement = candidateList.querySelector('.selected');
+        if (previouslySelectedCandidateElement)
+            previouslySelectedCandidateElement.classList.remove('selected');
+
+        if (selectedCandidateElement) {
+            selectedCandidateElement.className = 'selected';
+            selectedCandidateElement.scrollIntoViewIfNeeded();
+        }
+    }
+
+    static htmlTemplate()
+    {
+        return `<div id='combox-box'>
+            <input id='text-field'></input>
+            <ul id="candidate-list"></ul>
+        </div>`;
+    }
+
+    static cssTemplate()
+    {
+        return `
+            div {
+                position: relative;
+                height: 1.4rem;
+                left: 0;
+            }
+
+            ul:empty {
+                display: none;
+            }
+
+            ul {
+                transition: background 250ms ease-in;
+                margin: 0;
+                padding: 0.1rem 0.3rem;
+                list-style: none;
+                background: rgba(255, 255, 255, 0.95);
+                border: solid 1px #ccc;
+                top: 1.5rem;
+                border-radius: 0.2rem;
+                z-index: 10;
+                position: absolute;
+                min-width: 8.5rem;
+                max-height: 10rem;
+                overflow: auto;
+            }
+
+            li {
+                text-align: left;
+                margin: 0;
+                padding: 0;
+            }
+
+            li:hover,
+            li.selected {
+                background: rgba(204, 153, 51, 0.1);
+            }
+
+            li a {
+                text-decoration: none;
+                font-size: 0.8rem;
+                color: inherit;
+                display: block;
+            }
+        `;
+    }
+}
+
+ComponentBase.defineElement('combo-box', ComboBox);
index 46938019cb35a28094e7f208e232df4d20569f2b..53ef7f7f577383adcb2e6c23ac80615c7f84cb1d 100644 (file)
@@ -1,4 +1,3 @@
-
 class CustomizableTestGroupForm extends TestGroupForm {
 
     constructor()
@@ -7,15 +6,20 @@ class CustomizableTestGroupForm extends TestGroupForm {
         this._commitSetMap = null;
         this._name = null;
         this._isCustomized = false;
-        this._revisionEditorMap = {};
-
-        this._renderCustomRevisionTableLazily = new LazilyEvaluatedFunction(this._renderCustomRevisionTable.bind(this));
+        this._revisionEditorMap = null;
+        this._ownerRevisionMap = null;
+        this._checkedLabelByPosition = null;
+        this._hasIncompleteOwnedRepository = null;
+        this._fetchingCommitPromises = [];
     }
 
     setCommitSetMap(map)
     {
         this._commitSetMap = map;
         this._isCustomized = false;
+        this._fetchingCommitPromises = [];
+        this._checkedLabelByPosition = new Map;
+        this._hasIncompleteOwnedRepository = new Map;
         this.enqueueToRender();
     }
 
@@ -32,9 +36,30 @@ class CustomizableTestGroupForm extends TestGroupForm {
         nameControl.oninput = () => {
             this._name = nameControl.value;
             this.enqueueToRender();
-        }
+        };
 
         this.content('customize-link').onclick = this.createEventHandler(() => {
+            if (!this._isCustomized) {
+                const originalCommitSetMap = this._commitSetMap;
+                const fetchingCommitPromises = [];
+                this._commitSetMap = new Map;
+                for (const label in originalCommitSetMap) {
+                    const intermediateCommitSet = new IntermediateCommitSet(originalCommitSetMap[label]);
+                    fetchingCommitPromises.push(intermediateCommitSet.fetchCommitLogs());
+                    this._commitSetMap.set(label, intermediateCommitSet);
+                }
+                this._fetchingCommitPromises = fetchingCommitPromises;
+
+                return Promise.all(fetchingCommitPromises).then(() => {
+                    if (this._fetchingCommitPromises !== fetchingCommitPromises)
+                        return;
+                    this._isCustomized = true;
+                    this._fetchingCommitPromises = [];
+                    for (const label in originalCommitSetMap)
+                        this._checkedLabelByPosition.set(label, new Map);
+                    this.enqueueToRender();
+                });
+            }
             this._isCustomized = true;
             this.enqueueToRender();
         });
@@ -47,13 +72,14 @@ class CustomizableTestGroupForm extends TestGroupForm {
             return this._commitSetMap;
 
         const map = {};
-        for (const label in this._commitSetMap) {
-            const originalCommitSet = this._commitSetMap;
+        for (const [label, commitSet] of this._commitSetMap) {
             const customCommitSet = new CustomCommitSet;
-            for (let repository of this._commitSetMap[label].repositories()) {
-                const revisionEditor = this._revisionEditorMap[label].get(repository);
+            for (const repository of commitSet.repositories()) {
+                const revisionEditor = this._revisionEditorMap.get(label).get(repository);
                 console.assert(revisionEditor);
-                customCommitSet.setRevisionForRepository(repository, revisionEditor.value);
+                const ownerRevision = this._ownerRevisionMap.get(label).get(repository) || null;
+
+                customCommitSet.setRevisionForRepository(repository, revisionEditor.value, null, ownerRevision);
             }
             map[label] = customCommitSet;
         }
@@ -67,24 +93,38 @@ class CustomizableTestGroupForm extends TestGroupForm {
         this.content('start-button').disabled = !(this._commitSetMap && this._name);
         this.content('customize-link-container').style.display = !this._commitSetMap ? 'none' : null;
 
-        this._renderCustomRevisionTableLazily.evaluate(this._commitSetMap, this._isCustomized);
+        this._renderCustomRevisionTable(this._commitSetMap, this._isCustomized);
     }
 
     _renderCustomRevisionTable(commitSetMap, isCustomized)
     {
         if (!commitSetMap || !isCustomized) {
             this.renderReplace(this.content('custom-table'), []);
-            return null;
+            return;
         }
 
         const repositorySet = new Set;
+        const ownedRepositoriesByRepository = new Map;
         const commitSetLabels = [];
-        this._revisionEditorMap = {};
-        for (const label in commitSetMap) {
-            for (const repository of commitSetMap[label].repositories())
+        this._revisionEditorMap = new Map;
+        this._ownerRevisionMap = new Map;
+        for (const [label, commitSet] of commitSetMap) {
+            for (const repository of commitSet.highestLevelRepositories()) {
                 repositorySet.add(repository);
+                const ownedRepositories = commitSetMap.get(label).ownedRepositoriesForOwnerRepository(repository);
+
+                if (!ownedRepositories)
+                    continue;
+
+                if (!ownedRepositoriesByRepository.has(repository))
+                    ownedRepositoriesByRepository.set(repository, new Set);
+                const ownedRepositorySet = ownedRepositoriesByRepository.get(repository);
+                for (const ownedRepository of ownedRepositories)
+                    ownedRepositorySet.add(ownedRepository)
+            }
             commitSetLabels.push(label);
-            this._revisionEditorMap[label] = new Map;
+            this._revisionEditorMap.set(label, new Map);
+            this._ownerRevisionMap.set(label, new Map);
         }
 
         const repositoryList = Repository.sortByNamePreferringOnesWithURL(Array.from(repositorySet.values()));
@@ -92,34 +132,145 @@ class CustomizableTestGroupForm extends TestGroupForm {
         this.renderReplace(this.content('custom-table'), [
             element('thead',
                 element('tr',
-                    [element('td', 'Repository'), commitSetLabels.map((label) => element('td', {colspan: commitSetLabels.length + 1}, label))])),
-            element('tbody',
-                repositoryList.map((repository) => {
-                    const cells = [element('th', repository.label())];
-                    for (const label in commitSetMap)
-                        cells.push(this._constructRevisionRadioButtons(commitSetMap, repository, label));
-                    return element('tr', cells);
-                }))]);
-
-        return repositoryList;
+                    [element('td', {colspan: 2}, 'Repository'), commitSetLabels.map((label) => element('td', {colspan: commitSetLabels.length + 1}, label)), element('td')])),
+            this._constructTableBodyList(repositoryList, commitSetMap, ownedRepositoriesByRepository, this._hasIncompleteOwnedRepository)]);
+    }
+
+    _constructTableBodyList(repositoryList, commitSetMap, ownedRepositoriesByRepository, hasIncompleteOwnedRepository)
+    {
+        const element = ComponentBase.createElement;
+        const tableBodyList = [];
+        for(const repository of repositoryList) {
+            const rows = [];
+            const allCommitSetSpecifiesOwnerCommit = Array.from(commitSetMap.values()).every((commitSet) => commitSet.ownsCommitsForRepository(repository));
+            const hasIncompleteRow = hasIncompleteOwnedRepository.get(repository);
+            const ownedRepositories = ownedRepositoriesByRepository.get(repository);
+
+            rows.push(this._constructTableRowForCommitsWithoutOwner(commitSetMap, repository, allCommitSetSpecifiesOwnerCommit, hasIncompleteOwnedRepository));
+
+            if ((!ownedRepositories || !ownedRepositories.size) && !hasIncompleteRow) {
+                tableBodyList.push(element('tbody', rows));
+                continue;
+            }
+
+            if (ownedRepositories) {
+                for (const ownedRepository of ownedRepositories)
+                    rows.push(this._constructTableRowForCommitsWithOwner(commitSetMap, ownedRepository, repository));
+            }
+
+            if (hasIncompleteRow) {
+                const commits = Array.from(commitSetMap.values()).map((commitSet) => commitSet.commitForRepository(repository));
+                const commitDiff = CommitLog.ownedCommitDifferenceForOwnerCommits(...commits);
+                rows.push(this._constructTableRowForIncompleteOwnedCommits(commitSetMap, repository, commitDiff));
+            }
+            tableBodyList.push(element('tbody', rows));
+        }
+        return tableBodyList;
+    }
+
+    _constructTableRowForCommitsWithoutOwner(commitSetMap, repository, ownsRepositories, hasIncompleteOwnedRepository)
+    {
+        const element = ComponentBase.createElement;
+        const cells = [element('th', {colspan: 2}, repository.label())];
+
+        for (const label of commitSetMap.keys())
+            cells.push(this._constructRevisionRadioButtons(commitSetMap, repository, label, null, ownsRepositories));
+
+        if (ownsRepositories) {
+            const plusButton = new PlusButton();
+            plusButton.setDisabled(hasIncompleteOwnedRepository.get(repository));
+            plusButton.listenToAction('activate', () => {
+                this._hasIncompleteOwnedRepository.set(repository, true);
+                this.enqueueToRender();
+            });
+            cells.push(element('td', plusButton));
+        } else
+            cells.push(element('td'));
+
+        return element('tr', cells);
+    }
+
+    _constructTableRowForCommitsWithOwner(commitSetMap, repository, ownerRepository)
+    {
+        const element = ComponentBase.createElement;
+        const cells = [element('td', {class: 'owner-repository-label'}), element('th', repository.label())];
+        const minusButton = new MinusButton();
+
+        for (const label of commitSetMap.keys())
+            cells.push(this._constructRevisionRadioButtons(commitSetMap, repository, label, ownerRepository, false));
+
+        minusButton.listenToAction('activate', () => {
+            for (const commitSet of commitSetMap.values())
+                commitSet.removeCommitForRepository(repository)
+            this.enqueueToRender();
+        });
+        cells.push(element('td', minusButton));
+        return element('tr', cells);
     }
 
-    _constructRevisionRadioButtons(commitSetMap, repository, rowLabel)
+    _constructTableRowForIncompleteOwnedCommits(commitSetMap, ownerRepository, commitDiff)
     {
         const element = ComponentBase.createElement;
-        const revisionEditor = element('input');
+        const configurationCount =  commitSetMap.size;
+        const numberOfCellsPerConfiguration = configurationCount + 1;
+        const changedRepositories = Array.from(commitDiff.keys());
+        const minusButton = new MinusButton();
+        const comboBox = new ComboBox(changedRepositories.map((repository) => repository.name()).sort());
 
-        this._revisionEditorMap[rowLabel].set(repository, revisionEditor);
+        comboBox.listenToAction('update', (repositoryName) => {
+            const targetRepository = changedRepositories.find((repository) => repositoryName === repository.name());
+            const ownedCommitDifferenceForRepository = Array.from(commitDiff.get(targetRepository).values());
+            const commitSetList = Array.from(commitSetMap.values());
+
+            console.assert(ownedCommitDifferenceForRepository.length === commitSetList.length);
+            for (let i = 0; i < commitSetList.length; i++)
+                commitSetList[i].setCommitForRepository(targetRepository, ownedCommitDifferenceForRepository[i]);
+            this._hasIncompleteOwnedRepository.set(ownerRepository, false);
+            this.enqueueToRender();
+        });
+
+        const cells = [element('td', {class: 'owner-repository-label'}), element('th', comboBox), element('td', {colspan: configurationCount * numberOfCellsPerConfiguration})];
+
+        minusButton.listenToAction('activate', () => {
+            this._hasIncompleteOwnedRepository.set(ownerRepository, false);
+            this.enqueueToRender();
+        });
+        cells.push(element('td', minusButton));
+
+        return element('tr', cells);
+    }
+
+    _constructRevisionRadioButtons(commitSetMap, repository, rowLabel, ownerRepository, ownsRepositories)
+    {
+        const element = ComponentBase.createElement;
+        const revisionEditor = element('input', {disabled: !!ownerRepository,
+            onchange: () => {
+                if (!ownsRepositories)
+                    return;
+                commitSetMap.get(rowLabel).updateRevisionForOwnerRepository(repository, revisionEditor.value).catch(
+                    () => revisionEditor.value = '');
+            }});
+
+        this._revisionEditorMap.get(rowLabel).set(repository, revisionEditor);
 
         const nodes = [];
-        for (let labelToChoose in commitSetMap) {
-            const commit = commitSetMap[labelToChoose].commitForRepository(repository);
-            const checked = labelToChoose == rowLabel;
+        for (const labelToChoose of commitSetMap.keys()) {
+            const commit = commitSetMap.get(labelToChoose).commitForRepository(repository);
+            const checkedLabel = this._checkedLabelByPosition.get(rowLabel).get(repository) || rowLabel;
+            const checked =  labelToChoose == checkedLabel;
             const radioButton = element('input', {type: 'radio', name: `${rowLabel}-${repository.id()}-radio`, checked,
-                onchange: () => { revisionEditor.value = commit ? commit.revision() : ''; }});
+                onchange: () => {
+                    this._checkedLabelByPosition.get(rowLabel).set(repository, labelToChoose);
+                    revisionEditor.value = commit ? commit.revision() : '';
+                    if (commit && commit.ownerCommit())
+                        this._ownerRevisionMap.get(rowLabel).set(repository, commit.ownerCommit().revision());
+                }});
 
-            if (checked)
+            if (checked) {
                 revisionEditor.value = commit ? commit.revision() : '';
+                if (commit && commit.ownerCommit())
+                    this._ownerRevisionMap.get(rowLabel).set(repository, commit.ownerCommit().revision());
+            }
             nodes.push(element('td', element('label', [radioButton, labelToChoose])));
         }
         nodes.push(element('td', revisionEditor));
@@ -139,6 +290,21 @@ class CustomizableTestGroupForm extends TestGroupForm {
                 margin: 1rem 0;
             }
 
+            #custom-table td.owner-repository-label {
+                border-top: solid 2px transparent;
+                border-bottom: solid 1px transparent;
+                min-width: 2rem;
+                text-align: right;
+            }
+
+            #custom-table tr:last-child td.owner-repository-label {
+                border-bottom: solid 1px #ddd;
+            }
+
+            #custom-table th {
+                min-width: 12rem;
+            }
+
             #custom-table,
             #custom-table td,
             #custom-table th {
diff --git a/Websites/perf.webkit.org/public/v3/components/minus-button.js b/Websites/perf.webkit.org/public/v3/components/minus-button.js
new file mode 100644 (file)
index 0000000..4095665
--- /dev/null
@@ -0,0 +1,17 @@
+
+class MinusButton extends ButtonBase {
+    constructor()
+    {
+        super('minus-button');
+    }
+
+    static buttonContent()
+    {
+        return `<g stroke="black" stroke-width="10" id="icon">
+            <circle cx="50" cy="50" r="40" fill="transparent"/>
+            <polygon points="25,50 75,50" />
+        </g>`;
+    }
+}
+
+ComponentBase.defineElement('minus-button', MinusButton);
index 68f743385b4505e9efed7d4f4f07dfc6f5f12e12..5ec2e6f7e2104e105344c6e3198c16ae0453d2f3 100644 (file)
@@ -43,7 +43,7 @@ class OwnedCommitViewer extends ComponentBase {
         if (!previousOwnedCommits || !currentOwnedCommits)
             return;
 
-        const difference = CommitLog.diffOwnedCommits(this._previousCommit, this._currentCommit);
+        const difference = CommitLog.ownedCommitDifferenceForOwnerCommits(this._previousCommit, this._currentCommit);
         const sortedRepositories = Repository.sortByName([...difference.keys()]);
         const element = ComponentBase.createElement;
 
diff --git a/Websites/perf.webkit.org/public/v3/components/plus-button.js b/Websites/perf.webkit.org/public/v3/components/plus-button.js
new file mode 100644 (file)
index 0000000..0849e2c
--- /dev/null
@@ -0,0 +1,18 @@
+
+class PlusButton extends ButtonBase {
+    constructor()
+    {
+        super('plus-button');
+    }
+
+    static buttonContent()
+    {
+        return `<g stroke="black" stroke-width="10" id="icon">
+            <circle cx="50" cy="50" r="40" fill="transparent"/>
+            <polygon points="50,25 50,75" />
+            <polygon points="25,50 75,50" />
+        </g>`;
+    }
+}
+
+ComponentBase.defineElement('plus-button', PlusButton);
index 1a21c0f657993396e397af02fca770e39a147dd9..0233f96feae7837e82d7041027760ea5e01be3bf 100644 (file)
@@ -99,6 +99,9 @@ Run tools/bundle-v3-scripts to speed up the load time for production.`);
         <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="components/plus-button.js"></script>
+        <script src="components/minus-button.js"></script>
+        <script src="components/combo-box.js"></script>
 
         <script src="pages/page.js"></script>
         <script src="pages/page-router.js"></script>
index 7be421456cadd5fb30f3c3c2c570ce2ebda17271..2c4b49f2ffd4799464b283011f6fa6243bf38cfd 100644 (file)
@@ -12,6 +12,8 @@ class CommitLog extends DataModelObject {
         if (this._remoteId)
             this.ensureNamedStaticMap('remoteId')[this._remoteId] = this;
         this._ownedCommits = null;
+        this._ownerCommit = null;
+        this._ownedCommitByOwnedRepository = new Map;
     }
 
     updateSingleton(rawData)
@@ -36,13 +38,17 @@ class CommitLog extends DataModelObject {
     message() { return this._rawData['message']; }
     url() { return this._repository.urlForRevision(this._rawData['revision']); }
     ownsCommits() { return this._rawData['ownsCommits']; }
+    ownedCommits() { return this._ownedCommits; }
+    ownerCommit() { return this._ownerCommit; }
+
+    setOwnerCommits(ownerCommit) { this._ownerCommit = ownerCommit; }
 
     label()
     {
-        var revision = this.revision();
+        const revision = this.revision();
         if (parseInt(revision) == revision) // e.g. r12345
             return 'r' + revision;
-        else if (revision.length == 40) // e.g. git hash
+        if (revision.length == 40) // e.g. git hash
             return revision.substring(0, 8);
         return revision;
     }
@@ -59,12 +65,10 @@ class CommitLog extends DataModelObject {
 
         const to = this.revision();
         const from = previousCommit.revision();
-        let fromRevisionForURL = from;
         let label = null;
-        if (parseInt(from) == from) { // e.g. r12345.
-            fromRevisionForURL = (parseInt(from) + 1).toString;
+        if (parseInt(from) == from)// e.g. r12345.
             label = `r${from}-r${this.revision()}`;
-        else if (to.length == 40) // e.g. git hash
+        else if (to.length == 40) // e.g. git hash
             label = `${from.substring(0, 8)}..${to.substring(0, 8)}`;
         else
             label = `${from} - ${to}`;
@@ -86,6 +90,8 @@ class CommitLog extends DataModelObject {
         });
     }
 
+    ownedCommitForOwnedRepository(ownedRepository) { return this._ownedCommitByOwnedRepository.get(ownedRepository); }
+
     fetchOwnedCommits()
     {
         if (!this.repository().ownedRepositories())
@@ -99,6 +105,10 @@ class CommitLog extends DataModelObject {
 
         return CommitLog.cachedFetch(`../api/commits/${this.repository().id()}/owned-commits?owner-revision=${escape(this.revision())}`).then((data) => {
             this._ownedCommits = CommitLog._constructFromRawData(data);
+            this._ownedCommits.forEach((ownedCommit) => {
+                ownedCommit.setOwnerCommits(this);
+                this._ownedCommitByOwnedRepository.set(ownedCommit.repository(), ownedCommit);
+            });
             return this._ownedCommits;
         });
     }
@@ -111,25 +121,27 @@ class CommitLog extends DataModelObject {
         return ownedCommitMap;
     }
 
-    static diffOwnedCommits(previousCommit, currentCommit)
+    static ownedCommitDifferenceForOwnerCommits(...commits)
     {
-        console.assert(previousCommit);
-        console.assert(currentCommit);
-        console.assert(previousCommit._ownedCommits);
-        console.assert(currentCommit._ownedCommits);
-
-        const previousOwnedCommitMap = previousCommit._buildOwnedCommitMap();
-        const currentOwnedCommitMap = currentCommit._buildOwnedCommitMap();
-        const ownedCommitRepositories = new Set([...currentOwnedCommitMap.keys(), ...previousOwnedCommitMap.keys()]);
-        const difference = new Map;
+        console.assert(commits.length >= 2);
+
+        const ownedCommitRepositories = new Set;
+        const ownedCommitMapList = commits.map((commit) => {
+            console.assert(commit);
+            console.assert(commit._ownedCommits);
+            const ownedCommitMap = commit._buildOwnedCommitMap();
+            for (const repository of ownedCommitMap.keys())
+                ownedCommitRepositories.add(repository);
+            return ownedCommitMap;
+        });
 
+        const difference = new Map;
         ownedCommitRepositories.forEach((ownedCommitRepository) => {
-            const currentRevision = currentOwnedCommitMap.get(ownedCommitRepository);
-            const previousRevision = previousOwnedCommitMap.get(ownedCommitRepository);
-            if (currentRevision != previousRevision)
-                difference.set(ownedCommitRepository, [previousRevision, currentRevision]);
+            const ownedCommits = ownedCommitMapList.map((ownedCommitMap) => ownedCommitMap.get(ownedCommitRepository));
+            const uniqueOwnedCommits = new Set(ownedCommits);
+            if (uniqueOwnedCommits.size > 1)
+                difference.set(ownedCommitRepository, ownedCommits);
         });
-
         return difference;
     }
 
index b178ac80e523d40aff1ced2f2974460982656ef7..cf8a1cc4b833624e53f109b72c533f434656ffa7 100644 (file)
@@ -70,8 +70,8 @@ class CommitSet extends DataModelObject {
     commitForRepository(repository) { return this._repositoryToCommitMap.get(repository); }
     ownerCommitForRepository(repository) { return this._repositoryToCommitOwnerMap.get(repository); }
     topLevelRepositories() { return Repository.sortByNamePreferringOnesWithURL(this._repositories.filter((repository) => !this.ownerRevisionForRepository(repository))); }
-
     ownedRepositoriesForOwnerRepository(repository) { return this._ownerRepositoryToOwnedRepositoriesMap.get(repository); }
+    commitForRepository(repository) { return this._repositoryToCommitMap.get(repository); }
 
     revisionForRepository(repository)
     {
@@ -248,8 +248,104 @@ class CustomCommitSet {
     }
 }
 
+class IntermediateCommitSet {
+
+    constructor(commitSet)
+    {
+        console.assert(commitSet instanceof CommitSet);
+        this._commitByRepository = new Map;
+        this._ownerToOwnedRepositories = new Map;
+        this._fetchingPromiseByRepository = new Map;
+
+        for (const repository of commitSet.repositories())
+            this.setCommitForRepository(repository, commitSet.commitForRepository(repository), commitSet.ownerCommitForRepository(repository));
+    }
+
+    fetchCommitLogs()
+    {
+        const fetchingPromises = [];
+        for (const [repository, commit] of this._commitByRepository)
+            fetchingPromises.push(this._fetchCommitLogAndOwnedCommits(repository, commit.revision()));
+        return Promise.all(fetchingPromises);
+    }
+
+    _fetchCommitLogAndOwnedCommits(repository, revision)
+    {
+        return CommitLog.fetchForSingleRevision(repository, revision).then((commits) => {
+            console.assert(commits.length === 1);
+            const commit = commits[0];
+            if (!commit.ownsCommits())
+                return commit;
+            return commit.fetchOwnedCommits().then(() => commit);
+        });
+    }
+
+    updateRevisionForOwnerRepository(repository, revision)
+    {
+        const fetchingPromise = this._fetchCommitLogAndOwnedCommits(repository, revision);
+        this._fetchingPromiseByRepository.set(repository, fetchingPromise);
+        return fetchingPromise.then((commit) => {
+            const currentFetchingPromise = this._fetchingPromiseByRepository.get(repository);
+            if (currentFetchingPromise !== fetchingPromise)
+                return;
+            this._fetchingPromiseByRepository.set(repository, null);
+            this.setCommitForRepository(repository, commit);
+        });
+    }
+
+    setCommitForRepository(repository, commit, ownerCommit = null)
+    {
+        console.assert(repository instanceof Repository);
+        console.assert(commit instanceof CommitLog);
+        this._commitByRepository.set(repository, commit);
+        if (!ownerCommit)
+            ownerCommit = commit.ownerCommit();
+        if (ownerCommit) {
+            const ownerRepository = ownerCommit.repository();
+            if (!this._ownerToOwnedRepositories.has(ownerRepository))
+                this._ownerToOwnedRepositories.set(ownerRepository, new Set);
+            const repositorySet = this._ownerToOwnedRepositories.get(ownerRepository);
+            repositorySet.add(repository);
+        }
+    }
+
+    removeCommitForRepository(repository)
+    {
+        console.assert(repository instanceof Repository);
+        this._fetchingPromiseByRepository.set(repository, null);
+        const ownerCommit = this.ownerCommitForRepository(repository);
+        if (ownerCommit) {
+            const repositorySet = this._ownerToOwnedRepositories.get(ownerCommit.repository());
+            console.assert(repositorySet.has(repository));
+            repositorySet.delete(repository);
+        } else if (this._ownerToOwnedRepositories.has(repository)) {
+            const ownedRepositories = this._ownerToOwnedRepositories.get(repository);
+            for (const ownedRepository of ownedRepositories)
+                this._commitByRepository.delete(ownedRepository);
+            this._ownerToOwnedRepositories.delete(repository);
+        }
+        this._commitByRepository.delete(repository);
+    }
+
+    ownsCommitsForRepository(repository) { return this.commitForRepository(repository).ownsCommits(); }
+
+    repositories() { return Array.from(this._commitByRepository.keys()); }
+    highestLevelRepositories() { return Repository.sortByNamePreferringOnesWithURL(this.repositories().filter((repository) => !this.ownerCommitForRepository(repository))); }
+    commitForRepository(repository) { return this._commitByRepository.get(repository); }
+    ownedRepositoriesForOwnerRepository(repository) { return this._ownerToOwnedRepositories.get(repository); }
+
+    ownerCommitForRepository(repository)
+    {
+        const commit = this._commitByRepository.get(repository);
+        if (!commit)
+            return null;
+        return commit.ownerCommit();
+    }
+}
+
 if (typeof module != 'undefined') {
     module.exports.CommitSet = CommitSet;
     module.exports.MeasurementCommitSet = MeasurementCommitSet;
     module.exports.CustomCommitSet = CustomCommitSet;
+    module.exports.IntermediateCommitSet = IntermediateCommitSet;
 }
index aad8e1e402f72fc367718f246c087500b0330202..d0efe7c0d5204589d7b7df60d1bcf713db54aa6c 100644 (file)
@@ -16,7 +16,6 @@ importFromV3('models/build-request.js', 'BuildRequest');
 importFromV3('models/builder.js', 'Build');
 importFromV3('models/builder.js', 'Builder');
 importFromV3('models/commit-log.js', 'CommitLog');
-importFromV3('models/commit-set.js', 'CustomCommitSet')
 importFromV3('models/manifest.js', 'Manifest');
 importFromV3('models/measurement-adaptor.js', 'MeasurementAdaptor');
 importFromV3('models/measurement-cluster.js', 'MeasurementCluster');
@@ -27,6 +26,7 @@ importFromV3('models/repository.js', 'Repository');
 importFromV3('models/commit-set.js', 'MeasurementCommitSet');
 importFromV3('models/commit-set.js', 'CommitSet');
 importFromV3('models/commit-set.js', 'CustomCommitSet');
+importFromV3('models/commit-set.js', 'IntermediateCommitSet');
 importFromV3('models/test.js', 'Test');
 importFromV3('models/test-group.js', 'TestGroup');
 importFromV3('models/time-series.js', 'TimeSeries');
index 3b31e5c071d1cc05cc8a8c0d46bf3d5a367ef309..d9263b04817379e01fe84cc8b3e4e1c3000e7415 100644 (file)
@@ -191,7 +191,8 @@ describe('CommitLog', function () {
         });
 
         it('should return owned-commit for a valid commit revision', () => {
-            const fetchingPromise = ownerCommit().fetchOwnedCommits();
+            const commit = ownerCommit();
+            const fetchingPromise = commit.fetchOwnedCommits();
             const requests = MockRemoteAPI.requests;
             assert.equal(requests.length, 1);
             assert.equal(requests[0].url, '../api/commits/111/owned-commits?owner-revision=10.11.4%2015E65');
@@ -208,6 +209,7 @@ describe('CommitLog', function () {
                 assert.equal(ownedCommits[0].repository(), MockModels.ownedRepository);
                 assert.equal(ownedCommits[0].revision(), '6f8b0dbbda95a440503b88db1dd03dad3a7b07fb');
                 assert.equal(ownedCommits[0].id(), 233);
+                assert.equal(ownedCommits[0].ownerCommit(), commit);
             });
         });
 
@@ -233,6 +235,7 @@ describe('CommitLog', function () {
                 assert.equal(ownedCommits[0].repository(), MockModels.ownedRepository);
                 assert.equal(ownedCommits[0].revision(), '6f8b0dbbda95a440503b88db1dd03dad3a7b07fb');
                 assert.equal(ownedCommits[0].id(), 233);
+                assert.equal(ownedCommits[0].ownerCommit(), commit);
                 return commit.fetchOwnedCommits();
             }).then((ownedCommits) => {
                 assert.equal(requests.length, 1);
@@ -241,12 +244,12 @@ describe('CommitLog', function () {
         });
     });
 
-    describe('diffOwnedCommits', () => {
+    describe('ownedCommitDifferenceForOwnerCommits', () => {
         beforeEach(() => {
             MockRemoteAPI.reset();
         });
 
-        it('should return difference between 2 owned-commits', () => {
+        it('should return difference between owned-commits of 2 owner commits', () => {
             const oneCommit = ownerCommit();
             const otherCommit = otherOwnerCommit();
             const fetchingPromise = oneCommit.fetchOwnedCommits();
@@ -267,7 +270,17 @@ describe('CommitLog', function () {
                 time: +(new Date('2016-05-13T00:55:57.841344Z')),
             }]});
 
-            return fetchingPromise.then(() => {
+            return fetchingPromise.then((ownedCommits) => {
+                assert.equal(ownedCommits.length, 2);
+                assert.equal(ownedCommits[0].repository(), MockModels.ownedRepository);
+                assert.equal(ownedCommits[0].revision(), '6f8b0dbbda95a440503b88db1dd03dad3a7b07fb');
+                assert.equal(ownedCommits[0].id(), 233);
+                assert.equal(ownedCommits[0].ownerCommit(), oneCommit);
+                assert.equal(ownedCommits[1].repository(), MockModels.webkitGit);
+                assert.equal(ownedCommits[1].revision(), '04a6c72038f0b771a19248ca2549e1258617b5fc');
+                assert.equal(ownedCommits[1].id(), 299);
+                assert.equal(ownedCommits[1].ownerCommit(), oneCommit);
+
                 const otherFetchingPromise = otherCommit.fetchOwnedCommits();
                 assert.equal(requests.length, 2);
                 assert.equal(requests[1].url, '../api/commits/111/owned-commits?owner-revision=10.11.4%2015E66');
@@ -286,8 +299,17 @@ describe('CommitLog', function () {
                 }]});
 
                 return otherFetchingPromise;
-            }).then(() => {
-                const difference = CommitLog.diffOwnedCommits(oneCommit, otherCommit);
+            }).then((ownedCommits) => {
+                assert.equal(ownedCommits.length, 2);
+                assert.equal(ownedCommits[0].repository(), MockModels.ownedRepository);
+                assert.equal(ownedCommits[0].revision(), 'd5099e03b482abdd77f6c4dcb875afd05bda5ab8');
+                assert.equal(ownedCommits[0].id(), 234);
+                assert.equal(ownedCommits[0].ownerCommit(), otherCommit);
+                assert.equal(ownedCommits[1].repository(), MockModels.webkitGit);
+                assert.equal(ownedCommits[1].revision(), '04a6c72038f0b771a19248ca2549e1258617b5fc');
+                assert.equal(ownedCommits[1].id(), 299);
+                assert.equal(ownedCommits[1].ownerCommit(), otherCommit);
+                const difference = CommitLog.ownedCommitDifferenceForOwnerCommits(oneCommit, otherCommit);
                 assert.equal(difference.size, 1);
                 assert.equal(difference.keys().next().value, MockModels.ownedRepository);
             });
index ab76d589cebd20057b3a79c688a56a3586f008f7..ebb7bcdacd5a681e58b7b11f58a09e1a99b4375e 100644 (file)
@@ -3,6 +3,7 @@
 const assert = require('assert');
 require('../tools/js/v3-models.js');
 const MockModels = require('./resources/mock-v3-models.js').MockModels;
+const MockRemoteAPI = require('../unit-tests/resources/mock-remote-api.js').MockRemoteAPI;
 
 function createPatch()
 {
@@ -51,6 +52,272 @@ function customCommitSetWithOwnedRepositoryHasSameNameAsNotOwnedRepository()
     return customCommitSet;
 }
 
+function ownerCommit()
+{
+    return new CommitLog(5, {
+        repository: MockModels.ownerRepository,
+        revision: 'owner-commit-0',
+        ownsCommits: true,
+        time: null,
+    });
+}
+
+function partialOwnerCommit()
+{
+    return new CommitLog(5, {
+        repository: MockModels.ownerRepository,
+        revision: 'owner-commit-0',
+        ownsCommits: null,
+        time: +(new Date('2016-05-13T00:55:57.841344Z')),
+    });
+}
+
+function ownedCommit()
+{
+    return new CommitLog(6, {
+        repository: MockModels.ownedRepository,
+        revision: 'owned-commit-0',
+        ownsCommits: null,
+        time: 1456932774000
+    });
+}
+
+function webkitCommit()
+{
+    return new CommitLog(2017, {
+        repository: MockModels.webkit,
+        revision: 'webkit-commit-0',
+        ownsCommits: false,
+        time: 1456932773000
+    });
+}
+
+describe('IntermediateCommitSet', () => {
+    MockRemoteAPI.inject();
+    MockModels.inject();
+
+    describe('setCommitForRepository', () => {
+        it('should allow set commit for owner repository', () => {
+            const commitSet = new IntermediateCommitSet(new CommitSet);
+            const commit = ownerCommit();
+            commitSet.setCommitForRepository(MockModels.ownerRepository, commit);
+            assert.equal(commit, commitSet.commitForRepository(MockModels.ownerRepository));
+        });
+
+        it('should allow set commit for owned repository', () => {
+            const commitSet = new IntermediateCommitSet(new CommitSet);
+            const commit = ownerCommit();
+
+            const fetchingPromise = commit.fetchOwnedCommits();
+            const requests = MockRemoteAPI.requests;
+            assert.equal(requests.length, 1);
+            assert.equal(requests[0].url, '../api/commits/111/owned-commits?owner-revision=owner-commit-0');
+            assert.equal(requests[0].method, 'GET');
+
+            requests[0].resolve({commits: [{
+                id: 233,
+                repository: MockModels.ownedRepository.id(),
+                revision: '6f8b0dbbda95a440503b88db1dd03dad3a7b07fb',
+                time: +(new Date('2016-05-13T00:55:57.841344Z')),
+            }]});
+
+            return fetchingPromise.then(() => {
+                const ownedCommit = commit.ownedCommits()[0];
+                commitSet.setCommitForRepository(MockModels.ownerRepository, commit);
+                commitSet.setCommitForRepository(MockModels.ownedRepository, ownedCommit);
+                assert.equal(commit, commitSet.commitForRepository(MockModels.ownerRepository));
+                assert.equal(ownedCommit, commitSet.commitForRepository(MockModels.ownedRepository));
+                assert.deepEqual(commitSet.repositories(), [MockModels.ownerRepository, MockModels.ownedRepository]);
+            });
+        });
+    });
+
+    describe('fetchCommitLogs', () => {
+
+        it('should fetch CommitLog object with owned commits information',  () => {
+            const commit = partialOwnerCommit();
+            assert.equal(commit.ownsCommits(), null);
+            const owned = ownedCommit();
+
+            const commitSet = CommitSet.ensureSingleton('53246456', {revisionItems: [{commit}, {commit: owned, ownerCommit: commit}]});
+            const intermediateCommitSet =new IntermediateCommitSet(commitSet);
+            const fetchingPromise = intermediateCommitSet.fetchCommitLogs();
+
+            const requests = MockRemoteAPI.requests;
+            assert.equal(requests.length, 2);
+            assert.equal(requests[0].url, '/api/commits/111/owner-commit-0');
+            assert.equal(requests[0].method, 'GET');
+            assert.equal(requests[1].url, '/api/commits/112/owned-commit-0');
+            assert.equal(requests[1].method, 'GET');
+
+            requests[0].resolve({commits: [{
+                id: 5,
+                repository: MockModels.ownerRepository,
+                revision: 'owner-commit-0',
+                ownsCommits: true,
+                time: +(new Date('2016-05-13T00:55:57.841344Z')),
+            }]});
+            requests[1].resolve({commits: [{
+                id: 6,
+                repository: MockModels.ownedRepository,
+                revision: 'owned-commit-0',
+                ownsCommits: false,
+                time: 1456932774000,
+            }]});
+
+            return MockRemoteAPI.waitForRequest().then(() => {
+                assert.equal(requests.length, 3);
+                assert.equal(requests[2].url, '../api/commits/111/owned-commits?owner-revision=owner-commit-0');
+                assert.equal(requests[2].method, 'GET');
+
+                requests[2].resolve({commits: [{
+                    id: 6,
+                    repository: MockModels.ownedRepository.id(),
+                    revision: 'owned-commit-0',
+                    ownsCommits: false,
+                    time: 1456932774000,
+                }]});
+                return fetchingPromise;
+            }).then(() => {
+                assert(commit.ownsCommits());
+                assert.equal(commit.ownedCommits().length, 1);
+                assert.equal(commit.ownedCommits()[0], owned);
+                assert.equal(owned.ownerCommit(), commit);
+                assert.equal(owned.repository(), MockModels.ownedRepository);
+                assert.equal(intermediateCommitSet.commitForRepository(MockModels.ownedRepository), owned);
+                assert.equal(intermediateCommitSet.ownerCommitForRepository(MockModels.ownedRepository), commit);
+                assert.deepEqual(intermediateCommitSet.repositories(), [MockModels.ownerRepository, MockModels.ownedRepository]);
+            });
+        });
+    });
+
+    describe('updateRevisionForOwnerRepository', () => {
+
+        it('should update CommitSet based on the latest invocation', () => {
+            const commitSet = new IntermediateCommitSet(new CommitSet);
+            const firstUpdatePromise = commitSet.updateRevisionForOwnerRepository(MockModels.webkit, 'webkit-commit-0');
+            const secondUpdatePromise = commitSet.updateRevisionForOwnerRepository(MockModels.webkit, 'webkit-commit-1');
+            const requests = MockRemoteAPI.requests;
+
+            assert(requests.length, 2);
+            assert.equal(requests[0].url, '/api/commits/11/webkit-commit-0');
+            assert.equal(requests[0].method, 'GET');
+            assert.equal(requests[1].url, '/api/commits/11/webkit-commit-1');
+            assert.equal(requests[1].method, 'GET');
+
+            requests[1].resolve({commits: [{
+                id: 2018,
+                repository: MockModels.webkit.id(),
+                revision: 'webkit-commit-1',
+                ownsCommits: false,
+                time: 1456932774000,
+            }]});
+
+            let commit = null;
+            return secondUpdatePromise.then(() => {
+                commit = commitSet.commitForRepository(MockModels.webkit);
+
+                requests[0].resolve({commits: [{
+                    id: 2017,
+                    repository: MockModels.webkit.id(),
+                    revision: 'webkit-commit-0',
+                    ownsCommits: false,
+                    time: 1456932773000,
+                }]});
+
+                assert.equal(commit.revision(), 'webkit-commit-1');
+                assert.equal(commit.id(), 2018);
+
+                return firstUpdatePromise;
+            }).then(() => {
+                const currentCommit = commitSet.commitForRepository(MockModels.webkit);
+                assert.equal(commit, currentCommit);
+            });
+        });
+
+    });
+
+    describe('removeCommitForRepository', () => {
+        it('should remove owned commits when owner commit is removed', () => {
+            const commitSet = new IntermediateCommitSet(new CommitSet);
+            const commit = ownerCommit();
+
+            const fetchingPromise = commit.fetchOwnedCommits();
+            const requests = MockRemoteAPI.requests;
+            assert.equal(requests.length, 1);
+            assert.equal(requests[0].url, '../api/commits/111/owned-commits?owner-revision=owner-commit-0');
+            assert.equal(requests[0].method, 'GET');
+
+            requests[0].resolve({commits: [{
+                id: 233,
+                repository: MockModels.ownedRepository.id(),
+                revision: '6f8b0dbbda95a440503b88db1dd03dad3a7b07fb',
+                ownsCommits: true
+            }]});
+
+            return fetchingPromise.then(() => {
+                commitSet.setCommitForRepository(MockModels.ownerRepository, commit);
+                commitSet.setCommitForRepository(MockModels.ownedRepository, commit.ownedCommits()[0]);
+                commitSet.removeCommitForRepository(MockModels.ownerRepository);
+                assert.deepEqual(commitSet.repositories(), []);
+            });
+        });
+
+        it('should not remove owner commits when owned commit is removed', () => {
+            const commitSet = new IntermediateCommitSet(new CommitSet);
+            const commit = ownerCommit();
+
+            const fetchingPromise = commit.fetchOwnedCommits();
+            const requests = MockRemoteAPI.requests;
+            assert.equal(requests.length, 1);
+            assert.equal(requests[0].url, '../api/commits/111/owned-commits?owner-revision=owner-commit-0');
+            assert.equal(requests[0].method, 'GET');
+
+            requests[0].resolve({commits: [{
+                id: 233,
+                repository: MockModels.ownedRepository.id(),
+                revision: '6f8b0dbbda95a440503b88db1dd03dad3a7b07fb',
+                time: +(new Date('2016-05-13T00:55:57.841344Z')),
+            }]});
+
+            return fetchingPromise.then(() => {
+                commitSet.setCommitForRepository(MockModels.ownerRepository, commit);
+                commitSet.setCommitForRepository(MockModels.ownedRepository, commit.ownedCommits()[0]);
+                commitSet.removeCommitForRepository(MockModels.ownedRepository);
+                assert.deepEqual(commitSet.repositories(), [MockModels.ownerRepository]);
+            });
+        });
+
+        it('should not update commit set for repository if removeCommitForRepository called before updateRevisionForOwnerRepository finishes', () => {
+            const commitSet = new IntermediateCommitSet(new CommitSet);
+            const commit = webkitCommit();
+            commitSet.setCommitForRepository(MockModels.webkit, commit);
+            const updatePromise = commitSet.updateRevisionForOwnerRepository(MockModels.webkit, 'webkit-commit-1');
+
+            commitSet.removeCommitForRepository(MockModels.webkit);
+
+            const requests = MockRemoteAPI.requests;
+            assert.equal(requests[0].url, '/api/commits/11/webkit-commit-1');
+            assert.equal(requests[0].method, 'GET');
+
+            requests[0].resolve({commits: [{
+                id: 2018,
+                repository: MockModels.webkit.id(),
+                revision: 'webkit-commit-1',
+                ownsCommits: false,
+                time: 1456932774000,
+            }]});
+
+            return updatePromise.then(() => {
+                assert.deepEqual(commitSet.repositories(), []);
+                assert(!commitSet.commitForRepository(MockModels.webkit));
+            });
+        });
+
+    });
+
+});
+
 describe('CustomCommitSet', () => {
     MockModels.inject();