Skip to content

feat(material-experimental): add test harness for radio-group #16609

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@
* found in the LICENSE file at https://angular.io/license
*/

export type RadioGroupHarnessFilters = {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It kind of feels like id and name should just be things you can do with normal selectors and shouldn't need a special filter option, e.g.

const myIdLoader = await loader.getChildLoader('#my-id')
const harness = myIdLoader.getHarness(RadioGroupHarness);

There is a problem with this currently though, which is that getHarness searches for the harness on an element under the root of the loader. So if my-id is the id of your radio group, it won't check that element, just the ones under it. Maybe getHarness should check the given element and anything under it? That would eliminate the need for these filters I think

Copy link
Member Author

@devversion devversion Jul 29, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For this case it is a bit more special though because the name or id is not necessarily set on the radio-group or radio-button element, but rather on the underlying input.

So if someone queries a radio-group by name, we check if it has a DOM attribute or find the right group by respecting the name attribute for child radio-buttons in groups.

Another pain point here is that if someone sets the radio-group name through an input binding, the name attribute will not be set. There is no host-binding for that. That's why we need to respect the inputs of child radio-button's.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah I see that's a good point, I wonder if I should add similar ones for checkbox. It will probably have the same issue if people want to search by name...

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. I will double-check on that and make a note.

id?: string;
name?: string;
};

