From 8d35585ee173baaa5bcabc7b3b9b824287db4eba Mon Sep 17 00:00:00 2001 From: Tetsuharu OHZEKI Date: Wed, 14 Sep 2016 20:05:10 +0900 Subject: [PATCH] feat(lib): implement RxAutomaton inspired by https://speakerdeck.com/inamiy/reactive-state-machine-japanese?slide=65 --- src/client/example_rxautomaton.ts | 66 ++++++++++++++ src/lib/RxAutomaton.ts | 143 ++++++++++++++++++++++++++++++ src/lib/test/test_RxAutomaton.ts | 83 +++++++++++++++++ src/lib/test_manifest.js | 1 + 4 files changed, 293 insertions(+) create mode 100644 src/client/example_rxautomaton.ts create mode 100644 src/lib/RxAutomaton.ts create mode 100644 src/lib/test/test_RxAutomaton.ts diff --git a/src/client/example_rxautomaton.ts b/src/client/example_rxautomaton.ts new file mode 100644 index 00000000..30859bb9 --- /dev/null +++ b/src/client/example_rxautomaton.ts @@ -0,0 +1,66 @@ +import * as Rx from 'rxjs'; +import {Automaton} from '../lib/RxAutomaton'; + +// tslint:disable-next-line: no-namespace +declare global { + interface Window { + auto: Automaton; + input: Rx.Subject; + } +} + +const input = new Rx.Subject(); + +interface State { + current: number; +} + +const enum OpCode { + Increment, + Decrement, +} + +interface Input { + type: OpCode; + value: number; +} + +const inputStream = input.map((value): Input => { + return { + type: OpCode.Increment, + value, + }; +}); + +window.auto = new Automaton({ current: 0 }, inputStream, (state: State, input: Input) => { + switch (input.type) { + case OpCode.Increment: + return { + state: { + current: state.current + input.value, + }, + input: Rx.Observable.of({ + type: OpCode.Decrement, + value: input.value, + }).delay(500), + }; + + case OpCode.Decrement: + return { + state: { + current: state.current - input.value, + }, + input: Rx.Observable.empty(), + }; + } +}); + +window.auto.state().asObservable().subscribe((state) => { + console.log(`new state: ${state.current}`); +}, (e) => { + console.error(e); +}); + +window.input = input; + +input.next(1); diff --git a/src/lib/RxAutomaton.ts b/src/lib/RxAutomaton.ts new file mode 100644 index 00000000..08041684 --- /dev/null +++ b/src/lib/RxAutomaton.ts @@ -0,0 +1,143 @@ +import { + Observable, + Subject, + Subscription, +} from 'rxjs'; +import {ReactiveProperty} from './ReactiveProperty'; + +const enum TransitionType { + Success, + Failure, +} + +type TransitionSucess = { + type: TransitionType.Success, + next: TState, + input: Observable, +}; + +type TransitionFailure = { + type: TransitionType.Failure, + from: TState, + input: TInput, + err: Error, +}; + +type TransitionResult = TransitionSucess | TransitionFailure; + +function isTransitionSucess(v: TransitionResult): v is TransitionSucess { + return v.type === TransitionType.Success; +} + +type NextMapping = (state: TState, input: TInput) => { + state: TState, + input: Observable; +}; + +/** + * Inspired by https://speakerdeck.com/inamiy/reactive-state-machine-japanese?slide=65 + */ +export class Automaton { + private _state: ReactiveProperty; + private _disposer: Subscription; + + constructor(initial: TState, input: Observable, mapping: NextMapping) { + const state = new ReactiveProperty(initial); + const nextState: Observable = transitionState(state.asObservable(), input, mapping); + this._state = state; + this._disposer = nextState.subscribe(state); + } + + state(): ReactiveProperty { + return this._state; + } +} + +function transitionState(state: Observable, + input: Observable, + mapping: NextMapping): Observable { + const inputPipe = new Subject>(); + const nextInput: Observable = inputPipe.flatMap((inner) => inner); + const grandInput = input.merge(nextInput); + + type Result = TransitionResult; + type Success = TransitionSucess; + + const transition: Observable = grandInput + .withLatestFrom(state, (input: TInput, from: TState) => { + return { + input, + from, + }; + }).map((container) => { + return callStateMapper(mapping, container); + }); + + const postTransition: Observable = transition + .do((result: Result) => { + switch (result.type) { + case TransitionType.Success: + inputPipe.next(result.input); + break; + case TransitionType.Failure: + console.error(result); + break; + default: + throw new RangeError('undefined TransitionType'); + } + }) + .do((result: Result) => { + let type: string; + switch (result.type) { + case TransitionType.Success: + type = 'Success'; + break; + case TransitionType.Failure: + type = 'Failure'; + break; + default: + throw new RangeError('undefined TransitionType'); + } + console.group(); + console.log(`type: ${type}`); + console.dir(result); + console.groupEnd(); + }); + + const successTransition = postTransition.filter(isTransitionSucess); + + return successTransition.map((container) => { + if (container.type === TransitionType.Success) { + return container.next; + } + else { + throw new TypeError('unreachable'); + } + }); +} + +function callStateMapper(mapping: NextMapping, + container: { from: TState, input: TInput }): TransitionResult { + const { input, from, } = container; + let next: { + state: TState, + input: Observable; + }; + try { + next = mapping(from, input); + } + catch (err) { + return { + type: TransitionType.Failure, + from, + input, + err, + } as TransitionFailure; + } + + return { + type: TransitionType.Success, + next: next.state, + input: next.input, + } as TransitionSucess; +} diff --git a/src/lib/test/test_RxAutomaton.ts b/src/lib/test/test_RxAutomaton.ts new file mode 100644 index 00000000..d4bcf249 --- /dev/null +++ b/src/lib/test/test_RxAutomaton.ts @@ -0,0 +1,83 @@ +import * as assert from 'assert'; +import { + Observable, + Subject, +} from 'rxjs'; + +import {Automaton} from '../RxAutomaton'; + +describe('RxAutomaton', () => { + + describe('Automaton', () => { + describe('state()', () => { + + describe('get initial state', () => { + const INITIAL_STATE = 0; + + let resultBySubscribe: number; + let resultByGetter: number; + + before((done) => { + const input = new Subject(); + const m = new Automaton(INITIAL_STATE, input, (state: number, _: number) => { + return { + state, + input: Observable.empty(), + }; + }); + + const state = m.state(); + resultByGetter = state.value(); + state.asObservable().subscribe((state) => { + resultBySubscribe = state; + }, done, done); + + state.complete(); + }); + + it('initial state from subscription', () => { + assert.deepStrictEqual(resultBySubscribe, INITIAL_STATE); + }); + + it('initial state from getter', () => { + assert.deepStrictEqual(resultByGetter, INITIAL_STATE); + }); + }); + + describe('set state from outer', () => { + const seq: Array = []; + const mapperSeq: Array = []; + + before((done) => { + const input = new Subject(); + const m = new Automaton(0, input, (state: number, _: number) => { + mapperSeq.push(state); + + return { + state, + input: Observable.empty(), + }; + }); + + const state = m.state(); + state.asObservable().subscribe((state) => { + seq.push(state); + }, done, done); + + state.setValue(1); + state.setValue(2); + state.setValue(3); + state.complete(); + }); + + it('state should be updated', () => { + assert.deepStrictEqual(seq, [0, 1, 2, 3]); + }); + + it('mapper should not call', () => { + assert.deepStrictEqual(mapperSeq, []); + }); + }); + }); + }); +}); diff --git a/src/lib/test_manifest.js b/src/lib/test_manifest.js index 2f6f6239..d1eb39b1 100644 --- a/src/lib/test_manifest.js +++ b/src/lib/test_manifest.js @@ -4,4 +4,5 @@ import './test/test_FetchDriver_fetch'; import './test/test_FetchDriver_get'; import './test/test_FetchDriver_post'; import './test/test_FetchDriver_utils'; +import './test/test_RxAutomaton'; import './test/test_ViewContext';