diff --git a/src/material-experimental/mdc-menu/testing/menu-harness.spec.ts b/src/material-experimental/mdc-menu/testing/menu-harness.spec.ts index c83ccfe5eddf..ab8f99887494 100644 --- a/src/material-experimental/mdc-menu/testing/menu-harness.spec.ts +++ b/src/material-experimental/mdc-menu/testing/menu-harness.spec.ts @@ -3,8 +3,5 @@ import {MatMenuModule} from '../index'; import {MatMenuHarness} from './menu-harness'; describe('MDC-based MatMenuHarness', () => { - it('TODO: re-enable after implementing missing methods', () => expect(true).toBe(true)); - if (false) { - runHarnessTests(MatMenuModule, MatMenuHarness as any); - } + runHarnessTests(MatMenuModule, MatMenuHarness as any); }); diff --git a/src/material-experimental/mdc-menu/testing/menu-harness.ts b/src/material-experimental/mdc-menu/testing/menu-harness.ts index 22fd3bdb6105..bdb562111990 100644 --- a/src/material-experimental/mdc-menu/testing/menu-harness.ts +++ b/src/material-experimental/mdc-menu/testing/menu-harness.ts @@ -6,24 +6,23 @@ * found in the LICENSE file at https://angular.io/license */ -import {ComponentHarness, HarnessPredicate} from '@angular/cdk/testing'; +import {ComponentHarness, HarnessPredicate, TestElement, TestKey} from '@angular/cdk/testing'; import {coerceBooleanProperty} from '@angular/cdk/coercion'; -import { - MenuHarnessFilters, - MenuItemHarnessFilters -} from '@angular/material/menu/testing'; +import {MenuHarnessFilters, MenuItemHarnessFilters} from '@angular/material/menu/testing'; -/** Harness for interacting with a MDC-based mat-menu in tests. */ +/** Harness for interacting with an MDC-based mat-menu in tests. */ export class MatMenuHarness extends ComponentHarness { + /** The selector for the host element of a `MatMenu` instance. */ static hostSelector = '.mat-menu-trigger'; + private _documentRootLocator = this.documentRootLocatorFactory(); + // TODO: potentially extend MatButtonHarness /** - * Gets a `HarnessPredicate` that can be used to search for a menu with specific attributes. - * @param options Options for narrowing the search: - * - `selector` finds a menu whose host element matches the given selector. - * - `label` finds a menu with specific label text. + * 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 { @@ -32,26 +31,28 @@ export class MatMenuHarness extends ComponentHarness { (harness, text) => HarnessPredicate.stringMatches(harness.getTriggerText(), text)); } - /** Gets a boolean promise indicating if the menu is disabled. */ + /** Whether the menu is disabled. */ async isDisabled(): Promise { const disabled = (await this.host()).getAttribute('disabled'); return coerceBooleanProperty(await disabled); } + /** Whether the menu is open. */ async isOpen(): Promise { - throw Error('not implemented'); + return !!(await this._getMenuPanel()); } + /** Gets the text of the menu's trigger element. */ async getTriggerText(): Promise { return (await this.host()).text(); } - /** Focuses the menu and returns a void promise that indicates when the action is complete. */ + /** Focuses the menu. */ async focus(): Promise { return (await this.host()).focus(); } - /** Blurs the menu and returns a void promise that indicates when the action is complete. */ + /** Blurs the menu. */ async blur(): Promise { return (await this.host()).blur(); } @@ -61,35 +62,86 @@ export class MatMenuHarness extends ComponentHarness { return (await this.host()).isFocused(); } + /** Opens the menu. */ async open(): Promise { - throw Error('not implemented'); + if (!await this.isOpen()) { + return (await this.host()).click(); + } } + /** Closes the menu. */ async close(): Promise { - throw Error('not implemented'); + 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 = {}): Promise { - throw Error('not implemented'); + const panelId = await this._getPanelId(); + if (panelId) { + return this._documentRootLocator.locatorForAll( + MatMenuItemHarness.with({...filters, ancestor: `#${panelId}`}))(); + } + return []; } - async clickItem(filter: Omit, - ...filters: Omit[]): Promise { - throw Error('not implemented'); + /** + * 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, + ...subItemFilters: Omit[]): Promise { + 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]); + } + + /** Gets the menu panel associated with this menu. */ + private async _getMenuPanel(): Promise { + 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 { + const panelId = await (await this.host()).getAttribute('aria-controls'); + return panelId || null; } } -/** Harness for interacting with a standard mat-menu in tests. */ +/** Harness for interacting with an MDC-based mat-menu-item in tests. */ export class MatMenuItemHarness extends ComponentHarness { - static hostSelector = '.mat-menu-item'; + /** The selector for the host element of a `MatMenuItem` instance. */ + static hostSelector = '.mat-mdc-menu-item'; /** - * Gets a `HarnessPredicate` that can be used to search for a menu with specific attributes. - * @param options Options for narrowing the search: - * - `selector` finds a menu item whose host element matches the given selector. - * - `label` finds a menu item with specific label text. + * 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 { @@ -100,24 +152,23 @@ export class MatMenuItemHarness extends ComponentHarness { async (harness, hasSubmenu) => (await harness.hasSubmenu()) === hasSubmenu); } - /** Gets a boolean promise indicating if the menu is disabled. */ + /** Whether the menu is disabled. */ async isDisabled(): Promise { const disabled = (await this.host()).getAttribute('disabled'); return coerceBooleanProperty(await disabled); } + /** Gets the text of the menu item. */ async getText(): Promise { return (await this.host()).text(); } - /** - * Focuses the menu item and returns a void promise that indicates when the action is complete. - */ + /** Focuses the menu item. */ async focus(): Promise { return (await this.host()).focus(); } - /** Blurs the menu item and returns a void promise that indicates when the action is complete. */ + /** Blurs the menu item. */ async blur(): Promise { return (await this.host()).blur(); } @@ -127,15 +178,21 @@ export class MatMenuItemHarness extends ComponentHarness { return (await this.host()).isFocused(); } + /** Clicks the menu item. */ async click(): Promise { - throw Error('not implemented'); + return (await this.host()).click(); } + /** Whether this item has a submenu. */ async hasSubmenu(): Promise { - throw Error('not implemented'); + return (await this.host()).matchesSelector(MatMenuHarness.hostSelector); } + /** Gets the submenu associated with this menu item, or null if none. */ async getSubmenu(): Promise { - throw Error('not implemented'); + if (await this.hasSubmenu()) { + return new MatMenuHarness(this.locatorFactory); + } + return null; } } diff --git a/src/material/menu/testing/shared.spec.ts b/src/material/menu/testing/shared.spec.ts index fe8e0e3a9abf..9f1abfec1e7f 100644 --- a/src/material/menu/testing/shared.spec.ts +++ b/src/material/menu/testing/shared.spec.ts @@ -123,7 +123,7 @@ export function runHarnessTests( }); it('should get submenus', async () => { - const menu1 = await loader.getHarness(MatMenuHarness.with({triggerText: 'Menu 1'})); + const menu1 = await loader.getHarness(menuHarness.with({triggerText: 'Menu 1'})); await menu1.open(); let submenus = await menu1.getItems({hasSubmenu: true}); @@ -147,25 +147,25 @@ export function runHarnessTests( }); it('should select item in top-level menu', async () => { - const menu1 = await loader.getHarness(MatMenuHarness.with({triggerText: 'Menu 1'})); + const menu1 = await loader.getHarness(menuHarness.with({triggerText: 'Menu 1'})); await menu1.clickItem({text: /Leaf/}); expect(fixture.componentInstance.lastClickedLeaf).toBe(1); }); it('should throw when item is not found', async () => { - const menu1 = await loader.getHarness(MatMenuHarness.with({triggerText: 'Menu 1'})); + const menu1 = await loader.getHarness(menuHarness.with({triggerText: 'Menu 1'})); await expectAsync(menu1.clickItem({text: 'Fake Item'})).toBeRejectedWithError( /Could not find item matching {"text":"Fake Item"}/); }); it('should select item in nested menu', async () => { - const menu1 = await loader.getHarness(MatMenuHarness.with({triggerText: 'Menu 1'})); + const menu1 = await loader.getHarness(menuHarness.with({triggerText: 'Menu 1'})); await menu1.clickItem({text: 'Menu 3'}, {text: 'Menu 4'}, {text: /Leaf/}); expect(fixture.componentInstance.lastClickedLeaf).toBe(3); }); it('should throw when intermediate item does not have submenu', async () => { - const menu1 = await loader.getHarness(MatMenuHarness.with({triggerText: 'Menu 1'})); + const menu1 = await loader.getHarness(menuHarness.with({triggerText: 'Menu 1'})); await expectAsync(menu1.clickItem({text: 'Leaf Item 1'}, {})).toBeRejectedWithError( /Item matching {"text":"Leaf Item 1"} does not have a submenu/); });