// Copyright 2014 The Closure Library Authors. All Rights Reserved.
//
// 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.

goog.provide('goog.labs.pubsub.BroadcastPubSubTest');
goog.setTestOnly('goog.labs.pubsub.BroadcastPubSubTest');


goog.require('goog.array');
goog.require('goog.debug.Logger');
goog.require('goog.json');
goog.require('goog.labs.pubsub.BroadcastPubSub');
goog.require('goog.storage.Storage');
goog.require('goog.structs.Map');
goog.require('goog.testing.MockClock');
goog.require('goog.testing.MockControl');
goog.require('goog.testing.events');
goog.require('goog.testing.events.Event');
goog.require('goog.testing.jsunit');
goog.require('goog.testing.mockmatchers');
goog.require('goog.testing.mockmatchers.ArgumentMatcher');
goog.require('goog.testing.recordFunction');
goog.require('goog.userAgent');


/** @type {goog.labs.pubsub.BroadcastPubSub} */
var broadcastPubSub;


/** @type {goog.testing.MockControl} */
var mockControl;


/** @type {goog.testing.MockClock} */
var mockClock;


/** @type {goog.testing.MockInterface} */
var mockStorage;


/** @type {goog.testing.MockInterface} */
var mockStorageCtor;


/** @type {goog.structs.Map} */
var mockHtml5LocalStorage;


/** @type {goog.testing.MockInterface} */
var mockHTML5LocalStorageCtor;


/** @const {boolean} */
var isIe8 = goog.userAgent.IE && goog.userAgent.DOCUMENT_MODE == 8;


/**
 * Sends a remote storage event with special handling for IE8. With IE8 an
 * event is pushed to the event queue stored in local storage as a result of
 * behaviour by the mockHtml5LocalStorage instanciated when using IE8 an event
 * is automatically generated in the local browser context. For other browsers
 * this simply creates a new browser event.
 * @param {{'args': !Array<string>, 'timestamp': number}} data Value stored
 *     in localStorage which generated the remote event.
 */
function remoteStorageEvent(data) {
  if (!isIe8) {
    var event = new goog.testing.events.Event('storage', window);
    event.key = goog.labs.pubsub.BroadcastPubSub.STORAGE_KEY_;
    event.newValue = goog.json.serialize(data);
    goog.testing.events.fireBrowserEvent(event);
  } else {
    var uniqueKey =
        goog.labs.pubsub.BroadcastPubSub.IE8_EVENTS_KEY_PREFIX_ +
        '1234567890';
    var ie8Events = mockHtml5LocalStorage.get(uniqueKey);
    if (goog.isDefAndNotNull(ie8Events)) {
      ie8Events = goog.json.parse(ie8Events);
      // Events should never overlap in IE8 mode.
      if (ie8Events.length > 0 &&
          ie8Events[ie8Events.length - 1]['timestamp'] >=
          data['timestamp']) {
        data['timestamp'] =
            ie8Events[ie8Events.length - 1]['timestamp'] +
            goog.labs.pubsub.BroadcastPubSub.
            IE8_TIMESTAMP_UNIQUE_OFFSET_MS_;
      }
    } else {
      ie8Events = [];
    }
    ie8Events.push(data);
    // This will cause an event.
    mockHtml5LocalStorage.set(uniqueKey, goog.json.serialize(ie8Events));
  }
}

function setUp() {
  mockControl = new goog.testing.MockControl();

  mockClock = new goog.testing.MockClock(true);
  // Time should never be 0...
  mockClock.tick();
  /** @suppress {missingRequire} */
  mockHTML5LocalStorageCtor = mockControl.createConstructorMock(
      goog.storage.mechanism, 'HTML5LocalStorage');

  mockHtml5LocalStorage = new goog.structs.Map();

  // The builtin localStorage returns null instead of undefined.
  var originalGetFn = goog.bind(mockHtml5LocalStorage.get,
      mockHtml5LocalStorage);
  mockHtml5LocalStorage.get = function(key) {
    var value = originalGetFn(key);
    if (!goog.isDef(value)) {
      return null;
    }
    return value;
  };
  mockHtml5LocalStorage.key = function(idx) {
    return mockHtml5LocalStorage.getKeys()[idx];
  };
  mockHtml5LocalStorage.isAvailable = function() {
    return true;
  };


  // IE has problems. IE9+ still dispatches storage events locally. IE8 also
  // doesn't include the key/value information. So for IE, everytime we get a
  // "set" on localStorage we simulate for the appropriate browser.
  if (goog.userAgent.IE) {
    var target = isIe8 ? document : window;
    var originalSetFn = goog.bind(mockHtml5LocalStorage.set,
        mockHtml5LocalStorage);
    mockHtml5LocalStorage.set = function(key, value) {
      originalSetFn(key, value);
      var event = new goog.testing.events.Event('storage', target);
      if (!isIe8) {
        event.key = key;
        event.newValue = value;
      }
      goog.testing.events.fireBrowserEvent(event);
    };
  }

}

