diff --git a/apps/code-examples/src/app/code-examples/lookup/autocomplete/basic/demo.component.spec.ts b/apps/code-examples/src/app/code-examples/lookup/autocomplete/basic/demo.component.spec.ts index 949552b368..4fd057f17e 100644 --- a/apps/code-examples/src/app/code-examples/lookup/autocomplete/basic/demo.component.spec.ts +++ b/apps/code-examples/src/app/code-examples/lookup/autocomplete/basic/demo.component.spec.ts @@ -4,7 +4,7 @@ import { SkyAutocompleteHarness } from '@skyux/lookup/testing'; import { DemoComponent } from './demo.component'; -describe('Basic colorpicker demo', () => { +describe('Basic autocomplete demo', () => { async function setupTest(options: { dataSkyId: string }): Promise<{ harness: SkyAutocompleteHarness; fixture: ComponentFixture; diff --git a/apps/code-examples/src/app/code-examples/lookup/country-field/basic/demo.component.html b/apps/code-examples/src/app/code-examples/lookup/country-field/basic/demo.component.html index 8d6d91b7de..1f26b461a3 100644 --- a/apps/code-examples/src/app/code-examples/lookup/country-field/basic/demo.component.html +++ b/apps/code-examples/src/app/code-examples/lookup/country-field/basic/demo.component.html @@ -1,5 +1,6 @@
{ + async function setupTest(options: { dataSkyId: string }): Promise<{ + harness: SkyCountryFieldHarness; + fixture: ComponentFixture; + }> { + const fixture = TestBed.createComponent(DemoComponent); + const loader = TestbedHarnessEnvironment.loader(fixture); + + const harness = await ( + await loader.getHarness( + SkyInputBoxHarness.with({ dataSkyId: options.dataSkyId }), + ) + ).queryHarness(SkyCountryFieldHarness); + + fixture.detectChanges(); + await fixture.whenStable(); + + return { harness, fixture }; + } + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [DemoComponent], + }); + }); + + it('should set up country field input', async () => { + const { harness, fixture } = await setupTest({ + dataSkyId: 'country-field', + }); + + await harness.focus(); + await harness.enterText('ger'); + + const searchResultsText = await harness.getSearchResultsText(); + + expect(searchResultsText.length).toBe(4); + + await harness.clear(); + await harness.enterText('can'); + + const searchResults = await harness.getSearchResults(); + await expectAsync(searchResults[1].getText()).toBeResolvedTo('Canada'); + + await searchResults[1].select(); + const value = fixture.componentInstance.countryForm.get('country')?.value; + expect(value?.name).toBe('Canada'); + }); +}); diff --git a/apps/code-examples/src/app/code-examples/lookup/country-field/basic/demo.component.ts b/apps/code-examples/src/app/code-examples/lookup/country-field/basic/demo.component.ts index 2b30cf6d65..40e76bf030 100644 --- a/apps/code-examples/src/app/code-examples/lookup/country-field/basic/demo.component.ts +++ b/apps/code-examples/src/app/code-examples/lookup/country-field/basic/demo.component.ts @@ -1,6 +1,7 @@ -import { Component } from '@angular/core'; +import { Component, inject } from '@angular/core'; import { AbstractControl, + FormBuilder, FormControl, FormGroup, FormsModule, @@ -34,11 +35,13 @@ function validateCountry( }) export class DemoComponent { protected countryControl: FormControl; - protected countryForm: FormGroup; + public countryForm: FormGroup; protected helpPopoverContent = 'We use the country to validate your passport within 10 business days. You can update it at any time.'; + #formBuilder = inject(FormBuilder); + constructor() { this.countryControl = new FormControl( { @@ -51,7 +54,7 @@ export class DemoComponent { }, ); - this.countryForm = new FormGroup({ + this.countryForm = this.#formBuilder.group({ country: this.countryControl, }); } diff --git a/libs/components/lookup/testing/src/country-field/country-field-harness-filters.ts b/libs/components/lookup/testing/src/country-field/country-field-harness-filters.ts new file mode 100644 index 0000000000..840b455a5f --- /dev/null +++ b/libs/components/lookup/testing/src/country-field/country-field-harness-filters.ts @@ -0,0 +1,7 @@ +import { SkyHarnessFilters } from '@skyux/core/testing'; + +/** + * A set of criteria that can be used to filter a list of `SkyCountryFieldHarness` instances. + */ +// eslint-disable-next-line @typescript-eslint/no-empty-interface, @typescript-eslint/no-empty-object-type +export interface SkyCountryFieldHarnessFilters extends SkyHarnessFilters {} diff --git a/libs/components/lookup/testing/src/country-field/country-field-harness.spec.ts b/libs/components/lookup/testing/src/country-field/country-field-harness.spec.ts new file mode 100644 index 0000000000..6ef852d9d8 --- /dev/null +++ b/libs/components/lookup/testing/src/country-field/country-field-harness.spec.ts @@ -0,0 +1,183 @@ +import { HarnessLoader } from '@angular/cdk/testing'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SkyCountryFieldHarness } from './country-field-harness'; +import { CountryFieldHarnessTestComponent } from './fixtures/country-field-harness-test.component'; + +describe('Country field harness', () => { + async function setupTest(options: { dataSkyId?: string } = {}): Promise<{ + countryFieldHarness: SkyCountryFieldHarness; + fixture: ComponentFixture; + loader: HarnessLoader; + }> { + await TestBed.configureTestingModule({ + imports: [CountryFieldHarnessTestComponent], + }).compileComponents(); + + const fixture = TestBed.createComponent(CountryFieldHarnessTestComponent); + const loader = TestbedHarnessEnvironment.loader(fixture); + + let countryFieldHarness: SkyCountryFieldHarness | undefined; + if (options.dataSkyId) { + countryFieldHarness = await loader.getHarness( + SkyCountryFieldHarness.with({ dataSkyId: options.dataSkyId }), + ); + } + + return { countryFieldHarness, fixture, loader }; + } + + it('should focus and blur input', async () => { + const { countryFieldHarness } = await setupTest({ + dataSkyId: 'country-field', + }); + + await expectAsync(countryFieldHarness?.isFocused()).toBeResolvedTo(false); + + await countryFieldHarness?.focus(); + await expectAsync(countryFieldHarness?.isFocused()).toBeResolvedTo(true); + + await countryFieldHarness?.blur(); + await expectAsync(countryFieldHarness?.isFocused()).toBeResolvedTo(false); + }); + + it('should check if country field is disabled', async () => { + const { fixture, countryFieldHarness } = await setupTest({ + dataSkyId: 'country-field', + }); + + await expectAsync(countryFieldHarness?.isDisabled()).toBeResolvedTo(false); + + fixture.componentInstance.disableForm(); + + await expectAsync(countryFieldHarness?.isDisabled()).toBeResolvedTo(true); + }); + + it('should check if country field is open', async () => { + const { countryFieldHarness } = await setupTest({ + dataSkyId: 'country-field', + }); + + await countryFieldHarness?.enterText('gr'); + + await expectAsync(countryFieldHarness?.isOpen()).toBeResolvedTo(true); + }); + + it('should return search result harnesses', async () => { + const { countryFieldHarness } = await setupTest({ + dataSkyId: 'country-field', + }); + + await countryFieldHarness?.enterText('gr'); + + const results = (await countryFieldHarness?.getSearchResults()) ?? []; + + await expectAsync(results[0].getDescriptorValue()).toBeResolvedTo('Greece'); + await expectAsync(results[0].getText()).toBeResolvedTo('Greece'); + }); + + it('should return search results text content', async () => { + const { countryFieldHarness } = await setupTest({ + dataSkyId: 'country-field', + }); + + await countryFieldHarness?.enterText('gr'); + + await expectAsync( + countryFieldHarness?.getSearchResultsText(), + ).toBeResolvedTo([ + 'Greece', + 'Greenland', + 'Grenada', + 'Montenegro', + 'St. Vincent & Grenadines', + ]); + }); + + it('should select a search result', async () => { + const { countryFieldHarness } = await setupTest({ + dataSkyId: 'country-field', + }); + + await countryFieldHarness?.enterText('gr'); + const result = ((await countryFieldHarness?.getSearchResults()) ?? [])[0]; + await result.select(); + + await expectAsync(countryFieldHarness?.getValue()).toBeResolvedTo('Greece'); + }); + + it('should select a search result using filters', async () => { + const { countryFieldHarness } = await setupTest({ + dataSkyId: 'country-field', + }); + + await countryFieldHarness?.enterText('gr'); + await countryFieldHarness?.selectSearchResult({ + text: 'Grenada', + }); + + await expectAsync(countryFieldHarness?.getValue()).toBeResolvedTo( + 'Grenada', + ); + }); + + it('should clear the input value', async () => { + const { countryFieldHarness } = await setupTest({ + dataSkyId: 'country-field', + }); + + // First, set a value on the countryField. + await countryFieldHarness?.enterText('gr'); + await countryFieldHarness?.selectSearchResult({ + text: 'Greenland', + }); + await expectAsync(countryFieldHarness?.getValue()).toBeResolvedTo( + 'Greenland', + ); + + // Now, clear the value. + await countryFieldHarness?.clear(); + await expectAsync(countryFieldHarness?.getValue()).toBeResolvedTo(''); + }); + + it('should throw error if getting search results when country field not open', async () => { + const { countryFieldHarness } = await setupTest({ + dataSkyId: 'country-field', + }); + + await expectAsync( + countryFieldHarness?.getSearchResults(), + ).toBeRejectedWithError( + 'Unable to retrieve search results. The country field is closed.', + ); + }); + + it('should throw error if filtered search results are empty', async () => { + const { countryFieldHarness } = await setupTest({ + dataSkyId: 'country-field', + }); + + await countryFieldHarness?.enterText('gr'); + + await expectAsync( + countryFieldHarness?.getSearchResults({ + text: /invalidSearchText/, + }), + ).toBeRejectedWithError( + 'Could not find search results matching filter(s): {"text":"/invalidSearchText/"}', + ); + }); + + it('should return an empty array if search results are not filtered', async () => { + const { countryFieldHarness } = await setupTest({ + dataSkyId: 'country-field', + }); + + await countryFieldHarness?.enterText('invalidSearchText'); + + await expectAsync(countryFieldHarness?.getSearchResults()).toBeResolvedTo( + [], + ); + }); +}); diff --git a/libs/components/lookup/testing/src/country-field/country-field-harness.ts b/libs/components/lookup/testing/src/country-field/country-field-harness.ts new file mode 100644 index 0000000000..362ac74731 --- /dev/null +++ b/libs/components/lookup/testing/src/country-field/country-field-harness.ts @@ -0,0 +1,188 @@ +import { HarnessPredicate } from '@angular/cdk/testing'; +import { SkyOverlayHarness } from '@skyux/core/testing'; + +import { SkyAutocompleteHarness } from '../autocomplete/autocomplete-harness'; +import { SkyAutocompleteInputHarness } from '../autocomplete/autocomplete-input-harness'; + +import { SkyCountryFieldHarnessFilters } from './country-field-harness-filters'; +import { SkyCountryFieldSearchResultHarness } from './country-field-search-result-harness'; +import { SkyCountryFieldSearchResultHarnessFilters } from './country-field-search-result-harness-filters'; + +/** + * Harness for interacting with a country field component in tests. + */ +export class SkyCountryFieldHarness extends SkyAutocompleteHarness { + /** + * Finds a standard country field component, or a country field component that is wrapped by an input box component. + * For input box implementations, we need to use the `.sky-input-box` selector since the `sky-country-field` + * element is removed from the DOM. + * @internal + */ + public static override hostSelector = + 'sky-country-field,.sky-country-field-container'; + + #documentRootLocator = this.documentRootLocatorFactory(); + + #getInput = this.locatorFor(SkyAutocompleteInputHarness); + + /** + * Gets a `HarnessPredicate` that can be used to search for a + * `SkyCountryFieldHarness` that meets certain criteria. + * These filters only work for standalone country fields. + * For country fields wrapped inside `sky-input-box`, place filters + * on the input box instead, and query the country field using a `SkyInputBoxHarness`. + * For the input box implementation, see the code example. + */ + public static override with( + filters: SkyCountryFieldHarnessFilters, + ): HarnessPredicate { + return SkyCountryFieldHarness.getDataSkyIdPredicate(filters); + } + + /** + * Blurs the country field input. + */ + public override async blur(): Promise { + return await super.blur(); + } + + /** + * Clears the country field input value. + */ + public override async clear(): Promise { + return await super.clear(); + } + + /** + * Enters text into the country field input. + */ + public override async enterText(value: string): Promise { + return await super.enterText(value); + } + + /** + * Focuses the country field input. + */ + public override async focus(): Promise { + return await super.focus(); + } + + /** + * Gets the country field `aria-labelledby` value. + * This is not needed for country field because the id is generated internally, + * and the method is marked internal to prevent it from being documented publicly. + * @internal + */ + /* istanbul ignore next */ + public override async getAriaLabelledby(): Promise { + return await super.getAriaLabelledby(); + } + + /** + * Returns country field search result harnesses. + */ + public override async getSearchResults( + filters?: SkyCountryFieldSearchResultHarnessFilters, + ): Promise { + const overlay = await this.#getOverlay(); + + if (!overlay) { + throw new Error( + 'Unable to retrieve search results. The country field is closed.', + ); + } + + const harnesses = await overlay.queryHarnesses( + SkyCountryFieldSearchResultHarness.with(filters || {}), + ); + + if (filters && harnesses.length === 0) { + // Stringify the regular expression so that it's readable in the console log. + if (filters.text instanceof RegExp) { + filters.text = filters.text.toString(); + } + + throw new Error( + `Could not find search results matching filter(s): ${JSON.stringify( + filters, + )}`, + ); + } + + return harnesses; + } + + /** + * Returns the text content for each country field search result. + */ + public override async getSearchResultsText( + filters?: SkyCountryFieldSearchResultHarnessFilters, + ): Promise { + const harnesses = await this.getSearchResults(filters); + + const text: string[] = []; + for (const harness of harnesses) { + text.push(await harness.getText()); + } + + return text; + } + + /** + * Gets the value of the country field input. + */ + public override async getValue(): Promise { + return await super.getValue(); + } + + /** + * Gets the text displayed when no search results are found. + * For a country field, this is always the default text and the method + * is marked internal to prevent it from being documented publicly. + * @internal + */ + /* istanbul ignore next */ + public override async getNoResultsFoundText(): Promise { + return await super.getNoResultsFoundText(); + } + + /** + * Whether the country field input is disabled. + */ + public override async isDisabled(): Promise { + return await super.isDisabled(); + } + + /** + * Whether the country field input is focused. + */ + public override async isFocused(): Promise { + return await super.isFocused(); + } + + /** + * Whether the country field is open. + */ + public override async isOpen(): Promise { + return await super.isOpen(); + } + + /** + * Selects a search result. + */ + public override async selectSearchResult( + filters: SkyCountryFieldSearchResultHarnessFilters, + ): Promise { + return await super.selectSearchResult(filters); + } + + async #getOverlay(): Promise { + const overlayId = await (await this.#getInput()).getAriaControls(); + + return overlayId + ? this.#documentRootLocator.locatorForOptional( + SkyOverlayHarness.with({ selector: `#${overlayId}` }), + )() + : null; + } +} diff --git a/libs/components/lookup/testing/src/country-field/country-field-search-result-harness-filters.ts b/libs/components/lookup/testing/src/country-field/country-field-search-result-harness-filters.ts new file mode 100644 index 0000000000..890f480045 --- /dev/null +++ b/libs/components/lookup/testing/src/country-field/country-field-search-result-harness-filters.ts @@ -0,0 +1,9 @@ +import { SkyAutocompleteSearchResultHarnessFilters } from '../public-api'; + +/** + * A set of criteria that can be used to filter a list of `SkyCountryFieldSearchResultHarness` instances. + * @internal + */ +// eslint-disable-next-line @typescript-eslint/no-empty-interface, @typescript-eslint/no-empty-object-type +export interface SkyCountryFieldSearchResultHarnessFilters + extends SkyAutocompleteSearchResultHarnessFilters {} diff --git a/libs/components/lookup/testing/src/country-field/country-field-search-result-harness.ts b/libs/components/lookup/testing/src/country-field/country-field-search-result-harness.ts new file mode 100644 index 0000000000..1d17b00000 --- /dev/null +++ b/libs/components/lookup/testing/src/country-field/country-field-search-result-harness.ts @@ -0,0 +1,35 @@ +import { HarnessPredicate } from '@angular/cdk/testing'; + +import { SkyAutocompleteSearchResultHarness } from '../autocomplete/autocomplete-search-result-harness'; + +import { SkyCountryFieldSearchResultHarnessFilters } from './country-field-search-result-harness-filters'; + +/** + * Harness for interacting with an autocomplete search result in tests. + */ +export class SkyCountryFieldSearchResultHarness extends SkyAutocompleteSearchResultHarness { + /** + * Gets a `HarnessPredicate` that can be used to search for a + * `SkyCountryFieldSearchResultHarness` that meets certain criteria. + */ + public static override with( + filters: SkyCountryFieldSearchResultHarnessFilters, + ): HarnessPredicate { + return new HarnessPredicate( + SkyCountryFieldSearchResultHarness, + filters, + ).addOption('textContent', filters.text, async (harness, text) => + HarnessPredicate.stringMatches(await harness.getText(), text), + ); + } + + /** + * Returns the value of the search result's descriptor property. + * This is not needed by country field because it is always set to the country name, + * and the method is marked internal to prevent it from being documented publicly. + * @internal + */ + public override async getDescriptorValue(): Promise { + return await super.getDescriptorValue(); + } +} diff --git a/libs/components/lookup/testing/src/country-field/fixtures/country-field-harness-test.component.html b/libs/components/lookup/testing/src/country-field/fixtures/country-field-harness-test.component.html new file mode 100644 index 0000000000..368378b148 --- /dev/null +++ b/libs/components/lookup/testing/src/country-field/fixtures/country-field-harness-test.component.html @@ -0,0 +1,7 @@ + + + diff --git a/libs/components/lookup/testing/src/country-field/fixtures/country-field-harness-test.component.ts b/libs/components/lookup/testing/src/country-field/fixtures/country-field-harness-test.component.ts new file mode 100644 index 0000000000..bf9efa26b6 --- /dev/null +++ b/libs/components/lookup/testing/src/country-field/fixtures/country-field-harness-test.component.ts @@ -0,0 +1,39 @@ +import { Component, inject } from '@angular/core'; +import { + FormsModule, + ReactiveFormsModule, + UntypedFormBuilder, + UntypedFormControl, + UntypedFormGroup, +} from '@angular/forms'; +import { SkyInputBoxModule } from '@skyux/forms'; +import { SkyCountryFieldModule } from '@skyux/lookup'; + +@Component({ + standalone: true, + selector: 'sky-country-field-fixture', + templateUrl: './country-field-harness-test.component.html', + imports: [ + FormsModule, + ReactiveFormsModule, + SkyCountryFieldModule, + SkyInputBoxModule, + ], +}) +export class CountryFieldHarnessTestComponent { + public myForm: UntypedFormGroup; + + #formBuilder = inject(UntypedFormBuilder); + + constructor() { + this.myForm = this.#formBuilder.group({ + countryControl: new UntypedFormControl(), + }); + } + + public disableForm(): void { + this.myForm.disable(); + } + + public selectedCountryChange(event: any): void {} +} diff --git a/libs/components/lookup/testing/src/public-api.ts b/libs/components/lookup/testing/src/public-api.ts index 1994d39cba..71172e64a6 100644 --- a/libs/components/lookup/testing/src/public-api.ts +++ b/libs/components/lookup/testing/src/public-api.ts @@ -4,6 +4,10 @@ export { SkyAutocompleteSearchResultHarness } from './autocomplete/autocomplete- export { SkyAutocompleteSearchResultHarnessFilters } from './autocomplete/autocomplete-search-result-harness-filters'; export { SkyCountryFieldFixture } from './country-field/country-field-fixture'; +export { SkyCountryFieldHarness } from './country-field/country-field-harness'; +export { SkyCountryFieldHarnessFilters } from './country-field/country-field-harness-filters'; +export { SkyCountryFieldSearchResultHarness } from './country-field/country-field-search-result-harness'; +export { SkyCountryFieldSearchResultHarnessFilters } from './country-field/country-field-search-result-harness-filters'; export { SkyCountryFieldTestingModule } from './country-field/country-field-testing.module'; export { SkyLookupHarness } from './lookup/lookup-harness';