@truenas/ui-components 0.1.46 → 0.1.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@truenas/ui-components",
3
- "version": "0.1.46",
3
+ "version": "0.1.48",
4
4
  "publishConfig": {
5
5
  "registry": "https://registry.npmjs.org",
6
6
  "access": "public"
@@ -0,0 +1,30 @@
1
+ <!-- Static tn-icon -->
2
+ <tn-icon name="folder" library="mdi"></tn-icon>
3
+
4
+ <!-- Bound tn-icon with ternary -->
5
+ <tn-icon [name]="expanded ? 'chevron-down' : 'chevron-right'" library="mdi"></tn-icon>
6
+
7
+ <!-- Material icon -->
8
+ <tn-icon name="check_circle" library="material"></tn-icon>
9
+
10
+ <!-- Registry format (should be skipped) -->
11
+ <tn-icon name="lucide:home"></tn-icon>
12
+
13
+ <!-- tn-icon-button -->
14
+ <tn-icon-button name="close" library="mdi"></tn-icon-button>
15
+
16
+ <!-- Forwarding: tn-empty with static icon -->
17
+ <tn-empty icon="inbox" iconLibrary="mdi" title="No messages"></tn-empty>
18
+
19
+ <!-- Forwarding: tn-empty with bound icon ternary -->
20
+ <tn-empty [icon]="hasData ? 'check' : 'alert'" title="Status"></tn-empty>
21
+
22
+ <!-- Forwarding: tn-input with prefix/suffix icons -->
23
+ <tn-input prefixIcon="search" prefixIconLibrary="mdi"></tn-input>
24
+ <tn-input suffixIcon="eye" suffixIconLibrary="material"></tn-input>
25
+
26
+ <!-- Forwarding: tn-chip (no library, no default) -->
27
+ <tn-chip icon="star"></tn-chip>
28
+
29
+ <!-- Dynamic binding (not extractable) -->
30
+ <tn-empty [icon]="dynamicIcon()" title="Dynamic"></tn-empty>
@@ -0,0 +1 @@
1
+ <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/></svg>
@@ -0,0 +1 @@
1
+ <svg viewBox="0 0 24 24"><path d="M12 2L2 7v10l10 5 10-5V7z"/></svg>
@@ -0,0 +1,24 @@
1
+ import { tnIconMarker } from '../lib/icon/icon-marker';
2
+ import { libIconMarker } from '../lib/icon/icon-marker';
3
+
4
+ // MDI icons
5
+ const saveIcon = tnIconMarker('content-save', 'mdi');
6
+ const deleteIcon = tnIconMarker('delete', 'mdi');
7
+
8
+ // Material icons
9
+ const checkIcon = tnIconMarker('check_circle', 'material');
10
+
11
+ // Custom icon
12
+ const logoIcon = tnIconMarker('my-logo', 'custom');
13
+
14
+ // No library
15
+ const rawIcon = tnIconMarker('some-icon');
16
+
17
+ // Library internal
18
+ const datasetIcon = libIconMarker('tn-dataset');
19
+
20
+ // Array of icons
21
+ const actions = [
22
+ { name: 'Edit', icon: tnIconMarker('pencil', 'mdi') },
23
+ { name: 'Remove', icon: tnIconMarker('trash-can', 'mdi') },
24
+ ];
@@ -5,6 +5,7 @@
5
5
  *
6
6
  * Usage:
7
7
  * npx truenas-icons generate [options]
8
+ * npx truenas-icons validate [options]
8
9
  *
9
10
  * Options:
10
11
  * --src <dirs> Comma-separated source directories to scan (default: ./src/lib,./src/app)
@@ -25,6 +26,8 @@
25
26
  */
26
27
 
27
28
  import { generateSprite } from './generate-sprite.js';
29
+ import { validateIcons, printValidationReport } from './lib/validate-icons.js';
30
+ import { resolveConfig } from './sprite-config-interface.js';
28
31
  import fs from 'fs';
29
32
  import path from 'path';
30
33
 
@@ -33,6 +36,11 @@ truenas-icons - Icon sprite generation for TrueNAS UI components
33
36
 
34
37
  Usage:
35
38
  npx truenas-icons generate [options]
39
+ npx truenas-icons validate [options]
40
+
41
+ Commands:
42
+ generate Scan source files and generate the icon sprite
43
+ validate Check for missing or stale icons without rebuilding
36
44
 
37
45
  Options:
38
46
  --src <dirs> Comma-separated source directories to scan
@@ -67,6 +75,9 @@ Examples:
67
75
  # Generate with defaults
68
76
  npx truenas-icons generate
69
77
 
78
+ # Validate current sprite against code
79
+ npx truenas-icons validate
80
+
70
81
  # Specify custom source directories
71
82
  npx truenas-icons generate --src ./src,./app
72
83
 
@@ -94,8 +105,8 @@ function parseArgs() {
94
105
 
95
106
  if (arg === '--help' || arg === '-h') {
96
107
  parsed.showHelp = true;
97
- } else if (arg === 'generate') {
98
- parsed.command = 'generate';
108
+ } else if (arg === 'generate' || arg === 'validate') {
109
+ parsed.command = arg;
99
110
  } else if (arg === '--src') {
100
111
  parsed.srcDirs = args[++i]?.split(',') || null;
101
112
  } else if (arg === '--output') {
@@ -143,8 +154,8 @@ async function main() {
143
154
  process.exit(0);
144
155
  }
145
156
 
146
- if (!args.command || args.command !== 'generate') {
147
- console.error('Error: No command specified. Use "generate" to create icon sprite.');
157
+ if (!args.command || !['generate', 'validate'].includes(args.command)) {
158
+ console.error('Error: No command specified. Use "generate" or "validate".');
148
159
  console.log('\nRun "truenas-icons --help" for usage information.');
149
160
  process.exit(1);
150
161
  }
@@ -162,11 +173,19 @@ async function main() {
162
173
  projectRoot: process.cwd(),
163
174
  };
164
175
 
165
- console.log('Generating icon sprite...\n');
166
- await generateSprite(config);
167
- console.log('\nIcon sprite generated successfully!');
176
+ if (args.command === 'generate') {
177
+ console.log('Generating icon sprite...\n');
178
+ await generateSprite(config);
179
+ console.log('\nIcon sprite generated successfully!');
180
+ } else if (args.command === 'validate') {
181
+ console.log('Validating icon sprite...\n');
182
+ const resolved = resolveConfig(config);
183
+ const result = validateIcons(resolved);
184
+ const exitCode = printValidationReport(result);
185
+ process.exit(exitCode);
186
+ }
168
187
  } catch (error: any) {
169
- console.error('\nError generating sprite:', error.message);
188
+ console.error('\nError:', error.message);
170
189
  process.exit(1);
171
190
  }
172
191
  }
@@ -2,6 +2,7 @@ import crypto from 'crypto';
2
2
  import fs from 'fs';
3
3
  import { resolve } from 'path';
4
4
  import { buildSprite } from './lib/build-sprite';
5
+ import { discoverForwardingMappings } from './lib/find-icons-in-forwarding-components';
5
6
  import { findIconsInTemplates } from './lib/find-icons-in-templates';
6
7
  import { findIconsWithMarker } from './lib/find-icons-with-marker';
7
8
  import { getIconPaths } from './lib/get-icon-paths';
@@ -35,17 +36,27 @@ export async function generateSprite(config: SpriteGeneratorConfig = {}): Promis
35
36
  console.info(`Loaded ${libraryIcons.size} icon(s) from truenas-ui library`);
36
37
  }
37
38
 
39
+ // Discover icon-forwarding components from source dirs AND from the library
40
+ const forwardingMappings = discoverForwardingMappings(srcDirs, resolved.projectRoot);
41
+ if (forwardingMappings.length > 0) {
42
+ console.info(`Discovered ${forwardingMappings.length} icon-forwarding component(s):`);
43
+ for (const mapping of forwardingMappings) {
44
+ const slots = mapping.iconSlots.map(s => s.iconAttribute).join(', ');
45
+ console.info(` ${mapping.selector} → [${slots}]`);
46
+ }
47
+ }
48
+
38
49
  // Scan all source directories for icon usage, skipping icons already in library
39
50
  const templateIcons = new Set<string>();
40
51
  const markerIcons = new Set<string>();
41
52
 
42
53
  for (const srcDir of srcDirs) {
43
54
  if (fs.existsSync(srcDir)) {
44
- const dirTemplateIcons = findIconsInTemplates(srcDir, libraryIcons);
45
- const dirMarkerIcons = findIconsWithMarker(srcDir, libraryIcons);
55
+ const dirTemplateResult = findIconsInTemplates(srcDir, libraryIcons, forwardingMappings);
56
+ const dirMarkerResult = findIconsWithMarker(srcDir, libraryIcons);
46
57
 
47
- dirTemplateIcons.forEach(icon => templateIcons.add(icon));
48
- dirMarkerIcons.forEach(icon => markerIcons.add(icon));
58
+ dirTemplateResult.icons.forEach(icon => templateIcons.add(icon));
59
+ dirMarkerResult.icons.forEach(icon => markerIcons.add(icon));
49
60
  } else {
50
61
  console.warn(`Source directory not found, skipping: ${srcDir}`);
51
62
  }
@@ -0,0 +1,14 @@
1
+ import type { Config } from 'jest';
2
+
3
+ const config: Config = {
4
+ testEnvironment: 'node',
5
+ transform: {
6
+ '^.+\\.tsx?$': ['ts-jest', {
7
+ tsconfig: '<rootDir>/tsconfig.json',
8
+ }],
9
+ },
10
+ testMatch: ['<rootDir>/**/*.spec.ts'],
11
+ moduleFileExtensions: ['ts', 'js', 'json'],
12
+ };
13
+
14
+ export default config;
@@ -0,0 +1,182 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { discoverForwardingMappings } from './find-icons-in-forwarding-components';
5
+ import type { ForwardingComponentMapping } from './find-icons-in-forwarding-components';
6
+
7
+ function writeManifest(dir: string, mappings: ForwardingComponentMapping[]): void {
8
+ fs.mkdirSync(dir, { recursive: true });
9
+ fs.writeFileSync(
10
+ path.join(dir, 'forwarding-mappings.json'),
11
+ JSON.stringify(mappings),
12
+ );
13
+ }
14
+
15
+ describe('discoverForwardingMappings', () => {
16
+ let tempDir: string;
17
+
18
+ beforeEach(() => {
19
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fwd-mappings-'));
20
+ });
21
+
22
+ afterEach(() => {
23
+ fs.rmSync(tempDir, { recursive: true, force: true });
24
+ });
25
+
26
+ describe('library manifest loading', () => {
27
+ it('should load mappings from the installed library manifest', () => {
28
+ const manifestDir = path.join(tempDir, 'node_modules/@truenas/ui-components/assets/tn-icons');
29
+ writeManifest(manifestDir, [
30
+ {
31
+ selector: 'tn-empty',
32
+ iconSlots: [{ iconAttribute: 'icon', libraryAttribute: 'iconLibrary', defaultLibrary: 'mdi' }],
33
+ },
34
+ {
35
+ selector: 'tn-chip',
36
+ iconSlots: [{ iconAttribute: 'icon' }],
37
+ },
38
+ ]);
39
+
40
+ const mappings = discoverForwardingMappings([], tempDir);
41
+ expect(mappings).toHaveLength(2);
42
+
43
+ const selectors = mappings.map(m => m.selector).sort();
44
+ expect(selectors).toEqual(['tn-chip', 'tn-empty']);
45
+ });
46
+
47
+ it('should extract icon slot details from manifest', () => {
48
+ const manifestDir = path.join(tempDir, 'node_modules/@truenas/ui-components/assets/tn-icons');
49
+ writeManifest(manifestDir, [
50
+ {
51
+ selector: 'tn-empty',
52
+ iconSlots: [{ iconAttribute: 'icon', libraryAttribute: 'iconLibrary', defaultLibrary: 'mdi' }],
53
+ },
54
+ ]);
55
+
56
+ const mappings = discoverForwardingMappings([], tempDir);
57
+ expect(mappings[0].iconSlots[0]).toEqual({
58
+ iconAttribute: 'icon',
59
+ libraryAttribute: 'iconLibrary',
60
+ defaultLibrary: 'mdi',
61
+ });
62
+ });
63
+
64
+ it('should return empty array when library is not installed', () => {
65
+ const mappings = discoverForwardingMappings([], tempDir);
66
+ expect(mappings).toEqual([]);
67
+ });
68
+
69
+ it('should handle corrupt library manifest gracefully', () => {
70
+ const manifestDir = path.join(tempDir, 'node_modules/@truenas/ui-components/assets/tn-icons');
71
+ fs.mkdirSync(manifestDir, { recursive: true });
72
+ fs.writeFileSync(path.join(manifestDir, 'forwarding-mappings.json'), 'not valid json');
73
+
74
+ const mappings = discoverForwardingMappings([], tempDir);
75
+ expect(mappings).toEqual([]);
76
+ });
77
+
78
+ it('should handle manifest with non-array content gracefully', () => {
79
+ const manifestDir = path.join(tempDir, 'node_modules/@truenas/ui-components/assets/tn-icons');
80
+ fs.mkdirSync(manifestDir, { recursive: true });
81
+ fs.writeFileSync(path.join(manifestDir, 'forwarding-mappings.json'), '{"not": "an array"}');
82
+
83
+ const mappings = discoverForwardingMappings([], tempDir);
84
+ expect(mappings).toEqual([]);
85
+ });
86
+ });
87
+
88
+ describe('consumer manifest loading', () => {
89
+ it('should load mappings from a consumer source directory', () => {
90
+ const consumerSrc = path.join(tempDir, 'src/app');
91
+ writeManifest(consumerSrc, [
92
+ {
93
+ selector: 'app-status',
94
+ iconSlots: [{ iconAttribute: 'statusIcon', libraryAttribute: 'statusIconLibrary' }],
95
+ },
96
+ ]);
97
+
98
+ const mappings = discoverForwardingMappings([consumerSrc], tempDir);
99
+ expect(mappings).toHaveLength(1);
100
+ expect(mappings[0].selector).toBe('app-status');
101
+ });
102
+
103
+ it('should ignore source dirs without a manifest', () => {
104
+ const emptySrc = path.join(tempDir, 'src/app');
105
+ fs.mkdirSync(emptySrc, { recursive: true });
106
+
107
+ const mappings = discoverForwardingMappings([emptySrc], tempDir);
108
+ expect(mappings).toEqual([]);
109
+ });
110
+
111
+ it('should handle non-existent source dirs', () => {
112
+ const mappings = discoverForwardingMappings(['/non/existent/path'], tempDir);
113
+ expect(mappings).toEqual([]);
114
+ });
115
+ });
116
+
117
+ describe('merging library and consumer manifests', () => {
118
+ it('should merge mappings from library and consumer', () => {
119
+ // Library provides tn-empty
120
+ const libraryDir = path.join(tempDir, 'node_modules/@truenas/ui-components/assets/tn-icons');
121
+ writeManifest(libraryDir, [
122
+ {
123
+ selector: 'tn-empty',
124
+ iconSlots: [{ iconAttribute: 'icon', libraryAttribute: 'iconLibrary', defaultLibrary: 'mdi' }],
125
+ },
126
+ ]);
127
+
128
+ // Consumer provides app-status
129
+ const consumerSrc = path.join(tempDir, 'src/app');
130
+ writeManifest(consumerSrc, [
131
+ {
132
+ selector: 'app-status',
133
+ iconSlots: [{ iconAttribute: 'statusIcon' }],
134
+ },
135
+ ]);
136
+
137
+ const mappings = discoverForwardingMappings([consumerSrc], tempDir);
138
+ const selectors = mappings.map(m => m.selector).sort();
139
+ expect(selectors).toEqual(['app-status', 'tn-empty']);
140
+ });
141
+
142
+ it('should allow consumer to override a library mapping by selector', () => {
143
+ // Library defines tn-empty with defaultLibrary: 'mdi'
144
+ const libraryDir = path.join(tempDir, 'node_modules/@truenas/ui-components/assets/tn-icons');
145
+ writeManifest(libraryDir, [
146
+ {
147
+ selector: 'tn-empty',
148
+ iconSlots: [{ iconAttribute: 'icon', libraryAttribute: 'iconLibrary', defaultLibrary: 'mdi' }],
149
+ },
150
+ ]);
151
+
152
+ // Consumer overrides tn-empty with a different default
153
+ const consumerSrc = path.join(tempDir, 'src/app');
154
+ writeManifest(consumerSrc, [
155
+ {
156
+ selector: 'tn-empty',
157
+ iconSlots: [{ iconAttribute: 'icon', libraryAttribute: 'iconLibrary', defaultLibrary: 'material' }],
158
+ },
159
+ ]);
160
+
161
+ const mappings = discoverForwardingMappings([consumerSrc], tempDir);
162
+ expect(mappings).toHaveLength(1);
163
+ expect(mappings[0].iconSlots[0].defaultLibrary).toBe('material');
164
+ });
165
+
166
+ it('should merge manifests from multiple consumer source dirs', () => {
167
+ const srcApp = path.join(tempDir, 'src/app');
168
+ writeManifest(srcApp, [
169
+ { selector: 'app-foo', iconSlots: [{ iconAttribute: 'icon' }] },
170
+ ]);
171
+
172
+ const srcLib = path.join(tempDir, 'src/lib');
173
+ writeManifest(srcLib, [
174
+ { selector: 'app-bar', iconSlots: [{ iconAttribute: 'barIcon' }] },
175
+ ]);
176
+
177
+ const mappings = discoverForwardingMappings([srcApp, srcLib], tempDir);
178
+ const selectors = mappings.map(m => m.selector).sort();
179
+ expect(selectors).toEqual(['app-bar', 'app-foo']);
180
+ });
181
+ });
182
+ });
@@ -0,0 +1,68 @@
1
+ import fs from 'fs';
2
+ import { resolve } from 'path';
3
+
4
+ export interface IconSlotMapping {
5
+ iconAttribute: string;
6
+ libraryAttribute?: string;
7
+ defaultLibrary?: string;
8
+ }
9
+
10
+ export interface ForwardingComponentMapping {
11
+ selector: string;
12
+ iconSlots: IconSlotMapping[];
13
+ }
14
+
15
+ /**
16
+ * Load forwarding component mappings from JSON manifest files.
17
+ *
18
+ * Mappings tell the sprite scanner which component attributes forward icon
19
+ * values to `<tn-icon>`. For example, `<tn-empty icon="inbox">` forwards
20
+ * its `icon` attribute to an internal `<tn-icon [name]>`.
21
+ *
22
+ * Sources (merged in order, later entries override by selector):
23
+ * 1. The library's published manifest at
24
+ * `node_modules/@truenas/ui-components/assets/tn-icons/forwarding-mappings.json`
25
+ * 2. Any `forwarding-mappings.json` files found in the consumer's source dirs
26
+ */
27
+ export function discoverForwardingMappings(srcDirs: string[], projectRoot: string): ForwardingComponentMapping[] {
28
+ const bySelector = new Map<string, ForwardingComponentMapping>();
29
+
30
+ // Load library manifest (published with the npm package)
31
+ const libraryMappings = loadManifest(resolve(
32
+ projectRoot,
33
+ 'node_modules/@truenas/ui-components/assets/tn-icons/forwarding-mappings.json',
34
+ ));
35
+ for (const mapping of libraryMappings) {
36
+ bySelector.set(mapping.selector, mapping);
37
+ }
38
+
39
+ // Load consumer manifests from source dirs
40
+ for (const srcDir of srcDirs) {
41
+ const consumerManifest = resolve(srcDir, 'forwarding-mappings.json');
42
+ for (const mapping of loadManifest(consumerManifest)) {
43
+ bySelector.set(mapping.selector, mapping);
44
+ }
45
+ }
46
+
47
+ return [...bySelector.values()];
48
+ }
49
+
50
+ /**
51
+ * Read and parse a forwarding-mappings.json file.
52
+ * Returns an empty array if the file is missing or malformed.
53
+ */
54
+ function loadManifest(filePath: string): ForwardingComponentMapping[] {
55
+ if (!fs.existsSync(filePath)) {
56
+ return [];
57
+ }
58
+
59
+ try {
60
+ const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
61
+ if (!Array.isArray(data)) {
62
+ return [];
63
+ }
64
+ return data;
65
+ } catch {
66
+ return [];
67
+ }
68
+ }
@@ -0,0 +1,119 @@
1
+ import path from 'path';
2
+ import { findIconsInTemplates } from './find-icons-in-templates';
3
+ import type { ForwardingComponentMapping } from './find-icons-in-forwarding-components';
4
+
5
+ const CONSUMER_FIXTURES = path.resolve(__dirname, '../__fixtures__/consumer-templates');
6
+
7
+ const FORWARDING_MAPPINGS: ForwardingComponentMapping[] = [
8
+ {
9
+ selector: 'tn-empty',
10
+ iconSlots: [{ iconAttribute: 'icon', libraryAttribute: 'iconLibrary', defaultLibrary: 'mdi' }],
11
+ },
12
+ {
13
+ selector: 'tn-input',
14
+ iconSlots: [
15
+ { iconAttribute: 'prefixIcon', libraryAttribute: 'prefixIconLibrary' },
16
+ { iconAttribute: 'suffixIcon', libraryAttribute: 'suffixIconLibrary' },
17
+ ],
18
+ },
19
+ {
20
+ selector: 'tn-chip',
21
+ iconSlots: [{ iconAttribute: 'icon' }],
22
+ },
23
+ ];
24
+
25
+ describe('findIconsInTemplates', () => {
26
+ describe('tn-icon scanning (existing behavior)', () => {
27
+ it('should find static tn-icon names with library prefix', () => {
28
+ const { icons } = findIconsInTemplates(CONSUMER_FIXTURES);
29
+ expect(icons.has('mdi-folder')).toBe(true);
30
+ });
31
+
32
+ it('should find tn-icon ternary expressions', () => {
33
+ const { icons } = findIconsInTemplates(CONSUMER_FIXTURES);
34
+ expect(icons.has('mdi-chevron-down')).toBe(true);
35
+ expect(icons.has('mdi-chevron-right')).toBe(true);
36
+ });
37
+
38
+ it('should find material library icons', () => {
39
+ const { icons } = findIconsInTemplates(CONSUMER_FIXTURES);
40
+ expect(icons.has('mat-check_circle')).toBe(true);
41
+ });
42
+
43
+ it('should skip registry format icons (with colon)', () => {
44
+ const { icons } = findIconsInTemplates(CONSUMER_FIXTURES);
45
+ const registryIcons = [...icons].filter(i => i.includes(':'));
46
+ expect(registryIcons).toEqual([]);
47
+ });
48
+
49
+ it('should find tn-icon-button names', () => {
50
+ const { icons } = findIconsInTemplates(CONSUMER_FIXTURES);
51
+ expect(icons.has('mdi-close')).toBe(true);
52
+ });
53
+ });
54
+
55
+ describe('forwarding component scanning', () => {
56
+ it('should find icons from tn-empty static attributes', () => {
57
+ const { icons } = findIconsInTemplates(CONSUMER_FIXTURES, undefined, FORWARDING_MAPPINGS);
58
+ expect(icons.has('mdi-inbox')).toBe(true);
59
+ });
60
+
61
+ it('should find icons from tn-empty ternary with default library', () => {
62
+ const { icons } = findIconsInTemplates(CONSUMER_FIXTURES, undefined, FORWARDING_MAPPINGS);
63
+ // tn-empty has defaultLibrary: 'mdi', ternary values get prefixed
64
+ expect(icons.has('mdi-check')).toBe(true);
65
+ expect(icons.has('mdi-alert')).toBe(true);
66
+ });
67
+
68
+ it('should find icons from tn-input prefixIcon with explicit library', () => {
69
+ const { icons } = findIconsInTemplates(CONSUMER_FIXTURES, undefined, FORWARDING_MAPPINGS);
70
+ expect(icons.has('mdi-search')).toBe(true);
71
+ });
72
+
73
+ it('should find icons from tn-input suffixIcon with material library', () => {
74
+ const { icons } = findIconsInTemplates(CONSUMER_FIXTURES, undefined, FORWARDING_MAPPINGS);
75
+ expect(icons.has('mat-eye')).toBe(true);
76
+ });
77
+
78
+ it('should find icons from tn-chip (no library, no default)', () => {
79
+ const { icons } = findIconsInTemplates(CONSUMER_FIXTURES, undefined, FORWARDING_MAPPINGS);
80
+ expect(icons.has('star')).toBe(true);
81
+ });
82
+
83
+ it('should not extract dynamic signal bindings', () => {
84
+ const { icons } = findIconsInTemplates(CONSUMER_FIXTURES, undefined, FORWARDING_MAPPINGS);
85
+ // [icon]="dynamicIcon()" should NOT produce any icon
86
+ const dynamicArtifacts = [...icons].filter(i => i.includes('dynamic') || i.includes('Icon'));
87
+ expect(dynamicArtifacts).toEqual([]);
88
+ });
89
+
90
+ it('should not find forwarding icons when mappings are not provided', () => {
91
+ const withMappings = findIconsInTemplates(CONSUMER_FIXTURES, undefined, FORWARDING_MAPPINGS);
92
+ const withoutMappings = findIconsInTemplates(CONSUMER_FIXTURES);
93
+
94
+ // Without mappings, tn-empty/tn-input/tn-chip icons should be missing
95
+ expect(withMappings.icons.size).toBeGreaterThan(withoutMappings.icons.size);
96
+ expect(withoutMappings.icons.has('mdi-inbox')).toBe(false);
97
+ expect(withoutMappings.icons.has('mdi-search')).toBe(false);
98
+ });
99
+ });
100
+
101
+ describe('skipIcons filtering', () => {
102
+ it('should exclude icons in the skip set', () => {
103
+ const skipIcons = new Set(['mdi-folder', 'mat-check_circle']);
104
+ const { icons } = findIconsInTemplates(CONSUMER_FIXTURES, skipIcons);
105
+ expect(icons.has('mdi-folder')).toBe(false);
106
+ expect(icons.has('mat-check_circle')).toBe(false);
107
+ });
108
+ });
109
+
110
+ describe('source tracking', () => {
111
+ it('should return source file paths for each icon', () => {
112
+ const { sources } = findIconsInTemplates(CONSUMER_FIXTURES);
113
+ expect(sources.has('mdi-folder')).toBe(true);
114
+ const folderSources = sources.get('mdi-folder')!;
115
+ expect(folderSources.length).toBeGreaterThan(0);
116
+ expect(folderSources[0]).toContain('basic.component.html');
117
+ });
118
+ });
119
+ });