function tearDown() {
  mockControl.$tearDown();
  mockClock.dispose();
  broadcastPubSub = undefined;
}


function testConstructor() {
  mockHTML5LocalStorageCtor().$returns(mockHtml5LocalStorage);
  mockControl.$replayAll();
  broadcastPubSub = new goog.labs.pubsub.BroadcastPubSub();
  broadcastPubSub.logger_.setLevel(goog.debug.Logger.Level.OFF);
  assertNotNullNorUndefined(
      'BroadcastChannel instance must not be null', broadcastPubSub);
  assertTrue('BroadcastChannel instance must have the expected type',
      broadcastPubSub instanceof goog.labs.pubsub.BroadcastPubSub);
  assertArrayEquals(
      goog.labs.pubsub.BroadcastPubSub.instances_, [broadcastPubSub]);
  broadcastPubSub.dispose();
  mockControl.$verifyAll();
  assertNotNullNorUndefined(
      'Storage should not be undefined or null in broadcastPubSub.',
      broadcastPubSub.storage_);
  assertArrayEquals(goog.labs.pubsub.BroadcastPubSub.instances_, []);
}


function testConstructor_noLocalStorage() {
  mockHTML5LocalStorageCtor().$returns({isAvailable: function() {
    return false;
  }});
  mockControl.$replayAll();
  broadcastPubSub = new goog.labs.pubsub.BroadcastPubSub();
  broadcastPubSub.logger_.setLevel(goog.debug.Logger.Level.OFF);
  assertNotNullNorUndefined(
      'BroadcastChannel instance must not be null', broadcastPubSub);
  assertTrue('BroadcastChannel instance must have the expected type',
      broadcastPubSub instanceof goog.labs.pubsub.BroadcastPubSub);
  assertArrayEquals(
      goog.labs.pubsub.BroadcastPubSub.instances_, [broadcastPubSub]);
  broadcastPubSub.dispose();
  mockControl.$verifyAll();
  assertNull(
      'Storage should be null in broadcastPubSub.', broadcastPubSub.storage_);
  assertArrayEquals(goog.labs.pubsub.BroadcastPubSub.instances_, []);
}


/** Verify we cleanup after ourselves. */
function testDispose() {
  mockHTML5LocalStorageCtor().$returns(mockHtml5LocalStorage);
  mockHTML5LocalStorageCtor().$returns(mockHtml5LocalStorage);
  var mockStorage = mockControl.createLooseMock(goog.storage.Storage);

  var mockStorageCtor = mockControl.createConstructorMock(
      goog.storage, 'Storage');

  mockStorageCtor(mockHtml5LocalStorage).$returns(mockStorage);
  mockStorageCtor(mockHtml5LocalStorage).$returns(mockStorage);

  if (isIe8) {
    mockStorage.remove(
        goog.labs.pubsub.BroadcastPubSub.IE8_EVENTS_KEY_);
  }

  mockControl.$replayAll();
  broadcastPubSub = new goog.labs.pubsub.BroadcastPubSub();
  broadcastPubSub.logger_.setLevel(goog.debug.Logger.Level.OFF);

  var broadcastPubSubExtra = new goog.labs.pubsub.BroadcastPubSub();
  assertArrayEquals(goog.labs.pubsub.BroadcastPubSub.instances_,
      [broadcastPubSub, broadcastPubSubExtra]);

  assertFalse('BroadcastChannel extra instance must not have been disposed of',
      broadcastPubSubExtra.isDisposed());
  broadcastPubSubExtra.dispose();
  assertTrue('BroadcastChannel extra instance must have been disposed of',
      broadcastPubSubExtra.isDisposed());
  assertFalse('BroadcastChannel instance must not have been disposed of',
      broadcastPubSub.isDisposed());

  assertArrayEquals(
      goog.labs.pubsub.BroadcastPubSub.instances_, [broadcastPubSub]);
  assertFalse('BroadcastChannel instance must not have been disposed of',
      broadcastPubSub.isDisposed());
  broadcastPubSub.dispose();
  assertTrue('BroadcastChannel instance must have been disposed of',
      broadcastPubSub.isDisposed());
  assertArrayEquals(goog.labs.pubsub.BroadcastPubSub.instances_, []);
  mockControl.$verifyAll();
}


/**
 * Tests related to remote events that an instance of BroadcastChannel
 * should handle.
 */
