Write test suite for DropdownView.

This commit is contained in:
Jake Harding
2013-07-09 22:51:12 -07:00
parent fcd784cf11
commit ebaa190e81
8 changed files with 400 additions and 40 deletions

View File

@@ -13,9 +13,17 @@ var DropdownView = (function() {
var that = this, onMouseEnter, onMouseLeave, onSuggestionClick,
onSuggestionMouseEnter, onSuggestionMouseLeave;
o = o || {};
if (!o.menu || !o.sections) {
$.error('menu and/or sections are required');
}
this.isOpen = false;
this.isMouseOverDropdown = false;
this.sections = o.sections;
// bound functions
onMouseEnter = utils.bind(this._onMouseEnter, this);
onMouseLeave = utils.bind(this._onMouseLeave, this);
@@ -30,8 +38,6 @@ var DropdownView = (function() {
.on('mouseenter.tt', '.tt-suggestion', onSuggestionMouseEnter)
.on('mouseleave.tt', '.tt-suggestion', onSuggestionMouseLeave);
this.sections = utils.map(o.sections, initializeSection);
utils.each(this.sections, function(i, section) {
that.$menu.append(section.getRoot());
section.onSync('rendered', that._onRendered, that);
@@ -66,7 +72,7 @@ var DropdownView = (function() {
},
_onRendered: function onRendered() {
this.trigger('suggestionsRendered');
this.trigger('sectionRendered');
},
_getSuggestions: function getSuggestions() {
@@ -74,11 +80,11 @@ var DropdownView = (function() {
},
_getCursor: function getCursor() {
return this.$menu.find('.tt-cursor');
return this.$menu.find('.tt-cursor').first();
},
_setCursor: function setCursor($el) {
$el.addClass('tt-cursor');
$el.first().addClass('tt-cursor');
},
_removeCursor: function removeCursor() {
@@ -196,15 +202,21 @@ var DropdownView = (function() {
utils.each(this.sections, clearSection);
function clearSection(i, section) { section.clear(); }
},
isEmpty: function isEmpty() {
var hasHeaderOrFooter, sectionsAreEmpty;
sectionsAreEmpty = utils.every(this.sections, isSectionEmpty);
hasHeaderOrFooter =
!!this.$menu.children(':not([class^="tt-section-"])').length;
return !hasHeaderOrFooter && sectionsAreEmpty;
function isSectionEmpty(section) { return section.isEmpty(); }
}
});
return DropdownView;
// helper functions
// ----------------
function initializeSection(o) {
return new SectionView(o);
}
})();

View File

@@ -5,17 +5,25 @@
*/
var SectionView = (function() {
var suggestionKey = 'ttSuggestion';
var datumKey = 'ttDatum';
// constructor
// -----------
function SectionView(o) {
o = o || {};
if (!o.dataset) {
$.error('missing dataset');
}
// tracks the last query the section was updated for
this.query = null;
this.dataset = o.dataset;
this.templates = o.templates;
this.templates = o.templates || {};
this.templates.suggestion =
this.templates.suggestion || defaultSuggestionTemplate;
this.$el = $(html.section.replace('%CLASS%', this.dataset.name));
}
@@ -23,8 +31,16 @@ var SectionView = (function() {
// static methods
// --------------
SectionView.many = function many(configs) {
configs = utils.isArray(configs) ? configs : [configs];
return utils.map(configs, initialize);
function initialize(config) { return new SectionView(config); }
};
SectionView.extractDatum = function extractDatum(el) {
return $(el).data(suggestionKey);
return $(el).data(datumKey);
};
// instance methods
@@ -52,7 +68,7 @@ var SectionView = (function() {
innerHtml = that.templates.suggestion(suggestion.raw);
outerHtml = html.suggestion.replace('%BODY%', innerHtml);
$el = $(outerHtml).data(suggestionKey, suggestion);
$el = $(outerHtml).data(datumKey, suggestion);
$el.children().each(function() { $(this).css(css.suggestionChild); });
@@ -88,4 +104,10 @@ var SectionView = (function() {
return SectionView;
// helper functions
// ----------------
function defaultSuggestionTemplate(context) {
return '<p>' + context.value + '</p>';
}
})();

View File

@@ -13,6 +13,15 @@ var TypeaheadView = (function() {
function TypeaheadView(o) {
var $menu, $input, $hint;
o = o || {};
if (!o.input || !o.sections) {
$.error('missing input and/or sections');
}
// maps the section configs to SectionView instances
o.sections = SectionView.many(o.sections);
this.$node = buildDomStructure(o.input);
$menu = this.$node.find('.tt-dropdown-menu');
@@ -23,7 +32,7 @@ var TypeaheadView = (function() {
.onSync('suggestionClicked', this._onSuggestionClicked, this)
.onSync('cursorMoved', this._onCursorMoved, this)
.onSync('cursorRemoved', this._onCursorRemoved, this)
.onSync('suggestionsRendered', this._onSuggestionsRendered, this)
.onSync('sectionRendered', this._onSectionRendered, this)
.onSync('opened', this._onOpened, this)
.onSync('closed', this._onClosed, this);
@@ -71,7 +80,7 @@ var TypeaheadView = (function() {
this._updateHint();
},
_onSuggestionsRendered: function onSuggestionsRendered() {
_onSectionRendered: function onSectionRendered() {
this._updateHint();
},

View File

@@ -1,3 +1,290 @@
describe('DropdownView', function() {
beforeEach(function() {
var $fixture;
jasmine.SectionView.useMock();
setFixtures(fixtures.html.menu);
$fixture = $('#jasmine-fixtures');
this.$menu = $fixture.find('.tt-dropdown-menu');
this.$menu.html(fixtures.html.section);
this.section = new SectionView();
this.section.onSync.andCallThrough();
this.section.onAsync.andCallThrough();
this.section.off.andCallThrough();
this.section.trigger.andCallThrough();
this.view = new DropdownView({
menu: this.$menu,
sections: [this.section]
});
});
it('should throw an error if menu and/or sections is missing', function() {
expect(noMenu).toThrow();
expect(noSections).toThrow();
function noMenu() { new DropdownView({ menu: '.menu' }); }
function noSections() { new DropdownView({ sections: true }); }
});
describe('when mouseenter is triggered', function() {
it('should set isMouseOverDropdown to true', function() {
this.$menu.mouseleave().mouseenter();
expect(this.view.isMouseOverDropdown).toBe(true);
});
});
describe('when mouseleave is triggered', function() {
it('should set isMouseOverDropdown to false', function() {
this.$menu.mouseenter().mouseleave();
expect(this.view.isMouseOverDropdown).toBe(false);
});
});
describe('when click event is triggered on a suggestion', function() {
it('should trigger suggestionClicked', function() {
var spy;
this.view.onSync('suggestionClicked', spy = jasmine.createSpy());
this.$menu.find('.tt-suggestion').first().click();
expect(spy).toHaveBeenCalled();
});
});
describe('when mouseenter is triggered on a suggestion', function() {
it('should set the cursor', function() {
var $suggestion;
$suggestion = this.$menu.find('.tt-suggestion').first();
$suggestion.mouseenter();
expect($suggestion).toHaveClass('tt-cursor');
});
});
describe('when mouseleave is triggered on a suggestion', function() {
it('should remove the cursor', function() {
var $suggestion;
$suggestion = this.$menu.find('.tt-suggestion').first();
$suggestion.mouseenter().mouseleave();
expect($suggestion).not.toHaveClass('tt-cursor');
});
});
describe('when rendered is triggered on a section', function() {
it('should trigger sectionRendered', function() {
var spy;
this.view.onSync('sectionRendered', spy = jasmine.createSpy());
this.section.trigger('rendered');
expect(spy).toHaveBeenCalled();
});
});
describe('#open', function() {
it('should display the menu', function() {
this.view.close();
this.view.open();
expect(this.$menu).toBeVisible();
});
it('should trigger opened', function() {
var spy;
this.view.onSync('opened', spy = jasmine.createSpy());
this.view.close();
this.view.open();
expect(spy).toHaveBeenCalled();
});
});
describe('#close', function() {
it('should hide the menu', function() {
this.view.open();
this.view.close();
expect(this.$menu).not.toBeVisible();
});
it('should trigger closed', function() {
var spy;
this.view.onSync('closed', spy = jasmine.createSpy());
this.view.open();
this.view.close();
expect(spy).toHaveBeenCalled();
});
});
describe('#setLanguageDirection', function() {
it('should update css for given language direction', function() {
// TODO: eh, the toHaveCss matcher doesn't seem to work very well
/*
this.view.setLanguageDirection('rtl');
expect(this.$menu).toHaveCss({ left: 'auto', right: '0px' });
this.view.setLanguageDirection('ltr');
expect(this.$menu).toHaveCss({ left: '0px', right: 'auto' });
*/
});
});
describe('#moveCursorUp', function() {
beforeEach(function() {
this.view.open();
});
it('should move the cursor up', function() {
var $first, $second;
$first = this.view._getSuggestions().eq(0);
$second = this.view._getSuggestions().eq(1);
this.view._setCursor($second);
this.view.moveCursorUp();
expect(this.view._getCursor()).toBe($first);
});
it('should move cursor to bottom if cursor is not present', function() {
var $bottom;
$bottom = this.view._getSuggestions().eq(-1);
this.view.moveCursorUp();
expect(this.view._getCursor()).toBe($bottom);
});
it('should remove cursor if already at top', function() {
var $first;
$first = this.view._getSuggestions().eq(0);
this.view._setCursor($first);
this.view.moveCursorUp();
expect(this.view._getCursor().length).toBe(0);
});
});
describe('#moveCursorDown', function() {
beforeEach(function() {
this.view.open();
});
it('should move the cursor down', function() {
var $first, $second;
$first = this.view._getSuggestions().eq(0);
$second = this.view._getSuggestions().eq(1);
this.view._setCursor($first);
this.view.moveCursorDown();
expect(this.view._getCursor()).toBe($second);
});
it('should move cursor to top if cursor is not present', function() {
var $first;
$first = this.view._getSuggestions().eq(0);
this.view.moveCursorDown();
expect(this.view._getCursor()).toBe($first);
});
it('should remove cursor if already at bottom', function() {
var $bottom;
$bottom = this.view._getSuggestions().eq(-1);
this.view._setCursor($bottom);
this.view.moveCursorDown();
expect(this.view._getCursor().length).toBe(0);
});
});
describe('#getDatumForSuggestion', function() {
it('should extract the datum from the suggestion element', function() {
var $suggestion, datum;
$suggestion = $('<div>').data('ttDatum', { value: 'one' });
datum = this.view.getDatumForSuggestion($suggestion);
expect(datum).toEqual({ value: 'one' });
});
it('should return null if no element is given', function() {
expect(this.view.getDatumForSuggestion($('notreal'))).toBeNull();
});
});
describe('#getDatumForCursor', function() {
it('should return the datum for the cursor', function() {
var $first;
$first = this.view._getSuggestions().eq(0);
$first.data('ttDatum', { value: 'one' });
this.view._setCursor($first);
expect(this.view.getDatumForCursor()).toEqual({ value: 'one' });
});
});
describe('#getDatumForTopSuggestion', function() {
it('should return the datum for top suggestion', function() {
var $first;
$first = this.view._getSuggestions().eq(0);
$first.data('ttDatum', { value: 'one' });
expect(this.view.getDatumForTopSuggestion()).toEqual({ value: 'one' });
});
});
describe('#update', function() {
it('should invoke update on each section', function() {
this.view.update();
expect(this.section.update).toHaveBeenCalled();
});
});
describe('#empty', function() {
it('should invoke clear on each section', function() {
this.view.empty();
expect(this.section.clear).toHaveBeenCalled();
});
});
describe('#isEmpty', function() {
it('should return false if a header or footer is present', function() {
this.section.isEmpty.andReturn(true);
this.$menu.append('<div class="footer">');
expect(this.view.isEmpty()).toBe(false);
});
it('should return false if a section is not empty', function() {
this.section.isEmpty.andReturn(false);
expect(this.view.isEmpty()).toBe(false);
});
it('should return true otherwise', function() {
this.section.isEmpty.andReturn(true);
expect(this.view.isEmpty()).toBe(true);
});
});
});

16
test/fixtures/html.js vendored Normal file
View File

@@ -0,0 +1,16 @@
var fixtures = fixtures || {};
fixtures.html = {
input: '<input class="tt-input" type="text" autocomplete="false" spellcheck="false">',
hint: '<input class="tt-hint" type="text" autocomplete="false" spellcheck="false" disabled>',
menu: '<span class="tt-dropdown-menu"></span>',
section: [
'<div class="tt-section-test">',
'<span class="tt-suggestions">',
'<div class="tt-suggestion"><p>one</p></div>',
'<div class="tt-suggestion"><p>two</p></div>',
'<div class="tt-suggestion"><p>three</p></div>',
'</span>',
'</div>'
].join('')
};

View File

@@ -5,7 +5,8 @@
'Dataset',
'PersistentStorage',
'Transport',
'SearchIndex'
'SearchIndex',
'SectionView'
];
for (var i = 0; i < components.length; i++) {
@@ -31,15 +32,24 @@
}
function mock(Constructor) {
var mockConstructor;
Mock.prototype = Constructor.prototype;
return jasmine.createSpy('mock constructor').andCallFake(Mock);
mockConstructor = jasmine.createSpy('mock constructor').andCallFake(Mock);
// copy instance methods
for (var key in Constructor) {
if (typeof Constructor[key] === 'function') {
mockConstructor[key] = Constructor[key];
}
}
return mockConstructor;
function Mock() {
var instance = utils.mixin({}, Constructor.prototype);
Constructor.apply(instance, arguments);
for (var key in instance) {
if (typeof instance[key] === 'function') {
spyOn(instance, key);

View File

@@ -15,7 +15,6 @@ describe('InputView', function() {
beforeEach(function() {
var $fixture;
jasmine.SectionView.useMock();
setFixtures(fixtures.html.input + fixtures.html.hint);
$fixture = $('#jasmine-fixtures');

View File

@@ -3,18 +3,20 @@ describe('SectionView', function() {
beforeEach(function() {
jasmine.Dataset.useMock();
this.dataset = new Dataset({ name: 'test', local: [] });
this.dataset = new Dataset();
this.dataset.name = 'test';
this.section = new SectionView({
dataset: this.dataset,
templates: {
suggestion: function(context) { return '<p>' + context.value + '</p>'; }
}
});
this.section = new SectionView({ dataset: this.dataset });
this.$root = this.section.getRoot();
});
it('should throw an error if dataset is missing', function() {
expect(noDataset).toThrow();
function noDataset() { new SectionView(); }
});
describe('#getRoot', function() {
it('should return the root element', function() {
expect(this.section.getRoot()).toBe('div.tt-section-test');
@@ -48,6 +50,17 @@ describe('SectionView', function() {
expect(this.$root).not.toContainText('five');
});
});
it('should trigger rendered after suggestions are rendered', function() {
var spy;
this.section.onSync('rendered', spy = jasmine.createSpy());
this.dataset.get.andCallFake(fakeGetWithSyncResults);
this.section.update('woah');
waitsFor(function() { return spy.callCount; });
});
});
describe('#clear', function() {
@@ -64,17 +77,6 @@ describe('SectionView', function() {
});
});
it('should trigger rendered after suggestions are rendered', function() {
var spy;
this.section.onSync('rendered', spy = jasmine.createSpy());
this.dataset.get.andCallFake(fakeGetWithSyncResults);
this.section.update('woah');
waitsFor(function() { return spy.callCount; });
});
describe('#isEmpty', function() {
it('should return true when empty', function() {
expect(this.section.isEmpty()).toBe(true);
@@ -88,6 +90,9 @@ describe('SectionView', function() {
});
});
// helper functions
// ----------------
function fakeGetWithSyncResults(query, cb) {
cb([
{ value: 'one', raw: { value: 'one' } },