/* eslint-env mocha */
/* eslint no-new:0 */
/* eslint-disable max-len */
import jsdom from 'mocha-jsdom';
import expect from 'expect';
import sinon from 'sinon';
import {waitForAndRun} from './helpers';
describe('DocSearch', () => {
let DocSearch;
let $;
jsdom({useEach: true});
beforeEach(() => {
// We need a DOM to be ready before importing Zepto
// We need to load DocSearch from here as it depends on Zepto, which itself
// needs jsdom to be called before being loaded.
DocSearch = require('../src/lib/DocSearch.js');
$ = require('../src/lib/zepto.js');
// Note: If you edit this HTML while doing TDD with `npm run test:watch`,
// you will have to restart `npm run test:watch` for the new HTML to be
// updated
document.body.innerHTML = `
span span
`;
// We prevent the logging of expected errors
window.console.warn = sinon.spy();
});
describe('constructor', () => {
let AlgoliaSearch;
let algoliasearch;
let AutoComplete;
let autocomplete;
let defaultOptions;
beforeEach(() => {
algoliasearch = {
algolia: 'client',
addAlgoliaAgent: sinon.spy()
};
AlgoliaSearch = sinon.stub().returns(algoliasearch);
autocomplete = {
on: sinon.spy()
};
AutoComplete = sinon.stub().returns(autocomplete);
defaultOptions = {
indexName: 'indexName',
apiKey: 'apiKey',
inputSelector: '#input'
};
sinon.spy(DocSearch, 'checkArguments');
sinon.stub(DocSearch, 'getInputFromSelector').returns(true);
DocSearch.__Rewire__('algoliasearch', AlgoliaSearch);
DocSearch.__Rewire__('autocomplete', AutoComplete);
});
afterEach(() => {
DocSearch.checkArguments.restore();
DocSearch.getInputFromSelector.restore();
DocSearch.__ResetDependency__('algoliasearch');
DocSearch.__ResetDependency__('autocomplete');
});
it('should call checkArguments', () => {
// Given
let options = defaultOptions;
// When
new DocSearch(options);
// Then
expect(DocSearch.checkArguments.calledOnce).toBe(true);
});
it('should pass main options as instance properties', () => {
// Given
let options = defaultOptions;
// When
let actual = new DocSearch(options);
// Then
expect(actual.indexName).toEqual('indexName');
expect(actual.apiKey).toEqual('apiKey');
});
it('should set docsearch App Id as default', () => {
// Given
let options = defaultOptions;
// When
let actual = new DocSearch(options);
// Then
expect(actual.appId).toEqual('BH4D9OD16A');
});
it('should allow overriding appId', () => {
// Given
let options = {...defaultOptions, appId: 'foo'};
// When
let actual = new DocSearch(options);
// Then
expect(actual.appId).toEqual('foo');
});
it('should allow customize algoliaOptions without loosing default options', () => {
// Given
let options = {
algoliaOptions: {
facetFilters: ['version:1.0']
},
...defaultOptions
};
// When
let actual = new DocSearch(options);
// Then
expect(actual.algoliaOptions).toEqual({hitsPerPage: 5, facetFilters: ['version:1.0']});
});
it('should allow customize hitsPerPage', () => {
// Given
let options = {
algoliaOptions: {
hitsPerPage: 10
},
...defaultOptions
};
// When
let actual = new DocSearch(options);
// Then
expect(actual.algoliaOptions).toEqual({hitsPerPage: 10});
});
it('should pass the input element as an instance property', () => {
// Given
let options = defaultOptions;
DocSearch.getInputFromSelector.returns($('foo'));
// When
let actual = new DocSearch(options);
// Then
let $input = actual.input;
expect($input.text()).toEqual('foo');
expect($input[0].tagName).toEqual('SPAN');
});
it('should pass secondary options as instance properties', () => {
// Given
let options = {
...defaultOptions,
algoliaOptions: {anOption: 42},
autocompleteOptions: {anOption: 44}
};
// When
let actual = new DocSearch(options);
// Then
expect(typeof actual.algoliaOptions).toEqual('object');
expect(actual.algoliaOptions.anOption).toEqual(42);
expect(actual.autocompleteOptions).toEqual({debug: false, cssClasses: {prefix: 'ds'}, anOption: 44});
});
it('should instantiate algoliasearch with the correct values', () => {
// Given
let options = defaultOptions;
// When
new DocSearch(options);
// Then
expect(AlgoliaSearch.calledOnce).toBe(true);
expect(AlgoliaSearch.calledWith('BH4D9OD16A', 'apiKey')).toBe(true);
});
it('should set a custom User-Agent to algoliasearch', () => {
// Given
let options = defaultOptions;
// When
new DocSearch(options);
// Then
expect(algoliasearch.addAlgoliaAgent.calledOnce).toBe(true);
});
it('should instantiate autocomplete.js', () => {
// Given
let options = {
...defaultOptions,
autocompleteOptions: {anOption: '44'}
};
let $input = $('');
DocSearch.getInputFromSelector.returns($input);
// When
new DocSearch(options);
// Then
expect(AutoComplete.calledOnce).toBe(true);
expect(AutoComplete.calledWith($input, {anOption: '44', cssClasses: {prefix: 'ds'}, debug: false})).toBe(true);
});
it('should listen to the selected and shown event of autocomplete', () => {
// Given
let options = defaultOptions;
// When
new DocSearch(options);
// Then
expect(autocomplete.on.calledTwice).toBe(true);
expect(autocomplete.on.calledWith('autocomplete:selected')).toBe(true);
});
});
describe('checkArguments', () => {
let checkArguments;
beforeEach(() => {
checkArguments = DocSearch.checkArguments;
});
afterEach(() => {
if (DocSearch.getInputFromSelector.restore) {
DocSearch.getInputFromSelector.restore();
}
});
it('should throw an error if no apiKey defined', () => {
// Given
let options = {
indexName: 'indexName'
};
// When
expect(() => {
checkArguments(options);
}).toThrow(/^Usage:/);
});
it('should throw an error if no indexName defined', () => {
// Given
let options = {
apiKey: 'apiKey'
};
// When
expect(() => {
checkArguments(options);
}).toThrow(/^Usage:/);
});
it('should throw an error if no selector matches', () => {
// Given
let options = {
apiKey: 'apiKey',
indexName: 'indexName'
};
sinon.stub(DocSearch, 'getInputFromSelector').returns(false);
// When
expect(() => {
checkArguments(options);
}).toThrow(/^Error:/);
});
});
describe('getInputFromSelector', () => {
let getInputFromSelector;
beforeEach(() => {
getInputFromSelector = DocSearch.getInputFromSelector;
});
it('should return null if no element matches the selector', () => {
// Given
let selector = '.i-do-not-exist > at #all';
// When
let actual = getInputFromSelector(selector);
// Then
expect(actual).toEqual(null);
});
it('should return null if the matched element is not an input', () => {
// Given
let selector = '.i-am-a-span';
// When
let actual = getInputFromSelector(selector);
// Then
expect(actual).toEqual(null);
});
it('should return a Zepto wrapped element if it matches', () => {
// Given
let selector = '#input';
// When
let actual = getInputFromSelector(selector);
// Then
expect($.zepto.isZ(actual)).toBe(true);
});
});
describe('getAutocompleteSource', () => {
let client;
let AlgoliaSearch;
let docsearch;
beforeEach(() => {
client = {
algolia: 'client',
addAlgoliaAgent: sinon.spy(),
search: sinon.stub().returns({
then: sinon.spy()
})
};
AlgoliaSearch = sinon.stub().returns(client);
DocSearch.__Rewire__('algoliasearch', AlgoliaSearch);
docsearch = new DocSearch({
indexName: 'indexName',
apiKey: 'apiKey',
inputSelector: '#input'
});
});
afterEach(() => {
DocSearch.__ResetDependency__('algoliasearch');
});
it('returns a function', () => {
// Given
let actual = docsearch.getAutocompleteSource();
// When
// Then
expect(actual).toBeA('function');
});
describe('the returned function', () => {
it('calls the Agolia client with the correct parameters', () => {
// Given
let actual = docsearch.getAutocompleteSource();
// When
actual('query');
// Then
expect(client.search.calledOnce).toBe(true);
// expect(resolvedQuery.calledOnce).toBe(true);
let expectedArguments = {
indexName: 'indexName',
query: 'query',
params: {hitsPerPage: 5}
};
expect(client.search.calledWith([expectedArguments])).toBe(true);
});
});
});
describe('handleSelected', () => {
it('should change the location', (done) => {
// Given
const options = {
apiKey: 'key',
indexName: 'foo',
inputSelector: '#input'
};
// When
let ds = new DocSearch(options);
ds.autocomplete.trigger('autocomplete:selected', {url: 'https://website.com/doc/page'});
// Then asynchronously
waitForAndRun(() => {
// escape as soon as the URL has changed
return window.location.href !== 'about:blank';
}, () => {
// then
expect(window.location.href).toEqual('https://website.com/doc/page');
done();
}, 100);
});
});
describe('handleShown', () => {
it('should add an alignment class', () => {
// Given
const options = {
apiKey: 'key',
indexName: 'foo',
inputSelector: '#input'
};
// When
let ds = new DocSearch(options);
ds.autocomplete.trigger('autocomplete:shown');
expect($('.algolia-autocomplete').attr('class')).toEqual('algolia-autocomplete algolia-autocomplete-left');
});
});
describe('formatHits', () => {
it('should not mutate the input', () => {
// Given
let input = [{
hierarchy: {
lvl0: 'Ruby',
lvl1: 'API',
lvl2: null,
lvl3: null,
lvl4: null,
lvl5: null
}
}];
// When
let actual = DocSearch.formatHits(input);
// Then
expect(input).toNotBe(actual);
});
it('should set category headers to the first of each category', () => {
// Given
let input = [{
hierarchy: {
lvl0: 'Ruby',
lvl1: 'API',
lvl2: null,
lvl3: null,
lvl4: null,
lvl5: null
}
}, {
hierarchy: {
lvl0: 'Ruby',
lvl1: 'Geo-search',
lvl2: null,
lvl3: null,
lvl4: null,
lvl5: null
}
}, {
hierarchy: {
lvl0: 'Python',
lvl1: 'API',
lvl2: null,
lvl3: null,
lvl4: null,
lvl5: null
}
}];
// When
let actual = DocSearch.formatHits(input);
// Then
expect(actual[0].isCategoryHeader).toEqual(true);
expect(actual[2].isCategoryHeader).toEqual(true);
});
it('should group items of same category together', () => {
// Given
let input = [{
hierarchy: {
lvl0: 'Ruby',
lvl1: 'API',
lvl2: null,
lvl3: null,
lvl4: null,
lvl5: null
}
}, {
hierarchy: {
lvl0: 'Python',
lvl1: 'API',
lvl2: null,
lvl3: null,
lvl4: null,
lvl5: null
}
}, {
hierarchy: {
lvl0: 'Ruby',
lvl1: 'Geo-search',
lvl2: null,
lvl3: null,
lvl4: null,
lvl5: null
}
}];
// When
let actual = DocSearch.formatHits(input);
// Then
expect(actual[0].category).toEqual('Ruby');
expect(actual[1].category).toEqual('Ruby');
expect(actual[2].category).toEqual('Python');
});
it('should mark all first elements as subcategories', () => {
// Given
let input = [{
hierarchy: {
lvl0: 'Ruby',
lvl1: 'API',
lvl2: null,
lvl3: null,
lvl4: null,
lvl5: null
}
}, {
hierarchy: {
lvl0: 'Python',
lvl1: 'API',
lvl2: null,
lvl3: null,
lvl4: null,
lvl5: null
}
}, {
hierarchy: {
lvl0: 'Ruby',
lvl1: 'Geo-search',
lvl2: null,
lvl3: null,
lvl4: null,
lvl5: null
}
}];
// When
let actual = DocSearch.formatHits(input);
// Then
expect(actual[0].isSubCategoryHeader).toEqual(true);
expect(actual[2].isSubCategoryHeader).toEqual(true);
});
it('should mark new subcategories as such', () => {
// Given
let input = [{
hierarchy: {
lvl0: 'Ruby',
lvl1: 'API',
lvl2: 'Foo',
lvl3: null,
lvl4: null,
lvl5: null
}
}, {
hierarchy: {
lvl0: 'Python',
lvl1: 'API',
lvl2: null,
lvl3: null,
lvl4: null,
lvl5: null
}
}, {
hierarchy: {
lvl0: 'Ruby',
lvl1: 'API',
lvl2: 'Bar',
lvl3: null,
lvl4: null,
lvl5: null
}
}, {
hierarchy: {
lvl0: 'Ruby',
lvl1: 'Geo-search',
lvl2: null,
lvl3: null,
lvl4: null,
lvl5: null
}
}];
// When
let actual = DocSearch.formatHits(input);
// Then
expect(actual[0].isSubCategoryHeader).toEqual(true);
expect(actual[1].isSubCategoryHeader).toEqual(false);
expect(actual[2].isSubCategoryHeader).toEqual(true);
expect(actual[3].isSubCategoryHeader).toEqual(true);
});
it('should use highlighted category and subcategory if exists', () => {
// Given
let input = [{
hierarchy: {
lvl0: 'Ruby',
lvl1: 'API',
lvl2: 'Foo',
lvl3: null,
lvl4: null,
lvl5: null
},
_highlightResult: {
hierarchy: {
lvl0: {
value: 'Ruby'
},
lvl1: {
value: 'API'
}
}
}
}];
// When
let actual = DocSearch.formatHits(input);
// Then
expect(actual[0].category).toEqual('Ruby');
expect(actual[0].subcategory).toEqual('API');
});
it('should use highlighted camel if exists and matchLevel not none', () => {
// Given
let input = [{
hierarchy: {
lvl0: 'Ruby',
lvl1: 'API',
lvl2: 'Foo',
lvl3: null,
lvl4: null,
lvl5: null
},
_highlightResult: {
hierarchy_camel: {
lvl0: {
value: 'Python',
matchLevel: 'full'
},
lvl1: {
value: 'API2',
matchLevel: 'full'
}
},
hierarchy: {
lvl0: {
value: 'Ruby'
},
lvl1: {
value: 'API'
}
}
}
}];
// When
let actual = DocSearch.formatHits(input);
// Then
expect(actual[0].category).toEqual('Python');
expect(actual[0].subcategory).toEqual('API2');
});
it('should use lvl2 as title', () => {
// Given
let input = [{
hierarchy: {
lvl0: 'Ruby',
lvl1: 'API',
lvl2: 'Foo',
lvl3: null,
lvl4: null,
lvl5: null
}
}];
// When
let actual = DocSearch.formatHits(input);
// Then
expect(actual[0].title).toEqual('Foo');
});
it('should use lvl1 as title if no lvl2', () => {
// Given
let input = [{
hierarchy: {
lvl0: 'Ruby',
lvl1: 'API',
lvl2: null,
lvl3: null,
lvl4: null,
lvl5: null
}
}];
// When
let actual = DocSearch.formatHits(input);
// Then
expect(actual[0].title).toEqual('API');
});
it('should use lvl0 as title if no lvl2 nor lvl2', () => {
// Given
let input = [{
hierarchy: {
lvl0: 'Ruby',
lvl1: null,
lvl2: null,
lvl3: null,
lvl4: null,
lvl5: null
}
}];
// When
let actual = DocSearch.formatHits(input);
// Then
expect(actual[0].title).toEqual('Ruby');
});
it('should concatenate lvl2+ for title if more', () => {
// Given
let input = [{
hierarchy: {
lvl0: 'Ruby',
lvl1: 'API',
lvl2: 'Geo-search',
lvl3: 'Foo',
lvl4: 'Bar',
lvl5: 'Baz'
}
}];
// When
let actual = DocSearch.formatHits(input);
let separator = ' › ';
// Then
expect(actual[0].title).toEqual('Geo-search' + separator + 'Foo' + separator + 'Bar' + separator + 'Baz');
});
it('should concatenate highlighted elements', () => {
// Given
let input = [{
hierarchy: {
lvl0: 'Ruby',
lvl1: 'API',
lvl2: 'Geo-search',
lvl3: 'Foo',
lvl4: 'Bar',
lvl5: 'Baz'
},
_highlightResult: {
hierarchy: {
lvl0: {
value: 'Ruby'
},
lvl1: {
value: 'API'
},
lvl2: {
value: 'Geo-search'
},
lvl3: {
value: 'Foo'
},
lvl4: {
value: 'Bar'
},
lvl5: {
value: 'Baz'
}
}
}
}];
// When
let actual = DocSearch.formatHits(input);
let separator = ' › ';
// Then
let expected = 'Geo-search' + separator + 'Foo' + separator +
'Bar' + separator + 'Baz';
expect(actual[0].title).toEqual(expected);
});
it('should add ellipsis to content', () => {
// Given
let input = [{
hierarchy: {
lvl0: 'Ruby',
lvl1: 'API',
lvl2: null,
lvl3: null,
lvl4: null,
lvl5: null
},
content: 'foo bar',
_snippetResult: {
content: {
value: 'lorem foo bar ipsum.'
}
}
}];
// When
let actual = DocSearch.formatHits(input);
// Then
expect(actual[0].text).toEqual('…lorem foo bar ipsum.');
});
it('should add the anchor to the url if one is set', () => {
// Given
let input = [{
hierarchy: {
lvl0: 'Ruby',
lvl1: 'API',
lvl2: null,
lvl3: null,
lvl4: null,
lvl5: null
},
content: 'foo bar',
url: 'http://foo.bar/',
anchor: 'anchor'
}];
// When
let actual = DocSearch.formatHits(input);
// Then
expect(actual[0].url).toEqual('http://foo.bar/#anchor');
});
it('should not add the anchor to the url if one is set but it is already in the URL', () => {
// Given
let input = [{
hierarchy: {
lvl0: 'Ruby',
lvl1: 'API',
lvl2: null,
lvl3: null,
lvl4: null,
lvl5: null
},
content: 'foo bar',
url: 'http://foo.bar/#anchor',
anchor: 'anchor'
}];
// When
let actual = DocSearch.formatHits(input);
// Then
expect(actual[0].url).toEqual('http://foo.bar/#anchor');
});
it('should just use the URL if no anchor is provided', () => {
// Given
let input = [{
hierarchy: {
lvl0: 'Ruby',
lvl1: 'API',
lvl2: null,
lvl3: null,
lvl4: null,
lvl5: null
},
content: 'foo bar',
url: 'http://foo.bar/'
}];
// When
let actual = DocSearch.formatHits(input);
// Then
expect(actual[0].url).toEqual(input[0].url);
});
it('should return the anchor if there is no URL', () => {
// Given
let input = [{
hierarchy: {
lvl0: 'Ruby',
lvl1: 'API',
lvl2: null,
lvl3: null,
lvl4: null,
lvl5: null
},
content: 'foo bar',
anchor: 'anchor'
}];
// When
let actual = DocSearch.formatHits(input);
// Then
expect(actual[0].url).toEqual('#' + input[0].anchor);
});
});
describe('formatUrl', () => {
it('concatenates url and anchor', () => {
// Given
let input = {
url: 'url',
anchor: 'anchor'
};
// When
let actual = DocSearch.formatURL(input);
// Then
expect(actual).toEqual('url#anchor');
});
it('returns only the url if no anchor', () => {
// Given
let input = {
url: 'url'
};
// When
let actual = DocSearch.formatURL(input);
// Then
expect(actual).toEqual('url');
});
it('returns the anchor if no url', () => {
// Given
let input = {
anchor: 'anchor'
};
// When
let actual = DocSearch.formatURL(input);
// Then
expect(actual).toEqual('#anchor');
});
it('does not concatenate if already an anchor', () => {
// Given
let input = {
url: 'url#anchor',
anchor: 'anotheranchor'
};
// When
let actual = DocSearch.formatURL(input);
// Then
expect(actual).toEqual('url#anchor');
});
it('returns null if no anchor nor url', () => {
// Given
let input = {
};
// When
let actual = DocSearch.formatURL(input);
// Then
expect(actual).toEqual(null);
});
it('emits a warning if no anchor nor url', () => {
// Given
let input = {
};
// When
DocSearch.formatURL(input);
// Then
expect(window.console.warn.calledOnce).toBe(true);
});
});
describe('getSuggestionTemplate', () => {
beforeEach(() => {
let templates = {
suggestion: ''
};
DocSearch.__Rewire__('templates', templates);
});
afterEach(() => {
DocSearch.__ResetDependency__('templates');
});
it('should return a function', () => {
// Given
// When
let actual = DocSearch.getSuggestionTemplate();
// Then
expect(actual).toBeA('function');
});
describe('returned function', () => {
let Hogan;
let render;
beforeEach(() => {
render = sinon.spy();
Hogan = {
compile: sinon.stub().returns({render})
};
DocSearch.__Rewire__('Hogan', Hogan);
});
it('should compile the suggestion template', () => {
// Given
// When
DocSearch.getSuggestionTemplate();
// Then
expect(Hogan.compile.calledOnce).toBe(true);
expect(Hogan.compile.calledWith('')).toBe(true);
});
it('should call render on a Hogan template', () => {
// Given
let actual = DocSearch.getSuggestionTemplate();
// When
actual({foo: 'bar'});
// Then
expect(render.calledOnce).toBe(true);
expect(render.args[0][0]).toEqual({foo: 'bar'});
});
});
});
});