UNPKG

@platform/state

Version:

A small, simple, strongly typed, [rx/observable] state-machine.

394 lines (393 loc) 19 kB
import { isDraft } from 'immer'; import { Subject } from 'rxjs'; import { filter } from 'rxjs/operators'; import { StateObject } from '.'; import { expect } from '../test'; import { StateObject as StateObjectClass } from './StateObject'; describe('StateObject', () => { describe('lifecycle', () => { it('create: store initial state', () => { const initial = { count: 1 }; const obj = StateObject.create(initial); expect(obj.state).to.eql(initial); expect(obj.original).to.eql(initial); expect(obj.state).to.not.equal(initial); expect(obj.original).to.not.equal(initial); }); it('create: readonly version', () => { const obj = StateObject.create({ count: 0 }); const readonly = obj.readonly; expect(readonly).to.be.an.instanceof(StateObjectClass); expect(readonly).to.equal(obj); expect(StateObject.readonly(obj)).to.be.an.instanceof(StateObjectClass); expect(StateObject.readonly(readonly)).to.be.an.instanceof(StateObjectClass); expect(StateObject.readonly(obj)).to.equal(obj); expect(StateObject.readonly(readonly)).to.equal(obj); }); it('dispose', () => { const obj = StateObject.create({ count: 1 }); let count = 0; obj.dispose$.subscribe((e) => count++); expect(obj.isDisposed).to.eql(false); obj.dispose(); obj.dispose(); obj.dispose(); expect(obj.isDisposed).to.eql(true); expect(count).to.eql(1); }); it('dispose: events cease firing', () => { const obj = StateObject.create({ count: 1 }); let fired = []; obj.event.$.subscribe((e) => fired.push(e)); obj.change((draft) => (draft.message = 'hello')); expect(fired.length).to.eql(2); obj.dispose(); obj.dispose(); obj.dispose(); expect(obj.isDisposed).to.eql(true); expect(fired.length).to.eql(3); const event = fired[2]; expect(event.type).to.eql('StateObject/disposed'); expect(event.payload.original).to.eql({ count: 1 }); expect(event.payload.final).to.eql({ count: 1, message: 'hello' }); obj.change((draft) => (draft.message = 'hello')); expect(fired.length).to.eql(3); }); }); describe('static', () => { it('static: toObject (original)', () => { const initial = { count: 0 }; const obj = StateObject.create(initial); let original; obj.change((draft) => { draft.count = 123; expect(draft.count).to.eql(123); original = StateObject.toObject(draft); }); expect(isDraft(original)).to.eql(false); expect(original === null || original === void 0 ? void 0 : original.count).to.eql(0); expect(obj.state.count).to.eql(123); }); it('isStateObject', () => { const test = (input, expected) => { const res = StateObject.isStateObject(input); expect(res).to.eql(expected); }; test(undefined, false); test(null, false); test('', false); test(123, false); test(true, false); test({}, false); const obj = StateObject.create({ count: 0 }); test(obj, true); }); }); describe('change', () => { it('change: update (via function)', () => { var _a, _b, _c, _d; const initial = { count: 1 }; const obj = StateObject.create(initial); expect(obj.state).to.equal(obj.original); const res1 = obj.change((draft) => { draft.count += 2; draft.message = 'hello'; }); const res2 = obj.change((draft) => (draft.count += 1)); expect(res1.op).to.eql('update'); expect(res1.cid.length).to.greaterThan(4); expect(res2.cid.length).to.greaterThan(4); expect(res1.cid).to.not.eql(res2.cid); expect(obj.state).to.eql({ count: 4, message: 'hello' }); expect(obj.state).to.not.equal(obj.original); expect((_a = res1.changed) === null || _a === void 0 ? void 0 : _a.from).to.eql({ count: 1 }); expect((_b = res1.changed) === null || _b === void 0 ? void 0 : _b.to).to.eql({ count: 3, message: 'hello' }); expect(res1.cancelled).to.eql(undefined); expect(res2.op).to.eql('update'); expect((_c = res2.changed) === null || _c === void 0 ? void 0 : _c.from).to.eql({ count: 3, message: 'hello' }); expect((_d = res2.changed) === null || _d === void 0 ? void 0 : _d.to).to.eql({ count: 4, message: 'hello' }); expect(res2.cancelled).to.eql(undefined); expect(obj.original).to.eql(initial); }); it('no change (does not fire events)', () => { const initial = { count: 0 }; const obj = StateObject.create(initial); let changing = 0; let changed = 0; obj.event.changing$.subscribe((e) => changing++); obj.event.changed$.subscribe((e) => changed++); const test = (changer) => { const res = obj.change(changer); expect(changing).to.eql(0); expect(changed).to.eql(0); expect(obj.state).to.eql(initial); expect(res.changed).to.eql(undefined); expect(res.cancelled).to.eql(undefined); }; test((draft) => undefined); test((draft) => (draft.count = 0)); }); it('change: replace (via {object} value)', () => { var _a, _b, _c, _d; const initial = { count: 1 }; const obj = StateObject.create(initial); expect(obj.state).to.equal(obj.original); const res1 = obj.change({ count: 2, message: 'hello' }); expect(res1.op).to.eql('replace'); expect((_a = res1.changed) === null || _a === void 0 ? void 0 : _a.from).to.eql({ count: 1 }); expect((_b = res1.changed) === null || _b === void 0 ? void 0 : _b.to).to.eql({ count: 2, message: 'hello' }); expect(obj.state).to.eql({ count: 2, message: 'hello' }); const res2 = obj.change({ count: 3 }); expect(res2.op).to.eql('replace'); expect((_c = res2.changed) === null || _c === void 0 ? void 0 : _c.from).to.eql({ count: 2, message: 'hello' }); expect((_d = res2.changed) === null || _d === void 0 ? void 0 : _d.to).to.eql({ count: 3 }); expect(obj.state).to.eql({ count: 3 }); expect(obj.original).to.eql(initial); }); it('change: disconnected method function can be passed around (bound)', () => { const initial = { count: 1 }; const obj = StateObject.create(initial); const change = obj.change; const increment = () => change((draft) => draft.count++); expect(obj.state.count).to.eql(1); change((draft) => (draft.count -= 1)); expect(obj.state.count).to.eql(0); change({ count: 99 }); expect(obj.state.count).to.eql(99); increment(); increment(); expect(obj.state.count).to.eql(101); expect(obj.original).to.eql(initial); }); it('throw: when property name contains "/"', () => { const obj = StateObject.create({}); const fn = () => obj.change((draft) => (draft['foo/bar'] = 123)); expect(fn).to.throw(/Property names cannot contain the "\/" character/); }); }); describe('patches', () => { it('no change', () => { const obj = StateObject.create({ count: 1 }); const res = obj.change((draft) => undefined); expect(obj.state.count).to.eql(1); expect(res.patches.next).to.eql([]); expect(res.patches.prev).to.eql([]); }); it('update', () => { var _a, _b; const initial = { foo: { count: 1 } }; const obj = StateObject.create(initial); const res = obj.change((draft) => { draft.foo.count++; draft.foo.message = 'hello'; draft.bar = { count: 9 }; return 123; }); expect(res.op).to.eql('update'); expect((_a = res.changed) === null || _a === void 0 ? void 0 : _a.to.foo.count).to.eql(2); expect((_b = res.changed) === null || _b === void 0 ? void 0 : _b.to.foo.message).to.eql('hello'); const { next, prev } = res.patches; expect(next.length).to.eql(3); expect(prev.length).to.eql(3); expect(prev[0]).to.eql({ op: 'replace', path: 'foo/count', value: 1 }); expect(prev[1]).to.eql({ op: 'remove', path: 'foo/message' }); expect(prev[2]).to.eql({ op: 'remove', path: 'bar' }); expect(next[0]).to.eql({ op: 'replace', path: 'foo/count', value: 2 }); expect(next[1]).to.eql({ op: 'add', path: 'foo/message', value: 'hello' }); expect(next[2]).to.eql({ op: 'add', path: 'bar', value: { count: 9 } }); }); it('replace', () => { const obj = StateObject.create({ count: 0 }); const res = obj.change({ count: 888 }); expect(res.op).to.eql('replace'); obj.change({ count: 5, message: 'hello' }); const { next, prev } = res.patches; expect(next.length).to.eql(1); expect(prev.length).to.eql(1); expect(prev[0]).to.eql({ op: 'replace', path: '', value: { count: 0 } }); expect(next[0]).to.eql({ op: 'replace', path: '', value: { count: 888 } }); }); }); describe('events', () => { it('event: changing', () => { const initial = { count: 1 }; const obj = StateObject.create(initial); const events = []; const changing = []; obj.event.$.subscribe((e) => events.push(e)); obj.event.changing$.subscribe((e) => changing.push(e)); const res = obj.change((draft) => (draft.count += 1)); expect(obj.state.count).to.eql(2); expect(events.length).to.eql(2); expect(changing.length).to.eql(1); expect(events[0].type).to.eql('StateObject/changing'); expect(events[1].type).to.eql('StateObject/changed'); const event = changing[0]; expect(event.op).to.eql('update'); expect(event.cancelled).to.eql(false); expect(event.action).to.eql(''); expect(event.from).to.eql(initial); expect(event.to).to.eql({ count: 2 }); expect(event.patches).to.eql(res.patches); }); it('event: changing (cancelled)', () => { const initial = { count: 1 }; const obj = StateObject.create(initial); const cancelled = []; const changing = []; const changed = []; obj.event.cancelled$.subscribe((e) => cancelled.push(e)); obj.event.changed$.subscribe((e) => changed.push(e)); obj.event.changing$.subscribe((e) => { changing.push(e); e.cancel(); }); obj.change((draft) => (draft.count += 1)); expect(changing.length).to.eql(1); expect(cancelled.length).to.eql(1); expect(changed.length).to.eql(0); obj.change({ count: 2 }); expect(changing.length).to.eql(2); expect(cancelled.length).to.eql(2); expect(changed.length).to.eql(0); expect(obj.state).to.eql(initial); expect(obj.state).to.equal(obj.original); }); it('event: changed', () => { const initial = { count: 1 }; const obj = StateObject.create(initial); const events = []; const changing = []; const changed = []; obj.event.$.subscribe((e) => events.push(e)); obj.event.changing$.subscribe((e) => changing.push(e)); obj.event.changed$.subscribe((e) => changed.push(e)); const res = obj.change((draft) => draft.count++); expect(events.length).to.eql(2); expect(changed.length).to.eql(1); const event = changed[0]; expect(event.op).to.eql('update'); expect(event.from).to.eql(initial); expect(event.to).to.eql({ count: 2 }); expect(event.to).to.equal(obj.state); expect(event.action).to.equal(''); expect(event.patches).to.eql(res.patches); expect(changing[0].cid).to.eql(changed[0].cid); }); it('event: changing/changed (with "action")', () => { const obj = StateObject.create({ count: 1 }); const changing = []; const changed = []; const actions = []; obj.event.changing$.subscribe((e) => changing.push(e)); obj.event.changed$.subscribe((e) => changed.push(e)); obj.event.changed$ .pipe(filter((e) => e.action === 'INCREMENT')) .subscribe((e) => actions.push(e)); obj.change((draft) => (draft.message = 'hello')); expect(changing.length).to.eql(1); expect(changed.length).to.eql(1); expect(actions.length).to.eql(0); obj.change((draft) => draft.count++, { action: 'INCREMENT' }); expect(changing.length).to.eql(2); expect(changed.length).to.eql(2); expect(actions.length).to.eql(1); expect(changing[0].action).to.eql(''); expect(changing[1].action).to.eql('INCREMENT'); expect(changed[0].action).to.eql(''); expect(changed[1].action).to.eql('INCREMENT'); }); it('event: changing/changed (via "replace" operation)', () => { const obj = StateObject.create({ count: 1 }); const changing = []; const changed = []; obj.event.changing$.subscribe((e) => changing.push(e)); obj.event.changed$.subscribe((e) => changed.push(e)); obj.change({ count: 888 }); expect(changing.length).to.eql(1); expect(changed.length).to.eql(1); expect(changing[0].op).to.eql('replace'); expect(changed[0].op).to.eql('replace'); expect(changing[0].patches).to.eql(changed[0].patches); const patches = changed[0].patches; expect(patches.prev[0]).to.eql({ op: 'replace', path: '', value: { count: 1 } }); expect(patches.next[0]).to.eql({ op: 'replace', path: '', value: { count: 888 } }); }); it('event: changedPatches', () => { const obj = StateObject.create({ count: 1 }); const patches = []; obj.event.patched$.subscribe((e) => patches.push(e)); obj.change({ count: 888 }, { action: 'INCREMENT' }); expect(patches.length).to.eql(1); const e = patches[0]; expect(e.op).to.eql('replace'); expect(e.action).to.eql('INCREMENT'); expect(e.prev[0]).to.eql({ op: 'replace', path: '', value: { count: 1 } }); expect(e.next[0]).to.eql({ op: 'replace', path: '', value: { count: 888 } }); }); }); describe('merge', () => { it('create: from initial {object} values', () => { const initial = { foo: { count: 0 }, bar: {} }; const merged = StateObject.merge(initial); expect(merged.store.state).to.eql(merged.state); expect(merged.state).to.eql(initial); }); it('create: from initial {state-object} values', () => { const foo = StateObject.create({ count: 123 }); const bar = StateObject.create({ isEnabled: true }); const merged = StateObject.merge({ foo, bar }); expect(merged.state.foo).to.eql({ count: 123 }); expect(merged.state.bar).to.eql({ isEnabled: true }); foo.change((draft) => draft.count++); expect(merged.state.foo.count).to.eql(124); }); it('exposes [changed$] events', () => { const merged = StateObject.merge({ foo: { count: 0 }, bar: {} }); expect(merged.changed$).to.equal(merged.store.event.changed$); }); it('add: sync values', () => { const initial = { foo: { count: 0 }, bar: {} }; const bar = StateObject.create({ isEnabled: true }); const merged = StateObject.merge(initial); expect(merged.state.bar.isEnabled).to.eql(undefined); merged.add('bar', bar); expect(merged.store.state).to.eql(merged.state); expect(merged.state.bar.isEnabled).to.eql(true); }); it('change: sync values', () => { const initial = { foo: { count: 0 }, bar: {} }; const foo = StateObject.create({ count: 1 }); const bar = StateObject.create({}); const merged = StateObject.merge(initial).add('bar', bar).add('foo', foo); expect(merged.state).to.eql({ foo: { count: 1 }, bar: {} }); foo.change((draft) => { draft.count = 123; draft.message = 'hello'; }); bar.change({ isEnabled: true }); expect(merged.state.foo).to.eql({ count: 123, message: 'hello' }); expect(merged.state.bar).to.eql({ isEnabled: true }); }); it('dispose$ (param)', () => { const dispose$ = new Subject(); const merged = StateObject.merge({ foo: { count: 0 }, bar: {} }, dispose$); expect(merged.store.isDisposed).to.eql(false); dispose$.next(); expect(merged.store.isDisposed).to.eql(true); }); it('stop syncing on [store.dispose]', () => { const initial = { foo: { count: 0 }, bar: {} }; const foo = StateObject.create({ count: 1 }); const bar = StateObject.create({}); const merged = StateObject.merge(initial).add('bar', bar).add('foo', foo); foo.change((draft) => draft.count++); bar.change((draft) => (draft.isEnabled = !Boolean(draft.isEnabled))); expect(merged.state.foo.count).to.eql(2); expect(merged.state.bar.isEnabled).to.eql(true); merged.dispose(); foo.change((draft) => draft.count++); expect(merged.state.foo.count).to.eql(2); }); }); });