From 20bdb88ccd34756f219cf419ec19991c698e1633 Mon Sep 17 00:00:00 2001 From: GreenWizard2015 Date: Wed, 10 Jan 2024 12:28:31 +0000 Subject: [PATCH] update API --- ui/src/App.test.js | 6 -- ui/src/api/CWaterPumpAPI.js | 58 ++---------- ui/src/api/CWaterPumpAPIImpl.js | 66 +++++++++++++ ui/src/api/CWaterPumpAPIImpl.test.js | 135 +++++++++++++++++++++++++++ 4 files changed, 208 insertions(+), 57 deletions(-) delete mode 100644 ui/src/App.test.js create mode 100644 ui/src/api/CWaterPumpAPIImpl.js create mode 100644 ui/src/api/CWaterPumpAPIImpl.test.js diff --git a/ui/src/App.test.js b/ui/src/App.test.js deleted file mode 100644 index 0d76197..0000000 --- a/ui/src/App.test.js +++ /dev/null @@ -1,6 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import App from './App'; - -test('1 == 1', () => { - expect(1).toEqual(1); -}); \ No newline at end of file diff --git a/ui/src/api/CWaterPumpAPI.js b/ui/src/api/CWaterPumpAPI.js index d77098f..33b6a8a 100644 --- a/ui/src/api/CWaterPumpAPI.js +++ b/ui/src/api/CWaterPumpAPI.js @@ -1,4 +1,5 @@ import axios from 'axios'; +import { CWaterPumpAPIImpl } from './CWaterPumpAPIImpl.js'; // helper function to preprocess the API host function preprocessApiHost(apiHost) { @@ -10,61 +11,16 @@ function preprocessApiHost(apiHost) { return url; } -function preprocessResponse(response) { - if(null == response) return null; - if('error' in response) { - // TODO: handle errors in slice/SystemStatus.js - throw new Error(response.error); - } - // normal response - // convert "water threshold" to "waterThreshold" - response.waterThreshold = response["water threshold"]; - delete response["water threshold"]; - - // convert "time left" to "timeLeft" - response.pump.timeLeft = response.pump["time left"]; - delete response.pump["time left"]; - - // add field "updated" - response.updated = Date.now(); - // difference between current time on client and time on device - response.timeDelta = response.updated - response.time; - // TODO: add field response.pump.estimatedEndTime - return response; -} - -// TODO: probably we need to know "ping" time to sync time more accurately -// Example: -// 00:00.000 - client sends request -// 00:00.100 - server receives request and set 'time' to 00:00.100, timeLeft = 1000ms -// 00:00.200 - server sends response -// 00:00.300 - client receives response, but 'time' is 00:00.100 and timeLeft = 1000ms -// 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 = 850ms -// in this case, error is 50ms (150ms - actual 00:00.100), instead of 200ms (300ms - actual 00:00.100) -////////////////////////////////////////////////////////////////////// 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 preprocessResponse(response.data); } - async stop() { - const response = await this._client.get('/stop'); - return preprocessResponse(response.data); - } - - async status() { - const response = await this._client.get('/status'); - return preprocessResponse(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; diff --git a/ui/src/api/CWaterPumpAPIImpl.js b/ui/src/api/CWaterPumpAPIImpl.js new file mode 100644 index 0000000..e83837f --- /dev/null +++ b/ui/src/api/CWaterPumpAPIImpl.js @@ -0,0 +1,66 @@ +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', { milliseconds: runTimeMs }) + ); + return this.preprocessResponse({ response: data, requestTime }); + } + + async stop() { + const { response: { data }, requestTime } = await this._execute( + async () => await this._client.get('/stop', {}) + ); + return this.preprocessResponse({ response: data, requestTime }); + } + + async status() { + const { response: { data }, requestTime } = await this._execute( + async () => await this._client.get('/status', {}) + ); + return this.preprocessResponse({ response: data, requestTime }); + } + /////////////////////// + // helper functions + preprocessResponse({ response, requestTime }) { + if(null == response) return null; + if('error' in response) { + // TODO: handle errors in slice/SystemStatus.js + 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 }; \ No newline at end of file diff --git a/ui/src/api/CWaterPumpAPIImpl.test.js b/ui/src/api/CWaterPumpAPIImpl.test.js new file mode 100644 index 0000000..8d75b99 --- /dev/null +++ b/ui/src/api/CWaterPumpAPIImpl.test.js @@ -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=null) { + 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); + }); + }); +}); \ No newline at end of file