function testHandleRemoteEvent() {
  mockHTML5LocalStorageCtor().$returns(mockHtml5LocalStorage);
  var foo = mockControl.createFunctionMock();
  foo('x', 'y').$times(2);

  var context = {'foo': 'bar'};
  var bar = goog.testing.recordFunction();

  mockControl.$replayAll();
  broadcastPubSub = new goog.labs.pubsub.BroadcastPubSub();
  broadcastPubSub.logger_.setLevel(goog.debug.Logger.Level.OFF);
  var eventData = {
    'args': ['someTopic', 'x', 'y'],
    'timestamp': goog.now()
  };

  broadcastPubSub.subscribe('someTopic', foo);
  broadcastPubSub.subscribe('someTopic', bar, context);

  remoteStorageEvent(eventData);
  mockClock.tick();

  assertEquals(1, bar.getCallCount());
  assertEquals(context, bar.getLastCall().getThis());
  assertArrayEquals(['x', 'y'], bar.getLastCall().getArguments());

  broadcastPubSub.unsubscribe('someTopic', foo);
  eventData['timestamp'] = goog.now();
  remoteStorageEvent(eventData);
  mockClock.tick();

  assertEquals(2, bar.getCallCount());
  assertEquals(context, bar.getLastCall().getThis());
  assertArrayEquals(['x', 'y'], bar.getLastCall().getArguments());

  broadcastPubSub.subscribe('someTopic', foo);
  broadcastPubSub.unsubscribe('someTopic', bar, context);
  eventData['timestamp'] = goog.now();
  remoteStorageEvent(eventData);
  mockClock.tick();

  assertEquals(2, bar.getCallCount());
  broadcastPubSub.dispose();
  mockControl.$verifyAll();
}


function testHandleRemoteEventSubscribeOnce() {
  mockHTML5LocalStorageCtor().$returns(mockHtml5LocalStorage);
  var foo = mockControl.createFunctionMock();
  foo('x', 'y');

  mockControl.$replayAll();
  broadcastPubSub = new goog.labs.pubsub.BroadcastPubSub();
  broadcastPubSub.logger_.setLevel(goog.debug.Logger.Level.OFF);

  broadcastPubSub.subscribeOnce('someTopic', foo);
  assertEquals('BroadcastChannel must have one subscriber',
      1, broadcastPubSub.getCount());

  remoteStorageEvent({
    'args': ['someTopic', 'x', 'y'],
    'timestamp': goog.now()
  });
  mockClock.tick();

  assertEquals(
      'BroadcastChannel must have no subscribers after receiving the event',
      0, broadcastPubSub.getCount());
  broadcastPubSub.dispose();
  mockControl.$verifyAll();
}


function testHandleQueuedRemoteEvents() {
  mockHTML5LocalStorageCtor().$returns(mockHtml5LocalStorage);
  var foo = mockControl.createFunctionMock();
  var bar = mockControl.createFunctionMock();

  foo('x', 'y');
  bar('d', 'c');

  mockControl.$replayAll();

  broadcastPubSub = new goog.labs.pubsub.BroadcastPubSub();
  broadcastPubSub.logger_.setLevel(goog.debug.Logger.Level.OFF);
  broadcastPubSub.subscribe('fooTopic', foo);
  broadcastPubSub.subscribe('barTopic', bar);

  var eventData = {
    'args': ['fooTopic', 'x', 'y'],
    'timestamp': goog.now()
  };
  remoteStorageEvent(eventData);

  var eventData = {
    'args': ['barTopic', 'd', 'c'],
    'timestamp': goog.now()
  };
  remoteStorageEvent(eventData);
  mockClock.tick();

  broadcastPubSub.dispose();
  mockControl.$verifyAll();
}


function testHandleRemoteEventsUnsubscribe() {
  mockHTML5LocalStorageCtor().$returns(mockHtml5LocalStorage);
  var foo = mockControl.createFunctionMock();
  var bar = mockControl.createFunctionMock();

  foo('x', 'y').$does(function() {
    broadcastPubSub.unsubscribe('barTopic', bar);
  });

  mockControl.$replayAll();

  broadcastPubSub = new goog.labs.pubsub.BroadcastPubSub();
  broadcastPubSub.logger_.setLevel(goog.debug.Logger.Level.OFF);
  broadcastPubSub.subscribe('fooTopic', foo);
  broadcastPubSub.subscribe('barTopic', bar);

  var eventData = {
    'args': ['fooTopic', 'x', 'y'],
    'timestamp': goog.now()
  };
  remoteStorageEvent(eventData);
  mockClock.tick();

  var eventData = {
    'args': ['barTopic', 'd', 'c'],
    'timestamp': goog.now()
  };
  remoteStorageEvent(eventData);
  mockClock.tick();

  broadcastPubSub.dispose();
  mockControl.$verifyAll();
}


function testHandleRemoteEventsCalledOnce() {
  mockHTML5LocalStorageCtor().$returns(mockHtml5LocalStorage);
  var foo = mockControl.createFunctionMock();
  foo('x', 'y');

  mockControl.$replayAll();

  broadcastPubSub = new goog.labs.pubsub.BroadcastPubSub();
  broadcastPubSub.logger_.setLevel(goog.debug.Logger.Level.OFF);
  broadcastPubSub.subscribeOnce('someTopic', foo);

  var eventData = {
    'args': ['someTopic', 'x', 'y'],
    'timestamp': goog.now()
  };
  remoteStorageEvent(eventData);
  mockClock.tick();

  var eventData = {
    'args': ['someTopic', 'x', 'y'],
    'timestamp': goog.now()
  };
  remoteStorageEvent(eventData);
  mockClock.tick();

  broadcastPubSub.dispose();
  mockControl.$verifyAll();
}


