Skip to content

Commit

Permalink
feat(material/timepicker): add test harnesses
Browse files Browse the repository at this point in the history
Adds test harnesses for `MatTimepickerInput`, `MatTimepicker` and `MatTimepickerToggle`.
  • Loading branch information
crisbeto committed Oct 4, 2024
1 parent 2646e08 commit 9546fe7
Show file tree
Hide file tree
Showing 10 changed files with 635 additions and 17 deletions.
3 changes: 3 additions & 0 deletions src/material/timepicker/testing/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ ts_library(
exclude = ["**/*.spec.ts"],
),
deps = [
"//src/cdk/coercion",
"//src/cdk/testing",
"//src/material/core/testing",
"//src/material/timepicker",
],
)
Expand All @@ -27,6 +29,7 @@ ng_test_library(
"//src/cdk/testing",
"//src/cdk/testing/private",
"//src/cdk/testing/testbed",
"//src/material/core",
"//src/material/timepicker",
"@npm//@angular/platform-browser",
],
Expand Down
2 changes: 2 additions & 0 deletions src/material/timepicker/testing/public-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,5 @@

export * from './timepicker-harness';
export * from './timepicker-harness-filters';
export * from './timepicker-input-harness';
export * from './timepicker-toggle-harness';
11 changes: 11 additions & 0 deletions src/material/timepicker/testing/timepicker-harness-filters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,14 @@ import {BaseHarnessFilters} from '@angular/cdk/testing';

/** A set of criteria that can be used to filter a list of `MatTimepickerHarness` instances. */
export interface TimepickerHarnessFilters extends BaseHarnessFilters {}

/** A set of criteria that can be used to filter a list of timepicker input instances. */
export interface TimepickerInputHarnessFilters extends BaseHarnessFilters {
/** Filters based on the value of the input. */
value?: string | RegExp;
/** Filters based on the placeholder text of the input. */
placeholder?: string | RegExp;
}

/** A set of criteria that can be used to filter a list of timepicker toggle instances. */
export interface TimepickerToggleHarnessFilters extends BaseHarnessFilters {}
61 changes: 53 additions & 8 deletions src/material/timepicker/testing/timepicker-harness.spec.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,25 @@
import {Component} from '@angular/core';
import {Component, signal} from '@angular/core';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {HarnessLoader} from '@angular/cdk/testing';
import {HarnessLoader, parallel} from '@angular/cdk/testing';
import {DateAdapter, provideNativeDateAdapter} from '@angular/material/core';
import {TestbedHarnessEnvironment} from '@angular/cdk/testing/testbed';
import {NoopAnimationsModule} from '@angular/platform-browser/animations';
import {MatTimepicker} from '@angular/material/timepicker';
import {MatTimepicker, MatTimepickerInput} from '@angular/material/timepicker';
import {MatTimepickerHarness} from './timepicker-harness';
import {MatTimepickerInputHarness} from './timepicker-input-harness';

describe('MatTimepicker', () => {
describe('MatTimepickerHarness', () => {
let fixture: ComponentFixture<TimepickerHarnessTest>;
let loader: HarnessLoader;

beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideNativeDateAdapter()],
imports: [NoopAnimationsModule, TimepickerHarnessTest],
});

const adapter = TestBed.inject(DateAdapter);
adapter.setLocale('en-US');
fixture = TestBed.createComponent(TimepickerHarnessTest);
fixture.detectChanges();
loader = TestbedHarnessEnvironment.documentRootLoader(fixture);
Expand All @@ -24,14 +29,54 @@ describe('MatTimepicker', () => {
const harnesses = await loader.getAllHarnesses(MatTimepickerHarness);
expect(harnesses.length).toBe(2);
});

it('should get the open state of a timepicker', async () => {
const input = await loader.getHarness(MatTimepickerInputHarness.with({selector: '#one'}));
const timepicker = await input.getTimepicker();
expect(await timepicker.isOpen()).toBe(false);

await input.openTimepicker();
expect(await timepicker.isOpen()).toBe(true);
});

it('should throw when trying to get the options while closed', async () => {
const input = await loader.getHarness(MatTimepickerInputHarness.with({selector: '#one'}));
const timepicker = await input.getTimepicker();

await expectAsync(timepicker.getOptions()).toBeRejectedWithError(
/Unable to retrieve options for timepicker\. Timepicker panel is closed\./,
);
});

it('should get the options in a timepicker', async () => {
const input = await loader.getHarness(MatTimepickerInputHarness.with({selector: '#one'}));
const timepicker = await input.openTimepicker();
const options = await timepicker.getOptions();
const labels = await parallel(() => options.map(o => o.getText()));
expect(labels).toEqual(['12:00 AM', '4:00 AM', '8:00 AM', '12:00 PM', '4:00 PM', '8:00 PM']);
});

it('should be able to select an option', async () => {
const input = await loader.getHarness(MatTimepickerInputHarness.with({selector: '#one'}));
const timepicker = await input.openTimepicker();
expect(await input.getValue()).toBe('');

await timepicker.selectOption({text: '4:00 PM'});
expect(await input.getValue()).toBe('4:00 PM');
expect(await timepicker.isOpen()).toBe(false);
});
});

