Skip to content

Commit 092b151

Browse files
authored
feat(icon): add test harness (#20072)
Sets up a test harness for `mat-icon`.
1 parent ba441d4 commit 092b151

File tree

9 files changed

+286
-7
lines changed

9 files changed

+286
-7
lines changed

src/material/icon/icon.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,9 @@ const funcIriPattern = /^url\(['"]?#(.*?)['"]?\)$/;
128128
host: {
129129
'role': 'img',
130130
'class': 'mat-icon notranslate',
131+
'[attr.data-mat-icon-type]': '_usingFontIcon() ? "font" : "svg"',
132+
'[attr.data-mat-icon-name]': '_svgName || fontIcon',
133+
'[attr.data-mat-icon-namespace]': '_svgNamespace || fontSet',
131134
'[class.mat-icon-inline]': 'inline',
132135
'[class.mat-icon-no-color]': 'color !== "primary" && color !== "accent" && color !== "warn"',
133136
},
@@ -172,6 +175,9 @@ export class MatIcon extends _MatIconMixinBase implements OnChanges, OnInit, Aft
172175
private _previousFontSetClass: string;
173176
private _previousFontIconClass: string;
174177

178+
_svgName: string | null;
179+
_svgNamespace: string | null;
180+
175181
/** Keeps track of the current page path. */
176182
private _previousPath?: string;
177183

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

233+
this._svgNamespace = null;
234+
this._svgName = null;
235+
227236
if (svgIconChanges) {
228237
this._currentIconFetch.unsubscribe();
229238

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

242+
if (namespace) {
243+
this._svgNamespace = namespace;
244+
}
245+
246+
if (iconName) {
247+
this._svgName = iconName;
248+
}
249+
233250
this._currentIconFetch = this._iconRegistry.getNamedSvgIcon(iconName, namespace)
234251
.pipe(take(1))
235252
.subscribe(svg => this._setSvgElement(svg), (err: Error) => {
@@ -281,7 +298,7 @@ export class MatIcon extends _MatIconMixinBase implements OnChanges, OnInit, Aft
281298
}
282299
}
283300

284-
private _usingFontIcon(): boolean {
301+
_usingFontIcon(): boolean {
285302
return !this.svgIcon;
286303
}
287304

src/material/icon/testing/BUILD.bazel

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
1-
load("//tools:defaults.bzl", "ng_module")
1+
load("//tools:defaults.bzl", "ng_module", "ng_test_library", "ng_web_test_suite")
22

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

55
ng_module(
66
name = "testing",
7-
srcs = [
8-
"fake-icon-registry.ts",
9-
"index.ts",
10-
"public-api.ts",
11-
],
7+
srcs = glob(
8+
["**/*.ts"],
9+
exclude = ["**/*.spec.ts"],
10+
),
1211
module_name = "@angular/material/icon/testing",
1312
deps = [
1413
"//src/cdk/coercion",
14+
"//src/cdk/testing",
1515
"//src/material/icon",
1616
"@npm//@angular/common",
1717
"@npm//@angular/core",
@@ -23,3 +23,37 @@ filegroup(
2323
name = "source-files",
2424
srcs = glob(["**/*.ts"]),
2525
)
26+
27+
ng_test_library(
28+
name = "harness_tests_lib",
29+
srcs = ["shared.spec.ts"],
30+
deps = [
31+
":testing",
32+
"//src/cdk/platform",
33+
"//src/cdk/testing",
34+
"//src/cdk/testing/testbed",
35+
"//src/material/icon",
36+
"@npm//@angular/platform-browser",
37+
],
38+
)
39+
40+
ng_test_library(
41+
name = "unit_tests_lib",
42+
srcs = glob(
43+
["**/*.spec.ts"],
44+
exclude = [
45+
"**/*.e2e.spec.ts",
46+
"shared.spec.ts",
47+
],
48+
),
49+
deps = [
50+
":harness_tests_lib",
51+
":testing",
52+
"//src/material/icon",
53+
],
54+
)
55+
56+
ng_web_test_suite(
57+
name = "unit_tests",
58+
deps = [":unit_tests_lib"],
59+
)
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
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.io/license
7+
*/
8+
9+
import {BaseHarnessFilters} from '@angular/cdk/testing';
10+
11+
/** Possible types of icons. */
12+
export const enum IconType {SVG, FONT}
13+
14+
/** A set of criteria that can be used to filter a list of `MatIconHarness` instances. */
15+
export interface IconHarnessFilters extends BaseHarnessFilters {
16+
/** Filters based on the typef of the icon. */
17+
type?: IconType;
18+
/** Filters based on the name of the icon. */
19+
name?: string | RegExp;
20+
/** Filters based on the namespace of the icon. */
21+
namespace?: string | null | RegExp;
22+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import {MatIconModule, MatIconRegistry} from '@angular/material/icon';
2+
import {runHarnessTests} from '@angular/material/icon/testing/shared.spec';
3+
import {MatIconHarness} from './icon-harness';
4+
5+
describe('Non-MDC-based MatIconHarness', () => {
6+
runHarnessTests(MatIconModule, MatIconRegistry, MatIconHarness);
7+
});
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
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.io/license
7+
*/
8+
9+
import {ComponentHarness, HarnessPredicate} from '@angular/cdk/testing';
10+
import {IconHarnessFilters, IconType} from './icon-harness-filters';
11+
12+
13+
/** Harness for interacting with a standard mat-icon in tests. */
14+
export class MatIconHarness extends ComponentHarness {
15+
/** The selector for the host element of a `MatIcon` instance. */
16+
static hostSelector = '.mat-icon';
17+
18+
/**
19+
* Gets a `HarnessPredicate` that can be used to search for a `MatIconHarness` that meets
20+
* certain criteria.
21+
* @param options Options for filtering which icon instances are considered a match.
22+
* @return a `HarnessPredicate` configured with the given options.
23+
*/
24+
static with(options: IconHarnessFilters = {}): HarnessPredicate<MatIconHarness> {
25+
return new HarnessPredicate(MatIconHarness, options)
26+
.addOption('type', options.type,
27+
async (harness, type) => (await harness.getType()) === type)
28+
.addOption('name', options.name,
29+
(harness, text) => HarnessPredicate.stringMatches(harness.getName(), text))
30+
.addOption('namespace', options.namespace,
31+
(harness, text) => HarnessPredicate.stringMatches(harness.getNamespace(), text));
32+
}
33+
34+
/** Gets the type of the icon. */
35+
async getType(): Promise<IconType> {
36+
const type = await (await this.host()).getAttribute('data-mat-icon-type');
37+
return type === 'svg' ? IconType.SVG : IconType.FONT;
38+
}
39+
40+
/** Gets the name of the icon. */
41+
async getName(): Promise<string | null> {
42+
const host = await this.host();
43+
const nameFromDom = await host.getAttribute('data-mat-icon-name');
44+
45+
// If we managed to figure out the name from the attribute, use it.
46+
if (nameFromDom) {
47+
return nameFromDom;
48+
}
49+
50+
// Some icons support defining the icon as a ligature.
51+
// As a fallback, try to extract it from the DOM text.
52+
if (await this.getType() === IconType.FONT) {
53+
return host.text();
54+
}
55+
56+
return null;
57+
}
58+
59+
/** Gets the namespace of the icon. */
60+
async getNamespace(): Promise<string | null> {
61+
return (await this.host()).getAttribute('data-mat-icon-namespace');
62+
}
63+
64+
/** Gets whether the icon is inline. */
65+
async isInline(): Promise<boolean> {
66+
return (await this.host()).hasClass('mat-icon-inline');
67+
}
68+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,6 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9+
export * from './icon-harness';
10+
export * from './icon-harness-filters';
911
export * from './fake-icon-registry';
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import {HarnessLoader} from '@angular/cdk/testing';
2+
import {TestbedHarnessEnvironment} from '@angular/cdk/testing/testbed';
3+
import {Component} from '@angular/core';
4+
import {ComponentFixture, TestBed} from '@angular/core/testing';
5+
import {MatIconModule, MatIconRegistry} from '@angular/material/icon';
6+
import {MatIconHarness} from '@angular/material/icon/testing/icon-harness';
7+
import {DomSanitizer} from '@angular/platform-browser';
8+
import {IconType} from './icon-harness-filters';
9+
10+
/** Shared tests to run on both the original and MDC-based icons. */
11+
export function runHarnessTests(
12+
iconModule: typeof MatIconModule,
13+
iconRegistry: typeof MatIconRegistry,
14+
iconHarness: typeof MatIconHarness) {
15+
let fixture: ComponentFixture<IconHarnessTest>;
16+
let loader: HarnessLoader;
17+
18+
beforeEach(async () => {
19+
await TestBed.configureTestingModule({
20+
imports: [iconModule],
21+
declarations: [IconHarnessTest],
22+
}).compileComponents();
23+
24+
const registry = TestBed.inject(iconRegistry);
25+
const sanitizer = TestBed.inject(DomSanitizer);
26+
27+
registry.addSvgIconLiteralInNamespace('svgIcons', 'svgIcon',
28+
sanitizer.bypassSecurityTrustHtml('<svg></svg>'));
29+
fixture = TestBed.createComponent(IconHarnessTest);
30+
fixture.detectChanges();
31+
loader = TestbedHarnessEnvironment.loader(fixture);
32+
});
33+
34+
it('should load all icon harnesses', async () => {
35+
const icons = await loader.getAllHarnesses(iconHarness);
36+
expect(icons.length).toBe(3);
37+
});
38+
39+
it('should filter icon harnesses based on their type', async () => {
40+
const [svgIcons, fontIcons] = await Promise.all([
41+
loader.getAllHarnesses(iconHarness.with({type: IconType.SVG})),
42+
loader.getAllHarnesses(iconHarness.with({type: IconType.FONT}))
43+
]);
44+
45+
expect(svgIcons.length).toBe(1);
46+
expect(fontIcons.length).toBe(2);
47+
});
48+
49+
it('should filter icon harnesses based on their name', async () => {
50+
const [regexFilterResults, stringFilterResults] = await Promise.all([
51+
loader.getAllHarnesses(iconHarness.with({name: /^font/})),
52+
loader.getAllHarnesses(iconHarness.with({name: 'fontIcon'}))
53+
]);
54+
55+
expect(regexFilterResults.length).toBe(1);
56+
expect(stringFilterResults.length).toBe(1);
57+
});
58+
59+
it('should filter icon harnesses based on their namespace', async () => {
60+
const [regexFilterResults, stringFilterResults, nullFilterResults] = await Promise.all([
61+
loader.getAllHarnesses(iconHarness.with({namespace: /^font/})),
62+
loader.getAllHarnesses(iconHarness.with({namespace: 'svgIcons'})),
63+
loader.getAllHarnesses(iconHarness.with({namespace: null}))
64+
]);
65+
66+
expect(regexFilterResults.length).toBe(1);
67+
expect(stringFilterResults.length).toBe(1);
68+
expect(nullFilterResults.length).toBe(1);
69+
});
70+
71+
it('should get the type of each icon', async () => {
72+
const icons = await loader.getAllHarnesses(iconHarness);
73+
const types = await Promise.all(icons.map(icon => icon.getType()));
74+
expect(types).toEqual([IconType.FONT, IconType.SVG, IconType.FONT]);
75+
});
76+
77+
it('should get the name of an icon', async () => {
78+
const icons = await loader.getAllHarnesses(iconHarness);
79+
const names = await Promise.all(icons.map(icon => icon.getName()));
80+
expect(names).toEqual(['fontIcon', 'svgIcon', 'ligature_icon']);
81+
});
82+
83+
it('should get the namespace of an icon', async () => {
84+
const icons = await loader.getAllHarnesses(iconHarness);
85+
const namespaces = await Promise.all(icons.map(icon => icon.getNamespace()));
86+
expect(namespaces).toEqual(['fontIcons', 'svgIcons', null]);
87+
});
88+
89+
it('should get whether an icon is inline', async () => {
90+
const icons = await loader.getAllHarnesses(iconHarness);
91+
const inlineStates = await Promise.all(icons.map(icon => icon.isInline()));
92+
expect(inlineStates).toEqual([false, false, true]);
93+
});
94+
95+
}
96+
97+
@Component({
98+
template: `
99+
<mat-icon fontSet="fontIcons" fontIcon="fontIcon"></mat-icon>
100+
<mat-icon svgIcon="svgIcons:svgIcon"></mat-icon>
101+
<mat-icon inline>ligature_icon</mat-icon>
102+
`
103+
})
104+
class IconHarnessTest {
105+
}
106+

tools/public_api_guard/material/icon.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ export declare const MAT_ICON_LOCATION: InjectionToken<MatIconLocation>;
2424
export declare function MAT_ICON_LOCATION_FACTORY(): MatIconLocation;
2525

2626
export declare class MatIcon extends _MatIconMixinBase implements OnChanges, OnInit, AfterViewChecked, CanColor, OnDestroy {
27+
_svgName: string | null;
28+
_svgNamespace: string | null;
2729
get fontIcon(): string;
2830
set fontIcon(value: string);
2931
get fontSet(): string;
@@ -32,6 +34,7 @@ export declare class MatIcon extends _MatIconMixinBase implements OnChanges, OnI
3234
set inline(inline: boolean);
3335
svgIcon: string;
3436
constructor(elementRef: ElementRef<HTMLElement>, _iconRegistry: MatIconRegistry, ariaHidden: string, _location: MatIconLocation, _errorHandler: ErrorHandler);
37+
_usingFontIcon(): boolean;
3538
ngAfterViewChecked(): void;
3639
ngOnChanges(changes: SimpleChanges): void;
3740
ngOnDestroy(): void;

tools/public_api_guard/material/icon/testing.d.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,26 @@ export declare class FakeMatIconRegistry implements PublicApi<MatIconRegistry>,
1818
static ɵprov: i0.ɵɵInjectableDef<FakeMatIconRegistry>;
1919
}
2020

21+
export interface IconHarnessFilters extends BaseHarnessFilters {
22+
name?: string | RegExp;
23+
namespace?: string | null | RegExp;
24+
type?: IconType;
25+
}
26+
27+
export declare const enum IconType {
28+
SVG = 0,
29+
FONT = 1
30+
}
31+
32+
export declare class MatIconHarness extends ComponentHarness {
33+
getName(): Promise<string | null>;
34+
getNamespace(): Promise<string | null>;
35+
getType(): Promise<IconType>;
36+
isInline(): Promise<boolean>;
37+
static hostSelector: string;
38+
static with(options?: IconHarnessFilters): HarnessPredicate<MatIconHarness>;
39+
}
40+
2141
export declare class MatIconTestingModule {
2242
static ɵinj: i0.ɵɵInjectorDef<MatIconTestingModule>;
2343
static ɵmod: i0.ɵɵNgModuleDefWithMeta<MatIconTestingModule, never, never, never>;

0 commit comments

Comments
 (0)