Add MapDiffer, cleanup MapChangeRecord (#8)

* Fix bug in list_differ

* Add MapDiffer and refactor MapChangeRecord
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 08d659b..398c7c4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,10 @@
+## 0.16.0
+
+* Refactored `MapChangeRecord`
+    * Added equality and hashCode checks
+    * Added `MapChangeRecord.apply` to apply a change record
+* Added `MapDiffer`, which implements `Differ` for a `Map`
+
 ## 0.15.0+2
 
 * Fix a bug in `ListDiffer` that caused a `RangeError`
diff --git a/lib/observable.dart b/lib/observable.dart
index 179a938..32a6560 100644
--- a/lib/observable.dart
+++ b/lib/observable.dart
@@ -4,8 +4,8 @@
 
 library observable;
 
-export 'src/differs.dart' show Differ, EqualityDiffer, ListDiffer;
-export 'src/records.dart' show ChangeRecord, ListChangeRecord;
+export 'src/differs.dart' show Differ, EqualityDiffer, ListDiffer, MapDiffer;
+export 'src/records.dart' show ChangeRecord, ListChangeRecord, MapChangeRecord;
 export 'src/observable.dart';
 export 'src/observable_list.dart';
 export 'src/observable_map.dart';
diff --git a/lib/src/differs.dart b/lib/src/differs.dart
index 41f8a89..8d155ed 100644
--- a/lib/src/differs.dart
+++ b/lib/src/differs.dart
@@ -10,14 +10,17 @@
 
 import 'records.dart';
 
+import 'internal.dart';
+
 part 'differs/list_differ.dart';
+part 'differs/map_differ.dart';
 
 /// Generic comparisons between two comparable objects.
 abstract class Differ<E> {
   /// Returns a list of change records between [e1] and [e2].
   ///
   /// A return value of an empty [ChangeRecord.NONE] means no changes found.
-  List<ChangeRecord> diff(E e1, E e2);
+  List<ChangeRecord> diff(E oldValue, E newValue);
 }
 
 /// Uses [Equality] to determine a simple [ChangeRecord.ANY] response.
@@ -29,7 +32,9 @@
   const EqualityDiffer.identity() : this._equality = const IdentityEquality();
 
   @override
-  List<ChangeRecord> diff(E e1, E e2) {
-    return _equality.equals(e1, e2) ? ChangeRecord.NONE : ChangeRecord.ANY;
+  List<ChangeRecord> diff(E oldValue, E newValue) {
+    return _equality.equals(oldValue, newValue)
+        ? ChangeRecord.NONE
+        : ChangeRecord.ANY;
   }
 }
diff --git a/lib/src/differs/map_differ.dart b/lib/src/differs/map_differ.dart
new file mode 100644
index 0000000..c6041a4
--- /dev/null
+++ b/lib/src/differs/map_differ.dart
@@ -0,0 +1,38 @@
+// 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.
+
+part of observable.src.differs;
+
+/// Determines differences between two maps, returning [MapChangeRecord]s.
+///
+/// While [MapChangeRecord] has more information and can be replayed they carry
+/// a more significant cost to calculate and create and should only be used when
+/// the details in the record will actually be used.
+///
+/// See also [EqualityDiffer] for a simpler comparison.
+class MapDiffer<K, V> implements Differ<Map<K, V>> {
+  const MapDiffer();
+
+  @override
+  List<MapChangeRecord<K, V>> diff(Map<K, V> oldValue, Map<K, V> newValue) {
+    if (identical(oldValue, newValue)) {
+      return ChangeRecord.NONE;
+    }
+    final changes = <MapChangeRecord<K, V>>[];
+    oldValue.forEach((oldK, oldV) {
+      final newV = newValue[oldK];
+      if (newV == null && !newValue.containsKey(oldK)) {
+        changes.add(new MapChangeRecord<K, V>.remove(oldK, oldV));
+      } else if (newV != oldV) {
+        changes.add(new MapChangeRecord<K, V>(oldK, oldV, newV));
+      }
+    });
+    newValue.forEach((newK, newV) {
+      if (!oldValue.containsKey(newK)) {
+        changes.add(new MapChangeRecord<K, V>.insert(newK, newV));
+      }
+    });
+    return freezeInDevMode(changes);
+  }
+}
diff --git a/lib/src/internal.dart b/lib/src/internal.dart
new file mode 100644
index 0000000..d0ae2e1
--- /dev/null
+++ b/lib/src/internal.dart
@@ -0,0 +1,12 @@
+// 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.
+
+List/*<E>*/ freezeInDevMode/*<E>*/(List/*<E>*/ list) {
+  if (list == null) return const [];
+  assert(() {
+    list = new List/*<E>*/ .unmodifiable(list);
+    return true;
+  });
+  return list;
+}
diff --git a/lib/src/observable.dart b/lib/src/observable.dart
index b76b13f..8a90e09 100644
--- a/lib/src/observable.dart
+++ b/lib/src/observable.dart
@@ -9,7 +9,6 @@
 
 import 'package:meta/meta.dart';
 
-import 'observable_map.dart';
 import 'property_change_record.dart' show PropertyChangeRecord;
 import 'records.dart' show ChangeRecord;
 
diff --git a/lib/src/observable_map.dart b/lib/src/observable_map.dart
index c1e2c90..a99610c 100644
--- a/lib/src/observable_map.dart
+++ b/lib/src/observable_map.dart
@@ -8,7 +8,7 @@
 
 import 'observable.dart' show Observable;
 import 'property_change_record.dart' show PropertyChangeRecord;
-import 'records.dart' show ChangeRecord;
+import 'records.dart' show MapChangeRecord;
 import 'to_observable.dart';
 
 // TODO(jmesserly): this needs to be faster. We currently require multiple
@@ -17,47 +17,6 @@
 // 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;
-
-  @override
-  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.
diff --git a/lib/src/records.dart b/lib/src/records.dart
index a04ae9e..07a9826 100644
--- a/lib/src/records.dart
+++ b/lib/src/records.dart
@@ -7,7 +7,10 @@
 import 'package:collection/collection.dart';
 import 'package:quiver/core.dart';
 
+import 'internal.dart';
+
 part 'records/list_change_record.dart';
+part 'records/map_change_record.dart';
 
 /// Result of a change to an observed object.
 class ChangeRecord {
diff --git a/lib/src/records/list_change_record.dart b/lib/src/records/list_change_record.dart
index aeb77d6..59867b2 100644
--- a/lib/src/records/list_change_record.dart
+++ b/lib/src/records/list_change_record.dart
@@ -4,15 +4,6 @@
 
 part of observable.src.records;
 
-List/*<E>*/ _freezeInDevMode/*<E>*/(List/*<E>*/ list) {
-  if (list == null) return const [];
-  assert(() {
-    list = new List/*<E>*/ .unmodifiable(list);
-    return true;
-  });
-  return list;
-}
-
 /// A [ChangeRecord] that denotes adding or removing nodes at [index].
 ///
 /// It should be assumed that elements are [removed] *before* being added.
@@ -50,7 +41,7 @@
 
   /// Records a `remove` operation at `object[index]` of [removed] elements.
   ListChangeRecord.remove(this.object, this.index, List<E> removed)
-      : this.removed = _freezeInDevMode/*<E>*/(removed),
+      : this.removed = freezeInDevMode/*<E>*/(removed),
         this.addedCount = 0 {
     _assertValidState();
   }
@@ -60,7 +51,7 @@
   /// If [addedCount] is not specified it defaults to `removed.length`.
   ListChangeRecord.replace(this.object, this.index, List<E> removed,
       [int addedCount])
-      : this.removed = _freezeInDevMode/*<E>*/(removed),
+      : this.removed = freezeInDevMode/*<E>*/(removed),
         this.addedCount = addedCount ?? removed.length {
     _assertValidState();
   }
diff --git a/lib/src/records/map_change_record.dart b/lib/src/records/map_change_record.dart
new file mode 100644
index 0000000..9150732
--- /dev/null
+++ b/lib/src/records/map_change_record.dart
@@ -0,0 +1,82 @@
+// 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.
+
+part of observable.src.records;
+
+/// A [ChangeRecord] that denotes adding, removing, or updating a map.
+class MapChangeRecord<K, V> implements ChangeRecord {
+  /// The map key that changed.
+  final K key;
+
+  /// The previous value associated with this key.
+  ///
+  /// Is always `null` if [isInsert].
+  final V oldValue;
+
+  /// The new value associated with this key.
+  ///
+  /// Is always `null` if [isRemove].
+  final V newValue;
+
+  /// True if this key was inserted.
+  final bool isInsert;
+
+  /// True if this key was removed.
+  final bool isRemove;
+
+  /// Create an update record of [key] from [oldValue] to [newValue].
+  const MapChangeRecord(this.key, this.oldValue, this.newValue)
+      : isInsert = false,
+        isRemove = false;
+
+  /// Create an insert record of [key] and [newValue].
+  const MapChangeRecord.insert(this.key, this.newValue)
+      : isInsert = true,
+        isRemove = false,
+        oldValue = null;
+
+  /// Create a remove record of [key] with a former [oldValue].
+  const MapChangeRecord.remove(this.key, this.oldValue)
+      : isInsert = false,
+        isRemove = true,
+        newValue = null;
+
+  /// Apply this change record to [map].
+  void apply(Map<K, V> map) {
+    if (isRemove) {
+      map.remove(key);
+    } else {
+      map[key] = newValue;
+    }
+  }
+
+  @override
+  bool operator ==(Object o) {
+    if (o is MapChangeRecord<K, V>) {
+      return key == o.key &&
+             oldValue == o.oldValue &&
+             newValue == o.newValue &&
+             isInsert == o.isInsert &&
+             isRemove == o.isRemove;
+    }
+    return false;
+  }
+
+  @override
+  int get hashCode {
+    return hashObjects([
+      key,
+      oldValue,
+      newValue,
+      isInsert,
+      isRemove,
+    ]);
+  }
+
+  @override
+  String toString() {
+    final kind = isInsert ? 'insert' : isRemove ? 'remove' : 'set';
+    return '#<MapChangeRecord $kind $key from $oldValue to $newValue';
+  }
+}
diff --git a/pubspec.yaml b/pubspec.yaml
index 7e3e9b1..a5451f0 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,5 +1,5 @@
 name: observable
-version: 0.15.0+2
+version: 0.16.0
 author: Dart Team <misc@dartlang.org>
 description: Support for marking objects as observable
 homepage: https://github.com/dart-lang/observable
diff --git a/test/map_differ_test.dart b/test/map_differ_test.dart
new file mode 100644
index 0000000..b6a11c8
--- /dev/null
+++ b/test/map_differ_test.dart
@@ -0,0 +1,115 @@
+import 'package:observable/observable.dart';
+import 'package:test/test.dart';
+
+main() {
+  group('$MapDiffer', () {
+    final diff = const MapDiffer<String, String>().diff;
+
+    test('should emit no changes for identical maps', () {
+      final map = new Map<String, String>.fromIterable(
+        new Iterable.generate(10, (i) => '$i'),
+      );
+      expect(diff(map, map), ChangeRecord.NONE);
+    });
+
+    test('should emit no changes for maps with identical content', () {
+      final map1 = new Map<String, String>.fromIterable(
+        new Iterable.generate(10, (i) => '$i'),
+      );
+      final map2 = new Map<String, String>.fromIterable(
+        new Iterable.generate(10, (i) => '$i'),
+      );
+      expect(diff(map1, map2), ChangeRecord.NONE);
+    });
+
+    test('should detect insertions', () {
+      expect(
+        diff({
+          'key-a': 'value-a',
+          'key-b': 'value-b',
+        }, {
+          'key-a': 'value-a',
+          'key-b': 'value-b',
+          'key-c': 'value-c',
+        }),
+        [
+          new MapChangeRecord.insert('key-c', 'value-c'),
+        ],
+      );
+    });
+
+    test('should detect removals', () {
+      expect(
+        diff({
+          'key-a': 'value-a',
+          'key-b': 'value-b',
+          'key-c': 'value-c',
+        }, {
+          'key-a': 'value-a',
+          'key-b': 'value-b',
+        }),
+        [
+          new MapChangeRecord.remove('key-c', 'value-c'),
+        ],
+      );
+    });
+
+    test('should detect updates', () {
+      expect(
+        diff({
+          'key-a': 'value-a',
+          'key-b': 'value-b-old',
+        }, {
+          'key-a': 'value-a',
+          'key-b': 'value-b-new',
+        }),
+        [
+          new MapChangeRecord('key-b', 'value-b-old', 'value-b-new'),
+        ],
+      );
+    });
+  });
+
+  group('$MapChangeRecord', () {
+    test('should reply an insertion', () {
+      final map1 = {
+        'key-a': 'value-a',
+        'key-b': 'value-b',
+      };
+      final map2 = {
+        'key-a': 'value-a',
+        'key-b': 'value-b',
+        'key-c': 'value-c',
+      };
+      new MapChangeRecord.insert('key-c', 'value-c').apply(map1);
+      expect(map1, map2);
+    });
+
+    test('should replay a removal', () {
+      final map1 = {
+        'key-a': 'value-a',
+        'key-b': 'value-b',
+        'key-c': 'value-c',
+      };
+      final map2 = {
+        'key-a': 'value-a',
+        'key-b': 'value-b',
+      };
+      new MapChangeRecord.remove('key-c', 'value-c').apply(map1);
+      expect(map1, map2);
+    });
+
+    test('should replay an update', () {
+      final map1 = {
+        'key-a': 'value-a',
+        'key-b': 'value-b-old',
+      };
+      final map2 = {
+        'key-a': 'value-a',
+        'key-b': 'value-b-new',
+      };
+      new MapChangeRecord('key-b', 'value-b-old', 'value-b-new').apply(map1);
+      expect(map1, map2);
+    });
+  });
+}