/**
 * @license
 * 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.
 */

import '../../../test/common-test-setup-karma';
import '../../edit/gr-edit-constants';
import './gr-change-view';
import {
  ChangeStatus,
  CommentSide,
  DefaultBase,
  DiffViewMode,
  HttpMethod,
  PrimaryTab,
  SecondaryTab,
} from '../../../constants/constants';
import {GrEditConstants} from '../../edit/gr-edit-constants';
import {_testOnly_resetEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
import {getComputedStyleValue} from '../../../utils/dom-util';
import {GerritNav, GerritView} from '../../core/gr-navigation/gr-navigation';
import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit';
import {EventType, PluginApi} from '../../plugins/gr-plugin-types';

import 'lodash/lodash';
import {TestKeyboardShortcutBinder} from '../../../test/test-utils';
import {SPECIAL_PATCH_SET_NUM} from '../../../utils/patch-set-util';
import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
import {
  createAppElementChangeViewParams,
  createApproval,
  createChange,
  createChangeConfig,
  createChangeMessages,
  createCommit,
  createMergeable,
  createPreferences,
  createRevision,
  createRevisions,
  createServerInfo,
  createUserConfig,
  TEST_NUMERIC_CHANGE_ID,
  TEST_PROJECT_NAME,
  getCurrentRevision,
  createEditRevision,
  createAccountWithIdNameAndEmail,
} from '../../../test/test-data-generators';
import {ChangeViewPatchRange, GrChangeView} from './gr-change-view';
import {
  AccountId,
  ApprovalInfo,
  ChangeId,
  ChangeInfo,
  CommitId,
  CommitInfo,
  EditInfo,
  EditPatchSetNum,
  ElementPropertyDeepChange,
  GitRef,
  NumericChangeId,
  ParentPatchSetNum,
  ParsedJSON,
  PatchRange,
  PatchSetNum,
  RevisionInfo,
  RobotId,
  Timestamp,
  UrlEncodedCommentId,
} from '../../../types/common';
import {
  pressAndReleaseKeyOn,
  tap,
} from '@polymer/iron-test-helpers/mock-interactions';
import {GrEditControls} from '../../edit/gr-edit-controls/gr-edit-controls';
import {AppElementChangeViewParams} from '../../gr-app-types';
import {
  SinonFakeTimers,
  SinonSpy,
  SinonStubbedMember,
} from 'sinon/pkg/sinon-esm';
import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
import {CustomKeyboardEvent} from '../../../types/events';
import {
  CommentThread,
  DraftInfo,
  UIDraft,
  UIRobot,
} from '../../../utils/comment-util';
import 'lodash/lodash';
import {ParsedChangeInfo} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';

const pluginApi = _testOnly_initGerritPluginApi();
const fixture = fixtureFromElement('gr-change-view');

type SinonSpyMember<F extends (...args: any) => any> = SinonSpy<
  Parameters<F>,
  ReturnType<F>
>;

suite('gr-change-view tests', () => {
  let element: GrChangeView;

  let navigateToChangeStub: SinonStubbedMember<typeof GerritNav.navigateToChange>;

  suiteSetup(() => {
    const kb = TestKeyboardShortcutBinder.push();
    kb.bindShortcut(Shortcut.SEND_REPLY, 'ctrl+enter');
    kb.bindShortcut(Shortcut.REFRESH_CHANGE, 'shift+r');
    kb.bindShortcut(Shortcut.OPEN_REPLY_DIALOG, 'a');
    kb.bindShortcut(Shortcut.OPEN_DOWNLOAD_DIALOG, 'd');
    kb.bindShortcut(Shortcut.TOGGLE_DIFF_MODE, 'm');
    kb.bindShortcut(Shortcut.TOGGLE_CHANGE_STAR, 's');
    kb.bindShortcut(Shortcut.UP_TO_DASHBOARD, 'u');
    kb.bindShortcut(Shortcut.EXPAND_ALL_MESSAGES, 'x');
    kb.bindShortcut(Shortcut.COLLAPSE_ALL_MESSAGES, 'z');
    kb.bindShortcut(Shortcut.OPEN_DIFF_PREFS, ',');
    kb.bindShortcut(Shortcut.EDIT_TOPIC, 't');
  });

  suiteTeardown(() => {
    TestKeyboardShortcutBinder.pop();
  });

  const TEST_SCROLL_TOP_PX = 100;

  const ROBOT_COMMENTS_LIMIT = 10;

  // TODO: should have a mock service to generate VALID fake data
  const THREADS: CommentThread[] = [
    {
      comments: [
        {
          __path: '/COMMIT_MSG',
          author: {
            _account_id: 1000000 as AccountId,
            name: 'user',
            username: 'user',
          },
          patch_set: 2 as PatchSetNum,
          robot_id: 'rb1' as RobotId,
          id: 'ecf0b9fa_fe1a5f62' as UrlEncodedCommentId,
          line: 5,
          updated: '2018-02-08 18:49:18.000000000' as Timestamp,
          message: 'test',
          unresolved: true,
        },
        {
          __path: '/COMMIT_MSG',
          author: {
            _account_id: 1000000 as AccountId,
            name: 'user',
            username: 'user',
          },
          patch_set: 4 as PatchSetNum,
          id: 'ecf0b9fa_fe1a5f62_1' as UrlEncodedCommentId,
          line: 5,
          updated: '2018-02-08 18:49:18.000000000' as Timestamp,
          message: 'test',
          unresolved: true,
        },
        {
          id: '503008e2_0ab203ee' as UrlEncodedCommentId,
          path: '/COMMIT_MSG',
          line: 5,
          in_reply_to: 'ecf0b9fa_fe1a5f62' as UrlEncodedCommentId,
          updated: '2018-02-13 22:48:48.018000000' as Timestamp,
          message: 'draft',
          unresolved: false,
          __draft: true,
          __draftID: '0.m683trwff68',
          __editing: false,
          patch_set: 2 as PatchSetNum,
        },
      ],
      patchNum: 4 as PatchSetNum,
      path: '/COMMIT_MSG',
      line: 5,
      rootId: 'ecf0b9fa_fe1a5f62' as UrlEncodedCommentId,
    },
    {
      comments: [
        {
          __path: '/COMMIT_MSG',
          author: {
            _account_id: 1000000 as AccountId,
            name: 'user',
            username: 'user',
          },
          patch_set: 3 as PatchSetNum,
          id: 'ecf0b9fa_fe5f62' as UrlEncodedCommentId,
          robot_id: 'rb2' as RobotId,
          line: 5,
          updated: '2018-02-08 18:49:18.000000000' as Timestamp,
          message: 'test',
          unresolved: true,
        },
        {
          __path: 'test.txt',
          author: {
            _account_id: 1000000 as AccountId,
            name: 'user',
            username: 'user',
          },
          patch_set: 3 as PatchSetNum,
          id: '09a9fb0a_1484e6cf' as UrlEncodedCommentId,
          side: CommentSide.PARENT,
          updated: '2018-02-13 22:47:19.000000000' as Timestamp,
          message: 'Some comment on another patchset.',
          unresolved: false,
        },
      ],
      patchNum: 3 as PatchSetNum,
      path: 'test.txt',
      rootId: '09a9fb0a_1484e6cf' as UrlEncodedCommentId,
      commentSide: CommentSide.PARENT,
    },
    {
      comments: [
        {
          __path: '/COMMIT_MSG',
          author: {
            _account_id: 1000000 as AccountId,
            name: 'user',
            username: 'user',
          },
          patch_set: 2 as PatchSetNum,
          id: '8caddf38_44770ec1' as UrlEncodedCommentId,
          line: 4,
          updated: '2018-02-13 22:48:40.000000000' as Timestamp,
          message: 'Another unresolved comment',
          unresolved: true,
        },
      ],
      patchNum: 2 as PatchSetNum,
      path: '/COMMIT_MSG',
      line: 4,
      rootId: '8caddf38_44770ec1' as UrlEncodedCommentId,
    },
    {
      comments: [
        {
          __path: '/COMMIT_MSG',
          author: {
            _account_id: 1000000 as AccountId,
            name: 'user',
            username: 'user',
          },
          patch_set: 2 as PatchSetNum,
          id: 'scaddf38_44770ec1' as UrlEncodedCommentId,
          line: 4,
          updated: '2018-02-14 22:48:40.000000000' as Timestamp,
          message: 'Yet another unresolved comment',
          unresolved: true,
        },
      ],
      patchNum: 2 as PatchSetNum,
      path: '/COMMIT_MSG',
      line: 4,
      rootId: 'scaddf38_44770ec1' as UrlEncodedCommentId,
    },
    {
      comments: [
        {
          id: 'zcf0b9fa_fe1a5f62' as UrlEncodedCommentId,
          path: '/COMMIT_MSG',
          line: 6,
          updated: '2018-02-15 22:48:48.018000000' as Timestamp,
          message: 'resolved draft',
          unresolved: false,
          __draft: true,
          __draftID: '0.m683trwff68',
          __editing: false,
          patch_set: 2 as PatchSetNum,
        },
      ],
      patchNum: 4 as PatchSetNum,
      path: '/COMMIT_MSG',
      line: 6,
      rootId: 'zcf0b9fa_fe1a5f62' as UrlEncodedCommentId,
    },
    {
      comments: [
        {
          __path: '/COMMIT_MSG',
          author: {
            _account_id: 1000000 as AccountId,
            name: 'user',
            username: 'user',
          },
          patch_set: 4 as PatchSetNum,
          id: 'rc1' as UrlEncodedCommentId,
          line: 5,
          updated: '2019-02-08 18:49:18.000000000' as Timestamp,
          message: 'test',
          unresolved: true,
          robot_id: 'rc1' as RobotId,
        },
      ],
      patchNum: 4 as PatchSetNum,
      path: '/COMMIT_MSG',
      line: 5,
      rootId: 'rc1' as UrlEncodedCommentId,
    },
    {
      comments: [
        {
          __path: '/COMMIT_MSG',
          author: {
            _account_id: 1000000 as AccountId,
            name: 'user',
            username: 'user',
          },
          patch_set: 4 as PatchSetNum,
          id: 'rc2' as UrlEncodedCommentId,
          line: 5,
          updated: '2019-03-08 18:49:18.000000000' as Timestamp,
          message: 'test',
          unresolved: true,
          robot_id: 'rc2' as RobotId,
        },
        {
          __path: '/COMMIT_MSG',
          author: {
            _account_id: 1000000 as AccountId,
            name: 'user',
            username: 'user',
          },
          patch_set: 4 as PatchSetNum,
          id: 'c2_1' as UrlEncodedCommentId,
          line: 5,
          updated: '2019-03-08 18:49:18.000000000' as Timestamp,
          message: 'test',
          unresolved: true,
        },
      ],
      patchNum: 4 as PatchSetNum,
      path: '/COMMIT_MSG',
      line: 5,
      rootId: 'rc2' as UrlEncodedCommentId,
    },
  ];

  setup(() => {
    // Since pluginEndpoints are global, must reset state.
    _testOnly_resetEndpoints();
    navigateToChangeStub = sinon.stub(GerritNav, 'navigateToChange');

    function getCommentsStub() {
      return Promise.resolve({});
    }
    stub('gr-rest-api-interface', {
      getConfig() {
        return Promise.resolve({
          ...createServerInfo(),
          user: {
            ...createUserConfig(),
            anonymous_coward_name: 'test coward name',
          },
        });
      },
      getAccount() {
        return Promise.resolve(undefined);
      },
      getDiffComments: (getCommentsStub as unknown) as RestApiService['getDiffComments'],
      getDiffRobotComments: (getCommentsStub as unknown) as RestApiService['getDiffRobotComments'],
      getDiffDrafts: (getCommentsStub as unknown) as RestApiService['getDiffDrafts'],
      _fetchSharedCacheURL() {
        return Promise.resolve({} as ParsedJSON);
      },
    });
    element = fixture.instantiate();
    element._changeNum = 1 as NumericChangeId;
    sinon.stub(element.$.actions, 'reload').returns(Promise.resolve());
    getPluginLoader().loadPlugins([]);
    pluginApi.install(
      plugin => {
        plugin.registerDynamicCustomComponent(
          'change-view-tab-header',
          'gr-checks-change-view-tab-header-view'
        );
        plugin.registerDynamicCustomComponent(
          'change-view-tab-content',
          'gr-checks-view'
        );
      },
      '0.1',
      'http://some/plugins/url.html'
    );
  });

  teardown(done => {
    flush(() => {
      done();
    });
  });

  const getCustomCssValue = (cssParam: string) =>
    getComputedStyleValue(cssParam, element);

  test('_handleMessageAnchorTap', () => {
    element._changeNum = 1 as NumericChangeId;
    element._patchRange = {
      basePatchNum: ParentPatchSetNum,
      patchNum: 1 as PatchSetNum,
    };
    element._change = createChange();
    const getUrlStub = sinon.stub(GerritNav, 'getUrlForChange');
    const replaceStateStub = sinon.stub(history, 'replaceState');
    element._handleMessageAnchorTap(
      new CustomEvent('message-anchor-tap', {detail: {id: 'a12345'}})
    );

    assert.equal(getUrlStub.lastCall.args[4], '#message-a12345');
    assert.isTrue(replaceStateStub.called);
  });

  test('_handleDiffAgainstBase', () => {
    element._change = {
      ...createChange(),
      revisions: createRevisions(10),
    };
    element._patchRange = {
      patchNum: 3 as PatchSetNum,
      basePatchNum: 1 as PatchSetNum,
    };
    sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
    element._handleDiffAgainstBase(new CustomEvent('') as CustomKeyboardEvent);
    assert(navigateToChangeStub.called);
    const args = navigateToChangeStub.getCall(0).args;
    assert.equal(args[0], element._change);
    assert.equal(args[1], 3 as PatchSetNum);
  });

  test('_handleDiffAgainstLatest', () => {
    element._change = {
      ...createChange(),
      revisions: createRevisions(10),
    };
    element._patchRange = {
      basePatchNum: 1 as PatchSetNum,
      patchNum: 3 as PatchSetNum,
    };
    sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
    element._handleDiffAgainstLatest(
      new CustomEvent('') as CustomKeyboardEvent
    );
    assert(navigateToChangeStub.called);
    const args = navigateToChangeStub.getCall(0).args;
    assert.equal(args[0], element._change);
    assert.equal(args[1], 10 as PatchSetNum);
    assert.equal(args[2], 1 as PatchSetNum);
  });

  test('_handleDiffBaseAgainstLeft', () => {
    element._change = {
      ...createChange(),
      revisions: createRevisions(10),
    };
    element._patchRange = {
      patchNum: 3 as PatchSetNum,
      basePatchNum: 1 as PatchSetNum,
    };
    sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
    element._handleDiffBaseAgainstLeft(
      new CustomEvent('') as CustomKeyboardEvent
    );
    assert(navigateToChangeStub.called);
    const args = navigateToChangeStub.getCall(0).args;
    assert.equal(args[0], element._change);
    assert.equal(args[1], 1 as PatchSetNum);
  });

  test('_handleDiffRightAgainstLatest', () => {
    element._change = {
      ...createChange(),
      revisions: createRevisions(10),
    };
    element._patchRange = {
      basePatchNum: 1 as PatchSetNum,
      patchNum: 3 as PatchSetNum,
    };
    sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
    element._handleDiffRightAgainstLatest(
      new CustomEvent('') as CustomKeyboardEvent
    );
    assert(navigateToChangeStub.called);
    const args = navigateToChangeStub.getCall(0).args;
    assert.equal(args[1], 10 as PatchSetNum);
    assert.equal(args[2], 3 as PatchSetNum);
  });

  test('_handleDiffBaseAgainstLatest', () => {
    element._change = {
      ...createChange(),
      revisions: createRevisions(10),
    };
    element._patchRange = {
      basePatchNum: 1 as PatchSetNum,
      patchNum: 3 as PatchSetNum,
    };
    sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
    element._handleDiffBaseAgainstLatest(
      new CustomEvent('') as CustomKeyboardEvent
    );
    assert(navigateToChangeStub.called);
    const args = navigateToChangeStub.getCall(0).args;
    assert.equal(args[1], 10 as PatchSetNum);
    assert.isNotOk(args[2]);
  });

  suite('plugins adding to file tab', () => {
    setup(done => {
      element._changeNum = 1 as NumericChangeId;
      // Resolving it here instead of during setup() as other tests depend
      // on flush() not being called during setup.
      flush(() => done());
    });

    test('plugin added tab shows up as a dynamic endpoint', () => {
      assert(
        element._dynamicTabHeaderEndpoints.includes(
          'change-view-tab-header-url'
        )
      );
      const primaryTabs = element.shadowRoot!.querySelector('#primaryTabs')!;
      const paperTabs = primaryTabs.querySelectorAll<HTMLElement>('paper-tab');
      // 4 Tabs are : Files, Comment Threads, Plugin, Findings
      assert.equal(primaryTabs.querySelectorAll('paper-tab').length, 4);
      assert.equal(paperTabs[2].dataset.name, 'change-view-tab-header-url');
    });

    test('_setActivePrimaryTab switched tab correctly', done => {
      element._setActivePrimaryTab(
        new CustomEvent('', {
          detail: {tab: 'change-view-tab-header-url'},
        })
      );
      flush(() => {
        assert.equal(element._activeTabs[0], 'change-view-tab-header-url');
        done();
      });
    });

    test('show-primary-tab switched primary tab correctly', done => {
      element.dispatchEvent(
        new CustomEvent('show-primary-tab', {
          composed: true,
          bubbles: true,
          detail: {
            tab: 'change-view-tab-header-url',
          },
        })
      );
      flush(() => {
        assert.equal(element._activeTabs[0], 'change-view-tab-header-url');
        done();
      });
    });

    test('param change should switch primary tab correctly', done => {
      assert.equal(element._activeTabs[0], PrimaryTab.FILES);
      const queryMap = new Map<string, string>();
      queryMap.set('tab', PrimaryTab.FINDINGS);
      // view is required
      element.params = {
        ...createAppElementChangeViewParams(),
        ...element.params,
        queryMap,
      };
      flush(() => {
        assert.equal(element._activeTabs[0], PrimaryTab.FINDINGS);
        done();
      });
    });

    test('invalid param change should not switch primary tab', done => {
      assert.equal(element._activeTabs[0], PrimaryTab.FILES);
      const queryMap = new Map<string, string>();
      queryMap.set('tab', 'random');
      // view is required
      element.params = {
        ...createAppElementChangeViewParams(),
        ...element.params,
        queryMap,
      };
      flush(() => {
        assert.equal(element._activeTabs[0], PrimaryTab.FILES);
        done();
      });
    });

    test('switching tab sets _selectedTabPluginEndpoint', done => {
      const paperTabs = element.shadowRoot!.querySelector('#primaryTabs')!;
      tap(paperTabs.querySelectorAll('paper-tab')[2]);
      flush(() => {
        assert.equal(
          element._selectedTabPluginEndpoint,
          'change-view-tab-content-url'
        );
        done();
      });
    });
  });

  suite('keyboard shortcuts', () => {
    let clock: SinonFakeTimers;
    setup(() => {
      clock = sinon.useFakeTimers();
    });

    teardown(() => {
      clock.restore();
      sinon.restore();
    });

    test('t to add topic', () => {
      const editStub = sinon.stub(element.$.metadata, 'editTopic');
      pressAndReleaseKeyOn(element, 83, null, 't');
      assert(editStub.called);
    });

    test('S should toggle the CL star', () => {
      const starStub = sinon.stub(element.$.changeStar, 'toggleStar');
      pressAndReleaseKeyOn(element, 83, null, 's');
      assert(starStub.called);
    });

    test('toggle star is throttled', () => {
      const starStub = sinon.stub(element.$.changeStar, 'toggleStar');
      pressAndReleaseKeyOn(element, 83, null, 's');
      assert(starStub.called);
      pressAndReleaseKeyOn(element, 83, null, 's');
      assert.equal(starStub.callCount, 1);
      clock.tick(1000);
      pressAndReleaseKeyOn(element, 83, null, 's');
      assert.equal(starStub.callCount, 2);
    });

    test('U should navigate to root if no backPage set', () => {
      const relativeNavStub = sinon.stub(GerritNav, 'navigateToRelativeUrl');
      pressAndReleaseKeyOn(element, 85, null, 'u');
      assert.isTrue(relativeNavStub.called);
      assert.isTrue(
        relativeNavStub.lastCall.calledWithExactly(GerritNav.getUrlForRoot())
      );
    });

    test('U should navigate to backPage if set', () => {
      const relativeNavStub = sinon.stub(GerritNav, 'navigateToRelativeUrl');
      element.backPage = '/dashboard/self';
      pressAndReleaseKeyOn(element, 85, null, 'u');
      assert.isTrue(relativeNavStub.called);
      assert.isTrue(
        relativeNavStub.lastCall.calledWithExactly('/dashboard/self')
      );
    });

    test('A fires an error event when not logged in', done => {
      sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(false));
      const loggedInErrorSpy = sinon.spy();
      element.addEventListener('show-auth-required', loggedInErrorSpy);
      pressAndReleaseKeyOn(element, 65, null, 'a');
      flush(() => {
        assert.isFalse(element.$.replyOverlay.opened);
        assert.isTrue(loggedInErrorSpy.called);
        done();
      });
    });

    test('shift A does not open reply overlay', done => {
      sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
      pressAndReleaseKeyOn(element, 65, 'shift', 'a');
      flush(() => {
        assert.isFalse(element.$.replyOverlay.opened);
        done();
      });
    });

    test('A toggles overlay when logged in', done => {
      sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
      element._change = {
        ...createChange(),
        revisions: createRevisions(1),
        messages: createChangeMessages(1),
      };
      element._change.labels = {};
      sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(() =>
        Promise.resolve({
          ...createChange(),
          // element has latest info
          revisions: createRevisions(1),
          messages: createChangeMessages(1),
          current_revision: 'rev1' as CommitId,
        })
      );

      const openSpy = sinon.spy(element, '_openReplyDialog');

      pressAndReleaseKeyOn(element, 65, null, 'a');
      flush(() => {
        assert.isTrue(element.$.replyOverlay.opened);
        element.$.replyOverlay.close();
        assert.isFalse(element.$.replyOverlay.opened);
        assert(
          openSpy.lastCall.calledWithExactly(
            element.$.replyDialog.FocusTarget.ANY
          ),
          '_openReplyDialog should have been passed ANY'
        );
        assert.equal(openSpy.callCount, 1);
        done();
      });
    });

    test('fullscreen-overlay-opened hides content', () => {
      element._loggedIn = true;
      element._loading = false;
      element._change = {
        ...createChange(),
        labels: {},
        actions: {
          abandon: {
            enabled: true,
            label: 'Abandon',
            method: HttpMethod.POST,
            title: 'Abandon',
          },
        },
      };
      const handlerSpy = sinon.spy(element, '_handleHideBackgroundContent');
      element.$.replyDialog.dispatchEvent(
        new CustomEvent('fullscreen-overlay-opened', {
          composed: true,
          bubbles: true,
        })
      );
      assert.isTrue(handlerSpy.called);
      assert.isTrue(element.$.mainContent.classList.contains('overlayOpen'));
      assert.equal(getComputedStyle(element.$.actions).display, 'flex');
    });

    test('fullscreen-overlay-closed shows content', () => {
      element._loggedIn = true;
      element._loading = false;
      element._change = {
        ...createChange(),
        labels: {},
        actions: {
          abandon: {
            enabled: true,
            label: 'Abandon',
            method: HttpMethod.POST,
            title: 'Abandon',
          },
        },
      };
      const handlerSpy = sinon.spy(element, '_handleShowBackgroundContent');
      element.$.replyDialog.dispatchEvent(
        new CustomEvent('fullscreen-overlay-closed', {
          composed: true,
          bubbles: true,
        })
      );
      assert.isTrue(handlerSpy.called);
      assert.isFalse(element.$.mainContent.classList.contains('overlayOpen'));
    });

    test('expand all messages when expand-diffs fired', () => {
      const handleExpand = sinon.stub(element.$.fileList, 'expandAllDiffs');
      element.$.fileListHeader.dispatchEvent(
        new CustomEvent('expand-diffs', {
          composed: true,
          bubbles: true,
        })
      );
      assert.isTrue(handleExpand.called);
    });

    test('collapse all messages when collapse-diffs fired', () => {
      const handleCollapse = sinon.stub(element.$.fileList, 'collapseAllDiffs');
      element.$.fileListHeader.dispatchEvent(
        new CustomEvent('collapse-diffs', {
          composed: true,
          bubbles: true,
        })
      );
      assert.isTrue(handleCollapse.called);
    });

    test('X should expand all messages', done => {
      flush(() => {
        const handleExpand = sinon.stub(
          element.messagesList!,
          'handleExpandCollapse'
        );
        pressAndReleaseKeyOn(element, 88, null, 'x');
        assert(handleExpand.calledWith(true));
        done();
      });
    });

    test('Z should collapse all messages', done => {
      flush(() => {
        const handleExpand = sinon.stub(
          element.messagesList!,
          'handleExpandCollapse'
        );
        pressAndReleaseKeyOn(element, 90, null, 'z');
        assert(handleExpand.calledWith(false));
        done();
      });
    });

    test('reload event from reply dialog is processed', () => {
      const handleReloadStub = sinon.stub(element, '_reload');
      element.$.replyDialog.dispatchEvent(
        new CustomEvent('reload', {
          detail: {clearPatchset: true},
          bubbles: true,
          composed: true,
        })
      );
      assert.isTrue(handleReloadStub.called);
    });

    test('shift + R should fetch and navigate to the latest patch set', done => {
      element._changeNum = TEST_NUMERIC_CHANGE_ID;
      element._patchRange = {
        basePatchNum: ParentPatchSetNum,
        patchNum: 1 as PatchSetNum,
      };
      element._change = {
        ...createChange(),
        revisions: {
          rev1: createRevision(),
        },
        current_revision: 'rev1' as CommitId,
        status: ChangeStatus.NEW,
        labels: {},
        actions: {},
      };

      const reloadChangeStub = sinon.stub(element, '_reload');
      pressAndReleaseKeyOn(element, 82, 'shift', 'r');
      flush(() => {
        assert.isTrue(reloadChangeStub.called);
        done();
      });
    });

    test('d should open download overlay', () => {
      const stub = sinon
        .stub(element.$.downloadOverlay, 'open')
        .returns(Promise.resolve());
      pressAndReleaseKeyOn(element, 68, null, 'd');
      assert.isTrue(stub.called);
    });

    test(', should open diff preferences', () => {
      const stub = sinon.stub(
        element.$.fileList.$.diffPreferencesDialog,
        'open'
      );
      element._loggedIn = false;
      element.disableDiffPrefs = true;
      pressAndReleaseKeyOn(element, 188, null, ',');
      assert.isFalse(stub.called);

      element._loggedIn = true;
      pressAndReleaseKeyOn(element, 188, null, ',');
      assert.isFalse(stub.called);

      element.disableDiffPrefs = false;
      pressAndReleaseKeyOn(element, 188, null, ',');
      assert.isTrue(stub.called);
    });

    test('m should toggle diff mode', () => {
      sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
      const setModeStub = sinon.stub(
        element.$.fileListHeader,
        'setDiffViewMode'
      );
      const e = {preventDefault: () => {}} as CustomKeyboardEvent;
      flush();

      element.viewState.diffMode = DiffViewMode.SIDE_BY_SIDE;
      element._handleToggleDiffMode(e);
      assert.isTrue(setModeStub.calledWith(DiffViewMode.UNIFIED));

      element.viewState.diffMode = DiffViewMode.UNIFIED;
      element._handleToggleDiffMode(e);
      assert.isTrue(setModeStub.calledWith(DiffViewMode.SIDE_BY_SIDE));
    });
  });

  suite('reloading drafts', () => {
    let reloadStub: SinonStubbedMember<typeof element.$.commentAPI.reloadDrafts>;
    const drafts: {[path: string]: UIDraft[]} = {
      'testfile.txt': [
        {
          patch_set: 5 as PatchSetNum,
          id: 'dd2982f5_c01c9e6a' as UrlEncodedCommentId,
          line: 1,
          updated: '2017-11-08 18:47:45.000000000' as Timestamp,
          message: 'test',
          unresolved: true,
        },
      ],
    };
    setup(() => {
      // Fake computeDraftCount as its required for ChangeComments,
      // see gr-comment-api#reloadDrafts.
      reloadStub = sinon.stub(element.$.commentAPI, 'reloadDrafts').returns(
        Promise.resolve({
          drafts,
          getAllThreadsForChange: () => [] as CommentThread[],
          computeDraftCount: () => 1,
        } as ChangeComments)
      );
      element._changeNum = 1 as NumericChangeId;
    });

    test('drafts are reloaded when reload-drafts fired', done => {
      element.$.fileList.dispatchEvent(
        new CustomEvent('reload-drafts', {
          detail: {
            resolve: () => {
              assert.isTrue(reloadStub.called);
              assert.deepEqual(element._diffDrafts, drafts);
              done();
            },
          },
          composed: true,
          bubbles: true,
        })
      );
    });

    test('drafts are reloaded when comment-refresh fired', () => {
      element.dispatchEvent(
        new CustomEvent('comment-refresh', {
          composed: true,
          bubbles: true,
        })
      );
      assert.isTrue(reloadStub.called);
    });
  });

  suite('_recomputeComments', () => {
    setup(() => {
      element._changeNum = TEST_NUMERIC_CHANGE_ID;
      element._change = createChange();
      flush();
      // Fake computeDraftCount as its required for ChangeComments,
      // see gr-comment-api#reloadDrafts.
      sinon.stub(element.$.commentAPI, 'reloadDrafts').returns(
        Promise.resolve({
          drafts: {},
          getAllThreadsForChange: () => THREADS,
          computeDraftCount: () => 0,
        } as ChangeComments)
      );
      element._change = createChange();
      element._changeNum = element._change._number;
    });

    test('draft threads should be a new copy with correct states', done => {
      element.$.fileList.dispatchEvent(
        new CustomEvent('reload-drafts', {
          detail: {
            resolve: () => {
              assert.equal(element._draftCommentThreads!.length, 2);
              assert.equal(
                element._draftCommentThreads![0].rootId,
                THREADS[0].rootId
              );
              assert.notEqual(
                element._draftCommentThreads![0].comments,
                THREADS[0].comments
              );
              assert.notEqual(
                element._draftCommentThreads![0].comments[0],
                THREADS[0].comments[0]
              );
              assert.isTrue(
                element
                  ._draftCommentThreads![0].comments.slice(0, 2)
                  .every(c => c.collapsed === true)
              );

              assert.isTrue(
                element._draftCommentThreads![0].comments[2].collapsed === false
              );
              done();
            },
          },
          composed: true,
          bubbles: true,
        })
      );
    });
  });

  test('diff comments modified', () => {
    const reloadThreadsSpy = sinon.spy(element, '_handleReloadCommentThreads');
    return element._reloadComments().then(() => {
      element.dispatchEvent(
        new CustomEvent('diff-comments-modified', {
          composed: true,
          bubbles: true,
        })
      );
      assert.isTrue(reloadThreadsSpy.called);
    });
  });

  test('thread list modified', () => {
    const reloadDiffSpy = sinon.spy(element, '_handleReloadDiffComments');
    element._activeTabs = [PrimaryTab.COMMENT_THREADS, SecondaryTab.CHANGE_LOG];
    flush();

    return element._reloadComments().then(() => {
      element.threadList!.dispatchEvent(
        new CustomEvent('thread-list-modified', {
          composed: true,
          bubbles: true,
        })
      );
      assert.isTrue(reloadDiffSpy.called);

      let draftStub = sinon
        .stub(element._changeComments!, 'computeDraftCount')
        .returns(1);
      assert.equal(
        element._computeTotalCommentCounts(5, element._changeComments!),
        '5 unresolved, 1 draft'
      );
      assert.equal(
        element._computeTotalCommentCounts(0, element._changeComments!),
        '1 draft'
      );
      draftStub.restore();
      draftStub = sinon
        .stub(element._changeComments!, 'computeDraftCount')
        .returns(0);
      assert.equal(
        element._computeTotalCommentCounts(0, element._changeComments!),
        ''
      );
      assert.equal(
        element._computeTotalCommentCounts(1, element._changeComments!),
        '1 unresolved'
      );
      draftStub.restore();
      draftStub = sinon
        .stub(element._changeComments!, 'computeDraftCount')
        .returns(2);
      assert.equal(
        element._computeTotalCommentCounts(1, element._changeComments!),
        '1 unresolved, 2 drafts'
      );
      draftStub.restore();
    });
  });

  suite('thread list and change log tabs', () => {
    setup(() => {
      element._changeNum = TEST_NUMERIC_CHANGE_ID;
      element._patchRange = {
        basePatchNum: ParentPatchSetNum,
        patchNum: 1 as PatchSetNum,
      };
      element._change = {
        ...createChange(),
        revisions: {
          rev2: createRevision(2),
          rev1: createRevision(1),
          rev13: createRevision(13),
          rev3: createRevision(3),
        },
        current_revision: 'rev3' as CommitId,
        status: ChangeStatus.NEW,
        labels: {
          test: {
            all: [],
            default_value: 0,
            values: {},
            approved: {},
          },
        },
      };
      sinon.stub(element.$.relatedChanges, 'reload');
      sinon.stub(element, '_reload').returns(Promise.resolve([]));
      sinon.spy(element, '_paramsChanged');
      element.params = createAppElementChangeViewParams();
    });
  });

  suite('Findings comment tab', () => {
    setup(done => {
      element._changeNum = TEST_NUMERIC_CHANGE_ID;
      element._change = {
        ...createChange(),
        revisions: {
          rev2: createRevision(2),
          rev1: createRevision(1),
          rev13: createRevision(13),
          rev3: createRevision(3),
          rev4: createRevision(4),
        },
        current_revision: 'rev4' as CommitId,
      };
      element._commentThreads = THREADS;
      const paperTabs = element.shadowRoot!.querySelector('#primaryTabs')!;
      tap(paperTabs.querySelectorAll('paper-tab')[3]);
      flush(() => {
        done();
      });
    });

    test('robot comments count per patchset', () => {
      const count = element._robotCommentCountPerPatchSet(THREADS);
      const expectedCount = {
        2: 1,
        3: 1,
        4: 2,
      };
      assert.deepEqual(count, expectedCount);
      assert.equal(
        element._computeText(createRevision(2), THREADS),
        'Patchset 2 (1 finding)'
      );
      assert.equal(
        element._computeText(createRevision(4), THREADS),
        'Patchset 4 (2 findings)'
      );
      assert.equal(
        element._computeText(createRevision(5), THREADS),
        'Patchset 5'
      );
    });

    test('only robot comments are rendered', () => {
      assert.equal(element._robotCommentThreads!.length, 2);
      assert.equal(
        (element._robotCommentThreads![0].comments[0] as UIRobot).robot_id,
        'rc1'
      );
      assert.equal(
        (element._robotCommentThreads![1].comments[0] as UIRobot).robot_id,
        'rc2'
      );
    });

    test('changing patchsets resets robot comments', done => {
      element.set('_change.current_revision', 'rev3');
      flush(() => {
        assert.equal(element._robotCommentThreads!.length, 1);
        done();
      });
    });

    test('Show more button is hidden', () => {
      assert.isNull(element.shadowRoot!.querySelector('.show-robot-comments'));
    });

    suite('robot comments show more button', () => {
      setup(done => {
        const arr = [];
        for (let i = 0; i <= 30; i++) {
          arr.push(...THREADS);
        }
        element._commentThreads = arr;
        flush(() => {
          done();
        });
      });

      test('Show more button is rendered', () => {
        assert.isOk(element.shadowRoot!.querySelector('.show-robot-comments'));
        assert.equal(
          element._robotCommentThreads!.length,
          ROBOT_COMMENTS_LIMIT
        );
      });

      test('Clicking show more button renders all comments', done => {
        tap(element.shadowRoot!.querySelector('.show-robot-comments')!);
        flush(() => {
          assert.equal(element._robotCommentThreads!.length, 62);
          done();
        });
      });
    });
  });

  test('reply button is not visible when logged out', () => {
    assert.equal(getComputedStyle(element.$.replyBtn).display, 'none');
    element._loggedIn = true;
    assert.notEqual(getComputedStyle(element.$.replyBtn).display, 'none');
  });

  test('download tap calls _handleOpenDownloadDialog', () => {
    const openDialogStub = sinon.stub(element, '_handleOpenDownloadDialog');
    element.$.actions.dispatchEvent(
      new CustomEvent('download-tap', {
        composed: true,
        bubbles: true,
      })
    );
    assert.isTrue(openDialogStub.called);
  });

  test('fetches the server config on attached', done => {
    flush(() => {
      assert.equal(
        element._serverConfig!.user.anonymous_coward_name,
        'test coward name'
      );
      done();
    });
  });

  test('_changeStatuses', () => {
    element._loading = false;
    element._change = {
      ...createChange(),
      revisions: {
        rev2: createRevision(2),
        rev1: createRevision(1),
        rev13: createRevision(13),
        rev3: createRevision(3),
      },
      current_revision: 'rev3' as CommitId,
      status: ChangeStatus.MERGED,
      work_in_progress: true,
      labels: {
        test: {
          all: [],
          default_value: 0,
          values: {},
          approved: {},
        },
      },
    };
    element._mergeable = true;
    const expectedStatuses = ['Merged', 'WIP'];
    assert.deepEqual(element._changeStatuses, expectedStatuses);
    assert.equal(element._changeStatus, expectedStatuses.join(', '));
    flush();
    const statusChips = element.shadowRoot!.querySelectorAll(
      'gr-change-status'
    );
    assert.equal(statusChips.length, 2);
  });

  test('diff preferences open when open-diff-prefs is fired', () => {
    const overlayOpenStub = sinon.stub(element.$.fileList, 'openDiffPrefs');
    element.$.fileListHeader.dispatchEvent(
      new CustomEvent('open-diff-prefs', {
        composed: true,
        bubbles: true,
      })
    );
    assert.isTrue(overlayOpenStub.called);
  });

  test('_prepareCommitMsgForLinkify', () => {
    let commitMessage = 'R=test@google.com';
    let result = element._prepareCommitMsgForLinkify(commitMessage);
    assert.equal(result, 'R=\u200Btest@google.com');

    commitMessage = 'R=test@google.com\nR=test@google.com';
    result = element._prepareCommitMsgForLinkify(commitMessage);
    assert.equal(result, 'R=\u200Btest@google.com\nR=\u200Btest@google.com');

    commitMessage = 'CC=test@google.com';
    result = element._prepareCommitMsgForLinkify(commitMessage);
    assert.equal(result, 'CC=\u200Btest@google.com');
  });

  test('_isSubmitEnabled', () => {
    assert.isFalse(element._isSubmitEnabled({}));
    assert.isFalse(element._isSubmitEnabled({submit: {}}));
    assert.isTrue(element._isSubmitEnabled({submit: {enabled: true}}));
  });

  test('_reload is called when an approved label is removed', () => {
    const vote: ApprovalInfo = {
      ...createApproval(),
      _account_id: 1 as AccountId,
      name: 'bojack',
      value: 1,
    };
    element._changeNum = TEST_NUMERIC_CHANGE_ID;
    element._patchRange = {
      basePatchNum: ParentPatchSetNum,
      patchNum: 1 as PatchSetNum,
    };
    const change = {
      ...createChange(),
      owner: createAccountWithIdNameAndEmail(),
      revisions: {
        rev2: createRevision(2),
        rev1: createRevision(1),
        rev13: createRevision(13),
        rev3: createRevision(3),
      },
      current_revision: 'rev3' as CommitId,
      status: ChangeStatus.NEW,
      labels: {
        test: {
          all: [vote],
          default_value: 0,
          values: {},
          approved: {},
        },
      },
    };
    element._change = change;
    flush();
    const reloadStub = sinon.stub(element, '_reload');
    element.splice('_change.labels.test.all', 0, 1);
    assert.isFalse(reloadStub.called);
    change.labels.test.all.push(vote);
    change.labels.test.all.push(vote);
    change.labels.test.approved = vote;
    flush();
    element.splice('_change.labels.test.all', 0, 2);
    assert.isTrue(reloadStub.called);
    assert.isTrue(reloadStub.calledOnce);
  });

  test('reply button has updated count when there are drafts', () => {
    const getLabel = element._computeReplyButtonLabel;

    assert.equal(getLabel(null, false), 'Reply');
    assert.equal(getLabel(null, true), 'Start Review');

    const changeRecord: ElementPropertyDeepChange<
      GrChangeView,
      '_diffDrafts'
    > = {base: undefined, path: '', value: undefined};
    assert.equal(getLabel(changeRecord, false), 'Reply');

    changeRecord.base = {};
    assert.equal(getLabel(changeRecord, false), 'Reply');

    changeRecord.base = {
      'file1.txt': [{}],
      'file2.txt': [{}, {}],
    };
    assert.equal(getLabel(changeRecord, false), 'Reply (3)');
    assert.equal(getLabel(changeRecord, true), 'Start Review (3)');
  });

  test('comment events properly update diff drafts', () => {
    element._patchRange = {
      basePatchNum: ParentPatchSetNum,
      patchNum: 2 as PatchSetNum,
    };
    const draft: DraftInfo = {
      __draft: true,
      id: 'id1' as UrlEncodedCommentId,
      path: '/foo/bar.txt',
      message: 'hello',
    };
    element._handleCommentSave(new CustomEvent('', {detail: {comment: draft}}));
    draft.patch_set = 2 as PatchSetNum;
    assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft]});
    draft.patch_set = undefined;
    draft.message = 'hello, there';
    element._handleCommentSave(new CustomEvent('', {detail: {comment: draft}}));
    draft.patch_set = 2 as PatchSetNum;
    assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft]});
    const draft2: DraftInfo = {
      __draft: true,
      id: 'id2' as UrlEncodedCommentId,
      path: '/foo/bar.txt',
      message: 'hola',
    };
    element._handleCommentSave(
      new CustomEvent('', {detail: {comment: draft2}})
    );
    draft2.patch_set = 2 as PatchSetNum;
    assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft, draft2]});
    draft.patch_set = undefined;
    element._handleCommentDiscard(
      new CustomEvent('', {detail: {comment: draft}})
    );
    draft.patch_set = 2 as PatchSetNum;
    assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft2]});
    element._handleCommentDiscard(
      new CustomEvent('', {detail: {comment: draft2}})
    );
    assert.deepEqual(element._diffDrafts, {});
  });

  test('change num change', () => {
    element._changeNum = undefined;
    element._patchRange = {
      basePatchNum: ParentPatchSetNum,
      patchNum: 2 as PatchSetNum,
    };
    element._change = {
      ...createChange(),
      labels: {},
    };
    element.viewState.changeNum = null;
    element.viewState.diffMode = DiffViewMode.UNIFIED;
    assert.equal(element.viewState.numFilesShown, 200);
    assert.equal(element._numFilesShown, 200);
    element._numFilesShown = 150;
    flush();
    assert.equal(element.viewState.diffMode, DiffViewMode.UNIFIED);
    assert.equal(element.viewState.numFilesShown, 150);

    element._changeNum = 1 as NumericChangeId;
    element.params = {
      ...createAppElementChangeViewParams(),
      changeNum: 1 as NumericChangeId,
    };
    flush();
    assert.equal(element.viewState.diffMode, DiffViewMode.UNIFIED);
    assert.equal(element.viewState.changeNum, 1);

    element._changeNum = 2 as NumericChangeId;
    element.params = {
      ...createAppElementChangeViewParams(),
      changeNum: 2 as NumericChangeId,
    };
    flush();
    assert.equal(element.viewState.diffMode, DiffViewMode.UNIFIED);
    assert.equal(element.viewState.changeNum, 2);
    assert.equal(element.viewState.numFilesShown, 200);
    assert.equal(element._numFilesShown, 200);
  });

  test('_setDiffViewMode is called with reset when new change is loaded', () => {
    const setDiffViewModeStub = sinon.stub(element, '_setDiffViewMode');
    element.viewState = {changeNum: 1 as NumericChangeId};
    element._changeNum = 2 as NumericChangeId;
    element._resetFileListViewState();
    assert.isTrue(setDiffViewModeStub.calledWithExactly(true));
  });

  test('diffViewMode is propagated from file list header', () => {
    element.viewState = {diffMode: DiffViewMode.UNIFIED};
    element.$.fileListHeader.diffViewMode = DiffViewMode.SIDE_BY_SIDE;
    assert.equal(element.viewState.diffMode, DiffViewMode.SIDE_BY_SIDE);
  });

  test('diffMode defaults to side by side without preferences', done => {
    sinon
      .stub(element.$.restAPI, 'getPreferences')
      .returns(Promise.resolve(createPreferences()));
    // No user prefs or diff view mode set.

    element._setDiffViewMode()!.then(() => {
      assert.equal(element.viewState.diffMode, DiffViewMode.SIDE_BY_SIDE);
      done();
    });
  });

  test('diffMode defaults to preference when not already set', done => {
    sinon.stub(element.$.restAPI, 'getPreferences').returns(
      Promise.resolve({
        ...createPreferences(),
        default_diff_view: DiffViewMode.UNIFIED,
      })
    );

    element._setDiffViewMode()!.then(() => {
      assert.equal(element.viewState.diffMode, DiffViewMode.UNIFIED);
      done();
    });
  });

  test('existing diffMode overrides preference', done => {
    element.viewState.diffMode = DiffViewMode.SIDE_BY_SIDE;
    sinon.stub(element.$.restAPI, 'getPreferences').returns(
      Promise.resolve({
        ...createPreferences(),
        default_diff_view: DiffViewMode.UNIFIED,
      })
    );
    element._setDiffViewMode()!.then(() => {
      assert.equal(element.viewState.diffMode, DiffViewMode.SIDE_BY_SIDE);
      done();
    });
  });

  test('don’t reload entire page when patchRange changes', () => {
    const reloadStub = sinon
      .stub(element, '_reload')
      .callsFake(() => Promise.resolve([]));
    const reloadPatchDependentStub = sinon
      .stub(element, '_reloadPatchNumDependentResources')
      .callsFake(() => Promise.resolve([undefined, undefined]));
    const relatedClearSpy = sinon.spy(element.$.relatedChanges, 'clear');
    const collapseStub = sinon.stub(element.$.fileList, 'collapseAllDiffs');

    const value: AppElementChangeViewParams = {
      ...createAppElementChangeViewParams(),
      view: GerritView.CHANGE,
      patchNum: 1 as PatchSetNum,
    };
    element._paramsChanged(value);
    assert.isTrue(reloadStub.calledOnce);
    assert.isTrue(relatedClearSpy.calledOnce);

    element._initialLoadComplete = true;

    value.basePatchNum = 1 as PatchSetNum;
    value.patchNum = 2 as PatchSetNum;
    element._paramsChanged(value);
    assert.isFalse(reloadStub.calledTwice);
    assert.isTrue(reloadPatchDependentStub.calledOnce);
    assert.isTrue(relatedClearSpy.calledOnce);
    assert.isTrue(collapseStub.calledTwice);
  });

  test('reload entire page when patchRange doesnt change', () => {
    const reloadStub = sinon
      .stub(element, '_reload')
      .callsFake(() => Promise.resolve([]));
    const collapseStub = sinon.stub(element.$.fileList, 'collapseAllDiffs');
    const value: AppElementChangeViewParams = createAppElementChangeViewParams();
    element._paramsChanged(value);
    assert.isTrue(reloadStub.calledOnce);
    element._initialLoadComplete = true;
    element._paramsChanged(value);
    assert.isTrue(reloadStub.calledTwice);
    assert.isTrue(collapseStub.calledTwice);
  });

  test('related changes are not updated after other action', done => {
    sinon.stub(element, '_reload').callsFake(() => Promise.resolve([]));
    sinon.stub(element.$.relatedChanges, 'reload');
    element._reload(true).then(() => {
      assert.isFalse(navigateToChangeStub.called);
      done();
    });
  });

  test('_computeMergedCommitInfo', () => {
    const dummyRevs: {[revisionId: string]: RevisionInfo} = {
      1: createRevision(1),
      2: createRevision(2),
    };
    assert.deepEqual(
      element._computeMergedCommitInfo('0' as CommitId, dummyRevs),
      {}
    );
    assert.deepEqual(
      element._computeMergedCommitInfo('1' as CommitId, dummyRevs),
      dummyRevs[1].commit
    );

    // Regression test for issue 5337.
    const commit = element._computeMergedCommitInfo('2' as CommitId, dummyRevs);
    assert.notDeepEqual(commit, dummyRevs[2]);
    assert.deepEqual(commit, dummyRevs[2].commit);
  });

  test('_computeCopyTextForTitle', () => {
    const change: ChangeInfo = {
      ...createChange(),
      _number: 123 as NumericChangeId,
      subject: 'test subject',
      revisions: {
        rev1: createRevision(1),
        rev3: createRevision(3),
      },
      current_revision: 'rev3' as CommitId,
    };
    sinon.stub(GerritNav, 'getUrlForChange').returns('/change/123');
    assert.equal(
      element._computeCopyTextForTitle(change),
      `123: test subject | http://${location.host}/change/123`
    );
  });

  test('get latest revision', () => {
    let change: ChangeInfo = {
      ...createChange(),
      revisions: {
        rev1: createRevision(1),
        rev3: createRevision(3),
      },
      current_revision: 'rev3' as CommitId,
    };
    assert.equal(element._getLatestRevisionSHA(change), 'rev3');
    change = {
      ...createChange(),
      revisions: {
        rev1: createRevision(1),
      },
    };
    assert.equal(element._getLatestRevisionSHA(change), 'rev1');
  });

  test('show commit message edit button', () => {
    const change = createChange();
    const mergedChanged: ChangeInfo = {
      ...createChange(),
      status: ChangeStatus.MERGED,
    };
    assert.isTrue(element._computeHideEditCommitMessage(false, false, change));
    assert.isTrue(element._computeHideEditCommitMessage(true, true, change));
    assert.isTrue(element._computeHideEditCommitMessage(false, true, change));
    assert.isFalse(element._computeHideEditCommitMessage(true, false, change));
    assert.isTrue(
      element._computeHideEditCommitMessage(true, false, mergedChanged)
    );
    assert.isTrue(
      element._computeHideEditCommitMessage(true, false, change, true)
    );
    assert.isFalse(
      element._computeHideEditCommitMessage(true, false, change, false)
    );
  });

  test('_handleCommitMessageSave trims trailing whitespace', () => {
    element._change = createChange();
    // Response code is 500, because we want to avoid window reloading
    const putStub = sinon
      .stub(element.$.restAPI, 'putChangeCommitMessage')
      .returns(Promise.resolve(new Response(null, {status: 500})));

    const mockEvent = (content: string) => {
      return new CustomEvent('', {detail: {content}});
    };

    element._handleCommitMessageSave(mockEvent('test \n  test '));
    assert.equal(putStub.lastCall.args[1], 'test\n  test');

    element._handleCommitMessageSave(mockEvent('  test\ntest'));
    assert.equal(putStub.lastCall.args[1], '  test\ntest');

    element._handleCommitMessageSave(mockEvent('\n\n\n\n\n\n\n\n'));
    assert.equal(putStub.lastCall.args[1], '\n\n\n\n\n\n\n\n');
  });

  test('_computeChangeIdCommitMessageError', () => {
    let commitMessage = 'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483';
    let change: ChangeInfo = {
      ...createChange(),
      change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483' as ChangeId,
    };
    assert.equal(
      element._computeChangeIdCommitMessageError(commitMessage, change),
      null
    );

    change = {
      ...createChange(),
      change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484' as ChangeId,
    };
    assert.equal(
      element._computeChangeIdCommitMessageError(commitMessage, change),
      'mismatch'
    );

    commitMessage = 'This is the greatest change.';
    assert.equal(
      element._computeChangeIdCommitMessageError(commitMessage, change),
      'missing'
    );
  });

  test('multiple change Ids in commit message picks last', () => {
    const commitMessage = [
      'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282484',
      'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483',
    ].join('\n');
    let change: ChangeInfo = {
      ...createChange(),
      change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483' as ChangeId,
    };
    assert.equal(
      element._computeChangeIdCommitMessageError(commitMessage, change),
      null
    );
    change = {
      ...createChange(),
      change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484' as ChangeId,
    };
    assert.equal(
      element._computeChangeIdCommitMessageError(commitMessage, change),
      'mismatch'
    );
  });

  test('does not count change Id that starts mid line', () => {
    const commitMessage = [
      'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282484',
      'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483',
    ].join(' and ');
    let change: ChangeInfo = {
      ...createChange(),
      change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484' as ChangeId,
    };
    assert.equal(
      element._computeChangeIdCommitMessageError(commitMessage, change),
      null
    );
    change = {
      ...createChange(),
      change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483' as ChangeId,
    };
    assert.equal(
      element._computeChangeIdCommitMessageError(commitMessage, change),
      'mismatch'
    );
  });

  test('_computeTitleAttributeWarning', () => {
    let changeIdCommitMessageError = 'missing';
    assert.equal(
      element._computeTitleAttributeWarning(changeIdCommitMessageError),
      'No Change-Id in commit message'
    );

    changeIdCommitMessageError = 'mismatch';
    assert.equal(
      element._computeTitleAttributeWarning(changeIdCommitMessageError),
      'Change-Id mismatch'
    );
  });

  test('_computeChangeIdClass', () => {
    let changeIdCommitMessageError = 'missing';
    assert.equal(element._computeChangeIdClass(changeIdCommitMessageError), '');

    changeIdCommitMessageError = 'mismatch';
    assert.equal(
      element._computeChangeIdClass(changeIdCommitMessageError),
      'warning'
    );
  });

  test('topic is coalesced to null', done => {
    sinon.stub(element, '_changeChanged');
    sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(() =>
      Promise.resolve({
        ...createChange(),
        labels: {},
        current_revision: 'foo' as CommitId,
        revisions: {foo: createRevision()},
      })
    );

    element._getChangeDetail().then(() => {
      assert.isNull(element._change!.topic);
      done();
    });
  });

  test('commit sha is populated from getChangeDetail', done => {
    sinon.stub(element, '_changeChanged');
    sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(() =>
      Promise.resolve({
        ...createChange(),
        labels: {},
        current_revision: 'foo' as CommitId,
        revisions: {foo: createRevision()},
      })
    );

    element._getChangeDetail().then(() => {
      assert.equal('foo', element._commitInfo!.commit);
      done();
    });
  });

  test('edit is added to change', () => {
    sinon.stub(element, '_changeChanged');
    const changeRevision = createRevision();
    sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(() =>
      Promise.resolve({
        ...createChange(),
        labels: {},
        current_revision: 'foo' as CommitId,
        revisions: {foo: {...changeRevision}},
      })
    );
    const editCommit: CommitInfo = {
      ...createCommit(),
      commit: 'bar' as CommitId,
    };
    sinon.stub(element, '_getEdit').callsFake(() =>
      Promise.resolve({
        base_patch_set_number: 1 as PatchSetNum,
        commit: {...editCommit},
        base_revision: 'abc',
        ref: 'some/ref' as GitRef,
      })
    );
    element._patchRange = {};

    return element._getChangeDetail().then(() => {
      const revs = element._change!.revisions!;
      assert.equal(Object.keys(revs).length, 2);
      assert.deepEqual(revs['foo'], changeRevision);
      assert.deepEqual(revs['bar'], {
        ...createEditRevision(),
        commit: editCommit,
        fetch: undefined,
      });
    });
  });

  test('_getBasePatchNum', () => {
    const _change: ChangeInfo = {
      ...createChange(),
      revisions: {
        '98da160735fb81604b4c40e93c368f380539dd0e': createRevision(),
      },
    };
    const _patchRange: ChangeViewPatchRange = {
      basePatchNum: ParentPatchSetNum,
    };
    assert.equal(element._getBasePatchNum(_change, _patchRange), 'PARENT');

    element._prefs = {
      ...createPreferences(),
      default_base_for_merges: DefaultBase.FIRST_PARENT,
    };

    const _change2: ChangeInfo = {
      ...createChange(),
      revisions: {
        '98da160735fb81604b4c40e93c368f380539dd0e': {
          ...createRevision(1),
          commit: {
            ...createCommit(),
            parents: [
              {
                commit: '6e12bdf1176eb4ab24d8491ba3b6d0704409cde8' as CommitId,
                subject: 'test',
              },
              {
                commit: '22f7db4754b5d9816fc581f3d9a6c0ef8429c841' as CommitId,
                subject: 'test3',
              },
            ],
          },
        },
      },
    };
    assert.equal(element._getBasePatchNum(_change2, _patchRange), -1);

    _patchRange.patchNum = 1 as PatchSetNum;
    assert.equal(element._getBasePatchNum(_change2, _patchRange), 'PARENT');
  });

  test('_openReplyDialog called with `ANY` when coming from tap event', done => {
    flush(() => {
      const openStub = sinon.stub(element, '_openReplyDialog');
      tap(element.$.replyBtn);
      assert(
        openStub.lastCall.calledWithExactly(
          element.$.replyDialog.FocusTarget.ANY
        ),
        '_openReplyDialog should have been passed ANY'
      );
      assert.equal(openStub.callCount, 1);
      done();
    });
  });

  test(
    '_openReplyDialog called with `BODY` when coming from message reply' +
      'event',
    done => {
      flush(() => {
        const openStub = sinon.stub(element, '_openReplyDialog');
        element.messagesList!.dispatchEvent(
          new CustomEvent('reply', {
            detail: {message: {message: 'text'}},
            composed: true,
            bubbles: true,
          })
        );
        assert(
          openStub.lastCall.calledWithExactly(
            element.$.replyDialog.FocusTarget.BODY
          ),
          '_openReplyDialog should have been passed BODY'
        );
        assert.equal(openStub.callCount, 1);
        done();
      });
    }
  );

  test('reply dialog focus can be controlled', () => {
    const FocusTarget = element.$.replyDialog.FocusTarget;
    const openStub = sinon.stub(element, '_openReplyDialog');

    const e = new CustomEvent('show-reply-dialog', {
      detail: {value: {ccsOnly: false}},
    });
    element._handleShowReplyDialog(e);
    assert(
      openStub.lastCall.calledWithExactly(FocusTarget.REVIEWERS),
      '_openReplyDialog should have been passed REVIEWERS'
    );
    assert.equal(openStub.callCount, 1);

    e.detail.value = {ccsOnly: true};
    element._handleShowReplyDialog(e);
    assert(
      openStub.lastCall.calledWithExactly(FocusTarget.CCS),
      '_openReplyDialog should have been passed CCS'
    );
    assert.equal(openStub.callCount, 2);
  });

  test('getUrlParameter functionality', () => {
    const locationStub = sinon.stub(element, '_getLocationSearch');

    locationStub.returns('?test');
    assert.equal(element._getUrlParameter('test'), 'test');
    locationStub.returns('?test2=12&test=3');
    assert.equal(element._getUrlParameter('test'), 'test');
    locationStub.returns('');
    assert.isNull(element._getUrlParameter('test'));
    locationStub.returns('?');
    assert.isNull(element._getUrlParameter('test'));
    locationStub.returns('?test2');
    assert.isNull(element._getUrlParameter('test'));
  });

  test('revert dialog opened with revert param', done => {
    sinon
      .stub(element.$.restAPI, 'getLoggedIn')
      .callsFake(() => Promise.resolve(true));
    const awaitPluginsLoadedStub = sinon
      .stub(getPluginLoader(), 'awaitPluginsLoaded')
      .callsFake(() => Promise.resolve());

    element._patchRange = {
      basePatchNum: ParentPatchSetNum,
      patchNum: 2 as PatchSetNum,
    };
    element._change = {
      ...createChange(),
      revisions: {
        rev1: createRevision(1),
        rev2: createRevision(2),
      },
      current_revision: 'rev1' as CommitId,
      status: ChangeStatus.MERGED,
      labels: {},
      actions: {},
    };

    sinon.stub(element, '_getUrlParameter').callsFake(param => {
      assert.equal(param, 'revert');
      return param;
    });

    sinon.stub(element.$.actions, 'showRevertDialog').callsFake(done);

    element._maybeShowRevertDialog();
    assert.isTrue(awaitPluginsLoadedStub.called);
  });

  suite('scroll related tests', () => {
    test('document scrolling calls function to set scroll height', done => {
      const originalHeight = document.body.scrollHeight;
      const scrollStub = sinon.stub(element, '_handleScroll').callsFake(() => {
        assert.isTrue(scrollStub.called);
        document.body.style.height = `${originalHeight}px`;
        scrollStub.restore();
        done();
      });
      document.body.style.height = '10000px';
      element._handleScroll();
    });

    test('scrollTop is set correctly', () => {
      element.viewState = {scrollTop: TEST_SCROLL_TOP_PX};

      sinon.stub(element, '_reload').callsFake(() => {
        // When element is reloaded, ensure that the history
        // state has the scrollTop set earlier. This will then
        // be reset.
        assert.isTrue(element.viewState.scrollTop === TEST_SCROLL_TOP_PX);
        return Promise.resolve([]);
      });

      // simulate reloading component, which is done when route
      // changes to match a regex of change view type.
      element._paramsChanged({...createAppElementChangeViewParams()});
    });

    test('scrollTop is reset when new change is loaded', () => {
      element._resetFileListViewState();
      assert.equal(element.viewState.scrollTop, 0);
    });
  });

  suite('reply dialog tests', () => {
    setup(() => {
      sinon.stub(element.$.replyDialog, '_draftChanged');
      element._change = {
        ...createChange(),
        revisions: createRevisions(1),
        messages: createChangeMessages(1),
      };
      element._change.labels = {};
      sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(() =>
        Promise.resolve({
          ...createChange(),
          // element has latest info
          revisions: {rev1: createRevision()},
          messages: createChangeMessages(1),
          current_revision: 'rev1' as CommitId,
        })
      );
    });

    test('show reply dialog on open-reply-dialog event', done => {
      const openReplyDialogStub = sinon.stub(element, '_openReplyDialog');
      element.dispatchEvent(
        new CustomEvent('open-reply-dialog', {
          composed: true,
          bubbles: true,
          detail: {},
        })
      );
      flush(() => {
        assert.isTrue(openReplyDialogStub.calledOnce);
        done();
      });
    });

    test('reply from comment adds quote text', () => {
      const e = new CustomEvent('', {
        detail: {message: {message: 'quote text'}},
      });
      element._handleMessageReply(e);
      assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
    });

    test('reply from comment replaces quote text', () => {
      element.$.replyDialog.draft = '> old quote text\n\n some draft text';
      element.$.replyDialog.quote = '> old quote text\n\n';
      const e = new CustomEvent('', {
        detail: {message: {message: 'quote text'}},
      });
      element._handleMessageReply(e);
      assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
    });

    test('reply from same comment preserves quote text', () => {
      element.$.replyDialog.draft = '> quote text\n\n some draft text';
      element.$.replyDialog.quote = '> quote text\n\n';
      const e = new CustomEvent('', {
        detail: {message: {message: 'quote text'}},
      });
      element._handleMessageReply(e);
      assert.equal(
        element.$.replyDialog.draft,
        '> quote text\n\n some draft text'
      );
      assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
    });

    test('reply from top of page contains previous draft', () => {
      const div = document.createElement('div');
      element.$.replyDialog.draft = '> quote text\n\n some draft text';
      element.$.replyDialog.quote = '> quote text\n\n';
      const e = ({
        target: div,
        preventDefault: sinon.spy(),
      } as unknown) as MouseEvent;
      element._handleReplyTap(e);
      assert.equal(
        element.$.replyDialog.draft,
        '> quote text\n\n some draft text'
      );
      assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
    });
  });

  test('reply button is disabled until server config is loaded', done => {
    assert.isTrue(element._replyDisabled);
    // fetches the server config on attached
    flush(() => {
      assert.isFalse(element._replyDisabled);
      done();
    });
  });

  suite('commit message expand/collapse', () => {
    setup(() => {
      element._change = {
        ...createChange(),
        revisions: createRevisions(1),
        messages: createChangeMessages(1),
      };
      element._change.labels = {};
      sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(() =>
        Promise.resolve({
          ...createChange(),
          // new patchset was uploaded
          revisions: createRevisions(2),
          current_revision: getCurrentRevision(2),
          messages: createChangeMessages(1),
        })
      );
    });

    test('commitCollapseToggle hidden for short commit message', () => {
      element._latestCommitMessage = '';
      assert.isTrue(element.$.commitCollapseToggle.hasAttribute('hidden'));
    });

    test('commitCollapseToggle shown for long commit message', () => {
      element._latestCommitMessage = _.times(31, String).join('\n');
      assert.isFalse(element.$.commitCollapseToggle.hasAttribute('hidden'));
    });

    test('commitCollapseToggle functions', () => {
      element._latestCommitMessage = _.times(35, String).join('\n');
      assert.isTrue(element._commitCollapsed);
      assert.isTrue(element._commitCollapsible);
      assert.isTrue(element.$.commitMessageEditor.hasAttribute('collapsed'));
      tap(element.$.commitCollapseToggleButton);
      assert.isFalse(element._commitCollapsed);
      assert.isTrue(element._commitCollapsible);
      assert.isFalse(element.$.commitMessageEditor.hasAttribute('collapsed'));
    });
  });

  suite('related changes expand/collapse', () => {
    let updateHeightSpy: SinonSpyMember<typeof element._updateRelatedChangeMaxHeight>;
    setup(() => {
      updateHeightSpy = sinon.spy(element, '_updateRelatedChangeMaxHeight');
    });

    test('relatedChangesToggle shown height greater than changeInfo height', () => {
      assert.isFalse(
        element.$.relatedChangesToggle.classList.contains('showToggle')
      );
      sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
      sinon.stub(element, '_getScrollHeight').callsFake(() => 60);
      sinon.stub(element, '_getLineHeight').callsFake(() => 5);
      sinon.stub(window, 'matchMedia').callsFake(() => {
        return {matches: true} as MediaQueryList;
      });
      element.$.relatedChanges.dispatchEvent(
        new CustomEvent('new-section-loaded')
      );
      assert.isTrue(
        element.$.relatedChangesToggle.classList.contains('showToggle')
      );
      assert.equal(updateHeightSpy.callCount, 1);
    });

    test('relatedChangesToggle hidden height less than changeInfo height', () => {
      assert.isFalse(
        element.$.relatedChangesToggle.classList.contains('showToggle')
      );
      sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
      sinon.stub(element, '_getScrollHeight').callsFake(() => 40);
      sinon.stub(element, '_getLineHeight').callsFake(() => 5);
      sinon.stub(window, 'matchMedia').callsFake(() => {
        return {matches: true} as MediaQueryList;
      });
      element.$.relatedChanges.dispatchEvent(
        new CustomEvent('new-section-loaded')
      );
      assert.isFalse(
        element.$.relatedChangesToggle.classList.contains('showToggle')
      );
      assert.equal(updateHeightSpy.callCount, 1);
    });

    test('relatedChangesToggle functions', () => {
      sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
      sinon.stub(window, 'matchMedia').callsFake(() => {
        return {matches: false} as MediaQueryList;
      });
      assert.isTrue(element._relatedChangesCollapsed);
      assert.isTrue(element.$.relatedChanges.classList.contains('collapsed'));
      tap(element.$.relatedChangesToggleButton);
      assert.isFalse(element._relatedChangesCollapsed);
      assert.isFalse(element.$.relatedChanges.classList.contains('collapsed'));
    });

    test('_updateRelatedChangeMaxHeight without commit toggle', () => {
      sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
      sinon.stub(element, '_getLineHeight').callsFake(() => 12);
      sinon.stub(window, 'matchMedia').callsFake(() => {
        return {matches: false} as MediaQueryList;
      });

      // 50 (existing height) - 30 (extra height) = 20 (adjusted height).
      // 20 (max existing height)  % 12 (line height) = 6 (remainder).
      // 20 (adjusted height) - 8 (remainder) = 12 (max height to set).

      element._updateRelatedChangeMaxHeight();
      assert.equal(getCustomCssValue('--relation-chain-max-height'), '12px');
      assert.equal(getCustomCssValue('--related-change-btn-top-padding'), '');
    });

    test('_updateRelatedChangeMaxHeight with commit toggle', () => {
      element._latestCommitMessage = _.times(31, String).join('\n');
      sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
      sinon.stub(element, '_getLineHeight').callsFake(() => 12);
      sinon.stub(window, 'matchMedia').callsFake(() => {
        return {matches: false} as MediaQueryList;
      });

      // 50 (existing height) % 12 (line height) = 2 (remainder).
      // 50 (existing height)  - 2 (remainder) = 48 (max height to set).

      element._updateRelatedChangeMaxHeight();
      assert.equal(getCustomCssValue('--relation-chain-max-height'), '48px');
      assert.equal(
        getCustomCssValue('--related-change-btn-top-padding'),
        '2px'
      );
    });

    test('_updateRelatedChangeMaxHeight in small screen mode', () => {
      element._latestCommitMessage = _.times(31, String).join('\n');
      sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
      sinon.stub(element, '_getLineHeight').callsFake(() => 12);
      sinon.stub(window, 'matchMedia').callsFake(() => {
        return {matches: true} as MediaQueryList;
      });

      element._updateRelatedChangeMaxHeight();

      // 400 (new height) % 12 (line height) = 4 (remainder).
      // 400 (new height) - 4 (remainder) = 396.

      assert.equal(getCustomCssValue('--relation-chain-max-height'), '396px');
    });

    test('_updateRelatedChangeMaxHeight in medium screen mode', () => {
      element._latestCommitMessage = _.times(31, String).join('\n');
      sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
      sinon.stub(element, '_getLineHeight').callsFake(() => 12);
      const matchMediaStub = sinon.stub(window, 'matchMedia').callsFake(() => {
        if (matchMediaStub.lastCall.args[0] === '(max-width: 75em)') {
          return {matches: true} as MediaQueryList;
        } else {
          return {matches: false} as MediaQueryList;
        }
      });

      // 100 (new height) % 12 (line height) = 4 (remainder).
      // 100 (new height) - 4 (remainder) = 96.
      element._updateRelatedChangeMaxHeight();
      assert.equal(getCustomCssValue('--relation-chain-max-height'), '96px');
    });

    suite('update checks', () => {
      let startUpdateCheckTimerSpy: SinonSpyMember<typeof element._startUpdateCheckTimer>;
      let asyncStub: SinonStubbedMember<typeof element.async>;
      setup(() => {
        startUpdateCheckTimerSpy = sinon.spy(element, '_startUpdateCheckTimer');
        asyncStub = sinon.stub(element, 'async').callsFake(f => {
          // Only fire the async callback one time.
          if (asyncStub.callCount > 1) {
            return 1;
          }
          f.call(element);
          return 1;
        });
        element._change = {
          ...createChange(),
          revisions: createRevisions(1),
          messages: createChangeMessages(1),
        };
      });

      test('_startUpdateCheckTimer negative delay', () => {
        const getChangeDetailStub = sinon
          .stub(element.$.restAPI, 'getChangeDetail')
          .callsFake(() =>
            Promise.resolve({
              ...createChange(),
              // element has latest info
              revisions: {rev1: createRevision()},
              messages: createChangeMessages(1),
              current_revision: 'rev1' as CommitId,
            })
          );

        element._serverConfig = {
          ...createServerInfo(),
          change: {...createChangeConfig(), update_delay: -1},
        };

        assert.isTrue(startUpdateCheckTimerSpy.called);
        assert.isFalse(getChangeDetailStub.called);
      });

      test('_startUpdateCheckTimer up-to-date', async () => {
        const getChangeDetailStub = sinon
          .stub(element.$.restAPI, 'getChangeDetail')
          .callsFake(() =>
            Promise.resolve({
              ...createChange(),
              // element has latest info
              revisions: {rev1: createRevision()},
              messages: createChangeMessages(1),
              current_revision: 'rev1' as CommitId,
            })
          );

        element._serverConfig = {
          ...createServerInfo(),
          change: {...createChangeConfig(), update_delay: 12345},
        };
        await flush();

        assert.equal(startUpdateCheckTimerSpy.callCount, 2);
        assert.isTrue(getChangeDetailStub.called);
        assert.equal(asyncStub.lastCall.args[1], 12345 * 1000);
      });

      test('_startUpdateCheckTimer out-of-date shows an alert', done => {
        sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(() =>
          Promise.resolve({
            ...createChange(),
            // new patchset was uploaded
            revisions: createRevisions(2),
            current_revision: getCurrentRevision(2),
            messages: createChangeMessages(1),
          })
        );

        element.addEventListener('show-alert', e => {
          assert.equal(e.detail.message, 'A newer patch set has been uploaded');
          done();
        });
        element._serverConfig = {
          ...createServerInfo(),
          change: {...createChangeConfig(), update_delay: 12345},
        };

        assert.equal(startUpdateCheckTimerSpy.callCount, 1);
      });

      test('_startUpdateCheckTimer respects _loading', async () => {
        sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(() =>
          Promise.resolve({
            ...createChange(),
            // new patchset was uploaded
            revisions: createRevisions(2),
            current_revision: getCurrentRevision(2),
            messages: createChangeMessages(1),
          })
        );

        element._loading = true;
        element._serverConfig = {
          ...createServerInfo(),
          change: {...createChangeConfig(), update_delay: 12345},
        };
        await flush();

        // No toast, instead a second call to _startUpdateCheckTimer().
        assert.equal(startUpdateCheckTimerSpy.callCount, 2);
      });

      test('_startUpdateCheckTimer new status shows an alert', done => {
        sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(() =>
          Promise.resolve({
            ...createChange(),
            // element has latest info
            revisions: {rev1: createRevision()},
            messages: createChangeMessages(1),
            current_revision: 'rev1' as CommitId,
            status: ChangeStatus.MERGED,
          })
        );

        element.addEventListener('show-alert', e => {
          assert.equal(e.detail.message, 'This change has been merged');
          done();
        });
        element._serverConfig = {
          ...createServerInfo(),
          change: {...createChangeConfig(), update_delay: 12345},
        };
      });

      test('_startUpdateCheckTimer new messages shows an alert', done => {
        sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(() =>
          Promise.resolve({
            ...createChange(),
            revisions: {rev1: createRevision()},
            // element has new message
            messages: createChangeMessages(2),
            current_revision: 'rev1' as CommitId,
          })
        );
        element.addEventListener('show-alert', e => {
          assert.equal(
            e.detail.message,
            'There are new messages on this change'
          );
          done();
        });
        element._serverConfig = {
          ...createServerInfo(),
          change: {...createChangeConfig(), update_delay: 12345},
        };
      });
    });

    test('canStartReview computation', () => {
      const change1: ChangeInfo = createChange();
      const change2: ChangeInfo = {
        ...createChange(),
        actions: {
          ready: {
            enabled: true,
          },
        },
      };
      const change3: ChangeInfo = {
        ...createChange(),
        actions: {
          ready: {
            label: 'Ready for Review',
          },
        },
      };
      assert.isFalse(element._computeCanStartReview(change1));
      assert.isTrue(element._computeCanStartReview(change2));
      assert.isFalse(element._computeCanStartReview(change3));
    });
  });

  test('header class computation', () => {
    assert.equal(element._computeHeaderClass(), 'header');
    assert.equal(element._computeHeaderClass(true), 'header editMode');
  });

  test('_maybeScrollToMessage', done => {
    flush(() => {
      const scrollStub = sinon.stub(element.messagesList!, 'scrollToMessage');

      element._maybeScrollToMessage('');
      assert.isFalse(scrollStub.called);
      element._maybeScrollToMessage('message');
      assert.isFalse(scrollStub.called);
      element._maybeScrollToMessage('#message-TEST');
      assert.isTrue(scrollStub.called);
      assert.equal(scrollStub.lastCall.args[0], 'TEST');
      done();
    });
  });

  test('topic update reloads related changes', () => {
    const reloadStub = sinon.stub(element.$.relatedChanges, 'reload');
    element.dispatchEvent(new CustomEvent('topic-changed'));
    assert.isTrue(reloadStub.calledOnce);
  });

  test('_computeEditMode', () => {
    const callCompute = (
      range: PatchRange,
      params: AppElementChangeViewParams
    ) =>
      element._computeEditMode(
        {base: range, path: '', value: range},
        {base: params, path: '', value: params}
      );
    assert.isTrue(
      callCompute(
        {basePatchNum: ParentPatchSetNum, patchNum: 1 as PatchSetNum},
        {...createAppElementChangeViewParams(), edit: true}
      )
    );
    assert.isFalse(
      callCompute(
        {basePatchNum: ParentPatchSetNum, patchNum: 1 as PatchSetNum},
        createAppElementChangeViewParams()
      )
    );
    assert.isFalse(
      callCompute(
        {basePatchNum: EditPatchSetNum, patchNum: 1 as PatchSetNum},
        createAppElementChangeViewParams()
      )
    );
    assert.isTrue(
      callCompute(
        {basePatchNum: 1 as PatchSetNum, patchNum: EditPatchSetNum},
        createAppElementChangeViewParams()
      )
    );
  });

  test('_processEdit', () => {
    element._patchRange = {};
    const change: ParsedChangeInfo = {
      ...createChange(),
      current_revision: 'foo' as CommitId,
      revisions: {
        foo: {...createRevision(), actions: {cherrypick: {enabled: true}}},
      },
    };
    let mockChange;

    // With no edit, mockChange should be unmodified.
    element._processEdit((mockChange = _.cloneDeep(change)), false);
    assert.deepEqual(mockChange, change);

    const editCommit: CommitInfo = {
      ...createCommit(),
      commit: 'bar' as CommitId,
    };
    // When edit is not based on the latest PS, current_revision should be
    // unmodified.
    const edit: EditInfo = {
      ref: 'ref/test/abc' as GitRef,
      base_revision: 'abc',
      base_patch_set_number: 1 as PatchSetNum,
      commit: {...editCommit},
      fetch: {},
    };
    element._processEdit((mockChange = _.cloneDeep(change)), edit);
    assert.notDeepEqual(mockChange, change);
    assert.equal(mockChange.revisions.bar._number, EditPatchSetNum);
    assert.equal(mockChange.current_revision, change.current_revision);
    assert.deepEqual(mockChange.revisions.bar.commit, editCommit);
    assert.notOk(mockChange.revisions.bar.actions);

    edit.base_revision = 'foo';
    element._processEdit((mockChange = _.cloneDeep(change)), edit);
    assert.notDeepEqual(mockChange, change);
    assert.equal(mockChange.current_revision, 'bar');
    assert.deepEqual(
      mockChange.revisions.bar.actions,
      mockChange.revisions.foo.actions
    );

    // If _patchRange.patchNum is defined, do not load edit.
    element._patchRange.patchNum = 5 as PatchSetNum;
    change.current_revision = 'baz' as CommitId;
    element._processEdit((mockChange = _.cloneDeep(change)), edit);
    assert.equal(element._patchRange.patchNum, 5 as PatchSetNum);
    assert.notOk(mockChange.revisions.bar.actions);
  });

  test('file-action-tap handling', () => {
    element._patchRange = {
      basePatchNum: ParentPatchSetNum,
      patchNum: 1 as PatchSetNum,
    };
    element._change = {
      ...createChange(),
    };
    const fileList = element.$.fileList;
    const Actions = GrEditConstants.Actions;
    element.$.fileListHeader.editMode = true;
    flush();
    const controls = element.$.fileListHeader.shadowRoot!.querySelector(
      '#editControls'
    ) as GrEditControls;
    const openDeleteDialogStub = sinon.stub(controls, 'openDeleteDialog');
    const openRenameDialogStub = sinon.stub(controls, 'openRenameDialog');
    const openRestoreDialogStub = sinon.stub(controls, 'openRestoreDialog');
    const getEditUrlForDiffStub = sinon.stub(GerritNav, 'getEditUrlForDiff');
    const navigateToRelativeUrlStub = sinon.stub(
      GerritNav,
      'navigateToRelativeUrl'
    );

    // Delete
    fileList.dispatchEvent(
      new CustomEvent('file-action-tap', {
        detail: {action: Actions.DELETE.id, path: 'foo'},
        bubbles: true,
        composed: true,
      })
    );
    flush();

    assert.isTrue(openDeleteDialogStub.called);
    assert.equal(openDeleteDialogStub.lastCall.args[0], 'foo');

    // Restore
    fileList.dispatchEvent(
      new CustomEvent('file-action-tap', {
        detail: {action: Actions.RESTORE.id, path: 'foo'},
        bubbles: true,
        composed: true,
      })
    );
    flush();

    assert.isTrue(openRestoreDialogStub.called);
    assert.equal(openRestoreDialogStub.lastCall.args[0], 'foo');

    // Rename
    fileList.dispatchEvent(
      new CustomEvent('file-action-tap', {
        detail: {action: Actions.RENAME.id, path: 'foo'},
        bubbles: true,
        composed: true,
      })
    );
    flush();

    assert.isTrue(openRenameDialogStub.called);
    assert.equal(openRenameDialogStub.lastCall.args[0], 'foo');

    // Open
    fileList.dispatchEvent(
      new CustomEvent('file-action-tap', {
        detail: {action: Actions.OPEN.id, path: 'foo'},
        bubbles: true,
        composed: true,
      })
    );
    flush();

    assert.isTrue(getEditUrlForDiffStub.called);
    assert.equal(getEditUrlForDiffStub.lastCall.args[1], 'foo');
    assert.equal(getEditUrlForDiffStub.lastCall.args[2], 1 as PatchSetNum);
    assert.isTrue(navigateToRelativeUrlStub.called);
  });

  test('_selectedRevision updates when patchNum is changed', () => {
    const revision1: RevisionInfo = createRevision(1);
    const revision2: RevisionInfo = createRevision(2);
    sinon.stub(element.$.restAPI, 'getChangeDetail').returns(
      Promise.resolve({
        ...createChange(),
        revisions: {
          aaa: revision1,
          bbb: revision2,
        },
        labels: {},
        actions: {},
        current_revision: 'bbb' as CommitId,
      })
    );
    sinon.stub(element, '_getEdit').returns(Promise.resolve(false));
    sinon
      .stub(element, '_getPreferences')
      .returns(Promise.resolve(createPreferences()));
    element._patchRange = {patchNum: 2 as PatchSetNum};
    return element._getChangeDetail().then(() => {
      assert.strictEqual(element._selectedRevision, revision2);

      element.set('_patchRange.patchNum', '1');
      assert.strictEqual(element._selectedRevision, revision1);
    });
  });

  test('_selectedRevision is assigned when patchNum is edit', () => {
    const revision1 = createRevision(1);
    const revision2 = createRevision(2);
    const revision3 = createEditRevision();
    sinon.stub(element.$.restAPI, 'getChangeDetail').returns(
      Promise.resolve({
        ...createChange(),
        revisions: {
          aaa: revision1,
          bbb: revision2,
          ccc: revision3,
        },
        labels: {},
        actions: {},
        current_revision: 'ccc' as CommitId,
      })
    );
    sinon.stub(element, '_getEdit').returns(Promise.resolve(undefined));
    sinon
      .stub(element, '_getPreferences')
      .returns(Promise.resolve(createPreferences()));
    element._patchRange = {patchNum: EditPatchSetNum};
    return element._getChangeDetail().then(() => {
      assert.strictEqual(element._selectedRevision, revision3);
    });
  });

  test('_sendShowChangeEvent', () => {
    const change = {...createChange(), labels: {}};
    element._change = {...change};
    element._patchRange = {patchNum: 4 as PatchSetNum};
    element._mergeable = true;
    const showStub = sinon.stub(element.$.jsAPI, 'handleEvent');
    element._sendShowChangeEvent();
    assert.isTrue(showStub.calledOnce);
    assert.equal(showStub.lastCall.args[0], EventType.SHOW_CHANGE);
    assert.deepEqual(showStub.lastCall.args[1], {
      change,
      patchNum: 4,
      info: {mergeable: true},
    });
  });

  suite('_handleEditTap', () => {
    let fireEdit: () => void;

    setup(() => {
      fireEdit = () => {
        element.$.actions.dispatchEvent(new CustomEvent('edit-tap'));
      };
      navigateToChangeStub.restore();

      element._change = {
        ...createChange(),
        revisions: {rev1: createRevision()},
      };
    });

    test('edit exists in revisions', done => {
      sinon.stub(GerritNav, 'navigateToChange').callsFake((...args) => {
        assert.equal(args.length, 2);
        assert.equal(args[1], EditPatchSetNum); // patchNum
        done();
      });

      element.set('_change.revisions.rev2', {
        _number: SPECIAL_PATCH_SET_NUM.EDIT,
      });
      flush();

      fireEdit();
    });

    test('no edit exists in revisions, non-latest patchset', done => {
      sinon.stub(GerritNav, 'navigateToChange').callsFake((...args) => {
        assert.equal(args.length, 4);
        assert.equal(args[1], 1 as PatchSetNum); // patchNum
        assert.equal(args[3], true); // opt_isEdit
        done();
      });

      element.set('_change.revisions.rev2', {_number: 2});
      element._patchRange = {patchNum: 1 as PatchSetNum};
      flush();

      fireEdit();
    });

    test('no edit exists in revisions, latest patchset', done => {
      sinon.stub(GerritNav, 'navigateToChange').callsFake((...args) => {
        assert.equal(args.length, 4);
        // No patch should be specified when patchNum == latest.
        assert.isNotOk(args[1]); // patchNum
        assert.equal(args[3], true); // opt_isEdit
        done();
      });

      element.set('_change.revisions.rev2', {_number: 2});
      element._patchRange = {patchNum: 2 as PatchSetNum};
      flush();

      fireEdit();
    });
  });

  test('_handleStopEditTap', done => {
    element._change = {
      ...createChange(),
    };
    sinon.stub(element.$.metadata, '_computeLabelNames');
    navigateToChangeStub.restore();
    sinon.stub(GerritNav, 'navigateToChange').callsFake((...args) => {
      assert.equal(args.length, 2);
      assert.equal(args[1], 1 as PatchSetNum); // patchNum
      done();
    });

    element._patchRange = {patchNum: 1 as PatchSetNum};
    element.$.actions.dispatchEvent(
      new CustomEvent('stop-edit-tap', {bubbles: false})
    );
  });

  suite('plugin endpoints', () => {
    test('endpoint params', done => {
      element._change = {...createChange(), labels: {}};
      element._selectedRevision = createRevision();
      let hookEl: HTMLElement;
      let plugin: PluginApi;
      pluginApi.install(
        p => {
          plugin = p;
          plugin
            .hook('change-view-integration')
            .getLastAttached()
            .then(el => (hookEl = el));
        },
        '0.1',
        'http://some/plugins/url.html'
      );
      flush(() => {
        assert.strictEqual((hookEl as any).plugin, plugin);
        assert.strictEqual((hookEl as any).change, element._change);
        assert.strictEqual((hookEl as any).revision, element._selectedRevision);
        done();
      });
    });
  });

  suite('_getMergeability', () => {
    let getMergeableStub: SinonStubbedMember<RestApiService['getMergeable']>;
    setup(() => {
      element._change = {...createChange(), labels: {}};
      getMergeableStub = sinon
        .stub(element.$.restAPI, 'getMergeable')
        .returns(Promise.resolve({...createMergeable(), mergeable: true}));
    });

    test('merged change', () => {
      element._mergeable = null;
      element._change!.status = ChangeStatus.MERGED;
      return element._getMergeability().then(() => {
        assert.isFalse(element._mergeable);
        assert.isFalse(getMergeableStub.called);
      });
    });

    test('abandoned change', () => {
      element._mergeable = null;
      element._change!.status = ChangeStatus.ABANDONED;
      return element._getMergeability().then(() => {
        assert.isFalse(element._mergeable);
        assert.isFalse(getMergeableStub.called);
      });
    });

    test('open change', () => {
      element._mergeable = null;
      return element._getMergeability().then(() => {
        assert.isTrue(element._mergeable);
        assert.isTrue(getMergeableStub.called);
      });
    });
  });

  test('_paramsChanged sets in projectLookup', () => {
    sinon.stub(element.$.relatedChanges, 'reload');
    sinon.stub(element, '_reload').returns(Promise.resolve([]));
    const setStub = sinon.stub(element.$.restAPI, 'setInProjectLookup');
    element._paramsChanged({
      view: GerritNav.View.CHANGE,
      changeNum: 101 as NumericChangeId,
      project: TEST_PROJECT_NAME,
    });
    assert.isTrue(setStub.calledOnce);
    assert.isTrue(
      setStub.calledWith(101 as NumericChangeId, TEST_PROJECT_NAME)
    );
  });

  test('_handleToggleStar called when star is tapped', () => {
    element._change = {
      ...createChange(),
      owner: {_account_id: 1 as AccountId},
      starred: false,
    };
    element._loggedIn = true;
    const stub = sinon.stub(element, '_handleToggleStar');
    flush();

    tap(element.$.changeStar.shadowRoot!.querySelector('button')!);
    assert.isTrue(stub.called);
  });

  suite('gr-reporting tests', () => {
    setup(() => {
      element._patchRange = {
        basePatchNum: ParentPatchSetNum,
        patchNum: 1 as PatchSetNum,
      };
      sinon.stub(element, '_getChangeDetail').returns(Promise.resolve(false));
      sinon.stub(element, '_getProjectConfig').returns(Promise.resolve());
      sinon.stub(element, '_reloadComments').returns(Promise.resolve());
      sinon.stub(element, '_getMergeability').returns(Promise.resolve());
      sinon.stub(element, '_getLatestCommitMessage').returns(Promise.resolve());
    });

    test("don't report changedDisplayed on reply", done => {
      const changeDisplayStub = sinon.stub(
        element.reporting,
        'changeDisplayed'
      );
      const changeFullyLoadedStub = sinon.stub(
        element.reporting,
        'changeFullyLoaded'
      );
      element._handleReplySent();
      flush(() => {
        assert.isFalse(changeDisplayStub.called);
        assert.isFalse(changeFullyLoadedStub.called);
        done();
      });
    });

    test('report changedDisplayed on _paramsChanged', done => {
      const changeDisplayStub = sinon.stub(
        element.reporting,
        'changeDisplayed'
      );
      const changeFullyLoadedStub = sinon.stub(
        element.reporting,
        'changeFullyLoaded'
      );
      element._paramsChanged({
        ...createAppElementChangeViewParams(),
        changeNum: 101 as NumericChangeId,
        project: TEST_PROJECT_NAME,
      });
      flush(() => {
        assert.isTrue(changeDisplayStub.called);
        assert.isTrue(changeFullyLoadedStub.called);
        done();
      });
    });
  });
});
