Skip to content

Commit 68b4374

Browse files
authored
feat(material-experimental/mdc-radio): add test harness (#20283)
Adds test harnesses for the MDC-based radio button and radio button group.
1 parent b2d11a6 commit 68b4374

File tree

7 files changed

+335
-0
lines changed

7 files changed

+335
-0
lines changed

src/material-experimental/config.bzl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ entryPoints = [
2323
"mdc-progress-spinner",
2424
"mdc-progress-spinner/testing",
2525
"mdc-radio",
26+
"mdc-radio/testing",
2627
"mdc-select",
2728
"mdc-sidenav",
2829
"mdc-slide-toggle",
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
load("//tools:defaults.bzl", "ng_test_library", "ng_web_test_suite", "ts_library")
2+
3+
package(default_visibility = ["//visibility:public"])
4+
5+
ts_library(
6+
name = "testing",
7+
srcs = glob(
8+
["**/*.ts"],
9+
exclude = ["**/*.spec.ts"],
10+
),
11+
module_name = "@angular/material-experimental/mdc-radio/testing",
12+
deps = [
13+
"//src/cdk/coercion",
14+
"//src/cdk/testing",
15+
"//src/material/radio/testing",
16+
],
17+
)
18+
19+
ng_test_library(
20+
name = "unit_tests_lib",
21+
srcs = glob(["**/*.spec.ts"]),
22+
deps = [
23+
":testing",
24+
"//src/material-experimental/mdc-radio",
25+
"//src/material/radio/testing:harness_tests_lib",
26+
],
27+
)
28+
29+
ng_web_test_suite(
30+
name = "unit_tests",
31+
static_files = [
32+
"@npm//:node_modules/@material/radio/dist/mdc.radio.js",
33+
"@npm//:node_modules/@material/ripple/dist/mdc.ripple.js",
34+
],
35+
deps = [
36+
":unit_tests_lib",
37+
"//src/material-experimental:mdc_require_config.js",
38+
],
39+
)
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
export * from './public-api';
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
export * from './radio-harness';
10+
export * from './radio-harness-filters';
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {BaseHarnessFilters} from '@angular/cdk/testing';
10+
11+
/** A set of criteria that can be used to filter a list of `MatRadioGroupHarness` instances. */
12+
export interface RadioGroupHarnessFilters extends BaseHarnessFilters {
13+
/** Only find instances whose name attribute is the given value. */
14+
name?: string;
15+
}
16+
17+
/** A set of criteria that can be used to filter a list of `MatRadioButtonHarness` instances. */
18+
export interface RadioButtonHarnessFilters extends BaseHarnessFilters {
19+
/** Only find instances whose label matches the given value. */
20+
label?: string | RegExp;
21+
/** Only find instances whose name attribute is the given value. */
22+
name?: string;
23+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import {MatRadioModule} from '@angular/material-experimental/mdc-radio';
2+
import {runHarnessTests} from '@angular/material/radio/testing/shared.spec';
3+
import {MatRadioButtonHarness, MatRadioGroupHarness} from './radio-harness';
4+
5+
describe('MDC-based radio harness', () => {
6+
runHarnessTests(MatRadioModule, MatRadioGroupHarness as any, MatRadioButtonHarness as any);
7+
});
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {coerceBooleanProperty} from '@angular/cdk/coercion';
10+
import {ComponentHarness, HarnessPredicate} from '@angular/cdk/testing';
11+
import {RadioButtonHarnessFilters, RadioGroupHarnessFilters} from './radio-harness-filters';
12+
13+
/** Harness for interacting with an MDC-based mat-radio-group in tests. */
14+
export class MatRadioGroupHarness extends ComponentHarness {
15+
/** The selector for the host element of a `MatRadioGroup` instance. */
16+
static hostSelector = '.mat-mdc-radio-group';
17+
18+
/**
19+
* Gets a `HarnessPredicate` that can be used to search for a `MatRadioGroupHarness` that meets
20+
* certain criteria.
21+
* @param options Options for filtering which radio group instances are considered a match.
22+
* @return a `HarnessPredicate` configured with the given options.
23+
*/
24+
static with(options: RadioGroupHarnessFilters = {}): HarnessPredicate<MatRadioGroupHarness> {
25+
return new HarnessPredicate(MatRadioGroupHarness, options)
26+
.addOption('name', options.name, this._checkRadioGroupName);
27+
}
28+
29+
/** Gets the name of the radio-group. */
30+
async getName(): Promise<string|null> {
31+
const hostName = await this._getGroupNameFromHost();
32+
// It's not possible to always determine the "name" of a radio-group by reading
33+
// the attribute. This is because the radio-group does not set the "name" as an
34+
// element attribute if the "name" value is set through a binding.
35+
if (hostName !== null) {
36+
return hostName;
37+
}
38+
// In case we couldn't determine the "name" of a radio-group by reading the
39+
// "name" attribute, we try to determine the "name" of the group by going
40+
// through all radio buttons.
41+
const radioNames = await this._getNamesFromRadioButtons();
42+
if (!radioNames.length) {
43+
return null;
44+
}
45+
if (!this._checkRadioNamesInGroupEqual(radioNames)) {
46+
throw Error('Radio buttons in radio-group have mismatching names.');
47+
}
48+
return radioNames[0]!;
49+
}
50+
51+
/** Gets the id of the radio-group. */
52+
async getId(): Promise<string|null> {
53+
return (await this.host()).getProperty('id');
54+
}
55+
56+
/** Gets the checked radio-button in a radio-group. */
57+
async getCheckedRadioButton(): Promise<MatRadioButtonHarness|null> {
58+
for (let radioButton of await this.getRadioButtons()) {
59+
if (await radioButton.isChecked()) {
60+
return radioButton;
61+
}
62+
}
63+
return null;
64+
}
65+
66+
/** Gets the checked value of the radio-group. */
67+
async getCheckedValue(): Promise<string|null> {
68+
const checkedRadio = await this.getCheckedRadioButton();
69+
if (!checkedRadio) {
70+
return null;
71+
}
72+
return checkedRadio.getValue();
73+
}
74+
75+
/**
76+
* Gets a list of radio buttons which are part of the radio-group.
77+
* @param filter Optionally filters which radio buttons are included.
78+
*/
79+
async getRadioButtons(filter: RadioButtonHarnessFilters = {}): Promise<MatRadioButtonHarness[]> {
80+
return this.locatorForAll(MatRadioButtonHarness.with(filter))();
81+
}
82+
83+
/**
84+
* Checks a radio button in this group.
85+
* @param filter An optional filter to apply to the child radio buttons. The first tab matching
86+
* the filter will be selected.
87+
*/
88+
async checkRadioButton(filter: RadioButtonHarnessFilters = {}): Promise<void> {
89+
const radioButtons = await this.getRadioButtons(filter);
90+
if (!radioButtons.length) {
91+
throw Error(`Could not find radio button matching ${JSON.stringify(filter)}`);
92+
}
93+
return radioButtons[0].check();
94+
}
95+
96+
/** Gets the name attribute of the host element. */
97+
private async _getGroupNameFromHost() {
98+
return (await this.host()).getAttribute('name');
99+
}
100+
101+
/** Gets a list of the name attributes of all child radio buttons. */
102+
private async _getNamesFromRadioButtons(): Promise<string[]> {
103+
const groupNames: string[] = [];
104+
for (let radio of await this.getRadioButtons()) {
105+
const radioName = await radio.getName();
106+
if (radioName !== null) {
107+
groupNames.push(radioName);
108+
}
109+
}
110+
return groupNames;
111+
}
112+
113+
/** Checks if the specified radio names are all equal. */
114+
private _checkRadioNamesInGroupEqual(radioNames: string[]): boolean {
115+
let groupName: string|null = null;
116+
for (let radioName of radioNames) {
117+
if (groupName === null) {
118+
groupName = radioName;
119+
} else if (groupName !== radioName) {
120+
return false;
121+
}
122+
}
123+
return true;
124+
}
125+
126+
/**
127+
* Checks if a radio-group harness has the given name. Throws if a radio-group with
128+
* matching name could be found but has mismatching radio-button names.
129+
*/
130+
private static async _checkRadioGroupName(harness: MatRadioGroupHarness, name: string) {
131+
// Check if there is a radio-group which has the "name" attribute set
132+
// to the expected group name. It's not possible to always determine
133+
// the "name" of a radio-group by reading the attribute. This is because
134+
// the radio-group does not set the "name" as an element attribute if the
135+
// "name" value is set through a binding.
136+
if (await harness._getGroupNameFromHost() === name) {
137+
return true;
138+
}
139+
// Check if there is a group with radio-buttons that all have the same
140+
// expected name. This implies that the group has the given name. It's
141+
// not possible to always determine the name of a radio-group through
142+
// the attribute because there is
143+
const radioNames = await harness._getNamesFromRadioButtons();
144+
if (radioNames.indexOf(name) === -1) {
145+
return false;
146+
}
147+
if (!harness._checkRadioNamesInGroupEqual(radioNames)) {
148+
throw Error(
149+
`The locator found a radio-group with name "${name}", but some ` +
150+
`radio-button's within the group have mismatching names, which is invalid.`);
151+
}
152+
return true;
153+
}
154+
}
155+
156+
/** Harness for interacting with an MDC-based mat-radio-button in tests. */
157+
export class MatRadioButtonHarness extends ComponentHarness {
158+
/** The selector for the host element of a `MatRadioButton` instance. */
159+
static hostSelector = '.mat-mdc-radio-button';
160+
161+
/**
162+
* Gets a `HarnessPredicate` that can be used to search for a `MatRadioButtonHarness` that meets
163+
* certain criteria.
164+
* @param options Options for filtering which radio button instances are considered a match.
165+
* @return a `HarnessPredicate` configured with the given options.
166+
*/
167+
static with(options: RadioButtonHarnessFilters = {}): HarnessPredicate<MatRadioButtonHarness> {
168+
return new HarnessPredicate(MatRadioButtonHarness, options)
169+
.addOption(
170+
'label', options.label,
171+
(harness, label) => HarnessPredicate.stringMatches(harness.getLabelText(), label))
172+
.addOption(
173+
'name', options.name, async (harness, name) => (await harness.getName()) === name);
174+
}
175+
176+
private _label = this.locatorFor('label');
177+
private _input = this.locatorFor('input');
178+
179+
/** Whether the radio-button is checked. */
180+
async isChecked(): Promise<boolean> {
181+
const checked = (await this._input()).getProperty('checked');
182+
return coerceBooleanProperty(await checked);
183+
}
184+
185+
/** Whether the radio-button is disabled. */
186+
async isDisabled(): Promise<boolean> {
187+
const disabled = (await this._input()).getAttribute('disabled');
188+
return coerceBooleanProperty(await disabled);
189+
}
190+
191+
/** Whether the radio-button is required. */
192+
async isRequired(): Promise<boolean> {
193+
const required = (await this._input()).getAttribute('required');
194+
return coerceBooleanProperty(await required);
195+
}
196+
197+
/** Gets the radio-button's name. */
198+
async getName(): Promise<string|null> {
199+
return (await this._input()).getAttribute('name');
200+
}
201+
202+
/** Gets the radio-button's id. */
203+
async getId(): Promise<string|null> {
204+
return (await this.host()).getProperty('id');
205+
}
206+
207+
/**
208+
* Gets the value of the radio-button. The radio-button value will be converted to a string.
209+
*
210+
* Note: This means that for radio-button's with an object as a value `[object Object]` is
211+
* intentionally returned.
212+
*/
213+
async getValue(): Promise<string|null> {
214+
return (await this._input()).getProperty('value');
215+
}
216+
217+
/** Gets the radio-button's label text. */
218+
async getLabelText(): Promise<string> {
219+
return (await this._label()).text();
220+
}
221+
222+
/** Focuses the radio-button. */
223+
async focus(): Promise<void> {
224+
return (await this._input()).focus();
225+
}
226+
227+
/** Blurs the radio-button. */
228+
async blur(): Promise<void> {
229+
return (await this._input()).blur();
230+
}
231+
232+
/** Whether the radio-button is focused. */
233+
async isFocused(): Promise<boolean> {
234+
return (await this._input()).isFocused();
235+
}
236+
237+
/**
238+
* Puts the radio-button in a checked state by clicking it if it is currently unchecked,
239+
* or doing nothing if it is already checked.
240+
*/
241+
async check(): Promise<void> {
242+
if (!(await this.isChecked())) {
243+
return (await this._label()).click();
244+
}
245+
}
246+
}

0 commit comments

Comments
 (0)