From ebaa190e81140e9bb5a2fcf4d3c53425cd55c903 Mon Sep 17 00:00:00 2001 From: Jake Harding Date: Tue, 9 Jul 2013 22:51:12 -0700 Subject: [PATCH] Write test suite for DropdownView. --- src/dropdown_view.js | 34 ++-- src/section_view.js | 30 +++- src/typeahead_view.js | 13 +- test/dropdown_view_spec.js | 287 ++++++++++++++++++++++++++++++++ test/fixtures/html.js | 16 ++ test/helpers/typeahead_mocks.js | 18 +- test/input_view_spec.js | 1 - test/section_view_spec.js | 41 +++-- 8 files changed, 400 insertions(+), 40 deletions(-) create mode 100644 test/fixtures/html.js diff --git a/src/dropdown_view.js b/src/dropdown_view.js index cc69e01..d355ba5 100644 --- a/src/dropdown_view.js +++ b/src/dropdown_view.js @@ -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); - } })(); diff --git a/src/section_view.js b/src/section_view.js index 9dadbdd..b2381a8 100644 --- a/src/section_view.js +++ b/src/section_view.js @@ -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 '

' + context.value + '

'; + } })(); diff --git a/src/typeahead_view.js b/src/typeahead_view.js index 9f9852f..cfbcb31 100644 --- a/src/typeahead_view.js +++ b/src/typeahead_view.js @@ -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(); }, diff --git a/test/dropdown_view_spec.js b/test/dropdown_view_spec.js index d9653da..96ef3b6 100644 --- a/test/dropdown_view_spec.js +++ b/test/dropdown_view_spec.js @@ -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 = $('
').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('