From c52502c5b68d9eb5ed515d7343c554e8c729893d Mon Sep 17 00:00:00 2001 From: OZGCloud <ozgcloud@mgm-tp.com> Date: Fri, 9 Aug 2024 08:37:25 +0200 Subject: [PATCH] OZG-6129 Add unit tests --- .../apps/demo/src/app/app.component.ts | 10 +- .../instant-search.component.spec.ts | 254 ++++++++++++++++-- .../instant-search.component.ts | 84 ++++-- .../instant-search/instant-search.model.ts | 2 +- .../search-result-item.component.spec.ts | 8 +- .../search-result-item.component.ts | 8 +- 6 files changed, 301 insertions(+), 65 deletions(-) diff --git a/alfa-client/apps/demo/src/app/app.component.ts b/alfa-client/apps/demo/src/app/app.component.ts index 2e7493754a..31d5d75101 100644 --- a/alfa-client/apps/demo/src/app/app.component.ts +++ b/alfa-client/apps/demo/src/app/app.component.ts @@ -77,19 +77,19 @@ export class AppComponent { instantSearchItems: InstantSearchResult<unknown>[] = [ { - caption: 'Landeshauptstadt Kiel - Ordnungsamt, Gewerbe- und Schornsteinfegeraufsicht', + title: 'Landeshauptstadt Kiel - Ordnungsamt, Gewerbe- und Schornsteinfegeraufsicht', description: 'Fabrikstraße 8-10, 24103 Kiel', }, { - caption: 'Amt für Digitalisierung, Breitband und Vermessung Nürnberg Außenstelle Hersbruck', + title: 'Amt für Digitalisierung, Breitband und Vermessung Nürnberg Außenstelle Hersbruck', description: 'Rathausmarkt 7, Hersbruck', }, { - caption: 'Amt für Digitalisierung, Breitband und Vermessung Stuttgart', + title: 'Amt für Digitalisierung, Breitband und Vermessung Stuttgart', description: 'Rathausmarkt 7, Stuttgart', }, { - caption: 'Amt für Digitalisierung, Breitband und Vermessung Ulm', + title: 'Amt für Digitalisierung, Breitband und Vermessung Ulm', description: 'Rathausmarkt 7, Ulm', }, ]; @@ -98,7 +98,7 @@ export class AppComponent { getInstantSearchResults() { if (this.instantSearchFormControl.value.length < 2) return []; return this.instantSearchItems.filter((item) => - item.caption.includes(this.instantSearchFormControl.value), + item.title.toLowerCase().includes(this.instantSearchFormControl.value.toLowerCase()), ); } diff --git a/alfa-client/libs/design-system/src/lib/instant-search/instant-search/instant-search.component.spec.ts b/alfa-client/libs/design-system/src/lib/instant-search/instant-search/instant-search.component.spec.ts index d61bc19f65..d9ae0f0ab8 100644 --- a/alfa-client/libs/design-system/src/lib/instant-search/instant-search/instant-search.component.spec.ts +++ b/alfa-client/libs/design-system/src/lib/instant-search/instant-search/instant-search.component.spec.ts @@ -1,5 +1,6 @@ import { getElementFromFixtureByType } from '@alfa-client/test-utils'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Subscription } from 'rxjs'; import { SearchResultItemComponent } from '../search-result-item/search-result-item.component'; import { InstantSearchComponent } from './instant-search.component'; import { InstantSearchResult } from './instant-search.model'; @@ -9,8 +10,8 @@ describe('InstantSearchComponent', () => { let fixture: ComponentFixture<InstantSearchComponent>; const searchResults: InstantSearchResult<unknown>[] = [ - { caption: 'test', description: 'test' }, - { caption: 'caption', description: 'desc' }, + { title: 'test', description: 'test' }, + { title: 'caption', description: 'desc' }, ]; beforeEach(async () => { @@ -28,6 +29,19 @@ describe('InstantSearchComponent', () => { }); describe('ngOnInit', () => { + // it('should call showResults after inputting 2 chars', fakeAsync(() => { + // component.showResults = jest.fn(); + // const input: HTMLInputElement = getElementFromFixture(fixture, 'input'); + + // component.ngOnInit(); + // input.value = 'Am'; + // dispatchEventFromFixture(fixture, 'input', 'input'); + // fixture.detectChanges(); + // tick(400); + + // expect(component.showResults).toHaveBeenCalledTimes(1); + // })); + it('should subscribe to value changes', () => { component.control.valueChanges.subscribe = jest.fn(); @@ -37,42 +51,106 @@ describe('InstantSearchComponent', () => { }); }); + describe('ngOnDestroy', () => { + it('should subscribe to value changes', () => { + component.formControlSubscription = new Subscription(); + component.formControlSubscription.unsubscribe = jest.fn(); + + component.ngOnDestroy(); + + expect(component.formControlSubscription.unsubscribe).toHaveBeenCalled(); + }); + }); + describe('searchResultSelected', () => { it('should emit event', () => { - const result = { caption: 'test', description: 'test' }; + const result: InstantSearchResult<unknown> = { title: 'test', description: 'test' }; component.searchResults = [result]; - component.isShowResults = true; + component.showResults(); fixture.detectChanges(); const element = getElementFromFixtureByType(fixture, SearchResultItemComponent); const emitSpy = jest.spyOn(component.searchResultSelected, 'emit'); - element.clickItem.emit(); + element.itemClicked.emit(); expect(emitSpy).toHaveBeenCalledWith(result); }); }); describe('set searchResults', () => { - it('should set results if they are different', () => { - component.searchResults = searchResults; + describe('on different results', () => { + it('should call setSearchResults', () => { + component.setSearchResults = jest.fn(); + + component.searchResults = searchResults; + + expect(component.setSearchResults).toHaveBeenCalled(); + }); + }); + + describe('on same results', () => { + it('should not call setSearchResults', () => { + component.setSearchResults = jest.fn(); + + component.searchResults = []; + + expect(component.setSearchResults).not.toHaveBeenCalled(); + }); + }); + + describe('on null or undefined', () => { + it.each([null, undefined])( + 'should not call setSearchResults for %s', + (searchResults: InstantSearchResult<unknown>[]) => { + component.setSearchResults = jest.fn(); + + component.searchResults = searchResults; + + expect(component.setSearchResults).not.toHaveBeenCalled(); + }, + ); + }); + }); + + describe('setSearchResults', () => { + it('should set results', () => { + component.setSearchResults(searchResults); expect(component.results).toEqual(searchResults); }); - it('should call buildAriaLiveText with search results length if results are different', () => { + it('should call buildAriaLiveText with search results length', () => { component.buildAriaLiveText = jest.fn(); - component.searchResults = searchResults; + component.setSearchResults(searchResults); expect(component.buildAriaLiveText).toHaveBeenCalledWith(searchResults.length); }); }); + describe('setFocusOnResultItem', () => { + beforeEach(() => { + component.resultsRef.get = jest.fn().mockReturnValue({ setFocus: jest.fn() }); + }); + it('should call get for resultsRef with index', () => { + component.setFocusOnResultItem(1); + + expect(component.resultsRef.get).toHaveBeenCalledWith(1); + }); + + it('should call setFocus', () => { + component.setFocusOnResultItem(1); + + expect(component.resultsRef.get(1).setFocus).toHaveBeenCalled(); + }); + }); + describe('handleArrowNavigation', () => { beforeEach(() => { component.setFocusOnResultItem = jest.fn(); + component.getResultIndexForKey = jest.fn(); }); - const event = new KeyboardEvent('arrow'); + const event: KeyboardEvent = new KeyboardEvent('arrow'); it('should call prevent default', () => { event.preventDefault = jest.fn(); @@ -83,15 +161,13 @@ describe('InstantSearchComponent', () => { }); it('should call getResultIndexForKey with key of event', () => { - component.getResultIndexForKey = jest.fn(); - component.handleArrowNavigation(event); expect(component.getResultIndexForKey).toHaveBeenCalledWith(event.key); }); it('should call setFocusOnResultItem new index', () => { - component.getResultIndexForKey = jest.fn(() => 0); + component.getResultIndexForKey = jest.fn().mockReturnValue(0); component.handleArrowNavigation(event); @@ -100,7 +176,7 @@ describe('InstantSearchComponent', () => { }); describe('handleEscape', () => { - const event = new KeyboardEvent('esc'); + const event: KeyboardEvent = new KeyboardEvent('esc'); it('should call prevent default', () => { event.preventDefault = jest.fn(); @@ -121,39 +197,39 @@ describe('InstantSearchComponent', () => { describe('getNextResultIndex', () => { it('should return 0 if index is undefined', () => { - const result = component.getNextResultIndex(undefined, 2); + const result: number = component.getNextResultIndex(undefined, 2); expect(result).toBe(0); }); - it('should return 0 if index is more or equal than results length', () => { - const result = component.getNextResultIndex(2, 2); + it('should return 0 if current index is last', () => { + const result: number = component.getNextResultIndex(1, 2); expect(result).toBe(0); }); - it('should iterate', () => { - const result = component.getNextResultIndex(0, 2); + it('should return next search result index', () => { + const result: number = component.getNextResultIndex(0, 2); expect(result).toBe(1); }); }); describe('getPreviousResultIndex', () => { - it('should return results length -1 if index is undefined', () => { - const result = component.getPreviousResultIndex(undefined, 2); + it('should return last index if current index is undefined', () => { + const result: number = component.getPreviousResultIndex(undefined, 2); expect(result).toBe(1); }); - it('should return results length -1 if index is less or equal than 0', () => { - const result = component.getPreviousResultIndex(0, 2); + it('should return last index if current index is first', () => { + const result: number = component.getPreviousResultIndex(0, 2); expect(result).toBe(1); }); - it('should decrement', () => { - const result = component.getPreviousResultIndex(1, 2); + it('should return previous search result index', () => { + const result: number = component.getPreviousResultIndex(1, 2); expect(result).toBe(0); }); @@ -177,13 +253,27 @@ describe('InstantSearchComponent', () => { }); }); + describe('getLastItemIndex', () => { + it('should return 0', () => { + const result: number = component.getLastItemIndex(0); + + expect(result).toBe(0); + }); + + it('should return decrement of array length', () => { + const result: number = component.getLastItemIndex(5); + + expect(result).toBe(4); + }); + }); + describe('buildAriaLiveText', () => { beforeEach(() => { component.control.setValue('test'); }); it('should return text for one result', () => { - const result = component.buildAriaLiveText(1); + const result: string = component.buildAriaLiveText(1); expect(result).toBe( 'Ein Suchergebnis für Eingabe test. Nutze Pfeiltaste nach unten, um das zu erreichen.', @@ -191,7 +281,7 @@ describe('InstantSearchComponent', () => { }); it('should return text for many results', () => { - const result = component.buildAriaLiveText(4); + const result: string = component.buildAriaLiveText(4); expect(result).toBe( '4 Suchergebnisse für Eingabe test. Nutze Pfeiltaste nach unten, um diese zu erreichen.', @@ -199,7 +289,7 @@ describe('InstantSearchComponent', () => { }); it('should return text for no results', () => { - const result = component.buildAriaLiveText(0); + const result: string = component.buildAriaLiveText(0); expect(result).toBe('Keine Ergebnisse'); }); @@ -221,6 +311,92 @@ describe('InstantSearchComponent', () => { }); }); + describe('isSearchResultsEmpty', () => { + it('should return true', () => { + component.results = []; + + const result: boolean = component.isSearchResultsEmpty(); + + expect(result).toBe(true); + }); + + it('should return false', () => { + component.results = searchResults; + + const result: boolean = component.isSearchResultsEmpty(); + + expect(result).toBe(false); + }); + }); + + describe('isArrowNavigationKey', () => { + it('should return true if key is ArrowDown', () => { + const arrowDownEvent = { ...new KeyboardEvent('key'), key: 'ArrowDown' }; + + const result: boolean = component.isArrowNavigationKey(arrowDownEvent); + + expect(result).toBe(true); + }); + + it('should return true if key is ArrowUp', () => { + const arrowUpEvent = { ...new KeyboardEvent('navigation'), key: 'ArrowUp' }; + + const result: boolean = component.isArrowNavigationKey(arrowUpEvent); + + expect(result).toBe(true); + }); + + it('should return false', () => { + const result: boolean = component.isArrowNavigationKey(new KeyboardEvent('not arrow')); + + expect(result).toBe(false); + }); + }); + + describe('isEscapeKey', () => { + it('should return true', () => { + const escapeKeyEvent = { ...new KeyboardEvent('esc'), key: 'Escape' }; + + const result: boolean = component.isEscapeKey(escapeKeyEvent); + + expect(result).toBe(true); + }); + + it('should return false', () => { + const result: boolean = component.isEscapeKey(new KeyboardEvent('not escape')); + + expect(result).toBe(false); + }); + }); + + describe('isLastItemOrOutOfArray', () => { + it.each([3, 5])('should return true for %s', (index: number) => { + const result: boolean = component.isLastItemOrOutOfArray(index, 4); + + expect(result).toBe(true); + }); + + it('should return false', () => { + const result: boolean = component.isLastItemOrOutOfArray(1, 3); + + expect(result).toBe(false); + }); + }); + + describe('isFirstItemOrOutOfArray', () => { + it.each([0, -1])('should return true for %s', (index: number) => { + const result: boolean = component.isFirstItemOrOutOfArray(index); + + expect(result).toBe(true); + }); + + it('should return false', () => { + const result: boolean = component.isFirstItemOrOutOfArray(1); + + expect(result).toBe(false); + }); + }); + describe('onClickItem', () => { it('should emit searchResultSelected', () => { component.searchResultSelected.emit = jest.fn(); @@ -230,4 +406,26 @@ describe('InstantSearchComponent', () => { expect(component.searchResultSelected.emit).toHaveBeenCalledWith(searchResults[0]); }); }); + + describe('onClickHandler', () => { + beforeEach(() => { + component.hideResults = jest.fn(); + }); + + const e: MouseEvent = { ...new MouseEvent('test') }; + + it('should call hideResults if instant search does not contain event target', () => { + component.onClickHandler(e); + + expect(component.hideResults).toHaveBeenCalled(); + }); + + it('should not call hideResults if instant search contains event target', () => { + component.ref.nativeElement.contains = jest.fn().mockReturnValue(true); + + component.onClickHandler(e); + + expect(component.hideResults).not.toHaveBeenCalled(); + }); + }); }); diff --git a/alfa-client/libs/design-system/src/lib/instant-search/instant-search/instant-search.component.ts b/alfa-client/libs/design-system/src/lib/instant-search/instant-search/instant-search.component.ts index a34aef1436..e1e2e7224f 100644 --- a/alfa-client/libs/design-system/src/lib/instant-search/instant-search/instant-search.component.ts +++ b/alfa-client/libs/design-system/src/lib/instant-search/instant-search/instant-search.component.ts @@ -13,7 +13,7 @@ import { ViewChildren, } from '@angular/core'; import { FormControl } from '@angular/forms'; -import { isEqual } from 'lodash-es'; +import { isEqual, isUndefined } from 'lodash-es'; import { Subscription, debounceTime, distinctUntilChanged, filter } from 'rxjs'; import { AriaLiveRegionComponent } from '../../aria-live-region/aria-live-region.component'; import { SearchFieldComponent } from '../search-field/search-field.component'; @@ -52,9 +52,9 @@ import { InstantSearchResult } from './instant-search.model'; <ods-search-result-header [text]="headerText" [count]="results.length" header /> <ods-search-result-item *ngFor="let result of results; let i = index" - [caption]="result.caption" + [title]="result.title" [description]="result.description" - (clickItem)="onClickItem(result, i)" + (itemClicked)="onClickItem(result, i)" #results ></ods-search-result-item> </ods-search-result-layer> @@ -67,9 +67,8 @@ export class InstantSearchComponent implements OnInit, OnDestroy { @Input() control: FormControl<string> = new FormControl(EMPTY_STRING); @Input() set searchResults(searchResults: InstantSearchResult<unknown>[]) { - if (!isEqual(searchResults, this.results)) { - this.results = searchResults; - this.ariaLiveText = this.buildAriaLiveText(searchResults.length); + if (!isEqual(searchResults, this.results) && isNotNil(searchResults)) { + this.setSearchResults(searchResults); } } @@ -77,14 +76,15 @@ export class InstantSearchComponent implements OnInit, OnDestroy { InstantSearchResult<unknown> >(); - readonly PREVIEW_SEARCH_STRING_MIN_LENGTH = 2; + readonly FIRST_ITEM_INDEX: number = 0; + readonly PREVIEW_SEARCH_STRING_MIN_LENGTH: number = 2; results: InstantSearchResult<unknown>[] = []; ariaLiveText: string = ''; isShowResults: boolean = true; private focusedResult: number | undefined = undefined; - private formControlSubscription: Subscription; + formControlSubscription: Subscription; - constructor(private ref: ElementRef) {} + constructor(public ref: ElementRef) {} @ViewChildren('results') resultsRef: QueryList<SearchResultItemComponent>; @@ -96,7 +96,9 @@ export class InstantSearchComponent implements OnInit, OnDestroy { filter((value: string) => value.length >= this.PREVIEW_SEARCH_STRING_MIN_LENGTH), distinctUntilChanged(), ) - .subscribe(() => this.showResults()); + .subscribe(() => { + this.showResults(); + }); } ngOnDestroy(): void { @@ -104,18 +106,18 @@ export class InstantSearchComponent implements OnInit, OnDestroy { } @HostListener('document:keydown', ['$event']) - onKeydownHandler(e: KeyboardEvent) { - if (this.results.length === 0) return; - if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + onKeydownHandler(e: KeyboardEvent): void { + if (this.isSearchResultsEmpty()) return; + if (this.isArrowNavigationKey(e)) { this.handleArrowNavigation(e); } - if (e.key === 'Escape') { + if (this.isEscapeKey(e)) { this.handleEscape(e); } } @HostListener('document:click', ['$event']) - onClickHandler(e: MouseEvent) { + onClickHandler(e: MouseEvent): void { if (!this.ref.nativeElement.contains(e.target)) { this.hideResults(); } @@ -128,29 +130,45 @@ export class InstantSearchComponent implements OnInit, OnDestroy { this.setFocusOnResultItem(newIndex); } - setFocusOnResultItem(index: number) { - this.resultsRef.get(index).setFocus(); - } - handleEscape(e: KeyboardEvent): void { e.preventDefault(); this.hideResults(); } + setFocusOnResultItem(index: number): void { + this.resultsRef.get(index).setFocus(); + } + + setSearchResults(searchResults: InstantSearchResult<unknown>[]): void { + this.results = searchResults; + this.ariaLiveText = this.buildAriaLiveText(searchResults.length); + } + getNextResultIndex(index: number | undefined, resultLength: number): number { - if (index === undefined || index >= resultLength - 1) { - return 0; + if (isUndefined(index)) { + return this.FIRST_ITEM_INDEX; + } + if (this.isLastItemOrOutOfArray(index, resultLength)) { + return this.FIRST_ITEM_INDEX; } return index + 1; } getPreviousResultIndex(index: number | undefined, resultLength: number): number { - if (index === undefined || index <= 0) { - return resultLength - 1; + if (isUndefined(index)) { + return this.getLastItemIndex(resultLength); + } + if (this.isFirstItemOrOutOfArray(index)) { + return this.getLastItemIndex(resultLength); } return index - 1; } + getLastItemIndex(arrayLength: number): number { + if (arrayLength < 1) return this.FIRST_ITEM_INDEX; + return arrayLength - 1; + } + getResultIndexForKey(key: string): number { switch (key) { case 'ArrowDown': @@ -180,6 +198,26 @@ export class InstantSearchComponent implements OnInit, OnDestroy { this.focusedResult = undefined; } + isLastItemOrOutOfArray(index: number, arrayLength: number): boolean { + return index >= arrayLength - 1; + } + + isFirstItemOrOutOfArray(index: number): boolean { + return index <= this.FIRST_ITEM_INDEX; + } + + isSearchResultsEmpty(): boolean { + return this.results.length === 0; + } + + isArrowNavigationKey(e: KeyboardEvent): boolean { + return e.key === 'ArrowDown' || e.key === 'ArrowUp'; + } + + isEscapeKey(e: KeyboardEvent): boolean { + return e.key === 'Escape'; + } + onClickItem(result: InstantSearchResult<unknown>, index: number) { this.searchResultSelected.emit(result); this.focusedResult = index; diff --git a/alfa-client/libs/design-system/src/lib/instant-search/instant-search/instant-search.model.ts b/alfa-client/libs/design-system/src/lib/instant-search/instant-search/instant-search.model.ts index 61f66832f5..8b09565f30 100644 --- a/alfa-client/libs/design-system/src/lib/instant-search/instant-search/instant-search.model.ts +++ b/alfa-client/libs/design-system/src/lib/instant-search/instant-search/instant-search.model.ts @@ -1,5 +1,5 @@ export interface InstantSearchResult<T> { - caption: string; + title: string; description: string; data?: T; } diff --git a/alfa-client/libs/design-system/src/lib/instant-search/search-result-item/search-result-item.component.spec.ts b/alfa-client/libs/design-system/src/lib/instant-search/search-result-item/search-result-item.component.spec.ts index 8cf0cc8f6d..52538ebbe1 100644 --- a/alfa-client/libs/design-system/src/lib/instant-search/search-result-item/search-result-item.component.spec.ts +++ b/alfa-client/libs/design-system/src/lib/instant-search/search-result-item/search-result-item.component.spec.ts @@ -14,7 +14,7 @@ describe('SearchResultItemComponent', () => { fixture = TestBed.createComponent(SearchResultItemComponent); component = fixture.componentInstance; - component.caption = 'Test'; + component.title = 'Test'; fixture.detectChanges(); }); @@ -25,11 +25,11 @@ describe('SearchResultItemComponent', () => { describe('clickItem', () => { it('should emit event', () => { const button = getElementFromFixture(fixture, getDataTestIdOf('item-button')); - const emitSpy = jest.spyOn(component.clickItem, 'emit'); + const emitSpy = jest.spyOn(component.itemClicked, 'emit'); button.click(); - expect(emitSpy).toHaveBeenCalledTimes(1); + expect(emitSpy).toHaveBeenCalled(); }); }); @@ -39,7 +39,7 @@ describe('SearchResultItemComponent', () => { component.setFocus(); - expect(component.buttonRef.nativeElement.focus).toHaveBeenCalledTimes(1); + expect(component.buttonRef.nativeElement.focus).toHaveBeenCalled(); }); }); }); diff --git a/alfa-client/libs/design-system/src/lib/instant-search/search-result-item/search-result-item.component.ts b/alfa-client/libs/design-system/src/lib/instant-search/search-result-item/search-result-item.component.ts index d14c174358..7a19b9d4b9 100644 --- a/alfa-client/libs/design-system/src/lib/instant-search/search-result-item/search-result-item.component.ts +++ b/alfa-client/libs/design-system/src/lib/instant-search/search-result-item/search-result-item.component.ts @@ -6,7 +6,7 @@ import { Component, ElementRef, EventEmitter, Input, Output, ViewChild } from '@ standalone: true, imports: [CommonModule], template: `<button - *ngIf="caption" + *ngIf="title" [ngClass]="[ 'flex w-full justify-between border-2 border-transparent px-6 py-3', 'hover:border-focus focus:border-focus focus:outline-none', @@ -18,17 +18,17 @@ import { Component, ElementRef, EventEmitter, Input, Output, ViewChild } from '@ #button > <div class="flex flex-col items-start justify-between text-text"> - <p class="text-base font-medium">{{ caption }}</p> + <p class="text-base font-medium">{{ title }}</p> <p class="text-sm">{{ description }}</p> </div> <ng-content select="[action-button]" /> </button>`, }) export class SearchResultItemComponent { - @Input({ required: true }) caption!: string; + @Input({ required: true }) title!: string; @Input() description: string = ''; - @Output() public clickItem: EventEmitter<MouseEvent> = new EventEmitter<MouseEvent>(); + @Output() public itemClicked: EventEmitter<MouseEvent> = new EventEmitter<MouseEvent>(); @ViewChild('button') buttonRef: ElementRef; -- GitLab