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
40 changes: 40 additions & 0 deletions e2e/search-dictation-query.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* A filtered /search-dictation URL fills the form and runs the search.
* Changing a filter rewrites the query with replaceUrl.
* Run: PLAYWRIGHT_BASE_URL=http://localhost:4200 npx playwright test e2e/search-dictation-query.spec.ts
*/
import {expect, test} from '@playwright/test';

test.describe('search dictation query params', () => {
test('a filtered URL restores the form and results', async ({ page }) => {
await page.goto('/search-dictation?q=apple&suitable=JuniorPrimary&type=Vocab');

const keyword = page.locator('ion-input[formcontrolname="keyword"] input');
await expect(keyword).toHaveValue('apple');
await expect(page.locator('[data-filter="suitable"]')).toContainText('Junior Primary (6-8)');
await expect(page.locator('[data-filter="type"]')).toContainText('Word');
await expect(page).toHaveURL(/q=apple/);
await expect(page).toHaveURL(/suitable=JuniorPrimary/);
await expect(page).toHaveURL(/type=Vocab/);

const firstResult = page.locator('#dictation-list ion-item').first();
await expect(firstResult).toBeVisible({ timeout: 20_000 });

await page.screenshot({
path: 'test-results/search-query-deep-link.png',
fullPage: true,
});

const historyLength = await page.evaluate(() => history.length);
await page.locator('#type-filter').click();
await page.locator('ion-item', { hasText: 'Sentence' }).click();
await expect(page).toHaveURL(/type=Article/);
await expect(page).toHaveURL(/q=apple/);
expect(await page.evaluate(() => history.length)).toBe(historyLength);

await page.locator('ion-button', { hasText: 'Search' }).click();
await expect(page.locator('#dictation-list ion-item').first()).toBeVisible({ timeout: 20_000 });
await expect(page).toHaveURL(/type=Article/);
expect(await page.evaluate(() => history.length)).toBe(historyLength);
});
});
4 changes: 2 additions & 2 deletions src/app/pages/search-dictation/search-dictation.page.html
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@
<ion-popover trigger="suitable-filter" triggerAction="click" [dismissOnSelect]="true" alignment="start">
<ng-template>
<ion-list>
@for (state of suitableStudentOptions; track state) {
<ion-item button (click)="chooseSuitable(state)"><ion-label class="ion-text-nowrap">{{'SuitableStudent.' + state|translate}}</ion-label></ion-item>
@for (option of suitableStudentOptions; track option) {
<ion-item button (click)="chooseSuitable(option)"><ion-label class="ion-text-nowrap">{{'SuitableStudent.' + option|translate}}</ion-label></ion-item>
}
</ion-list>
</ng-template>
Expand Down
167 changes: 166 additions & 1 deletion src/app/pages/search-dictation/search-dictation.page.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {CUSTOM_ELEMENTS_SCHEMA} from '@angular/core';
import {ComponentFixture, fakeAsync, TestBed, tick, waitForAsync} from '@angular/core/testing';
import {By} from '@angular/platform-browser';
import {ActivatedRoute, convertToParamMap, Params, Router} from '@angular/router';
import {TranslateService} from '@ngx-translate/core';
import {of} from 'rxjs';

Expand All @@ -14,14 +15,24 @@ import {DICTATION_SEARCH_MAX_RESULTS} from '../../services/dictation/dictation.s
import {TestData} from '../../../testing/test-data';
import en from '../../../assets/i18n/en.json';

class SearchQueryRoute {
query: Params = {};
get snapshot() {
return { queryParamMap: convertToParamMap(this.query) };
}
}

describe('SearchDictationPage', () => {
let component: SearchDictationPage;
let fixture: ComponentFixture<SearchDictationPage>;
let storageSpy;
let queryRoute: SearchQueryRoute;
let navigateSpy: jasmine.Spy;

beforeEach(waitForAsync(() => {
storageSpy = StorageSpy();
storageSpy.get.and.returnValue(Promise.resolve(['old search history']));
queryRoute = new SearchQueryRoute();

TestBed.configureTestingModule({
declarations: [ SearchDictationPage ],
Expand All @@ -32,12 +43,14 @@ describe('SearchDictationPage', () => {
providers: [
{ provide: StorageService, useValue: storageSpy },
{ provide: ManageVocabHistoryService, useValue: ManageVocabHistoryServiceSpy},
{ provide: ActivatedRoute, useValue: queryRoute },
]
})
.compileComponents();
}));

beforeEach(() => {
navigateSpy = spyOn(TestBed.inject(Router), 'navigate').and.returnValue(Promise.resolve(true));
fixture = TestBed.createComponent(SearchDictationPage);
component = fixture.componentInstance;
fixture.detectChanges();
Expand Down Expand Up @@ -176,9 +189,14 @@ describe('SearchDictationPage', () => {

component.chooseDate(component.dateOptions[2]);
fixture.detectChanges();
expect(component.minDate.value).toBe(component.dateOptions[2]);
expect(component.date.value).toBe(component.dateOptions[2]);
expect(chipText('date')).toBe('Date: Within 3 Month');
expect(search).not.toHaveBeenCalled();
expect(navigateSpy).toHaveBeenCalledTimes(2);
const lastParams = navigateSpy.calls.mostRecent().args[1].queryParams;
expect(lastParams).toEqual({ suitable: 'JuniorPrimary', type: 'Vocab' });
expect(navigateSpy.calls.mostRecent().args[1].replaceUrl).toBeTrue();
expect(navigateSpy.calls.mostRecent().args[1].queryParamsHandling).toBe('merge');
});

function chipText(group: string): string {
Expand Down Expand Up @@ -298,6 +316,148 @@ describe('SearchDictationPage', () => {
expect(fixture.nativeElement.querySelector('#dictation-list ion-item')).toBeTruthy();
}));

it('loads q, suitable, and type from the URL and searches', fakeAsync(() => {
const search = spyOn(component.dictationService, 'search').and.returnValue(of([TestData.fillInDictation()]));
component.creator.setValue('ann');
component.chooseDate(component.dateOptions[2]);
queryRoute.query = { q: ' apple ', suitable: 'JuniorPrimary', type: 'Vocab', sort: 'new' };
openFromUrl();

expect(component.keyword.value).toBe('apple');
expect(component.suitableStudent.value).toBe('JuniorPrimary');
expect(component.type.value).toBe('Vocab');
expect(component.creator.value).toBe('');
expect(component.date.value).toBe(component.dateOptions[0]);
expect(search).toHaveBeenCalledTimes(1);
expect(search.calls.mostRecent().args[0]).toEqual(jasmine.objectContaining({
keyword: 'apple',
suitableStudent: 'JuniorPrimary',
type: 'Vocab',
creator: '',
}));
expect(search.calls.mostRecent().args[0].minDate).toBeUndefined();
expect(component.history[0]).toBe('apple');
expect(component.history[1]).toBe('old search history');
expect(navigateSpy).toHaveBeenCalledWith([], jasmine.objectContaining({
replaceUrl: true,
queryParamsHandling: 'merge',
queryParams: { suitable: 'JuniorPrimary', type: 'Vocab', q: 'apple' },
}));
fixture.detectChanges();
expect(chipText('suitable')).toBe('Suitable (Age): SuitableStudent.JuniorPrimary');
expect(chipText('type')).toBe('Type: Word');

const results = component.results;
openFromUrl();
expect(search).toHaveBeenCalledTimes(1);
expect(component.results).toBe(results);
}));

it('drops unknown suitable and type and skips a blank search', fakeAsync(() => {
const search = spyOn(component.dictationService, 'search').and.returnValue(of([]));
queryRoute.query = { q: ' ', suitable: 'nope', type: 'Sentence' };
openFromUrl();

expect(component.keyword.value).toBe('');
expect(component.suitableStudent.value).toBe('Any');
expect(component.type.value).toBe('Any');
expect(search).not.toHaveBeenCalled();
expect(navigateSpy).toHaveBeenCalledWith([], jasmine.objectContaining({
replaceUrl: true,
queryParamsHandling: 'merge',
queryParams: { q: null, suitable: null, type: null },
}));
}));

it('shows the keyword error and does not search a too-short q', fakeAsync(() => {
useNoResultsCopy();
const search = spyOn(component.dictationService, 'search').and.returnValue(of([]));
queryRoute.query = { q: 'ab', suitable: 'nope' };
openFromUrl();
fixture.detectChanges();

expect(search).not.toHaveBeenCalled();
expect(component.keyword.errors.pattern).toBeDefined();
expect(component.suitableStudent.value).toBe('Any');
expect(fixture.nativeElement.querySelector('.error-text').textContent)
.toContain('cannot less than 3 characters');
expect(navigateSpy).toHaveBeenCalledWith([], jasmine.objectContaining({
replaceUrl: true,
queryParams: { q: 'ab', suitable: null, type: null },
}));
}));

it('writes q, suitable, and type when search runs', fakeAsync(() => {
spyOn(component.dictationService, 'search').and.returnValue(of([]));
component.keyword.setValue(' apple ');
component.chooseSuitable('JuniorPrimary');
component.chooseType('Article');
navigateSpy.calls.reset();
runSearch();

expect(component.keyword.value).toBe('apple');
expect(navigateSpy).toHaveBeenCalledWith([], jasmine.objectContaining({
replaceUrl: true,
queryParamsHandling: 'merge',
queryParams: { q: 'apple', suitable: 'JuniorPrimary', type: 'Article' },
}));
}));

it('clears q, suitable, and type from the URL', () => {
component.keyword.setValue('apple');
component.chooseType('Vocab');
queryRoute.query = { q: 'apple', type: 'Vocab' };
navigateSpy.calls.reset();

component.clearSearch();

expect(navigateSpy).toHaveBeenCalledWith([], jasmine.objectContaining({
replaceUrl: true,
queryParamsHandling: 'merge',
queryParams: { q: null, suitable: null, type: null },
}));
});

it('does not store a blank keyword for a filter-only link', fakeAsync(() => {
const search = spyOn(component.dictationService, 'search').and.returnValue(of([]));
queryRoute.query = { type: 'Vocab' };
openFromUrl();

expect(search).toHaveBeenCalledTimes(1);
expect(search.calls.mostRecent().args[0]).toEqual(jasmine.objectContaining({
keyword: '',
type: 'Vocab',
}));
expect(component.history).toEqual(['old search history']);
}));

it('searches the updated URL when a chip changed before leaving', fakeAsync(() => {
const search = spyOn(component.dictationService, 'search').and.returnValue(of([TestData.fillInDictation()]));
queryRoute.query = { q: 'apple' };
openFromUrl();

component.chooseType('Article');
queryRoute.query = { q: 'apple', type: 'Article' };
openFromUrl();

expect(search).toHaveBeenCalledTimes(2);
expect(search.calls.mostRecent().args[0].type).toBe('Article');
expect(component.type.value).toBe('Article');
}));

it('a later visit with no params clears a previous filtered search', fakeAsync(() => {
const search = spyOn(component.dictationService, 'search').and.returnValue(of([TestData.fillInDictation()]));
queryRoute.query = { q: 'apple' };
openFromUrl();
expect(search).toHaveBeenCalledTimes(1);

queryRoute.query = {};
openFromUrl();
expect(component.keyword.value).toBe('');
expect(component.results).toBeNull();
expect(search).toHaveBeenCalledTimes(1);
}));

it('do not store duplicate search history', fakeAsync(() => {
component.keyword.setValue(`new search`);
component.search();
Expand All @@ -321,6 +481,11 @@ describe('SearchDictationPage', () => {
fixture.detectChanges();
}

function openFromUrl() {
component.ionViewWillEnter();
tick();
}

function noResultsMessage(): string {
return fixture.nativeElement.querySelector('.no-results-message')?.textContent.replace(/\s+/g, ' ').trim() ?? '';
}
Expand Down
Loading
Loading