├── .gitignore ├── .gitmodules ├── AUTHORS ├── README.md ├── benchmark ├── benchmark.js ├── d8_benchmarks.js ├── index.html └── observation_benchmark.js ├── bower.json ├── codereview.settings ├── conf ├── karma.conf.js └── mocha.conf.js ├── examples ├── circles.html ├── constrain.html ├── constrain.js ├── persist.html └── persist.js ├── gruntfile.js ├── index.html ├── observe-js.html ├── package.json ├── src └── observe.js ├── tests ├── array_fuzzer.js ├── d8_array_fuzzer.js ├── d8_planner_test.js ├── index.html ├── planner.html └── test.js └── util └── planner.js /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | tools -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "benchmark/chartjs"] 2 | path = benchmark/chartjs 3 | url = https://github.com/nnnick/Chart.js.git 4 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Analytics](https://ga-beacon.appspot.com/UA-39334307-2/Polymer/observe-js/README)](https://github.com/igrigorik/ga-beacon) 2 | 3 | ## Learn the tech 4 | 5 | ### Why observe-js? 6 | 7 | observe-js is a library for observing changes in JavaScript data. It exposes a high-level API and uses [Object.observe](https://github.com/arv/ecmascript-object-observe) if available, and otherwise performs dirty-checking. observe-js requires ECMAScript 5. 8 | 9 | ### Observable 10 | 11 | observe-js implements a set of observers (PathObserver, ArrayObserver, ObjectObserver, CompoundObserver, ObserverTransform) which all implement the Observable interface: 12 | 13 | ```JavaScript 14 | { 15 | // Begins observation. Value changes will be reported by invoking |changeFn| with 16 | // |opt_receiver| as the target, if provided. Returns the initial value of the observation. 17 | open: function(changeFn, opt_receiver) {}, 18 | 19 | // Report any changes now (does nothing if there are no changes to report). 20 | deliver: function() {}, 21 | 22 | // If there are changes to report, ignore them. Returns the current value of the observation. 23 | discardChanges: function() {}, 24 | 25 | // Ends observation. Frees resources and drops references to observed objects. 26 | close: function() {} 27 | } 28 | ``` 29 | 30 | ### PathObserver 31 | 32 | PathObserver observes a "value-at-a-path" from a given object: 33 | 34 | ```JavaScript 35 | var obj = { foo: { bar: 'baz' } }; 36 | var defaultValue = 42; 37 | var observer = new PathObserver(obj, 'foo.bar', defaultValue); 38 | observer.open(function(newValue, oldValue) { 39 | // respond to obj.foo.bar having changed value. 40 | }); 41 | ``` 42 | 43 | PathObserver will report a change whenever the value obtained by the corresponding path expression (e.g. `obj.foo.bar`) would return a different value. 44 | 45 | PathObserver also exposes a `setValue` method which attempts to update the underlying value. Setting the value does not affect notification state (in other words, a caller sets the value but does not `discardChanges`, the `changeFn` will be notified of the change). 46 | 47 | ```JavaScript 48 | observer.setValue('boo'); 49 | assert(obj.foo.bar == 'boo'); 50 | ``` 51 | 52 | Notes: 53 | * If the path is ever unreachable, the value is considered to be `undefined` (unless you pass an overriding `defaultValue` to `new PathObserver(...)` as shown in the above example). 54 | * If the path is empty (e.g. `''`), it is said to be the empty path and its value is its root object. 55 | * PathObservation respects values on the prototype chain 56 | 57 | ### ArrayObserver 58 | 59 | ArrayObserver observes the index-positions of an Array and reports changes as the minimal set of "splices" which would have had the same effect. 60 | 61 | ```JavaScript 62 | var arr = [0, 1, 2, 4]; 63 | var observer = new ArrayObserver(arr); 64 | observer.open(function(splices) { 65 | // respond to changes to the elements of arr. 66 | splices.forEach(function(splice) { 67 | splice.index; // the index position that the change occurred. 68 | splice.removed; // an array of values representing the sequence of removed elements 69 | splice.addedCount; // the number of elements which were inserted. 70 | }); 71 | }); 72 | ``` 73 | 74 | ArrayObserver also exposes a utility function: `applySplices`. The purpose of `applySplices` is to transform a copy of an old state of an array into a copy of its current state, given the current state and the splices reported from the ArrayObserver. 75 | 76 | ```JavaScript 77 | AraryObserver.applySplices = function(previous, current, splices) { } 78 | ``` 79 | 80 | ### ObjectObserver 81 | 82 | ObjectObserver observes the set of own-properties of an object and their values. 83 | 84 | ```JavaScript 85 | var myObj = { id: 1, foo: 'bar' }; 86 | var observer = new ObjectObserver(myObj); 87 | observer.open(function(added, removed, changed, getOldValueFn) { 88 | // respond to changes to the obj. 89 | Object.keys(added).forEach(function(property) { 90 | property; // a property which has been been added to obj 91 | added[property]; // its value 92 | }); 93 | Object.keys(removed).forEach(function(property) { 94 | property; // a property which has been been removed from obj 95 | getOldValueFn(property); // its old value 96 | }); 97 | Object.keys(changed).forEach(function(property) { 98 | property; // a property on obj which has changed value. 99 | changed[property]; // its value 100 | getOldValueFn(property); // its old value 101 | }); 102 | }); 103 | ``` 104 | 105 | ### CompoundObserver 106 | 107 | CompoundObserver allows simultaneous observation of multiple paths and/or Observables. It reports any and all changes in to the provided `changeFn` callback. 108 | 109 | ```JavaScript 110 | var obj = { 111 | a: 1, 112 | b: 2, 113 | }; 114 | 115 | var otherObj = { c: 3 }; 116 | 117 | var observer = new CompoundObserver(); 118 | observer.addPath(obj, 'a'); 119 | observer.addObserver(new PathObserver(obj, 'b')); 120 | observer.addPath(otherObj, 'c'); 121 | var logTemplate = 'The %sth value before & after:'; 122 | observer.open(function(newValues, oldValues) { 123 | // Use for-in to iterate which values have changed. 124 | for (var i in oldValues) { 125 | console.log(logTemplate, i, oldValues[i], newValues[i]); 126 | } 127 | }); 128 | ``` 129 | 130 | 131 | ### ObserverTransform 132 | 133 | ObserverTransform is used to dynamically transform observed value(s). 134 | 135 | ```JavaScript 136 | var obj = { value: 10 }; 137 | var observer = new PathObserver(obj, 'value'); 138 | function getValue(value) { return value * 2 }; 139 | function setValue(value) { return value / 2 }; 140 | 141 | var transform = new ObserverTransform(observer, getValue, setValue); 142 | 143 | // returns 20. 144 | transform.open(function(newValue, oldValue) { 145 | console.log('new: ' + newValue + ', old: ' + oldValue); 146 | }); 147 | 148 | obj.value = 20; 149 | transform.deliver(); // 'new: 40, old: 20' 150 | transform.setValue(4); // obj.value === 2; 151 | ``` 152 | 153 | ObserverTransform can also be used to reduce a set of observed values to a single value: 154 | 155 | ```JavaScript 156 | var obj = { a: 1, b: 2, c: 3 }; 157 | var observer = new CompoundObserver(); 158 | observer.addPath(obj, 'a'); 159 | observer.addPath(obj, 'b'); 160 | observer.addPath(obj, 'c'); 161 | var transform = new ObserverTransform(observer, function(values) { 162 | var value = 0; 163 | for (var i = 0; i < values.length; i++) 164 | value += values[i] 165 | return value; 166 | }); 167 | 168 | // returns 6. 169 | transform.open(function(newValue, oldValue) { 170 | console.log('new: ' + newValue + ', old: ' + oldValue); 171 | }); 172 | 173 | obj.a = 2; 174 | obj.c = 10; 175 | transform.deliver(); // 'new: 14, old: 6' 176 | ``` 177 | 178 | ### Path objects 179 | 180 | A path is an ECMAScript expression consisting only of identifiers (`myVal`), member accesses (`foo.bar`) and key lookup with literal values (`arr[0]` `obj['str-value'].bar.baz`). 181 | 182 | `Path.get('foo.bar.baz')` returns a Path object which represents the path. Path objects have the following API: 183 | 184 | ```JavaScript 185 | { 186 | // Returns the current value of the path from the provided object. If eval() is available, 187 | // a compiled getter will be used for better performance. Like PathObserver above, undefined 188 | // is returned unless you provide an overriding defaultValue. 189 | getValueFrom: function(obj, defaultValue) { }, 190 | 191 | // Attempts to set the value of the path from the provided object. Returns true IFF the path 192 | // was reachable and set. 193 | setValueFrom: function(obj, newValue) { } 194 | } 195 | ``` 196 | 197 | Path objects are interned (e.g. `assert(Path.get('foo.bar.baz') === Path.get('foo.bar.baz'));`) and are used internally to avoid excessive parsing of path strings. Observers which take path strings as arguments will also accept Path objects. 198 | 199 | ## About delivery of changes 200 | 201 | observe-js is intended for use in environments which implement Object.observe, but it supports use in environments which do not. 202 | 203 | If `Object.observe` is present, and observers have changes to report, their callbacks will be invoked at the end of the current turn (microtask). In a browser environment, this is generally at the end of an event. 204 | 205 | If `Object.observe` is absent, `Platform.performMicrotaskCheckpoint()` must be called to trigger delivery of changes. If `Object.observe` is implemented, `Platform.performMicrotaskCheckpoint()` has no effect. 206 | -------------------------------------------------------------------------------- /benchmark/benchmark.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2014 The Polymer Project Authors. All rights reserved. 3 | * This code may only be used under the BSD style license found at http://polymer.github.io/LICENSE.txt 4 | * The complete set of authors may be found at http://polymer.github.io/AUTHORS.txt 5 | * The complete set of contributors may be found at http://polymer.github.io/CONTRIBUTORS.txt 6 | * Code distributed by Google as part of the polymer project is also 7 | * subject to an additional IP rights grant found at http://polymer.github.io/PATENTS.txt 8 | */ 9 | 10 | (function(global) { 11 | 'use strict'; 12 | 13 | function now() { 14 | return global.performance && typeof performance.now == 'function' ? 15 | performance.now() : Date.now(); 16 | } 17 | 18 | function checkpoint() { 19 | if (global.Platform && 20 | typeof Platform.performMicrotaskCheckpoint == 'function') { 21 | Platform.performMicrotaskCheckpoint(); 22 | } 23 | } 24 | 25 | // TODO(rafaelw): Add simple Promise polyfill for IE. 26 | 27 | var TESTING_TICKS = 400; 28 | var TICKS_PER_FRAME = 16; 29 | var MAX_RUNS = 50; 30 | 31 | function Benchmark(testingTicks, ticksPerFrame, maxRuns) { 32 | this.testingTicks = testingTicks || TESTING_TICKS; 33 | this.ticksPerFrame = ticksPerFrame || TICKS_PER_FRAME; 34 | this.maxRuns = maxRuns || 50; 35 | this.average = 0; 36 | } 37 | 38 | Benchmark.prototype = { 39 | // Abstract API 40 | setup: function(variation) {}, 41 | test: function() { 42 | throw Error('Not test function found'); 43 | }, 44 | cleanup: function() {}, 45 | 46 | runOne: function(variation) { 47 | this.setup(variation); 48 | 49 | var before = now(); 50 | this.test(variation); 51 | 52 | var self = this; 53 | 54 | return Promise.resolve().then(function() { 55 | checkpoint(); 56 | 57 | var after = now(); 58 | 59 | self.cleanup(variation); 60 | return after - before; 61 | }); 62 | }, 63 | 64 | runMany: function(count, variation) { 65 | var self = this; 66 | 67 | return new Promise(function(fulfill) { 68 | var total = 0; 69 | 70 | function next(time) { 71 | if (!count) { 72 | fulfill(total); 73 | return; 74 | } 75 | 76 | self.runOne(variation).then(function(time) { 77 | count--; 78 | total += time; 79 | next(); 80 | }); 81 | } 82 | 83 | requestAnimationFrame(next); 84 | }); 85 | }, 86 | 87 | runVariation: function(variation, reportFn) { 88 | var self = this; 89 | reportFn = reportFn || function() {} 90 | 91 | return new Promise(function(fulfill) { 92 | self.runMany(3, variation).then(function(time) { 93 | return time/3; 94 | }).then(function(estimate) { 95 | var runsPerFrame = Math.ceil(self.ticksPerFrame / estimate); 96 | var frames = Math.ceil(self.testingTicks / self.ticksPerFrame); 97 | var maxFrames = Math.ceil(self.maxRuns / runsPerFrame); 98 | 99 | frames = Math.min(frames, maxFrames); 100 | var count = 0; 101 | var total = 0; 102 | 103 | function next() { 104 | if (!frames) { 105 | self.average = total / count; 106 | self.dispose(); 107 | fulfill(self.average); 108 | return; 109 | } 110 | 111 | self.runMany(runsPerFrame, variation).then(function(time) { 112 | frames--; 113 | total += time; 114 | count += runsPerFrame; 115 | reportFn(variation, count); 116 | next(); 117 | }); 118 | } 119 | 120 | next(); 121 | }); 122 | }); 123 | }, 124 | 125 | run: function(variations, reportFn) { 126 | if (!Array.isArray(variations)) { 127 | return this.runVariation(variations, reportFn); 128 | } 129 | 130 | var self = this; 131 | variations = variations.slice(); 132 | return new Promise(function(fulfill) { 133 | var results = []; 134 | 135 | function next() { 136 | if (!variations.length) { 137 | fulfill(results); 138 | return; 139 | } 140 | 141 | var variation = variations.shift(); 142 | self.runVariation(variation, reportFn).then(function(time) { 143 | results.push(time); 144 | next(); 145 | }); 146 | } 147 | 148 | next(); 149 | }); 150 | } 151 | }; 152 | 153 | function all(benchmarks, variations, statusFn) { 154 | return new Promise(function(fulfill) { 155 | var results = []; 156 | var current; 157 | 158 | function next() { 159 | current = benchmarks.shift(); 160 | 161 | if (!current) { 162 | fulfill(results); 163 | return; 164 | } 165 | 166 | function update(variation, runs) { 167 | statusFn(current, variation, runs); 168 | } 169 | 170 | current.run(variations, update).then(function(time) { 171 | results.push(time); 172 | next(); 173 | }); 174 | } 175 | 176 | next(); 177 | }); 178 | } 179 | 180 | global.Benchmark = Benchmark; 181 | global.Benchmark.all = all; 182 | 183 | })(this); 184 | -------------------------------------------------------------------------------- /benchmark/d8_benchmarks.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2014 The Polymer Project Authors. All rights reserved. 3 | * This code may only be used under the BSD style license found at http://polymer.github.io/LICENSE.txt 4 | * The complete set of authors may be found at http://polymer.github.io/AUTHORS.txt 5 | * The complete set of contributors may be found at http://polymer.github.io/CONTRIBUTORS.txt 6 | * Code distributed by Google as part of the polymer project is also 7 | * subject to an additional IP rights grant found at http://polymer.github.io/PATENTS.txt 8 | */ 9 | 10 | var console = { 11 | log: print 12 | }; 13 | 14 | var requestAnimationFrame = function(callback) { 15 | callback(); 16 | } 17 | 18 | recordCount = 0; 19 | 20 | var alert = print; 21 | 22 | function reportResults(times) { 23 | console.log(JSON.stringify(times)); 24 | } 25 | 26 | function reportStatus(b, variation, count) { 27 | console.log(b.objectCount + ' objects, ' + count + ' runs.'); 28 | } 29 | 30 | var objectCounts = [ 4000, 8000, 16000 ]; 31 | 32 | var benchmarks = []; 33 | 34 | objectCounts.forEach(function(objectCount, i) { 35 | benchmarks.push( 36 | new SetupPathBenchmark('', objectCount)); 37 | }); 38 | 39 | Benchmark.all(benchmarks, 0, reportStatus).then(reportResults); 40 | 41 | -------------------------------------------------------------------------------- /benchmark/index.html: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | Observation Benchmarks 12 | 13 | 14 | 15 | 16 | 17 | 28 | 29 | 30 |

Observation Benchmarks

31 | 32 | 40 | 42 | 43 | 44 | 45 | Object Count: 46 |
47 | Mutation Count: 48 |
49 |
50 | 51 | 52 |
53 |
54 |
55 | Times in ms 56 |
57 |
58 | 59 |
60 |
61 |
    62 |
63 |
64 |
65 |
66 |

Object Set Size

