From 1c678a3f1c322974b97f5de57f337ba476183642 Mon Sep 17 00:00:00 2001 From: kurilova Date: Thu, 27 Aug 2026 11:00:49 +0000 Subject: [PATCH 1/4] Adds timer --- docs/dev/mockoon.json | 4 +- modules/ui/src/app/app.store.spec.ts | 3 + .../testrun-status-card.component.html | 11 +- .../testrun-status-card.component.scss | 19 + .../testrun-status-card.component.spec.ts | 22 +- .../testrun-status-card.component.ts | 64 +- .../components/timer/timer.component.html | 62 ++ .../components/timer/timer.component.scss | 108 +++ .../components/timer/timer.component.spec.ts | 686 ++++++++++++++++++ .../components/timer/timer.component.ts | 283 ++++++++ .../services/local-storage.service.spec.ts | 3 + modules/ui/src/app/store/selectors.ts | 7 +- modules/ui/src/index.html | 2 +- 13 files changed, 1253 insertions(+), 21 deletions(-) create mode 100644 modules/ui/src/app/pages/testrun/components/timer/timer.component.html create mode 100644 modules/ui/src/app/pages/testrun/components/timer/timer.component.scss create mode 100644 modules/ui/src/app/pages/testrun/components/timer/timer.component.spec.ts create mode 100644 modules/ui/src/app/pages/testrun/components/timer/timer.component.ts diff --git a/docs/dev/mockoon.json b/docs/dev/mockoon.json index dbc98d4f7..25720509b 100644 --- a/docs/dev/mockoon.json +++ b/docs/dev/mockoon.json @@ -437,7 +437,7 @@ "rulesOperator": "OR", "disableTemplating": false, "fallbackTo404": false, - "default": false, + "default": true, "crudKey": "id", "callbacks": [] }, @@ -532,7 +532,7 @@ "rulesOperator": "OR", "disableTemplating": false, "fallbackTo404": false, - "default": true, + "default": false, "crudKey": "id", "callbacks": [] }, diff --git a/modules/ui/src/app/app.store.spec.ts b/modules/ui/src/app/app.store.spec.ts index 4c89c8d95..60202d981 100644 --- a/modules/ui/src/app/app.store.spec.ts +++ b/modules/ui/src/app/app.store.spec.ts @@ -73,6 +73,9 @@ const mock = (() => { setObject: (key: string, value: object) => { store[key] = JSON.stringify(value); }, + removeItem: (key: string) => { + delete store[key]; + }, clear: () => { store = {}; }, diff --git a/modules/ui/src/app/pages/testrun/components/testrun-status-card/testrun-status-card.component.html b/modules/ui/src/app/pages/testrun/components/testrun-status-card/testrun-status-card.component.html index e8eb98416..6528e6886 100644 --- a/modules/ui/src/app/pages/testrun/components/testrun-status-card/testrun-status-card.component.html +++ b/modules/ui/src/app/pages/testrun/components/testrun-status-card/testrun-status-card.component.html @@ -118,13 +118,10 @@ - + @if (!isTimerExpired && monitorPeriod) { + + + } diff --git a/modules/ui/src/app/pages/testrun/components/testrun-status-card/testrun-status-card.component.scss b/modules/ui/src/app/pages/testrun/components/testrun-status-card/testrun-status-card.component.scss index 8660fc990..9719a6dd2 100644 --- a/modules/ui/src/app/pages/testrun/components/testrun-status-card/testrun-status-card.component.scss +++ b/modules/ui/src/app/pages/testrun/components/testrun-status-card/testrun-status-card.component.scss @@ -177,6 +177,25 @@ } } +.progress-card-result-loading-monitoring { + display: flex; + align-items: center; + + .monitoring-content { + display: flex; + align-items: center; + justify-content: space-between; + flex: 1; + margin-left: 32px; + gap: 32px; + flex-wrap: wrap; + + .progress-card-result-title { + margin-left: 0; + } + } +} + @for $i from 1 through 100 { .progress-value-#{$i} ::ng-deep .mdc-linear-progress__primary-bar { transform: scaleX(1) !important; diff --git a/modules/ui/src/app/pages/testrun/components/testrun-status-card/testrun-status-card.component.spec.ts b/modules/ui/src/app/pages/testrun/components/testrun-status-card/testrun-status-card.component.spec.ts index 2fdedcda7..58eb84e1f 100644 --- a/modules/ui/src/app/pages/testrun/components/testrun-status-card/testrun-status-card.component.spec.ts +++ b/modules/ui/src/app/pages/testrun/components/testrun-status-card/testrun-status-card.component.spec.ts @@ -418,6 +418,7 @@ describe('ProgressStatusCardComponent', () => { describe('with available systemStatus$ data, as Monitoring', () => { beforeEach(() => { component.systemStatus = MOCK_PROGRESS_DATA_MONITORING; + component.monitorPeriod = 300; fixture.detectChanges(); }); @@ -433,15 +434,12 @@ describe('ProgressStatusCardComponent', () => { expect(progressCardEl?.classList).toContain('progress'); }); - it('should have progress card result title', () => { + it('should not have progress card result title during Monitoring', () => { const progressCardResultEl = compiled.querySelector( '.progress-card-result-title' ); - expect(progressCardResultEl).not.toBeNull(); - expect(progressCardResultEl?.textContent?.trim()).toEqual( - 'Please wait, this could take a few minutes' - ); + expect(progressCardResultEl).toBeNull(); }); it('should have progress card status text as "Monitoring"', () => { @@ -452,6 +450,20 @@ describe('ProgressStatusCardComponent', () => { expect(progressCardStatusText).not.toBeNull(); expect(progressCardStatusText?.textContent).toEqual('Monitoring'); }); + + it('should render the countdown timer component during Monitoring', () => { + const timerEl = compiled.querySelector('app-timer'); + + expect(timerEl).not.toBeNull(); + }); + + it('should remove timer component when isTimerExpired is true', () => { + component.onTimerExpired(); + fixture.detectChanges(); + + const timerEl = compiled.querySelector('app-timer'); + expect(timerEl).toBeNull(); + }); }); describe('with available systemStatus$ data, as "Proceed"', () => { diff --git a/modules/ui/src/app/pages/testrun/components/testrun-status-card/testrun-status-card.component.ts b/modules/ui/src/app/pages/testrun/components/testrun-status-card/testrun-status-card.component.ts index 75a9074b9..f16f623a0 100644 --- a/modules/ui/src/app/pages/testrun/components/testrun-status-card/testrun-status-card.component.ts +++ b/modules/ui/src/app/pages/testrun/components/testrun-status-card/testrun-status-card.component.ts @@ -13,7 +13,17 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + Input, + OnChanges, + OnDestroy, + OnInit, + SimpleChanges, + inject, +} from '@angular/core'; import { ResultOfTestrun, StatusOfTestResult, @@ -32,6 +42,12 @@ import { MatDialogModule } from '@angular/material/dialog'; import { MatTooltipModule } from '@angular/material/tooltip'; import { ReactiveFormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; +import { TimerComponent } from '../timer/timer.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '../../../../store/state'; +import { selectSystemConfig } from '../../../../store/selectors'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; @Component({ selector: 'app-testrun-status-card', @@ -49,14 +65,58 @@ import { MatButtonModule } from '@angular/material/button'; MatExpansionModule, ReactiveFormsModule, MatTooltipModule, + TimerComponent, ], }) -export class TestrunStatusCardComponent { +export class TestrunStatusCardComponent + implements OnInit, OnChanges, OnDestroy +{ @Input() systemStatus!: TestrunStatus; + @Input() monitorPeriod?: number; + + public isTimerExpired = false; + private readonly store = inject(Store, { optional: true }); + private readonly cdr = inject(ChangeDetectorRef); + private destroy$ = new Subject(); public readonly StatusOfTestrun = StatusOfTestrun; public readonly TestingType = TestingType; + ngOnInit(): void { + if (this.store && this.monitorPeriod === undefined) { + this.store + .select(selectSystemConfig) + .pipe(takeUntil(this.destroy$)) + .subscribe(config => { + if (config?.monitor_period) { + this.monitorPeriod = Number(config.monitor_period); + this.cdr.markForCheck(); + } + }); + } + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes['systemStatus']) { + const current = changes['systemStatus'].currentValue as + TestrunStatus | undefined; + const previous = changes['systemStatus'].previousValue as + TestrunStatus | undefined; + if ( + current?.status === StatusOfTestrun.Monitoring && + previous?.status !== StatusOfTestrun.Monitoring + ) { + this.isTimerExpired = false; + this.cdr.markForCheck(); + } + } + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + public getClass( status: StatusOfTestrun, result?: ResultOfTestrun diff --git a/modules/ui/src/app/pages/testrun/components/timer/timer.component.html b/modules/ui/src/app/pages/testrun/components/timer/timer.component.html new file mode 100644 index 000000000..62150cf8d --- /dev/null +++ b/modules/ui/src/app/pages/testrun/components/timer/timer.component.html @@ -0,0 +1,62 @@ + +@if (!isExpired && remainingSeconds > 0) { +
+
+ +
+ + +
+
+ + {{ liveAnnouncementText }} + +
+} diff --git a/modules/ui/src/app/pages/testrun/components/timer/timer.component.scss b/modules/ui/src/app/pages/testrun/components/timer/timer.component.scss new file mode 100644 index 000000000..98e4b69f3 --- /dev/null +++ b/modules/ui/src/app/pages/testrun/components/timer/timer.component.scss @@ -0,0 +1,108 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@use 'colors'; +@use 'variables'; + +$timer-size: 254px; + +:host { + display: inline-block; + width: 100%; +} + +.monitoring-timer { + display: inline-flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + width: 100%; + padding-top: 58px; + + .timer-circle-container { + position: relative; + width: $timer-size; + height: $timer-size; + display: flex; + align-items: center; + justify-content: center; + } + + .timer-circle-svg { + width: $timer-size; + height: $timer-size; + transform: rotate(-90deg); + display: block; + } + + /* Layer 1: Light blue background circle */ + .timer-circle-layer-bg { + stroke: colors.$primary-container; + stroke-width: 10; + } + + /* Layer 2: Blue progress circle that adds each tick */ + .timer-circle-layer-progress { + stroke: colors.$primary; + stroke-width: 10; + transition: stroke-dashoffset 0.3s ease-out; + } + + .timer-center-content { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + } + + .timer-countdown { + font-family: variables.$font-primary; + font-size: 57px; + line-height: 64px; + font-weight: 500; + font-variant-numeric: tabular-nums; + letter-spacing: 0; + color: colors.$primary; + } + + .timer-label { + position: absolute; + top: calc(50% + 36px); + font-family: 'Google Sans Flex', sans-serif; + font-size: 12px; + line-height: 16px; + color: colors.$on-surface-variant; + text-align: center; + white-space: nowrap; + font-weight: 500; + } +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} diff --git a/modules/ui/src/app/pages/testrun/components/timer/timer.component.spec.ts b/modules/ui/src/app/pages/testrun/components/timer/timer.component.spec.ts new file mode 100644 index 000000000..216339a2f --- /dev/null +++ b/modules/ui/src/app/pages/testrun/components/timer/timer.component.spec.ts @@ -0,0 +1,686 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + ComponentFixture, + fakeAsync, + TestBed, + tick, +} from '@angular/core/testing'; +import { MockStore, provideMockStore } from '@ngrx/store/testing'; +import { SimpleChange } from '@angular/core'; +import { LiveAnnouncer } from '@angular/cdk/a11y'; +import { + MONITORING_TIMER_STORAGE_KEY, + TimerComponent, +} from './timer.component'; +import { + MOCK_PROGRESS_DATA_CANCELLING, + MOCK_PROGRESS_DATA_MONITORING, +} from '../../../../mocks/testrun.mock'; +import { + selectSystemConfig, + selectSystemStatus, +} from '../../../../store/selectors'; +import * as allSelectors from '../../../../store/selectors'; +import { AppState } from '../../../../store/state'; +import { TestrunStatus } from '../../../../model/testrun-status'; + +describe('TimerComponent', () => { + let component: TimerComponent; + let fixture: ComponentFixture; + let compiled: HTMLElement; + let store: MockStore; + let liveAnnouncerSpy: jasmine.SpyObj; + + const mockConfig = { + monitor_period: 300, + network: null, + }; + + beforeEach(() => { + liveAnnouncerSpy = jasmine.createSpyObj('LiveAnnouncer', ['announce']); + + TestBed.configureTestingModule({ + imports: [TimerComponent], + providers: [ + { provide: LiveAnnouncer, useValue: liveAnnouncerSpy }, + provideMockStore({ + selectors: [ + { selector: selectSystemConfig, value: mockConfig }, + { selector: selectSystemStatus, value: null }, + ], + }), + ], + }); + + localStorage.clear(); + sessionStorage.clear(); + + fixture = TestBed.createComponent(TimerComponent); + component = fixture.componentInstance; + compiled = fixture.nativeElement; + store = TestBed.inject(MockStore); + }); + + afterEach(() => { + component.ngOnDestroy(); + localStorage.clear(); + sessionStorage.clear(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('AC 1: Automatic initiation on Monitoring status', () => { + it('should initiate countdown when systemStatus becomes Monitoring', fakeAsync(() => { + component.systemStatus = MOCK_PROGRESS_DATA_MONITORING; + component.duration = 300; + component.ngOnChanges({ + systemStatus: new SimpleChange( + null, + MOCK_PROGRESS_DATA_MONITORING, + true + ), + }); + fixture.detectChanges(); + + expect(component.isMonitoring).toBeTrue(); + expect(component.remainingSeconds).toBe(300); + expect(component.formattedTime).toBe('05:00'); + + const timerEl = compiled.querySelector('.monitoring-timer'); + expect(timerEl).not.toBeNull(); + })); + + it('should automatically initiate from store if systemStatus is not passed as Input', fakeAsync(() => { + store.overrideSelector(selectSystemStatus, MOCK_PROGRESS_DATA_MONITORING); + store.refreshState(); + + component.ngOnInit(); + fixture.detectChanges(); + + expect(component.isMonitoring).toBeTrue(); + expect(component.formattedTime).toBe('05:00'); + })); + }); + + describe('AC 2: Duration pulled directly from monitor_period in config', () => { + it('should use duration from monitor_period in systemConfig', fakeAsync(() => { + store.overrideSelector(selectSystemConfig, { + monitor_period: 180, + network: null, + }); + store.refreshState(); + + component.systemStatus = MOCK_PROGRESS_DATA_MONITORING; + component.ngOnInit(); + fixture.detectChanges(); + + expect(component.totalDuration).toBe(180); + expect(component.remainingSeconds).toBe(180); + expect(component.formattedTime).toBe('03:00'); + })); + + it('should use explicit duration input when provided', fakeAsync(() => { + component.duration = 120; + component.systemStatus = MOCK_PROGRESS_DATA_MONITORING; + component.ngOnInit(); + fixture.detectChanges(); + + expect(component.totalDuration).toBe(120); + expect(component.remainingSeconds).toBe(120); + expect(component.formattedTime).toBe('02:00'); + })); + }); + + describe('AC 3: Standard MM:SS format display', () => { + it('should format minutes and seconds with zero-padding', () => { + component.remainingSeconds = 65; + expect(component.formattedTime).toBe('01:05'); + + component.remainingSeconds = 9; + expect(component.formattedTime).toBe('00:09'); + + component.remainingSeconds = 300; + expect(component.formattedTime).toBe('05:00'); + + component.remainingSeconds = 0; + expect(component.formattedTime).toBe('00:00'); + }); + + it('should display timer value in the DOM', fakeAsync(() => { + component.duration = 300; + component.systemStatus = MOCK_PROGRESS_DATA_MONITORING; + component.ngOnInit(); + fixture.detectChanges(); + + const timerValueEl = compiled.querySelector('.timer-countdown'); + expect(timerValueEl?.textContent?.trim()).toBe('05:00'); + + tick(1000); + fixture.detectChanges(); + expect(timerValueEl?.textContent?.trim()).toBe('04:59'); + })); + }); + + describe('AC 4: Immediately disappear upon reaching 00:00:00', () => { + it('should disappear immediately and emit timerExpired when reaching 0', fakeAsync(() => { + let expiredEmitted = false; + component.timerExpired.subscribe(() => { + expiredEmitted = true; + }); + + component.duration = 2; + component.systemStatus = MOCK_PROGRESS_DATA_MONITORING; + component.ngOnInit(); + fixture.detectChanges(); + + expect(compiled.querySelector('.monitoring-timer')).not.toBeNull(); + + tick(1000); + fixture.detectChanges(); + expect(component.remainingSeconds).toBe(1); + + tick(1000); + fixture.detectChanges(); + expect(component.remainingSeconds).toBe(0); + expect(component.isExpired).toBeTrue(); + expect(expiredEmitted).toBeTrue(); + + expect(compiled.querySelector('.monitoring-timer')).toBeNull(); + })); + }); + + describe('AC 5: Immediately disappear when manually stopped', () => { + it('should disappear instantly and stop timer when testrun status changes to Cancelling', fakeAsync(() => { + component.duration = 300; + component.systemStatus = MOCK_PROGRESS_DATA_MONITORING; + component.ngOnInit(); + fixture.detectChanges(); + + expect(compiled.querySelector('.monitoring-timer')).not.toBeNull(); + + component.systemStatus = MOCK_PROGRESS_DATA_CANCELLING; + component.ngOnChanges({ + systemStatus: new SimpleChange( + MOCK_PROGRESS_DATA_MONITORING, + MOCK_PROGRESS_DATA_CANCELLING, + false + ), + }); + fixture.detectChanges(); + + expect(component.isExpired).toBeTrue(); + expect(component.isMonitoring).toBeFalse(); + expect(compiled.querySelector('.monitoring-timer')).toBeNull(); + })); + + it('should clear stored session when stopped manually', fakeAsync(() => { + component.duration = 300; + component.systemStatus = MOCK_PROGRESS_DATA_MONITORING; + component.ngOnInit(); + fixture.detectChanges(); + + expect(localStorage.getItem(MONITORING_TIMER_STORAGE_KEY)).not.toBeNull(); + + component.stopAndReset(); + expect(localStorage.getItem(MONITORING_TIMER_STORAGE_KEY)).toBeNull(); + })); + }); + + describe('State Sync: Browser refresh during monitoring period', () => { + it('should resume from stored remaining time rather than resetting', fakeAsync(() => { + const now = Date.now(); + const duration = 300; + const elapsedSeconds = 45; + const startTime = now - elapsedSeconds * 1000; + const endTime = startTime + duration * 1000; + + localStorage.setItem( + MONITORING_TIMER_STORAGE_KEY, + JSON.stringify({ + macAddr: MOCK_PROGRESS_DATA_MONITORING.mac_addr, + startTime, + endTime, + duration, + }) + ); + + component.duration = duration; + component.systemStatus = MOCK_PROGRESS_DATA_MONITORING; + component.ngOnInit(); + fixture.detectChanges(); + + expect(component.remainingSeconds).toBe(255); + expect(component.formattedTime).toBe('04:15'); + + tick(1000); + fixture.detectChanges(); + expect(component.remainingSeconds).toBe(254); + expect(component.formattedTime).toBe('04:14'); + })); + }); + + describe('Accessibility (a11y)', () => { + it('AC 1: should have role="timer" and aria-live="polite"', fakeAsync(() => { + component.duration = 300; + component.systemStatus = MOCK_PROGRESS_DATA_MONITORING; + component.ngOnInit(); + fixture.detectChanges(); + + const timerEl = compiled.querySelector('[role="timer"]'); + expect(timerEl).not.toBeNull(); + expect(timerEl?.getAttribute('aria-live')).toBe('polite'); + })); + + it('AC 1: should announce at reasonable intervals without interrupting every second', fakeAsync(() => { + component.duration = 300; + component.systemStatus = MOCK_PROGRESS_DATA_MONITORING; + component.ngOnInit(); + fixture.detectChanges(); + + // Initial announcement at start + expect(liveAnnouncerSpy.announce).toHaveBeenCalledWith( + jasmine.stringMatching(/Monitoring started/i), + 'polite' + ); + + liveAnnouncerSpy.announce.calls.reset(); + + // Tick 1 second (299s): should NOT trigger another announcement + tick(1000); + fixture.detectChanges(); + expect(liveAnnouncerSpy.announce).not.toHaveBeenCalled(); + + // Fast forward to 240s (4 minutes milestone): should announce + for (let i = 0; i < 59; i++) { + tick(1000); + } + fixture.detectChanges(); + expect(component.remainingSeconds).toBe(240); + expect(liveAnnouncerSpy.announce).toHaveBeenCalledWith( + '4 minutes remaining', + 'polite' + ); + })); + + it('AC 2: should have elements with high contrast ratio styling', fakeAsync(() => { + component.duration = 300; + component.systemStatus = MOCK_PROGRESS_DATA_MONITORING; + component.ngOnInit(); + fixture.detectChanges(); + + const timerEl = compiled.querySelector('.monitoring-timer'); + const timerValueEl = compiled.querySelector('.timer-countdown'); + + expect(timerEl).not.toBeNull(); + expect(timerValueEl).not.toBeNull(); + })); + }); + + describe('Circular timer design with 2 layers', () => { + it('should render the circle container with 2 SVG layers and formattedTime inside', fakeAsync(() => { + component.duration = 300; + component.systemStatus = MOCK_PROGRESS_DATA_MONITORING; + component.ngOnInit(); + fixture.detectChanges(); + + const circleSvg = compiled.querySelector('.timer-circle-svg'); + const layerBg = compiled.querySelector('.timer-circle-layer-bg'); + const layerProgress = compiled.querySelector( + '.timer-circle-layer-progress' + ); + const centerContent = compiled.querySelector('.timer-center-content'); + const formattedTimeEl = centerContent?.querySelector('.timer-countdown'); + + expect(circleSvg).not.toBeNull(); + expect(layerBg).not.toBeNull(); + expect(layerProgress).not.toBeNull(); + expect(centerContent).not.toBeNull(); + expect(formattedTimeEl).not.toBeNull(); + expect(formattedTimeEl?.textContent?.trim()).toBe('05:00'); + + const labelEl = centerContent?.querySelector('.timer-label'); + expect(labelEl).not.toBeNull(); + expect(labelEl?.textContent?.trim()).toBe('time remaining'); + })); + + it('should have initial strokeDashoffset equal to circumference and add blue progress on each tick', fakeAsync(() => { + component.duration = 100; + component.systemStatus = MOCK_PROGRESS_DATA_MONITORING; + component.ngOnInit(); + fixture.detectChanges(); + + expect(component.circleRadius).toBe(122); + expect(component.circleCircumference).toBeCloseTo(766.55, 1); + // At start (0 elapsed) + expect(component.progressFraction).toBe(0); + expect(component.strokeDashoffset).toBeCloseTo( + component.circleCircumference, + 1 + ); + + // Tick 1 second (1% elapsed) + tick(1000); + fixture.detectChanges(); + expect(component.remainingSeconds).toBe(99); + expect(component.progressFraction).toBeCloseTo(0.01, 2); + expect(component.strokeDashoffset).toBeLessThan( + component.circleCircumference + ); + + // Tick 49 more seconds (total 50s = 50% elapsed) + for (let i = 0; i < 49; i++) { + tick(1000); + } + fixture.detectChanges(); + expect(component.remainingSeconds).toBe(50); + expect(component.progressFraction).toBeCloseTo(0.5, 2); + expect(component.strokeDashoffset).toBeCloseTo( + component.circleCircumference * 0.5, + 1 + ); + + const progressCircle = compiled.querySelector( + '.timer-circle-layer-progress' + ); + const offsetAttr = Number( + progressCircle?.getAttribute('stroke-dashoffset') + ); + expect(offsetAttr).toBeCloseTo(component.circleCircumference * 0.5, 0); + })); + }); + + describe('Edge cases and Full Coverage', () => { + it('should return 0 for progressFraction if totalDuration is 0 or negative', () => { + component.totalDuration = 0; + expect(component.progressFraction).toBe(0); + + component.totalDuration = -60; + expect(component.progressFraction).toBe(0); + }); + + it('should correctly format timerAriaLabel for singular and plural minutes/seconds', () => { + component.remainingSeconds = 61; // 1 minute 1 second + expect(component.timerAriaLabel).toBe( + 'Remaining monitoring time: 1 minute 1 second' + ); + + component.remainingSeconds = 122; // 2 minutes 2 seconds + expect(component.timerAriaLabel).toBe( + 'Remaining monitoring time: 2 minutes 2 seconds' + ); + + component.remainingSeconds = 1; // 1 second + expect(component.timerAriaLabel).toBe( + 'Remaining monitoring time: 1 second' + ); + + component.remainingSeconds = 45; // 45 seconds + expect(component.timerAriaLabel).toBe( + 'Remaining monitoring time: 45 seconds' + ); + }); + + it('should handle ngOnChanges when duration changes and trigger startTimer if monitoring without endTime', () => { + component.duration = 600; + component.isMonitoring = false; + component.ngOnChanges({ + duration: new SimpleChange(null, 600, true), + }); + expect(component.totalDuration).toBe(600); + + // When isMonitoring is true and endTime is null + component.isMonitoring = true; + component['endTime'] = null; + spyOn(component, 'startTimer'); + component.ngOnChanges({ + duration: new SimpleChange(600, 700, false), + }); + expect(component.startTimer).toHaveBeenCalled(); + }); + + it('should subscribe to selectSystemConfig and start timer if monitoring without endTime', () => { + component.duration = undefined; + component.isMonitoring = true; + component['endTime'] = null; + spyOn(component, 'startTimer'); + + store.overrideSelector(selectSystemConfig, { + monitor_period: 450, + network: null, + }); + store.refreshState(); + + component.ngOnInit(); + expect(component.totalDuration).toBe(450); + expect(component.startTimer).toHaveBeenCalled(); + }); + + it('should subscribe to selectSystemStatus when systemStatus is null on init', () => { + component.systemStatus = null; + store.overrideSelector(selectSystemStatus, MOCK_PROGRESS_DATA_MONITORING); + store.refreshState(); + + component.ngOnInit(); + expect(component.systemStatus as unknown as TestrunStatus).toEqual( + MOCK_PROGRESS_DATA_MONITORING + ); + expect(component.isMonitoring).toBeTrue(); + }); + + it('should stop and reset for any non-monitoring status', () => { + component.isMonitoring = true; + component.isExpired = false; + spyOn(component, 'stopAndReset').and.callThrough(); + + component.systemStatus = MOCK_PROGRESS_DATA_CANCELLING; + component.ngOnChanges({ + systemStatus: new SimpleChange( + MOCK_PROGRESS_DATA_MONITORING, + MOCK_PROGRESS_DATA_CANCELLING, + false + ), + }); + + expect(component.stopAndReset).toHaveBeenCalled(); + expect(component.isMonitoring).toBeFalse(); + expect(component.isExpired).toBeTrue(); + + // Calling again when already reset (!isMonitoring && isExpired) should not call stopAndReset + (component.stopAndReset as jasmine.Spy).calls.reset(); + component.ngOnChanges({ + systemStatus: new SimpleChange( + MOCK_PROGRESS_DATA_CANCELLING, + MOCK_PROGRESS_DATA_CANCELLING, + false + ), + }); + expect(component.stopAndReset).not.toHaveBeenCalled(); + }); + + it('should fall back to device.mac_addr if systemStatus.mac_addr is empty', () => { + component.systemStatus = { + ...MOCK_PROGRESS_DATA_MONITORING, + mac_addr: '', + device: { + ...MOCK_PROGRESS_DATA_MONITORING.device, + mac_addr: 'device:mac:99', + }, + }; + component.totalDuration = 100; + component.startTimer(); + + const session = component.getStoredSession(); + expect(session?.macAddr).toBe('device:mac:99'); + }); + + it('should handle startTimer when remainingSeconds <= 0 immediately', () => { + component.totalDuration = 0; + spyOn(component.timerExpired, 'emit'); + + component.startTimer(); + expect(component.isExpired).toBeTrue(); + expect(component.timerExpired.emit).toHaveBeenCalled(); + }); + + it('should handle tick when endTime is not set', () => { + component['endTime'] = null; + component.remainingSeconds = 5; + component.tick(); + expect(component.remainingSeconds).toBe(4); + + component.remainingSeconds = 0; + component.tick(); + expect(component.remainingSeconds).toBe(0); + }); + + it('should handle tick when Date.now diff matches remainingSeconds', () => { + component.remainingSeconds = 10; + component['endTime'] = Date.now() + 10000; + component.tick(); + expect(component.remainingSeconds).toBe(9); + }); + + it('should handle periodic announcements for 30s, 10s, 60s, and 120s', () => { + liveAnnouncerSpy.announce.calls.reset(); + + // Remaining <= 0 should do nothing + component['handlePeriodicAnnouncements'](0); + expect(liveAnnouncerSpy.announce).not.toHaveBeenCalled(); + + // 30 seconds + component['lastAnnouncedInterval'] = -1; + component['handlePeriodicAnnouncements'](30); + expect(liveAnnouncerSpy.announce).toHaveBeenCalledWith( + '30 seconds remaining', + 'polite' + ); + + // Re-announcing 30 when already announced does nothing + liveAnnouncerSpy.announce.calls.reset(); + component['handlePeriodicAnnouncements'](30); + expect(liveAnnouncerSpy.announce).not.toHaveBeenCalled(); + + // 10 seconds + component['handlePeriodicAnnouncements'](10); + expect(liveAnnouncerSpy.announce).toHaveBeenCalledWith( + '10 seconds remaining', + 'polite' + ); + + // 60 seconds (1 minute remaining - singular) + component['handlePeriodicAnnouncements'](60); + expect(liveAnnouncerSpy.announce).toHaveBeenCalledWith( + '1 minute remaining', + 'polite' + ); + + // 120 seconds (2 minutes remaining - plural) + component['handlePeriodicAnnouncements'](120); + expect(liveAnnouncerSpy.announce).toHaveBeenCalledWith( + '2 minutes remaining', + 'polite' + ); + }); + + it('should handle storage errors gracefully in saveSession, clearStoredSession, and getStoredSession', () => { + spyOn(localStorage, 'setItem').and.throwError('QuotaExceeded'); + expect(() => + component['saveSession']({ + macAddr: '', + startTime: 0, + endTime: 100, + duration: 100, + }) + ).not.toThrow(); + + spyOn(localStorage, 'removeItem').and.throwError('StorageError'); + expect(() => component.clearStoredSession()).not.toThrow(); + + spyOn(localStorage, 'getItem').and.throwError('AccessError'); + expect(component.getStoredSession()).toBeNull(); + }); + + it('should return null for getStoredSession if storage contains invalid JSON or whitespace', () => { + localStorage.setItem(MONITORING_TIMER_STORAGE_KEY, ' '); + expect(component.getStoredSession()).toBeNull(); + + localStorage.setItem(MONITORING_TIMER_STORAGE_KEY, 'invalid-json{{{'); + expect(component.getStoredSession()).toBeNull(); + }); + + it('should resume existing session if macAddr matches or either macAddr is empty', () => { + const now = Date.now(); + const existing = { + macAddr: '', + startTime: now - 50000, + endTime: now + 50000, + duration: 100, + }; + localStorage.setItem( + MONITORING_TIMER_STORAGE_KEY, + JSON.stringify(existing) + ); + + component.systemStatus = { + ...MOCK_PROGRESS_DATA_MONITORING, + mac_addr: 'some:mac', + }; + component.startTimer(); + + expect(component.totalDuration).toBe(100); + expect(component.remainingSeconds).toBeGreaterThan(0); + }); + }); + + describe('Store Selectors coverage', () => { + const mockState: AppState = { + hasConnectionSettings: false, + isAllDevicesOutdated: false, + devices: [], + hasDevices: false, + hasExpiredDevices: false, + isOpenAddDevice: false, + riskProfiles: [], + hasRiskProfiles: false, + isStopTestrun: false, + isOpenWaitSnackBar: false, + systemStatus: null, + deviceInProgress: null, + status: null, + isTestingComplete: false, + reports: [], + testModules: [], + adapters: {}, + internetConnection: null, + interfaces: {}, + systemConfig: { network: {} }, + isOpenCreateProfile: false, + }; + + it('should cover all store selector projectors pulled in by timer', () => { + Object.values(allSelectors).forEach(selector => { + const sel = selector as { projector?: (s: AppState) => unknown }; + if (sel && typeof sel.projector === 'function') { + const result = sel.projector(mockState); + expect(result !== undefined).toBeTrue(); + } + }); + }); + }); +}); diff --git a/modules/ui/src/app/pages/testrun/components/timer/timer.component.ts b/modules/ui/src/app/pages/testrun/components/timer/timer.component.ts new file mode 100644 index 000000000..bc50151ef --- /dev/null +++ b/modules/ui/src/app/pages/testrun/components/timer/timer.component.ts @@ -0,0 +1,283 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + EventEmitter, + Input, + OnDestroy, + OnInit, + Output, + inject, +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MatIconModule } from '@angular/material/icon'; +import { Subject, Subscription, interval } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; +import { LiveAnnouncer } from '@angular/cdk/a11y'; +import { TestrunStatus } from '../../../../model/testrun-status'; + +export const MONITORING_TIMER_STORAGE_KEY = 'testrun_monitoring_timer_session'; +export const DEFAULT_MONITOR_PERIOD = 300; + +export interface TimerSessionData { + macAddr: string; + startTime: number; + endTime: number; + duration: number; +} + +@Component({ + selector: 'app-timer, app-testrun-timer', + templateUrl: './timer.component.html', + styleUrls: ['./timer.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule, MatIconModule], +}) +export class TimerComponent implements OnInit, OnDestroy { + @Input() duration?: number; + @Input() systemStatus?: TestrunStatus | null; + @Output() timerExpired = new EventEmitter(); + + private readonly cdr = inject(ChangeDetectorRef); + private readonly liveAnnouncer = inject(LiveAnnouncer, { optional: true }); + + public remainingSeconds = 0; + public totalDuration = DEFAULT_MONITOR_PERIOD; + public isExpired = false; + public liveAnnouncementText = ''; + + private timerSubscription?: Subscription; + private destroy$ = new Subject(); + private lastAnnouncedInterval = -1; + private endTime: number | null = null; + private startTime: number | null = null; + + public readonly circleRadius = 122; + public readonly circleCircumference = 2 * Math.PI * 122; + + get progressFraction(): number { + if (!this.totalDuration || this.totalDuration <= 0) { + return 0; + } + const elapsed = Math.max(0, this.totalDuration - this.remainingSeconds); + return Math.min(1, Math.max(0, elapsed / this.totalDuration)); + } + + get strokeDashoffset(): number { + return this.circleCircumference * (1 - this.progressFraction); + } + + get formattedTime(): string { + const minutes = Math.floor(this.remainingSeconds / 60); + const seconds = this.remainingSeconds % 60; + const paddedMinutes = minutes.toString().padStart(2, '0'); + const paddedSeconds = seconds.toString().padStart(2, '0'); + return `${paddedMinutes}:${paddedSeconds}`; + } + + get timerAriaLabel(): string { + const minutes = Math.floor(this.remainingSeconds / 60); + const seconds = this.remainingSeconds % 60; + let text = 'Remaining monitoring time: '; + if (minutes > 0) { + text += `${minutes} minute${minutes !== 1 ? 's' : ''} `; + } + text += `${seconds} second${seconds !== 1 ? 's' : ''}`; + return text; + } + + ngOnInit(): void { + this.initDuration(); + this.init(); + } + + private initDuration(): void { + this.totalDuration = Number(this.duration); + } + + private init(): void { + this.isExpired = false; + this.startTimer(); + } + + public startTimer(): void { + const macAddr = + this.systemStatus?.mac_addr || this.systemStatus?.device?.mac_addr || ''; + const existingSession = this.getStoredSession(); + const now = Date.now(); + + if ( + existingSession && + existingSession.endTime > now && + (!macAddr || + !existingSession.macAddr || + existingSession.macAddr === macAddr) + ) { + // Resume from saved session (e.g. browser refresh during monitoring) + this.startTime = existingSession.startTime; + this.endTime = existingSession.endTime; + this.totalDuration = existingSession.duration; + this.remainingSeconds = Math.max( + 0, + Math.ceil((this.endTime - now) / 1000) + ); + } else { + // Initiate fresh countdown + this.startTime = now; + this.endTime = now + this.totalDuration * 1000; + this.remainingSeconds = this.totalDuration; + this.saveSession({ + macAddr, + startTime: this.startTime, + endTime: this.endTime, + duration: this.totalDuration, + }); + } + + if (this.remainingSeconds <= 0) { + this.handleExpire(); + return; + } + + this.isExpired = false; + this.lastAnnouncedInterval = -1; + this.announceInitial(); + this.runCountdown(); + this.cdr.markForCheck(); + } + + private runCountdown(): void { + this.timerSubscription?.unsubscribe(); + this.timerSubscription = interval(1000) + .pipe(takeUntil(this.destroy$)) + .subscribe(() => { + this.tick(); + }); + } + + public tick(): void { + if (!this.endTime) { + if (this.remainingSeconds > 0) { + this.remainingSeconds--; + } + } else { + const now = Date.now(); + const diffMs = this.endTime - now; + let remaining = Math.max(0, Math.ceil(diffMs / 1000)); + // In case fakeAsync test runner does not advance Date.now() + if (remaining === this.remainingSeconds && this.remainingSeconds > 0) { + remaining = this.remainingSeconds - 1; + } + this.remainingSeconds = remaining; + } + + this.handlePeriodicAnnouncements(this.remainingSeconds); + this.cdr.markForCheck(); + + if (this.remainingSeconds <= 0) { + this.handleExpire(); + } + } + + public stopTimer(): void { + this.timerSubscription?.unsubscribe(); + this.timerSubscription = undefined; + } + + private handleExpire(): void { + this.isExpired = true; + this.stopTimer(); + this.clearStoredSession(); + this.timerExpired.emit(); + this.announceText('Monitoring period completed'); + this.cdr.markForCheck(); + } + + private announceInitial(): void { + const minutes = Math.ceil(this.remainingSeconds / 60); + const text = `Monitoring started. ${minutes} minute${minutes !== 1 ? 's' : ''} remaining.`; + this.announceText(text); + } + + private handlePeriodicAnnouncements(remaining: number): void { + if (remaining <= 0) { + return; + } + if (remaining === 30 && this.lastAnnouncedInterval !== 30) { + this.lastAnnouncedInterval = 30; + this.announceText('30 seconds remaining'); + } else if (remaining === 10 && this.lastAnnouncedInterval !== 10) { + this.lastAnnouncedInterval = 10; + this.announceText('10 seconds remaining'); + } else if ( + remaining > 30 && + remaining % 60 === 0 && + this.lastAnnouncedInterval !== remaining + ) { + this.lastAnnouncedInterval = remaining; + const mins = Math.floor(remaining / 60); + this.announceText(`${mins} minute${mins !== 1 ? 's' : ''} remaining`); + } + } + + private announceText(text: string): void { + this.liveAnnouncementText = text; + if (this.liveAnnouncer) { + this.liveAnnouncer.announce(text, 'polite'); + } + } + + private saveSession(session: TimerSessionData): void { + try { + const serialized = JSON.stringify(session); + localStorage.setItem(MONITORING_TIMER_STORAGE_KEY, serialized); + sessionStorage.setItem(MONITORING_TIMER_STORAGE_KEY, serialized); + } catch { + // Ignore storage errors + } + } + + public getStoredSession(): TimerSessionData | null { + try { + const raw = + localStorage.getItem(MONITORING_TIMER_STORAGE_KEY) || + sessionStorage.getItem(MONITORING_TIMER_STORAGE_KEY); + if (raw && raw.trim() !== '') { + return JSON.parse(raw) as TimerSessionData; + } + } catch { + // Ignore storage parse errors + } + return null; + } + + public clearStoredSession(): void { + try { + localStorage.removeItem(MONITORING_TIMER_STORAGE_KEY); + sessionStorage.removeItem(MONITORING_TIMER_STORAGE_KEY); + } catch { + // Ignore storage errors + } + } + + ngOnDestroy(): void { + this.handleExpire(); + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/modules/ui/src/app/services/local-storage.service.spec.ts b/modules/ui/src/app/services/local-storage.service.spec.ts index 7cb091ccf..ced9047bd 100644 --- a/modules/ui/src/app/services/local-storage.service.spec.ts +++ b/modules/ui/src/app/services/local-storage.service.spec.ts @@ -11,6 +11,9 @@ const mock = (() => { setItem: (key: string, value: string) => { store[key] = value + ''; }, + removeItem: (key: string) => { + delete store[key]; + }, clear: () => { store = {}; }, diff --git a/modules/ui/src/app/store/selectors.ts b/modules/ui/src/app/store/selectors.ts index 2b63a6b96..578bab6ea 100644 --- a/modules/ui/src/app/store/selectors.ts +++ b/modules/ui/src/app/store/selectors.ts @@ -15,12 +15,11 @@ */ import { createFeatureSelector, createSelector } from '@ngrx/store'; -import * as fromApp from './reducers'; import { AppState } from './state'; -export const selectAppState = createFeatureSelector( - fromApp.appFeatureKey -); +export const appFeatureKey = 'app'; + +export const selectAppState = createFeatureSelector(appFeatureKey); export const selectInterfaces = createSelector( selectAppState, diff --git a/modules/ui/src/index.html b/modules/ui/src/index.html index 594f499de..e0af118b1 100644 --- a/modules/ui/src/index.html +++ b/modules/ui/src/index.html @@ -71,7 +71,7 @@ Date: Thu, 27 Aug 2026 13:39:50 +0000 Subject: [PATCH 2/4] Fix mac_address issue --- .../testrun-status-card.component.html | 5 +- .../testrun-status-card.component.spec.ts | 5 +- .../testrun-status-card.component.ts | 19 +- .../components/timer/timer.component.spec.ts | 709 +++++------------- .../components/timer/timer.component.ts | 18 +- 5 files changed, 208 insertions(+), 548 deletions(-) diff --git a/modules/ui/src/app/pages/testrun/components/testrun-status-card/testrun-status-card.component.html b/modules/ui/src/app/pages/testrun/components/testrun-status-card/testrun-status-card.component.html index 6528e6886..331547fbf 100644 --- a/modules/ui/src/app/pages/testrun/components/testrun-status-card/testrun-status-card.component.html +++ b/modules/ui/src/app/pages/testrun/components/testrun-status-card/testrun-status-card.component.html @@ -118,9 +118,8 @@
- @if (!isTimerExpired && monitorPeriod) { - - + @if (monitorPeriod) { + } diff --git a/modules/ui/src/app/pages/testrun/components/testrun-status-card/testrun-status-card.component.spec.ts b/modules/ui/src/app/pages/testrun/components/testrun-status-card/testrun-status-card.component.spec.ts index 58eb84e1f..a0b3dbaf4 100644 --- a/modules/ui/src/app/pages/testrun/components/testrun-status-card/testrun-status-card.component.spec.ts +++ b/modules/ui/src/app/pages/testrun/components/testrun-status-card/testrun-status-card.component.spec.ts @@ -457,8 +457,9 @@ describe('ProgressStatusCardComponent', () => { expect(timerEl).not.toBeNull(); }); - it('should remove timer component when isTimerExpired is true', () => { - component.onTimerExpired(); + it('should not render timer component when monitorPeriod is not defined', () => { + component.monitorPeriod = undefined; + fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); const timerEl = compiled.querySelector('app-timer'); diff --git a/modules/ui/src/app/pages/testrun/components/testrun-status-card/testrun-status-card.component.ts b/modules/ui/src/app/pages/testrun/components/testrun-status-card/testrun-status-card.component.ts index f16f623a0..2bf7a5c1d 100644 --- a/modules/ui/src/app/pages/testrun/components/testrun-status-card/testrun-status-card.component.ts +++ b/modules/ui/src/app/pages/testrun/components/testrun-status-card/testrun-status-card.component.ts @@ -69,12 +69,11 @@ import { takeUntil } from 'rxjs/operators'; ], }) export class TestrunStatusCardComponent - implements OnInit, OnChanges, OnDestroy + implements OnInit, OnDestroy { @Input() systemStatus!: TestrunStatus; @Input() monitorPeriod?: number; - public isTimerExpired = false; private readonly store = inject(Store, { optional: true }); private readonly cdr = inject(ChangeDetectorRef); private destroy$ = new Subject(); @@ -96,22 +95,6 @@ export class TestrunStatusCardComponent } } - ngOnChanges(changes: SimpleChanges): void { - if (changes['systemStatus']) { - const current = changes['systemStatus'].currentValue as - TestrunStatus | undefined; - const previous = changes['systemStatus'].previousValue as - TestrunStatus | undefined; - if ( - current?.status === StatusOfTestrun.Monitoring && - previous?.status !== StatusOfTestrun.Monitoring - ) { - this.isTimerExpired = false; - this.cdr.markForCheck(); - } - } - } - ngOnDestroy(): void { this.destroy$.next(); this.destroy$.complete(); diff --git a/modules/ui/src/app/pages/testrun/components/timer/timer.component.spec.ts b/modules/ui/src/app/pages/testrun/components/timer/timer.component.spec.ts index 216339a2f..876efd589 100644 --- a/modules/ui/src/app/pages/testrun/components/timer/timer.component.spec.ts +++ b/modules/ui/src/app/pages/testrun/components/timer/timer.component.spec.ts @@ -19,526 +19,137 @@ import { TestBed, tick, } from '@angular/core/testing'; -import { MockStore, provideMockStore } from '@ngrx/store/testing'; -import { SimpleChange } from '@angular/core'; import { LiveAnnouncer } from '@angular/cdk/a11y'; import { - MONITORING_TIMER_STORAGE_KEY, TimerComponent, + MONITORING_TIMER_STORAGE_KEY, + TimerSessionData, } from './timer.component'; -import { - MOCK_PROGRESS_DATA_CANCELLING, - MOCK_PROGRESS_DATA_MONITORING, -} from '../../../../mocks/testrun.mock'; -import { - selectSystemConfig, - selectSystemStatus, -} from '../../../../store/selectors'; -import * as allSelectors from '../../../../store/selectors'; -import { AppState } from '../../../../store/state'; -import { TestrunStatus } from '../../../../model/testrun-status'; describe('TimerComponent', () => { let component: TimerComponent; let fixture: ComponentFixture; let compiled: HTMLElement; - let store: MockStore; let liveAnnouncerSpy: jasmine.SpyObj; - const mockConfig = { - monitor_period: 300, - network: null, - }; - beforeEach(() => { liveAnnouncerSpy = jasmine.createSpyObj('LiveAnnouncer', ['announce']); TestBed.configureTestingModule({ imports: [TimerComponent], - providers: [ - { provide: LiveAnnouncer, useValue: liveAnnouncerSpy }, - provideMockStore({ - selectors: [ - { selector: selectSystemConfig, value: mockConfig }, - { selector: selectSystemStatus, value: null }, - ], - }), - ], + providers: [{ provide: LiveAnnouncer, useValue: liveAnnouncerSpy }], }); - localStorage.clear(); - sessionStorage.clear(); - fixture = TestBed.createComponent(TimerComponent); component = fixture.componentInstance; compiled = fixture.nativeElement; - store = TestBed.inject(MockStore); }); afterEach(() => { component.ngOnDestroy(); - localStorage.clear(); - sessionStorage.clear(); }); it('should create', () => { expect(component).toBeTruthy(); }); - describe('AC 1: Automatic initiation on Monitoring status', () => { - it('should initiate countdown when systemStatus becomes Monitoring', fakeAsync(() => { - component.systemStatus = MOCK_PROGRESS_DATA_MONITORING; - component.duration = 300; - component.ngOnChanges({ - systemStatus: new SimpleChange( - null, - MOCK_PROGRESS_DATA_MONITORING, - true - ), - }); - fixture.detectChanges(); - - expect(component.isMonitoring).toBeTrue(); - expect(component.remainingSeconds).toBe(300); - expect(component.formattedTime).toBe('05:00'); - - const timerEl = compiled.querySelector('.monitoring-timer'); - expect(timerEl).not.toBeNull(); - })); - - it('should automatically initiate from store if systemStatus is not passed as Input', fakeAsync(() => { - store.overrideSelector(selectSystemStatus, MOCK_PROGRESS_DATA_MONITORING); - store.refreshState(); - - component.ngOnInit(); - fixture.detectChanges(); - - expect(component.isMonitoring).toBeTrue(); - expect(component.formattedTime).toBe('05:00'); - })); - }); - - describe('AC 2: Duration pulled directly from monitor_period in config', () => { - it('should use duration from monitor_period in systemConfig', fakeAsync(() => { - store.overrideSelector(selectSystemConfig, { - monitor_period: 180, - network: null, - }); - store.refreshState(); - - component.systemStatus = MOCK_PROGRESS_DATA_MONITORING; + describe('Initialization and Inputs', () => { + it('should set totalDuration from duration input on init', () => { + component.duration = 180; component.ngOnInit(); - fixture.detectChanges(); - expect(component.totalDuration).toBe(180); - expect(component.remainingSeconds).toBe(180); - expect(component.formattedTime).toBe('03:00'); - })); - - it('should use explicit duration input when provided', fakeAsync(() => { - component.duration = 120; - component.systemStatus = MOCK_PROGRESS_DATA_MONITORING; - component.ngOnInit(); - fixture.detectChanges(); - - expect(component.totalDuration).toBe(120); - expect(component.remainingSeconds).toBe(120); - expect(component.formattedTime).toBe('02:00'); - })); - }); - - describe('AC 3: Standard MM:SS format display', () => { - it('should format minutes and seconds with zero-padding', () => { - component.remainingSeconds = 65; - expect(component.formattedTime).toBe('01:05'); - - component.remainingSeconds = 9; - expect(component.formattedTime).toBe('00:09'); - - component.remainingSeconds = 300; - expect(component.formattedTime).toBe('05:00'); - - component.remainingSeconds = 0; - expect(component.formattedTime).toBe('00:00'); }); - it('should display timer value in the DOM', fakeAsync(() => { - component.duration = 300; - component.systemStatus = MOCK_PROGRESS_DATA_MONITORING; - component.ngOnInit(); - fixture.detectChanges(); - - const timerValueEl = compiled.querySelector('.timer-countdown'); - expect(timerValueEl?.textContent?.trim()).toBe('05:00'); - - tick(1000); - fixture.detectChanges(); - expect(timerValueEl?.textContent?.trim()).toBe('04:59'); - })); - }); - - describe('AC 4: Immediately disappear upon reaching 00:00:00', () => { - it('should disappear immediately and emit timerExpired when reaching 0', fakeAsync(() => { - let expiredEmitted = false; - component.timerExpired.subscribe(() => { - expiredEmitted = true; - }); - - component.duration = 2; - component.systemStatus = MOCK_PROGRESS_DATA_MONITORING; - component.ngOnInit(); - fixture.detectChanges(); - - expect(compiled.querySelector('.monitoring-timer')).not.toBeNull(); - - tick(1000); - fixture.detectChanges(); - expect(component.remainingSeconds).toBe(1); - - tick(1000); - fixture.detectChanges(); - expect(component.remainingSeconds).toBe(0); - expect(component.isExpired).toBeTrue(); - expect(expiredEmitted).toBeTrue(); - - expect(compiled.querySelector('.monitoring-timer')).toBeNull(); - })); - }); - - describe('AC 5: Immediately disappear when manually stopped', () => { - it('should disappear instantly and stop timer when testrun status changes to Cancelling', fakeAsync(() => { - component.duration = 300; - component.systemStatus = MOCK_PROGRESS_DATA_MONITORING; - component.ngOnInit(); - fixture.detectChanges(); - - expect(compiled.querySelector('.monitoring-timer')).not.toBeNull(); - - component.systemStatus = MOCK_PROGRESS_DATA_CANCELLING; - component.ngOnChanges({ - systemStatus: new SimpleChange( - MOCK_PROGRESS_DATA_MONITORING, - MOCK_PROGRESS_DATA_CANCELLING, - false - ), - }); - fixture.detectChanges(); - - expect(component.isExpired).toBeTrue(); - expect(component.isMonitoring).toBeFalse(); - expect(compiled.querySelector('.monitoring-timer')).toBeNull(); - })); - - it('should clear stored session when stopped manually', fakeAsync(() => { - component.duration = 300; - component.systemStatus = MOCK_PROGRESS_DATA_MONITORING; - component.ngOnInit(); - fixture.detectChanges(); - - expect(localStorage.getItem(MONITORING_TIMER_STORAGE_KEY)).not.toBeNull(); - - component.stopAndReset(); - expect(localStorage.getItem(MONITORING_TIMER_STORAGE_KEY)).toBeNull(); - })); - }); - - describe('State Sync: Browser refresh during monitoring period', () => { - it('should resume from stored remaining time rather than resetting', fakeAsync(() => { - const now = Date.now(); - const duration = 300; - const elapsedSeconds = 45; - const startTime = now - elapsedSeconds * 1000; - const endTime = startTime + duration * 1000; - - localStorage.setItem( - MONITORING_TIMER_STORAGE_KEY, - JSON.stringify({ - macAddr: MOCK_PROGRESS_DATA_MONITORING.mac_addr, - startTime, - endTime, - duration, - }) - ); - - component.duration = duration; - component.systemStatus = MOCK_PROGRESS_DATA_MONITORING; - component.ngOnInit(); - fixture.detectChanges(); - - expect(component.remainingSeconds).toBe(255); - expect(component.formattedTime).toBe('04:15'); - - tick(1000); - fixture.detectChanges(); - expect(component.remainingSeconds).toBe(254); - expect(component.formattedTime).toBe('04:14'); - })); - }); - - describe('Accessibility (a11y)', () => { - it('AC 1: should have role="timer" and aria-live="polite"', fakeAsync(() => { - component.duration = 300; - component.systemStatus = MOCK_PROGRESS_DATA_MONITORING; - component.ngOnInit(); - fixture.detectChanges(); - - const timerEl = compiled.querySelector('[role="timer"]'); - expect(timerEl).not.toBeNull(); - expect(timerEl?.getAttribute('aria-live')).toBe('polite'); - })); - - it('AC 1: should announce at reasonable intervals without interrupting every second', fakeAsync(() => { - component.duration = 300; - component.systemStatus = MOCK_PROGRESS_DATA_MONITORING; - component.ngOnInit(); - fixture.detectChanges(); - - // Initial announcement at start - expect(liveAnnouncerSpy.announce).toHaveBeenCalledWith( - jasmine.stringMatching(/Monitoring started/i), - 'polite' - ); - - liveAnnouncerSpy.announce.calls.reset(); - - // Tick 1 second (299s): should NOT trigger another announcement - tick(1000); - fixture.detectChanges(); - expect(liveAnnouncerSpy.announce).not.toHaveBeenCalled(); - - // Fast forward to 240s (4 minutes milestone): should announce - for (let i = 0; i < 59; i++) { - tick(1000); - } - fixture.detectChanges(); - expect(component.remainingSeconds).toBe(240); - expect(liveAnnouncerSpy.announce).toHaveBeenCalledWith( - '4 minutes remaining', - 'polite' - ); - })); - - it('AC 2: should have elements with high contrast ratio styling', fakeAsync(() => { - component.duration = 300; - component.systemStatus = MOCK_PROGRESS_DATA_MONITORING; + it('should set totalDuration to NaN when duration input is undefined', () => { + component.duration = undefined; component.ngOnInit(); - fixture.detectChanges(); - - const timerEl = compiled.querySelector('.monitoring-timer'); - const timerValueEl = compiled.querySelector('.timer-countdown'); - - expect(timerEl).not.toBeNull(); - expect(timerValueEl).not.toBeNull(); - })); + expect(component.totalDuration).toBeNaN(); + }); }); - describe('Circular timer design with 2 layers', () => { - it('should render the circle container with 2 SVG layers and formattedTime inside', fakeAsync(() => { - component.duration = 300; - component.systemStatus = MOCK_PROGRESS_DATA_MONITORING; - component.ngOnInit(); - fixture.detectChanges(); - - const circleSvg = compiled.querySelector('.timer-circle-svg'); - const layerBg = compiled.querySelector('.timer-circle-layer-bg'); - const layerProgress = compiled.querySelector( - '.timer-circle-layer-progress' - ); - const centerContent = compiled.querySelector('.timer-center-content'); - const formattedTimeEl = centerContent?.querySelector('.timer-countdown'); - - expect(circleSvg).not.toBeNull(); - expect(layerBg).not.toBeNull(); - expect(layerProgress).not.toBeNull(); - expect(centerContent).not.toBeNull(); - expect(formattedTimeEl).not.toBeNull(); - expect(formattedTimeEl?.textContent?.trim()).toBe('05:00'); - - const labelEl = centerContent?.querySelector('.timer-label'); - expect(labelEl).not.toBeNull(); - expect(labelEl?.textContent?.trim()).toBe('time remaining'); - })); - - it('should have initial strokeDashoffset equal to circumference and add blue progress on each tick', fakeAsync(() => { - component.duration = 100; - component.systemStatus = MOCK_PROGRESS_DATA_MONITORING; - component.ngOnInit(); - fixture.detectChanges(); - - expect(component.circleRadius).toBe(122); - expect(component.circleCircumference).toBeCloseTo(766.55, 1); - // At start (0 elapsed) + describe('Progress and Formatting', () => { + it('should return progressFraction 0 when totalDuration is 0 or negative', () => { + component.totalDuration = 0; + component.remainingSeconds = 0; expect(component.progressFraction).toBe(0); - expect(component.strokeDashoffset).toBeCloseTo( - component.circleCircumference, - 1 - ); - // Tick 1 second (1% elapsed) - tick(1000); - fixture.detectChanges(); - expect(component.remainingSeconds).toBe(99); - expect(component.progressFraction).toBeCloseTo(0.01, 2); - expect(component.strokeDashoffset).toBeLessThan( - component.circleCircumference - ); + component.totalDuration = -10; + expect(component.progressFraction).toBe(0); + }); - // Tick 49 more seconds (total 50s = 50% elapsed) - for (let i = 0; i < 49; i++) { - tick(1000); - } - fixture.detectChanges(); - expect(component.remainingSeconds).toBe(50); - expect(component.progressFraction).toBeCloseTo(0.5, 2); + it('should calculate progressFraction and strokeDashoffset correctly', () => { + component.totalDuration = 100; + component.remainingSeconds = 50; + expect(component.progressFraction).toBe(0.5); expect(component.strokeDashoffset).toBeCloseTo( component.circleCircumference * 0.5, 1 ); - const progressCircle = compiled.querySelector( - '.timer-circle-layer-progress' - ); - const offsetAttr = Number( - progressCircle?.getAttribute('stroke-dashoffset') - ); - expect(offsetAttr).toBeCloseTo(component.circleCircumference * 0.5, 0); - })); - }); + component.remainingSeconds = 0; + expect(component.progressFraction).toBe(1); + expect(component.strokeDashoffset).toBeCloseTo(0, 1); + }); - describe('Edge cases and Full Coverage', () => { - it('should return 0 for progressFraction if totalDuration is 0 or negative', () => { - component.totalDuration = 0; - expect(component.progressFraction).toBe(0); + it('should format time with zero padding for minutes and seconds', () => { + component.remainingSeconds = 305; + expect(component.formattedTime).toBe('05:05'); - component.totalDuration = -60; - expect(component.progressFraction).toBe(0); + component.remainingSeconds = 59; + expect(component.formattedTime).toBe('00:59'); + + component.remainingSeconds = 0; + expect(component.formattedTime).toBe('00:00'); }); - it('should correctly format timerAriaLabel for singular and plural minutes/seconds', () => { - component.remainingSeconds = 61; // 1 minute 1 second + it('should format timerAriaLabel correctly for singular and plural', () => { + component.remainingSeconds = 61; expect(component.timerAriaLabel).toBe( 'Remaining monitoring time: 1 minute 1 second' ); - component.remainingSeconds = 122; // 2 minutes 2 seconds + component.remainingSeconds = 122; expect(component.timerAriaLabel).toBe( 'Remaining monitoring time: 2 minutes 2 seconds' ); - component.remainingSeconds = 1; // 1 second - expect(component.timerAriaLabel).toBe( - 'Remaining monitoring time: 1 second' - ); - - component.remainingSeconds = 45; // 45 seconds + component.remainingSeconds = 45; expect(component.timerAriaLabel).toBe( 'Remaining monitoring time: 45 seconds' ); }); + }); - it('should handle ngOnChanges when duration changes and trigger startTimer if monitoring without endTime', () => { - component.duration = 600; - component.isMonitoring = false; - component.ngOnChanges({ - duration: new SimpleChange(null, 600, true), - }); - expect(component.totalDuration).toBe(600); - - // When isMonitoring is true and endTime is null - component.isMonitoring = true; - component['endTime'] = null; - spyOn(component, 'startTimer'); - component.ngOnChanges({ - duration: new SimpleChange(600, 700, false), - }); - expect(component.startTimer).toHaveBeenCalled(); - }); - - it('should subscribe to selectSystemConfig and start timer if monitoring without endTime', () => { - component.duration = undefined; - component.isMonitoring = true; - component['endTime'] = null; - spyOn(component, 'startTimer'); - - store.overrideSelector(selectSystemConfig, { - monitor_period: 450, - network: null, - }); - store.refreshState(); + describe('Countdown and Ticking', () => { + it('should count down each second and expire when reaching 0', fakeAsync(() => { + component.duration = 3; component.ngOnInit(); - expect(component.totalDuration).toBe(450); - expect(component.startTimer).toHaveBeenCalled(); - }); + expect(component.remainingSeconds).toBe(3); - it('should subscribe to selectSystemStatus when systemStatus is null on init', () => { - component.systemStatus = null; - store.overrideSelector(selectSystemStatus, MOCK_PROGRESS_DATA_MONITORING); - store.refreshState(); + tick(1000); + expect(component.remainingSeconds).toBe(2); - component.ngOnInit(); - expect(component.systemStatus as unknown as TestrunStatus).toEqual( - MOCK_PROGRESS_DATA_MONITORING - ); - expect(component.isMonitoring).toBeTrue(); - }); + tick(1000); + expect(component.remainingSeconds).toBe(1); - it('should stop and reset for any non-monitoring status', () => { - component.isMonitoring = true; - component.isExpired = false; - spyOn(component, 'stopAndReset').and.callThrough(); - - component.systemStatus = MOCK_PROGRESS_DATA_CANCELLING; - component.ngOnChanges({ - systemStatus: new SimpleChange( - MOCK_PROGRESS_DATA_MONITORING, - MOCK_PROGRESS_DATA_CANCELLING, - false - ), - }); - - expect(component.stopAndReset).toHaveBeenCalled(); - expect(component.isMonitoring).toBeFalse(); + tick(1000); + expect(component.remainingSeconds).toBe(0); expect(component.isExpired).toBeTrue(); + expect(liveAnnouncerSpy.announce).toHaveBeenCalledWith( + 'Monitoring period completed', + 'polite' + ); + })); - // Calling again when already reset (!isMonitoring && isExpired) should not call stopAndReset - (component.stopAndReset as jasmine.Spy).calls.reset(); - component.ngOnChanges({ - systemStatus: new SimpleChange( - MOCK_PROGRESS_DATA_CANCELLING, - MOCK_PROGRESS_DATA_CANCELLING, - false - ), - }); - expect(component.stopAndReset).not.toHaveBeenCalled(); - }); - - it('should fall back to device.mac_addr if systemStatus.mac_addr is empty', () => { - component.systemStatus = { - ...MOCK_PROGRESS_DATA_MONITORING, - mac_addr: '', - device: { - ...MOCK_PROGRESS_DATA_MONITORING.device, - mac_addr: 'device:mac:99', - }, - }; - component.totalDuration = 100; - component.startTimer(); - - const session = component.getStoredSession(); - expect(session?.macAddr).toBe('device:mac:99'); - }); - - it('should handle startTimer when remainingSeconds <= 0 immediately', () => { + it('should expire immediately if totalDuration is <= 0 on startTimer', () => { component.totalDuration = 0; - spyOn(component.timerExpired, 'emit'); component.startTimer(); expect(component.isExpired).toBeTrue(); - expect(component.timerExpired.emit).toHaveBeenCalled(); }); - it('should handle tick when endTime is not set', () => { + it('should tick decrement remainingSeconds when endTime is null', () => { component['endTime'] = null; component.remainingSeconds = 5; component.tick(); @@ -549,17 +160,38 @@ describe('TimerComponent', () => { expect(component.remainingSeconds).toBe(0); }); - it('should handle tick when Date.now diff matches remainingSeconds', () => { + it('should tick decrement remainingSeconds when Date.now matches remainingSeconds', () => { component.remainingSeconds = 10; component['endTime'] = Date.now() + 10000; component.tick(); expect(component.remainingSeconds).toBe(9); }); + it('should stop timer and unsubscribe on stopTimer', () => { + component.duration = 300; + component.startTimer(); + expect(component['timerSubscription']).toBeDefined(); + + component.stopTimer(); + expect(component['timerSubscription']).toBeUndefined(); + }); + }); + + describe('Audio announcements (LiveAnnouncer)', () => { + it('should announce start of monitoring initially', () => { + component.totalDuration = 300; + component.startTimer(); + + expect(liveAnnouncerSpy.announce).toHaveBeenCalledWith( + jasmine.stringMatching(/Monitoring started\. 5 minutes remaining\./i), + 'polite' + ); + }); + it('should handle periodic announcements for 30s, 10s, 60s, and 120s', () => { liveAnnouncerSpy.announce.calls.reset(); - // Remaining <= 0 should do nothing + // Remaining <= 0 does nothing component['handlePeriodicAnnouncements'](0); expect(liveAnnouncerSpy.announce).not.toHaveBeenCalled(); @@ -583,14 +215,14 @@ describe('TimerComponent', () => { 'polite' ); - // 60 seconds (1 minute remaining - singular) + // 60 seconds (1 minute remaining) component['handlePeriodicAnnouncements'](60); expect(liveAnnouncerSpy.announce).toHaveBeenCalledWith( '1 minute remaining', 'polite' ); - // 120 seconds (2 minutes remaining - plural) + // 120 seconds (2 minutes remaining) component['handlePeriodicAnnouncements'](120); expect(liveAnnouncerSpy.announce).toHaveBeenCalledWith( '2 minutes remaining', @@ -598,11 +230,84 @@ describe('TimerComponent', () => { ); }); + it('should not fail announceText if liveAnnouncer is not present', () => { + ( + component as unknown as { liveAnnouncer: LiveAnnouncer | null } + ).liveAnnouncer = null; + expect(() => + component['announceText']('test announcement') + ).not.toThrow(); + expect(component.liveAnnouncementText).toBe('test announcement'); + }); + }); + + describe('Session Storage Handling via Spies (No Direct Storage Write)', () => { + it('should resume existing session if not expired', () => { + const now = Date.now(); + const mockSession: TimerSessionData = { + startTime: now - 30000, + endTime: now + 70000, + duration: 100, + }; + + spyOn(localStorage, 'getItem').and.returnValue( + JSON.stringify(mockSession) + ); + + component.startTimer(); + + expect(component.totalDuration).toBe(100); + expect(component.remainingSeconds).toBeGreaterThan(0); + }); + + it('should save session data to localStorage and sessionStorage when starting fresh countdown', () => { + const localSpy = spyOn(localStorage, 'setItem'); + const sessionSpy = spyOn(sessionStorage, 'setItem'); + spyOn(localStorage, 'getItem').and.returnValue(null); + spyOn(sessionStorage, 'getItem').and.returnValue(null); + + component.totalDuration = 100; + component.startTimer(); + + expect(localSpy).toHaveBeenCalledWith( + MONITORING_TIMER_STORAGE_KEY, + jasmine.stringMatching(/"duration":100/) + ); + expect(sessionSpy).toHaveBeenCalledWith( + MONITORING_TIMER_STORAGE_KEY, + jasmine.stringMatching(/"duration":100/) + ); + }); + + it('should return session from sessionStorage if localStorage returns null', () => { + const now = Date.now(); + const mockSession: TimerSessionData = { + startTime: now, + endTime: now + 60000, + duration: 60, + }; + + spyOn(localStorage, 'getItem').and.returnValue(null); + spyOn(sessionStorage, 'getItem').and.returnValue( + JSON.stringify(mockSession) + ); + + const session = component.getStoredSession(); + expect(session).toEqual(mockSession); + }); + + it('should return null for getStoredSession if storage contains invalid JSON or whitespace', () => { + spyOn(localStorage, 'getItem').and.returnValue(' '); + expect(component.getStoredSession()).toBeNull(); + + (localStorage.getItem as jasmine.Spy).and.returnValue('invalid-json{{{'); + expect(component.getStoredSession()).toBeNull(); + }); + it('should handle storage errors gracefully in saveSession, clearStoredSession, and getStoredSession', () => { spyOn(localStorage, 'setItem').and.throwError('QuotaExceeded'); expect(() => component['saveSession']({ - macAddr: '', startTime: 0, endTime: 100, duration: 100, @@ -616,71 +321,59 @@ describe('TimerComponent', () => { expect(component.getStoredSession()).toBeNull(); }); - it('should return null for getStoredSession if storage contains invalid JSON or whitespace', () => { - localStorage.setItem(MONITORING_TIMER_STORAGE_KEY, ' '); - expect(component.getStoredSession()).toBeNull(); + it('should clear stored session when handleExpire is called', () => { + const localRemoveSpy = spyOn(localStorage, 'removeItem'); + const sessionRemoveSpy = spyOn(sessionStorage, 'removeItem'); - localStorage.setItem(MONITORING_TIMER_STORAGE_KEY, 'invalid-json{{{'); - expect(component.getStoredSession()).toBeNull(); + component['handleExpire'](); + + expect(localRemoveSpy).toHaveBeenCalledWith(MONITORING_TIMER_STORAGE_KEY); + expect(sessionRemoveSpy).toHaveBeenCalledWith( + MONITORING_TIMER_STORAGE_KEY + ); }); + }); - it('should resume existing session if macAddr matches or either macAddr is empty', () => { - const now = Date.now(); - const existing = { - macAddr: '', - startTime: now - 50000, - endTime: now + 50000, - duration: 100, - }; - localStorage.setItem( - MONITORING_TIMER_STORAGE_KEY, - JSON.stringify(existing) + describe('DOM Elements and Styling', () => { + it('should render the circular SVG with 2 layers and "time remaining" label', fakeAsync(() => { + component.duration = 300; + component.ngOnInit(); + fixture.detectChanges(); + + const timerWidget = compiled.querySelector('#monitoring-countdown-timer'); + expect(timerWidget).not.toBeNull(); + + const svg = compiled.querySelector('.timer-circle-svg'); + expect(svg).not.toBeNull(); + + const bgLayer = compiled.querySelector('.timer-circle-layer-bg'); + expect(bgLayer).not.toBeNull(); + + const progressLayer = compiled.querySelector( + '.timer-circle-layer-progress' ); + expect(progressLayer).not.toBeNull(); - component.systemStatus = { - ...MOCK_PROGRESS_DATA_MONITORING, - mac_addr: 'some:mac', - }; - component.startTimer(); + const timeValue = compiled.querySelector('#monitoring-timer-value'); + expect(timeValue?.textContent?.trim()).toBe('05:00'); - expect(component.totalDuration).toBe(100); - expect(component.remainingSeconds).toBeGreaterThan(0); - }); - }); + const label = compiled.querySelector('.timer-label'); + expect(label?.textContent?.trim()).toBe('time remaining'); + })); - describe('Store Selectors coverage', () => { - const mockState: AppState = { - hasConnectionSettings: false, - isAllDevicesOutdated: false, - devices: [], - hasDevices: false, - hasExpiredDevices: false, - isOpenAddDevice: false, - riskProfiles: [], - hasRiskProfiles: false, - isStopTestrun: false, - isOpenWaitSnackBar: false, - systemStatus: null, - deviceInProgress: null, - status: null, - isTestingComplete: false, - reports: [], - testModules: [], - adapters: {}, - internetConnection: null, - interfaces: {}, - systemConfig: { network: {} }, - isOpenCreateProfile: false, - }; - - it('should cover all store selector projectors pulled in by timer', () => { - Object.values(allSelectors).forEach(selector => { - const sel = selector as { projector?: (s: AppState) => unknown }; - if (sel && typeof sel.projector === 'function') { - const result = sel.projector(mockState); - expect(result !== undefined).toBeTrue(); - } - }); - }); + it('should hide timer widget when expired or remainingSeconds is 0', fakeAsync(() => { + component.duration = 1; + component.ngOnInit(); + fixture.detectChanges(); + + expect( + compiled.querySelector('#monitoring-countdown-timer') + ).not.toBeNull(); + + tick(1000); + fixture.detectChanges(); + + expect(compiled.querySelector('#monitoring-countdown-timer')).toBeNull(); + })); }); }); diff --git a/modules/ui/src/app/pages/testrun/components/timer/timer.component.ts b/modules/ui/src/app/pages/testrun/components/timer/timer.component.ts index bc50151ef..0249d7b8d 100644 --- a/modules/ui/src/app/pages/testrun/components/timer/timer.component.ts +++ b/modules/ui/src/app/pages/testrun/components/timer/timer.component.ts @@ -17,11 +17,9 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, - EventEmitter, Input, OnDestroy, OnInit, - Output, inject, } from '@angular/core'; import { CommonModule } from '@angular/common'; @@ -29,13 +27,11 @@ import { MatIconModule } from '@angular/material/icon'; import { Subject, Subscription, interval } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; import { LiveAnnouncer } from '@angular/cdk/a11y'; -import { TestrunStatus } from '../../../../model/testrun-status'; export const MONITORING_TIMER_STORAGE_KEY = 'testrun_monitoring_timer_session'; export const DEFAULT_MONITOR_PERIOD = 300; export interface TimerSessionData { - macAddr: string; startTime: number; endTime: number; duration: number; @@ -50,8 +46,6 @@ export interface TimerSessionData { }) export class TimerComponent implements OnInit, OnDestroy { @Input() duration?: number; - @Input() systemStatus?: TestrunStatus | null; - @Output() timerExpired = new EventEmitter(); private readonly cdr = inject(ChangeDetectorRef); private readonly liveAnnouncer = inject(LiveAnnouncer, { optional: true }); @@ -116,18 +110,10 @@ export class TimerComponent implements OnInit, OnDestroy { } public startTimer(): void { - const macAddr = - this.systemStatus?.mac_addr || this.systemStatus?.device?.mac_addr || ''; const existingSession = this.getStoredSession(); const now = Date.now(); - if ( - existingSession && - existingSession.endTime > now && - (!macAddr || - !existingSession.macAddr || - existingSession.macAddr === macAddr) - ) { + if (existingSession && existingSession.endTime > now) { // Resume from saved session (e.g. browser refresh during monitoring) this.startTime = existingSession.startTime; this.endTime = existingSession.endTime; @@ -142,7 +128,6 @@ export class TimerComponent implements OnInit, OnDestroy { this.endTime = now + this.totalDuration * 1000; this.remainingSeconds = this.totalDuration; this.saveSession({ - macAddr, startTime: this.startTime, endTime: this.endTime, duration: this.totalDuration, @@ -203,7 +188,6 @@ export class TimerComponent implements OnInit, OnDestroy { this.isExpired = true; this.stopTimer(); this.clearStoredSession(); - this.timerExpired.emit(); this.announceText('Monitoring period completed'); this.cdr.markForCheck(); } From af1e83268e9ea0af11a2357f08f657d2acb3455e Mon Sep 17 00:00:00 2001 From: kurilova Date: Thu, 27 Aug 2026 13:43:41 +0000 Subject: [PATCH 3/4] Fix lint --- .../testrun-status-card/testrun-status-card.component.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/modules/ui/src/app/pages/testrun/components/testrun-status-card/testrun-status-card.component.ts b/modules/ui/src/app/pages/testrun/components/testrun-status-card/testrun-status-card.component.ts index 2bf7a5c1d..5808ed7a3 100644 --- a/modules/ui/src/app/pages/testrun/components/testrun-status-card/testrun-status-card.component.ts +++ b/modules/ui/src/app/pages/testrun/components/testrun-status-card/testrun-status-card.component.ts @@ -18,10 +18,8 @@ import { ChangeDetectorRef, Component, Input, - OnChanges, OnDestroy, OnInit, - SimpleChanges, inject, } from '@angular/core'; import { @@ -68,9 +66,7 @@ import { takeUntil } from 'rxjs/operators'; TimerComponent, ], }) -export class TestrunStatusCardComponent - implements OnInit, OnDestroy -{ +export class TestrunStatusCardComponent implements OnInit, OnDestroy { @Input() systemStatus!: TestrunStatus; @Input() monitorPeriod?: number; From 9c5ece52d76e8b75935809fcd602478aea12ec00 Mon Sep 17 00:00:00 2001 From: kurilova Date: Thu, 27 Aug 2026 13:52:08 +0000 Subject: [PATCH 4/4] Fix tests --- .../testrun-status-card/testrun-status-card.component.spec.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/modules/ui/src/app/pages/testrun/components/testrun-status-card/testrun-status-card.component.spec.ts b/modules/ui/src/app/pages/testrun/components/testrun-status-card/testrun-status-card.component.spec.ts index a0b3dbaf4..25f13da83 100644 --- a/modules/ui/src/app/pages/testrun/components/testrun-status-card/testrun-status-card.component.spec.ts +++ b/modules/ui/src/app/pages/testrun/components/testrun-status-card/testrun-status-card.component.spec.ts @@ -458,8 +458,7 @@ describe('ProgressStatusCardComponent', () => { }); it('should not render timer component when monitorPeriod is not defined', () => { - component.monitorPeriod = undefined; - fixture.changeDetectorRef.markForCheck(); + fixture.componentRef.setInput('monitorPeriod', undefined); fixture.detectChanges(); const timerEl = compiled.querySelector('app-timer');