function testHandleRemoteEventNestedPublish() {
  mockHTML5LocalStorageCtor().$returns(mockHtml5LocalStorage);
  var foo1 = mockControl.createFunctionMock();
  foo1().$does(function() {
    remoteStorageEvent({
      'args': ['bar'],
      'timestamp': goog.now()
    });
  });
  var foo2 = mockControl.createFunctionMock();
  foo2();
  var bar1 = mockControl.createFunctionMock();
  bar1().$does(function() {
    broadcastPubSub.publish('baz');
  });
  var bar2 = mockControl.createFunctionMock();
  bar2();
  var baz1 = mockControl.createFunctionMock();
  baz1();
  var baz2 = mockControl.createFunctionMock();
  baz2();

  mockControl.$replayAll();
  broadcastPubSub = new goog.labs.pubsub.BroadcastPubSub();
  broadcastPubSub.logger_.setLevel(goog.debug.Logger.Level.OFF);

  broadcastPubSub.subscribe('foo', foo1);
  broadcastPubSub.subscribe('foo', foo2);
  broadcastPubSub.subscribe('bar', bar1);
  broadcastPubSub.subscribe('bar', bar2);
  broadcastPubSub.subscribe('baz', baz1);
  broadcastPubSub.subscribe('baz', baz2);

  remoteStorageEvent({
    'args': ['foo'],
    'timestamp': goog.now()
  });
  mockClock.tick();
  broadcastPubSub.dispose();
  mockControl.$verifyAll();
}


/**
 * Local publish that originated from another instance of BroadcastChannel
 * in the same Javascript context.
 */
function testSecondInstancePublish() {
  mockHTML5LocalStorageCtor().$returns(mockHtml5LocalStorage).$times(2);
  var foo = mockControl.createFunctionMock();
  foo('x', 'y');
  var context = {'foo': 'bar'};
  var bar = goog.testing.recordFunction();

  mockControl.$replayAll();

  broadcastPubSub = new goog.labs.pubsub.BroadcastPubSub();
  broadcastPubSub.logger_.setLevel(goog.debug.Logger.Level.OFF);
  broadcastPubSub.subscribe('someTopic', foo);
  broadcastPubSub.subscribe('someTopic', bar, context);

  var broadcastPubSub2 = new goog.labs.pubsub.BroadcastPubSub();
  broadcastPubSub2.publish('someTopic', 'x', 'y');
  mockClock.tick();

  assertEquals(1, bar.getCallCount());
  assertEquals(context, bar.getLastCall().getThis());
  assertArrayEquals(['x', 'y'], bar.getLastCall().getArguments());

  broadcastPubSub.dispose();
  mockControl.$verifyAll();
}


function testSecondInstanceNestedPublish() {
  mockHTML5LocalStorageCtor().$returns(mockHtml5LocalStorage).$times(2);
  var foo = mockControl.createFunctionMock();
  foo('m', 'n').$does(function() {
    broadcastPubSub.publish('barTopic', 'd', 'c');
  });
  var bar = mockControl.createFunctionMock();
  bar('d', 'c');

  mockControl.$replayAll();

  broadcastPubSub = new goog.labs.pubsub.BroadcastPubSub();
  broadcastPubSub.logger_.setLevel(goog.debug.Logger.Level.OFF);
  broadcastPubSub.subscribe('fooTopic', foo);

  var broadcastPubSub2 = new goog.labs.pubsub.BroadcastPubSub();
  broadcastPubSub2.subscribe('barTopic', bar);
  broadcastPubSub2.publish('fooTopic', 'm', 'n');
  mockClock.tick();
  mockClock.tick();

  broadcastPubSub.dispose();
  mockControl.$verifyAll();
}


/**
 * Validate the localStorage data is being set as we expect.
 */