@Component({
template: `
<mat-timepicker/>
<mat-timepicker/>
<input id="one" [matTimepicker]="onePicker">
<mat-timepicker #onePicker [interval]="interval()"/>
<input id="two" [matTimepicker]="twoPicker">
<mat-timepicker #twoPicker [interval]="interval()"/>
`,
standalone: true,
imports: [MatTimepicker],
imports: [MatTimepickerInput, MatTimepicker],
})
class TimepickerHarnessTest {}
class TimepickerHarnessTest {
interval = signal('4h');
}
60 changes: 51 additions & 9 deletions src/material/timepicker/testing/timepicker-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,63 @@
* found in the LICENSE file at https://angular.dev/license
*/

import {ComponentHarness, HarnessPredicate} from '@angular/cdk/testing';
import {
ComponentHarness,
ComponentHarnessConstructor,
HarnessPredicate,
} from '@angular/cdk/testing';
import {MatOptionHarness, OptionHarnessFilters} from '@angular/material/core/testing';
import {TimepickerHarnessFilters} from './timepicker-harness-filters';

/** Harness for interacting with a standard `MatTimepicker` in tests. */
export class MatTimepickerHarness extends ComponentHarness {
/** The selector for the host element of a `MatTimepicker` instance. */
static hostSelector = '.mat-timepicker';
private _documentRootLocator = this.documentRootLocatorFactory();
static hostSelector = 'mat-timepicker';

/**
* Gets a `HarnessPredicate` that can be used to search for a `MatTimepicker`
* that meets certain criteria.
* @param options Options for filtering which dialog instances are considered a match.
* Gets a `HarnessPredicate` that can be used to search for a timepicker with specific
* attributes.
* @param options Options for filtering which timepicker instances are considered a match.
* @return a `HarnessPredicate` configured with the given options.
*/
static with(options: TimepickerHarnessFilters = {}): HarnessPredicate<MatTimepickerHarness> {
return new HarnessPredicate(MatTimepickerHarness, options);
static with<T extends MatTimepickerHarness>(
this: ComponentHarnessConstructor<T>,
options: TimepickerHarnessFilters = {},
): HarnessPredicate<T> {
return new HarnessPredicate(this, options);
}

/** Whether the timepicker is open. */
async isOpen(): Promise<boolean> {
const selector = await this._getPanelSelector();
const panel = await this._documentRootLocator.locatorForOptional(selector)();
return panel !== null;
}

/** Gets the options inside the timepicker panel. */
async getOptions(filters?: Omit<OptionHarnessFilters, 'ancestor'>): Promise<MatOptionHarness[]> {
if (!(await this.isOpen())) {
throw new Error('Unable to retrieve options for timepicker. Timepicker panel is closed.');
}

return this._documentRootLocator.locatorForAll(
MatOptionHarness.with({
...(filters || {}),
ancestor: await this._getPanelSelector(),
} as OptionHarnessFilters),
)();
}

/** Selects the first option matching the given filters. */
async selectOption(filters: OptionHarnessFilters): Promise<void> {
const options = await this.getOptions(filters);
if (!options.length) {
throw Error(`Could not find a mat-option matching ${JSON.stringify(filters)}`);
}
await options[0].click();
}

/** Gets the selector that can be used to find the timepicker's panel. */
protected async _getPanelSelector(): Promise<string> {
return `#${await (await this.host()).getAttribute('mat-timepicker-panel-id')}`;
}
}
181 changes: 181 additions & 0 deletions src/material/timepicker/testing/timepicker-input-harness.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import {HarnessLoader, parallel} from '@angular/cdk/testing';
import {TestbedHarnessEnvironment} from '@angular/cdk/testing/testbed';
import {Component, signal} from '@angular/core';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {NoopAnimationsModule} from '@angular/platform-browser/animations';
import {DateAdapter, provideNativeDateAdapter} from '@angular/material/core';
import {MatTimepicker, MatTimepickerInput} from '@angular/material/timepicker';
import {MatTimepickerHarness} from './timepicker-harness';
import {MatTimepickerInputHarness} from './timepicker-input-harness';

describe('MatTimepickerInputHarness', () => {
let fixture: ComponentFixture<TimepickerInputHarnessTest>;
let loader: HarnessLoader;
let adapter: DateAdapter<Date>;

beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideNativeDateAdapter()],
imports: [NoopAnimationsModule, TimepickerInputHarnessTest],
});

adapter = TestBed.inject(DateAdapter);
adapter.setLocale('en-US');
fixture = TestBed.createComponent(TimepickerInputHarnessTest);
fixture.detectChanges();
loader = TestbedHarnessEnvironment.loader(fixture);
});

it('should load all timepicker input harnesses', async () => {
const inputs = await loader.getAllHarnesses(MatTimepickerInputHarness);
expect(inputs.length).toBe(2);
});