67 | 180 | 181 | 182 | -------------------------------------------------------------------------------- /benchmark/observation_benchmark.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2014 The Polymer Project Authors. All rights reserved. 3 | * This code may only be used under the BSD style license found at http://polymer.github.io/LICENSE.txt 4 | * The complete set of authors may be found at http://polymer.github.io/AUTHORS.txt 5 | * The complete set of contributors may be found at http://polymer.github.io/CONTRIBUTORS.txt 6 | * Code distributed by Google as part of the polymer project is also 7 | * subject to an additional IP rights grant found at http://polymer.github.io/PATENTS.txt 8 | */ 9 | 10 | (function(global) { 11 | 'use strict'; 12 | 13 | var createObject = ('__proto__' in {}) ? 14 | function(obj) { return obj; } : 15 | function(obj) { 16 | var proto = obj.__proto__; 17 | if (!proto) 18 | return obj; 19 | var newObject = Object.create(proto); 20 | Object.getOwnPropertyNames(obj).forEach(function(name) { 21 | Object.defineProperty(newObject, name, 22 | Object.getOwnPropertyDescriptor(obj, name)); 23 | }); 24 | return newObject; 25 | }; 26 | 27 | function ObservationBenchmark(objectCount) { 28 | Benchmark.call(this); 29 | this.objectCount = objectCount; 30 | } 31 | 32 | ObservationBenchmark.prototype = createObject({ 33 | __proto__: Benchmark.prototype, 34 | 35 | setup: function() { 36 | this.mutations = 0; 37 | 38 | if (this.objects) 39 | return; 40 | 41 | this.objects = []; 42 | this.observers = []; 43 | this.objectIndex = 0; 44 | 45 | while (this.objects.length < this.objectCount) { 46 | var obj = this.newObject(); 47 | this.objects.push(obj); 48 | var observer = this.newObserver(obj); 49 | observer.open(this.observerCallback, this); 50 | this.observers.push(observer); 51 | } 52 | }, 53 | 54 | test: function(mutationCount) { 55 | while (mutationCount > 0) { 56 | var obj = this.objects[this.objectIndex]; 57 | mutationCount -= this.mutateObject(obj); 58 | this.mutations++; 59 | this.objectIndex++; 60 | if (this.objectIndex == this.objects.length) { 61 | this.objectIndex = 0; 62 | } 63 | } 64 | }, 65 | 66 | cleanup: function() { 67 | if (this.mutations !== 0) 68 | alert('Error: mutationCount == ' + this.mutationCount); 69 | 70 | this.mutations = 0; 71 | }, 72 | 73 | dispose: function() { 74 | this.objects = null; 75 | while (this.observers.length) { 76 | this.observers.pop().close(); 77 | } 78 | this.observers = null; 79 | if (Observer._allObserversCount != 0) { 80 | alert('Observers leaked'); 81 | } 82 | }, 83 | 84 | observerCallback: function() { 85 | this.mutations--; 86 | } 87 | }); 88 | 89 | function SetupObservationBenchmark(objectCount) { 90 | Benchmark.call(this); 91 | this.objectCount = objectCount; 92 | } 93 | 94 | SetupObservationBenchmark.prototype = createObject({ 95 | __proto__: Benchmark.prototype, 96 | 97 | setup: function() { 98 | this.mutations = 0; 99 | this.objects = []; 100 | this.observers = []; 101 | 102 | while (this.objects.length < this.objectCount) { 103 | var obj = this.newObject(); 104 | this.objects.push(obj); 105 | } 106 | }, 107 | 108 | test: function() { 109 | for (var i = 0; i < this.objects.length; i++) { 110 | var obj = this.objects[i]; 111 | var observer = this.newObserver(obj); 112 | observer.open(this.observerCallback, this); 113 | this.observers.push(observer); 114 | } 115 | }, 116 | 117 | cleanup: function() { 118 | while (this.observers.length) { 119 | this.observers.pop().close(); 120 | } 121 | if (Observer._allObserversCount != 0) { 122 | alert('Observers leaked'); 123 | } 124 | this.objects = null; 125 | this.observers = null; 126 | 127 | }, 128 | 129 | dispose: function() { 130 | } 131 | }); 132 | 133 | function ObjectBenchmark(config, objectCount) { 134 | ObservationBenchmark.call(this, objectCount); 135 | this.properties = []; 136 | for (var i = 0; i < ObjectBenchmark.propertyCount; i++) { 137 | this.properties.push(String.fromCharCode(97 + i)); 138 | } 139 | } 140 | 141 | ObjectBenchmark.configs = []; 142 | ObjectBenchmark.propertyCount = 15; 143 | 144 | ObjectBenchmark.prototype = createObject({ 145 | __proto__: ObservationBenchmark.prototype, 146 | 147 | newObject: function() { 148 | var obj = {}; 149 | for (var j = 0; j < ObjectBenchmark.propertyCount; j++) 150 | obj[this.properties[j]] = j; 151 | 152 | return obj; 153 | }, 154 | 155 | newObserver: function(obj) { 156 | return new ObjectObserver(obj); 157 | }, 158 | 159 | mutateObject: function(obj) { 160 | var size = Math.floor(ObjectBenchmark.propertyCount / 3); 161 | for (var i = 0; i < size; i++) { 162 | obj[this.properties[i]]++; 163 | } 164 | 165 | return size; 166 | } 167 | }); 168 | 169 | function SetupObjectBenchmark(config, objectCount) { 170 | SetupObservationBenchmark.call(this, objectCount); 171 | this.properties = []; 172 | for (var i = 0; i < ObjectBenchmark.propertyCount; i++) { 173 | this.properties.push(String.fromCharCode(97 + i)); 174 | } 175 | } 176 | 177 | SetupObjectBenchmark.configs = []; 178 | SetupObjectBenchmark.propertyCount = 15; 179 | 180 | SetupObjectBenchmark.prototype = createObject({ 181 | __proto__: SetupObservationBenchmark.prototype, 182 | 183 | newObject: function() { 184 | var obj = {}; 185 | for (var j = 0; j < SetupObjectBenchmark.propertyCount; j++) 186 | obj[this.properties[j]] = j; 187 | 188 | return obj; 189 | }, 190 | 191 | newObserver: function(obj) { 192 | return new ObjectObserver(obj); 193 | } 194 | }); 195 | 196 | function ArrayBenchmark(config, objectCount) { 197 | ObservationBenchmark.call(this, objectCount); 198 | var tokens = config.split('/'); 199 | this.operation = tokens[0]; 200 | this.undo = tokens[1]; 201 | }; 202 | 203 | ArrayBenchmark.configs = ['splice', 'update', 'push/pop', 'shift/unshift']; 204 | ArrayBenchmark.elementCount = 100; 205 | 206 | ArrayBenchmark.prototype = createObject({ 207 | __proto__: ObservationBenchmark.prototype, 208 | 209 | newObject: function() { 210 | var array = []; 211 | for (var i = 0; i < ArrayBenchmark.elementCount; i++) 212 | array.push(i); 213 | return array; 214 | }, 215 | 216 | newObserver: function(array) { 217 | return new ArrayObserver(array); 218 | }, 219 | 220 | mutateObject: function(array) { 221 | switch (this.operation) { 222 | case 'update': 223 | var mutationsMade = 0; 224 | var size = Math.floor(ArrayBenchmark.elementCount / 10); 225 | for (var j = 0; j < size; j++) { 226 | array[j*size] += 1; 227 | mutationsMade++; 228 | } 229 | return mutationsMade; 230 | 231 | case 'splice': 232 | var size = Math.floor(ArrayBenchmark.elementCount / 5); 233 | var removed = array.splice(size, size); 234 | Array.prototype.splice.apply(array, [size*2, 0].concat(removed)); 235 | return size * 2; 236 | 237 | default: 238 | var val = array[this.undo](); 239 | array[this.operation](val + 1); 240 | return 2; 241 | } 242 | } 243 | }); 244 | 245 | function SetupArrayBenchmark(config, objectCount) { 246 | ObservationBenchmark.call(this, objectCount); 247 | }; 248 | 249 | SetupArrayBenchmark.configs = []; 250 | SetupArrayBenchmark.propertyCount = 15; 251 | 252 | SetupArrayBenchmark.prototype = createObject({ 253 | __proto__: SetupObservationBenchmark.prototype, 254 | 255 | newObject: function() { 256 | var array = []; 257 | for (var i = 0; i < ArrayBenchmark.elementCount; i++) 258 | array.push(i); 259 | return array; 260 | }, 261 | 262 | newObserver: function(array) { 263 | return new ArrayObserver(array); 264 | } 265 | }); 266 | 267 | function PathBenchmark(config, objectCount) { 268 | ObservationBenchmark.call(this, objectCount); 269 | this.leaf = config === 'leaf'; 270 | this.path = Path.get('foo.bar.baz'); 271 | this.firstPathProp = Path.get(this.path[0]); 272 | } 273 | 274 | PathBenchmark.configs = ['leaf', 'root']; 275 | 276 | PathBenchmark.prototype = createObject({ 277 | __proto__: ObservationBenchmark.prototype, 278 | 279 | newPath: function(parts, value) { 280 | var obj = {}; 281 | var ref = obj; 282 | var prop; 283 | for (var i = 0; i < parts.length - 1; i++) { 284 | prop = parts[i]; 285 | ref[prop] = {}; 286 | ref = ref[prop]; 287 | } 288 | 289 | prop = parts[parts.length - 1]; 290 | ref[prop] = value; 291 | 292 | return obj; 293 | }, 294 | 295 | newObject: function() { 296 | return this.newPath(this.path, 1); 297 | }, 298 | 299 | newObserver: function(obj) { 300 | return new PathObserver(obj, this.path); 301 | }, 302 | 303 | mutateObject: function(obj) { 304 | var val = this.path.getValueFrom(obj); 305 | if (this.leaf) { 306 | this.path.setValueFrom(obj, val + 1); 307 | } else { 308 | this.firstPathProp.setValueFrom(obj, this.newPath(this.path.slice(1), val + 1)); 309 | } 310 | 311 | return 1; 312 | } 313 | }); 314 | 315 | function SetupPathBenchmark(config, objectCount) { 316 | ObservationBenchmark.call(this, objectCount); 317 | this.path = Path.get('foo.bar.baz'); 318 | } 319 | 320 | SetupPathBenchmark.configs = []; 321 | 322 | SetupPathBenchmark.prototype = createObject({ 323 | __proto__: SetupObservationBenchmark.prototype, 324 | 325 | newPath: function(parts, value) { 326 | var obj = {}; 327 | var ref = obj; 328 | var prop; 329 | for (var i = 0; i < parts.length - 1; i++) { 330 | prop = parts[i]; 331 | ref[prop] = {}; 332 | ref = ref[prop]; 333 | } 334 | 335 | prop = parts[parts.length - 1]; 336 | ref[prop] = value; 337 | 338 | return obj; 339 | }, 340 | 341 | newObject: function() { 342 | return this.newPath(this.path, 1); 343 | }, 344 | 345 | newObserver: function(obj) { 346 | return new PathObserver(obj, this.path); 347 | } 348 | }); 349 | 350 | global.ObjectBenchmark = ObjectBenchmark; 351 | global.SetupObjectBenchmark = SetupObjectBenchmark; 352 | global.ArrayBenchmark = ArrayBenchmark; 353 | global.SetupArrayBenchmark = SetupArrayBenchmark; 354 | global.PathBenchmark = PathBenchmark; 355 | global.SetupPathBenchmark = SetupPathBenchmark; 356 | 357 | })(this); 358 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "observe-js", 3 | "homepage": "https://github.com/Polymer/observe-js", 4 | "authors": [ 5 | "The Polymer Authors" 6 | ], 7 | "description": "A library for observing Arrays, Objects and PathValues", 8 | "main": "src/observe.js", 9 | "keywords": [ 10 | "Object.observe" 11 | ], 12 | "license": "BSD", 13 | "private": true, 14 | "ignore": [ 15 | "**/.*", 16 | "node_modules", 17 | "bower_components", 18 | "test", 19 | "tests" 20 | ], 21 | "version": "0.5.7" 22 | } -------------------------------------------------------------------------------- /codereview.settings: -------------------------------------------------------------------------------- 1 | # This file is used by gcl to get repository specific information. 2 | CODE_REVIEW_SERVER: https://codereview.appspot.com 3 | VIEW_VC: https://github.com/Polymer/observe-js/commit/ 4 | 5 | -------------------------------------------------------------------------------- /conf/karma.conf.js: -------------------------------------------------------------------------------- 1 | /* 2 | * @license 3 | * Copyright (c) 2014 The Polymer Project Authors. All rights reserved. 4 | * This code may only be used under the BSD style license found at http://polymer.github.io/LICENSE.txt 5 | * The complete set of authors may be found at http://polymer.github.io/AUTHORS.txt 6 | * The complete set of contributors may be found at http://polymer.github.io/CONTRIBUTORS.txt 7 | * Code distributed by Google as part of the polymer project is also 8 | * subject to an additional IP rights grant found at http://polymer.github.io/PATENTS.txt 9 | */ 10 | 11 | module.exports = function(karma) { 12 | var common = require('../../tools/test/karma-common.conf.js'); 13 | karma.set(common.mixin_common_opts(karma, { 14 | // base path, that will be used to resolve files and exclude 15 | basePath: '../', 16 | 17 | // list of files / patterns to load in the browser 18 | files: [ 19 | 'node_modules/chai/chai.js', 20 | 'conf/mocha.conf.js', 21 | 'src/observe.js', 22 | 'util/array_reduction.js', 23 | 'tests/*.js' 24 | ], 25 | 26 | // list of files to exclude 27 | exclude: [ 28 | 'tests/d8_array_fuzzer.js', 29 | 'tests/d8_planner_test.js' 30 | ], 31 | })); 32 | }; 33 | -------------------------------------------------------------------------------- /conf/mocha.conf.js: -------------------------------------------------------------------------------- 1 | /* 2 | * @license 3 | * Copyright (c) 2014 The Polymer Project Authors. All rights reserved. 4 | * This code may only be used under the BSD style license found at http://polymer.github.io/LICENSE.txt 5 | * The complete set of authors may be found at http://polymer.github.io/AUTHORS.txt 6 | * The complete set of contributors may be found at http://polymer.github.io/CONTRIBUTORS.txt 7 | * Code distributed by Google as part of the polymer project is also 8 | * subject to an additional IP rights grant found at http://polymer.github.io/PATENTS.txt 9 | */ 10 | 11 | mocha.setup({ 12 | ui:'tdd', 13 | ignoreLeaks: true 14 | }); 15 | var assert = chai.assert; 16 | -------------------------------------------------------------------------------- /examples/circles.html: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |

Circles

16 |
17 | 27 | 28 |
29 | 64 | -------------------------------------------------------------------------------- /examples/constrain.html: -------------------------------------------------------------------------------- 1 | 9 | 10 |

The world's simplest constraint-solver

11 | 12 | 30 | -------------------------------------------------------------------------------- /examples/constrain.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2014 The Polymer Project Authors. All rights reserved. 3 | * This code may only be used under the BSD style license found at http://polymer.github.io/LICENSE.txt 4 | * The complete set of authors may be found at http://polymer.github.io/AUTHORS.txt 5 | * The complete set of contributors may be found at http://polymer.github.io/CONTRIBUTORS.txt 6 | * Code distributed by Google as part of the polymer project is also 7 | * subject to an additional IP rights grant found at http://polymer.github.io/PATENTS.txt 8 | */ 9 | 10 | (function(global) { 11 | 12 | /* This is a very simple version of the QuickPlan algorithm for solving 13 | * mutli-variable contraints. (http://www.cs.utk.edu/~bvz/quickplan.html) 14 | * The implementation varies from the standard described approach in a few ways: 15 | * 16 | * -There is no notion of constraint heirarchy. Here, all constraints are 17 | * considered REQUIRED. 18 | * 19 | * -There is no "improvement" phase where rejected constraints are added back 20 | * in an attempt to find a "better solution" 21 | * 22 | * -In place of the above two, a heuristic is used to pick the "weakest" 23 | * free constraint to remove. A function, "stayFunc" is passed to the 24 | * Variable class and is expected to return a priority value for the variable 25 | * 0 being highest and 1, 2, 3, etc... being lower. 26 | * 27 | * I suspect these variations result in there being no guarentee of choosing the 28 | * optimal solution, but it does seem to work well for the examples I've tested. 29 | * Note also that the DeltaBlue planner can be used in a similar pattern, 30 | * but it only supports single variable assignment. 31 | * 32 | * Note also that this is hacky and thrown together. Don't expect it to work 33 | * much at all =-). 34 | */ 35 | 36 | function Map() { 37 | this.map_ = new global.Map; 38 | this.keys_ = []; 39 | } 40 | 41 | Map.prototype = { 42 | get: function(key) { 43 | return this.map_.get(key); 44 | }, 45 | 46 | set: function(key, value) { 47 | if (!this.map_.has(key)) 48 | this.keys_.push(key); 49 | return this.map_.set(key, value); 50 | }, 51 | 52 | has: function(key) { 53 | return this.map_.has(key); 54 | }, 55 | 56 | delete: function(key) { 57 | this.keys_.splice(this.keys_.indexOf(key), 1); 58 | this.map_.delete(key); 59 | }, 60 | 61 | keys: function() { 62 | return this.keys_.slice(); 63 | } 64 | } 65 | 66 | function Variable(property, stayFunc) { 67 | this.property = property; 68 | this.stayFunc = stayFunc || function() { 69 | //console.log("Warning: using default stay func"); 70 | return 0; 71 | }; 72 | this.methods = []; 73 | }; 74 | 75 | Variable.prototype = { 76 | addMethod: function(method) { 77 | this.methods.push(method); 78 | }, 79 | 80 | removeMethod: function(method) { 81 | this.methods.splice(this.methods.indexOf(method), 1); 82 | }, 83 | 84 | isFree: function() { 85 | return this.methods.length <= 1; 86 | }, 87 | 88 | get stayPriority() { 89 | return this.stayFunc(this.property); 90 | } 91 | } 92 | 93 | function Method(opts) { 94 | opts = opts || {}; 95 | this.name = opts.name || 'function() { ... }'; 96 | this.outputs = opts.outputs || []; 97 | this.f = opts.f || function() { 98 | console.log('Warning: using default execution function'); 99 | }; 100 | }; 101 | 102 | Method.prototype = { 103 | planned_: false, 104 | variables_: [], 105 | 106 | set planned(planned) { 107 | this.planned_ = planned; 108 | 109 | if (this.planned_) { 110 | if (this.variables_) { 111 | // Remove this method from all variables. 112 | this.variables_.forEach(function(variable) { 113 | variable.removeMethod(this); 114 | }, this); 115 | } 116 | 117 | this.variables_ = null; 118 | } else { 119 | this.variables_ = null; 120 | 121 | // Get & add this method to all variables. 122 | if (this.constraint && this.constraint.planner) { 123 | this.variables_ = this.outputs.map(function(output) { 124 | var variable = this.constraint.planner.getVariable(output); 125 | variable.addMethod(this); 126 | return variable; 127 | }, this); 128 | } 129 | } 130 | }, 131 | 132 | get planned() { 133 | return this.planned_; 134 | }, 135 | 136 | isFree: function() { 137 | // Return true only if all variables are free. 138 | var variables = this.variables_; 139 | for (var i = variables.length - 1; i >= 0; i--) { 140 | if (!variables[i].isFree()) 141 | return false; 142 | } 143 | return true; 144 | }, 145 | 146 | weakerOf: function(other) { 147 | if (!other) { 148 | return this; 149 | } 150 | 151 | // Prefer a method that assigns to fewer variables. 152 | if (this.variables_.length != other.variables_.length) { 153 | return this.variables_.length < other.variables_.length ? this : other; 154 | } 155 | 156 | // Note: A weaker stay priority is a higher number. 157 | return this.getStayPriority() >= other.getStayPriority() ? this : other; 158 | }, 159 | 160 | getStayPriority: function() { 161 | // This returns the strongest (lowest) stay priority of this method's 162 | // output variables. 163 | return retval = this.variables_.reduce(function(min, variable) { 164 | return Math.min(min, variable.stayPriority); 165 | }, Infinity); 166 | }, 167 | 168 | execute: function() { 169 | console.log(JSON.stringify(this.outputs) + ' <= ' + this.name); 170 | this.f(); 171 | } 172 | }; 173 | 174 | function Constraint(methods, when) { 175 | this.methods = methods; 176 | this.when = when; 177 | }; 178 | 179 | Constraint.prototype = { 180 | executionMethod_: null, 181 | 182 | set executionMethod(executionMethod) { 183 | this.executionMethod_ = executionMethod; 184 | var planned = !!this.executionMethod_; 185 | 186 | this.methods.forEach(function(method) { 187 | method.constraint = this; 188 | method.planned = planned; 189 | }, this); 190 | }, 191 | 192 | get executionMethod() { 193 | return this.executionMethod_; 194 | }, 195 | 196 | getWeakestFreeMethod: function() { 197 | var methods = this.methods; 198 | var weakest = null; 199 | for (var i = 0; i < methods.length; i++) { 200 | var method = methods[i]; 201 | if (method.isFree()) 202 | weakest = method.weakerOf(weakest); 203 | } 204 | return weakest; 205 | }, 206 | 207 | execute: function() { 208 | this.executionMethod.execute(); 209 | } 210 | }; 211 | 212 | function Planner(object) { 213 | this.object = object; 214 | this.properties = {}; 215 | this.priority = [] 216 | var self = this; 217 | 218 | this.stayFunc = function(property) { 219 | if (self.object[property] === undefined) 220 | return Infinity; 221 | var index = self.priority.indexOf(property); 222 | return index >= 0 ? index : Infinity; 223 | } 224 | 225 | Object.observe(this.object, internalCallback); 226 | }; 227 | 228 | Planner.prototype = { 229 | plan_: null, 230 | 231 | deliverChanged: function(changeRecords) { 232 | var needsResolve = false; 233 | 234 | changeRecords.forEach(function(change) { 235 | var property = change.name; 236 | if (!(property in this.properties)) 237 | return; 238 | 239 | var index = this.priority.indexOf(property); 240 | if (index >= 0) 241 | this.priority.splice(this.priority.indexOf(property), 1); 242 | 243 | this.priority.unshift(property); 244 | needsResolve = true; 245 | }, this); 246 | 247 | if (!needsResolve) 248 | return; 249 | 250 | console.log('Resolving: ' + Object.getPrototypeOf(changeRecords[0].object).constructor.name); 251 | 252 | Object.unobserve(this.object, internalCallback); 253 | this.execute(); 254 | console.log('...Done: ' + JSON.stringify(this.object)); 255 | Object.observe(this.object, internalCallback); 256 | }, 257 | 258 | addConstraint: function(methods) { 259 | methods.forEach(function(method) { 260 | method.outputs.forEach(function(output) { 261 | this.properties[output] = true; 262 | }, this); 263 | }, this); 264 | 265 | var constraint = new Constraint(methods); 266 | 267 | this.constraints = this.constraints || []; 268 | if (this.constraints.indexOf(constraint) < 0) { 269 | this.plan_ = null; 270 | this.constraints.push(constraint); 271 | } 272 | return constraint; 273 | }, 274 | 275 | removeConstraint: function(constraint) { 276 | var index = this.constraints.indexOf(constraint); 277 | if (index >= 0) { 278 | this.plan_ = null; 279 | var removed = this.constraints.splice(index, 1)[0]; 280 | } 281 | return constraint; 282 | }, 283 | 284 | getVariable: function(property) { 285 | var index = this.properties_.indexOf(property); 286 | if (index >= 0) { 287 | return this.variables_[index]; 288 | } 289 | 290 | this.properties_.push(property); 291 | var variable = new Variable(property, this.stayFunc); 292 | this.variables_.push(variable); 293 | return variable; 294 | }, 295 | 296 | get plan() { 297 | if (this.plan_) { 298 | return this.plan_; 299 | } 300 | 301 | this.plan_ = []; 302 | this.properties_ = []; 303 | this.variables_ = []; 304 | 305 | var unplanned = this.constraints.filter(function(constraint) { 306 | // Note: setting executionMethod must take place after setting planner. 307 | if (constraint.when && !constraint.when()) { 308 | // Conditional and currenty disabled => not in use. 309 | constraint.planner = null; 310 | constraint.executionMethod = null; 311 | return false; 312 | } else { 313 | // In use. 314 | constraint.planner = this; 315 | constraint.executionMethod = null; 316 | return true; 317 | } 318 | }, this); 319 | 320 | while (unplanned.length > 0) { 321 | var method = this.chooseNextMethod(unplanned); 322 | if (!method) { 323 | throw "Cycle detected"; 324 | } 325 | 326 | var nextConstraint = method.constraint; 327 | unplanned.splice(unplanned.indexOf(nextConstraint), 1); 328 | this.plan_.unshift(nextConstraint); 329 | nextConstraint.executionMethod = method; 330 | } 331 | 332 | return this.plan_; 333 | }, 334 | 335 | chooseNextMethod: function(constraints) { 336 | var weakest = null; 337 | for (var i = 0; i < constraints.length; i++) { 338 | var current = constraints[i].getWeakestFreeMethod(); 339 | weakest = current ? current.weakerOf(weakest) : weakest; 340 | } 341 | return weakest; 342 | }, 343 | 344 | run: function() { 345 | this.execute(); 346 | }, 347 | 348 | execute: function() { 349 | this.plan_ = null; 350 | this.executing = true; 351 | this.plan.forEach(function(constraint) { 352 | constraint.execute(); 353 | }); 354 | this.executing = false; 355 | } 356 | } 357 | 358 | var planners = new WeakMap; 359 | 360 | function internalCallback(changeRecords) { 361 | var changeMap = new Map; 362 | 363 | changeRecords.forEach(function(change) { 364 | if (!planners.has(change.object)) 365 | return; 366 | 367 | var changes = changeMap.get(change.object); 368 | if (!changes) { 369 | changeMap.set(change.object, [change]); 370 | return; 371 | } 372 | 373 | changes.push(change); 374 | }); 375 | 376 | changeMap.keys().forEach(function(object) { 377 | planners.get(object).deliverChanged(changeMap.get(object)); 378 | }); 379 | } 380 | 381 | // Register callback to assign delivery order. 382 | var register = {}; 383 | Object.observe(register, internalCallback); 384 | Object.unobserve(register, internalCallback); 385 | 386 | global.constrain = function(obj, methodFunctions) { 387 | var planner = planners.get(obj); 388 | if (!planner) { 389 | planner = new Planner(obj); 390 | planners.set(obj, planner); 391 | } 392 | 393 | planner.addConstraint(Object.keys(methodFunctions).map(function(property) { 394 | var func = methodFunctions[property]; 395 | 396 | return new Method({ 397 | name: func.toString(), 398 | outputs: [ property ], 399 | f: function() { obj[property] = func.apply(obj); } 400 | }); 401 | })); 402 | } 403 | })(this); 404 | -------------------------------------------------------------------------------- /examples/persist.html: -------------------------------------------------------------------------------- 1 | 9 | 10 |

The worlds simplest persistence system

