Skip to content

feat(icon): add test harness #20072

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
Jul 22, 2020
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
19 changes: 18 additions & 1 deletion src/material/icon/icon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,9 @@ const funcIriPattern = /^url\(['"]?#(.*?)['"]?\)$/;
host: {
'role': 'img',
'class': 'mat-icon notranslate',
'[attr.data-mat-icon-type]': '_usingFontIcon() ? "font" : "svg"',
'[attr.data-mat-icon-name]': '_svgName || fontIcon',
'[attr.data-mat-icon-namespace]': '_svgNamespace || fontSet',
'[class.mat-icon-inline]': 'inline',
'[class.mat-icon-no-color]': 'color !== "primary" && color !== "accent" && color !== "warn"',
},
Expand Down Expand Up @@ -172,6 +175,9 @@ export class MatIcon extends _MatIconMixinBase implements OnChanges, OnInit, Aft
private _previousFontSetClass: string;
private _previousFontIconClass: string;

_svgName: string | null;
_svgNamespace: string | null;

/** Keeps track of the current page path. */
private _previousPath?: string;

Expand Down Expand Up @@ -224,12 +230,23 @@ export class MatIcon extends _MatIconMixinBase implements OnChanges, OnInit, Aft
// Only update the inline SVG icon if the inputs changed, to avoid unnecessary DOM operations.
const svgIconChanges = changes['svgIcon'];

this._svgNamespace = null;
this._svgName = null;

if (svgIconChanges) {
this._currentIconFetch.unsubscribe();

if (this.svgIcon) {
const [namespace, iconName] = this._splitIconName(this.svgIcon);

if (namespace) {
this._svgNamespace = namespace;
}

if (iconName) {
this._svgName = iconName;
}

this._currentIconFetch = this._iconRegistry.getNamedSvgIcon(iconName, namespace)
.pipe(take(1))
.subscribe(svg => this._setSvgElement(svg), (err: Error) => {
Expand Down Expand Up @@ -281,7 +298,7 @@ export class MatIcon extends _MatIconMixinBase implements OnChanges, OnInit, Aft
}
}

private _usingFontIcon(): boolean {
_usingFontIcon(): boolean {
return !this.svgIcon;
}

Expand Down
46 changes: 40 additions & 6 deletions src/material/icon/testing/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
load("//tools:defaults.bzl", "ng_module")
load("//tools:defaults.bzl", "ng_module", "ng_test_library", "ng_web_test_suite")

package(default_visibility = ["//visibility:public"])

ng_module(
name = "testing",
srcs = [
"fake-icon-registry.ts",
"index.ts",
"public-api.ts",
],
srcs = glob(
["**/*.ts"],
exclude = ["**/*.spec.ts"],
),
module_name = "@angular/material/icon/testing",
deps = [
"//src/cdk/coercion",
"//src/cdk/testing",
"//src/material/icon",
"@npm//@angular/common",
"@npm//@angular/core",
Expand All @@ -23,3 +23,37 @@ filegroup(
name = "source-files",
srcs = glob(["**/*.ts"]),
)

ng_test_library(
name = "harness_tests_lib",
srcs = ["shared.spec.ts"],
deps = [
":testing",
"//src/cdk/platform",
"//src/cdk/testing",
"//src/cdk/testing/testbed",
"//src/material/icon",
"@npm//@angular/platform-browser",
],
)

ng_test_library(
name = "unit_tests_lib",
srcs = glob(
["**/*.spec.ts"],
exclude = [
"**/*.e2e.spec.ts",
"shared.spec.ts",
],
),
deps = [
":harness_tests_lib",
":testing",
"//src/material/icon",
],
)

ng_web_test_suite(
name = "unit_tests",
deps = [":unit_tests_lib"],
)
22 changes: 22 additions & 0 deletions src/material/icon/testing/icon-harness-filters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* @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';

/** Possible types of icons. */
export const enum IconType {SVG, FONT}

/** A set of criteria that can be used to filter a list of `MatIconHarness` instances. */
export interface IconHarnessFilters extends BaseHarnessFilters {
/** Filters based on the typef of the icon. */
type?: IconType;
/** Filters based on the name of the icon. */
name?: string | RegExp;
/** Filters based on the namespace of the icon. */
namespace?: string | null | RegExp;
}
7 changes: 7 additions & 0 deletions src/material/icon/testing/icon-harness.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import {MatIconModule, MatIconRegistry} from '@angular/material/icon';
import {runHarnessTests} from '@angular/material/icon/testing/shared.spec';
import {MatIconHarness} from './icon-harness';

describe('Non-MDC-based MatIconHarness', () => {
runHarnessTests(MatIconModule, MatIconRegistry, MatIconHarness);
});
68 changes: 68 additions & 0 deletions src/material/icon/testing/icon-harness.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* @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 {IconHarnessFilters, IconType} from './icon-harness-filters';


/** Harness for interacting with a standard mat-icon in tests. */
export class MatIconHarness extends ComponentHarness {
/** The selector for the host element of a `MatIcon` instance. */
static hostSelector = '.mat-icon';

/**
* Gets a `HarnessPredicate` that can be used to search for a `MatIconHarness` that meets
* certain criteria.
* @param options Options for filtering which icon instances are considered a match.
* @return a `HarnessPredicate` configured with the given options.
*/
static with(options: IconHarnessFilters = {}): HarnessPredicate<MatIconHarness> {
return new HarnessPredicate(MatIconHarness, options)
.addOption('type', options.type,
async (harness, type) => (await harness.getType()) === type)
.addOption('name', options.name,
(harness, text) => HarnessPredicate.stringMatches(harness.getName(), text))
.addOption('namespace', options.namespace,
(harness, text) => HarnessPredicate.stringMatches(harness.getNamespace(), text));
}

/** Gets the type of the icon. */
async getType(): Promise<IconType> {
const type = await (await this.host()).getAttribute('data-mat-icon-type');
return type === 'svg' ? IconType.SVG : IconType.FONT;
}

/** Gets the name of the icon. */
async getName(): Promise<string | null> {
const host = await this.host();
const nameFromDom = await host.getAttribute('data-mat-icon-name');

// If we managed to figure out the name from the attribute, use it.
if (nameFromDom) {
return nameFromDom;
}

// Some icons support defining the icon as a ligature.
// As a fallback, try to extract it from the DOM text.
if (await this.getType() === IconType.FONT) {
return host.text();
}

return null;
}

/** Gets the namespace of the icon. */
async getNamespace(): Promise<string | null> {
Copy link
Member Author

@crisbeto crisbeto Jul 22, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure about the naming of this and getName. Technically they correspond to two different inputs depending on whether it's an SVG icon (fontSet/svgIcon vs fontIcon/svgIcon), but they basically mean the same thing so I decided to combine them under the same methods. I'm open to suggestions and potentially separating them out so they mirror the inputs.

return (await this.host()).getAttribute('data-mat-icon-namespace');
}

/** Gets whether the icon is inline. */
async isInline(): Promise<boolean> {
return (await this.host()).hasClass('mat-icon-inline');
}
}
2 changes: 2 additions & 0 deletions src/material/icon/testing/public-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,6 @@
* found in the LICENSE file at https://angular.io/license
*/

export * from './icon-harness';
export * from './icon-harness-filters';
export * from './fake-icon-registry';
106 changes: 106 additions & 0 deletions src/material/icon/testing/shared.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import {HarnessLoader} from '@angular/cdk/testing';
import {TestbedHarnessEnvironment} from '@angular/cdk/testing/testbed';
import {Component} from '@angular/core';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {MatIconModule, MatIconRegistry} from '@angular/material/icon';
import {MatIconHarness} from '@angular/material/icon/testing/icon-harness';
import {DomSanitizer} from '@angular/platform-browser';
import {IconType} from './icon-harness-filters';

/** Shared tests to run on both the original and MDC-based icons. */
export function runHarnessTests(
iconModule: typeof MatIconModule,
iconRegistry: typeof MatIconRegistry,
iconHarness: typeof MatIconHarness) {
let fixture: ComponentFixture<IconHarnessTest>;
let loader: HarnessLoader;

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [iconModule],
declarations: [IconHarnessTest],
}).compileComponents();

const registry = TestBed.inject(iconRegistry);
const sanitizer = TestBed.inject(DomSanitizer);

registry.addSvgIconLiteralInNamespace('svgIcons', 'svgIcon',
sanitizer.bypassSecurityTrustHtml('<svg></svg>'));
fixture = TestBed.createComponent(IconHarnessTest);
fixture.detectChanges();
loader = TestbedHarnessEnvironment.loader(fixture);
});

it('should load all icon harnesses', async () => {
const icons = await loader.getAllHarnesses(iconHarness);
expect(icons.length).toBe(3);
});

it('should filter icon harnesses based on their type', async () => {
const [svgIcons, fontIcons] = await Promise.all([
loader.getAllHarnesses(iconHarness.with({type: IconType.SVG})),
loader.getAllHarnesses(iconHarness.with({type: IconType.FONT}))
]);

expect(svgIcons.length).toBe(1);
expect(fontIcons.length).toBe(2);
});

it('should filter icon harnesses based on their name', async () => {
const [regexFilterResults, stringFilterResults] = await Promise.all([
loader.getAllHarnesses(iconHarness.with({name: /^font/})),
loader.getAllHarnesses(iconHarness.with({name: 'fontIcon'}))
]);

expect(regexFilterResults.length).toBe(1);
expect(stringFilterResults.length).toBe(1);
});

it('should filter icon harnesses based on their namespace', async () => {
const [regexFilterResults, stringFilterResults, nullFilterResults] = await Promise.all([
loader.getAllHarnesses(iconHarness.with({namespace: /^font/})),
loader.getAllHarnesses(iconHarness.with({namespace: 'svgIcons'})),
loader.getAllHarnesses(iconHarness.with({namespace: null}))
]);

expect(regexFilterResults.length).toBe(1);
expect(stringFilterResults.length).toBe(1);
expect(nullFilterResults.length).toBe(1);
});

it('should get the type of each icon', async () => {
const icons = await loader.getAllHarnesses(iconHarness);
const types = await Promise.all(icons.map(icon => icon.getType()));
expect(types).toEqual([IconType.FONT, IconType.SVG, IconType.FONT]);
});

it('should get the name of an icon', async () => {
const icons = await loader.getAllHarnesses(iconHarness);
const names = await Promise.all(icons.map(icon => icon.getName()));
expect(names).toEqual(['fontIcon', 'svgIcon', 'ligature_icon']);
});

it('should get the namespace of an icon', async () => {
const icons = await loader.getAllHarnesses(iconHarness);
const namespaces = await Promise.all(icons.map(icon => icon.getNamespace()));
expect(namespaces).toEqual(['fontIcons', 'svgIcons', null]);
});

it('should get whether an icon is inline', async () => {
const icons = await loader.getAllHarnesses(iconHarness);
const inlineStates = await Promise.all(icons.map(icon => icon.isInline()));
expect(inlineStates).toEqual([false, false, true]);
});

}

@Component({
template: `
<mat-icon fontSet="fontIcons" fontIcon="fontIcon"></mat-icon>
<mat-icon svgIcon="svgIcons:svgIcon"></mat-icon>
<mat-icon inline>ligature_icon</mat-icon>
`
})
class IconHarnessTest {
}

3 changes: 3 additions & 0 deletions tools/public_api_guard/material/icon.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export declare const MAT_ICON_LOCATION: InjectionToken<MatIconLocation>;
export declare function MAT_ICON_LOCATION_FACTORY(): MatIconLocation;

export declare class MatIcon extends _MatIconMixinBase implements OnChanges, OnInit, AfterViewChecked, CanColor, OnDestroy {
_svgName: string | null;
_svgNamespace: string | null;
get fontIcon(): string;
set fontIcon(value: string);
get fontSet(): string;
Expand All @@ -32,6 +34,7 @@ export declare class MatIcon extends _MatIconMixinBase implements OnChanges, OnI
set inline(inline: boolean);
svgIcon: string;
constructor(elementRef: ElementRef<HTMLElement>, _iconRegistry: MatIconRegistry, ariaHidden: string, _location: MatIconLocation, _errorHandler: ErrorHandler);
_usingFontIcon(): boolean;
ngAfterViewChecked(): void;
ngOnChanges(changes: SimpleChanges): void;
ngOnDestroy(): void;
Expand Down
20 changes: 20 additions & 0 deletions tools/public_api_guard/material/icon/testing.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,26 @@ export declare class FakeMatIconRegistry implements PublicApi<MatIconRegistry>,
static ɵprov: i0.ɵɵInjectableDef<FakeMatIconRegistry>;
}

export interface IconHarnessFilters extends BaseHarnessFilters {
name?: string | RegExp;
namespace?: string | null | RegExp;
type?: IconType;
}

export declare const enum IconType {
SVG = 0,
FONT = 1
}

export declare class MatIconHarness extends ComponentHarness {
getName(): Promise<string | null>;
getNamespace(): Promise<string | null>;
getType(): Promise<IconType>;
isInline(): Promise<boolean>;
static hostSelector: string;
static with(options?: IconHarnessFilters): HarnessPredicate<MatIconHarness>;
}

export declare class MatIconTestingModule {
static ɵinj: i0.ɵɵInjectorDef<MatIconTestingModule>;
static ɵmod: i0.ɵɵNgModuleDefWithMeta<MatIconTestingModule, never, never, never>;
Expand Down