From ecc3610cf60d3ccbb6458b633e529d31e4362c77 Mon Sep 17 00:00:00 2001 From: kurilova Date: Tue, 8 Sep 2026 09:00:27 +0000 Subject: [PATCH] Revert "Implement the counter on the FE (#1635)" This reverts commit bfa7e2f840b45271019564f718a6e8788efc2bd7. --- docs/dev/mockoon.json | 4 +- modules/ui/src/app/app.store.spec.ts | 3 - .../testrun-status-card.component.html | 10 +- .../testrun-status-card.component.scss | 19 - .../testrun-status-card.component.spec.ts | 22 +- .../testrun-status-card.component.ts | 43 +- .../components/timer/timer.component.html | 62 --- .../components/timer/timer.component.scss | 108 ----- .../components/timer/timer.component.spec.ts | 379 ------------------ .../components/timer/timer.component.ts | 267 ------------ .../services/local-storage.service.spec.ts | 3 - modules/ui/src/app/store/selectors.ts | 7 +- modules/ui/src/index.html | 2 +- 13 files changed, 21 insertions(+), 908 deletions(-) delete mode 100644 modules/ui/src/app/pages/testrun/components/timer/timer.component.html delete mode 100644 modules/ui/src/app/pages/testrun/components/timer/timer.component.scss delete mode 100644 modules/ui/src/app/pages/testrun/components/timer/timer.component.spec.ts delete 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 25720509b..dbc98d4f7 100644 --- a/docs/dev/mockoon.json +++ b/docs/dev/mockoon.json @@ -437,7 +437,7 @@ "rulesOperator": "OR", "disableTemplating": false, "fallbackTo404": false, - "default": true, + "default": false, "crudKey": "id", "callbacks": [] }, @@ -532,7 +532,7 @@ "rulesOperator": "OR", "disableTemplating": false, "fallbackTo404": false, - "default": false, + "default": true, "crudKey": "id", "callbacks": [] }, diff --git a/modules/ui/src/app/app.store.spec.ts b/modules/ui/src/app/app.store.spec.ts index 60202d981..4c89c8d95 100644 --- a/modules/ui/src/app/app.store.spec.ts +++ b/modules/ui/src/app/app.store.spec.ts @@ -73,9 +73,6 @@ 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 331547fbf..e8eb98416 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,13 @@ - @if (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 9719a6dd2..8660fc990 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,25 +177,6 @@ } } -.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 25f13da83..2fdedcda7 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,7 +418,6 @@ describe('ProgressStatusCardComponent', () => { describe('with available systemStatus$ data, as Monitoring', () => { beforeEach(() => { component.systemStatus = MOCK_PROGRESS_DATA_MONITORING; - component.monitorPeriod = 300; fixture.detectChanges(); }); @@ -434,12 +433,15 @@ describe('ProgressStatusCardComponent', () => { expect(progressCardEl?.classList).toContain('progress'); }); - it('should not have progress card result title during Monitoring', () => { + it('should have progress card result title', () => { const progressCardResultEl = compiled.querySelector( '.progress-card-result-title' ); - expect(progressCardResultEl).toBeNull(); + expect(progressCardResultEl).not.toBeNull(); + expect(progressCardResultEl?.textContent?.trim()).toEqual( + 'Please wait, this could take a few minutes' + ); }); it('should have progress card status text as "Monitoring"', () => { @@ -450,20 +452,6 @@ 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 not render timer component when monitorPeriod is not defined', () => { - fixture.componentRef.setInput('monitorPeriod', undefined); - 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 5808ed7a3..75a9074b9 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,15 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { - ChangeDetectionStrategy, - ChangeDetectorRef, - Component, - Input, - OnDestroy, - OnInit, - inject, -} from '@angular/core'; +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; import { ResultOfTestrun, StatusOfTestResult, @@ -40,12 +32,6 @@ 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', @@ -63,39 +49,14 @@ import { takeUntil } from 'rxjs/operators'; MatExpansionModule, ReactiveFormsModule, MatTooltipModule, - TimerComponent, ], }) -export class TestrunStatusCardComponent implements OnInit, OnDestroy { +export class TestrunStatusCardComponent { @Input() systemStatus!: TestrunStatus; - @Input() monitorPeriod?: number; - - 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(); - } - }); - } - } - - 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 deleted file mode 100644 index 62150cf8d..000000000 --- a/modules/ui/src/app/pages/testrun/components/timer/timer.component.html +++ /dev/null @@ -1,62 +0,0 @@ - -@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 deleted file mode 100644 index 98e4b69f3..000000000 --- a/modules/ui/src/app/pages/testrun/components/timer/timer.component.scss +++ /dev/null @@ -1,108 +0,0 @@ -/** - * 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 deleted file mode 100644 index 876efd589..000000000 --- a/modules/ui/src/app/pages/testrun/components/timer/timer.component.spec.ts +++ /dev/null @@ -1,379 +0,0 @@ -/** - * 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 { LiveAnnouncer } from '@angular/cdk/a11y'; -import { - TimerComponent, - MONITORING_TIMER_STORAGE_KEY, - TimerSessionData, -} from './timer.component'; - -describe('TimerComponent', () => { - let component: TimerComponent; - let fixture: ComponentFixture; - let compiled: HTMLElement; - let liveAnnouncerSpy: jasmine.SpyObj; - - beforeEach(() => { - liveAnnouncerSpy = jasmine.createSpyObj('LiveAnnouncer', ['announce']); - - TestBed.configureTestingModule({ - imports: [TimerComponent], - providers: [{ provide: LiveAnnouncer, useValue: liveAnnouncerSpy }], - }); - - fixture = TestBed.createComponent(TimerComponent); - component = fixture.componentInstance; - compiled = fixture.nativeElement; - }); - - afterEach(() => { - component.ngOnDestroy(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - describe('Initialization and Inputs', () => { - it('should set totalDuration from duration input on init', () => { - component.duration = 180; - component.ngOnInit(); - expect(component.totalDuration).toBe(180); - }); - - it('should set totalDuration to NaN when duration input is undefined', () => { - component.duration = undefined; - component.ngOnInit(); - expect(component.totalDuration).toBeNaN(); - }); - }); - - 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); - - component.totalDuration = -10; - expect(component.progressFraction).toBe(0); - }); - - 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 - ); - - component.remainingSeconds = 0; - expect(component.progressFraction).toBe(1); - expect(component.strokeDashoffset).toBeCloseTo(0, 1); - }); - - it('should format time with zero padding for minutes and seconds', () => { - component.remainingSeconds = 305; - expect(component.formattedTime).toBe('05:05'); - - component.remainingSeconds = 59; - expect(component.formattedTime).toBe('00:59'); - - component.remainingSeconds = 0; - expect(component.formattedTime).toBe('00:00'); - }); - - 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; - expect(component.timerAriaLabel).toBe( - 'Remaining monitoring time: 2 minutes 2 seconds' - ); - - component.remainingSeconds = 45; - expect(component.timerAriaLabel).toBe( - 'Remaining monitoring time: 45 seconds' - ); - }); - }); - - describe('Countdown and Ticking', () => { - it('should count down each second and expire when reaching 0', fakeAsync(() => { - component.duration = 3; - - component.ngOnInit(); - expect(component.remainingSeconds).toBe(3); - - tick(1000); - expect(component.remainingSeconds).toBe(2); - - tick(1000); - expect(component.remainingSeconds).toBe(1); - - tick(1000); - expect(component.remainingSeconds).toBe(0); - expect(component.isExpired).toBeTrue(); - expect(liveAnnouncerSpy.announce).toHaveBeenCalledWith( - 'Monitoring period completed', - 'polite' - ); - })); - - it('should expire immediately if totalDuration is <= 0 on startTimer', () => { - component.totalDuration = 0; - - component.startTimer(); - expect(component.isExpired).toBeTrue(); - }); - - it('should tick decrement remainingSeconds when endTime is null', () => { - 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 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 does 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) - component['handlePeriodicAnnouncements'](60); - expect(liveAnnouncerSpy.announce).toHaveBeenCalledWith( - '1 minute remaining', - 'polite' - ); - - // 120 seconds (2 minutes remaining) - component['handlePeriodicAnnouncements'](120); - expect(liveAnnouncerSpy.announce).toHaveBeenCalledWith( - '2 minutes remaining', - 'polite' - ); - }); - - 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']({ - 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 clear stored session when handleExpire is called', () => { - const localRemoveSpy = spyOn(localStorage, 'removeItem'); - const sessionRemoveSpy = spyOn(sessionStorage, 'removeItem'); - - component['handleExpire'](); - - expect(localRemoveSpy).toHaveBeenCalledWith(MONITORING_TIMER_STORAGE_KEY); - expect(sessionRemoveSpy).toHaveBeenCalledWith( - MONITORING_TIMER_STORAGE_KEY - ); - }); - }); - - 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(); - - const timeValue = compiled.querySelector('#monitoring-timer-value'); - expect(timeValue?.textContent?.trim()).toBe('05:00'); - - const label = compiled.querySelector('.timer-label'); - expect(label?.textContent?.trim()).toBe('time remaining'); - })); - - 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 deleted file mode 100644 index 0249d7b8d..000000000 --- a/modules/ui/src/app/pages/testrun/components/timer/timer.component.ts +++ /dev/null @@ -1,267 +0,0 @@ -/** - * 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, - Input, - OnDestroy, - OnInit, - 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'; - -export const MONITORING_TIMER_STORAGE_KEY = 'testrun_monitoring_timer_session'; -export const DEFAULT_MONITOR_PERIOD = 300; - -export interface TimerSessionData { - 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; - - 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 existingSession = this.getStoredSession(); - const now = Date.now(); - - if (existingSession && existingSession.endTime > now) { - // 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({ - 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.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 ced9047bd..7cb091ccf 100644 --- a/modules/ui/src/app/services/local-storage.service.spec.ts +++ b/modules/ui/src/app/services/local-storage.service.spec.ts @@ -11,9 +11,6 @@ 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 578bab6ea..2b63a6b96 100644 --- a/modules/ui/src/app/store/selectors.ts +++ b/modules/ui/src/app/store/selectors.ts @@ -15,11 +15,12 @@ */ import { createFeatureSelector, createSelector } from '@ngrx/store'; +import * as fromApp from './reducers'; import { AppState } from './state'; -export const appFeatureKey = 'app'; - -export const selectAppState = createFeatureSelector(appFeatureKey); +export const selectAppState = createFeatureSelector( + fromApp.appFeatureKey +); export const selectInterfaces = createSelector( selectAppState, diff --git a/modules/ui/src/index.html b/modules/ui/src/index.html index e0af118b1..594f499de 100644 --- a/modules/ui/src/index.html +++ b/modules/ui/src/index.html @@ -71,7 +71,7 @@