From 57c004fbbe045c72140f5f2048e2230a8becc3f8 Mon Sep 17 00:00:00 2001 From: Kirill Agalakov Date: Wed, 8 Apr 2020 10:33:35 +0300 Subject: [PATCH 1/2] build(npm): add fp-ts-laws dev dependency --- package-lock.json | 29 +++++++++++++++++++++++++++++ package.json | 1 + 2 files changed, 30 insertions(+) diff --git a/package-lock.json b/package-lock.json index 92ba8d9..dc59daa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2132,6 +2132,22 @@ "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=", "dev": true }, + "fast-check": { + "version": "1.24.1", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-1.24.1.tgz", + "integrity": "sha512-ECF5LDbt4F8sJyTDI62fRLn0BdHDAdBacxlEsxaYbtqwbsdWofoYZUSaUp9tJrLsqCQ8jG28SkNvPZpDfNo3tw==", + "requires": { + "pure-rand": "^2.0.0", + "tslib": "^1.10.0" + }, + "dependencies": { + "tslib": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.11.1.tgz", + "integrity": "sha512-aZW88SY8kQbU7gpV19lN24LtXh/yD4ZZg6qieAJDDg+YBsJcSmLGK9QpnUjAKVG/xefmvJGd1WUmfpT/g6AJGA==" + } + } + }, "fast-deep-equal": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.1.tgz", @@ -2284,6 +2300,14 @@ "integrity": "sha512-R/QwlxnRlboZIObqKjOUf8rkXz36uSWfJO2M5o2cqf7UiHoHlNMojr/PiUyj8j3l1fOP62dR6nsKvFaRSJBIaQ==", "dev": true }, + "fp-ts-laws": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/fp-ts-laws/-/fp-ts-laws-0.2.1.tgz", + "integrity": "sha512-KHsBlV7Cq0iHQ6x6tqZ01Kzzao/k1Z8oFHX/jPugRbd254xyDfw1sDSQYKErKInHNIwyqjnO+HU+CtJg85d38w==", + "requires": { + "fast-check": "^1.16.0" + } + }, "fragment-cache": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/fragment-cache/-/fragment-cache-0.2.1.tgz", @@ -5339,6 +5363,11 @@ "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", "dev": true }, + "pure-rand": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-2.0.0.tgz", + "integrity": "sha512-mk98aayyd00xbfHgE3uEmAUGzz3jCdm8Mkf5DUXUhc7egmOaGG2D7qhVlynGenNe9VaNJZvzO9hkc8myuTkDgw==" + }, "qs": { "version": "6.5.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", diff --git a/package.json b/package.json index 1382471..973ebc5 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@types/node": "7.0.4", "docs-ts": "^0.3.4", "fp-ts": "^2.4.3", + "fp-ts-laws": "^0.2.1", "import-path-rewrite": "github:gcanti/import-path-rewrite", "jest": "^24.8.0", "mocha": "^5.2.0", From f4ed3a156fa58305b8fdb6c99018ccd5b133f2da Mon Sep 17 00:00:00 2001 From: Kirill Agalakov Date: Wed, 8 Apr 2020 16:36:40 +0300 Subject: [PATCH 2/2] feat: replace Alternative instance with Plus Observable does not always satisfy Alternative's distributivity law --- src/Observable.ts | 5 +- src/Plus.ts | 20 +++++++ test/Observable.ts | 137 ++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 158 insertions(+), 4 deletions(-) create mode 100644 src/Plus.ts diff --git a/src/Observable.ts b/src/Observable.ts index 553aeca..0b6f155 100644 --- a/src/Observable.ts +++ b/src/Observable.ts @@ -1,7 +1,6 @@ /** * @since 0.6.0 */ -import { Alternative1 } from 'fp-ts/lib/Alternative' import * as E from 'fp-ts/lib/Either' import { Filterable1 } from 'fp-ts/lib/Filterable' import { identity, Predicate } from 'fp-ts/lib/function' @@ -14,6 +13,8 @@ import { map as rxMap, mergeMap } from 'rxjs/operators' import { IO } from 'fp-ts/lib/IO' import { Task } from 'fp-ts/lib/Task' import { MonadObservable1 } from './MonadObservable' +import { Alt1 } from 'fp-ts/lib/Alt' +import { Plus1 } from './Plus' declare module 'fp-ts/lib/HKT' { interface URItoKind { @@ -79,7 +80,7 @@ export function toTask(o: Observable): Task { /** * @since 0.6.0 */ -export const observable: Monad1 & Alternative1 & Filterable1 & MonadObservable1 = { +export const observable: Monad1 & Alt1 & Plus1 & Filterable1 & MonadObservable1 = { URI, map: (fa, f) => fa.pipe(rxMap(f)), of, diff --git a/src/Plus.ts b/src/Plus.ts new file mode 100644 index 0000000..0db4761 --- /dev/null +++ b/src/Plus.ts @@ -0,0 +1,20 @@ +import { Kind, URIS } from 'fp-ts/lib/HKT' +import { Alt1 } from 'fp-ts/lib/Alt' + +/** + * The `Plus` type class extends the `Alt` type class with a value that should be the left and right identity for `alt`. + * + * It is similar to `Monoid`, except that it applies to types of kind `* -> *`, like `Array` or `Option`, rather than + * concrete types like `string` or `number`. + * + * `Plus` instances should satisfy the following laws: + * + * 1. Left identity: `A.alt(zero, fa) == fa` + * 2. Right identity: `A.alt(fa, zero) == fa` + * 3. Annihilation: `A.map(zero, f) == zero` + * + * @since 0.7.0 + */ +export interface Plus1 extends Alt1 { + readonly zero: () => Kind +} diff --git a/test/Observable.ts b/test/Observable.ts index 1afbb73..565ad8e 100644 --- a/test/Observable.ts +++ b/test/Observable.ts @@ -1,14 +1,147 @@ import * as assert from 'assert' -import { from } from 'rxjs' -import { bufferTime } from 'rxjs/operators' +import { from, Observable } from 'rxjs' +import { bufferTime, subscribeOn } from 'rxjs/operators' import * as O from 'fp-ts/lib/Option' import * as E from 'fp-ts/lib/Either' import * as T from 'fp-ts/lib/Task' import { identity } from 'fp-ts/lib/function' +import * as laws from 'fp-ts-laws' import { observable as R } from '../src' +import { TestScheduler } from 'rxjs/testing' +import { Eq, eqNumber, eqString } from 'fp-ts/lib/Eq' +import { getEq } from 'fp-ts/lib/Array' + +const liftE = (E: Eq): Eq> => { + const arrayE = getEq(E) + return { + equals: (x, y) => { + const scheduler = new TestScheduler(assert.deepStrictEqual) + const xas: Array = [] + x.pipe(subscribeOn(scheduler)).subscribe(a => xas.push(a)) + const yas: Array = [] + y.pipe(subscribeOn(scheduler)).subscribe(a => yas.push(a)) + scheduler.flush() + assert.deepStrictEqual(xas, yas) + return arrayE.equals(xas, yas) + } + } +} describe('Observable', () => { + describe('laws', () => { + const f = (n: number): string => `map(${n})` + const a = R.observable.of(1) + const b = R.observable.of(2) + const c = R.observable.of(3) + it('Monad', () => { + laws.monad(R.observable)(liftE) + }) + describe('Alt', () => { + it('associativity', () => { + const left = R.observable.alt( + R.observable.alt(a, () => b), + () => c + ) + const right = R.observable.alt(a, () => R.observable.alt(b, () => c)) + assert.ok(liftE(eqNumber).equals(left, right)) + }) + it('distributivity', () => { + const left = R.observable.map( + R.observable.alt(a, () => b), + f + ) + const right = R.observable.alt(R.observable.map(a, f), () => R.observable.map(b, f)) + assert.ok(liftE(eqString).equals(left, right)) + }) + }) + describe('Plus', () => { + it('right identity', () => { + const left = R.observable.alt(a, () => R.observable.zero()) + assert.ok(liftE(eqNumber).equals(left, a)) + }) + it('left identity', () => { + const left = R.observable.alt(R.observable.zero(), () => a) + assert.ok(liftE(eqNumber).equals(left, a)) + }) + it('annihilation', () => { + const left = R.observable.map(R.observable.zero(), f) + const right = R.observable.zero() + assert.ok(liftE(eqString).equals(left, right)) + }) + }) + describe('Observable is not an Alternative', () => { + describe('no distributivity', () => { + const a = 1 + const f = (n: number) => n + 1 + const g = (n: number) => n / 2 + const result = { b: f(a), c: g(a) } + const success = { + fa: ' ------------a----------------|', + fab: ' ---f-------------------------|', + gac: ' ----------------g------------|', + + 'LEFT SIDE': '', + 'alt(fab, gac)': ' ---f------------g------------|', + 'ap(alt(fab, gac), fa)': ' ------------b---c------------|', + + 'RIGHT SIDE': '', + 'ap(fab, fa)': ' ------------b----------------|', + 'ap(gac, fa)': ' ----------------c------------|', + 'alt(ap(fab, fa), ap(gac, fa))': '------------b---c------------|' + } + const failure = { + fa: ' ------------a----------------|', + fab: ' ---f-------------------------|', + gac: ' --------g--------------------|', + + 'LEFT SIDE': '', + 'alt(fab, gac)': ' ---f----g--------------------|', + 'ap(alt(fab, gac), fa)': ' ------------c----------------|', + + 'RIGHT SIDE': '', + 'ap(fab, fa)': ' ------------b----------------|', + 'ap(gac, fa)': ' ----------------c------------|', + 'alt(ap(fab, fa), ap(gac, fa))': '------------b---c------------|' + } + it('left sides are not equal but they should be', () => { + assert.notDeepStrictEqual(success['ap(alt(fab, gac), fa)'], failure['ap(alt(fab, gac), fa)']) + }) + it('right sides should be equal', () => { + assert.deepStrictEqual(success['alt(ap(fab, fa), ap(gac, fa))'], failure['alt(ap(fab, fa), ap(gac, fa))']) + }) + it('success', () => { + new TestScheduler(assert.deepStrictEqual).run(({ cold, expectObservable }) => { + const fa = cold(success.fa, { a }) + const fab = cold(success.fab, { f }) + const gac = cold(success.gac, { g }) + const left = R.observable.ap( + R.observable.alt(fab, () => gac), + fa + ) + const right = R.observable.alt(R.observable.ap(fab, fa), () => R.observable.ap(gac, fa)) + expectObservable(left).toBe(success['ap(alt(fab, gac), fa)'], result) + expectObservable(right).toBe(success['alt(ap(fab, fa), ap(gac, fa))'], result) + }) + }) + it('failure', () => { + // use assert.notDeepStrictEqual as assert + new TestScheduler(assert.notDeepStrictEqual).run(({ cold, expectObservable }) => { + const fa = cold(failure.fa, { a }) + const fab = cold(failure.fab, { f }) + const gac = cold(failure.gac, { g }) + const left = R.observable.ap( + R.observable.alt(fab, () => gac), + fa + ) + const right = R.observable.alt(R.observable.ap(fab, fa), () => R.observable.ap(gac, fa)) + expectObservable(left).toBe(success['ap(alt(fab, gac), fa)'], result) + expectObservable(right).toBe(success['alt(ap(fab, fa), ap(gac, fa))'], result) + }) + }) + }) + }) + }) it('of', () => { const fa = R.observable.of(1) return fa