diff --git a/goldens/aria/tree/testing/index.api.md b/goldens/aria/tree/testing/index.api.md new file mode 100644 index 000000000000..1f83652e94b1 --- /dev/null +++ b/goldens/aria/tree/testing/index.api.md @@ -0,0 +1,57 @@ +## API Report File for "@angular/aria_tree_testing" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { BaseHarnessFilters } from '@angular/cdk/testing'; +import { ComponentHarness } from '@angular/cdk/testing'; +import { ContentContainerComponentHarness } from '@angular/cdk/testing'; +import { HarnessPredicate } from '@angular/cdk/testing'; + +// @public (undocumented) +export interface TextTree { + // (undocumented) + children?: TextTree[]; + // (undocumented) + text?: string; +} + +// @public +export class TreeHarness extends ComponentHarness { + getItems(filter?: TreeItemHarnessFilters): Promise; + getTreeStructure(): Promise; + // (undocumented) + static hostSelector: string; + static with(options?: TreeHarnessFilters): HarnessPredicate; +} + +// @public +export interface TreeHarnessFilters extends BaseHarnessFilters { +} + +// @public +export class TreeItemHarness extends ContentContainerComponentHarness { + click(): Promise; + getLevel(): Promise; + getText(): Promise; + // (undocumented) + static hostSelector: string; + isDisabled(): Promise; + isExpanded(): Promise; + isSelected(): Promise; + static with(options?: TreeItemHarnessFilters): HarnessPredicate; +} + +// @public +export interface TreeItemHarnessFilters extends BaseHarnessFilters { + disabled?: boolean; + expanded?: boolean; + level?: number; + selected?: boolean; + text?: string | RegExp; +} + +// (No @packageDocumentation comment for this package) + +``` diff --git a/src/aria/config.bzl b/src/aria/config.bzl index 291412b5a3fb..10069d32848a 100644 --- a/src/aria/config.bzl +++ b/src/aria/config.bzl @@ -8,6 +8,7 @@ ARIA_ENTRYPOINTS = [ "tabs", "toolbar", "tree", + "tree/testing", "private", ] diff --git a/src/aria/tree/testing/BUILD.bazel b/src/aria/tree/testing/BUILD.bazel new file mode 100644 index 000000000000..63e72fd18bdf --- /dev/null +++ b/src/aria/tree/testing/BUILD.bazel @@ -0,0 +1,38 @@ +load("//tools:defaults.bzl", "ng_project", "ng_web_test_suite", "ts_project") + +package(default_visibility = ["//visibility:public"]) + +ts_project( + name = "testing", + srcs = glob( + ["**/*.ts"], + exclude = ["**/*.spec.ts"], + ), + deps = [ + "//src/cdk/testing", + ], +) + +filegroup( + name = "source-files", + srcs = glob(["**/*.ts"]), +) + +ng_project( + name = "unit_tests_lib", + testonly = True, + srcs = glob(["**/*.spec.ts"]), + deps = [ + ":testing", + "//:node_modules/@angular/common", + "//:node_modules/@angular/core", + "//src/aria/tree", + "//src/cdk/testing", + "//src/cdk/testing/testbed", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [":unit_tests_lib"], +) diff --git a/src/aria/tree/testing/index.ts b/src/aria/tree/testing/index.ts new file mode 100644 index 000000000000..52b3c7a5156f --- /dev/null +++ b/src/aria/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.dev/license + */ + +export * from './public-api'; diff --git a/src/aria/tree/testing/item-harness.ts b/src/aria/tree/testing/item-harness.ts new file mode 100644 index 000000000000..204236b5adad --- /dev/null +++ b/src/aria/tree/testing/item-harness.ts @@ -0,0 +1,82 @@ +/** + * @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.dev/license + */ + +import {ContentContainerComponentHarness, HarnessPredicate} from '@angular/cdk/testing'; +import {TreeItemHarnessFilters} from './tree-harness-filters'; + +/** Harness for interacting with an Aria tree item. */ +export class TreeItemHarness extends ContentContainerComponentHarness { + static hostSelector = '[ngTreeItem]'; + + /** + * Gets a `HarnessPredicate` that can be used to search for a tree item with specific attributes. + * @param options Options for narrowing the search + * @return a `HarnessPredicate` configured with the given options. + */ + static with(options: TreeItemHarnessFilters = {}): HarnessPredicate { + return new HarnessPredicate(TreeItemHarness, 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( + 'selected', + options.selected, + async (harness, selected) => (await harness.isSelected()) === selected, + ) + .addOption( + 'level', + options.level, + async (harness, level) => (await harness.getLevel()) === level, + ); + } + + /** Whether the tree item is expanded. */ + async isExpanded(): Promise { + return (await this._getHostAttribute('aria-expanded')) === 'true'; + } + + /** Whether the tree item is disabled. */ + async isDisabled(): Promise { + return (await this._getHostAttribute('aria-disabled')) === 'true'; + } + + /** Whether the tree item is selected. */ + async isSelected(): Promise { + return (await this._getHostAttribute('aria-selected')) === 'true'; + } + + /** Gets the level of the tree item. Note that this gets the aria-level and is 1 indexed. */ + async getLevel(): Promise { + const level = (await this._getHostAttribute('aria-level')) ?? '1'; + return parseInt(level); + } + + /** Gets the tree item's text. */ + async getText(): Promise { + return (await this.host()).text({exclude: '[ngTreeItem], [ngTreeItemGroup]'}); + } + + /** Clicks the tree item. */ + async click(): Promise { + return (await this.host()).click(); + } + + private async _getHostAttribute(attributeName: string): Promise { + return (await this.host()).getAttribute(attributeName); + } +} diff --git a/src/aria/tree/testing/public-api.ts b/src/aria/tree/testing/public-api.ts new file mode 100644 index 000000000000..8795f62017b1 --- /dev/null +++ b/src/aria/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.dev/license + */ + +export * from './item-harness'; +export * from './tree-harness'; +export * from './tree-harness-filters'; diff --git a/src/aria/tree/testing/tree-harness-filters.ts b/src/aria/tree/testing/tree-harness-filters.ts new file mode 100644 index 000000000000..a0177eaa3bf7 --- /dev/null +++ b/src/aria/tree/testing/tree-harness-filters.ts @@ -0,0 +1,30 @@ +/** + * @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.dev/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 tree item harness instances. */ +export interface TreeItemHarnessFilters 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 selection state matches the given value. */ + selected?: boolean; + + /** Only find instances whose level matches the given value. */ + level?: number; +} diff --git a/src/aria/tree/testing/tree-harness.spec.ts b/src/aria/tree/testing/tree-harness.spec.ts new file mode 100644 index 000000000000..2203a59c216a --- /dev/null +++ b/src/aria/tree/testing/tree-harness.spec.ts @@ -0,0 +1,214 @@ +import {Component} from '@angular/core'; +import {NgTemplateOutlet} from '@angular/common'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {HarnessLoader} from '@angular/cdk/testing'; +import {TestbedHarnessEnvironment} from '@angular/cdk/testing/testbed'; +import {Tree, TreeItem, TreeItemGroup} from '../../tree'; +import {TreeHarness} from './tree-harness'; + +describe('TreeHarness', () => { + let fixture: ComponentFixture; + let loader: HarnessLoader; + + beforeEach(() => { + 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(1); + }); + + it('should get correct number of children and descendants', async () => { + const tree = await loader.getHarness(TreeHarness); + const items = await tree.getItems(); + expect(items.length).toBe(5); + + await items[0].click(); + expect((await tree.getItems()).length).toBe(8); + }); + + it('should correctly get correct item with text', async () => { + const tree = await loader.getHarness(TreeHarness); + const items = await tree.getItems({text: /\.json$/}); + expect(items.length).toBe(2); + const item = items[0]; + + expect(await item.getText()).toBe('angular.json'); + expect(await item.getLevel()).toBe(1); + expect(await item.isDisabled()).toBe(false); + expect(await item.isExpanded()).toBe(false); + }); + + it('should toggle expansion', async () => { + const tree = await loader.getHarness(TreeHarness); + const item = (await tree.getItems())[0]; + + expect(await item.isExpanded()).toBe(false); + await item.click(); + expect(await item.isExpanded()).toBe(true); + await item.click(); + expect(await item.isExpanded()).toBe(false); + }); + + it('should correctly get tree structure', async () => { + const tree = await loader.getHarness(TreeHarness); + + expect(await tree.getTreeStructure()).toEqual({ + children: [ + {text: 'public'}, + {text: 'src'}, + {text: 'angular.json'}, + {text: 'package.json'}, + {text: 'README.md'}, + ], + }); + + const firstGroup = (await tree.getItems({text: 'public'}))[0]; + await firstGroup.click(); + + expect(await tree.getTreeStructure()).toEqual({ + children: [ + { + text: 'public', + children: [{text: 'index.html'}, {text: 'favicon.ico'}, {text: 'styles.css'}], + }, + {text: 'src'}, + {text: 'angular.json'}, + {text: 'package.json'}, + {text: 'README.md'}, + ], + }); + + const secondGroup = (await tree.getItems({text: 'src'}))[0]; + await secondGroup.click(); + + expect(await tree.getTreeStructure()).toEqual({ + children: [ + { + text: 'public', + children: [{text: 'index.html'}, {text: 'favicon.ico'}, {text: 'styles.css'}], + }, + { + text: 'src', + children: [ + {text: 'app'}, + {text: 'assets'}, + {text: 'environments'}, + {text: 'main.ts'}, + {text: 'polyfills.ts'}, + {text: 'styles.css'}, + {text: 'test.ts'}, + ], + }, + {text: 'angular.json'}, + {text: 'package.json'}, + {text: 'README.md'}, + ], + }); + }); +}); + +interface TreeNode { + name: string; + value: string; + children?: TreeNode[]; + disabled?: boolean; + expanded?: boolean; +} + +@Component({ + template: ` +
    + +
