From e9883c506981063a7fa7770aa234d9c4393d4cef Mon Sep 17 00:00:00 2001 From: Vittorio Lo Preiato <36620514+falconeta@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:04:07 +0200 Subject: [PATCH 1/8] add unit test WIP --- package-lock.json | 174 +++++++++++++++--- package.json | 9 +- .../lib/components/button/ui-button.spec.ts | 168 +++++++++++++++++ .../ui-kit/src/lib/components/p/ui-p.spec.ts | 34 ++++ .../progress-bar/ui-progress-bar.spec.ts | 43 +++++ .../src/lib/components/text/ui-text.spec.ts | 31 ++++ .../src/lib/components/title/ui-title.spec.ts | 53 ++++++ projects/ui-kit/tsconfig.spec.json | 5 +- testing/helpers.ts | 43 +++++ testing/setup.ts | 27 +++ vitest.config.mts | 36 ++++ 11 files changed, 595 insertions(+), 28 deletions(-) create mode 100644 projects/ui-kit/src/lib/components/button/ui-button.spec.ts create mode 100644 projects/ui-kit/src/lib/components/p/ui-p.spec.ts create mode 100644 projects/ui-kit/src/lib/components/progress-bar/ui-progress-bar.spec.ts create mode 100644 projects/ui-kit/src/lib/components/text/ui-text.spec.ts create mode 100644 projects/ui-kit/src/lib/components/title/ui-title.spec.ts create mode 100644 testing/helpers.ts create mode 100644 testing/setup.ts create mode 100644 vitest.config.mts diff --git a/package-lock.json b/package-lock.json index eea9125..aa3bcbf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "tslib": "^2.3.0" }, "devDependencies": { + "@analogjs/vite-plugin-angular": "2.7.1", "@angular-devkit/build-angular": "21.2.18", "@angular/animations": "21.2.17", "@angular/build": "^21.0.0", @@ -39,13 +40,14 @@ "@storybook/addon-a11y": "10.5.10", "@storybook/addon-docs": "10.5.10", "@storybook/angular-vite": "10.5.10", + "@vitest/coverage-v8": "4.1.9", "jsdom": "^28.0.0", "ng-packagr": "^21.0.0", "prettier": "^3.8.1", "storybook": "10.5.10", "typescript": ">=5.9.0 <6.0.0", "vite": "8.2.2", - "vitest": "^4.0.8" + "vitest": "4.1.9" } }, "node_modules/@acemir/cssom": { @@ -298,7 +300,6 @@ "integrity": "sha512-avRNF3ryDkWqBNege4kIuEalGYI7XnmFJXf/IT/hDjSeHObi+NNXLYfYBcZifjOYuChTRcr7FhR2Ogu4EToiBg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "magic-string": "^0.30.21", "obug": "^2.1.1", @@ -3151,6 +3152,16 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", @@ -6456,7 +6467,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -6474,7 +6484,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -6492,7 +6501,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -6510,7 +6518,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -6528,7 +6535,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -6546,7 +6552,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -6564,7 +6569,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -6585,7 +6589,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -6606,7 +6609,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -6627,7 +6629,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -6648,7 +6649,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -6669,7 +6669,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -6690,7 +6689,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -6711,7 +6709,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -6732,7 +6729,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -6750,7 +6746,6 @@ "os": [ "openharmony" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -6765,7 +6760,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, @@ -6786,7 +6780,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -6804,7 +6797,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -6822,7 +6814,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -9357,6 +9348,37 @@ "@types/node": "*" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz", + "integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.9", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.9", + "vitest": "4.1.9" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", @@ -10016,6 +10038,35 @@ "node": ">=4" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz", + "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/autoprefixer": { "version": "10.4.27", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", @@ -12978,6 +13029,13 @@ ], "license": "MIT" }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/htmlparser2": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", @@ -13680,6 +13738,64 @@ "node": ">=10" } }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/istanbul-lib-report/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jackspeak": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", @@ -14595,6 +14711,18 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.4.tgz", + "integrity": "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "source-map-js": "^1.2.1" + } + }, "node_modules/make-dir": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-5.1.0.tgz", @@ -17949,7 +18077,6 @@ "integrity": "sha512-ek9o58+SCv6AV7nchiAcUJy1DNE2CC5WRdBcO0mF+W4oRjNQfPO7b3pLjTHSFECpHkKGOZSQxx3hk8viIL5YCg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@oxc-project/types": "^0.121.0" }, @@ -17988,7 +18115,6 @@ "integrity": "sha512-CGtOARQb9tyv7ECgdAlFxi0Fv7lmzvmlm2rpD/RdijOO9rfk/JvB1CjT8EnoD+tjna/IYgKKw3IV7objRb+aYw==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/Boshen" } diff --git a/package.json b/package.json index 67ca8ac..305f866 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,10 @@ "start": "ng serve", "build": "ng build", "watch": "ng build --watch --configuration development", - "test": "ng test", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "test:typecheck": "tsc --project projects/ui-kit/tsconfig.spec.json --noEmit", "storybook": "ng run ui-kit:storybook", "storybook:build": "ng run ui-kit:build-storybook", "icons:import": "node ./scripts/import-icons.mjs", @@ -37,6 +40,7 @@ "tslib": "^2.3.0" }, "devDependencies": { + "@analogjs/vite-plugin-angular": "2.7.1", "@angular-devkit/build-angular": "21.2.18", "@angular/animations": "21.2.17", "@angular/build": "^21.0.0", @@ -50,12 +54,13 @@ "@storybook/addon-a11y": "10.5.10", "@storybook/addon-docs": "10.5.10", "@storybook/angular-vite": "10.5.10", + "@vitest/coverage-v8": "4.1.9", "jsdom": "^28.0.0", "ng-packagr": "^21.0.0", "prettier": "^3.8.1", "storybook": "10.5.10", "typescript": ">=5.9.0 <6.0.0", "vite": "8.2.2", - "vitest": "^4.0.8" + "vitest": "4.1.9" } } diff --git a/projects/ui-kit/src/lib/components/button/ui-button.spec.ts b/projects/ui-kit/src/lib/components/button/ui-button.spec.ts new file mode 100644 index 0000000..7a48990 --- /dev/null +++ b/projects/ui-kit/src/lib/components/button/ui-button.spec.ts @@ -0,0 +1,168 @@ +import { Component } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { configureComponent, render, setInputs, shadow } from '../../../../../../testing/helpers'; +import { UIButton } from './ui-button'; + +@Component({ + imports: [UIButton], + template: `Read more`, +}) +class InlineHost { + disabled = false; + hidden = false; + clicked = vi.fn(); +} + +describe('UIButton', () => { + it('defaults to a primary, solid button and forwards its accessible name', () => { + const fixture = render(UIButton, { ariaLabel: 'Continue' }); + const button = shadow(fixture).querySelector('ion-button')!; + expect(button.color).toBe('primary'); + expect(button.fill).toBe('solid'); + expect(button.disabled).toBe(false); + expect(button.getAttribute('aria-label')).toBe('Continue'); + expect(button.hasAttribute('aria-hidden')).toBe(false); + expect(button.hasAttribute('tabindex')).toBe(false); + expect(fixture.nativeElement.classList.contains('primary')).toBe(true); + }); + + it.each([ + ['primary', 'primary', 'solid'], + ['secondary', 'default-grey', 'clear'], + ['outlined', 'default-grey', 'outline'], + ])('maps %s to Ionic color %s and fill %s', (variant, color, fill) => { + const fixture = render(UIButton, { ariaLabel: 'Action', variant }); + const button = shadow(fixture).querySelector('ion-button')!; + expect(button.color).toBe(color); + expect(button.fill).toBe(fill); + for (const name of ['primary', 'secondary', 'outlined']) { + expect(fixture.nativeElement.classList.contains(name)).toBe(name === variant); + } + setInputs(fixture, { variant: 'primary' }); + fixture.detectChanges(); + expect(button.fill).toBe('solid'); + }); + + it.each(['icon', 'min-icon'])('exposes the %s size and expand classes', (size) => { + const fixture = render(UIButton, { ariaLabel: 'Action', size, expand: '' }); + expect(fixture.nativeElement.classList.contains(size)).toBe(true); + expect(fixture.nativeElement.classList.contains('expand')).toBe(true); + setInputs(fixture, { size: 'default', expand: 'false' }); + fixture.detectChanges(); + expect(fixture.nativeElement.classList.contains(size)).toBe(false); + expect(fixture.nativeElement.classList.contains('expand')).toBe(false); + }); + + it.each([undefined, '', ' '])('rejects an unlabelled regular button (%j)', (ariaLabel) => { + configureComponent(UIButton); + const fixture = TestBed.createComponent(UIButton); + setInputs(fixture, { ariaLabel }); + expect(() => fixture.detectChanges()).toThrow('[UIButton] ariaLabel is required'); + }); + + it('allows an unlabelled decorative button and removes it from keyboard navigation', () => { + const fixture = render(UIButton, { ariaHidden: '' }); + const button = shadow(fixture).querySelector('ion-button')!; + expect(button.getAttribute('aria-hidden')).toBe('true'); + expect(button.getAttribute('tabindex')).toBe('-1'); + expect(button.disabled).toBe(false); + expect(button.hasAttribute('inert')).toBe(false); + }); + + it.each(['primary', 'outlined'])('shows a spinner and disables %s while loading', (variant) => { + const fixture = render(UIButton, { ariaLabel: 'Action', variant, isLoading: '' }); + expect(shadow(fixture).querySelector('ion-spinner')).not.toBeNull(); + expect(shadow(fixture).querySelector('ion-button')!.disabled).toBe(true); + expect(fixture.componentInstance.isDisabled()).toBe(true); + setInputs(fixture, { isLoading: 'false' }); + fixture.detectChanges(); + expect(shadow(fixture).querySelector('ion-spinner')).toBeNull(); + expect(shadow(fixture).querySelector('ion-button')!.disabled).toBe(false); + }); + + it('ignores loading on secondary buttons but still respects disabled', () => { + const fixture = render(UIButton, { ariaLabel: 'Action', variant: 'secondary', isLoading: true }); + const button = shadow(fixture).querySelector('ion-button')!; + expect(shadow(fixture).querySelector('ion-spinner')).toBeNull(); + expect(button.disabled).toBe(false); + setInputs(fixture, { disabled: '' }); + fixture.detectChanges(); + expect(button.disabled).toBe(true); + }); + + it.each([ + [{ size: 'inline', variant: 'primary' }, 'can only be used'], + [{ size: 'inline', variant: 'outlined' }, 'can only be used'], + [{ size: 'inline', variant: 'secondary', expand: true }, 'cannot be used with expand'], + ])('rejects incompatible inline inputs %j', (inputs, message) => { + configureComponent(UIButton); + const fixture = TestBed.createComponent(UIButton); + setInputs(fixture, inputs); + expect(() => fixture.detectChanges()).toThrow(message); + }); + + describe('inline interaction', () => { + function setup(disabled = false, hidden = false) { + configureComponent(InlineHost); + const fixture = TestBed.createComponent(InlineHost); + Object.assign(fixture.componentInstance, { disabled, hidden }); + fixture.detectChanges(); + const host = fixture.nativeElement.querySelector('ui-button') as HTMLElement; + const action = host.shadowRoot!.querySelector('span')!; + return { fixture, host, action, clicked: fixture.componentInstance.clicked }; + } + + it('uses projected content as the accessible name and keeps inline semantic markup', () => { + const { action, host } = setup(); + expect(action.getAttribute('role')).toBe('button'); + expect(action.getAttribute('tabindex')).toBe('0'); + expect(action.hasAttribute('aria-label')).toBe(false); + expect(action.textContent).toContain('Read more'); + expect(action.querySelector('strong')?.textContent).toBe('more'); + expect(host.classList.contains('inline')).toBe(true); + expect(host.shadowRoot!.querySelector('ion-button')).toBeNull(); + expect(host.shadowRoot!.querySelector('ion-text')!.color).toBe('default-grey'); + }); + + it.each([false, true])('delivers pointer clicks even when ariaHidden=%s', (hidden) => { + const { action, clicked } = setup(false, hidden); + action.click(); + expect(clicked).toHaveBeenCalledTimes(1); + expect(action.hasAttribute('inert')).toBe(false); + expect(action.getAttribute('aria-hidden')).toBe(hidden ? 'true' : null); + expect(action.tabIndex).toBe(hidden ? -1 : 0); + }); + + it('blocks a disabled click before it reaches the consumer', () => { + const { action, clicked } = setup(true); + const event = new MouseEvent('click', { bubbles: true, composed: true, cancelable: true }); + expect(action.dispatchEvent(event)).toBe(false); + expect(clicked).not.toHaveBeenCalled(); + expect(action.getAttribute('aria-disabled')).toBe('true'); + expect(action.tabIndex).toBe(-1); + }); + + it.each(['Enter', ' ', 'Spacebar', 'Escape'])( + 'supports the activation phase for keyboard key %j', + (key) => { + const { action, clicked } = setup(); + const down = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }); + action.dispatchEvent(down); + expect(clicked).toHaveBeenCalledTimes(key === 'Enter' ? 1 : 0); + expect(down.defaultPrevented).toBe(key !== 'Escape'); + const up = new KeyboardEvent('keyup', { key, bubbles: true, cancelable: true }); + action.dispatchEvent(up); + expect(clicked).toHaveBeenCalledTimes(key === 'Escape' ? 0 : 1); + expect(up.defaultPrevented).toBe(key === ' ' || key === 'Spacebar'); + }, + ); + + it.each(['Enter', ' ', 'Spacebar'])('does not activate a disabled action on %j', (key) => { + const { action, clicked } = setup(true); + action.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true })); + action.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true })); + expect(clicked).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/projects/ui-kit/src/lib/components/p/ui-p.spec.ts b/projects/ui-kit/src/lib/components/p/ui-p.spec.ts new file mode 100644 index 0000000..830297d --- /dev/null +++ b/projects/ui-kit/src/lib/components/p/ui-p.spec.ts @@ -0,0 +1,34 @@ +import { Component } from '@angular/core'; +import { render, setInputs, shadow } from '../../../../../../testing/helpers'; +import { UIP } from './ui-p'; + +@Component({ + imports: [UIP], + template: 'Hello world', +}) +class ParagraphHost {} + +describe('UIP', () => { + it('projects text and inline markup into a semantic paragraph without Ionic wrappers', () => { + const fixture = render(ParagraphHost); + const root = fixture.nativeElement.querySelector('ui-p').shadowRoot as ShadowRoot; + expect(root.querySelector('p')?.textContent).toContain('Hello world'); + expect(root.querySelector('p > strong')?.textContent).toBe('world'); + expect(root.querySelector('ion-text')).toBeNull(); + expect(root.querySelector('p')?.hasAttribute('aria-hidden')).toBe(false); + }); + + it.each([true, '', false, 'false'])( + 'coerces ariaHidden=%j and updates the semantic element', + (ariaHidden) => { + const fixture = render(UIP, { ariaHidden }); + const paragraph = shadow(fixture).querySelector('p')!; + expect(paragraph.getAttribute('aria-hidden')).toBe( + ariaHidden === true || ariaHidden === '' ? 'true' : null, + ); + setInputs(fixture, { ariaHidden: false }); + fixture.detectChanges(); + expect(paragraph.hasAttribute('aria-hidden')).toBe(false); + }, + ); +}); diff --git a/projects/ui-kit/src/lib/components/progress-bar/ui-progress-bar.spec.ts b/projects/ui-kit/src/lib/components/progress-bar/ui-progress-bar.spec.ts new file mode 100644 index 0000000..8cec210 --- /dev/null +++ b/projects/ui-kit/src/lib/components/progress-bar/ui-progress-bar.spec.ts @@ -0,0 +1,43 @@ +import { TestBed } from '@angular/core/testing'; +import { configureComponent, render, setInputs, shadow } from '../../../../../../testing/helpers'; +import { UIProgressBar } from './ui-progress-bar'; + +describe('UIProgressBar', () => { + it('defaults to zero progress and exposes the accessible name', () => { + const fixture = render(UIProgressBar, { ariaLabel: 'Registration' }); + const progress = shadow(fixture).querySelector('ion-progress-bar')!; + expect(progress.value).toBe(0); + expect(progress.color).toBe('tertiary'); + expect(progress.getAttribute('aria-label')).toBe('Registration'); + expect(progress.getAttribute('aria-hidden')).toBe('false'); + expect(progress.hasAttribute('aria-valuetext')).toBe(false); + }); + + it('forwards changing values and readable step descriptions', () => { + const fixture = render(UIProgressBar, { ariaLabel: 'Registration', value: 0.5, ariaValueText: '2 of 4' }); + const progress = shadow(fixture).querySelector('ion-progress-bar')!; + expect(progress.value).toBe(0.5); + expect(progress.getAttribute('aria-valuetext')).toBe('2 of 4'); + setInputs(fixture, { value: 1, ariaValueText: '4 of 4', ariaLabel: 'Complete' }); + fixture.detectChanges(); + expect(progress.value).toBe(1); + expect(progress.getAttribute('aria-valuetext')).toBe('4 of 4'); + expect(progress.getAttribute('aria-label')).toBe('Complete'); + }); + + it.each([undefined, '', ' '])('rejects missing accessible labels (%j)', (ariaLabel) => { + configureComponent(UIProgressBar); + const fixture = TestBed.createComponent(UIProgressBar); + setInputs(fixture, { ariaLabel }); + expect(() => fixture.detectChanges()).toThrow('[UIProgressBar] ariaLabel is required'); + }); + + it('allows decorative progress without a label and coerces ariaHidden', () => { + const fixture = render(UIProgressBar, { ariaHidden: '' }); + const progress = shadow(fixture).querySelector('ion-progress-bar')!; + expect(progress.getAttribute('aria-hidden')).toBe('true'); + setInputs(fixture, { ariaHidden: 'false', ariaLabel: 'Progress' }); + fixture.detectChanges(); + expect(progress.getAttribute('aria-hidden')).toBe('false'); + }); +}); diff --git a/projects/ui-kit/src/lib/components/text/ui-text.spec.ts b/projects/ui-kit/src/lib/components/text/ui-text.spec.ts new file mode 100644 index 0000000..2afa75c --- /dev/null +++ b/projects/ui-kit/src/lib/components/text/ui-text.spec.ts @@ -0,0 +1,31 @@ +import { Component } from '@angular/core'; +import { render, setInputs, shadow } from '../../../../../../testing/helpers'; +import { UIText } from './ui-text'; + +@Component({ imports: [UIText], template: 'Hello world' }) +class TextHost {} + +describe('UIText', () => { + it('defaults to inherited typography and default-grey without losing projected markup', () => { + const fixture = render(TextHost); + const host = fixture.nativeElement.querySelector('ui-text') as HTMLElement; + expect(host.getAttribute('data-variant')).toBe('inherit'); + const text = host.shadowRoot!.querySelector('ion-text')!; + expect(text.color).toBe('default-grey'); + expect(text.textContent).toBe('Hello world'); + expect(text.querySelector('strong')?.textContent).toBe('world'); + }); + + it.each(['inherit', 'body-l', 'body-m', 'body-s', 'label-l', 'label-m', 'label-s'])( + 'exposes %s for styling and reactively forwards color', + (variant) => { + const fixture = render(UIText, { variant, color: 'green' }); + expect(fixture.nativeElement.getAttribute('data-variant')).toBe(variant); + expect(shadow(fixture).querySelector('ion-text')!.color).toBe('green'); + setInputs(fixture, { variant: 'inherit', color: 'white' }); + fixture.detectChanges(); + expect(fixture.nativeElement.getAttribute('data-variant')).toBe('inherit'); + expect(shadow(fixture).querySelector('ion-text')!.color).toBe('white'); + }, + ); +}); diff --git a/projects/ui-kit/src/lib/components/title/ui-title.spec.ts b/projects/ui-kit/src/lib/components/title/ui-title.spec.ts new file mode 100644 index 0000000..357d018 --- /dev/null +++ b/projects/ui-kit/src/lib/components/title/ui-title.spec.ts @@ -0,0 +1,53 @@ +import { Component } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { configureComponent, render, setInputs, shadow } from '../../../../../../testing/helpers'; +import { UITitle } from './ui-title'; + +@Component({ imports: [UITitle], template: 'Section title' }) +class TitleHost {} + +describe('UITitle', () => { + it('defaults to h2 with projected content and default-grey text', () => { + const fixture = render(TitleHost); + const root = fixture.nativeElement.querySelector('ui-title').shadowRoot as ShadowRoot; + expect(root.querySelector('h2')?.textContent).toContain('Section title'); + expect(root.querySelector('h2 ion-text')?.getAttribute('mode')).toBe('ios'); + expect(root.querySelector('ion-text')!.color).toBe('default-grey'); + expect(root.querySelector('em')?.textContent).toBe('title'); + }); + + it.each([['xl', 'h1'], ['l', 'h2'], ['m', 'h3'], ['s', 'h4']])( + 'maps size %s to %s and forwards color and a trimmed accessible name', + (size, tag) => { + const fixture = render(UITitle, { size, color: 'green', ariaLabel: ' Accessible title ' }); + const heading = shadow(fixture).querySelector(tag)!; + expect(heading.getAttribute('aria-label')).toBe('Accessible title'); + expect(heading.hasAttribute('aria-hidden')).toBe(false); + expect(shadow(fixture).querySelector('ion-text')!.color).toBe('green'); + setInputs(fixture, { size: 's', ariaLabel: ' ' }); + fixture.detectChanges(); + expect(shadow(fixture).querySelector('h4')!.hasAttribute('aria-label')).toBe(false); + }, + ); + + it.each([undefined, '', ' '])('does not impose a redundant label (%j)', (ariaLabel) => { + const fixture = render(UITitle, { ariaLabel }); + expect(shadow(fixture).querySelector('h2')!.hasAttribute('aria-label')).toBe(false); + }); + + it('coerces ariaHidden and updates the heading accessibility', () => { + const fixture = render(UITitle, { ariaHidden: '' }); + expect(shadow(fixture).querySelector('h2')!.getAttribute('aria-hidden')).toBe('true'); + setInputs(fixture, { ariaHidden: 'false', ariaLabel: 'New title' }); + fixture.detectChanges(); + expect(shadow(fixture).querySelector('h2')!.hasAttribute('aria-hidden')).toBe(false); + expect(shadow(fixture).querySelector('h2')!.getAttribute('aria-label')).toBe('New title'); + }); + + it('rejects a hidden heading with a nonempty accessible label', () => { + configureComponent(UITitle); + const fixture = TestBed.createComponent(UITitle); + setInputs(fixture, { ariaHidden: true, ariaLabel: 'Title' }); + expect(() => fixture.detectChanges()).toThrow('[UITitle] ariaLabel cannot be used'); + }); +}); diff --git a/projects/ui-kit/tsconfig.spec.json b/projects/ui-kit/tsconfig.spec.json index 48fcc2f..f9ed9de 100644 --- a/projects/ui-kit/tsconfig.spec.json +++ b/projects/ui-kit/tsconfig.spec.json @@ -4,7 +4,8 @@ "extends": "../../tsconfig.json", "compilerOptions": { "outDir": "../../out-tsc/spec", - "types": ["vitest/globals"] + "types": ["vitest/globals", "node"], + "sourceMap": true }, - "include": ["src/**/*.d.ts", "src/**/*.spec.ts"] + "include": ["src/**/*.d.ts", "src/**/*.spec.ts", "../../testing/**/*.ts"] } diff --git a/testing/helpers.ts b/testing/helpers.ts new file mode 100644 index 0000000..5f02aac --- /dev/null +++ b/testing/helpers.ts @@ -0,0 +1,43 @@ +import { type Type, provideZonelessChangeDetection } from '@angular/core'; +import { TestBed, type ComponentFixture } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { provideIonicAngular } from '@ionic/angular/standalone'; + +export function configureComponent(component: Type): void { + TestBed.configureTestingModule({ + imports: [component], + providers: [ + provideZonelessChangeDetection(), + provideRouter([]), + provideIonicAngular({ animated: false }), + ], + }); +} + +export function setInputs(fixture: ComponentFixture, inputs: Record): void { + for (const [name, value] of Object.entries(inputs)) { + fixture.componentRef.setInput(name, value); + } +} + +export function render(component: Type, inputs: Record = {}): ComponentFixture { + configureComponent(component); + const fixture = TestBed.createComponent(component); + setInputs(fixture, inputs); + fixture.detectChanges(); + return fixture; +} + +export function shadow(fixture: ComponentFixture): ShadowRoot { + return (fixture.nativeElement as HTMLElement).shadowRoot!; +} + +export function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} diff --git a/testing/setup.ts b/testing/setup.ts new file mode 100644 index 0000000..b032b2d --- /dev/null +++ b/testing/setup.ts @@ -0,0 +1,27 @@ +import '@angular/compiler'; + +import { TestBed } from '@angular/core/testing'; +import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing'; + +TestBed.initTestEnvironment(BrowserTestingModule, platformBrowserTesting(), { + teardown: { destroyAfterEach: true }, + errorOnUnknownElements: true, + errorOnUnknownProperties: true, +}); + +beforeEach(() => { + vi.stubGlobal('matchMedia', (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })); +}); + +afterEach(() => { + TestBed.resetTestingModule(); +}); diff --git a/vitest.config.mts b/vitest.config.mts new file mode 100644 index 0000000..ed2d48f --- /dev/null +++ b/vitest.config.mts @@ -0,0 +1,36 @@ +import angular from '@analogjs/vite-plugin-angular'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + // Transform source files for TestBed in memory. Never build or resolve dist/ui-kit. + plugins: [angular({ tsconfig: 'projects/ui-kit/tsconfig.spec.json', jit: true })], + css: { + preprocessorOptions: { + scss: { loadPaths: ['projects/ui-kit/src/lib'] }, + }, + }, + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['testing/setup.ts'], + include: ['projects/ui-kit/src/**/*.spec.ts'], + restoreMocks: true, + unstubGlobals: true, + maxWorkers: 2, + server: { deps: { inline: [/@ionic\//, /@stencil\//] } }, + coverage: { + provider: 'v8', + include: ['projects/ui-kit/src/lib/**/*.ts'], + exclude: [ + '**/*.spec.ts', + '**/*.stories.ts', + '**/interfaces/**', + '**/*.interface.ts', + '**/icons/**', + ], + reporter: ['text', 'html', 'lcov', 'json-summary'], + reportsDirectory: 'coverage', + thresholds: { lines: 95, statements: 95, functions: 95, branches: 90 }, + }, + }, +}); From 1520e74a74769e4cfb639db130f72d08772bd488 Mon Sep 17 00:00:00 2001 From: Vittorio Lo Preiato <36620514+falconeta@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:16:46 +0200 Subject: [PATCH 2/8] add unit test --- README.md | 8 +- .../lib/components/button/ui-button.spec.ts | 16 +- .../src/lib/components/col/ui-col.spec.ts | 60 ++ .../src/lib/components/grid/ui-grid.spec.ts | 57 ++ .../src/lib/components/icon/icon-map.spec.ts | 16 + .../src/lib/components/icon/ui-icon.spec.ts | 110 ++++ .../src/lib/components/page/ui-page.spec.ts | 526 ++++++++++++++++++ .../progress-bar/ui-progress-bar.spec.ts | 6 +- .../src/lib/components/row/ui-row.spec.ts | 40 ++ .../src/lib/components/title/ui-title.spec.ts | 28 +- .../services/screen-reader.service.spec.ts | 160 ++++++ .../lib/services/status-bar.service.spec.ts | 63 +++ testing/helpers.ts | 5 +- vitest.config.mts | 2 +- 14 files changed, 1076 insertions(+), 21 deletions(-) create mode 100644 projects/ui-kit/src/lib/components/col/ui-col.spec.ts create mode 100644 projects/ui-kit/src/lib/components/grid/ui-grid.spec.ts create mode 100644 projects/ui-kit/src/lib/components/icon/icon-map.spec.ts create mode 100644 projects/ui-kit/src/lib/components/icon/ui-icon.spec.ts create mode 100644 projects/ui-kit/src/lib/components/page/ui-page.spec.ts create mode 100644 projects/ui-kit/src/lib/components/row/ui-row.spec.ts create mode 100644 projects/ui-kit/src/lib/services/screen-reader.service.spec.ts create mode 100644 projects/ui-kit/src/lib/services/status-bar.service.spec.ts diff --git a/README.md b/README.md index 29caf44..c636b2c 100644 --- a/README.md +++ b/README.md @@ -38,12 +38,16 @@ This will compile your project and store the build artifacts in the `dist/` dire ## Running unit tests -To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command: +The unit tests run directly against the library sources in memory and do not build or modify +`dist/ui-kit`. ```bash -ng test +npm test ``` +Use `npm run test:watch` during development, `npm run test:coverage` for the HTML and LCOV +reports in `coverage/`, and `npm run test:typecheck` to validate the test TypeScript sources. + ## Running end-to-end tests For end-to-end (e2e) testing, run: diff --git a/projects/ui-kit/src/lib/components/button/ui-button.spec.ts b/projects/ui-kit/src/lib/components/button/ui-button.spec.ts index 7a48990..fe8bdd9 100644 --- a/projects/ui-kit/src/lib/components/button/ui-button.spec.ts +++ b/projects/ui-kit/src/lib/components/button/ui-button.spec.ts @@ -5,8 +5,14 @@ import { UIButton } from './ui-button'; @Component({ imports: [UIButton], - template: `Read more`, + template: `Read more`, }) class InlineHost { disabled = false; @@ -82,7 +88,11 @@ describe('UIButton', () => { }); it('ignores loading on secondary buttons but still respects disabled', () => { - const fixture = render(UIButton, { ariaLabel: 'Action', variant: 'secondary', isLoading: true }); + const fixture = render(UIButton, { + ariaLabel: 'Action', + variant: 'secondary', + isLoading: true, + }); const button = shadow(fixture).querySelector('ion-button')!; expect(shadow(fixture).querySelector('ion-spinner')).toBeNull(); expect(button.disabled).toBe(false); diff --git a/projects/ui-kit/src/lib/components/col/ui-col.spec.ts b/projects/ui-kit/src/lib/components/col/ui-col.spec.ts new file mode 100644 index 0000000..4e9ef63 --- /dev/null +++ b/projects/ui-kit/src/lib/components/col/ui-col.spec.ts @@ -0,0 +1,60 @@ +import { Component } from '@angular/core'; +import { render, setInputs, shadow } from '../../../../../../testing/helpers'; +import { UICol } from './ui-col'; + +@Component({ imports: [UICol], template: 'Content' }) +class ColHost {} + +@Component({ imports: [UICol], template: 'Legacy' }) +class LegacyColHost {} + +describe('UICol', () => { + it('defaults to start alignment, no spacing and no explicit size', () => { + const fixture = render(UICol); + const col = shadow(fixture).querySelector('ion-col')!; + expect(col.classList).toContain('ion-display-flex'); + expect(col.classList).toContain('ion-align-items-start'); + expect(col.classList).toContain('ion-justify-content-start'); + expect(col.getAttribute('data-horizontal')).toBe('none'); + expect(col.size).toBeUndefined(); + }); + + it.each(['start', 'center', 'end'])('applies align and justify value %s exclusively', (value) => { + const fixture = render(UICol, { align: value, justify: value }); + const col = shadow(fixture).querySelector('ion-col')!; + for (const candidate of ['start', 'center', 'end']) { + expect(col.classList.contains(`ion-align-items-${candidate}`)).toBe(candidate === value); + expect(col.classList.contains(`ion-justify-content-${candidate}`)).toBe(candidate === value); + } + }); + + it.each([ + [6, '6'], + ['12', '12'], + ['auto', 'auto'], + ])('normalizes size %j to %s', (size, expected) => { + const fixture = render(UICol, { size, horizontal: 's' }); + const col = shadow(fixture).querySelector('ion-col')!; + expect(col.size).toBe(expected); + expect(col.getAttribute('data-horizontal')).toBe('s'); + setInputs(fixture, { size: 4 }); + fixture.detectChanges(); + expect(col.size).toBe('4'); + }); + + it.each([ + [ColHost, false, 'Content'], + [LegacyColHost, true, 'Legacy'], + ])('supports isolated and native-slot projection', (hostType, legacy, text) => { + const fixture = render(hostType); + const host = fixture.nativeElement.querySelector('ui-col') as HTMLElement; + const root: ParentNode = legacy ? host : host.shadowRoot!; + const content = root.querySelector('span')!; + expect(content.textContent).toBe(text); + expect(host.shadowRoot!.querySelector('slot') !== null).toBe(legacy); + if (legacy) + expect((host.shadowRoot!.querySelector('slot') as HTMLSlotElement).assignedNodes()).toContain( + content, + ); + }); +}); diff --git a/projects/ui-kit/src/lib/components/grid/ui-grid.spec.ts b/projects/ui-kit/src/lib/components/grid/ui-grid.spec.ts new file mode 100644 index 0000000..4f5b207 --- /dev/null +++ b/projects/ui-kit/src/lib/components/grid/ui-grid.spec.ts @@ -0,0 +1,57 @@ +import { Component } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { configureComponent, render, setInputs, shadow } from '../../../../../../testing/helpers'; +import { UIGrid } from './ui-grid'; + +@Component({ imports: [UIGrid], template: '' }) +class GridHost {} + +@Component({ + imports: [UIGrid], + template: '', +}) +class LegacyGridHost {} + +describe('UIGrid', () => { + it('forwards its layout inputs to ion-grid and updates them', () => { + const fixture = render(UIGrid); + const grid = shadow(fixture).querySelector('ion-grid')!; + expect(grid.getAttribute('data-horizontal')).toBe('xs'); + expect(grid.fixed).toBe(false); + setInputs(fixture, { horizontal: 'm', fixed: '' }); + fixture.detectChanges(); + expect(grid.getAttribute('data-horizontal')).toBe('m'); + expect(grid.fixed).toBe(true); + setInputs(fixture, { fixed: 'false' }); + fixture.detectChanges(); + expect(grid.fixed).toBe(false); + }); + + it('isolates projected content in its shadow root by default', () => { + const fixture = render(GridHost); + const host = fixture.nativeElement.querySelector('ui-grid') as HTMLElement; + expect(host.querySelector('button')).toBeNull(); + expect(host.shadowRoot!.querySelector('ion-grid > button')?.textContent).toBe('Content'); + expect(host.shadowRoot!.querySelector('slot')).toBeNull(); + }); + + it('keeps legacy content in light DOM and assigns it to a native slot', () => { + const fixture = render(LegacyGridHost); + const host = fixture.nativeElement.querySelector('ui-grid') as HTMLElement; + const button = host.querySelector('button')!; + const slot = host.shadowRoot!.querySelector('slot') as HTMLSlotElement; + expect(button.textContent).toBe('Legacy'); + expect(slot.assignedNodes()).toContain(button); + }); + + it('reads legacyContent only at initialization', () => { + configureComponent(UIGrid); + const fixture = TestBed.createComponent(UIGrid); + setInputs(fixture, { legacyContent: false }); + fixture.detectChanges(); + expect(shadow(fixture).querySelector('slot')).toBeNull(); + setInputs(fixture, { legacyContent: true }); + fixture.detectChanges(); + expect(shadow(fixture).querySelector('slot')).toBeNull(); + }); +}); diff --git a/projects/ui-kit/src/lib/components/icon/icon-map.spec.ts b/projects/ui-kit/src/lib/components/icon/icon-map.spec.ts new file mode 100644 index 0000000..cc98e83 --- /dev/null +++ b/projects/ui-kit/src/lib/components/icon/icon-map.spec.ts @@ -0,0 +1,16 @@ +import { iconMap } from './icon-map'; + +describe('iconMap', () => { + it('contains only callable variants that resolve to encoded SVG assets', async () => { + expect(Object.keys(iconMap).length).toBeGreaterThan(40); + const variants = Object.entries(iconMap).flatMap(([name, icons]) => + Object.entries(icons).map(async ([variant, load]) => { + expect(['fill', 'no-fill']).toContain(variant); + expect(load, `${name}/${variant}`).toBeTypeOf('function'); + const asset = await load!(); + expect(asset, `${name}/${variant}`).toMatch(/^data:image\/svg\+xml/); + }), + ); + await Promise.all(variants); + }); +}); diff --git a/projects/ui-kit/src/lib/components/icon/ui-icon.spec.ts b/projects/ui-kit/src/lib/components/icon/ui-icon.spec.ts new file mode 100644 index 0000000..ae8b662 --- /dev/null +++ b/projects/ui-kit/src/lib/components/icon/ui-icon.spec.ts @@ -0,0 +1,110 @@ +import { TestBed } from '@angular/core/testing'; +import { configureComponent, deferred, setInputs, shadow } from '../../../../../../testing/helpers'; + +const loaders = vi.hoisted(() => ({ + fill: vi.fn<() => Promise>(), + noFill: vi.fn<() => Promise>(), +})); + +vi.mock('./icon-map', () => ({ + iconMap: { + arrow: { fill: loaders.fill, 'no-fill': loaders.noFill }, + 'arrow-left': { 'no-fill': loaders.noFill }, + }, +})); + +import { UIIcon } from './ui-icon'; + +async function flush(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +describe('UIIcon', () => { + beforeEach(() => { + loaders.fill.mockReset(); + loaders.noFill.mockReset(); + loaders.fill.mockResolvedValue('data:image/svg+xml,fill'); + loaders.noFill.mockResolvedValue('data:image/svg+xml,no-fill'); + }); + + it('requires an icon name', () => { + configureComponent(UIIcon); + const fixture = TestBed.createComponent(UIIcon); + expect(() => fixture.detectChanges()).toThrow(); + }); + + it('loads the requested asset, hiding the icon until it resolves', async () => { + const pending = deferred(); + loaders.noFill.mockReturnValueOnce(pending.promise); + configureComponent(UIIcon); + const fixture = TestBed.createComponent(UIIcon); + setInputs(fixture, { name: 'arrow', fill: 'no-fill', size: '40', color: 'green' }); + fixture.detectChanges(); + expect(shadow(fixture).querySelector('ion-icon')).toBeNull(); + pending.resolve('data:image/svg+xml,arrow'); + await flush(); + fixture.detectChanges(); + const icon = shadow(fixture).querySelector('ion-icon')!; + expect(icon.src).toBe('data:image/svg+xml,arrow'); + expect(icon.color).toBe('green'); + expect(icon.classList).toContain('size-40'); + expect(icon.getAttribute('aria-hidden')).toBe('true'); + }); + + it('ignores a stale async response after inputs change', async () => { + const first = deferred(); + loaders.fill.mockReturnValueOnce(first.promise); + loaders.noFill.mockResolvedValueOnce('data:image/svg+xml,current'); + configureComponent(UIIcon); + const fixture = TestBed.createComponent(UIIcon); + setInputs(fixture, { name: 'arrow', fill: 'fill' }); + fixture.detectChanges(); + setInputs(fixture, { fill: 'no-fill' }); + fixture.detectChanges(); + await flush(); + fixture.detectChanges(); + expect(shadow(fixture).querySelector('ion-icon')?.src).toBe('data:image/svg+xml,current'); + first.resolve('data:image/svg+xml,stale'); + await flush(); + fixture.detectChanges(); + expect(shadow(fixture).querySelector('ion-icon')?.src).toBe('data:image/svg+xml,current'); + }); + + it('reports unsupported variants without rendering an icon', () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined); + configureComponent(UIIcon); + const fixture = TestBed.createComponent(UIIcon); + setInputs(fixture, { name: 'arrow-left', fill: 'fill' }); + fixture.detectChanges(); + expect(error).toHaveBeenCalledWith('[UI ICON] - icon not found, arrow-left fill'); + expect(shadow(fixture).querySelector('ion-icon')).toBeNull(); + }); + + it('reports loader failures and keeps the icon hidden', async () => { + const failure = new Error('network'); + loaders.fill.mockRejectedValueOnce(failure); + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined); + configureComponent(UIIcon); + const fixture = TestBed.createComponent(UIIcon); + setInputs(fixture, { name: 'arrow', fill: 'fill' }); + fixture.detectChanges(); + await flush(); + expect(error).toHaveBeenCalledWith('[UI ICON] - failed to load icon, arrow fill', failure); + expect(shadow(fixture).querySelector('ion-icon')).toBeNull(); + }); + + it('does not report a rejected request after the component is destroyed', async () => { + const pending = deferred(); + loaders.fill.mockReturnValueOnce(pending.promise); + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined); + configureComponent(UIIcon); + const fixture = TestBed.createComponent(UIIcon); + setInputs(fixture, { name: 'arrow', fill: 'fill' }); + fixture.detectChanges(); + fixture.destroy(); + pending.reject(new Error('late')); + await flush(); + expect(error).not.toHaveBeenCalled(); + }); +}); diff --git a/projects/ui-kit/src/lib/components/page/ui-page.spec.ts b/projects/ui-kit/src/lib/components/page/ui-page.spec.ts new file mode 100644 index 0000000..b926562 --- /dev/null +++ b/projects/ui-kit/src/lib/components/page/ui-page.spec.ts @@ -0,0 +1,526 @@ +import { Component, PLATFORM_ID, signal } from '@angular/core'; +import { TestBed, type ComponentFixture } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { IonContent, IonRouterOutlet, provideIonicAngular } from '@ionic/angular/standalone'; +import { provideZonelessChangeDetection } from '@angular/core'; +import { setInputs } from '../../../../../../testing/helpers'; +import { ScreenReaderService } from '../../services/screen-reader.service'; +import { StatusBarService } from '../../services/status-bar.service'; +import { UIPage, type HeaderStep } from './ui-page'; + +@Component({ + imports: [UIPage], + template: '', +}) +class PageHost {} + +@Component({ + imports: [UIPage], + template: ` + + `, +}) +class FooterPageHost {} + +@Component({ + imports: [UIPage], + template: ` + + `, +}) +class LegacyPageHost {} + +const validStep: HeaderStep = { + currentStep: 2, + totalSteps: 4, + label: 'of', + ariaLabel: 'Registration progress', +}; + +function createScrollable() { + const element = document.createElement('div'); + const state = { clientHeight: 100, scrollHeight: 300, scrollTop: 0 }; + for (const property of ['clientHeight', 'scrollHeight', 'scrollTop'] as const) { + Object.defineProperty(element, property, { + configurable: true, + get: () => state[property], + set: (value: number) => (state[property] = value), + }); + } + return { element, state }; +} + +function createServices() { + const screenReader = { + screenState: signal(false), + screenStateKnown: signal(true), + focus: vi.fn(() => vi.fn()), + }; + const statusBar = { + setStatusBarStyleLight: vi.fn(async () => undefined), + setStatusBarStyleDark: vi.fn(async () => undefined), + }; + return { screenReader, statusBar }; +} + +function configurePage( + services = createServices(), + routerOutlet?: { canGoBack: ReturnType }, +): typeof services { + TestBed.configureTestingModule({ + imports: [UIPage], + providers: [ + provideZonelessChangeDetection(), + provideRouter([]), + provideIonicAngular({ animated: false }), + { provide: ScreenReaderService, useValue: services.screenReader }, + { provide: StatusBarService, useValue: services.statusBar }, + ...(routerOutlet ? [{ provide: IonRouterOutlet, useValue: routerOutlet }] : []), + ], + }); + return services; +} + +function createPage( + inputs: Record, + services = createServices(), + routerOutlet?: { canGoBack: ReturnType }, +): { fixture: ComponentFixture; services: typeof services; root: ShadowRoot } { + configurePage(services, routerOutlet); + const fixture = TestBed.createComponent(UIPage); + setInputs(fixture, inputs); + fixture.detectChanges(); + return { fixture, services, root: fixture.nativeElement.shadowRoot as ShadowRoot }; +} + +async function flush(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +describe('UIPage', () => { + beforeEach(() => { + vi.spyOn(IonContent.prototype, 'getScrollElement').mockResolvedValue( + document.createElement('div'), + ); + }); + + describe('input validation', () => { + it.each([ + [{}, '[UIPage] screenReaderTitle is required when hasHeader is false.'], + [ + { screenReaderTitle: ' ' }, + '[UIPage] screenReaderTitle is required when hasHeader is false.', + ], + [{ hasHeader: true }, '[UIPage] A title or screenReaderTitle is required.'], + [{ hasHeader: true, title: ' ' }, '[UIPage] A title or screenReaderTitle is required.'], + [{ hasHeader: true, title: 'Title', headerType: 'step' }, 'headerStep is required'], + [ + { + hasHeader: true, + title: 'Title', + headerType: 'step', + headerStep: { ...validStep, label: ' ' }, + }, + 'headerStep.label is required', + ], + [ + { + hasHeader: true, + title: 'Title', + headerType: 'step', + headerStep: { ...validStep, ariaLabel: '' }, + }, + 'headerStep.ariaLabel is required', + ], + [ + { + hasHeader: true, + title: 'Title', + headerType: 'step', + headerStep: { ...validStep, totalSteps: 0 }, + }, + 'headerStep.totalSteps must be a positive integer', + ], + [ + { + hasHeader: true, + title: 'Title', + headerType: 'step', + headerStep: { ...validStep, totalSteps: 2.5 }, + }, + 'headerStep.totalSteps must be a positive integer', + ], + [ + { + hasHeader: true, + title: 'Title', + headerType: 'step', + headerStep: { ...validStep, currentStep: 0 }, + }, + 'headerStep.currentStep must be an integer', + ], + [ + { + hasHeader: true, + title: 'Title', + headerType: 'step', + headerStep: { ...validStep, currentStep: 2.5 }, + }, + 'headerStep.currentStep must be an integer', + ], + [ + { + hasHeader: true, + title: 'Title', + headerType: 'step', + headerStep: { ...validStep, currentStep: 5 }, + }, + 'headerStep.currentStep must be an integer', + ], + ])('rejects invalid configuration %#', (inputs, message) => { + configurePage(); + const fixture = TestBed.createComponent(UIPage); + setInputs(fixture, inputs); + expect(() => fixture.detectChanges()).toThrow(message); + }); + + it('allows a screen-reader-only title with or without a visible header title', () => { + const { fixture, root } = createPage({ + hasHeader: true, + screenReaderTitle: 'Accessible only', + }); + expect(root.querySelector('ion-title')).toBeNull(); + expect(root.querySelector('h1.screen-reader-only')?.textContent).toBe('Accessible only'); + fixture.destroy(); + }); + }); + + it('renders a visible title while using screenReaderTitle as its accessible name', () => { + const { root } = createPage({ + hasHeader: true, + title: 'Short title', + screenReaderTitle: 'Full page title', + }); + const title = root.querySelector('ion-title span')!; + expect(title.textContent).toBe('Short title'); + expect(title.getAttribute('role')).toBe('heading'); + expect(title.getAttribute('aria-level')).toBe('1'); + expect(title.getAttribute('aria-label')).toBe('Full page title'); + expect(title.getAttribute('tabindex')).toBe('-1'); + expect(root.querySelector('h1.screen-reader-only')).toBeNull(); + }); + + it('forwards page, header, content and back-button configuration', () => { + const { fixture, root } = createPage({ + hasHeader: true, + hasFooter: true, + title: 'Title', + background: 'yellow', + translucent: true, + forceOverscroll: true, + backButtonDefaultHref: '/home', + }); + const header = root.querySelector('ion-header')!; + const content = root.querySelector('ion-content')!; + const back = root.querySelector('ion-back-button')!; + expect(header.translucent).toBe(true); + expect(root.querySelector('ion-toolbar')?.classList).toContain('background-yellow'); + expect(content.forceOverscroll).toBe(true); + expect(content.fullscreen).toBe(true); + expect(content.scrollEvents).toBe(true); + expect(back.defaultHref).toBe('/home'); + expect(back.text).toBe(''); + expect(back.icon).toBe(fixture.componentInstance.backButtonIcon); + expect(fixture.nativeElement.classList.contains('no-header')).toBe(false); + expect(fixture.nativeElement.classList.contains('no-footer')).toBe(false); + }); + + it('exposes safe computed defaults when no header and no footer are present', () => { + const { fixture } = createPage({ screenReaderTitle: 'Page' }); + expect(fixture.componentInstance.headerStepValue()).toBe(0); + expect(fixture.componentInstance.headerStepAriaLabel()).toBe(''); + expect(fixture.componentInstance.shouldObserveScroll()).toBe(false); + expect(fixture.nativeElement.classList.contains('no-header')).toBe(true); + expect(fixture.nativeElement.classList.contains('no-footer')).toBe(true); + }); + + it.each([ + ['white', 'default-grey', 'green'], + ['tab', 'green', 'green'], + ['green', 'white', 'white'], + ['step', 'default-grey', 'green'], + ])('maps %s header colors to title=%s and icon=%s', (headerType, titleColor, iconColor) => { + const inputs: Record = { hasHeader: true, title: 'Title', headerType }; + if (headerType === 'step') inputs['headerStep'] = validStep; + const { fixture } = createPage(inputs); + expect(fixture.componentInstance.ionicHeaderColor()).toBe(titleColor); + expect(fixture.componentInstance.ionicHeaderIconColor()).toBe(iconColor); + }); + + it('renders and reactively updates a step header', () => { + const { fixture, root } = createPage({ + hasHeader: true, + title: 'Registration', + headerType: 'step', + headerStep: validStep, + }); + expect(fixture.componentInstance.isStepHeader()).toBe(true); + expect(fixture.componentInstance.headerStepValue()).toBe(0.5); + expect(fixture.componentInstance.headerStepAriaLabel()).toBe('2 of 4'); + const progressHost = root.querySelector('ui-progress-bar') as HTMLElement; + const progress = progressHost.shadowRoot!.querySelector('ion-progress-bar')!; + expect(progress.value).toBe(0.5); + expect(progress.getAttribute('aria-label')).toBe('Registration progress'); + expect(progress.getAttribute('aria-valuetext')).toBe('2 of 4'); + const stepTexts = (root.querySelector('ui-p') as HTMLElement).shadowRoot!.querySelectorAll( + 'ui-text', + ); + expect((stepTexts[0] as HTMLElement).shadowRoot?.textContent).toContain('2'); + expect((stepTexts[1] as HTMLElement).shadowRoot?.textContent).toContain('of 4'); + setInputs(fixture, { headerStep: { ...validStep, currentStep: 4 } }); + fixture.detectChanges(); + expect(progress.value).toBe(1); + expect(progress.getAttribute('aria-valuetext')).toBe('4 of 4'); + }); + + it('enables condensed header only when it is safe for accessibility', () => { + const services = createServices(); + const { fixture } = createPage( + { hasHeader: true, title: 'Title', condensedHeader: true }, + services, + ); + expect(fixture.componentInstance.isCondensedHeaderEnabled()).toBe(true); + expect(fixture.componentInstance.hasCondensedHeader()).toBe(true); + services.screenReader.screenState.set(true); + expect(fixture.componentInstance.isCondensedHeaderEnabled()).toBe(false); + services.screenReader.screenState.set(false); + services.screenReader.screenStateKnown.set(false); + expect(fixture.componentInstance.isCondensedHeaderEnabled()).toBe(false); + services.screenReader.screenStateKnown.set(true); + setInputs(fixture, { headerType: 'step', headerStep: validStep }); + fixture.detectChanges(); + expect(fixture.componentInstance.isCondensedHeaderEnabled()).toBe(false); + }); + + it('updates back-stack state on Ionic enter hooks', () => { + const canGoBack = vi.fn().mockReturnValueOnce(false).mockReturnValue(true); + const { fixture } = createPage({ screenReaderTitle: 'Page' }, createServices(), { canGoBack }); + fixture.componentInstance.ionViewWillEnter(); + expect(fixture.componentInstance.canGoBack()).toBe(false); + fixture.componentInstance.ionViewDidEnter(); + expect(fixture.componentInstance.canGoBack()).toBe(true); + fixture.detectChanges(); + expect(fixture.nativeElement.classList.contains('can-go-back')).toBe(true); + }); + + it.each([ + [{ screenReaderTitle: 'Page' }, 'light'], + [{ hasHeader: true, title: 'Title', headerType: 'white' }, 'light'], + [{ hasHeader: true, title: 'Title', headerType: 'tab' }, 'light'], + [{ hasHeader: true, title: 'Title', headerType: 'green' }, 'dark'], + [{ hasHeader: true, title: 'Title', headerType: 'step', headerStep: validStep }, 'light'], + ])('applies the %s status bar policy when entering', (inputs, expected) => { + const services = createServices(); + const { fixture } = createPage(inputs, services); + fixture.componentInstance.ionViewDidEnter(); + expect(services.statusBar.setStatusBarStyleLight).toHaveBeenCalledTimes( + expected === 'light' ? 1 : 0, + ); + expect(services.statusBar.setStatusBarStyleDark).toHaveBeenCalledTimes( + expected === 'dark' ? 1 : 0, + ); + }); + + it('focuses the title on entry and cancels pending requests on re-entry, leave and destroy', () => { + const cancelFirst = vi.fn(); + const cancelSecond = vi.fn(); + const services = createServices(); + services.screenReader.focus.mockReturnValueOnce(cancelFirst).mockReturnValueOnce(cancelSecond); + const { fixture, root } = createPage({ hasHeader: true, title: 'Title' }, services); + fixture.componentInstance.ionViewDidEnter(); + expect(services.screenReader.focus).toHaveBeenCalledWith(root.querySelector('ion-title span')); + fixture.componentInstance.ionViewDidEnter(); + expect(cancelFirst).toHaveBeenCalledOnce(); + fixture.componentInstance.ionViewWillLeave(); + expect(cancelSecond).toHaveBeenCalledOnce(); + fixture.componentInstance.ionViewDidLeave(); + fixture.destroy(); + }); + + it('handles an enter hook before the view title is available', () => { + const services = createServices(); + configurePage(services); + const fixture = TestBed.createComponent(UIPage); + setInputs(fixture, { screenReaderTitle: 'Page' }); + expect(() => fixture.componentInstance.ionViewDidEnter()).not.toThrow(); + expect(services.screenReader.focus).not.toHaveBeenCalled(); + expect(services.statusBar.setStatusBarStyleLight).toHaveBeenCalledOnce(); + }); + + it('executes configured header actions and forwards disabled state', () => { + const callback = vi.fn(); + const { root } = createPage({ + hasHeader: true, + title: 'Title', + headerButtons: [ + { name: 'arrow-left', fill: 'no-fill', ariaLabel: 'Action', callback }, + { + name: 'arrow-left', + fill: 'no-fill', + ariaLabel: 'Disabled', + disabled: true, + callback: vi.fn(), + }, + ], + }); + const buttons = root.querySelectorAll('ui-button'); + expect((buttons[1] as HTMLElement).shadowRoot!.querySelector('ion-button')!.disabled).toBe( + true, + ); + (buttons[0] as HTMLElement).click(); + expect(callback).toHaveBeenCalledOnce(); + }); + + describe('scroll elevations', () => { + it('adds and removes header/footer elevation according to scroll position', async () => { + const { element, state } = createScrollable(); + vi.mocked(IonContent.prototype.getScrollElement).mockResolvedValue(element); + const { fixture } = createPage({ hasHeader: true, title: 'Title', hasFooter: true }); + await flush(); + expect(fixture.componentInstance.isHeaderElevated()).toBe(false); + expect(fixture.componentInstance.isFooterElevated()).toBe(true); + state.scrollTop = 50; + fixture.componentInstance.onContentScroll(); + expect(fixture.componentInstance.isHeaderElevated()).toBe(true); + expect(fixture.componentInstance.isFooterElevated()).toBe(true); + state.scrollTop = 200; + fixture.componentInstance.onContentScroll(); + expect(fixture.componentInstance.isHeaderElevated()).toBe(true); + expect(fixture.componentInstance.isFooterElevated()).toBe(false); + state.scrollHeight = 100; + state.scrollTop = 0; + fixture.componentInstance.onContentScroll(); + expect(fixture.componentInstance.isHeaderElevated()).toBe(false); + expect(fixture.componentInstance.isFooterElevated()).toBe(false); + }); + + it('keeps elevation off before the asynchronous scroll element is available', () => { + const pending = new Promise(() => undefined); + vi.mocked(IonContent.prototype.getScrollElement).mockReturnValue(pending); + const { fixture } = createPage({ hasHeader: true, title: 'Title', hasFooter: true }); + fixture.componentInstance.isHeaderElevated.set(true); + fixture.componentInstance.isFooterElevated.set(true); + fixture.componentInstance.onContentScroll(); + expect(fixture.componentInstance.isHeaderElevated()).toBe(false); + expect(fixture.componentInstance.isFooterElevated()).toBe(false); + }); + + it('observes both scroll viewport and content surface, then disconnects', async () => { + const { element, state } = createScrollable(); + vi.mocked(IonContent.prototype.getScrollElement).mockResolvedValue(element); + const observe = vi.fn(); + const disconnect = vi.fn(); + let notifyResize!: () => void; + vi.stubGlobal( + 'ResizeObserver', + class { + constructor(callback: () => void) { + notifyResize = callback; + } + observe = observe; + disconnect = disconnect; + }, + ); + const { fixture, root } = createPage({ screenReaderTitle: 'Page' }); + await flush(); + expect(observe).toHaveBeenCalledWith(element); + expect(observe).toHaveBeenCalledWith(root.querySelector('.page-content-surface')); + state.scrollTop = 10; + notifyResize(); + expect(fixture.componentInstance.isHeaderElevated()).toBe(false); + fixture.destroy(); + expect(disconnect).toHaveBeenCalledOnce(); + }); + + it('stops initialization safely when the page is destroyed before content resolves', async () => { + let resolveScroll!: (element: HTMLElement) => void; + const pending = new Promise((resolve) => (resolveScroll = resolve)); + vi.mocked(IonContent.prototype.getScrollElement).mockReturnValue(pending); + const { fixture } = createPage({ screenReaderTitle: 'Page' }); + fixture.destroy(); + resolveScroll(document.createElement('div')); + await flush(); + expect(fixture.componentInstance.isHeaderElevated()).toBe(false); + expect(fixture.componentInstance.isFooterElevated()).toBe(false); + }); + }); + + it.each([ + [PageHost, false, 'Question'], + [FooterPageHost, false, 'Question'], + [LegacyPageHost, true, 'Question'], + ])('supports isolated and native-slot body projection', async (hostType, legacy, text) => { + TestBed.configureTestingModule({ + imports: [hostType], + providers: [ + provideZonelessChangeDetection(), + provideRouter([]), + provideIonicAngular({ animated: false }), + ], + }); + const fixture = TestBed.createComponent(hostType); + fixture.detectChanges(); + const page = fixture.nativeElement.querySelector('ui-page') as HTMLElement; + const root: ParentNode = legacy ? page : page.shadowRoot!; + expect(root.querySelector('.body')?.textContent).toBe(text); + expect(page.shadowRoot!.querySelector('slot') !== null).toBe(legacy); + if (hostType !== PageHost) { + expect(page.shadowRoot!.querySelector('ion-footer [footer]')?.textContent).toBe('Footer'); + } + if (legacy) { + expect((page.shadowRoot!.querySelector('slot') as HTMLSlotElement).assignedNodes()).toContain( + page.querySelector('.body'), + ); + } + await flush(); + }); + + it('binds Ionic lifecycle events to the routed wrapper, including through a shadow root', () => { + const services = createServices(); + configurePage(services); + const fixture = TestBed.createComponent(UIPage); + setInputs(fixture, { screenReaderTitle: 'Page' }); + const nav = document.createElement('ion-nav'); + const routedView = document.createElement('section'); + const shadowHost = document.createElement('article'); + const nestedRoot = shadowHost.attachShadow({ mode: 'open' }); + nestedRoot.append(fixture.nativeElement); + routedView.append(shadowHost); + nav.append(routedView); + document.body.append(nav); + fixture.detectChanges(); + routedView.dispatchEvent(new CustomEvent('ionViewDidEnter')); + expect(services.statusBar.setStatusBarStyleLight).toHaveBeenCalledOnce(); + fixture.destroy(); + nav.remove(); + }); + + it('skips browser lifecycle binding on the server platform', () => { + const services = createServices(); + TestBed.configureTestingModule({ + imports: [UIPage], + providers: [ + provideZonelessChangeDetection(), + provideRouter([]), + provideIonicAngular({ animated: false }), + { provide: PLATFORM_ID, useValue: 'server' }, + { provide: ScreenReaderService, useValue: services.screenReader }, + { provide: StatusBarService, useValue: services.statusBar }, + ], + }); + const fixture = TestBed.createComponent(UIPage); + setInputs(fixture, { screenReaderTitle: 'Server page' }); + expect(() => fixture.detectChanges()).not.toThrow(); + }); +}); diff --git a/projects/ui-kit/src/lib/components/progress-bar/ui-progress-bar.spec.ts b/projects/ui-kit/src/lib/components/progress-bar/ui-progress-bar.spec.ts index 8cec210..aa372f5 100644 --- a/projects/ui-kit/src/lib/components/progress-bar/ui-progress-bar.spec.ts +++ b/projects/ui-kit/src/lib/components/progress-bar/ui-progress-bar.spec.ts @@ -14,7 +14,11 @@ describe('UIProgressBar', () => { }); it('forwards changing values and readable step descriptions', () => { - const fixture = render(UIProgressBar, { ariaLabel: 'Registration', value: 0.5, ariaValueText: '2 of 4' }); + const fixture = render(UIProgressBar, { + ariaLabel: 'Registration', + value: 0.5, + ariaValueText: '2 of 4', + }); const progress = shadow(fixture).querySelector('ion-progress-bar')!; expect(progress.value).toBe(0.5); expect(progress.getAttribute('aria-valuetext')).toBe('2 of 4'); diff --git a/projects/ui-kit/src/lib/components/row/ui-row.spec.ts b/projects/ui-kit/src/lib/components/row/ui-row.spec.ts new file mode 100644 index 0000000..9365dfe --- /dev/null +++ b/projects/ui-kit/src/lib/components/row/ui-row.spec.ts @@ -0,0 +1,40 @@ +import { Component } from '@angular/core'; +import { render, setInputs, shadow } from '../../../../../../testing/helpers'; +import { UIRow } from './ui-row'; + +@Component({ imports: [UIRow], template: 'Content' }) +class RowHost {} + +@Component({ imports: [UIRow], template: 'Legacy' }) +class LegacyRowHost {} + +describe('UIRow', () => { + it('exposes default and changing spacing tokens as data attributes', () => { + const fixture = render(UIRow); + const row = shadow(fixture).querySelector('ion-row')!; + expect(row.getAttribute('data-top')).toBe('none'); + expect(row.getAttribute('data-bottom')).toBe('none'); + expect(row.getAttribute('data-gap')).toBe('none'); + setInputs(fixture, { top: 'l', bottom: 'm', gap: 'xxxl' }); + fixture.detectChanges(); + expect(row.getAttribute('data-top')).toBe('l'); + expect(row.getAttribute('data-bottom')).toBe('m'); + expect(row.getAttribute('data-gap')).toBe('xxxl'); + }); + + it.each([ + [RowHost, false, 'Content'], + [LegacyRowHost, true, 'Legacy'], + ])('supports isolated and native-slot projection', (hostType, legacy, text) => { + const fixture = render(hostType); + const host = fixture.nativeElement.querySelector('ui-row') as HTMLElement; + const root: ParentNode = legacy ? host : host.shadowRoot!; + const content = root.querySelector('span')!; + expect(content.textContent).toBe(text); + expect(host.shadowRoot!.querySelector('slot') !== null).toBe(legacy); + if (legacy) + expect((host.shadowRoot!.querySelector('slot') as HTMLSlotElement).assignedNodes()).toContain( + content, + ); + }); +}); diff --git a/projects/ui-kit/src/lib/components/title/ui-title.spec.ts b/projects/ui-kit/src/lib/components/title/ui-title.spec.ts index 357d018..7cad90a 100644 --- a/projects/ui-kit/src/lib/components/title/ui-title.spec.ts +++ b/projects/ui-kit/src/lib/components/title/ui-title.spec.ts @@ -16,19 +16,21 @@ describe('UITitle', () => { expect(root.querySelector('em')?.textContent).toBe('title'); }); - it.each([['xl', 'h1'], ['l', 'h2'], ['m', 'h3'], ['s', 'h4']])( - 'maps size %s to %s and forwards color and a trimmed accessible name', - (size, tag) => { - const fixture = render(UITitle, { size, color: 'green', ariaLabel: ' Accessible title ' }); - const heading = shadow(fixture).querySelector(tag)!; - expect(heading.getAttribute('aria-label')).toBe('Accessible title'); - expect(heading.hasAttribute('aria-hidden')).toBe(false); - expect(shadow(fixture).querySelector('ion-text')!.color).toBe('green'); - setInputs(fixture, { size: 's', ariaLabel: ' ' }); - fixture.detectChanges(); - expect(shadow(fixture).querySelector('h4')!.hasAttribute('aria-label')).toBe(false); - }, - ); + it.each([ + ['xl', 'h1'], + ['l', 'h2'], + ['m', 'h3'], + ['s', 'h4'], + ])('maps size %s to %s and forwards color and a trimmed accessible name', (size, tag) => { + const fixture = render(UITitle, { size, color: 'green', ariaLabel: ' Accessible title ' }); + const heading = shadow(fixture).querySelector(tag)!; + expect(heading.getAttribute('aria-label')).toBe('Accessible title'); + expect(heading.hasAttribute('aria-hidden')).toBe(false); + expect(shadow(fixture).querySelector('ion-text')!.color).toBe('green'); + setInputs(fixture, { size: 's', ariaLabel: ' ' }); + fixture.detectChanges(); + expect(shadow(fixture).querySelector('h4')!.hasAttribute('aria-label')).toBe(false); + }); it.each([undefined, '', ' '])('does not impose a redundant label (%j)', (ariaLabel) => { const fixture = render(UITitle, { ariaLabel }); diff --git a/projects/ui-kit/src/lib/services/screen-reader.service.spec.ts b/projects/ui-kit/src/lib/services/screen-reader.service.spec.ts new file mode 100644 index 0000000..c7317f9 --- /dev/null +++ b/projects/ui-kit/src/lib/services/screen-reader.service.spec.ts @@ -0,0 +1,160 @@ +import { Capacitor } from '@capacitor/core'; +import type { PluginListenerHandle } from '@capacitor/core'; +import { deferred } from '../../../../../testing/helpers'; +import { ScreenReaderService } from './screen-reader.service'; + +const screenReaderMocks = vi.hoisted(() => ({ isEnabled: vi.fn(), addListener: vi.fn() })); + +vi.mock('@capacitor/screen-reader', () => ({ ScreenReader: screenReaderMocks })); + +const listenerHandle: PluginListenerHandle = { remove: vi.fn(async () => undefined) }; + +async function flush(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +describe('ScreenReaderService', () => { + beforeEach(() => { + screenReaderMocks.isEnabled.mockReset(); + screenReaderMocks.addListener.mockReset(); + }); + it('starts in a known disabled state and skips native observation on web', async () => { + vi.spyOn(Capacitor, 'getPlatform').mockReturnValue('web'); + const service = new ScreenReaderService(); + expect(service.screenState()).toBe(false); + expect(service.screenStateKnown()).toBe(true); + expect(screenReaderMocks.isEnabled).not.toHaveBeenCalled(); + expect(screenReaderMocks.addListener).not.toHaveBeenCalled(); + + const element = document.createElement('h1'); + const focus = vi.spyOn(element, 'focus'); + service.focus(element); + await flush(); + expect(focus).not.toHaveBeenCalled(); + }); + + it('loads the initial native state and follows stateChange events', async () => { + vi.spyOn(Capacitor, 'getPlatform').mockReturnValue('ios'); + screenReaderMocks.isEnabled.mockResolvedValue({ value: true }); + let stateChange: ((event: { value: boolean }) => void) | undefined; + screenReaderMocks.addListener.mockImplementation( + (_event: string, listener: (event: { value: boolean }) => void) => { + stateChange = listener; + return Promise.resolve(listenerHandle); + }, + ); + const service = new ScreenReaderService(); + expect(service.screenStateKnown()).toBe(false); + await flush(); + expect(service.screenState()).toBe(true); + expect(service.screenStateKnown()).toBe(true); + stateChange?.({ value: false }); + expect(service.screenState()).toBe(false); + expect(service.screenStateKnown()).toBe(true); + }); + + it('keeps a newer listener state when the initial request resolves late', async () => { + vi.spyOn(Capacitor, 'getPlatform').mockReturnValue('android'); + const initial = deferred<{ value: boolean }>(); + screenReaderMocks.isEnabled.mockReturnValue(initial.promise); + screenReaderMocks.addListener.mockImplementation( + (_event: string, listener: (event: { value: boolean }) => void) => { + listener({ value: true }); + return Promise.resolve(listenerHandle); + }, + ); + const service = new ScreenReaderService(); + expect(service.screenState()).toBe(true); + initial.resolve({ value: false }); + await flush(); + expect(service.screenState()).toBe(true); + }); + + it('does not mark state unknown when an obsolete initial request rejects', async () => { + vi.spyOn(Capacitor, 'getPlatform').mockReturnValue('android'); + const initial = deferred<{ value: boolean }>(); + screenReaderMocks.isEnabled.mockReturnValue(initial.promise); + screenReaderMocks.addListener.mockImplementation( + (_event: string, listener: (event: { value: boolean }) => void) => { + listener({ value: true }); + return Promise.resolve(listenerHandle); + }, + ); + const service = new ScreenReaderService(); + initial.reject(new Error('stale failure')); + await flush(); + expect(service.screenState()).toBe(true); + expect(service.screenStateKnown()).toBe(true); + }); + + it('leaves the state unknown when initial native detection fails', async () => { + vi.spyOn(Capacitor, 'getPlatform').mockReturnValue('ios'); + screenReaderMocks.isEnabled.mockRejectedValue(new Error('unavailable')); + screenReaderMocks.addListener.mockResolvedValue(listenerHandle); + const service = new ScreenReaderService(); + await flush(); + expect(service.screenState()).toBe(false); + expect(service.screenStateKnown()).toBe(false); + }); + + it('continues using the initial state when listener registration fails', async () => { + vi.spyOn(Capacitor, 'getPlatform').mockReturnValue('ios'); + screenReaderMocks.isEnabled.mockResolvedValue({ value: true }); + screenReaderMocks.addListener.mockRejectedValue(new Error('unavailable')); + const service = new ScreenReaderService(); + await flush(); + expect(service.screenState()).toBe(true); + expect(service.screenStateKnown()).toBe(true); + }); + + it('focuses a connected element without scrolling when screen reader is enabled', async () => { + vi.spyOn(Capacitor, 'getPlatform').mockReturnValue('ios'); + screenReaderMocks.isEnabled.mockResolvedValue({ value: true }); + screenReaderMocks.addListener.mockResolvedValue(listenerHandle); + const service = new ScreenReaderService(); + const element = document.createElement('h1'); + document.body.append(element); + const focus = vi.spyOn(element, 'focus'); + service.focus(element); + await flush(); + expect(focus).toHaveBeenCalledWith({ preventScroll: true }); + element.remove(); + }); + + it.each([false, true])( + 'does not focus when cancellation=%s prevents the request', + async (cancel) => { + vi.spyOn(Capacitor, 'getPlatform').mockReturnValue('ios'); + const focusState = deferred<{ value: boolean }>(); + screenReaderMocks.isEnabled + .mockResolvedValueOnce({ value: false }) + .mockReturnValueOnce(focusState.promise); + screenReaderMocks.addListener.mockResolvedValue(listenerHandle); + const service = new ScreenReaderService(); + const element = document.createElement('h1'); + if (cancel) document.body.append(element); + const focus = vi.spyOn(element, 'focus'); + const cancelRequest = service.focus(element); + if (cancel) cancelRequest(); + focusState.resolve({ value: true }); + await flush(); + expect(focus).not.toHaveBeenCalled(); + element.remove(); + }, + ); + + it('does not focus when native state lookup fails', async () => { + vi.spyOn(Capacitor, 'getPlatform').mockReturnValue('ios'); + screenReaderMocks.isEnabled.mockRejectedValue(new Error('unavailable')); + screenReaderMocks.addListener.mockResolvedValue(listenerHandle); + const service = new ScreenReaderService(); + const element = document.createElement('h1'); + document.body.append(element); + const focus = vi.spyOn(element, 'focus'); + service.focus(element); + await flush(); + expect(focus).not.toHaveBeenCalled(); + element.remove(); + }); +}); diff --git a/projects/ui-kit/src/lib/services/status-bar.service.spec.ts b/projects/ui-kit/src/lib/services/status-bar.service.spec.ts new file mode 100644 index 0000000..c042207 --- /dev/null +++ b/projects/ui-kit/src/lib/services/status-bar.service.spec.ts @@ -0,0 +1,63 @@ +import { Capacitor } from '@capacitor/core'; +import { Style } from '@capacitor/status-bar'; +import { StatusBarService } from './status-bar.service'; + +const statusBarMocks = vi.hoisted(() => ({ + setStyle: vi.fn(), + getInfo: vi.fn(), + hide: vi.fn(), + show: vi.fn(), +})); + +vi.mock('@capacitor/status-bar', () => ({ + Style: { Dark: 'DARK', Light: 'LIGHT' }, + StatusBar: statusBarMocks, +})); + +describe('StatusBarService', () => { + beforeEach(() => { + Object.values(statusBarMocks).forEach((mock) => mock.mockReset()); + }); + it('does not call native APIs on the web', async () => { + vi.spyOn(Capacitor, 'getPlatform').mockReturnValue('web'); + statusBarMocks.setStyle.mockResolvedValue(undefined); + const service = new StatusBarService(); + + await service.setStatusBarStyleDark(); + await service.setStatusBarStyleLight(); + await service.hideStatusBar(); + await service.showStatusBar(); + expect(await service.getInfo()).toEqual({}); + expect(statusBarMocks.setStyle).not.toHaveBeenCalled(); + expect(statusBarMocks.getInfo).not.toHaveBeenCalled(); + expect(statusBarMocks.hide).not.toHaveBeenCalled(); + expect(statusBarMocks.show).not.toHaveBeenCalled(); + }); + + it('delegates every operation to the Capacitor plugin on native platforms', async () => { + vi.spyOn(Capacitor, 'getPlatform').mockReturnValue('ios'); + statusBarMocks.setStyle.mockResolvedValue(undefined); + const info = { visible: true, style: Style.Dark, color: '#000000', overlays: false }; + statusBarMocks.getInfo.mockResolvedValue(info); + statusBarMocks.hide.mockResolvedValue(undefined); + statusBarMocks.show.mockResolvedValue(undefined); + const service = new StatusBarService(); + + await service.setStatusBarStyleDark(); + await service.setStatusBarStyleLight(); + await service.hideStatusBar(); + await service.showStatusBar(); + expect(await service.getInfo()).toBe(info); + expect(statusBarMocks.setStyle).toHaveBeenNthCalledWith(1, { style: Style.Dark }); + expect(statusBarMocks.setStyle).toHaveBeenNthCalledWith(2, { style: Style.Light }); + expect(statusBarMocks.hide).toHaveBeenCalledOnce(); + expect(statusBarMocks.show).toHaveBeenCalledOnce(); + expect(statusBarMocks.getInfo).toHaveBeenCalledOnce(); + }); + + it('propagates native failures to the caller', async () => { + vi.spyOn(Capacitor, 'getPlatform').mockReturnValue('android'); + statusBarMocks.setStyle.mockRejectedValue(new Error('unavailable')); + await expect(new StatusBarService().setStatusBarStyleDark()).rejects.toThrow('unavailable'); + }); +}); diff --git a/testing/helpers.ts b/testing/helpers.ts index 5f02aac..77542a5 100644 --- a/testing/helpers.ts +++ b/testing/helpers.ts @@ -20,7 +20,10 @@ export function setInputs(fixture: ComponentFixture, inputs: Record(component: Type, inputs: Record = {}): ComponentFixture { +export function render( + component: Type, + inputs: Record = {}, +): ComponentFixture { configureComponent(component); const fixture = TestBed.createComponent(component); setInputs(fixture, inputs); diff --git a/vitest.config.mts b/vitest.config.mts index ed2d48f..7532701 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -30,7 +30,7 @@ export default defineConfig({ ], reporter: ['text', 'html', 'lcov', 'json-summary'], reportsDirectory: 'coverage', - thresholds: { lines: 95, statements: 95, functions: 95, branches: 90 }, + thresholds: { lines: 100, statements: 100, functions: 100, branches: 100 }, }, }, }); From e8ea5d47d8c5a1191eb9ca7dd952bf32daefc696 Mon Sep 17 00:00:00 2001 From: Vittorio Lo Preiato <36620514+falconeta@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:21:17 +0200 Subject: [PATCH 3/8] add test coverage --- .github/workflows/build-deploy-storybook.yml | 2 +- .github/workflows/test-and-coverage.yml | 25 ++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/test-and-coverage.yml diff --git a/.github/workflows/build-deploy-storybook.yml b/.github/workflows/build-deploy-storybook.yml index ecb149f..e59021b 100644 --- a/.github/workflows/build-deploy-storybook.yml +++ b/.github/workflows/build-deploy-storybook.yml @@ -23,7 +23,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: 24.x + node-version-file: .nvmrc - name: Install dependencies run: npm ci diff --git a/.github/workflows/test-and-coverage.yml b/.github/workflows/test-and-coverage.yml new file mode 100644 index 0000000..69b6478 --- /dev/null +++ b/.github/workflows/test-and-coverage.yml @@ -0,0 +1,25 @@ +on: + pull_request: + types: [opened, synchronize, reopened] + +name: Test and coverage +jobs: + test_and_coverage: + name: Test and coverage + runs-on: ubuntu-latest + steps: + - name: Checking out + uses: actions/checkout@master + with: + # Disabling shallow clone is recommended for improving relevancy of reporting + fetch-depth: 0 + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + - name: Install dependencies + run: npm ci + env: + GITHUB_TOKEN: ${{ secrets.BEP_GITHUB_TOKEN }} + - name: Test and coverage + run: npm run test:coverage From 6127a3a004d7712dc8ef8139f0adc01685d39670 Mon Sep 17 00:00:00 2001 From: Vittorio Lo Preiato <36620514+falconeta@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:51:32 +0200 Subject: [PATCH 4/8] add sonar props --- .sonarcloud.properties | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .sonarcloud.properties diff --git a/.sonarcloud.properties b/.sonarcloud.properties new file mode 100644 index 0000000..124281a --- /dev/null +++ b/.sonarcloud.properties @@ -0,0 +1,15 @@ +# Path to sources +#sonar.sources=. +sonar.exclusions=**/*.spec.ts +#sonar.inclusions= + +# Path to tests +#sonar.tests= +#sonar.test.exclusions= +#sonar.test.inclusions= + +# Source encoding +#sonar.sourceEncoding=UTF-8 + +# Exclusions for copy-paste detection +#sonar.cpd.exclusions= From 36ecfffc5ad7d82298b5562d2ba72cf888606f05 Mon Sep 17 00:00:00 2001 From: Vittorio Lo Preiato <36620514+falconeta@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:53:43 +0200 Subject: [PATCH 5/8] bump --- projects/ui-kit/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/ui-kit/package.json b/projects/ui-kit/package.json index 7b71025..eebb26f 100644 --- a/projects/ui-kit/package.json +++ b/projects/ui-kit/package.json @@ -1,6 +1,6 @@ { "name": "@bepower/ui-kit", - "version": "0.0.2", + "version": "0.0.3", "peerDependencies": { "@angular/common": "^21.0.0", "@angular/core": "^21.0.0", From 175da13632ab0cdbcb3a3d0948ab6e7db7b15458 Mon Sep 17 00:00:00 2001 From: Vittorio Lo Preiato <36620514+falconeta@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:00:14 +0200 Subject: [PATCH 6/8] add some config --- .github/workflows/test-and-coverage.yml | 4 + .sonarcloud.properties | 2 +- README.md | 117 ++++++++++++++++++------ sonar-project.properties | 2 + 4 files changed, 97 insertions(+), 28 deletions(-) create mode 100644 sonar-project.properties diff --git a/.github/workflows/test-and-coverage.yml b/.github/workflows/test-and-coverage.yml index 69b6478..776d213 100644 --- a/.github/workflows/test-and-coverage.yml +++ b/.github/workflows/test-and-coverage.yml @@ -23,3 +23,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.BEP_GITHUB_TOKEN }} - name: Test and coverage run: npm run test:coverage + - name: SonarQube Scan + uses: SonarSource/sonarqube-scan-action@v7 + env: + SONAR_TOKEN: ${{ secrets.SONARCLOUD_TOKEN }} diff --git a/.sonarcloud.properties b/.sonarcloud.properties index 124281a..3bb04ca 100644 --- a/.sonarcloud.properties +++ b/.sonarcloud.properties @@ -1,6 +1,6 @@ # Path to sources #sonar.sources=. -sonar.exclusions=**/*.spec.ts +sonar.exclusions=**/*.spec.ts,.github/**,.vscode/**,.angular/**,coverage/**,dist/**,storybook-static/**,node_modules/**,icons/**,projects/ui-kit/.storybook/**,projects/ui-kit/docs/**,projects/ui-kit/stories/**,projects/ui-kit/src/lib/components/icon/icons/** #sonar.inclusions= # Path to tests diff --git a/README.md b/README.md index c636b2c..f52d9ad 100644 --- a/README.md +++ b/README.md @@ -1,63 +1,126 @@ -# BepowerUiKit +# BePower UI Kit -This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 22.0.4. +[![Quality gate status](https://sonarcloud.io/api/project_badges/measure?project=BePower_ui-kit&metric=alert_status&token=affaccf10d2a93c9969767e9c53063e30ef8e5d2)](https://sonarcloud.io/summary/new_code?id=BePower_ui-kit) -## Development server +Angular and Ionic component library implementing the BePower design system. Components are +standalone, use native Shadow DOM encapsulation, and expose shared foundations for colors, +typography, spacing, sizes, and shadows. -To start a local development server, run: +- [Library and consumer documentation](./projects/ui-kit/README.md) +- [Component documentation](./projects/ui-kit/docs/ui-elements.md) +- [Interactive Storybook](https://app-ui-kit.bepower.tech) + +## Requirements + +- Node.js `v24.17.0`, defined in [`.nvmrc`](./.nvmrc) +- npm `11.13.0`, defined by the `packageManager` field in `package.json` + +GitHub Actions also reads `.nvmrc`, so local development and CI use the same Node.js version. + +## Setup ```bash -ng serve +nvm use +npm ci ``` -Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files. +## Storybook + +Run the interactive component documentation locally: + +```bash +npm run storybook +``` -## Code scaffolding +Storybook imports the library sources directly and does not require a build of `dist/ui-kit`. -Angular CLI includes powerful code scaffolding tools. To generate a new component, run: +Generate the static Storybook website with: ```bash -ng generate component component-name +npm run storybook:build ``` -For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run: +The output is written to `storybook-static/ui-kit`. + +## Unit tests + +Run the complete Vitest suite once: ```bash -ng generate --help +npm test ``` -## Building +Additional commands: + +```bash +npm run test:watch +npm run test:coverage +npm run test:typecheck +``` -To build the project run: +The tests run directly against the library sources in memory and do not build or modify +`dist/ui-kit`. Coverage reports are generated in `coverage/`; the configured thresholds require +100% statements, branches, functions, and lines for the measured runtime code. + +## Library development + +Build the publishable package: + +```bash +npm run build +``` + +The package is written to `dist/ui-kit`. + +Use watch mode when a local consumer is linked to the generated package: ```bash -ng build +npm run watch ``` -This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed. +Watch mode continuously rebuilds `dist/ui-kit`, so it should not be run at the same time as tasks +that expect the linked output to remain unchanged. -## Running unit tests +## Icons -The unit tests run directly against the library sources in memory and do not build or modify -`dist/ui-kit`. +Source SVG files are stored in `icons/`. Import or update the generated TypeScript icon registry +with: ```bash -npm test +npm run icons:import ``` -Use `npm run test:watch` during development, `npm run test:coverage` for the HTML and LCOV -reports in `coverage/`, and `npm run test:typecheck` to validate the test TypeScript sources. +See the [icon importing guide](./projects/ui-kit/src/lib/components/icon/IMPORTING_ICONS.md) for +file naming and variant conventions. + +## Quality analysis + +Pull requests run unit tests, generate the LCOV report, and execute the SonarQube Cloud scanner +through [the test and coverage workflow](./.github/workflows/test-and-coverage.yml). + +The two Sonar configuration files belong to different analysis modes: + +- `sonar-project.properties` is read by the CI-based Sonar scanner and configures the project key + and LCOV report. +- `.sonarcloud.properties` is read only by SonarQube Cloud Automatic Analysis. + +Only one Sonar analysis mode should be active for the project. When using the scanner configured in +the GitHub workflow, CI analysis settings and exclusions must be defined in +`sonar-project.properties`. -## Running end-to-end tests +## Publishing -For end-to-end (e2e) testing, run: +Build the library, then publish the generated package: ```bash -ng e2e +npm run build +npm publish ./dist/ui-kit ``` -Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs. +Keep the leading `./` in the package path; otherwise npm may interpret `dist/ui-kit` as a remote +package reference. -## Additional Resources +## Continuous integration -For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page. +- Pull requests run tests, coverage, and SonarQube Cloud checks. +- Pushes to `main` build and deploy Storybook to GitHub Pages. diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..79a5724 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,2 @@ +sonar.projectKey=BePower_ui-kit +sonar.javascript.lcov.reportPaths=./coverage/lcov.info From c063ecc285045ff7d0376cea1f01682e994788a2 Mon Sep 17 00:00:00 2001 From: Vittorio Lo Preiato <36620514+falconeta@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:02:29 +0200 Subject: [PATCH 7/8] add sonar org --- sonar-project.properties | 1 + 1 file changed, 1 insertion(+) diff --git a/sonar-project.properties b/sonar-project.properties index 79a5724..312f927 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,2 +1,3 @@ sonar.projectKey=BePower_ui-kit +sonar.organization=bepower sonar.javascript.lcov.reportPaths=./coverage/lcov.info From 098590c820d686625adf69d0454bb33b38ce6639 Mon Sep 17 00:00:00 2001 From: Vittorio Lo Preiato <36620514+falconeta@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:22:22 +0200 Subject: [PATCH 8/8] add some fix --- .github/workflows/test-and-coverage.yml | 5 +++++ .sonarcloud.properties | 15 --------------- README.md | 14 ++++---------- sonar-project.properties | 5 ++++- 4 files changed, 13 insertions(+), 26 deletions(-) delete mode 100644 .sonarcloud.properties diff --git a/.github/workflows/test-and-coverage.yml b/.github/workflows/test-and-coverage.yml index 776d213..575c89f 100644 --- a/.github/workflows/test-and-coverage.yml +++ b/.github/workflows/test-and-coverage.yml @@ -1,4 +1,7 @@ on: + push: + branches: + - main pull_request: types: [opened, synchronize, reopened] @@ -23,6 +26,8 @@ jobs: GITHUB_TOKEN: ${{ secrets.BEP_GITHUB_TOKEN }} - name: Test and coverage run: npm run test:coverage + - name: Verify coverage report + run: test -s coverage/lcov.info - name: SonarQube Scan uses: SonarSource/sonarqube-scan-action@v7 env: diff --git a/.sonarcloud.properties b/.sonarcloud.properties deleted file mode 100644 index 3bb04ca..0000000 --- a/.sonarcloud.properties +++ /dev/null @@ -1,15 +0,0 @@ -# Path to sources -#sonar.sources=. -sonar.exclusions=**/*.spec.ts,.github/**,.vscode/**,.angular/**,coverage/**,dist/**,storybook-static/**,node_modules/**,icons/**,projects/ui-kit/.storybook/**,projects/ui-kit/docs/**,projects/ui-kit/stories/**,projects/ui-kit/src/lib/components/icon/icons/** -#sonar.inclusions= - -# Path to tests -#sonar.tests= -#sonar.test.exclusions= -#sonar.test.inclusions= - -# Source encoding -#sonar.sourceEncoding=UTF-8 - -# Exclusions for copy-paste detection -#sonar.cpd.exclusions= diff --git a/README.md b/README.md index f52d9ad..f925c7f 100644 --- a/README.md +++ b/README.md @@ -98,15 +98,9 @@ file naming and variant conventions. Pull requests run unit tests, generate the LCOV report, and execute the SonarQube Cloud scanner through [the test and coverage workflow](./.github/workflows/test-and-coverage.yml). -The two Sonar configuration files belong to different analysis modes: - -- `sonar-project.properties` is read by the CI-based Sonar scanner and configures the project key - and LCOV report. -- `.sonarcloud.properties` is read only by SonarQube Cloud Automatic Analysis. - -Only one Sonar analysis mode should be active for the project. When using the scanner configured in -the GitHub workflow, CI analysis settings and exclusions must be defined in -`sonar-project.properties`. +The CI scanner reads `sonar-project.properties`, which defines the source scope, analysis +exclusions, and the LCOV report generated in `coverage/lcov.info`. Automatic Analysis must remain +disabled in SonarQube Cloud while this workflow is active. ## Publishing @@ -122,5 +116,5 @@ package reference. ## Continuous integration -- Pull requests run tests, coverage, and SonarQube Cloud checks. +- Pull requests and pushes to `main` run tests, coverage, and SonarQube Cloud checks. - Pushes to `main` build and deploy Storybook to GitHub Pages. diff --git a/sonar-project.properties b/sonar-project.properties index 312f927..cf77319 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,3 +1,6 @@ sonar.projectKey=BePower_ui-kit sonar.organization=bepower -sonar.javascript.lcov.reportPaths=./coverage/lcov.info +sonar.sources=projects/ui-kit/src +sonar.exclusions=**/*.spec.ts,projects/ui-kit/src/lib/components/icon/icons/** +sonar.javascript.lcov.reportPaths=coverage/lcov.info +sonar.sourceEncoding=UTF-8