11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /examples/persist.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2014 The Polymer Project Authors. All rights reserved. 3 | * This code may only be used under the BSD style license found at http://polymer.github.io/LICENSE.txt 4 | * The complete set of authors may be found at http://polymer.github.io/AUTHORS.txt 5 | * The complete set of contributors may be found at http://polymer.github.io/CONTRIBUTORS.txt 6 | * Code distributed by Google as part of the polymer project is also 7 | * subject to an additional IP rights grant found at http://polymer.github.io/PATENTS.txt 8 | */ 9 | 10 | (function(global) { 11 | 12 | function Set() { 13 | this.set_ = new global.Set; 14 | this.keys_ = []; 15 | } 16 | 17 | Set.prototype = { 18 | add: function(key) { 19 | if (!this.set_.has(key)) 20 | this.keys_.push(key); 21 | return this.set_.add(key); 22 | }, 23 | 24 | has: function(key) { 25 | return this.set_.has(key); 26 | }, 27 | 28 | delete: function(key) { 29 | this.keys_.splice(this.keys_.indexOf(key), 1); 30 | this.set_.delete(key); 31 | }, 32 | 33 | keys: function() { 34 | return this.keys_.slice(); 35 | } 36 | } 37 | 38 | var dbName = 'PersistObserved'; 39 | var version; 40 | var db; 41 | var storeNames = {}; 42 | 43 | function constructorName(objOrFunction) { 44 | if (typeof objOrFunction == 'function') 45 | return objOrFunction.name; 46 | else 47 | return Object.getPrototypeOf(objOrFunction).constructor.name; 48 | } 49 | 50 | function getKeyPath(constructor) { 51 | return constructor.keyPath || 'id'; 52 | } 53 | 54 | function onerror(e) { 55 | console.log('Error: ' + e); 56 | }; 57 | 58 | var postOpen = []; 59 | 60 | function openDB() { 61 | var request = webkitIndexedDB.open(dbName); 62 | 63 | request.onerror = onerror; 64 | request.onsuccess = function(e) { 65 | db = e.target.result; 66 | version = db.version || 0; 67 | for (var i = 0; i < db.objectStoreNames.length; i++) 68 | storeNames[db.objectStoreNames.item(i)] = true; 69 | 70 | postOpen.forEach(function(action) { 71 | action(); 72 | }); 73 | }; 74 | } 75 | 76 | function handleChanged(changeRecords) { 77 | changeRecords.forEach(function(change) { 78 | persist(change.object); 79 | }); 80 | } 81 | 82 | var observer = new ChangeSummary(function(summaries) { 83 | storeChanges = {}; 84 | 85 | function getChange(obj) { 86 | var change = storeChanges[constructorName(obj)]; 87 | if (change) 88 | return change; 89 | 90 | change = { 91 | keyPath: getKeyPath(obj), 92 | needsAdd: new Set, 93 | needsSave: new Set, 94 | needsDelete: new Set 95 | }; 96 | 97 | storeChanges[storeName] = change; 98 | return change; 99 | } 100 | 101 | summaries.forEach(function(summary) { 102 | if (!Array.isArray(summary.object)) { 103 | getChange(summary.object).needsSave.add(summary.object); 104 | return; 105 | } 106 | 107 | summary.arraySplices.forEach(function(splice) { 108 | for (var i = 0; i < splice.removed.length; i++) { 109 | var obj = splice.removed[i]; 110 | var change = getChange(obj); 111 | if (change.needsAdd.has(obj)) 112 | change.needsAdd.delete(obj); 113 | else 114 | change.needsDelete.add(obj); 115 | } 116 | 117 | for (var i = splice.index; i < splice.index + splice.addedCount; i++) { 118 | var obj = summary.object[i]; 119 | var change = getChange(obj); 120 | if (change.needsDelete.has(obj)) 121 | change.needsDelete.delete(obj); 122 | else 123 | change.needsAdd.add(obj); 124 | } 125 | }); 126 | }); 127 | 128 | var storeNames = Object.keys(storeChanges); 129 | 130 | console.log('Persisting: ' + JSON.stringify(storeNames)); 131 | var trans = db.transaction(storeNames, "readwrite"); 132 | trans.onerror = onerror; 133 | trans.oncomplete = function() { 134 | console.log('...complete'); 135 | } 136 | storeNames.forEach(function(storeName) { 137 | 138 | var change = storeChanges[storeName]; 139 | var store = trans.objectStore(storeName); 140 | 141 | change.needsDelete.keys().forEach(function(obj) { 142 | var request = store.delete(obj[change.keyPath]); 143 | request.onerror = onerror; 144 | request.onsuccess = function(e) { 145 | console.log(' deleted: ' + JSON.stringify(obj)); 146 | delete obj[keyPath]; 147 | observer.unobserve(obj); 148 | if (change.needsSave.has(obj)) 149 | change.needsSave.delete(obj); 150 | }; 151 | }); 152 | 153 | change.needsSave.keys().forEach(function(obj) { 154 | var request = store.put(obj); 155 | request.onerror = onerror; 156 | request.onsuccess = function(e) { 157 | console.log(' saved: ' + JSON.stringify(obj)); 158 | }; 159 | }); 160 | 161 | change.needsAdd.keys().forEach(function(obj) { 162 | obj[keyPath] = ++maxIds[storeName]; 163 | var request = store.put(obj); 164 | request.onerror = onerror; 165 | request.onsuccess = function(e) { 166 | console.log(' created: ' + JSON.stringify(obj)); 167 | observer.observe(obj); 168 | }; 169 | }); 170 | }); 171 | }); 172 | 173 | var maxIds = {}; 174 | 175 | global.persistDB = {}; 176 | 177 | global.persistDB.retrieve = function(constructor) { 178 | var results = []; 179 | var instance = new constructor(); 180 | 181 | keyPath = constructor.keyPath || 'id'; 182 | storeName = constructor.name; 183 | maxIds[storeName] = maxIds[storeName] || 0; 184 | 185 | function doRetrieve() { 186 | console.log("Retrieving: " + storeName); 187 | 188 | var trans = db.transaction([storeName]); 189 | var store = trans.objectStore(storeName); 190 | 191 | var keyRange = webkitIDBKeyRange.lowerBound(0); 192 | var request = store.openCursor(keyRange); 193 | 194 | request.onerror = onerror; 195 | 196 | request.onsuccess = function(e) { 197 | var result = e.target.result; 198 | if (!!result == false) { 199 | observer.observePropertySet(results); 200 | console.log('...complete'); 201 | return; 202 | } 203 | 204 | var object = result.value; 205 | maxIds[storeName] = Math.max(maxIds[storeName], object[keyPath]); 206 | 207 | object.__proto__ = instance; 208 | constructor.apply(object); 209 | results.push(object); 210 | observer.observe(object); 211 | 212 | console.log(' => ' + JSON.stringify(object)); 213 | result.continue(); 214 | }; 215 | } 216 | 217 | function createStore() { 218 | console.log('Creating store: ' + storeName); 219 | version++; 220 | var request = db.setVersion(version); 221 | request.onerror = onerror; 222 | 223 | request.onsuccess = function(e) { 224 | var store = db.createObjectStore(storeName, { keyPath: keyPath }); 225 | storeNames[storeName] = true; 226 | e.target.transaction.oncomplete = doRetrieve; 227 | }; 228 | } 229 | 230 | var action = function() { 231 | if (storeName in storeNames) 232 | doRetrieve() 233 | else 234 | createStore(); 235 | } 236 | 237 | if (db) 238 | action(); 239 | else 240 | postOpen.push(action); 241 | 242 | return results; 243 | }; 244 | 245 | openDB(); 246 | })(this); 247 | -------------------------------------------------------------------------------- /gruntfile.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2014 The Polymer Project Authors. All rights reserved. 3 | * This code may only be used under the BSD style license found at http://polymer.github.io/LICENSE.txt 4 | * The complete set of authors may be found at http://polymer.github.io/AUTHORS.txt 5 | * The complete set of contributors may be found at http://polymer.github.io/CONTRIBUTORS.txt 6 | * Code distributed by Google as part of the polymer project is also 7 | * subject to an additional IP rights grant found at http://polymer.github.io/PATENTS.txt 8 | */ 9 | 10 | module.exports = function(grunt) { 11 | grunt.initConfig({ 12 | karma: { 13 | options: { 14 | configFile: 'conf/karma.conf.js', 15 | keepalive: true 16 | }, 17 | buildbot: { 18 | reporters: ['crbot'], 19 | logLevel: 'OFF' 20 | }, 21 | 'observe-js': { 22 | } 23 | } 24 | }); 25 | 26 | grunt.loadTasks('../tools/tasks'); 27 | grunt.loadNpmTasks('grunt-karma'); 28 | 29 | grunt.registerTask('default', 'test'); 30 | grunt.registerTask('test', ['override-chrome-launcher', 'karma:observe-js']); 31 | grunt.registerTask('test-buildbot', ['override-chrome-launcher', 'karma:buildbot']); 32 | }; 33 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | 13 | polymer api 14 | 45 | 46 | 47 | 48 | 49 | 50 | 51 |
52 | [remote] 53 | 54 |
55 | 56 | 72 | 73 | 74 | -------------------------------------------------------------------------------- /observe-js.html: -------------------------------------------------------------------------------- 1 | 9 | 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "observe-js", 3 | "version": "0.5.7", 4 | "description": "observe-js is a library for observing changes on JavaScript objects/arrays", 5 | "main": "src/observe.js", 6 | "directories": { 7 | "example": "examples", 8 | "test": "tests" 9 | }, 10 | "scripts": { 11 | "test": "echo \"Error: no test specified\" && exit 1" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/Polymer/observe-js.git" 16 | }, 17 | "author": "The Polymer Authors", 18 | "license": "BSD", 19 | "readmeFilename": "README.md", 20 | "devDependencies": { 21 | "chai": "*", 22 | "mocha": "*", 23 | "grunt": "*", 24 | "grunt-karma": "*", 25 | "karma": "~0.12.0", 26 | "karma-mocha": "*", 27 | "karma-firefox-launcher": "*", 28 | "karma-ie-launcher": "*", 29 | "karma-safari-launcher": "*", 30 | "karma-script-launcher": "*", 31 | "karma-crbot-reporter": "*" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/observe.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2014 The Polymer Project Authors. All rights reserved. 3 | * This code may only be used under the BSD style license found at http://polymer.github.io/LICENSE.txt 4 | * The complete set of authors may be found at http://polymer.github.io/AUTHORS.txt 5 | * The complete set of contributors may be found at http://polymer.github.io/CONTRIBUTORS.txt 6 | * Code distributed by Google as part of the polymer project is also 7 | * subject to an additional IP rights grant found at http://polymer.github.io/PATENTS.txt 8 | */ 9 | 10 | (function(global) { 11 | 'use strict'; 12 | 13 | var testingExposeCycleCount = global.testingExposeCycleCount; 14 | 15 | // Detect and do basic sanity checking on Object/Array.observe. 16 | function detectObjectObserve() { 17 | if (typeof Object.observe !== 'function' || 18 | typeof Array.observe !== 'function') { 19 | return false; 20 | } 21 | 22 | var records = []; 23 | 24 | function callback(recs) { 25 | records = recs; 26 | } 27 | 28 | var test = {}; 29 | var arr = []; 30 | Object.observe(test, callback); 31 | Array.observe(arr, callback); 32 | test.id = 1; 33 | test.id = 2; 34 | delete test.id; 35 | arr.push(1, 2); 36 | arr.length = 0; 37 | 38 | Object.deliverChangeRecords(callback); 39 | if (records.length !== 5) 40 | return false; 41 | 42 | if (records[0].type != 'add' || 43 | records[1].type != 'update' || 44 | records[2].type != 'delete' || 45 | records[3].type != 'splice' || 46 | records[4].type != 'splice') { 47 | return false; 48 | } 49 | 50 | Object.unobserve(test, callback); 51 | Array.unobserve(arr, callback); 52 | 53 | return true; 54 | } 55 | 56 | var hasObserve = detectObjectObserve(); 57 | 58 | function detectEval() { 59 | // Don't test for eval if we're running in a Chrome App environment. 60 | // We check for APIs set that only exist in a Chrome App context. 61 | if (typeof chrome !== 'undefined' && chrome.app && chrome.app.runtime) { 62 | return false; 63 | } 64 | 65 | // Firefox OS Apps do not allow eval. This feature detection is very hacky 66 | // but even if some other platform adds support for this function this code 67 | // will continue to work. 68 | if (typeof navigator != 'undefined' && navigator.getDeviceStorage) { 69 | return false; 70 | } 71 | 72 | try { 73 | var f = new Function('', 'return true;'); 74 | return f(); 75 | } catch (ex) { 76 | return false; 77 | } 78 | } 79 | 80 | var hasEval = detectEval(); 81 | 82 | function isIndex(s) { 83 | return +s === s >>> 0 && s !== ''; 84 | } 85 | 86 | function toNumber(s) { 87 | return +s; 88 | } 89 | 90 | function isObject(obj) { 91 | return obj === Object(obj); 92 | } 93 | 94 | var numberIsNaN = global.Number.isNaN || function(value) { 95 | return typeof value === 'number' && global.isNaN(value); 96 | }; 97 | 98 | function areSameValue(left, right) { 99 | if (left === right) 100 | return left !== 0 || 1 / left === 1 / right; 101 | if (numberIsNaN(left) && numberIsNaN(right)) 102 | return true; 103 | 104 | return left !== left && right !== right; 105 | } 106 | 107 | var createObject = ('__proto__' in {}) ? 108 | function(obj) { return obj; } : 109 | function(obj) { 110 | var proto = obj.__proto__; 111 | if (!proto) 112 | return obj; 113 | var newObject = Object.create(proto); 114 | Object.getOwnPropertyNames(obj).forEach(function(name) { 115 | Object.defineProperty(newObject, name, 116 | Object.getOwnPropertyDescriptor(obj, name)); 117 | }); 118 | return newObject; 119 | }; 120 | 121 | var identStart = '[\$_a-zA-Z]'; 122 | var identPart = '[\$_a-zA-Z0-9]'; 123 | var identRegExp = new RegExp('^' + identStart + '+' + identPart + '*' + '$'); 124 | 125 | function getPathCharType(char) { 126 | if (char === undefined) 127 | return 'eof'; 128 | 129 | var code = char.charCodeAt(0); 130 | 131 | switch(code) { 132 | case 0x5B: // [ 133 | case 0x5D: // ] 134 | case 0x2E: // . 135 | case 0x22: // " 136 | case 0x27: // ' 137 | case 0x30: // 0 138 | return char; 139 | 140 | case 0x5F: // _ 141 | case 0x24: // $ 142 | return 'ident'; 143 | 144 | case 0x20: // Space 145 | case 0x09: // Tab 146 | case 0x0A: // Newline 147 | case 0x0D: // Return 148 | case 0xA0: // No-break space 149 | case 0xFEFF: // Byte Order Mark 150 | case 0x2028: // Line Separator 151 | case 0x2029: // Paragraph Separator 152 | return 'ws'; 153 | } 154 | 155 | // a-z, A-Z 156 | if ((0x61 <= code && code <= 0x7A) || (0x41 <= code && code <= 0x5A)) 157 | return 'ident'; 158 | 159 | // 1-9 160 | if (0x31 <= code && code <= 0x39) 161 | return 'number'; 162 | 163 | return 'else'; 164 | } 165 | 166 | var pathStateMachine = { 167 | 'beforePath': { 168 | 'ws': ['beforePath'], 169 | 'ident': ['inIdent', 'append'], 170 | '[': ['beforeElement'], 171 | 'eof': ['afterPath'] 172 | }, 173 | 174 | 'inPath': { 175 | 'ws': ['inPath'], 176 | '.': ['beforeIdent'], 177 | '[': ['beforeElement'], 178 | 'eof': ['afterPath'] 179 | }, 180 | 181 | 'beforeIdent': { 182 | 'ws': ['beforeIdent'], 183 | 'ident': ['inIdent', 'append'] 184 | }, 185 | 186 | 'inIdent': { 187 | 'ident': ['inIdent', 'append'], 188 | '0': ['inIdent', 'append'], 189 | 'number': ['inIdent', 'append'], 190 | 'ws': ['inPath', 'push'], 191 | '.': ['beforeIdent', 'push'], 192 | '[': ['beforeElement', 'push'], 193 | 'eof': ['afterPath', 'push'] 194 | }, 195 | 196 | 'beforeElement': { 197 | 'ws': ['beforeElement'], 198 | '0': ['afterZero', 'append'], 199 | 'number': ['inIndex', 'append'], 200 | "'": ['inSingleQuote', 'append', ''], 201 | '"': ['inDoubleQuote', 'append', ''] 202 | }, 203 | 204 | 'afterZero': { 205 | 'ws': ['afterElement', 'push'], 206 | ']': ['inPath', 'push'] 207 | }, 208 | 209 | 'inIndex': { 210 | '0': ['inIndex', 'append'], 211 | 'number': ['inIndex', 'append'], 212 | 'ws': ['afterElement'], 213 | ']': ['inPath', 'push'] 214 | }, 215 | 216 | 'inSingleQuote': { 217 | "'": ['afterElement'], 218 | 'eof': ['error'], 219 | 'else': ['inSingleQuote', 'append'] 220 | }, 221 | 222 | 'inDoubleQuote': { 223 | '"': ['afterElement'], 224 | 'eof': ['error'], 225 | 'else': ['inDoubleQuote', 'append'] 226 | }, 227 | 228 | 'afterElement': { 229 | 'ws': ['afterElement'], 230 | ']': ['inPath', 'push'] 231 | } 232 | }; 233 | 234 | function noop() {} 235 | 236 | function parsePath(path) { 237 | var keys = []; 238 | var index = -1; 239 | var c, newChar, key, type, transition, action, typeMap, mode = 'beforePath'; 240 | 241 | var actions = { 242 | push: function() { 243 | if (key === undefined) 244 | return; 245 | 246 | keys.push(key); 247 | key = undefined; 248 | }, 249 | 250 | append: function() { 251 | if (key === undefined) 252 | key = newChar; 253 | else 254 | key += newChar; 255 | } 256 | }; 257 | 258 | function maybeUnescapeQuote() { 259 | if (index >= path.length) 260 | return; 261 | 262 | var nextChar = path[index + 1]; 263 | if ((mode == 'inSingleQuote' && nextChar == "'") || 264 | (mode == 'inDoubleQuote' && nextChar == '"')) { 265 | index++; 266 | newChar = nextChar; 267 | actions.append(); 268 | return true; 269 | } 270 | } 271 | 272 | while (mode) { 273 | index++; 274 | c = path[index]; 275 | 276 | if (c == '\\' && maybeUnescapeQuote(mode)) 277 | continue; 278 | 279 | type = getPathCharType(c); 280 | typeMap = pathStateMachine[mode]; 281 | transition = typeMap[type] || typeMap['else'] || 'error'; 282 | 283 | if (transition == 'error') 284 | return; // parse error; 285 | 286 | mode = transition[0]; 287 | action = actions[transition[1]] || noop; 288 | newChar = transition[2] === undefined ? c : transition[2]; 289 | action(); 290 | 291 | if (mode === 'afterPath') { 292 | return keys; 293 | } 294 | } 295 | 296 | return; // parse error 297 | } 298 | 299 | function isIdent(s) { 300 | return identRegExp.test(s); 301 | } 302 | 303 | var constructorIsPrivate = {}; 304 | 305 | function Path(parts, privateToken) { 306 | if (privateToken !== constructorIsPrivate) 307 | throw Error('Use Path.get to retrieve path objects'); 308 | 309 | for (var i = 0; i < parts.length; i++) { 310 | this.push(String(parts[i])); 311 | } 312 | 313 | if (hasEval && this.length) { 314 | this.getValueFrom = this.compiledGetValueFromFn(); 315 | } 316 | } 317 | 318 | // TODO(rafaelw): Make simple LRU cache 319 | var pathCache = {}; 320 | 321 | function getPath(pathString) { 322 | if (pathString instanceof Path) 323 | return pathString; 324 | 325 | if (pathString == null || pathString.length == 0) 326 | pathString = ''; 327 | 328 | if (typeof pathString != 'string') { 329 | if (isIndex(pathString.length)) { 330 | // Constructed with array-like (pre-parsed) keys 331 | return new Path(pathString, constructorIsPrivate); 332 | } 333 | 334 | pathString = String(pathString); 335 | } 336 | 337 | var path = pathCache[pathString]; 338 | if (path) 339 | return path; 340 | 341 | var parts = parsePath(pathString); 342 | if (!parts) 343 | return invalidPath; 344 | 345 | path = new Path(parts, constructorIsPrivate); 346 | pathCache[pathString] = path; 347 | return path; 348 | } 349 | 350 | Path.get = getPath; 351 | 352 | function formatAccessor(key) { 353 | if (isIndex(key)) { 354 | return '[' + key + ']'; 355 | } else { 356 | return '["' + key.replace(/"/g, '\\"') + '"]'; 357 | } 358 | } 359 | 360 | Path.prototype = createObject({ 361 | __proto__: [], 362 | valid: true, 363 | 364 | toString: function() { 365 | var pathString = ''; 366 | for (var i = 0; i < this.length; i++) { 367 | var key = this[i]; 368 | if (isIdent(key)) { 369 | pathString += i ? '.' + key : key; 370 | } else { 371 | pathString += formatAccessor(key); 372 | } 373 | } 374 | 375 | return pathString; 376 | }, 377 | 378 | getValueFrom: function(obj, defaultValue) { 379 | for (var i = 0; i < this.length; i++) { 380 | var key = this[i]; 381 | if (obj == null || !(key in obj)) 382 | return defaultValue; 383 | obj = obj[key]; 384 | } 385 | return obj; 386 | }, 387 | 388 | iterateObjects: function(obj, observe) { 389 | for (var i = 0; i < this.length; i++) { 390 | if (i) 391 | obj = obj[this[i - 1]]; 392 | if (!isObject(obj)) 393 | return; 394 | observe(obj, this[i]); 395 | } 396 | }, 397 | 398 | compiledGetValueFromFn: function() { 399 | var str = ''; 400 | var pathString = 'obj'; 401 | str += 'if (obj != null'; 402 | var i = 0; 403 | var key; 404 | for (; i < (this.length - 1); i++) { 405 | key = this[i]; 406 | pathString += isIdent(key) ? '.' + key : formatAccessor(key); 407 | str += ' &&\n ' + pathString + ' != null'; 408 | } 409 | 410 | key = this[i]; 411 | var keyIsIdent = isIdent(key); 412 | var keyForInOperator = keyIsIdent ? '"' + key.replace(/"/g, '\\"') + '"' : key; 413 | str += ' &&\n ' + keyForInOperator + ' in ' + pathString + ')\n'; 414 | pathString += keyIsIdent ? '.' + key : formatAccessor(key); 415 | 416 | str += ' return ' + pathString + ';\nelse\n return defaultValue;'; 417 | return new Function('obj', 'defaultValue', str); 418 | }, 419 | 420 | setValueFrom: function(obj, value) { 421 | if (!this.length) 422 | return false; 423 | 424 | for (var i = 0; i < this.length - 1; i++) { 425 | if (!isObject(obj)) 426 | return false; 427 | obj = obj[this[i]]; 428 | } 429 | 430 | if (!isObject(obj)) 431 | return false; 432 | 433 | obj[this[i]] = value; 434 | return true; 435 | } 436 | }); 437 | 438 | var invalidPath = new Path('', constructorIsPrivate); 439 | invalidPath.valid = false; 440 | invalidPath.getValueFrom = invalidPath.setValueFrom = function() {}; 441 | 442 | var MAX_DIRTY_CHECK_CYCLES = 1000; 443 | 444 | function dirtyCheck(observer) { 445 | var cycles = 0; 446 | while (cycles < MAX_DIRTY_CHECK_CYCLES && observer.check_()) { 447 | cycles++; 448 | } 449 | if (testingExposeCycleCount) 450 | global.dirtyCheckCycleCount = cycles; 451 | 452 | return cycles > 0; 453 | } 454 | 455 | function objectIsEmpty(object) { 456 | for (var prop in object) 457 | return false; 458 | return true; 459 | } 460 | 461 | function diffIsEmpty(diff) { 462 | return objectIsEmpty(diff.added) && 463 | objectIsEmpty(diff.removed) && 464 | objectIsEmpty(diff.changed); 465 | } 466 | 467 | function diffObjectFromOldObject(object, oldObject) { 468 | var added = {}; 469 | var removed = {}; 470 | var changed = {}; 471 | var prop; 472 | 473 | for (prop in oldObject) { 474 | var newValue = object[prop]; 475 | 476 | if (newValue !== undefined && newValue === oldObject[prop]) 477 | continue; 478 | 479 | if (!(prop in object)) { 480 | removed[prop] = undefined; 481 | continue; 482 | } 483 | 484 | if (newValue !== oldObject[prop]) 485 | changed[prop] = newValue; 486 | } 487 | 488 | for (prop in object) { 489 | if (prop in oldObject) 490 | continue; 491 | 492 | added[prop] = object[prop]; 493 | } 494 | 495 | if (Array.isArray(object) && object.length !== oldObject.length) 496 | changed.length = object.length; 497 | 498 | return { 499 | added: added, 500 | removed: removed, 501 | changed: changed 502 | }; 503 | } 504 | 505 | var eomTasks = []; 506 | function runEOMTasks() { 507 | if (!eomTasks.length) 508 | return false; 509 | 510 | for (var i = 0; i < eomTasks.length; i++) { 511 | eomTasks[i](); 512 | } 513 | eomTasks.length = 0; 514 | return true; 515 | } 516 | 517 | var runEOM = hasObserve ? (function(){ 518 | return function(fn) { 519 | return Promise.resolve().then(fn); 520 | }; 521 | })() : 522 | (function() { 523 | return function(fn) { 524 | eomTasks.push(fn); 525 | }; 526 | })(); 527 | 528 | var observedObjectCache = []; 529 | 530 | function newObservedObject() { 531 | var observer; 532 | var object; 533 | var discardRecords = false; 534 | var first = true; 535 | 536 | function callback(records) { 537 | if (observer && observer.state_ === OPENED && !discardRecords) 538 | observer.check_(records); 539 | } 540 | 541 | return { 542 | open: function(obs) { 543 | if (observer) 544 | throw Error('ObservedObject in use'); 545 | 546 | if (!first) 547 | Object.deliverChangeRecords(callback); 548 | 549 | observer = obs; 550 | first = false; 551 | }, 552 | observe: function(obj, arrayObserve) { 553 | object = obj; 554 | if (arrayObserve) 555 | Array.observe(object, callback); 556 | else 557 | Object.observe(object, callback); 558 | }, 559 | deliver: function(discard) { 560 | discardRecords = discard; 561 | Object.deliverChangeRecords(callback); 562 | discardRecords = false; 563 | }, 564 | close: function() { 565 | observer = undefined; 566 | Object.unobserve(object, callback); 567 | observedObjectCache.push(this); 568 | } 569 | }; 570 | } 571 | 572 | /* 573 | * The observedSet abstraction is a perf optimization which reduces the total 574 | * number of Object.observe observations of a set of objects. The idea is that 575 | * groups of Observers will have some object dependencies in common and this 576 | * observed set ensures that each object in the transitive closure of 577 | * dependencies is only observed once. The observedSet acts as a write barrier 578 | * such that whenever any change comes through, all Observers are checked for 579 | * changed values. 580 | * 581 | * Note that this optimization is explicitly moving work from setup-time to 582 | * change-time. 583 | * 584 | * TODO(rafaelw): Implement "garbage collection". In order to move work off 585 | * the critical path, when Observers are closed, their observed objects are 586 | * not Object.unobserve(d). As a result, it's possible that if the observedSet 587 | * is kept open, but some Observers have been closed, it could cause "leaks" 588 | * (prevent otherwise collectable objects from being collected). At some 589 | * point, we should implement incremental "gc" which keeps a list of 590 | * observedSets which may need clean-up and does small amounts of cleanup on a 591 | * timeout until all is clean. 592 | */ 593 | 594 | function getObservedObject(observer, object, arrayObserve) { 595 | var dir = observedObjectCache.pop() || newObservedObject(); 596 | dir.open(observer); 597 | dir.observe(object, arrayObserve); 598 | return dir; 599 | } 600 | 601 | var observedSetCache = []; 602 | 603 | function newObservedSet() { 604 | var observerCount = 0; 605 | var observers = []; 606 | var objects = []; 607 | var rootObj; 608 | var rootObjProps; 609 | 610 | function observe(obj, prop) { 611 | if (!obj) 612 | return; 613 | 614 | if (obj === rootObj) 615 | rootObjProps[prop] = true; 616 | 617 | if (objects.indexOf(obj) < 0) { 618 | objects.push(obj); 619 | Object.observe(obj, callback); 620 | } 621 | 622 | observe(Object.getPrototypeOf(obj), prop); 623 | } 624 | 625 | function allRootObjNonObservedProps(recs) { 626 | for (var i = 0; i < recs.length; i++) { 627 | var rec = recs[i]; 628 | if (rec.object !== rootObj || 629 | rootObjProps[rec.name] || 630 | rec.type === 'setPrototype') { 631 | return false; 632 | } 633 | } 634 | return true; 635 | } 636 | 637 | function callback(recs) { 638 | if (allRootObjNonObservedProps(recs)) 639 | return; 640 | 641 | var i, observer; 642 | for (i = 0; i < observers.length; i++) { 643 | observer = observers[i]; 644 | if (observer.state_ == OPENED) { 645 | observer.iterateObjects_(observe); 646 | } 647 | } 648 | 649 | for (i = 0; i < observers.length; i++) { 650 | observer = observers[i]; 651 | if (observer.state_ == OPENED) { 652 | observer.check_(); 653 | } 654 | } 655 | } 656 | 657 | var record = { 658 | objects: objects, 659 | get rootObject() { return rootObj; }, 660 | set rootObject(value) { 661 | rootObj = value; 662 | rootObjProps = {}; 663 | }, 664 | open: function(obs, object) { 665 | observers.push(obs); 666 | observerCount++; 667 | obs.iterateObjects_(observe); 668 | }, 669 | close: function(obs) { 670 | observerCount--; 671 | if (observerCount > 0) { 672 | return; 673 | } 674 | 675 | for (var i = 0; i < objects.length; i++) { 676 | Object.unobserve(objects[i], callback); 677 | Observer.unobservedCount++; 678 | } 679 | 680 | observers.length = 0; 681 | objects.length = 0; 682 | rootObj = undefined; 683 | rootObjProps = undefined; 684 | observedSetCache.push(this); 685 | if (lastObservedSet === this) 686 | lastObservedSet = null; 687 | }, 688 | }; 689 | 690 | return record; 691 | } 692 | 693 | var lastObservedSet; 694 | 695 | function getObservedSet(observer, obj) { 696 | if (!lastObservedSet || lastObservedSet.rootObject !== obj) { 697 | lastObservedSet = observedSetCache.pop() || newObservedSet(); 698 | lastObservedSet.rootObject = obj; 699 | } 700 | lastObservedSet.open(observer, obj); 701 | return lastObservedSet; 702 | } 703 | 704 | var UNOPENED = 0; 705 | var OPENED = 1; 706 | var CLOSED = 2; 707 | var RESETTING = 3; 708 | 709 | var nextObserverId = 1; 710 | 711 | function Observer() { 712 | this.state_ = UNOPENED; 713 | this.callback_ = undefined; 714 | this.target_ = undefined; // TODO(rafaelw): Should be WeakRef 715 | this.directObserver_ = undefined; 716 | this.value_ = undefined; 717 | this.id_ = nextObserverId++; 718 | } 719 | 720 | Observer.prototype = { 721 | open: function(callback, target) { 722 | if (this.state_ != UNOPENED) 723 | throw Error('Observer has already been opened.'); 724 | 725 | addToAll(this); 726 | this.callback_ = callback; 727 | this.target_ = target; 728 | this.connect_(); 729 | this.state_ = OPENED; 730 | return this.value_; 731 | }, 732 | 733 | close: function() { 734 | if (this.state_ != OPENED) 735 | return; 736 | 737 | removeFromAll(this); 738 | this.disconnect_(); 739 | this.value_ = undefined; 740 | this.callback_ = undefined; 741 | this.target_ = undefined; 742 | this.state_ = CLOSED; 743 | }, 744 | 745 | deliver: function() { 746 | if (this.state_ != OPENED) 747 | return; 748 | 749 | dirtyCheck(this); 750 | }, 751 | 752 | report_: function(changes) { 753 | try { 754 | this.callback_.apply(this.target_, changes); 755 | } catch (ex) { 756 | Observer._errorThrownDuringCallback = true; 757 | console.error('Exception caught during observer callback: ' + 758 | (ex.stack || ex)); 759 | } 760 | }, 761 | 762 | discardChanges: function() { 763 | this.check_(undefined, true); 764 | return this.value_; 765 | } 766 | }; 767 | 768 | var collectObservers = !hasObserve; 769 | var allObservers; 770 | Observer._allObserversCount = 0; 771 | 772 | if (collectObservers) { 773 | allObservers = []; 774 | } 775 | 776 | function addToAll(observer) { 777 | Observer._allObserversCount++; 778 | if (!collectObservers) 779 | return; 780 | 781 | allObservers.push(observer); 782 | } 783 | 784 | function removeFromAll(observer) { 785 | Observer._allObserversCount--; 786 | } 787 | 788 | var runningMicrotaskCheckpoint = false; 789 | 790 | global.Platform = global.Platform || {}; 791 | 792 | global.Platform.performMicrotaskCheckpoint = function() { 793 | if (runningMicrotaskCheckpoint) 794 | return; 795 | 796 | if (!collectObservers) 797 | return; 798 | 799 | runningMicrotaskCheckpoint = true; 800 | 801 | var cycles = 0; 802 | var anyChanged, toCheck; 803 | 804 | do { 805 | cycles++; 806 | toCheck = allObservers; 807 | allObservers = []; 808 | anyChanged = false; 809 | 810 | for (var i = 0; i < toCheck.length; i++) { 811 | var observer = toCheck[i]; 812 | if (observer.state_ != OPENED) 813 | continue; 814 | 815 | if (observer.check_()) 816 | anyChanged = true; 817 | 818 | allObservers.push(observer); 819 | } 820 | if (runEOMTasks()) 821 | anyChanged = true; 822 | } while (cycles < MAX_DIRTY_CHECK_CYCLES && anyChanged); 823 | 824 | if (testingExposeCycleCount) 825 | global.dirtyCheckCycleCount = cycles; 826 | 827 | runningMicrotaskCheckpoint = false; 828 | }; 829 | 830 | if (collectObservers) { 831 | global.Platform.clearObservers = function() { 832 | allObservers = []; 833 | }; 834 | } 835 | 836 | function ObjectObserver(object) { 837 | Observer.call(this); 838 | this.value_ = object; 839 | this.oldObject_ = undefined; 840 | } 841 | 842 | ObjectObserver.prototype = createObject({ 843 | __proto__: Observer.prototype, 844 | 845 | arrayObserve: false, 846 | 847 | connect_: function(callback, target) { 848 | if (hasObserve) { 849 | this.directObserver_ = getObservedObject(this, this.value_, 850 | this.arrayObserve); 851 | } else { 852 | this.oldObject_ = this.copyObject(this.value_); 853 | } 854 | 855 | }, 856 | 857 | copyObject: function(object) { 858 | var copy = Array.isArray(object) ? [] : {}; 859 | for (var prop in object) { 860 | copy[prop] = object[prop]; 861 | } 862 | if (Array.isArray(object)) 863 | copy.length = object.length; 864 | return copy; 865 | }, 866 | 867 | check_: function(changeRecords, skipChanges) { 868 | var diff; 869 | var oldValues; 870 | if (hasObserve) { 871 | if (!changeRecords) 872 | return false; 873 | 874 | oldValues = {}; 875 | diff = diffObjectFromChangeRecords(this.value_, changeRecords, 876 | oldValues); 877 | } else { 878 | oldValues = this.oldObject_; 879 | diff = diffObjectFromOldObject(this.value_, this.oldObject_); 880 | } 881 | 882 | if (diffIsEmpty(diff)) 883 | return false; 884 | 885 | if (!hasObserve) 886 | this.oldObject_ = this.copyObject(this.value_); 887 | 888 | this.report_([ 889 | diff.added || {}, 890 | diff.removed || {}, 891 | diff.changed || {}, 892 | function(property) { 893 | return oldValues[property]; 894 | } 895 | ]); 896 | 897 | return true; 898 | }, 899 | 900 | disconnect_: function() { 901 | if (hasObserve) { 902 | this.directObserver_.close(); 903 | this.directObserver_ = undefined; 904 | } else { 905 | this.oldObject_ = undefined; 906 | } 907 | }, 908 | 909 | deliver: function() { 910 | if (this.state_ != OPENED) 911 | return; 912 | 913 | if (hasObserve) 914 | this.directObserver_.deliver(false); 915 | else 916 | dirtyCheck(this); 917 | }, 918 | 919 | discardChanges: function() { 920 | if (this.directObserver_) 921 | this.directObserver_.deliver(true); 922 | else 923 | this.oldObject_ = this.copyObject(this.value_); 924 | 925 | return this.value_; 926 | } 927 | }); 928 | 929 | function ArrayObserver(array) { 930 | if (!Array.isArray(array)) 931 | throw Error('Provided object is not an Array'); 932 | ObjectObserver.call(this, array); 933 | } 934 | 935 | ArrayObserver.prototype = createObject({ 936 | 937 | __proto__: ObjectObserver.prototype, 938 | 939 | arrayObserve: true, 940 | 941 | copyObject: function(arr) { 942 | return arr.slice(); 943 | }, 944 | 945 | check_: function(changeRecords) { 946 | var splices; 947 | if (hasObserve) { 948 | if (!changeRecords) 949 | return false; 950 | splices = projectArraySplices(this.value_, changeRecords); 951 | } else { 952 | splices = calcSplices(this.value_, 0, this.value_.length, 953 | this.oldObject_, 0, this.oldObject_.length); 954 | } 955 | 956 | if (!splices || !splices.length) 957 | return false; 958 | 959 | if (!hasObserve) 960 | this.oldObject_ = this.copyObject(this.value_); 961 | 962 | this.report_([splices]); 963 | return true; 964 | } 965 | }); 966 | 967 | ArrayObserver.applySplices = function(previous, current, splices) { 968 | splices.forEach(function(splice) { 969 | var spliceArgs = [splice.index, splice.removed.length]; 970 | var addIndex = splice.index; 971 | while (addIndex < splice.index + splice.addedCount) { 972 | spliceArgs.push(current[addIndex]); 973 | addIndex++; 974 | } 975 | 976 | Array.prototype.splice.apply(previous, spliceArgs); 977 | }); 978 | }; 979 | 980 | function PathObserver(object, path, defaultValue) { 981 | Observer.call(this); 982 | 983 | this.object_ = object; 984 | this.path_ = getPath(path); 985 | this.defaultValue_ = defaultValue; 986 | this.directObserver_ = undefined; 987 | } 988 | 989 | PathObserver.prototype = createObject({ 990 | __proto__: Observer.prototype, 991 | 992 | get path() { 993 | return this.path_; 994 | }, 995 | 996 | connect_: function() { 997 | if (hasObserve) 998 | this.directObserver_ = getObservedSet(this, this.object_); 999 | 1000 | this.check_(undefined, true); 1001 | }, 1002 | 1003 | disconnect_: function() { 1004 | this.value_ = undefined; 1005 | 1006 | if (this.directObserver_) { 1007 | this.directObserver_.close(this); 1008 | this.directObserver_ = undefined; 1009 | } 1010 | }, 1011 | 1012 | iterateObjects_: function(observe) { 1013 | this.path_.iterateObjects(this.object_, observe); 1014 | }, 1015 | 1016 | check_: function(changeRecords, skipChanges) { 1017 | var oldValue = this.value_; 1018 | this.value_ = this.path_.getValueFrom(this.object_, this.defaultValue_); 1019 | if (skipChanges || areSameValue(this.value_, oldValue)) 1020 | return false; 1021 | 1022 | this.report_([this.value_, oldValue, this]); 1023 | return true; 1024 | }, 1025 | 1026 | setValue: function(newValue) { 1027 | if (this.path_) 1028 | this.path_.setValueFrom(this.object_, newValue); 1029 | } 1030 | }); 1031 | 1032 | function CompoundObserver(reportChangesOnOpen) { 1033 | Observer.call(this); 1034 | 1035 | this.reportChangesOnOpen_ = reportChangesOnOpen; 1036 | this.value_ = []; 1037 | this.directObserver_ = undefined; 1038 | this.observed_ = []; 1039 | } 1040 | 1041 | var observerSentinel = {}; 1042 | 1043 | CompoundObserver.prototype = createObject({ 1044 | __proto__: Observer.prototype, 1045 | 1046 | connect_: function() { 1047 | if (hasObserve) { 1048 | var object; 1049 | var needsDirectObserver = false; 1050 | for (var i = 0; i < this.observed_.length; i += 2) { 1051 | object = this.observed_[i]; 1052 | if (object !== observerSentinel) { 1053 | needsDirectObserver = true; 1054 | break; 1055 | } 1056 | } 1057 | 1058 | if (needsDirectObserver) 1059 | this.directObserver_ = getObservedSet(this, object); 1060 | } 1061 | 1062 | this.check_(undefined, !this.reportChangesOnOpen_); 1063 | }, 1064 | 1065 | disconnect_: function() { 1066 | for (var i = 0; i < this.observed_.length; i += 2) { 1067 | if (this.observed_[i] === observerSentinel) 1068 | this.observed_[i + 1].close(); 1069 | } 1070 | this.observed_.length = 0; 1071 | this.value_.length = 0; 1072 | 1073 | if (this.directObserver_) { 1074 | this.directObserver_.close(this); 1075 | this.directObserver_ = undefined; 1076 | } 1077 | }, 1078 | 1079 | addPath: function(object, path) { 1080 | if (this.state_ != UNOPENED && this.state_ != RESETTING) 1081 | throw Error('Cannot add paths once started.'); 1082 | 1083 | path = getPath(path); 1084 | this.observed_.push(object, path); 1085 | if (!this.reportChangesOnOpen_) 1086 | return; 1087 | var index = this.observed_.length / 2 - 1; 1088 | this.value_[index] = path.getValueFrom(object); 1089 | }, 1090 | 1091 | addObserver: function(observer) { 1092 | if (this.state_ != UNOPENED && this.state_ != RESETTING) 1093 | throw Error('Cannot add observers once started.'); 1094 | 1095 | this.observed_.push(observerSentinel, observer); 1096 | if (!this.reportChangesOnOpen_) 1097 | return; 1098 | var index = this.observed_.length / 2 - 1; 1099 | this.value_[index] = observer.open(this.deliver, this); 1100 | }, 1101 | 1102 | startReset: function() { 1103 | if (this.state_ != OPENED) 1104 | throw Error('Can only reset while open'); 1105 | 1106 | this.state_ = RESETTING; 1107 | this.disconnect_(); 1108 | }, 1109 | 1110 | finishReset: function() { 1111 | if (this.state_ != RESETTING) 1112 | throw Error('Can only finishReset after startReset'); 1113 | this.state_ = OPENED; 1114 | this.connect_(); 1115 | 1116 | return this.value_; 1117 | }, 1118 | 1119 | iterateObjects_: function(observe) { 1120 | var object; 1121 | for (var i = 0; i < this.observed_.length; i += 2) { 1122 | object = this.observed_[i]; 1123 | if (object !== observerSentinel) 1124 | this.observed_[i + 1].iterateObjects(object, observe); 1125 | } 1126 | }, 1127 | 1128 | check_: function(changeRecords, skipChanges) { 1129 | var oldValues; 1130 | for (var i = 0; i < this.observed_.length; i += 2) { 1131 | var object = this.observed_[i]; 1132 | var path = this.observed_[i+1]; 1133 | var value; 1134 | if (object === observerSentinel) { 1135 | var observable = path; 1136 | value = this.state_ === UNOPENED ? 1137 | observable.open(this.deliver, this) : 1138 | observable.discardChanges(); 1139 | } else { 1140 | value = path.getValueFrom(object); 1141 | } 1142 | 1143 | if (skipChanges) { 1144 | this.value_[i / 2] = value; 1145 | continue; 1146 | } 1147 | 1148 | if (areSameValue(value, this.value_[i / 2])) 1149 | continue; 1150 | 1151 | oldValues = oldValues || []; 1152 | oldValues[i / 2] = this.value_[i / 2]; 1153 | this.value_[i / 2] = value; 1154 | } 1155 | 1156 | if (!oldValues) 1157 | return false; 1158 | 1159 | // TODO(rafaelw): Having observed_ as the third callback arg here is 1160 | // pretty lame API. Fix. 1161 | this.report_([this.value_, oldValues, this.observed_]); 1162 | return true; 1163 | } 1164 | }); 1165 | 1166 | function identFn(value) { return value; } 1167 | 1168 | function ObserverTransform(observable, getValueFn, setValueFn, 1169 | dontPassThroughSet) { 1170 | this.callback_ = undefined; 1171 | this.target_ = undefined; 1172 | this.value_ = undefined; 1173 | this.observable_ = observable; 1174 | this.getValueFn_ = getValueFn || identFn; 1175 | this.setValueFn_ = setValueFn || identFn; 1176 | // TODO(rafaelw): This is a temporary hack. PolymerExpressions needs this 1177 | // at the moment because of a bug in it's dependency tracking. 1178 | this.dontPassThroughSet_ = dontPassThroughSet; 1179 | } 1180 | 1181 | ObserverTransform.prototype = { 1182 | open: function(callback, target) { 1183 | this.callback_ = callback; 1184 | this.target_ = target; 1185 | this.value_ = 1186 | this.getValueFn_(this.observable_.open(this.observedCallback_, this)); 1187 | return this.value_; 1188 | }, 1189 | 1190 | observedCallback_: function(value) { 1191 | value = this.getValueFn_(value); 1192 | if (areSameValue(value, this.value_)) 1193 | return; 1194 | var oldValue = this.value_; 1195 | this.value_ = value; 1196 | this.callback_.call(this.target_, this.value_, oldValue); 1197 | }, 1198 | 1199 | discardChanges: function() { 1200 | this.value_ = this.getValueFn_(this.observable_.discardChanges()); 1201 | return this.value_; 1202 | }, 1203 | 1204 | deliver: function() { 1205 | return this.observable_.deliver(); 1206 | }, 1207 | 1208 | setValue: function(value) { 1209 | value = this.setValueFn_(value); 1210 | if (!this.dontPassThroughSet_ && this.observable_.setValue) 1211 | return this.observable_.setValue(value); 1212 | }, 1213 | 1214 | close: function() { 1215 | if (this.observable_) 1216 | this.observable_.close(); 1217 | this.callback_ = undefined; 1218 | this.target_ = undefined; 1219 | this.observable_ = undefined; 1220 | this.value_ = undefined; 1221 | this.getValueFn_ = undefined; 1222 | this.setValueFn_ = undefined; 1223 | } 1224 | }; 1225 | 1226 | var expectedRecordTypes = { 1227 | add: true, 1228 | update: true, 1229 | delete: true 1230 | }; 1231 | 1232 | function diffObjectFromChangeRecords(object, changeRecords, oldValues) { 1233 | var added = {}; 1234 | var removed = {}; 1235 | 1236 | for (var i = 0; i < changeRecords.length; i++) { 1237 | var record = changeRecords[i]; 1238 | if (!expectedRecordTypes[record.type]) { 1239 | console.error('Unknown changeRecord type: ' + record.type); 1240 | console.error(record); 1241 | continue; 1242 | } 1243 | 1244 | if (!(record.name in oldValues)) 1245 | oldValues[record.name] = record.oldValue; 1246 | 1247 | if (record.type == 'update') 1248 | continue; 1249 | 1250 | if (record.type == 'add') { 1251 | if (record.name in removed) 1252 | delete removed[record.name]; 1253 | else 1254 | added[record.name] = true; 1255 | 1256 | continue; 1257 | } 1258 | 1259 | // type = 'delete' 1260 | if (record.name in added) { 1261 | delete added[record.name]; 1262 | delete oldValues[record.name]; 1263 | } else { 1264 | removed[record.name] = true; 1265 | } 1266 | } 1267 | 1268 | var prop; 1269 | for (prop in added) 1270 | added[prop] = object[prop]; 1271 | 1272 | for (prop in removed) 1273 | removed[prop] = undefined; 1274 | 1275 | var changed = {}; 1276 | for (prop in oldValues) { 1277 | if (prop in added || prop in removed) 1278 | continue; 1279 | 1280 | var newValue = object[prop]; 1281 | if (oldValues[prop] !== newValue) 1282 | changed[prop] = newValue; 1283 | } 1284 | 1285 | return { 1286 | added: added, 1287 | removed: removed, 1288 | changed: changed 1289 | }; 1290 | } 1291 | 1292 | function newSplice(index, removed, addedCount) { 1293 | return { 1294 | index: index, 1295 | removed: removed, 1296 | addedCount: addedCount 1297 | }; 1298 | } 1299 | 1300 | var EDIT_LEAVE = 0; 1301 | var EDIT_UPDATE = 1; 1302 | var EDIT_ADD = 2; 1303 | var EDIT_DELETE = 3; 1304 | 1305 | function ArraySplice() {} 1306 | 1307 | ArraySplice.prototype = { 1308 | 1309 | // Note: This function is *based* on the computation of the Levenshtein 1310 | // "edit" distance. The one change is that "updates" are treated as two 1311 | // edits - not one. With Array splices, an update is really a delete 1312 | // followed by an add. By retaining this, we optimize for "keeping" the 1313 | // maximum array items in the original array. For example: 1314 | // 1315 | // 'xxxx123' -> '123yyyy' 1316 | // 1317 | // With 1-edit updates, the shortest path would be just to update all seven 1318 | // characters. With 2-edit updates, we delete 4, leave 3, and add 4. This 1319 | // leaves the substring '123' intact. 1320 | calcEditDistances: function(current, currentStart, currentEnd, 1321 | old, oldStart, oldEnd) { 1322 | // "Deletion" columns 1323 | var rowCount = oldEnd - oldStart + 1; 1324 | var columnCount = currentEnd - currentStart + 1; 1325 | var distances = new Array(rowCount); 1326 | 1327 | var i, j; 1328 | 1329 | // "Addition" rows. Initialize null column. 1330 | for (i = 0; i < rowCount; i++) { 1331 | distances[i] = new Array(columnCount); 1332 | distances[i][0] = i; 1333 | } 1334 | 1335 | // Initialize null row 1336 | for (j = 0; j < columnCount; j++) 1337 | distances[0][j] = j; 1338 | 1339 | for (i = 1; i < rowCount; i++) { 1340 | for (j = 1; j < columnCount; j++) { 1341 | if (this.equals(current[currentStart + j - 1], old[oldStart + i - 1])) 1342 | distances[i][j] = distances[i - 1][j - 1]; 1343 | else { 1344 | var north = distances[i - 1][j] + 1; 1345 | var west = distances[i][j - 1] + 1; 1346 | distances[i][j] = north < west ? north : west; 1347 | } 1348 | } 1349 | } 1350 | 1351 | return distances; 1352 | }, 1353 | 1354 | // This starts at the final weight, and walks "backward" by finding 1355 | // the minimum previous weight recursively until the origin of the weight 1356 | // matrix. 1357 | spliceOperationsFromEditDistances: function(distances) { 1358 | var i = distances.length - 1; 1359 | var j = distances[0].length - 1; 1360 | var current = distances[i][j]; 1361 | var edits = []; 1362 | while (i > 0 || j > 0) { 1363 | if (i == 0) { 1364 | edits.push(EDIT_ADD); 1365 | j--; 1366 | continue; 1367 | } 1368 | if (j == 0) { 1369 | edits.push(EDIT_DELETE); 1370 | i--; 1371 | continue; 1372 | } 1373 | var northWest = distances[i - 1][j - 1]; 1374 | var west = distances[i - 1][j]; 1375 | var north = distances[i][j - 1]; 1376 | 1377 | var min; 1378 | if (west < north) 1379 | min = west < northWest ? west : northWest; 1380 | else 1381 | min = north < northWest ? north : northWest; 1382 | 1383 | if (min == northWest) { 1384 | if (northWest == current) { 1385 | edits.push(EDIT_LEAVE); 1386 | } else { 1387 | edits.push(EDIT_UPDATE); 1388 | current = northWest; 1389 | } 1390 | i--; 1391 | j--; 1392 | } else if (min == west) { 1393 | edits.push(EDIT_DELETE); 1394 | i--; 1395 | current = west; 1396 | } else { 1397 | edits.push(EDIT_ADD); 1398 | j--; 1399 | current = north; 1400 | } 1401 | } 1402 | 1403 | edits.reverse(); 1404 | return edits; 1405 | }, 1406 | 1407 | /** 1408 | * Splice Projection functions: 1409 | * 1410 | * A splice map is a representation of how a previous array of items 1411 | * was transformed into a new array of items. Conceptually it is a list of 1412 | * tuples of 1413 | * 1414 | * 1415 | * 1416 | * which are kept in ascending index order of. The tuple represents that at 1417 | * the |index|, |removed| sequence of items were removed, and counting forward 1418 | * from |index|, |addedCount| items were added. 1419 | */ 1420 | 1421 | /** 1422 | * Lacking individual splice mutation information, the minimal set of 1423 | * splices can be synthesized given the previous state and final state of an 1424 | * array. The basic approach is to calculate the edit distance matrix and 1425 | * choose the shortest path through it. 1426 | * 1427 | * Complexity: O(l * p) 1428 | * l: The length of the current array 1429 | * p: The length of the old array 1430 | */ 1431 | calcSplices: function(current, currentStart, currentEnd, 1432 | old, oldStart, oldEnd) { 1433 | var prefixCount = 0; 1434 | var suffixCount = 0; 1435 | 1436 | var minLength = Math.min(currentEnd - currentStart, oldEnd - oldStart); 1437 | if (currentStart == 0 && oldStart == 0) 1438 | prefixCount = this.sharedPrefix(current, old, minLength); 1439 | 1440 | if (currentEnd == current.length && oldEnd == old.length) 1441 | suffixCount = this.sharedSuffix(current, old, minLength - prefixCount); 1442 | 1443 | currentStart += prefixCount; 1444 | oldStart += prefixCount; 1445 | currentEnd -= suffixCount; 1446 | oldEnd -= suffixCount; 1447 | 1448 | if (currentEnd - currentStart == 0 && oldEnd - oldStart == 0) 1449 | return []; 1450 | 1451 | var splice; 1452 | if (currentStart == currentEnd) { 1453 | splice = newSplice(currentStart, [], 0); 1454 | while (oldStart < oldEnd) 1455 | splice.removed.push(old[oldStart++]); 1456 | 1457 | return [ splice ]; 1458 | } else if (oldStart == oldEnd) 1459 | return [ newSplice(currentStart, [], currentEnd - currentStart) ]; 1460 | 1461 | var ops = this.spliceOperationsFromEditDistances( 1462 | this.calcEditDistances(current, currentStart, currentEnd, 1463 | old, oldStart, oldEnd)); 1464 | 1465 | var splices = []; 1466 | var index = currentStart; 1467 | var oldIndex = oldStart; 1468 | for (var i = 0; i < ops.length; i++) { 1469 | switch(ops[i]) { 1470 | case EDIT_LEAVE: 1471 | if (splice) { 1472 | splices.push(splice); 1473 | splice = undefined; 1474 | } 1475 | 1476 | index++; 1477 | oldIndex++; 1478 | break; 1479 | case EDIT_UPDATE: 1480 | if (!splice) 1481 | splice = newSplice(index, [], 0); 1482 | 1483 | splice.addedCount++; 1484 | index++; 1485 | 1486 | splice.removed.push(old[oldIndex]); 1487 | oldIndex++; 1488 | break; 1489 | case EDIT_ADD: 1490 | if (!splice) 1491 | splice = newSplice(index, [], 0); 1492 | 1493 | splice.addedCount++; 1494 | index++; 1495 | break; 1496 | case EDIT_DELETE: 1497 | if (!splice) 1498 | splice = newSplice(index, [], 0); 1499 | 1500 | splice.removed.push(old[oldIndex]); 1501 | oldIndex++; 1502 | break; 1503 | } 1504 | } 1505 | 1506 | if (splice) { 1507 | splices.push(splice); 1508 | } 1509 | return splices; 1510 | }, 1511 | 1512 | sharedPrefix: function(current, old, searchLength) { 1513 | for (var i = 0; i < searchLength; i++) 1514 | if (!this.equals(current[i], old[i])) 1515 | return i; 1516 | return searchLength; 1517 | }, 1518 | 1519 | sharedSuffix: function(current, old, searchLength) { 1520 | var index1 = current.length; 1521 | var index2 = old.length; 1522 | var count = 0; 1523 | while (count < searchLength && this.equals(current[--index1], old[--index2])) 1524 | count++; 1525 | 1526 | return count; 1527 | }, 1528 | 1529 | calculateSplices: function(current, previous) { 1530 | return this.calcSplices(current, 0, current.length, previous, 0, 1531 | previous.length); 1532 | }, 1533 | 1534 | equals: function(currentValue, previousValue) { 1535 | return currentValue === previousValue; 1536 | } 1537 | }; 1538 | 1539 | var arraySplice = new ArraySplice(); 1540 | 1541 | function calcSplices(current, currentStart, currentEnd, 1542 | old, oldStart, oldEnd) { 1543 | return arraySplice.calcSplices(current, currentStart, currentEnd, 1544 | old, oldStart, oldEnd); 1545 | } 1546 | 1547 | function intersect(start1, end1, start2, end2) { 1548 | // Disjoint 1549 | if (end1 < start2 || end2 < start1) 1550 | return -1; 1551 | 1552 | // Adjacent 1553 | if (end1 == start2 || end2 == start1) 1554 | return 0; 1555 | 1556 | // Non-zero intersect, span1 first 1557 | if (start1 < start2) { 1558 | if (end1 < end2) 1559 | return end1 - start2; // Overlap 1560 | else 1561 | return end2 - start2; // Contained 1562 | } else { 1563 | // Non-zero intersect, span2 first 1564 | if (end2 < end1) 1565 | return end2 - start1; // Overlap 1566 | else 1567 | return end1 - start1; // Contained 1568 | } 1569 | } 1570 | 1571 | function mergeSplice(splices, index, removed, addedCount) { 1572 | 1573 | var splice = newSplice(index, removed, addedCount); 1574 | 1575 | var inserted = false; 1576 | var insertionOffset = 0; 1577 | 1578 | for (var i = 0; i < splices.length; i++) { 1579 | var current = splices[i]; 1580 | current.index += insertionOffset; 1581 | 1582 | if (inserted) 1583 | continue; 1584 | 1585 | var intersectCount = intersect(splice.index, 1586 | splice.index + splice.removed.length, 1587 | current.index, 1588 | current.index + current.addedCount); 1589 | 1590 | if (intersectCount >= 0) { 1591 | // Merge the two splices 1592 | 1593 | splices.splice(i, 1); 1594 | i--; 1595 | 1596 | insertionOffset -= current.addedCount - current.removed.length; 1597 | 1598 | splice.addedCount += current.addedCount - intersectCount; 1599 | var deleteCount = splice.removed.length + 1600 | current.removed.length - intersectCount; 1601 | 1602 | if (!splice.addedCount && !deleteCount) { 1603 | // merged splice is a noop. discard. 1604 | inserted = true; 1605 | } else { 1606 | removed = current.removed; 1607 | 1608 | if (splice.index < current.index) { 1609 | // some prefix of splice.removed is prepended to current.removed. 1610 | var prepend = splice.removed.slice(0, current.index - splice.index); 1611 | Array.prototype.push.apply(prepend, removed); 1612 | removed = prepend; 1613 | } 1614 | 1615 | if (splice.index + splice.removed.length > current.index + current.addedCount) { 1616 | // some suffix of splice.removed is appended to current.removed. 1617 | var append = splice.removed.slice(current.index + current.addedCount - splice.index); 1618 | Array.prototype.push.apply(removed, append); 1619 | } 1620 | 1621 | splice.removed = removed; 1622 | if (current.index < splice.index) { 1623 | splice.index = current.index; 1624 | } 1625 | } 1626 | } else if (splice.index < current.index) { 1627 | // Insert splice here. 1628 | 1629 | inserted = true; 1630 | 1631 | splices.splice(i, 0, splice); 1632 | i++; 1633 | 1634 | var offset = splice.addedCount - splice.removed.length; 1635 | current.index += offset; 1636 | insertionOffset += offset; 1637 | } 1638 | } 1639 | 1640 | if (!inserted) 1641 | splices.push(splice); 1642 | } 1643 | 1644 | function createInitialSplices(array, changeRecords) { 1645 | var splices = []; 1646 | 1647 | for (var i = 0; i < changeRecords.length; i++) { 1648 | var record = changeRecords[i]; 1649 | switch(record.type) { 1650 | case 'splice': 1651 | mergeSplice(splices, record.index, record.removed.slice(), record.addedCount); 1652 | break; 1653 | case 'add': 1654 | case 'update': 1655 | case 'delete': 1656 | if (!isIndex(record.name)) 1657 | continue; 1658 | var index = toNumber(record.name); 1659 | if (index < 0) 1660 | continue; 1661 | mergeSplice(splices, index, [record.oldValue], 1); 1662 | break; 1663 | default: 1664 | console.error('Unexpected record type: ' + JSON.stringify(record)); 1665 | break; 1666 | } 1667 | } 1668 | 1669 | return splices; 1670 | } 1671 | 1672 | function projectArraySplices(array, changeRecords) { 1673 | var splices = []; 1674 | 1675 | createInitialSplices(array, changeRecords).forEach(function(splice) { 1676 | if (splice.addedCount == 1 && splice.removed.length == 1) { 1677 | if (splice.removed[0] !== array[splice.index]) 1678 | splices.push(splice); 1679 | 1680 | return; 1681 | } 1682 | 1683 | splices = splices.concat(calcSplices(array, splice.index, splice.index + splice.addedCount, 1684 | splice.removed, 0, splice.removed.length)); 1685 | }); 1686 | 1687 | return splices; 1688 | } 1689 | 1690 | // Export the observe-js object for **Node.js**, with backwards-compatibility 1691 | // for the old `require()` API. Also ensure `exports` is not a DOM Element. 1692 | // If we're in the browser, export as a global object. 1693 | 1694 | var expose = global; 1695 | 1696 | if (typeof exports !== 'undefined' && !exports.nodeType) { 1697 | if (typeof module !== 'undefined' && module.exports) { 1698 | exports = module.exports; 1699 | } 1700 | expose = exports; 1701 | } 1702 | 1703 | expose.Observer = Observer; 1704 | expose.Observer.runEOM_ = runEOM; 1705 | expose.Observer.observerSentinel_ = observerSentinel; // for testing. 1706 | expose.Observer.hasObjectObserve = hasObserve; 1707 | expose.ArrayObserver = ArrayObserver; 1708 | expose.ArrayObserver.calculateSplices = function(current, previous) { 1709 | return arraySplice.calculateSplices(current, previous); 1710 | }; 1711 | 1712 | expose.ArraySplice = ArraySplice; 1713 | expose.ObjectObserver = ObjectObserver; 1714 | expose.PathObserver = PathObserver; 1715 | expose.CompoundObserver = CompoundObserver; 1716 | expose.Path = Path; 1717 | expose.ObserverTransform = ObserverTransform; 1718 | 1719 | })(typeof global !== 'undefined' && global && typeof module !== 'undefined' && module ? global : this || window); 1720 | -------------------------------------------------------------------------------- /tests/array_fuzzer.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2014 The Polymer Project Authors. All rights reserved. 3 | * This code may only be used under the BSD style license found at http://polymer.github.io/LICENSE.txt 4 | * The complete set of authors may be found at http://polymer.github.io/AUTHORS.txt 5 | * The complete set of contributors may be found at http://polymer.github.io/CONTRIBUTORS.txt 6 | * Code distributed by Google as part of the polymer project is also 7 | * subject to an additional IP rights grant found at http://polymer.github.io/PATENTS.txt 8 | */ 9 | 10 | (function(global) { 11 | 12 | function ArrayFuzzer() {} 13 | 14 | ArrayFuzzer.valMax = 16; 15 | ArrayFuzzer.arrayLengthMax = 128; 16 | ArrayFuzzer.operationCount = 64; 17 | 18 | function randDouble(start, end) { 19 | return Math.random()*(end-start) + start; 20 | } 21 | 22 | function randInt(start, end) { 23 | return Math.round(randDouble(start, end)); 24 | } 25 | 26 | function randASCIIChar() { 27 | return String.fromCharCode(randInt(32, 126)); 28 | } 29 | 30 | function randValue() { 31 | switch(randInt(0, 5)) { 32 | case 0: 33 | return {}; 34 | case 1: 35 | return undefined; 36 | case 2: 37 | return null; 38 | case 3: 39 | return randInt(0, ArrayFuzzer.valMax); 40 | case 4: 41 | return randDouble(0, ArrayFuzzer.valMax); 42 | case 5: 43 | return randASCIIChar(); 44 | } 45 | } 46 | 47 | function randArray() { 48 | var args = []; 49 | var count = randInt(0, ArrayFuzzer.arrayLengthMax); 50 | while(count-- > 0) 51 | args.push(randValue()); 52 | 53 | return args; 54 | } 55 | 56 | function randomArrayOperation(arr) { 57 | function empty() { 58 | return []; 59 | } 60 | 61 | var operations = { 62 | push: randArray, 63 | unshift: randArray, 64 | pop: empty, 65 | shift: empty, 66 | splice: function() { 67 | var args = []; 68 | args.push(randInt(-arr.length*2, arr.length*2), randInt(0, arr.length*2)); 69 | args = args.concat(randArray()); 70 | return args; 71 | } 72 | }; 73 | 74 | // Do a splice once for each of the other operations. 75 | var operationList = ['splice', 'update', 76 | 'splice', 'delete', 77 | 'splice', 'push', 78 | 'splice', 'pop', 79 | 'splice', 'shift', 80 | 'splice', 'unshift']; 81 | 82 | var op = { 83 | name: operationList[randInt(0, operationList.length - 1)] 84 | }; 85 | 86 | switch(op.name) { 87 | case 'delete': 88 | op.index = randInt(0, arr.length - 1); 89 | delete arr[op.index]; 90 | break; 91 | 92 | case 'update': 93 | op.index = randInt(0, arr.length); 94 | op.value = randValue(); 95 | arr[op.index] = op.value; 96 | break; 97 | 98 | default: 99 | op.args = operations[op.name](); 100 | arr[op.name].apply(arr, op.args); 101 | break; 102 | } 103 | 104 | return op; 105 | } 106 | 107 | function randomArrayOperations(arr, count) { 108 | var ops = [] 109 | for (var i = 0; i < count; i++) { 110 | ops.push(randomArrayOperation(arr)); 111 | } 112 | 113 | return ops; 114 | } 115 | 116 | ArrayFuzzer.prototype.go = function() { 117 | var orig = this.arr = randArray(); 118 | randomArrayOperations(this.arr, ArrayFuzzer.operationCount); 119 | var copy = this.copy = this.arr.slice(); 120 | this.origCopy = this.copy.slice(); 121 | 122 | var observer = new ArrayObserver(this.arr); 123 | observer.open(function(splices) { 124 | ArrayObserver.applySplices(copy, orig, splices); 125 | }); 126 | 127 | this.ops = randomArrayOperations(this.arr, ArrayFuzzer.operationCount); 128 | observer.deliver(); 129 | observer.close(); 130 | } 131 | 132 | global.ArrayFuzzer = ArrayFuzzer; 133 | 134 | })(this); 135 | -------------------------------------------------------------------------------- /tests/d8_array_fuzzer.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2014 The Polymer Project Authors. All rights reserved. 3 | * This code may only be used under the BSD style license found at http://polymer.github.io/LICENSE.txt 4 | * The complete set of authors may be found at http://polymer.github.io/AUTHORS.txt 5 | * The complete set of contributors may be found at http://polymer.github.io/CONTRIBUTORS.txt 6 | * Code distributed by Google as part of the polymer project is also 7 | * subject to an additional IP rights grant found at http://polymer.github.io/PATENTS.txt 8 | */ 9 | 10 | // Run ArrayFuzzer under d8, e.g. 11 | // path/to/d8 change_summary.js tests/array_fuzzer.js tests/d8_array_fuzzer.js (--harmony) 12 | 13 | function checkEqual(arr1, arr2) { 14 | if (arr1.length != arr2.length) 15 | throw 'Lengths not equal: ' + arr1.length + ', ' + arr2.length; 16 | for (var i = 0; i < arr1.length; i++) { 17 | if (arr1[i] !== arr2[i]) 18 | throw 'Value at i: ' + i + ' not equal: ' + arr1[i] + ', ' + arr2[i]; 19 | } 20 | } 21 | 22 | var t1 = new Date(); 23 | for (var i = 0; i < 2048 * 1000; i++) { 24 | print('pass: ' + i); 25 | var fuzzer = new ArrayFuzzer(); 26 | fuzzer.go(); 27 | try { 28 | checkEqual(fuzzer.arr, fuzzer.copy); 29 | } catch (ex) { 30 | console.log('Fail: ' + ex); 31 | console.log(JSON.stringify(fuzzer.origCopy)); 32 | console.log(JSON.stringify(fuzzer.ops)); 33 | throw ex; 34 | } 35 | } 36 | 37 | var t2 = new Date(); 38 | print('Finished in: ' + (t2.getTime() - t1.getTime()) + 'ms'); 39 | -------------------------------------------------------------------------------- /tests/d8_planner_test.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2014 The Polymer Project Authors. All rights reserved. 3 | * This code may only be used under the BSD style license found at http://polymer.github.io/LICENSE.txt 4 | * The complete set of authors may be found at http://polymer.github.io/AUTHORS.txt 5 | * The complete set of contributors may be found at http://polymer.github.io/CONTRIBUTORS.txt 6 | * Code distributed by Google as part of the polymer project is also 7 | * subject to an additional IP rights grant found at http://polymer.github.io/PATENTS.txt 8 | */ 9 | 10 | "use strict"; 11 | 12 | var planner; 13 | 14 | function setup() { 15 | 16 | planner = new Planner(); 17 | 18 | function addVariable(priority) { 19 | return planner.addVariable(function() { 20 | return priority; 21 | }); 22 | } 23 | 24 | function bind(prop1, prop2) { 25 | var c = planner.addConstraint(); 26 | return { 27 | constraint: c, 28 | to: c.addMethod(prop2), 29 | from: c.addMethod(prop1) 30 | }; 31 | } 32 | 33 | var count = 1000000; 34 | var variable = addVariable(count); 35 | 36 | while (count-- > 0) { 37 | var newVar = addVariable(count); 38 | bind(variable, newVar); 39 | variable = newVar; 40 | } 41 | } 42 | 43 | function run() { 44 | var t1 = new Date(); 45 | planner.getPlan(); 46 | var t2 = new Date(); 47 | print('Finished in: ' + (t2.getTime() - t1.getTime()) + 'ms'); 48 | } 49 | 50 | setup(); 51 | run(); 52 | -------------------------------------------------------------------------------- /tests/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 11 | 12 | observe-js tests 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 29 | 30 | 31 | 32 | 33 |
34 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /tests/planner.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 11 | 12 | Planner Tests 13 | 14 | 15 | 16 | 17 | 18 | 26 | 27 | 28 | 125 |
126 | 129 | 130 | 131 | -------------------------------------------------------------------------------- /tests/test.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2014 The Polymer Project Authors. All rights reserved. 3 | * This code may only be used under the BSD style license found at http://polymer.github.io/LICENSE.txt 4 | * The complete set of authors may be found at http://polymer.github.io/AUTHORS.txt 5 | * The complete set of contributors may be found at http://polymer.github.io/CONTRIBUTORS.txt 6 | * Code distributed by Google as part of the polymer project is also 7 | * subject to an additional IP rights grant found at http://polymer.github.io/PATENTS.txt 8 | */ 9 | 10 | var observer; 11 | var callbackArgs = undefined; 12 | var callbackInvoked = false; 13 | 14 | function then(fn) { 15 | setTimeout(function() { 16 | Platform.performMicrotaskCheckpoint(); 17 | fn(); 18 | }, 0); 19 | 20 | return { 21 | then: function(next) { 22 | return then(next); 23 | } 24 | }; 25 | } 26 | 27 | function noop() {} 28 | 29 | function callback() { 30 | callbackArgs = Array.prototype.slice.apply(arguments); 31 | callbackInvoked = true; 32 | } 33 | 34 | function doSetup() {} 35 | function doTeardown() { 36 | callbackInvoked = false; 37 | callbackArgs = undefined; 38 | } 39 | 40 | function assertNoChanges() { 41 | if (observer) 42 | observer.deliver(); 43 | assert.isFalse(callbackInvoked); 44 | assert.isUndefined(callbackArgs); 45 | } 46 | 47 | function assertPathChanges(expectNewValue, expectOldValue, dontDeliver) { 48 | if (!dontDeliver) 49 | observer.deliver(); 50 | 51 | assert.isTrue(callbackInvoked); 52 | 53 | var newValue = callbackArgs[0]; 54 | var oldValue = callbackArgs[1]; 55 | assert.deepEqual(expectNewValue, newValue); 56 | assert.deepEqual(expectOldValue, oldValue); 57 | 58 | if (!dontDeliver) { 59 | assert.isTrue(window.dirtyCheckCycleCount === undefined || 60 | window.dirtyCheckCycleCount === 1); 61 | } 62 | 63 | callbackArgs = undefined; 64 | callbackInvoked = false; 65 | } 66 | 67 | function assertCompoundPathChanges(expectNewValues, expectOldValues, 68 | expectObserved, dontDeliver) { 69 | if (!dontDeliver) 70 | observer.deliver(); 71 | 72 | assert.isTrue(callbackInvoked); 73 | 74 | var newValues = callbackArgs[0]; 75 | var oldValues = callbackArgs[1]; 76 | var observed = callbackArgs[2]; 77 | assert.deepEqual(expectNewValues, newValues); 78 | assert.deepEqual(expectOldValues, oldValues); 79 | assert.deepEqual(expectObserved, observed); 80 | 81 | if (!dontDeliver) { 82 | assert.isTrue(window.dirtyCheckCycleCount === undefined || 83 | window.dirtyCheckCycleCount === 1); 84 | } 85 | 86 | callbackArgs = undefined; 87 | callbackInvoked = false; 88 | } 89 | 90 | var createObject = ('__proto__' in {}) ? 91 | function(obj) { return obj; } : 92 | function(obj) { 93 | var proto = obj.__proto__; 94 | if (!proto) 95 | return obj; 96 | var newObject = Object.create(proto); 97 | Object.getOwnPropertyNames(obj).forEach(function(name) { 98 | Object.defineProperty(newObject, name, 99 | Object.getOwnPropertyDescriptor(obj, name)); 100 | }); 101 | return newObject; 102 | }; 103 | 104 | function assertPath(pathString, expectKeys, expectSerialized) { 105 | var path = Path.get(pathString); 106 | if (!expectKeys) { 107 | assert.isFalse(path.valid); 108 | return; 109 | } 110 | 111 | assert.deepEqual(Array.prototype.slice.apply(path), expectKeys); 112 | assert.strictEqual(path.toString(), expectSerialized); 113 | } 114 | 115 | function assertInvalidPath(pathString) { 116 | assertPath(pathString); 117 | } 118 | 119 | suite('Path', function() { 120 | test('constructor throws', function() { 121 | assert.throws(function() { 122 | new Path('foo') 123 | }); 124 | }); 125 | 126 | test('path validity', function() { 127 | // invalid path get value is always undefined 128 | var p = Path.get('a b'); 129 | assert.isFalse(p.valid); 130 | assert.isUndefined(p.getValueFrom({ a: { b: 2 }})); 131 | 132 | assertPath('', [], ''); 133 | assertPath(' ', [], ''); 134 | assertPath(null, [], ''); 135 | assertPath(undefined, [], ''); 136 | assertPath('a', ['a'], 'a'); 137 | assertPath('a.b', ['a', 'b'], 'a.b'); 138 | assertPath('a. b', ['a', 'b'], 'a.b'); 139 | assertPath('a .b', ['a', 'b'], 'a.b'); 140 | assertPath('a . b', ['a', 'b'], 'a.b'); 141 | assertPath(' a . b ', ['a', 'b'], 'a.b'); 142 | assertPath('a[0]', ['a', '0'], 'a[0]'); 143 | assertPath('a [0]', ['a', '0'], 'a[0]'); 144 | assertPath('a[0][1]', ['a', '0', '1'], 'a[0][1]'); 145 | assertPath('a [ 0 ] [ 1 ] ', ['a', '0', '1'], 'a[0][1]'); 146 | assertPath('[1234567890] ', ['1234567890'], '[1234567890]'); 147 | assertPath(' [1234567890] ', ['1234567890'], '[1234567890]'); 148 | assertPath('opt0', ['opt0'], 'opt0'); 149 | assertPath('$foo.$bar._baz', ['$foo', '$bar', '_baz'], '$foo.$bar._baz'); 150 | assertPath('foo["baz"]', ['foo', 'baz'], 'foo.baz'); 151 | assertPath('foo["b\\"az"]', ['foo', 'b"az'], 'foo["b\\"az"]'); 152 | assertPath("foo['b\\'az']", ['foo', "b'az"], 'foo["b\'az"]'); 153 | assertPath(['a', 'b'], ['a', 'b'], 'a.b'); 154 | assertPath([''], [''], '[""]'); 155 | 156 | function Foo(val) { this.val = val; } 157 | Foo.prototype.toString = function() { return 'Foo' + this.val; }; 158 | assertPath([new Foo('a'), new Foo('b')], ['Fooa', 'Foob'], 'Fooa.Foob'); 159 | 160 | assertInvalidPath('.'); 161 | assertInvalidPath(' . '); 162 | assertInvalidPath('..'); 163 | assertInvalidPath('a[4'); 164 | assertInvalidPath('a.b.'); 165 | assertInvalidPath('a,b'); 166 | assertInvalidPath('a["foo]'); 167 | assertInvalidPath('[0x04]'); 168 | assertInvalidPath('[0foo]'); 169 | assertInvalidPath('[foo-bar]'); 170 | assertInvalidPath('foo-bar'); 171 | assertInvalidPath('42'); 172 | assertInvalidPath('a[04]'); 173 | assertInvalidPath(' a [ 04 ]'); 174 | assertInvalidPath(' 42 '); 175 | assertInvalidPath('foo["bar]'); 176 | assertInvalidPath("foo['bar]"); 177 | }); 178 | 179 | test('Paths are interned', function() { 180 | var p = Path.get('foo.bar'); 181 | var p2 = Path.get('foo.bar'); 182 | assert.strictEqual(p, p2); 183 | 184 | var p3 = Path.get(''); 185 | var p4 = Path.get(''); 186 | assert.strictEqual(p3, p4); 187 | }); 188 | 189 | test('null is empty path', function() { 190 | assert.strictEqual(Path.get(''), Path.get(null)); 191 | }); 192 | 193 | test('undefined is empty path', function() { 194 | assert.strictEqual(Path.get(undefined), Path.get(null)); 195 | }); 196 | 197 | test('Path.getValueFrom', function() { 198 | var obj = { 199 | a: { 200 | b: { 201 | c: 1 202 | } 203 | } 204 | }; 205 | 206 | var p1 = Path.get('a'); 207 | var p2 = Path.get('a.b'); 208 | var p3 = Path.get('a.b.c'); 209 | 210 | assert.strictEqual(obj.a, p1.getValueFrom(obj)); 211 | assert.strictEqual(obj.a.b, p2.getValueFrom(obj)); 212 | assert.strictEqual(1, p3.getValueFrom(obj)); 213 | 214 | obj.a.b.c = 2; 215 | assert.strictEqual(2, p3.getValueFrom(obj)); 216 | 217 | obj.a.b = { 218 | c: 3 219 | }; 220 | assert.strictEqual(3, p3.getValueFrom(obj)); 221 | 222 | obj.a = { 223 | b: 4 224 | }; 225 | assert.strictEqual(undefined, p3.getValueFrom(obj)); 226 | assert.strictEqual(4, p2.getValueFrom(obj)); 227 | 228 | var defaultValue = 42; 229 | assert.strictEqual(defaultValue, p3.getValueFrom(obj, defaultValue)); 230 | }); 231 | 232 | test('Path.setValueFrom', function() { 233 | var obj = {}; 234 | var p2 = Path.get('bar'); 235 | 236 | Path.get('foo').setValueFrom(obj, 3); 237 | assert.equal(3, obj.foo); 238 | 239 | var bar = { baz: 3 }; 240 | 241 | Path.get('bar').setValueFrom(obj, bar); 242 | assert.equal(bar, obj.bar); 243 | 244 | var p = Path.get('bar.baz.bat'); 245 | p.setValueFrom(obj, 'not here'); 246 | assert.equal(undefined, p.getValueFrom(obj)); 247 | }); 248 | 249 | test('Degenerate Values', function() { 250 | var emptyPath = Path.get(); 251 | var foo = {}; 252 | 253 | assert.equal(null, emptyPath.getValueFrom(null)); 254 | assert.equal(foo, emptyPath.getValueFrom(foo)); 255 | assert.equal(3, emptyPath.getValueFrom(3)); 256 | assert.equal(undefined, Path.get('a').getValueFrom(undefined)); 257 | }); 258 | }); 259 | 260 | suite('Basic Tests', function() { 261 | 262 | test('Exception Doesnt Stop Notification', function() { 263 | var model = [1]; 264 | var count = 0; 265 | 266 | var observer1 = new ObjectObserver(model); 267 | observer1.open(function() { 268 | count++; 269 | throw 'ouch'; 270 | }); 271 | 272 | var observer2 = new PathObserver(model, '[0]'); 273 | observer2.open(function() { 274 | count++; 275 | throw 'ouch'; 276 | }); 277 | 278 | var observer3 = new ArrayObserver(model); 279 | observer3.open(function() { 280 | count++; 281 | throw 'ouch'; 282 | }); 283 | 284 | model[0] = 2; 285 | model[1] = 2; 286 | 287 | observer1.deliver(); 288 | observer2.deliver(); 289 | observer3.deliver(); 290 | 291 | assert.equal(3, count); 292 | 293 | observer1.close(); 294 | observer2.close(); 295 | observer3.close(); 296 | }); 297 | 298 | test('Can only open once', function() { 299 | observer = new PathObserver({ id: 1 }, 'id'); 300 | observer.open(callback); 301 | assert.throws(function() { 302 | observer.open(callback); 303 | }); 304 | observer.close(); 305 | 306 | observer = new CompoundObserver(); 307 | observer.open(callback); 308 | assert.throws(function() { 309 | observer.open(callback); 310 | }); 311 | observer.close(); 312 | 313 | observer = new ObjectObserver({}, 'id'); 314 | observer.open(callback); 315 | assert.throws(function() { 316 | observer.open(callback); 317 | }); 318 | observer.close(); 319 | 320 | observer = new ArrayObserver([], 'id'); 321 | observer.open(callback); 322 | assert.throws(function() { 323 | observer.open(callback); 324 | }); 325 | observer.close(); 326 | 327 | }); 328 | 329 | test('No Object.observe performMicrotaskCheckpoint', function() { 330 | if (typeof Object.observe == 'function') 331 | return; 332 | 333 | var model = [1]; 334 | var count = 0; 335 | 336 | var observer1 = new ObjectObserver(model); 337 | observer1.open(function() { 338 | count++; 339 | }); 340 | 341 | var observer2 = new PathObserver(model, '[0]'); 342 | observer2.open(function() { 343 | count++; 344 | }); 345 | 346 | var observer3 = new ArrayObserver(model); 347 | observer3.open(function() { 348 | count++; 349 | }); 350 | 351 | model[0] = 2; 352 | model[1] = 2; 353 | 354 | Platform.performMicrotaskCheckpoint(); 355 | assert.equal(3, count); 356 | 357 | observer1.close(); 358 | observer2.close(); 359 | observer3.close(); 360 | }); 361 | }); 362 | 363 | suite('ObserverTransform', function() { 364 | 365 | test('Close Invokes Close', function() { 366 | var count = 0; 367 | var observer = { 368 | open: function() {}, 369 | close: function() { count++; } 370 | }; 371 | 372 | var observer = new ObserverTransform(observer); 373 | observer.open(); 374 | observer.close(); 375 | assert.strictEqual(1, count); 376 | }); 377 | 378 | test('valueFn/setValueFn', function() { 379 | var obj = { foo: 1 }; 380 | 381 | function valueFn(value) { return value * 2; } 382 | 383 | function setValueFn(value) { return value / 2; } 384 | 385 | observer = new ObserverTransform(new PathObserver(obj, 'foo'), 386 | valueFn, 387 | setValueFn); 388 | observer.open(callback); 389 | 390 | obj.foo = 2; 391 | 392 | assert.strictEqual(4, observer.discardChanges()); 393 | assertNoChanges(); 394 | 395 | observer.setValue(2); 396 | assert.strictEqual(obj.foo, 1); 397 | assertPathChanges(2, 4); 398 | 399 | obj.foo = 10; 400 | assertPathChanges(20, 2); 401 | 402 | observer.close(); 403 | }); 404 | 405 | test('valueFn - object literal', function() { 406 | var model = {}; 407 | 408 | function valueFn(value) { 409 | return [ value ]; 410 | } 411 | 412 | observer = new ObserverTransform(new PathObserver(model, 'foo'), valueFn); 413 | observer.open(callback); 414 | 415 | model.foo = 1; 416 | assertPathChanges([1], [undefined]); 417 | 418 | model.foo = 3; 419 | assertPathChanges([3], [1]); 420 | 421 | observer.close(); 422 | }); 423 | 424 | test('CompoundObserver - valueFn reduction', function() { 425 | var model = { a: 1, b: 2, c: 3 }; 426 | 427 | function valueFn(values) { 428 | return values.reduce(function(last, cur) { 429 | return typeof cur === 'number' ? last + cur : undefined; 430 | }, 0); 431 | } 432 | 433 | var compound = new CompoundObserver(); 434 | compound.addPath(model, 'a'); 435 | compound.addPath(model, 'b'); 436 | compound.addPath(model, Path.get('c')); 437 | 438 | observer = new ObserverTransform(compound, valueFn); 439 | assert.strictEqual(6, observer.open(callback)); 440 | 441 | model.a = -10; 442 | model.b = 20; 443 | model.c = 30; 444 | assertPathChanges(40, 6); 445 | 446 | observer.close(); 447 | }); 448 | }) 449 | 450 | suite('PathObserver Tests', function() { 451 | 452 | setup(doSetup); 453 | 454 | teardown(doTeardown); 455 | 456 | test('Callback args', function() { 457 | var obj = { 458 | foo: 'bar' 459 | }; 460 | 461 | var path = Path.get('foo'); 462 | var observer = new PathObserver(obj, path); 463 | 464 | var args; 465 | observer.open(function() { 466 | args = Array.prototype.slice.apply(arguments); 467 | }); 468 | 469 | obj.foo = 'baz'; 470 | observer.deliver(); 471 | assert.strictEqual(args.length, 3); 472 | assert.strictEqual(args[0], 'baz'); 473 | assert.strictEqual(args[1], 'bar'); 474 | assert.strictEqual(args[2], observer); 475 | assert.strictEqual(args[2].path, path); 476 | observer.close(); 477 | }); 478 | 479 | test('PathObserver.path', function() { 480 | var obj = { 481 | foo: 'bar' 482 | }; 483 | 484 | var path = Path.get('foo'); 485 | var observer = new PathObserver(obj, 'foo'); 486 | assert.strictEqual(observer.path, Path.get('foo')); 487 | }); 488 | 489 | 490 | test('invalid', function() { 491 | var observer = new PathObserver({ a: { b: 1 }} , 'a b'); 492 | observer.open(callback); 493 | assert.strictEqual(undefined, observer.value); 494 | observer.deliver(); 495 | assert.isFalse(callbackInvoked); 496 | }); 497 | 498 | test('Optional target for callback', function() { 499 | var target = { 500 | changed: function(value, oldValue) { 501 | this.called = true; 502 | } 503 | }; 504 | var obj = { foo: 1 }; 505 | var observer = new PathObserver(obj, 'foo'); 506 | observer.open(target.changed, target); 507 | obj.foo = 2; 508 | observer.deliver(); 509 | assert.isTrue(target.called); 510 | 511 | observer.close(); 512 | }); 513 | 514 | test('Delivery Until No Changes', function() { 515 | var obj = { foo: { bar: 5 }}; 516 | var callbackCount = 0; 517 | var observer = new PathObserver(obj, 'foo . bar'); 518 | observer.open(function() { 519 | callbackCount++; 520 | if (!obj.foo.bar) 521 | return; 522 | 523 | obj.foo.bar--; 524 | }); 525 | 526 | obj.foo.bar--; 527 | observer.deliver(); 528 | 529 | assert.equal(5, callbackCount); 530 | 531 | observer.close(); 532 | }); 533 | 534 | test('Path disconnect', function() { 535 | var arr = {}; 536 | 537 | arr.foo = 'bar'; 538 | observer = new PathObserver(arr, 'foo'); 539 | observer.open(callback); 540 | arr.foo = 'baz'; 541 | 542 | assertPathChanges('baz', 'bar'); 543 | arr.foo = 'bar'; 544 | 545 | observer.close(); 546 | 547 | arr.foo = 'boo'; 548 | assertNoChanges(); 549 | }); 550 | 551 | test('Path discardChanges', function() { 552 | var arr = {}; 553 | 554 | arr.foo = 'bar'; 555 | observer = new PathObserver(arr, 'foo'); 556 | observer.open(callback); 557 | arr.foo = 'baz'; 558 | 559 | assertPathChanges('baz', 'bar'); 560 | 561 | arr.foo = 'bat'; 562 | observer.discardChanges(); 563 | assertNoChanges(); 564 | 565 | arr.foo = 'bag'; 566 | assertPathChanges('bag', 'bat'); 567 | observer.close(); 568 | }); 569 | 570 | test('Path setValue', function() { 571 | var obj = {}; 572 | 573 | obj.foo = 'bar'; 574 | observer = new PathObserver(obj, 'foo'); 575 | observer.open(callback); 576 | obj.foo = 'baz'; 577 | 578 | observer.setValue('bat'); 579 | assert.strictEqual(obj.foo, 'bat'); 580 | assertPathChanges('bat', 'bar'); 581 | 582 | observer.setValue('bot'); 583 | observer.discardChanges(); 584 | assertNoChanges(); 585 | 586 | observer.close(); 587 | }); 588 | 589 | test('Degenerate Values', function() { 590 | var emptyPath = Path.get(); 591 | observer = new PathObserver(null, ''); 592 | observer.open(callback); 593 | assert.equal(null, observer.value); 594 | observer.close(); 595 | 596 | var foo = {}; 597 | observer = new PathObserver(foo, ''); 598 | assert.equal(foo, observer.open(callback)); 599 | observer.close(); 600 | 601 | observer = new PathObserver(3, ''); 602 | assert.equal(3, observer.open(callback)); 603 | observer.close(); 604 | 605 | observer = new PathObserver(undefined, 'a'); 606 | assert.equal(undefined, observer.open(callback)); 607 | observer.close(); 608 | 609 | var bar = { id: 23 }; 610 | observer = new PathObserver(undefined, 'a/3!'); 611 | assert.equal(undefined, observer.open(callback)); 612 | observer.close(); 613 | }); 614 | 615 | test('Path NaN', function() { 616 | var foo = { val: 1 }; 617 | observer = new PathObserver(foo, 'val'); 618 | observer.open(callback); 619 | foo.val = 0/0; 620 | 621 | // Can't use assertSummary because deepEqual() will fail with NaN 622 | observer.deliver(); 623 | assert.isTrue(callbackInvoked); 624 | assert.isTrue(isNaN(callbackArgs[0])); 625 | assert.strictEqual(1, callbackArgs[1]); 626 | observer.close(); 627 | }); 628 | 629 | test('Path Set Value Back To Same', function() { 630 | var obj = {}; 631 | var path = Path.get('foo'); 632 | 633 | path.setValueFrom(obj, 3); 634 | assert.equal(3, obj.foo); 635 | 636 | observer = new PathObserver(obj, 'foo'); 637 | assert.equal(3, observer.open(callback)); 638 | 639 | path.setValueFrom(obj, 2); 640 | assert.equal(2, observer.discardChanges()); 641 | 642 | path.setValueFrom(obj, 3); 643 | assert.equal(3, observer.discardChanges()); 644 | 645 | assertNoChanges(); 646 | 647 | observer.close(); 648 | }); 649 | 650 | test('Path Triple Equals', function() { 651 | var model = { }; 652 | 653 | observer = new PathObserver(model, 'foo'); 654 | observer.open(callback); 655 | 656 | model.foo = null; 657 | assertPathChanges(null, undefined); 658 | 659 | model.foo = undefined; 660 | assertPathChanges(undefined, null); 661 | 662 | observer.close(); 663 | }); 664 | 665 | test('Path Simple', function() { 666 | var model = { }; 667 | 668 | observer = new PathObserver(model, 'foo'); 669 | observer.open(callback); 670 | 671 | model.foo = 1; 672 | assertPathChanges(1, undefined); 673 | 674 | model.foo = 2; 675 | assertPathChanges(2, 1); 676 | 677 | delete model.foo; 678 | assertPathChanges(undefined, 2); 679 | 680 | observer.close(); 681 | }); 682 | 683 | test('Path Simple - path object', function() { 684 | var model = { }; 685 | 686 | var path = Path.get('foo'); 687 | observer = new PathObserver(model, path); 688 | observer.open(callback); 689 | 690 | model.foo = 1; 691 | assertPathChanges(1, undefined); 692 | 693 | model.foo = 2; 694 | assertPathChanges(2, 1); 695 | 696 | delete model.foo; 697 | assertPathChanges(undefined, 2); 698 | 699 | observer.close(); 700 | }); 701 | 702 | test('Path - root is initially null', function(done) { 703 | var model = { }; 704 | 705 | var path = Path.get('foo'); 706 | observer = new PathObserver(model, 'foo.bar'); 707 | observer.open(callback); 708 | 709 | model.foo = { }; 710 | then(function() { 711 | model.foo.bar = 1; 712 | 713 | }).then(function() { 714 | assertPathChanges(1, undefined, true); 715 | 716 | observer.close(); 717 | done(); 718 | }); 719 | }); 720 | 721 | test('Path With Indices', function() { 722 | var model = []; 723 | 724 | observer = new PathObserver(model, '[0]'); 725 | observer.open(callback); 726 | 727 | model.push(1); 728 | assertPathChanges(1, undefined); 729 | 730 | observer.close(); 731 | }); 732 | 733 | test('Path Observation', function() { 734 | var model = { 735 | a: { 736 | b: { 737 | c: 'hello, world' 738 | } 739 | } 740 | }; 741 | 742 | observer = new PathObserver(model, 'a.b.c'); 743 | observer.open(callback); 744 | 745 | model.a.b.c = 'hello, mom'; 746 | assertPathChanges('hello, mom', 'hello, world'); 747 | 748 | model.a.b = { 749 | c: 'hello, dad' 750 | }; 751 | assertPathChanges('hello, dad', 'hello, mom'); 752 | 753 | model.a = { 754 | b: { 755 | c: 'hello, you' 756 | } 757 | }; 758 | assertPathChanges('hello, you', 'hello, dad'); 759 | 760 | model.a.b = 1; 761 | assertPathChanges(undefined, 'hello, you'); 762 | 763 | // Stop observing 764 | observer.close(); 765 | 766 | model.a.b = {c: 'hello, back again -- but not observing'}; 767 | assertNoChanges(); 768 | 769 | // Resume observing 770 | observer = new PathObserver(model, 'a.b.c'); 771 | observer.open(callback); 772 | 773 | model.a.b.c = 'hello. Back for reals'; 774 | assertPathChanges('hello. Back for reals', 775 | 'hello, back again -- but not observing'); 776 | 777 | observer.close(); 778 | }); 779 | 780 | test('Path Set To Same As Prototype', function() { 781 | var model = createObject({ 782 | __proto__: { 783 | id: 1 784 | } 785 | }); 786 | 787 | observer = new PathObserver(model, 'id'); 788 | observer.open(callback); 789 | model.id = 1; 790 | 791 | assertNoChanges(); 792 | observer.close(); 793 | }); 794 | 795 | test('Path Set Read Only', function() { 796 | var model = {}; 797 | Object.defineProperty(model, 'x', { 798 | configurable: true, 799 | writable: false, 800 | value: 1 801 | }); 802 | observer = new PathObserver(model, 'x'); 803 | observer.open(callback); 804 | 805 | model.x = 2; 806 | 807 | assertNoChanges(); 808 | observer.close(); 809 | }); 810 | 811 | test('Path Set Shadows', function() { 812 | var model = createObject({ 813 | __proto__: { 814 | x: 1 815 | } 816 | }); 817 | 818 | observer = new PathObserver(model, 'x'); 819 | observer.open(callback); 820 | model.x = 2; 821 | assertPathChanges(2, 1); 822 | observer.close(); 823 | }); 824 | 825 | test('Delete With Same Value On Prototype', function() { 826 | var model = createObject({ 827 | __proto__: { 828 | x: 1, 829 | }, 830 | x: 1 831 | }); 832 | 833 | observer = new PathObserver(model, 'x'); 834 | observer.open(callback); 835 | delete model.x; 836 | assertNoChanges(); 837 | observer.close(); 838 | }); 839 | 840 | test('Delete With Different Value On Prototype', function() { 841 | var model = createObject({ 842 | __proto__: { 843 | x: 1, 844 | }, 845 | x: 2 846 | }); 847 | 848 | observer = new PathObserver(model, 'x'); 849 | observer.open(callback); 850 | delete model.x; 851 | assertPathChanges(1, 2); 852 | observer.close(); 853 | }); 854 | 855 | test('Value Change On Prototype', function() { 856 | var proto = { 857 | x: 1 858 | } 859 | var model = createObject({ 860 | __proto__: proto 861 | }); 862 | 863 | observer = new PathObserver(model, 'x'); 864 | observer.open(callback); 865 | model.x = 2; 866 | assertPathChanges(2, 1); 867 | 868 | delete model.x; 869 | assertPathChanges(1, 2); 870 | 871 | proto.x = 3; 872 | assertPathChanges(3, 1); 873 | observer.close(); 874 | }); 875 | 876 | // FIXME: Need test of observing change on proto. 877 | 878 | test('Delete Of Non Configurable', function() { 879 | var model = {}; 880 | Object.defineProperty(model, 'x', { 881 | configurable: false, 882 | value: 1 883 | }); 884 | 885 | observer = new PathObserver(model, 'x'); 886 | observer.open(callback); 887 | 888 | delete model.x; 889 | assertNoChanges(); 890 | observer.close(); 891 | }); 892 | 893 | test('Notify', function() { 894 | if (typeof Object.getNotifier !== 'function') 895 | return; 896 | 897 | var model = { 898 | a: {} 899 | } 900 | 901 | var _b = 2; 902 | 903 | Object.defineProperty(model.a, 'b', { 904 | get: function() { return _b; }, 905 | set: function(b) { 906 | Object.getNotifier(this).notify({ 907 | type: 'update', 908 | name: 'b', 909 | oldValue: _b 910 | }); 911 | 912 | _b = b; 913 | } 914 | }); 915 | 916 | observer = new PathObserver(model, 'a.b'); 917 | observer.open(callback); 918 | _b = 3; 919 | assertPathChanges(3, 2); 920 | 921 | model.a.b = 4; // will be observed. 922 | assertPathChanges(4, 3); 923 | 924 | observer.close(); 925 | }); 926 | 927 | test('issue-161', function(done) { 928 | var model = { model: 'model' }; 929 | var ob1 = new PathObserver(model, 'obj.bar'); 930 | var called = false 931 | ob1.open(function() { 932 | called = true; 933 | }); 934 | 935 | var obj2 = new PathObserver(model, 'obj'); 936 | obj2.open(function() { 937 | model.obj.bar = true; 938 | }); 939 | 940 | model.obj = { 'obj': 'obj' }; 941 | model.obj.foo = true; 942 | 943 | then(function() { 944 | assert.strictEqual(called, true); 945 | done(); 946 | }); 947 | }); 948 | 949 | test('object cycle', function(done) { 950 | var model = { a: {}, c: 1 }; 951 | model.a.b = model; 952 | 953 | var called = 0; 954 | new PathObserver(model, 'a.b.c').open(function() { 955 | called++; 956 | }); 957 | 958 | // This change should be detected, even though it's a change to the root 959 | // object and isn't a change to `a`. 960 | model.c = 42; 961 | 962 | then(function() { 963 | assert.equal(called, 1); 964 | done(); 965 | }); 966 | }); 967 | 968 | }); 969 | 970 | 971 | suite('CompoundObserver Tests', function() { 972 | 973 | setup(doSetup); 974 | 975 | teardown(doTeardown); 976 | 977 | test('Simple', function() { 978 | var model = { a: 1, b: 2, c: 3 }; 979 | 980 | observer = new CompoundObserver(); 981 | observer.addPath(model, 'a'); 982 | observer.addPath(model, 'b'); 983 | observer.addPath(model, Path.get('c')); 984 | observer.open(callback); 985 | assertNoChanges(); 986 | 987 | var observerCallbackArg = [model, Path.get('a'), 988 | model, Path.get('b'), 989 | model, Path.get('c')]; 990 | model.a = -10; 991 | model.b = 20; 992 | model.c = 30; 993 | assertCompoundPathChanges([-10, 20, 30], [1, 2, 3], 994 | observerCallbackArg); 995 | 996 | model.a = 'a'; 997 | model.c = 'c'; 998 | assertCompoundPathChanges(['a', 20, 'c'], [-10,, 30], 999 | observerCallbackArg); 1000 | 1001 | model.a = 2; 1002 | model.b = 3; 1003 | model.c = 4; 1004 | 1005 | assertCompoundPathChanges([2, 3, 4], ['a', 20, 'c'], 1006 | observerCallbackArg); 1007 | 1008 | model.a = 'z'; 1009 | model.b = 'y'; 1010 | model.c = 'x'; 1011 | assert.deepEqual(['z', 'y', 'x'], observer.discardChanges()); 1012 | assertNoChanges(); 1013 | 1014 | assert.strictEqual('z', model.a); 1015 | assert.strictEqual('y', model.b); 1016 | assert.strictEqual('x', model.c); 1017 | assertNoChanges(); 1018 | 1019 | observer.close(); 1020 | }); 1021 | 1022 | test('reportChangesOnOpen', function() { 1023 | var model = { a: 1, b: 2, c: 3 }; 1024 | 1025 | observer = new CompoundObserver(true); 1026 | observer.addPath(model, 'a'); 1027 | observer.addPath(model, 'b'); 1028 | observer.addPath(model, Path.get('c')); 1029 | 1030 | model.a = -10; 1031 | model.b = 20; 1032 | observer.open(callback); 1033 | var observerCallbackArg = [model, Path.get('a'), 1034 | model, Path.get('b'), 1035 | model, Path.get('c')]; 1036 | assertCompoundPathChanges([-10, 20, 3], [1, 2, ], 1037 | observerCallbackArg, true); 1038 | observer.close(); 1039 | }); 1040 | 1041 | test('All Observers', function() { 1042 | function ident(value) { return value; } 1043 | 1044 | var model = { a: 1, b: 2, c: 3 }; 1045 | 1046 | observer = new CompoundObserver(); 1047 | var pathObserver1 = new PathObserver(model, 'a'); 1048 | var pathObserver2 = new PathObserver(model, 'b'); 1049 | var pathObserver3 = new PathObserver(model, Path.get('c')); 1050 | 1051 | observer.addObserver(pathObserver1); 1052 | observer.addObserver(pathObserver2); 1053 | observer.addObserver(pathObserver3); 1054 | observer.open(callback); 1055 | 1056 | var observerCallbackArg = [Observer.observerSentinel_, pathObserver1, 1057 | Observer.observerSentinel_, pathObserver2, 1058 | Observer.observerSentinel_, pathObserver3]; 1059 | model.a = -10; 1060 | model.b = 20; 1061 | model.c = 30; 1062 | assertCompoundPathChanges([-10, 20, 30], [1, 2, 3], 1063 | observerCallbackArg); 1064 | 1065 | model.a = 'a'; 1066 | model.c = 'c'; 1067 | assertCompoundPathChanges(['a', 20, 'c'], [-10,, 30], 1068 | observerCallbackArg); 1069 | 1070 | observer.close(); 1071 | }); 1072 | 1073 | test('Degenerate Values', function() { 1074 | var model = {}; 1075 | observer = new CompoundObserver(); 1076 | observer.addPath({}, '.'); // invalid path 1077 | observer.addPath('obj-value', ''); // empty path 1078 | observer.addPath({}, 'foo'); // unreachable 1079 | observer.addPath(3, 'bar'); // non-object with non-empty path 1080 | var values = observer.open(callback); 1081 | assert.strictEqual(4, values.length); 1082 | assert.strictEqual(undefined, values[0]); 1083 | assert.strictEqual('obj-value', values[1]); 1084 | assert.strictEqual(undefined, values[2]); 1085 | assert.strictEqual(undefined, values[3]); 1086 | observer.close(); 1087 | }); 1088 | 1089 | test('valueFn - return object literal', function() { 1090 | var model = { a: 1}; 1091 | 1092 | function valueFn(values) { 1093 | return {}; 1094 | } 1095 | 1096 | observer = new CompoundObserver(valueFn); 1097 | 1098 | observer.addPath(model, 'a'); 1099 | observer.open(callback); 1100 | model.a = 2; 1101 | 1102 | observer.deliver(); 1103 | assert.isTrue(window.dirtyCheckCycleCount === undefined || 1104 | window.dirtyCheckCycleCount === 1); 1105 | observer.close(); 1106 | }); 1107 | 1108 | test('reset', function() { 1109 | var model = { a: 1, b: 2, c: 3 }; 1110 | var callCount = 0; 1111 | function callback() { 1112 | callCount++; 1113 | } 1114 | 1115 | observer = new CompoundObserver(); 1116 | 1117 | observer.addPath(model, 'a'); 1118 | observer.addPath(model, 'b'); 1119 | assert.deepEqual([1, 2], observer.open(callback)); 1120 | 1121 | model.a = 2; 1122 | observer.deliver(); 1123 | assert.strictEqual(1, callCount); 1124 | 1125 | model.b = 3; 1126 | observer.deliver(); 1127 | assert.strictEqual(2, callCount); 1128 | 1129 | model.c = 4; 1130 | observer.deliver(); 1131 | assert.strictEqual(2, callCount); 1132 | 1133 | observer.startReset(); 1134 | observer.addPath(model, 'b'); 1135 | observer.addPath(model, 'c'); 1136 | assert.deepEqual([3, 4], observer.finishReset()) 1137 | 1138 | model.a = 3; 1139 | observer.deliver(); 1140 | assert.strictEqual(2, callCount); 1141 | 1142 | model.b = 4; 1143 | observer.deliver(); 1144 | assert.strictEqual(3, callCount); 1145 | 1146 | model.c = 5; 1147 | observer.deliver(); 1148 | assert.strictEqual(4, callCount); 1149 | 1150 | observer.close(); 1151 | }); 1152 | 1153 | test('Heterogeneous', function() { 1154 | var model = { a: 1, b: 2 }; 1155 | var otherModel = { c: 3 }; 1156 | 1157 | function valueFn(value) { return value * 2; } 1158 | function setValueFn(value) { return value / 2; } 1159 | 1160 | var compound = new CompoundObserver; 1161 | compound.addPath(model, 'a'); 1162 | compound.addObserver(new ObserverTransform(new PathObserver(model, 'b'), 1163 | valueFn, setValueFn)); 1164 | compound.addObserver(new PathObserver(otherModel, 'c')); 1165 | 1166 | function combine(values) { 1167 | return values[0] + values[1] + values[2]; 1168 | }; 1169 | observer = new ObserverTransform(compound, combine); 1170 | assert.strictEqual(8, observer.open(callback)); 1171 | 1172 | model.a = 2; 1173 | model.b = 4; 1174 | assertPathChanges(13, 8); 1175 | 1176 | model.b = 10; 1177 | otherModel.c = 5; 1178 | assertPathChanges(27, 13); 1179 | 1180 | model.a = 20; 1181 | model.b = 1; 1182 | otherModel.c = 5; 1183 | assertNoChanges(); 1184 | 1185 | observer.close(); 1186 | }) 1187 | }); 1188 | 1189 | suite('ArrayObserver Tests', function() { 1190 | 1191 | setup(doSetup); 1192 | 1193 | teardown(doTeardown); 1194 | 1195 | function ensureNonSparse(arr) { 1196 | for (var i = 0; i < arr.length; i++) { 1197 | if (i in arr) 1198 | continue; 1199 | arr[i] = undefined; 1200 | } 1201 | } 1202 | 1203 | function assertArrayChanges(expectSplices) { 1204 | observer.deliver(); 1205 | var splices = callbackArgs[0]; 1206 | 1207 | assert.isTrue(callbackInvoked); 1208 | 1209 | splices.forEach(function(splice) { 1210 | ensureNonSparse(splice.removed); 1211 | }); 1212 | 1213 | expectSplices.forEach(function(splice) { 1214 | ensureNonSparse(splice.removed); 1215 | }); 1216 | 1217 | assert.deepEqual(expectSplices, splices); 1218 | callbackArgs = undefined; 1219 | callbackInvoked = false; 1220 | } 1221 | 1222 | function applySplicesAndAssertDeepEqual(orig, copy) { 1223 | observer.deliver(); 1224 | if (callbackInvoked) { 1225 | var splices = callbackArgs[0]; 1226 | ArrayObserver.applySplices(copy, orig, splices); 1227 | } 1228 | 1229 | ensureNonSparse(orig); 1230 | ensureNonSparse(copy); 1231 | assert.deepEqual(orig, copy); 1232 | callbackArgs = undefined; 1233 | callbackInvoked = false; 1234 | } 1235 | 1236 | function assertEditDistance(orig, expectDistance) { 1237 | observer.deliver(); 1238 | var splices = callbackArgs[0]; 1239 | var actualDistance = 0; 1240 | 1241 | if (callbackInvoked) { 1242 | splices.forEach(function(splice) { 1243 | actualDistance += splice.addedCount + splice.removed.length; 1244 | }); 1245 | } 1246 | 1247 | assert.deepEqual(expectDistance, actualDistance); 1248 | callbackArgs = undefined; 1249 | callbackInvoked = false; 1250 | } 1251 | 1252 | function arrayMutationTest(arr, operations) { 1253 | var copy = arr.slice(); 1254 | observer = new ArrayObserver(arr); 1255 | observer.open(callback); 1256 | operations.forEach(function(op) { 1257 | switch(op.name) { 1258 | case 'delete': 1259 | delete arr[op.index]; 1260 | break; 1261 | 1262 | case 'update': 1263 | arr[op.index] = op.value; 1264 | break; 1265 | 1266 | default: 1267 | arr[op.name].apply(arr, op.args); 1268 | break; 1269 | } 1270 | }); 1271 | 1272 | applySplicesAndAssertDeepEqual(arr, copy); 1273 | observer.close(); 1274 | } 1275 | 1276 | test('Optional target for callback', function() { 1277 | var target = { 1278 | changed: function(splices) { 1279 | this.called = true; 1280 | } 1281 | }; 1282 | var obj = []; 1283 | var observer = new ArrayObserver(obj); 1284 | observer.open(target.changed, target); 1285 | obj.length = 1; 1286 | observer.deliver(); 1287 | assert.isTrue(target.called); 1288 | observer.close(); 1289 | }); 1290 | 1291 | test('Delivery Until No Changes', function() { 1292 | var arr = [0, 1, 2, 3, 4]; 1293 | var callbackCount = 0; 1294 | var observer = new ArrayObserver(arr); 1295 | observer.open(function() { 1296 | callbackCount++; 1297 | arr.shift(); 1298 | }); 1299 | 1300 | arr.shift(); 1301 | observer.deliver(); 1302 | 1303 | assert.equal(5, callbackCount); 1304 | 1305 | observer.close(); 1306 | }); 1307 | 1308 | test('Array disconnect', function() { 1309 | var arr = [ 0 ]; 1310 | 1311 | observer = new ArrayObserver(arr); 1312 | observer.open(callback); 1313 | 1314 | arr[0] = 1; 1315 | 1316 | assertArrayChanges([{ 1317 | index: 0, 1318 | removed: [0], 1319 | addedCount: 1 1320 | }]); 1321 | 1322 | observer.close(); 1323 | arr[1] = 2; 1324 | assertNoChanges(); 1325 | }); 1326 | 1327 | test('Array discardChanges', function() { 1328 | var arr = []; 1329 | 1330 | arr.push(1); 1331 | observer = new ArrayObserver(arr); 1332 | observer.open(callback); 1333 | arr.push(2); 1334 | 1335 | assertArrayChanges([{ 1336 | index: 1, 1337 | removed: [], 1338 | addedCount: 1 1339 | }]); 1340 | 1341 | arr.push(3); 1342 | observer.discardChanges(); 1343 | assertNoChanges(); 1344 | 1345 | arr.pop(); 1346 | assertArrayChanges([{ 1347 | index: 2, 1348 | removed: [3], 1349 | addedCount: 0 1350 | }]); 1351 | observer.close(); 1352 | }); 1353 | 1354 | test('Array', function() { 1355 | var model = [0, 1]; 1356 | 1357 | observer = new ArrayObserver(model); 1358 | observer.open(callback); 1359 | 1360 | model[0] = 2; 1361 | 1362 | assertArrayChanges([{ 1363 | index: 0, 1364 | removed: [0], 1365 | addedCount: 1 1366 | }]); 1367 | 1368 | model[1] = 3; 1369 | assertArrayChanges([{ 1370 | index: 1, 1371 | removed: [1], 1372 | addedCount: 1 1373 | }]); 1374 | 1375 | observer.close(); 1376 | }); 1377 | 1378 | test('Array observe non-array throws', function() { 1379 | assert.throws(function () { 1380 | observer = new ArrayObserver({}); 1381 | }); 1382 | }); 1383 | 1384 | test('Array Set Same', function() { 1385 | var model = [1]; 1386 | 1387 | observer = new ArrayObserver(model); 1388 | observer.open(callback); 1389 | 1390 | model[0] = 1; 1391 | observer.deliver(); 1392 | assert.isFalse(callbackInvoked); 1393 | observer.close(); 1394 | }); 1395 | 1396 | test('Array Splice', function() { 1397 | var model = [0, 1] 1398 | 1399 | observer = new ArrayObserver(model); 1400 | observer.open(callback); 1401 | 1402 | model.splice(1, 1, 2, 3); // [0, 2, 3] 1403 | assertArrayChanges([{ 1404 | index: 1, 1405 | removed: [1], 1406 | addedCount: 2 1407 | }]); 1408 | 1409 | model.splice(0, 1); // [2, 3] 1410 | assertArrayChanges([{ 1411 | index: 0, 1412 | removed: [0], 1413 | addedCount: 0 1414 | }]); 1415 | 1416 | model.splice(); 1417 | assertNoChanges(); 1418 | 1419 | model.splice(0, 0); 1420 | assertNoChanges(); 1421 | 1422 | model.splice(0, -1); 1423 | assertNoChanges(); 1424 | 1425 | model.splice(-1, 0, 1.5); // [2, 1.5, 3] 1426 | assertArrayChanges([{ 1427 | index: 1, 1428 | removed: [], 1429 | addedCount: 1 1430 | }]); 1431 | 1432 | model.splice(3, 0, 0); // [2, 1.5, 3, 0] 1433 | assertArrayChanges([{ 1434 | index: 3, 1435 | removed: [], 1436 | addedCount: 1 1437 | }]); 1438 | 1439 | model.splice(0); // [] 1440 | assertArrayChanges([{ 1441 | index: 0, 1442 | removed: [2, 1.5, 3, 0], 1443 | addedCount: 0 1444 | }]); 1445 | 1446 | observer.close(); 1447 | }); 1448 | 1449 | test('Array Splice Truncate And Expand With Length', function() { 1450 | var model = ['a', 'b', 'c', 'd', 'e']; 1451 | 1452 | observer = new ArrayObserver(model); 1453 | observer.open(callback); 1454 | 1455 | model.length = 2; 1456 | 1457 | assertArrayChanges([{ 1458 | index: 2, 1459 | removed: ['c', 'd', 'e'], 1460 | addedCount: 0 1461 | }]); 1462 | 1463 | model.length = 5; 1464 | 1465 | assertArrayChanges([{ 1466 | index: 2, 1467 | removed: [], 1468 | addedCount: 3 1469 | }]); 1470 | 1471 | observer.close(); 1472 | }); 1473 | 1474 | test('Array Splice Delete Too Many', function() { 1475 | var model = ['a', 'b', 'c']; 1476 | 1477 | observer = new ArrayObserver(model); 1478 | observer.open(callback); 1479 | 1480 | model.splice(2, 3); // ['a', 'b'] 1481 | assertArrayChanges([{ 1482 | index: 2, 1483 | removed: ['c'], 1484 | addedCount: 0 1485 | }]); 1486 | 1487 | observer.close(); 1488 | }); 1489 | 1490 | test('Array Length', function() { 1491 | var model = [0, 1]; 1492 | 1493 | observer = new ArrayObserver(model); 1494 | observer.open(callback); 1495 | 1496 | model.length = 5; // [0, 1, , , ,]; 1497 | assertArrayChanges([{ 1498 | index: 2, 1499 | removed: [], 1500 | addedCount: 3 1501 | }]); 1502 | 1503 | model.length = 1; 1504 | assertArrayChanges([{ 1505 | index: 1, 1506 | removed: [1, , , ,], 1507 | addedCount: 0 1508 | }]); 1509 | 1510 | model.length = 1; 1511 | assertNoChanges(); 1512 | 1513 | observer.close(); 1514 | }); 1515 | 1516 | test('Array Push', function() { 1517 | var model = [0, 1]; 1518 | 1519 | observer = new ArrayObserver(model); 1520 | observer.open(callback); 1521 | 1522 | model.push(2, 3); // [0, 1, 2, 3] 1523 | assertArrayChanges([{ 1524 | index: 2, 1525 | removed: [], 1526 | addedCount: 2 1527 | }]); 1528 | 1529 | model.push(); 1530 | assertNoChanges(); 1531 | 1532 | observer.close(); 1533 | }); 1534 | 1535 | test('Array Pop', function() { 1536 | var model = [0, 1]; 1537 | 1538 | observer = new ArrayObserver(model); 1539 | observer.open(callback); 1540 | 1541 | model.pop(); // [0] 1542 | assertArrayChanges([{ 1543 | index: 1, 1544 | removed: [1], 1545 | addedCount: 0 1546 | }]); 1547 | 1548 | model.pop(); // [] 1549 | assertArrayChanges([{ 1550 | index: 0, 1551 | removed: [0], 1552 | addedCount: 0 1553 | }]); 1554 | 1555 | model.pop(); 1556 | assertNoChanges(); 1557 | 1558 | observer.close(); 1559 | }); 1560 | 1561 | test('Array Shift', function() { 1562 | var model = [0, 1]; 1563 | 1564 | observer = new ArrayObserver(model); 1565 | observer.open(callback); 1566 | 1567 | model.shift(); // [1] 1568 | assertArrayChanges([{ 1569 | index: 0, 1570 | removed: [0], 1571 | addedCount: 0 1572 | }]); 1573 | 1574 | model.shift(); // [] 1575 | assertArrayChanges([{ 1576 | index: 0, 1577 | removed: [1], 1578 | addedCount: 0 1579 | }]); 1580 | 1581 | model.shift(); 1582 | assertNoChanges(); 1583 | 1584 | observer.close(); 1585 | }); 1586 | 1587 | test('Array Unshift', function() { 1588 | var model = [0, 1]; 1589 | 1590 | observer = new ArrayObserver(model); 1591 | observer.open(callback); 1592 | 1593 | model.unshift(-1); // [-1, 0, 1] 1594 | assertArrayChanges([{ 1595 | index: 0, 1596 | removed: [], 1597 | addedCount: 1 1598 | }]); 1599 | 1600 | model.unshift(-3, -2); // [] 1601 | assertArrayChanges([{ 1602 | index: 0, 1603 | removed: [], 1604 | addedCount: 2 1605 | }]); 1606 | 1607 | model.unshift(); 1608 | assertNoChanges(); 1609 | 1610 | observer.close(); 1611 | }); 1612 | 1613 | test('Array Tracker Contained', function() { 1614 | arrayMutationTest( 1615 | ['a', 'b'], 1616 | [ 1617 | { name: 'splice', args: [1, 1] }, 1618 | { name: 'unshift', args: ['c', 'd', 'e'] }, 1619 | { name: 'splice', args: [1, 2, 'f'] } 1620 | ] 1621 | ); 1622 | }); 1623 | 1624 | test('Array Tracker Delete Empty', function() { 1625 | arrayMutationTest( 1626 | [], 1627 | [ 1628 | { name: 'delete', index: 0 }, 1629 | { name: 'splice', args: [0, 0, 'a', 'b', 'c'] } 1630 | ] 1631 | ); 1632 | }); 1633 | 1634 | test('Array Tracker Right Non Overlap', function() { 1635 | arrayMutationTest( 1636 | ['a', 'b', 'c', 'd'], 1637 | [ 1638 | { name: 'splice', args: [0, 1, 'e'] }, 1639 | { name: 'splice', args: [2, 1, 'f', 'g'] } 1640 | ] 1641 | ); 1642 | }); 1643 | 1644 | test('Array Tracker Left Non Overlap', function() { 1645 | arrayMutationTest( 1646 | ['a', 'b', 'c', 'd'], 1647 | [ 1648 | { name: 'splice', args: [3, 1, 'f', 'g'] }, 1649 | { name: 'splice', args: [0, 1, 'e'] } 1650 | ] 1651 | ); 1652 | }); 1653 | 1654 | test('Array Tracker Right Adjacent', function() { 1655 | arrayMutationTest( 1656 | ['a', 'b', 'c', 'd'], 1657 | [ 1658 | { name: 'splice', args: [1, 1, 'e'] }, 1659 | { name: 'splice', args: [2, 1, 'f', 'g'] } 1660 | ] 1661 | ); 1662 | }); 1663 | 1664 | test('Array Tracker Left Adjacent', function() { 1665 | arrayMutationTest( 1666 | ['a', 'b', 'c', 'd'], 1667 | [ 1668 | { name: 'splice', args: [2, 2, 'e'] }, 1669 | { name: 'splice', args: [1, 1, 'f', 'g'] } 1670 | ] 1671 | ); 1672 | }); 1673 | 1674 | test('Array Tracker Right Overlap', function() { 1675 | arrayMutationTest( 1676 | ['a', 'b', 'c', 'd'], 1677 | [ 1678 | { name: 'splice', args: [1, 1, 'e'] }, 1679 | { name: 'splice', args: [1, 1, 'f', 'g'] } 1680 | ] 1681 | ); 1682 | }); 1683 | 1684 | test('Array Tracker Left Overlap', function() { 1685 | arrayMutationTest( 1686 | ['a', 'b', 'c', 'd'], 1687 | [ 1688 | // a b [e f g] d 1689 | { name: 'splice', args: [2, 1, 'e', 'f', 'g'] }, 1690 | // a [h i j] f g d 1691 | { name: 'splice', args: [1, 2, 'h', 'i', 'j'] } 1692 | ] 1693 | ); 1694 | }); 1695 | 1696 | test('Array Tracker Prefix And Suffix One In', function() { 1697 | arrayMutationTest( 1698 | ['a', 'b', 'c', 'd'], 1699 | [ 1700 | { name: 'unshift', args: ['z'] }, 1701 | { name: 'push', arg: ['z'] } 1702 | ] 1703 | ); 1704 | }); 1705 | 1706 | test('Array Tracker Shift One', function() { 1707 | arrayMutationTest( 1708 | [16, 15, 15], 1709 | [ 1710 | { name: 'shift', args: ['z'] } 1711 | ] 1712 | ); 1713 | }); 1714 | 1715 | test('Array Tracker Update Delete', function() { 1716 | arrayMutationTest( 1717 | ['a', 'b', 'c', 'd'], 1718 | [ 1719 | { name: 'splice', args: [2, 1, 'e', 'f', 'g'] }, 1720 | { name: 'update', index: 0, value: 'h' }, 1721 | { name: 'delete', index: 1 } 1722 | ] 1723 | ); 1724 | }); 1725 | 1726 | test('Array Tracker Update After Delete', function() { 1727 | arrayMutationTest( 1728 | ['a', 'b', undefined, 'd'], 1729 | [ 1730 | { name: 'update', index: 2, value: 'e' } 1731 | ] 1732 | ); 1733 | }); 1734 | 1735 | test('Array Tracker Delete Mid Array', function() { 1736 | arrayMutationTest( 1737 | ['a', 'b', 'c', 'd'], 1738 | [ 1739 | { name: 'delete', index: 2 } 1740 | ] 1741 | ); 1742 | }); 1743 | 1744 | test('Array Random Case 1', function() { 1745 | var model = ['a','b']; 1746 | var copy = model.slice(); 1747 | 1748 | observer = new ArrayObserver(model); 1749 | observer.open(callback); 1750 | 1751 | model.splice(0, 1, 'c', 'd', 'e'); 1752 | model.splice(4,0,'f'); 1753 | model.splice(3,2); 1754 | 1755 | applySplicesAndAssertDeepEqual(model, copy); 1756 | }); 1757 | 1758 | test('Array Random Case 2', function() { 1759 | var model = [3,4]; 1760 | var copy = model.slice(); 1761 | 1762 | observer = new ArrayObserver(model); 1763 | observer.open(callback); 1764 | 1765 | model.splice(2,0,8); 1766 | model.splice(0,1,0,5); 1767 | model.splice(2,2); 1768 | 1769 | applySplicesAndAssertDeepEqual(model, copy); 1770 | }); 1771 | 1772 | test('Array Random Case 3', function() { 1773 | var model = [1,3,6]; 1774 | var copy = model.slice(); 1775 | 1776 | observer = new ArrayObserver(model); 1777 | observer.open(callback); 1778 | 1779 | model.splice(1,1); 1780 | model.splice(0,2,1,7); 1781 | model.splice(1,0,3,7); 1782 | 1783 | applySplicesAndAssertDeepEqual(model, copy); 1784 | }); 1785 | 1786 | test('Array Tracker Fuzzer', function() { 1787 | var testCount = 64; 1788 | 1789 | console.log('Fuzzing spliceProjection ' + testCount + 1790 | ' passes with ' + ArrayFuzzer.operationCount + ' operations each.'); 1791 | 1792 | for (var i = 0; i < testCount; i++) { 1793 | console.log('pass: ' + i); 1794 | var fuzzer = new ArrayFuzzer(); 1795 | fuzzer.go(); 1796 | ensureNonSparse(fuzzer.arr); 1797 | ensureNonSparse(fuzzer.copy); 1798 | assert.deepEqual(fuzzer.arr, fuzzer.copy); 1799 | } 1800 | }); 1801 | 1802 | test('Array Tracker No Proxies Edits', function() { 1803 | model = []; 1804 | observer = new ArrayObserver(model); 1805 | observer.open(callback); 1806 | model.length = 0; 1807 | model.push(1, 2, 3); 1808 | assertEditDistance(model, 3); 1809 | observer.close(); 1810 | 1811 | model = ['x', 'x', 'x', 'x', '1', '2', '3']; 1812 | observer = new ArrayObserver(model); 1813 | observer.open(callback); 1814 | model.length = 0; 1815 | model.push('1', '2', '3', 'y', 'y', 'y', 'y'); 1816 | assertEditDistance(model, 8); 1817 | observer.close(); 1818 | 1819 | model = ['1', '2', '3', '4', '5']; 1820 | observer = new ArrayObserver(model); 1821 | observer.open(callback); 1822 | model.length = 0; 1823 | model.push('a', '2', 'y', 'y', '4', '5', 'z', 'z'); 1824 | assertEditDistance(model, 7); 1825 | observer.close(); 1826 | }); 1827 | }); 1828 | 1829 | suite('ObjectObserver Tests', function() { 1830 | 1831 | setup(doSetup); 1832 | 1833 | teardown(doTeardown); 1834 | 1835 | function assertObjectChanges(expect) { 1836 | observer.deliver(); 1837 | 1838 | assert.isTrue(callbackInvoked); 1839 | 1840 | var added = callbackArgs[0]; 1841 | var removed = callbackArgs[1]; 1842 | var changed = callbackArgs[2]; 1843 | var getOldValue = callbackArgs[3]; 1844 | var oldValues = {}; 1845 | 1846 | function collectOldValues(type) { 1847 | Object.keys(type).forEach(function(prop) { 1848 | oldValues[prop] = getOldValue(prop); 1849 | }); 1850 | }; 1851 | collectOldValues(added); 1852 | collectOldValues(removed); 1853 | collectOldValues(changed); 1854 | 1855 | assert.deepEqual(expect.added, added); 1856 | assert.deepEqual(expect.removed, removed); 1857 | assert.deepEqual(expect.changed, changed); 1858 | assert.deepEqual(expect.oldValues, oldValues); 1859 | 1860 | callbackArgs = undefined; 1861 | callbackInvoked = false; 1862 | } 1863 | 1864 | test('Optional target for callback', function() { 1865 | var target = { 1866 | changed: function(value, oldValue) { 1867 | this.called = true; 1868 | } 1869 | }; 1870 | var obj = { foo: 1 }; 1871 | var observer = new PathObserver(obj, 'foo'); 1872 | observer.open(target.changed, target); 1873 | obj.foo = 2; 1874 | observer.deliver(); 1875 | assert.isTrue(target.called); 1876 | 1877 | observer.close(); 1878 | }); 1879 | 1880 | test('Delivery Until No Changes', function() { 1881 | var obj = { foo: 5 }; 1882 | var callbackCount = 0; 1883 | var observer = new ObjectObserver(obj); 1884 | observer.open(function() { 1885 | callbackCount++; 1886 | if (!obj.foo) 1887 | return; 1888 | 1889 | obj.foo--; 1890 | }); 1891 | 1892 | obj.foo--; 1893 | observer.deliver(); 1894 | 1895 | assert.equal(5, callbackCount); 1896 | 1897 | observer.close(); 1898 | }); 1899 | 1900 | test('Object disconnect', function() { 1901 | var obj = {}; 1902 | 1903 | obj.foo = 'bar'; 1904 | observer = new ObjectObserver(obj); 1905 | observer.open(callback); 1906 | 1907 | obj.foo = 'baz'; 1908 | obj.bat = 'bag'; 1909 | obj.blaz = 'foo'; 1910 | 1911 | delete obj.foo; 1912 | delete obj.blaz; 1913 | 1914 | assertObjectChanges({ 1915 | added: { 1916 | 'bat': 'bag' 1917 | }, 1918 | removed: { 1919 | 'foo': undefined 1920 | }, 1921 | changed: {}, 1922 | oldValues: { 1923 | 'foo': 'bar', 1924 | 'bat': undefined 1925 | } 1926 | }); 1927 | 1928 | obj.foo = 'blarg'; 1929 | 1930 | observer.close(); 1931 | 1932 | obj.bar = 'blaz'; 1933 | assertNoChanges(); 1934 | }); 1935 | 1936 | test('Object discardChanges', function() { 1937 | var obj = {}; 1938 | 1939 | obj.foo = 'bar'; 1940 | observer = new ObjectObserver(obj); 1941 | observer.open(callback); 1942 | obj.foo = 'baz'; 1943 | 1944 | assertObjectChanges({ 1945 | added: {}, 1946 | removed: {}, 1947 | changed: { 1948 | foo: 'baz' 1949 | }, 1950 | oldValues: { 1951 | foo: 'bar' 1952 | } 1953 | }); 1954 | 1955 | obj.blaz = 'bat'; 1956 | observer.discardChanges(); 1957 | assertNoChanges(); 1958 | 1959 | obj.bat = 'bag'; 1960 | assertObjectChanges({ 1961 | added: { 1962 | bat: 'bag' 1963 | }, 1964 | removed: {}, 1965 | changed: {}, 1966 | oldValues: { 1967 | bat: undefined 1968 | } 1969 | }); 1970 | observer.close(); 1971 | }); 1972 | 1973 | test('Object observe array', function() { 1974 | var arr = []; 1975 | 1976 | observer = new ObjectObserver(arr); 1977 | observer.open(callback); 1978 | 1979 | arr.length = 5; 1980 | arr.foo = 'bar'; 1981 | arr[3] = 'baz'; 1982 | 1983 | assertObjectChanges({ 1984 | added: { 1985 | foo: 'bar', 1986 | '3': 'baz' 1987 | }, 1988 | removed: {}, 1989 | changed: { 1990 | 'length': 5 1991 | }, 1992 | oldValues: { 1993 | length: 0, 1994 | foo: undefined, 1995 | '3': undefined 1996 | } 1997 | }); 1998 | 1999 | observer.close(); 2000 | }); 2001 | 2002 | test('Object', function() { 2003 | var model = {}; 2004 | 2005 | observer = new ObjectObserver(model); 2006 | observer.open(callback); 2007 | model.id = 0; 2008 | assertObjectChanges({ 2009 | added: { 2010 | id: 0 2011 | }, 2012 | removed: {}, 2013 | changed: {}, 2014 | oldValues: { 2015 | id: undefined 2016 | } 2017 | }); 2018 | 2019 | delete model.id; 2020 | assertObjectChanges({ 2021 | added: {}, 2022 | removed: { 2023 | id: undefined 2024 | }, 2025 | changed: {}, 2026 | oldValues: { 2027 | id: 0 2028 | } 2029 | }); 2030 | 2031 | // Stop observing -- shouldn't see an event 2032 | observer.close(); 2033 | model.id = 101; 2034 | assertNoChanges(); 2035 | 2036 | // Re-observe -- should see an new event again. 2037 | observer = new ObjectObserver(model); 2038 | observer.open(callback); 2039 | model.id2 = 202;; 2040 | assertObjectChanges({ 2041 | added: { 2042 | id2: 202 2043 | }, 2044 | removed: {}, 2045 | changed: {}, 2046 | oldValues: { 2047 | id2: undefined 2048 | } 2049 | }); 2050 | 2051 | observer.close(); 2052 | }); 2053 | 2054 | test('Object Delete Add Delete', function() { 2055 | var model = { id: 1 }; 2056 | 2057 | observer = new ObjectObserver(model); 2058 | observer.open(callback); 2059 | 2060 | // If mutation occurs in seperate "runs", two events fire. 2061 | delete model.id; 2062 | assertObjectChanges({ 2063 | added: {}, 2064 | removed: { 2065 | id: undefined 2066 | }, 2067 | changed: {}, 2068 | oldValues: { 2069 | id: 1 2070 | } 2071 | }); 2072 | 2073 | model.id = 1; 2074 | assertObjectChanges({ 2075 | added: { 2076 | id: 1 2077 | }, 2078 | removed: {}, 2079 | changed: {}, 2080 | oldValues: { 2081 | id: undefined 2082 | } 2083 | }); 2084 | 2085 | // If mutation occurs in the same "run", no events fire (nothing changed). 2086 | delete model.id; 2087 | model.id = 1; 2088 | assertNoChanges(); 2089 | 2090 | observer.close(); 2091 | }); 2092 | 2093 | test('Object Set Undefined', function() { 2094 | var model = {}; 2095 | 2096 | observer = new ObjectObserver(model); 2097 | observer.open(callback); 2098 | 2099 | model.x = undefined; 2100 | assertObjectChanges({ 2101 | added: { 2102 | x: undefined 2103 | }, 2104 | removed: {}, 2105 | changed: {}, 2106 | oldValues: { 2107 | x: undefined 2108 | } 2109 | }); 2110 | 2111 | observer.close(); 2112 | }); 2113 | }); 2114 | -------------------------------------------------------------------------------- /util/planner.js: -------------------------------------------------------------------------------- 1 | // Copyright 2012 Google Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | (function(global) { 16 | 17 | "use strict"; 18 | 19 | function ArraySet() { 20 | this.entries = []; 21 | } 22 | 23 | ArraySet.prototype = { 24 | add: function(key) { 25 | if (this.entries.indexOf(key) >= 0) 26 | return; 27 | 28 | this.entries.push(key); 29 | }, 30 | 31 | delete: function(key) { 32 | var i = this.entries.indexOf(key); 33 | if (i < 0) 34 | return; 35 | 36 | this.entries.splice(i, 1); 37 | }, 38 | 39 | first: function() { 40 | return this.entries[0]; 41 | }, 42 | 43 | get size() { 44 | return this.entries.length; 45 | } 46 | }; 47 | 48 | function UIDSet() { 49 | this.entries = {}; 50 | this.size = 0; 51 | } 52 | 53 | UIDSet.prototype = { 54 | add: function(key) { 55 | if (this.entries[key.__UID__] !== undefined) 56 | return; 57 | 58 | this.entries[key.__UID__] = key; 59 | this.size++; 60 | }, 61 | 62 | delete: function(key) { 63 | if (this.entries[key.__UID__] === undefined) 64 | return; 65 | 66 | this.entries[key.__UID__] = undefined; 67 | this.size--; 68 | } 69 | }; 70 | 71 | function Heap(scoreFunction, populate) { 72 | this.scoreFunction = scoreFunction; 73 | this.content = populate || []; 74 | if (this.content.length) 75 | this.build(); 76 | } 77 | 78 | Heap.prototype = { 79 | get size() { 80 | return this.content.length; 81 | }, 82 | 83 | build: function() { 84 | var lastNonLeaf = Math.floor(this.content.length / 2) - 1; 85 | for (var i = lastNonLeaf; i >= 0; i--) 86 | this.sinkDown(i); 87 | }, 88 | 89 | push: function(element) { 90 | this.content.push(element); 91 | this.bubbleUp(this.content.length - 1); 92 | }, 93 | 94 | pop: function() { 95 | var result = this.content[0]; 96 | var end = this.content.pop(); 97 | if (this.content.length) { 98 | this.content[0] = end; 99 | this.sinkDown(0); 100 | } 101 | return result; 102 | }, 103 | 104 | delete: function(element) { 105 | var len = this.content.length; 106 | for (var i = 0; i < len; i++) { 107 | if (this.content[i] == element) { 108 | var end = this.content.pop(); 109 | if (i != len - 1) { 110 | this.content[i] = end; 111 | if (this.scoreFunction(end) < this.scoreFunction(node)) this.bubbleUp(i); 112 | else this.sinkDown(i); 113 | } 114 | return; 115 | } 116 | } 117 | }, 118 | 119 | bubbleUp: function(n) { 120 | var element = this.content[n]; 121 | while (n > 0) { 122 | var parentN = Math.floor((n + 1) / 2) - 1, 123 | parent = this.content[parentN]; 124 | 125 | if (this.scoreFunction(element) <= this.scoreFunction(parent)) 126 | break; 127 | 128 | this.content[parentN] = element; 129 | this.content[n] = parent; 130 | n = parentN; 131 | } 132 | }, 133 | 134 | sinkDown: function(n) { 135 | var length = this.content.length, 136 | element = this.content[n], 137 | elemScore = this.scoreFunction(element); 138 | 139 | do { 140 | var child2N = (n + 1) * 2 141 | var child1N = child2N - 1; 142 | 143 | var swap = null; 144 | var swapScore = elemScore; 145 | 146 | if (child1N < length) { 147 | var child1 = this.content[child1N], 148 | child1Score = this.scoreFunction(child1); 149 | if (child1Score > elemScore) { 150 | swap = child1N; 151 | swapScore = child1Score; 152 | } 153 | } 154 | 155 | if (child2N < length) { 156 | var child2 = this.content[child2N], 157 | child2Score = this.scoreFunction(child2); 158 | if (child2Score > swapScore) 159 | swap = child2N; 160 | } 161 | 162 | if (swap != null) { 163 | this.content[n] = this.content[swap]; 164 | this.content[swap] = element; 165 | n = swap; 166 | } 167 | } while (swap != null); 168 | } 169 | }; 170 | 171 | function Variable(stayFunc) { 172 | this.stayFunc = stayFunc; 173 | this.methods = new ArraySet; 174 | }; 175 | 176 | Variable.prototype = { 177 | freeMethod: function() { 178 | return this.methods.first(); 179 | } 180 | } 181 | 182 | function Method(constraint, variable) { 183 | this.constraint = constraint; 184 | this.variable = variable; 185 | }; 186 | 187 | function Constraint(planner) { 188 | this.planner = planner; 189 | this.methods = []; 190 | }; 191 | 192 | Constraint.prototype = { 193 | addMethod: function(variable) { 194 | var method = new Method(this, variable); 195 | this.methods.push(method); 196 | method.__UID__ = this.planner.methodUIDCounter++; 197 | return method; 198 | }, 199 | 200 | reset: function() { 201 | this.methods.forEach(function(method) { 202 | method.variable.methods.add(method); 203 | }); 204 | }, 205 | 206 | remove: function() { 207 | this.methods.forEach(function(method) { 208 | method.variable.methods.delete(method); 209 | }); 210 | } 211 | }; 212 | 213 | function Planner() { 214 | this.variables = []; 215 | this.constraints = []; 216 | this.variableUIDCounter = 1; 217 | this.methodUIDCounter = 1; 218 | }; 219 | 220 | Planner.prototype = { 221 | addVariable: function(stayFunc) { 222 | var variable = new Variable(stayFunc); 223 | variable.__UID__ = this.variableUIDCounter++; 224 | this.variables.push(variable); 225 | return variable; 226 | }, 227 | 228 | addConstraint: function() { 229 | var constraint = new Constraint(this); 230 | this.constraints.push(constraint); 231 | return constraint; 232 | }, 233 | 234 | removeConstraint: function(constraint) { 235 | var index = this.constraints.indexOf(constraint); 236 | if (index < 0) 237 | return; 238 | 239 | constraint.remove(); 240 | this.constraints.splice(index, 1); 241 | 242 | this.constraints.forEach(function(constraint) { 243 | constraint.reset(); 244 | }); 245 | 246 | this.variables = this.variables.filter(function(variable) { 247 | return variable.methods.size; 248 | }); 249 | }, 250 | 251 | getPlan: function() { 252 | this.variables.forEach(function(variable) { 253 | variable.priority = variable.stayFunc(); 254 | }); 255 | 256 | this.constraints.forEach(function(constraint) { 257 | constraint.reset(); 258 | }); 259 | 260 | var methods = []; 261 | var free = []; 262 | var overconstrained = new UIDSet; 263 | 264 | this.variables.forEach(function(variable) { 265 | var methodCount = variable.methods.size; 266 | 267 | if (methodCount > 1) 268 | overconstrained.add(variable); 269 | else if (methodCount == 1) 270 | free.push(variable); 271 | }); 272 | 273 | free = new Heap(function(variable) { 274 | return variable.priority; 275 | }, free); 276 | 277 | while (free.size) { 278 | var lowest; 279 | do { 280 | lowest = free.pop(); 281 | } while (free.size && !lowest.methods.size); 282 | 283 | if (!lowest.methods.size) 284 | break; 285 | 286 | var method = lowest.freeMethod(); 287 | var constraint = method.constraint; 288 | 289 | constraint.remove(); 290 | constraint.methods.forEach(function(method) { 291 | var variable = method.variable; 292 | if (variable.methods.size == 1) { 293 | overconstrained.delete(variable); 294 | free.push(variable); 295 | } 296 | }); 297 | 298 | methods.push(method); 299 | } 300 | 301 | if (overconstrained.size) 302 | return undefined; 303 | 304 | return methods.reverse(); 305 | } 306 | } 307 | 308 | global.Planner = Planner; 309 | })(this); 310 | --------------------------------------------------------------------------------