function testLocalStorageData() {
  var topic = 'someTopic';
  var anotherTopic = 'anotherTopic';
  var now = goog.now();

  mockHTML5LocalStorageCtor().$returns(mockHtml5LocalStorage);
  var mockStorage = mockControl.createLooseMock(goog.storage.Storage);

  var mockStorageCtor = mockControl.createConstructorMock(
      goog.storage, 'Storage');

  mockStorageCtor(mockHtml5LocalStorage).$returns(mockStorage);
  if (!isIe8) {
    mockStorage.set(goog.labs.pubsub.BroadcastPubSub.STORAGE_KEY_,
        {'args': [topic, '10'], 'timestamp': now});
    mockStorage.remove(goog.labs.pubsub.BroadcastPubSub.STORAGE_KEY_);
    mockStorage.set(goog.labs.pubsub.BroadcastPubSub.STORAGE_KEY_,
        {'args': [anotherTopic, '13'], 'timestamp': now});
    mockStorage.remove(goog.labs.pubsub.BroadcastPubSub.STORAGE_KEY_);
  } else {
    var firstEventArray = [
      {'args': [topic, '10'], 'timestamp': now}
    ];
    var secondEventArray = [
      {'args': [topic, '10'], 'timestamp': now},
      {'args': [anotherTopic, '13'], 'timestamp': now +
            goog.labs.pubsub.BroadcastPubSub.
            IE8_TIMESTAMP_UNIQUE_OFFSET_MS_}
    ];

    mockStorage.get(goog.labs.pubsub.BroadcastPubSub.IE8_EVENTS_KEY_).
        $returns(null);
    mockStorage.set(goog.labs.pubsub.BroadcastPubSub.IE8_EVENTS_KEY_,
        new goog.testing.mockmatchers.ArgumentMatcher(function(val) {
          return goog.testing.mockmatchers.flexibleArrayMatcher(
              firstEventArray, val);
        }, 'First event array'));

    // Make sure to clone or you're going to have a bad time.
    mockStorage.get(goog.labs.pubsub.BroadcastPubSub.IE8_EVENTS_KEY_).
        $returns(goog.array.clone(firstEventArray));

    mockStorage.set(goog.labs.pubsub.BroadcastPubSub.IE8_EVENTS_KEY_,
        new goog.testing.mockmatchers.ArgumentMatcher(function(val) {
          return goog.testing.mockmatchers.flexibleArrayMatcher(
              secondEventArray, val);
        }, 'Second event array'));

    mockStorage.remove(
        goog.labs.pubsub.BroadcastPubSub.IE8_EVENTS_KEY_);
  }

  var fn = goog.testing.recordFunction();
  fn('10');
  mockControl.$replayAll();

  broadcastPubSub = new goog.labs.pubsub.BroadcastPubSub();
  broadcastPubSub.logger_.setLevel(goog.debug.Logger.Level.OFF);
  broadcastPubSub.subscribe(topic, fn);

  broadcastPubSub.publish(topic, '10');
  broadcastPubSub.publish(anotherTopic, '13');

  mockClock.tick();

  broadcastPubSub.dispose();
  mockControl.$verifyAll();
}


function testBrokenTimestamp() {
  mockHTML5LocalStorageCtor().$returns(mockHtml5LocalStorage);
  var fn = mockControl.createFunctionMock();
  mockControl.$replayAll();

  broadcastPubSub = new goog.labs.pubsub.BroadcastPubSub();
  broadcastPubSub.logger_.setLevel(goog.debug.Logger.Level.OFF);

  broadcastPubSub.subscribe('someTopic', fn);

  remoteStorageEvent({
    'args': 'WAT?',
    'timestamp': 'wat?'
  });
  mockClock.tick();

  broadcastPubSub.dispose();
  mockControl.$verifyAll();
}


/** Test response to bad localStorage data. */
function testBrokenEvent() {
  mockHTML5LocalStorageCtor().$returns(mockHtml5LocalStorage);
  var fn = mockControl.createFunctionMock();
  mockControl.$replayAll();

  broadcastPubSub = new goog.labs.pubsub.BroadcastPubSub();
  broadcastPubSub.logger_.setLevel(goog.debug.Logger.Level.OFF);
  broadcastPubSub.subscribe('someTopic', fn);

  if (!isIe8) {
    var event = new goog.testing.events.Event('storage', window);
    event.key = 'FooBarBaz';
    event.newValue = goog.json.serialize({'keyby': 'word'});
    goog.testing.events.fireBrowserEvent(event);
  } else {
    var uniqueKey =
        goog.labs.pubsub.BroadcastPubSub.IE8_EVENTS_KEY_PREFIX_ +
        '1234567890';
    // This will cause an event.
    mockHtml5LocalStorage.set(uniqueKey, 'Toothpaste!');
  }
  mockClock.tick();

  broadcastPubSub.dispose();
  mockControl.$verifyAll();
}


/**
 * The following tests are duplicated from pubsub because they depend
 * on functionality (mostly "publish") that has changed in BroadcastChannel.
 */
function testPublish() {
  mockHTML5LocalStorageCtor().$returns(mockHtml5LocalStorage);
  var foo = mockControl.createFunctionMock();
  foo('x', 'y');

  var context = {'foo': 'bar'};
  var bar = goog.testing.recordFunction();

  var baz = mockControl.createFunctionMock();
  baz('d', 'c');

  mockControl.$replayAll();
  broadcastPubSub = new goog.labs.pubsub.BroadcastPubSub();
  broadcastPubSub.logger_.setLevel(goog.debug.Logger.Level.OFF);

  broadcastPubSub.subscribe('someTopic', foo);
  broadcastPubSub.subscribe('someTopic', bar, context);
  broadcastPubSub.subscribe('anotherTopic', baz, context);

  broadcastPubSub.publish('someTopic', 'x', 'y');
  mockClock.tick();

  assertTrue(broadcastPubSub.unsubscribe('someTopic', foo));

  broadcastPubSub.publish('anotherTopic', 'd', 'c');
  broadcastPubSub.publish('someTopic', 'x', 'y');
  mockClock.tick();

  broadcastPubSub.subscribe('differentTopic', foo);

  broadcastPubSub.publish('someTopic', 'x', 'y');
  mockClock.tick();

  assertEquals(3, bar.getCallCount());
  goog.array.forEach(bar.getCalls(), function(call) {
    assertArrayEquals(['x', 'y'], call.getArguments());
    assertEquals(context, call.getThis());
  });
  broadcastPubSub.dispose();
  mockControl.$verifyAll();
}


