diff --git a/apps/code-examples/src/app/code-examples/forms/radio/standard/demo.component.html b/apps/code-examples/src/app/code-examples/forms/radio/standard/demo.component.html index bfafc01c76..8e78bd76eb 100644 --- a/apps/code-examples/src/app/code-examples/forms/radio/standard/demo.component.html +++ b/apps/code-examples/src/app/code-examples/forms/radio/standard/demo.component.html @@ -1,6 +1,7 @@
{ + async function setupTest(options: { + dataSkyId: string; + }): Promise { + const fixture = TestBed.createComponent(DemoComponent); + + const loader = TestbedHarnessEnvironment.loader(fixture); + + const harness = await loader.getHarness( + SkyRadioGroupHarness.with({ dataSkyId: options.dataSkyId }), + ); + + fixture.detectChanges(); + await fixture.whenStable(); + + return harness; + } + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, DemoComponent], + }); + }); + + it('should have the appropriate heading text/level/style, label text, and hint text', async () => { + const harness = await setupTest({ dataSkyId: 'radio-group' }); + + const radioButtons = await harness.getRadioButtons(); + + await expectAsync(harness.getHeadingText()).toBeResolvedTo( + 'Payment method', + ); + await expectAsync(harness.getHeadingLevel()).toBeResolvedTo(4); + await expectAsync(harness.getHeadingStyle()).toBeResolvedTo(4); + await expectAsync(harness.getHintText()).toBeResolvedTo( + 'Card methods require proof of identification.', + ); + + await expectAsync(radioButtons[0].getLabelText()).toBeResolvedTo('Cash'); + await expectAsync(radioButtons[0].getHintText()).toBeResolvedTo(''); + + await expectAsync(radioButtons[1].getLabelText()).toBeResolvedTo('Check'); + await expectAsync(radioButtons[1].getHintText()).toBeResolvedTo(''); + + await expectAsync(radioButtons[2].getLabelText()).toBeResolvedTo( + 'Apple pay', + ); + await expectAsync(radioButtons[2].getHintText()).toBeResolvedTo(''); + + await expectAsync(radioButtons[3].getLabelText()).toBeResolvedTo('Credit'); + await expectAsync(radioButtons[3].getHintText()).toBeResolvedTo( + 'A 2% late fee is applied to payments made after the due date.', + ); + + await expectAsync(radioButtons[4].getLabelText()).toBeResolvedTo('Debit'); + await expectAsync(radioButtons[4].getHintText()).toBeResolvedTo(''); + }); + + it('should display an error message when there is a custom validation error', async () => { + const harness = await setupTest({ dataSkyId: 'radio-group' }); + + const radioHarness = (await harness.getRadioButtons())[1]; + + await radioHarness.check(); + + await expectAsync(harness.hasError('processingIssue')).toBeResolvedTo(true); + }); + + it('should show a help popover with the expected text', async () => { + const harness = await setupTest({ + dataSkyId: 'radio-group', + }); + + await harness.clickHelpInline(); + + const helpPopoverTitle = await harness.getHelpPopoverTitle(); + expect(helpPopoverTitle).toBe('Are there fees?'); + + const helpPopoverContent = await harness.getHelpPopoverContent(); + expect(helpPopoverContent).toBe( + `We don't charge fees for any payment method. The only exception is when credit card payments are late, which incurs a 2% fee.`, + ); + }); +}); diff --git a/libs/components/forms/src/lib/modules/radio/radio-group.component.html b/libs/components/forms/src/lib/modules/radio/radio-group.component.html index 246e7eec62..8f6811b82c 100644 --- a/libs/components/forms/src/lib/modules/radio/radio-group.component.html +++ b/libs/components/forms/src/lib/modules/radio/radio-group.component.html @@ -36,7 +36,7 @@
{{ headingText }}
} @else { - + {{ headingText }} } diff --git a/libs/components/forms/src/lib/modules/radio/radio-group.component.scss b/libs/components/forms/src/lib/modules/radio/radio-group.component.scss index 66100a5b6e..132be2b485 100644 --- a/libs/components/forms/src/lib/modules/radio/radio-group.component.scss +++ b/libs/components/forms/src/lib/modules/radio/radio-group.component.scss @@ -13,9 +13,15 @@ display: flex; } -h3, -h4, -h5 { - margin: 0; - display: inline-block; +legend { + h3, + h4, + h5 { + margin: 0; + display: inline-block; + } + + span { + line-height: 1.1; + } } diff --git a/libs/components/forms/src/lib/modules/radio/radio.component.html b/libs/components/forms/src/lib/modules/radio/radio.component.html index 0a7024dd80..45b0f43c21 100644 --- a/libs/components/forms/src/lib/modules/radio/radio.component.html +++ b/libs/components/forms/src/lib/modules/radio/radio.component.html @@ -35,9 +35,11 @@ } @if (labelText) { - @if (!labelHidden) { - {{ labelText }} - } + {{ labelText }} } @else { } diff --git a/libs/components/forms/testing/src/public-api.ts b/libs/components/forms/testing/src/public-api.ts index 41e7d7224e..d14cbaef51 100644 --- a/libs/components/forms/testing/src/public-api.ts +++ b/libs/components/forms/testing/src/public-api.ts @@ -16,3 +16,8 @@ export { SkyFormErrorHarness } from './form-error/form-error-harness'; export { SkyFormErrorHarnessFilters } from './form-error/form-error-harness.filters'; export { SkyRadioFixture } from './radio-fixture'; +export { SkyRadioGroupHarness } from './radio/radio-group-harness'; +export { SkyRadioGroupHarnessFilters } from './radio/radio-group-harness-filters'; +export { SkyRadioHarness } from './radio/radio-harness'; +export { SkyRadioHarnessFilters } from './radio/radio-harness-filters'; +export { SkyRadioLabelHarness } from './radio/radio-label-harness'; diff --git a/libs/components/forms/testing/src/radio/fixtures/radio-harness-test.component.html b/libs/components/forms/testing/src/radio/fixtures/radio-harness-test.component.html new file mode 100644 index 0000000000..08e58858ab --- /dev/null +++ b/libs/components/forms/testing/src/radio/fixtures/radio-harness-test.component.html @@ -0,0 +1,49 @@ + + + + + @if (!hideCheckLabel) { + Check + } + + + @if (paymentMethod.errors?.['processingIssue']) { + + } + + diff --git a/libs/components/forms/testing/src/radio/fixtures/radio-harness-test.component.ts b/libs/components/forms/testing/src/radio/fixtures/radio-harness-test.component.ts new file mode 100644 index 0000000000..7a73464e64 --- /dev/null +++ b/libs/components/forms/testing/src/radio/fixtures/radio-harness-test.component.ts @@ -0,0 +1,62 @@ +import { Component, inject } from '@angular/core'; +import { + AbstractControl, + FormsModule, + ReactiveFormsModule, + UntypedFormBuilder, + UntypedFormControl, + UntypedFormGroup, + ValidationErrors, +} from '@angular/forms'; +// eslint-disable-next-line @nx/enforce-module-boundaries +import { + SkyRadioGroupHeadingLevel, + SkyRadioGroupHeadingStyle, + SkyRadioModule, +} from '@skyux/forms'; + +function validatePaymentMethod( + control: AbstractControl, +): ValidationErrors | null { + return control.value === 'check' ? { processingIssue: true } : null; +} + +@Component({ + standalone: true, + selector: 'test-radio-harness', + templateUrl: './radio-harness-test.component.html', + imports: [FormsModule, ReactiveFormsModule, SkyRadioModule], +}) +export class RadioHarnessTestComponent { + public class = ''; + public cashHintText: string | undefined; + public headingLevel: SkyRadioGroupHeadingLevel | undefined = 3; + public headingStyle: SkyRadioGroupHeadingStyle = 3; + public helpKey: string | undefined; + public helpPopoverContent: string | undefined; + public helpPopoverTitle: string | undefined; + public hideCashLabel = false; + public hideCheckLabel = false; + public hideGroupHeading = false; + public hintText: string | undefined; + public myForm: UntypedFormGroup; + public paymentMethod: UntypedFormControl; + public required = false; + public stacked = false; + + #formBuilder = inject(UntypedFormBuilder); + + constructor() { + this.paymentMethod = this.#formBuilder.control('cash', { + validators: [validatePaymentMethod], + }); + + this.myForm = this.#formBuilder.group({ + paymentMethod: this.paymentMethod, + }); + } + + public disableForm(): void { + this.myForm.disable(); + } +} diff --git a/libs/components/forms/testing/src/radio/radio-group-harness-filters.ts b/libs/components/forms/testing/src/radio/radio-group-harness-filters.ts new file mode 100644 index 0000000000..335076940f --- /dev/null +++ b/libs/components/forms/testing/src/radio/radio-group-harness-filters.ts @@ -0,0 +1,8 @@ +import { SkyHarnessFilters } from '@skyux/core/testing'; + +/** + * A set of criteria that can be used to filter a list of `SkyRadioGroupHarness` instances. + * @internal + */ +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface SkyRadioGroupHarnessFilters extends SkyHarnessFilters {} diff --git a/libs/components/forms/testing/src/radio/radio-group-harness.spec.ts b/libs/components/forms/testing/src/radio/radio-group-harness.spec.ts new file mode 100644 index 0000000000..77d1de9898 --- /dev/null +++ b/libs/components/forms/testing/src/radio/radio-group-harness.spec.ts @@ -0,0 +1,297 @@ +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { SkyHelpService } from '@skyux/core'; +import { SkyHelpTestingModule } from '@skyux/core/testing'; + +import { RadioHarnessTestComponent } from './fixtures/radio-harness-test.component'; +import { SkyRadioGroupHarness } from './radio-group-harness'; + +async function setupTest(options: { dataSkyId?: string } = {}): Promise<{ + radioGroupHarness: SkyRadioGroupHarness; + fixture: ComponentFixture; +}> { + await TestBed.configureTestingModule({ + imports: [ + RadioHarnessTestComponent, + SkyHelpTestingModule, + NoopAnimationsModule, + ], + }).compileComponents(); + + const fixture = TestBed.createComponent(RadioHarnessTestComponent); + const loader = TestbedHarnessEnvironment.loader(fixture); + + const radioGroupHarness: SkyRadioGroupHarness = options.dataSkyId + ? await loader.getHarness( + SkyRadioGroupHarness.with({ + dataSkyId: options.dataSkyId, + }), + ) + : await loader.getHarness(SkyRadioGroupHarness); + + return { radioGroupHarness, fixture }; +} + +describe('Radio group harness', () => { + it('should get the heading text', async () => { + const { radioGroupHarness } = await setupTest(); + + await expectAsync(radioGroupHarness.getHeadingText()).toBeResolvedTo( + 'Payment method', + ); + }); + + it('should get the heading text when heading text is hidden', async () => { + const { radioGroupHarness, fixture } = await setupTest({ + dataSkyId: 'radio-group', + }); + + fixture.componentInstance.hideGroupHeading = true; + fixture.detectChanges(); + + await expectAsync(radioGroupHarness.getHeadingText()).toBeResolvedTo( + 'Payment method', + ); + }); + + it('should indicate the heading is not hidden', async () => { + const { radioGroupHarness } = await setupTest(); + + await expectAsync(radioGroupHarness.getHeadingHidden()).toBeResolvedTo( + false, + ); + }); + + it('should indicate the heading is hidden', async () => { + const { radioGroupHarness, fixture } = await setupTest(); + + fixture.componentInstance.hideGroupHeading = true; + fixture.detectChanges(); + + await expectAsync(radioGroupHarness.getHeadingHidden()).toBeResolvedTo( + true, + ); + }); + + it('should return the heading level', async () => { + const { radioGroupHarness, fixture } = await setupTest(); + + fixture.componentInstance.headingLevel = undefined; + fixture.detectChanges(); + + await expectAsync(radioGroupHarness.getHeadingLevel()).toBeResolvedTo( + undefined, + ); + + fixture.componentInstance.headingLevel = 3; + fixture.detectChanges(); + + await expectAsync(radioGroupHarness.getHeadingLevel()).toBeResolvedTo(3); + + fixture.componentInstance.headingLevel = 4; + fixture.detectChanges(); + + await expectAsync(radioGroupHarness.getHeadingLevel()).toBeResolvedTo(4); + + fixture.componentInstance.headingLevel = 5; + fixture.detectChanges(); + + await expectAsync(radioGroupHarness.getHeadingLevel()).toBeResolvedTo(5); + }); + + it('should return the heading style', async () => { + const { radioGroupHarness, fixture } = await setupTest(); + + fixture.componentInstance.headingLevel = undefined; + fixture.componentInstance.headingStyle = 3; + fixture.detectChanges(); + + await expectAsync(radioGroupHarness.getHeadingLevel()).toBeResolvedTo( + undefined, + ); + await expectAsync(radioGroupHarness.getHeadingStyle()).toBeResolvedTo(3); + + fixture.componentInstance.headingLevel = 3; + fixture.componentInstance.headingStyle = 4; + fixture.detectChanges(); + + await expectAsync(radioGroupHarness.getHeadingLevel()).toBeResolvedTo(3); + await expectAsync(radioGroupHarness.getHeadingStyle()).toBeResolvedTo(4); + + fixture.componentInstance.headingLevel = 4; + fixture.componentInstance.headingStyle = 5; + fixture.detectChanges(); + + await expectAsync(radioGroupHarness.getHeadingLevel()).toBeResolvedTo(4); + await expectAsync(radioGroupHarness.getHeadingStyle()).toBeResolvedTo(5); + + fixture.componentInstance.headingLevel = 5; + fixture.componentInstance.headingStyle = 3; + fixture.detectChanges(); + + await expectAsync(radioGroupHarness.getHeadingLevel()).toBeResolvedTo(5); + await expectAsync(radioGroupHarness.getHeadingStyle()).toBeResolvedTo(3); + }); + + it('should get the hint text', async () => { + const { radioGroupHarness, fixture } = await setupTest(); + const hintText = 'Hint text for the section.'; + + await expectAsync(radioGroupHarness.getHintText()).toBeResolvedTo(''); + + fixture.componentInstance.hintText = hintText; + fixture.detectChanges(); + + await expectAsync(radioGroupHarness.getHintText()).toBeResolvedTo(hintText); + }); + + it('should indicate the component is stacked when margin is lg and headingLevel is not set', async () => { + const { radioGroupHarness, fixture } = await setupTest(); + + fixture.componentInstance.stacked = true; + fixture.componentInstance.headingLevel = undefined; + fixture.detectChanges(); + + await expectAsync(radioGroupHarness.getStacked()).toBeResolvedTo(true); + }); + + it('should indicate the component is not stacked when margin is lg and headingLevel is set', async () => { + const { radioGroupHarness, fixture } = await setupTest(); + + fixture.componentInstance.class = 'sky-margin-stacked-lg'; + fixture.componentInstance.headingLevel = 4; + fixture.detectChanges(); + + await expectAsync(radioGroupHarness.getStacked()).toBeResolvedTo(false); + }); + + it('should indicate the component is stacked when margin is xl and headingLevel is set', async () => { + const { radioGroupHarness, fixture } = await setupTest(); + + fixture.componentInstance.stacked = true; + fixture.detectChanges(); + + await expectAsync(radioGroupHarness.getStacked()).toBeResolvedTo(true); + }); + + it('should indicate the component is not stacked when margin is xl and headingLevel is not set', async () => { + const { radioGroupHarness, fixture } = await setupTest(); + + fixture.componentInstance.class = 'sky-margin-stacked-xl'; + fixture.componentInstance.headingLevel = undefined; + fixture.detectChanges(); + + await expectAsync(radioGroupHarness.getStacked()).toBeResolvedTo(false); + }); + + it('should indicate the component is not stacked', async () => { + const { radioGroupHarness } = await setupTest(); + + await expectAsync(radioGroupHarness.getStacked()).toBeResolvedTo(false); + }); + + it('should indicate the component is required', async () => { + const { radioGroupHarness, fixture } = await setupTest(); + + fixture.componentInstance.required = true; + fixture.detectChanges(); + + await expectAsync(radioGroupHarness.getRequired()).toBeResolvedTo(true); + }); + + it('should indicate the component is not required', async () => { + const { radioGroupHarness } = await setupTest(); + + await expectAsync(radioGroupHarness.getRequired()).toBeResolvedTo(false); + }); + + it('should display an error message when there is a custom validation error', async () => { + const { radioGroupHarness, fixture } = await setupTest(); + fixture.componentInstance.required = true; + fixture.detectChanges(); + + const radioHarness = (await radioGroupHarness.getRadioButtons())[1]; + + await radioHarness.check(); + + await expectAsync( + radioGroupHarness.hasError('processingIssue'), + ).toBeResolvedTo(true); + }); + + it('should throw an error if no form error is found', async () => { + const { radioGroupHarness } = await setupTest(); + const radioHarness = (await radioGroupHarness.getRadioButtons())[2]; + + await radioHarness.check(); + + await expectAsync(radioGroupHarness.hasError('test')).toBeResolvedTo(false); + }); + + it('should throw an error if no help inline is found', async () => { + const { radioGroupHarness } = await setupTest(); + + await expectAsync( + radioGroupHarness.clickHelpInline(), + ).toBeRejectedWithError('No help inline found.'); + }); + + it('should open help inline popover when clicked', async () => { + const { radioGroupHarness, fixture } = await setupTest(); + + fixture.componentInstance.helpPopoverContent = 'popover content'; + fixture.detectChanges(); + + await radioGroupHarness.clickHelpInline(); + fixture.detectChanges(); + await fixture.whenStable(); + + await expectAsync(radioGroupHarness.getHelpPopoverContent()).toBeResolved(); + }); + + it('should open global help widget when clicked', async () => { + const { radioGroupHarness, fixture } = await setupTest(); + const helpSvc = TestBed.inject(SkyHelpService); + const helpSpy = spyOn(helpSvc, 'openHelp'); + + fixture.componentInstance.helpPopoverContent = undefined; + fixture.componentInstance.helpKey = 'helpKey.html'; + fixture.detectChanges(); + + await radioGroupHarness.clickHelpInline(); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(helpSpy).toHaveBeenCalledWith({ helpKey: 'helpKey.html' }); + }); + + it('should get help popover content', async () => { + const { radioGroupHarness, fixture } = await setupTest(); + fixture.componentInstance.helpPopoverContent = 'popover content'; + fixture.detectChanges(); + + await radioGroupHarness.clickHelpInline(); + fixture.detectChanges(); + await fixture.whenStable(); + + await expectAsync(radioGroupHarness.getHelpPopoverContent()).toBeResolvedTo( + 'popover content', + ); + }); + + it('should get help popover title', async () => { + const { radioGroupHarness, fixture } = await setupTest(); + fixture.componentInstance.helpPopoverContent = 'popover content'; + fixture.componentInstance.helpPopoverTitle = 'popover title'; + fixture.detectChanges(); + + await radioGroupHarness.clickHelpInline(); + fixture.detectChanges(); + await fixture.whenStable(); + + await expectAsync(radioGroupHarness.getHelpPopoverTitle()).toBeResolvedTo( + 'popover title', + ); + }); +}); diff --git a/libs/components/forms/testing/src/radio/radio-group-harness.ts b/libs/components/forms/testing/src/radio/radio-group-harness.ts new file mode 100644 index 0000000000..34bd547fbe --- /dev/null +++ b/libs/components/forms/testing/src/radio/radio-group-harness.ts @@ -0,0 +1,196 @@ +import { HarnessPredicate } from '@angular/cdk/testing'; +import { SkyComponentHarness } from '@skyux/core/testing'; +// eslint-disable-next-line @nx/enforce-module-boundaries +import { + SkyRadioGroupHeadingLevel, + SkyRadioGroupHeadingStyle, +} from '@skyux/forms'; +import { SkyHelpInlineHarness } from '@skyux/help-inline/testing'; + +import { SkyFormErrorsHarness } from '../form-error/form-errors-harness'; + +import { SkyRadioGroupHarnessFilters } from './radio-group-harness-filters'; +import { SkyRadioHarness } from './radio-harness'; + +/** + * Harness for interacting with a radio group component in tests. + */ +export class SkyRadioGroupHarness extends SkyComponentHarness { + /** + * @internal + */ + public static hostSelector = 'sky-radio-group'; + + #getH3 = this.locatorForOptional('legend h3'); + #getH4 = this.locatorForOptional('legend h4'); + #getH5 = this.locatorForOptional('legend h5'); + #getHeading = this.locatorFor('.sky-control-label'); + #getHeadingText = this.locatorForOptional( + 'legend .sky-radio-group-heading-text', + ); + #getHintText = this.locatorForOptional('.sky-radio-group-hint-text'); + #getRadioButtons = this.locatorForAll(SkyRadioHarness); + + /** + * Gets a `HarnessPredicate` that can be used to search for a + * `SkyRadioGroupHarness` that meets certain criteria. + */ + public static with( + filters: SkyRadioGroupHarnessFilters, + ): HarnessPredicate { + return SkyRadioGroupHarness.getDataSkyIdPredicate(filters); + } + + /** + * Clicks the help inline button. + */ + public async clickHelpInline(): Promise { + return (await this.#getHelpInline()).click(); + } + + /** + * Whether the heading is hidden. + */ + public async getHeadingHidden(): Promise { + return (await this.#getHeading()).hasClass('sky-screen-reader-only'); + } + + /** + * The semantic heading level used for the radio group. Returns undefined if heading level is not set. + */ + public async getHeadingLevel(): Promise< + SkyRadioGroupHeadingLevel | undefined + > { + const h3 = await this.#getH3(); + const h4 = await this.#getH4(); + const h5 = await this.#getH5(); + + if (h3) { + return 3; + } else if (h4) { + return 4; + } else if (h5) { + return 5; + } else { + return undefined; + } + } + + /** + * The heading style used for the radio group. + */ + public async getHeadingStyle(): Promise { + const headingOrLabel = + (await this.#getH3()) || + (await this.#getH4()) || + (await this.#getH5()) || + (await this.#getHeadingText()); + + const isHeadingStyle3 = + await headingOrLabel?.hasClass('sky-font-heading-3'); + const isHeadingStyle4 = + await headingOrLabel?.hasClass('sky-font-heading-4'); + + if (isHeadingStyle3) { + return 3; + } else if (isHeadingStyle4) { + return 4; + } else { + return 5; + } + } + + /** + * Gets the radio group's heading text. If `headingHidden` is true, + * the text will still be returned. + */ + public async getHeadingText(): Promise { + return (await this.#getHeading()).text(); + } + + /** + * Gets the help popover content. + */ + public async getHelpPopoverContent(): Promise { + const content = await (await this.#getHelpInline()).getPopoverContent(); + + /* istanbul ignore if */ + if (typeof content === 'object') { + throw Error('Unexpected template ref'); + } + + return content; + } + + /** + * Gets the help popover title. + */ + public async getHelpPopoverTitle(): Promise { + return await (await this.#getHelpInline()).getPopoverTitle(); + } + + /** + * Gets the radio group's hint text. + */ + public async getHintText(): Promise { + const hintText = await this.#getHintText(); + + return (await hintText?.text())?.trim() ?? ''; + } + + /** + * Gets an array of harnesses for the radio buttons in the radio group. + */ + public async getRadioButtons(): Promise { + return await this.#getRadioButtons(); + } + + /** + * Whether the radio group is required. + */ + public async getRequired(): Promise { + const heading = await this.#getHeading(); + + return await heading.hasClass('sky-control-label-required'); + } + + /** + * Whether the radio group is stacked. + */ + public async getStacked(): Promise { + const host = await this.host(); + const heading = + (await this.#getH3()) || (await this.#getH4()) || (await this.#getH5()); + const label = await this.#getHeadingText(); + + return ( + ((await host.hasClass('sky-margin-stacked-lg')) && !!label) || + ((await host.hasClass('sky-margin-stacked-xl')) && !!heading) + ); + } + + /** + * Whether the radio group has errors. + */ + public async hasError(errorName: string): Promise { + return (await this.#getFormErrors()).hasError(errorName); + } + + async #getFormErrors(): Promise { + return await this.locatorFor(SkyFormErrorsHarness)(); + } + + async #getHelpInline(): Promise { + const harness = await this.locatorForOptional( + SkyHelpInlineHarness.with({ + ancestor: '.sky-radio-group > .sky-radio-group-label-wrapper', + }), + )(); + + if (harness) { + return harness; + } + + throw Error('No help inline found.'); + } +} diff --git a/libs/components/forms/testing/src/radio/radio-harness-filters.ts b/libs/components/forms/testing/src/radio/radio-harness-filters.ts new file mode 100644 index 0000000000..12164b6ec8 --- /dev/null +++ b/libs/components/forms/testing/src/radio/radio-harness-filters.ts @@ -0,0 +1,8 @@ +import { SkyHarnessFilters } from '@skyux/core/testing'; + +/** + * A set of criteria that can be used to filter a list of `SkyRadioHarness` instances. + * @internal + */ +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface SkyRadioHarnessFilters extends SkyHarnessFilters {} diff --git a/libs/components/forms/testing/src/radio/radio-harness.spec.ts b/libs/components/forms/testing/src/radio/radio-harness.spec.ts new file mode 100644 index 0000000000..5b69abf69a --- /dev/null +++ b/libs/components/forms/testing/src/radio/radio-harness.spec.ts @@ -0,0 +1,245 @@ +import { HarnessLoader } from '@angular/cdk/testing'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { SkyHelpService } from '@skyux/core'; +import { SkyHelpTestingModule } from '@skyux/core/testing'; + +import { RadioHarnessTestComponent } from './fixtures/radio-harness-test.component'; +import { SkyRadioHarness } from './radio-harness'; + +async function setupTest( + options: { dataSkyId?: string; hideCheckLabel?: boolean } = {}, +): Promise<{ + radioHarness: SkyRadioHarness; + fixture: ComponentFixture; + loader: HarnessLoader; +}> { + await TestBed.configureTestingModule({ + imports: [ + RadioHarnessTestComponent, + SkyHelpTestingModule, + NoopAnimationsModule, + ], + }).compileComponents(); + + const fixture = TestBed.createComponent(RadioHarnessTestComponent); + if (options.hideCheckLabel) { + fixture.componentInstance.hideCheckLabel = true; + fixture.detectChanges(); + } + const loader = TestbedHarnessEnvironment.loader(fixture); + + const radioHarness: SkyRadioHarness = options.dataSkyId + ? await loader.getHarness( + SkyRadioHarness.with({ + dataSkyId: options.dataSkyId, + }), + ) + : await loader.getHarness(SkyRadioHarness); + + return { radioHarness, fixture, loader }; +} + +describe('Radio harness', () => { + it('should check if radio is disabled', async () => { + const { radioHarness, fixture } = await setupTest({ + dataSkyId: 'my-check-radio', + }); + + await expectAsync(radioHarness.isDisabled()).toBeResolvedTo(false); + + fixture.componentInstance.disableForm(); + await expectAsync(radioHarness.isDisabled()).toBeResolvedTo(true); + }); + + it('should focus the radio', async () => { + const { radioHarness } = await setupTest({ + dataSkyId: 'my-check-radio', + }); + + await expectAsync(radioHarness.isFocused()).toBeResolvedTo(false); + + await radioHarness.focus(); + await expectAsync(radioHarness.isFocused()).toBeResolvedTo(true); + + await radioHarness.blur(); + await expectAsync(radioHarness.isFocused()).toBeResolvedTo(false); + }); + + it('should get ARIA attributes', async () => { + const { radioHarness, fixture } = await setupTest({ + dataSkyId: 'my-check-radio', + }); + + fixture.detectChanges(); + await fixture.whenStable(); + + await expectAsync(radioHarness.getAriaLabel()).toBeResolvedTo( + 'Pay by check', + ); + await expectAsync(radioHarness.getAriaLabelledby()).toBeResolvedTo( + 'foo-check-id', + ); + await expectAsync(radioHarness.getLabelText()).toBeResolvedTo('Check'); + }); + + it('should handle a missing label when getting the label text', async () => { + const { radioHarness } = await setupTest({ + dataSkyId: 'my-check-radio', + hideCheckLabel: true, + }); + await expectAsync(radioHarness.getLabelText()).toBeResolvedTo(undefined); + }); + + it('should get the label text when specified via `labelText` input', async () => { + const { radioHarness } = await setupTest({ + dataSkyId: 'my-cash-radio', + }); + + await expectAsync(radioHarness.getLabelText()).toBeResolvedTo('Cash'); + }); + + it('should get the label text when specified via `labelText` input and label is hidden', async () => { + const { radioHarness, fixture } = await setupTest({ + dataSkyId: 'my-cash-radio', + }); + + fixture.componentInstance.hideCashLabel = true; + fixture.detectChanges(); + + await expectAsync(radioHarness.getLabelText()).toBeResolvedTo('Cash'); + }); + + it('should indicate the label is not hidden when the label is specified via `labelText`', async () => { + const { radioHarness } = await setupTest({ + dataSkyId: 'my-cash-radio', + }); + + await expectAsync(radioHarness.getLabelHidden()).toBeResolvedTo(false); + }); + + it('should indicate the label is hidden when the label is specified via `labelText`', async () => { + const { radioHarness, fixture } = await setupTest({ + dataSkyId: 'my-cash-radio', + }); + + fixture.componentInstance.hideCashLabel = true; + fixture.detectChanges(); + + await expectAsync(radioHarness.getLabelHidden()).toBeResolvedTo(true); + }); + + it('should throw an error when getting `labelIsHidden` for a radio using `sky-radio-label`', async () => { + const { radioHarness } = await setupTest({ + dataSkyId: 'my-check-radio', + }); + + await expectAsync(radioHarness.getLabelHidden()).toBeRejectedWithError( + '`labelIsHidden` is only supported when setting the radio label via the `labelText` input.', + ); + }); + + it('should get the hint text', async () => { + const { radioHarness, fixture } = await setupTest({ + dataSkyId: 'my-cash-radio', + }); + const hintText = 'Hint text for the radio.'; + + await expectAsync(radioHarness.getHintText()).toBeResolvedTo(''); + + fixture.componentInstance.cashHintText = hintText; + fixture.detectChanges(); + + await expectAsync(radioHarness.getHintText()).toBeResolvedTo(hintText); + }); + + it('should get the radio name', async () => { + const { radioHarness } = await setupTest({ + dataSkyId: 'my-check-radio', + }); + + await expectAsync(radioHarness.getName()).toBeResolvedTo( + jasmine.stringMatching(/sky-radio-group-[0-9]+/), + ); + }); + + it('should throw error if toggling a disabled radio', async () => { + const { radioHarness, fixture } = await setupTest({ + dataSkyId: 'my-check-radio', + }); + + fixture.componentInstance.disableForm(); + + await expectAsync(radioHarness.isChecked()).toBeResolvedTo(false); + + await expectAsync(radioHarness.check()).toBeRejectedWithError( + 'Could not check the radio button because it is disabled.', + ); + }); + + it('should throw an error if no help inline is found', async () => { + const { radioHarness } = await setupTest({ + dataSkyId: 'my-credit-radio', + }); + + await expectAsync(radioHarness.clickHelpInline()).toBeRejectedWithError( + 'No help inline found.', + ); + }); + + it('should open help inline popover when clicked', async () => { + const { radioHarness, fixture } = await setupTest({ + dataSkyId: 'my-cash-radio', + }); + + await radioHarness.clickHelpInline(); + fixture.detectChanges(); + await fixture.whenStable(); + + await expectAsync(radioHarness.getHelpPopoverContent()).toBeResolved(); + }); + + it('should open help widget when clicked', async () => { + const { radioHarness, fixture } = await setupTest({ + dataSkyId: 'my-cash-radio', + }); + const helpSvc = TestBed.inject(SkyHelpService); + const helpSpy = spyOn(helpSvc, 'openHelp'); + fixture.componentInstance.helpKey = 'helpKey.html'; + fixture.componentInstance.helpPopoverContent = undefined; + fixture.detectChanges(); + + await radioHarness.clickHelpInline(); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(helpSpy).toHaveBeenCalledWith({ helpKey: 'helpKey.html' }); + }); + + it('should get help popover content', async () => { + const { radioHarness, fixture } = await setupTest({ + dataSkyId: 'my-cash-radio', + }); + await radioHarness.clickHelpInline(); + fixture.detectChanges(); + await fixture.whenStable(); + + await expectAsync(radioHarness.getHelpPopoverContent()).toBeResolvedTo( + '(xxx)xxx-xxxx', + ); + }); + + it('should get help popover title', async () => { + const { radioHarness, fixture } = await setupTest({ + dataSkyId: 'my-cash-radio', + }); + await radioHarness.clickHelpInline(); + fixture.detectChanges(); + await fixture.whenStable(); + + await expectAsync(radioHarness.getHelpPopoverTitle()).toBeResolvedTo( + 'Format', + ); + }); +}); diff --git a/libs/components/forms/testing/src/radio/radio-harness.ts b/libs/components/forms/testing/src/radio/radio-harness.ts new file mode 100644 index 0000000000..ecfaee800b --- /dev/null +++ b/libs/components/forms/testing/src/radio/radio-harness.ts @@ -0,0 +1,183 @@ +import { HarnessPredicate } from '@angular/cdk/testing'; +import { SkyComponentHarness } from '@skyux/core/testing'; +import { SkyHelpInlineHarness } from '@skyux/help-inline/testing'; + +import { SkyRadioHarnessFilters } from './radio-harness-filters'; +import { SkyRadioLabelHarness } from './radio-label-harness'; + +/** + * Harness for interacting with a radio button component in tests. + */ +export class SkyRadioHarness extends SkyComponentHarness { + /** + * @internal + */ + public static hostSelector = 'sky-radio'; + + #getHintText = this.locatorForOptional('.sky-radio-hint-text'); + + #getInput = this.locatorFor('input.sky-radio-input'); + + #getLabel = this.locatorForOptional(SkyRadioLabelHarness); + + #getLabelText = this.locatorForOptional( + 'span.sky-switch-label.sky-radio-label-text', + ); + + /** + * Gets a `HarnessPredicate` that can be used to search for a + * `SkyRadioHarness` that meets certain criteria. + */ + public static with( + filters: SkyRadioHarnessFilters, + ): HarnessPredicate { + return SkyRadioHarness.getDataSkyIdPredicate(filters); + } + + /** + * Blurs the radio button. + */ + public async blur(): Promise { + return (await this.#getInput()).blur(); + } + + /** + * Puts the radio button in a checked state if it is currently unchecked. + */ + public async check(): Promise { + if (await this.isDisabled()) { + throw new Error( + 'Could not check the radio button because it is disabled.', + ); + } else if (!(await this.isChecked())) { + await (await this.#getInput()).click(); + } + } + + /** + * Clicks the help inline button. + */ + public async clickHelpInline(): Promise { + return (await this.#getHelpInline()).click(); + } + + /** + * Focuses the radio button. + */ + public async focus(): Promise { + return (await this.#getInput()).focus(); + } + + /** + * Gets the radio button's aria-label. + */ + public async getAriaLabel(): Promise { + return (await this.#getInput()).getAttribute('aria-label'); + } + + /** + * Gets the radio button's aria-labelledby. + */ + public async getAriaLabelledby(): Promise { + return (await this.#getInput()).getAttribute('aria-labelledby'); + } + + /** + * Gets the help popover content. + */ + public async getHelpPopoverContent(): Promise { + const content = await (await this.#getHelpInline()).getPopoverContent(); + + /* istanbul ignore if */ + if (typeof content === 'object') { + throw Error('Unexpected template ref'); + } + + return content; + } + + /** + * Gets the help popover title. + */ + public async getHelpPopoverTitle(): Promise { + return await (await this.#getHelpInline()).getPopoverTitle(); + } + + /** + * Gets the radio button's hint text. + */ + public async getHintText(): Promise { + const hintText = await this.#getHintText(); + + return (await hintText?.text())?.trim() ?? ''; + } + + /** + * Whether the label is hidden. Only supported when using the `labelText` input to set the label. + */ + public async getLabelHidden(): Promise { + const labelText = await this.#getLabelText(); + const label = await this.#getLabel(); + + if (label) { + throw new Error( + '`labelIsHidden` is only supported when setting the radio label via the `labelText` input.', + ); + } else { + return !!(await labelText?.hasClass('sky-screen-reader-only')); + } + } + + /** + * Gets the radio button's label text. If the label is set via `labelText` and `labelHidden` is true, + * the text will still be returned. + */ + public async getLabelText(): Promise { + const labelText = await this.#getLabelText(); + + if (labelText) { + return labelText.text(); + } else { + return (await this.#getLabel())?.getText(); + } + } + + /** + * Gets the radio button's name. + */ + public async getName(): Promise { + return (await this.#getInput()).getAttribute('name'); + } + + /** + * Whether the radio button is checked. + */ + public async isChecked(): Promise { + return (await this.#getInput()).getProperty('checked'); + } + + /** + * Whether the radio button is disabled. + */ + public async isDisabled(): Promise { + const disabled = await (await this.#getInput()).getAttribute('disabled'); + return disabled !== null; + } + + /** + * Whether the radio button is focused. + */ + public async isFocused(): Promise { + return (await this.#getInput()).isFocused(); + } + + async #getHelpInline(): Promise { + const harness = await this.locatorForOptional(SkyHelpInlineHarness)(); + + if (harness) { + return harness; + } + + throw Error('No help inline found.'); + } +} diff --git a/libs/components/forms/testing/src/radio/radio-label-harness.ts b/libs/components/forms/testing/src/radio/radio-label-harness.ts new file mode 100644 index 0000000000..aef9c063dc --- /dev/null +++ b/libs/components/forms/testing/src/radio/radio-label-harness.ts @@ -0,0 +1,21 @@ +import { ComponentHarness } from '@angular/cdk/testing'; + +/** + * Harness for interacting with a radio label component in tests. + * @internal + */ +export class SkyRadioLabelHarness extends ComponentHarness { + /** + * @internal + */ + public static hostSelector = 'sky-radio-label'; + + #getLabelContent = this.locatorFor('.sky-switch-label'); + + /** + * Gets the text content of the radio label. + */ + public async getText(): Promise { + return (await this.#getLabelContent()).text(); + } +}