First commit of pkg/observable
diff --git a/.analysis_options b/.analysis_options
new file mode 100644
index 0000000..dbb6342
--- /dev/null
+++ b/.analysis_options
@@ -0,0 +1,49 @@
+analyzer:
+  strong-mode: true
+linter:
+  rules:
+    #- always_declare_return_types
+    #- always_specify_types
+    #- annotate_overrides
+    #- avoid_as
+    - avoid_empty_else
+    - avoid_init_to_null
+    - avoid_return_types_on_setters
+    - await_only_futures
+    - camel_case_types
+    - cancel_subscriptions
+    #- close_sinks
+    #- comment_references
+    - constant_identifier_names
+    - control_flow_in_finally
+    - empty_catches
+    - empty_constructor_bodies
+    - empty_statements
+    - hash_and_equals
+    - implementation_imports
+    - iterable_contains_unrelated_type
+    - library_names
+    - library_prefixes
+    - list_remove_unrelated_type
+    - non_constant_identifier_names
+    - one_member_abstracts
+    - only_throw_errors
+    - overridden_fields
+    - package_api_docs
+    - package_names
+    - package_prefixed_library_names
+    - prefer_is_not_empty
+    #- public_member_api_docs
+    #- slash_for_doc_comments
+    #- sort_constructors_first
+    #- sort_unnamed_constructors_first
+    - super_goes_last
+    - test_types_in_equals
+    - throw_in_finally
+    #- type_annotate_public_apis
+    - type_init_formals
+    #- unawaited_futures
+    - unnecessary_brace_in_string_interp
+    - unnecessary_getters_setters
+    - unrelated_type_equality_checks
+    - valid_regexps
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..96bc6bb
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+# Files and directories created by pub
+.packages
+.pub/
+packages
+pubspec.lock
+
+# Directory created by dartdoc
+doc/api/
diff --git a/AUTHORS b/AUTHORS
new file mode 100644
index 0000000..0617765
--- /dev/null
+++ b/AUTHORS
@@ -0,0 +1,9 @@
+# Names should be added to this file with this pattern:
+#
+# For individuals:
+#   Name <email address>
+#
+# For organizations:
+#   Organization <fnmatch pattern>
+#
+Google Inc. <*@google.com>
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..6f5e0ea
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,33 @@
+Want to contribute? Great! First, read this page (including the small print at
+the end).
+
+### Before you contribute
+Before we can use your code, you must sign the
+[Google Individual Contributor License Agreement](https://cla.developers.google.com/about/google-individual)
+(CLA), which you can do online. The CLA is necessary mainly because you own the
+copyright to your changes, even after your contribution becomes part of our
+codebase, so we need your permission to use and distribute your code. We also
+need to be sure of various other things—for instance that you'll tell us if you
+know that your code infringes on other people's patents. You don't have to sign
+the CLA until after you've submitted your code for review and a member has
+approved it, but you must do it before we can put your code into our codebase.
+
+Before you start working on a larger contribution, you should get in touch with
+us first through the issue tracker with your idea so that we can help out and
+possibly guide you. Coordinating up front makes it much easier to avoid
+frustration later on.
+
+### Code reviews
+All submissions, including submissions by project members, require review.
+
+### File headers
+All files in the project must start with the following header.
+
+    // Copyright (c) 2015, the Dart project authors.  Please see the AUTHORS file
+    // for details. All rights reserved. Use of this source code is governed by a
+    // BSD-style license that can be found in the LICENSE file.
+
+### The small print
+Contributions made by corporations are covered by a different agreement than the
+one above, the
+[Software Grant and Corporate Contributor License Agreement](https://developers.google.com/open-source/cla/corporate).
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..82e9b52
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,26 @@
+Copyright 2016, the Dart project authors. All rights reserved.
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+    * Redistributions of source code must retain the above copyright
+      notice, this list of conditions and the following disclaimer.
+    * Redistributions in binary form must reproduce the above
+      copyright notice, this list of conditions and the following
+      disclaimer in the documentation and/or other materials provided
+      with the distribution.
+    * Neither the name of Google Inc. nor the names of its
+      contributors may be used to endorse or promote products derived
+      from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/PATENTS b/PATENTS
new file mode 100644
index 0000000..6954196
--- /dev/null
+++ b/PATENTS
@@ -0,0 +1,23 @@
+Additional IP Rights Grant (Patents)
+
+"This implementation" means the copyrightable works distributed by
+Google as part of the Dart Project.
+
+Google hereby grants to you a perpetual, worldwide, non-exclusive,
+no-charge, royalty-free, irrevocable (except as stated in this
+section) patent license to make, have made, use, offer to sell, sell,
+import, transfer, and otherwise run, modify and propagate the contents
+of this implementation of Dart, where such license applies only to
+those patent claims, both currently owned by Google and acquired in
+the future, licensable by Google that are necessarily infringed by
+this implementation of Dart. This grant does not include claims that
+would be infringed only as a consequence of further modification of
+this implementation. If you or your agent or exclusive licensee
+institute or order or agree to the institution of patent litigation
+against any entity (including a cross-claim or counterclaim in a
+lawsuit) alleging that this implementation of Dart or any code
+incorporated within this implementation of Dart constitutes direct or
+contributory patent infringement, or inducement of patent
+infringement, then any patent rights granted to you under this License
+for this implementation of Dart shall terminate as of the date such
+litigation is filed.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..3965a6f
--- /dev/null
+++ b/README.md
@@ -0,0 +1,5 @@
+Support for marking objects as observable, and getting notifications when those
+objects are mutated.
+
+This library is used to observe changes to observable types. It also
+has helpers to make implementing and using observable objects easy.
diff --git a/lib/observable.dart b/lib/observable.dart
new file mode 100644
index 0000000..ce82061
--- /dev/null
+++ b/lib/observable.dart
@@ -0,0 +1,13 @@
+// Copyright (c) 2016, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+library observable;
+
+export 'src/change_record.dart';
+export 'src/list_diff.dart' show ListChangeRecord;
+export 'src/observable.dart';
+export 'src/observable_list.dart';
+export 'src/observable_map.dart';
+export 'src/property_change_record.dart';
+export 'src/to_observable.dart';
diff --git a/lib/src/change_record.dart b/lib/src/change_record.dart
new file mode 100644
index 0000000..b4c4f24
--- /dev/null
+++ b/lib/src/change_record.dart
@@ -0,0 +1,9 @@
+// Copyright (c) 2016, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+library observable.src.change_record;
+
+/// Records a change to an [Observable].
+// TODO(jmesserly): remove this type
+abstract class ChangeRecord {}
diff --git a/lib/src/list_diff.dart b/lib/src/list_diff.dart
new file mode 100644
index 0000000..c16a613
--- /dev/null
+++ b/lib/src/list_diff.dart
@@ -0,0 +1,398 @@
+// Copyright (c) 2016, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+library observable.src.list_diff;
+
+import 'dart:collection' show UnmodifiableListView;
+import 'dart:math' as math;
+
+import 'change_record.dart' show ChangeRecord;
+
+/// A summary of an individual change to a [List].
+///
+/// Each delta represents that at the [index], [removed] sequence of items were
+/// removed, and counting forward from [index], [addedCount] items were added.
+class ListChangeRecord /* TODO(tvolkert): remove */ extends ChangeRecord {
+  /// The list that changed.
+  final List object;
+
+  /// The index of the change.
+  int get index => _index;
+
+  /// The items removed, if any. Otherwise this will be an empty list.
+  List get removed => _unmodifiableRemoved;
+  UnmodifiableListView _unmodifiableRemoved;
+
+  /// Mutable version of [removed], used during the algorithms as they are
+  /// constructing the object.
+  List _removed;
+
+  /// The number of items added.
+  int get addedCount => _addedCount;
+
+  // Note: conceptually these are final, but for convenience we increment it as
+  // we build the object. It will be "frozen" by the time it is returned the the
+  // user.
+  int _index, _addedCount;
+
+  ListChangeRecord._(this.object, this._index, List removed, this._addedCount)
+      : _removed = removed,
+        _unmodifiableRemoved = new UnmodifiableListView(removed);
+
+  factory ListChangeRecord(List object, int index,
+      {List removed, int addedCount}) {
+    if (removed == null) removed = [];
+    if (addedCount == null) addedCount = 0;
+    return new ListChangeRecord._(object, index, removed, addedCount);
+  }
+
+  /// Returns true if the provided [ref] index was changed by this operation.
+  bool indexChanged(int ref) {
+    // If ref is before the index, then it wasn't changed.
+    if (ref < index) return false;
+
+    // If this was a shift operation, anything after index is changed.
+    if (addedCount != removed.length) return true;
+
+    // Otherwise, anything in the update range was changed.
+    return ref < index + addedCount;
+  }
+
+  String toString() => '#<ListChangeRecord index: $index, '
+      'removed: $removed, addedCount: $addedCount>';
+}
+
+// Note: This function is *based* on the computation of the Levenshtein
+// "edit" distance. The one change is that "updates" are treated as two
+// edits - not one. With List splices, an update is really a delete
+// followed by an add. By retaining this, we optimize for "keeping" the
+// maximum array items in the original array. For example:
+//
+//   'xxxx123' -> '123yyyy'
+//
+// With 1-edit updates, the shortest path would be just to update all seven
+// characters. With 2-edit updates, we delete 4, leave 3, and add 4. This
+// leaves the substring '123' intact.
+List<List<int>> _calcEditDistances(List current, int currentStart,
+    int currentEnd, List old, int oldStart, int oldEnd) {
+  // "Deletion" columns
+  int rowCount = oldEnd - oldStart + 1;
+  int columnCount = currentEnd - currentStart + 1;
+  List<List<int>> distances = new List<List<int>>(rowCount);
+
+  // "Addition" rows. Initialize null column.
+  for (int i = 0; i < rowCount; i++) {
+    distances[i] = new List(columnCount);
+    distances[i][0] = i;
+  }
+
+  // Initialize null row
+  for (int j = 0; j < columnCount; j++) {
+    distances[0][j] = j;
+  }
+
+  for (int i = 1; i < rowCount; i++) {
+    for (int j = 1; j < columnCount; j++) {
+      if (old[oldStart + i - 1] == current[currentStart + j - 1]) {
+        distances[i][j] = distances[i - 1][j - 1];
+      } else {
+        int north = distances[i - 1][j] + 1;
+        int west = distances[i][j - 1] + 1;
+        distances[i][j] = math.min(north, west);
+      }
+    }
+  }
+
+  return distances;
+}
+
+const _kEditLeave = 0;
+const _kEditUpdate = 1;
+const _kEditAdd = 2;
+const _kEditDelete = 3;
+
+// This starts at the final weight, and walks "backward" by finding
+// the minimum previous weight recursively until the origin of the weight
+// matrix.
+List<int> _spliceOperationsFromEditDistances(List<List<int>> distances) {
+  int i = distances.length - 1;
+  int j = distances[0].length - 1;
+  int current = distances[i][j];
+  List<int> edits = <int>[];
+  while (i > 0 || j > 0) {
+    if (i == 0) {
+      edits.add(_kEditAdd);
+      j--;
+      continue;
+    }
+    if (j == 0) {
+      edits.add(_kEditDelete);
+      i--;
+      continue;
+    }
+    int northWest = distances[i - 1][j - 1];
+    int west = distances[i - 1][j];
+    int north = distances[i][j - 1];
+
+    int min = math.min(math.min(west, north), northWest);
+
+    if (min == northWest) {
+      if (northWest == current) {
+        edits.add(_kEditLeave);
+      } else {
+        edits.add(_kEditUpdate);
+        current = northWest;
+      }
+      i--;
+      j--;
+    } else if (min == west) {
+      edits.add(_kEditDelete);
+      i--;
+      current = west;
+    } else {
+      edits.add(_kEditAdd);
+      j--;
+      current = north;
+    }
+  }
+
+  return edits.reversed.toList();
+}
+
+int _sharedPrefix(List arr1, List arr2, int searchLength) {
+  for (int i = 0; i < searchLength; i++) {
+    if (arr1[i] != arr2[i]) {
+      return i;
+    }
+  }
+  return searchLength;
+}
+
+int _sharedSuffix(List arr1, List arr2, int searchLength) {
+  int index1 = arr1.length;
+  int index2 = arr2.length;
+  int count = 0;
+  while (count < searchLength && arr1[--index1] == arr2[--index2]) {
+    count++;
+  }
+  return count;
+}
+
+/// Lacking individual splice mutation information, the minimal set of
+/// splices can be synthesized given the previous state and final state of an
+/// array. The basic approach is to calculate the edit distance matrix and
+/// choose the shortest path through it.
+///
+/// Complexity: O(l * p)
+///   l: The length of the current array
+///   p: The length of the old array
+List<ListChangeRecord> calcSplices(List current, int currentStart,
+    int currentEnd, List old, int oldStart, int oldEnd) {
+  int prefixCount = 0;
+  int suffixCount = 0;
+
+  int minLength = math.min(currentEnd - currentStart, oldEnd - oldStart);
+  if (currentStart == 0 && oldStart == 0) {
+    prefixCount = _sharedPrefix(current, old, minLength);
+  }
+
+  if (currentEnd == current.length && oldEnd == old.length) {
+    suffixCount = _sharedSuffix(current, old, minLength - prefixCount);
+  }
+
+  currentStart += prefixCount;
+  oldStart += prefixCount;
+  currentEnd -= suffixCount;
+  oldEnd -= suffixCount;
+
+  if (currentEnd - currentStart == 0 && oldEnd - oldStart == 0) {
+    return const [];
+  }
+
+  if (currentStart == currentEnd) {
+    ListChangeRecord splice = new ListChangeRecord(current, currentStart);
+    while (oldStart < oldEnd) {
+      splice._removed.add(old[oldStart++]);
+    }
+
+    return [splice];
+  } else if (oldStart == oldEnd) {
+    return [
+      new ListChangeRecord(current, currentStart,
+          addedCount: currentEnd - currentStart)
+    ];
+  }
+
+  List<int> ops = _spliceOperationsFromEditDistances(_calcEditDistances(
+      current, currentStart, currentEnd, old, oldStart, oldEnd));
+
+  ListChangeRecord splice;
+  List<ListChangeRecord> splices = <ListChangeRecord>[];
+  int index = currentStart;
+  int oldIndex = oldStart;
+  for (int i = 0; i < ops.length; i++) {
+    switch (ops[i]) {
+      case _kEditLeave:
+        if (splice != null) {
+          splices.add(splice);
+          splice = null;
+        }
+
+        index++;
+        oldIndex++;
+        break;
+      case _kEditUpdate:
+        if (splice == null) splice = new ListChangeRecord(current, index);
+
+        splice._addedCount++;
+        index++;
+
+        splice._removed.add(old[oldIndex]);
+        oldIndex++;
+        break;
+      case _kEditAdd:
+        if (splice == null) splice = new ListChangeRecord(current, index);
+
+        splice._addedCount++;
+        index++;
+        break;
+      case _kEditDelete:
+        if (splice == null) splice = new ListChangeRecord(current, index);
+
+        splice._removed.add(old[oldIndex]);
+        oldIndex++;
+        break;
+    }
+  }
+
+  if (splice != null) splices.add(splice);
+  return splices;
+}
+
+int _intersect(int start1, int end1, int start2, int end2) =>
+    math.min(end1, end2) - math.max(start1, start2);
+
+void _mergeSplice(List<ListChangeRecord> splices, ListChangeRecord record) {
+  ListChangeRecord splice = new ListChangeRecord(record.object, record.index,
+      removed: record._removed.toList(), addedCount: record.addedCount);
+
+  bool inserted = false;
+  int insertionOffset = 0;
+
+  // I think the way this works is:
+  // - the loop finds where the merge should happen
+  // - it applies the merge in a particular splice
+  // - then continues and updates the subsequent splices with any offset diff.
+  for (int i = 0; i < splices.length; i++) {
+    final ListChangeRecord current = splices[i];
+    current._index += insertionOffset;
+
+    if (inserted) continue;
+
+    int intersectCount = _intersect(
+        splice.index,
+        splice.index + splice.removed.length,
+        current.index,
+        current.index + current.addedCount);
+
+    if (intersectCount >= 0) {
+      // Merge the two splices
+
+      splices.removeAt(i);
+      i--;
+
+      insertionOffset -= current.addedCount - current.removed.length;
+
+      splice._addedCount += current.addedCount - intersectCount;
+      int deleteCount =
+          splice.removed.length + current.removed.length - intersectCount;
+
+      if (splice.addedCount == 0 && deleteCount == 0) {
+        // merged splice is a noop. discard.
+        inserted = true;
+      } else {
+        List removed = current._removed;
+
+        if (splice.index < current.index) {
+          // some prefix of splice.removed is prepended to current.removed.
+          removed.insertAll(
+              0, splice.removed.getRange(0, current.index - splice.index));
+        }
+
+        if (splice.index + splice.removed.length >
+            current.index + current.addedCount) {
+          // some suffix of splice.removed is appended to current.removed.
+          removed.addAll(splice.removed.getRange(
+              current.index + current.addedCount - splice.index,
+              splice.removed.length));
+        }
+
+        splice._removed = removed;
+        splice._unmodifiableRemoved = current._unmodifiableRemoved;
+        if (current.index < splice.index) {
+          splice._index = current.index;
+        }
+      }
+    } else if (splice.index < current.index) {
+      // Insert splice here.
+
+      inserted = true;
+
+      splices.insert(i, splice);
+      i++;
+
+      int offset = splice.addedCount - splice.removed.length;
+      current._index += offset;
+      insertionOffset += offset;
+    }
+  }
+
+  if (!inserted) splices.add(splice);
+}
+
+List<ListChangeRecord> _createInitialSplices(
+    List<Object> list, List<ListChangeRecord> records) {
+  List<ListChangeRecord> splices = [];
+  for (ListChangeRecord record in records) {
+    _mergeSplice(splices, record);
+  }
+  return splices;
+}
+
+/// We need to summarize change records. Consumers of these records want to
+/// apply the batch sequentially, and ensure that they can find inserted
+/// items by looking at that position in the list. This property does not
+/// hold in our record-as-you-go records. Consider:
+///
+///     var model = toObservable(['a', 'b']);
+///     model.removeAt(1);
+///     model.insertAll(0, ['c', 'd', 'e']);
+///     model.removeRange(1, 3);
+///     model.insert(1, 'f');
+///
+/// Here, we inserted some records and then removed some of them.
+/// If someone processed these records naively, they would "play back" the
+/// insert incorrectly, because those items will be shifted.
+List<ListChangeRecord> projectListSplices(
+    List<Object> list, List<ListChangeRecord> records) {
+  if (records.length <= 1) return records;
+
+  List<ListChangeRecord> splices = <ListChangeRecord>[];
+  for (ListChangeRecord splice in _createInitialSplices(list, records)) {
+    if (splice.addedCount == 1 && splice.removed.length == 1) {
+      if (splice.removed[0] != list[splice.index]) splices.add(splice);
+      continue;
+    }
+
+    splices.addAll(calcSplices(
+        list,
+        splice.index,
+        splice.index + splice.addedCount,
+        splice._removed,
+        0,
+        splice.removed.length));
+  }
+
+  return splices;
+}
diff --git a/lib/src/observable.dart b/lib/src/observable.dart
new file mode 100644
index 0000000..7d1ec6f
--- /dev/null
+++ b/lib/src/observable.dart
@@ -0,0 +1,106 @@
+// Copyright (c) 2016, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+library observable.src.observable;
+
+import 'dart:async';
+import 'dart:collection' show UnmodifiableListView;
+
+import 'package:meta/meta.dart';
+
+import 'change_record.dart' show ChangeRecord;
+import 'property_change_record.dart' show PropertyChangeRecord;
+
+/// Represents an object with observable properties. This is used by data in
+/// model-view architectures to notify interested parties of [changes] to the
+/// object's properties (fields or getter/setter pairs).
+///
+/// The interface does not require any specific technique to implement
+/// observability. You can implement it in the following ways:
+///
+/// - Deriving from this class via a mixin or base class. When a field,
+///   property, or indexable item is changed, the derived class should call
+///   [notifyPropertyChange]. See that method for an example.
+/// - Implementing this interface and providing your own implementation.
+abstract class Observable {
+  StreamController<List<ChangeRecord>> _changes;
+
+  List<ChangeRecord> _records;
+
+  /// The stream of property change records to this object, delivered
+  /// asynchronously.
+  ///
+  /// [deliverChanges] can be called to force synchronous delivery.
+  Stream<List<ChangeRecord>> get changes {
+    if (_changes == null) {
+      _changes = new StreamController.broadcast(
+          sync: true, onListen: observed, onCancel: unobserved);
+    }
+    return _changes.stream;
+  }
+
+  /// Derived classes may override this method to be called when the [changes]
+  /// are first observed.
+  // TODO(tvolkert): @mustCallSuper (github.com/dart-lang/sdk/issues/27275)
+  @protected
+  void observed() {}
+
+  /// Derived classes may override this method to be called when the [changes]
+  /// are no longer being observed.
+  // TODO(tvolkert): @mustCallSuper (github.com/dart-lang/sdk/issues/27275)
+  @protected
+  void unobserved() {
+    // Free some memory
+    _changes = null;
+  }
+
+  /// True if this object has any observers.
+  bool get hasObservers => _changes != null && _changes.hasListener;
+
+  /// Synchronously deliver pending [changes].
+  ///
+  /// Returns `true` if any records were delivered, otherwise `false`.
+  /// Pending records will be cleared regardless, to keep newly added
+  /// observers from being notified of changes that occurred before
+  /// they started observing.
+  bool deliverChanges() {
+    List<ChangeRecord> records = _records;
+    _records = null;
+    if (hasObservers && records != null) {
+      _changes.add(new UnmodifiableListView<ChangeRecord>(records));
+      return true;
+    }
+    return false;
+  }
+
+  /// Notify that the [field] name of this object has been changed.
+  ///
+  /// The [oldValue] and [newValue] are also recorded. If the two values are
+  /// equal, no change will be recorded.
+  ///
+  /// For convenience this returns [newValue].
+  /*=T*/ notifyPropertyChange/*<T>*/(
+      Symbol field, /*=T*/ oldValue, /*=T*/ newValue) {
+    if (hasObservers && oldValue != newValue) {
+      notifyChange(new PropertyChangeRecord(this, field, oldValue, newValue));
+    }
+    return newValue;
+  }
+
+  /// Notify observers of a change.
+  ///
+  /// This will automatically schedule [deliverChanges].
+  ///
+  /// For most objects [Observable.notifyPropertyChange] is more convenient, but
+  /// collections sometimes deliver other types of changes such as a
+  /// [MapChangeRecord].
+  void notifyChange(ChangeRecord record) {
+    if (!hasObservers) return;
+    if (_records == null) {
+      _records = [];
+      scheduleMicrotask(deliverChanges);
+    }
+    _records.add(record);
+  }
+}
diff --git a/lib/src/observable_list.dart b/lib/src/observable_list.dart
new file mode 100644
index 0000000..16c49f5
--- /dev/null
+++ b/lib/src/observable_list.dart
@@ -0,0 +1,306 @@
+// Copyright (c) 2016, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+library observable.src.observable_list;
+
+import 'dart:async';
+import 'dart:collection' show ListBase, UnmodifiableListView;
+
+import 'list_diff.dart' show ListChangeRecord, projectListSplices, calcSplices;
+import 'observable.dart' show Observable;
+
+/// Represents an observable list of model values. If any items are added,
+/// removed, or replaced, then observers that are listening to [changes]
+/// will be notified.
+class ObservableList<E> extends ListBase<E> with Observable {
+  List<ListChangeRecord> _listRecords;
+
+  StreamController<List<ListChangeRecord>> _listChanges;
+
+  /// The inner [List<E>] with the actual storage.
+  final List<E> _list;
+
+  /// Creates an observable list of the given [length].
+  ///
+  /// If no [length] argument is supplied an extendable list of
+  /// length 0 is created.
+  ///
+  /// If a [length] argument is supplied, a fixed size list of that
+  /// length is created.
+  ObservableList([int length])
+      : _list = length != null ? new List<E>(length) : <E>[];
+
+  /// Creates an observable list of the given [length].
+  ///
+  /// This constructor exists to work around an issue in the VM whereby
+  /// classes that derive from [ObservableList] and mixin other classes
+  /// require a default generative constructor in the super class that
+  /// does not take optional arguments.
+  ObservableList.withLength(int length) : this(length);
+
+  /// Creates an observable list with the elements of [other]. The order in
+  /// the list will be the order provided by the iterator of [other].
+  factory ObservableList.from(Iterable<E> other) =>
+      new ObservableList<E>()..addAll(other);
+
+  /// The stream of summarized list changes, delivered asynchronously.
+  ///
+  /// Each list change record contains information about an individual mutation.
+  /// The records are projected so they can be applied sequentially. For
+  /// example, this set of mutations:
+  ///
+  ///     var model = new ObservableList.from(['a', 'b']);
+  ///     model.listChanges.listen((records) => records.forEach(print));
+  ///     model.removeAt(1);
+  ///     model.insertAll(0, ['c', 'd', 'e']);
+  ///     model.removeRange(1, 3);
+  ///     model.insert(1, 'f');
+  ///
+  /// The change records will be summarized so they can be "played back", using
+  /// the final list positions to figure out which item was added:
+  ///
+  ///     #<ListChangeRecord index: 0, removed: [], addedCount: 2>
+  ///     #<ListChangeRecord index: 3, removed: [b], addedCount: 0>
+  ///
+  /// [deliverChanges] can be called to force synchronous delivery.
+  Stream<List<ListChangeRecord>> get listChanges {
+    if (_listChanges == null) {
+      // TODO(jmesserly): split observed/unobserved notions?
+      _listChanges = new StreamController.broadcast(
+        sync: true,
+        onCancel: () {
+          _listChanges = null;
+        },
+      );
+    }
+    return _listChanges.stream;
+  }
+
+  bool get hasListObservers => _listChanges != null && _listChanges.hasListener;
+
+  int get length => _list.length;
+
+  set length(int value) {
+    int len = _list.length;
+    if (len == value) return;
+
+    // Produce notifications if needed
+    _notifyChangeLength(len, value);
+    if (hasListObservers) {
+      if (value < len) {
+        _notifyListChange(value, removed: _list.getRange(value, len).toList());
+      } else {
+        _notifyListChange(len, addedCount: value - len);
+      }
+    }
+
+    _list.length = value;
+  }
+
+  E operator [](int index) => _list[index];
+
+  void operator []=(int index, E value) {
+    E oldValue = _list[index];
+    if (hasListObservers && oldValue != value) {
+      _notifyListChange(index, addedCount: 1, removed: [oldValue]);
+    }
+    _list[index] = value;
+  }
+
+  // Forwarders so we can reflect on the properties.
+  bool get isEmpty => super.isEmpty;
+  bool get isNotEmpty => super.isNotEmpty;
+
+  // TODO(jmesserly): should we support first/last/single? They're kind of
+  // dangerous to use in a path because they throw exceptions. Also we'd need
+  // to produce property change notifications which seems to conflict with our
+  // existing list notifications.
+
+  // The following methods are here so that we can provide nice change events.
+
+  void setAll(int index, Iterable<E> iterable) {
+    if (iterable is! List && iterable is! Set) {
+      iterable = iterable.toList();
+    }
+    int length = iterable.length;
+    if (hasListObservers && length > 0) {
+      _notifyListChange(index,
+          addedCount: length, removed: _list.sublist(index, length));
+    }
+    _list.setAll(index, iterable);
+  }
+
+  void add(E value) {
+    int len = _list.length;
+    _notifyChangeLength(len, len + 1);
+    if (hasListObservers) {
+      _notifyListChange(len, addedCount: 1);
+    }
+
+    _list.add(value);
+  }
+
+  void addAll(Iterable<E> iterable) {
+    int len = _list.length;
+    _list.addAll(iterable);
+
+    _notifyChangeLength(len, _list.length);
+
+    int added = _list.length - len;
+    if (hasListObservers && added > 0) {
+      _notifyListChange(len, addedCount: added);
+    }
+  }
+
+  bool remove(Object element) {
+    for (int i = 0; i < this.length; i++) {
+      if (this[i] == element) {
+        removeRange(i, i + 1);
+        return true;
+      }
+    }
+    return false;
+  }
+
+  void removeRange(int start, int end) {
+    _rangeCheck(start, end);
+    int rangeLength = end - start;
+    int len = _list.length;
+
+    _notifyChangeLength(len, len - rangeLength);
+    if (hasListObservers && rangeLength > 0) {
+      _notifyListChange(start, removed: _list.getRange(start, end).toList());
+    }
+
+    _list.removeRange(start, end);
+  }
+
+  void insertAll(int index, Iterable<E> iterable) {
+    if (index < 0 || index > length) {
+      throw new RangeError.range(index, 0, length);
+    }
+    // TODO(floitsch): we can probably detect more cases.
+    if (iterable is! List && iterable is! Set) {
+      iterable = iterable.toList();
+    }
+    int insertionLength = iterable.length;
+    // There might be errors after the length change, in which case the list
+    // will end up being modified but the operation not complete. Unless we
+    // always go through a "toList" we can't really avoid that.
+    int len = _list.length;
+    _list.length += insertionLength;
+
+    _list.setRange(index + insertionLength, this.length, this, index);
+    _list.setAll(index, iterable);
+
+    _notifyChangeLength(len, _list.length);
+
+    if (hasListObservers && insertionLength > 0) {
+      _notifyListChange(index, addedCount: insertionLength);
+    }
+  }
+
+  void insert(int index, E element) {
+    if (index < 0 || index > length) {
+      throw new RangeError.range(index, 0, length);
+    }
+    if (index == length) {
+      add(element);
+      return;
+    }
+    // We are modifying the length just below the is-check. Without the check
+    // Array.copy could throw an exception, leaving the list in a bad state
+    // (with a length that has been increased, but without a new element).
+    if (index is! int) throw new ArgumentError(index);
+    _list.length++;
+    _list.setRange(index + 1, length, this, index);
+
+    _notifyChangeLength(_list.length - 1, _list.length);
+    if (hasListObservers) {
+      _notifyListChange(index, addedCount: 1);
+    }
+    _list[index] = element;
+  }
+
+  E removeAt(int index) {
+    E result = this[index];
+    removeRange(index, index + 1);
+    return result;
+  }
+
+  void _rangeCheck(int start, int end) {
+    if (start < 0 || start > this.length) {
+      throw new RangeError.range(start, 0, this.length);
+    }
+    if (end < start || end > this.length) {
+      throw new RangeError.range(end, start, this.length);
+    }
+  }
+
+  void _notifyListChange(int index, {List removed, int addedCount}) {
+    if (!hasListObservers) return;
+    if (_listRecords == null) {
+      _listRecords = [];
+      scheduleMicrotask(deliverListChanges);
+    }
+    _listRecords.add(new ListChangeRecord(this, index,
+        removed: removed, addedCount: addedCount));
+  }
+
+  void _notifyChangeLength(int oldValue, int newValue) {
+    notifyPropertyChange(#length, oldValue, newValue);
+    notifyPropertyChange(#isEmpty, oldValue == 0, newValue == 0);
+    notifyPropertyChange(#isNotEmpty, oldValue != 0, newValue != 0);
+  }
+
+  void discardListChanges() {
+    // Leave _listRecords set so we don't schedule another delivery.
+    if (_listRecords != null) _listRecords = [];
+  }
+
+  bool deliverListChanges() {
+    if (_listRecords == null) return false;
+    List<ListChangeRecord> records = projectListSplices(this, _listRecords);
+    _listRecords = null;
+
+    if (hasListObservers && records.isNotEmpty) {
+      _listChanges.add(new UnmodifiableListView<ListChangeRecord>(records));
+      return true;
+    }
+    return false;
+  }
+
+  /// Calculates the changes to the list, if lacking individual splice mutation
+  /// information.
+  ///
+  /// This is not needed for change records produced by [ObservableList] itself,
+  /// but it can be used if the list instance was replaced by another list.
+  ///
+  /// The minimal set of splices can be synthesized given the previous state and
+  /// final state of a list. The basic approach is to calculate the edit
+  /// distance matrix and choose the shortest path through it.
+  ///
+  /// Complexity is `O(l * p)` where `l` is the length of the current list and
+  /// `p` is the length of the old list.
+  static List<ListChangeRecord> calculateChangeRecords(
+          List<Object> oldValue, List<Object> newValue) =>
+      calcSplices(newValue, 0, newValue.length, oldValue, 0, oldValue.length);
+
+  /// Updates the [previous] list using the [changeRecords]. For added items,
+  /// the [current] list is used to find the current value.
+  static void applyChangeRecords(List<Object> previous, List<Object> current,
+      List<ListChangeRecord> changeRecords) {
+    if (identical(previous, current)) {
+      throw new ArgumentError("can't use same list for previous and current");
+    }
+
+    for (ListChangeRecord change in changeRecords) {
+      int addEnd = change.index + change.addedCount;
+      int removeEnd = change.index + change.removed.length;
+
+      Iterable addedItems = current.getRange(change.index, addEnd);
+      previous.replaceRange(change.index, removeEnd, addedItems);
+    }
+  }
+}
diff --git a/lib/src/observable_map.dart b/lib/src/observable_map.dart
new file mode 100644
index 0000000..23a7f11
--- /dev/null
+++ b/lib/src/observable_map.dart
@@ -0,0 +1,189 @@
+// Copyright (c) 2016, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+library observable.src.observable_map;
+
+import 'dart:collection';
+
+import 'change_record.dart' show ChangeRecord;
+import 'observable.dart' show Observable;
+import 'property_change_record.dart' show PropertyChangeRecord;
+
+// TODO(jmesserly): this needs to be faster. We currently require multiple
+// lookups per key to get the old value.
+// TODO(jmesserly): this doesn't implement the precise interfaces like
+// LinkedHashMap, SplayTreeMap or HashMap. However it can use them for the
+// backing store.
+
+// TODO(jmesserly): should we summarize map changes like we do for list changes?
+class MapChangeRecord<K, V> extends ChangeRecord {
+  // TODO(jmesserly): we could store this more compactly if it matters, with
+  // subtypes for inserted and removed.
+
+  /// The map key that changed.
+  final K key;
+
+  /// The previous value associated with this key.
+  final V oldValue;
+
+  /// The new value associated with this key.
+  final V newValue;
+
+  /// True if this key was inserted.
+  final bool isInsert;
+
+  /// True if this key was removed.
+  final bool isRemove;
+
+  MapChangeRecord(this.key, this.oldValue, this.newValue)
+      : isInsert = false,
+        isRemove = false;
+
+  MapChangeRecord.insert(this.key, this.newValue)
+      : isInsert = true,
+        isRemove = false,
+        oldValue = null;
+
+  MapChangeRecord.remove(this.key, this.oldValue)
+      : isInsert = false,
+        isRemove = true,
+        newValue = null;
+
+  String toString() {
+    var kind = isInsert ? 'insert' : isRemove ? 'remove' : 'set';
+    return '#<MapChangeRecord $kind $key from: $oldValue to: $newValue>';
+  }
+}
+
+/// Represents an observable map of model values. If any items are added,
+/// removed, or replaced, then observers that are listening to [changes]
+/// will be notified.
+class ObservableMap<K, V> extends Observable implements Map<K, V> {
+  final Map<K, V> _map;
+
+  /// Creates an observable map.
+  ObservableMap() : _map = new HashMap<K, V>();
+
+  /// Creates a new observable map using a [LinkedHashMap].
+  ObservableMap.linked() : _map = new LinkedHashMap<K, V>();
+
+  /// Creates a new observable map using a [SplayTreeMap].
+  ObservableMap.sorted() : _map = new SplayTreeMap<K, V>();
+
+  /// Creates an observable map that contains all key value pairs of [other].
+  /// It will attempt to use the same backing map type if the other map is a
+  /// [LinkedHashMap], [SplayTreeMap], or [HashMap]. Otherwise it defaults to
+  /// [HashMap].
+  ///
+  /// Note this will perform a shallow conversion. If you want a deep conversion
+  /// you should use [toObservable].
+  factory ObservableMap.from(Map<K, V> other) {
+    return new ObservableMap<K, V>.createFromType(other)..addAll(other);
+  }
+
+  /// Like [ObservableMap.from], but creates an empty map.
+  factory ObservableMap.createFromType(Map<K, V> other) {
+    ObservableMap<K, V> result;
+    if (other is SplayTreeMap) {
+      result = new ObservableMap<K, V>.sorted();
+    } else if (other is LinkedHashMap) {
+      result = new ObservableMap<K, V>.linked();
+    } else {
+      result = new ObservableMap<K, V>();
+    }
+    return result;
+  }
+
+  Iterable<K> get keys => _map.keys;
+
+  Iterable<V> get values => _map.values;
+
+  int get length => _map.length;
+
+  bool get isEmpty => length == 0;
+
+  bool get isNotEmpty => !isEmpty;
+
+  bool containsValue(Object value) => _map.containsValue(value);
+
+  bool containsKey(Object key) => _map.containsKey(key);
+
+  V operator [](Object key) => _map[key];
+
+  void operator []=(K key, V value) {
+    if (!hasObservers) {
+      _map[key] = value;
+      return;
+    }
+
+    int len = _map.length;
+    V oldValue = _map[key];
+
+    _map[key] = value;
+
+    if (len != _map.length) {
+      notifyPropertyChange(#length, len, _map.length);
+      notifyChange(new MapChangeRecord.insert(key, value));
+      _notifyKeysValuesChanged();
+    } else if (oldValue != value) {
+      notifyChange(new MapChangeRecord(key, oldValue, value));
+      _notifyValuesChanged();
+    }
+  }
+
+  void addAll(Map<K, V> other) {
+    other.forEach((K key, V value) {
+      this[key] = value;
+    });
+  }
+
+  V putIfAbsent(K key, V ifAbsent()) {
+    int len = _map.length;
+    V result = _map.putIfAbsent(key, ifAbsent);
+    if (hasObservers && len != _map.length) {
+      notifyPropertyChange(#length, len, _map.length);
+      notifyChange(new MapChangeRecord.insert(key, result));
+      _notifyKeysValuesChanged();
+    }
+    return result;
+  }
+
+  V remove(Object key) {
+    int len = _map.length;
+    V result = _map.remove(key);
+    if (hasObservers && len != _map.length) {
+      notifyChange(new MapChangeRecord.remove(key, result));
+      notifyPropertyChange(#length, len, _map.length);
+      _notifyKeysValuesChanged();
+    }
+    return result;
+  }
+
+  void clear() {
+    int len = _map.length;
+    if (hasObservers && len > 0) {
+      _map.forEach((key, value) {
+        notifyChange(new MapChangeRecord.remove(key, value));
+      });
+      notifyPropertyChange(#length, len, 0);
+      _notifyKeysValuesChanged();
+    }
+    _map.clear();
+  }
+
+  void forEach(void f(K key, V value)) => _map.forEach(f);
+
+  String toString() => Maps.mapToString(this);
+
+  // Note: we don't really have a reasonable old/new value to use here.
+  // But this should fix "keys" and "values" in templates with minimal overhead.
+  void _notifyKeysValuesChanged() {
+    notifyChange(new PropertyChangeRecord(this, #keys, null, null));
+    _notifyValuesChanged();
+  }
+
+  void _notifyValuesChanged() {
+    notifyChange(new PropertyChangeRecord(this, #values, null, null));
+  }
+}
diff --git a/lib/src/property_change_record.dart b/lib/src/property_change_record.dart
new file mode 100644
index 0000000..a09c196
--- /dev/null
+++ b/lib/src/property_change_record.dart
@@ -0,0 +1,27 @@
+// Copyright (c) 2016, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+library observable.src.property_change_record;
+
+import 'change_record.dart';
+
+/// A change record to a field of an [Observable] object.
+class PropertyChangeRecord<T> extends ChangeRecord {
+  /// The object that changed.
+  final Object object;
+
+  /// The name of the property that changed.
+  final Symbol name;
+
+  /// The previous value of the property.
+  final T oldValue;
+
+  /// The new value of the property.
+  final T newValue;
+
+  PropertyChangeRecord(this.object, this.name, this.oldValue, this.newValue);
+
+  String toString() =>
+      '#<PropertyChangeRecord $name from: $oldValue to: $newValue>';
+}
diff --git a/lib/src/to_observable.dart b/lib/src/to_observable.dart
new file mode 100644
index 0000000..0a0f316
--- /dev/null
+++ b/lib/src/to_observable.dart
@@ -0,0 +1,49 @@
+// Copyright (c) 2016, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+library observable.src.to_observable;
+
+import 'observable.dart' show Observable;
+import 'observable_list.dart' show ObservableList;
+import 'observable_map.dart' show ObservableMap;
+
+/// Converts the [Iterable] or [Map] to an [ObservableList] or [ObservableMap],
+/// respectively. This is a convenience function to make it easier to convert
+/// literals into the corresponding observable collection type.
+///
+/// If [value] is not one of those collection types, or is already [Observable],
+/// it will be returned unmodified.
+///
+/// If [value] is a [Map], the resulting value will use the appropriate kind of
+/// backing map: either [HashMap], [LinkedHashMap], or [SplayTreeMap].
+///
+/// By default this performs a deep conversion, but you can set [deep] to false
+/// for a shallow conversion. This does not handle circular data structures.
+/// If a conversion is peformed, mutations are only observed to the result of
+/// this function. Changing the original collection will not affect it.
+// TODO(jmesserly): ObservableSet?
+toObservable(dynamic value, {bool deep: true}) =>
+    deep ? _toObservableDeep(value) : _toObservableShallow(value);
+
+dynamic _toObservableShallow(dynamic value) {
+  if (value is Observable) return value;
+  if (value is Map) return new ObservableMap.from(value);
+  if (value is Iterable) return new ObservableList.from(value);
+  return value;
+}
+
+dynamic _toObservableDeep(dynamic value) {
+  if (value is Observable) return value;
+  if (value is Map) {
+    var result = new ObservableMap.createFromType(value);
+    value.forEach((k, v) {
+      result[_toObservableDeep(k)] = _toObservableDeep(v);
+    });
+    return result;
+  }
+  if (value is Iterable) {
+    return new ObservableList.from(value.map(_toObservableDeep));
+  }
+  return value;
+}
diff --git a/pubspec.yaml b/pubspec.yaml
new file mode 100644
index 0000000..0bedbf6
--- /dev/null
+++ b/pubspec.yaml
@@ -0,0 +1,9 @@
+name: observable
+version: 0.14.0
+author: Dart Team <misc@dartlang.org>
+description: Support for marking objects as observable
+homepage: https://github.com/dart-lang/observable
+environment:
+  sdk: '>=1.19.0 <2.0.0'
+dev_dependencies:
+  test: '^0.12.0'
diff --git a/test/list_change_test.dart b/test/list_change_test.dart
new file mode 100644
index 0000000..b07c0b8
--- /dev/null
+++ b/test/list_change_test.dart
@@ -0,0 +1,268 @@
+// Copyright (c) 2016, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:async';
+
+import 'package:observable/observable.dart';
+import 'package:test/test.dart';
+
+import 'observable_test_utils.dart';
+
+// This file contains code ported from:
+// https://github.com/rafaelw/ChangeSummary/blob/master/tests/test.js
+
+main() => listChangeTests();
+
+// TODO(jmesserly): port or write array fuzzer tests
+listChangeTests() {
+  StreamSubscription sub;
+  var model;
+
+  tearDown(() {
+    sub.cancel();
+    model = null;
+  });
+
+  _delta(i, r, a) => new ListChangeRecord(model, i, removed: r, addedCount: a);
+
+  test('sequential adds', () {
+    model = toObservable([]);
+    model.add(0);
+
+    var summary;
+    sub = model.listChanges.listen((r) => summary = r);
+
+    model.add(1);
+    model.add(2);
+
+    expect(summary, null);
+    return new Future(() => expectChanges(summary, [_delta(1, [], 2)]));
+  });
+
+  test('List Splice Truncate And Expand With Length', () {
+    model = toObservable(['a', 'b', 'c', 'd', 'e']);
+
+    var summary;
+    sub = model.listChanges.listen((r) => summary = r);
+
+    model.length = 2;
+
+    return new Future(() {
+      expectChanges(summary, [
+        _delta(2, ['c', 'd', 'e'], 0)
+      ]);
+      summary = null;
+      model.length = 5;
+    }).then(newMicrotask).then((_) {
+      expectChanges(summary, [_delta(2, [], 3)]);
+    });
+  });
+
+  group('List deltas can be applied', () {
+    applyAndCheckDeltas(model, copy, changes) => changes.then((summary) {
+          // apply deltas to the copy
+          for (var delta in summary) {
+            copy.removeRange(delta.index, delta.index + delta.removed.length);
+            for (int i = delta.addedCount - 1; i >= 0; i--) {
+              copy.insert(delta.index, model[delta.index + i]);
+            }
+          }
+
+          // Note: compare strings for easier debugging.
+          expect('$copy', '$model', reason: 'summary $summary');
+        });
+
+    test('Contained', () {
+      var model = toObservable(['a', 'b']);
+      var copy = model.toList();
+      var changes = model.listChanges.first;
+
+      model.removeAt(1);
+      model.insertAll(0, ['c', 'd', 'e']);
+      model.removeRange(1, 3);
+      model.insert(1, 'f');
+
+      return applyAndCheckDeltas(model, copy, changes);
+    });
+
+    test('Delete Empty', () {
+      var model = toObservable([1]);
+      var copy = model.toList();
+      var changes = model.listChanges.first;
+
+      model.removeAt(0);
+      model.insertAll(0, ['a', 'b', 'c']);
+
+      return applyAndCheckDeltas(model, copy, changes);
+    });
+
+    test('Right Non Overlap', () {
+      var model = toObservable(['a', 'b', 'c', 'd']);
+      var copy = model.toList();
+      var changes = model.listChanges.first;
+
+      model.removeRange(0, 1);
+      model.insert(0, 'e');
+      model.removeRange(2, 3);
+      model.insertAll(2, ['f', 'g']);
+
+      return applyAndCheckDeltas(model, copy, changes);
+    });
+
+    test('Left Non Overlap', () {
+      var model = toObservable(['a', 'b', 'c', 'd']);
+      var copy = model.toList();
+      var changes = model.listChanges.first;
+
+      model.removeRange(3, 4);
+      model.insertAll(3, ['f', 'g']);
+      model.removeRange(0, 1);
+      model.insert(0, 'e');
+
+      return applyAndCheckDeltas(model, copy, changes);
+    });
+
+    test('Right Adjacent', () {
+      var model = toObservable(['a', 'b', 'c', 'd']);
+      var copy = model.toList();
+      var changes = model.listChanges.first;
+
+      model.removeRange(1, 2);
+      model.insert(3, 'e');
+      model.removeRange(2, 3);
+      model.insertAll(0, ['f', 'g']);
+
+      return applyAndCheckDeltas(model, copy, changes);
+    });
+
+    test('Left Adjacent', () {
+      var model = toObservable(['a', 'b', 'c', 'd']);
+      var copy = model.toList();
+      var changes = model.listChanges.first;
+
+      model.removeRange(2, 4);
+      model.insert(2, 'e');
+
+      model.removeAt(1);
+      model.insertAll(1, ['f', 'g']);
+
+      return applyAndCheckDeltas(model, copy, changes);
+    });
+
+    test('Right Overlap', () {
+      var model = toObservable(['a', 'b', 'c', 'd']);
+      var copy = model.toList();
+      var changes = model.listChanges.first;
+
+      model.removeAt(1);
+      model.insert(1, 'e');
+      model.removeAt(1);
+      model.insertAll(1, ['f', 'g']);
+
+      return applyAndCheckDeltas(model, copy, changes);
+    });
+
+    test('Left Overlap', () {
+      var model = toObservable(['a', 'b', 'c', 'd']);
+      var copy = model.toList();
+      var changes = model.listChanges.first;
+
+      model.removeAt(2);
+      model.insertAll(2, ['e', 'f', 'g']);
+      // a b [e f g] d
+      model.removeRange(1, 3);
+      model.insertAll(1, ['h', 'i', 'j']);
+      // a [h i j] f g d
+
+      return applyAndCheckDeltas(model, copy, changes);
+    });
+
+    test('Prefix And Suffix One In', () {
+      var model = toObservable(['a', 'b', 'c', 'd']);
+      var copy = model.toList();
+      var changes = model.listChanges.first;
+
+      model.insert(0, 'z');
+      model.add('z');
+
+      return applyAndCheckDeltas(model, copy, changes);
+    });
+
+    test('Remove First', () {
+      var model = toObservable([16, 15, 15]);
+      var copy = model.toList();
+      var changes = model.listChanges.first;
+
+      model.removeAt(0);
+
+      return applyAndCheckDeltas(model, copy, changes);
+    });
+
+    test('Update Remove', () {
+      var model = toObservable(['a', 'b', 'c', 'd']);
+      var copy = model.toList();
+      var changes = model.listChanges.first;
+
+      model.removeAt(2);
+      model.insertAll(2, ['e', 'f', 'g']); // a b [e f g] d
+      model[0] = 'h';
+      model.removeAt(1);
+
+      return applyAndCheckDeltas(model, copy, changes);
+    });
+
+    test('Remove Mid List', () {
+      var model = toObservable(['a', 'b', 'c', 'd']);
+      var copy = model.toList();
+      var changes = model.listChanges.first;
+
+      model.removeAt(2);
+
+      return applyAndCheckDeltas(model, copy, changes);
+    });
+  });
+
+  group('edit distance', () {
+    assertEditDistance(orig, changes, expectedDist) => changes.then((summary) {
+          var actualDistance = 0;
+          for (var delta in summary) {
+            actualDistance += delta.addedCount + delta.removed.length;
+          }
+
+          expect(actualDistance, expectedDist);
+        });
+
+    test('add items', () {
+      var model = toObservable([]);
+      var changes = model.listChanges.first;
+      model.addAll([1, 2, 3]);
+      return assertEditDistance(model, changes, 3);
+    });
+
+    test('trunacte and add, sharing a contiguous block', () {
+      var model = toObservable(['x', 'x', 'x', 'x', '1', '2', '3']);
+      var changes = model.listChanges.first;
+      model.length = 0;
+      model.addAll(['1', '2', '3', 'y', 'y', 'y', 'y']);
+      return assertEditDistance(model, changes, 8);
+    });
+
+    test('truncate and add, sharing a discontiguous block', () {
+      var model = toObservable(['1', '2', '3', '4', '5']);
+      var changes = model.listChanges.first;
+      model.length = 0;
+      model.addAll(['a', '2', 'y', 'y', '4', '5', 'z', 'z']);
+      return assertEditDistance(model, changes, 7);
+    });
+
+    test('insert at beginning and end', () {
+      var model = toObservable([2, 3, 4]);
+      var changes = model.listChanges.first;
+      model.insert(0, 5);
+      model[2] = 6;
+      model.add(7);
+      return assertEditDistance(model, changes, 4);
+    });
+  });
+}
diff --git a/test/observable_list_test.dart b/test/observable_list_test.dart
new file mode 100644
index 0000000..51f07bf
--- /dev/null
+++ b/test/observable_list_test.dart
@@ -0,0 +1,320 @@
+// Copyright (c) 2016, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:async';
+
+import 'package:observable/observable.dart';
+import 'package:test/test.dart';
+
+import 'observable_test_utils.dart';
+
+main() => _runTests();
+
+_runTests() {
+  // TODO(jmesserly): need all standard List API tests.
+
+  StreamSubscription sub, sub2;
+
+  sharedTearDown() {
+    list = null;
+    sub.cancel();
+    if (sub2 != null) {
+      sub2.cancel();
+      sub2 = null;
+    }
+  }
+
+  group('observe length', () {
+    ObservableList list;
+    List<ChangeRecord> changes;
+
+    setUp(() {
+      list = toObservable([1, 2, 3]) as ObservableList;
+      changes = null;
+      sub = list.changes.listen((records) {
+        changes = getPropertyChangeRecords(records, #length);
+      });
+    });
+
+    tearDown(sharedTearDown);
+
+    test('add changes length', () {
+      list.add(4);
+      expect(list, [1, 2, 3, 4]);
+      return new Future(() {
+        expectChanges(changes, [_lengthChange(3, 4)]);
+      });
+    });
+
+    test('removeObject changes length', () {
+      list.remove(2);
+      expect(list, orderedEquals([1, 3]));
+
+      return new Future(() {
+        expectChanges(changes, [_lengthChange(3, 2)]);
+      });
+    });
+
+    test('removeRange changes length', () {
+      list.add(4);
+      list.removeRange(1, 3);
+      expect(list, [1, 4]);
+      return new Future(() {
+        expectChanges(changes, [_lengthChange(3, 4), _lengthChange(4, 2)]);
+      });
+    });
+
+    test('length= changes length', () {
+      list.length = 5;
+      expect(list, [1, 2, 3, null, null]);
+      return new Future(() {
+        expectChanges(changes, [_lengthChange(3, 5)]);
+      });
+    });
+
+    test('[]= does not change length', () {
+      list[2] = 9000;
+      expect(list, [1, 2, 9000]);
+      return new Future(() {
+        expectChanges(changes, null);
+      });
+    });
+
+    test('clear changes length', () {
+      list.clear();
+      expect(list, []);
+      return new Future(() {
+        expectChanges(changes, [_lengthChange(3, 0)]);
+      });
+    });
+  });
+
+  group('observe index', () {
+    List<ListChangeRecord> changes;
+
+    setUp(() {
+      list = toObservable([1, 2, 3]) as ObservableList;
+      changes = null;
+      sub = list.listChanges.listen((List<ListChangeRecord> records) {
+        changes = getListChangeRecords(records, 1);
+      });
+    });
+
+    tearDown(sharedTearDown);
+
+    test('add does not change existing items', () {
+      list.add(4);
+      expect(list, [1, 2, 3, 4]);
+      return new Future(() {
+        expectChanges(changes, []);
+      });
+    });
+
+    test('[]= changes item', () {
+      list[1] = 777;
+      expect(list, [1, 777, 3]);
+      return new Future(() {
+        expectChanges(changes, [
+          _change(1, addedCount: 1, removed: [2])
+        ]);
+      });
+    });
+
+    test('[]= on a different item does not fire change', () {
+      list[2] = 9000;
+      expect(list, [1, 2, 9000]);
+      return new Future(() {
+        expectChanges(changes, []);
+      });
+    });
+
+    test('set multiple times results in one change', () {
+      list[1] = 777;
+      list[1] = 42;
+      expect(list, [1, 42, 3]);
+      return new Future(() {
+        expectChanges(changes, [
+          _change(1, addedCount: 1, removed: [2]),
+        ]);
+      });
+    });
+
+    test('set length without truncating item means no change', () {
+      list.length = 2;
+      expect(list, [1, 2]);
+      return new Future(() {
+        expectChanges(changes, []);
+      });
+    });
+
+    test('truncate removes item', () {
+      list.length = 1;
+      expect(list, [1]);
+      return new Future(() {
+        expectChanges(changes, [
+          _change(1, removed: [2, 3])
+        ]);
+      });
+    });
+
+    test('truncate and add new item', () {
+      list.length = 1;
+      list.add(42);
+      expect(list, [1, 42]);
+      return new Future(() {
+        expectChanges(changes, [
+          _change(1, removed: [2, 3], addedCount: 1)
+        ]);
+      });
+    });
+
+    test('truncate and add same item', () {
+      list.length = 1;
+      list.add(2);
+      expect(list, [1, 2]);
+      return new Future(() {
+        expectChanges(changes, []);
+      });
+    });
+  });
+
+  test('toString', () {
+    var list = toObservable([1, 2, 3]);
+    expect(list.toString(), '[1, 2, 3]');
+  });
+
+  group('change records', () {
+    List<ChangeRecord> propRecords;
+    List<ListChangeRecord> listRecords;
+
+    setUp(() {
+      list = toObservable([1, 2, 3, 1, 3, 4]) as ObservableList;
+      propRecords = null;
+      listRecords = null;
+      sub = list.changes.listen((r) => propRecords = r);
+      sub2 = list.listChanges.listen((r) => listRecords = r);
+    });
+
+    tearDown(sharedTearDown);
+
+    test('read operations', () {
+      expect(list.length, 6);
+      expect(list[0], 1);
+      expect(list.indexOf(4), 5);
+      expect(list.indexOf(1), 0);
+      expect(list.indexOf(1, 1), 3);
+      expect(list.lastIndexOf(1), 3);
+      expect(list.last, 4);
+      var copy = new List<int>();
+      list.forEach((int i) => copy.add(i));
+      expect(copy, orderedEquals([1, 2, 3, 1, 3, 4]));
+      return new Future(() {
+        // no change from read-only operators
+        expectChanges(propRecords, null);
+        expectChanges(listRecords, null);
+      });
+    });
+
+    test('add', () {
+      list.add(5);
+      list.add(6);
+      expect(list, orderedEquals([1, 2, 3, 1, 3, 4, 5, 6]));
+
+      return new Future(() {
+        expectChanges(propRecords, [
+          _lengthChange(6, 7),
+          _lengthChange(7, 8),
+        ]);
+        expectChanges(listRecords, [_change(6, addedCount: 2)]);
+      });
+    });
+
+    test('[]=', () {
+      list[1] = list.last;
+      expect(list, orderedEquals([1, 4, 3, 1, 3, 4]));
+
+      return new Future(() {
+        expectChanges(propRecords, null);
+        expectChanges(listRecords, [
+          _change(1, addedCount: 1, removed: [2])
+        ]);
+      });
+    });
+
+    test('removeLast', () {
+      expect(list.removeLast(), 4);
+      expect(list, orderedEquals([1, 2, 3, 1, 3]));
+
+      return new Future(() {
+        expectChanges(propRecords, [_lengthChange(6, 5)]);
+        expectChanges(listRecords, [
+          _change(5, removed: [4])
+        ]);
+      });
+    });
+
+    test('removeRange', () {
+      list.removeRange(1, 4);
+      expect(list, orderedEquals([1, 3, 4]));
+
+      return new Future(() {
+        expectChanges(propRecords, [_lengthChange(6, 3)]);
+        expectChanges(listRecords, [
+          _change(1, removed: [2, 3, 1])
+        ]);
+      });
+    });
+
+    test('sort', () {
+      list.sort((x, y) => x - y);
+      expect(list, orderedEquals([1, 1, 2, 3, 3, 4]));
+
+      return new Future(() {
+        expectChanges(propRecords, null);
+        expectChanges(listRecords, [
+          _change(1, addedCount: 1),
+          _change(4, removed: [1])
+        ]);
+      });
+    });
+
+    test('sort of 2 elements', () {
+      var list = toObservable([3, 1]);
+      // Dummy listener to record changes.
+      // TODO(jmesserly): should we just record changes always, to support the sync api?
+      sub = list.listChanges.listen((List<ListChangeRecord> records) => null)
+          as StreamSubscription;
+      list.sort();
+      expect(list.deliverListChanges(), true);
+      list.sort();
+      expect(list.deliverListChanges(), false);
+      list.sort();
+      expect(list.deliverListChanges(), false);
+    });
+
+    test('clear', () {
+      list.clear();
+      expect(list, []);
+
+      return new Future(() {
+        expectChanges(propRecords, [
+          _lengthChange(6, 0),
+          new PropertyChangeRecord(list, #isEmpty, false, true),
+          new PropertyChangeRecord(list, #isNotEmpty, true, false),
+        ]);
+        expectChanges(listRecords, [
+          _change(0, removed: [1, 2, 3, 1, 3, 4])
+        ]);
+      });
+    });
+  });
+}
+
+ObservableList list;
+
+_lengthChange(int oldValue, int newValue) =>
+    new PropertyChangeRecord(list, #length, oldValue, newValue);
+
+_change(int index, {List removed: const [], int addedCount: 0}) =>
+    new ListChangeRecord(list, index, removed: removed, addedCount: addedCount);
diff --git a/test/observable_map_test.dart b/test/observable_map_test.dart
new file mode 100644
index 0000000..a424ce1
--- /dev/null
+++ b/test/observable_map_test.dart
@@ -0,0 +1,376 @@
+// Copyright (c) 2016, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:async';
+
+import 'package:observable/observable.dart';
+import 'package:test/test.dart';
+
+import 'observable_test_utils.dart';
+
+main() => _runTests();
+
+_runTests() {
+  // TODO(jmesserly): need all standard Map API tests.
+
+  StreamSubscription sub;
+
+  sharedTearDown() {
+    if (sub != null) {
+      sub.cancel();
+      sub = null;
+    }
+  }
+
+  group('observe length', () {
+    ObservableMap map;
+    List<ChangeRecord> changes;
+
+    setUp(() {
+      map = toObservable({'a': 1, 'b': 2, 'c': 3});
+      changes = null;
+      sub = map.changes.listen((records) {
+        changes = getPropertyChangeRecords(records, #length);
+      });
+    });
+
+    tearDown(sharedTearDown);
+
+    test('add item changes length', () {
+      map['d'] = 4;
+      expect(map, {'a': 1, 'b': 2, 'c': 3, 'd': 4});
+      return new Future(() {
+        expectChanges(changes, [_lengthChange(map, 3, 4)]);
+      });
+    });
+
+    test('putIfAbsent changes length', () {
+      map.putIfAbsent('d', () => 4);
+      expect(map, {'a': 1, 'b': 2, 'c': 3, 'd': 4});
+      return new Future(() {
+        expectChanges(changes, [_lengthChange(map, 3, 4)]);
+      });
+    });
+
+    test('remove changes length', () {
+      map.remove('c');
+      map.remove('a');
+      expect(map, {'b': 2});
+      return new Future(() {
+        expectChanges(changes, [
+          _lengthChange(map, 3, 2),
+          _lengthChange(map, 2, 1),
+        ]);
+      });
+    });
+
+    test('remove non-existent item does not change length', () {
+      map.remove('d');
+      expect(map, {'a': 1, 'b': 2, 'c': 3});
+      return new Future(() {
+        expectChanges(changes, null);
+      });
+    });
+
+    test('set existing item does not change length', () {
+      map['c'] = 9000;
+      expect(map, {'a': 1, 'b': 2, 'c': 9000});
+      return new Future(() {
+        expectChanges(changes, []);
+      });
+    });
+
+    test('clear changes length', () {
+      map.clear();
+      expect(map, {});
+      return new Future(() {
+        expectChanges(changes, [_lengthChange(map, 3, 0)]);
+      });
+    });
+  });
+
+  group('observe item', () {
+    ObservableMap map;
+    List<ChangeRecord> changes;
+
+    setUp(() {
+      map = toObservable({'a': 1, 'b': 2, 'c': 3});
+      changes = null;
+      sub = map.changes.listen((records) {
+        changes =
+            records.where((r) => r is MapChangeRecord && r.key == 'b').toList();
+      });
+    });
+
+    tearDown(sharedTearDown);
+
+    test('putIfAbsent new item does not change existing item', () {
+      map.putIfAbsent('d', () => 4);
+      expect(map, {'a': 1, 'b': 2, 'c': 3, 'd': 4});
+      return new Future(() {
+        expectChanges(changes, []);
+      });
+    });
+
+    test('set item to null', () {
+      map['b'] = null;
+      expect(map, {'a': 1, 'b': null, 'c': 3});
+      return new Future(() {
+        expectChanges(changes, [_changeKey('b', 2, null)]);
+      });
+    });
+
+    test('set item to value', () {
+      map['b'] = 777;
+      expect(map, {'a': 1, 'b': 777, 'c': 3});
+      return new Future(() {
+        expectChanges(changes, [_changeKey('b', 2, 777)]);
+      });
+    });
+
+    test('putIfAbsent does not change if already there', () {
+      map.putIfAbsent('b', () => 1234);
+      expect(map, {'a': 1, 'b': 2, 'c': 3});
+      return new Future(() {
+        expectChanges(changes, null);
+      });
+    });
+
+    test('change a different item', () {
+      map['c'] = 9000;
+      expect(map, {'a': 1, 'b': 2, 'c': 9000});
+      return new Future(() {
+        expectChanges(changes, []);
+      });
+    });
+
+    test('change the item', () {
+      map['b'] = 9001;
+      map['b'] = 42;
+      expect(map, {'a': 1, 'b': 42, 'c': 3});
+      return new Future(() {
+        expectChanges(changes, [
+          _changeKey('b', 2, 9001),
+          _changeKey('b', 9001, 42),
+        ]);
+      });
+    });
+
+    test('remove other items', () {
+      map.remove('a');
+      expect(map, {'b': 2, 'c': 3});
+      return new Future(() {
+        expectChanges(changes, []);
+      });
+    });
+
+    test('remove the item', () {
+      map.remove('b');
+      expect(map, {'a': 1, 'c': 3});
+      return new Future(() {
+        expectChanges(changes, [_removeKey('b', 2)]);
+      });
+    });
+
+    test('remove and add back', () {
+      map.remove('b');
+      map['b'] = 2;
+      expect(map, {'a': 1, 'b': 2, 'c': 3});
+      return new Future(() {
+        expectChanges(changes, [
+          _removeKey('b', 2),
+          _insertKey('b', 2),
+        ]);
+      });
+    });
+  });
+
+  test('toString', () {
+    var map = toObservable({'a': 1, 'b': 2});
+    expect(map.toString(), '{a: 1, b: 2}');
+  });
+
+  group('observe keys/values', () {
+    ObservableMap map;
+    int keysChanged;
+    int valuesChanged;
+
+    setUp(() {
+      map = toObservable({'a': 1, 'b': 2, 'c': 3});
+      keysChanged = 0;
+      valuesChanged = 0;
+      sub = map.changes.listen((records) {
+        keysChanged += getPropertyChangeRecords(records, #keys).length;
+        valuesChanged += getPropertyChangeRecords(records, #values).length;
+      });
+    });
+
+    tearDown(sharedTearDown);
+
+    test('add item changes keys/values', () {
+      map['d'] = 4;
+      expect(map, {'a': 1, 'b': 2, 'c': 3, 'd': 4});
+      return new Future(() {
+        expect(keysChanged, 1);
+        expect(valuesChanged, 1);
+      });
+    });
+
+    test('putIfAbsent changes keys/values', () {
+      map.putIfAbsent('d', () => 4);
+      expect(map, {'a': 1, 'b': 2, 'c': 3, 'd': 4});
+      return new Future(() {
+        expect(keysChanged, 1);
+        expect(valuesChanged, 1);
+      });
+    });
+
+    test('remove changes keys/values', () {
+      map.remove('c');
+      map.remove('a');
+      expect(map, {'b': 2});
+      return new Future(() {
+        expect(keysChanged, 2);
+        expect(valuesChanged, 2);
+      });
+    });
+
+    test('remove non-existent item does not change keys/values', () {
+      map.remove('d');
+      expect(map, {'a': 1, 'b': 2, 'c': 3});
+      return new Future(() {
+        expect(keysChanged, 0);
+        expect(valuesChanged, 0);
+      });
+    });
+
+    test('set existing item does not change keys', () {
+      map['c'] = 9000;
+      expect(map, {'a': 1, 'b': 2, 'c': 9000});
+      return new Future(() {
+        expect(keysChanged, 0);
+        expect(valuesChanged, 1);
+      });
+    });
+
+    test('clear changes keys/values', () {
+      map.clear();
+      expect(map, {});
+      return new Future(() {
+        expect(keysChanged, 1);
+        expect(valuesChanged, 1);
+      });
+    });
+  });
+
+  group('change records', () {
+    List<ChangeRecord> records;
+    ObservableMap map;
+
+    setUp(() {
+      map = toObservable({'a': 1, 'b': 2});
+      records = null;
+      map.changes.first.then((r) => records = r);
+    });
+
+    tearDown(sharedTearDown);
+
+    test('read operations', () {
+      expect(map.length, 2);
+      expect(map.isEmpty, false);
+      expect(map['a'], 1);
+      expect(map.containsKey(2), false);
+      expect(map.containsValue(2), true);
+      expect(map.containsKey('b'), true);
+      expect(map.keys.toList(), ['a', 'b']);
+      expect(map.values.toList(), [1, 2]);
+      var copy = {};
+      map.forEach((k, v) => copy[k] = v);
+      expect(copy, {'a': 1, 'b': 2});
+      return new Future(() {
+        // no change from read-only operators
+        expect(records, null);
+
+        // Make a change so the subscription gets unregistered.
+        map.clear();
+      });
+    });
+
+    test('putIfAbsent', () {
+      map.putIfAbsent('a', () => 42);
+      expect(map, {'a': 1, 'b': 2});
+
+      map.putIfAbsent('c', () => 3);
+      expect(map, {'a': 1, 'b': 2, 'c': 3});
+
+      return new Future(() {
+        expectChanges(records, [
+          _lengthChange(map, 2, 3),
+          _insertKey('c', 3),
+          _propChange(map, #keys),
+          _propChange(map, #values),
+        ]);
+      });
+    });
+
+    test('[]=', () {
+      map['a'] = 42;
+      expect(map, {'a': 42, 'b': 2});
+
+      map['c'] = 3;
+      expect(map, {'a': 42, 'b': 2, 'c': 3});
+
+      return new Future(() {
+        expectChanges(records, [
+          _changeKey('a', 1, 42),
+          _propChange(map, #values),
+          _lengthChange(map, 2, 3),
+          _insertKey('c', 3),
+          _propChange(map, #keys),
+          _propChange(map, #values),
+        ]);
+      });
+    });
+
+    test('remove', () {
+      map.remove('b');
+      expect(map, {'a': 1});
+
+      return new Future(() {
+        expectChanges(records, [
+          _removeKey('b', 2),
+          _lengthChange(map, 2, 1),
+          _propChange(map, #keys),
+          _propChange(map, #values),
+        ]);
+      });
+    });
+
+    test('clear', () {
+      map.clear();
+      expect(map, {});
+
+      return new Future(() {
+        expectChanges(records, [
+          _removeKey('a', 1),
+          _removeKey('b', 2),
+          _lengthChange(map, 2, 0),
+          _propChange(map, #keys),
+          _propChange(map, #values),
+        ]);
+      });
+    });
+  });
+}
+
+_lengthChange(map, int oldValue, int newValue) =>
+    new PropertyChangeRecord(map, #length, oldValue, newValue);
+
+_changeKey(key, old, newValue) => new MapChangeRecord(key, old, newValue);
+
+_insertKey(key, newValue) => new MapChangeRecord.insert(key, newValue);
+
+_removeKey(key, oldValue) => new MapChangeRecord.remove(key, oldValue);
+
+_propChange(map, prop) => new PropertyChangeRecord(map, prop, null, null);
diff --git a/test/observable_test.dart b/test/observable_test.dart
new file mode 100644
index 0000000..b89d654
--- /dev/null
+++ b/test/observable_test.dart
@@ -0,0 +1,222 @@
+// Copyright (c) 2016, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:async';
+
+import 'package:observable/observable.dart';
+import 'package:test/test.dart';
+
+import 'observable_test_utils.dart';
+
+main() => observableTests();
+
+void observableTests() {
+  // Track the subscriptions so we can clean them up in tearDown.
+  List subs;
+
+  setUp(() {
+    subs = [];
+  });
+
+  tearDown(() {
+    for (var sub in subs) sub.cancel();
+  });
+
+  test('handle future result', () {
+    var callback = expectAsync(() {});
+    return new Future(callback);
+  });
+
+  test('no observers', () {
+    var t = createModel(123);
+    expect(t.value, 123);
+    t.value = 42;
+    expect(t.value, 42);
+    expect(t.hasObservers, false);
+  });
+
+  test('listen adds an observer', () {
+    var t = createModel(123);
+    expect(t.hasObservers, false);
+
+    subs.add(t.changes.listen((n) {}));
+    expect(t.hasObservers, true);
+  });
+
+  test('changes delived async', () {
+    var t = createModel(123);
+    int called = 0;
+
+    subs.add(t.changes.listen(expectAsync((records) {
+      called++;
+      expectPropertyChanges(records, 2);
+    })));
+
+    t.value = 41;
+    t.value = 42;
+    expect(called, 0);
+  });
+
+  test('cause changes in handler', () {
+    var t = createModel(123);
+    int called = 0;
+
+    subs.add(t.changes.listen(expectAsync((records) {
+      called++;
+      expectPropertyChanges(records, 1);
+      if (called == 1) {
+        // Cause another change
+        t.value = 777;
+      }
+    }, count: 2)));
+
+    t.value = 42;
+  });
+
+  test('multiple observers', () {
+    var t = createModel(123);
+
+    verifyRecords(records) {
+      expectPropertyChanges(records, 2);
+    }
+
+    subs.add(t.changes.listen(expectAsync(verifyRecords)));
+    subs.add(t.changes.listen(expectAsync(verifyRecords)));
+
+    t.value = 41;
+    t.value = 42;
+  });
+
+  test('async processing model', () {
+    var t = createModel(123);
+    var records = [];
+    subs.add(t.changes.listen((r) {
+      records.addAll(r);
+    }));
+    t.value = 41;
+    t.value = 42;
+    expectChanges(records, [], reason: 'changes delived async');
+
+    return new Future(() {
+      expectPropertyChanges(records, 2);
+      records.clear();
+
+      t.value = 777;
+      expectChanges(records, [], reason: 'changes delived async');
+    }).then(newMicrotask).then((_) {
+      expectPropertyChanges(records, 1);
+    });
+  });
+
+  test('cancel listening', () {
+    var t = createModel(123);
+    var sub;
+    sub = t.changes.listen(expectAsync((records) {
+      expectPropertyChanges(records, 1);
+      sub.cancel();
+      t.value = 777;
+    }));
+    t.value = 42;
+  });
+
+  test('cancel and reobserve', () {
+    var t = createModel(123);
+    var sub;
+    sub = t.changes.listen(expectAsync((records) {
+      expectPropertyChanges(records, 1);
+      sub.cancel();
+
+      scheduleMicrotask(() {
+        subs.add(t.changes.listen(expectAsync((records) {
+          expectPropertyChanges(records, 1);
+        })));
+        t.value = 777;
+      });
+    }));
+    t.value = 42;
+  });
+
+  test('cannot modify changes list', () {
+    var t = createModel(123);
+    var records;
+    subs.add(t.changes.listen((r) {
+      records = r;
+    }));
+    t.value = 42;
+
+    return new Future(() {
+      expectPropertyChanges(records, 1);
+
+      // Verify that mutation operations on the list fail:
+
+      expect(() {
+        records[0] = new PropertyChangeRecord(t, #value, 0, 1);
+      }, throwsUnsupportedError);
+
+      expect(() {
+        records.clear();
+      }, throwsUnsupportedError);
+
+      expect(() {
+        records.length = 0;
+      }, throwsUnsupportedError);
+    });
+  });
+
+  test('notifyChange', () {
+    var t = createModel(123);
+    var records = [];
+    subs.add(t.changes.listen((r) {
+      records.addAll(r);
+    }));
+    t.notifyChange(new PropertyChangeRecord(t, #value, 123, 42));
+
+    return new Future(() {
+      expectPropertyChanges(records, 1);
+      expect(t.value, 123, reason: 'value did not actually change.');
+    });
+  });
+
+  test('notifyPropertyChange', () {
+    var t = createModel(123);
+    var records;
+    subs.add(t.changes.listen((r) {
+      records = r;
+    }));
+    expect(t.notifyPropertyChange(#value, t.value, 42), 42,
+        reason: 'notifyPropertyChange returns newValue');
+
+    return new Future(() {
+      expectPropertyChanges(records, 1);
+      expect(t.value, 123, reason: 'value did not actually change.');
+    });
+  });
+}
+
+expectPropertyChanges(records, int number) {
+  expect(records.length, number, reason: 'expected $number change records');
+  for (var record in records) {
+    expect(record is PropertyChangeRecord, true,
+        reason: 'record should be PropertyChangeRecord');
+    expect((record as PropertyChangeRecord).name, #value,
+        reason: 'record should indicate a change to the "value" property');
+  }
+}
+
+createModel(int number) => new ObservableSubclass(number);
+
+class ObservableSubclass<T> extends Observable {
+  ObservableSubclass([T initialValue]) : _value = initialValue;
+
+  T get value => _value;
+  set value(T newValue) {
+    T oldValue = _value;
+    _value = newValue;
+    notifyPropertyChange(#value, oldValue, newValue);
+  }
+
+  T _value;
+
+  String toString() => '#<$runtimeType value: $value>';
+}
diff --git a/test/observable_test_utils.dart b/test/observable_test_utils.dart
new file mode 100644
index 0000000..91d4d8f
--- /dev/null
+++ b/test/observable_test_utils.dart
@@ -0,0 +1,32 @@
+// Copyright (c) 2016, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+library observable.test.observable_test_utils;
+
+import 'dart:async';
+
+import 'package:observable/observable.dart';
+import 'package:test/test.dart';
+
+/// A small method to help readability. Used to cause the next "then" in a chain
+/// to happen in the next microtask:
+///
+///     future.then(newMicrotask).then(...)
+///
+/// Uses [mu].
+newMicrotask(_) => new Future.value();
+
+// TODO(jmesserly): use matchers when we have a way to compare ChangeRecords.
+// For now just use the toString.
+void expectChanges(actual, expected, {String reason}) =>
+    expect('$actual', '$expected', reason: reason);
+
+List<ListChangeRecord> getListChangeRecords(
+        List<ListChangeRecord> changes, int index) =>
+    new List.from(changes.where((ListChangeRecord c) => c.indexChanged(index)));
+
+List<PropertyChangeRecord> getPropertyChangeRecords(
+        List<ChangeRecord> changes, Symbol property) =>
+    new List.from(changes.where(
+        (ChangeRecord c) => c is PropertyChangeRecord && c.name == property));