function testPublishEmptyTopic() {
  mockHTML5LocalStorageCtor().$returns(mockHtml5LocalStorage);
  var foo = mockControl.createFunctionMock();
  foo();

  mockControl.$replayAll();
  broadcastPubSub = new goog.labs.pubsub.BroadcastPubSub();
  broadcastPubSub.logger_.setLevel(goog.debug.Logger.Level.OFF);

  broadcastPubSub.publish('someTopic');
  mockClock.tick();

  broadcastPubSub.subscribe('someTopic', foo);
  broadcastPubSub.publish('someTopic');
  mockClock.tick();

  broadcastPubSub.unsubscribe('someTopic', foo);
  broadcastPubSub.publish('someTopic');
  mockClock.tick();

  broadcastPubSub.dispose();
  mockControl.$verifyAll();
}


function testSubscribeWhilePublishing() {
  mockHTML5LocalStorageCtor().$returns(mockHtml5LocalStorage);
  // It's OK for a subscriber to add a new subscriber to its own topic,
  // but the newly added subscriber shouldn't be called until the next
  // publish cycle.

  var fn1 = mockControl.createFunctionMock();
  var fn2 = mockControl.createFunctionMock();
  fn1().$does(function() {
    broadcastPubSub.subscribe('someTopic', fn2);
  }).$times(2);
  fn2();

  mockControl.$replayAll();
  broadcastPubSub = new goog.labs.pubsub.BroadcastPubSub();
  broadcastPubSub.logger_.setLevel(goog.debug.Logger.Level.OFF);

  broadcastPubSub.subscribe('someTopic', fn1);
  assertEquals('Topic must have one subscriber', 1,
      broadcastPubSub.getCount('someTopic'));

  broadcastPubSub.publish('someTopic');
  mockClock.tick();

  assertEquals('Topic must have two subscribers', 2,
      broadcastPubSub.getCount('someTopic'));

  broadcastPubSub.publish('someTopic');
  mockClock.tick();

  assertEquals('Topic must have three subscribers', 3,
      broadcastPubSub.getCount('someTopic'));
  broadcastPubSub.dispose();
  mockControl.$verifyAll();
}


function testUnsubscribeWhilePublishing() {
  mockHTML5LocalStorageCtor().$returns(mockHtml5LocalStorage);
  // It's OK for a subscriber to unsubscribe another subscriber from its
  // own topic, but the subscriber in question won't actually be removed
  // until after publishing is complete.


  var fn1 = mockControl.createFunctionMock();
  var fn2 = mockControl.createFunctionMock();
  var fn3 = mockControl.createFunctionMock();

  fn1().$does(function() {
    assertFalse('unsubscribe() must return false during publishing',
        broadcastPubSub.unsubscribe('X', fn2));
    assertEquals('Topic "X" must still have 3 subscribers', 3,
        broadcastPubSub.getCount('X'));
  });
  fn2().$does(function() {
    assertEquals('Topic "X" must still have 3 subscribers', 3,
        broadcastPubSub.getCount('X'));
  });
  fn3().$does(function() {
    assertFalse('unsubscribe() must return false during publishing',
        broadcastPubSub.unsubscribe('X', fn1));
    assertEquals('Topic "X" must still have 3 subscribers', 3,
        broadcastPubSub.getCount('X'));
  });

  mockControl.$replayAll();
  broadcastPubSub = new goog.labs.pubsub.BroadcastPubSub();
  broadcastPubSub.logger_.setLevel(goog.debug.Logger.Level.OFF);

  broadcastPubSub.subscribe('X', fn1);
  broadcastPubSub.subscribe('X', fn2);
  broadcastPubSub.subscribe('X', fn3);

  assertEquals('Topic "X" must have 3 subscribers', 3,
      broadcastPubSub.getCount('X'));

  broadcastPubSub.publish('X');
  mockClock.tick();

  assertEquals('Topic "X" must have 1 subscriber after publishing', 1,
      broadcastPubSub.getCount('X'));
  assertEquals(
      'BroadcastChannel must not have any subscriptions pending removal',
      0, broadcastPubSub.pubSub_.pendingKeys_.length);
  broadcastPubSub.dispose();
  mockControl.$verifyAll();
}


