diff --git a/src/material/config.bzl b/src/material/config.bzl index 6de7312902b6..62339f8f27f0 100644 --- a/src/material/config.bzl +++ b/src/material/config.bzl @@ -65,6 +65,7 @@ entryPoints = [ "tooltip", "tooltip/testing", "tree", + "tree/testing", "form-field/testing", "form-field/testing/control", "input/testing", diff --git a/src/material/tree/testing/BUILD.bazel b/src/material/tree/testing/BUILD.bazel new file mode 100644 index 000000000000..f88c68d49b5d --- /dev/null +++ b/src/material/tree/testing/BUILD.bazel @@ -0,0 +1,51 @@ +load("//tools:defaults.bzl", "ng_test_library", "ng_web_test_suite", "ts_library") + +package(default_visibility = ["//visibility:public"]) + +ts_library( + name = "testing", + srcs = glob( + ["**/*.ts"], + exclude = ["**/*.spec.ts"], + ), + module_name = "@angular/material/tree/testing", + deps = [ + "//src/cdk/coercion", + "//src/cdk/testing", + ], +) + +filegroup( + name = "source-files", + srcs = glob(["**/*.ts"]), +) + +ng_test_library( + name = "harness_tests_lib", + srcs = ["shared.spec.ts"], + deps = [ + ":testing", + "//src/cdk/testing", + "//src/cdk/testing/testbed", + "//src/cdk/tree", + "//src/material/tree", + ], +) + +ng_test_library( + name = "unit_tests_lib", + srcs = glob( + ["**/*.spec.ts"], + exclude = ["shared.spec.ts"], + ), + deps = [ + ":harness_tests_lib", + ":testing", + "//src/material/tree", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [":unit_tests_lib"], +) diff --git a/src/material/tree/testing/index.ts b/src/material/tree/testing/index.ts new file mode 100644 index 000000000000..676ca90f1ffa --- /dev/null +++ b/src/material/tree/testing/index.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +export * from './public-api'; diff --git a/src/material/tree/testing/node-harness.ts b/src/material/tree/testing/node-harness.ts new file mode 100644 index 000000000000..c2565714974f --- /dev/null +++ b/src/material/tree/testing/node-harness.ts @@ -0,0 +1,91 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { + ComponentHarness, + ComponentHarnessConstructor, + HarnessPredicate +} from '@angular/cdk/testing'; +import {TreeNodeHarnessFilters} from './tree-harness-filters'; +import {coerceBooleanProperty, coerceNumberProperty} from '@angular/cdk/coercion'; + +/** Harness for interacting with a standard Angular Material tree node. */ +export class MatTreeNodeHarness extends ComponentHarness { + /** The selector of the host element of a `MatTreeNode` instance. */ + static hostSelector = '.mat-tree-node, .mat-nested-tree-node'; + + _toggle = this.locatorForOptional('[matTreeNodeToggle]'); + + /** + * Gets a `HarnessPredicate` that can be used to search for a tree node with specific attributes. + * @param options Options for narrowing the search + * @return a `HarnessPredicate` configured with the given options. + */ + static with(options: TreeNodeHarnessFilters = {}): HarnessPredicate { + return getNodePredicate(MatTreeNodeHarness, options); + } + + /** Whether the tree node is expanded. */ + async isExpanded(): Promise { + return coerceBooleanProperty(await (await this.host()).getAttribute('aria-expanded')); + } + + /** Whether the tree node is disabled. */ + async isDisabled(): Promise { + return coerceBooleanProperty(await (await this.host()).getProperty('aria-disabled')); + } + + /** Gets the level of the tree node. Note that this gets the aria-level and is 1 indexed. */ + async getLevel(): Promise { + return coerceNumberProperty(await (await this.host()).getAttribute('aria-level')); + } + + /** Gets the tree node's text. */ + async getText(): Promise { + return (await this.host()).text({exclude: '.mat-tree-node, .mat-nested-tree-node, button'}); + } + + /** Toggles node between expanded/collapsed. Only works when node is not disabled. */ + async toggle(): Promise { + const toggle = await this._toggle(); + if (toggle) { + return toggle.click(); + } + } + + /** Expands the node if it is collapsed. Only works when node is not disabled. */ + async expand(): Promise { + if (!(await this.isExpanded())) { + await this.toggle(); + } + } + + /** Collapses the node if it is expanded. Only works when node is not disabled. */ + async collapse(): Promise { + if (await this.isExpanded()) { + await this.toggle(); + } + } +} + +function getNodePredicate( + type: ComponentHarnessConstructor, + options: TreeNodeHarnessFilters): HarnessPredicate { + return new HarnessPredicate(type, options) + .addOption('text', options.text, + (harness, text) => HarnessPredicate.stringMatches(harness.getText(), text)) + .addOption( + 'disabled', options.disabled, + async (harness, disabled) => (await harness.isDisabled()) === disabled) + .addOption( + 'expanded', options.expanded, + async (harness, expanded) => (await harness.isExpanded()) === expanded) + .addOption( + 'level', options.level, + async (harness, level) => (await harness.getLevel()) === level); +} diff --git a/src/material/tree/testing/public-api.ts b/src/material/tree/testing/public-api.ts new file mode 100644 index 000000000000..78f03e568d2a --- /dev/null +++ b/src/material/tree/testing/public-api.ts @@ -0,0 +1,11 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +export * from './node-harness'; +export * from './tree-harness'; +export * from './tree-harness-filters'; diff --git a/src/material/tree/testing/shared.spec.ts b/src/material/tree/testing/shared.spec.ts new file mode 100644 index 000000000000..fec3c5bd39dc --- /dev/null +++ b/src/material/tree/testing/shared.spec.ts @@ -0,0 +1,215 @@ +import {Component} from '@angular/core'; +import {FlatTreeControl, NestedTreeControl} from '@angular/cdk/tree'; +import { + MatTreeFlatDataSource, + MatTreeFlattener, + MatTreeModule, + MatTreeNestedDataSource +} from '@angular/material/tree'; +import {MatTreeHarness} from '@angular/material/tree/testing'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {HarnessLoader} from '@angular/cdk/testing'; +import {TestbedHarnessEnvironment} from '@angular/cdk/testing/testbed'; + +/** Shared tests to run on both the original and MDC-based trees. */ +export function runHarnessTests( + treeModule: typeof MatTreeModule, + treeHarness: typeof MatTreeHarness) { + let fixture: ComponentFixture; + let loader: HarnessLoader; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [treeModule], + declarations: [TreeHarnessTest], + }).compileComponents(); + + fixture = TestBed.createComponent(TreeHarnessTest); + fixture.detectChanges(); + loader = TestbedHarnessEnvironment.loader(fixture); + }); + + it('should load harness with 2 tress', async () => { + const trees = await loader.getAllHarnesses(treeHarness); + + expect(trees.length).toBe(2); + }); + + it('should get correct number of children and descendants', async () => { + const trees = await loader.getAllHarnesses(treeHarness); + const flatTree = trees[0]; + const nestedTree = trees[1]; + const flatTreeDescendants = await flatTree.getNodes(); + const nestedDescendants = await nestedTree.getNodes(); + + // flat nodes are not rendered until expanded + expect(flatTreeDescendants.length).toBe(2); + + await flatTreeDescendants[0].expand(); + + expect((await flatTree.getNodes()).length).toBe(5); + + expect(nestedDescendants.length).toBe(8); + }); + + it('should correctly get correct node with text (flat tree)', async () => { + const trees = await loader.getAllHarnesses(treeHarness); + const flatTree = trees[0]; + const flatTreeNodes = await flatTree.getNodes({text: /Flat Group/}); + expect(flatTreeNodes.length).toBe(2); + const secondGroup = flatTreeNodes[0]; + + expect(await secondGroup.getText()).toBe('Flat Group 1'); + expect(await secondGroup.getLevel()).toBe(1); + expect(await secondGroup.isDisabled()).toBe(false); + expect(await secondGroup.isExpanded()).toBe(false); + }); + + it('should correctly get correct node with text (nested tree)', async () => { + const trees = await loader.getAllHarnesses(treeHarness); + const nestedTree = trees[1]; + const nestedTreeNodes = await nestedTree.getNodes({text: /2./}); + expect(nestedTreeNodes.length).toBe(3); + const thirdGroup = nestedTreeNodes[1]; + + expect(await thirdGroup.getText()).toBe('Nested Leaf 2.1.1'); + expect(await thirdGroup.getLevel()).toBe(3); + expect(await thirdGroup.isDisabled()).toBe(false); + expect(await thirdGroup.isExpanded()).toBe(false); + }); + + it('should toggle expansion', async () => { + const trees = await loader.getAllHarnesses(treeHarness); + const nestedTree = trees[1]; + const nestedTreeNodes = await nestedTree.getNodes(); + const firstGroup = nestedTreeNodes[0]; + + expect(await firstGroup.isExpanded()).toBe(false); + await firstGroup.expand(); + expect(await firstGroup.isExpanded()).toBe(true); + await firstGroup.expand(); + // no-op if already expanded + expect(await firstGroup.isExpanded()).toBe(true); + await firstGroup.collapse(); + expect(await firstGroup.isExpanded()).toBe(false); + await firstGroup.collapse(); + // no-op if already collapsed + expect(await firstGroup.isExpanded()).toBe(false); + }); +} + +interface FoodNode { + name: string; + children?: FoodNode[]; +} + +const FLAT_TREE_DATA: FoodNode[] = [ + { + name: 'Flat Group 1', + children: [ + {name: 'Flat Leaf 1.1'}, + {name: 'Flat Leaf 1.2'}, + {name: 'Flat Leaf 1.3'}, + ] + }, { + name: 'Flat Group 2', + children: [ + { + name: 'Flat Group 2.1', + children: [ + {name: 'Flat Leaf 2.1.1'}, + {name: 'Flat Leaf 2.1.2'}, + {name: 'Flat Leaf 2.1.3'}, + ] + } + ] + }, +]; + +const NESTED_TREE_DATA: FoodNode[] = [ + { + name: 'Nested Group 1', + children: [ + {name: 'Nested Leaf 1.1'}, + {name: 'Nested Leaf 1.2'}, + {name: 'Nested Leaf 1.3'}, + ] + }, { + name: 'Nested Group 2', + children: [ + { + name: 'Nested Group 2.1', + children: [ + {name: 'Nested Leaf 2.1.1'}, + {name: 'Nested Leaf 2.1.2'}, + ] + }, + ] + }, +]; + +interface ExampleFlatNode { + expandable: boolean; + name: string; + level: number; +} + +@Component({ + template: ` + + + + {{node.name}} + + + + + {{node.name}} + + + + + + {{node.name}} + + + + + {{node.name}} +
    + +
+
+
+ ` +}) +class TreeHarnessTest { + private _transformer = (node: FoodNode, level: number) => { + return { + expandable: !!node.children && node.children.length > 0, + name: node.name, + level: level, + }; + } + + treeFlattener = new MatTreeFlattener( + this._transformer, node => node.level, node => node.expandable, node => node.children); + flatTreeControl = new FlatTreeControl( + node => node.level, node => node.expandable); + flatTreeDataSource = new MatTreeFlatDataSource(this.flatTreeControl, this.treeFlattener); + nestedTreeControl = new NestedTreeControl(node => node.children); + nestedTreeDataSource = new MatTreeNestedDataSource(); + + constructor() { + this.flatTreeDataSource.data = FLAT_TREE_DATA; + this.nestedTreeDataSource.data = NESTED_TREE_DATA; + } + + flatTreeHasChild = (_: number, node: ExampleFlatNode) => node.expandable; + + nestedTreeHasChild = (_: number, node: FoodNode) => !!node.children && node.children.length > 0; +} diff --git a/src/material/tree/testing/tree-harness-filters.ts b/src/material/tree/testing/tree-harness-filters.ts new file mode 100644 index 000000000000..e2893059894a --- /dev/null +++ b/src/material/tree/testing/tree-harness-filters.ts @@ -0,0 +1,28 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {BaseHarnessFilters} from '@angular/cdk/testing'; + +/** A set of criteria that can be used to filter a list of tree harness instances */ +export interface TreeHarnessFilters extends BaseHarnessFilters { +} + +/** A set of criteria that can be used to filter a list of node harness instances. */ +export interface TreeNodeHarnessFilters extends BaseHarnessFilters { + /** Only find instances whose text matches the given value. */ + text?: string | RegExp; + + /** Only find instances whose disabled state matches the given value. */ + disabled?: boolean; + + /** Only find instances whose expansion state matches the given value. */ + expanded?: boolean; + + /** Only find instances whose level matches the given value. */ + level?: number; +} diff --git a/src/material/tree/testing/tree-harness.spec.ts b/src/material/tree/testing/tree-harness.spec.ts new file mode 100644 index 000000000000..0eaa745a8fd3 --- /dev/null +++ b/src/material/tree/testing/tree-harness.spec.ts @@ -0,0 +1,7 @@ +import {MatTreeHarness} from '@angular/material/tree/testing'; +import {MatTreeModule} from '@angular/material/tree'; +import {runHarnessTests} from '@angular/material/tree/testing/shared.spec'; + +describe('Non-MDC-based MatTreeHarness', () => { + runHarnessTests(MatTreeModule, MatTreeHarness); +}); diff --git a/src/material/tree/testing/tree-harness.ts b/src/material/tree/testing/tree-harness.ts new file mode 100644 index 000000000000..caffb2672bdd --- /dev/null +++ b/src/material/tree/testing/tree-harness.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {ComponentHarness, HarnessPredicate} from '@angular/cdk/testing'; +import {MatTreeNodeHarness} from './node-harness'; +import {TreeHarnessFilters, TreeNodeHarnessFilters} from './tree-harness-filters'; + +/** Harness for interacting with a standard mat-tree in tests. */ +export class MatTreeHarness extends ComponentHarness { + /** The selector for the host element of a `MatTableHarness` instance. */ + static hostSelector = '.mat-tree'; + + /** + * Gets a `HarnessPredicate` that can be used to search for a tree with specific attributes. + * @param options Options for narrowing the search + * @return a `HarnessPredicate` configured with the given options. + */ + static with(options: TreeHarnessFilters = {}): HarnessPredicate { + return new HarnessPredicate(MatTreeHarness, options); + } + + /** Gets all of the nodes in the tree. */ + async getNodes(filter: TreeNodeHarnessFilters = {}): Promise { + return this.locatorForAll(MatTreeNodeHarness.with(filter))(); + } +} diff --git a/tools/public_api_guard/material/tree/testing.d.ts b/tools/public_api_guard/material/tree/testing.d.ts new file mode 100644 index 000000000000..76184d53cc13 --- /dev/null +++ b/tools/public_api_guard/material/tree/testing.d.ts @@ -0,0 +1,28 @@ +export declare class MatTreeHarness extends ComponentHarness { + getNodes(filter?: TreeNodeHarnessFilters): Promise; + static hostSelector: string; + static with(options?: TreeHarnessFilters): HarnessPredicate; +} + +export declare class MatTreeNodeHarness extends ComponentHarness { + _toggle: import("@angular/cdk/testing").AsyncFactoryFn; + collapse(): Promise; + expand(): Promise; + getLevel(): Promise; + getText(): Promise; + isDisabled(): Promise; + isExpanded(): Promise; + toggle(): Promise; + static hostSelector: string; + static with(options?: TreeNodeHarnessFilters): HarnessPredicate; +} + +export interface TreeHarnessFilters extends BaseHarnessFilters { +} + +export interface TreeNodeHarnessFilters extends BaseHarnessFilters { + disabled?: boolean; + expanded?: boolean; + level?: number; + text?: string | RegExp; +}