Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion aio/src/app/app.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -632,7 +632,6 @@ describe('AppComponent', () => {
it('should initialize the search worker', inject([SearchService], (searchService: SearchService) => {
fixture.detectChanges(); // triggers ngOnInit
expect(searchService.initWorker).toHaveBeenCalled();
expect(searchService.loadIndex).toHaveBeenCalled();
}));
});

Expand Down
4 changes: 2 additions & 2 deletions aio/src/app/app.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,8 @@ export class AppComponent implements OnInit {
ngOnInit() {
// Do not initialize the search on browsers that lack web worker support
if ('Worker' in window) {
this.searchService.initWorker('app/search/search-worker.js');
this.searchService.loadIndex();
// Delay initialization by up to 2 seconds
this.searchService.initWorker('app/search/search-worker.js', 2000);
}

this.onResize(window.innerWidth);
Expand Down
76 changes: 48 additions & 28 deletions aio/src/app/search/search-box/search-box.component.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Component } from '@angular/core';
import { async, ComponentFixture, TestBed, inject } from '@angular/core/testing';
import { ComponentFixture, fakeAsync, inject, TestBed, tick } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { SearchBoxComponent } from './search-box.component';
import { MockSearchService } from 'testing/search.service';
Expand Down Expand Up @@ -36,30 +36,67 @@ describe('SearchBoxComponent', () => {
});

describe('initialisation', () => {
it('should get the current search query from the location service', inject([LocationService], (location: MockLocationService) => {
it('should get the current search query from the location service',
inject([LocationService], (location: MockLocationService) => fakeAsync(() => {
location.search.and.returnValue({ search: 'initial search' });
component.ngOnInit();
expect(location.search).toHaveBeenCalled();
tick(300);
expect(host.searchHandler).toHaveBeenCalledWith('initial search');
expect(component.searchBox.nativeElement.value).toEqual('initial search');
})));
});

describe('onSearch', () => {
it('should debounce by 300ms', fakeAsync(() => {
component.doSearch();
expect(host.searchHandler).not.toHaveBeenCalled();
tick(300);
expect(host.searchHandler).toHaveBeenCalled();
}));

it('should pass through the value of the input box', fakeAsync(() => {
const input = fixture.debugElement.query(By.css('input'));
input.nativeElement.value = 'some query (input)';
component.doSearch();
tick(300);
expect(host.searchHandler).toHaveBeenCalledWith('some query (input)');
}));

it('should only send events if the search value has changed', fakeAsync(() => {
const input = fixture.debugElement.query(By.css('input'));

input.nativeElement.value = 'some query';
component.doSearch();
tick(300);
expect(host.searchHandler).toHaveBeenCalledTimes(1);

component.doSearch();
tick(300);
expect(host.searchHandler).toHaveBeenCalledTimes(1);

input.nativeElement.value = 'some other query';
component.doSearch();
tick(300);
expect(host.searchHandler).toHaveBeenCalledTimes(2);
}));
});

describe('on input', () => {
it('should trigger the onSearch event', () => {
it('should trigger a search', () => {
const input = fixture.debugElement.query(By.css('input'));
input.nativeElement.value = 'some query (input)';
spyOn(component, 'doSearch');
input.triggerEventHandler('input', { });
expect(host.searchHandler).toHaveBeenCalledWith('some query (input)');
expect(component.doSearch).toHaveBeenCalled();
});
});

describe('on keyup', () => {
it('should trigger the onSearch event', () => {
it('should trigger a search', () => {
const input = fixture.debugElement.query(By.css('input'));
input.nativeElement.value = 'some query (keyup)';
spyOn(component, 'doSearch');
input.triggerEventHandler('keyup', { });
expect(host.searchHandler).toHaveBeenCalledWith('some query (keyup)');
expect(component.doSearch).toHaveBeenCalled();
});
});

Expand All @@ -73,28 +110,11 @@ describe('SearchBoxComponent', () => {
});

describe('on click', () => {
it('should trigger the search event', () => {
it('should trigger a search', () => {
const input = fixture.debugElement.query(By.css('input'));
input.nativeElement.value = 'some query (click)';
spyOn(component, 'doSearch');
input.triggerEventHandler('click', { });
expect(host.searchHandler).toHaveBeenCalledWith('some query (click)');
});
});

describe('event filtering', () => {
it('should only send events if the search value has changed', () => {
const input = fixture.debugElement.query(By.css('input'));

input.nativeElement.value = 'some query';
input.triggerEventHandler('input', { });
expect(host.searchHandler).toHaveBeenCalledTimes(1);

input.triggerEventHandler('input', { });
expect(host.searchHandler).toHaveBeenCalledTimes(1);

input.nativeElement.value = 'some other query';
input.triggerEventHandler('input', { });
expect(host.searchHandler).toHaveBeenCalledTimes(2);
expect(component.doSearch).toHaveBeenCalled();
});
});

Expand Down
3 changes: 2 additions & 1 deletion aio/src/app/search/search-box/search-box.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,11 @@ import 'rxjs/add/operator/distinctUntilChanged';
})
export class SearchBoxComponent implements OnInit {

private searchDebounce = 300;
private searchSubject = new Subject<string>();

@ViewChild('searchBox') searchBox: ElementRef;
@Output() onSearch = this.searchSubject.distinctUntilChanged();
@Output() onSearch = this.searchSubject.distinctUntilChanged().debounceTime(this.searchDebounce);
@Output() onFocus = new EventEmitter<string>();

constructor(private locationService: LocationService) { }
Expand Down
48 changes: 43 additions & 5 deletions aio/src/app/search/search.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,62 @@
import { ReflectiveInjector, NgZone } from '@angular/core';
import { fakeAsync, tick } from '@angular/core/testing';
import { Observable } from 'rxjs/Observable';
import { SearchService } from './search.service';
import { WebWorkerClient } from 'app/shared/web-worker';

describe('SearchService', () => {

let injector: ReflectiveInjector;
let service: SearchService;
let sendMessageSpy: jasmine.Spy;
let mockWorker: WebWorkerClient;

beforeEach(() => {
sendMessageSpy = jasmine.createSpy('sendMessage').and.returnValue(Observable.of({}));
mockWorker = { sendMessage: sendMessageSpy } as any;
spyOn(WebWorkerClient, 'create').and.returnValue(mockWorker);

injector = ReflectiveInjector.resolveAndCreate([
SearchService,
{ provide: NgZone, useFactory: () => new NgZone({ enableLongStackTrace: false }) }
]);
service = injector.get(SearchService);
});

describe('loadIndex', () => {
it('should send a "load-index" message to the worker');
it('should connect the `ready` property to the response to the "load-index" message');
describe('initWorker', () => {
it('should create the worker and load the index after the specified delay', fakeAsync(() => {
service.initWorker('some/url', 100);
expect(WebWorkerClient.create).not.toHaveBeenCalled();
expect(mockWorker.sendMessage).not.toHaveBeenCalled();
tick(100);
expect(WebWorkerClient.create).toHaveBeenCalledWith('some/url', jasmine.any(NgZone));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now the real question is: Does it use the url? 😛

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a question to ask this unit test :-P

expect(mockWorker.sendMessage).toHaveBeenCalledWith('load-index');
}));
});

describe('search', () => {
it('should send a "query-index" message to the worker');
it('should push the response to the `searchResults` observable');
beforeEach(() => {
// We must initialize the service before calling search
service.initWorker('some/url', 100);
});

it('should trigger a `loadIndex` synchronously', () => {
service.search('some query');
expect(mockWorker.sendMessage).toHaveBeenCalledWith('load-index');
});

it('should send a "query-index" message to the worker', () => {
service.search('some query');
expect(mockWorker.sendMessage).toHaveBeenCalledWith('query-index', 'some query');
});

it('should push the response to the `searchResults` observable', () => {
const mockSearchResults = { results: ['a', 'b'] };
(mockWorker.sendMessage as jasmine.Spy).and.returnValue(Observable.of(mockSearchResults));
let searchResults: any;
service.searchResults.subscribe(results => searchResults = results);
service.search('some query');
expect(searchResults).toEqual(mockSearchResults);
});
});
});
56 changes: 41 additions & 15 deletions aio/src/app/search/search.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ can be found in the LICENSE file at http://angular.io/license
import { NgZone, Injectable, Type } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import { ReplaySubject } from 'rxjs/ReplaySubject';
import 'rxjs/add/operator/publishLast';
import 'rxjs/add/observable/race';
import 'rxjs/add/observable/timer';
import 'rxjs/add/operator/concatMap';
import 'rxjs/add/operator/publish';
import { WebWorkerClient } from 'app/shared/web-worker';

export interface SearchResults {
Expand All @@ -27,26 +29,50 @@ export interface SearchResult {

@Injectable()
export class SearchService {
private worker: WebWorkerClient;
private ready: Observable<boolean>;
private resultsSubject = new ReplaySubject<SearchResults>(1);
readonly searchResults = this.resultsSubject.asObservable();
private searchesSubject = new ReplaySubject<string>(1);
searchResults: Observable<SearchResults>;

constructor(private zone: NgZone) {}

initWorker(workerUrl) {
this.worker = new WebWorkerClient(new Worker(workerUrl), this.zone);
}
/**
* Initialize the search engine. We offer an `initDelay` to prevent the search initialisation from delaying the
* initial rendering of the web page. Triggering a search will override this delay and cause the index to be
* loaded immediately.
*
* @param workerUrl the url of the WebWorker script that runs the searches
* @param initDelay the number of milliseconds to wait before we load the WebWorker and generate the search index
*/
initWorker(workerUrl: string, initDelay: number) {
const searchResults = Observable
// Wait for the initDelay or the first search
.race(
Observable.timer(initDelay),
this.searchesSubject.first()
)
.concatMap(() => {
// Create the worker and load the index
const worker = WebWorkerClient.create(workerUrl, this.zone);
return worker.sendMessage('load-index').concatMap(() =>
// Once the index has loaded, switch to listening to the searches coming in
this.searchesSubject.switchMap((query) =>
// Each search gets switched to a web worker message, whose results are returned via an observable
worker.sendMessage<SearchResults>('query-index', query)
)
);
}).publish();

// Connect to the observable to kick off the timer
searchResults.connect();

loadIndex() {
const ready = this.ready = this.worker.sendMessage<boolean>('load-index').publishLast();
// trigger the index to be loaded immediately
ready.connect();
// Expose the connected observable to the rest of the world
this.searchResults = searchResults;
}

/**
* Send a search query to the index.
* The results will appear on the `searchResults` observable.
*/
search(query: string) {
this.ready.concatMap(ready => {
return this.worker.sendMessage('query-index', query) as Observable<SearchResults>;
}).subscribe(results => this.resultsSubject.next(results));
this.searchesSubject.next(query);
}
}
6 changes: 5 additions & 1 deletion aio/src/app/shared/web-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ export interface WebWorkerMessage {
export class WebWorkerClient {
private nextId = 0;

constructor(private worker: Worker, private zone: NgZone) {
static create(workerUrl: string, zone: NgZone) {
return new WebWorkerClient(new Worker(workerUrl), zone);
}

private constructor(private worker: Worker, private zone: NgZone) {
}

sendMessage<T>(type: string, payload?: any): Observable<T> {
Expand Down