Skip to content

Commit

Permalink
Merge branch 'feature/ui/countdown' of github.com:psmgeelen/projectte…
Browse files Browse the repository at this point in the history
…a into feature/ui/countdown
  • Loading branch information
GreenWizard2015 committed Jan 13, 2024
2 parents 20cc111 + f4d4623 commit a7164fe
Show file tree
Hide file tree
Showing 19 changed files with 369 additions and 143 deletions.
7 changes: 7 additions & 0 deletions ui/src/App.css
Original file line number Diff line number Diff line change
@@ -1,2 +1,9 @@
.App {
}

.countdown-area {
width: 100%;
text-align: center;
font-weight: bold;
font-size: 2rem;
}
11 changes: 6 additions & 5 deletions ui/src/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ import { Container, Form } from 'react-bootstrap';
import { connect } from 'react-redux';

import NotificationsArea from './components/NotificationsArea.js';
import APIAddressField from './components/APIAddressField';
import PourTimeField from './components/PourTimeField';
import SystemControls from './components/SystemControls';
import SystemStatusArea from './components/SystemStatusArea';
import APIAddressField from './components/APIAddressField.js';
import PourTimeField from './components/PourTimeField.js';
import SystemControls from './components/SystemControls.js';
import SystemStatusArea from './components/SystemStatusArea.js';
import CurrentOperationInfoArea from './components/CurrentOperationInfoArea.js';

