Repository: vigetlabs/microcosm
Pull request #514 (2018-04-26 12:17:40)

[microcosm] Split Subject.hash into separate class
But it was getting pretty massive, and I didn't like having it as a static method on Subject. This commit moves this function into a separate class that extends from Subject.

## What is Subject.hash

Subject.hash is a method used in a few places to reduce an object of nested asynchronous values into a POJO. This is extremely useful for building view models and for generally reducing down any complex async type in Microcosm down into something usable to the outside world:

```javascript
import { http } from 'microcosm-http'

let answer = Subject.hash({
  users: http.get('/users'),
  postsWithComments: Subject.hash({
    posts: http.get('/posts'),
    comments: http.get('/comments')
  }).map(mergePostsWithComments)
})

// This will emit a stream of updates as it gets answers
answer.subscribe({
  next() {}, // Partially loaded. You might have users, but not posts
  complete() { }, // All users, posts, and comments are loaded
  error () {} // Something went wrong with this entire behavior
})

// Or say you stop caring:
// with microcosm-http, this would cancel all requests
answer.cancel()
```

_This request behavior is additionally illustrated in the [file-uploads example](https://github.com/vigetlabs/microcosm/blob/master/packages/examples/file-uploads/src/views/file-uploader.js)._

## Why call it SubjectHash?

I don't know. I think this is the closest thing we have to the concept of a ViewModel, but it covers a broad use case of behavior. SubjectTree also came to mind, or something that doesn't use the word Subject, which is pretty vague. I have an issue to track that in https://github.com/vigetlabs/microcosm/issues/511

codecov-io: # [Codecov](https://codecov.io/gh/vigetlabs/microcosm/pull/514?src=pr&el=h1) Report
> Merging [#514](https://codecov.io/gh/vigetlabs/microcosm/pull/514?src=pr&el=desc) into [master](https://codecov.io/gh/vigetlabs/microcosm/commit/037234545e73883fe8567594c8fefaa5b44197ec?src=pr&el=desc) will **decrease** coverage by `0.01%`.
> The diff coverage is `100%`.

[![Impacted file tree graph](https://codecov.io/gh/vigetlabs/microcosm/pull/514/graphs/tree.svg?height=150&width=650&token=AaE0FLz9sU&src=pr)](https://codecov.io/gh/vigetlabs/microcosm/pull/514?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master     #514      +/-   ##
==========================================
- Coverage   98.79%   98.78%   -0.02%     
==========================================
  Files          29       30       +1     
  Lines         745      738       -7     
  Branches      149      147       -2     
==========================================
- Hits          736      729       -7     
  Misses          9        9
```


| [Impacted Files](https://codecov.io/gh/vigetlabs/microcosm/pull/514?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [packages/microcosm/src/observable.js](https://codecov.io/gh/vigetlabs/microcosm/pull/514/diff?src=pr&el=tree#diff-cGFja2FnZXMvbWljcm9jb3NtL3NyYy9vYnNlcnZhYmxlLmpz) | `96.07% <ø> (-0.99%)` | :arrow_down: |
| [packages/microcosm/src/subject.js](https://codecov.io/gh/vigetlabs/microcosm/pull/514/diff?src=pr&el=tree#diff-cGFja2FnZXMvbWljcm9jb3NtL3NyYy9zdWJqZWN0Lmpz) | `100% <100%> (+0.99%)` | :arrow_up: |
| [packages/microcosm-dom/src/action-form.js](https://codecov.io/gh/vigetlabs/microcosm/pull/514/diff?src=pr&el=tree#diff-cGFja2FnZXMvbWljcm9jb3NtLWRvbS9zcmMvYWN0aW9uLWZvcm0uanM=) | `100% <100%> (ø)` | :arrow_up: |
| [packages/microcosm-dom/src/action-button.js](https://codecov.io/gh/vigetlabs/microcosm/pull/514/diff?src=pr&el=tree#diff-cGFja2FnZXMvbWljcm9jb3NtLWRvbS9zcmMvYWN0aW9uLWJ1dHRvbi5qcw==) | `100% <100%> (ø)` | :arrow_up: |
| [packages/microcosm/src/subject-map.js](https://codecov.io/gh/vigetlabs/microcosm/pull/514/diff?src=pr&el=tree#diff-cGFja2FnZXMvbWljcm9jb3NtL3NyYy9zdWJqZWN0LW1hcC5qcw==) | `100% <100%> (ø)` | |
| [packages/microcosm/src/coroutine.js](https://codecov.io/gh/vigetlabs/microcosm/pull/514/diff?src=pr&el=tree#diff-cGFja2FnZXMvbWljcm9jb3NtL3NyYy9jb3JvdXRpbmUuanM=) | `100% <100%> (ø)` | :arrow_up: |
| [packages/microcosm-dom/src/presenter.js](https://codecov.io/gh/vigetlabs/microcosm/pull/514/diff?src=pr&el=tree#diff-cGFja2FnZXMvbWljcm9jb3NtLWRvbS9zcmMvcHJlc2VudGVyLmpz) | `100% <100%> (ø)` | :arrow_up: |
| [packages/microcosm/src/microcosm.js](https://codecov.io/gh/vigetlabs/microcosm/pull/514/diff?src=pr&el=tree#diff-cGFja2FnZXMvbWljcm9jb3NtL3NyYy9taWNyb2Nvc20uanM=) | `100% <100%> (ø)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/vigetlabs/microcosm/pull/514?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/vigetlabs/microcosm/pull/514?src=pr&el=footer). Last update [0372345...a8acb59](https://codecov.io/gh/vigetlabs/microcosm/pull/514?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

nhunzaker: @zporter Renamed this to SubjectMap. I'm going to keep jamming on a name, but I'm hopeful that I'll hit some clarity once I've better distinguished the responsibility of this component in Presenters vs Microcosm itself.

Thanks for the review!

Patch:
diff --git a/packages/examples/react-router/src/domains/lists.js b/packages/examples/react-router/src/domains/lists.js
index 09d4e278..c1e97711 100644
--- a/packages/examples/react-router/src/domains/lists.js
+++ b/packages/examples/react-router/src/domains/lists.js
@@ -1,4 +1,4 @@
-import { Subject, set } from 'microcosm'
+import { SubjectHash, set } from 'microcosm'
 import { groupBy } from 'lodash'
 import uid from 'uid'
 import Collection from './collection'
@@ -20,7 +20,7 @@ class Lists extends Collection {
   }
 
   withCounts(items) {
-    return Subject.hash({ items, lists: this }).map(next => {
+    return new SubjectHash({ items, lists: this }).map(next => {
       let groups = groupBy(items, 'list')
 
       return next.lists.map(list => {
diff --git a/packages/microcosm-dom/src/action-button.js b/packages/microcosm-dom/src/action-button.js
index 37d466bd..66e361e3 100644
--- a/packages/microcosm-dom/src/action-button.js
+++ b/packages/microcosm-dom/src/action-button.js
@@ -1,4 +1,4 @@
-import { Subject, merge } from 'microcosm'
+import { Subject, SubjectMap, merge } from 'microcosm'
 import { identity, noop, toCallbackName } from './utilities'
 
 export function generateActionButton(createElement, Component) {
@@ -38,7 +38,7 @@ export function generateActionButton(createElement, Component) {
 
     click() {
       let result = this.send(this.props.action, this._parameterize())
-      let action = Subject.hash(result)
+      let action = new SubjectMap(result)
 
       this.props.onSend(action)
 
diff --git a/packages/microcosm-dom/src/action-form.js b/packages/microcosm-dom/src/action-form.js
index 08b31a2e..5af9ce98 100644
--- a/packages/microcosm-dom/src/action-form.js
+++ b/packages/microcosm-dom/src/action-form.js
@@ -1,5 +1,5 @@
 import serialize from 'form-serialize'
-import { Subject, merge } from 'microcosm'
+import { Subject, SubjectMap, merge } from 'microcosm'
 import { identity, noop, toCallbackName } from './utilities'
 
 export function generateActionForm(createElement, Component) {
@@ -43,7 +43,7 @@ export function generateActionForm(createElement, Component) {
 
     submit(event) {
       let result = this.send(this.props.action, this._parameterize())
-      let action = Subject.hash(result)
+      let action = new SubjectMap(result)
 
       this.props.onSend(action)
 
diff --git a/packages/microcosm-dom/src/presenter.js b/packages/microcosm-dom/src/presenter.js
index 060893f3..a0844e50 100644
--- a/packages/microcosm-dom/src/presenter.js
+++ b/packages/microcosm-dom/src/presenter.js
@@ -1,4 +1,4 @@
-import { Microcosm, Subject } from 'microcosm'
+import { Microcosm, Subject, SubjectMap } from 'microcosm'
 import { advice, noop, shallowDiffers } from './utilities'
 import { intercept } from './intercept'
 
@@ -106,7 +106,7 @@ export function generatePresenter(createElement, Component) {
     constructor(props, context) {
       super(props, context)
 
-      this.model = Subject.hash({})
+      this.model = Subject.of({})
       this.presenter = props.presenter
 
       let prepo = props.repo || context.repo || new Microcosm()
@@ -148,7 +148,7 @@ export function generatePresenter(createElement, Component) {
     updateModel(props, state) {
       this.model.clear()
 
-      this.model = Subject.hash(
+      this.model = new SubjectMap(
         this.presenter.getModel(this.repo, props, state)
       )
 
diff --git a/packages/microcosm/src/coroutine.js b/packages/microcosm/src/coroutine.js
index d821000d..88767152 100644
--- a/packages/microcosm/src/coroutine.js
+++ b/packages/microcosm/src/coroutine.js
@@ -2,7 +2,8 @@
  * @flow
  */
 
-import { Subject } from './subject'
+import { type Subject } from './subject'
+import { SubjectMap } from './subject-map'
 import { toStringTag } from './symbols'
 import { isPromise } from './type-checks'
 
@@ -56,7 +57,7 @@ function asGenerator(action: Subject, iterator: Iterator<*>) {
     if (next.done) {
       action.complete()
     } else {
-      let subject = Subject.hash(value)
+      let subject = new SubjectMap(value)
 
       subject.subscribe(
         // next
diff --git a/packages/microcosm/src/index.js b/packages/microcosm/src/index.js
index 7f12c668..61e39ec6 100644
--- a/packages/microcosm/src/index.js
+++ b/packages/microcosm/src/index.js
@@ -3,6 +3,7 @@ export { Microcosm, Microcosm as default } from './microcosm'
 // Observables libraries
 export { Observable } from './observable'
 export { Subject } from './subject'
+export { SubjectMap } from './subject-map'
 
 // Actions for performing key lifecycle actions
 export { RESET as reset, PATCH as patch } from './lifecycle'
diff --git a/packages/microcosm/src/microcosm.js b/packages/microcosm/src/microcosm.js
index 3f90d043..047fc75d 100644
--- a/packages/microcosm/src/microcosm.js
+++ b/packages/microcosm/src/microcosm.js
@@ -81,7 +81,7 @@ export class Microcosm extends Subject {
     )
 
     if (blueprint instanceof Observable) {
-      this.domains[key] = Subject.hash(blueprint)
+      this.domains[key] = Subject.from(blueprint)
       this.subscribe({ complete: this.domains[key].complete })
     } else {
       let Entity = inherit(blueprint, Domain)
diff --git a/packages/microcosm/src/observable.js b/packages/microcosm/src/observable.js
index c9fe9ffa..3094bcfd 100644
--- a/packages/microcosm/src/observable.js
+++ b/packages/microcosm/src/observable.js
@@ -111,7 +111,7 @@ export class Subscription implements Unsubscribable {
 }
 
 export class Observable {
-  _subscriber: SubscriptionObserver => ?Cleanup
+  _subscriber: Subscriber
 
   constructor(subscriber?: Subscriber) {
     this._subscriber = subscriber || noop
diff --git a/packages/microcosm/src/subject-map.js b/packages/microcosm/src/subject-map.js
new file mode 100644
index 00000000..ac88e37e
--- /dev/null
+++ b/packages/microcosm/src/subject-map.js
@@ -0,0 +1,80 @@
+/*
+ * @flow
+ */
+
+import { Subject } from './subject'
+import { set } from './data'
+import { isObject, isPlainObject } from './type-checks'
+import { EMPTY_OBJECT, EMPTY_ARRAY } from './empty'
+
+export class SubjectMap extends Subject {
+  jobs: number
+
+  constructor(object: *) {
+    super()
+
+    let isArray = Array.isArray(object)
+    let isPojo = isPlainObject(object)
+
+    if (!isArray && !isPojo) {
+      return Subject.from(object)
+    }
+
+    this.jobs = 0
+    this.payload = isArray ? EMPTY_ARRAY : EMPTY_OBJECT
+
+    let keys = Object.keys(object)
+    for (let i = 0, len = keys.length; i < len; i++) {
+      this.link(keys[i], object[keys[i]])
+    }
+
+    if (this.jobs <= 0) {
+      this.complete()
+    }
+  }
+
+  link(key: string, value: any) {
+    if (!isObject(value)) {
+      this.payload = set(this.payload, key, value)
+    } else {
+      this.jobs += 1
+
+      if (value instanceof Subject) {
+        this.payload = set(this.payload, key, value.valueOf())
+      }
+
+      let subscription = new SubjectMap(value).subscribe(
+        // Next
+        this.set.bind(this, key),
+        // Error
+        this.error,
+        // Complete
+        this.unlink.bind(this),
+        // Cancel
+        this.unlink.bind(this)
+      )
+
+      this.subscribe({ cancel: subscription.unsubscribe })
+    }
+  }
+
+  unlink() {
+    this.jobs -= 1
+
+    if (this.jobs <= 0) {
+      if (this.status === 'start') {
+        this.next(this.payload)
+      }
+
+      this.complete()
+    }
+  }
+
+  set(key: string, value: *) {
+    let payload = set(this.payload, key, value)
+
+    if (payload !== this.payload) {
+      this.next(payload)
+    }
+  }
+}
diff --git a/packages/microcosm/src/subject.js b/packages/microcosm/src/subject.js
index 63c65fde..c30707a5 100644
--- a/packages/microcosm/src/subject.js
+++ b/packages/microcosm/src/subject.js
@@ -14,22 +14,21 @@
  *
  * @flow
  */
-import assert from 'assert'
+
 import {
   Observable,
   type SubscriptionObserver,
-  type Cleanup,
   type Subscriber
 } from './observable'
-import { set, merge } from './data'
-import { isPromise, isObservable, isObject, isPlainObject } from './type-checks'
+import { merge } from './data'
+import { isPromise, isObservable } from './type-checks'
 
 export class Subject extends Observable {
   meta: { key: *, status: string, origin: * }
   payload: any
   disabled: boolean
 
-  _observers: SubscriptionObserver[]
+  _observers: Set<SubscriptionObserver>
   _subscriber: Subscriber
 
   constructor(payload?: *, meta?: Object) {
@@ -40,11 +39,11 @@ export class Subject extends Observable {
     this.disabled = false
 
     this._subscriber = this._multicast
-    this._observers = []
+    this._observers = new Set()
   }
 
-  _multicast(observer: SubscriptionObserver): Cleanup {
-    this._observers.push(observer)
+  _multicast(observer: SubscriptionObserver) {
+    this._observers.add(observer)
 
     switch (this.status) {
       case 'next':
@@ -62,7 +61,7 @@ export class Subject extends Observable {
         break
     }
 
-    return remove.bind(null, this._observers, observer)
+    return this._observers.delete.bind(this._observers, observer)
   }
 
   get status(): string {
@@ -100,19 +99,18 @@ export class Subject extends Observable {
     return () => {
       this.meta.status = 'start'
       this.payload = null
-
-      for (var i = 0; i < this._observers.length; i++) {
-        this._observers[i].unsubscribe()
-      }
+      this._observers.forEach(observer => observer.unsubscribe())
     }
   }
 
   then(pass: *, fail: *): Promise<*> {
     return new Promise((resolve, reject) => {
-      this.subscribe(null, reject, resolve, resolve)
-    })
-      .then(() => this.payload)
-      .then(pass, fail)
+      this.subscribe({
+        error: reject,
+        complete: () => resolve(this.payload),
+        cancel: () => resolve(this.payload)
+      })
+    }).then(pass, fail)
   }
 
   toString(): string {
@@ -131,73 +129,6 @@ export class Subject extends Observable {
     return this.payload
   }
 
-  static hash(obj: *): Subject {
-    let isArray = Array.isArray(obj)
-    let isPojo = isPlainObject(obj)
-
-    if (!isArray && !isPojo) {
-      return Subject.from(obj)
-    }
-
-    let payload = isArray ? [] : {}
-    let subject = new Subject(payload)
-    let building = true
-    let jobs = 0
-
-    function taskFinished() {
-      if (--jobs <= 0) {
-        if (!building) {
-          subject.complete(payload)
-        }
-      }
-    }
-
-    function assign(key: string, value: *): void {
-      // $FlowFixMe - Signature of set() is too strict
-      payload = set(payload, key, value)
-
-      if (payload !== subject.payload) {
-        subject.next(payload)
-      }
-    }
-
-    for (var key in obj) {
-      let value = obj[key]
-
-      if (!isObject(value)) {
-        payload = set(payload, key, value)
-        continue
-      }
-
-      jobs += 1
-
-      if (value instanceof Subject) {
-        payload = set(payload, key, value.payload)
-      }
-
-      let subscription = Subject.hash(value).subscribe({
-        next: assign.bind(null, key),
-        error: subject.error,
-        complete: taskFinished,
-        cancel: taskFinished
-      })
-
-      subject.subscribe({ cancel: subscription.unsubscribe })
-    }
-
-    if (payload !== subject.payload) {
-      subject.next(payload)
-    }
-
-    building = false
-
-    if (jobs <= 0) {
-      subject.complete()
-    }
-
-    return subject
-  }
-
   static from(source: *): Subject {
     if (source instanceof Subject) {
       return source
@@ -245,16 +176,6 @@ function fromObservable(observable: Observable): Subject {
   return subject
 }
 
-function remove(list: *[], item: *): void {
-  let index = list.indexOf(item)
-
-  if (index >= 0) {
-    list.splice(index, -1)
-  } else {
-    assert(false, 'Attempted to remove an observer that was never added.')
-  }
-}
-
 function update(subject: Subject, status: string, value: *): void {
   if (subject.closed) {
     return


Test patch:
diff --git a/packages/microcosm/test/unit/subject-hash.test.js b/packages/microcosm/test/unit/subject-hash.test.js
new file mode 100644
index 00000000..bc20c357
--- /dev/null
+++ b/packages/microcosm/test/unit/subject-hash.test.js
@@ -0,0 +1,213 @@
+import { Subject, Observable, SubjectMap } from 'microcosm'
+
+describe('SubjectMap', function() {
+  describe('primitives', () => {
+    it('immedately resolves null', async () => {
+      let hash = new SubjectMap(null)
+
+      expect(hash.closed).toBe(true)
+      expect(hash.payload).toBe(null)
+    })
+
+    it('immedately resolves numbers', async () => {
+      let hash = new SubjectMap(10)
+
+      expect(hash.closed).toBe(true)
+      expect(hash.payload).toBe(10)
+    })
+
+    it('immedately resolves strings', async () => {
+      let hash = new SubjectMap('test')
+
+      expect(hash.closed).toBe(true)
+      expect(hash.payload).toBe('test')
+    })
+
+    it('immedately resolves booleans', async () => {
+      let hash = new SubjectMap(true)
+
+      expect(hash.closed).toBe(true)
+      expect(hash.payload).toBe(true)
+    })
+  })
+
+  describe('objects', () => {
+    it('immediately resolves simple values', async () => {
+      let hash = new SubjectMap({ one: 1, two: 2 })
+      let next = jest.fn()
+
+      hash.subscribe({ next })
+
+      await hash
+
+      // Ensure that resolving primitive
+      expect(next).toHaveBeenCalledTimes(1)
+      expect(hash.payload).toEqual({ one: 1, two: 2 })
+    })
+
+    it('resolves promises', async () => {
+      let hash = new SubjectMap({ key: Promise.resolve(true) })
+
+      await hash
+
+      expect(hash.payload).toEqual({ key: true })
+    })
+
+    it('works on other observables', async () => {
+      let next = jest.fn()
+      let complete = jest.fn()
+
+      let hash = new SubjectMap({ key: Observable.of(1, 2, 3) })
+
+      hash.subscribe({
+        next,
+        complete
+      })
+
+      await hash
+
+      expect(next).toHaveBeenCalledWith({ key: 3 })
+      expect(complete).toHaveBeenCalled()
+    })
+
+    it('works on subjects', async () => {
+      let next = jest.fn()
+      let complete = jest.fn()
+
+      let subject = new Subject()
+
+      subject.next(1)
+
+      let hash = new SubjectMap({ key: subject })
+
+      hash.subscribe({ next, complete })
+
+      subject.next(2)
+      subject.complete()
+
+      await hash
+
+      expect(hash.payload).toEqual({ key: 2 })
+
+      expect(next).toHaveBeenCalledTimes(1)
+      expect(next).toHaveBeenCalledWith({ key: 2 })
+
+      expect(complete).toHaveBeenCalled()
+    })
+
+    it('starts with the current value of a subject', async () => {
+      let subject = new Subject()
+
+      subject.next(1)
+
+      let hash = new SubjectMap({ key: subject })
+
+      expect(hash.payload).toHaveProperty('key', 1)
+    })
+
+    it('resolves nests', async () => {
+      let hash = new SubjectMap({
+        colors: {
+          red: Promise.resolve('red'),
+          blue: Promise.resolve('blue'),
+          green: Promise.resolve('green')
+        }
+      })
+
+      let next = jest.fn()
+
+      hash.subscribe({ next })
+
+      await hash
+
+      expect(next).toHaveBeenCalledTimes(3)
+
+      expect(next).toHaveBeenCalledWith({
+        colors: {
+          red: 'red',
+          blue: undefined,
+          green: undefined
+        }
+      })
+
+      expect(next).toHaveBeenCalledWith({
+        colors: {
+          red: 'red',
+          blue: 'blue',
+          green: undefined
+        }
+      })
+
+      expect(next).toHaveBeenCalledWith({
+        colors: {
+          red: 'red',
+          blue: 'blue',
+          green: 'green'
+        }
+      })
+    })
+  })
+
+  describe('arrays', () => {
+    it('immediately resolves simple values', async () => {
+      let hash = new SubjectMap([1, 2])
+      let next = jest.fn()
+
+      hash.subscribe({ next })
+
+      await hash
+
+      // Ensure that resolving primitive
+      expect(next).toHaveBeenCalledTimes(1)
+      expect(hash.payload).toEqual([1, 2])
+    })
+
+    it('resolves async values', async () => {
+      let hash = new SubjectMap([Promise.resolve(true), Promise.resolve(false)])
+
+      await hash
+
+      expect(hash.payload).toEqual([true, false])
+    })
+  })
+
+  describe('updates', () => {
+    it('does not send updates if the value did not change', async () => {
+      let next = jest.fn()
+      let complete = jest.fn()
+
+      let hash = new SubjectMap({
+        key: Observable.of(1, 1, 1)
+      })
+
+      hash.subscribe({ next, complete })
+
+      await hash
+
+      expect(next).toHaveBeenCalledWith({ key: 1 })
+      expect(next).toHaveBeenCalledTimes(1)
+      expect(complete).toHaveBeenCalledTimes(1)
+    })
+
+    it('sends updates for simple values, then async values later', async () => {
+      let next = jest.fn()
+
+      let hash = new SubjectMap({ now: 1, later: Observable.of(1, 2, 3) })
+
+      hash.subscribe({
+        next
+      })
+
+      expect(hash).toHaveProperty('payload.now', 1)
+      expect(hash).toHaveProperty('payload.later', undefined)
+
+      await hash
+
+      expect(next).toHaveBeenCalledWith({ now: 1, later: 1 })
+      expect(next).toHaveBeenCalledWith({ now: 1, later: 2 })
+      expect(next).toHaveBeenCalledWith({ now: 1, later: 3 })
+
+      expect(hash.payload).toEqual({ now: 1, later: 3 })
+    })
+  })
+})
diff --git a/packages/microcosm/test/unit/subject.test.js b/packages/microcosm/test/unit/subject.test.js
index bb006eac..fe6b5b5c 100644
--- a/packages/microcosm/test/unit/subject.test.js
+++ b/packages/microcosm/test/unit/subject.test.js
@@ -1,4 +1,4 @@
-import { Subject, Observable, scheduler } from 'microcosm'
+import { Subject } from 'microcosm'
 
 describe('Subject', function() {
   describe('next', function() {
@@ -9,8 +9,9 @@ describe('Subject', function() {
       subject.subscribe({ next })
       subject.next(1)
       subject.next(2)
+      subject.complete()
 
-      await scheduler()
+      await subject
 
       expect(next).toHaveBeenCalledTimes(2)
       expect(next).toHaveBeenCalledWith(1)
@@ -24,8 +25,9 @@ describe('Subject', function() {
       subject.next(1)
       subject.subscribe({ next })
       subject.next(2)
+      subject.complete()
 
-      await scheduler()
+      await subject
 
       expect(next).toHaveBeenCalledTimes(2)
       expect(next).toHaveBeenCalledWith(1)
@@ -39,8 +41,9 @@ describe('Subject', function() {
       subject.subscribe({ next })
       subject.next(1)
       subject.next(null)
+      subject.complete()
 
-      await scheduler()
+      await subject
 
       expect(next).toHaveBeenCalledTimes(2)
       expect(next).toHaveBeenCalledWith(1)
@@ -55,7 +58,7 @@ describe('Subject', function() {
       subject.subscribe({ next })
       subject.next(2)
 
-      await scheduler()
+      await subject
 
       expect(next).toHaveBeenCalledTimes(1)
       expect(next).toHaveBeenCalledWith(1)
@@ -69,7 +72,7 @@ describe('Subject', function() {
       subject.complete()
       subject.next(true)
 
-      await scheduler()
+      await subject
 
       expect(next).toHaveBeenCalledTimes(0)
     })
@@ -78,11 +81,15 @@ describe('Subject', function() {
       let subject = new Subject()
       let next = jest.fn()
 
-      subject.error()
+      subject.error('reason')
       subject.subscribe({ next })
       subject.next(true)
 
-      await scheduler()
+      try {
+        await subject
+      } catch (error) {
+        expect(error).toEqual('reason')
+      }
 
       expect(next).toHaveBeenCalledTimes(0)
     })
@@ -95,7 +102,7 @@ describe('Subject', function() {
       subject.subscribe({ next })
       subject.next(true)
 
-      await scheduler()
+      await subject
 
       expect(next).toHaveBeenCalledTimes(0)
     })
@@ -111,7 +118,7 @@ describe('Subject', function() {
       subject.complete()
       subject.complete()
 
-      await scheduler()
+      await subject
 
       expect(subject.closed).toBe(true)
       expect(complete).toHaveBeenCalledTimes(1)
@@ -124,7 +131,7 @@ describe('Subject', function() {
       subject.complete()
       subject.subscribe({ complete })
 
-      await scheduler()
+      await subject
 
       expect(subject.closed).toBe(true)
       expect(complete).toHaveBeenCalledTimes(1)
@@ -137,7 +144,7 @@ describe('Subject', function() {
       subject.subscribe({ unsubscribe })
       subject.complete(true)
 
-      await scheduler()
+      await subject
 
       expect(unsubscribe).not.toHaveBeenCalled()
     })
@@ -148,12 +155,17 @@ describe('Subject', function() {
       let subject = new Subject()
       let error = jest.fn()
 
-      subject.error()
+      subject.error('reason')
       subject.subscribe({ error })
 
-      await scheduler()
+      try {
+        await subject
+      } catch (error) {
+        expect(error).toEqual('reason')
+      }
 
-      expect(error).toHaveBeenCalled()
+      expect(error).toHaveBeenCalledTimes(1)
+      expect(error).toHaveBeenCalledWith('reason')
     })
 
     it('does not invoke the error if already completed', async () => {
@@ -164,7 +176,7 @@ describe('Subject', function() {
       subject.complete()
       subject.error()
 
-      await scheduler()
+      await subject
 
       expect(error).not.toHaveBeenCalled()
     })
@@ -176,7 +188,7 @@ describe('Subject', function() {
 
       subject.cancel()
 
-      await scheduler()
+      await subject
 
       expect(subject.closed).toBe(true)
     })
@@ -260,94 +272,6 @@ describe('Subject', function() {
     })
   })
 
-  describe('.hash', function() {
-    it('works on promises', async () => {
-      expect.assertions(1)
-
-      let hash = Subject.hash({ key: Promise.resolve(true) })
-
-      hash.subscribe({
-        complete: () => {
-          expect(hash.payload).toEqual({ key: true })
-        }
-      })
-
-      await scheduler()
-    })
-
-    it('works on primitive values', async () => {
-      expect.assertions(1)
-
-      let hash = Subject.hash({ key: true })
-
-      hash.subscribe({
-        next: value => {
-          expect(value).toEqual({ key: true })
-        }
-      })
-
-      await scheduler()
-    })
-
-    it('works on other observables', async () => {
-      let next = jest.fn()
-      let complete = jest.fn()
-
-      let hash = Subject.hash({ key: Observable.of(1, 2, 3) })
-
-      hash.subscribe({
-        next,
-        complete
-      })
-
-      await scheduler()
-
-      expect(next).toHaveBeenCalledWith({ key: 3 })
-      expect(complete).toHaveBeenCalled()
-    })
-
-    it('does not send updates if the value did not change', async () => {
-      let next = jest.fn()
-      let complete = jest.fn()
-
-      let hash = Subject.hash({
-        key: Observable.of(1, 1, 1)
-      })
-
-      hash.subscribe({ next, complete })
-
-      await scheduler()
-
-      expect(next).toHaveBeenCalledWith({ key: 1 })
-      expect(next).toHaveBeenCalledTimes(1)
-      expect(complete).toHaveBeenCalledTimes(1)
-    })
-
-    it('works on subjects', async () => {
-      let next = jest.fn()
-      let complete = jest.fn()
-
-      let subject = new Subject()
-
-      subject.next(1)
-
-      let hash = Subject.hash({ key: subject })
-
-      hash.subscribe({ next, complete })
-
-      subject.next(2)
-      subject.complete()
-
-      await scheduler()
-
-      expect(hash.payload).toEqual({ key: 2 })
-
-      expect(next).toHaveBeenCalledWith({ key: 1 })
-      expect(next).toHaveBeenCalledWith({ key: 2 })
-      expect(complete).toHaveBeenCalled()
-    })
-  })
-
   describe('.map', () => {
     it('works like observable.map', async () => {
       let subject = new Subject()
@@ -361,7 +285,9 @@ describe('Subject', function() {
         subject.next(i)
       }
 
-      await scheduler()
+      subject.complete()
+
+      await subject
 
       expect(answers).toEqual([0, 2, 4, 6, 8])
     })
@@ -376,7 +302,9 @@ describe('Subject', function() {
 
       double.subscribe(value => answers.push(value))
 
-      await scheduler()
+      subject.complete()
+
+      await subject
 
       expect(answers).toEqual([10])
     })
@@ -392,8 +320,9 @@ describe('Subject', function() {
       double.subscribe(value => answers.push(value))
       subject.next(2)
       subject.next(3)
+      subject.complete()
 
-      await scheduler()
+      await subject
 
       expect(answers).toEqual([2, 4, 6])
     })
@@ -401,19 +330,19 @@ describe('Subject', function() {
     it('only emits the final value when it completes', async () => {
       let subject = new Subject()
 
-      let double = subject.map(value => value * 2)
+      let reflect = subject.map(value => value)
       let answers = []
 
       subject.next(0)
       subject.next(1)
       subject.complete()
-      double.subscribe(value => answers.push(value))
+      reflect.subscribe(value => answers.push(value))
       subject.next(2)
       subject.next(3)
 
-      await scheduler()
+      await subject
 
-      expect(answers).toEqual([2])
+      expect(answers).toEqual([1])
     })
   })
 })