function testUnsubscribeSelfWhilePublishing() {
  mockHTML5LocalStorageCtor().$returns(mockHtml5LocalStorage);
  // It's OK for a subscriber to unsubscribe itself, but it won't actually
  // be removed until after publishing is complete.

  var fn = mockControl.createFunctionMock();
  fn().$does(function() {
    assertFalse('unsubscribe() must return false during publishing',
        broadcastPubSub.unsubscribe('someTopic', fn));
    assertEquals('Topic must still have 1 subscriber', 1,
        broadcastPubSub.getCount('someTopic'));
  });

  mockControl.$replayAll();
  broadcastPubSub = new goog.labs.pubsub.BroadcastPubSub();
  broadcastPubSub.logger_.setLevel(goog.debug.Logger.Level.OFF);

  broadcastPubSub.subscribe('someTopic', fn);
  assertEquals('Topic must have 1 subscriber', 1,
      broadcastPubSub.getCount('someTopic'));

  broadcastPubSub.publish('someTopic');
  mockClock.tick();

  assertEquals('Topic must have no subscribers after publishing', 0,
      broadcastPubSub.getCount('someTopic'));
  assertEquals(
      'BroadcastChannel must not have any subscriptions pending removal',
      0, broadcastPubSub.pubSub_.pendingKeys_.length);
  broadcastPubSub.dispose();
  mockControl.$verifyAll();
}


function testNestedPublish() {
  mockHTML5LocalStorageCtor().$returns(mockHtml5LocalStorage);
  var xFn1 = mockControl.createFunctionMock();
  xFn1().$does(function() {
    broadcastPubSub.publish('Y');
    broadcastPubSub.unsubscribe('X', arguments.callee);
  });
  var xFn2 = mockControl.createFunctionMock();
  xFn2();

  var yFn1 = mockControl.createFunctionMock();
  yFn1().$does(function() {
    broadcastPubSub.unsubscribe('Y', arguments.callee);
  });
  var yFn2 = mockControl.createFunctionMock();
  yFn2();

  mockControl.$replayAll();

  broadcastPubSub = new goog.labs.pubsub.BroadcastPubSub();
  broadcastPubSub.logger_.setLevel(goog.debug.Logger.Level.OFF);
  broadcastPubSub.subscribe('X', xFn1);
  broadcastPubSub.subscribe('X', xFn2);
  broadcastPubSub.subscribe('Y', yFn1);
  broadcastPubSub.subscribe('Y', yFn2);

  broadcastPubSub.publish('X');
  mockClock.tick();

  broadcastPubSub.dispose();
  mockControl.$verifyAll();
}


function testSubscribeOnce() {
  mockHTML5LocalStorageCtor().$returns(mockHtml5LocalStorage);
  var fn = goog.testing.recordFunction();

  mockControl.$replayAll();
  broadcastPubSub = new goog.labs.pubsub.BroadcastPubSub();
  broadcastPubSub.logger_.setLevel(goog.debug.Logger.Level.OFF);
  broadcastPubSub.subscribeOnce('someTopic', fn);

  assertEquals('Topic must have one subscriber', 1,
      broadcastPubSub.getCount('someTopic'));

  broadcastPubSub.publish('someTopic');
  mockClock.tick();

  assertEquals('Topic must have no subscribers', 0,
      broadcastPubSub.getCount('someTopic'));

  var context = {'foo': 'bar'};
  broadcastPubSub.subscribeOnce('someTopic', fn, context);
  assertEquals('Topic must have one subscriber', 1,
      broadcastPubSub.getCount('someTopic'));
  assertEquals('Subscriber must not have been called yet',
      1, fn.getCallCount());

  broadcastPubSub.publish('someTopic');
  mockClock.tick();

  assertEquals('Topic must have no subscribers', 0,
      broadcastPubSub.getCount('someTopic'));
  assertEquals('Subscriber must have been called',
      2, fn.getCallCount());
  assertEquals(context, fn.getLastCall().getThis());
  assertArrayEquals([], fn.getLastCall().getArguments());

  context = {'foo': 'bar'};
  broadcastPubSub.subscribeOnce('someTopic', fn, context);
  assertEquals('Topic must have one subscriber', 1,
      broadcastPubSub.getCount('someTopic'));
  assertEquals('Subscriber must not have been called',
      2, fn.getCallCount());

  broadcastPubSub.publish('someTopic', '17');
  mockClock.tick();

  assertEquals('Topic must have no subscribers', 0,
      broadcastPubSub.getCount('someTopic'));
  assertEquals(context, fn.getLastCall().getThis());
  assertEquals('Subscriber must have been called',
      3, fn.getCallCount());
  assertArrayEquals(['17'], fn.getLastCall().getArguments());
  broadcastPubSub.dispose();
  mockControl.$verifyAll();
}


