Skip to content

Commit 1f1bfb9

Browse files
feat: unit tests and CI (#12)
1 parent 3455b31 commit 1f1bfb9

12 files changed

+347
-28
lines changed

.github/workflows/ci.yml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
pull_request:
8+
9+
jobs:
10+
build-and-test:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- name: Checkout repository
14+
uses: actions/checkout@v4
15+
16+
- name: Set up Node.js
17+
uses: actions/setup-node@v4
18+
with:
19+
node-version: 'lts/*'
20+
21+
- name: Install dependencies
22+
run: npm ci
23+
24+
- name: Run Prettier
25+
run: npm run prettier -- --check .
26+
27+
- name: Run ESLint
28+
run: npm run lint
29+
30+
- name: Build app
31+
run: npm run build
32+
33+
- name: Run tests
34+
run: npm test -- --watch=false --browsers=ChromeHeadless

src/app/app.component.spec.ts

Lines changed: 61 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,70 @@
1-
import { TestBed } from '@angular/core/testing';
1+
import { ComponentFixture, TestBed } from '@angular/core/testing';
22
import { AppComponent } from './app.component';
3+
import { BombTimerState } from './types';
4+
import { ConfigurationComponent } from './configuration/configuration.component';
5+
import { BombTimerComponent } from './bomb-timer/bomb-timer.component';
6+
import { TimerExpiredComponent } from './timer-expired/timer-expired.component';
7+
import { ConfigurationStore } from './configuration.store';
38

49
describe('AppComponent', () => {
5-
beforeEach(() =>
10+
let fixture: ComponentFixture<AppComponent>;
11+
let app: AppComponent;
12+
13+
beforeEach(() => {
614
TestBed.configureTestingModule({
7-
declarations: [AppComponent],
8-
})
9-
);
15+
imports: [
16+
AppComponent,
17+
ConfigurationComponent,
18+
BombTimerComponent,
19+
TimerExpiredComponent,
20+
],
21+
providers: [
22+
{
23+
provide: ConfigurationStore,
24+
useValue: {
25+
configuration: () => ({
26+
hours: '00',
27+
minutes: '01',
28+
color: '#FF0000',
29+
showMilliseconds: false,
30+
}),
31+
setConfiguration: jasmine.createSpy('setConfiguration'),
32+
reset: jasmine.createSpy('reset'),
33+
},
34+
},
35+
],
36+
});
37+
fixture = TestBed.createComponent(AppComponent);
38+
app = fixture.componentInstance;
39+
fixture.detectChanges();
40+
});
1041

1142
it('should create the app', () => {
12-
const fixture = TestBed.createComponent(AppComponent);
13-
const app = fixture.componentInstance;
1443
expect(app).toBeTruthy();
1544
});
45+
46+
it('should start in CONFIGURATION state', () => {
47+
expect(app.state).toBe(BombTimerState.CONFIGURATION);
48+
});
49+
50+
it('should move to TIMER_RUNNING after configuration completed', () => {
51+
app.onConfigurationCompleted({
52+
hours: '00',
53+
minutes: '01',
54+
color: '#FF0000',
55+
showMilliseconds: false,
56+
});
57+
expect(app.state).toBe(BombTimerState.TIMER_RUNNING);
58+
});
59+
60+
it('should move to TIMER_EXPIRED after countdown completed', () => {
61+
app.onCountdownCompleted();
62+
expect(app.state).toBe(BombTimerState.TIMER_EXPIRED);
63+
});
64+
65+
it('should return to CONFIGURATION after moveToConfiguration', () => {
66+
app.state = BombTimerState.TIMER_EXPIRED;
67+
app.onMoveToConfiguration();
68+
expect(app.state).toBe(BombTimerState.CONFIGURATION);
69+
});
1670
});

src/app/app.component.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@ import { TimerExpiredComponent } from './timer-expired/timer-expired.component';
77
import { ConfigurationStore } from './configuration.store';
88

