Chromium Code Reviews| Index: pkg/polymer_expressions/test/syntax_test.dart |
| diff --git a/pkg/polymer_expressions/test/syntax_test.dart b/pkg/polymer_expressions/test/syntax_test.dart |
| index 5d15e172c5eb6e66849d7a697b6ecbcf02725f7e..758a3d868b3d5ab7a42da438bcf98a6211532d86 100644 |
| --- a/pkg/polymer_expressions/test/syntax_test.dart |
| +++ b/pkg/polymer_expressions/test/syntax_test.dart |
| @@ -8,105 +8,550 @@ import 'dart:html'; |
| import 'package:observe/observe.dart'; |
| import 'package:observe/mirrors_used.dart'; // make test smaller. |
| import 'package:polymer_expressions/polymer_expressions.dart'; |
| +import 'package:polymer_expressions/eval.dart'; |
| import 'package:template_binding/template_binding.dart'; |
| -import 'package:unittest/html_config.dart'; |
| +import 'package:unittest/html_enhanced_config.dart'; |
| import 'package:unittest/unittest.dart'; |
| +import 'package:smoke/mirrors.dart' as smoke; |
| + |
| +class TestScopeFactory implements ScopeFactory { |
| + int scopeCount = 0; |
| + |
| + modelScope({Object model, Map<String, Object> variables}) { |
| + scopeCount++; |
| + return new Scope(model: model, variables: variables); |
| + } |
| + |
| + childScope(Scope parent, String name, Object value) { |
| + scopeCount++; |
| + return parent.childScope(name, value); |
| + } |
| +} |
| main() { |
| - useHtmlConfiguration(); |
| + useHtmlEnhancedConfiguration(); |
|
Jennifer Messerly
2014/04/24 00:51:34
i think i mentioned this in an earlier comment, bu
justinfagnani
2014/05/28 00:29:37
Done.
|
| + smoke.useMirrors(); |
| group('PolymerExpressions', () { |
| - var testDiv; |
| + DivElement testDiv; |
| + TestScopeFactory testScopeFactory; |
| setUp(() { |
| document.body.append(testDiv = new DivElement()); |
| + testScopeFactory = new TestScopeFactory(); |
| }); |
| tearDown(() { |
| - testDiv.firstChild.remove(); |
| + testDiv.children.clear(); |
| testDiv = null; |
| }); |
| - test('should make two-way bindings to inputs', () { |
| - testDiv.nodes.add(new Element.html(''' |
| - <template id="test" bind> |
| - <input id="input" value="{{ firstName }}"> |
| - </template>''')); |
| - var person = new Person('John', 'Messerly', ['A', 'B', 'C']); |
| - templateBind(querySelector('#test')) |
| - ..bindingDelegate = new PolymerExpressions() |
| - ..model = person; |
| - return new Future(() {}).then((_) { |
| - InputElement input = querySelector('#input'); |
| - expect(input.value, 'John'); |
| - input.focus(); |
| - input.value = 'Justin'; |
| - input.blur(); |
| - var event = new Event('change'); |
| - // TODO(justin): figure out how to trigger keyboard events to test |
| - // two-way bindings |
| + Future<Element> setUpTest(String html, {model, Map globals}) { |
| + var tag = new Element.html(html, |
| + treeSanitizer: new NullNodeTreeSanitizer()); |
| + templateBind(tag) |
| + ..bindingDelegate = new PolymerExpressions(globals: globals, |
| + scopeFactory: testScopeFactory) |
| + ..model = model; |
| + testDiv.children.clear(); |
| + testDiv.append(tag); |
| + return waitForChange(testDiv); |
| + } |
| + |
| + group('scope creation', () { |
| + // These tests are sensitive to some internals of the implementation that |
| + // might not be visible to applications, but are useful for verifying that |
| + // that we're not creating too many Scopes. |
| + |
| + // The reason that we create two Scopes in the cases with one binding is |
| + // that <template bind> has one scope for the context to evaluate the bind |
| + // binding in, and another scope for the bindings inside the template. |
| + |
| + // We could try to optimize the outer scope away in cases where the |
| + // expression is empty, but there are a lot of special cases in the |
| + // syntax code already. |
| + test('should create one scope for a single binding', () => |
| + setUpTest(''' |
| + <template id="test" bind> |
| + <div>{{ data }}</div> |
| + </template>''', |
| + model: new Model('a')) |
| + .then((_) { |
| + expect(testDiv.children.length, 2); |
| + expect(testDiv.children[1].text, 'a'); |
| + expect(testScopeFactory.scopeCount, 1); |
| + })); |
| + |
| + test('should only create a single scope for two bindings', () => |
| + setUpTest(''' |
| + <template id="test" bind> |
| + <div>{{ data }}</div> |
| + <div>{{ data }}</div> |
| + </template>''', |
| + model: new Model('a')) |
| + .then((_) { |
| + expect(testDiv.children.length, 3); |
| + expect(testDiv.children[1].text, 'a'); |
| + expect(testDiv.children[2].text, 'a'); |
| + expect(testScopeFactory.scopeCount, 1); |
| + })); |
| + |
| + test('should create a new scope for a bind/as binding', () { |
| + return setUpTest(''' |
| + <template id="test" bind> |
| + <div>{{ data }}</div> |
| + <template bind="{{ data as a }}" id="inner"> |
| + <div>{{ a }}</div> |
| + <div>{{ data }}</div> |
| + </template> |
| + </template>''', |
| + model: new Model('foo')) |
| + .then((_) { |
| + expect(testDiv.children.length, 5); |
| + expect(testDiv.children[1].text, 'foo'); |
| + expect(testDiv.children[3].text, 'foo'); |
| + expect(testDiv.children[4].text, 'foo'); |
| + expect(testScopeFactory.scopeCount, 2); |
| + }); |
| + }); |
| + |
| + test('should create scopes for a repeat/in binding', () { |
| + return setUpTest(''' |
| + <template id="test" bind> |
| + <div>{{ data }}</div> |
| + <template repeat="{{ i in items }}" id="inner"> |
| + <div>{{ i }}</div> |
| + <div>{{ data }}</div> |
| + </template> |
| + </template>''', |
| + model: new Model('foo'), globals: {'items': ['a', 'b', 'c']}) |
| + .then((_) { |
| + expect(testDiv.children.length, 9); |
| + expect(testDiv.children[1].text, 'foo'); |
| + expect(testDiv.children[3].text, 'a'); |
| + expect(testDiv.children[4].text, 'foo'); |
| + expect(testDiv.children[5].text, 'b'); |
| + expect(testDiv.children[6].text, 'foo'); |
| + expect(testDiv.children[7].text, 'c'); |
| + expect(testDiv.children[8].text, 'foo'); |
| + // 1 scopes for <template bind>, 1 for each repeat |
| + expect(testScopeFactory.scopeCount, 4); |
| + }); |
| }); |
| + |
| + |
| }); |
| - test('should handle null collections in "in" expressions', () { |
| - testDiv.nodes.add(new Element.html(''' |
| - <template id="test" bind> |
| - <template repeat="{{ item in items }}"> |
| - {{ item }} |
| - </template> |
| - </template>''')); |
| - templateBind(querySelector('#test')).bindingDelegate = |
| - new PolymerExpressions(globals: {'items': null}); |
| - // the template should be the only node |
| - expect(testDiv.nodes.length, 1); |
| - expect(testDiv.nodes[0].id, 'test'); |
| + group('with template bind', () { |
| + |
| + test('should show a simple binding on the model', () => |
| + setUpTest(''' |
| + <template id="test" bind> |
| + <div>{{ data }}</div> |
| + </template>''', |
| + model: new Model('a')) |
| + .then((_) { |
| + expect(testDiv.children.length, 2); |
| + expect(testDiv.children[1].text, 'a'); |
| + })); |
| + |
| + test('should handle an empty binding on the model', () => |
| + setUpTest(''' |
| + <template id="test" bind> |
| + <div>{{ }}</div> |
| + </template>''', |
| + model: 'a') |
| + .then((_) { |
| + expect(testDiv.children.length, 2); |
| + expect(testDiv.children[1].text, 'a'); |
| + })); |
| + |
| + test('should show a simple binding to a global', () => |
| + setUpTest(''' |
| + <template id="test" bind> |
| + <div>{{ a }}</div> |
| + </template>''', |
| + globals: {'a': '123'}) |
| + .then((_) { |
| + expect(testDiv.children.length, 2); |
| + expect(testDiv.children[1].text, '123'); |
| + })); |
| + |
| + test('should show an expression binding', () => |
| + setUpTest(''' |
| + <template id="test" bind> |
| + <div>{{ data + 'b' }}</div> |
| + </template>''', |
| + model: new Model('a')) |
| + .then((_) { |
| + expect(testDiv.children.length, 2); |
| + expect(testDiv.children[1].text, 'ab'); |
| + })); |
| + |
| + test('should handle an expression in the bind attribute', () => |
| + setUpTest(''' |
| + <template id="test" bind="{{ data }}"> |
| + <div>{{ this }}</div> |
| + </template>''', |
| + model: new Model('a')) |
| + .then((_) { |
| + expect(testDiv.children.length, 2); |
| + expect(testDiv.children[1].text, 'a'); |
| + })); |
| + |
| + test('should handle a nested template with an expression in the bind ' |
| + 'attribute', () => |
| + setUpTest(''' |
| + <template id="test" bind> |
| + <template id="inner" bind="{{ data }}"> |
| + <div>{{ this }}</div> |
| + </template> |
| + </template>''', |
| + model: new Model('a')) |
| + .then((_) { |
| + expect(testDiv.children.length, 3); |
| + expect(testDiv.children[2].text, 'a'); |
| + })); |
| + |
| + |
| + test('should handle an "as" expression in the bind attribute', () => |
| + setUpTest(''' |
| + <template id="test" bind="{{ data as a }}"> |
| + <div>{{ data }}b</div> |
| + <div>{{ a }}c</div> |
| + </template>''', |
| + model: new Model('a')) |
| + .then((_) { |
| + expect(testDiv.children.length, 3); |
| + expect(testDiv.children[1].text, 'ab'); |
| + expect(testDiv.children[2].text, 'ac'); |
| + })); |
| + |
| + test('should not resolve names in the outer template from within a nested' |
| + ' template with a bind binding', () { |
| + var completer = new Completer(); |
| + var bindingErrorHappened = false; |
| + var templateRendered = false; |
| + maybeComplete() { |
| + if (bindingErrorHappened && templateRendered) { |
| + completer.complete(true); |
| + } |
| + } |
| + runZoned(() { |
| + setUpTest(''' |
| + <template id="test" bind> |
| + <div>{{ data }}</div> |
| + <div>{{ b }}</div> |
| + <template id="inner" bind="{{ b }}"> |
| + <div>{{ data }}</div> |
| + <div>{{ b }}</div> |
| + <div>{{ this }}</div> |
| + </template> |
| + </template>''', |
| + model: new Model('foo'), globals: {'b': 'bbb'}) |
| + .then((_) { |
| + expect(testDiv.children.map((c) => c.text), |
| + ['', 'foo', 'bbb', '', '', 'bbb', 'bbb']); |
| + templateRendered = true; |
| + maybeComplete(); |
| + }); |
| + }, onError: (e, s) { |
| + expect('$e', contains('data')); |
| + bindingErrorHappened = true; |
| + maybeComplete(); |
| + }); |
| + return completer.future; |
| + }); |
| + |
| + test('should shadow names in the outer template from within a nested ' |
| + 'template', () => |
| + setUpTest(''' |
| + <template id="test" bind> |
| + <div>{{ a }}</div> |
| + <div>{{ b }}</div> |
| + <template bind="{{ b as a }}"> |
| + <div>{{ a }}</div> |
| + <div>{{ b }}</div> |
| + </template> |
| + </template>''', |
| + globals: {'a': 'aaa', 'b': 'bbb'}) |
| + .then((_) { |
| + expect(testDiv.children.map((c) => c.text), |
| + ['', 'aaa', 'bbb', '', 'bbb', 'bbb']); |
| + })); |
| + |
| }); |
| - test('should silently handle bad variable names', () { |
| - var completer = new Completer(); |
| - runZoned(() { |
| - testDiv.nodes.add(new Element.html(''' |
| - <template id="test" bind>{{ foo }}</template>''')); |
| - templateBind(querySelector('#test')) |
| - ..bindingDelegate = new PolymerExpressions() |
| - ..model = []; |
| - return new Future(() {}); |
| - }, onError: (e, s) { |
| - expect('$e', contains('foo')); |
| - completer.complete(true); |
| + group('with template repeat', () { |
| + |
| + test('should not resolve names in the outer template from within a nested' |
| + ' template with a repeat binding', () { |
| + var completer = new Completer(); |
| + var bindingErrorHappened = false; |
| + var templateRendered = false; |
| + maybeComplete() { |
| + if (bindingErrorHappened && templateRendered) { |
| + completer.complete(true); |
| + } |
| + } |
| + runZoned(() { |
| + setUpTest(''' |
| + <template id="test" bind> |
| + <div>{{ data }}</div> |
| + <template repeat="{{ items }}"> |
| + <div>{{ }}{{ data }}</div> |
| + </template> |
| + </template>''', |
| + globals: {'items': [1, 2, 3]}, |
| + model: new Model('a')) |
| + .then((_) { |
| + expect(testDiv.children.map((c) => c.text), |
| + ['', 'a', '', '1', '2', '3']); |
| + templateRendered = true; |
| + maybeComplete(); |
| + }); |
| + }, onError: (e, s) { |
| + expect('$e', contains('data')); |
| + bindingErrorHappened = true; |
| + maybeComplete(); |
| + }); |
| + return completer.future; |
| + }); |
| + |
| + test('should handle repeat/in bindings', () => |
| + setUpTest(''' |
| + <template id="test" bind> |
| + <div>{{ data }}</div> |
| + <template repeat="{{ item in items }}"> |
| + <div>{{ item }}{{ data }}</div> |
| + </template> |
| + </template>''', |
| + globals: {'items': [1, 2, 3]}, |
| + model: new Model('a')) |
| + .then((_) { |
| + // expect 6 children: two templates, a div and three instances |
| + expect(testDiv.children.map((c) => c.text), |
| + ['', 'a', '', '1a', '2a', '3a']); |
| + })); |
| + |
| + test('should observe changes to lists in repeat bindings', () { |
| + var items = new ObservableList.from([1, 2, 3]); |
| + return setUpTest(''' |
| + <template id="test" bind> |
| + <template repeat="{{ items }}"> |
| + <div>{{ }}</div> |
| + </template> |
| + </template>''', |
| + globals: {'items': items}, |
| + model: new Model('a')) |
| + .then((_) { |
| + expect(testDiv.children.map((c) => c.text), |
| + ['', '', '1', '2', '3']); |
| + items.add(4); |
| + return waitForChange(testDiv); |
| + }).then((_) { |
| + expect(testDiv.children.map((c) => c.text), |
| + ['', '', '1', '2', '3', '4']); |
| + }); |
| + }); |
| + |
| + test('should observe changes to lists in repeat/in bindings', () { |
| + var items = new ObservableList.from([1, 2, 3]); |
| + return setUpTest(''' |
| + <template id="test" bind> |
| + <template repeat="{{ item in items }}"> |
| + <div>{{ item }}</div> |
| + </template> |
| + </template>''', |
| + globals: {'items': items}, |
| + model: new Model('a')) |
| + .then((_) { |
| + expect(testDiv.children.map((c) => c.text), |
| + ['', '', '1', '2', '3']); |
| + items.add(4); |
| + return waitForChange(testDiv); |
| + }).then((_) { |
| + expect(testDiv.children.map((c) => c.text), |
| + ['', '', '1', '2', '3', '4']); |
| + }); |
| }); |
| - return completer.future; |
| }); |
| - }); |
| -} |
| -@reflectable |
| -class Person extends ChangeNotifier { |
| - String _firstName; |
| - String _lastName; |
| - List<String> _items; |
| + group('with template if', () { |
| - Person(this._firstName, this._lastName, this._items); |
| + Future doTest(value, bool shouldRender) => |
| + setUpTest(''' |
| + <template id="test" bind> |
| + <div>{{ data }}</div> |
| + <template if="{{ show }}"> |
| + <div>{{ data }}</div> |
| + </template> |
| + </template>''', |
| + globals: {'show': value}, |
| + model: new Model('a')) |
| + .then((_) { |
| + if (shouldRender) { |
| + expect(testDiv.children.length, 4); |
| + expect(testDiv.children[1].text, 'a'); |
| + expect(testDiv.children[3].text, 'a'); |
| + } else { |
| + expect(testDiv.children.length, 3); |
| + expect(testDiv.children[1].text, 'a'); |
| + } |
| + }); |
| - String get firstName => _firstName; |
| + test('should render for a true expression', |
| + () => doTest(true, true)); |
| - void set firstName(String value) { |
| - _firstName = notifyPropertyChange(#firstName, _firstName, value); |
| - } |
| + test('should treat a non-null expression as truthy', |
| + () => doTest('a', true)); |
| - String get lastName => _lastName; |
| + test('should treat an empty list as truthy', |
| + () => doTest([], true)); |
| - void set lastName(String value) { |
| - _lastName = notifyPropertyChange(#lastName, _lastName, value); |
| - } |
| + test('should handle a false expression', |
| + () => doTest(false, false)); |
| + |
| + test('should treat null as falsey', |
| + () => doTest(null, false)); |
| + }); |
| + |
| + group('error handling', () { |
| + |
| + test('should silently handle bad variable names', () { |
| + var completer = new Completer(); |
| + runZoned(() { |
| + testDiv.nodes.add(new Element.html(''' |
| + <template id="test" bind>{{ foo }}</template>''')); |
| + templateBind(query('#test')) |
| + ..bindingDelegate = new PolymerExpressions() |
| + ..model = []; |
| + return new Future(() {}); |
| + }, onError: (e, s) { |
| + expect('$e', contains('foo')); |
| + completer.complete(true); |
| + }); |
| + return completer.future; |
| + }); |
| + |
| + test('should handle null collections in "in" expressions', () => |
| + setUpTest(''' |
| + <template id="test" bind> |
| + <template repeat="{{ item in items }}"> |
| + {{ item }} |
| + </template> |
| + </template>''', |
| + globals: {'items': null}) |
| + .then((_) { |
| + expect(testDiv.children.length, 2); |
| + expect(testDiv.children[0].id, 'test'); |
| + })); |
| + |
| + }); |
| + |
| + group('special bindings', () { |
| + |
| + test('should handle class attributes with lists', () => |
| + setUpTest(''' |
| + <template id="test" bind> |
| + <div class="{{ classes }}"> |
| + </template>''', |
| + globals: {'classes': ['a', 'b']}) |
| + .then((_) { |
| + expect(testDiv.children.length, 2); |
| + expect(testDiv.children[1].attributes['class'], 'a b'); |
| + expect(testDiv.children[1].classes, ['a', 'b']); |
| + })); |
| + |
| + test('should handle class attributes with maps', () => |
| + setUpTest(''' |
| + <template id="test" bind> |
| + <div class="{{ classes }}"> |
| + </template>''', |
| + globals: {'classes': {'a': true, 'b': false, 'c': true}}) |
| + .then((_) { |
| + expect(testDiv.children.length, 2); |
| + expect(testDiv.children[1].attributes['class'], 'a c'); |
| + expect(testDiv.children[1].classes, ['a', 'c']); |
| + })); |
| - String getFullName() => '$_firstName $_lastName'; |
| + test('should handle style attributes with lists', () => |
| + setUpTest(''' |
| + <template id="test" bind> |
| + <div style="{{ styles }}"> |
| + </template>''', |
| + globals: {'styles': ['display: none', 'color: black']}) |
| + .then((_) { |
| + expect(testDiv.children.length, 2); |
| + expect(testDiv.children[1].attributes['style'], |
| + 'display: none;color: black'); |
| + })); |
| - List<String> get items => _items; |
| + test('should handle style attributes with maps', () => |
| + setUpTest(''' |
| + <template id="test" bind> |
| + <div style="{{ styles }}"> |
| + </template>''', |
| + globals: {'styles': {'display': 'none', 'color': 'black'}}) |
| + .then((_) { |
| + expect(testDiv.children.length, 2); |
| + expect(testDiv.children[1].attributes['style'], |
| + 'display: none;color: black'); |
| + })); |
| + }); |
| + |
| + group('regression tests', () { |
| + |
| + test('should bind to literals', () => |
| + setUpTest(''' |
| + <template id="test" bind> |
| + <div>{{ 123 }}</div> |
| + <div>{{ 123.456 }}</div> |
| + <div>{{ "abc" }}</div> |
| + <div>{{ true }}</div> |
| + <div>{{ null }}</div> |
| + </template>''', |
| + globals: {'items': null}) |
| + .then((_) { |
| + expect(testDiv.children.length, 6); |
| + expect(testDiv.children[1].text, '123'); |
| + expect(testDiv.children[2].text, '123.456'); |
| + expect(testDiv.children[3].text, 'abc'); |
| + expect(testDiv.children[4].text, 'true'); |
| + expect(testDiv.children[5].text, ''); |
| + })); |
| + |
| + }); |
| + |
| + }); |
| +} |
| - void set items(List<String> value) { |
| - _items = notifyPropertyChange(#items, _items, value); |
| +Future<Element> waitForChange(Element e) { |
| + var completer = new Completer<Element>(); |
| + new MutationObserver((mutations, observer) { |
| + observer.disconnect(); |
| + completer.complete(e); |
| + }).observe(e, childList: true); |
| + return completer.future.timeout(new Duration(seconds: 1)); |
| +} |
| + |
| +@reflectable |
| +class Model extends ChangeNotifier { |
| + String _data; |
| + |
| + Model(this._data); |
| + |
| + String get data => _data; |
| + |
| + void set data(String value) { |
| + _data = notifyPropertyChange(#data, _data, value); |
| } |
| - String toString() => "Person(firstName: $_firstName, lastName: $_lastName)"; |
| + String toString() => "Model(data: $_data)"; |
| } |
| + |
| +class NullNodeTreeSanitizer implements NodeTreeSanitizer { |
| + |
| + @override |
| + void sanitizeTree(Node node) {} |
| +} |