function App({ isConnected }) {
// TODO: Add a fake countdown timer of timeLeft
return (
<Container className="App">
<h1>Tea System UI</h1>
Expand All @@ -21,6 +21,7 @@ function App({ isConnected }) {
{isConnected ? (
<>
<PourTimeField />
<CurrentOperationInfoArea />
<SystemControls />
</>
) : null}
Expand Down
6 changes: 0 additions & 6 deletions ui/src/App.test.js

This file was deleted.

20 changes: 20 additions & 0 deletions ui/src/Utils/time.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
function toTimeStr(diff) {
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);

const secondsStr = (seconds % 60).toString().padStart(2, '0');
const minutesStr = (minutes % 60).toString().padStart(2, '0');
const hoursStr = hours.toString().padStart(2, '0');

return `${hoursStr}:${minutesStr}:${secondsStr}`;
}

export function timeBetweenAsString({endTime=null, startTime=null}) {
if (null === startTime) startTime = new Date();
if (null === endTime) endTime = new Date();

const diff = endTime - startTime; // in ms
if (diff < 0) return '-' + toTimeStr(-diff);
return toTimeStr(diff);
}
24 changes: 7 additions & 17 deletions ui/src/api/CWaterPumpAPI.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import axios from 'axios';
import { CWaterPumpAPIImpl } from './CWaterPumpAPIImpl.js';

// helper function to preprocess the API host
function preprocessApiHost(apiHost) {
Expand All @@ -11,26 +12,15 @@ function preprocessApiHost(apiHost) {
}

class CWaterPumpAPI {
constructor({ client=null, URL }) {
this._client = client || axios.create({ baseURL: preprocessApiHost(URL) });
}

async start(runTimeMs) {
const response = await this._client.get('/pour_tea', {
milliseconds: runTimeMs,
constructor({ URL }) {
this._impl = new CWaterPumpAPIImpl({
client: axios.create({ baseURL: preprocessApiHost(URL) }),
});
return response.data;
}

async stop() {
const response = await this._client.get('/stop');
return response.data;
}

async status() {
const response = await this._client.get('/status');
return response.data;
}
async start(runTimeMs) { return await this._impl.start(runTimeMs); }
async stop() { return await this._impl.stop(); }
async status() { return await this._impl.status(); }
}

export default CWaterPumpAPI;
Expand Down
65 changes: 65 additions & 0 deletions ui/src/api/CWaterPumpAPIImpl.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
class CWaterPumpAPIImpl {
constructor({ client, currentTime=null }) {
this._client = client;
this._currentTime = currentTime || Date.now;
}

async _execute(callback) {
const start = this._currentTime();
const response = await callback();
const end = this._currentTime();
return { response, requestTime: end - start };
}

async start(runTimeMs) {
const { response: { data }, requestTime } = await this._execute(
async () => await this._client.get('/pour_tea', { params: { milliseconds: runTimeMs } })
);
return this.preprocessResponse({ response: data, requestTime });
}

async stop() {
const { response: { data }, requestTime } = await this._execute(
async () => await this._client.get('/stop', { params: {} })
);
return this.preprocessResponse({ response: data, requestTime });
}

async status() {
const { response: { data }, requestTime } = await this._execute(
async () => await this._client.get('/status', { params: {} })
);
return this.preprocessResponse({ response: data, requestTime });
}
///////////////////////
// helper functions
preprocessResponse({ response, requestTime }) {
if(null == response) return null;
if('error' in response) {
throw new Error(response.error);
}
// make a deep copy of response
response = JSON.parse(JSON.stringify(response));
// normal response
// convert "water threshold" to "waterThreshold"
response.waterThreshold = response["water threshold"];
delete response["water threshold"];

// convert "time left" to "timeLeft" and adjust time
response.pump.timeLeft = response.pump["time left"];
delete response.pump["time left"];

// adjust time by network delay
const oneWayTripTime = Math.round(requestTime / 2);
response.time += oneWayTripTime;
response.pump.timeLeft -= oneWayTripTime;

const now = this._currentTime();
response.updated = now;
response.pump.estimatedEndTime = response.pump.timeLeft + now;
return response;
}
}

export default CWaterPumpAPIImpl;
export { CWaterPumpAPIImpl };
135 changes: 135 additions & 0 deletions ui/src/api/CWaterPumpAPIImpl.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { CWaterPumpAPIImpl } from './CWaterPumpAPIImpl.js';

describe('CWaterPumpAPIImpl', () => {
const DUMMY_STATUS = {
pump: {
"running": true,
"time left": 1000,
"water threshold": 100,
},
time: 1000,
};
// common test cases
async function shouldThrowErrorFromResponse(apiCall) {
const mockClient = { get: jest.fn() };
const errorMessage = 'Error ' + Math.random();
mockClient.get.mockResolvedValue({ data: { error: errorMessage } });

const api = new CWaterPumpAPIImpl({ client: mockClient });
await expect(apiCall(api)).rejects.toThrow(errorMessage);
}

async function shouldBeCalledWith(apiCall, url, params) {
const mockClient = { get: jest.fn() };
mockClient.get.mockResolvedValue({ data: DUMMY_STATUS });

const api = new CWaterPumpAPIImpl({ client: mockClient });
await apiCall(api);

expect(mockClient.get).toHaveBeenCalledWith(url, { params });
}

async function shouldRethrowError(apiCall) {
const mockClient = { get: jest.fn() };
mockClient.get.mockRejectedValue(new Error('Network Error'));

const api = new CWaterPumpAPIImpl({ client: mockClient });
await expect(apiCall(api)).rejects.toThrow('Network Error');
}

async function shouldPreprocessResponse(apiCall) {
const mockClient = { get: jest.fn() };
mockClient.get.mockResolvedValue({ data: DUMMY_STATUS });

const api = new CWaterPumpAPIImpl({ client: mockClient });
const response = await apiCall(api);

expect(response.waterThreshold).toBe(DUMMY_STATUS["water threshold"]);
expect(response.pump.timeLeft).toBe(DUMMY_STATUS.pump["time left"]);
expect(response).toHaveProperty('updated');
}
// end of common test cases
// tests per method
describe('start', () => {
it('common test cases', async () => {
const T = Math.random() * 1000;
const callback = async (api) => await api.start(T);
await shouldThrowErrorFromResponse(callback);
await shouldRethrowError(callback);
await shouldPreprocessResponse(callback);
await shouldBeCalledWith(callback, '/pour_tea', { milliseconds: T });
});
});

describe('stop', () => {
it('common test cases', async () => {
const callback = async (api) => await api.stop();
await shouldThrowErrorFromResponse(callback);
await shouldRethrowError(callback);
await shouldPreprocessResponse(callback);
await shouldBeCalledWith(callback, '/stop', {});
});
});

describe('status', () => {
it('common test cases', async () => {
const callback = async (api) => await api.status();
await shouldThrowErrorFromResponse(callback);
await shouldRethrowError(callback);
await shouldPreprocessResponse(callback);
await shouldBeCalledWith(callback, '/status', {});
});
});
// tests for helper function preprocessResponse
describe('preprocessResponse', () => {
it('should return null if response is null', () => {
const api = new CWaterPumpAPIImpl({ client: {} });
expect(api.preprocessResponse({ response: null, requestTime: 0 })).toBeNull();
});

it('should throw error if response has error', () => {
const api = new CWaterPumpAPIImpl({ client: {} });
const errorMessage = 'Error ' + Math.random();
expect(() => api.preprocessResponse({
response: { error: errorMessage },
requestTime: 0,
})).toThrow(errorMessage);
});

it('should preprocess response', () => {
const api = new CWaterPumpAPIImpl({ client: {} });
const response = api.preprocessResponse({ response: DUMMY_STATUS, requestTime: 0 });
expect(response.waterThreshold).toBe(DUMMY_STATUS["water threshold"]);
expect(response.pump.timeLeft).toBe(DUMMY_STATUS.pump["time left"]);
});

it('should add field "updated" with current time', () => {
const T = Math.random() * 1000;
const api = new CWaterPumpAPIImpl({ client: {}, currentTime: () => T });
const response = api.preprocessResponse({ response: DUMMY_STATUS, requestTime: 0 });
expect(response.updated).toBe(T);
});

///////////
// Scenario:
// 00:00.000 - client sends request
// 00:00.100 - server receives request and set 'time' to 00:00.100, timeLeft = 1234ms
// 00:00.200 - server sends response
// 00:00.300 - client receives response, but 'time' is 00:00.100 and timeLeft = 1234ms
// total time: 300ms
// on average, time to one-way trip is 150ms
// so, we adjust time by 150ms i.e. time = 00:00.250, timeLeft = 1084ms
// estimatedEndTime = 00:00.300 + 1084ms = 00:01.384
it('should adjust time', () => {
const responseObj = JSON.parse(JSON.stringify(DUMMY_STATUS));
responseObj.time = 100;
responseObj.pump["time left"] = 1234;

const api = new CWaterPumpAPIImpl({ client: {}, currentTime: () => 300 });
const response = api.preprocessResponse({ response: responseObj, requestTime: 300 });
expect(response.time).toBe(250);
expect(response.pump.timeLeft).toBe(1084);
expect(response.pump.estimatedEndTime).toBe(1384);
});
});
});
22 changes: 22 additions & 0 deletions ui/src/components/CurrentOperationInfoArea.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import React from "react";
import { connect } from "react-redux";
import TimerArea from "./TimerArea";

export function CurrentOperationInfoAreaComponent({
isRunning, estimatedEndTime
}) {
if (!isRunning) return null;
return (
<div className="countdown-area">
<TimerArea startTime={null} endTime={estimatedEndTime} />
</div>
);
}

export default connect(
state => ({
isRunning: state.systemStatus.pump.running,
estimatedEndTime: state.systemStatus.pump.estimatedEndTime,
}),
[]
)(CurrentOperationInfoAreaComponent);
24 changes: 14 additions & 10 deletions ui/src/components/NotificationsArea.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
import React from 'react';
import { connect } from 'react-redux';
import { Alert } from 'react-bootstrap';
import { useNotificationsSystem } from '../contexts/NotificationsContext';
import { NotificationsSystemActions } from '../store/slices/Notifications';

function NotificationsArea() {
const NotificationsSystem = useNotificationsSystem();
const { currentNotifications } = NotificationsSystem;
if(!currentNotifications) return null;

const hideNotifications = () => { NotificationsSystem.clear(); };
function NotificationsArea({ hasNotifications, message, clearNotifications }) {
if(!hasNotifications) return null;

return (
<Alert variant="info" onClose={hideNotifications} dismissible>
{currentNotifications.message}
<Alert variant="info" onClose={clearNotifications} dismissible>
{message}
</Alert>
);
}

export default NotificationsArea;
export default connect(
(state) => ({
hasNotifications: state.notifications.currentNotifications != null,
message: state.notifications.currentNotifications?.message
}), {
clearNotifications: NotificationsSystemActions.clear
}
)(NotificationsArea);
Loading

0 comments on commit a7164fe

Please sign in to comment.