Refactor directory structure of components

There is no change in functionality. Only moving things around.

+ Separate html from the js.
+ Place the unit test for a component within the same folder.
+ Organize the components in subfolders.

Change-Id: I51fdc510db75fc1b33f040ca63decbbdfd4d5513
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
new file mode 100644
index 0000000..0dc18ae
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
@@ -0,0 +1,174 @@
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../bower_components/iron-dropdown/iron-dropdown.html">
+<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior.html">
+<link rel="import" href="../../../behaviors/rest-client-behavior.html">
+<link rel="import" href="../../shared/gr-ajax/gr-ajax.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-request/gr-request.html">
+<link rel="import" href="../gr-diff/gr-diff.html">
+
+<dom-module id="gr-diff-view">
+  <template>
+    <style>
+      :host {
+        background-color: var(--view-background-color);
+        display: block;
+      }
+      h3 {
+        margin-top: 1em;
+        padding: .75em var(--default-horizontal-margin);
+      }
+      .reviewed {
+        display: inline-block;
+        margin: 0 .25em;
+        vertical-align: .15em;
+      }
+      .jumpToFileContainer {
+        display: inline-block;
+      }
+      .mobileJumpToFileContainer {
+        display: none;
+      }
+      .downArrow {
+        display: inline-block;
+        font-size: .6em;
+        vertical-align: middle;
+      }
+      .dropdown-trigger {
+        color: #00e;
+        cursor: pointer;
+        padding: 0;
+      }
+      .dropdown-content {
+        background-color: #fff;
+        box-shadow: 0 1px 5px rgba(0, 0, 0, .3);
+      }
+      .dropdown-content a {
+        cursor: pointer;
+        display: block;
+        font-weight: normal;
+        padding: .3em .5em;
+      }
+      .dropdown-content a:before {
+        color: #ccc;
+        content: attr(data-key-nav);
+        display: inline-block;
+        margin-right: .5em;
+        width: .3em;
+      }
+      .dropdown-content a:hover {
+        background-color: #00e;
+        color: #fff;
+      }
+      .dropdown-content a[selected] {
+        color: #000;
+        font-weight: bold;
+        pointer-events: none;
+        text-decoration: none;
+      }
+      .dropdown-content a[selected]:hover {
+        background-color: #fff;
+        color: #000;
+      }
+      gr-button {
+        font: inherit;
+        padding: .3em 0;
+        text-decoration: none;
+      }
+      @media screen and (max-width: 50em) {
+        .dash {
+          display: none;
+        }
+        .reviewed {
+          vertical-align: -.1em;
+        }
+        .jumpToFileContainer {
+          display: none;
+        }
+        .mobileJumpToFileContainer {
+          display: block;
+          width: 100%;
+        }
+      }
+    </style>
+    <gr-ajax id="changeDetailXHR"
+        auto
+        url="[[_computeChangeDetailPath(_changeNum)]]"
+        params="[[_computeChangeDetailQueryParams()]]"
+        last-response="{{_change}}"></gr-ajax>
+    <gr-ajax id="filesXHR"
+        auto
+        url="[[_computeFilesPath(_changeNum, _patchRange.patchNum)]]"
+        on-response="_handleFilesResponse"></gr-ajax>
+    <gr-ajax id="configXHR"
+        auto
+        url="[[_computeProjectConfigPath(_change.project)]]"
+        last-response="{{_projectConfig}}"></gr-ajax>
+    <h3>
+      <a href$="[[_computeChangePath(_changeNum, _patchRange.patchNum, _change.revisions)]]">
+        [[_changeNum]]</a><span>:</span>
+      <span>[[_change.subject]]</span>
+      <span class="dash">—</span>
+      <input id="reviewed"
+          class="reviewed"
+          type="checkbox"
+          on-change="_handleReviewedChange"
+          hidden$="[[!_loggedIn]]" hidden>
+      <div class="jumpToFileContainer">
+        <gr-button link class="dropdown-trigger" id="trigger" on-tap="_showDropdownTapHandler">
+          <span>[[_computeFileDisplayName(_path)]]</span>
+          <span class="downArrow">&#9660;</span>
+        </gr-button>
+        <iron-dropdown id="dropdown" vertical-align="top" vertical-offset="25">
+          <div class="dropdown-content">
+            <template is="dom-repeat" items="[[_fileList]]" as="path">
+              <a href$="[[_computeDiffURL(_changeNum, _patchRange, path)]]"
+                 selected$="[[_computeFileSelected(path, _path)]]"
+                 data-key-nav$="[[_computeKeyNav(path, _path, _fileList)]]"
+                 on-tap="_handleFileTap">
+                 [[_computeFileDisplayName(path)]]
+              </a>
+            </template>
+          </div>
+        </iron-dropdown>
+      </div>
+      <div class="mobileJumpToFileContainer">
+        <select on-change="_handleMobileSelectChange">
+          <template is="dom-repeat" items="[[_fileList]]" as="path">
+            <option
+                value$="[[path]]"
+                selected$="[[_computeFileSelected(path, _path)]]">
+              [[_computeFileDisplayName(path)]]
+            </option>
+          </template>
+        </select>
+      </div>
+    </h3>
+    <gr-diff id="diff"
+        change-num="[[_changeNum]]"
+        prefs="{{prefs}}"
+        patch-range="[[_patchRange]]"
+        path="[[_path]]"
+        project-config="[[_projectConfig]]"
+        available-patches="[[_computeAvailablePatches(_change.revisions)]]"
+        on-render="_handleDiffRender">
+    </gr-diff>
+  </template>
+  <script src="gr-diff-view.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
new file mode 100644
index 0000000..847a641
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
@@ -0,0 +1,315 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  var COMMIT_MESSAGE_PATH = '/COMMIT_MSG';
+
+  Polymer({
+    is: 'gr-diff-view',
+
+    /**
+     * Fired when the title of the page should change.
+     *
+     * @event title-change
+     */
+
+    properties: {
+      prefs: {
+        type: Object,
+        notify: true,
+      },
+      /**
+       * URL params passed from the router.
+       */
+      params: {
+        type: Object,
+        observer: '_paramsChanged',
+      },
+      keyEventTarget: {
+        type: Object,
+        value: function() { return document.body; },
+      },
+      changeViewState: {
+        type: Object,
+        notify: true,
+        value: function() { return {}; },
+      },
+
+      _patchRange: Object,
+      _change: Object,
+      _changeNum: String,
+      _diff: Object,
+      _fileList: {
+        type: Array,
+        value: function() { return []; },
+      },
+      _path: {
+        type: String,
+        observer: '_pathChanged',
+      },
+      _loggedIn: {
+        type: Boolean,
+        value: false,
+      },
+      _xhrPromise: Object,  // Used for testing.
+    },
+
+    behaviors: [
+      Gerrit.KeyboardShortcutBehavior,
+      Gerrit.RESTClientBehavior,
+    ],
+
+    ready: function() {
+      app.accountReady.then(function() {
+        this._loggedIn = app.loggedIn;
+        if (this._loggedIn) {
+          this._setReviewed(true);
+        }
+      }.bind(this));
+    },
+
+    attached: function() {
+      if (this._path) {
+        this.fire('title-change',
+            {title: this._computeFileDisplayName(this._path)});
+      }
+      window.addEventListener('resize', this._boundWindowResizeHandler);
+    },
+
+    detached: function() {
+      window.removeEventListener('resize', this._boundWindowResizeHandler);
+    },
+
+    _handleReviewedChange: function(e) {
+      this._setReviewed(Polymer.dom(e).rootTarget.checked);
+    },
+
+    _setReviewed: function(reviewed) {
+      this.$.reviewed.checked = reviewed;
+      var method = reviewed ? 'PUT' : 'DELETE';
+      var url = this.changeBaseURL(this._changeNum,
+          this._patchRange.patchNum) + '/files/' +
+          encodeURIComponent(this._path) + '/reviewed';
+      this._send(method, url).catch(function(err) {
+        alert('Couldn’t change file review status. Check the console ' +
+            'and contact the PolyGerrit team for assistance.');
+        throw err;
+      }.bind(this));
+    },
+
+    _handleKey: function(e) {
+      if (this.shouldSupressKeyboardShortcut(e)) { return; }
+
+      switch (e.keyCode) {
+        case 219:  // '['
+          e.preventDefault();
+          this._navToFile(this._fileList, -1);
+          break;
+        case 221:  // ']'
+          e.preventDefault();
+          this._navToFile(this._fileList, 1);
+          break;
+        case 78:  // 'n'
+          if (e.shiftKey) {
+            this.$.diff.scrollToNextCommentThread();
+          } else {
+            this.$.diff.scrollToNextDiffChunk();
+          }
+          break;
+        case 80:  // 'p'
+          if (e.shiftKey) {
+            this.$.diff.scrollToPreviousCommentThread();
+          } else {
+            this.$.diff.scrollToPreviousDiffChunk();
+          }
+          break;
+        case 65:  // 'a'
+          if (!this._loggedIn) { return; }
+
+          this.set('changeViewState.showReplyDialog', true);
+          /* falls through */ // required by JSHint
+        case 85:  // 'u'
+          if (this._changeNum && this._patchRange.patchNum) {
+            e.preventDefault();
+            page.show(this._computeChangePath(
+                this._changeNum,
+                this._patchRange.patchNum,
+                this._change && this._change.revisions));
+          }
+          break;
+        case 188:  // ','
+          this.$.diff.showDiffPreferences();
+          break;
+      }
+    },
+
+    _handleDiffRender: function() {
+      if (window.location.hash.length > 0) {
+        this.$.diff.scrollToLine(
+            parseInt(window.location.hash.substring(1), 10));
+      }
+    },
+
+    _navToFile: function(fileList, direction) {
+      if (fileList.length == 0) { return; }
+
+      var idx = fileList.indexOf(this._path) + direction;
+      if (idx < 0 || idx > fileList.length - 1) {
+        page.show(this._computeChangePath(
+            this._changeNum,
+            this._patchRange.patchNum,
+            this._change && this._change.revisions));
+        return;
+      }
+      page.show(this._computeDiffURL(this._changeNum,
+                                     this._patchRange,
+                                     fileList[idx]));
+    },
+
+    _paramsChanged: function(value) {
+      if (value.view != this.tagName.toLowerCase()) { return; }
+
+      this._changeNum = value.changeNum;
+      this._patchRange = {
+        patchNum: value.patchNum,
+        basePatchNum: value.basePatchNum || 'PARENT',
+      };
+      this._path = value.path;
+
+      this.fire('title-change',
+          {title: this._computeFileDisplayName(this._path)});
+
+      // When navigating away from the page, there is a possibility that the
+      // patch number is no longer a part of the URL (say when navigating to
+      // the top-level change info view) and therefore undefined in `params`.
+      if (!this._patchRange.patchNum) {
+        return;
+      }
+
+      this.$.diff.reload();
+    },
+
+    _pathChanged: function(path) {
+      if (this._fileList.length == 0) { return; }
+
+      this.set('changeViewState.selectedFileIndex',
+          this._fileList.indexOf(path));
+
+      if (this._loggedIn) {
+        this._setReviewed(true);
+      }
+    },
+
+    _computeDiffURL: function(changeNum, patchRange, path) {
+      var patchStr = patchRange.patchNum;
+      if (patchRange.basePatchNum != null &&
+          patchRange.basePatchNum != 'PARENT') {
+        patchStr = patchRange.basePatchNum + '..' + patchRange.patchNum;
+      }
+      return '/c/' + changeNum + '/' + patchStr + '/' + path;
+    },
+
+    _computeAvailablePatches: function(revisions) {
+      var patchNums = [];
+      for (var rev in revisions) {
+        patchNums.push(revisions[rev]._number);
+      }
+      return patchNums.sort(function(a, b) { return a - b; });
+    },
+
+    _computeChangePath: function(changeNum, patchNum, revisions) {
+      var base = '/c/' + changeNum + '/';
+
+      // The change may not have loaded yet, making revisions unavailable.
+      if (!revisions) {
+        return base + patchNum;
+      }
+
+      var latestPatchNum = -1;
+      for (var rev in revisions) {
+        latestPatchNum = Math.max(latestPatchNum, revisions[rev]._number);
+      }
+      if (parseInt(patchNum, 10) != latestPatchNum) {
+        return base + patchNum;
+      }
+
+      return base;
+    },
+
+    _computeFileDisplayName: function(path) {
+      return path == COMMIT_MESSAGE_PATH ? 'Commit message' : path;
+    },
+
+    _computeChangeDetailPath: function(changeNum) {
+      return '/changes/' + changeNum + '/detail';
+    },
+
+    _computeChangeDetailQueryParams: function() {
+      return {O: this.listChangesOptionsToHex(
+          this.ListChangesOption.ALL_REVISIONS
+      )};
+    },
+
+    _computeFilesPath: function(changeNum, patchNum) {
+      return this.changeBaseURL(changeNum, patchNum) + '/files';
+    },
+
+    _computeProjectConfigPath: function(project) {
+      return '/projects/' + encodeURIComponent(project) + '/config';
+    },
+
+    _computeFileSelected: function(path, currentPath) {
+      return path == currentPath;
+    },
+
+    _computeKeyNav: function(path, selectedPath, fileList) {
+      var selectedIndex = fileList.indexOf(selectedPath);
+      if (fileList.indexOf(path) == selectedIndex - 1) {
+        return '[';
+      }
+      if (fileList.indexOf(path) == selectedIndex + 1) {
+        return ']';
+      }
+      return '';
+    },
+
+    _handleFileTap: function(e) {
+      this.$.dropdown.close();
+    },
+
+    _handleMobileSelectChange: function(e) {
+      var path = Polymer.dom(e).rootTarget.value;
+      page.show(
+          this._computeDiffURL(this._changeNum, this._patchRange, path));
+    },
+
+    _handleFilesResponse: function(e, req) {
+      this._fileList = Object.keys(e.detail.response).sort();
+    },
+
+    _showDropdownTapHandler: function(e) {
+      this.$.dropdown.open();
+    },
+
+    _send: function(method, url) {
+      var xhr = document.createElement('gr-request');
+      this._xhrPromise = xhr.send({
+        method: method,
+        url: url,
+      });
+      return this._xhrPromise;
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
new file mode 100644
index 0000000..bfe4906
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
@@ -0,0 +1,395 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-diff-view</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="../../../bower_components/page/page.js"></script>
+<script src="../../../scripts/fake-app.js"></script>
+<script src="../../../scripts/util.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-diff-view.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-diff-view></gr-diff-view>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-diff-view tests', function() {
+    var element;
+    var server;
+
+    setup(function() {
+      element = fixture('basic');
+      element.$.changeDetailXHR.auto = false;
+      element.$.filesXHR.auto = false;
+      element.$.configXHR.auto = false;
+      element.$.diff.auto = false;
+
+      server = sinon.fakeServer.create();
+      server.respondWith(
+        'PUT',
+        '/changes/42/revisions/2/files/%2FCOMMIT_MSG/reviewed',
+        [
+          201,
+          {'Content-Type': 'application/json'},
+          ')]}\'\n' +
+          '""',
+        ]
+      );
+      server.respondWith(
+        'DELETE',
+        '/changes/42/revisions/2/files/%2FCOMMIT_MSG/reviewed',
+        [
+          204,
+          {'Content-Type': 'application/json'},
+          '',
+        ]
+      );
+    });
+
+    teardown(function() {
+      server.restore();
+    });
+
+    test('keyboard shortcuts', function() {
+      element._changeNum = '42';
+      element._patchRange = {
+        patchNum: '10',
+      };
+      element._change = {
+        revisions: {
+          a: { _number: 10, },
+        },
+      };
+      element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
+      element._path = 'glados.txt';
+      element.changeViewState.selectedFileIndex = 1;
+
+      var showStub = sinon.stub(page, 'show');
+      MockInteractions.pressAndReleaseKeyOn(element, 85);  // 'u'
+      assert(showStub.lastCall.calledWithExactly('/c/42/'),
+          'Should navigate to /c/42/');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 221);  // ']'
+      assert(showStub.lastCall.calledWithExactly('/c/42/10/wheatley.md'),
+          'Should navigate to /c/42/10/wheatley.md');
+      element._path = 'wheatley.md';
+      assert.equal(element.changeViewState.selectedFileIndex, 2);
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
+      assert(showStub.lastCall.calledWithExactly('/c/42/10/glados.txt'),
+          'Should navigate to /c/42/10/glados.txt');
+      element._path = 'glados.txt';
+      assert.equal(element.changeViewState.selectedFileIndex, 1);
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
+      assert(showStub.lastCall.calledWithExactly('/c/42/10/chell.go'),
+          'Should navigate to /c/42/10/chell.go');
+      element._path = 'chell.go';
+      assert.equal(element.changeViewState.selectedFileIndex, 0);
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
+      assert(showStub.lastCall.calledWithExactly('/c/42/'),
+          'Should navigate to /c/42/');
+      assert.equal(element.changeViewState.selectedFileIndex, 0);
+
+      var showPrefsStub = sinon.stub(element.$.diff, 'showDiffPreferences');
+      MockInteractions.pressAndReleaseKeyOn(element, 188);  // ','
+      assert(showPrefsStub.calledOnce);
+
+      var scrollStub = sinon.stub(element.$.diff, 'scrollToNextDiffChunk');
+      MockInteractions.pressAndReleaseKeyOn(element, 78);  // 'n'
+      assert(scrollStub.calledOnce);
+      scrollStub.restore();
+
+      scrollStub = sinon.stub(element.$.diff, 'scrollToPreviousDiffChunk');
+      MockInteractions.pressAndReleaseKeyOn(element, 80);  // 'p'
+      assert(scrollStub.calledOnce);
+      scrollStub.restore();
+
+      scrollStub = sinon.stub(element.$.diff, 'scrollToNextCommentThread');
+      MockInteractions.pressAndReleaseKeyOn(element, 78, ['shift']);  // 'N'
+      assert(scrollStub.calledOnce);
+      scrollStub.restore();
+
+      scrollStub = sinon.stub(element.$.diff, 'scrollToPreviousCommentThread');
+      MockInteractions.pressAndReleaseKeyOn(element, 80, ['shift']);  // 'P'
+      assert(scrollStub.calledOnce);
+      scrollStub.restore();
+
+      showPrefsStub.restore();
+      showStub.restore();
+    });
+
+    test('keyboard shortcuts with patch range', function() {
+      element._changeNum = '42';
+      element._patchRange = {
+        basePatchNum: '5',
+        patchNum: '10',
+      };
+      element._change = {
+        revisions: {
+          a: { _number: 10, },
+        },
+      };
+      element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
+      element._path = 'glados.txt';
+
+      var showStub = sinon.stub(page, 'show');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 65);  // 'a'
+      assert.isTrue(showStub.notCalled, 'The `a` keyboard shortcut should ' +
+          'only work when the user is logged in.');
+      assert.isNull(window.sessionStorage.getItem(
+          'changeView.showReplyDialog'));
+
+      element._loggedIn = true;
+      MockInteractions.pressAndReleaseKeyOn(element, 65);  // 'a'
+      assert.isTrue(element.changeViewState.showReplyDialog);
+
+      assert(showStub.lastCall.calledWithExactly('/c/42/'),
+          'Should navigate to /c/42/');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 85);  // 'u'
+      assert(showStub.lastCall.calledWithExactly('/c/42/'),
+          'Should navigate to /c/42/');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 221);  // ']'
+      assert(showStub.lastCall.calledWithExactly('/c/42/5..10/wheatley.md'),
+          'Should navigate to /c/42/5..10/wheatley.md');
+      element._path = 'wheatley.md';
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
+      assert(showStub.lastCall.calledWithExactly('/c/42/5..10/glados.txt'),
+          'Should navigate to /c/42/5..10/glados.txt');
+      element._path = 'glados.txt';
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
+      assert(showStub.lastCall.calledWithExactly('/c/42/5..10/chell.go'),
+          'Should navigate to /c/42/5..10/chell.go');
+      element._path = 'chell.go';
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
+      assert(showStub.lastCall.calledWithExactly('/c/42/'),
+          'Should navigate to /c/42/');
+
+      showStub.restore();
+    });
+
+    test('keyboard shortcuts with old patch number', function() {
+      element._changeNum = '42';
+      element._patchRange = {
+        patchNum: '1',
+      };
+      element._change = {
+        revisions: {
+          a: { _number: 1, },
+          b: { _number: 2, },
+        },
+      };
+      element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
+      element._path = 'glados.txt';
+
+      var showStub = sinon.stub(page, 'show');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 65);  // 'a'
+      assert.isTrue(showStub.notCalled, 'The `a` keyboard shortcut should ' +
+          'only work when the user is logged in.');
+      assert.isNull(window.sessionStorage.getItem(
+          'changeView.showReplyDialog'));
+
+      element._loggedIn = true;
+      MockInteractions.pressAndReleaseKeyOn(element, 65);  // 'a'
+      assert.isTrue(element.changeViewState.showReplyDialog);
+
+      assert(showStub.lastCall.calledWithExactly('/c/42/1'),
+          'Should navigate to /c/42/1');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 85);  // 'u'
+      assert(showStub.lastCall.calledWithExactly('/c/42/1'),
+          'Should navigate to /c/42/1');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 221);  // ']'
+      assert(showStub.lastCall.calledWithExactly('/c/42/1/wheatley.md'),
+          'Should navigate to /c/42/1/wheatley.md');
+      element._path = 'wheatley.md';
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
+      assert(showStub.lastCall.calledWithExactly('/c/42/1/glados.txt'),
+          'Should navigate to /c/42/1/glados.txt');
+      element._path = 'glados.txt';
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
+      assert(showStub.lastCall.calledWithExactly('/c/42/1/chell.go'),
+          'Should navigate to /c/42/1/chell.go');
+      element._path = 'chell.go';
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
+      assert(showStub.lastCall.calledWithExactly('/c/42/1'),
+          'Should navigate to /c/42/1');
+
+      showStub.restore();
+    });
+
+    test('go up to change via kb without change loaded', function() {
+      element._changeNum = '42';
+      element._patchRange = {
+        patchNum: '1',
+      };
+
+      element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
+      element._path = 'glados.txt';
+
+      var showStub = sinon.stub(page, 'show');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 65);  // 'a'
+      assert.isTrue(showStub.notCalled, 'The `a` keyboard shortcut should ' +
+          'only work when the user is logged in.');
+      assert.isNull(window.sessionStorage.getItem(
+          'changeView.showReplyDialog'));
+
+      element._loggedIn = true;
+      MockInteractions.pressAndReleaseKeyOn(element, 65);  // 'a'
+      assert.isTrue(element.changeViewState.showReplyDialog);
+
+      assert(showStub.lastCall.calledWithExactly('/c/42/1'),
+          'Should navigate to /c/42/1');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 85);  // 'u'
+      assert(showStub.lastCall.calledWithExactly('/c/42/1'),
+          'Should navigate to /c/42/1');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 221);  // ']'
+      assert(showStub.lastCall.calledWithExactly('/c/42/1/wheatley.md'),
+          'Should navigate to /c/42/1/wheatley.md');
+      element._path = 'wheatley.md';
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
+      assert(showStub.lastCall.calledWithExactly('/c/42/1/glados.txt'),
+          'Should navigate to /c/42/1/glados.txt');
+      element._path = 'glados.txt';
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
+      assert(showStub.lastCall.calledWithExactly('/c/42/1/chell.go'),
+          'Should navigate to /c/42/1/chell.go');
+      element._path = 'chell.go';
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
+      assert(showStub.lastCall.calledWithExactly('/c/42/1'),
+          'Should navigate to /c/42/1');
+
+      showStub.restore();
+    });
+
+    test('jump to file dropdown', function() {
+      element._changeNum = '42';
+      element._patchRange = {
+        patchNum: '10',
+      };
+      element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
+      element._path = 'glados.txt';
+      flushAsynchronousOperations();
+      var linkEls =
+          Polymer.dom(element.root).querySelectorAll('.dropdown-content > a');
+      assert.equal(linkEls.length, 3);
+      assert.isFalse(linkEls[0].hasAttribute('selected'));
+      assert.isTrue(linkEls[1].hasAttribute('selected'));
+      assert.isFalse(linkEls[2].hasAttribute('selected'));
+      assert.equal(linkEls[0].getAttribute('data-key-nav'), '[');
+      assert.equal(linkEls[1].getAttribute('data-key-nav'), '');
+      assert.equal(linkEls[2].getAttribute('data-key-nav'), ']');
+      assert.equal(linkEls[0].getAttribute('href'), '/c/42/10/chell.go');
+      assert.equal(linkEls[1].getAttribute('href'), '/c/42/10/glados.txt');
+      assert.equal(linkEls[2].getAttribute('href'), '/c/42/10/wheatley.md');
+
+      assert.equal(element._computeFileDisplayName('/foo/bar/baz'),
+          '/foo/bar/baz');
+      assert.equal(element._computeFileDisplayName('/COMMIT_MSG'),
+          'Commit message');
+    });
+
+    test('jump to file dropdown with patch range', function() {
+      element._changeNum = '42';
+      element._patchRange = {
+        basePatchNum: '5',
+        patchNum: '10',
+      };
+      element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
+      element._path = 'glados.txt';
+      flushAsynchronousOperations();
+      var linkEls =
+          Polymer.dom(element.root).querySelectorAll('.dropdown-content > a');
+      assert.equal(linkEls.length, 3);
+      assert.isFalse(linkEls[0].hasAttribute('selected'));
+      assert.isTrue(linkEls[1].hasAttribute('selected'));
+      assert.isFalse(linkEls[2].hasAttribute('selected'));
+      assert.equal(linkEls[0].getAttribute('data-key-nav'), '[');
+      assert.equal(linkEls[1].getAttribute('data-key-nav'), '');
+      assert.equal(linkEls[2].getAttribute('data-key-nav'), ']');
+      assert.equal(linkEls[0].getAttribute('href'), '/c/42/5..10/chell.go');
+      assert.equal(linkEls[1].getAttribute('href'), '/c/42/5..10/glados.txt');
+      assert.equal(linkEls[2].getAttribute('href'), '/c/42/5..10/wheatley.md');
+    });
+
+    test('file review status', function(done) {
+      element._loggedIn = true;
+      element._changeNum = '42';
+      element._patchRange = {
+        basePatchNum: '1',
+        patchNum: '2',
+      };
+      element._fileList = ['/COMMIT_MSG'];
+      element._path = '/COMMIT_MSG';
+
+      server.respond();
+
+      element.async(function() {
+        var commitMsg = Polymer.dom(element.root).querySelector(
+            'input[type="checkbox"]');
+
+        assert.isTrue(commitMsg.checked);
+
+        MockInteractions.tap(commitMsg);
+        server.respond();
+        element._xhrPromise.then(function(req) {
+          assert.isFalse(commitMsg.checked);
+          assert.equal(req.status, 204);
+          assert.equal(req.url,
+              '/changes/42/revisions/2/files/%2FCOMMIT_MSG/reviewed');
+
+          MockInteractions.tap(commitMsg);
+          server.respond();
+        }).then(function() {
+          element._xhrPromise.then(function(req) {
+            assert.isTrue(commitMsg.checked);
+            assert.equal(req.status, 201);
+            assert.equal(req.url,
+                '/changes/42/revisions/2/files/%2FCOMMIT_MSG/reviewed');
+
+            done();
+          });
+        });
+      }, 1);
+    });
+  });
+</script>