/**
 * @license
 * Copyright (C) 2020 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 '../../core/gr-router/gr-router';
import './gr-change-metadata';
import {GerritNav} 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 {GrChangeMetadata} from './gr-change-metadata';
import {
  createServerInfo,
  createUserConfig,
  createParsedChange,
  createAccountWithId,
  createRequirement,
  createCommitInfoWithRequiredCommit,
  createWebLinkInfo,
  createGerritInfo,
  createGitPerson,
  createCommit,
  createRevision,
  createAccountDetailWithId,
  createChangeConfig,
} from '../../../test/test-data-generators';
import {
  ChangeStatus,
  SubmitType,
  RequirementStatus,
  GpgKeyInfoStatus,
} from '../../../constants/constants';
import {
  EmailAddress,
  AccountId,
  CommitId,
  ServerInfo,
  RevisionInfo,
  ParentCommitInfo,
  TopicName,
  ElementPropertyDeepChange,
  PatchSetNum,
  NumericChangeId,
  LabelValueToDescriptionMap,
  Hashtag,
} from '../../../types/common';
import {SinonStubbedMember} from 'sinon/pkg/sinon-esm';
import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
import {tap} from '@polymer/iron-test-helpers/mock-interactions';
import {GrEditableLabel} from '../../shared/gr-editable-label/gr-editable-label';
import {PluginApi} from '../../../api/plugin';
import {GrEndpointDecorator} from '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
import {stubRestApi} from '../../../test/test-utils';
import {ParsedChangeInfo} from '../../../types/types';

const basicFixture = fixtureFromElement('gr-change-metadata');

const pluginApi = _testOnly_initGerritPluginApi();

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

  setup(() => {
    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
    stubRestApi('getConfig').returns(
      Promise.resolve({
        ...createServerInfo(),
        user: {
          ...createUserConfig(),
          anonymous_coward_name: 'test coward name',
        },
      })
    );
    element = basicFixture.instantiate();
  });

  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('computed fields', () => {
    assert.isFalse(
      element._computeHideStrategy({
        ...createParsedChange(),
        status: ChangeStatus.NEW,
      })
    );
    assert.isTrue(
      element._computeHideStrategy({
        ...createParsedChange(),
        status: ChangeStatus.MERGED,
      })
    );
    assert.isTrue(
      element._computeHideStrategy({
        ...createParsedChange(),
        status: ChangeStatus.ABANDONED,
      })
    );
    assert.equal(
      element._computeStrategy({
        ...createParsedChange(),
        submit_type: SubmitType.CHERRY_PICK,
      }),
      'Cherry Pick'
    );
    assert.equal(
      element._computeStrategy({
        ...createParsedChange(),
        submit_type: SubmitType.REBASE_ALWAYS,
      }),
      'Rebase Always'
    );
  });

  test('computed fields requirements', () => {
    assert.isFalse(
      element._computeShowRequirements({
        ...createParsedChange(),
        status: ChangeStatus.MERGED,
      })
    );
    assert.isFalse(
      element._computeShowRequirements({
        ...createParsedChange(),
        status: ChangeStatus.ABANDONED,
      })
    );

    // No labels and no requirements: submit status is useless
    assert.isFalse(
      element._computeShowRequirements({
        ...createParsedChange(),
        status: ChangeStatus.NEW,
        labels: {},
      })
    );

    // Work in Progress: submit status should be present
    assert.isTrue(
      element._computeShowRequirements({
        ...createParsedChange(),
        status: ChangeStatus.NEW,
        labels: {},
        work_in_progress: true,
      })
    );

    // We have at least one reason to display Submit Status
    assert.isTrue(
      element._computeShowRequirements({
        ...createParsedChange(),
        status: ChangeStatus.NEW,
        labels: {
          Verified: {
            approved: createAccountWithId(),
          },
        },
        requirements: [],
      })
    );
    assert.isTrue(
      element._computeShowRequirements({
        ...createParsedChange(),
        status: ChangeStatus.NEW,
        labels: {},
        requirements: [
          {
            ...createRequirement(),
            fallbackText: 'Resolve all comments',
            status: RequirementStatus.OK,
          },
        ],
      })
    );
  });

  test('show strategy for open change', () => {
    element.change = {
      ...createParsedChange(),
      status: ChangeStatus.NEW,
      submit_type: SubmitType.CHERRY_PICK,
      labels: {},
    };
    flush();
    const strategy = element.shadowRoot?.querySelector('.strategy');
    assert.ok(strategy);
    assert.isFalse(strategy?.hasAttribute('hidden'));
    assert.equal(strategy?.children[1].innerHTML, 'Cherry Pick');
  });

  test('hide strategy for closed change', () => {
    element.change = {
      ...createParsedChange(),
      status: ChangeStatus.MERGED,
      labels: {},
    };
    flush();
    assert.isTrue(
      element.shadowRoot?.querySelector('.strategy')?.hasAttribute('hidden')
    );
  });

  test('weblinks use GerritNav interface', () => {
    const weblinksStub = sinon
      .stub(GerritNav, '_generateWeblinks')
      .returns([{name: 'stubb', url: '#s'}]);
    element.commitInfo = createCommitInfoWithRequiredCommit();
    element.serverConfig = createServerInfo();
    flush();
    const webLinks = element.$.webLinks;
    assert.isTrue(weblinksStub.called);
    assert.isFalse(webLinks.hasAttribute('hidden'));
    assert.equal(element._computeWebLinks(element.commitInfo)?.length, 1);
  });

  test('weblinks hidden when no weblinks', () => {
    element.commitInfo = createCommitInfoWithRequiredCommit();
    element.serverConfig = createServerInfo();
    flush();
    const webLinks = element.$.webLinks;
    assert.isTrue(webLinks.hasAttribute('hidden'));
  });

  test('weblinks hidden when only gitiles weblink', () => {
    element.commitInfo = {
      ...createCommitInfoWithRequiredCommit(),
      web_links: [{...createWebLinkInfo(), name: 'gitiles', url: '#'}],
    };
    element.serverConfig = createServerInfo();
    flush();
    const webLinks = element.$.webLinks;
    assert.isTrue(webLinks.hasAttribute('hidden'));
    assert.equal(element._computeWebLinks(element.commitInfo), null);
  });

  test('weblinks hidden when sole weblink is set as primary', () => {
    const browser = 'browser';
    element.commitInfo = {
      ...createCommitInfoWithRequiredCommit(),
      web_links: [{...createWebLinkInfo(), name: browser, url: '#'}],
    };
    element.serverConfig = {
      ...createServerInfo(),
      gerrit: {
        ...createGerritInfo(),
        primary_weblink_name: browser,
      },
    };
    flush();
    const webLinks = element.$.webLinks;
    assert.isTrue(webLinks.hasAttribute('hidden'));
  });

  test('weblinks are visible when other weblinks', () => {
    const router = document.createElement('gr-router');
    sinon
      .stub(GerritNav, '_generateWeblinks')
      .callsFake(router._generateWeblinks.bind(router));

    element.commitInfo = {
      ...createCommitInfoWithRequiredCommit(),
      web_links: [{...createWebLinkInfo(), name: 'test', url: '#'}],
    };
    flush();
    const webLinks = element.$.webLinks;
    assert.isFalse(webLinks.hasAttribute('hidden'));
    assert.equal(element._computeWebLinks(element.commitInfo)?.length, 1);
    // With two non-gitiles weblinks, there are two returned.
    element.commitInfo = {
      ...createCommitInfoWithRequiredCommit(),
      web_links: [
        {...createWebLinkInfo(), name: 'test', url: '#'},
        {...createWebLinkInfo(), name: 'test2', url: '#'},
      ],
    };
    assert.equal(element._computeWebLinks(element.commitInfo)?.length, 2);
  });

  test('weblinks are visible when gitiles and other weblinks', () => {
    const router = document.createElement('gr-router');
    sinon
      .stub(GerritNav, '_generateWeblinks')
      .callsFake(router._generateWeblinks.bind(router));

    element.commitInfo = {
      ...createCommitInfoWithRequiredCommit(),
      web_links: [
        {...createWebLinkInfo(), name: 'test', url: '#'},
        {...createWebLinkInfo(), name: 'gitiles', url: '#'},
      ],
    };
    flush();
    const webLinks = element.$.webLinks;
    assert.isFalse(webLinks.hasAttribute('hidden'));
    // Only the non-gitiles weblink is returned.
    assert.equal(element._computeWebLinks(element.commitInfo)?.length, 1);
  });

  suite('_getNonOwnerRole', () => {
    let change: ParsedChangeInfo | undefined;

    setup(() => {
      change = {
        ...createParsedChange(),
        owner: {
          ...createAccountWithId(),
          email: 'abc@def' as EmailAddress,
          _account_id: 1019328 as AccountId,
        },
        revisions: {
          rev1: {
            ...createRevision(),
            uploader: {
              ...createAccountWithId(),
              email: 'ghi@def' as EmailAddress,
              _account_id: 1011123 as AccountId,
            },
            commit: {
              ...createCommit(),
              author: {...createGitPerson(), email: 'jkl@def'},
              committer: {...createGitPerson(), email: 'ghi@def'},
            },
          },
        },
        current_revision: 'rev1' as CommitId,
      };
    });

    suite('role=uploader', () => {
      test('_getNonOwnerRole for uploader', () => {
        assert.deepEqual(
          element._getNonOwnerRole(change, element._CHANGE_ROLE.UPLOADER),
          {
            ...createAccountWithId(),
            email: 'ghi@def' as EmailAddress,
            _account_id: 1011123 as AccountId,
          }
        );
      });

      test('_getNonOwnerRole that it does not return uploader', () => {
        // Set the uploader email to be the same as the owner.
        change!.revisions.rev1.uploader!._account_id = 1019328 as AccountId;
        assert.isNotOk(
          element._getNonOwnerRole(change, element._CHANGE_ROLE.UPLOADER)
        );
      });

      test('_computeShowRoleClass show uploader', () => {
        assert.equal(
          element._computeShowRoleClass(change, element._CHANGE_ROLE.UPLOADER),
          ''
        );
      });

      test('_computeShowRoleClass hide uploader', () => {
        // Set the uploader email to be the same as the owner.
        change!.revisions.rev1.uploader!._account_id = 1019328 as AccountId;
        assert.equal(
          element._computeShowRoleClass(change, element._CHANGE_ROLE.UPLOADER),
          'hideDisplay'
        );
      });
    });

    suite('role=committer', () => {
      test('_getNonOwnerRole for committer', () => {
        change!.revisions.rev1.uploader!.email = 'ghh@def' as EmailAddress;
        assert.deepEqual(
          element._getNonOwnerRole(change, element._CHANGE_ROLE.COMMITTER),
          {...createGitPerson(), email: 'ghi@def'}
        );
      });

      test('_getNonOwnerRole is null if committer is same as uploader', () => {
        assert.isNotOk(
          element._getNonOwnerRole(change, element._CHANGE_ROLE.COMMITTER)
        );
      });

      test('_getNonOwnerRole that it does not return committer', () => {
        // Set the committer email to be the same as the owner.
        change!.revisions.rev1.commit!.committer.email = 'abc@def';
        assert.isNotOk(
          element._getNonOwnerRole(change, element._CHANGE_ROLE.COMMITTER)
        );
      });

      test('_getNonOwnerRole null for committer with no commit', () => {
        delete change!.revisions.rev1.commit;
        assert.isNotOk(
          element._getNonOwnerRole(change, element._CHANGE_ROLE.COMMITTER)
        );
      });
    });

    suite('role=author', () => {
      test('_getNonOwnerRole for author', () => {
        assert.deepEqual(
          element._getNonOwnerRole(change, element._CHANGE_ROLE.AUTHOR),
          {...createGitPerson(), email: 'jkl@def'}
        );
      });

      test('_getNonOwnerRole that it does not return author', () => {
        // Set the author email to be the same as the owner.
        change!.revisions.rev1.commit!.author.email = 'abc@def';
        assert.isNotOk(
          element._getNonOwnerRole(change, element._CHANGE_ROLE.AUTHOR)
        );
      });

      test('_getNonOwnerRole null for author with no commit', () => {
        delete change!.revisions.rev1.commit;
        assert.isNotOk(
          element._getNonOwnerRole(change, element._CHANGE_ROLE.AUTHOR)
        );
      });
    });
  });

  suite('Push Certificate Validation', () => {
    let serverConfig: ServerInfo | undefined;
    let change: ParsedChangeInfo | undefined;

    setup(() => {
      serverConfig = {
        ...createServerInfo(),
        receive: {
          enable_signed_push: 'true',
        },
      };
      change = {
        ...createParsedChange(),
        revisions: {
          rev1: {
            ...createRevision(1),
            push_certificate: {
              certificate: 'Push certificate',
              key: {
                status: GpgKeyInfoStatus.BAD,
                problems: ['No public keys found for key ID E5E20E52'],
              },
            },
          },
        },
        current_revision: 'rev1' as CommitId,
        status: ChangeStatus.NEW,
        labels: {},
        mergeable: true,
      };
    });

    test('Push Certificate Validation test BAD', () => {
      change!.revisions.rev1!.push_certificate = {
        certificate: 'Push certificate',
        key: {
          status: GpgKeyInfoStatus.BAD,
          problems: ['No public keys found for key ID E5E20E52'],
        },
      };
      const result = element._computePushCertificateValidation(
        serverConfig,
        change
      );
      assert.equal(
        result?.message,
        'Push certificate is invalid:\n' +
          'No public keys found for key ID E5E20E52'
      );
      assert.equal(result?.icon, 'gr-icons:close');
      assert.equal(result?.class, 'invalid');
    });

    test('Push Certificate Validation test TRUSTED', () => {
      change!.revisions.rev1!.push_certificate = {
        certificate: 'Push certificate',
        key: {
          status: GpgKeyInfoStatus.TRUSTED,
        },
      };
      const result = element._computePushCertificateValidation(
        serverConfig,
        change
      );
      assert.equal(
        result?.message,
        'Push certificate is valid and key is trusted'
      );
      assert.equal(result?.icon, 'gr-icons:check');
      assert.equal(result?.class, 'trusted');
    });

    test('Push Certificate Validation is missing test', () => {
      change!.revisions.rev1! = createRevision(1);
      const result = element._computePushCertificateValidation(
        serverConfig,
        change
      );
      assert.equal(
        result?.message,
        'This patch set was created without a push certificate'
      );
      assert.equal(result?.icon, 'gr-icons:help');
      assert.equal(result?.class, 'help');
    });
  });

  test('_computeParents', () => {
    const parents: ParentCommitInfo[] = [
      {...createCommit(), commit: '123' as CommitId, subject: 'abc'},
    ];
    const revision: RevisionInfo = {
      ...createRevision(1),
      commit: {...createCommit(), parents},
    };
    assert.equal(element._computeParents(undefined, revision), parents);
    const change = (current_revision: CommitId): ParsedChangeInfo => {
      return {
        ...createParsedChange(),
        current_revision,
        revisions: {456: revision},
      };
    };
    const change_bad_revision = change('789' as CommitId);
    assert.deepEqual(
      element._computeParents(change_bad_revision, createRevision()),
      []
    );
    const change_no_commit: ParsedChangeInfo = {
      ...createParsedChange(),
      current_revision: '456' as CommitId,
      revisions: {456: createRevision()},
    };
    assert.deepEqual(element._computeParents(change_no_commit, undefined), []);
    const change_good = change('456' as CommitId);
    assert.equal(element._computeParents(change_good, undefined), parents);
  });

  test('_currentParents', () => {
    const revision = (parent: CommitId): RevisionInfo => {
      return {
        ...createRevision(),
        commit: {
          ...createCommit(),
          parents: [{...createCommit(), commit: parent, subject: 'abc'}],
        },
      };
    };
    element.change = {
      ...createParsedChange(),
      current_revision: '456' as CommitId,
      revisions: {456: revision('111' as CommitId)},
      owner: {},
    };
    element.revision = revision('222' as CommitId);
    assert.equal(element._currentParents[0].commit, '222');
    element.revision = revision('333' as CommitId);
    assert.equal(element._currentParents[0].commit, '333');
    element.revision = undefined;
    assert.equal(element._currentParents[0].commit, '111');
    element.change = createParsedChange();
    assert.deepEqual(element._currentParents, []);
  });

  test('_computeParentsLabel', () => {
    const parent: ParentCommitInfo = {
      ...createCommit(),
      commit: 'abc123' as CommitId,
      subject: 'My parent commit',
    };
    assert.equal(element._computeParentsLabel([parent]), 'Parent');
    assert.equal(element._computeParentsLabel([parent, parent]), 'Parents');
  });

  test('_computeParentListClass', () => {
    const parent: ParentCommitInfo = {
      ...createCommit(),
      commit: 'abc123' as CommitId,
      subject: 'My parent commit',
    };
    assert.equal(
      element._computeParentListClass([parent], true),
      'parentList nonMerge current'
    );
    assert.equal(
      element._computeParentListClass([parent], false),
      'parentList nonMerge notCurrent'
    );
    assert.equal(
      element._computeParentListClass([parent, parent], false),
      'parentList merge notCurrent'
    );
    assert.equal(
      element._computeParentListClass([parent, parent], true),
      'parentList merge current'
    );
  });

  test('_showAddTopic', () => {
    const changeRecord: ElementPropertyDeepChange<
      GrChangeMetadata,
      'change'
    > = {
      base: {...createParsedChange()},
      path: '',
      value: undefined,
    };
    assert.isTrue(element._showAddTopic(undefined, false));
    assert.isTrue(element._showAddTopic(changeRecord, false));
    assert.isFalse(element._showAddTopic(changeRecord, true));
    changeRecord.base!.topic = 'foo' as TopicName;
    assert.isFalse(element._showAddTopic(changeRecord, true));
    assert.isFalse(element._showAddTopic(changeRecord, false));
  });

  test('_showTopicChip', () => {
    const changeRecord: ElementPropertyDeepChange<
      GrChangeMetadata,
      'change'
    > = {
      base: {...createParsedChange()},
      path: '',
      value: undefined,
    };
    assert.isFalse(element._showTopicChip(undefined, false));
    assert.isFalse(element._showTopicChip(changeRecord, false));
    assert.isFalse(element._showTopicChip(changeRecord, true));
    changeRecord.base!.topic = 'foo' as TopicName;
    assert.isFalse(element._showTopicChip(changeRecord, true));
    assert.isTrue(element._showTopicChip(changeRecord, false));
  });

  test('_showCherryPickOf', () => {
    const changeRecord: ElementPropertyDeepChange<
      GrChangeMetadata,
      'change'
    > = {
      base: {...createParsedChange()},
      path: '',
      value: undefined,
    };
    assert.isFalse(element._showCherryPickOf(undefined));
    assert.isFalse(element._showCherryPickOf(changeRecord));
    changeRecord.base!.cherry_pick_of_change = 123 as NumericChangeId;
    changeRecord.base!.cherry_pick_of_patch_set = 1 as PatchSetNum;
    assert.isTrue(element._showCherryPickOf(changeRecord));
  });

  suite('Topic removal', () => {
    let change: ParsedChangeInfo;
    setup(() => {
      change = {
        ...createParsedChange(),
        actions: {
          topic: {enabled: false},
        },
        topic: 'the topic' as TopicName,
        status: ChangeStatus.NEW,
        submit_type: SubmitType.CHERRY_PICK,
        labels: {
          test: {
            all: [{_account_id: 1 as AccountId, name: 'bojack', value: 1}],
            default_value: 0,
            values: ([] as unknown) as LabelValueToDescriptionMap,
          },
        },
        removable_reviewers: [],
      };
    });

    test('_computeTopicReadOnly', () => {
      let mutable = false;
      assert.isTrue(element._computeTopicReadOnly(mutable, change));
      mutable = true;
      assert.isTrue(element._computeTopicReadOnly(mutable, change));
      change!.actions!.topic!.enabled = true;
      assert.isFalse(element._computeTopicReadOnly(mutable, change));
      mutable = false;
      assert.isTrue(element._computeTopicReadOnly(mutable, change));
    });

    test('topic read only hides delete button', () => {
      element.account = createAccountDetailWithId();
      element.change = change;
      flush();
      const button = element!
        .shadowRoot!.querySelector('gr-linked-chip')!
        .shadowRoot!.querySelector('gr-button');
      assert.isTrue(button?.hasAttribute('hidden'));
    });

    test('topic not read only does not hide delete button', () => {
      element.account = createAccountDetailWithId();
      change.actions!.topic!.enabled = true;
      element.change = change;
      flush();
      const button = element!
        .shadowRoot!.querySelector('gr-linked-chip')!
        .shadowRoot!.querySelector('gr-button');
      assert.isFalse(button?.hasAttribute('hidden'));
    });
  });

  suite('Hashtag removal', () => {
    let change: ParsedChangeInfo;
    setup(() => {
      change = {
        ...createParsedChange(),
        actions: {
          hashtags: {enabled: false},
        },
        hashtags: ['test-hashtag' as Hashtag],
        labels: {
          test: {
            all: [{_account_id: 1 as AccountId, name: 'bojack', value: 1}],
            default_value: 0,
            values: ([] as unknown) as LabelValueToDescriptionMap,
          },
        },
        removable_reviewers: [],
      };
    });

    test('_computeHashtagReadOnly', () => {
      flush();
      let mutable = false;
      assert.isTrue(element._computeHashtagReadOnly(mutable, change));
      mutable = true;
      assert.isTrue(element._computeHashtagReadOnly(mutable, change));
      change!.actions!.hashtags!.enabled = true;
      assert.isFalse(element._computeHashtagReadOnly(mutable, change));
      mutable = false;
      assert.isTrue(element._computeHashtagReadOnly(mutable, change));
    });

    test('hashtag read only hides delete button', () => {
      flush();
      element.account = createAccountDetailWithId();
      element.change = change;
      flush();
      const button = element!
        .shadowRoot!.querySelector('gr-linked-chip')!
        .shadowRoot!.querySelector('gr-button');
      assert.isTrue(button?.hasAttribute('hidden'));
    });

    test('hashtag not read only does not hide delete button', () => {
      flush();
      element.account = createAccountDetailWithId();
      change!.actions!.hashtags!.enabled = true;
      element.change = change;
      flush();
      const button = element!
        .shadowRoot!.querySelector('gr-linked-chip')!
        .shadowRoot!.querySelector('gr-button');
      assert.isFalse(button?.hasAttribute('hidden'));
    });
  });

  suite('remove reviewer votes', () => {
    setup(() => {
      sinon.stub(element, '_computeTopicReadOnly').returns(true);
      element.change = {
        ...createParsedChange(),
        topic: 'the topic' as TopicName,
        labels: {
          test: {
            all: [{_account_id: 1 as AccountId, name: 'bojack', value: 1}],
            default_value: 0,
            values: ([] as unknown) as LabelValueToDescriptionMap,
          },
        },
        removable_reviewers: [],
      };
      flush();
    });

    suite('assignee field', () => {
      const dummyAccount = createAccountWithId();
      const change: ParsedChangeInfo = {
        ...createParsedChange(),
        actions: {
          assignee: {enabled: false},
        },
        assignee: dummyAccount,
      };
      let deleteStub: SinonStubbedMember<RestApiService['deleteAssignee']>;
      let setStub: SinonStubbedMember<RestApiService['setAssignee']>;

      setup(() => {
        deleteStub = stubRestApi('deleteAssignee');
        setStub = stubRestApi('setAssignee');
        element.serverConfig = {
          ...createServerInfo(),
          change: {
            ...createChangeConfig(),
            enable_assignee: true,
          },
        };
      });

      test('changing change recomputes _assignee', () => {
        assert.isFalse(!!element._assignee?.length);
        const change = element.change;
        change!.assignee = dummyAccount;
        element._changeChanged(change);
        assert.deepEqual(element?._assignee?.[0], dummyAccount);
      });

      test('modifying _assignee calls API', () => {
        assert.isFalse(!!element._assignee?.length);
        element.set('_assignee', [dummyAccount]);
        assert.isTrue(setStub.calledOnce);
        assert.deepEqual(element.change!.assignee, dummyAccount);
        element.set('_assignee', [dummyAccount]);
        assert.isTrue(setStub.calledOnce);
        element.set('_assignee', []);
        assert.isTrue(deleteStub.calledOnce);
        assert.equal(element.change!.assignee, undefined);
        element.set('_assignee', []);
        assert.isTrue(deleteStub.calledOnce);
      });

      test('_computeAssigneeReadOnly', () => {
        let mutable = false;
        assert.isTrue(element._computeAssigneeReadOnly(mutable, change));
        mutable = true;
        assert.isTrue(element._computeAssigneeReadOnly(mutable, change));
        change.actions!.assignee!.enabled = true;
        assert.isFalse(element._computeAssigneeReadOnly(mutable, change));
        mutable = false;
        assert.isTrue(element._computeAssigneeReadOnly(mutable, change));
      });
    });

    test('changing topic', () => {
      const newTopic = 'the new topic' as TopicName;
      const setChangeTopicStub = stubRestApi('setChangeTopic').returns(
        Promise.resolve(newTopic)
      );
      element._handleTopicChanged(new CustomEvent('test', {detail: newTopic}));
      const topicChangedSpy = sinon.spy();
      element.addEventListener('topic-changed', topicChangedSpy);
      assert.isTrue(
        setChangeTopicStub.calledWith(42 as NumericChangeId, newTopic)
      );
      return setChangeTopicStub.lastCall.returnValue.then(() => {
        assert.equal(element.change!.topic, newTopic);
        assert.isTrue(topicChangedSpy.called);
      });
    });

    test('topic removal', () => {
      const newTopic = 'the new topic' as TopicName;
      const setChangeTopicStub = stubRestApi('setChangeTopic').returns(
        Promise.resolve(newTopic)
      );
      const chip = element.shadowRoot!.querySelector('gr-linked-chip');
      const remove = chip!.$.remove;
      const topicChangedSpy = sinon.spy();
      element.addEventListener('topic-changed', topicChangedSpy);
      tap(remove);
      assert.isTrue(chip?.disabled);
      assert.isTrue(setChangeTopicStub.calledWith(42 as NumericChangeId));
      return setChangeTopicStub.lastCall.returnValue.then(() => {
        assert.isFalse(chip?.disabled);
        assert.equal(element.change!.topic, '' as TopicName);
        assert.isTrue(topicChangedSpy.called);
      });
    });

    test('changing hashtag', () => {
      flush();
      element._newHashtag = 'new hashtag' as Hashtag;
      const newHashtag: Hashtag[] = ['new hashtag' as Hashtag];
      const setChangeHashtagStub = stubRestApi('setChangeHashtag').returns(
        Promise.resolve(newHashtag)
      );
      element._handleHashtagChanged();
      assert.isTrue(
        setChangeHashtagStub.calledWith(42 as NumericChangeId, {
          add: ['new hashtag' as Hashtag],
        })
      );
      return setChangeHashtagStub.lastCall.returnValue.then(() => {
        assert.equal(element.change!.hashtags, newHashtag);
      });
    });
  });

  test('editTopic', () => {
    element.account = createAccountDetailWithId();
    element.change = {
      ...createParsedChange(),
      actions: {topic: {enabled: true}},
    };
    flush();

    const label = element.shadowRoot!.querySelector(
      '.topicEditableLabel'
    ) as GrEditableLabel;
    assert.ok(label);
    const openStub = sinon.stub(label, 'open');
    element.editTopic();
    flush();

    assert.isTrue(openStub.called);
  });

  suite('plugin endpoints', () => {
    test('endpoint params', done => {
      element.change = createParsedChange();
      element.revision = createRevision();
      interface MetadataGrEndpointDecorator extends GrEndpointDecorator {
        plugin: PluginApi;
        change: ParsedChangeInfo;
        revision: RevisionInfo;
      }
      let hookEl: MetadataGrEndpointDecorator;
      let plugin: PluginApi;
      pluginApi.install(
        p => {
          plugin = p;
          plugin
            .hook('change-metadata-item')
            .getLastAttached()
            .then(el => (hookEl = el as MetadataGrEndpointDecorator));
        },
        '0.1',
        'http://some/plugins/url.js'
      );
      getPluginLoader().loadPlugins([]);
      flush(() => {
        assert.strictEqual(hookEl!.plugin, plugin);
        assert.strictEqual(hookEl!.change, element.change);
        assert.strictEqual(hookEl!.revision, element.revision);
        done();
      });
    });
  });
});
