diff --git a/src/material-experimental/mdc-slide-toggle/BUILD.bazel b/src/material-experimental/mdc-slide-toggle/BUILD.bazel index a87a5a0568f6..288f27134ff5 100644 --- a/src/material-experimental/mdc-slide-toggle/BUILD.bazel +++ b/src/material-experimental/mdc-slide-toggle/BUILD.bazel @@ -1,14 +1,17 @@ package(default_visibility = ["//visibility:public"]) load("@io_bazel_rules_sass//:defs.bzl", "sass_binary", "sass_library") -load("//tools:defaults.bzl", "ng_e2e_test_library", "ng_module", "ng_test_library", "ng_web_test_suite") +load("//tools:defaults.bzl", "ng_e2e_test_library", "ng_module", "ng_test_library", "ng_web_test_suite", "ts_library") load("//src/e2e-app:test_suite.bzl", "e2e_test_suite") ng_module( name = "mdc-slide-toggle", srcs = glob( ["**/*.ts"], - exclude = ["**/*.spec.ts"], + exclude = [ + "**/*.spec.ts", + "harness/**", + ], ), assets = [":slide_toggle_scss"] + glob(["**/*.html"]), module_name = "@angular/material-experimental/mdc-slide-toggle", @@ -24,6 +27,18 @@ ng_module( ], ) +ts_library( + name = "harness", + srcs = glob( + ["harness/**/*.ts"], + exclude = ["**/*.spec.ts"], + ), + deps = [ + "//src/cdk-experimental/testing", + "//src/cdk/coercion", + ], +) + sass_library( name = "mdc_slide_toggle_scss_lib", srcs = glob(["**/_*.scss"]), @@ -53,9 +68,13 @@ ng_test_library( exclude = ["**/*.e2e.spec.ts"], ), deps = [ + ":harness", ":mdc-slide-toggle", + "//src/cdk-experimental/testing", + "//src/cdk-experimental/testing/testbed", "//src/cdk/bidi", "//src/cdk/testing", + "//src/material/slide-toggle", "@npm//@angular/forms", "@npm//@angular/platform-browser", ], diff --git a/src/material-experimental/mdc-slide-toggle/harness/mdc-slide-toggle-harness.ts b/src/material-experimental/mdc-slide-toggle/harness/mdc-slide-toggle-harness.ts new file mode 100644 index 000000000000..1320b071e2a6 --- /dev/null +++ b/src/material-experimental/mdc-slide-toggle/harness/mdc-slide-toggle-harness.ts @@ -0,0 +1,127 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {ComponentHarness, HarnessPredicate} from '@angular/cdk-experimental/testing'; +import {coerceBooleanProperty} from '@angular/cdk/coercion'; +import {SlideToggleHarnessFilters} from './slide-toggle-harness-filters'; + + +/** + * Harness for interacting with a MDC-based mat-slide-toggle in tests. + * @dynamic + */ +export class MatSlideToggleHarness extends ComponentHarness { + static hostSelector = 'mat-slide-toggle'; + + /** + * Gets a `HarnessPredicate` that can be used to search for a slide-toggle w/ specific attributes. + * @param options Options for narrowing the search: + * - `label` finds a slide-toggle with specific label text. + * @return a `HarnessPredicate` configured with the given options. + */ + static with(options: SlideToggleHarnessFilters = {}): HarnessPredicate { + return new HarnessPredicate(MatSlideToggleHarness) + .addOption('label', options.label, + (harness, label) => HarnessPredicate.stringMatches(harness.getLabelText(), label)); + } + + private _label = this.locatorFor('label'); + private _input = this.locatorFor('input'); + private _inputContainer = this.locatorFor('.mdc-switch'); + + /** Gets a boolean promise indicating if the slide-toggle is checked. */ + async isChecked(): Promise { + const checked = (await this._input()).getAttribute('checked'); + return coerceBooleanProperty(await checked); + } + + /** Gets a boolean promise indicating if the slide-toggle is disabled. */ + async isDisabled(): Promise { + const disabled = (await this._input()).getAttribute('disabled'); + return coerceBooleanProperty(await disabled); + } + + /** Gets a boolean promise indicating if the slide-toggle is required. */ + async isRequired(): Promise { + const required = (await this._input()).getAttribute('required'); + return coerceBooleanProperty(await required); + } + + /** Gets a boolean promise indicating if the slide-toggle is valid. */ + async isValid(): Promise { + const invalid = (await this.host()).hasClass('ng-invalid'); + return !(await invalid); + } + + /** Gets a promise for the slide-toggle's name. */ + async getName(): Promise { + return (await this._input()).getAttribute('name'); + } + + /** Gets a promise for the slide-toggle's aria-label. */ + async getAriaLabel(): Promise { + return (await this._input()).getAttribute('aria-label'); + } + + /** Gets a promise for the slide-toggle's aria-labelledby. */ + async getAriaLabelledby(): Promise { + return (await this._input()).getAttribute('aria-labelledby'); + } + + /** Gets a promise for the slide-toggle's label text. */ + async getLabelText(): Promise { + return (await this._label()).text(); + } + + /** Focuses the slide-toggle and returns a void promise that indicates action completion. */ + async foucs(): Promise { + return (await this._input()).focus(); + } + + /** Blurs the slide-toggle and returns a void promise that indicates action completion. */ + async blur(): Promise { + return (await this._input()).blur(); + } + + /** + * Toggle the checked state of the slide-toggle and returns a void promise that indicates when the + * action is complete. + * + * Note: This attempts to toggle the slide-toggle as a user would, by clicking it. + */ + async toggle(): Promise { + const elToClick = await this.isDisabled() ? this._inputContainer() : this._input(); + return (await elToClick).click(); + } + + /** + * Puts the slide-toggle in a checked state by toggling it if it is currently unchecked, or doing + * nothing if it is already checked. Returns a void promise that indicates when the action is + * complete. + * + * Note: This attempts to check the slide-toggle as a user would, by clicking it. + */ + async check(): Promise { + if (!(await this.isChecked())) { + await this.toggle(); + } + } + + /** + * Puts the slide-toggle in an unchecked state by toggling it if it is currently checked, or doing + * nothing if it is already unchecked. Returns a void promise that indicates when the action is + * complete. + * + * Note: This attempts to uncheck the slide-toggle as a user would, by clicking it. + */ + async uncheck(): Promise { + if (await this.isChecked()) { + await this.toggle(); + } + } +} diff --git a/src/material-experimental/mdc-slide-toggle/harness/slide-toggle-harness-filters.ts b/src/material-experimental/mdc-slide-toggle/harness/slide-toggle-harness-filters.ts new file mode 100644 index 000000000000..d0ed25251198 --- /dev/null +++ b/src/material-experimental/mdc-slide-toggle/harness/slide-toggle-harness-filters.ts @@ -0,0 +1,11 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +export type SlideToggleHarnessFilters = { + label?: string | RegExp +}; diff --git a/src/material-experimental/mdc-slide-toggle/harness/slide-toggle-harness.spec.ts b/src/material-experimental/mdc-slide-toggle/harness/slide-toggle-harness.spec.ts new file mode 100644 index 000000000000..549869b5e820 --- /dev/null +++ b/src/material-experimental/mdc-slide-toggle/harness/slide-toggle-harness.spec.ts @@ -0,0 +1,193 @@ +import {HarnessLoader} from '@angular/cdk-experimental/testing'; +import {TestbedHarnessEnvironment} from '@angular/cdk-experimental/testing/testbed'; +import {Component} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {FormControl, ReactiveFormsModule} from '@angular/forms'; +import {MatSlideToggleModule} from '@angular/material/slide-toggle'; +import {MatSlideToggleModule as MatMdcSlideToggleModule} from '../index'; +import {MatSlideToggleHarness} from './slide-toggle-harness'; +import {MatSlideToggleHarness as MatMdcSlideToggleHarness} from './mdc-slide-toggle-harness'; + + +let fixture: ComponentFixture; +let loader: HarnessLoader; +let slideToggleHarness: typeof MatSlideToggleHarness; + +describe('MatSlideToggleHarness', () => { + describe('non-MDC-based', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [MatSlideToggleModule, ReactiveFormsModule], + declarations: [SlideToggleHarnessTest], + }).compileComponents(); + + fixture = TestBed.createComponent(SlideToggleHarnessTest); + fixture.detectChanges(); + loader = TestbedHarnessEnvironment.loader(fixture); + slideToggleHarness = MatSlideToggleHarness; + }); + + runTests(); + }); + + describe('MDC-based', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [MatMdcSlideToggleModule, ReactiveFormsModule], + declarations: [SlideToggleHarnessTest], + }).compileComponents(); + + fixture = TestBed.createComponent(SlideToggleHarnessTest); + fixture.detectChanges(); + loader = TestbedHarnessEnvironment.loader(fixture); + // Public APIs are the same as MatSlideToggleHarness, but cast is necessary because of + // different private fields. + slideToggleHarness = MatMdcSlideToggleHarness as any; + }); + + runTests(); + }); +}); + +/** Shared tests to run on both the original and MDC-based slide-toggles. */ +function runTests() { + it('should load all slide-toggle harnesses', async () => { + const slideToggles = await loader.getAllHarnesses(slideToggleHarness); + expect(slideToggles.length).toBe(2); + }); + + it('should load slide-toggle with exact label', async () => { + const slideToggles = await loader.getAllHarnesses(slideToggleHarness.with({label: 'First'})); + expect(slideToggles.length).toBe(1); + expect(await slideToggles[0].getLabelText()).toBe('First'); + }); + + it('should load slide-toggle with regex label match', async () => { + const slideToggles = await loader.getAllHarnesses(slideToggleHarness.with({label: /^s/i})); + expect(slideToggles.length).toBe(1); + expect(await slideToggles[0].getLabelText()).toBe('Second'); + }); + + it('should get checked state', async () => { + const [checkedToggle, uncheckedToggle] = await loader.getAllHarnesses(slideToggleHarness); + expect(await checkedToggle.isChecked()).toBe(true); + expect(await uncheckedToggle.isChecked()).toBe(false); + }); + + it('should get disabled state', async () => { + const [enabledToggle, disabledToggle] = await loader.getAllHarnesses(slideToggleHarness); + expect(await enabledToggle.isDisabled()).toBe(false); + expect(await disabledToggle.isDisabled()).toBe(true); + }); + + it('should get required state', async () => { + const [requiredToggle, optionalToggle] = await loader.getAllHarnesses(slideToggleHarness); + expect(await requiredToggle.isRequired()).toBe(true); + expect(await optionalToggle.isRequired()).toBe(false); + }); + + it('should get valid state', async () => { + const [requiredToggle, optionalToggle] = await loader.getAllHarnesses(slideToggleHarness); + expect(await optionalToggle.isValid()).toBe(true, 'Expected optional toggle to be valid'); + expect(await requiredToggle.isValid()) + .toBe(true, 'Expected required checked toggle to be valid'); + await requiredToggle.uncheck(); + expect(await requiredToggle.isValid()) + .toBe(false, 'Expected required unchecked toggle to be invalid'); + }); + + it('should get name', async () => { + const slideToggle = await loader.getHarness(slideToggleHarness.with({label: 'First'})); + expect(await slideToggle.getName()).toBe('first-name'); + }); + + it('should get aria-label', async () => { + const slideToggle = await loader.getHarness(slideToggleHarness.with({label: 'First'})); + expect(await slideToggle.getAriaLabel()).toBe('First slide-toggle'); + }); + + it('should get aria-labelledby', async () => { + const slideToggle = await loader.getHarness(slideToggleHarness.with({label: 'Second'})); + expect(await slideToggle.getAriaLabelledby()).toBe('second-label'); + }); + + it('should get label text', async () => { + const [firstToggle, secondToggle] = await loader.getAllHarnesses(slideToggleHarness); + expect(await firstToggle.getLabelText()).toBe('First'); + expect(await secondToggle.getLabelText()).toBe('Second'); + }); + + it('should focus slide-toggle', async () => { + const slideToggle = await loader.getHarness(slideToggleHarness.with({label: 'First'})); + expect(getActiveElementTagName()).not.toBe('input'); + await slideToggle.foucs(); + expect(getActiveElementTagName()).toBe('input'); + }); + + it('should blur slide-toggle', async () => { + const slideToggle = await loader.getHarness(slideToggleHarness.with({label: 'First'})); + await slideToggle.foucs(); + expect(getActiveElementTagName()).toBe('input'); + await slideToggle.blur(); + expect(getActiveElementTagName()).not.toBe('input'); + }); + + it('should toggle slide-toggle', async () => { + fixture.componentInstance.disabled = false; + const [checkedToggle, uncheckedToggle] = await loader.getAllHarnesses(slideToggleHarness); + await checkedToggle.toggle(); + await uncheckedToggle.toggle(); + expect(await checkedToggle.isChecked()).toBe(false); + expect(await uncheckedToggle.isChecked()).toBe(true); + }); + + it('should check slide-toggle', async () => { + fixture.componentInstance.disabled = false; + const [checkedToggle, uncheckedToggle] = await loader.getAllHarnesses(slideToggleHarness); + await checkedToggle.check(); + await uncheckedToggle.check(); + expect(await checkedToggle.isChecked()).toBe(true); + expect(await uncheckedToggle.isChecked()).toBe(true); + }); + + it('should uncheck slide-toggle', async () => { + fixture.componentInstance.disabled = false; + const [checkedToggle, uncheckedToggle] = await loader.getAllHarnesses(slideToggleHarness); + await checkedToggle.uncheck(); + await uncheckedToggle.uncheck(); + expect(await checkedToggle.isChecked()).toBe(false); + expect(await uncheckedToggle.isChecked()).toBe(false); + }); + + it('should not toggle disabled slide-toggle', async () => { + const disabledToggle = await loader.getHarness(slideToggleHarness.with({label: 'Second'})); + expect(await disabledToggle.isChecked()).toBe(false); + await disabledToggle.toggle(); + expect(await disabledToggle.isChecked()).toBe(false); + }); +} + +function getActiveElementTagName() { + return document.activeElement ? document.activeElement.tagName.toLowerCase() : ''; +} + +@Component({ + template: ` + + First + + + Second + + Second slide-toggle + ` +}) +class SlideToggleHarnessTest { + ctrl = new FormControl(true); + disabled = true; +} + diff --git a/src/material-experimental/mdc-slide-toggle/harness/slide-toggle-harness.ts b/src/material-experimental/mdc-slide-toggle/harness/slide-toggle-harness.ts new file mode 100644 index 000000000000..59d7b4bc7081 --- /dev/null +++ b/src/material-experimental/mdc-slide-toggle/harness/slide-toggle-harness.ts @@ -0,0 +1,126 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {ComponentHarness, HarnessPredicate} from '@angular/cdk-experimental/testing'; +import {coerceBooleanProperty} from '@angular/cdk/coercion'; +import {SlideToggleHarnessFilters} from './slide-toggle-harness-filters'; + + +/** + * Harness for interacting with a standard mat-slide-toggle in tests. + * @dynamic + */ +export class MatSlideToggleHarness extends ComponentHarness { + static hostSelector = 'mat-slide-toggle'; + + /** + * Gets a `HarnessPredicate` that can be used to search for a slide-toggle w/ specific attributes. + * @param options Options for narrowing the search: + * - `label` finds a slide-toggle with specific label text. + * @return a `HarnessPredicate` configured with the given options. + */ + static with(options: SlideToggleHarnessFilters = {}): HarnessPredicate { + return new HarnessPredicate(MatSlideToggleHarness) + .addOption('label', options.label, + (harness, label) => HarnessPredicate.stringMatches(harness.getLabelText(), label)); + } + + private _label = this.locatorFor('label'); + private _input = this.locatorFor('input'); + private _inputContainer = this.locatorFor('.mat-slide-toggle-bar'); + + /** Gets a boolean promise indicating if the slide-toggle is checked. */ + async isChecked(): Promise { + const checked = (await this._input()).getAttribute('checked'); + return coerceBooleanProperty(await checked); + } + + /** Gets a boolean promise indicating if the slide-toggle is disabled. */ + async isDisabled(): Promise { + const disabled = (await this._input()).getAttribute('disabled'); + return coerceBooleanProperty(await disabled); + } + + /** Gets a boolean promise indicating if the slide-toggle is required. */ + async isRequired(): Promise { + const required = (await this._input()).getAttribute('required'); + return coerceBooleanProperty(await required); + } + + /** Gets a boolean promise indicating if the slide-toggle is valid. */ + async isValid(): Promise { + const invalid = (await this.host()).hasClass('ng-invalid'); + return !(await invalid); + } + + /** Gets a promise for the slide-toggle's name. */ + async getName(): Promise { + return (await this._input()).getAttribute('name'); + } + + /** Gets a promise for the slide-toggle's aria-label. */ + async getAriaLabel(): Promise { + return (await this._input()).getAttribute('aria-label'); + } + + /** Gets a promise for the slide-toggle's aria-labelledby. */ + async getAriaLabelledby(): Promise { + return (await this._input()).getAttribute('aria-labelledby'); + } + + /** Gets a promise for the slide-toggle's label text. */ + async getLabelText(): Promise { + return (await this._label()).text(); + } + + /** Focuses the slide-toggle and returns a void promise that indicates action completion. */ + async foucs(): Promise { + return (await this._input()).focus(); + } + + /** Blurs the slide-toggle and returns a void promise that indicates action completion. */ + async blur(): Promise { + return (await this._input()).blur(); + } + + /** + * Toggle the checked state of the slide-toggle and returns a void promise that indicates when the + * action is complete. + * + * Note: This toggles the slide-toggle as a user would, by clicking it. + */ + async toggle(): Promise { + return (await this._inputContainer()).click(); + } + + /** + * Puts the slide-toggle in a checked state by toggling it if it is currently unchecked, or doing + * nothing if it is already checked. Returns a void promise that indicates when the action is + * complete. + * + * Note: This attempts to check the slide-toggle as a user would, by clicking it. + */ + async check(): Promise { + if (!(await this.isChecked())) { + await this.toggle(); + } + } + + /** + * Puts the slide-toggle in an unchecked state by toggling it if it is currently checked, or doing + * nothing if it is already unchecked. Returns a void promise that indicates when the action is + * complete. + * + * Note: This toggles the slide-toggle as a user would, by clicking it. + */ + async uncheck(): Promise { + if (await this.isChecked()) { + await this.toggle(); + } + } +}