Skip to content

Commit a7164fe

Browse files
Merge branch 'feature/ui/countdown' of github.com:psmgeelen/projecttea into feature/ui/countdown
2 parents 20cc111 + f4d4623 commit a7164fe

19 files changed

+369
-143
lines changed

ui/src/App.css

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,9 @@
11
.App {
2+
}
3+
4+
.countdown-area {
5+
width: 100%;
6+
text-align: center;
7+
font-weight: bold;
8+
font-size: 2rem;
29
}

ui/src/App.js

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,13 @@ import { Container, Form } from 'react-bootstrap';
44
import { connect } from 'react-redux';
55

66
import NotificationsArea from './components/NotificationsArea.js';
7-
import APIAddressField from './components/APIAddressField';
8-
import PourTimeField from './components/PourTimeField';
9-
import SystemControls from './components/SystemControls';
10-
import SystemStatusArea from './components/SystemStatusArea';
7+
import APIAddressField from './components/APIAddressField.js';
8+
import PourTimeField from './components/PourTimeField.js';
9+
import SystemControls from './components/SystemControls.js';
10+
import SystemStatusArea from './components/SystemStatusArea.js';
11+
import CurrentOperationInfoArea from './components/CurrentOperationInfoArea.js';
1112

1213
function App({ isConnected }) {
13-
// TODO: Add a fake countdown timer of timeLeft
1414
return (
1515
<Container className="App">
1616
<h1>Tea System UI</h1>
@@ -21,6 +21,7 @@ function App({ isConnected }) {
2121
{isConnected ? (
2222
<>
2323
<PourTimeField />
24+
<CurrentOperationInfoArea />
2425
<SystemControls />
2526
</>
2627
) : null}

ui/src/App.test.js

Lines changed: 0 additions & 6 deletions
This file was deleted.

ui/src/Utils/time.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
function toTimeStr(diff) {
2+
const seconds = Math.floor(diff / 1000);
3+
const minutes = Math.floor(seconds / 60);
4+
const hours = Math.floor(minutes / 60);
5+
6+
const secondsStr = (seconds % 60).toString().padStart(2, '0');
7+
const minutesStr = (minutes % 60).toString().padStart(2, '0');
8+
const hoursStr = hours.toString().padStart(2, '0');
9+
10+
return `${hoursStr}:${minutesStr}:${secondsStr}`;
11+
}
12+
13+
export function timeBetweenAsString({endTime=null, startTime=null}) {
14+
if (null === startTime) startTime = new Date();
15+
if (null === endTime) endTime = new Date();
16+
17+
const diff = endTime - startTime; // in ms
18+
if (diff < 0) return '-' + toTimeStr(-diff);
19+
return toTimeStr(diff);
20+
}

ui/src/api/CWaterPumpAPI.js

Lines changed: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import axios from 'axios';
2+
import { CWaterPumpAPIImpl } from './CWaterPumpAPIImpl.js';
23

34
// helper function to preprocess the API host
45
function preprocessApiHost(apiHost) {
@@ -11,26 +12,15 @@ function preprocessApiHost(apiHost) {
1112
}
1213

1314
class CWaterPumpAPI {
14-
constructor({ client=null, URL }) {
15-
this._client = client || axios.create({ baseURL: preprocessApiHost(URL) });
16-
}
17-
18-
async start(runTimeMs) {
19-
const response = await this._client.get('/pour_tea', {
20-
milliseconds: runTimeMs,
15+
constructor({ URL }) {
16+
this._impl = new CWaterPumpAPIImpl({
17+
client: axios.create({ baseURL: preprocessApiHost(URL) }),
2118
});
22-
return response.data;
2319
}
2420

25-
async stop() {
26-
const response = await this._client.get('/stop');
27-
return response.data;
28-
}
29-
30-
async status() {
31-
const response = await this._client.get('/status');
32-
return response.data;
33-
}
21+
async start(runTimeMs) { return await this._impl.start(runTimeMs); }
22+
async stop() { return await this._impl.stop(); }
23+
async status() { return await this._impl.status(); }
3424
}
3525

3626
export default CWaterPumpAPI;

ui/src/api/CWaterPumpAPIImpl.js

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
class CWaterPumpAPIImpl {
2+
constructor({ client, currentTime=null }) {
3+
this._client = client;
4+
this._currentTime = currentTime || Date.now;
5+
}
6+
7+
async _execute(callback) {
8+
const start = this._currentTime();
9+
const response = await callback();
10+
const end = this._currentTime();
11+
return { response, requestTime: end - start };
12+
}
13+
14+
async start(runTimeMs) {
15+
const { response: { data }, requestTime } = await this._execute(
16+
async () => await this._client.get('/pour_tea', { params: { milliseconds: runTimeMs } })
17+
);
18+
return this.preprocessResponse({ response: data, requestTime });
19+
}
20+
21+
async stop() {
22+
const { response: { data }, requestTime } = await this._execute(
23+
async () => await this._client.get('/stop', { params: {} })
24+
);
25+
return this.preprocessResponse({ response: data, requestTime });
26+
}
27+
28+
async status() {
29+
const { response: { data }, requestTime } = await this._execute(
30+
async () => await this._client.get('/status', { params: {} })
31+
);
32+
return this.preprocessResponse({ response: data, requestTime });
33+
}
34+
///////////////////////
35+
// helper functions
36+
preprocessResponse({ response, requestTime }) {
37+
if(null == response) return null;
38+
if('error' in response) {
39+
throw new Error(response.error);
40+
}
41+
// make a deep copy of response
42+
response = JSON.parse(JSON.stringify(response));
43+
// normal response
44+
// convert "water threshold" to "waterThreshold"
45+
response.waterThreshold = response["water threshold"];
46+
delete response["water threshold"];
47+
48+
// convert "time left" to "timeLeft" and adjust time
49+
response.pump.timeLeft = response.pump["time left"];
50+
delete response.pump["time left"];
51+
52+
// adjust time by network delay
53+
const oneWayTripTime = Math.round(requestTime / 2);
54+
response.time += oneWayTripTime;
55+
response.pump.timeLeft -= oneWayTripTime;
56+
57+
const now = this._currentTime();
58+
response.updated = now;
59+
response.pump.estimatedEndTime = response.pump.timeLeft + now;
60+
return response;
61+
}
62+
}
63+
64+
export default CWaterPumpAPIImpl;
65+
export { CWaterPumpAPIImpl };

ui/src/api/CWaterPumpAPIImpl.test.js

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { CWaterPumpAPIImpl } from './CWaterPumpAPIImpl.js';
2+
3+
describe('CWaterPumpAPIImpl', () => {
4+
const DUMMY_STATUS = {
5+
pump: {
6+
"running": true,
7+
"time left": 1000,
8+
"water threshold": 100,
9+
},
10+
time: 1000,
11+
};
12+
// common test cases
13+
async function shouldThrowErrorFromResponse(apiCall) {
14+
const mockClient = { get: jest.fn() };
15+
const errorMessage = 'Error ' + Math.random();
16+
mockClient.get.mockResolvedValue({ data: { error: errorMessage } });
17+
18+
const api = new CWaterPumpAPIImpl({ client: mockClient });
19+
await expect(apiCall(api)).rejects.toThrow(errorMessage);
20+
}
21+
22+
async function shouldBeCalledWith(apiCall, url, params) {
23+
const mockClient = { get: jest.fn() };
24+
mockClient.get.mockResolvedValue({ data: DUMMY_STATUS });
25+
26+
const api = new CWaterPumpAPIImpl({ client: mockClient });
27+
await apiCall(api);
28+
29+
expect(mockClient.get).toHaveBeenCalledWith(url, { params });
30+
}
31+
32+
async function shouldRethrowError(apiCall) {
33+
const mockClient = { get: jest.fn() };
34+
mockClient.get.mockRejectedValue(new Error('Network Error'));
35+
36+
const api = new CWaterPumpAPIImpl({ client: mockClient });
37+
await expect(apiCall(api)).rejects.toThrow('Network Error');
38+
}
39+
40+
async function shouldPreprocessResponse(apiCall) {
41+
const mockClient = { get: jest.fn() };
42+
mockClient.get.mockResolvedValue({ data: DUMMY_STATUS });
43+
44+
const api = new CWaterPumpAPIImpl({ client: mockClient });
45+
const response = await apiCall(api);
46+
47+
expect(response.waterThreshold).toBe(DUMMY_STATUS["water threshold"]);
48+
expect(response.pump.timeLeft).toBe(DUMMY_STATUS.pump["time left"]);
49+
expect(response).toHaveProperty('updated');
50+
}
51+
// end of common test cases
52+
// tests per method
53+
describe('start', () => {
54+
it('common test cases', async () => {
55+
const T = Math.random() * 1000;
56+
const callback = async (api) => await api.start(T);
57+
await shouldThrowErrorFromResponse(callback);
58+
await shouldRethrowError(callback);
59+
await shouldPreprocessResponse(callback);
60+
await shouldBeCalledWith(callback, '/pour_tea', { milliseconds: T });
61+
});
62+
});
63+
64+
describe('stop', () => {
65+
it('common test cases', async () => {
66+
const callback = async (api) => await api.stop();
67+
await shouldThrowErrorFromResponse(callback);
68+
await shouldRethrowError(callback);
69+
await shouldPreprocessResponse(callback);
70+
await shouldBeCalledWith(callback, '/stop', {});
71+
});
72+
});
73+
74+
describe('status', () => {
75+
it('common test cases', async () => {
76+
const callback = async (api) => await api.status();
77+
await shouldThrowErrorFromResponse(callback);
78+
await shouldRethrowError(callback);
79+
await shouldPreprocessResponse(callback);
80+
await shouldBeCalledWith(callback, '/status', {});
81+
});
82+
});
83+
// tests for helper function preprocessResponse
84+
describe('preprocessResponse', () => {
85+
it('should return null if response is null', () => {
86+
const api = new CWaterPumpAPIImpl({ client: {} });
87+
expect(api.preprocessResponse({ response: null, requestTime: 0 })).toBeNull();
88+
});
89+
90+
it('should throw error if response has error', () => {
91+
const api = new CWaterPumpAPIImpl({ client: {} });
92+
const errorMessage = 'Error ' + Math.random();
93+
expect(() => api.preprocessResponse({
94+
response: { error: errorMessage },
95+
requestTime: 0,
96+
})).toThrow(errorMessage);
97+
});
98+
99+
it('should preprocess response', () => {
100+
const api = new CWaterPumpAPIImpl({ client: {} });
101+
const response = api.preprocessResponse({ response: DUMMY_STATUS, requestTime: 0 });
102+
expect(response.waterThreshold).toBe(DUMMY_STATUS["water threshold"]);
103+
expect(response.pump.timeLeft).toBe(DUMMY_STATUS.pump["time left"]);
104+
});
105+
106+
it('should add field "updated" with current time', () => {
107+
const T = Math.random() * 1000;
108+
const api = new CWaterPumpAPIImpl({ client: {}, currentTime: () => T });
109+
const response = api.preprocessResponse({ response: DUMMY_STATUS, requestTime: 0 });
110+
expect(response.updated).toBe(T);
111+
});
112+
113+
///////////
114+
// Scenario:
115+
// 00:00.000 - client sends request
116+
// 00:00.100 - server receives request and set 'time' to 00:00.100, timeLeft = 1234ms
117+
// 00:00.200 - server sends response
118+
// 00:00.300 - client receives response, but 'time' is 00:00.100 and timeLeft = 1234ms
119+
// total time: 300ms
120+
// on average, time to one-way trip is 150ms
121+
// so, we adjust time by 150ms i.e. time = 00:00.250, timeLeft = 1084ms
122+
// estimatedEndTime = 00:00.300 + 1084ms = 00:01.384
123+
it('should adjust time', () => {
124+
const responseObj = JSON.parse(JSON.stringify(DUMMY_STATUS));
125+
responseObj.time = 100;
126+
responseObj.pump["time left"] = 1234;
127+
128+
const api = new CWaterPumpAPIImpl({ client: {}, currentTime: () => 300 });
129+
const response = api.preprocessResponse({ response: responseObj, requestTime: 300 });
130+
expect(response.time).toBe(250);
131+
expect(response.pump.timeLeft).toBe(1084);
132+
expect(response.pump.estimatedEndTime).toBe(1384);
133+
});
134+
});
135+
});
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import React from "react";
2+
import { connect } from "react-redux";
3+
import TimerArea from "./TimerArea";
4+
5+
export function CurrentOperationInfoAreaComponent({
6+
isRunning, estimatedEndTime
7+
}) {
8+
if (!isRunning) return null;
9+
return (
10+
<div className="countdown-area">
11+
<TimerArea startTime={null} endTime={estimatedEndTime} />
12+
</div>
13+
);
14+
}
15+
16+
export default connect(
17+
state => ({
18+
isRunning: state.systemStatus.pump.running,
19+
estimatedEndTime: state.systemStatus.pump.estimatedEndTime,
20+
}),
21+
[]
22+
)(CurrentOperationInfoAreaComponent);
Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,23 @@
11
import React from 'react';
2+
import { connect } from 'react-redux';
23
import { Alert } from 'react-bootstrap';
3-
import { useNotificationsSystem } from '../contexts/NotificationsContext';
4+
import { NotificationsSystemActions } from '../store/slices/Notifications';
45

5-
function NotificationsArea() {
6-
const NotificationsSystem = useNotificationsSystem();
7-
const { currentNotifications } = NotificationsSystem;
8-
if(!currentNotifications) return null;
9-
10-
const hideNotifications = () => { NotificationsSystem.clear(); };
6+
function NotificationsArea({ hasNotifications, message, clearNotifications }) {
7+
if(!hasNotifications) return null;
118

129
return (
13-
<Alert variant="info" onClose={hideNotifications} dismissible>
14-
{currentNotifications.message}
10+
<Alert variant="info" onClose={clearNotifications} dismissible>
11+
{message}
1512
</Alert>
1613
);
1714
}
1815

19-
export default NotificationsArea;
16+
export default connect(
17+
(state) => ({
18+
hasNotifications: state.notifications.currentNotifications != null,
19+
message: state.notifications.currentNotifications?.message
20+
}), {
21+
clearNotifications: NotificationsSystemActions.clear
22+
}
23+
)(NotificationsArea);

0 commit comments

Comments
 (0)