diff --git a/modules/ui/src/app/app.component.html b/modules/ui/src/app/app.component.html index 6f9601946..3de72db0c 100644 --- a/modules/ui/src/app/app.component.html +++ b/modules/ui/src/app/app.component.html @@ -296,6 +296,10 @@

Testrun

+ { { selector: selectHasRiskProfiles, value: false }, { selector: selectStatus, value: null }, { selector: selectSystemStatus, value: null }, + { selector: selectIsTestingComplete, value: false }, + { selector: selectRiskProfiles, value: [] }, { selector: selectIsOpenStartTestrun, value: false }, { selector: selectIsOpenWaitSnackBar, value: false }, { selector: selectReports, value: [] }, @@ -189,6 +195,7 @@ describe('AppComponent', () => { FakeSpinnerComponent, FakeShutdownAppComponent, FakeVersionComponent, + FakeTestingCompleteComponent, ], }); @@ -456,6 +463,21 @@ describe('AppComponent', () => { expect(internet).toBeTruthy(); }); + describe('Testing complete', () => { + beforeEach(() => { + store.overrideSelector(selectIsTestingComplete, true); + fixture.detectChanges(); + }); + + it('should have testing complete component', () => { + const testingCompleteComp = compiled.querySelector( + 'app-testing-complete' + ); + + expect(testingCompleteComp).toBeTruthy(); + }); + }); + describe('Callout component visibility', () => { describe('with no connection settings', () => { beforeEach(() => { @@ -867,3 +889,12 @@ class FakeVersionComponent { @Input() consentShown!: boolean; @Output() consentShownEvent = new EventEmitter(); } + +@Component({ + selector: 'app-testing-complete', + template: '
', +}) +class FakeTestingCompleteComponent { + @Input() profiles: Profile[] = []; + @Input() data!: TestrunStatus | null; +} diff --git a/modules/ui/src/app/app.module.ts b/modules/ui/src/app/app.module.ts index 1ab5c9bdb..07c8b4b9e 100644 --- a/modules/ui/src/app/app.module.ts +++ b/modules/ui/src/app/app.module.ts @@ -50,6 +50,7 @@ import { WindowProvider } from './providers/window.provider'; import { CertificatesComponent } from './pages/certificates/certificates.component'; import { LOADER_TIMEOUT_CONFIG_TOKEN } from './services/loaderConfig'; import { WifiComponent } from './components/wifi/wifi.component'; +import { TestingCompleteComponent } from './components/testing-complete/testing-complete.component'; import { MqttModule, IMqttServiceOptions } from 'ngx-mqtt'; import { DynamicTopDirective } from './components/callout/dynamic-top.directive'; @@ -91,6 +92,7 @@ export const MQTT_SERVICE_OPTIONS: IMqttServiceOptions = { MqttModule.forRoot(MQTT_SERVICE_OPTIONS), WifiComponent, DynamicTopDirective, + TestingCompleteComponent, ], providers: [ WindowProvider, diff --git a/modules/ui/src/app/app.store.spec.ts b/modules/ui/src/app/app.store.spec.ts index d5f3f168b..4236b24fd 100644 --- a/modules/ui/src/app/app.store.spec.ts +++ b/modules/ui/src/app/app.store.spec.ts @@ -28,9 +28,12 @@ import { selectInternetConnection, selectIsAllDevicesOutdated, selectIsOpenWaitSnackBar, + selectIsTestingComplete, selectMenuOpened, selectReports, + selectRiskProfiles, selectStatus, + selectSystemStatus, selectTestModules, } from './store/selectors'; import { TestRunService } from './services/test-run.service'; @@ -106,6 +109,9 @@ describe('AppStore', () => { { selector: selectTestModules, value: MOCK_TEST_MODULES }, { selector: selectInternetConnection, value: false }, { selector: selectIsAllDevicesOutdated, value: false }, + { selector: selectSystemStatus, value: null }, + { selector: selectIsTestingComplete, value: false }, + { selector: selectRiskProfiles, value: [] }, ], }), { provide: TestRunService, useValue: mockService }, @@ -172,6 +178,9 @@ describe('AppStore', () => { reports: [], isStatusLoaded: false, systemStatus: null, + testrunStatus: null, + isTestingComplete: false, + riskProfiles: [], hasConnectionSettings: true, isMenuOpen: true, interfaces: {}, diff --git a/modules/ui/src/app/app.store.ts b/modules/ui/src/app/app.store.ts index 06a75d9c9..91280724c 100644 --- a/modules/ui/src/app/app.store.ts +++ b/modules/ui/src/app/app.store.ts @@ -26,9 +26,12 @@ import { selectInterfaces, selectInternetConnection, selectIsAllDevicesOutdated, + selectIsTestingComplete, selectMenuOpened, selectReports, + selectRiskProfiles, selectStatus, + selectSystemStatus, } from './store/selectors'; import { Store } from '@ngrx/store'; import { AppState } from './store/state'; @@ -53,6 +56,7 @@ import { import { FocusManagerService } from './services/focus-manager.service'; import { TestRunMqttService } from './services/test-run-mqtt.service'; import { NotificationService } from './services/notification.service'; +import { Profile } from './model/profile'; export const CONSENT_SHOWN_KEY = 'CONSENT_SHOWN'; export const CALLOUT_STATE_KEY = 'CALLOUT_STATE'; @@ -82,6 +86,12 @@ export class AppStore extends ComponentStore { private settingMissedError$: Observable = this.store.select(selectError); systemStatus$: Observable = this.store.select(selectStatus); + testrunStatus$: Observable = + this.store.select(selectSystemStatus); + isTestingComplete$: Observable = this.store.select( + selectIsTestingComplete + ); + riskProfiles$: Observable = this.store.select(selectRiskProfiles); viewModel$ = this.select({ consentShown: this.consentShown$, @@ -92,6 +102,9 @@ export class AppStore extends ComponentStore { reports: this.reports$, isStatusLoaded: this.isStatusLoaded$, systemStatus: this.systemStatus$, + testrunStatus: this.testrunStatus$, + isTestingComplete: this.isTestingComplete$, + riskProfiles: this.riskProfiles$, hasConnectionSettings: this.hasConnectionSetting$, isMenuOpen: this.isMenuOpen$, interfaces: this.interfaces$, diff --git a/modules/ui/src/app/components/download-report/download-report.component.scss b/modules/ui/src/app/components/download-report/download-report.component.scss index bf4f48926..c937b8bf8 100644 --- a/modules/ui/src/app/components/download-report/download-report.component.scss +++ b/modules/ui/src/app/components/download-report/download-report.component.scss @@ -21,6 +21,7 @@ display: flex; align-items: center; justify-content: center; + text-decoration: none; text-align: center; padding: 4px 0; margin: 0 4px; diff --git a/modules/ui/src/app/components/download-zip-modal/download-zip-modal.component.html b/modules/ui/src/app/components/download-zip-modal/download-zip-modal.component.html index 2e9446cfa..5ef8e15fd 100644 --- a/modules/ui/src/app/components/download-zip-modal/download-zip-modal.component.html +++ b/modules/ui/src/app/components/download-zip-modal/download-zip-modal.component.html @@ -13,7 +13,34 @@ See the License for the specific language governing permissions and limitations under the License. --> -Download ZIP file + +
+

+ {{ data.testrunStatus.device.manufacturer }} + {{ data.testrunStatus.device.model }} v{{ + data.testrunStatus.device.firmware + }} +

+

+ {{ data.testrunStatus.device.test_pack }} testing has just finished +

+
+
+

{{ data.testrunStatus.status }}

+

+ {{ data.testrunStatus.description }} +

+
+
+Download ZIP file

Risk Profile is required for device verification. Please consider going to Please choose a Risk Profile from the list - - - + + + + +

diff --git a/modules/ui/src/app/components/download-zip-modal/download-zip-modal.component.scss b/modules/ui/src/app/components/download-zip-modal/download-zip-modal.component.scss index 0a92617c1..dd937047a 100644 --- a/modules/ui/src/app/components/download-zip-modal/download-zip-modal.component.scss +++ b/modules/ui/src/app/components/download-zip-modal/download-zip-modal.component.scss @@ -13,7 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +@use '@angular/material' as mat; @import '../../../theming/colors'; +@import '../../../theming/variables'; :host { display: grid; @@ -31,6 +33,7 @@ } .risk-profile-select-form-content { + margin: 14px 0 6px; font-family: Roboto, sans-serif; font-size: 14px; line-height: 20px; @@ -49,8 +52,14 @@ } .risk-profile-select-form-actions { + justify-content: flex-end; min-height: 30px; padding: 16px 0 0; + gap: 8px; + + &:has(app-download-report) { + justify-content: space-between; + } } .profile-select { @@ -84,3 +93,81 @@ align-self: center; margin-right: 16px; } +.testing-result-heading { + margin: 16px 0; +} +.testing-result-title { + margin: 0; + font-size: 32px; + line-height: 40px; + text-align: center; + color: $grey-900; +} + +.testing-result-subtitle { + margin: 0; + font-family: $font-secondary; + font-size: 14px; + line-height: 20px; + letter-spacing: 0.2px; + text-align: center; + color: $grey-800; +} + +.testing-result { + display: flex; + height: auto; + min-height: 176px; + align-items: center; + gap: 8px; + margin: 6px 0 10px; + border-radius: 8px; +} + +.testing-result-status { + display: flex; + justify-content: center; + align-items: center; + flex: 1 0 0; + min-width: 208px; + width: fit-content; + height: 100%; + min-height: 176px; + box-sizing: border-box; + margin: 0; + padding: 16px; + border-radius: 8px; + color: $white; + font-size: 24px; + line-height: 32px; + background: red; +} + +.testing-result-description { + display: flex; + justify-content: center; + box-sizing: border-box; + margin: 0; + padding: 8px 24px; + color: $grey-800; + font-family: $font-secondary; + font-size: 14px; + line-height: 20px; + letter-spacing: 0.2px; +} + +.failed-result { + background: $red-50; + + .testing-result-status { + background: $red-800; + } +} + +.success-result { + background: $green-50; + + .testing-result-status { + background: mat.m2-get-color-from-palette($color-accent, 700); + } +} diff --git a/modules/ui/src/app/components/download-zip-modal/download-zip-modal.component.ts b/modules/ui/src/app/components/download-zip-modal/download-zip-modal.component.ts index b042bdabf..e12c9690e 100644 --- a/modules/ui/src/app/components/download-zip-modal/download-zip-modal.component.ts +++ b/modules/ui/src/app/components/download-zip-modal/download-zip-modal.component.ts @@ -20,9 +20,13 @@ import { TestRunService } from '../../services/test-run.service'; import { Routes } from '../../model/routes'; import { RouterLink } from '@angular/router'; import { MatTooltip, MatTooltipModule } from '@angular/material/tooltip'; +import { TestrunStatus, StatusOfTestrun } from '../../model/testrun-status'; +import { DownloadReportComponent } from '../download-report/download-report.component'; interface DialogData { profiles: Profile[]; + testrunStatus?: TestrunStatus; + isTestingComplete?: boolean; } @Component({ @@ -41,6 +45,7 @@ interface DialogData { RouterLink, MatTooltip, MatTooltipModule, + DownloadReportComponent, ], templateUrl: './download-zip-modal.component.html', styleUrl: './download-zip-modal.component.scss', @@ -52,6 +57,7 @@ export class DownloadZipModalComponent extends EscapableDialogComponent { questions: [], } as Profile; public readonly Routes = Routes; + public readonly StatusOfTestrun = StatusOfTestrun; profiles: Profile[] = []; selectedProfile: Profile; constructor( @@ -75,6 +81,7 @@ export class DownloadZipModalComponent extends EscapableDialogComponent { cancel(profile?: Profile | null) { if (profile === null) { this.dialogRef.close(null); + return; } let value = profile?.name; if (profile && profile?.name === this.NO_PROFILE.name) { diff --git a/modules/ui/src/app/components/testing-complete/testing-complete.component.spec.ts b/modules/ui/src/app/components/testing-complete/testing-complete.component.spec.ts new file mode 100644 index 000000000..6ed1a0c9e --- /dev/null +++ b/modules/ui/src/app/components/testing-complete/testing-complete.component.spec.ts @@ -0,0 +1,89 @@ +import { + ComponentFixture, + fakeAsync, + TestBed, + tick, +} from '@angular/core/testing'; + +import { TestingCompleteComponent } from './testing-complete.component'; +import { TestRunService } from '../../services/test-run.service'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { Router } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { Component } from '@angular/core'; +import { MOCK_PROGRESS_DATA_COMPLIANT } from '../../mocks/testrun.mock'; +import { of } from 'rxjs'; +import { MatDialogRef } from '@angular/material/dialog'; +import { DownloadZipModalComponent } from '../download-zip-modal/download-zip-modal.component'; +import { Routes } from '../../model/routes'; + +describe('TestingCompleteComponent', () => { + let component: TestingCompleteComponent; + let fixture: ComponentFixture; + let router: Router; + + const testrunServiceMock: jasmine.SpyObj = + jasmine.createSpyObj('testrunServiceMock', ['downloadZip']); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + RouterTestingModule.withRoutes([ + { path: 'risk-assessment', component: FakeRiskAssessmentComponent }, + ]), + TestingCompleteComponent, + BrowserAnimationsModule, + ], + providers: [{ provide: TestRunService, useValue: testrunServiceMock }], + }).compileComponents(); + + fixture = TestBed.createComponent(TestingCompleteComponent); + component = fixture.componentInstance; + router = TestBed.get(Router); + component.data = MOCK_PROGRESS_DATA_COMPLIANT; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('#onInit', () => { + beforeEach(() => { + testrunServiceMock.downloadZip.calls.reset(); + }); + + it('should call downloadZip on service if profile is a string', fakeAsync(() => { + const openSpy = spyOn(component.dialog, 'open').and.returnValue({ + afterClosed: () => of(''), + } as MatDialogRef); + + component.ngOnInit(); + + expect(openSpy).toHaveBeenCalledWith(DownloadZipModalComponent, { + ariaLabel: 'Testing complete', + data: { + profiles: [], + testrunStatus: MOCK_PROGRESS_DATA_COMPLIANT, + isTestingComplete: true, + }, + autoFocus: true, + hasBackdrop: true, + disableClose: true, + panelClass: 'initiate-test-run-dialog', + }); + + tick(); + + expect(testrunServiceMock.downloadZip).toHaveBeenCalled(); + expect(router.url).not.toBe(Routes.RiskAssessment); + openSpy.calls.reset(); + })); + }); +}); + +@Component({ + selector: 'app-fake-risk-assessment-component', + template: '', +}) +class FakeRiskAssessmentComponent {} diff --git a/modules/ui/src/app/components/testing-complete/testing-complete.component.ts b/modules/ui/src/app/components/testing-complete/testing-complete.component.ts new file mode 100644 index 000000000..53dc686e4 --- /dev/null +++ b/modules/ui/src/app/components/testing-complete/testing-complete.component.ts @@ -0,0 +1,78 @@ +import { + ChangeDetectionStrategy, + Component, + Input, + OnDestroy, + OnInit, +} from '@angular/core'; +import { Subject, takeUntil } from 'rxjs'; +import { MatDialog } from '@angular/material/dialog'; +import { TestRunService } from '../../services/test-run.service'; +import { Router } from '@angular/router'; +import { DownloadZipModalComponent } from '../download-zip-modal/download-zip-modal.component'; +import { Routes } from '../../model/routes'; +import { Profile } from '../../model/profile'; +import { TestrunStatus } from '../../model/testrun-status'; + +@Component({ + selector: 'app-testing-complete', + standalone: true, + imports: [], + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class TestingCompleteComponent implements OnDestroy, OnInit { + @Input() profiles: Profile[] = []; + @Input() data!: TestrunStatus | null; + private destroy$: Subject = new Subject(); + + constructor( + public dialog: MatDialog, + private testrunService: TestRunService, + private route: Router + ) {} + + ngOnInit() { + this.openTestingCompleteModal(); + } + ngOnDestroy() { + this.destroy$.next(true); + this.destroy$.unsubscribe(); + } + + private openTestingCompleteModal(): void { + const dialogRef = this.dialog.open(DownloadZipModalComponent, { + ariaLabel: 'Testing complete', + data: { + profiles: this.profiles, + testrunStatus: this.data, + isTestingComplete: true, + }, + autoFocus: true, + hasBackdrop: true, + disableClose: true, + panelClass: 'initiate-test-run-dialog', + }); + + dialogRef + ?.afterClosed() + .pipe(takeUntil(this.destroy$)) + .subscribe(profile => { + if (profile === undefined) { + return; + } + if (profile === null) { + this.route.navigate([Routes.RiskAssessment]); + } else if (this.data?.report != null) { + this.testrunService.downloadZip( + this.getZipLink(this.data?.report), + profile + ); + } + }); + } + + private getZipLink(reportURL: string): string { + return reportURL.replace('report', 'export'); + } +} diff --git a/modules/ui/src/app/model/testrun-status.ts b/modules/ui/src/app/model/testrun-status.ts index 2dc7f5cb1..776e486d6 100644 --- a/modules/ui/src/app/model/testrun-status.ts +++ b/modules/ui/src/app/model/testrun-status.ts @@ -18,6 +18,7 @@ import { Device } from './device'; export interface TestrunStatus { mac_addr: string | null; status: string; + description?: string; device: IDevice; started: string | null; finished: string | null; diff --git a/modules/ui/src/app/store/actions.ts b/modules/ui/src/app/store/actions.ts index 50ebc4783..b101ec499 100644 --- a/modules/ui/src/app/store/actions.ts +++ b/modules/ui/src/app/store/actions.ts @@ -132,6 +132,11 @@ export const setStatus = createAction( props<{ status: string }>() ); +export const setIsTestingComplete = createAction( + '[Shared] Set Is Open Testing Complete', + props<{ isTestingComplete: boolean }>() +); + export const stopInterval = createAction('[Shared] Stop Interval'); export const fetchRiskProfiles = createAction('[Shared] Fetch risk profiles'); diff --git a/modules/ui/src/app/store/effects.ts b/modules/ui/src/app/store/effects.ts index f176cc130..9dda0caa5 100644 --- a/modules/ui/src/app/store/effects.ts +++ b/modules/ui/src/app/store/effects.ts @@ -45,6 +45,7 @@ import { import { fetchSystemStatus, fetchSystemStatusSuccess, + setIsTestingComplete, setReports, setStatus, setTestrunStatus, @@ -248,6 +249,11 @@ export class AppEffects { return this.actions$.pipe( ofType(AppActions.fetchSystemStatusSuccess), tap(({ systemStatus }) => { + this.store.dispatch( + setIsTestingComplete({ + isTestingComplete: this.isTestrunFinished(systemStatus.status), + }) + ); if (this.testrunService.testrunInProgress(systemStatus.status)) { this.pullingSystemStatusData(); this.fetchInternetConnection(); diff --git a/modules/ui/src/app/store/reducers.spec.ts b/modules/ui/src/app/store/reducers.spec.ts index 983efb484..294de5fac 100644 --- a/modules/ui/src/app/store/reducers.spec.ts +++ b/modules/ui/src/app/store/reducers.spec.ts @@ -27,6 +27,7 @@ import { setIsOpenAddDevice, setIsOpenStartTestrun, setIsOpenWaitSnackBar, + setIsTestingComplete, setReports, setRiskProfiles, setStatus, @@ -262,6 +263,23 @@ describe('Reducer', () => { }); }); + describe('setIsTestingComplete action', () => { + it('should update state', () => { + const initialState = initialSharedState; + const action = setIsTestingComplete({ + isTestingComplete: true, + }); + const state = fromReducer.sharedReducer(initialState, action); + const newState = { + ...initialState, + ...{ isTestingComplete: true }, + }; + + expect(state).toEqual(newState); + expect(state).not.toBe(initialState); + }); + }); + describe('setIsOpenStartTestrun action', () => { it('should update state', () => { const initialState = initialSharedState; diff --git a/modules/ui/src/app/store/reducers.ts b/modules/ui/src/app/store/reducers.ts index fdbf64e44..bc7350c0b 100644 --- a/modules/ui/src/app/store/reducers.ts +++ b/modules/ui/src/app/store/reducers.ts @@ -101,6 +101,12 @@ export const sharedReducer = createReducer( systemStatus, }; }), + on(Actions.setIsTestingComplete, (state, { isTestingComplete }) => { + return { + ...state, + isTestingComplete, + }; + }), on(Actions.setIsOpenStartTestrun, (state, { isOpenStartTestrun }) => { return { ...state, diff --git a/modules/ui/src/app/store/selectors.spec.ts b/modules/ui/src/app/store/selectors.spec.ts index 4d9ac93c3..3919fd349 100644 --- a/modules/ui/src/app/store/selectors.spec.ts +++ b/modules/ui/src/app/store/selectors.spec.ts @@ -36,6 +36,7 @@ import { selectHasExpiredDevices, selectInternetConnection, selectIsAllDevicesOutdated, + selectIsTestingComplete, } from './selectors'; describe('Selectors', () => { @@ -63,6 +64,7 @@ describe('Selectors', () => { systemStatus: null, deviceInProgress: null, status: null, + isTestingComplete: false, reports: [], testModules: [], adapters: {}, @@ -130,6 +132,11 @@ describe('Selectors', () => { expect(result).toEqual(null); }); + it('should select isTestingComplete', () => { + const result = selectIsTestingComplete.projector(initialState); + expect(result).toEqual(false); + }); + it('should select isOpenStartTestrun', () => { const result = selectIsOpenStartTestrun.projector(initialState); expect(result).toEqual(false); diff --git a/modules/ui/src/app/store/selectors.ts b/modules/ui/src/app/store/selectors.ts index e38df84a2..0fec7b492 100644 --- a/modules/ui/src/app/store/selectors.ts +++ b/modules/ui/src/app/store/selectors.ts @@ -88,6 +88,11 @@ export const selectSystemStatus = createSelector( } ); +export const selectIsTestingComplete = createSelector( + selectAppState, + (state: AppState) => state.shared.isTestingComplete +); + export const selectIsOpenWaitSnackBar = createSelector( selectAppState, (state: AppState) => state.shared.isOpenWaitSnackBar diff --git a/modules/ui/src/app/store/state.ts b/modules/ui/src/app/store/state.ts index 53e2cfabe..a0a993fd1 100644 --- a/modules/ui/src/app/store/state.ts +++ b/modules/ui/src/app/store/state.ts @@ -51,6 +51,7 @@ export interface SharedState { //app, testrun status: string | null; systemStatus: TestrunStatus | null; + isTestingComplete: boolean; //app, settings hasConnectionSettings: boolean | null; // app, devices @@ -89,6 +90,7 @@ export const initialSharedState: SharedState = { hasRiskProfiles: false, isOpenStartTestrun: false, systemStatus: null, + isTestingComplete: false, status: null, reports: [], testModules: [],