Skip to content

refactor(material-experimental/mdc-menu): de-duplicate test harness logic #21483

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 1 commit into from
Jan 25, 2021
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
1 change: 0 additions & 1 deletion src/material-experimental/mdc-menu/testing/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ ts_library(
),
module_name = "@angular/material-experimental/mdc-menu/testing",
deps = [
"//src/cdk/coercion",
"//src/cdk/testing",
"//src/material/menu/testing",
],
Expand Down
177 changes: 12 additions & 165 deletions src/material-experimental/mdc-menu/testing/menu-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,25 +6,20 @@
* found in the LICENSE file at https://angular.io/license
*/

import {HarnessPredicate} from '@angular/cdk/testing';
import {
ComponentHarness,
ContentContainerComponentHarness,
HarnessLoader,
HarnessPredicate,
TestElement,
TestKey,
} from '@angular/cdk/testing';
import {coerceBooleanProperty} from '@angular/cdk/coercion';
import {MenuHarnessFilters, MenuItemHarnessFilters} from '@angular/material/menu/testing';
MenuHarnessFilters,
MenuItemHarnessFilters,
_MatMenuItemHarnessBase,
_MatMenuHarnessBase
} from '@angular/material/menu/testing';

/** Harness for interacting with an MDC-based mat-menu in tests. */
export class MatMenuHarness extends ContentContainerComponentHarness<string> {
export class MatMenuHarness extends _MatMenuHarnessBase<
typeof MatMenuItemHarness, MatMenuItemHarness, MenuItemHarnessFilters> {
/** The selector for the host element of a `MatMenu` instance. */
static hostSelector = '.mat-menu-trigger';

private _documentRootLocator = this.documentRootLocatorFactory();

// TODO: potentially extend MatButtonHarness
protected _itemClass = MatMenuItemHarness;

/**
* Gets a `HarnessPredicate` that can be used to search for a `MatMenuHarness` that meets certain
Expand All @@ -37,118 +32,14 @@ export class MatMenuHarness extends ContentContainerComponentHarness<string> {
.addOption('triggerText', options.triggerText,
(harness, text) => HarnessPredicate.stringMatches(harness.getTriggerText(), text));
}

/** Whether the menu is disabled. */
async isDisabled(): Promise<boolean> {
const disabled = (await this.host()).getAttribute('disabled');
return coerceBooleanProperty(await disabled);
}

/** Whether the menu is open. */
async isOpen(): Promise<boolean> {
return !!(await this._getMenuPanel());
}

/** Gets the text of the menu's trigger element. */
async getTriggerText(): Promise<string> {
return (await this.host()).text();
}

/** Focuses the menu. */
async focus(): Promise<void> {
return (await this.host()).focus();
}

/** Blurs the menu. */
async blur(): Promise<void> {
return (await this.host()).blur();
}

/** Whether the menu is focused. */
async isFocused(): Promise<boolean> {
return (await this.host()).isFocused();
}

/** Opens the menu. */
async open(): Promise<void> {
if (!await this.isOpen()) {
return (await this.host()).click();
}
}

/** Closes the menu. */
async close(): Promise<void> {
const panel = await this._getMenuPanel();
if (panel) {
return panel.sendKeys(TestKey.ESCAPE);
}
}

/**
* Gets a list of `MatMenuItemHarness` representing the items in the menu.
* @param filters Optionally filters which menu items are included.
*/
async getItems(filters: Omit<MenuItemHarnessFilters, 'ancestor'> = {}):
Promise<MatMenuItemHarness[]> {
const panelId = await this._getPanelId();
if (panelId) {
return this._documentRootLocator.locatorForAll(
MatMenuItemHarness.with({...filters, ancestor: `#${panelId}`}))();
}
return [];
}

/**
* Clicks an item in the menu, and optionally continues clicking items in subsequent sub-menus.
* @param itemFilter A filter used to represent which item in the menu should be clicked. The
* first matching menu item will be clicked.
* @param subItemFilters A list of filters representing the items to click in any subsequent
* sub-menus. The first item in the sub-menu matching the corresponding filter in
* `subItemFilters` will be clicked.
*/
async clickItem(
itemFilter: Omit<MenuItemHarnessFilters, 'ancestor'>,
...subItemFilters: Omit<MenuItemHarnessFilters, 'ancestor'>[]): Promise<void> {
await this.open();
const items = await this.getItems(itemFilter);
if (!items.length) {
throw Error(`Could not find item matching ${JSON.stringify(itemFilter)}`);
}

if (!subItemFilters.length) {
return await items[0].click();
}

const menu = await items[0].getSubmenu();
if (!menu) {
throw Error(`Item matching ${JSON.stringify(itemFilter)} does not have a submenu`);
}
return menu.clickItem(...subItemFilters as [Omit<MenuItemHarnessFilters, 'ancestor'>]);
}

protected async getRootHarnessLoader(): Promise<HarnessLoader> {
const panelId = await this._getPanelId();
return this.documentRootLocatorFactory().harnessLoaderFor(`#${panelId}`);
}

/** Gets the menu panel associated with this menu. */
private async _getMenuPanel(): Promise<TestElement | null> {
const panelId = await this._getPanelId();
return panelId ? this._documentRootLocator.locatorForOptional(`#${panelId}`)() : null;
}

/** Gets the id of the menu panel associated with this menu. */
private async _getPanelId(): Promise<string | null> {
const panelId = await (await this.host()).getAttribute('aria-controls');
return panelId || null;
}
}


/** Harness for interacting with an MDC-based mat-menu-item in tests. */
export class MatMenuItemHarness extends ComponentHarness {
export class MatMenuItemHarness extends
_MatMenuItemHarnessBase<typeof MatMenuHarness, MatMenuHarness> {
/** The selector for the host element of a `MatMenuItem` instance. */
static hostSelector = '.mat-mdc-menu-item';
protected _menuClass = MatMenuHarness;

/**
* Gets a `HarnessPredicate` that can be used to search for a `MatMenuItemHarness` that meets
Expand All @@ -163,48 +54,4 @@ export class MatMenuItemHarness extends ComponentHarness {
.addOption('hasSubmenu', options.hasSubmenu,
async (harness, hasSubmenu) => (await harness.hasSubmenu()) === hasSubmenu);
}

/** Whether the menu is disabled. */
async isDisabled(): Promise<boolean> {
const disabled = (await this.host()).getAttribute('disabled');
return coerceBooleanProperty(await disabled);
}

/** Gets the text of the menu item. */
async getText(): Promise<string> {
return (await this.host()).text();
}

/** Focuses the menu item. */
async focus(): Promise<void> {
return (await this.host()).focus();
}

/** Blurs the menu item. */
async blur(): Promise<void> {
return (await this.host()).blur();
}

/** Whether the menu item is focused. */
async isFocused(): Promise<boolean> {
return (await this.host()).isFocused();
}

/** Clicks the menu item. */
async click(): Promise<void> {
return (await this.host()).click();
}

/** Whether this item has a submenu. */
async hasSubmenu(): Promise<boolean> {
return (await this.host()).matchesSelector(MatMenuHarness.hostSelector);
}

/** Gets the submenu associated with this menu item, or null if none. */
async getSubmenu(): Promise<MatMenuHarness | null> {
if (await this.hasSubmenu()) {
return new MatMenuHarness(this.locatorFactory);
}
return null;
}
}
117 changes: 71 additions & 46 deletions src/material/menu/testing/menu-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
*/

import {
BaseHarnessFilters,
ComponentHarness,
ComponentHarnessConstructor,
ContentContainerComponentHarness,
HarnessLoader,
HarnessPredicate,
Expand All @@ -16,27 +19,19 @@ import {
import {coerceBooleanProperty} from '@angular/cdk/coercion';
import {MenuHarnessFilters, MenuItemHarnessFilters} from './menu-harness-filters';

/** Harness for interacting with a standard mat-menu in tests. */
export class MatMenuHarness extends ContentContainerComponentHarness<string> {
/** The selector for the host element of a `MatMenu` instance. */
static hostSelector = '.mat-menu-trigger';

export abstract class _MatMenuHarnessBase<
ItemType extends (ComponentHarnessConstructor<Item> & {
with: (options?: ItemFilters) => HarnessPredicate<Item>}),
Item extends ComponentHarness & {
click(): Promise<void>,
getSubmenu(): Promise<_MatMenuHarnessBase<ItemType, Item, ItemFilters> | null>},
ItemFilters extends BaseHarnessFilters
> extends ContentContainerComponentHarness<string> {
private _documentRootLocator = this.documentRootLocatorFactory();
protected abstract _itemClass: ItemType;

// TODO: potentially extend MatButtonHarness

/**
* Gets a `HarnessPredicate` that can be used to search for a `MatMenuHarness` that meets certain
* criteria.
* @param options Options for filtering which menu instances are considered a match.
* @return a `HarnessPredicate` configured with the given options.
*/
static with(options: MenuHarnessFilters = {}): HarnessPredicate<MatMenuHarness> {
return new HarnessPredicate(MatMenuHarness, options)
.addOption('triggerText', options.triggerText,
(harness, text) => HarnessPredicate.stringMatches(harness.getTriggerText(), text));
}

/** Whether the menu is disabled. */
async isDisabled(): Promise<boolean> {
const disabled = (await this.host()).getAttribute('disabled');
Expand Down Expand Up @@ -87,12 +82,13 @@ export class MatMenuHarness extends ContentContainerComponentHarness<string> {
* Gets a list of `MatMenuItemHarness` representing the items in the menu.
* @param filters Optionally filters which menu items are included.
*/
async getItems(filters: Omit<MenuItemHarnessFilters, 'ancestor'> = {}):
Promise<MatMenuItemHarness[]> {
async getItems(filters?: Omit<ItemFilters, 'ancestor'>): Promise<Item[]> {
const panelId = await this._getPanelId();
if (panelId) {
return this._documentRootLocator.locatorForAll(
MatMenuItemHarness.with({...filters, ancestor: `#${panelId}`}))();
return this._documentRootLocator.locatorForAll(this._itemClass.with({
...(filters || {}),
ancestor: `#${panelId}`
} as ItemFilters))();
}
return [];
}
Expand All @@ -106,8 +102,8 @@ export class MatMenuHarness extends ContentContainerComponentHarness<string> {
* `subItemFilters` will be clicked.
*/
async clickItem(
itemFilter: Omit<MenuItemHarnessFilters, 'ancestor'>,
...subItemFilters: Omit<MenuItemHarnessFilters, 'ancestor'>[]): Promise<void> {
itemFilter: Omit<ItemFilters, 'ancestor'>,
...subItemFilters: Omit<ItemFilters, 'ancestor'>[]): Promise<void> {
await this.open();
const items = await this.getItems(itemFilter);
if (!items.length) {
Expand All @@ -122,7 +118,7 @@ export class MatMenuHarness extends ContentContainerComponentHarness<string> {
if (!menu) {
throw Error(`Item matching ${JSON.stringify(itemFilter)} does not have a submenu`);
}
return menu.clickItem(...subItemFilters as [Omit<MenuItemHarnessFilters, 'ancestor'>]);
return menu.clickItem(...subItemFilters as [Omit<ItemFilters, 'ancestor'>]);
}

protected async getRootHarnessLoader(): Promise<HarnessLoader> {
Expand All @@ -143,25 +139,11 @@ export class MatMenuHarness extends ContentContainerComponentHarness<string> {
}
}


/** Harness for interacting with a standard mat-menu-item in tests. */
export class MatMenuItemHarness extends ContentContainerComponentHarness<string> {
/** The selector for the host element of a `MatMenuItem` instance. */
static hostSelector = '.mat-menu-item';

/**
* Gets a `HarnessPredicate` that can be used to search for a `MatMenuItemHarness` that meets
* certain criteria.
* @param options Options for filtering which menu item instances are considered a match.
* @return a `HarnessPredicate` configured with the given options.
*/
static with(options: MenuItemHarnessFilters = {}): HarnessPredicate<MatMenuItemHarness> {
return new HarnessPredicate(MatMenuItemHarness, options)
.addOption('text', options.text,
(harness, text) => HarnessPredicate.stringMatches(harness.getText(), text))
.addOption('hasSubmenu', options.hasSubmenu,
async (harness, hasSubmenu) => (await harness.hasSubmenu()) === hasSubmenu);
}
export abstract class _MatMenuItemHarnessBase<
MenuType extends ComponentHarnessConstructor<Menu>,
Menu extends ComponentHarness,
> extends ContentContainerComponentHarness<string> {
protected abstract _menuClass: MenuType;

/** Whether the menu is disabled. */
async isDisabled(): Promise<boolean> {
Expand Down Expand Up @@ -196,14 +178,57 @@ export class MatMenuItemHarness extends ContentContainerComponentHarness<string>

/** Whether this item has a submenu. */
async hasSubmenu(): Promise<boolean> {
return (await this.host()).matchesSelector(MatMenuHarness.hostSelector);
return (await this.host()).matchesSelector(this._menuClass.hostSelector);
}

/** Gets the submenu associated with this menu item, or null if none. */
async getSubmenu(): Promise<MatMenuHarness | null> {
async getSubmenu(): Promise<Menu | null> {
if (await this.hasSubmenu()) {
return new MatMenuHarness(this.locatorFactory);
return new this._menuClass(this.locatorFactory);
}
return null;
}
}


/** Harness for interacting with a standard mat-menu in tests. */
export class MatMenuHarness extends _MatMenuHarnessBase<
typeof MatMenuItemHarness, MatMenuItemHarness, MenuItemHarnessFilters> {
/** The selector for the host element of a `MatMenu` instance. */
static hostSelector = '.mat-menu-trigger';
protected _itemClass = MatMenuItemHarness;

/**
* Gets a `HarnessPredicate` that can be used to search for a `MatMenuHarness` that meets certain
* criteria.
* @param options Options for filtering which menu instances are considered a match.
* @return a `HarnessPredicate` configured with the given options.
*/
static with(options: MenuHarnessFilters = {}): HarnessPredicate<MatMenuHarness> {
return new HarnessPredicate(MatMenuHarness, options)
.addOption('triggerText', options.triggerText,
(harness, text) => HarnessPredicate.stringMatches(harness.getTriggerText(), text));
}
}

/** Harness for interacting with a standard mat-menu-item in tests. */
export class MatMenuItemHarness extends
_MatMenuItemHarnessBase<typeof MatMenuHarness, MatMenuHarness> {
/** The selector for the host element of a `MatMenuItem` instance. */
static hostSelector = '.mat-menu-item';
protected _menuClass = MatMenuHarness;

/**
* Gets a `HarnessPredicate` that can be used to search for a `MatMenuItemHarness` that meets
* certain criteria.
* @param options Options for filtering which menu item instances are considered a match.
* @return a `HarnessPredicate` configured with the given options.
*/
static with(options: MenuItemHarnessFilters = {}): HarnessPredicate<MatMenuItemHarness> {
return new HarnessPredicate(MatMenuItemHarness, options)
.addOption('text', options.text,
(harness, text) => HarnessPredicate.stringMatches(harness.getText(), text))
.addOption('hasSubmenu', options.hasSubmenu,
async (harness, hasSubmenu) => (await harness.hasSubmenu()) === hasSubmenu);
}
}
Loading