@platform/state
Version:
A small, simple, strongly typed, [rx/observable] state-machine.
394 lines (393 loc) • 19 kB
JavaScript
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);
});
});
});