├── .analysis_options ├── .gitignore ├── .test_config ├── AUTHORS ├── CHANGELOG.md ├── LICENSE ├── PATENTS ├── README.md ├── codereview.settings ├── lib ├── html.dart ├── mirrors_used.dart ├── observe.dart ├── src │ ├── auto_observable.dart │ ├── bind_property.dart │ ├── bindable.dart │ ├── dirty_check.dart │ ├── list_path_observer.dart │ ├── messages.dart │ ├── metadata.dart │ ├── observable_box.dart │ ├── observer_transform.dart │ └── path_observer.dart └── transformer.dart ├── pubspec.yaml └── test ├── list_change_test.dart ├── list_path_observer_test.dart ├── observe_test.dart ├── observe_test_utils.dart ├── path_observer_test.dart ├── transformer_test.dart └── unique_message_test.dart /.analysis_options: -------------------------------------------------------------------------------- 1 | analyzer: 2 | strong-mode: true 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Don’t commit the following directories created by pub. 2 | .pub 3 | build/ 4 | packages 5 | .packages 6 | 7 | # Or the files created by dart2js. 8 | *.dart.js 9 | *.dart.precompiled.js 10 | *.js_ 11 | *.js.deps 12 | *.js.map 13 | *.sw? 14 | .idea/ 15 | .pub/ 16 | 17 | # Include when developing application packages. 18 | pubspec.lock 19 | -------------------------------------------------------------------------------- /.test_config: -------------------------------------------------------------------------------- 1 | { 2 | "test_package": { 3 | "barback": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | # Names should be added to this file with this pattern: 2 | # 3 | # For individuals: 4 | # Name 5 | # 6 | # For organizations: 7 | # Organization 8 | # 9 | Google Inc. <*@google.com> 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | #### 0.15.2 2 | 3 | * Update to use strong-mode clean Zone API. 4 | 5 | #### 0.15.1 6 | 7 | * Update to use new analyzer API 8 | 9 | #### 0.15.0 10 | 11 | * update to use package:observable 0.17.0 12 | * cleanup and format 13 | 14 | #### 0.14.0 15 | 16 | * Update to be built on top of `package:observable`. Contains the following 17 | breaking changes: 18 | - `Observable` now lives in `package:observable` and behaves like the old 19 | `ChangeNotifier` did (except that it's now the base class) - with subclasses 20 | manually notifying listeners of changes via `notifyPropertyChange()`. 21 | - `ChangeNotifier` has been removed. 22 | - `ObservableList` has been moved to `package:observable`. 23 | - `ObservableMap` has been moved to `package:observable`. 24 | - `toObservable()` has been moved to `package:observable`. 25 | - `Observable` (the one with dirty checking) in `package:observe` has been 26 | renamed `AutoObservable` 27 | 28 | #### 0.13.5 29 | 30 | * Fixed strong mode errors and warnings 31 | 32 | #### 0.13.4 33 | 34 | * Fixed strong mode errors and warnings 35 | 36 | #### 0.13.3+1 37 | 38 | * Add support for code_transformers `0.4.x`. 39 | 40 | #### 0.13.3 41 | 42 | * Update to the `test` package. 43 | 44 | #### 0.13.2 45 | 46 | * Update to analyzer '^0.27.0'. 47 | 48 | #### 0.13.1+3 49 | 50 | * Sorting an already sorted list will no longer yield new change notifications. 51 | 52 | #### 0.13.1+2 53 | 54 | * Update to analyzer '<0.27.0' 55 | 56 | #### 0.13.1+1 57 | 58 | * Update to logging `<0.12.0`. 59 | 60 | #### 0.13.1 61 | 62 | * Update to analyzer `<0.26.0`. 63 | 64 | #### 0.13.0+2 65 | * Fixed `close` in `PathObserver` so it doesn't leak observers. 66 | * Ported the benchmarks from 67 | [observe-js](https://github.com/Polymer/observe-js/tree/master/benchmark). 68 | 69 | #### 0.13.0+1 70 | * Widen the constraint on analyzer. 71 | 72 | #### 0.13.0 73 | * Don't output log files by default in release mode, and provide option to 74 | turn them off entirely. 75 | * Changed the api for the ObserveTransformer to use named arguments. 76 | 77 | #### 0.12.2+1 78 | * Cleanup some method signatures. 79 | 80 | #### 0.12.2 81 | * Updated to match release 0.5.1 82 | [observe-js#d530515](https://github.com/Polymer/observe-js/commit/d530515). 83 | 84 | #### 0.12.1+1 85 | * Expand stack_trace version constraint. 86 | 87 | #### 0.12.1 88 | * Upgraded error messages to have a unique and stable identifier. 89 | 90 | #### 0.12.0 91 | * Old transform.dart file removed. If you weren't use it it, this change is 92 | backwards compatible with version 0.11.0. 93 | 94 | #### 0.11.0+5 95 | * Widen the constraint on analyzer. 96 | 97 | #### 0.11.0+4 98 | * Raise the lower bound on the source_maps constraint to exclude incompatible 99 | versions. 100 | 101 | #### 0.11.0+3 102 | * Widen the constraint on source_maps. 103 | 104 | #### 0.11.0+2 105 | * Widen the constraint on barback. 106 | 107 | #### 0.11.0+1 108 | * Switch from `source_maps`' `Span` class to `source_span`'s `SourceSpan` 109 | class. 110 | 111 | #### 0.11.0 112 | * Updated to match [observe-js#e212e74][e212e74] (release 0.3.4), which also 113 | matches [observe-js#fa70c37][fa70c37] (release 0.4.2). 114 | * ListPathObserver has been deprecated (it was deleted a while ago in 115 | observe-js). We plan to delete it in a future release. You may copy the code 116 | if you still need it. 117 | * PropertyPath now uses an expression syntax including indexers. For example, 118 | you can write `a.b["m"]` instead of `a.b.m`. 119 | * **breaking change**: PropertyPath no longer allows numbers as fields, you 120 | need to use indexers instead. For example, you now need to write `a[3].d` 121 | instead of `a.3.d`. 122 | * **breaking change**: PathObserver.value= no longer discards changes (this is 123 | in combination with a change in template_binding and polymer to improve 124 | interop with JS custom elements). 125 | 126 | #### 0.10.0+3 127 | * minor changes to documentation, deprecated `discardListChages` in favor of 128 | `discardListChanges` (the former had a typo). 129 | 130 | #### 0.10.0 131 | * package:observe no longer declares @MirrorsUsed. The package uses mirrors 132 | for development time, but assumes frameworks (like polymer) and apps that 133 | use it directly will either generate code that replaces the use of mirrors, 134 | or add the @MirrorsUsed declaration themselves. For convinience, you can 135 | import 'package:observe/mirrors_used.dart', and that will add a @MirrorsUsed 136 | annotation that preserves properties and classes labeled with @reflectable 137 | and properties labeled with @observable. 138 | * Updated to match [observe-js#0152d54][0152d54] 139 | 140 | [fa70c37]: https://github.com/Polymer/observe-js/blob/fa70c37099026225876f7c7a26bdee7c48129f1c/src/observe.js 141 | [0152d54]: https://github.com/Polymer/observe-js/blob/0152d542350239563d0f2cad39d22d3254bd6c2a/src/observe.js 142 | [e212e74]: https://github.com/Polymer/observe-js/blob/e212e7473962067c099a3d1859595c2f8baa36d7/src/observe.js 143 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2013, the Dart project authors. All rights reserved. 2 | Redistribution and use in source and binary forms, with or without 3 | modification, are permitted provided that the following conditions are 4 | met: 5 | 6 | * Redistributions of source code must retain the above copyright 7 | notice, this list of conditions and the following disclaimer. 8 | * Redistributions in binary form must reproduce the above 9 | copyright notice, this list of conditions and the following 10 | disclaimer in the documentation and/or other materials provided 11 | with the distribution. 12 | * Neither the name of Google Inc. nor the names of its 13 | contributors may be used to endorse or promote products derived 14 | from this software without specific prior written permission. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 17 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 18 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 19 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 20 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 21 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 22 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 23 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 24 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 25 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 26 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 27 | -------------------------------------------------------------------------------- /PATENTS: -------------------------------------------------------------------------------- 1 | Additional IP Rights Grant (Patents) 2 | 3 | "This implementation" means the copyrightable works distributed by 4 | Google as part of the Dart Project. 5 | 6 | Google hereby grants to you a perpetual, worldwide, non-exclusive, 7 | no-charge, royalty-free, irrevocable (except as stated in this 8 | section) patent license to make, have made, use, offer to sell, sell, 9 | import, transfer, and otherwise run, modify and propagate the contents 10 | of this implementation of Dart, where such license applies only to 11 | those patent claims, both currently owned by Google and acquired in 12 | the future, licensable by Google that are necessarily infringed by 13 | this implementation of Dart. This grant does not include claims that 14 | would be infringed only as a consequence of further modification of 15 | this implementation. If you or your agent or exclusive licensee 16 | institute or order or agree to the institution of patent litigation 17 | against any entity (including a cross-claim or counterclaim in a 18 | lawsuit) alleging that this implementation of Dart or any code 19 | incorporated within this implementation of Dart constitutes direct or 20 | contributory patent infringement, or inducement of patent 21 | infringement, then any patent rights granted to you under this License 22 | for this implementation of Dart shall terminate as of the date such 23 | litigation is filed. 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # observe 2 | 3 | Support for marking objects as observable, and getting notifications when those 4 | objects are mutated. 5 | 6 | This library is used to observe changes to [AutoObservable][] types. It also 7 | has helpers to make implementing and using [Observable][] objects easy. 8 | 9 | You can provide an observable object in two ways. The simplest way is to 10 | use dirty checking to discover changes automatically: 11 | 12 | ```dart 13 | import 'package:observe/observe.dart'; 14 | import 'package:observe/mirrors_used.dart'; // for smaller code 15 | 16 | class Monster extends Unit with AutoObservable { 17 | @observable int health = 100; 18 | 19 | void damage(int amount) { 20 | print('$this takes $amount damage!'); 21 | health -= amount; 22 | } 23 | 24 | toString() => 'Monster with $health hit points'; 25 | } 26 | 27 | main() { 28 | var obj = new Monster(); 29 | obj.changes.listen((records) { 30 | print('Changes to $obj were: $records'); 31 | }); 32 | // No changes are delivered until we check for them 33 | obj.damage(10); 34 | obj.damage(20); 35 | print('dirty checking!'); 36 | Observable.dirtyCheck(); 37 | print('done!'); 38 | } 39 | ``` 40 | 41 | **Note**: by default this package uses mirrors to access getters and setters 42 | marked with `@reflectable`. Dart2js disables tree-shaking if there are any 43 | uses of mirrors, unless you declare how mirrors are used (via the 44 | [MirrorsUsed](https://api.dartlang.org/apidocs/channels/stable/#dart-mirrors.MirrorsUsed) 45 | annotation). 46 | 47 | As of version 0.10.0, this package doesn't declare `@MirrorsUsed`. This is 48 | because we intend to use mirrors for development time, but assume that 49 | frameworks and apps that use this pacakge will either generate code that 50 | replaces the use of mirrors, or add the `@MirrorsUsed` declaration 51 | themselves. For convenience, you can import 52 | `package:observe/mirrors_used.dart` as shown on the first example above. 53 | That will add a `@MirrorsUsed` annotation that preserves properties and 54 | classes labeled with `@reflectable` and properties labeled with 55 | `@observable`. 56 | 57 | If you are using the `package:observe/mirrors_used.dart` import, you can 58 | also make use of `@reflectable` on your own classes and dart2js will 59 | preserve all of its members for reflection. 60 | 61 | [Tools](https://www.dartlang.org/polymer-dart/) exist to convert the first 62 | form into the second form automatically, to get the best of both worlds. 63 | 64 | [AutoObservable]: http://www.dartdocs.org/documentation/observe/latest/index.html#observe/observe.AutoObservable 65 | [AutoObservable.dirtyCheck]: http://www.dartdocs.org/documentation/observe/latest/index.html#observe/observe.AutoObservable@id_dirtyCheck 66 | -------------------------------------------------------------------------------- /codereview.settings: -------------------------------------------------------------------------------- 1 | CODE_REVIEW_SERVER: http://codereview.chromium.org/ 2 | VIEW_VC: https://github.com/dart-lang/observe/commit/ 3 | CC_LIST: reviews@dartlang.org 4 | -------------------------------------------------------------------------------- /lib/html.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file 2 | // for details. All rights reserved. Use of this source code is governed by a 3 | // BSD-style license that can be found in the LICENSE file. 4 | 5 | // TODO(jmesserly): can we handle this more elegantly? 6 | // In general, it seems like we want a convenient way to take a Stream plus a 7 | // getter and convert this into an AutoObservable. 8 | 9 | /// Helpers for exposing dart:html as observable data. 10 | @Deprecated('Parts of Observe used to support Polymer will move out of library') 11 | library observe.html; 12 | 13 | import 'dart:html'; 14 | 15 | import 'package:observable/observable.dart'; 16 | 17 | import 'observe.dart'; 18 | 19 | /// An observable version of [window.location.hash]. 20 | final ObservableLocationHash windowLocation = new ObservableLocationHash._(); 21 | 22 | class ObservableLocationHash extends PropertyChangeNotifier { 23 | Object _currentHash; 24 | 25 | ObservableLocationHash._() { 26 | // listen on changes to #hash in the URL 27 | // Note: listen on both popState and hashChange, because IE9 doesn't support 28 | // history API. See http://dartbug.com/5483 29 | // TODO(jmesserly): only listen to these if someone is listening to our 30 | // changes. 31 | window.onHashChange.listen(_notifyHashChange); 32 | window.onPopState.listen(_notifyHashChange); 33 | 34 | _currentHash = hash; 35 | } 36 | 37 | @reflectable 38 | String get hash => window.location.hash; 39 | 40 | /// Pushes a new URL state, similar to the affect of clicking a link. 41 | /// Has no effect if the [value] already equals [window.location.hash]. 42 | @reflectable 43 | void set hash(String value) { 44 | if (value == hash) return; 45 | 46 | window.history.pushState(null, '', value); 47 | _notifyHashChange(null); 48 | } 49 | 50 | void _notifyHashChange(Event _) { 51 | var oldValue = _currentHash; 52 | _currentHash = hash; 53 | notifyPropertyChange(#hash, oldValue, _currentHash); 54 | } 55 | } 56 | 57 | /// *Deprecated* use [CssClassSet.toggle] instead. 58 | /// 59 | /// Add or remove CSS class [className] based on the [value]. 60 | @deprecated 61 | void updateCssClass(Element element, String className, bool value) { 62 | if (value == true) { 63 | element.classes.add(className); 64 | } else { 65 | element.classes.remove(className); 66 | } 67 | } 68 | 69 | /// *Deprecated* use `class="{{ binding }}"` in your HTML instead. It will also 70 | /// work on a ``. 71 | /// 72 | /// Bind a CSS class to the observable [object] and property [path]. 73 | @deprecated 74 | PathObserver bindCssClass( 75 | Element element, String className, AutoObservable object, String path) { 76 | callback(value) { 77 | updateCssClass(element, className, value); 78 | } 79 | 80 | var obs = new PathObserver(object, path); 81 | callback(obs.open(callback)); 82 | return obs; 83 | } 84 | -------------------------------------------------------------------------------- /lib/mirrors_used.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file 2 | // for details. All rights reserved. Use of this source code is governed by a 3 | // BSD-style license that can be found in the LICENSE file. 4 | 5 | /// An empty library that declares what needs to be retained with [MirrorsUsed] 6 | /// if you were to use observables together with mirrors. By default this is not 7 | /// included because frameworks using this package also use code generation to 8 | /// avoid using mirrors at deploy time. 9 | @Deprecated('Parts of Observe used to support Polymer will move out of library') 10 | library observe.mirrors_used; 11 | 12 | // Note: ObservableProperty is in this list only for the unusual use case of 13 | // invoking dart2js without running this package's transformers. The 14 | // transformer in `lib/transformer.dart` will replace @observable with the 15 | // @reflectable annotation. 16 | @MirrorsUsed( 17 | metaTargets: const [Reflectable, ObservableProperty], 18 | override: 'smoke.mirrors') 19 | import 'dart:mirrors' show MirrorsUsed; 20 | import 'package:observe/observe.dart' show Reflectable, ObservableProperty; 21 | -------------------------------------------------------------------------------- /lib/observe.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file 2 | // for details. All rights reserved. Use of this source code is governed by a 3 | // BSD-style license that can be found in the LICENSE file. 4 | 5 | library observe; 6 | 7 | // This library contains code ported from observe-js: 8 | // https://github.com/Polymer/observe-js/blob/0152d542350239563d0f2cad39d22d3254bd6c2a/src/observe.js 9 | // We port what is needed for data bindings. Most of the functionality is 10 | // ported, except where differences are needed for Dart's AutoObservable type. 11 | 12 | export 'package:observable/observable.dart'; 13 | export 'src/auto_observable.dart'; 14 | export 'src/bindable.dart'; 15 | export 'src/bind_property.dart'; 16 | export 'src/list_path_observer.dart'; 17 | export 'src/metadata.dart'; 18 | export 'src/observable_box.dart'; 19 | export 'src/observer_transform.dart'; 20 | export 'src/path_observer.dart' hide getSegmentsOfPropertyPathForTesting; 21 | -------------------------------------------------------------------------------- /lib/src/auto_observable.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016, the Dart project authors. Please see the AUTHORS file 2 | // for details. All rights reserved. Use of this source code is governed by a 3 | // BSD-style license that can be found in the LICENSE file. 4 | 5 | library observe.src.auto_observable; 6 | 7 | import 'dart:async'; 8 | import 'dart:collection'; 9 | 10 | import 'package:observable/observable.dart'; 11 | import 'package:smoke/smoke.dart' as smoke; 12 | 13 | import 'dirty_check.dart' show dirtyCheckObservables, registerObservable; 14 | import 'metadata.dart' show ObservableProperty; 15 | 16 | abstract class AutoObservable implements ChangeNotifier { 17 | /// Performs dirty checking of objects that inherit from [AutoObservable]. 18 | /// This scans all observed objects using mirrors and determines if any fields 19 | /// have changed. If they have, it delivers the changes for the object. 20 | static void dirtyCheck() => dirtyCheckObservables(); 21 | 22 | StreamController> _changes; 23 | 24 | Map _values; 25 | List _records; 26 | 27 | /// The stream of change records to this object. Records will be delivered 28 | /// asynchronously. 29 | /// 30 | /// [deliverChanges] can be called to force synchronous delivery. 31 | @override 32 | Stream> get changes { 33 | if (_changes == null) { 34 | _changes = new StreamController.broadcast( 35 | sync: true, onListen: observed, onCancel: unobserved); 36 | } 37 | return _changes.stream; 38 | } 39 | 40 | /// True if this object has any observers, and should call 41 | /// [notifyChange] for changes. 42 | @override 43 | bool get hasObservers => _changes != null && _changes.hasListener; 44 | 45 | @override 46 | void observed() { 47 | // Register this object for dirty checking purposes. 48 | registerObservable(this); 49 | 50 | var values = new Map(); 51 | 52 | // Note: we scan for @observable regardless of whether the base type 53 | // actually includes this mixin. While perhaps too inclusive, it lets us 54 | // avoid complex logic that walks "with" and "implements" clauses. 55 | var queryOptions = new smoke.QueryOptions( 56 | includeInherited: true, 57 | includeProperties: false, 58 | withAnnotations: const [ObservableProperty]); 59 | for (var decl in smoke.query(this.runtimeType, queryOptions)) { 60 | var name = decl.name; 61 | // Note: since this is a field, getting the value shouldn't execute 62 | // user code, so we don't need to worry about errors. 63 | values[name] = smoke.read(this, name); 64 | } 65 | 66 | _values = values; 67 | } 68 | 69 | /// Release data associated with observation. 70 | @override 71 | void unobserved() { 72 | // Note: we don't need to explicitly unregister from the dirty check list. 73 | // This will happen automatically at the next call to dirtyCheck. 74 | if (_values != null) { 75 | _values = null; 76 | } 77 | } 78 | 79 | /// Synchronously deliver pending [changes]. Returns true if any records were 80 | /// delivered, otherwise false. 81 | // TODO(jmesserly): this is a bit different from the ES Harmony version, which 82 | // allows delivery of changes to a particular observer: 83 | // http://wiki.ecmascript.org/doku.php?id=harmony:observe#object.deliverchangerecords 84 | // 85 | // The rationale for that, and for async delivery in general, is the principal 86 | // that you shouldn't run code (observers) when it doesn't expect to be run. 87 | // If you do that, you risk violating invariants that the code assumes. 88 | // 89 | // For this reason, we need to match the ES Harmony version. The way we can do 90 | // this in Dart is to add a method on StreamSubscription (possibly by 91 | // subclassing Stream* types) that immediately delivers records for only 92 | // that subscription. Alternatively, we could consider using something other 93 | // than Stream to deliver the multicast change records, and provide an 94 | // Observable->Stream adapter. 95 | // 96 | // Also: we should be delivering changes to the observer (subscription) based 97 | // on the birth order of the observer. This is for compatibility with ES 98 | // Harmony as well as predictability for app developers. 99 | @override 100 | bool deliverChanges() { 101 | if (_values == null || !hasObservers) return false; 102 | 103 | // Start with manually notified records (computed properties, etc), 104 | // then scan all fields for additional changes. 105 | var records = _records; 106 | _records = null; 107 | 108 | _values.forEach((name, oldValue) { 109 | var newValue = smoke.read(this, name); 110 | if (oldValue != newValue) { 111 | if (records == null) records = []; 112 | records.add(new PropertyChangeRecord(this, name, oldValue, newValue)); 113 | _values[name] = newValue; 114 | } 115 | }); 116 | 117 | if (records == null) return false; 118 | 119 | _changes.add(new UnmodifiableListView(records)); 120 | return true; 121 | } 122 | 123 | /// Notify that the field [name] of this object has been changed. 124 | /// 125 | /// The [oldValue] and [newValue] are also recorded. If the two values are 126 | /// equal, no change will be recorded. 127 | /// 128 | /// For convenience this returns [newValue]. 129 | @override 130 | /*=T*/ notifyPropertyChange/**/( 131 | Symbol field, /*=T*/ oldValue, /*=T*/ newValue) { 132 | if (hasObservers && oldValue != newValue) { 133 | notifyChange(new PropertyChangeRecord(this, field, oldValue, newValue)); 134 | } 135 | return newValue; 136 | } 137 | 138 | /// Notify observers of a change. 139 | /// 140 | /// For most objects [AutoObservable.notifyPropertyChange] is more 141 | /// convenient, but collections sometimes deliver other types of changes 142 | /// such as a [ListChangeRecord]. 143 | /// 144 | /// Notes: 145 | /// - This is *not* required for fields if you mixin or extend 146 | /// [AutoObservable], but you can use it for computed properties. 147 | /// - Unlike [Observable] this will not schedule [deliverChanges]; use 148 | /// [AutoObservable.dirtyCheck] instead. 149 | @override 150 | void notifyChange([ChangeRecord record]) { 151 | if (record == null || !hasObservers) return; 152 | if (_records == null) _records = []; 153 | _records.add(record); 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /lib/src/bind_property.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016, the Dart project authors. Please see the AUTHORS file 2 | // for details. All rights reserved. Use of this source code is governed by a 3 | // BSD-style license that can be found in the LICENSE file. 4 | 5 | library observe.src.bind_property; 6 | 7 | import 'dart:async'; 8 | import 'package:observable/observable.dart' as obs; 9 | 10 | /// Forwards an observable property from one object to another. For example: 11 | /// 12 | /// class MyModel extends AutoObservable { 13 | /// StreamSubscription _sub; 14 | /// MyOtherModel _otherModel; 15 | /// 16 | /// MyModel() { 17 | /// ... 18 | /// _sub = onPropertyChange(_otherModel, #value, 19 | /// () => notifyPropertyChange(#prop, oldValue, newValue); 20 | /// } 21 | /// 22 | /// String get prop => _otherModel.value; 23 | /// set prop(String value) { _otherModel.value = value; } 24 | /// } 25 | /// 26 | /// See also [notifyPropertyChange]. 27 | // TODO(jmesserly): make this an instance method? 28 | StreamSubscription onPropertyChange( 29 | obs.Observable source, Symbol sourceName, void callback()) { 30 | return source.changes.listen((records) { 31 | for (var record in records) { 32 | if (record is obs.PropertyChangeRecord && record.name == sourceName) { 33 | callback(); 34 | break; 35 | } 36 | } 37 | }); 38 | } 39 | -------------------------------------------------------------------------------- /lib/src/bindable.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file 2 | // for details. All rights reserved. Use of this source code is governed by a 3 | // BSD-style license that can be found in the LICENSE file. 4 | 5 | library observe.src.bindable; 6 | 7 | /// An object that can be data bound. 8 | // Normally this is used with 'package:template_binding'. 9 | // TODO(jmesserly): Node.bind polyfill calls this "observable" 10 | abstract class Bindable { 11 | // Dart note: changed setValue to be "set value" and discardChanges() to 12 | // be "get value". 13 | 14 | /// Initiates observation and returns the initial value. 15 | /// The callback will be called with the updated [value]. 16 | /// 17 | /// Some subtypes may chose to provide additional arguments, such as 18 | /// [PathObserver] providing the old value as the second argument. 19 | /// However, they must support callbacks with as few as 0 or 1 argument. 20 | /// This can be implemented by performing an "is" type test on the callback. 21 | open(callback); 22 | 23 | /// Stops future notifications and frees the reference to the callback passed 24 | /// to [open], so its memory can be collected even if this Bindable is alive. 25 | void close(); 26 | 27 | /// Gets the current value of the bindings. 28 | /// Note: once the value of a [Bindable] is fetched, the callback passed to 29 | /// [open] should not be called again with this new value. 30 | /// In other words, any pending change notifications must be discarded. 31 | // TODO(jmesserly): I don't like a getter with side effects. Should we just 32 | // rename the getter/setter pair to discardChanges/setValue like they are in 33 | // JavaScript? 34 | get value; 35 | 36 | /// This can be implemented for two-way bindings. By default does nothing. 37 | set value(newValue) {} 38 | 39 | /// Deliver changes. Typically this will perform dirty-checking, if any is 40 | /// needed. 41 | void deliver() {} 42 | } 43 | -------------------------------------------------------------------------------- /lib/src/dirty_check.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file 2 | // for details. All rights reserved. Use of this source code is governed by a 3 | // BSD-style license that can be found in the LICENSE file. 4 | 5 | /// *Warning*: this library is **internal**, and APIs are subject to change. 6 | /// 7 | /// Tracks observable objects for dirty checking and testing purposes. 8 | /// 9 | /// It can collect all observed objects, which can be used to trigger 10 | /// predictable delivery of all pending changes in a test, including objects 11 | /// allocated internally to another library, such as those in 12 | /// `package:template_binding`. 13 | library observe.src.dirty_check; 14 | 15 | import 'dart:async'; 16 | 17 | import 'package:func/func.dart'; 18 | import 'package:logging/logging.dart'; 19 | import 'package:observable/observable.dart' as obs show Observable; 20 | 21 | import 'auto_observable.dart' show AutoObservable; 22 | 23 | /// The number of active observables in the system. 24 | int get allObservablesCount => _allObservablesCount; 25 | 26 | int _allObservablesCount = 0; 27 | 28 | List _allObservables = null; 29 | 30 | bool _delivering = false; 31 | 32 | void registerObservable(obs.Observable obj) { 33 | if (_allObservables == null) _allObservables = []; 34 | _allObservables.add(obj); 35 | _allObservablesCount++; 36 | } 37 | 38 | /// Synchronously deliver all change records for known observables. 39 | /// 40 | /// This will execute [AutoObservable.deliverChanges] on objects that inherit from 41 | /// [AutoObservable]. 42 | // Note: this is called performMicrotaskCheckpoint in change_summary.js. 43 | void dirtyCheckObservables() { 44 | if (_delivering) return; 45 | if (_allObservables == null) return; 46 | 47 | _delivering = true; 48 | 49 | int cycles = 0; 50 | bool anyChanged = false; 51 | List debugLoop = null; 52 | do { 53 | cycles++; 54 | if (cycles == MAX_DIRTY_CHECK_CYCLES) { 55 | debugLoop = []; 56 | } 57 | 58 | var toCheck = _allObservables; 59 | _allObservables = []; 60 | anyChanged = false; 61 | 62 | for (int i = 0; i < toCheck.length; i++) { 63 | final observer = toCheck[i]; 64 | if (observer.hasObservers) { 65 | if (observer.deliverChanges()) { 66 | anyChanged = true; 67 | if (debugLoop != null) debugLoop.add([i, observer]); 68 | } 69 | _allObservables.add(observer); 70 | } 71 | } 72 | } while (cycles < MAX_DIRTY_CHECK_CYCLES && anyChanged); 73 | 74 | if (debugLoop != null && anyChanged) { 75 | _logger.warning('Possible loop in AutoObservable.dirtyCheck, stopped ' 76 | 'checking.'); 77 | for (final info in debugLoop) { 78 | _logger.warning('In last iteration AutoObservable changed at index ' 79 | '${info[0]}, object: ${info[1]}.'); 80 | } 81 | } 82 | 83 | _allObservablesCount = _allObservables.length; 84 | _delivering = false; 85 | } 86 | 87 | const MAX_DIRTY_CHECK_CYCLES = 1000; 88 | 89 | /// Log for messages produced at runtime by this library. Logging can be 90 | /// configured by accessing Logger.root from the logging library. 91 | final Logger _logger = new Logger('AutoObservable.dirtyCheck'); 92 | 93 | /// Creates a [ZoneSpecification] to set up automatic dirty checking after each 94 | /// batch of async operations. This ensures that change notifications are always 95 | /// delivered. Typically used via [dirtyCheckZone]. 96 | ZoneSpecification dirtyCheckZoneSpec() { 97 | bool pending = false; 98 | 99 | enqueueDirtyCheck(ZoneDelegate parent, Zone zone) { 100 | // Only schedule one dirty check per microtask. 101 | if (pending) return; 102 | 103 | pending = true; 104 | parent.scheduleMicrotask(zone, () { 105 | pending = false; 106 | AutoObservable.dirtyCheck(); 107 | }); 108 | } 109 | 110 | ZoneCallback wrapCallback( 111 | Zone self, ZoneDelegate parent, Zone zone, R f()) { 112 | // TODO(jmesserly): why does this happen? 113 | if (f == null) return f; 114 | return () { 115 | enqueueDirtyCheck(parent, zone); 116 | return f(); 117 | }; 118 | } 119 | 120 | ZoneUnaryCallback wrapUnaryCallback( 121 | Zone self, ZoneDelegate parent, Zone zone, R f(T x)) { 122 | // TODO(jmesserly): why does this happen? 123 | if (f == null) return f; 124 | return (x) { 125 | enqueueDirtyCheck(parent, zone); 126 | return f(x); 127 | }; 128 | } 129 | 130 | return new ZoneSpecification( 131 | registerCallback: wrapCallback, registerUnaryCallback: wrapUnaryCallback); 132 | } 133 | 134 | /// Forks a [Zone] off the current one that does dirty-checking automatically 135 | /// after each batch of async operations. Equivalent to: 136 | /// 137 | /// Zone.current.fork(specification: dirtyCheckZoneSpec()); 138 | Zone dirtyCheckZone() => Zone.current.fork(specification: dirtyCheckZoneSpec()); 139 | -------------------------------------------------------------------------------- /lib/src/list_path_observer.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file 2 | // for details. All rights reserved. Use of this source code is governed by a 3 | // BSD-style license that can be found in the LICENSE file. 4 | 5 | library observe.src.list_path_observer; 6 | 7 | import 'dart:async'; 8 | import 'package:observable/observable.dart'; 9 | import 'package:observe/observe.dart'; 10 | 11 | // Inspired by ArrayReduction at: 12 | // https://raw.github.com/rafaelw/ChangeSummary/master/util/array_reduction.js 13 | // The main difference is we support anything on the rich Dart Iterable API. 14 | 15 | /// Observes a path starting from each item in the list. 16 | @deprecated 17 | class ListPathObserver extends PropertyChangeNotifier { 18 | final ObservableList list; 19 | final String _itemPath; 20 | final List _observers = []; 21 | StreamSubscription _sub; 22 | bool _scheduled = false; 23 | Iterable