+ + + @for (node of nodes; track node.value) { +
  • + {{ node.name }} +
  • + + @if (node.children) { +
      + + + +
    + } + } +
    + `, + imports: [Tree, TreeItem, TreeItemGroup, NgTemplateOutlet], +}) +class TreeHarnessTest { + readonly nodes: TreeNode[] = [ + { + name: 'public', + value: 'public', + children: [ + {name: 'index.html', value: 'public/index.html'}, + {name: 'favicon.ico', value: 'public/favicon.ico'}, + {name: 'styles.css', value: 'public/styles.css'}, + ], + expanded: false, + }, + { + name: 'src', + value: 'src', + children: [ + { + name: 'app', + value: 'src/app', + children: [ + {name: 'app.component.ts', value: 'src/app/app.component.ts'}, + {name: 'app.module.ts', value: 'src/app/app.module.ts', disabled: true}, + {name: 'app.css', value: 'src/app/app.css'}, + ], + expanded: false, + }, + { + name: 'assets', + value: 'src/assets', + children: [{name: 'logo.png', value: 'src/assets/logo.png'}], + expanded: false, + }, + { + name: 'environments', + value: 'src/environments', + children: [ + { + name: 'environment.prod.ts', + value: 'src/environments/environment.prod.ts', + expanded: false, + }, + {name: 'environment.ts', value: 'src/environments/environment.ts'}, + ], + expanded: false, + }, + {name: 'main.ts', value: 'src/main.ts'}, + {name: 'polyfills.ts', value: 'src/polyfills.ts'}, + {name: 'styles.css', value: 'src/styles.css', disabled: true}, + {name: 'test.ts', value: 'src/test.ts'}, + ], + expanded: false, + }, + {name: 'angular.json', value: 'angular.json'}, + {name: 'package.json', value: 'package.json'}, + {name: 'README.md', value: 'README.md'}, + ]; +} diff --git a/src/aria/tree/testing/tree-harness.ts b/src/aria/tree/testing/tree-harness.ts new file mode 100644 index 000000000000..b97c65e18637 --- /dev/null +++ b/src/aria/tree/testing/tree-harness.ts @@ -0,0 +1,102 @@ +/** + * @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.dev/license + */ + +import {ComponentHarness, HarnessPredicate, parallel} from '@angular/cdk/testing'; +import {TreeItemHarness} from './item-harness'; +import {TreeHarnessFilters, TreeItemHarnessFilters} from './tree-harness-filters'; + +export interface TextTree { + text?: string; + children?: TextTree[]; +} + +/** Harness for interacting with an Aria tree in tests. */ +export class TreeHarness extends ComponentHarness { + static hostSelector = '[ngTree]'; + + /** + * 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(TreeHarness, options); + } + + /** Gets all of the items in the tree. */ + async getItems(filter: TreeItemHarnessFilters = {}): Promise { + return this.locatorForAll(TreeItemHarness.with(filter))(); + } + + /** + * Gets an object representation for the visible tree structure + * If an item is under an unexpanded item it will not be included. + */ + async getTreeStructure(): Promise { + const items = await this.getItems(); + const itemInformation = await parallel(() => + items.map(item => parallel(() => [item.getLevel(), item.getText(), item.isExpanded()])), + ); + return this._getTreeStructure(itemInformation, 1, true); + } + + /** + * Recursively collect the structured text of the tree items. + * @param items A list of tree items + * @param level The level of items that are being accounted for during this iteration + * @param parentExpanded Whether the parent of the first item in param items is expanded + */ + private _getTreeStructure( + items: [number, string, boolean][], + level: number, + parentExpanded: boolean, + ): TextTree { + const result: TextTree = {}; + for (let i = 0; i < items.length; i++) { + const [itemLevel, text, expanded] = items[i]; + const nextItemLevel = items[i + 1]?.[0] ?? -1; + + // Return the accumulated value for the current level once we reach a shallower level item + if (itemLevel < level) { + return result; + } + // Skip deeper level items during this iteration, they will be picked up in a later iteration + if (itemLevel > level) { + continue; + } + // Only add to representation if it is visible (parent is expanded) + if (parentExpanded) { + // Collect the data under this item according to the following rules: + // 1. If the next item in the list is a sibling of the current item add it to the child list + // 2. If the next item is a child of the current item, get the sub-tree structure for the + // child and add it under this item + // 3. If the next item has a shallower level, we've reached the end of the child items for + // the current parent. + if (nextItemLevel === level) { + this._addChildToItem(result, {text}); + } else if (nextItemLevel > level) { + let children = this._getTreeStructure( + items.slice(i + 1), + nextItemLevel, + expanded, + )?.children; + let child = children ? {text, children} : {text}; + this._addChildToItem(result, child); + } else { + this._addChildToItem(result, {text}); + return result; + } + } + } + return result; + } + + private _addChildToItem(result: TextTree, child: TextTree) { + result.children ? result.children.push(child) : (result.children = [child]); + } +}