it('should filter inputs based on their value', async () => {
fixture.componentInstance.value.set(createTime(15, 10));
fixture.changeDetectorRef.markForCheck();
const inputs = await loader.getAllHarnesses(MatTimepickerInputHarness.with({value: /3:10/}));
expect(inputs.length).toBe(1);
});

it('should filter inputs based on their placeholder', async () => {
const inputs = await loader.getAllHarnesses(
MatTimepickerInputHarness.with({
placeholder: /^Pick/,
}),
);

expect(inputs.length).toBe(1);
});

it('should get whether the input is disabled', async () => {
const input = await loader.getHarness(MatTimepickerInputHarness.with({selector: '#bound'}));
expect(await input.isDisabled()).toBe(false);

fixture.componentInstance.disabled.set(true);
expect(await input.isDisabled()).toBe(true);
});

it('should get whether the input is required', async () => {
const input = await loader.getHarness(MatTimepickerInputHarness.with({selector: '#bound'}));
expect(await input.isRequired()).toBe(false);

fixture.componentInstance.required.set(true);
expect(await input.isRequired()).toBe(true);
});

it('should get the input value', async () => {
const input = await loader.getHarness(MatTimepickerInputHarness.with({selector: '#bound'}));
fixture.componentInstance.value.set(createTime(15, 10));
fixture.changeDetectorRef.markForCheck();

expect(await input.getValue()).toBe('3:10 PM');
});

it('should set the input value', async () => {
const input = await loader.getHarness(MatTimepickerInputHarness.with({selector: '#bound'}));
expect(await input.getValue()).toBeFalsy();

await input.setValue('3:10 PM');
expect(await input.getValue()).toBe('3:10 PM');
});

it('should set the input value based on date adapter validation and formatting', async () => {
const input = await loader.getHarness(MatTimepickerInputHarness.with({selector: '#bound'}));
const validValues: any[] = [createTime(15, 10), '', 0, false];
const invalidValues: any[] = [null, undefined];
spyOn(adapter, 'format').and.returnValue('FORMATTED_VALUE');
spyOn(adapter, 'isValid').and.callFake(value => validValues.includes(value));
spyOn(adapter, 'deserialize').and.callFake(value =>
validValues.includes(value) ? value : null,
);
spyOn(adapter, 'getValidDateOrNull').and.callFake((value: Date) =>
adapter.isValid(value) ? value : null,
);

for (let value of validValues) {
fixture.componentInstance.value.set(value);
fixture.changeDetectorRef.markForCheck();
expect(await input.getValue()).toBe('FORMATTED_VALUE');
}

for (let value of invalidValues) {
fixture.componentInstance.value.set(value);
fixture.changeDetectorRef.markForCheck();
expect(await input.getValue()).toBe('');
}
});

it('should get the input placeholder', async () => {
const inputs = await loader.getAllHarnesses(MatTimepickerInputHarness);
expect(await parallel(() => inputs.map(input => input.getPlaceholder()))).toEqual([
'Pick a time',
'Select a time',
]);
});

it('should be able to change the input focused state', async () => {
const input = await loader.getHarness(MatTimepickerInputHarness.with({selector: '#bound'}));
expect(await input.isFocused()).toBe(false);

await input.focus();
expect(await input.isFocused()).toBe(true);

await input.blur();
expect(await input.isFocused()).toBe(false);
});

it('should be able to open and close a timepicker', async () => {
const input = await loader.getHarness(MatTimepickerInputHarness.with({selector: '#bound'}));
expect(await input.isTimepickerOpen()).toBe(false);

await input.openTimepicker();
expect(await input.isTimepickerOpen()).toBe(true);

await input.closeTimepicker();
expect(await input.isTimepickerOpen()).toBe(false);
});

it('should be able to get the harness for the associated timepicker', async () => {
const input = await loader.getHarness(MatTimepickerInputHarness.with({selector: '#bound'}));
await input.openTimepicker();
expect(await input.getTimepicker()).toBeInstanceOf(MatTimepickerHarness);
});

it('should emit the `valueChange` event when the value is changed', async () => {
const input = await loader.getHarness(MatTimepickerInputHarness.with({selector: '#bound'}));
expect(fixture.componentInstance.changeCount).toBe(0);

await input.setValue('3:15 PM');
expect(fixture.componentInstance.changeCount).toBeGreaterThan(0);
});

function createTime(hours: number, minutes: number): Date {
return adapter.setTime(adapter.today(), hours, minutes, 0);
}
});

@Component({
template: `
<input
[matTimepicker]="boundPicker"
[value]="value()"
[disabled]="disabled()"
[required]="required()"
(valueChange)="changeCount = changeCount + 1"
placeholder="Pick a time"
id="bound">
<mat-timepicker #boundPicker/>
<input [matTimepicker]="basicPicker" id="basic" placeholder="Select a time">
<mat-timepicker #basicPicker/>
`,
standalone: true,
imports: [MatTimepickerInput, MatTimepicker],
})
class TimepickerInputHarnessTest {
readonly value = signal<Date | null>(null);
readonly disabled = signal(false);
readonly required = signal(false);
changeCount = 0;
}
Loading

0 comments on commit 9546fe7

Please sign in to comment.