99
@Component({
10-
selector: 'app-root',
11-
templateUrl: './app.component.html',
12-
styleUrls: ['./app.component.scss'],
13-
imports: [BombTimerComponent, ConfigurationComponent, TimerExpiredComponent]
10+
selector: 'app-root',
11+
templateUrl: './app.component.html',
12+
styleUrls: ['./app.component.scss'],
13+
imports: [BombTimerComponent, ConfigurationComponent, TimerExpiredComponent],
1414
})
1515
export class AppComponent {
1616
// TODO: Add ngOnInit to retrieve from localStorage existing timers

src/app/bomb-timer/bomb-timer.component.spec.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,34 @@
11
import { ComponentFixture, TestBed } from '@angular/core/testing';
22

33
import { BombTimerComponent } from './bomb-timer.component';
4+
import { ConfigurationStore } from '../configuration.store';
45

56
describe('BombTimerComponent', () => {
67
let component: BombTimerComponent;
78
let fixture: ComponentFixture<BombTimerComponent>;
89

910
beforeEach(() => {
11+
spyOn(window.HTMLMediaElement.prototype, 'play').and.returnValue(
12+
Promise.resolve()
13+
);
14+
1015
TestBed.configureTestingModule({
11-
declarations: [BombTimerComponent],
16+
imports: [BombTimerComponent],
17+
providers: [
18+
{
19+
provide: ConfigurationStore,
20+
useValue: {
21+
configuration: () => ({
22+
hours: '00',
23+
minutes: '01',
24+
color: '#FF0000',
25+
showMilliseconds: false,
26+
}),
27+
setConfiguration: jasmine.createSpy('setConfiguration'),
28+
reset: jasmine.createSpy('reset'),
29+
},
30+
},
31+
],
1232
});
1333
fixture = TestBed.createComponent(BombTimerComponent);
1434
component = fixture.componentInstance;
@@ -18,4 +38,22 @@ describe('BombTimerComponent', () => {
1838
it('should create', () => {
1939
expect(component).toBeTruthy();
2040
});
41+
42+
it('should display the timer and emit countdownCompleted on complete', done => {
43+
spyOn(component.countdownCompleted, 'emit');
44+
component.endDate = new Date(Date.now() + 100);
45+
component.ngOnInit();
46+
setTimeout(() => {
47+
expect(component.countdownCompleted.emit).toHaveBeenCalled();
48+
done();
49+
}, 200);
50+
});
51+
52+
it('should emit countdownCanceled on ESC when warning is shown', () => {
53+
spyOn(component.countdownCanceled, 'emit');
54+
component.showCancelWarning = true;
55+
const event = new KeyboardEvent('keydown', { key: 'Escape' });
56+
component.onKeydownHandler(event);
57+
expect(component.countdownCanceled.emit).toHaveBeenCalled();
58+
});
2159
});

src/app/bomb-timer/bomb-timer.component.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,10 @@ import { MILLISECONDS_IN_SECOND, getFormattedTimeLeft } from '../utils';
2222
import { ConfigurationStore } from '../configuration.store';
2323

2424
@Component({
25-
selector: 'bomb-timer',
26-
templateUrl: './bomb-timer.component.html',
27-
styleUrls: ['./bomb-timer.component.scss'],
28-
imports: [CommonModule]
25+
selector: 'bomb-timer',
26+
templateUrl: './bomb-timer.component.html',
27+
styleUrls: ['./bomb-timer.component.scss'],
28+
imports: [CommonModule],
2929
})
3030
export class BombTimerComponent implements OnDestroy, OnInit, AfterViewInit {
3131
@Input() endDate: Date = new Date();
@@ -65,6 +65,7 @@ export class BombTimerComponent implements OnDestroy, OnInit, AfterViewInit {
6565
const config = this.bombTimerConfiguration;
6666
if (!config) throw new Error('BombTimerOptions not set in store');
6767
const { showMilliseconds } = config;
68+
this.color = config.color;
6869

6970
this.countdownInterval$ = interval(61)
7071
.pipe(takeWhile(() => this.endDate.getTime() > new Date().getTime()))
@@ -89,7 +90,6 @@ export class BombTimerComponent implements OnDestroy, OnInit, AfterViewInit {
8990
ngAfterViewInit(): void {
9091
const config = this.bombTimerConfiguration;
9192
if (!config) throw new Error('BombTimerOptions not set in store');
92-
this.color = config.color;
9393
this.audioPlayerRef.nativeElement.play();
9494
}
9595

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { ConfigurationStore } from './configuration.store';
2+
import { BombTimerOptions, RED } from './types';
3+
4+
describe('ConfigurationStore', () => {
5+
let store: ConfigurationStore;
6+
7+
beforeEach(() => {
8+
store = new ConfigurationStore();
9+
});
10+
11+
it('should be created', () => {
12+
expect(store).toBeTruthy();
13+
});
14+
15+
it('should have null configuration by default', () => {
16+
expect(store.configuration()).toBeNull();
17+
});
18+
19+
it('should set configuration', () => {
20+
const config: BombTimerOptions = {
21+
hours: '01',
22+
minutes: '10',
23+
color: RED,
24+
showMilliseconds: true,
25+
};
26+
store.setConfiguration(config);
27+
expect(store.configuration()).toEqual(config);
28+
});
29+
30+
it('should reset configuration to null', () => {
31+
const config: BombTimerOptions = {
32+
hours: '01',
33+
minutes: '10',
34+
color: RED,
35+
showMilliseconds: true,
36+
};
37+
store.setConfiguration(config);
38+
store.reset();
39+
expect(store.configuration()).toBeNull();
40+
});
41+
});

src/app/configuration/configuration.component.spec.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,30 @@
11
import { ComponentFixture, TestBed } from '@angular/core/testing';
22

33
import { ConfigurationComponent } from './configuration.component';
4+
import { ConfigurationStore } from '../configuration.store';
45

56
describe('ConfigurationComponent', () => {
67
let component: ConfigurationComponent;
78
let fixture: ComponentFixture<ConfigurationComponent>;
89

910
beforeEach(() => {
1011
TestBed.configureTestingModule({
11-
declarations: [ConfigurationComponent],
12+
imports: [ConfigurationComponent],
13+
providers: [
14+
{
15+
provide: ConfigurationStore,
16+
useValue: {
17+
configuration: () => ({
18+
hours: '00',
19+
minutes: '01',
20+
color: '#FF0000',
21+
showMilliseconds: false,
22+
}),
23+
setConfiguration: jasmine.createSpy('setConfiguration'),
24+
reset: jasmine.createSpy('reset'),
25+
},
26+
},
27+
],
1228
});
1329
fixture = TestBed.createComponent(ConfigurationComponent);
1430
component = fixture.componentInstance;
@@ -18,4 +34,27 @@ describe('ConfigurationComponent', () => {
1834
it('should create', () => {
1935
expect(component).toBeTruthy();
2036
});
37+
38+
it('should emit configurationCompleted with valid options', () => {
39+
spyOn(component.configurationCompleted, 'emit');
40+
component.configuration.hours = '01';
41+
component.configuration.minutes = '10';
42+
component.configuration.color = '#FF0000';
43+
component.configuration.showMilliseconds = true;
44+
component.submitConfiguration();
45+
expect(component.configurationCompleted.emit).toHaveBeenCalledWith({
46+
hours: '01',
47+
minutes: '10',
48+
color: '#FF0000',
49+
showMilliseconds: true,
50+
});
51+
});
52+
53+
it('should validate configuration correctly', () => {
54+
component.configuration.hours = '00';
55+
component.configuration.minutes = '00';
56+
expect(component.isConfigurationValid()).toBeFalse();
57+
component.configuration.hours = '01';
58+
expect(component.isConfigurationValid()).toBeTrue();
59+
});
2160
});

src/app/configuration/configuration.component.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@ const MAX_HOURS_ALLOWED = 99;
88
const MAX_MINUTES_ALLOWED = 59;
99

1010
@Component({
11-
selector: 'configuration',
12-
imports: [FormsModule],
13-
templateUrl: './configuration.component.html',
14-
styleUrls: ['./configuration.component.scss']
11+
selector: 'configuration',
12+
imports: [FormsModule],
13+
templateUrl: './configuration.component.html',
14+
styleUrls: ['./configuration.component.scss'],
1515
})
1616
export class ConfigurationComponent {
1717
@Input() configuration: BombTimerOptions = getDefaultOptions();

0 commit comments

Comments
 (0)