export type RadioButtonHarnessFilters = {
label?: string|RegExp,
id?: string;
Expand Down
155 changes: 147 additions & 8 deletions src/material-experimental/mdc-radio/harness/radio-harness.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@ import {Component} from '@angular/core';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {ReactiveFormsModule} from '@angular/forms';
import {MatRadioModule} from '@angular/material/radio';
import {MatRadioButtonHarness} from './radio-harness';
import {MatRadioButtonHarness, MatRadioGroupHarness} from './radio-harness';

let fixture: ComponentFixture<MultipleRadioButtonsHarnessTest>;
let loader: HarnessLoader;
let radioButtonHarness: typeof MatRadioButtonHarness;
let radioGroupHarness: typeof MatRadioGroupHarness;

describe('MatRadioButtonHarness', () => {
describe('standard radio harnesses', () => {
describe('non-MDC-based', () => {
beforeEach(async () => {
await TestBed
Expand All @@ -24,9 +25,11 @@ describe('MatRadioButtonHarness', () => {
fixture.detectChanges();
loader = TestbedHarnessEnvironment.loader(fixture);
radioButtonHarness = MatRadioButtonHarness;
radioGroupHarness = MatRadioGroupHarness;
});

runTests();
describe('MatRadioButtonHarness', () => runRadioButtonTests());
describe('MatRadioGroupHarness', () => runRadioGroupTests());
});

describe(
Expand All @@ -36,11 +39,119 @@ describe('MatRadioButtonHarness', () => {
});
});

/** Shared tests to run on both the original and MDC-based radio-group's. */
function runRadioGroupTests() {
it('should load all radio-group harnesses', async () => {
const groups = await loader.getAllHarnesses(radioGroupHarness);
expect(groups.length).toBe(3);
});

it('should load radio-group with exact id', async () => {
const groups = await loader.getAllHarnesses(radioGroupHarness.with({id: 'my-group-2'}));
expect(groups.length).toBe(1);
});

it('should load radio-group by name', async () => {
let groups = await loader.getAllHarnesses(radioGroupHarness.with({name: 'my-group-2-name'}));
expect(groups.length).toBe(1);
expect(await groups[0].getId()).toBe('my-group-2');

groups = await loader.getAllHarnesses(radioGroupHarness.with({name: 'my-group-1-name'}));
expect(groups.length).toBe(1);
expect(await groups[0].getId()).toBe('my-group-1');
});

it('should throw when finding radio-group with specific name that has mismatched ' +
'radio-button names',
async () => {
fixture.componentInstance.thirdGroupButtonName = 'other-name';
fixture.detectChanges();

let errorMessage: string|null = null;
try {
await loader.getAllHarnesses(radioGroupHarness.with({name: 'third-group-name'}));
} catch (e) {
errorMessage = e.toString();
}

expect(errorMessage)
.toMatch(
/locator found a radio-group with name "third-group-name".*have mismatching names/);
});

it('should get name of radio-group', async () => {
const groups = await loader.getAllHarnesses(radioGroupHarness);
expect(groups.length).toBe(3);
expect(await groups[0].getName()).toBe('my-group-1-name');
expect(await groups[1].getName()).toBe('my-group-2-name');
expect(await groups[2].getName()).toBe('third-group-name');

fixture.componentInstance.secondGroupId = 'new-group';
fixture.detectChanges();

expect(await groups[1].getName()).toBe('new-group-name');

fixture.componentInstance.thirdGroupButtonName = 'other-button-name';
fixture.detectChanges();

let errorMessage: string|null = null;
try {
await groups[2].getName();
} catch (e) {
errorMessage = e.toString();
}

expect(errorMessage).toMatch(/Radio buttons in radio-group have mismatching names./);
});

it('should get id of radio-group', async () => {
const groups = await loader.getAllHarnesses(radioGroupHarness);
expect(groups.length).toBe(3);
expect(await groups[0].getId()).toBe('my-group-1');
expect(await groups[1].getId()).toBe('my-group-2');
expect(await groups[2].getId()).toBe('');

fixture.componentInstance.secondGroupId = 'new-group-name';
fixture.detectChanges();

expect(await groups[1].getId()).toBe('new-group-name');
});

it('should get selected value of radio-group', async () => {
const [firstGroup, secondGroup] = await loader.getAllHarnesses(radioGroupHarness);
expect(await firstGroup.getSelectedValue()).toBe('opt2');
expect(await secondGroup.getSelectedValue()).toBe(null);
});

it('should get radio-button harnesses of radio-group', async () => {
const groups = await loader.getAllHarnesses(radioGroupHarness);
expect(groups.length).toBe(3);

expect((await groups[0].getRadioButtons()).length).toBe(3);
expect((await groups[1].getRadioButtons()).length).toBe(1);
expect((await groups[2].getRadioButtons()).length).toBe(2);
});

it('should get selected radio-button harnesses of radio-group', async () => {
const groups = await loader.getAllHarnesses(radioGroupHarness);
expect(groups.length).toBe(3);

const groupOneSelected = await groups[0].getSelectedRadioButton();
const groupTwoSelected = await groups[1].getSelectedRadioButton();
const groupThreeSelected = await groups[2].getSelectedRadioButton();

expect(groupOneSelected).not.toBeNull();
expect(groupTwoSelected).toBeNull();
expect(groupThreeSelected).toBeNull();
expect(await groupOneSelected!.getId()).toBe('opt2-group-one');
});
}

/** Shared tests to run on both the original and MDC-based radio-button's. */
function runTests() {
function runRadioButtonTests() {
it('should load all radio-button harnesses', async () => {
const radios = await loader.getAllHarnesses(radioButtonHarness);
expect(radios.length).toBe(4);
expect(radios.length).toBe(9);
});

it('should load radio-button with exact label', async () => {
Expand Down Expand Up @@ -85,6 +196,13 @@ function runTests() {
expect(await thirdRadio.getLabelText()).toBe('Option #3');
});

it('should get value', async () => {
const [firstRadio, secondRadio, thirdRadio] = await loader.getAllHarnesses(radioButtonHarness);
expect(await firstRadio.getValue()).toBe('opt1');
expect(await secondRadio.getValue()).toBe('opt2');
expect(await thirdRadio.getValue()).toBe('opt3');
});

it('should get disabled state', async () => {
const [firstRadio] = await loader.getAllHarnesses(radioButtonHarness);
expect(await firstRadio.isDisabled()).toBe(false);
Expand Down Expand Up @@ -141,6 +259,7 @@ function runTests() {
expect(await radioButton.isRequired()).toBe(true);
});
}

function getActiveElementTagName() {
return document.activeElement ? document.activeElement.tagName.toLowerCase() : '';
}
Expand All @@ -157,12 +276,32 @@ function getActiveElementTagName() {
Option #{{i + 1}}
</mat-radio-button>

<mat-radio-button id="required-radio" required name="acceptsTerms">
Accept terms of conditions
</mat-radio-button>
<mat-radio-group id="my-group-1" name="my-group-1-name">
<mat-radio-button *ngFor="let value of values"
[checked]="value === 'opt2'"
[value]="value"
[id]="value + '-group-one'">
{{value}}
</mat-radio-button>
</mat-radio-group>


<mat-radio-group [id]="secondGroupId" [name]="secondGroupId + '-name'">
<mat-radio-button id="required-radio" required [value]="true">
Accept terms of conditions
</mat-radio-button>
</mat-radio-group>

<mat-radio-group [name]="thirdGroupName">
<mat-radio-button [value]="true">First</mat-radio-button>
<mat-radio-button [value]="false" [name]="thirdGroupButtonName"></mat-radio-button>
</mat-radio-group>
`
})
class MultipleRadioButtonsHarnessTest {
values = ['opt1', 'opt2', 'opt3'];
disableAll = false;
secondGroupId = 'my-group-2';
thirdGroupName: string = 'third-group-name';
thirdGroupButtonName: string|undefined = undefined;
}
146 changes: 145 additions & 1 deletion src/material-experimental/mdc-radio/harness/radio-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,139 @@

import {ComponentHarness, HarnessPredicate} from '@angular/cdk-experimental/testing';
import {coerceBooleanProperty} from '@angular/cdk/coercion';
import {RadioButtonHarnessFilters} from './radio-harness-filters';
import {RadioButtonHarnessFilters, RadioGroupHarnessFilters} from './radio-harness-filters';

/**
* Harness for interacting with a standard mat-radio-group in tests.
* @dynamic
*/
export class MatRadioGroupHarness extends ComponentHarness {
static hostSelector = 'mat-radio-group';

/**
* Gets a `HarnessPredicate` that can be used to search for a radio-group with
* specific attributes.
* @param options Options for narrowing the search:
* - `id` finds a radio-group with specific id.
* - `name` finds a radio-group with specific name.
* @return a `HarnessPredicate` configured with the given options.
*/
static with(options: RadioGroupHarnessFilters = {}): HarnessPredicate<MatRadioGroupHarness> {
return new HarnessPredicate(MatRadioGroupHarness)
.addOption('id', options.id, async (harness, id) => (await harness.getId()) === id)
.addOption('name', options.name, this._checkRadioGroupName);
}

private _radioButtons = this.locatorForAll(MatRadioButtonHarness);

/** Gets the name of the radio-group. */
async getName(): Promise<string|null> {
const hostName = await this._getGroupNameFromHost();
// It's not possible to always determine the "name" of a radio-group by reading
// the attribute. This is because the radio-group does not set the "name" as an
// element attribute if the "name" value is set through a binding.
if (hostName !== null) {
return hostName;
}
// In case we couldn't determine the "name" of a radio-group by reading the
// "name" attribute, we try to determine the "name" of the group by going
// through all radio buttons.
const radioNames = await this._getNamesFromRadioButtons();
if (!radioNames.length) {
return null;
}
if (!this._checkRadioNamesInGroupEqual(radioNames)) {
throw Error('Radio buttons in radio-group have mismatching names.');
}
return radioNames[0]!;
}

/** Gets the id of the radio-group. */
async getId(): Promise<string|null> {
return (await this.host()).getAttribute('id');
}

/** Gets the selected radio-button in a radio-group. */
async getSelectedRadioButton(): Promise<MatRadioButtonHarness|null> {
for (let radioButton of await this.getRadioButtons()) {
if (await radioButton.isChecked()) {
return radioButton;
}
}
return null;
}

/** Gets the selected value of the radio-group. */
async getSelectedValue(): Promise<string|null> {
const selectedRadio = await this.getSelectedRadioButton();
if (!selectedRadio) {
return null;
}
return selectedRadio.getValue();
}

/** Gets all radio buttons which are part of the radio-group. */
async getRadioButtons(): Promise<MatRadioButtonHarness[]> {
return (await this._radioButtons());
}

private async _getGroupNameFromHost() {
return (await this.host()).getAttribute('name');
}

private async _getNamesFromRadioButtons(): Promise<string[]> {
const groupNames: string[] = [];
for (let radio of await this.getRadioButtons()) {
const radioName = await radio.getName();
if (radioName !== null) {
groupNames.push(radioName);
}
}
return groupNames;
}

/** Checks if the specified radio names are all equal. */
private _checkRadioNamesInGroupEqual(radioNames: string[]): boolean {
let groupName: string|null = null;
for (let radioName of radioNames) {
if (groupName === null) {
groupName = radioName;
} else if (groupName !== radioName) {
return false;
}
}
return true;
}

/**
* Checks if a radio-group harness has the given name. Throws if a radio-group with
* matching name could be found but has mismatching radio-button names.
*/
private static async _checkRadioGroupName(harness: MatRadioGroupHarness, name: string) {
// Check if there is a radio-group which has the "name" attribute set
// to the expected group name. It's not possible to always determine
// the "name" of a radio-group by reading the attribute. This is because
// the radio-group does not set the "name" as an element attribute if the
// "name" value is set through a binding.
if (await harness._getGroupNameFromHost() === name) {
return true;
}
// Check if there is a group with radio-buttons that all have the same
// expected name. This implies that the group has the given name. It's
// not possible to always determine the name of a radio-group through
// the attribute because there is
const radioNames = await harness._getNamesFromRadioButtons();
if (radioNames.indexOf(name) === -1) {
return false;
}
if (!harness._checkRadioNamesInGroupEqual(radioNames)) {
throw Error(
`The locator found a radio-group with name "${name}", but some ` +
`radio-button's within the group have mismatching names, which is invalid.`);
}
return true;
}
}

/**
* Harness for interacting with a standard mat-radio-button in tests.
Expand All @@ -23,6 +155,7 @@ export class MatRadioButtonHarness extends ComponentHarness {
* @param options Options for narrowing the search:
* - `label` finds a radio-button with specific label text.
* - `name` finds a radio-button with specific name.
* - `id` finds a radio-button with specific id.
* @return a `HarnessPredicate` configured with the given options.
*/
static with(options: RadioButtonHarnessFilters = {}): HarnessPredicate<MatRadioButtonHarness> {
Expand Down Expand Up @@ -67,6 +200,17 @@ export class MatRadioButtonHarness extends ComponentHarness {
return (await this.host()).getAttribute('id');
}

/**
* Gets the value of the radio-button. The radio-button value will be
* converted to a string.
*
* Note that this means that radio-button's with objects as value will
* intentionally have the `[object Object]` as return value.
*/
async getValue(): Promise<string|null> {
return (await this._input()).getAttribute('value');
}

/** Gets a promise for the radio-button's label text. */
async getLabelText(): Promise<string> {
return (await this._textLabel()).text();
Expand Down
1 change: 1 addition & 0 deletions src/material/radio/radio.html
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
[disabled]="disabled"
[tabIndex]="tabIndex"
[attr.name]="name"
[attr.value]="value"
[required]="required"
[attr.aria-label]="ariaLabel"
[attr.aria-labelledby]="ariaLabelledby"
Expand Down
Loading