diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a630bf4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + +jobs: + build-and-test: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 'lts/*' + + - name: Install dependencies + run: npm ci + + - name: Run Prettier + run: npm run prettier -- --check . + + - name: Run ESLint + run: npm run lint + + - name: Build app + run: npm run build + + - name: Run tests + run: npm test -- --watch=false --browsers=ChromeHeadless diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index 8826632..6bfadcd 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -1,16 +1,70 @@ -import { TestBed } from '@angular/core/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; import { AppComponent } from './app.component'; +import { BombTimerState } from './types'; +import { ConfigurationComponent } from './configuration/configuration.component'; +import { BombTimerComponent } from './bomb-timer/bomb-timer.component'; +import { TimerExpiredComponent } from './timer-expired/timer-expired.component'; +import { ConfigurationStore } from './configuration.store'; describe('AppComponent', () => { - beforeEach(() => + let fixture: ComponentFixture; + let app: AppComponent; + + beforeEach(() => { TestBed.configureTestingModule({ - declarations: [AppComponent], - }) - ); + imports: [ + AppComponent, + ConfigurationComponent, + BombTimerComponent, + TimerExpiredComponent, + ], + providers: [ + { + provide: ConfigurationStore, + useValue: { + configuration: () => ({ + hours: '00', + minutes: '01', + color: '#FF0000', + showMilliseconds: false, + }), + setConfiguration: jasmine.createSpy('setConfiguration'), + reset: jasmine.createSpy('reset'), + }, + }, + ], + }); + fixture = TestBed.createComponent(AppComponent); + app = fixture.componentInstance; + fixture.detectChanges(); + }); it('should create the app', () => { - const fixture = TestBed.createComponent(AppComponent); - const app = fixture.componentInstance; expect(app).toBeTruthy(); }); + + it('should start in CONFIGURATION state', () => { + expect(app.state).toBe(BombTimerState.CONFIGURATION); + }); + + it('should move to TIMER_RUNNING after configuration completed', () => { + app.onConfigurationCompleted({ + hours: '00', + minutes: '01', + color: '#FF0000', + showMilliseconds: false, + }); + expect(app.state).toBe(BombTimerState.TIMER_RUNNING); + }); + + it('should move to TIMER_EXPIRED after countdown completed', () => { + app.onCountdownCompleted(); + expect(app.state).toBe(BombTimerState.TIMER_EXPIRED); + }); + + it('should return to CONFIGURATION after moveToConfiguration', () => { + app.state = BombTimerState.TIMER_EXPIRED; + app.onMoveToConfiguration(); + expect(app.state).toBe(BombTimerState.CONFIGURATION); + }); }); diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 46da0a0..59b47c2 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -7,10 +7,10 @@ import { TimerExpiredComponent } from './timer-expired/timer-expired.component'; import { ConfigurationStore } from './configuration.store'; @Component({ - selector: 'app-root', - templateUrl: './app.component.html', - styleUrls: ['./app.component.scss'], - imports: [BombTimerComponent, ConfigurationComponent, TimerExpiredComponent] + selector: 'app-root', + templateUrl: './app.component.html', + styleUrls: ['./app.component.scss'], + imports: [BombTimerComponent, ConfigurationComponent, TimerExpiredComponent], }) export class AppComponent { // TODO: Add ngOnInit to retrieve from localStorage existing timers diff --git a/src/app/bomb-timer/bomb-timer.component.spec.ts b/src/app/bomb-timer/bomb-timer.component.spec.ts index 36a5829..decf562 100644 --- a/src/app/bomb-timer/bomb-timer.component.spec.ts +++ b/src/app/bomb-timer/bomb-timer.component.spec.ts @@ -1,14 +1,34 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { BombTimerComponent } from './bomb-timer.component'; +import { ConfigurationStore } from '../configuration.store'; describe('BombTimerComponent', () => { let component: BombTimerComponent; let fixture: ComponentFixture; beforeEach(() => { + spyOn(window.HTMLMediaElement.prototype, 'play').and.returnValue( + Promise.resolve() + ); + TestBed.configureTestingModule({ - declarations: [BombTimerComponent], + imports: [BombTimerComponent], + providers: [ + { + provide: ConfigurationStore, + useValue: { + configuration: () => ({ + hours: '00', + minutes: '01', + color: '#FF0000', + showMilliseconds: false, + }), + setConfiguration: jasmine.createSpy('setConfiguration'), + reset: jasmine.createSpy('reset'), + }, + }, + ], }); fixture = TestBed.createComponent(BombTimerComponent); component = fixture.componentInstance; @@ -18,4 +38,22 @@ describe('BombTimerComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('should display the timer and emit countdownCompleted on complete', done => { + spyOn(component.countdownCompleted, 'emit'); + component.endDate = new Date(Date.now() + 100); + component.ngOnInit(); + setTimeout(() => { + expect(component.countdownCompleted.emit).toHaveBeenCalled(); + done(); + }, 200); + }); + + it('should emit countdownCanceled on ESC when warning is shown', () => { + spyOn(component.countdownCanceled, 'emit'); + component.showCancelWarning = true; + const event = new KeyboardEvent('keydown', { key: 'Escape' }); + component.onKeydownHandler(event); + expect(component.countdownCanceled.emit).toHaveBeenCalled(); + }); }); diff --git a/src/app/bomb-timer/bomb-timer.component.ts b/src/app/bomb-timer/bomb-timer.component.ts index e599e2b..d9fa5a3 100644 --- a/src/app/bomb-timer/bomb-timer.component.ts +++ b/src/app/bomb-timer/bomb-timer.component.ts @@ -22,10 +22,10 @@ import { MILLISECONDS_IN_SECOND, getFormattedTimeLeft } from '../utils'; import { ConfigurationStore } from '../configuration.store'; @Component({ - selector: 'bomb-timer', - templateUrl: './bomb-timer.component.html', - styleUrls: ['./bomb-timer.component.scss'], - imports: [CommonModule] + selector: 'bomb-timer', + templateUrl: './bomb-timer.component.html', + styleUrls: ['./bomb-timer.component.scss'], + imports: [CommonModule], }) export class BombTimerComponent implements OnDestroy, OnInit, AfterViewInit { @Input() endDate: Date = new Date(); @@ -65,6 +65,7 @@ export class BombTimerComponent implements OnDestroy, OnInit, AfterViewInit { const config = this.bombTimerConfiguration; if (!config) throw new Error('BombTimerOptions not set in store'); const { showMilliseconds } = config; + this.color = config.color; this.countdownInterval$ = interval(61) .pipe(takeWhile(() => this.endDate.getTime() > new Date().getTime())) @@ -89,7 +90,6 @@ export class BombTimerComponent implements OnDestroy, OnInit, AfterViewInit { ngAfterViewInit(): void { const config = this.bombTimerConfiguration; if (!config) throw new Error('BombTimerOptions not set in store'); - this.color = config.color; this.audioPlayerRef.nativeElement.play(); } diff --git a/src/app/configuration.store.spec.ts b/src/app/configuration.store.spec.ts new file mode 100644 index 0000000..4c1c163 --- /dev/null +++ b/src/app/configuration.store.spec.ts @@ -0,0 +1,41 @@ +import { ConfigurationStore } from './configuration.store'; +import { BombTimerOptions, RED } from './types'; + +describe('ConfigurationStore', () => { + let store: ConfigurationStore; + + beforeEach(() => { + store = new ConfigurationStore(); + }); + + it('should be created', () => { + expect(store).toBeTruthy(); + }); + + it('should have null configuration by default', () => { + expect(store.configuration()).toBeNull(); + }); + + it('should set configuration', () => { + const config: BombTimerOptions = { + hours: '01', + minutes: '10', + color: RED, + showMilliseconds: true, + }; + store.setConfiguration(config); + expect(store.configuration()).toEqual(config); + }); + + it('should reset configuration to null', () => { + const config: BombTimerOptions = { + hours: '01', + minutes: '10', + color: RED, + showMilliseconds: true, + }; + store.setConfiguration(config); + store.reset(); + expect(store.configuration()).toBeNull(); + }); +}); diff --git a/src/app/configuration/configuration.component.spec.ts b/src/app/configuration/configuration.component.spec.ts index a1abfc2..5524480 100644 --- a/src/app/configuration/configuration.component.spec.ts +++ b/src/app/configuration/configuration.component.spec.ts @@ -1,6 +1,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ConfigurationComponent } from './configuration.component'; +import { ConfigurationStore } from '../configuration.store'; describe('ConfigurationComponent', () => { let component: ConfigurationComponent; @@ -8,7 +9,22 @@ describe('ConfigurationComponent', () => { beforeEach(() => { TestBed.configureTestingModule({ - declarations: [ConfigurationComponent], + imports: [ConfigurationComponent], + providers: [ + { + provide: ConfigurationStore, + useValue: { + configuration: () => ({ + hours: '00', + minutes: '01', + color: '#FF0000', + showMilliseconds: false, + }), + setConfiguration: jasmine.createSpy('setConfiguration'), + reset: jasmine.createSpy('reset'), + }, + }, + ], }); fixture = TestBed.createComponent(ConfigurationComponent); component = fixture.componentInstance; @@ -18,4 +34,27 @@ describe('ConfigurationComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('should emit configurationCompleted with valid options', () => { + spyOn(component.configurationCompleted, 'emit'); + component.configuration.hours = '01'; + component.configuration.minutes = '10'; + component.configuration.color = '#FF0000'; + component.configuration.showMilliseconds = true; + component.submitConfiguration(); + expect(component.configurationCompleted.emit).toHaveBeenCalledWith({ + hours: '01', + minutes: '10', + color: '#FF0000', + showMilliseconds: true, + }); + }); + + it('should validate configuration correctly', () => { + component.configuration.hours = '00'; + component.configuration.minutes = '00'; + expect(component.isConfigurationValid()).toBeFalse(); + component.configuration.hours = '01'; + expect(component.isConfigurationValid()).toBeTrue(); + }); }); diff --git a/src/app/configuration/configuration.component.ts b/src/app/configuration/configuration.component.ts index 1aa1a3f..322bb4a 100644 --- a/src/app/configuration/configuration.component.ts +++ b/src/app/configuration/configuration.component.ts @@ -8,10 +8,10 @@ const MAX_HOURS_ALLOWED = 99; const MAX_MINUTES_ALLOWED = 59; @Component({ - selector: 'configuration', - imports: [FormsModule], - templateUrl: './configuration.component.html', - styleUrls: ['./configuration.component.scss'] + selector: 'configuration', + imports: [FormsModule], + templateUrl: './configuration.component.html', + styleUrls: ['./configuration.component.scss'], }) export class ConfigurationComponent { @Input() configuration: BombTimerOptions = getDefaultOptions(); diff --git a/src/app/timer-expired/timer-expired.component.spec.ts b/src/app/timer-expired/timer-expired.component.spec.ts index 2e23b82..77ae16e 100644 --- a/src/app/timer-expired/timer-expired.component.spec.ts +++ b/src/app/timer-expired/timer-expired.component.spec.ts @@ -1,14 +1,34 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { TimerExpiredComponent } from './timer-expired.component'; +import { ConfigurationStore } from '../configuration.store'; describe('TimerExpiredComponent', () => { let component: TimerExpiredComponent; let fixture: ComponentFixture; beforeEach(() => { + spyOn(window.HTMLMediaElement.prototype, 'play').and.returnValue( + Promise.resolve() + ); + TestBed.configureTestingModule({ - declarations: [TimerExpiredComponent], + imports: [TimerExpiredComponent], + providers: [ + { + provide: ConfigurationStore, + useValue: { + configuration: () => ({ + hours: '00', + minutes: '01', + color: '#FF0000', + showMilliseconds: false, + }), + setConfiguration: jasmine.createSpy('setConfiguration'), + reset: jasmine.createSpy('reset'), + }, + }, + ], }); fixture = TestBed.createComponent(TimerExpiredComponent); component = fixture.componentInstance; @@ -18,4 +38,30 @@ describe('TimerExpiredComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('should emit moveToConfiguration on ESC when showRestartText is true', () => { + spyOn(component.moveToConfiguration, 'emit'); + component.showRestartText = true; + const event = new KeyboardEvent('keydown', { key: 'Escape' }); + component.onKeydownHandler(event); + expect(component.moveToConfiguration.emit).toHaveBeenCalled(); + }); + + it('should switch colors every second', () => { + Object.defineProperty(component, 'bombTimerConfiguration', { + get: () => ({ color: '#FF0000' }), + }); + component.color = '#000000'; + component.backgroundColor = '#000000'; + + component.color = component.backgroundColor; + component.backgroundColor = + component.color === '#000000' ? '#FF0000' : '#000000'; + expect([component.backgroundColor, component.color]).toContain('#FF0000'); + }); + + it('should play audio on ngAfterViewInit', () => { + component.ngAfterViewInit(); + expect(component.audioPlayerRef.nativeElement.play).toHaveBeenCalled(); + }); }); diff --git a/src/app/timer-expired/timer-expired.component.ts b/src/app/timer-expired/timer-expired.component.ts index 22a6417..60e48ac 100644 --- a/src/app/timer-expired/timer-expired.component.ts +++ b/src/app/timer-expired/timer-expired.component.ts @@ -17,10 +17,10 @@ import { MILLISECONDS_IN_SECOND } from '../utils'; import { ConfigurationStore } from '../configuration.store'; @Component({ - selector: 'timer-expired', - templateUrl: './timer-expired.component.html', - styleUrls: ['./timer-expired.component.scss'], - imports: [] + selector: 'timer-expired', + templateUrl: './timer-expired.component.html', + styleUrls: ['./timer-expired.component.scss'], + imports: [], }) export class TimerExpiredComponent implements OnDestroy, OnDestroy, AfterViewInit diff --git a/src/app/utils.spec.ts b/src/app/utils.spec.ts new file mode 100644 index 0000000..b7aba07 --- /dev/null +++ b/src/app/utils.spec.ts @@ -0,0 +1,67 @@ +import { + getDefaultOptions, + getFormattedTimeLeft, + MILLISECONDS_IN_HOUR, + MILLISECONDS_IN_MINUTE, + MILLISECONDS_IN_SECOND, +} from './utils'; +import { RED } from './types'; + +describe('utils', () => { + describe('getDefaultOptions', () => { + it('should return default options', () => { + const options = getDefaultOptions(); + expect(options).toEqual({ + hours: '00', + minutes: '30', + showMilliseconds: false, + color: RED, + }); + }); + }); + + describe('getFormattedTimeLeft', () => { + it('should format time left as HH:MM:SS', () => { + const ms = + 1 * MILLISECONDS_IN_HOUR + + 2 * MILLISECONDS_IN_MINUTE + + 3 * MILLISECONDS_IN_SECOND; + expect(getFormattedTimeLeft(ms, { showMilliseconds: false })).toBe( + '01:02:03' + ); + }); + + it('should format time left as HH:MM:SS.mmm when showMilliseconds is true', () => { + const ms = + 1 * MILLISECONDS_IN_HOUR + + 2 * MILLISECONDS_IN_MINUTE + + 3 * MILLISECONDS_IN_SECOND + + 456; + expect(getFormattedTimeLeft(ms, { showMilliseconds: true })).toBe( + '01:02:03.456' + ); + }); + + it('should return 00:00:00 when negative milliseconds', () => { + expect(getFormattedTimeLeft(-100, { showMilliseconds: false })).toBe( + '00:00:00' + ); + }); + + it('should return 00:00:00.000 when negative milliseconds and showMilliseconds is true', () => { + expect(getFormattedTimeLeft(-100, { showMilliseconds: true })).toBe( + '00:00:00.000' + ); + }); + + it('should pad single digit numbers with zeros', () => { + const ms = + 9 * MILLISECONDS_IN_SECOND + + 8 * MILLISECONDS_IN_MINUTE + + 7 * MILLISECONDS_IN_HOUR; + expect(getFormattedTimeLeft(ms, { showMilliseconds: false })).toBe( + '07:08:09' + ); + }); + }); +}); diff --git a/tsconfig.spec.json b/tsconfig.spec.json index e00e30e..551166e 100644 --- a/tsconfig.spec.json +++ b/tsconfig.spec.json @@ -4,7 +4,7 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "./out-tsc/spec", - "types": ["jasmine"] + "types": ["jasmine", "node"] }, "include": ["src/**/*.spec.ts", "src/**/*.d.ts"] }