Skip to content

Commit 90d6b70

Browse files
authored
feat(datepicker/testing): add test harnesses for the datepicker module (#20219)
* Sets up test harnesses for the components in the datepicker module. Includes datepicker input, datepicker toggle, calendar, calendar cell, date range input, date range input start/end sub-inputs. * Fixes an issue in `mat-calendar` that showed up while adding unit tests for the harnesses. The standalone calendar wasn't picking up changes to the comparison range after init. * Expands the dialog `backdropClass` signature to allow `string[]`. This was necessary for the harness so that we can tie a harness to a particular backdrop.
1 parent 5453d91 commit 90d6b70

30 files changed

+1845
-7
lines changed

src/material/config.bzl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ entryPoints = [
1818
"core",
1919
"core/testing",
2020
"datepicker",
21+
"datepicker/testing",
2122
"dialog",
2223
"dialog/testing",
2324
"divider",

src/material/datepicker/date-range-input-parts.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ const _MatDateRangeInputBase:
179179
@Directive({
180180
selector: 'input[matStartDate]',
181181
host: {
182-
'class': 'mat-date-range-input-inner',
182+
'class': 'mat-start-date mat-date-range-input-inner',
183183
'[disabled]': 'disabled',
184184
'(input)': '_onInput($event.target.value)',
185185
'(change)': '_onChange()',
@@ -265,7 +265,7 @@ export class MatStartDate<D> extends _MatDateRangeInputBase<D> implements CanUpd
265265
@Directive({
266266
selector: 'input[matEndDate]',
267267
host: {
268-
'class': 'mat-date-range-input-inner',
268+
'class': 'mat-end-date mat-date-range-input-inner',
269269
'[disabled]': 'disabled',
270270
'(input)': '_onInput($event.target.value)',
271271
'(change)': '_onChange()',

src/material/datepicker/date-range-input.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,14 @@ let nextUniqueId = 0;
4848
host: {
4949
'class': 'mat-date-range-input',
5050
'[class.mat-date-range-input-hide-placeholders]': '_shouldHidePlaceholders()',
51+
'[class.mat-date-range-input-required]': 'required',
5152
'[attr.id]': 'null',
5253
'role': 'group',
5354
'[attr.aria-labelledby]': '_getAriaLabelledby()',
5455
'[attr.aria-describedby]': '_ariaDescribedBy',
56+
// Used by the test harness to tie this input to its calendar. We can't depend on
57+
// `aria-owns` for this, because it's only defined while the calendar is open.
58+
'[attr.data-mat-calendar]': 'rangePicker ? rangePicker.id : null',
5559
},
5660
changeDetection: ChangeDetectionStrategy.OnPush,
5761
encapsulation: ViewEncapsulation.None,

src/material/datepicker/datepicker-base.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,9 @@ export abstract class MatDatepickerBase<C extends MatDatepickerControl<D>, S,
366366
/** The element that was focused before the datepicker was opened. */
367367
private _focusedElementBeforeOpen: HTMLElement | null = null;
368368

369+
/** Unique class that will be added to the backdrop so that the test harnesses can look it up. */
370+
private _backdropHarnessClass = `${this.id}-backdrop`;
371+
369372
/** The input element this datepicker is associated with. */
370373
_datepickerInput: C;
371374

@@ -516,6 +519,7 @@ export abstract class MatDatepickerBase<C extends MatDatepickerControl<D>, S,
516519
// datepicker dialog behaves consistently even if the user changed the defaults.
517520
hasBackdrop: true,
518521
disableClose: false,
522+
backdropClass: ['cdk-overlay-dark-backdrop', this._backdropHarnessClass],
519523
width: '',
520524
height: '',
521525
minWidth: '',
@@ -572,7 +576,7 @@ export abstract class MatDatepickerBase<C extends MatDatepickerControl<D>, S,
572576
const overlayConfig = new OverlayConfig({
573577
positionStrategy: this._setConnectedPositions(positionStrategy),
574578
hasBackdrop: true,
575-
backdropClass: 'mat-overlay-transparent-backdrop',
579+
backdropClass: ['mat-overlay-transparent-backdrop', this._backdropHarnessClass],
576580
direction: this._dir,
577581
scrollStrategy: this._scrollStrategy(),
578582
panelClass: 'mat-datepicker-popup',

src/material/datepicker/datepicker-input.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,14 @@ export const MAT_DATEPICKER_VALIDATORS: any = {
5555
{provide: MAT_INPUT_VALUE_ACCESSOR, useExisting: MatDatepickerInput},
5656
],
5757
host: {
58+
'class': 'mat-datepicker-input',
5859
'[attr.aria-haspopup]': '_datepicker ? "dialog" : null',
5960
'[attr.aria-owns]': '(_datepicker?.opened && _datepicker.id) || null',
6061
'[attr.min]': 'min ? _dateAdapter.toIso8601(min) : null',
6162
'[attr.max]': 'max ? _dateAdapter.toIso8601(max) : null',
63+
// Used by the test harness to tie this input to its calendar. We can't depend on
64+
// `aria-owns` for this, because it's only defined while the calendar is open.
65+
'[attr.data-mat-calendar]': '_datepicker ? _datepicker.id : null',
6266
'[disabled]': 'disabled',
6367
'(input)': '_onInput($event.target.value)',
6468
'(change)': '_onChange()',

src/material/datepicker/datepicker-toggle.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ export class MatDatepickerToggleIcon {}
4747
'[class.mat-datepicker-toggle-active]': 'datepicker && datepicker.opened',
4848
'[class.mat-accent]': 'datepicker && datepicker.color === "accent"',
4949
'[class.mat-warn]': 'datepicker && datepicker.color === "warn"',
50+
// Used by the test harness to tie this toggle to its datepicker.
51+
'[attr.data-mat-calendar]': 'datepicker ? datepicker.id : null',
5052
'(focus)': '_button.focus()',
5153
},
5254
exportAs: 'matDatepickerToggle',

src/material/datepicker/month-view.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ import {
3232
ViewEncapsulation,
3333
ViewChild,
3434
OnDestroy,
35+
SimpleChanges,
36+
OnChanges,
3537
} from '@angular/core';
3638
import {DateAdapter, MAT_DATE_FORMATS, MatDateFormats} from '@angular/material/core';
3739
import {Directionality} from '@angular/cdk/bidi';
@@ -65,7 +67,7 @@ const DAYS_PER_WEEK = 7;
6567
encapsulation: ViewEncapsulation.None,
6668
changeDetection: ChangeDetectionStrategy.OnPush
6769
})
68-
export class MatMonthView<D> implements AfterContentInit, OnDestroy {
70+
export class MatMonthView<D> implements AfterContentInit, OnChanges, OnDestroy {
6971
private _rerenderSubscription = Subscription.EMPTY;
7072

7173
/**
@@ -199,6 +201,14 @@ export class MatMonthView<D> implements AfterContentInit, OnDestroy {
199201
.subscribe(() => this._init());
200202
}
201203

204+
ngOnChanges(changes: SimpleChanges) {
205+
const comparisonChange = changes['comparisonStart'] || changes['comparisonEnd'];
206+
207+
if (comparisonChange && !comparisonChange.firstChange) {
208+
this._setRanges(this.selected);
209+
}
210+
}
211+
202212
ngOnDestroy() {
203213
this._rerenderSubscription.unsubscribe();
204214
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
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/datepicker/testing",
12+
deps = [
13+
"//src/cdk/coercion",
14+
"//src/cdk/testing",
15+
],
16+
)
17+
18+
filegroup(
19+
name = "source-files",
20+
srcs = glob(["**/*.ts"]),
21+
)
22+
23+
ng_test_library(
24+
name = "harness_tests_lib",
25+
srcs = [
26+
"calendar-harness-shared.spec.ts",
27+
"date-range-input-harness-shared.spec.ts",
28+
"datepicker-input-harness-shared.spec.ts",
29+
"datepicker-toggle-harness-shared.spec.ts",
30+
],
31+
deps = [
32+
":testing",
33+
"//src/cdk/testing",
34+
"//src/cdk/testing/testbed",
35+
"//src/material/core",
36+
"//src/material/datepicker",
37+
"@npm//@angular/forms",
38+
"@npm//@angular/platform-browser",
39+
],
40+
)
41+
42+
ng_test_library(
43+
name = "unit_tests_lib",
44+
srcs = glob(
45+
["**/*.spec.ts"],
46+
exclude = [
47+
"date-range-input-harness-shared.spec.ts",
48+
"datepicker-input-harness-shared.spec.ts",
49+
"datepicker-toggle-harness-shared.spec.ts",
50+
"calendar-harness-shared.spec.ts",
51+
],
52+
),
53+
deps = [
54+
":harness_tests_lib",
55+
":testing",
56+
"//src/material/datepicker",
57+
],
58+
)
59+
60+
ng_web_test_suite(
61+
name = "unit_tests",
62+
deps = [":unit_tests_lib"],
63+
)
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
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 {HarnessPredicate, ComponentHarness} from '@angular/cdk/testing';
10+
import {CalendarCellHarnessFilters} from './datepicker-harness-filters';
11+
12+
/** Harness for interacting with a standard Material calendar cell in tests. */
13+
export class MatCalendarCellHarness extends ComponentHarness {
14+
static hostSelector = '.mat-calendar-body-cell';
15+
16+
/** Reference to the inner content element inside the cell. */
17+
private _content = this.locatorFor('.mat-calendar-body-cell-content');
18+
19+
/**
20+
* Gets a `HarnessPredicate` that can be used to search for a `MatCalendarCellHarness`
21+
* that meets certain criteria.
22+
* @param options Options for filtering which cell instances are considered a match.
23+
* @return a `HarnessPredicate` configured with the given options.
24+
*/
25+
static with(options: CalendarCellHarnessFilters = {}): HarnessPredicate<MatCalendarCellHarness> {
26+
return new HarnessPredicate(MatCalendarCellHarness, options)
27+
.addOption('text', options.text, (harness, text) => {
28+
return HarnessPredicate.stringMatches(harness.getText(), text);
29+
})
30+
.addOption('selected', options.selected, async (harness, selected) => {
31+
return (await harness.isSelected()) === selected;
32+
})
33+
.addOption('active', options.active, async (harness, active) => {
34+
return (await harness.isActive()) === active;
35+
})
36+
.addOption('disabled', options.disabled, async (harness, disabled) => {
37+
return (await harness.isDisabled()) === disabled;
38+
})
39+
.addOption('today', options.today, async (harness, today) => {
40+
return (await harness.isToday()) === today;
41+
})
42+
.addOption('inRange', options.inRange, async (harness, inRange) => {
43+
return (await harness.isInRange()) === inRange;
44+
})
45+
.addOption('inComparisonRange', options.inComparisonRange,
46+
async (harness, inComparisonRange) => {
47+
return (await harness.isInComparisonRange()) === inComparisonRange;
48+
})
49+
.addOption('inPreviewRange', options.inPreviewRange, async (harness, inPreviewRange) => {
50+
return (await harness.isInPreviewRange()) === inPreviewRange;
51+
});
52+
}
53+
54+
/** Gets the text of the calendar cell. */
55+
async getText(): Promise<string> {
56+
return (await this._content()).text();
57+
}
58+
59+
/** Gets the aria-label of the calendar cell. */
60+
async getAriaLabel(): Promise<string> {
61+
// We're guaranteed for the `aria-label` to be defined
62+
// since this is a private element that we control.
63+
return (await this.host()).getAttribute('aria-label') as Promise<string>;
64+
}
65+
66+
/** Whether the cell is selected. */
67+
async isSelected(): Promise<boolean> {
68+
const host = await this.host();
69+
return (await host.getAttribute('aria-selected')) === 'true';
70+
}
71+
72+
/** Whether the cell is disabled. */
73+
async isDisabled(): Promise<boolean> {
74+
return this._hasState('disabled');
75+
}
76+
77+
/** Whether the cell is currently activated using keyboard navigation. */
78+
async isActive(): Promise<boolean> {
79+
return this._hasState('active');
80+
}
81+
82+
/** Whether the cell represents today's date. */
83+
async isToday(): Promise<boolean> {
84+
return (await this._content()).hasClass('mat-calendar-body-today');
85+
}
86+
87+
/** Selects the calendar cell. Won't do anything if the cell is disabled. */
88+
async select(): Promise<void> {
89+
return (await this.host()).click();
90+
}
91+
92+
/** Hovers over the calendar cell. */
93+
async hover(): Promise<void> {
94+
return (await this.host()).hover();
95+
}
96+
97+
/** Moves the mouse away from the calendar cell. */
98+
async mouseAway(): Promise<void> {
99+
return (await this.host()).mouseAway();
100+
}
101+
102+
/** Focuses the calendar cell. */
103+
async focus(): Promise<void> {
104+
return (await this.host()).focus();
105+
}
106+
107+
/** Removes focus from the calendar cell. */
108+
async blur(): Promise<void> {
109+
return (await this.host()).blur();
110+
}
111+
112+
/** Whether the cell is the start of the main range. */
113+
async isRangeStart(): Promise<boolean> {
114+
return this._hasState('range-start');
115+
}
116+
117+
/** Whether the cell is the end of the main range. */
118+
async isRangeEnd(): Promise<boolean> {
119+
return this._hasState('range-end');
120+
}
121+
122+
/** Whether the cell is part of the main range. */
123+
async isInRange(): Promise<boolean> {
124+
return this._hasState('in-range');
125+
}
126+
127+
/** Whether the cell is the start of the comparison range. */
128+
async isComparisonRangeStart(): Promise<boolean> {
129+
return this._hasState('comparison-start');
130+
}
131+
132+
/** Whether the cell is the end of the comparison range. */
133+
async isComparisonRangeEnd(): Promise<boolean> {
134+
return this._hasState('comparison-end');
135+
}
136+
137+
/** Whether the cell is inside of the comparison range. */
138+
async isInComparisonRange(): Promise<boolean> {
139+
return this._hasState('in-comparison-range');
140+
}
141+
142+
/** Whether the cell is the start of the preview range. */
143+
async isPreviewRangeStart(): Promise<boolean> {
144+
return this._hasState('preview-start');
145+
}
146+
147+
/** Whether the cell is the end of the preview range. */
148+
async isPreviewRangeEnd(): Promise<boolean> {
149+
return this._hasState('preview-end');
150+
}
151+
152+
/** Whether the cell is inside of the preview range. */
153+
async isInPreviewRange(): Promise<boolean> {
154+
return this._hasState('in-preview');
155+
}
156+
157+
/** Returns whether the cell has a particular CSS class-based state. */
158+
private async _hasState(name: string): Promise<boolean> {
159+
return (await this.host()).hasClass(`mat-calendar-body-${name}`);
160+
}
161+
}

0 commit comments

Comments
 (0)