function testSubscribeOnce_boundFn() {
  mockHTML5LocalStorageCtor().$returns(mockHtml5LocalStorage);
  var fn = goog.testing.recordFunction();
  var context = {'foo': 'bar'};

  mockControl.$replayAll();
  broadcastPubSub = new goog.labs.pubsub.BroadcastPubSub();
  broadcastPubSub.logger_.setLevel(goog.debug.Logger.Level.OFF);

  broadcastPubSub.subscribeOnce('someTopic', goog.bind(fn, context));
  assertEquals('Topic must have one subscriber', 1,
      broadcastPubSub.getCount('someTopic'));
  assertNull('Subscriber must not have been called yet',
      fn.getLastCall());

  broadcastPubSub.publish('someTopic', '17');
  mockClock.tick();
  assertEquals('Topic must have no subscribers', 0,
      broadcastPubSub.getCount('someTopic'));
  assertEquals('Subscriber must have been called', 1, fn.getCallCount());
  assertEquals('Must receive correct argument.',
      '17', fn.getLastCall().getArgument(0));
  assertEquals('Must have appropriate context.',
      context, fn.getLastCall().getThis());

  broadcastPubSub.dispose();
  mockControl.$verifyAll();
}


function testSubscribeOnce_partialFn() {
  mockHTML5LocalStorageCtor().$returns(mockHtml5LocalStorage);
  var fullFn = mockControl.createFunctionMock();
  fullFn(true, '17');

  mockControl.$replayAll();
  broadcastPubSub = new goog.labs.pubsub.BroadcastPubSub();
  broadcastPubSub.logger_.setLevel(goog.debug.Logger.Level.OFF);

  broadcastPubSub.subscribeOnce('someTopic', goog.partial(fullFn, true));
  assertEquals('Topic must have one subscriber', 1,
      broadcastPubSub.getCount('someTopic'));

  broadcastPubSub.publish('someTopic', '17');
  mockClock.tick();

  assertEquals('Topic must have no subscribers', 0,
      broadcastPubSub.getCount('someTopic'));
  broadcastPubSub.dispose();
  mockControl.$verifyAll();
}


function testSelfResubscribe() {
  mockHTML5LocalStorageCtor().$returns(mockHtml5LocalStorage);
  var resubscribeFn = mockControl.createFunctionMock();
  var resubscribe = function() {
    broadcastPubSub.subscribeOnce('someTopic', resubscribeFn);
  };
  resubscribeFn('foo').$does(resubscribe);
  resubscribeFn('bar').$does(resubscribe);
  resubscribeFn('baz').$does(resubscribe);

  mockControl.$replayAll();
  broadcastPubSub = new goog.labs.pubsub.BroadcastPubSub();
  broadcastPubSub.logger_.setLevel(goog.debug.Logger.Level.OFF);

  broadcastPubSub.subscribeOnce('someTopic', resubscribeFn);
  assertEquals('Topic must have 1 subscriber', 1,
      broadcastPubSub.getCount('someTopic'));

  broadcastPubSub.publish('someTopic', 'foo');
  mockClock.tick();
  assertEquals('Topic must have 1 subscriber', 1,
      broadcastPubSub.getCount('someTopic'));
  assertEquals('BroadcastChannel must not have any pending unsubscribe keys', 0,
      broadcastPubSub.pubSub_.pendingKeys_.length);

  broadcastPubSub.publish('someTopic', 'bar');
  mockClock.tick();
  assertEquals('Topic must have 1 subscriber', 1,
      broadcastPubSub.getCount('someTopic'));
  assertEquals('BroadcastChannel must not have any pending unsubscribe keys', 0,
      broadcastPubSub.pubSub_.pendingKeys_.length);

  broadcastPubSub.publish('someTopic', 'baz');
  mockClock.tick();
  assertEquals('Topic must have 1 subscriber', 1,
      broadcastPubSub.getCount('someTopic'));
  assertEquals('BroadcastChannel must not have any pending unsubscribe keys', 0,
      broadcastPubSub.pubSub_.pendingKeys_.length);
  broadcastPubSub.dispose();
  mockControl.$verifyAll();
}


function testClear() {
  mockHTML5LocalStorageCtor().$returns(mockHtml5LocalStorage);
  var fn = mockControl.createFunctionMock();
  mockControl.$replayAll();
  broadcastPubSub = new goog.labs.pubsub.BroadcastPubSub();
  broadcastPubSub.logger_.setLevel(goog.debug.Logger.Level.OFF);

  goog.array.forEach(['V', 'W', 'X', 'Y', 'Z'], function(topic) {
    broadcastPubSub.subscribe(topic, fn);
  });
  assertEquals('BroadcastChannel must have 5 subscribers', 5,
      broadcastPubSub.getCount());

  broadcastPubSub.clear('W');
  assertEquals('BroadcastChannel must have 4 subscribers', 4,
      broadcastPubSub.getCount());

  goog.array.forEach(['X', 'Y'], function(topic) {
    broadcastPubSub.clear(topic);
  });
  assertEquals('BroadcastChannel must have 2 subscriber', 2,
      broadcastPubSub.getCount());

  broadcastPubSub.clear();
  assertEquals('BroadcastChannel must have no subscribers', 0,
      broadcastPubSub.getCount());
  broadcastPubSub.dispose();
  mockControl.$verifyAll();
}
