[ISSUE-11] Notify Message Limit (#12)

* [ISSUE-11] Notify Message Limit

* [ISSUE-11] Unit Testing
This commit is contained in:
jvallelunga 2017-10-26 00:50:42 +02:00 committed by GitHub
parent 3cb1fc853c
commit aa03b16916
19 changed files with 103 additions and 120 deletions

View File

@ -4,7 +4,7 @@ const { Provider, connect } = require('ink-redux');
const PropTypes = require('prop-types'); const PropTypes = require('prop-types');
export default (store) => { export default (store) => {
function Counter({ counter }) { function Counter({ counter, quote }) {
const color = { const color = {
blue: counter > 0, blue: counter > 0,
red: counter < 0, red: counter < 0,
@ -13,19 +13,24 @@ export default (store) => {
<div> <div>
Counter: Counter:
<Text {...color}> {counter}</Text> <Text {...color}> {counter}</Text>
<br />
<Text>{quote}</Text>
</div> </div>
); );
} }
Counter.propTypes = { Counter.propTypes = {
counter: PropTypes.number, counter: PropTypes.number,
quote: PropTypes.string,
}; };
Counter.defaultProps = { Counter.defaultProps = {
counter: 0, counter: 0,
quote: '',
}; };
const mapStateToProps = state => ({ const mapStateToProps = state => ({
counter: state, counter: state.counter,
quote: state.quote,
}); });
const ConnectedCounter = connect(mapStateToProps)(Counter); const ConnectedCounter = connect(mapStateToProps)(Counter);

View File

@ -1,9 +1,15 @@
export default function counter(state = 0, { type }) { export default function counter(state = { counter: 0 }, { type, payload }) {
switch (type) { switch (type) {
case 'INCREMENT': case 'INCREMENT':
return state + 1; return {
counter: state.counter + 1,
quote: payload,
};
case 'DECREMENT': case 'DECREMENT':
return state - 1; return {
counter: state.counter - 1,
quote: payload,
};
default: default:
return state; return state;
} }

View File

@ -28,6 +28,7 @@
}, },
"homepage": "https://jvallelunga.github.io/redux-bluetooth", "homepage": "https://jvallelunga.github.io/redux-bluetooth",
"dependencies": { "dependencies": {
"predator-quotes": "^1.1.16",
"prop-types": "^15.5.10", "prop-types": "^15.5.10",
"react": "^15.6.1", "react": "^15.6.1",
"react-dom": "^15.6.1", "react-dom": "^15.6.1",

View File

@ -1,9 +1,16 @@
import predatorQuotes from 'predator-quotes';
import * as TYPES from './types'; import * as TYPES from './types';
export function increment() { export function increment() {
return { type: TYPES.INCREMENT }; return {
type: TYPES.INCREMENT,
payload: predatorQuotes.random(),
};
} }
export function decrement() { export function decrement() {
return { type: TYPES.DECREMENT }; return {
type: TYPES.DECREMENT,
payload: predatorQuotes.random(),
};
} }

View File

@ -16,18 +16,20 @@ export default class App extends PureComponent {
} }
render() { render() {
const { store, status, onIncrement, onDecrement } = this.props; const { counter, quote, status, onIncrement, onDecrement } = this.props;
const counter = Number(store); const nCounter = Number(counter);
let className = 'app-counter'; let className = 'app-counter';
if (counter > 0) className += ' app-counter--positive'; if (nCounter > 0) className += ' app-counter--positive';
if (counter < 0) className += ' app-counter--negative'; if (nCounter < 0) className += ' app-counter--negative';
return ( return (
<div className="app"> <div className="app">
{status === 'CONNECTED' && {status === 'CONNECTED' &&
<div className={className}> <div>
{store} <span className={className}>{counter}</span>
<br />
<span>{quote}</span>
</div>} </div>}
<div className="app-actions"> <div className="app-actions">
{status !== 'CONNECTED' && {status !== 'CONNECTED' &&
@ -49,7 +51,8 @@ export default class App extends PureComponent {
} }
App.propTypes = { App.propTypes = {
store: PropTypes.number, counter: PropTypes.number,
quote: PropTypes.string,
status: PropTypes.string, status: PropTypes.string,
onConnect: PropTypes.func, onConnect: PropTypes.func,
onIncrement: PropTypes.func, onIncrement: PropTypes.func,
@ -57,7 +60,8 @@ App.propTypes = {
}; };
App.defaultProps = { App.defaultProps = {
store: 0, counter: 0,
quote: '',
status: '', status: '',
onConnect: () => true, onConnect: () => true,
onIncrement: () => true, onIncrement: () => true,

View File

@ -5,7 +5,10 @@ import { increment, decrement } from '../actions';
import Component from './component'; import Component from './component';
const mapState = state => state; const mapState = ({ status, store }) => ({
status,
...store,
});
const mapAction = { const mapAction = {
onConnect: actions.connectStore, onConnect: actions.connectStore,

View File

@ -10,7 +10,7 @@
.app-counter { .app-counter {
margin-bottom: 40px; margin-bottom: 40px;
line-height: calc(100vh - 200px); line-height: calc(100vh - 300px);
font-size: 80vh; font-size: 80vh;
color: black; color: black;
text-align: center; text-align: center;

View File

@ -9,3 +9,7 @@ export const BLENO_CONFIG = {
CHARACTERISTIC_UUID: CONFIG.CHARACTERISTIC_UUID.replace(/-/g, ''), CHARACTERISTIC_UUID: CONFIG.CHARACTERISTIC_UUID.replace(/-/g, ''),
DESCRIPTOR_UUID: '2901', DESCRIPTOR_UUID: '2901',
}; };
export const COMMON_TYPES = {
BLUETOOTH_SYNC_REQUEST: '@@bluetooth/SYNC_REQUEST',
};

View File

@ -2,16 +2,8 @@ export default function Encoder({ TextEncoder, TextDecoder }) {
const encoder = new TextEncoder('utf-8'); const encoder = new TextEncoder('utf-8');
const decoder = new TextDecoder('utf-8'); const decoder = new TextDecoder('utf-8');
const encode = (json) => { const encode = string => encoder.encode(string);
const string = JSON.stringify(json); const decode = data => decoder.decode(data);
return encoder.encode(string);
};
const decode = (data) => {
const string = decoder.decode(data);
const json = JSON.parse(string);
return typeof json === 'string' ? JSON.parse(json) : json;
};
return { encode, decode }; return { encode, decode };
} }

View File

@ -6,16 +6,10 @@ import Encoder from '.';
const { encode, decode } = new Encoder(TextEncoding); const { encode, decode } = new Encoder(TextEncoding);
test('encode / decode', () => { test('encode / decode', () => {
const data = encode({ type: 'TEST', payload: 'PAYLOAD' }); const message = JSON.stringify({ type: 'TEST', payload: 'PAYLOAD' });
const data = encode(message);
const result = decode(data); const result = decode(data);
expect(result).toEqual({ type: 'TEST', payload: 'PAYLOAD' }); expect(result).toEqual(message);
}); });
test('encode / decode: string', () => {
const data = encode('{ "type": "TEST", "payload": "PAYLOAD" }');
const result = decode(data);
expect(result).toEqual({ type: 'TEST', payload: 'PAYLOAD' });
});

View File

@ -2,11 +2,9 @@ export default function Characteristic(uuid, Parent, util, descriptor, { encode,
function ReduxCharacteristic() { function ReduxCharacteristic() {
Parent.call(this, { Parent.call(this, {
uuid, uuid,
properties: ['read', 'write', 'notify'], properties: ['write', 'notify'],
descriptors: [descriptor], descriptors: [descriptor],
}); });
this.state = null;
} }
util.inherits(ReduxCharacteristic, Parent); util.inherits(ReduxCharacteristic, Parent);
@ -18,21 +16,14 @@ export default function Characteristic(uuid, Parent, util, descriptor, { encode,
return; return;
} }
this.onAction(decode(data)); const action = decode(data);
this.onAction(JSON.parse(action));
callback(this.RESULT_SUCCESS); callback(this.RESULT_SUCCESS);
}; };
ReduxCharacteristic.prototype.onReadRequest = function (offset, callback) {
if (offset) {
callback(this.RESULT_ATTR_NOT_LONG, null);
return;
}
callback(this.RESULT_SUCCESS, this.state);
};
ReduxCharacteristic.prototype.onSubscribe = function (maxValueSize, updateValueCallback) { ReduxCharacteristic.prototype.onSubscribe = function (maxValueSize, updateValueCallback) {
this.maxValueSize = maxValueSize;
this.updateValueCallback = updateValueCallback; this.updateValueCallback = updateValueCallback;
}; };
@ -45,9 +36,16 @@ export default function Characteristic(uuid, Parent, util, descriptor, { encode,
}; };
ReduxCharacteristic.prototype.updateState = function (state) { ReduxCharacteristic.prototype.updateState = function (state) {
this.state = encode(state);
if (this.updateValueCallback) { if (this.updateValueCallback) {
this.updateValueCallback(this.state); const message = encode(`[[[${JSON.stringify(state)}]]]`);
let i = 0;
do {
const next = i + this.maxValueSize;
const end = Math.min(next, message.length);
const data = message.slice(i, end);
this.updateValueCallback(data);
i = next;
} while (i < message.length);
} }
}; };

View File

@ -16,7 +16,7 @@ let decode = null;
beforeEach(() => { beforeEach(() => {
encode = jest.fn().mockReturnValue('mockEncode'); encode = jest.fn().mockReturnValue('mockEncode');
decode = jest.fn().mockReturnValue('mockDecode'); decode = jest.fn().mockReturnValue('{"mockDecode":"mockDecode"}');
}); });
afterEach(() => { afterEach(() => {
@ -28,9 +28,8 @@ test('new Characteristic', () => {
const characteristic = Characteristic('mockUUID', Parent, util, 'mockDescriptor', { encode, decode }); const characteristic = Characteristic('mockUUID', Parent, util, 'mockDescriptor', { encode, decode });
expect(characteristic.uuid).toBe('mockUUID'); expect(characteristic.uuid).toBe('mockUUID');
expect(characteristic.properties).toEqual(['read', 'write', 'notify']); expect(characteristic.properties).toEqual(['write', 'notify']);
expect(characteristic.descriptors).toEqual(['mockDescriptor']); expect(characteristic.descriptors).toEqual(['mockDescriptor']);
expect(characteristic.state).toBe(null);
}); });
test('Characteristic.onWriteRequest: RESULT_ATTR_NOT_LONG', () => { test('Characteristic.onWriteRequest: RESULT_ATTR_NOT_LONG', () => {
@ -51,35 +50,17 @@ test('Characteristic.onWriteRequest: RESULT_SUCCESS', () => {
const spyOnAction = jest.spyOn(characteristic, 'onAction'); const spyOnAction = jest.spyOn(characteristic, 'onAction');
characteristic.onWriteRequest(null, false, false, callback); characteristic.onWriteRequest(null, false, false, callback);
expect(spyOnAction).toBeCalledWith('mockDecode'); expect(spyOnAction).toBeCalledWith({ mockDecode: 'mockDecode' });
expect(callback).toBeCalledWith('RESULT_SUCCESS'); expect(callback).toBeCalledWith('RESULT_SUCCESS');
callback = jest.fn(); callback = jest.fn();
characteristic.onAction = jest.fn(); characteristic.onAction = jest.fn();
characteristic.onWriteRequest(null, false, false, callback); characteristic.onWriteRequest(null, false, false, callback);
expect(characteristic.onAction).toBeCalledWith('mockDecode'); expect(characteristic.onAction).toBeCalledWith({ mockDecode: 'mockDecode' });
expect(callback).toBeCalledWith('RESULT_SUCCESS'); expect(callback).toBeCalledWith('RESULT_SUCCESS');
}); });
test('Characteristic.onReadRequest: RESULT_ATTR_NOT_LONG', () => {
const characteristic = Characteristic('mockUUID', Parent, util, 'mockDescriptor', { encode, decode });
const callback = jest.fn();
characteristic.onReadRequest(true, callback);
expect(callback).toBeCalledWith('RESULT_ATTR_NOT_LONG', null);
});
test('Characteristic.onReadRequest: RESULT_SUCCESS', () => {
const characteristic = Characteristic('mockUUID', Parent, util, 'mockDescriptor', { encode, decode });
const callback = jest.fn();
characteristic.onReadRequest(false, callback);
expect(callback).toBeCalledWith('RESULT_SUCCESS', characteristic.state);
});
test('Characteristic.onSubscribe', () => { test('Characteristic.onSubscribe', () => {
const characteristic = Characteristic('mockUUID', Parent, util, 'mockDescriptor', { encode, decode }); const characteristic = Characteristic('mockUUID', Parent, util, 'mockDescriptor', { encode, decode });
@ -100,12 +81,10 @@ test('Characteristic.onUnsubscribe', () => {
test('Characteristic.updateState', () => { test('Characteristic.updateState', () => {
const characteristic = Characteristic('mockUUID', Parent, util, 'mockDescriptor', { encode, decode }); const characteristic = Characteristic('mockUUID', Parent, util, 'mockDescriptor', { encode, decode });
characteristic.updateState('mockState'); const maxValueSize = 10;
expect(characteristic.state).toBe('mockEncode');
const updateValueCallback = jest.fn(); const updateValueCallback = jest.fn();
characteristic.onSubscribe(null, updateValueCallback); characteristic.onSubscribe(maxValueSize, updateValueCallback);
characteristic.updateState('mockState'); characteristic.updateState({ mockState: 'mockState' });
expect(updateValueCallback).toBeCalledWith('mockEncode'); expect(updateValueCallback).toBeCalledWith('mockEncode');
}); });

View File

@ -4,9 +4,9 @@ export default function Actions(central, TYPES) {
payload: state, payload: state,
}); });
const syncStore = () => ({ const sendAction = action => ({
type: TYPES.BLUETOOTH_SYNC_REQUEST, type: TYPES.BLUETOOTH_SEND_REQUEST,
request: dispatch => central.read().then(state => dispatch(syncState(state))), request: () => central.write(action),
}); });
const connectStore = name => ({ const connectStore = name => ({
@ -17,18 +17,17 @@ export default function Actions(central, TYPES) {
.connect(name) .connect(name)
.then(() => central.handler(state => dispatch(syncState(state)))) .then(() => central.handler(state => dispatch(syncState(state))))
.then(() => dispatch({ type: TYPES.BLUETOOTH_CONNECTED })) .then(() => dispatch({ type: TYPES.BLUETOOTH_CONNECTED }))
.then(() => dispatch(syncStore())); .then(() => dispatch(sendAction({
},
});
const sendAction = action => ({
type: TYPES.BLUETOOTH_SEND_REQUEST, type: TYPES.BLUETOOTH_SEND_REQUEST,
request: () => central.write(action), request: () => central.write({
type: TYPES.BLUETOOTH_SYNC_REQUEST,
}),
})));
},
}); });
return { return {
connectStore, connectStore,
syncStore,
syncState, syncState,
sendAction, sendAction,
}; };

View File

@ -33,7 +33,7 @@ test('syncState', () => {
test('connectStore', () => { test('connectStore', () => {
const { connectStore } = Actions(central, TYPES); const { connectStore } = Actions(central, TYPES);
const action = connectStore('mockName'); const action = connectStore('mockName');
expect.assertions(10); expect.assertions(9);
expect(action.type).toEqual(TYPES.BLUETOOTH_CONNECT_REQUEST); expect(action.type).toEqual(TYPES.BLUETOOTH_CONNECT_REQUEST);
@ -46,28 +46,10 @@ test('connectStore', () => {
expect(dispatch.mock.calls[1][0]).toEqual({ type: TYPES.BLUETOOTH_CONNECTED }); expect(dispatch.mock.calls[1][0]).toEqual({ type: TYPES.BLUETOOTH_CONNECTED });
const syncStore = dispatch.mock.calls[2][0]; const syncStore = dispatch.mock.calls[2][0];
expect(syncStore.type).toEqual(TYPES.BLUETOOTH_SYNC_REQUEST); expect(syncStore.type).toEqual(TYPES.BLUETOOTH_SEND_REQUEST);
return syncStore.request(dispatch); return syncStore.request(dispatch);
}).then(() => { }).then(() => {
expect(central.read).toBeCalled(); expect(central.write).toBeCalled();
expect(dispatch.mock.calls[3][0]).toEqual({ type: TYPES.BLUETOOTH_SYNC, payload: 'mockState' });
return true;
});
return expect(promise).resolves.toBe(true);
});
test('syncStore', () => {
const { syncStore } = Actions(central, TYPES);
const action = syncStore();
expect.assertions(4);
expect(action.type).toEqual(TYPES.BLUETOOTH_SYNC_REQUEST);
const promise = action.request(dispatch).then(() => {
expect(central.read).toBeCalled();
expect(dispatch).toBeCalledWith({ type: TYPES.BLUETOOTH_SYNC, payload: 'mockState' });
return true; return true;
}); });

View File

@ -6,7 +6,6 @@ jest.mock('../central', () => null);
test('actions', () => { test('actions', () => {
expect(Object.keys(actions)).toEqual([ expect(Object.keys(actions)).toEqual([
'connectStore', 'connectStore',
'syncStore',
'syncState', 'syncState',
'sendAction', 'sendAction',
]); ]);

View File

@ -5,6 +5,7 @@ export default function Central(
const state = { const state = {
server: null, server: null,
characteristic: null, characteristic: null,
message: '',
}; };
const connect = name => bluetooth const connect = name => bluetooth
@ -22,7 +23,18 @@ export default function Central(
}); });
const handler = callback => state.characteristic.startNotifications().then(() => { const handler = callback => state.characteristic.startNotifications().then(() => {
const listerner = event => callback(decode(event.target.value)); const listerner = (event) => {
const chunk = decode(event.target.value);
const message = `${state.message}${chunk}`;
if (message.startsWith('[[[') && message.endsWith(']]]')) {
const json = JSON.parse(message.slice(3, message.length - 3));
callback(json);
state.message = '';
} else {
state.message = message;
}
};
state.characteristic.addEventListener('characteristicvaluechanged', listerner); state.characteristic.addEventListener('characteristicvaluechanged', listerner);
return listerner; return listerner;
}); });

View File

@ -4,7 +4,7 @@ import Central from './central';
const encoder = { const encoder = {
encode: jest.fn().mockReturnValue('mockEncode'), encode: jest.fn().mockReturnValue('mockEncode'),
decode: jest.fn().mockReturnValue('mockDecode'), decode: jest.fn().mockReturnValue('[[[{"mockDecode":"mockDecode"}]]]'),
}; };
const characteristic = { const characteristic = {
@ -66,7 +66,7 @@ test('Central: handler', () => {
.then((listerner) => { .then((listerner) => {
expect(characteristic.startNotifications).toBeCalled(); expect(characteristic.startNotifications).toBeCalled();
listerner({ target: { value: 'mockEvent' } }); listerner({ target: { value: 'mockEvent' } });
expect(callback).toBeCalledWith('mockDecode'); expect(callback).toBeCalledWith({ mockDecode: 'mockDecode' });
return true; return true;
}); });
@ -82,7 +82,7 @@ test('Central: read', () => {
return data; return data;
}); });
return expect(promise).resolves.toBe('mockDecode'); return expect(promise).resolves.toBe('[[[{"mockDecode":"mockDecode"}]]]');
}); });
test('Central: write', () => { test('Central: write', () => {

View File

@ -5,11 +5,11 @@ import MIDDLEWARE from './middleware';
import REDUCERS from './reducers'; import REDUCERS from './reducers';
import STORE from './store'; import STORE from './store';
const { connectStore, syncStore } = ACTIONS; const { connectStore } = ACTIONS;
export const types = TYPES; export const types = TYPES;
export const status = STATUS; export const status = STATUS;
export const actions = { connectStore, syncStore }; export const actions = { connectStore };
export const reducers = REDUCERS; export const reducers = REDUCERS;
export const middleware = MIDDLEWARE; export const middleware = MIDDLEWARE;
export const createSyncStore = STORE; export const createSyncStore = STORE;

View File

@ -6,13 +6,11 @@ const { sendAction } = ACTIONS;
const { const {
BLUETOOTH_CONNECT_REQUEST, BLUETOOTH_CONNECT_REQUEST,
BLUETOOTH_SEND_REQUEST, BLUETOOTH_SEND_REQUEST,
BLUETOOTH_SYNC_REQUEST,
} = TYPES; } = TYPES;
const REQUESTS = [ const REQUESTS = [
BLUETOOTH_CONNECT_REQUEST, BLUETOOTH_CONNECT_REQUEST,
BLUETOOTH_SEND_REQUEST, BLUETOOTH_SEND_REQUEST,
BLUETOOTH_SYNC_REQUEST,
]; ];
export default (actions = []) => store => next => (action) => { export default (actions = []) => store => next => (action) => {