Skip to content

Instantly share code, notes, and snippets.

@ErikBean
Last active January 29, 2016 00:34
Show Gist options
  • Save ErikBean/9a9fecce3afe6e652191 to your computer and use it in GitHub Desktop.
Save ErikBean/9a9fecce3afe6e652191 to your computer and use it in GitHub Desktop.
Publisher and view mock. Once we have the callback to subscribe, we just call it with mocked bucket results
describe("new clean conversationDirective", function () {
/**
* Factory to mock:
* CreateUserChatMessageView
* CreateUserChatStateView
* CreateUserConversationView
* @param id {String} name for the createView() spy "Conversation", "ChatMessage", "ChatState" (name of factory w/o prefix/suffix)
* @return {jasmine.Spy} spy for view constructor function injected into conversationDirective.
* Spies will return a mockView object that conversationDirective calls .subscribe() on
*/
function mockCreateUserViewFactory(id) {
return jasmine.createSpy('createUser'+id+'View').and.callFake(function () {
return mockView(id);
});
}
/**
* mock view object that would be returned from createUser_____View
* @param id {String} name for the view that conversationDirective subscribes to,
* ( inherited from mockCreateUserViewFactory "Conversation", "ChatMessage", "ChatState")
* @returns {jasmine.spyObject} mock view Object with spied-on .subscribe() and .destroy() methods
*/
function mockView(id) {
viewSpies[id] = jasmine.createSpyObj('mock' + id + 'View', [
'subscribe',
'destroy'
]);
return viewSpies[id];
}
/**
* Get the callback passed to a given view.subscribe()
* When the chat partner changes, all createUser____View() functions are called to create a view.
* view.subscribe is than passed a callback to call when there is an update from the bucket.
* @param createdView {Object} view returned from call to createUser____View()
* @returns {Function} callback passed to view.subscribe() from conversationDirective,
*/
function getSubscribeCallbackForCreatedView(createdView) {
return createdView.subscribe.calls.mostRecent().args[0];
}
function mockRosterService() {
return {
roster: {
'first_chat_partner_jid': {
presence: 'something'
}
}
};
}
function mockChatService() {
return jasmine.createSpyObj('chatServiceSpy', [
'requestMessageHistory',
'sendReadReceipt'
]);
}
function mockChatWindowDisplay() {
return {
show: true,
minimized: false,
unfocused: false
};
}
function triggerScrollListener(scrollElement) {
angular.element(scrollElement).triggerHandler("scroll");
}
function getIdsOfReadReceiptsSent() {
var argsForActualReadReceiptsSent = chatService.sendReadReceipt.calls.allArgs();
return _.pluck(argsForActualReadReceiptsSent, '0');
}
var NUM_MESSAGES_TO_OVERFLOW_CHAT_WINDOW = 50,//more than enough
SCROLL_AREA_HEIGHT = '200px';
var chatMsgSubscribeFn, chatMessageSubscribeFn, mockMessagesUpdate = [
{
value: "foo"
},
{
value: "bar"
},
{
value: "bin"
}
], bucketMessagesFromChatPartner = _.pluck(mockMessagesUpdate, 'value');
var $rootScope, element, scrollElement, chatService, createUserConversationView, viewSpies ={},
mockCreateUserChatMessageView = mockCreateUserViewFactory.bind(this, 'ChatMessage'),
mockCreateUserChatStateView = mockCreateUserViewFactory.bind(this, 'ChatState'),
mockCreateUserConversationView = mockCreateUserViewFactory.bind(this, 'Conversation');
beforeEach(function () {
module('yeti.chat', function ($provide, $compileProvider) {
//Intentionally not mocking chatStates dependency because it's static
$provide.service('chatService', mockChatService);
$provide.service('rosterService', mockRosterService);
$provide.service('chatWindowDisplay', mockChatWindowDisplay);
$provide.factory('createUserChatMessageView', mockCreateUserChatMessageView);
$provide.factory('createUserChatStateView', mockCreateUserChatStateView);
$provide.factory('createUserConversationView', mockCreateUserConversationView);
$compileProvider.directive('yetiChatInput', function () {
return {
priority: 100,
terminal: true,
restrict: 'E',
template: '<div class="mockYetiConversationPanelDirective">mock chat input directive</div>'
};
});
$compileProvider.directive('yetiUserUnreadCounter', function () {
return {
priority: 100,
terminal: true,
restrict: 'E',
template: '<div class="mockYetiUnreadCounterDirective">mock unread message count directive</div>'
};
});
});
inject(function ($compile, $document, _$rootScope_, _chatService_, _createUserConversationView_) {
var directiveInlineStyle = "height:316px;width:280px;position:absolute";
$rootScope = _$rootScope_;
chatService = _chatService_;
chatService.currentConversation = {
jid: 'first_chat_partner_jid'
};
createUserConversationView = _createUserConversationView_;
element = $compile("<yeti-conversation style=" + directiveInlineStyle + "></yeti-conversation>")($rootScope);
$rootScope.$digest();
//Element that is scrollable, the scroll position of which is under test here:
scrollElement = element[0].querySelector(".messages-list-wrapper");
//Minimum CSS necessary for the element to be sized and overflow in a similar manner to the client:
angular.element(scrollElement).css({
'height': SCROLL_AREA_HEIGHT,
'position': 'relative',
'overflow': 'auto'
});
//attach element to DOM so it has a defined style and we can visually QA
angular.element($document[0].body).append(element[0]);
});
});
describe("[ Initialization ]", function () {
it("should create a view for the current chat partner", function () {
expect(createUserConversationView).toHaveBeenCalledWith(jasmine.any(String), 'first_chat_partner_jid');
expect(viewSpies['Conversation'].subscribe).toHaveBeenCalledWith(jasmine.any(Function));
});
});
describe("when there is no current chat partner", function () {
// i.e. when not yet selected a chat partner or current conversation was removed
beforeEach(function () {
createUserConversationView.calls.reset();
chatService.currentConversation.jid = undefined;
$rootScope.$digest();
});
it("should do nothing ", function () {
expect(createUserConversationView).not.toHaveBeenCalled();
});
});
describe("when the chat partner changes", function () {
beforeEach(function () {
createUserConversationView.calls.reset();
chatService.currentConversation.jid = 'some_other_jid';
$rootScope.$digest();
});
it("should create a view for the new chat partner", function () {
expect(createUserConversationView).toHaveBeenCalledWith(jasmine.any(String), 'some_other_jid');
expect(viewSpies['Conversation'].subscribe).toHaveBeenCalledWith(jasmine.any(Function));
expect(viewSpies['ChatMessage'].subscribe).toHaveBeenCalledWith(jasmine.any(Function));
//TODO: bring this back: -EB 1/21/16
//expect(viewSpies['ChatState'].subscribe).toHaveBeenCalledWith(jasmine.any(Function));
});
describe("when the new chat partner has messages", function () {
beforeEach(function () {
var createdView = viewSpies['ChatMessage'];
chatMsgSubscribeFn = getSubscribeCallbackForCreatedView(createdView);
chatMsgSubscribeFn(mockMessagesUpdate);
});
it("should put messages for that chat partner on scope", function () {
expect(element.isolateScope().currentConversation.messages).toEqual(bucketMessagesFromChatPartner);
});
});
});
describe("when switching into a conversation with unread messages", function () {
var convSubscribeFn, fillerMessages = _.range(NUM_MESSAGES_TO_OVERFLOW_CHAT_WINDOW).map(function (id) {
return {
id: 'stanza_id_' + id,
text: 'message ' + id
};
}), mockMessagesUpdate = _.map(fillerMessages, function (message) {
return {
id: 'bucket_id_' + message.id,
value: message
};
});
var mockConversationUpdate = [{
"value": {
"contactJid": 'first_chat_partner_jid',
"hasLoadedInitialChatHistory": false
}
}];
beforeEach(function () {
element.isolateScope().currentConversation.messages = fillerMessages;
element.isolateScope().currentConversation.hasLoadedInitialChatHistory = false;
$rootScope.$digest();
scrollElement.scrollTop = 234;//set to arbitrary position
convSubscribeFn = getSubscribeCallbackForCreatedView(viewSpies['Conversation']);
convSubscribeFn(mockConversationUpdate);
chatMessageSubscribeFn = getSubscribeCallbackForCreatedView(viewSpies['ChatMessage']);
chatMessageSubscribeFn(mockMessagesUpdate);
});
describe("when the chat history for the conversation has not yet loaded", function () {
it("should not auto-scroll", function () {
expect(scrollElement.scrollTop).toBe(234);
});
it("should not mark anything as read", function () {
expect(chatService.sendReadReceipt).not.toHaveBeenCalled();
});
});
describe("when there are unread messages", function () {
//assert against these:
var expectedScrollPosition, messageIdsNeedingReadReceiptsInView, unreadMessagesBelowViewport;
function getMessagesDisplayedInView(messageElements, firstUnreadElement) {
return _.filter(messageElements, function (message) {
var messageBottomPosition = message.offsetTop + message.offsetHeight,
chatWindowBottomThreshold = scrollElement.scrollTop + scrollElement.offsetHeight,
isMessageAfterFirstUnread = _.indexOf(messageElements, message) >= _.indexOf(messageElements, firstUnreadElement),
isAboveThreshold = messageBottomPosition <= chatWindowBottomThreshold;
return isAboveThreshold && isMessageAfterFirstUnread;
});
}
//triggers subscribe() callback, $digest()
function publishMockMessagesUpdate(mockMessagesUpdate) {
chatMessageSubscribeFn(mockMessagesUpdate);
}
function loadMockHistory(unreadMessageIndices) {
_.forEach(unreadMessageIndices, function markMessageInBucketAsUnread(index) {
_.extend(mockMessagesUpdate[index].value, {wasSeen: false, from:'first_chat_partner_jid'});
});
//allow auto-scroll to first-unread:
element.isolateScope().currentConversation.hasLoadedInitialChatHistory = true;
publishMockMessagesUpdate(mockMessagesUpdate);
}
beforeEach(function () {
// this index is arbitrary, indicates that message #16 is the first unread message in the conversation
//last unread is necessarily the last element in the messages array.
var INDEX_OF_FIRST_UNREAD = 16,
unreadMessageIndices = _.range(INDEX_OF_FIRST_UNREAD, mockMessagesUpdate.length);
loadMockHistory(unreadMessageIndices);
//Now that messages data is on scope/rendered in the DOM, determine which messages are visible to the user
//These are the messages that we need to send read receipts for:
var firstUnreadId = element.isolateScope().currentConversation.messages[INDEX_OF_FIRST_UNREAD].id,
messageElements = element.find("[data-id^=stanza_id_]"),
firstUnreadElement = element.find('[data-id="' + firstUnreadId + '"]')[0],
messagesNeedingReadReceipts = getMessagesDisplayedInView(messageElements, firstUnreadElement),
lastVisibleUnreadMessageElement = _.last(messagesNeedingReadReceipts),
indexOfFirstBelowViewport = _.indexOf(messageElements, lastVisibleUnreadMessageElement) + 1;
messageIdsNeedingReadReceiptsInView = _.map(messagesNeedingReadReceipts, function (elem) {
return elem.getAttribute('data-id');
});
//Expect to auto-scroll to the first unread message upon switching ot c conversation with multiple unreads:
expectedScrollPosition = firstUnreadElement.offsetTop;
//As the user scrolls down, more recent unread messages come into view.
//These messages should have read receipts sent for them as they enter the visible area:
unreadMessagesBelowViewport = _.rest(messageElements, indexOfFirstBelowViewport);
//This setup involves a lot of DOM stuff.
//This is just for visual confirmation that the setup completed successfully:
angular.element(firstUnreadElement).css({"background-color": "lightblue"});
angular.element(lastVisibleUnreadMessageElement).css({"background-color": "red"});
triggerScrollListener(scrollElement);
});
it("should auto-scroll the least recent unread message", function () {
expect(scrollElement.scrollTop).toEqual(expectedScrollPosition);
});
it("should send read receipts for all unread messages visible within the scrollable area", function () {
var idsOfReadReceiptsSent = getIdsOfReadReceiptsSent();
expect(idsOfReadReceiptsSent).toEqual(messageIdsNeedingReadReceiptsInView);
});
describe("when there are more unread messages than will fit in the scrollable area", function () {
var toastElement;
beforeEach(function () {
toastElement = element.find('.unread-messages-below-viewport-toast');
});
it("should display a message indicating that the user may scroll down to view the additional unread messages", function () {
expect(toastElement).not.toHaveClass('ng-hide');
});
it("should display the unread messages directive", function () {
expect(toastElement.text()).toContain('mock unread message count directive');
});
it("should bind the unread message directive to the jid of the current chat partner", function () {
expect(toastElement.find('yeti-user-unread-counter').attr('jid')).toBe('first_chat_partner_jid');
});
describe("when the user scrolls down", function () {
//This is because when a readReceipt is sent, xmppWrapper updates the bucket.
//This simulates the resulting publish, firing the callback to view.subscribe() in the directive
function triggerReadMessageBucketUpdate(messageId) {
var bucketMessage = _.find(mockMessagesUpdate, function (message) {
return message.id === 'bucket_id_' + messageId;
});
bucketMessage.value.wasSeen = true;
chatMessageSubscribeFn(mockMessagesUpdate);
}
beforeEach(function () {
var idsOfReadReceiptsSent = getIdsOfReadReceiptsSent();
_.each(idsOfReadReceiptsSent, triggerReadMessageBucketUpdate);
});
it("should send read receipts for the messages that come into view", function () {
// normally we would expect that the view updates the unread counter as the user scroll to view
// unread messages. However, since We are mocking the userUnreadMessageDirective,
// displaying the count is outside the scope of this directive
function scrollToUnreadMessage(messageElement) {
//set scroll position such that the bottom of the element is aligned with the bottom of the viewport:
scrollElement.scrollTop = (messageElement.offsetTop - scrollElement.offsetHeight) + messageElement.offsetHeight;
//trigger the scroll event listener bound to scrollElement:
angular.element(scrollElement).triggerHandler("scroll");
//Return the message for the chatService.sendReadReceipt spy expectation:
return messageElement;
}
//Scroll to each unread message in turn, and check that an RR was sent for each:
_.each(unreadMessagesBelowViewport, function (unreadElement) {
chatService.sendReadReceipt.calls.reset();
var nextUnreadElement = scrollToUnreadMessage(unreadElement),
IdForMessageNowInView = nextUnreadElement.getAttribute('data-id');
expect(chatService.sendReadReceipt.calls.count()).toBe(1);
expect(chatService.sendReadReceipt).toHaveBeenCalledWith(IdForMessageNowInView, 'first_chat_partner_jid');
triggerReadMessageBucketUpdate(unreadElement.getAttribute('data-id'));
});
});
});
});
});
});
describe("when a new message is received from the current chat partner", function () {
var batchMessages = _.range(0, 100).map(function (id) {
return {
id: id,
value: {
wasSeen: true,
from: 'first_chat_partner_jid',
id: id,
text: 'message ' + id
}
};
});
function receiveNewMessages(numberToRecv) {
var update = batchMessages;
_.range(0, numberToRecv).forEach(function (i) {
update.push({
id: batchMessages.length + i,
value: {
wasSeen: true,
from: 'first_chat_partner_jid',
id: batchMessages.length + i,
text: 'new message ' + i
}
});
});
chatMsgSubscribeFn(update);
}
beforeEach(function () {
element.isolateScope().currentConversation.hasLoadedInitialChatHistory = true;
var createdView = viewSpies['ChatMessage'];
chatMsgSubscribeFn = getSubscribeCallbackForCreatedView(createdView);
chatMsgSubscribeFn(batchMessages);
chatService.sendReadReceipt.calls.reset();
});
it("should add the updated list of messages to the directive scope", function () {
var messageValues = _.pluck(batchMessages, 'value');
expect(element.isolateScope().currentConversation.messages).toEqual(messageValues);
});
describe("when the user is scrolled up in chat history", function () {
beforeEach(function () {
scrollElement.scrollTop = 567;
receiveNewMessages(3);//receive arbitrary number of messages
});
it("should not change the scroll position", function () {
expect(scrollElement.scrollTop).toBe(567)
});
it("should not send a read receipt for that message", function () {
expect(chatService.sendReadReceipt).not.toHaveBeenCalled();
});
});
describe("when the user is at the bottom of chat history", function () {
function getMaxScrollHeight() {
return scrollElement.scrollHeight - scrollElement.offsetHeight;
}
beforeEach(function () {
//scroll to bottom:
scrollElement.scrollTop = getMaxScrollHeight();
receiveNewMessages(13);//receive arbitrary number of messages
});
it("should auto-scroll to the new message(s)", function () {
var isScrollToBottom = _.isEqual(scrollElement.scrollTop, getMaxScrollHeight());
expect(isScrollToBottom).toBe(true);
});
});
});
describe("when returning to a conversation with a previous chat partner", function () {
//Scenario: select partner 1, then select partner 2, then switch back to chatting with partner 1
//Assumes initial chat history already loaded for this conversation
describe("when there are no new messages", function () {
it("should display the message that was in view when the user left the conversation", function () {
});
});
describe("when there are unread messages", function () {
it("should behave as if we are loading a conversation with unread messages for the first time", function () {
//i.e. same expectations as above with different setup steps.
//Hey this might be a sign of SIDE EFFECTS!
});
});
});
describe("when the user scrolls to the top of the chat messages list", function () {
it("should request the previous page of chat history", function () {
});
});
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment