Skip to content

Commit f69cc73

Browse files
committed
refactor(material/menu): add test harness for context menu
Sets up a test harness for the context menu.
1 parent 564e7ad commit f69cc73

File tree

6 files changed

+246
-15
lines changed

6 files changed

+246
-15
lines changed

goldens/material/menu/testing/index.api.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,24 @@ import { ContentContainerComponentHarness } from '@angular/cdk/testing';
1010
import { HarnessLoader } from '@angular/cdk/testing';
1111
import { HarnessPredicate } from '@angular/cdk/testing';
1212

13+
// @public
14+
export interface ContextMenuHarnessFilters extends BaseHarnessFilters {
15+
}
16+
17+
// @public
18+
export class MatContextMenuHarness extends ContentContainerComponentHarness<string> {
19+
clickItem(itemFilter: Omit<MenuItemHarnessFilters, 'ancestor'>, ...subItemFilters: Omit<MenuItemHarnessFilters, 'ancestor'>[]): Promise<void>;
20+
close(): Promise<void>;
21+
getItems(filters?: Omit<MenuItemHarnessFilters, 'ancestor'>): Promise<MatMenuItemHarness[]>;
22+
// (undocumented)
23+
protected getRootHarnessLoader(): Promise<HarnessLoader>;
24+
static hostSelector: string;
25+
isDisabled(): Promise<boolean>;
26+
isOpen(): Promise<boolean>;
27+
open(relativeX?: number, relativeY?: number): Promise<void>;
28+
static with<T extends MatContextMenuHarness>(this: ComponentHarnessConstructor<T>, options?: ContextMenuHarnessFilters): HarnessPredicate<T>;
29+
}
30+
1331
// @public
1432
export class MatMenuHarness extends ContentContainerComponentHarness<string> {
1533
blur(): Promise<void>;
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import {Component, signal} from '@angular/core';
2+
import {ComponentFixture, TestBed} from '@angular/core/testing';
3+
import {HarnessLoader} from '@angular/cdk/testing';
4+
import {TestbedHarnessEnvironment} from '@angular/cdk/testing/testbed';
5+
import {MatMenuModule} from '../module';
6+
import {MatContextMenuHarness} from './context-menu-harness';
7+
8+
describe('MatContextMenuHarness', () => {
9+
let fixture: ComponentFixture<MenuHarnessTest>;
10+
let loader: HarnessLoader;
11+
12+
beforeEach(() => {
13+
fixture = TestBed.createComponent(MenuHarnessTest);
14+
fixture.detectChanges();
15+
loader = TestbedHarnessEnvironment.loader(fixture);
16+
});
17+
18+
it('should load all context menu harnesses', async () => {
19+
const menues = await loader.getAllHarnesses(MatContextMenuHarness);
20+
expect(menues.length).toBe(1);
21+
});
22+
23+
it('should open and close', async () => {
24+
const menu = await loader.getHarness(MatContextMenuHarness);
25+
expect(await menu.isOpen()).toBe(false);
26+
await menu.open();
27+
expect(await menu.isOpen()).toBe(true);
28+
await menu.open();
29+
expect(await menu.isOpen()).toBe(true);
30+
await menu.close();
31+
expect(await menu.isOpen()).toBe(false);
32+
await menu.close();
33+
expect(await menu.isOpen()).toBe(false);
34+
});
35+
36+
it('should get all items', async () => {
37+
const menu = await loader.getHarness(MatContextMenuHarness);
38+
await menu.open();
39+
expect((await menu.getItems()).length).toBe(3);
40+
});
41+
42+
it('should get filtered items', async () => {
43+
const menu = await loader.getHarness(MatContextMenuHarness);
44+
await menu.open();
45+
const items = await menu.getItems({text: 'Copy'});
46+
expect(items.length).toBe(1);
47+
expect(await items[0].getText()).toBe('Copy');
48+
});
49+
50+
it('should get whether the trigger is disabled', async () => {
51+
const menu = await loader.getHarness(MatContextMenuHarness);
52+
expect(await menu.isDisabled()).toBe(false);
53+
fixture.componentInstance.disabled.set(true);
54+
expect(await menu.isDisabled()).toBe(true);
55+
});
56+
});
57+
58+
@Component({
59+
template: `
60+
<div
61+
class="area"
62+
[matContextMenuTriggerFor]="contextMenu"
63+
[matContextMenuTriggerDisabled]="disabled()"></div>
64+
65+
<mat-menu #contextMenu>
66+
<menu mat-menu-item>Cut</menu>
67+
<menu mat-menu-item>Copy</menu>
68+
<menu mat-menu-item>Paste</menu>
69+
</mat-menu>
70+
`,
71+
imports: [MatMenuModule],
72+
styles: `
73+
.area {
74+
width: 100px;
75+
height: 100px;
76+
outline: solid 1px;
77+
}
78+
`,
79+
})
80+
class MenuHarnessTest {
81+
disabled = signal(false);
82+
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
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.dev/license
7+
*/
8+
9+
import {
10+
ComponentHarnessConstructor,
11+
ContentContainerComponentHarness,
12+
HarnessLoader,
13+
HarnessPredicate,
14+
TestElement,
15+
} from '@angular/cdk/testing';
16+
import {ContextMenuHarnessFilters, MenuItemHarnessFilters} from './menu-harness-filters';
17+
import {clickItemImplementation, MatMenuItemHarness} from './menu-harness';
18+
19+
/** Harness for interacting with context menus in tests. */
20+
export class MatContextMenuHarness extends ContentContainerComponentHarness<string> {
21+
private _documentRootLocator = this.documentRootLocatorFactory();
22+
23+
/** The selector for the host element of a `MatContextMenu` instance. */
24+
static hostSelector = '.mat-context-menu-trigger';
25+
26+
/**
27+
* Gets a `HarnessPredicate` that can be used to search for a context menu with specific
28+
* attributes.
29+
* @param options Options for filtering which menu instances are considered a match.
30+
* @return a `HarnessPredicate` configured with the given options.
31+
*/
32+
static with<T extends MatContextMenuHarness>(
33+
this: ComponentHarnessConstructor<T>,
34+
options: ContextMenuHarnessFilters = {},
35+
): HarnessPredicate<T> {
36+
return new HarnessPredicate(this, options);
37+
}
38+
39+
/** Whether the menu is open. */
40+
async isOpen(): Promise<boolean> {
41+
return !!(await this._getMenuPanel());
42+
}
43+
44+
/**
45+
* Opens the menu.
46+
* @param relativeX X coordinate, relative to the element, to dispatch the opening click at.
47+
* @param relativeY Y coordinate, relative to the element, to dispatch the opening click at.
48+
*/
49+
async open(relativeX = 0, relativeY = 0): Promise<void> {
50+
if (!(await this.isOpen())) {
51+
return (await this.host()).rightClick(relativeX, relativeY);
52+
}
53+
}
54+
55+
/** Closes the menu. */
56+
async close(): Promise<void> {
57+
const panel = await this._getMenuPanel();
58+
if (panel) {
59+
return panel.click();
60+
}
61+
}
62+
63+
/** Gets whether the context menu trigger is disabled. */
64+
async isDisabled(): Promise<boolean> {
65+
const host = await this.host();
66+
return host.hasClass('mat-context-menu-trigger-disabled');
67+
}
68+
69+
/**
70+
* Gets a list of `MatMenuItemHarness` representing the items in the menu.
71+
* @param filters Optionally filters which menu items are included.
72+
*/
73+
async getItems(
74+
filters?: Omit<MenuItemHarnessFilters, 'ancestor'>,
75+
): Promise<MatMenuItemHarness[]> {
76+
const panelId = await this._getPanelId();
77+
if (panelId) {
78+
return this._documentRootLocator.locatorForAll(
79+
MatMenuItemHarness.with({
80+
...(filters || {}),
81+
ancestor: `#${panelId}`,
82+
} as MenuItemHarnessFilters),
83+
)();
84+
}
85+
return [];
86+
}
87+
88+
/**
89+
* Clicks an item in the menu, and optionally continues clicking items in subsequent sub-menus.
90+
* @param itemFilter A filter used to represent which item in the menu should be clicked. The
91+
* first matching menu item will be clicked.
92+
* @param subItemFilters A list of filters representing the items to click in any subsequent
93+
* sub-menus. The first item in the sub-menu matching the corresponding filter in
94+
* `subItemFilters` will be clicked.
95+
*/
96+
async clickItem(
97+
itemFilter: Omit<MenuItemHarnessFilters, 'ancestor'>,
98+
...subItemFilters: Omit<MenuItemHarnessFilters, 'ancestor'>[]
99+
): Promise<void> {
100+
await this.open();
101+
return clickItemImplementation(await this.getItems(itemFilter), itemFilter, subItemFilters);
102+
}
103+
104+
protected override async getRootHarnessLoader(): Promise<HarnessLoader> {
105+
const panelId = await this._getPanelId();
106+
return this.documentRootLocatorFactory().harnessLoaderFor(`#${panelId}`);
107+
}
108+
109+
/** Gets the menu panel associated with this menu. */
110+
private async _getMenuPanel(): Promise<TestElement | null> {
111+
const panelId = await this._getPanelId();
112+
return panelId ? this._documentRootLocator.locatorForOptional(`#${panelId}`)() : null;
113+
}
114+
115+
/** Gets the id of the menu panel associated with this menu. */
116+
private async _getPanelId(): Promise<string | null> {
117+
const panelId = await (await this.host()).getAttribute('aria-controls');
118+
return panelId || null;
119+
}
120+
}

src/material/menu/testing/menu-harness-filters.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,6 @@ export interface MenuItemHarnessFilters extends BaseHarnessFilters {
2121
/** Only find instances that have a sub-menu. */
2222
hasSubmenu?: boolean;
2323
}
24+
25+
/** A set of criteria that can be used to filter a list of `MatContextMenuHarness` instances. */
26+
export interface ContextMenuHarnessFilters extends BaseHarnessFilters {}

src/material/menu/testing/menu-harness.ts

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -117,20 +117,7 @@ export class MatMenuHarness extends ContentContainerComponentHarness<string> {
117117
...subItemFilters: Omit<MenuItemHarnessFilters, 'ancestor'>[]
118118
): Promise<void> {
119119
await this.open();
120-
const items = await this.getItems(itemFilter);
121-
if (!items.length) {
122-
throw Error(`Could not find item matching ${JSON.stringify(itemFilter)}`);
123-
}
124-
125-
if (!subItemFilters.length) {
126-
return await items[0].click();
127-
}
128-
129-
const menu = await items[0].getSubmenu();
130-
if (!menu) {
131-
throw Error(`Item matching ${JSON.stringify(itemFilter)} does not have a submenu`);
132-
}
133-
return menu.clickItem(...(subItemFilters as [Omit<MenuItemHarnessFilters, 'ancestor'>]));
120+
return clickItemImplementation(await this.getItems(itemFilter), itemFilter, subItemFilters);
134121
}
135122

136123
protected override async getRootHarnessLoader(): Promise<HarnessLoader> {
@@ -219,3 +206,23 @@ export class MatMenuItemHarness extends ContentContainerComponentHarness<string>
219206
return null;
220207
}
221208
}
209+
210+
export async function clickItemImplementation(
211+
items: MatMenuItemHarness[],
212+
itemFilter: Omit<MenuItemHarnessFilters, 'ancestor'>,
213+
subItemFilters: Omit<MenuItemHarnessFilters, 'ancestor'>[],
214+
): Promise<void> {
215+
if (!items.length) {
216+
throw Error(`Could not find item matching ${JSON.stringify(itemFilter)}`);
217+
}
218+
219+
if (!subItemFilters.length) {
220+
return await items[0].click();
221+
}
222+
223+
const menu = await items[0].getSubmenu();
224+
if (!menu) {
225+
throw Error(`Item matching ${JSON.stringify(itemFilter)} does not have a submenu`);
226+
}
227+
return menu.clickItem(...(subItemFilters as [Omit<MenuItemHarnessFilters, 'ancestor'>]));
228+
}

src/material/menu/testing/public-api.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,6 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
export * from './menu-harness';
9+
export {MatMenuHarness, MatMenuItemHarness} from './menu-harness';
10+
export {MatContextMenuHarness} from './context-menu-harness';
1011
export * from './menu-harness-filters';

0 commit comments

Comments
 (0)