/*! @license
 * Shaka Player
 * Copyright 2016 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

// The receiver is only meant to run on the Chromecast, so we have the
// ability to use modern APIs there that may not be available on all of the
// browsers our library supports.  Because of this, CastReceiver tests will
// only be run on Chrome and Chromecast.
/** @return {boolean} */
const castReceiverSupport =
    () => shaka.util.Platform.isChrome() || shaka.util.Platform.isChromecast();
filterDescribe('CastReceiver', castReceiverSupport, () => {
  const CastReceiver = shaka.cast.CastReceiver;
  const CastUtils = shaka.cast.CastUtils;
  const Util = shaka.test.Util;

  const originalCast = window['cast'];
  const originalUserAgent = navigator.userAgent;
  const originalPollInterval = CastReceiver.POLL_INTERVAL;
  const originalIdleInterval = CastReceiver.IDLE_INTERVAL;

  /** @type {!shaka.test.FakeVideo} */
  let mockVideo;
  /** @type {!jasmine.Spy} */
  let mockAppDataCallback;
  let mockPlayer;
  let mockReceiverManager;

  let mockReceiverApi;
  let mockShakaMessageBus;
  let mockGenericMessageBus;
  /** @type {!jasmine.Spy} */
  let mockCanDisplayType;

  /** @type {shaka.cast.CastReceiver} */
  let receiver;

  beforeAll(() => {
    // In uncompiled mode, there is a UA check for Chromecast in order to make
    // manual testing easier.  For these automated tests, we want to act as if
    // we are running on the Chromecast, even in Chrome.
    // Since we can't write to window.navigator or navigator.userAgent, we use
    // Object.defineProperty.
    Object.defineProperty(window['navigator'],
        'userAgent', {value: 'CrKey', configurable: true});

    CastReceiver.POLL_INTERVAL = 0.001;
    CastReceiver.IDLE_INTERVAL = 0.001;
  });

  beforeEach(() => {
    mockReceiverApi = createMockReceiverApi();
    mockCanDisplayType = jasmine.createSpy('canDisplayType');
    mockCanDisplayType.and.returnValue(false);

    // We're using quotes to access window.cast because the compiler
    // knows about lots of Cast-specific APIs we aren't mocking.  We
    // don't need this mock strictly type-checked.
    window['cast'] = {
      receiver: mockReceiverApi,
      __platform__: {canDisplayType: mockCanDisplayType},
    };

    mockReceiverManager = createMockReceiverManager();
    mockShakaMessageBus = createMockMessageBus();
    mockGenericMessageBus = createMockMessageBus();
    mockVideo = new shaka.test.FakeVideo();
    mockPlayer = createMockPlayer();
    mockAppDataCallback = jasmine.createSpy('appDataCallback');
  });

  afterEach(async () => {
    if (receiver) {
      await receiver.destroy();
    }
  });

  afterAll(() => {
    if (originalUserAgent) {
      window['cast'] = originalCast;
      Object.defineProperty(window['navigator'],
          'userAgent', {value: originalUserAgent});
    }
    CastReceiver.POLL_INTERVAL = originalPollInterval;
    CastReceiver.IDLE_INTERVAL = originalIdleInterval;
  });

  describe('constructor', () => {
    it('starts the receiver manager', () => {
      receiver = new CastReceiver(
          mockVideo, mockPlayer, Util.spyFunc(mockAppDataCallback));
      expect(mockReceiverManager.start).toHaveBeenCalled();
    });

    it('listens for video and player events', () => {
      receiver = new CastReceiver(
          mockVideo, mockPlayer, Util.spyFunc(mockAppDataCallback));
      expect(Object.keys(mockVideo.on).length).toBeGreaterThan(0);
      expect(Object.keys(mockPlayer.listeners).length).toBeGreaterThan(0);
    });

    it('limits streams to 1080p on Chromecast v1 and v2', () => {
      // Simulate the canDisplayType reponse of Chromecast v1 or v2
      mockCanDisplayType.and.callFake((type) => {
        const matches = /height=(\d+)/.exec(type);
        const height = parseInt(matches[1], 10);
        if (height && height > 1080) {
          return false;
        }
        return true;
      });
      receiver = new CastReceiver(
          mockVideo, mockPlayer, Util.spyFunc(mockAppDataCallback));
      expect(mockCanDisplayType).toHaveBeenCalled();
      expect(mockPlayer.setMaxHardwareResolution)
          .toHaveBeenCalledWith(1920, 1080);
    });

    it('limits streams to 4k on Chromecast Ultra', () => {
      // Simulate the canDisplayType reponse of Chromecast Ultra
      mockCanDisplayType.and.callFake((type) => {
        const matches = /height=(\d+)/.exec(type);
        const height = parseInt(matches[1], 10);
        if (height && height > 2160) {
          return false;
        }
        return true;
      });
      receiver = new CastReceiver(
          mockVideo, mockPlayer, Util.spyFunc(mockAppDataCallback));
      expect(mockCanDisplayType).toHaveBeenCalled();
      expect(mockPlayer.setMaxHardwareResolution)
          .toHaveBeenCalledWith(3840, 2160);
    });

    it('does not start polling', () => {
      receiver = new CastReceiver(
          mockVideo, mockPlayer, Util.spyFunc(mockAppDataCallback));
      expect(mockPlayer.getConfiguration).not.toHaveBeenCalled();
      expect(mockShakaMessageBus.messages.length).toBe(0);
    });
  });

  describe('isConnected', () => {
    beforeEach(() => {
      receiver = new CastReceiver(
          mockVideo, mockPlayer, Util.spyFunc(mockAppDataCallback));
    });

    it('is true when there are senders', () => {
      expect(receiver.isConnected()).toBe(false);
      fakeConnectedSenders(1);
      expect(receiver.isConnected()).toBe(true);
      fakeConnectedSenders(2);
      expect(receiver.isConnected()).toBe(true);
      fakeConnectedSenders(99);
      expect(receiver.isConnected()).toBe(true);
      fakeConnectedSenders(0);
      expect(receiver.isConnected()).toBe(false);
    });
  });

  describe('"caststatuschanged" event', () => {
    beforeEach(() => {
      receiver = new CastReceiver(
          mockVideo, mockPlayer, Util.spyFunc(mockAppDataCallback));
    });

    it('triggers when senders connect or disconnect', async () => {
      /** @type {!jasmine.Spy} */
      const listener = jasmine.createSpy('listener');
      receiver.addEventListener('caststatuschanged', Util.spyFunc(listener));

      await shaka.test.Util.shortDelay();
      expect(listener).not.toHaveBeenCalled();
      fakeConnectedSenders(1);
      await shaka.test.Util.shortDelay();

      expect(listener).toHaveBeenCalled();
      listener.calls.reset();
      mockReceiverManager.onSenderDisconnected();
      await shaka.test.Util.shortDelay();

      expect(listener).toHaveBeenCalled();
    });

    it('triggers when idle state changes', async () => {
      /** @type {!jasmine.Spy} */
      const listener = jasmine.createSpy('listener');
      receiver.addEventListener('caststatuschanged', Util.spyFunc(listener));

      const fakeLoadingEvent = {type: 'loading'};
      const fakeUnloadingEvent = {type: 'unloading'};
      const fakeEndedEvent = {type: 'ended'};
      const fakePlayingEvent = {type: 'playing'};

      await shaka.test.Util.shortDelay();
      expect(listener).not.toHaveBeenCalled();
      expect(receiver.isIdle()).toBe(true);

      mockPlayer.listeners['loading'](fakeLoadingEvent);
      await shaka.test.Util.shortDelay();

      expect(listener).toHaveBeenCalled();
      expect(receiver.isIdle()).toBe(false);
      listener.calls.reset();

      mockPlayer.listeners['unloading'](fakeUnloadingEvent);
      await shaka.test.Util.shortDelay();

      expect(listener).toHaveBeenCalled();
      expect(receiver.isIdle()).toBe(true);
      listener.calls.reset();

      mockVideo.ended = true;
      mockVideo.on['ended'](fakeEndedEvent);
      await shaka.test.Util.shortDelay();

      expect(listener).toHaveBeenCalled();
      listener.calls.reset();
      expect(receiver.isIdle()).toBe(true);

      mockVideo.ended = false;
      mockVideo.on['playing'](fakePlayingEvent);
      await Promise.resolve();

      expect(listener).toHaveBeenCalled();
      expect(receiver.isIdle()).toBe(false);
    });
  });

  describe('local events', () => {
    beforeEach(() => {
      receiver = new CastReceiver(
          mockVideo, mockPlayer, Util.spyFunc(mockAppDataCallback));
    });

    it('trigger "update" and "event" messages', () => {
      fakeConnectedSenders(1);

      // No messages yet.
      expect(mockShakaMessageBus.messages).toEqual([]);
      const fakeEvent = {type: 'timeupdate'};
      mockVideo.on['timeupdate'](fakeEvent);

      // There are now "update" and "event" messages, in that order.
      expect(mockShakaMessageBus.messages).toEqual([
        {
          type: 'update',
          update: jasmine.any(Object),
        },
        {
          type: 'event',
          targetName: 'video',
          event: jasmine.objectContaining(fakeEvent),
        },
      ]);
    });
  });

  describe('"init" message', () => {
    /** @const */
    const fakeConfig = {key: 'value'};
    /** @const */
    const fakeAppData = {myFakeAppData: 1234};
    let fakeInitState;

    beforeEach(() => {
      receiver = new CastReceiver(
          mockVideo, mockPlayer, Util.spyFunc(mockAppDataCallback));

      fakeInitState = {
        manifest: null,
        startTime: null,
        player: {
          configure: fakeConfig,
        },
        playerAfterLoad: {
          setTextTrackVisibility: true,
        },
        video: {
          loop: true,
          playbackRate: 5,
        },
      };
    });

    it('sets initial state', async () => {
      expect(mockVideo.loop).toBe(false);
      expect(mockVideo.playbackRate).toBe(1);
      expect(mockPlayer.configure).not.toHaveBeenCalled();

      fakeIncomingMessage({
        type: 'init',
        initState: fakeInitState,
        appData: fakeAppData,
      }, mockShakaMessageBus);

      // Initial Player state first:
      expect(mockPlayer.configure).toHaveBeenCalledWith(fakeConfig);
      // App data next:
      expect(mockAppDataCallback).toHaveBeenCalledWith(fakeAppData);
      // Nothing else yet:
      expect(mockPlayer.setTextTrackVisibility).not.toHaveBeenCalled();
      expect(mockVideo.loop).toBe(false);
      expect(mockVideo.playbackRate).toBe(1);

      // The rest is done async:
      await shaka.test.Util.shortDelay();
      expect(mockPlayer.setTextTrackVisibility).toHaveBeenCalledWith(
          fakeInitState.playerAfterLoad.setTextTrackVisibility);
      expect(mockVideo.loop).toBe(fakeInitState.video.loop);
      expect(mockVideo.playbackRate).toBe(fakeInitState.video.playbackRate);
    });

    it('starts polling', () => {
      const fakeConfig = {key: 'value'};
      mockPlayer.getConfiguration.and.returnValue(fakeConfig);

      fakeConnectedSenders(1);

      mockPlayer.getConfiguration.calls.reset();

      expect(mockShakaMessageBus.messages.length).toBe(0);
      fakeIncomingMessage({
        type: 'init',
        initState: fakeInitState,
        appData: fakeAppData,
      }, mockShakaMessageBus);

      expect(mockPlayer.getConfiguration).toHaveBeenCalled();
      expect(mockShakaMessageBus.messages).toContain(jasmine.objectContaining({
        type: 'update',
        update: jasmine.objectContaining({
          player: jasmine.objectContaining({
            getConfiguration: fakeConfig,
          }),
        }),
      }));
    });

    it('doesn\'t poll live methods while loading a VOD', () => {
      mockPlayer.getConfiguration.and.returnValue({key: 'value'});
      mockPlayer.isLive.and.returnValue(false);

      fakeConnectedSenders(1);

      expect(mockShakaMessageBus.messages.length).toBe(0);
      fakeIncomingMessage({
        type: 'init',
        initState: fakeInitState,
        appData: fakeAppData,
      }, mockShakaMessageBus);

      expect(mockPlayer.getPlayheadTimeAsDate).not.toHaveBeenCalled();
    });

    it('does poll live methods while loading a livestream', () => {
      mockPlayer.getConfiguration.and.returnValue({key: 'value'});
      mockPlayer.isLive.and.returnValue(true);

      fakeConnectedSenders(1);

      expect(mockShakaMessageBus.messages.length).toBe(0);
      fakeIncomingMessage({
        type: 'init',
        initState: fakeInitState,
        appData: fakeAppData,
      }, mockShakaMessageBus);

      expect(mockPlayer.getPlayheadTimeAsDate).toHaveBeenCalled();
    });

    it('loads the manifest', () => {
      fakeInitState.startTime = 12;
      fakeInitState.manifest = 'foo://bar';
      expect(mockPlayer.load).not.toHaveBeenCalled();

      fakeIncomingMessage({
        type: 'init',
        initState: fakeInitState,
        appData: fakeAppData,
      }, mockShakaMessageBus);

      expect(mockPlayer.load).toHaveBeenCalledWith('foo://bar', 12);
    });

    it('plays the video after loading', async () => {
      fakeInitState.manifest = 'foo://bar';
      mockVideo.autoplay = true;

      fakeIncomingMessage({
        type: 'init',
        initState: fakeInitState,
        appData: fakeAppData,
      }, mockShakaMessageBus);

      // Video autoplay inhibited:
      expect(mockVideo.autoplay).toBe(false);
      await shaka.test.Util.shortDelay();
      expect(mockVideo.play).toHaveBeenCalled();
      // Video autoplay restored:
      expect(mockVideo.autoplay).toBe(true);
    });

    it('does not load or play without a manifest URI', async () => {
      fakeInitState.manifest = null;

      fakeIncomingMessage({
        type: 'init',
        initState: fakeInitState,
        appData: fakeAppData,
      }, mockShakaMessageBus);

      await shaka.test.Util.shortDelay();
      // Nothing loaded or played:
      expect(mockPlayer.load).not.toHaveBeenCalled();
      expect(mockVideo.play).not.toHaveBeenCalled();

      // State was still transferred, though:
      expect(mockPlayer.setTextTrackVisibility).toHaveBeenCalledWith(
          fakeInitState.playerAfterLoad.setTextTrackVisibility);
      expect(mockVideo.loop).toBe(fakeInitState.video.loop);
      expect(mockVideo.playbackRate).toBe(fakeInitState.video.playbackRate);
    });

    it('triggers an "error" event if load fails', async () => {
      fakeInitState.manifest = 'foo://bar';
      const fakeError = new shaka.util.Error(
          shaka.util.Error.Severity.CRITICAL,
          shaka.util.Error.Category.MANIFEST,
          shaka.util.Error.Code.UNABLE_TO_GUESS_MANIFEST_TYPE);
      mockPlayer.load.and.returnValue(Promise.reject(fakeError));

      const listener = jasmine.createSpy('listener');
      mockPlayer.addEventListener('error', listener);
      expect(listener).not.toHaveBeenCalled();

      fakeIncomingMessage({
        type: 'init',
        initState: fakeInitState,
        appData: fakeAppData,
      }, mockShakaMessageBus);

      await shaka.test.Util.shortDelay();
      expect(mockPlayer.load).toHaveBeenCalled();
      expect(mockPlayer.dispatchEvent).toHaveBeenCalledWith(
          jasmine.objectContaining({type: 'error', detail: fakeError}));
    });
  });

  describe('"appData" message', () => {
    beforeEach(() => {
      receiver = new CastReceiver(
          mockVideo, mockPlayer, Util.spyFunc(mockAppDataCallback));
    });

    it('triggers the app data callback', () => {
      expect(mockAppDataCallback).not.toHaveBeenCalled();

      const fakeAppData = {myFakeAppData: 1234};
      fakeIncomingMessage({
        type: 'appData',
        appData: fakeAppData,
      }, mockShakaMessageBus);

      expect(mockAppDataCallback).toHaveBeenCalledWith(fakeAppData);
    });
  });

  describe('"set" message', () => {
    beforeEach(() => {
      receiver = new CastReceiver(
          mockVideo, mockPlayer, Util.spyFunc(mockAppDataCallback));
    });

    it('sets local properties', () => {
      expect(mockVideo.currentTime).toBe(0);
      fakeIncomingMessage({
        type: 'set',
        targetName: 'video',
        property: 'currentTime',
        value: 12,
      }, mockShakaMessageBus);
      expect(mockVideo.currentTime).toBe(12);

      expect(mockPlayer['arbitraryName']).toBe(undefined);
      fakeIncomingMessage({
        type: 'set',
        targetName: 'player',
        property: 'arbitraryName',
        value: 'arbitraryValue',
      }, mockShakaMessageBus);
      expect(mockPlayer['arbitraryName']).toBe('arbitraryValue');
    });

    it('routes volume properties to the receiver manager', () => {
      expect(mockVideo.volume).toBe(1);
      expect(mockVideo.muted).toBe(false);
      expect(mockReceiverManager.setSystemVolumeLevel).not.toHaveBeenCalled();
      expect(mockReceiverManager.setSystemVolumeMuted).not.toHaveBeenCalled();

      fakeIncomingMessage({
        type: 'set',
        targetName: 'video',
        property: 'volume',
        value: 0.5,
      }, mockShakaMessageBus);
      fakeIncomingMessage({
        type: 'set',
        targetName: 'video',
        property: 'muted',
        value: true,
      }, mockShakaMessageBus);

      expect(mockVideo.volume).toBe(1);
      expect(mockVideo.muted).toBe(false);
      expect(mockReceiverManager.setSystemVolumeLevel)
          .toHaveBeenCalledWith(0.5);
      expect(mockReceiverManager.setSystemVolumeMuted)
          .toHaveBeenCalledWith(true);
    });
  });

  describe('"call" message', () => {
    beforeEach(() => {
      receiver = new CastReceiver(
          mockVideo, mockPlayer, Util.spyFunc(mockAppDataCallback));
    });

    it('calls local methods', () => {
      expect(mockVideo.play).not.toHaveBeenCalled();
      fakeIncomingMessage({
        type: 'call',
        targetName: 'video',
        methodName: 'play',
        args: [1, 2, 3],
      }, mockShakaMessageBus);
      expect(mockVideo.play).toHaveBeenCalledWith(1, 2, 3);

      expect(mockPlayer.configure).not.toHaveBeenCalled();
      fakeIncomingMessage({
        type: 'call',
        targetName: 'player',
        methodName: 'configure',
        args: [42],
      }, mockShakaMessageBus);
      expect(mockPlayer.configure).toHaveBeenCalledWith(42);
    });
  });

  describe('"asyncCall" message', () => {
    /** @const */
    const fakeSenderId = 'senderId';
    /** @const */
    const fakeCallId = '5';
    /** @type {!shaka.util.PublicPromise} */
    let p;

    beforeEach(() => {
      receiver = new CastReceiver(
          mockVideo, mockPlayer, Util.spyFunc(mockAppDataCallback));

      fakeConnectedSenders(1);
      p = new shaka.util.PublicPromise();
      mockPlayer.load.and.returnValue(p);

      expect(mockPlayer.load).not.toHaveBeenCalled();
      fakeIncomingMessage({
        type: 'asyncCall',
        id: fakeCallId,
        targetName: 'player',
        methodName: 'load',
        args: ['foo://bar', 12],
      }, mockShakaMessageBus, fakeSenderId);
    });

    it('calls local async methods', () => {
      expect(mockPlayer.load).toHaveBeenCalledWith('foo://bar', 12);
      p.resolve();
    });

    it('sends "asyncComplete" replies when resolved', async () => {
      // No messages have been sent, either broadcast  or privately.
      expect(mockShakaMessageBus.broadcast).not.toHaveBeenCalled();
      expect(mockShakaMessageBus.getCastChannel).not.toHaveBeenCalled();

      p.resolve();
      await shaka.test.Util.shortDelay();
      // No broadcast messages have been sent, but a private message has
      // been sent to the sender who started the async call.
      expect(mockShakaMessageBus.broadcast).not.toHaveBeenCalled();
      expect(mockShakaMessageBus.getCastChannel).toHaveBeenCalledWith(
          fakeSenderId);
      const senderChannel = mockShakaMessageBus.getCastChannel();
      expect(senderChannel.messages).toEqual([{
        type: 'asyncComplete',
        id: fakeCallId,
        error: null,
      }]);
    });

    it('sends "asyncComplete" replies when rejected', async () => {
      // No messages have been sent, either broadcast  or privately.
      expect(mockShakaMessageBus.broadcast).not.toHaveBeenCalled();
      expect(mockShakaMessageBus.getCastChannel).not.toHaveBeenCalled();

      const fakeError = new shaka.util.Error(
          shaka.util.Error.Severity.CRITICAL,
          shaka.util.Error.Category.MANIFEST,
          shaka.util.Error.Code.UNABLE_TO_GUESS_MANIFEST_TYPE);
      p.reject(fakeError);
      await shaka.test.Util.shortDelay();
      // No broadcast messages have been sent, but a private message has
      // been sent to the sender who started the async call.
      expect(mockShakaMessageBus.broadcast).not.toHaveBeenCalled();
      expect(mockShakaMessageBus.getCastChannel).toHaveBeenCalledWith(
          fakeSenderId);
      const senderChannel = mockShakaMessageBus.getCastChannel();
      expect(senderChannel.messages).toEqual([{
        type: 'asyncComplete',
        id: fakeCallId,
        error: jasmine.any(Object),
      }]);
      if (senderChannel.messages.length) {
        const error = senderChannel.messages[0].error;
        shaka.test.Util.expectToEqualError(fakeError, error);
      }
    });
  });

  describe('sends duration', () => {
    beforeEach(async () => {
      receiver = new CastReceiver(
          mockVideo, mockPlayer, Util.spyFunc(mockAppDataCallback));
      fakeConnectedSenders(1);
      mockPlayer.load.and.callFake(() => {
        mockVideo.duration = 1;
        mockPlayer.getAssetUri = () => 'URI A';
        return Promise.resolve();
      });
      fakeIncomingMessage({
        type: 'init',
        initState: {manifest: 'URI A'},
        appData: {},
      }, mockShakaMessageBus);

      // The messages will show up asychronously:
      await Util.shortDelay();
      expectMediaInfo('URI A', 1);
      mockGenericMessageBus.messages = [];
    });

    it('only once, if nothing else changes', async () => {
      await Util.shortDelay();
      expect(mockGenericMessageBus.messages.length).toBe(0);
    });

    it('after new sender connects', async () => {
      fakeConnectedSenders(1);
      await Util.shortDelay();
      expectMediaInfo('URI A', 1);
      expect(mockGenericMessageBus.messages.length).toBe(0);
    });

    it('for correct manifest after loading new', async () => {
      // Change media information, but only after a delay.
      mockPlayer.load.and.callFake(async () => {
        await Util.shortDelay();
        mockVideo.duration = 2;
        mockPlayer.getAssetUri = () => 'URI B';
      });
      fakeIncomingMessage({
        type: 'asyncCall',
        id: '5',
        targetName: 'player',
        methodName: 'load',
        args: ['URI B'],
      }, mockShakaMessageBus, 'senderId');

      // Wait for the mockPlayer to finish 'loading' before checking again.
      await Util.shortDelay();
      await Util.shortDelay();  // Delay again for the delay in "load" above.
      expectMediaInfo('URI B', 2); // pollAttributes_
      expect(mockGenericMessageBus.messages.length).toBe(0);
    });

    it('after LOAD system message', async () => {
      mockPlayer.load.and.callFake(() => {
        mockVideo.duration = 2;
        mockPlayer.getAssetUri = () => 'URI B';
        return Promise.resolve();
      });
      const message = {
        // Arbitrary number
        'requestId': 0,
        'type': 'LOAD',
        'autoplay': false,
        'currentTime': 10,
        'media': {
          'contentId': 'URI B',
          'contentType': 'video/mp4',
          'streamType': 'BUFFERED',
        },
      };
      fakeIncomingMessage(message, mockGenericMessageBus);

      await Util.shortDelay();
      expectMediaInfo('URI B', 2);
      expect(mockGenericMessageBus.messages.length).toBe(0);
    });
  });

  describe('respects generic control messages', () => {
    beforeEach(async () => {
      receiver = new CastReceiver(
          mockVideo, mockPlayer, Util.spyFunc(mockAppDataCallback));
      fakeConnectedSenders(1);

      mockPlayer.load.and.callFake(() => {
        mockVideo.duration = 1;
        mockPlayer.getAssetUri = () => 'URI A';
        return Promise.resolve();
      });
      fakeIncomingMessage({
        type: 'init',
        initState: {manifest: 'URI A'},
        appData: {},
      }, mockShakaMessageBus);

      // The messages will show up asychronously:
      await Util.shortDelay();
      expectMediaInfo('URI A', 1);
      mockGenericMessageBus.messages = [];
    });

    it('get status', () => {
      const message = {
        // Arbitrary number
        'requestId': 0,
        'type': 'GET_STATUS',
      };

      mockGenericMessageBus.broadcast.calls.reset();
      fakeIncomingMessage(message, mockGenericMessageBus);
      expect(mockGenericMessageBus.broadcast).toHaveBeenCalledTimes(1);

      // This covers the lack of scrubber in the Google Home app, as described
      // in https://github.com/google/shaka-player/issues/2606
      expectMediaInfo('URI A', 1);
    });

    it('play', () => {
      const message = {
        // Arbitrary number
        'requestId': 0,
        'type': 'PLAY',
      };

      fakeIncomingMessage(message, mockGenericMessageBus);
      expect(mockVideo.play).toHaveBeenCalled();
    });

    it('pause', () => {
      const message = {
        // Arbitrary number
        'requestId': 0,
        'type': 'PAUSE',
      };

      fakeIncomingMessage(message, mockGenericMessageBus);
      expect(mockVideo.pause).toHaveBeenCalled();
    });

    it('seek', () => {
      const message = {
        // Arbitrary number
        'requestId': 0,
        'type': 'SEEK',
        'resumeState': 'PLAYBACK_START',
        'currentTime': 10,
      };

      fakeIncomingMessage(message, mockGenericMessageBus);
      expect(mockVideo.play).toHaveBeenCalled();
      expect(mockVideo.currentTime).toBe(10);
    });

    it('stop', () => {
      const message = {
        // Arbitrary number
        'requestId': 0,
        'type': 'STOP',
      };

      fakeIncomingMessage(message, mockGenericMessageBus);
      expect(mockPlayer.unload).toHaveBeenCalled();
    });

    it('volume', () => {
      const message = {
        // Arbitrary number
        'requestId': 0,
        'type': 'VOLUME',
        'volume': {
          'level': 0.5,
          'muted': true,
        },
      };

      fakeIncomingMessage(message, mockGenericMessageBus);
      expect(mockVideo.volume).toBe(0.5);
      expect(mockVideo.muted).toBe(true);
    });

    it('load', () => {
      const message = {
        // Arbitrary number
        'requestId': 0,
        'type': 'LOAD',
        'autoplay': false,
        'currentTime': 10,
        'media': {
          'contentId': 'manifestUri',
          'contentType': 'video/mp4',
          'streamType': 'BUFFERED',
        },
      };

      fakeIncomingMessage(message, mockGenericMessageBus);
      expect(mockPlayer.load).toHaveBeenCalled();
    });

    it('dispatches error on unrecognized request type', () => {
      const message = {
        // Arbitrary number
        'requestId': 0,
        'type': 'UNKNOWN_TYPE',
      };

      mockGenericMessageBus.broadcast.calls.reset();
      fakeIncomingMessage(message, mockGenericMessageBus);
      expect(mockGenericMessageBus.broadcast).toHaveBeenCalledTimes(1);
      expect(mockGenericMessageBus.messages).toEqual(jasmine.arrayContaining([
        jasmine.objectContaining({
          requestId: 0,
          type: 'INVALID_REQUEST',
          reason: 'INVALID_COMMAND',
        }),
      ]));
    });
  });

  describe('content metadata methods', () => {
    beforeEach(async () => {
      receiver = new CastReceiver(
          mockVideo, mockPlayer, Util.spyFunc(mockAppDataCallback));
      fakeConnectedSenders(1);

      mockPlayer.load.and.callFake(() => {
        mockVideo.duration = 1;
        mockPlayer.getAssetUri = () => 'URI A';
        return Promise.resolve();
      });
      fakeIncomingMessage({
        type: 'init',
        initState: {manifest: 'URI A'},
        appData: {},
      }, mockShakaMessageBus);

      // The messages will show up asychronously:
      await Util.shortDelay();
      expectMediaInfo('URI A', 1);
      mockGenericMessageBus.messages = [];
    });

    it('setContentMetadata sets arbitrary metadata', () => {
      // In reality, this should follow one of the object formats defined by the
      // Cast SDK.  For unit testing, it can be anything.
      const metadata = {
        foo: 42,
        bar: 'any old bar',
      };
      receiver.setContentMetadata(metadata);

      getStatus();
      expectMediaInfo('URI A', 1, metadata);
    });

    it('setContentTitle sets the title individually', () => {
      const title = 'Title of the Song';
      receiver.setContentTitle(title);

      const expectedMetadata = {
        metadataType: cast.receiver.media.MetadataType.GENERIC,
        title,
      };
      getStatus();
      expectMediaInfo('URI A', 1, expectedMetadata);
    });

    it('setContentImage sets the image individually', () => {
      const imageUrl = 'https://i.ytimg.com/vi/281ax7Ovlsg/hqdefault.jpg';
      receiver.setContentImage(imageUrl);

      const expectedMetadata = {
        metadataType: cast.receiver.media.MetadataType.GENERIC,
        images: [
          {url: imageUrl},
        ],
      };
      getStatus();
      expectMediaInfo('URI A', 1, expectedMetadata);
    });

    it('setContentArtist sets the artist individually', () => {
      const artist = 'Da Vinci\'s Notebook';

      receiver.setContentArtist(artist);

      const expectedMetadata = {
        metadataType: cast.receiver.media.MetadataType.MUSIC_TRACK,
        artist,
      };
      getStatus();
      expectMediaInfo('URI A', 1, expectedMetadata);
    });

    it('setContentArtist creates music metadata after title', () => {
      const title = 'Title of the Song';
      const artist = 'Da Vinci\'s Notebook';
      // https://www.youtube.com/watch?v=734wnHnnNR4

      receiver.setContentTitle(title);
      receiver.setContentArtist(artist);

      const expectedMetadata = {
        metadataType: cast.receiver.media.MetadataType.MUSIC_TRACK,
        title,
        artist,
      };
      getStatus();
      expectMediaInfo('URI A', 1, expectedMetadata);
    });

    it('setContentArtist creates music metadata before title', () => {
      const title = 'Title of the Song';
      const artist = 'Da Vinci\'s Notebook';
      // https://www.youtube.com/watch?v=734wnHnnNR4

      receiver.setContentArtist(artist);
      receiver.setContentTitle(title);

      const expectedMetadata = {
        metadataType: cast.receiver.media.MetadataType.MUSIC_TRACK,
        title,
        artist,
      };
      getStatus();
      expectMediaInfo('URI A', 1, expectedMetadata);
    });

    it('clearContentMetadata clears all metadata', () => {
      const title = 'Title of the Song';

      receiver.setContentTitle(title);
      receiver.clearContentMetadata();

      const expectedMetadata = undefined;
      getStatus();
      expectMediaInfo('URI A', 1, expectedMetadata);
    });

    function getStatus() {
      const message = {
        // Arbitrary number
        'requestId': 0,
        'type': 'GET_STATUS',
      };

      mockGenericMessageBus.broadcast.calls.reset();
      fakeIncomingMessage(message, mockGenericMessageBus);
      expect(mockGenericMessageBus.broadcast).toHaveBeenCalledTimes(1);
    }
  });

  describe('destroy', () => {
    beforeEach(() => {
      receiver = new CastReceiver(
          mockVideo, mockPlayer, Util.spyFunc(mockAppDataCallback));
    });

    it('destroys the local player', async () => {
      expect(mockPlayer.destroy).not.toHaveBeenCalled();
      await receiver.destroy();
      expect(mockPlayer.destroy).toHaveBeenCalled();
    });

    it('stops polling', async () => {
      // Start polling:
      fakeIncomingMessage({
        type: 'init',
        initState: {},
        appData: {},
      }, mockShakaMessageBus);

      mockPlayer.getConfiguration.calls.reset();
      await shaka.test.Util.shortDelay();
      // We have polled at least once, so this getter has been called.
      expect(mockPlayer.getConfiguration).toHaveBeenCalled();
      mockPlayer.getConfiguration.calls.reset();
      // Destroy the receiver.
      await receiver.destroy();

      // Wait another second.
      await shaka.test.Util.shortDelay();

      // We have not polled again since destruction.
      expect(mockPlayer.getConfiguration).not.toHaveBeenCalled();
    });

    it('stops the receiver manager', async () => {
      expect(mockReceiverManager.stop).not.toHaveBeenCalled();
      await receiver.destroy();
      expect(mockReceiverManager.stop).toHaveBeenCalled();
    });
  });

  function createMockReceiverApi() {
    return {
      CastReceiverManager: {
        getInstance: () => mockReceiverManager,
      },
      media: {
        // Defined by the SDK, but we aren't loading it here.
        MetadataType: {
          GENERIC: 0,
          MUSIC_TRACK: 3,
        },
      },
    };
  }

  function createMockReceiverManager() {
    return {
      start: jasmine.createSpy('CastReceiverManager.start'),
      stop: jasmine.createSpy('CastReceiverManager.stop'),
      setSystemVolumeLevel:
          jasmine.createSpy('CastReceiverManager.setSystemVolumeLevel'),
      setSystemVolumeMuted:
          jasmine.createSpy('CastReceiverManager.setSystemVolumeMuted'),
      getSenders: jasmine.createSpy('CastReceiverManager.getSenders'),
      getSystemVolume: () => ({level: 1, muted: false}),
      getCastMessageBus: (namespace) => {
        if (namespace == shaka.cast.CastUtils.SHAKA_MESSAGE_NAMESPACE) {
          return mockShakaMessageBus;
        }

        return mockGenericMessageBus;
      },
    };
  }

  function createMockMessageBus() {
    const bus = {
      messages: [],
      broadcast: jasmine.createSpy('CastMessageBus.broadcast'),
      getCastChannel: jasmine.createSpy('CastMessageBus.getCastChannel'),
    };
    // For convenience, deserialize and store sent messages.
    bus.broadcast.and.callFake((message) => {
      bus.messages.push(CastUtils.deserialize(message));
    });
    const channel = {
      messages: [],
      send: (message) => {
        channel.messages.push(CastUtils.deserialize(message));
      },
    };
    bus.getCastChannel.and.returnValue(channel);
    return bus;
  }

  function createMockPlayer() {
    const player = {
      destroy: jasmine.createSpy('destroy').and.returnValue(Promise.resolve()),
      setMaxHardwareResolution: jasmine.createSpy('setMaxHardwareResolution'),

      addEventListener: (eventName, listener) => {
        player.listeners[eventName] = listener;
      },
      removeEventListener: (eventName, listener) => {
        player.listeners[eventName] = null;
      },
      dispatchEvent: jasmine.createSpy('dispatchEvent'),
      // For convenience:
      listeners: {},
    };

    for (const name of CastUtils.PlayerVoidMethods) {
      player[name] = jasmine.createSpy(name);
    }
    for (const name in CastUtils.PlayerGetterMethods) {
      player[name] = jasmine.createSpy(name);
    }
    for (const name in CastUtils.PlayerGetterMethodsThatRequireLive) {
      player[name] = jasmine.createSpy(name);
    }
    for (const name of CastUtils.PlayerPromiseMethods) {
      player[name] = jasmine.createSpy(name).and.returnValue(Promise.resolve());
    }

    return player;
  }

  /**
   * @param {number} num
   */
  function fakeConnectedSenders(num) {
    const senderArray = [];
    while (num--) {
      senderArray.push('senderId');
    }

    mockReceiverManager.getSenders.and.returnValue(senderArray);
    mockReceiverManager.onSenderConnected();
  }

  /**
   * @param {*} message
   * @param {!cast.receiver.CastMessageBus} bus
   * @param {string=} senderId
   */
  function fakeIncomingMessage(message, bus, senderId) {
    const serialized = CastUtils.serialize(message);
    const messageEvent = {
      senderId: senderId,
      data: serialized,
    };
    bus.onMessage(messageEvent);
  }

  /**
   * @param {string} expectedUri
   * @param {number} expectedDuration
   * @param {Object=} metadata
   */
  function expectMediaInfo(expectedUri, expectedDuration, metadata=null) {
    expect(mockGenericMessageBus.messages.length).toBeGreaterThan(0);
    if (mockGenericMessageBus.messages.length == 0) {
      return;
    }

    const expectedMedia = {
      contentId: expectedUri,
      streamType: 'BUFFERED',
      duration: expectedDuration,
      contentType: '',
    };

    if (metadata) {
      // This field only sometimes shows up.
      // Setting it to null would cause the check below to fail.
      expectedMedia.metadata = metadata;
    }

    expect(mockGenericMessageBus.messages[0]).toEqual(
        {
          requestId: 0,
          type: 'MEDIA_STATUS',
          status: [jasmine.objectContaining({
            media: expectedMedia,
          })],
        }
    );
    mockGenericMessageBus.messages.shift();
  }
});