_value; 24 | 25 | ListPathObserver(this.list, String path) : _itemPath = path { 26 | // TODO(jmesserly): delay observation until we are observed. 27 | _sub = list.listChanges.listen((records) { 28 | for (var record in records) { 29 | _observeItems(record.addedCount - record.removed.length); 30 | } 31 | _scheduleReduce(null); 32 | }); 33 | 34 | _observeItems(list.length); 35 | _reduce(); 36 | } 37 | 38 | @reflectable 39 | Iterable

get value => _value; 40 | 41 | void dispose() { 42 | if (_sub != null) _sub.cancel(); 43 | _observers.forEach((o) => o.close()); 44 | _observers.clear(); 45 | } 46 | 47 | void _reduce() { 48 | _scheduled = false; 49 | var newValue = _observers.map((o) => o.value as P); 50 | _value = notifyPropertyChange(#value, _value, newValue); 51 | } 52 | 53 | void _scheduleReduce(_) { 54 | if (_scheduled) return; 55 | _scheduled = true; 56 | scheduleMicrotask(_reduce); 57 | } 58 | 59 | void _observeItems(int lengthAdjust) { 60 | if (lengthAdjust > 0) { 61 | for (int i = 0; i < lengthAdjust; i++) { 62 | int len = _observers.length; 63 | var pathObs = new PathObserver(list, '[$len].$_itemPath'); 64 | pathObs.open(_scheduleReduce); 65 | _observers.add(pathObs); 66 | } 67 | } else if (lengthAdjust < 0) { 68 | for (int i = 0; i < -lengthAdjust; i++) { 69 | _observers.removeLast().close(); 70 | } 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /lib/src/messages.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file 2 | // for details. All rights reserved. Use of this source code is governed by a 3 | // BSD-style license that can be found in the LICENSE file. 4 | 5 | /// Contains all warning messages produced by the observe transformer. 6 | library observe.src.messages; 7 | 8 | import 'package:code_transformers/messages/messages.dart'; 9 | 10 | const NO_OBSERVABLE_ON_LIBRARY = const MessageTemplate( 11 | const MessageId('observe', 1), 12 | '@observable on a library no longer has any effect. ' 13 | 'Instead, annotate individual fields as @observable.', 14 | '`@observable` not supported on libraries', 15 | _COMMON_MESSAGE_WHERE_TO_USE_OBSERVABLE); 16 | 17 | const NO_OBSERVABLE_ON_TOP_LEVEL = const MessageTemplate( 18 | const MessageId('observe', 2), 19 | 'Top-level fields can no longer be observable. ' 20 | 'Observable fields must be in observable objects.', 21 | '`@observable` not supported on top-level fields', 22 | _COMMON_MESSAGE_WHERE_TO_USE_OBSERVABLE); 23 | 24 | const NO_OBSERVABLE_ON_CLASS = const MessageTemplate( 25 | const MessageId('observe', 3), 26 | '@observable on a class no longer has any effect. ' 27 | 'Instead, annotate individual fields as @observable.', 28 | '`@observable` not supported on classes', 29 | _COMMON_MESSAGE_WHERE_TO_USE_OBSERVABLE); 30 | 31 | const NO_OBSERVABLE_ON_STATIC_FIELD = const MessageTemplate( 32 | const MessageId('observe', 4), 33 | 'Static fields can no longer be observable. ' 34 | 'Observable fields must be in observable objects.', 35 | '`@observable` not supported on static fields', 36 | _COMMON_MESSAGE_WHERE_TO_USE_OBSERVABLE); 37 | 38 | const REQUIRE_OBSERVABLE_INTERFACE = const MessageTemplate( 39 | const MessageId('observe', 5), 40 | 'Observable fields must be in observable objects. ' 41 | 'Change this class to extend, mix in, or implement AutoObservable.', 42 | '`@observable` field not in an `AutoObservable` class', 43 | _COMMON_MESSAGE_WHERE_TO_USE_OBSERVABLE); 44 | 45 | const String _COMMON_MESSAGE_WHERE_TO_USE_OBSERVABLE = ''' 46 | Only instance fields on `Observable` classes can be observable, 47 | and you must explicitly annotate each observable field as `@observable`. 48 | 49 | Support for using the `@observable` annotation in libraries, classes, and 50 | elsewhere is deprecated. 51 | '''; 52 | -------------------------------------------------------------------------------- /lib/src/metadata.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file 2 | // for details. All rights reserved. Use of this source code is governed by a 3 | // BSD-style license that can be found in the LICENSE file. 4 | 5 | library observe.src.metadata; 6 | 7 | /// Use `@observable` to make a field automatically observable, or to indicate 8 | /// that a property is observable. This only works on classes that extend or 9 | /// mix in `AutoObservable`. 10 | const ObservableProperty observable = const ObservableProperty(); 11 | 12 | /// An annotation that is used to make a property observable. 13 | /// Normally this is used via the [observable] constant, for example: 14 | /// 15 | /// class Monster extends AutoObservable { 16 | /// @observable int health; 17 | /// } 18 | /// 19 | // TODO(sigmund): re-add this to the documentation when it's really true: 20 | // If needed, you can subclass this to create another annotation that will 21 | // also be treated as observable. 22 | // Note: observable properties imply reflectable. 23 | class ObservableProperty { 24 | const ObservableProperty(); 25 | } 26 | 27 | /// This can be used to retain any properties that you wish to access with 28 | /// Dart's mirror system. If you import `package:observe/mirrors_used.dart`, all 29 | /// classes or members annotated with `@reflectable` wil be preserved by dart2js 30 | /// during compilation. This is necessary to make the member visible to 31 | /// `PathObserver`, or similar systems, once the code is deployed, if you are 32 | /// not doing a different kind of code-generation for your app. If you are using 33 | /// polymer, you most likely don't need to use this annotation anymore. 34 | const Reflectable reflectable = const Reflectable(); 35 | 36 | /// An annotation that is used to make a type or member reflectable. This makes 37 | /// it available to `PathObserver` at runtime. For example: 38 | /// 39 | /// @reflectable 40 | /// class Monster extends AutoObservable { 41 | /// int _health; 42 | /// int get health => _health; 43 | /// ... 44 | /// } 45 | /// ... 46 | /// // This will work even if the code has been tree-shaken/minified: 47 | /// final monster = new Monster(); 48 | /// new PathObserver(monster, 'health').changes.listen(...); 49 | class Reflectable { 50 | const Reflectable(); 51 | } 52 | -------------------------------------------------------------------------------- /lib/src/observable_box.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file 2 | // for details. All rights reserved. Use of this source code is governed by a 3 | // BSD-style license that can be found in the LICENSE file. 4 | 5 | library observe.src.observable_box; 6 | 7 | import 'package:observable/observable.dart'; 8 | import 'package:observe/observe.dart'; 9 | 10 | // TODO(jmesserly): should the property name be configurable? 11 | // That would be more convenient. 12 | /// An observable box that holds a value. Use this if you want to store a single 13 | /// value. For other cases, it is better to use [AutoObservableList], 14 | /// [AutoObservableMap], or a custom [AutoObservable] implementation based on 15 | /// [AutoObservable]. The property name for changes is "value". 16 | class ObservableBox extends PropertyChangeNotifier { 17 | T _value; 18 | 19 | ObservableBox([T initialValue]) : _value = initialValue; 20 | 21 | @reflectable 22 | T get value => _value; 23 | 24 | @reflectable 25 | void set value(T newValue) { 26 | _value = notifyPropertyChange(#value, _value, newValue); 27 | } 28 | 29 | String toString() => '#<$runtimeType value: $value>'; 30 | } 31 | -------------------------------------------------------------------------------- /lib/src/observer_transform.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file 2 | // for details. All rights reserved. Use of this source code is governed by a 3 | // BSD-style license that can be found in the LICENSE file. 4 | 5 | library observe.src.observer_transform; 6 | 7 | import 'package:observe/observe.dart'; 8 | 9 | /// ObserverTransform is used to dynamically transform observed value(s). 10 | /// 11 | /// var obj = new ObservableBox(10); 12 | /// var observer = new PathObserver(obj, 'value'); 13 | /// var transform = new ObserverTransform(observer, 14 | /// (x) => x * 2, setValue: (x) => x ~/ 2); 15 | /// 16 | /// // Open returns the current value of 20. 17 | /// transform.open((newValue) => print('new: $newValue')); 18 | /// 19 | /// obj.value = 20; // prints 'new: 40' async 20 | /// new Future(() { 21 | /// transform.value = 4; // obj.value will be 2 22 | /// }); 23 | /// 24 | /// ObserverTransform can also be used to reduce a set of observed values to a 25 | /// single value: 26 | /// 27 | /// var obj = new AutoObservableMap.from({'a': 1, 'b': 2, 'c': 3}); 28 | /// var observer = new CompoundObserver() 29 | /// ..addPath(obj, 'a') 30 | /// ..addPath(obj, 'b') 31 | /// ..addPath(obj, 'c'); 32 | /// 33 | /// var transform = new ObserverTransform(observer, 34 | /// (values) => values.fold(0, (x, y) => x + y)); 35 | /// 36 | /// // Open returns the current value of 6. 37 | /// transform.open((newValue) => print('new: $newValue')); 38 | /// 39 | /// obj['a'] = 2; 40 | /// obj['c'] = 10; // will print 'new 14' asynchronously 41 | /// 42 | class ObserverTransform extends Bindable { 43 | Bindable _bindable; 44 | Function _getTransformer, _setTransformer; 45 | Function _notifyCallback; 46 | var _value; 47 | 48 | ObserverTransform(Bindable bindable, computeValue(value), {setValue(value)}) 49 | : _bindable = bindable, 50 | _getTransformer = computeValue, 51 | _setTransformer = setValue; 52 | 53 | open(callback) { 54 | _notifyCallback = callback; 55 | _value = _getTransformer(_bindable.open(_observedCallback)); 56 | return _value; 57 | } 58 | 59 | _observedCallback(newValue) { 60 | final value = _getTransformer(newValue); 61 | if (value == _value) return null; 62 | _value = value; 63 | return _notifyCallback(value); 64 | } 65 | 66 | void close() { 67 | if (_bindable != null) _bindable.close(); 68 | _bindable = null; 69 | _getTransformer = null; 70 | _setTransformer = null; 71 | _notifyCallback = null; 72 | _value = null; 73 | } 74 | 75 | get value => _value = _getTransformer(_bindable.value); 76 | 77 | set value(newValue) { 78 | if (_setTransformer != null) { 79 | newValue = _setTransformer(newValue); 80 | } 81 | _bindable.value = newValue; 82 | } 83 | 84 | deliver() => _bindable.deliver(); 85 | } 86 | -------------------------------------------------------------------------------- /lib/src/path_observer.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file 2 | // for details. All rights reserved. Use of this source code is governed by a 3 | // BSD-style license that can be found in the LICENSE file. 4 | 5 | library observe.src.path_observer; 6 | 7 | import 'dart:async'; 8 | import 'dart:collection'; 9 | import 'dart:math' show min; 10 | 11 | import 'package:logging/logging.dart' show Logger, Level; 12 | import 'package:observable/observable.dart'; 13 | import 'package:observe/observe.dart'; 14 | import 'package:smoke/smoke.dart' as smoke; 15 | 16 | import 'package:utf/utf.dart' show stringToCodepoints; 17 | 18 | /// A data-bound path starting from a view-model or model object, for example 19 | /// `foo.bar.baz`. 20 | /// 21 | /// When [open] is called, this will observe changes to the object and any 22 | /// intermediate object along the path, and send updated values accordingly. 23 | /// When [close] is called it will stop observing the objects. 24 | /// 25 | /// This class is used to implement `Node.bind` and similar functionality in 26 | /// the [template_binding](pub.dartlang.org/packages/template_binding) package. 27 | class PathObserver extends _Observer implements Bindable { 28 | PropertyPath _path; 29 | Object _object; 30 | _ObservedSet _directObserver; 31 | 32 | /// Observes [path] on [object] for changes. This returns an object 33 | /// that can be used to get the changes and get/set the value at this path. 34 | /// 35 | /// The path can be a [PropertyPath], or a [String] used to construct it. 36 | /// 37 | /// See [open] and [value]. 38 | PathObserver(Object object, [path]) 39 | : _object = object, 40 | _path = new PropertyPath(path); 41 | 42 | PropertyPath get path => _path; 43 | 44 | /// Sets the value at this path. 45 | void set value(newValue) { 46 | if (_path != null) _path.setValueFrom(_object, newValue); 47 | } 48 | 49 | int get _reportArgumentCount => 2; 50 | 51 | /// Initiates observation and returns the initial value. 52 | /// The callback will be passed the updated [value], and may optionally be 53 | /// declared to take a second argument, which will contain the previous value. 54 | open(callback) => super.open(callback); 55 | 56 | void _connect() { 57 | _directObserver = new _ObservedSet(this, _object); 58 | _check(skipChanges: true); 59 | } 60 | 61 | void _disconnect() { 62 | _value = null; 63 | if (_directObserver != null) { 64 | _directObserver.close(this); 65 | _directObserver = null; 66 | } 67 | // Dart note: the JS impl does not do this, but it seems consistent with 68 | // CompoundObserver. After closing the PathObserver can't be reopened. 69 | _path = null; 70 | _object = null; 71 | } 72 | 73 | void _iterateObjects(void observe(obj, prop)) { 74 | _path._iterateObjects(_object, observe); 75 | } 76 | 77 | bool _check({bool skipChanges: false}) { 78 | var oldValue = _value; 79 | _value = _path.getValueFrom(_object); 80 | if (skipChanges || _value == oldValue) return false; 81 | 82 | _report(_value, oldValue, this); 83 | return true; 84 | } 85 | } 86 | 87 | /// A dot-delimieted property path such as "foo.bar" or "foo.10.bar". 88 | /// 89 | /// The path specifies how to get a particular value from an object graph, where 90 | /// the graph can include arrays and maps. Each segment of the path describes 91 | /// how to take a single step in the object graph. Properties like 'foo' or 92 | /// 'bar' are read as properties on objects, or as keys if the object is a [Map] 93 | /// or a [Indexable], while integer values are read as indexes in a [List]. 94 | // TODO(jmesserly): consider specialized subclasses for: 95 | // * empty path 96 | // * "value" 97 | // * single token in path, e.g. "foo" 98 | class PropertyPath { 99 | /// The segments of the path. 100 | final List _segments; 101 | 102 | /// Creates a new [PropertyPath]. These can be stored to avoid excessive 103 | /// parsing of path strings. 104 | /// 105 | /// The provided [path] should be a String or a List. If it is a list it 106 | /// should contain only Symbols and integers. This can be used to avoid 107 | /// parsing. 108 | /// 109 | /// Note that this constructor will canonicalize identical paths in some cases 110 | /// to save memory, but this is not guaranteed. Use [==] for comparions 111 | /// purposes instead of [identical]. 112 | // Dart note: this is ported from `function getPath`. 113 | factory PropertyPath([path]) { 114 | if (path is PropertyPath) return path; 115 | if (path == null || (path is List && path.isEmpty)) path = ''; 116 | 117 | if (path is List) { 118 | var copy = new List.from(path, growable: false); 119 | for (var segment in copy) { 120 | // Dart note: unlike Javascript, we don't support arbitraty objects that 121 | // can be converted to a String. 122 | // TODO(sigmund): consider whether we should support that here. It might 123 | // be easier to add support for that if we switch first to use strings 124 | // for everything instead of symbols. 125 | if (segment is! int && segment is! String && segment is! Symbol) { 126 | throw new ArgumentError( 127 | 'List must contain only ints, Strings, and Symbols'); 128 | } 129 | } 130 | return new PropertyPath._(copy); 131 | } 132 | 133 | var pathObj = _pathCache[path]; 134 | if (pathObj != null) return pathObj; 135 | 136 | final segments = new _PathParser().parse(path); 137 | if (segments == null) return _InvalidPropertyPath._instance; 138 | 139 | // TODO(jmesserly): we could use an UnmodifiableListView here, but that adds 140 | // memory overhead. 141 | pathObj = new PropertyPath._(segments.toList(growable: false)); 142 | if (_pathCache.length >= _pathCacheLimit) { 143 | _pathCache.remove(_pathCache.keys.first); 144 | } 145 | _pathCache[path] = pathObj; 146 | return pathObj; 147 | } 148 | 149 | PropertyPath._(this._segments); 150 | 151 | int get length => _segments.length; 152 | bool get isEmpty => _segments.isEmpty; 153 | bool get isValid => true; 154 | 155 | String toString() { 156 | if (!isValid) return ''; 157 | var sb = new StringBuffer(); 158 | bool first = true; 159 | for (var key in _segments) { 160 | if (key is Symbol) { 161 | if (!first) sb.write('.'); 162 | sb.write(smoke.symbolToName(key)); 163 | } else { 164 | _formatAccessor(sb, key); 165 | } 166 | first = false; 167 | } 168 | return sb.toString(); 169 | } 170 | 171 | _formatAccessor(StringBuffer sb, Object key) { 172 | if (key is int) { 173 | sb.write('[$key]'); 174 | } else { 175 | sb.write('["${key.toString().replaceAll('"', '\\"')}"]'); 176 | } 177 | } 178 | 179 | bool operator ==(other) { 180 | if (identical(this, other)) return true; 181 | if (other is! PropertyPath) return false; 182 | if (isValid != other.isValid) return false; 183 | 184 | int len = _segments.length; 185 | if (len != other._segments.length) return false; 186 | for (int i = 0; i < len; i++) { 187 | if (_segments[i] != other._segments[i]) return false; 188 | } 189 | return true; 190 | } 191 | 192 | /// This is the [Jenkins hash function][1] but using masking to keep 193 | /// values in SMI range. 194 | /// [1]: http://en.wikipedia.org/wiki/Jenkins_hash_function 195 | // TODO(jmesserly): should reuse this instead, see 196 | // https://code.google.com/p/dart/issues/detail?id=11617 197 | int get hashCode { 198 | int hash = 0; 199 | for (int i = 0, len = _segments.length; i < len; i++) { 200 | hash = 0x1fffffff & (hash + _segments[i].hashCode); 201 | hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); 202 | hash = hash ^ (hash >> 6); 203 | } 204 | hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); 205 | hash = hash ^ (hash >> 11); 206 | return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); 207 | } 208 | 209 | /// Returns the current value of the path from the provided [obj]ect. 210 | getValueFrom(Object obj) { 211 | if (!isValid) return null; 212 | for (var segment in _segments) { 213 | if (obj == null) return null; 214 | obj = _getObjectProperty(obj, segment); 215 | } 216 | return obj; 217 | } 218 | 219 | /// Attempts to set the [value] of the path from the provided [obj]ect. 220 | /// Returns true if and only if the path was reachable and set. 221 | bool setValueFrom(Object obj, Object value) { 222 | var end = _segments.length - 1; 223 | if (end < 0) return false; 224 | for (int i = 0; i < end; i++) { 225 | if (obj == null) return false; 226 | obj = _getObjectProperty(obj, _segments[i]); 227 | } 228 | return _setObjectProperty(obj, _segments[end], value); 229 | } 230 | 231 | void _iterateObjects(Object obj, void observe(obj, prop)) { 232 | if (!isValid || isEmpty) return; 233 | 234 | int i = 0, last = _segments.length - 1; 235 | while (obj != null) { 236 | // _segments[i] is passed to indicate that we are only observing that 237 | // property of obj. See observe declaration in _ObservedSet. 238 | observe(obj, _segments[i]); 239 | 240 | if (i >= last) break; 241 | obj = _getObjectProperty(obj, _segments[i++]); 242 | } 243 | } 244 | 245 | // Dart note: it doesn't make sense to have compiledGetValueFromFn in Dart. 246 | } 247 | 248 | /// Visible only for testing: 249 | getSegmentsOfPropertyPathForTesting(p) => p._segments; 250 | 251 | class _InvalidPropertyPath extends PropertyPath { 252 | static final _instance = new _InvalidPropertyPath(); 253 | 254 | bool get isValid => false; 255 | _InvalidPropertyPath() : super._([]); 256 | } 257 | 258 | /// Properties in [Map] that need to be read as properties and not as keys in 259 | /// the map. We exclude methods ('containsValue', 'containsKey', 'putIfAbsent', 260 | /// 'addAll', 'remove', 'clear', 'forEach') because there is no use in reading 261 | /// them as part of path-observer segments. 262 | const _MAP_PROPERTIES = const [#keys, #values, #length, #isEmpty, #isNotEmpty]; 263 | 264 | _getObjectProperty(object, property) { 265 | if (object == null) return null; 266 | 267 | if (property is int) { 268 | if (object is List && property >= 0 && property < object.length) { 269 | return object[property]; 270 | } 271 | } else if (property is String) { 272 | return object[property]; 273 | } else if (property is Symbol) { 274 | // Support indexer if available, e.g. Maps or polymer_expressions Scope. 275 | // This is the default syntax used by polymer/nodebind and 276 | // polymer/observe-js PathObserver. 277 | // TODO(sigmund): should we also support using checking dynamically for 278 | // whether the type practically implements the indexer API 279 | // (smoke.hasInstanceMethod(type, const Symbol('[]')))? 280 | if (object is Indexable || 281 | object is Map && !_MAP_PROPERTIES.contains(property)) { 282 | return object[smoke.symbolToName(property)]; 283 | } 284 | try { 285 | return smoke.read(object, property); 286 | } on NoSuchMethodError catch (_) { 287 | // Rethrow, unless the type implements noSuchMethod, in which case we 288 | // interpret the exception as a signal that the method was not found. 289 | // Dart note: getting invalid properties is an error, unlike in JS where 290 | // it returns undefined. 291 | if (!smoke.hasNoSuchMethod(object.runtimeType)) rethrow; 292 | } 293 | } 294 | 295 | if (_logger.isLoggable(Level.FINER)) { 296 | _logger.finer("can't get $property in $object"); 297 | } 298 | return null; 299 | } 300 | 301 | bool _setObjectProperty(object, property, value) { 302 | if (object == null) return false; 303 | 304 | if (property is int) { 305 | if (object is List && property >= 0 && property < object.length) { 306 | object[property] = value; 307 | return true; 308 | } 309 | } else if (property is Symbol) { 310 | // Support indexer if available, e.g. Maps or polymer_expressions Scope. 311 | if (object is Indexable || 312 | object is Map && !_MAP_PROPERTIES.contains(property)) { 313 | object[smoke.symbolToName(property)] = value; 314 | return true; 315 | } 316 | try { 317 | smoke.write(object, property, value); 318 | return true; 319 | } on NoSuchMethodError catch (_) { 320 | if (!smoke.hasNoSuchMethod(object.runtimeType)) rethrow; 321 | } 322 | } 323 | 324 | if (_logger.isLoggable(Level.FINER)) { 325 | _logger.finer("can't set $property in $object"); 326 | } 327 | return false; 328 | } 329 | 330 | // From: https://github.com/rafaelw/ChangeSummary/blob/master/change_summary.js 331 | 332 | final RegExp _identRegExp = () { 333 | const identStart = '[\$_a-zA-Z]'; 334 | const identPart = '[\$_a-zA-Z0-9]'; 335 | return new RegExp('^$identStart+$identPart*\$'); 336 | }(); 337 | 338 | _isIdent(s) => _identRegExp.hasMatch(s); 339 | 340 | // Dart note: refactored to convert to codepoints once and operate on codepoints 341 | // rather than characters. 342 | class _PathParser { 343 | List keys = []; 344 | int index = -1; 345 | String key; 346 | 347 | final Map>> _pathStateMachine = { 348 | 'beforePath': { 349 | 'ws': ['beforePath'], 350 | 'ident': ['inIdent', 'append'], 351 | '[': ['beforeElement'], 352 | 'eof': ['afterPath'] 353 | }, 354 | 'inPath': { 355 | 'ws': ['inPath'], 356 | '.': ['beforeIdent'], 357 | '[': ['beforeElement'], 358 | 'eof': ['afterPath'] 359 | }, 360 | 'beforeIdent': { 361 | 'ws': ['beforeIdent'], 362 | 'ident': ['inIdent', 'append'] 363 | }, 364 | 'inIdent': { 365 | 'ident': ['inIdent', 'append'], 366 | '0': ['inIdent', 'append'], 367 | 'number': ['inIdent', 'append'], 368 | 'ws': ['inPath', 'push'], 369 | '.': ['beforeIdent', 'push'], 370 | '[': ['beforeElement', 'push'], 371 | 'eof': ['afterPath', 'push'] 372 | }, 373 | 'beforeElement': { 374 | 'ws': ['beforeElement'], 375 | '0': ['afterZero', 'append'], 376 | 'number': ['inIndex', 'append'], 377 | "'": ['inSingleQuote', 'append', ''], 378 | '"': ['inDoubleQuote', 'append', ''] 379 | }, 380 | 'afterZero': { 381 | 'ws': ['afterElement', 'push'], 382 | ']': ['inPath', 'push'] 383 | }, 384 | 'inIndex': { 385 | '0': ['inIndex', 'append'], 386 | 'number': ['inIndex', 'append'], 387 | 'ws': ['afterElement'], 388 | ']': ['inPath', 'push'] 389 | }, 390 | 'inSingleQuote': { 391 | "'": ['afterElement'], 392 | 'eof': ['error'], 393 | 'else': ['inSingleQuote', 'append'] 394 | }, 395 | 'inDoubleQuote': { 396 | '"': ['afterElement'], 397 | 'eof': ['error'], 398 | 'else': ['inDoubleQuote', 'append'] 399 | }, 400 | 'afterElement': { 401 | 'ws': ['afterElement'], 402 | ']': ['inPath', 'push'] 403 | } 404 | }; 405 | 406 | /// From getPathCharType: determines the type of a given [code]point. 407 | String _getPathCharType(code) { 408 | if (code == null) return 'eof'; 409 | switch (code) { 410 | case 0x5B: // [ 411 | case 0x5D: // ] 412 | case 0x2E: // . 413 | case 0x22: // " 414 | case 0x27: // ' 415 | case 0x30: // 0 416 | return _char(code); 417 | 418 | case 0x5F: // _ 419 | case 0x24: // $ 420 | return 'ident'; 421 | 422 | case 0x20: // Space 423 | case 0x09: // Tab 424 | case 0x0A: // Newline 425 | case 0x0D: // Return 426 | case 0xA0: // No-break space 427 | case 0xFEFF: // Byte Order Mark 428 | case 0x2028: // Line Separator 429 | case 0x2029: // Paragraph Separator 430 | return 'ws'; 431 | } 432 | 433 | // a-z, A-Z 434 | if ((0x61 <= code && code <= 0x7A) || (0x41 <= code && code <= 0x5A)) 435 | return 'ident'; 436 | 437 | // 1-9 438 | if (0x31 <= code && code <= 0x39) return 'number'; 439 | 440 | return 'else'; 441 | } 442 | 443 | static String _char(int codepoint) => new String.fromCharCodes([codepoint]); 444 | 445 | void push() { 446 | if (key == null) return; 447 | 448 | // Dart note: we store the keys with different types, rather than 449 | // parsing/converting things later in toString. 450 | if (_isIdent(key)) { 451 | keys.add(smoke.nameToSymbol(key)); 452 | } else { 453 | var index = int.parse(key, radix: 10, onError: (_) => null); 454 | keys.add(index != null ? index : key); 455 | } 456 | key = null; 457 | } 458 | 459 | void append(newChar) { 460 | key = (key == null) ? newChar : '$key$newChar'; 461 | } 462 | 463 | bool _maybeUnescapeQuote(String mode, codePoints) { 464 | if (index >= codePoints.length) return false; 465 | var nextChar = _char(codePoints[index + 1]); 466 | if ((mode == 'inSingleQuote' && nextChar == "'") || 467 | (mode == 'inDoubleQuote' && nextChar == '"')) { 468 | index++; 469 | append(nextChar); 470 | return true; 471 | } 472 | return false; 473 | } 474 | 475 | /// Returns the parsed keys, or null if there was a parse error. 476 | List parse(String path) { 477 | var codePoints = stringToCodepoints(path); 478 | var mode = 'beforePath'; 479 | 480 | while (mode != null) { 481 | index++; 482 | var c = index >= codePoints.length ? null : codePoints[index]; 483 | 484 | if (c != null && 485 | _char(c) == '\\' && 486 | _maybeUnescapeQuote(mode, codePoints)) continue; 487 | 488 | var type = _getPathCharType(c); 489 | if (mode == 'error') return null; 490 | 491 | var typeMap = _pathStateMachine[mode]; 492 | var transition = typeMap[type]; 493 | if (transition == null) transition = typeMap['else']; 494 | if (transition == null) return null; // parse error; 495 | 496 | mode = transition[0]; 497 | var actionName = transition.length > 1 ? transition[1] : null; 498 | if (actionName == 'push' && key != null) push(); 499 | if (actionName == 'append') { 500 | var newChar = transition.length > 2 && transition[2] != null 501 | ? transition[2] 502 | : _char(c); 503 | append(newChar); 504 | } 505 | 506 | if (mode == 'afterPath') return keys; 507 | } 508 | return null; // parse error 509 | } 510 | } 511 | 512 | final Logger _logger = new Logger('observe.PathObserver'); 513 | 514 | /// This is a simple cache. It's like LRU but we don't update an item on a 515 | /// cache hit, because that would require allocation. Better to let it expire 516 | /// and reallocate the PropertyPath. 517 | // TODO(jmesserly): this optimization is from observe-js, how valuable is it in 518 | // practice? 519 | final _pathCache = new LinkedHashMap(); 520 | 521 | /// The size of a path like "foo.bar" is approximately 160 bytes, so this 522 | /// reserves ~16Kb of memory for recently used paths. Since paths are frequently 523 | /// reused, the theory is that this ends up being a good tradeoff in practice. 524 | // (Note: the 160 byte estimate is from Dart VM 1.0.0.10_r30798 on x64 without 525 | // using UnmodifiableListView in PropertyPath) 526 | const int _pathCacheLimit = 100; 527 | 528 | /// [CompoundObserver] is a [Bindable] object which knows how to listen to 529 | /// multiple values (registered via [addPath] or [addObserver]) and invoke a 530 | /// callback when one or more of the values have changed. 531 | /// 532 | /// var obj = new ObservableMap.from({'a': 1, 'b': 2}); 533 | /// var otherObj = new ObservableMap.from({'c': 3}); 534 | /// 535 | /// var observer = new CompoundObserver() 536 | /// ..addPath(obj, 'a'); 537 | /// ..addObserver(new PathObserver(obj, 'b')); 538 | /// ..addPath(otherObj, 'c'); 539 | /// ..open((values) { 540 | /// for (int i = 0; i < values.length; i++) { 541 | /// print('The value at index $i is now ${values[i]}'); 542 | /// } 543 | /// }); 544 | /// 545 | /// obj['a'] = 10; // print will be triggered async 546 | /// 547 | class CompoundObserver extends _Observer implements Bindable { 548 | _ObservedSet _directObserver; 549 | bool _reportChangesOnOpen; 550 | List _observed = []; 551 | 552 | CompoundObserver([this._reportChangesOnOpen = false]) { 553 | _value = []; 554 | } 555 | 556 | int get _reportArgumentCount => 3; 557 | 558 | /// Initiates observation and returns the initial value. 559 | /// The callback will be passed the updated [value], and may optionally be 560 | /// declared to take a second argument, which will contain the previous value. 561 | /// 562 | /// Implementation note: a third argument can also be declared, which will 563 | /// receive a list of objects and paths, such that `list[2 * i]` will access 564 | /// the object and `list[2 * i + 1]` will access the path, where `i` is the 565 | /// order of the [addPath] call. This parameter is only used by 566 | /// `package:polymer` as a performance optimization, and should not be relied 567 | /// on in new code. 568 | open(callback) => super.open(callback); 569 | 570 | void _connect() { 571 | for (var i = 0; i < _observed.length; i += 2) { 572 | var object = _observed[i]; 573 | if (!identical(object, _observerSentinel)) { 574 | _directObserver = new _ObservedSet(this, object); 575 | break; 576 | } 577 | } 578 | 579 | _check(skipChanges: !_reportChangesOnOpen); 580 | } 581 | 582 | void _disconnect() { 583 | for (var i = 0; i < _observed.length; i += 2) { 584 | if (identical(_observed[i], _observerSentinel)) { 585 | _observed[i + 1].close(); 586 | } 587 | } 588 | 589 | _observed = null; 590 | _value = null; 591 | 592 | if (_directObserver != null) { 593 | _directObserver.close(this); 594 | _directObserver = null; 595 | } 596 | } 597 | 598 | /// Adds a dependency on the property [path] accessed from [object]. 599 | /// [path] can be a [PropertyPath] or a [String]. If it is omitted an empty 600 | /// path will be used. 601 | void addPath(Object object, [path]) { 602 | if (_isOpen || _isClosed) { 603 | throw new StateError('Cannot add paths once started.'); 604 | } 605 | 606 | path = new PropertyPath(path); 607 | _observed..add(object)..add(path); 608 | if (!_reportChangesOnOpen) return; 609 | _value.add(path.getValueFrom(object)); 610 | } 611 | 612 | void addObserver(Bindable observer) { 613 | if (_isOpen || _isClosed) { 614 | throw new StateError('Cannot add observers once started.'); 615 | } 616 | 617 | _observed..add(_observerSentinel)..add(observer); 618 | if (!_reportChangesOnOpen) return; 619 | _value.add(observer.open((_) => deliver())); 620 | } 621 | 622 | void _iterateObjects(void observe(obj, prop)) { 623 | for (var i = 0; i < _observed.length; i += 2) { 624 | var object = _observed[i]; 625 | if (!identical(object, _observerSentinel)) { 626 | (_observed[i + 1] as PropertyPath)._iterateObjects(object, observe); 627 | } 628 | } 629 | } 630 | 631 | bool _check({bool skipChanges: false}) { 632 | bool changed = false; 633 | _value.length = _observed.length ~/ 2; 634 | var oldValues = null; 635 | for (var i = 0; i < _observed.length; i += 2) { 636 | var object = _observed[i]; 637 | var path = _observed[i + 1]; 638 | var value; 639 | if (identical(object, _observerSentinel)) { 640 | var observable = path as Bindable; 641 | value = _state == _Observer._UNOPENED 642 | ? observable.open((_) => this.deliver()) 643 | : observable.value; 644 | } else { 645 | value = (path as PropertyPath).getValueFrom(object); 646 | } 647 | 648 | if (skipChanges) { 649 | _value[i ~/ 2] = value; 650 | continue; 651 | } 652 | 653 | if (value == _value[i ~/ 2]) continue; 654 | 655 | // don't allocate this unless necessary. 656 | if (_notifyArgumentCount >= 2) { 657 | if (oldValues == null) oldValues = new Map(); 658 | oldValues[i ~/ 2] = _value[i ~/ 2]; 659 | } 660 | 661 | changed = true; 662 | _value[i ~/ 2] = value; 663 | } 664 | 665 | if (!changed) return false; 666 | 667 | // TODO(rafaelw): Having _observed as the third callback arg here is 668 | // pretty lame API. Fix. 669 | _report(_value, oldValues, _observed); 670 | return true; 671 | } 672 | } 673 | 674 | /// An object accepted by [PropertyPath] where properties are read and written 675 | /// as indexing operations, just like a [Map]. 676 | abstract class Indexable { 677 | V operator [](K key); 678 | operator []=(K key, V value); 679 | } 680 | 681 | const _observerSentinel = const _ObserverSentinel(); 682 | 683 | class _ObserverSentinel { 684 | const _ObserverSentinel(); 685 | } 686 | 687 | // Visible for testing 688 | get observerSentinelForTesting => _observerSentinel; 689 | 690 | // A base class for the shared API implemented by PathObserver and 691 | // CompoundObserver and used in _ObservedSet. 692 | abstract class _Observer extends Bindable { 693 | Function _notifyCallback; 694 | int _notifyArgumentCount; 695 | var _value; 696 | 697 | // abstract members 698 | void _iterateObjects(void observe(obj, prop)); 699 | void _connect(); 700 | void _disconnect(); 701 | bool _check({bool skipChanges: false}); 702 | 703 | static int _UNOPENED = 0; 704 | static int _OPENED = 1; 705 | static int _CLOSED = 2; 706 | int _state = _UNOPENED; 707 | bool get _isOpen => _state == _OPENED; 708 | bool get _isClosed => _state == _CLOSED; 709 | 710 | /// The number of arguments the subclass will pass to [_report]. 711 | int get _reportArgumentCount; 712 | 713 | open(callback) { 714 | if (_isOpen || _isClosed) { 715 | throw new StateError('Observer has already been opened.'); 716 | } 717 | 718 | if (smoke.minArgs(callback) > _reportArgumentCount) { 719 | throw new ArgumentError('callback should take $_reportArgumentCount or ' 720 | 'fewer arguments'); 721 | } 722 | 723 | _notifyCallback = callback; 724 | _notifyArgumentCount = min(_reportArgumentCount, smoke.maxArgs(callback)); 725 | 726 | _connect(); 727 | _state = _OPENED; 728 | return _value; 729 | } 730 | 731 | get value => _discardChanges(); 732 | 733 | void close() { 734 | if (!_isOpen) return; 735 | 736 | _disconnect(); 737 | _value = null; 738 | _notifyCallback = null; 739 | _state = _CLOSED; 740 | } 741 | 742 | _discardChanges() { 743 | _check(skipChanges: true); 744 | return _value; 745 | } 746 | 747 | void deliver() { 748 | if (_isOpen) _dirtyCheck(); 749 | } 750 | 751 | bool _dirtyCheck() { 752 | var cycles = 0; 753 | while (cycles < _MAX_DIRTY_CHECK_CYCLES && _check()) { 754 | cycles++; 755 | } 756 | return cycles > 0; 757 | } 758 | 759 | void _report(newValue, oldValue, [extraArg]) { 760 | try { 761 | switch (_notifyArgumentCount) { 762 | case 0: 763 | _notifyCallback(); 764 | break; 765 | case 1: 766 | _notifyCallback(newValue); 767 | break; 768 | case 2: 769 | _notifyCallback(newValue, oldValue); 770 | break; 771 | case 3: 772 | _notifyCallback(newValue, oldValue, extraArg); 773 | break; 774 | } 775 | } catch (e, s) { 776 | // Deliver errors async, so if a single callback fails it doesn't prevent 777 | // other things from working. 778 | new Completer().completeError(e, s); 779 | } 780 | } 781 | } 782 | 783 | /// The observedSet abstraction is a perf optimization which reduces the total 784 | /// number of Object.observe observations of a set of objects. The idea is that 785 | /// groups of Observers will have some object dependencies in common and this 786 | /// observed set ensures that each object in the transitive closure of 787 | /// dependencies is only observed once. The observedSet acts as a write barrier 788 | /// such that whenever any change comes through, all Observers are checked for 789 | /// changed values. 790 | /// 791 | /// Note that this optimization is explicitly moving work from setup-time to 792 | /// change-time. 793 | /// 794 | /// TODO(rafaelw): Implement "garbage collection". In order to move work off 795 | /// the critical path, when Observers are closed, their observed objects are 796 | /// not Object.unobserve(d). As a result, it's possible that if the observedSet 797 | /// is kept open, but some Observers have been closed, it could cause "leaks" 798 | /// (prevent otherwise collectable objects from being collected). At some 799 | /// point, we should implement incremental "gc" which keeps a list of 800 | /// observedSets which may need clean-up and does small amounts of cleanup on a 801 | /// timeout until all is clean. 802 | class _ObservedSet { 803 | /// To prevent sequential [PathObserver]s and [CompoundObserver]s from 804 | /// observing the same object, we check if they are observing the same root 805 | /// as the most recently created observer, and if so merge it into the 806 | /// existing _ObservedSet. 807 | /// 808 | /// See and 809 | /// . 810 | static _ObservedSet _lastSet; 811 | 812 | /// The root object for a [PathObserver]. For a [CompoundObserver], the root 813 | /// object of the first path observed. This is used by the constructor to 814 | /// reuse an [_ObservedSet] that starts from the same object. 815 | Object _rootObject; 816 | 817 | /// Subset of properties in [_rootObject] that we care about. 818 | Set _rootObjectProperties; 819 | 820 | /// Observers associated with this root object, in birth order. 821 | final List<_Observer> _observers = []; 822 | 823 | // Dart note: the JS implementation is O(N^2) because Array.indexOf is used 824 | // for lookup in this array. We use HashMap to avoid this problem. It 825 | // also gives us a nice way of tracking the StreamSubscription. 826 | Map _objects; 827 | 828 | factory _ObservedSet(_Observer observer, Object rootObject) { 829 | if (_lastSet == null || !identical(_lastSet._rootObject, rootObject)) { 830 | _lastSet = new _ObservedSet._(rootObject); 831 | } 832 | _lastSet.open(observer, rootObject); 833 | return _lastSet; 834 | } 835 | 836 | _ObservedSet._(rootObject) 837 | : _rootObject = rootObject, 838 | _rootObjectProperties = rootObject == null ? null : new Set(); 839 | 840 | void open(_Observer obs, Object rootObject) { 841 | if (_rootObject == null) { 842 | _rootObject = rootObject; 843 | _rootObjectProperties = new Set(); 844 | } 845 | 846 | _observers.add(obs); 847 | obs._iterateObjects(observe); 848 | } 849 | 850 | void close(_Observer obs) { 851 | _observers.remove(obs); 852 | if (_observers.isNotEmpty) return; 853 | 854 | if (_objects != null) { 855 | for (var sub in _objects.values) sub.cancel(); 856 | _objects = null; 857 | } 858 | _rootObject = null; 859 | _rootObjectProperties = null; 860 | if (identical(_lastSet, this)) _lastSet = null; 861 | } 862 | 863 | /// Observe now takes a second argument to indicate which property of an 864 | /// object is being observed, so we don't trigger change notifications on 865 | /// changes to unrelated properties. 866 | void observe(Object obj, Object prop) { 867 | if (identical(obj, _rootObject)) _rootObjectProperties.add(prop); 868 | if (obj is ObservableList) _observeStream(obj.listChanges); 869 | if (obj is Observable) _observeStream(obj.changes); 870 | } 871 | 872 | void _observeStream(Stream stream) { 873 | // TODO(jmesserly): we hash on streams as we have two separate change 874 | // streams for ObservableList. Not sure if that is the design we will use 875 | // going forward. 876 | 877 | if (_objects == null) _objects = new HashMap(); 878 | if (!_objects.containsKey(stream)) { 879 | _objects[stream] = stream.listen(_callback); 880 | } 881 | } 882 | 883 | /// Whether we can ignore all change events in [records]. This is true if all 884 | /// records are for properties in the [_rootObject] and we are not observing 885 | /// any of those properties. Changes on objects other than [_rootObject], or 886 | /// changes for properties in [_rootObjectProperties] can't be ignored. 887 | // Dart note: renamed from `allRootObjNonObservedProps` in the JS code. 888 | bool _canIgnoreRecords(List records) { 889 | for (var rec in records) { 890 | if (rec is PropertyChangeRecord) { 891 | if (!identical(rec.object, _rootObject) || 892 | _rootObjectProperties.contains(rec.name)) { 893 | return false; 894 | } 895 | } else if (rec is ListChangeRecord) { 896 | if (!identical(rec.object, _rootObject) || 897 | _rootObjectProperties.contains(rec.index)) { 898 | return false; 899 | } 900 | } else { 901 | // TODO(sigmund): consider adding object to MapChangeRecord, and make 902 | // this more precise. 903 | return false; 904 | } 905 | } 906 | return true; 907 | } 908 | 909 | void _callback(List records) { 910 | if (_canIgnoreRecords(records)) return; 911 | for (var observer in _observers.toList(growable: false)) { 912 | if (observer._isOpen) observer._iterateObjects(observe); 913 | } 914 | 915 | for (var observer in _observers.toList(growable: false)) { 916 | if (observer._isOpen) observer._check(); 917 | } 918 | } 919 | } 920 | 921 | const int _MAX_DIRTY_CHECK_CYCLES = 1000; 922 | -------------------------------------------------------------------------------- /lib/transformer.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file 2 | // for details. All rights reserved. Use of this source code is governed by a 3 | // BSD-style license that can be found in the LICENSE file. 4 | 5 | /// Code transform for @observable. The core transformation is relatively 6 | /// straightforward, and essentially like an editor refactoring. 7 | @Deprecated('Parts of Observe used to support Polymer will move out of library') 8 | library observe.transformer; 9 | 10 | import 'dart:async'; 11 | 12 | import 'package:analyzer/analyzer.dart'; 13 | import 'package:analyzer/src/generated/ast.dart'; 14 | import 'package:analyzer/src/generated/scanner.dart'; 15 | import 'package:barback/barback.dart'; 16 | import 'package:code_transformers/messages/build_logger.dart'; 17 | import 'package:source_maps/refactor.dart'; 18 | import 'package:source_span/source_span.dart'; 19 | 20 | import 'src/messages.dart'; 21 | 22 | /// A [Transformer] that replaces observables based on dirty-checking with an 23 | /// implementation based on change notifications. 24 | /// 25 | /// The transformation adds hooks for field setters and notifies the observation 26 | /// system of the change. 27 | class ObservableTransformer extends Transformer { 28 | final bool releaseMode; 29 | final bool injectBuildLogsInOutput; 30 | final List _files; 31 | 32 | ObservableTransformer( 33 | {List files, bool releaseMode, bool injectBuildLogsInOutput}) 34 | : _files = files, 35 | releaseMode = releaseMode == true, 36 | injectBuildLogsInOutput = injectBuildLogsInOutput == null 37 | ? releaseMode != true 38 | : injectBuildLogsInOutput; 39 | 40 | ObservableTransformer.asPlugin(BarbackSettings settings) 41 | : _files = _readFiles(settings.configuration['files']), 42 | releaseMode = settings.mode == BarbackMode.RELEASE, 43 | injectBuildLogsInOutput = settings.mode != BarbackMode.RELEASE; 44 | 45 | static List _readFiles(value) { 46 | if (value == null) return null; 47 | var files = []; 48 | bool error; 49 | if (value is List) { 50 | files = new List.from(value); 51 | error = value.any((e) => e is! String); 52 | } else if (value is String) { 53 | files = [value]; 54 | error = false; 55 | } else { 56 | error = true; 57 | } 58 | if (error) print('Invalid value for "files" in the observe transformer.'); 59 | return files; 60 | } 61 | 62 | // TODO(nweiz): This should just take an AssetId when barback <0.13.0 support 63 | // is dropped. 64 | Future isPrimary(Object idOrAsset) { 65 | var id = idOrAsset is AssetId ? idOrAsset : (idOrAsset as Asset).id; 66 | return new Future.value(id.extension == '.dart' && 67 | (_files == null || _files.contains(id.path))); 68 | } 69 | 70 | Future apply(Transform transform) { 71 | return transform.primaryInput.readAsString().then((content) { 72 | // Do a quick string check to determine if this is this file even 73 | // plausibly might need to be transformed. If not, we can avoid an 74 | // expensive parse. 75 | if (!observableMatcher.hasMatch(content)) return null; 76 | 77 | var id = transform.primaryInput.id; 78 | // TODO(sigmund): improve how we compute this url 79 | var url = id.path.startsWith('lib/') 80 | ? 'package:${id.package}/${id.path.substring(4)}' 81 | : id.path; 82 | var sourceFile = new SourceFile(content, url: url); 83 | var logger = new BuildLogger(transform, 84 | convertErrorsToWarnings: !releaseMode, 85 | detailsUri: 'http://goo.gl/5HPeuP'); 86 | var transaction = _transformCompilationUnit(content, sourceFile, logger); 87 | if (!transaction.hasEdits) { 88 | transform.addOutput(transform.primaryInput); 89 | } else { 90 | var printer = transaction.commit(); 91 | // TODO(sigmund): emit source maps when barback supports it (see 92 | // dartbug.com/12340) 93 | printer.build(url); 94 | transform.addOutput(new Asset.fromString(id, printer.text)); 95 | } 96 | 97 | if (injectBuildLogsInOutput) return logger.writeOutput(); 98 | }); 99 | } 100 | } 101 | 102 | TextEditTransaction _transformCompilationUnit( 103 | String inputCode, SourceFile sourceFile, BuildLogger logger) { 104 | var unit = parseCompilationUnit(inputCode, suppressErrors: true); 105 | var code = new TextEditTransaction(inputCode, sourceFile); 106 | for (var directive in unit.directives) { 107 | if (directive is LibraryDirective && _hasObservable(directive)) { 108 | logger.warning(NO_OBSERVABLE_ON_LIBRARY, 109 | span: _getSpan(sourceFile, directive)); 110 | break; 111 | } 112 | } 113 | 114 | for (var declaration in unit.declarations) { 115 | if (declaration is ClassDeclaration) { 116 | _transformClass(declaration, code, sourceFile, logger); 117 | } else if (declaration is TopLevelVariableDeclaration) { 118 | if (_hasObservable(declaration)) { 119 | logger.warning(NO_OBSERVABLE_ON_TOP_LEVEL, 120 | span: _getSpan(sourceFile, declaration)); 121 | } 122 | } 123 | } 124 | return code; 125 | } 126 | 127 | _getSpan(SourceFile file, AstNode node) => file.span(node.offset, node.end); 128 | 129 | /// True if the node has the `@observable` or `@published` annotation. 130 | // TODO(jmesserly): it is not good to be hard coding Polymer support here. 131 | bool _hasObservable(AnnotatedNode node) => 132 | node.metadata.any(_isObservableAnnotation); 133 | 134 | // TODO(jmesserly): this isn't correct if the annotation has been imported 135 | // with a prefix, or cases like that. We should technically be resolving, but 136 | // that is expensive in analyzer, so it isn't feasible yet. 137 | bool _isObservableAnnotation(Annotation node) => 138 | _isAnnotationContant(node, 'observable') || 139 | _isAnnotationContant(node, 'published') || 140 | _isAnnotationType(node, 'ObservableProperty') || 141 | _isAnnotationType(node, 'PublishedProperty'); 142 | 143 | bool _isAnnotationContant(Annotation m, String name) => 144 | m.name.name == name && m.constructorName == null && m.arguments == null; 145 | 146 | bool _isAnnotationType(Annotation m, String name) => m.name.name == name; 147 | 148 | void _transformClass(ClassDeclaration cls, TextEditTransaction code, 149 | SourceFile file, BuildLogger logger) { 150 | if (_hasObservable(cls)) { 151 | logger.warning(NO_OBSERVABLE_ON_CLASS, span: _getSpan(file, cls)); 152 | } 153 | 154 | // We'd like to track whether observable was declared explicitly, otherwise 155 | // report a warning later below. Because we don't have type analysis (only 156 | // syntactic understanding of the code), we only report warnings that are 157 | // known to be true. 158 | var explicitObservable = false; 159 | var implicitObservable = false; 160 | if (cls.extendsClause != null) { 161 | var id = _getSimpleIdentifier(cls.extendsClause.superclass.name); 162 | if (id.name == 'AutoObservable') { 163 | code.edit(id.offset, id.end, 'Observable'); 164 | explicitObservable = true; 165 | } else if (id.name == 'Observable') { 166 | explicitObservable = true; 167 | } else if (id.name != 'HtmlElement' && 168 | id.name != 'CustomElement' && 169 | id.name != 'Object') { 170 | // TODO(sigmund): this is conservative, consider using type-resolution to 171 | // improve this check. 172 | implicitObservable = true; 173 | } 174 | } 175 | 176 | if (cls.withClause != null) { 177 | for (var type in cls.withClause.mixinTypes) { 178 | var id = _getSimpleIdentifier(type.name); 179 | if (id.name == 'AutoObservable') { 180 | code.edit(id.offset, id.end, 'Observable'); 181 | explicitObservable = true; 182 | break; 183 | } else if (id.name == 'Observable') { 184 | explicitObservable = true; 185 | break; 186 | } else { 187 | // TODO(sigmund): this is conservative, consider using type-resolution 188 | // to improve this check. 189 | implicitObservable = true; 190 | } 191 | } 192 | } 193 | 194 | if (cls.implementsClause != null) { 195 | // TODO(sigmund): consider adding type-resolution to give a more precise 196 | // answer. 197 | implicitObservable = true; 198 | } 199 | 200 | var declaresObservable = explicitObservable || implicitObservable; 201 | 202 | // Track fields that were transformed. 203 | var instanceFields = new Set(); 204 | 205 | for (var member in cls.members) { 206 | if (member is FieldDeclaration) { 207 | if (member.isStatic) { 208 | if (_hasObservable(member)) { 209 | logger.warning(NO_OBSERVABLE_ON_STATIC_FIELD, 210 | span: _getSpan(file, member)); 211 | } 212 | continue; 213 | } 214 | if (_hasObservable(member)) { 215 | if (!declaresObservable) { 216 | logger.warning(REQUIRE_OBSERVABLE_INTERFACE, 217 | span: _getSpan(file, member)); 218 | } 219 | _transformFields(file, member, code, logger); 220 | 221 | var names = member.fields.variables.map((v) => v.name.name); 222 | 223 | if (!_isReadOnly(member.fields)) instanceFields.addAll(names); 224 | } 225 | } 226 | } 227 | 228 | // If nothing was @observable, bail. 229 | if (instanceFields.length == 0) return; 230 | 231 | if (!explicitObservable) _mixinObservable(cls, code); 232 | 233 | // Fix initializers, because they aren't allowed to call the setter. 234 | for (var member in cls.members) { 235 | if (member is ConstructorDeclaration) { 236 | _fixConstructor(member, code, instanceFields); 237 | } 238 | } 239 | } 240 | 241 | /// Adds "with Observable" and associated implementation. 242 | void _mixinObservable(ClassDeclaration cls, TextEditTransaction code) { 243 | // Note: we need to be careful to put the with clause after extends, but 244 | // before implements clause. 245 | if (cls.withClause != null) { 246 | var pos = cls.withClause.end; 247 | code.edit(pos, pos, ', Observable'); 248 | } else if (cls.extendsClause != null) { 249 | var pos = cls.extendsClause.end; 250 | code.edit(pos, pos, ' with Observable '); 251 | } else { 252 | var params = cls.typeParameters; 253 | var pos = params != null ? params.end : cls.name.end; 254 | code.edit(pos, pos, ' extends Observable '); 255 | } 256 | } 257 | 258 | SimpleIdentifier _getSimpleIdentifier(Identifier id) => 259 | id is PrefixedIdentifier ? id.identifier : id; 260 | 261 | bool _hasKeyword(Token token, Keyword keyword) => 262 | (token?.type?.isKeyword ?? false) && token.lexeme == keyword.syntax; 263 | 264 | String _getOriginalCode(TextEditTransaction code, AstNode node) => 265 | code.original.substring(node.offset, node.end); 266 | 267 | void _fixConstructor(ConstructorDeclaration ctor, TextEditTransaction code, 268 | Set changedFields) { 269 | // Fix normal initializers 270 | for (var initializer in ctor.initializers) { 271 | if (initializer is ConstructorFieldInitializer) { 272 | var field = initializer.fieldName; 273 | if (changedFields.contains(field.name)) { 274 | code.edit(field.offset, field.end, '__\$${field.name}'); 275 | } 276 | } 277 | } 278 | 279 | // Fix "this." initializer in parameter list. These are tricky: 280 | // we need to preserve the name and add an initializer. 281 | // Preserving the name is important for named args, and for dartdoc. 282 | // BEFORE: Foo(this.bar, this.baz) { ... } 283 | // AFTER: Foo(bar, baz) : __$bar = bar, __$baz = baz { ... } 284 | 285 | var thisInit = []; 286 | for (var param in ctor.parameters.parameters) { 287 | if (param is DefaultFormalParameter) { 288 | param = (param as DefaultFormalParameter).parameter; 289 | } 290 | if (param is FieldFormalParameter) { 291 | var name = param.identifier.name; 292 | if (changedFields.contains(name)) { 293 | thisInit.add(name); 294 | // Remove "this." but keep everything else. 295 | code.edit(param.thisKeyword.offset, param.period.end, ''); 296 | } 297 | } 298 | } 299 | 300 | if (thisInit.length == 0) return; 301 | 302 | // TODO(jmesserly): smarter formatting with indent, etc. 303 | var inserted = thisInit.map((i) => '__\$$i = $i').join(', '); 304 | 305 | int offset; 306 | if (ctor.separator != null) { 307 | offset = ctor.separator.end; 308 | inserted = ' $inserted,'; 309 | } else { 310 | offset = ctor.parameters.end; 311 | inserted = ' : $inserted'; 312 | } 313 | 314 | code.edit(offset, offset, inserted); 315 | } 316 | 317 | bool _isReadOnly(VariableDeclarationList fields) { 318 | return _hasKeyword(fields.keyword, Keyword.CONST) || 319 | _hasKeyword(fields.keyword, Keyword.FINAL); 320 | } 321 | 322 | void _transformFields(SourceFile file, FieldDeclaration member, 323 | TextEditTransaction code, BuildLogger logger) { 324 | final fields = member.fields; 325 | if (_isReadOnly(fields)) return; 326 | 327 | // Private fields aren't supported: 328 | for (var field in fields.variables) { 329 | final name = field.name.name; 330 | if (Identifier.isPrivateName(name)) { 331 | logger.warning('Cannot make private field $name observable.', 332 | span: _getSpan(file, field)); 333 | return; 334 | } 335 | } 336 | 337 | // Unfortunately "var" doesn't work in all positions where type annotations 338 | // are allowed, such as "var get name". So we use "dynamic" instead. 339 | var type = 'dynamic'; 340 | if (fields.type != null) { 341 | type = _getOriginalCode(code, fields.type); 342 | } else if (_hasKeyword(fields.keyword, Keyword.VAR)) { 343 | // Replace 'var' with 'dynamic' 344 | code.edit(fields.keyword.offset, fields.keyword.end, type); 345 | } 346 | 347 | // Note: the replacements here are a bit subtle. It needs to support multiple 348 | // fields declared via the same @observable, as well as preserving newlines. 349 | // (Preserving newlines is important because it allows the generated code to 350 | // be debugged without needing a source map.) 351 | // 352 | // For example: 353 | // 354 | // @observable 355 | // @otherMetaData 356 | // Foo 357 | // foo = 1, bar = 2, 358 | // baz; 359 | // 360 | // Will be transformed into something like: 361 | // 362 | // @reflectable @observable 363 | // @OtherMetaData() 364 | // Foo 365 | // get foo => __foo; Foo __foo = 1; @reflectable set foo ...; ... 366 | // @observable @OtherMetaData() Foo get baz => __baz; Foo baz; ... 367 | // 368 | // Metadata is moved to the getter. 369 | 370 | String metadata = ''; 371 | if (fields.variables.length > 1) { 372 | metadata = member.metadata.map((m) => _getOriginalCode(code, m)).join(' '); 373 | metadata = '@reflectable $metadata'; 374 | } 375 | 376 | for (int i = 0; i < fields.variables.length; i++) { 377 | final field = fields.variables[i]; 378 | final name = field.name.name; 379 | 380 | var beforeInit = 'get $name => __\$$name; $type __\$$name'; 381 | 382 | // The first field is expanded differently from subsequent fields, because 383 | // we can reuse the metadata and type annotation. 384 | if (i == 0) { 385 | final begin = member.metadata.first.offset; 386 | code.edit(begin, begin, '@reflectable '); 387 | } else { 388 | beforeInit = '$metadata $type $beforeInit'; 389 | } 390 | 391 | code.edit(field.name.offset, field.name.end, beforeInit); 392 | 393 | // Replace comma with semicolon 394 | final end = _findFieldSeperator(field.endToken.next); 395 | if (end.type == TokenType.COMMA) code.edit(end.offset, end.end, ';'); 396 | 397 | code.edit( 398 | end.end, 399 | end.end, 400 | ' @reflectable set $name($type value) { ' 401 | '__\$$name = notifyPropertyChange(#$name, __\$$name, value); }'); 402 | } 403 | } 404 | 405 | Token _findFieldSeperator(Token token) { 406 | while (token != null) { 407 | if (token.type == TokenType.COMMA || token.type == TokenType.SEMICOLON) { 408 | break; 409 | } 410 | token = token.next; 411 | } 412 | return token; 413 | } 414 | 415 | // TODO(sigmund): remove hard coded Polymer support (@published). The proper way 416 | // to do this would be to switch to use the analyzer to resolve whether 417 | // annotations are subtypes of ObservableProperty. 418 | final observableMatcher = 419 | new RegExp("@(published|observable|PublishedProperty|ObservableProperty)"); 420 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: observe 2 | version: 0.15.2 3 | author: Polymer.dart Authors 4 | description: > 5 | Observable properties and objects for use in template_binding. 6 | Template Binding extends HTML and the DOM APIs to support a sensible 7 | separation between the UI (DOM) of a document or application and its 8 | underlying data (model). Updates to the model are reflected in the DOM and 9 | user input into the DOM is immediately assigned to the model. 10 | homepage: https://www.dartlang.org/polymer-dart/ 11 | dependencies: 12 | analyzer: '>=0.29.11 <0.30.0' 13 | barback: '>=0.14.2 <0.16.0' 14 | func: '>=0.1.0 <2.0.0' 15 | logging: '>=0.9.0 <0.12.0' 16 | observable: '>=0.17.0 <0.21.0' 17 | path: '>=0.9.0 <2.0.0' 18 | smoke: '>=0.1.0 <0.4.0' 19 | source_maps: '>=0.9.4 <0.11.0' 20 | source_span: ^1.0.0 21 | utf: ^0.9.0 22 | code_transformers: '>=0.4.2 <0.6.0' 23 | dev_dependencies: 24 | benchmark_harness: '>=1.0.0 <2.0.0' 25 | browser: any 26 | chart: '>=1.0.8 <2.0.0' 27 | test: '^0.12.18+1' 28 | stack_trace: '>=0.9.1 <2.0.0' 29 | environment: 30 | sdk: ">=1.24.0 <2.0.0" 31 | transformers: 32 | - observe: 33 | files: 34 | - benchmark/test_observable.dart 35 | - benchmark/test_path_observable.dart 36 | - test/pub_serve: 37 | $include: test/**_test.dart 38 | -------------------------------------------------------------------------------- /test/list_change_test.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file 2 | // for details. All rights reserved. Use of this source code is governed by a 3 | // BSD-style license that can be found in the LICENSE file. 4 | 5 | import 'dart:async'; 6 | import 'package:observable/observable.dart'; 7 | import 'package:observe/observe.dart'; 8 | import 'observe_test_utils.dart'; 9 | 10 | // This file contains code ported from: 11 | // https://github.com/rafaelw/ChangeSummary/blob/master/tests/test.js 12 | 13 | main() => listChangeTests(); 14 | 15 | // TODO(jmesserly): port or write array fuzzer tests 16 | listChangeTests() { 17 | StreamSubscription sub; 18 | var model; 19 | 20 | tearDown(() { 21 | sub.cancel(); 22 | model = null; 23 | }); 24 | 25 | _delta(i, r, a) => new ListChangeRecord(model, i, removed: r, addedCount: a); 26 | 27 | test('sequential adds', () { 28 | model = toObservable([]); 29 | model.add(0); 30 | 31 | var summary; 32 | sub = model.listChanges.listen((r) { 33 | summary = r; 34 | }); 35 | 36 | model.add(1); 37 | model.add(2); 38 | 39 | expect(summary, null); 40 | return new Future(() => expectChanges(summary, [_delta(1, [], 2)])); 41 | }); 42 | 43 | test('List Splice Truncate And Expand With Length', () { 44 | model = toObservable(['a', 'b', 'c', 'd', 'e']); 45 | 46 | var summary; 47 | sub = model.listChanges.listen((r) { 48 | summary = r; 49 | }); 50 | 51 | model.length = 2; 52 | 53 | return new Future(() { 54 | expectChanges(summary, [ 55 | _delta(2, ['c', 'd', 'e'], 0) 56 | ]); 57 | summary = null; 58 | model.length = 5; 59 | }).then(newMicrotask).then((_) { 60 | expectChanges(summary, [_delta(2, [], 3)]); 61 | }); 62 | }); 63 | 64 | group('List deltas can be applied', () { 65 | applyAndCheckDeltas(model, copy, changes) => changes.then((summary) { 66 | // apply deltas to the copy 67 | for (var delta in summary) { 68 | copy.removeRange(delta.index, delta.index + delta.removed.length); 69 | for (int i = delta.addedCount - 1; i >= 0; i--) { 70 | copy.insert(delta.index, model[delta.index + i]); 71 | } 72 | } 73 | 74 | // Note: compare strings for easier debugging. 75 | expect('$copy', '$model', reason: 'summary $summary'); 76 | }); 77 | 78 | test('Contained', () { 79 | var model = toObservable(['a', 'b']); 80 | var copy = model.toList(); 81 | var changes = model.listChanges.first; 82 | 83 | model.removeAt(1); 84 | model.insertAll(0, ['c', 'd', 'e']); 85 | model.removeRange(1, 3); 86 | model.insert(1, 'f'); 87 | 88 | return applyAndCheckDeltas(model, copy, changes); 89 | }); 90 | 91 | test('Delete Empty', () { 92 | var model = toObservable([1]); 93 | var copy = model.toList(); 94 | var changes = model.listChanges.first; 95 | 96 | model.removeAt(0); 97 | model.insertAll(0, ['a', 'b', 'c']); 98 | 99 | return applyAndCheckDeltas(model, copy, changes); 100 | }); 101 | 102 | test('Right Non Overlap', () { 103 | var model = toObservable(['a', 'b', 'c', 'd']); 104 | var copy = model.toList(); 105 | var changes = model.listChanges.first; 106 | 107 | model.removeRange(0, 1); 108 | model.insert(0, 'e'); 109 | model.removeRange(2, 3); 110 | model.insertAll(2, ['f', 'g']); 111 | 112 | return applyAndCheckDeltas(model, copy, changes); 113 | }); 114 | 115 | test('Left Non Overlap', () { 116 | var model = toObservable(['a', 'b', 'c', 'd']); 117 | var copy = model.toList(); 118 | var changes = model.listChanges.first; 119 | 120 | model.removeRange(3, 4); 121 | model.insertAll(3, ['f', 'g']); 122 | model.removeRange(0, 1); 123 | model.insert(0, 'e'); 124 | 125 | return applyAndCheckDeltas(model, copy, changes); 126 | }); 127 | 128 | test('Right Adjacent', () { 129 | var model = toObservable(['a', 'b', 'c', 'd']); 130 | var copy = model.toList(); 131 | var changes = model.listChanges.first; 132 | 133 | model.removeRange(1, 2); 134 | model.insert(3, 'e'); 135 | model.removeRange(2, 3); 136 | model.insertAll(0, ['f', 'g']); 137 | 138 | return applyAndCheckDeltas(model, copy, changes); 139 | }); 140 | 141 | test('Left Adjacent', () { 142 | var model = toObservable(['a', 'b', 'c', 'd']); 143 | var copy = model.toList(); 144 | var changes = model.listChanges.first; 145 | 146 | model.removeRange(2, 4); 147 | model.insert(2, 'e'); 148 | 149 | model.removeAt(1); 150 | model.insertAll(1, ['f', 'g']); 151 | 152 | return applyAndCheckDeltas(model, copy, changes); 153 | }); 154 | 155 | test('Right Overlap', () { 156 | var model = toObservable(['a', 'b', 'c', 'd']); 157 | var copy = model.toList(); 158 | var changes = model.listChanges.first; 159 | 160 | model.removeAt(1); 161 | model.insert(1, 'e'); 162 | model.removeAt(1); 163 | model.insertAll(1, ['f', 'g']); 164 | 165 | return applyAndCheckDeltas(model, copy, changes); 166 | }); 167 | 168 | test('Left Overlap', () { 169 | var model = toObservable(['a', 'b', 'c', 'd']); 170 | var copy = model.toList(); 171 | var changes = model.listChanges.first; 172 | 173 | model.removeAt(2); 174 | model.insertAll(2, ['e', 'f', 'g']); 175 | // a b [e f g] d 176 | model.removeRange(1, 3); 177 | model.insertAll(1, ['h', 'i', 'j']); 178 | // a [h i j] f g d 179 | 180 | return applyAndCheckDeltas(model, copy, changes); 181 | }); 182 | 183 | test('Prefix And Suffix One In', () { 184 | var model = toObservable(['a', 'b', 'c', 'd']); 185 | var copy = model.toList(); 186 | var changes = model.listChanges.first; 187 | 188 | model.insert(0, 'z'); 189 | model.add('z'); 190 | 191 | return applyAndCheckDeltas(model, copy, changes); 192 | }); 193 | 194 | test('Remove First', () { 195 | var model = toObservable([16, 15, 15]); 196 | var copy = model.toList(); 197 | var changes = model.listChanges.first; 198 | 199 | model.removeAt(0); 200 | 201 | return applyAndCheckDeltas(model, copy, changes); 202 | }); 203 | 204 | test('Update Remove', () { 205 | var model = toObservable(['a', 'b', 'c', 'd']); 206 | var copy = model.toList(); 207 | var changes = model.listChanges.first; 208 | 209 | model.removeAt(2); 210 | model.insertAll(2, ['e', 'f', 'g']); // a b [e f g] d 211 | model[0] = 'h'; 212 | model.removeAt(1); 213 | 214 | return applyAndCheckDeltas(model, copy, changes); 215 | }); 216 | 217 | test('Remove Mid List', () { 218 | var model = toObservable(['a', 'b', 'c', 'd']); 219 | var copy = model.toList(); 220 | var changes = model.listChanges.first; 221 | 222 | model.removeAt(2); 223 | 224 | return applyAndCheckDeltas(model, copy, changes); 225 | }); 226 | }); 227 | 228 | group('edit distance', () { 229 | assertEditDistance(orig, changes, expectedDist) => changes.then((summary) { 230 | var actualDistance = 0; 231 | for (var delta in summary) { 232 | actualDistance += delta.addedCount + delta.removed.length; 233 | } 234 | 235 | expect(actualDistance, expectedDist); 236 | }); 237 | 238 | test('add items', () { 239 | var model = toObservable([]); 240 | var changes = model.listChanges.first; 241 | model.addAll([1, 2, 3]); 242 | return assertEditDistance(model, changes, 3); 243 | }); 244 | 245 | test('trunacte and add, sharing a contiguous block', () { 246 | var model = toObservable(['x', 'x', 'x', 'x', '1', '2', '3']); 247 | var changes = model.listChanges.first; 248 | model.length = 0; 249 | model.addAll(['1', '2', '3', 'y', 'y', 'y', 'y']); 250 | return assertEditDistance(model, changes, 8); 251 | }); 252 | 253 | test('truncate and add, sharing a discontiguous block', () { 254 | var model = toObservable(['1', '2', '3', '4', '5']); 255 | var changes = model.listChanges.first; 256 | model.length = 0; 257 | model.addAll(['a', '2', 'y', 'y', '4', '5', 'z', 'z']); 258 | return assertEditDistance(model, changes, 7); 259 | }); 260 | 261 | test('insert at beginning and end', () { 262 | var model = toObservable([2, 3, 4]); 263 | var changes = model.listChanges.first; 264 | model.insert(0, 5); 265 | model[2] = 6; 266 | model.add(7); 267 | return assertEditDistance(model, changes, 4); 268 | }); 269 | }); 270 | } 271 | -------------------------------------------------------------------------------- /test/list_path_observer_test.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file 2 | // for details. All rights reserved. Use of this source code is governed by a 3 | // BSD-style license that can be found in the LICENSE file. 4 | 5 | import 'dart:async'; 6 | import 'package:observable/observable.dart'; 7 | import 'package:observe/observe.dart'; 8 | import 'observe_test_utils.dart'; 9 | 10 | import 'package:observe/mirrors_used.dart' as mu; // make test smaller. 11 | import 'package:smoke/mirrors.dart'; 12 | 13 | /// Uses [mu]. 14 | main() { 15 | useMirrors(); 16 | dirtyCheckZone().run(_runTests); 17 | } 18 | 19 | _runTests() { 20 | var list; 21 | var obs; 22 | var o1, o2; 23 | var sub; 24 | int changes; 25 | 26 | setUp(() { 27 | list = toObservable([ 28 | o1 = new TestModel()..a = (new TestModel()..b = 1), 29 | o2 = new TestModel()..a = (new TestModel()..b = 2), 30 | new TestModel()..a = (new TestModel()..b = 3) 31 | ]); 32 | obs = new ListPathObserver(list, 'a.b'); 33 | changes = 0; 34 | sub = obs.changes.listen((e) { 35 | changes++; 36 | }); 37 | }); 38 | 39 | tearDown(() { 40 | sub.cancel(); 41 | list = obs = o1 = o2 = null; 42 | }); 43 | 44 | test('list path observer noticed length changes', () { 45 | expect(o2.a.b, 2); 46 | expect(list[1].a.b, 2); 47 | return _nextMicrotask(null) 48 | .then((_) { 49 | expect(changes, 0); 50 | list.removeAt(1); 51 | }) 52 | .then(_nextMicrotask) 53 | .then((_) { 54 | expect(changes, 1); 55 | expect(list[1].a.b, 3); 56 | }); 57 | }); 58 | 59 | test('list path observer delivers deep change', () { 60 | expect(o2.a.b, 2); 61 | expect(list[1].a.b, 2); 62 | int changes = 0; 63 | obs.changes.listen((e) { 64 | changes++; 65 | }); 66 | return _nextMicrotask(null) 67 | .then((_) { 68 | expect(changes, 0); 69 | o2.a.b = 4; 70 | }) 71 | .then(_nextMicrotask) 72 | .then((_) { 73 | expect(changes, 1); 74 | expect(list[1].a.b, 4); 75 | o1.a = new TestModel()..b = 5; 76 | }) 77 | .then(_nextMicrotask) 78 | .then((_) { 79 | expect(changes, 2); 80 | expect(list[0].a.b, 5); 81 | }); 82 | }); 83 | } 84 | 85 | _nextMicrotask(_) => new Future(() {}); 86 | 87 | @reflectable 88 | class TestModel extends PropertyChangeNotifier { 89 | var _a, _b; 90 | TestModel(); 91 | 92 | get a => _a; 93 | void set a(newValue) { 94 | _a = notifyPropertyChange(#a, _a, newValue); 95 | } 96 | 97 | get b => _b; 98 | void set b(newValue) { 99 | _b = notifyPropertyChange(#b, _b, newValue); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /test/observe_test.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file 2 | // for details. All rights reserved. Use of this source code is governed by a 3 | // BSD-style license that can be found in the LICENSE file. 4 | 5 | import 'dart:async'; 6 | import 'package:logging/logging.dart'; 7 | import 'package:observable/observable.dart'; 8 | import 'package:observe/observe.dart'; 9 | import 'package:observe/src/dirty_check.dart' as dirty_check; 10 | import 'observe_test_utils.dart'; 11 | 12 | import 'package:observe/mirrors_used.dart' as mu; // make test smaller. 13 | import 'package:smoke/mirrors.dart'; 14 | 15 | /// Uses [mu]. 16 | main() { 17 | useMirrors(); 18 | _tests(); 19 | } 20 | 21 | void _tests() { 22 | // Note: to test the basic AutoObservable system, we use ObservableBox due to its 23 | // simplicity. We also test a variant that is based on dirty-checking. 24 | 25 | test('no observers at the start', () { 26 | expect(dirty_check.allObservablesCount, 0); 27 | }); 28 | 29 | group('WatcherModel', () => _observeTests(true, (x) => new WatcherModel(x))); 30 | 31 | group( 32 | 'ObservableBox', () => _observeTests(false, (x) => new ObservableBox(x))); 33 | 34 | group( 35 | 'ModelSubclass', () => _observeTests(true, (x) => new ModelSubclass(x))); 36 | 37 | group('dirtyCheck loops can be debugged', () { 38 | var messages; 39 | var subscription; 40 | setUp(() { 41 | messages = []; 42 | subscription = Logger.root.onRecord.listen((record) { 43 | messages.add(record.message); 44 | }); 45 | }); 46 | 47 | tearDown(() { 48 | subscription.cancel(); 49 | }); 50 | 51 | test('logs debug information', () { 52 | var maxNumIterations = dirty_check.MAX_DIRTY_CHECK_CYCLES; 53 | 54 | var x = new WatcherModel(0); 55 | int called = 0; 56 | var sub = x.changes.listen((_) { 57 | called++; 58 | x.value++; 59 | }); 60 | x.value = 1; 61 | AutoObservable.dirtyCheck(); 62 | expect(called, maxNumIterations); 63 | expect(x.value, maxNumIterations + 1); 64 | expect(messages.length, 2); 65 | 66 | expect(messages[0], contains('Possible loop')); 67 | expect(messages[1], contains('index 0')); 68 | expect(messages[1], contains('object: $x')); 69 | 70 | sub.cancel(); 71 | }); 72 | }); 73 | } 74 | 75 | void _observeTests(final bool watch, createModel(x)) { 76 | // Track the subscriptions so we can clean them up in tearDown. 77 | List subs; 78 | 79 | int initialObservers; 80 | setUp(() { 81 | initialObservers = dirty_check.allObservablesCount; 82 | subs = []; 83 | 84 | if (watch) scheduleMicrotask(AutoObservable.dirtyCheck); 85 | }); 86 | 87 | tearDown(() { 88 | for (var sub in subs) sub.cancel(); 89 | return new Future(() { 90 | expect(dirty_check.allObservablesCount, initialObservers, 91 | reason: 'AutoObservable object leaked'); 92 | }); 93 | }); 94 | 95 | test('handle future result', () { 96 | var callback = expectAsync0(() {}); 97 | return new Future(callback); 98 | }); 99 | 100 | test('no observers', () { 101 | var t = createModel(123); 102 | expect(t.value, 123); 103 | t.value = 42; 104 | expect(t.value, 42); 105 | expect(t.hasObservers, false); 106 | }); 107 | 108 | test('listen adds an observer', () { 109 | var t = createModel(123); 110 | expect(t.hasObservers, false); 111 | 112 | subs.add(t.changes.listen((n) {})); 113 | expect(t.hasObservers, true); 114 | }); 115 | 116 | test('changes delived async', () { 117 | var t = createModel(123); 118 | int called = 0; 119 | 120 | subs.add(t.changes.listen(expectAsync1((records) { 121 | called++; 122 | expectPropertyChanges(records, watch ? 1 : 2); 123 | }))); 124 | 125 | t.value = 41; 126 | t.value = 42; 127 | expect(called, 0); 128 | }); 129 | 130 | test('cause changes in handler', () { 131 | var t = createModel(123); 132 | int called = 0; 133 | 134 | subs.add(t.changes.listen(expectAsync1((records) { 135 | called++; 136 | expectPropertyChanges(records, 1); 137 | if (called == 1) { 138 | // Cause another change 139 | t.value = 777; 140 | } 141 | }, count: 2))); 142 | 143 | t.value = 42; 144 | }); 145 | 146 | test('multiple observers', () { 147 | var t = createModel(123); 148 | 149 | verifyRecords(records) { 150 | expectPropertyChanges(records, watch ? 1 : 2); 151 | } 152 | 153 | subs.add(t.changes.listen(expectAsync1(verifyRecords))); 154 | subs.add(t.changes.listen(expectAsync1(verifyRecords))); 155 | 156 | t.value = 41; 157 | t.value = 42; 158 | }); 159 | 160 | test('async processing model', () { 161 | var t = createModel(123); 162 | var records = []; 163 | subs.add(t.changes.listen((r) { 164 | records.addAll(r); 165 | })); 166 | t.value = 41; 167 | t.value = 42; 168 | expectChanges(records, [], reason: 'changes delived async'); 169 | 170 | return new Future(() { 171 | expectPropertyChanges(records, watch ? 1 : 2); 172 | records.clear(); 173 | 174 | t.value = 777; 175 | expectChanges(records, [], reason: 'changes delived async'); 176 | }).then(newMicrotask).then((_) { 177 | expectPropertyChanges(records, 1); 178 | 179 | // Has no effect if there are no changes 180 | AutoObservable.dirtyCheck(); 181 | expectPropertyChanges(records, 1); 182 | }); 183 | }); 184 | 185 | test('cancel listening', () { 186 | var t = createModel(123); 187 | var sub; 188 | sub = t.changes.listen(expectAsync1((records) { 189 | expectPropertyChanges(records, 1); 190 | sub.cancel(); 191 | t.value = 777; 192 | scheduleMicrotask(AutoObservable.dirtyCheck); 193 | })); 194 | t.value = 42; 195 | }); 196 | 197 | test('cancel and reobserve', () { 198 | var t = createModel(123); 199 | var sub; 200 | sub = t.changes.listen(expectAsync1((records) { 201 | expectPropertyChanges(records, 1); 202 | sub.cancel(); 203 | 204 | scheduleMicrotask(() { 205 | subs.add(t.changes.listen(expectAsync1((records) { 206 | expectPropertyChanges(records, 1); 207 | }))); 208 | t.value = 777; 209 | scheduleMicrotask(AutoObservable.dirtyCheck); 210 | }); 211 | })); 212 | t.value = 42; 213 | }); 214 | 215 | test('cannot modify changes list', () { 216 | var t = createModel(123); 217 | var records = null; 218 | subs.add(t.changes.listen((r) { 219 | records = r; 220 | })); 221 | t.value = 42; 222 | 223 | return new Future(() { 224 | expectPropertyChanges(records, 1); 225 | 226 | // Verify that mutation operations on the list fail: 227 | 228 | expect(() { 229 | records[0] = new PropertyChangeRecord(t, #value, 0, 1); 230 | }, throwsUnsupportedError); 231 | 232 | expect(() { 233 | records.clear(); 234 | }, throwsUnsupportedError); 235 | 236 | expect(() { 237 | records.length = 0; 238 | }, throwsUnsupportedError); 239 | }); 240 | }); 241 | 242 | test('notifyChange', () { 243 | var t = createModel(123); 244 | var records = []; 245 | subs.add(t.changes.listen((r) { 246 | records.addAll(r); 247 | })); 248 | t.notifyChange(new PropertyChangeRecord(t, #value, 123, 42)); 249 | 250 | return new Future(() { 251 | expectPropertyChanges(records, 1); 252 | expect(t.value, 123, reason: 'value did not actually change.'); 253 | }); 254 | }); 255 | 256 | test('notifyPropertyChange', () { 257 | var t = createModel(123); 258 | var records = null; 259 | subs.add(t.changes.listen((r) { 260 | records = r; 261 | })); 262 | expect(t.notifyPropertyChange(#value, t.value, 42), 42, 263 | reason: 'notifyPropertyChange returns newValue'); 264 | 265 | return new Future(() { 266 | expectPropertyChanges(records, 1); 267 | expect(t.value, 123, reason: 'value did not actually change.'); 268 | }); 269 | }); 270 | } 271 | 272 | expectPropertyChanges(records, int number) { 273 | expect(records.length, number, reason: 'expected $number change records'); 274 | for (var record in records) { 275 | expect(record is PropertyChangeRecord, true, 276 | reason: 'record should be PropertyChangeRecord'); 277 | expect((record as PropertyChangeRecord).name, #value, 278 | reason: 'record should indicate a change to the "value" property'); 279 | } 280 | } 281 | 282 | // A test model based on dirty checking. 283 | class WatcherModel extends AutoObservable { 284 | @observable 285 | T value; 286 | 287 | WatcherModel([T initialValue]) : value = initialValue; 288 | 289 | String toString() => '#<$runtimeType value: $value>'; 290 | } 291 | 292 | class ModelSubclass extends WatcherModel { 293 | ModelSubclass([T initialValue]) : super(initialValue); 294 | } 295 | -------------------------------------------------------------------------------- /test/observe_test_utils.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file 2 | // for details. All rights reserved. Use of this source code is governed by a 3 | // BSD-style license that can be found in the LICENSE file. 4 | 5 | library observe.test.observe_test_utils; 6 | 7 | import 'dart:async'; 8 | import 'package:observe/observe.dart'; 9 | import 'package:observe/mirrors_used.dart' as mu; // to make tests smaller 10 | import 'package:observe/src/dirty_check.dart'; 11 | import 'package:test/test.dart' hide test, group, setUp, tearDown; 12 | import 'package:test/test.dart' as original_test 13 | show test, group, setUp, tearDown; 14 | 15 | export 'package:observe/src/dirty_check.dart' show dirtyCheckZone; 16 | export 'package:test/test.dart' hide test, group, setUp, tearDown; 17 | 18 | /// Custom implementations of the functions from `package:test`. These ensure 19 | /// that the body of all test function are run in the dirty checking zone. 20 | test(String description, body()) => original_test.test( 21 | description, () => dirtyCheckZone().bindCallback(body)()); 22 | 23 | group(String description, body()) => original_test.group( 24 | description, () => dirtyCheckZone().bindCallback(body)()); 25 | 26 | setUp(body()) => 27 | original_test.setUp(() => dirtyCheckZone().bindCallback(body)()); 28 | 29 | tearDown(body()) => 30 | original_test.tearDown(() => dirtyCheckZone().bindCallback(body)()); 31 | 32 | /// A small method to help readability. Used to cause the next "then" in a chain 33 | /// to happen in the next microtask: 34 | /// 35 | /// future.then(newMicrotask).then(...) 36 | /// 37 | /// Uses [mu]. 38 | newMicrotask(_) => new Future.value(); 39 | 40 | // TODO(jmesserly): use matchers when we have a way to compare ChangeRecords. 41 | // For now just use the toString. 42 | expectChanges(actual, expected, {reason}) => 43 | expect('$actual', '$expected', reason: reason); 44 | 45 | List getListChangeRecords(List changes, int index) => 46 | new List.from(changes.where((c) => c.indexChanged(index))); 47 | 48 | List getPropertyChangeRecords( 49 | List changes, Symbol property) => 50 | new List.from( 51 | changes.where((c) => c is PropertyChangeRecord && c.name == property)); 52 | -------------------------------------------------------------------------------- /test/path_observer_test.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file 2 | // for details. All rights reserved. Use of this source code is governed by a 3 | // BSD-style license that can be found in the LICENSE file. 4 | 5 | import 'dart:async'; 6 | import 'package:observable/observable.dart'; 7 | import 'package:observe/observe.dart'; 8 | import 'package:observe/src/path_observer.dart' 9 | show getSegmentsOfPropertyPathForTesting, observerSentinelForTesting; 10 | 11 | import 'observe_test_utils.dart'; 12 | 13 | import 'package:observe/mirrors_used.dart' as mu; // make test smaller. 14 | import 'package:smoke/mirrors.dart'; 15 | 16 | // This file contains code ported from: 17 | // https://github.com/rafaelw/ChangeSummary/blob/master/tests/test.js 18 | // Dart note: getting invalid properties is an error, unlike in JS where it 19 | // returns undefined. This difference comes up where we check for _throwsNSM in 20 | // the tests below. 21 | // 22 | /// Uses [mu]. 23 | main() { 24 | useMirrors(); 25 | 26 | group('PathObserver', observePathTests); 27 | 28 | group('PropertyPath', () { 29 | test('toString length', () { 30 | expectPath(p, str, len, [keys]) { 31 | var path = new PropertyPath(p); 32 | expect(path.toString(), str); 33 | expect(path.length, len, reason: 'expected path length $len for $path'); 34 | if (keys == null) { 35 | expect(path.isValid, isFalse); 36 | } else { 37 | expect(path.isValid, isTrue); 38 | expect(getSegmentsOfPropertyPathForTesting(path), keys); 39 | } 40 | } 41 | 42 | expectPath('/foo', '', 0); 43 | expectPath('1.abc', '', 0); 44 | expectPath('abc', 'abc', 1, [#abc]); 45 | expectPath('a.b.c', 'a.b.c', 3, [#a, #b, #c]); 46 | expectPath('a.b.c ', 'a.b.c', 3, [#a, #b, #c]); 47 | expectPath(' a.b.c', 'a.b.c', 3, [#a, #b, #c]); 48 | expectPath(' a.b.c ', 'a.b.c', 3, [#a, #b, #c]); 49 | expectPath('[1].abc', '[1].abc', 2, [1, #abc]); 50 | expectPath([#qux], 'qux', 1, [#qux]); 51 | expectPath([1, #foo, #bar], '[1].foo.bar', 3, [1, #foo, #bar]); 52 | expectPath([1, #foo, 'bar'], '[1].foo["bar"]', 3, [1, #foo, 'bar']); 53 | 54 | // From test.js: "path validity" test: 55 | 56 | expectPath('', '', 0, []); 57 | expectPath(' ', '', 0, []); 58 | expectPath(null, '', 0, []); 59 | expectPath('a', 'a', 1, [#a]); 60 | expectPath('a.b', 'a.b', 2, [#a, #b]); 61 | expectPath('a. b', 'a.b', 2, [#a, #b]); 62 | expectPath('a .b', 'a.b', 2, [#a, #b]); 63 | expectPath('a . b', 'a.b', 2, [#a, #b]); 64 | expectPath(' a . b ', 'a.b', 2, [#a, #b]); 65 | expectPath('a[0]', 'a[0]', 2, [#a, 0]); 66 | expectPath('a [0]', 'a[0]', 2, [#a, 0]); 67 | expectPath('a[0][1]', 'a[0][1]', 3, [#a, 0, 1]); 68 | expectPath('a [ 0 ] [ 1 ] ', 'a[0][1]', 3, [#a, 0, 1]); 69 | expectPath('[1234567890] ', '[1234567890]', 1, [1234567890]); 70 | expectPath(' [1234567890] ', '[1234567890]', 1, [1234567890]); 71 | expectPath('opt0', 'opt0', 1, [#opt0]); 72 | // Dart note: Modified to avoid a private Dart symbol: 73 | expectPath( 74 | r'$foo.$bar.baz_', r'$foo.$bar.baz_', 3, [#$foo, #$bar, #baz_]); 75 | // Dart note: this test is different because we treat ["baz"] always as a 76 | // indexing operation. 77 | expectPath('foo["baz"]', 'foo.baz', 2, [#foo, #baz]); 78 | expectPath('foo["b\\"az"]', 'foo["b\\"az"]', 2, [#foo, 'b"az']); 79 | expectPath("foo['b\\'az']", 'foo["b\'az"]', 2, [#foo, "b'az"]); 80 | expectPath([#a, #b], 'a.b', 2, [#a, #b]); 81 | expectPath([], '', 0, []); 82 | 83 | expectPath('.', '', 0); 84 | expectPath(' . ', '', 0); 85 | expectPath('..', '', 0); 86 | expectPath('a[4', '', 0); 87 | expectPath('a.b.', '', 0); 88 | expectPath('a,b', '', 0); 89 | expectPath('a["foo]', '', 0); 90 | expectPath('[0x04]', '', 0); 91 | expectPath('[0foo]', '', 0); 92 | expectPath('[foo-bar]', '', 0); 93 | expectPath('foo-bar', '', 0); 94 | expectPath('42', '', 0); 95 | expectPath('a[04]', '', 0); 96 | expectPath(' a [ 04 ]', '', 0); 97 | expectPath(' 42 ', '', 0); 98 | expectPath('foo["bar]', '', 0); 99 | expectPath("foo['bar]", '', 0); 100 | }); 101 | 102 | test('objects with toString are not supported', () { 103 | // Dart note: this was intentionally not ported. See path_observer.dart. 104 | expect(() => new PropertyPath([new Foo('a'), new Foo('b')]), 105 | throwsArgumentError); 106 | }); 107 | 108 | test('invalid path returns null value', () { 109 | var path = new PropertyPath('a b'); 110 | expect(path.isValid, isFalse); 111 | expect( 112 | path.getValueFrom({ 113 | 'a': {'b': 2} 114 | }), 115 | isNull); 116 | }); 117 | 118 | test('caching and ==', () { 119 | var start = new PropertyPath('abc[0]'); 120 | for (int i = 1; i <= 100; i++) { 121 | expect(identical(new PropertyPath('abc[0]'), start), true, 122 | reason: 'should return identical path'); 123 | 124 | var p = new PropertyPath('abc[$i]'); 125 | expect(identical(p, start), false, 126 | reason: 'different paths should not be merged'); 127 | } 128 | var end = new PropertyPath('abc[0]'); 129 | expect(identical(end, start), false, reason: 'first entry expired'); 130 | expect(end, start, reason: 'different instances are equal'); 131 | }); 132 | 133 | test('hashCode equal', () { 134 | var a = new PropertyPath([#foo, 2, #bar]); 135 | var b = new PropertyPath('foo[2].bar'); 136 | expect(identical(a, b), false, reason: 'only strings cached'); 137 | expect(a, b, reason: 'same paths are equal'); 138 | expect(a.hashCode, b.hashCode, reason: 'equal hashCodes'); 139 | }); 140 | 141 | test('hashCode not equal', () { 142 | expect(2.hashCode, isNot(3.hashCode), 143 | reason: 'test depends on 2 and 3 having different hashcodes'); 144 | 145 | var a = new PropertyPath([2]); 146 | var b = new PropertyPath([3]); 147 | expect(a, isNot(b), reason: 'different paths'); 148 | expect(a.hashCode, isNot(b.hashCode), reason: 'different hashCodes'); 149 | }); 150 | }); 151 | 152 | group('CompoundObserver', compoundObserverTests); 153 | } 154 | 155 | observePathTests() { 156 | test('Degenerate Values', () { 157 | expect(new PathObserver(null, '').value, null); 158 | expect(new PathObserver(123, '').value, 123); 159 | expect(() => new PathObserver(123, 'foo.bar.baz').value, _throwsNSM('foo')); 160 | 161 | // shouldn't throw: 162 | new PathObserver(123, '') 163 | ..open((_) {}) 164 | ..close(); 165 | new PropertyPath('').setValueFrom(null, null); 166 | new PropertyPath('').setValueFrom(123, 42); 167 | expect(() => new PropertyPath('foo.bar.baz').setValueFrom(123, 42), 168 | _throwsNSM('foo')); 169 | Object foo = {}; 170 | expect(new PathObserver(foo, '').value, foo); 171 | 172 | foo = new Object(); 173 | expect(new PathObserver(foo, '').value, foo); 174 | 175 | expect(new PathObserver(foo, 'a/3!').value, null); 176 | }); 177 | 178 | test('get value at path ObservableBox', () { 179 | var obj = 180 | new ObservableBox(new ObservableBox(new ObservableBox(1))); 181 | 182 | expect(new PathObserver(obj, '').value, obj); 183 | expect(new PathObserver(obj, 'value').value, obj.value); 184 | expect(new PathObserver(obj, 'value.value').value, obj.value.value); 185 | expect(new PathObserver(obj, 'value.value.value').value, 1); 186 | 187 | obj.value.value.value = 2; 188 | expect(new PathObserver(obj, 'value.value.value').value, 2); 189 | 190 | obj.value.value = new ObservableBox(3); 191 | expect(new PathObserver(obj, 'value.value.value').value, 3); 192 | 193 | obj.value = new ObservableBox(4); 194 | expect(() => new PathObserver(obj, 'value.value.value').value, 195 | _throwsNSM('value')); 196 | expect(new PathObserver(obj, 'value.value').value, 4); 197 | }); 198 | 199 | test('get value at path ObservableMap', () { 200 | var obj = toObservable({ 201 | 'a': { 202 | 'b': {'c': 1} 203 | } 204 | }); 205 | 206 | expect(new PathObserver(obj, '').value, obj); 207 | expect(new PathObserver(obj, 'a').value, obj['a']); 208 | expect(new PathObserver(obj, 'a.b').value, obj['a']['b']); 209 | expect(new PathObserver(obj, 'a.b.c').value, 1); 210 | 211 | obj['a']['b']['c'] = 2; 212 | expect(new PathObserver(obj, 'a.b.c').value, 2); 213 | 214 | obj['a']['b'] = toObservable({'c': 3}); 215 | expect(new PathObserver(obj, 'a.b.c').value, 3); 216 | 217 | obj['a'] = toObservable({'b': 4}); 218 | expect(() => new PathObserver(obj, 'a.b.c').value, _throwsNSM('c')); 219 | expect(new PathObserver(obj, 'a.b').value, 4); 220 | }); 221 | 222 | test('set value at path', () { 223 | var obj = toObservable({}); 224 | new PropertyPath('foo').setValueFrom(obj, 3); 225 | expect(obj['foo'], 3); 226 | 227 | var bar = toObservable({'baz': 3}); 228 | new PropertyPath('bar').setValueFrom(obj, bar); 229 | expect(obj['bar'], bar); 230 | 231 | expect(() => new PropertyPath('bar.baz.bat').setValueFrom(obj, 'not here'), 232 | _throwsNSM('bat=')); 233 | expect(() => new PathObserver(obj, 'bar.baz.bat').value, _throwsNSM('bat')); 234 | }); 235 | 236 | test('set value back to same', () { 237 | var obj = toObservable({}); 238 | var path = new PathObserver(obj, 'foo'); 239 | var values = []; 240 | path.open((x) { 241 | expect(x, path.value, reason: 'callback should get current value'); 242 | values.add(x); 243 | }); 244 | 245 | path.value = 3; 246 | expect(obj['foo'], 3); 247 | expect(path.value, 3); 248 | 249 | new PropertyPath('foo').setValueFrom(obj, 2); 250 | return new Future(() { 251 | expect(path.value, 2); 252 | expect(new PathObserver(obj, 'foo').value, 2); 253 | 254 | new PropertyPath('foo').setValueFrom(obj, 3); 255 | }) 256 | .then(newMicrotask) 257 | .then((_) { 258 | expect(path.value, 3); 259 | }) 260 | .then(newMicrotask) 261 | .then((_) { 262 | expect(values, [2, 3]); 263 | }); 264 | }); 265 | 266 | test('Observe and Unobserve - Paths', () { 267 | var arr = toObservable({}); 268 | 269 | arr['foo'] = 'bar'; 270 | var fooValues = []; 271 | var fooPath = new PathObserver(arr, 'foo'); 272 | fooPath.open(fooValues.add); 273 | arr['foo'] = 'baz'; 274 | arr['bat'] = 'bag'; 275 | var batValues = []; 276 | var batPath = new PathObserver(arr, 'bat'); 277 | batPath.open(batValues.add); 278 | 279 | return new Future(() { 280 | expect(fooValues, ['baz']); 281 | expect(batValues, []); 282 | 283 | arr['foo'] = 'bar'; 284 | fooPath.close(); 285 | arr['bat'] = 'boo'; 286 | batPath.close(); 287 | arr['bat'] = 'boot'; 288 | }).then(newMicrotask).then((_) { 289 | expect(fooValues, ['baz']); 290 | expect(batValues, []); 291 | }); 292 | }); 293 | 294 | test('Path Value With Indices', () { 295 | var model = toObservable([]); 296 | var path = new PathObserver(model, '[0]'); 297 | path.open(expectAsync1((x) { 298 | expect(path.value, 123); 299 | expect(x, 123); 300 | })); 301 | model.add(123); 302 | }); 303 | 304 | group('ObservableList', () { 305 | test('isNotEmpty', () { 306 | var model = new ObservableList(); 307 | var path = new PathObserver(model, 'isNotEmpty'); 308 | expect(path.value, false); 309 | 310 | path.open(expectAsync1((_) { 311 | expect(path.value, true); 312 | })); 313 | model.add(123); 314 | }); 315 | 316 | test('isEmpty', () { 317 | var model = new ObservableList(); 318 | var path = new PathObserver(model, 'isEmpty'); 319 | expect(path.value, true); 320 | 321 | path.open(expectAsync1((_) { 322 | expect(path.value, false); 323 | })); 324 | model.add(123); 325 | }); 326 | }); 327 | 328 | for (var createModel in [() => new TestModel(), () => new WatcherModel()]) { 329 | test('Path Observation - ${createModel().runtimeType}', () { 330 | var model = createModel() 331 | ..a = (createModel()..b = (createModel()..c = 'hello, world')); 332 | 333 | var path = new PathObserver(model, 'a.b.c'); 334 | var lastValue = null; 335 | var errorSeen = false; 336 | runZoned(() { 337 | path.open((x) { 338 | lastValue = x; 339 | }); 340 | }, onError: (e) { 341 | expect(e, _isNoSuchMethodOf('c')); 342 | errorSeen = true; 343 | }); 344 | 345 | model.a.b.c = 'hello, mom'; 346 | 347 | expect(lastValue, null); 348 | return new Future(() { 349 | expect(lastValue, 'hello, mom'); 350 | 351 | model.a.b = createModel()..c = 'hello, dad'; 352 | }) 353 | .then(newMicrotask) 354 | .then((_) { 355 | expect(lastValue, 'hello, dad'); 356 | 357 | model.a = createModel()..b = (createModel()..c = 'hello, you'); 358 | }) 359 | .then(newMicrotask) 360 | .then((_) { 361 | expect(lastValue, 'hello, you'); 362 | 363 | model.a.b = 1; 364 | expect(errorSeen, isFalse); 365 | }) 366 | .then(newMicrotask) 367 | .then((_) { 368 | expect(errorSeen, isTrue); 369 | expect(lastValue, 'hello, you'); 370 | 371 | // Stop observing 372 | path.close(); 373 | 374 | model.a.b = createModel() 375 | ..c = 'hello, back again -- but not observing'; 376 | }) 377 | .then(newMicrotask) 378 | .then((_) { 379 | expect(lastValue, 'hello, you'); 380 | 381 | // Resume observing 382 | new PathObserver(model, 'a.b.c').open((x) { 383 | lastValue = x; 384 | }); 385 | 386 | model.a.b.c = 'hello. Back for reals'; 387 | }) 388 | .then(newMicrotask) 389 | .then((_) { 390 | expect(lastValue, 'hello. Back for reals'); 391 | }); 392 | }); 393 | } 394 | 395 | test('observe map', () { 396 | var model = toObservable({'a': 1}); 397 | var path = new PathObserver(model, 'a'); 398 | 399 | var values = [path.value]; 400 | path.open(values.add); 401 | expect(values, [1]); 402 | 403 | model['a'] = 2; 404 | return new Future(() { 405 | expect(values, [1, 2]); 406 | 407 | path.close(); 408 | model['a'] = 3; 409 | }).then(newMicrotask).then((_) { 410 | expect(values, [1, 2]); 411 | }); 412 | }); 413 | 414 | test('errors thrown from getter/setter', () { 415 | var model = new ObjectWithErrors(); 416 | var observer = new PathObserver(model, 'foo'); 417 | 418 | expect(() => observer.value, _throwsNSM('bar')); 419 | expect(model.getFooCalled, 1); 420 | 421 | expect(() { 422 | observer.value = 123; 423 | }, _throwsNSM('bar=')); 424 | expect(model.setFooCalled, [123]); 425 | }); 426 | 427 | test('object with noSuchMethod', () { 428 | var model = new NoSuchMethodModel(); 429 | var observer = new PathObserver(model, 'foo'); 430 | 431 | expect(observer.value, 42); 432 | observer.value = 'hi'; 433 | expect(model._foo, 'hi'); 434 | expect(observer.value, 'hi'); 435 | 436 | expect(model.log, [#foo, const Symbol('foo='), #foo]); 437 | 438 | // These shouldn't throw 439 | observer = new PathObserver(model, 'bar'); 440 | expect(observer.value, null, reason: 'path not found'); 441 | observer.value = 42; 442 | expect(observer.value, null, reason: 'path not found'); 443 | }); 444 | 445 | test('object with indexer', () { 446 | var model = new IndexerModel(); 447 | var observer = new PathObserver(model, 'foo'); 448 | 449 | expect(observer.value, 42); 450 | expect(model.log, ['[] foo']); 451 | model.log.clear(); 452 | 453 | observer.value = 'hi'; 454 | expect(model.log, ['[]= foo hi']); 455 | expect(model._foo, 'hi'); 456 | 457 | expect(observer.value, 'hi'); 458 | 459 | // These shouldn't throw 460 | model.log.clear(); 461 | observer = new PathObserver(model, 'bar'); 462 | expect(observer.value, null, reason: 'path not found'); 463 | expect(model.log, ['[] bar']); 464 | model.log.clear(); 465 | 466 | observer.value = 42; 467 | expect(model.log, ['[]= bar 42']); 468 | model.log.clear(); 469 | }); 470 | 471 | test('regression for TemplateBinding#161', () { 472 | var model = toObservable({ 473 | 'obj': toObservable({'bar': false}) 474 | }); 475 | var ob1 = new PathObserver(model, 'obj.bar'); 476 | var called = false; 477 | ob1.open(() { 478 | called = true; 479 | }); 480 | 481 | var obj2 = new PathObserver(model, 'obj'); 482 | obj2.open(() { 483 | model['obj']['bar'] = true; 484 | }); 485 | 486 | model['obj'] = toObservable({'obj': 'obj'}); 487 | 488 | return new Future(() {}).then((_) => expect(called, true)); 489 | }); 490 | } 491 | 492 | compoundObserverTests() { 493 | var model; 494 | var observer; 495 | bool called; 496 | var newValues; 497 | var oldValues; 498 | var observed; 499 | 500 | setUp(() { 501 | model = new TestModel(1, 2, 3); 502 | called = false; 503 | }); 504 | 505 | callback(a, b, c) { 506 | called = true; 507 | newValues = a; 508 | oldValues = b; 509 | observed = c; 510 | } 511 | 512 | reset() { 513 | called = false; 514 | newValues = null; 515 | oldValues = null; 516 | observed = null; 517 | } 518 | 519 | expectNoChanges() { 520 | observer.deliver(); 521 | expect(called, isFalse); 522 | expect(newValues, isNull); 523 | expect(oldValues, isNull); 524 | expect(observed, isNull); 525 | } 526 | 527 | expectCompoundPathChanges( 528 | expectedNewValues, expectedOldValues, expectedObserved, 529 | {deliver: true}) { 530 | if (deliver) observer.deliver(); 531 | expect(called, isTrue); 532 | 533 | expect(newValues, expectedNewValues); 534 | var oldValuesAsMap = {}; 535 | for (int i = 0; i < expectedOldValues.length; i++) { 536 | if (expectedOldValues[i] != null) { 537 | oldValuesAsMap[i] = expectedOldValues[i]; 538 | } 539 | } 540 | expect(oldValues, oldValuesAsMap); 541 | expect(observed, expectedObserved); 542 | 543 | reset(); 544 | } 545 | 546 | tearDown(() { 547 | observer.close(); 548 | reset(); 549 | }); 550 | 551 | _path(s) => new PropertyPath(s); 552 | 553 | test('simple', () { 554 | observer = new CompoundObserver(); 555 | observer.addPath(model, 'a'); 556 | observer.addPath(model, 'b'); 557 | observer.addPath(model, _path('c')); 558 | observer.open(callback); 559 | expectNoChanges(); 560 | 561 | var expectedObs = [model, _path('a'), model, _path('b'), model, _path('c')]; 562 | model.a = -10; 563 | model.b = 20; 564 | model.c = 30; 565 | expectCompoundPathChanges([-10, 20, 30], [1, 2, 3], expectedObs); 566 | 567 | model.a = 'a'; 568 | model.c = 'c'; 569 | expectCompoundPathChanges(['a', 20, 'c'], [-10, null, 30], expectedObs); 570 | 571 | model.a = 2; 572 | model.b = 3; 573 | model.c = 4; 574 | expectCompoundPathChanges([2, 3, 4], ['a', 20, 'c'], expectedObs); 575 | 576 | model.a = 'z'; 577 | model.b = 'y'; 578 | model.c = 'x'; 579 | expect(observer.value, ['z', 'y', 'x']); 580 | expectNoChanges(); 581 | 582 | expect(model.a, 'z'); 583 | expect(model.b, 'y'); 584 | expect(model.c, 'x'); 585 | expectNoChanges(); 586 | }); 587 | 588 | test('reportChangesOnOpen', () { 589 | observer = new CompoundObserver(true); 590 | observer.addPath(model, 'a'); 591 | observer.addPath(model, 'b'); 592 | observer.addPath(model, _path('c')); 593 | 594 | model.a = -10; 595 | model.b = 20; 596 | observer.open(callback); 597 | var expectedObs = [model, _path('a'), model, _path('b'), model, _path('c')]; 598 | expectCompoundPathChanges([-10, 20, 3], [1, 2, null], expectedObs, 599 | deliver: false); 600 | }); 601 | 602 | test('All Observers', () { 603 | observer = new CompoundObserver(); 604 | var pathObserver1 = new PathObserver(model, 'a'); 605 | var pathObserver2 = new PathObserver(model, 'b'); 606 | var pathObserver3 = new PathObserver(model, _path('c')); 607 | 608 | observer.addObserver(pathObserver1); 609 | observer.addObserver(pathObserver2); 610 | observer.addObserver(pathObserver3); 611 | observer.open(callback); 612 | 613 | var expectedObs = [ 614 | observerSentinelForTesting, 615 | pathObserver1, 616 | observerSentinelForTesting, 617 | pathObserver2, 618 | observerSentinelForTesting, 619 | pathObserver3 620 | ]; 621 | model.a = -10; 622 | model.b = 20; 623 | model.c = 30; 624 | expectCompoundPathChanges([-10, 20, 30], [1, 2, 3], expectedObs); 625 | 626 | model.a = 'a'; 627 | model.c = 'c'; 628 | expectCompoundPathChanges(['a', 20, 'c'], [-10, null, 30], expectedObs); 629 | }); 630 | 631 | test('Degenerate Values', () { 632 | observer = new CompoundObserver(); 633 | observer.addPath(model, '.'); // invalid path 634 | observer.addPath('obj-value', ''); // empty path 635 | // Dart note: we don't port these two tests because in Dart we produce 636 | // exceptions for these invalid paths. 637 | // observer.addPath(model, 'foo'); // unreachable 638 | // observer.addPath(3, 'bar'); // non-object with non-empty path 639 | var values = observer.open(callback); 640 | expect(values.length, 2); 641 | expect(values[0], null); 642 | expect(values[1], 'obj-value'); 643 | observer.close(); 644 | }); 645 | 646 | test('Heterogeneous', () { 647 | model.c = null; 648 | var otherModel = new TestModel(null, null, 3); 649 | 650 | twice(value) => value * 2; 651 | half(value) => value ~/ 2; 652 | 653 | var compound = new CompoundObserver(); 654 | compound.addPath(model, 'a'); 655 | compound.addObserver(new ObserverTransform( 656 | new PathObserver(model, 'b'), twice, 657 | setValue: half)); 658 | compound.addObserver(new PathObserver(otherModel, 'c')); 659 | 660 | combine(values) => values[0] + values[1] + values[2]; 661 | observer = new ObserverTransform(compound, combine); 662 | 663 | var newValue; 664 | transformCallback(v) { 665 | newValue = v; 666 | called = true; 667 | } 668 | 669 | expect(observer.open(transformCallback), 8); 670 | 671 | model.a = 2; 672 | model.b = 4; 673 | observer.deliver(); 674 | expect(called, isTrue); 675 | expect(newValue, 13); 676 | called = false; 677 | 678 | model.b = 10; 679 | otherModel.c = 5; 680 | observer.deliver(); 681 | expect(called, isTrue); 682 | expect(newValue, 27); 683 | called = false; 684 | 685 | model.a = 20; 686 | model.b = 1; 687 | otherModel.c = 5; 688 | observer.deliver(); 689 | expect(called, isFalse); 690 | expect(newValue, 27); 691 | }); 692 | } 693 | 694 | /// A matcher that checks that a closure throws a NoSuchMethodError matching the 695 | /// given [name]. 696 | _throwsNSM(String name) => throwsA(_isNoSuchMethodOf(name)); 697 | 698 | /// A matcher that checkes whether an exception is a NoSuchMethodError matching 699 | /// the given [name]. 700 | _isNoSuchMethodOf(String name) => predicate((e) => 701 | e is NoSuchMethodError && 702 | // Dart2js and VM error messages are a bit different, but they both contain 703 | // the name. 704 | ('$e'.contains("'$name'") || // VM error 705 | '$e'.contains('\'Symbol("$name")\''))); // dart2js error 706 | 707 | class ObjectWithErrors { 708 | int getFooCalled = 0; 709 | List setFooCalled = []; 710 | @reflectable 711 | get foo { 712 | getFooCalled++; 713 | (this as dynamic).bar; 714 | } 715 | 716 | @reflectable 717 | set foo(value) { 718 | setFooCalled.add(value); 719 | (this as dynamic).bar = value; 720 | } 721 | } 722 | 723 | class NoSuchMethodModel { 724 | var _foo = 42; 725 | List log = []; 726 | 727 | // TODO(ahe): Remove @reflectable from here (once either of 728 | // http://dartbug.com/15408 or http://dartbug.com/15409 are fixed). 729 | @reflectable 730 | noSuchMethod(Invocation invocation) { 731 | final name = invocation.memberName; 732 | log.add(name); 733 | if (name == #foo && invocation.isGetter) return _foo; 734 | if (name == const Symbol('foo=')) { 735 | _foo = invocation.positionalArguments[0]; 736 | return null; 737 | } 738 | return super.noSuchMethod(invocation); 739 | } 740 | } 741 | 742 | class IndexerModel implements Indexable { 743 | var _foo = 42; 744 | List log = []; 745 | 746 | operator [](index) { 747 | log.add('[] $index'); 748 | if (index == 'foo') return _foo; 749 | } 750 | 751 | operator []=(index, value) { 752 | log.add('[]= $index $value'); 753 | if (index == 'foo') _foo = value; 754 | } 755 | } 756 | 757 | @reflectable 758 | class TestModel extends ChangeNotifier implements WatcherModel { 759 | var _a, _b, _c; 760 | 761 | TestModel([this._a, this._b, this._c]); 762 | 763 | get a => _a; 764 | 765 | void set a(newValue) { 766 | _a = notifyPropertyChange(#a, _a, newValue); 767 | } 768 | 769 | get b => _b; 770 | 771 | void set b(newValue) { 772 | _b = notifyPropertyChange(#b, _b, newValue); 773 | } 774 | 775 | get c => _c; 776 | 777 | void set c(newValue) { 778 | _c = notifyPropertyChange(#c, _c, newValue); 779 | } 780 | 781 | @override 782 | /*=T*/ notifyPropertyChange/**/( 783 | Symbol field, /*=T*/ oldValue, /*=T*/ newValue) { 784 | if (hasObservers && oldValue != newValue) { 785 | notifyChange(new PropertyChangeRecord(this, field, oldValue, newValue)); 786 | } 787 | return newValue; 788 | } 789 | } 790 | 791 | class WatcherModel extends AutoObservable { 792 | // TODO(jmesserly): dart2js does not let these be on the same line: 793 | // @observable var a, b, c; 794 | @observable 795 | var a; 796 | @observable 797 | var b; 798 | @observable 799 | var c; 800 | 801 | WatcherModel(); 802 | } 803 | 804 | class Foo { 805 | var value; 806 | Foo(this.value); 807 | String toString() => 'Foo$value'; 808 | } 809 | -------------------------------------------------------------------------------- /test/transformer_test.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file 2 | // for details. All rights reserved. Use of this source code is governed by a 3 | // BSD-style license that can be found in the LICENSE file. 4 | @TestOn('vm') 5 | import 'dart:async'; 6 | import 'package:barback/barback.dart'; 7 | import 'package:observe/transformer.dart'; 8 | import 'package:stack_trace/stack_trace.dart'; 9 | import 'package:test/test.dart'; 10 | 11 | main() { 12 | group('replaces AutoObservable for Observable', () { 13 | _testClause('extends AutoObservable', 'extends Observable'); 14 | _testClause( 15 | 'extends Base with AutoObservable', 'extends Base with Observable'); 16 | _testClause('extends Base with AutoObservable', 17 | 'extends Base with Observable'); 18 | _testClause('extends Base with Mixin, AutoObservable', 19 | 'extends Base with Mixin, Observable'); 20 | _testClause('extends Base with AutoObservable, Mixin', 21 | 'extends Base with Observable, Mixin'); 22 | _testClause('extends Base with Mixin, AutoObservable', 23 | 'extends Base with Mixin, Observable'); 24 | _testClause('extends Base with Mixin, AutoObservable, Mixin2', 25 | 'extends Base with Mixin, Observable, Mixin2'); 26 | _testClause('extends AutoObservable implements Interface', 27 | 'extends Observable implements Interface'); 28 | _testClause('extends AutoObservable implements Interface', 29 | 'extends Observable implements Interface'); 30 | _testClause('extends Base with AutoObservable implements Interface', 31 | 'extends Base with Observable implements Interface'); 32 | _testClause('extends Base with Mixin, AutoObservable implements I1, I2', 33 | 'extends Base with Mixin, Observable implements I1, I2'); 34 | }); 35 | 36 | group('adds "with Observable" given', () { 37 | _testClause('', 'extends Observable'); 38 | _testClause('extends Base', 'extends Base with Observable'); 39 | _testClause('extends Base', 'extends Base with Observable'); 40 | _testClause( 41 | 'extends Base with Mixin', 'extends Base with Mixin, Observable'); 42 | _testClause( 43 | 'extends Base with Mixin', 'extends Base with Mixin, Observable'); 44 | _testClause('extends Base with Mixin, Mixin2', 45 | 'extends Base with Mixin, Mixin2, Observable'); 46 | _testClause( 47 | 'implements Interface', 'extends Observable implements Interface'); 48 | _testClause('implements Interface', 49 | 'extends Observable implements Interface'); 50 | _testClause('extends Base implements Interface', 51 | 'extends Base with Observable implements Interface'); 52 | _testClause('extends Base with Mixin implements I1, I2', 53 | 'extends Base with Mixin, Observable implements I1, I2'); 54 | }); 55 | 56 | group('fixes contructor calls ', () { 57 | _testInitializers('this.a', '(a) : __\$a = a'); 58 | _testInitializers('{this.a}', '({a}) : __\$a = a'); 59 | _testInitializers('[this.a]', '([a]) : __\$a = a'); 60 | _testInitializers('this.a, this.b', '(a, b) : __\$a = a, __\$b = b'); 61 | _testInitializers('{this.a, this.b}', '({a, b}) : __\$a = a, __\$b = b'); 62 | _testInitializers('[this.a, this.b]', '([a, b]) : __\$a = a, __\$b = b'); 63 | _testInitializers('this.a, [this.b]', '(a, [b]) : __\$a = a, __\$b = b'); 64 | _testInitializers('this.a, {this.b}', '(a, {b}) : __\$a = a, __\$b = b'); 65 | }); 66 | 67 | var annotations = [ 68 | 'observable', 69 | 'published', 70 | 'ObservableProperty()', 71 | 'PublishedProperty(reflect: true)' 72 | ]; 73 | for (var annotation in annotations) { 74 | group('@$annotation full text', () { 75 | test('with changes', () { 76 | return _transform(_sampleObservable(annotation)) 77 | .then((out) => expect(out, _sampleObservableOutput(annotation))); 78 | }); 79 | 80 | test('complex with changes', () { 81 | return _transform(_complexObservable(annotation)) 82 | .then((out) => expect(out, _complexObservableOutput(annotation))); 83 | }); 84 | 85 | test('no changes', () { 86 | var input = 87 | 'class A {/*@$annotation annotation to trigger transform */;}'; 88 | return _transform(input).then((output) => expect(output, input)); 89 | }); 90 | }); 91 | } 92 | } 93 | 94 | _testClause(String clauses, String expected) { 95 | test(clauses, () { 96 | var className = 'MyClass'; 97 | if (clauses.contains('')) className += ''; 98 | var code = ''' 99 | class $className $clauses { 100 | @observable var field; 101 | }'''; 102 | 103 | return _transform(code).then((output) { 104 | var classPos = output.indexOf(className) + className.length; 105 | var actualClauses = output 106 | .substring(classPos, output.indexOf('{')) 107 | .trim() 108 | .replaceAll(' ', ' '); 109 | expect(actualClauses, expected); 110 | }); 111 | }); 112 | } 113 | 114 | _testInitializers(String args, String expected) { 115 | test(args, () { 116 | var constructor = 'MyClass('; 117 | var code = ''' 118 | class MyClass { 119 | @observable var a; 120 | @observable var b; 121 | MyClass($args); 122 | }'''; 123 | 124 | return _transform(code).then((output) { 125 | var begin = output.indexOf(constructor) + constructor.length - 1; 126 | var end = output.indexOf(';', begin); 127 | if (end == -1) end = output.length; 128 | var init = output.substring(begin, end).trim().replaceAll(' ', ' '); 129 | expect(init, expected); 130 | }); 131 | }); 132 | } 133 | 134 | /// Helper that applies the transform by creating mock assets. 135 | Future _transform(String code) { 136 | return Chain.capture(() async { 137 | var id = new AssetId('foo', 'a/b/c.dart'); 138 | var asset = new Asset.fromString(id, code); 139 | var transformer = new ObservableTransformer(); 140 | bool isPrimary = await transformer.isPrimary(asset); 141 | expect(isPrimary, isTrue); 142 | var transform = new _MockTransform(asset); 143 | await transformer.apply(transform); 144 | 145 | expect(transform.outs, hasLength(2)); 146 | expect(transform.outs[0].id, id); 147 | expect(transform.outs[1].id, id.addExtension('._buildLogs.1')); 148 | return transform.outs.first.readAsString(); 149 | }); 150 | } 151 | 152 | class _MockTransform implements Transform { 153 | bool shouldConsumePrimary = false; 154 | List outs = []; 155 | Asset _asset; 156 | TransformLogger logger = new TransformLogger(_mockLogFn); 157 | Asset get primaryInput => _asset; 158 | 159 | _MockTransform(this._asset); 160 | Future getInput(AssetId id) { 161 | if (id == primaryInput.id) return new Future.value(primaryInput); 162 | fail('_MockTransform fail'); 163 | return null; // Satisfy analyzer 164 | } 165 | 166 | void addOutput(Asset output) { 167 | outs.add(output); 168 | } 169 | 170 | void consumePrimary() { 171 | shouldConsumePrimary = true; 172 | } 173 | 174 | readInput(id) => throw new UnimplementedError(); 175 | readInputAsString(id, {encoding}) => throw new UnimplementedError(); 176 | hasInput(id) => 177 | new Future.value(id == _asset.id || outs.any((a) => a.id == id)); 178 | 179 | static void _mockLogFn(AssetId asset, LogLevel level, String message, span) { 180 | // Do nothing. 181 | } 182 | } 183 | 184 | String _sampleObservable(String annotation) => ''' 185 | library A_foo; 186 | import 'package:observe/observe.dart'; 187 | 188 | class A extends AutoObservable { 189 | @$annotation int foo; 190 | A(this.foo); 191 | } 192 | '''; 193 | 194 | String _sampleObservableOutput(String annotation) => "library A_foo;\n" 195 | "import 'package:observe/observe.dart';\n\n" 196 | "class A extends Observable {\n" 197 | " @reflectable @$annotation int get foo => __\$foo; int __\$foo; " 198 | "${_makeSetter('int', 'foo')}\n" 199 | " A(foo) : __\$foo = foo;\n" 200 | "}\n"; 201 | 202 | _makeSetter(type, name) => '@reflectable set $name($type value) { ' 203 | '__\$$name = notifyPropertyChange(#$name, __\$$name, value); }'; 204 | 205 | String _complexObservable(String annotation) => ''' 206 | class Foo extends AutoObservable { 207 | @$annotation 208 | @otherMetadata 209 | Foo 210 | foo/*D*/= 1, bar =/*A*/2/*B*/, 211 | quux/*C*/; 212 | 213 | @$annotation var baz; 214 | } 215 | '''; 216 | 217 | String _complexObservableOutput(String meta) => 218 | "class Foo extends Observable {\n" 219 | " @reflectable @$meta\n" 220 | " @otherMetadata\n" 221 | " Foo\n" 222 | " get foo => __\$foo; Foo __\$foo/*D*/= 1; " 223 | "${_makeSetter('Foo', 'foo')} " 224 | "@reflectable @$meta @otherMetadata Foo get bar => __\$bar; " 225 | "Foo __\$bar =/*A*/2/*B*/; ${_makeSetter('Foo', 'bar')}\n" 226 | " @reflectable @$meta @otherMetadata Foo get quux => __\$quux; " 227 | "Foo __\$quux/*C*/; ${_makeSetter('Foo', 'quux')}\n\n" 228 | " @reflectable @$meta dynamic get baz => __\$baz; dynamic __\$baz; " 229 | "${_makeSetter('dynamic', 'baz')}\n" 230 | "}\n"; 231 | -------------------------------------------------------------------------------- /test/unique_message_test.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file 2 | // for details. All rights reserved. Use of this source code is governed by a 3 | // BSD-style license that can be found in the LICENSE file. 4 | 5 | /// Tests for some of the utility helper functions used by the compiler. 6 | library polymer.test.build.messages_test; 7 | 8 | import 'dart:mirrors'; 9 | 10 | import 'package:test/test.dart'; 11 | import 'package:code_transformers/messages/messages.dart' show Message; 12 | 13 | import 'package:observe/src/messages.dart' as p1; 14 | 15 | /// [p1] is accessed via mirrors, this comment prevents the analyzer from 16 | /// complaining about it. 17 | main() { 18 | test('each message id is unique', () { 19 | var seen = {}; 20 | int total = 0; 21 | var mirrors = currentMirrorSystem(); 22 | var lib = mirrors.findLibrary(#observe.src.messages); 23 | expect(lib, isNotNull); 24 | lib.declarations.forEach((symbol, decl) { 25 | if (decl is! VariableMirror) return; 26 | var field = lib.getField(symbol).reflectee; 27 | var name = MirrorSystem.getName(symbol); 28 | if (field is! Message) return; 29 | var id = field.id; 30 | expect(seen.containsKey(id), isFalse, 31 | reason: 'Duplicate id `$id`. ' 32 | 'Currently set for both `$name` and `${seen[id]}`.'); 33 | seen[id] = name; 34 | total++; 35 | }); 36 | expect(seen.length, total); 37 | }); 38 | } 39 | --------------------------------------------------------------------------------