Skip to content
Browse files

initial commit

  • Loading branch information...
0 parents commit 22c0fba6136c53e9afed7d3c512495224d7b4287 @vsavkin committed Sep 25, 2012
5 .gitignore
@@ -0,0 +1,5 @@
+pubspec.lock
+packages
+test/packages
+.idea
+.project
23 LICENCE
@@ -0,0 +1,23 @@
+Copyright (c) 2012, Victor Savkin
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+ * Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in the
+ documentation and/or other materials provided with the distribution.
+ * The name of the copyright owner may be used to endorse or promote products
+ derived from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER BE LIABLE FOR ANY
+DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
+ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
27 README.md
@@ -0,0 +1,27 @@
+# SimpleMVP
+
+SimpleMVP is a framework for writing single-page applications in Dart. Similar to Backbone it has the following key components:
+
+* Models
+* ModelLists (Collections in Backbone)
+* Presenters (Views in Backbone)
+
+## Example App
+
+There is a task list application using SimpleMVP in the example folder.
+
+To try it out:
+
+* Run: dart dummy_server.dart
+* Open Dartium: localhost:8080/task_list.html
+
+The application illustrates most of the features of SimpleMVP, and it's about 100 lines long (including all templates). Check out `task_list.dart` to see how it's implemented.
+
+## Video
+
+I've put together a 15-minute video showing how to build a TODO app using SimpleMVP.
+[Building a TODO app using SimpleMVP](https://vimeo.com/49728673)
+
+## Learning Dart
+
+Even if you don't end up using SimpleMVP, you can still use it as an example of an MV* framework written in Dart.
95 example/dummy_server.dart
@@ -0,0 +1,95 @@
+#import("dart:io");
+#import("dart:json");
+#import("dart:math");
+
+final HOST = "127.0.0.1";
+final PORT = 8080;
+
+void main() {
+ var server = new HttpServer();
+ server.addRequestHandler((request) => true, (request, response){
+ new RequestHandler(request,response).process();
+ }
+ );
+ server.listen(HOST, PORT);
+}
+
+class RequestHandler {
+ HttpRequest request;
+ HttpResponse response;
+
+ RequestHandler(this.request, this.response);
+
+ void process() {
+ var uri = request.uri;
+ print("${request.method} ${uri}");
+
+ if (uri.startsWith("/api/tasks") && request.method == "GET"){
+ _render("text/json", _items());
+
+ } else if (uri.startsWith("/api/tasks") && request.method == "POST"){
+ _renderNewRecord();
+
+ } else if (uri.startsWith("/api/task")){
+ _renderUpdatedRecord();
+
+ } else if (uri.endsWith(".dart")) {
+ _renderDartFile();
+
+ } else if (uri.endsWith(".css")){
+ var file = new File("example/${_filename(uri)}");
+ _render("text/css", file.readAsTextSync());
+
+ } else if (uri.endsWith(".html")){
+ var file = new File("example/${_filename(uri)}");
+ _render("text/html", file.readAsTextSync());
+ }
+ }
+
+ _renderNewRecord(){
+ var s = new StringInputStream(request.inputStream);
+ s.onData = (){
+ var data = s.read();
+ print("body: ${data}");
+
+ var map = JSON.parse(data);
+ map["id"] = new Random().nextInt(10000);
+ _render("text/json", JSON.stringify(map));
+ };
+ }
+
+ _renderUpdatedRecord(){
+ var s = new StringInputStream(request.inputStream);
+ s.onData = (){
+ var data = s.read();
+ print("body: ${data}");
+ _render("text/json", data);
+ };
+ }
+
+ _renderDartFile(){
+ var uri = request.uri;
+
+ var libFile = new File(_filename(uri));
+ var exampleFile = new File("example/${_filename(uri)}");
+
+ if(libFile.existsSync()){
+ _render("application/dart", libFile.readAsTextSync());
+ } else {
+ _render("application/dart", exampleFile.readAsTextSync());
+ }
+ }
+
+ _filename(uri) => uri.substring(uri.indexOf("/") + 1);
+
+ _render(contentType, body){
+ response.headers.set(HttpHeaders.CONTENT_TYPE, "$contentType; charset=UTF-8");
+ response.outputStream.writeString(body);
+ response.outputStream.close();
+ }
+
+ _items() {
+ var r = [{"id": 1, "text": "Task 1", "status" : "inProgress"}, {"id": 2, "text": "Task 2", "status" : "inProgress"}];
+ return JSON.stringify(r);
+ }
+}
20 example/task_list.css
@@ -0,0 +1,20 @@
+li {
+ list-style-type: none;
+ margin-top: 15px;
+ /*background-color: #F8F8F8;*/
+ font-size: 15px;
+ padding-left: 5px;
+}
+
+span.done-true {
+ text-decoration: line-through;
+}
+
+.actions {
+ float: right;
+}
+
+input.task-text {
+ width: 400px;
+ margin-bottom: 0px;
+}
137 example/task_list.dart
@@ -0,0 +1,137 @@
+#import('dart:html');
+#import('../lib/simple_mvp.dart', prefix: "smvp");
+
+class Tasks extends smvp.ModelList<Task>{
+ final rootUrl = "/api/tasks";
+
+ makeInstance(attrs, list) => new Task(attrs, list);
+}
+
+class Task extends smvp.Model {
+ Task(attrs, modelList): super(attrs, modelList);
+ Task.fromText(String text): this({"text": text, "status": "inProgress"}, null);
+
+ final rootUrl = "/api/task";
+ final createUrl = "/api/tasks";
+
+ get isCompleted => status == "completed";
+
+ complete(){
+ status = "completed";
+ save();
+ }
+
+ inProgress(){
+ status = "inProgress";
+ save();
+ }
+}
+
+
+
+
+oneTaskTemplate(c) => """
+<div class="well well-small">
+ <span class="text">${c.text}</span>
+ <span class="actions">
+ <a class="complete" href="#">[DONE]</a>
+ <a class="delete" href="#">[DELETE]</a>
+ </span>
+</div>
+""";
+
+newTaskTemplate(c) => """
+<div class="well well-small">
+ <input type="text" class="task-text"/>
+ <button class="btn">Create!</button>
+</div>
+""";
+
+taskListTemplate(c) => """
+<div id="tasks">
+</div>
+""";
+
+
+
+
+class TaskPresenter extends smvp.Presenter<Task> {
+ TaskPresenter(task, el) : super(task, el, oneTaskTemplate);
+
+ subscribeToModelEvents() {
+ model.on.change.add(_onChange);
+ }
+
+ _onChange(e){
+ render();
+ el.query(".text").classes.add("done-${model.isCompleted}");
+ }
+
+ get events => {
+ "click a.delete": _onDelete,
+ "click a.complete": _onComplete
+ };
+
+ _onDelete(event) => model.destroy();
+ _onComplete(event) => model.isCompleted ? model.inProgress() : model.complete();
+}
+
+class NewTaskPresenter extends smvp.Presenter<Tasks> {
+ NewTaskPresenter(tasks, el) :super(tasks, el, newTaskTemplate);
+
+ get events => {
+ "click button": _addNewTask,
+ "keypress input": _maybeAddNewTask
+ };
+
+ _maybeAddNewTask(event){
+ if(event.keyIdentifier == "Enter"){
+ _createTask();
+ }
+ }
+
+ _addNewTask(event){
+ _createTask();
+ }
+
+ _createTask(){
+ var textField = el.query(".task-text");
+
+ var task = new Task.fromText(textField.value);
+ model.add(task);
+ task.save();
+
+ textField.value = "";
+ }
+}
+
+class TasksPresenter extends smvp.Presenter<Tasks>{
+ TasksPresenter(tasks, el) : super(tasks, el, taskListTemplate){
+ model.fetch();
+ }
+
+ subscribeToModelEvents(){
+ model.on.load.add(_rerenderTasks);
+ model.on.insert.add(_rerenderTasks);
+ model.on.remove.add(_rerenderTasks);
+ }
+
+ _rerenderTasks(event){
+ var t = el.query("#tasks");
+ t.elements.clear();
+
+ _buildPresenters().forEach((v){
+ t.elements.add(v.render().el);
+ });
+ }
+
+ _buildPresenters() => model.map((t) => new TaskPresenter(t, new Element.tag("li")));
+}
+
+main() {
+ var tasks = new Tasks();
+ var newTaskPresenter = new NewTaskPresenter(tasks, new Element.tag("div"));
+ var tasksPresenter = new TasksPresenter(tasks, new Element.tag("div"));
+
+ query("#container").elements.addAll([newTaskPresenter.render().el, tasksPresenter.render().el]);
+}
20 example/task_list.html
@@ -0,0 +1,20 @@
+<!DOCTYPE html>
+
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Dart_clientside</title>
+ <link rel="stylesheet" href="http://netdna.bootstrapcdn.com/twitter-bootstrap/2.1.0/css/bootstrap-combined.min.css">
+ <link rel="stylesheet" href="task_list.css">
+</head>
+<body>
+<div class="hero-unit">
+ <div id="container">
+ <h1>Task List</h1>
+ </div>
+</div>
+
+<script type="application/dart" src="task_list.dart"></script>
+<script src="http://dart.googlecode.com/svn/branches/bleeding_edge/dart/client/dart.js"></script>
+</body>
+</html>
12 lib/simple_mvp.dart
@@ -0,0 +1,12 @@
+#library('simple_mvp.dart');
+
+#import("dart:html");
+#import("dart:json");
+
+#source('src/model_attributes.dart');
+#source('src/storage.dart');
+#source('src/events.dart');
+#source('src/model_list.dart');
+#source('src/model.dart');
+#source('src/delegated_event.dart');
+#source('src/presenter.dart');
34 lib/src/delegated_event.dart
@@ -0,0 +1,34 @@
+/**
+* Utility class implementing "jquery delegate" functionality.
+*/
+class _DelegatedEvent {
+ final _eventSelector, _callback;
+
+ _DelegatedEvent(this._eventSelector, this._callback);
+
+ void registerOn(HtmlElement parent) {
+ var parentCallback = _createCallbackOn(parent);
+ var eventList = parent.on[_eventType()];
+ eventList.add(parentCallback);
+ }
+
+ _createCallbackOn(parent) => (event) {
+ if (_isTriggeredBySelector(parent, event)) {
+ _callback(event);
+ }
+ };
+
+ _isTriggeredBySelector(parent, event) =>
+ _delimiter() == -1 ?
+ true :
+ parent.queryAll(_selector()).some((el) => el == event.target);
+
+ _eventType() =>
+ _delimiter() == -1 ?
+ _eventSelector :
+ _eventSelector.substring(0, _delimiter());
+
+ _selector() => _eventSelector.substring(_delimiter() + 1);
+
+ _delimiter() => _eventSelector.indexOf(' ');
+}
85 lib/src/events.dart
@@ -0,0 +1,85 @@
+typedef Listener(event);
+
+/**
+* Utility class to manage event listeners.
+*/
+class Listeners {
+ final List listeners = [];
+
+ Listeners add(Listener listener){
+ listeners.add(listener);
+ return this;
+ }
+
+ void dispatch(event) {
+ listeners.forEach((fn) {fn(event);});
+ }
+}
+
+/**
+* Utility class to manage multiple event types.
+*/
+class EventMap {
+ final _listeners = new Map();
+
+ Listeners listeners(String eventType){
+ _listeners.putIfAbsent(eventType, () => new Listeners());
+ return _listeners[eventType];
+ }
+}
+
+/**
+* Event map for the ModelList class.
+*/
+class CollectionEvents extends EventMap {
+ Listeners get load => listeners("load");
+ Listeners get insert => listeners("insert");
+ Listeners get remove => listeners("remove");
+ Listeners get update => listeners("update");
+}
+
+/**
+* An event that is raised after a collection's initial load.
+*/
+class CollectionLoadEvent {
+ final collection;
+
+ CollectionLoadEvent(this.collection);
+}
+
+/**
+* Event raised after a new record was inserted into a collection.
+*/
+class CollectionInsertEvent {
+ final collection, model;
+
+ CollectionInsertEvent(this.collection, this.model);
+}
+
+/**
+* Event raised after a record was removed from its collection.
+*/
+class CollectionRemoveEvent {
+ final collection, model;
+
+ CollectionRemoveEvent(this.collection, this.model);
+}
+
+/**
+* Event map for the Model class.
+*/
+class ModelEvents extends EventMap {
+ Listeners get change => listeners("change");
+}
+
+/**
+* Event raised after a model's attribute was changed.
+*/
+class ChangeEvent {
+ final String attrName;
+ final model;
+ final oldValue;
+ final newValue;
+
+ ChangeEvent(this.model, this.attrName, this.oldValue, this.newValue);
+}
62 lib/src/model.dart
@@ -0,0 +1,62 @@
+/**
+* The base class for model classes. It manages attribute changes, and persistence.
+*/
+abstract class Model {
+ Storage storage;
+ ModelAttributes attributes;
+ ModelList modelList;
+
+ final ModelEvents on = new ModelEvents();
+
+ Model(Map attributes, [this.modelList]){
+ this.attributes = new ModelAttributes(this, attributes);
+ storage = new Storage({
+ "read" : rootUrl,
+ "create" : createUrl,
+ "update" : updateUrl,
+ "destroy" : destroyUrl});
+ }
+
+ /**
+ * Abstract property to be overriden by sublasses. It's used by Storage.
+ * By default [createUrl], [updateUrl], and [destroyUrl] are equal to [rootUrl].
+ */
+ String get rootUrl;
+ String get createUrl => rootUrl;
+ String get updateUrl => rootUrl;
+ String get destroyUrl => rootUrl;
+
+ get id => attributes["id"];
+
+ bool get saved => attributes.hasId();
+
+ Future save(){
+ var f = saved ? storage.update(id, attributes) : storage.create(attributes);
+ f.then((attrs) => attributes.reset(attrs));
+ return f;
+ }
+
+ Future destroy(){
+ if(modelList != null){
+ modelList.remove(this);
+ }
+ return storage.destroy(id);
+ }
+
+ operator [] (String name) => attributes[name];
+
+ operator []= (String name, value) => attributes[name] = value;
+
+ noSuchMethod(String name, args){
+ if(name.startsWith("get:")){
+ return this[_extractAttributeName(name)];
+
+ } else if (name.startsWith("set:")){
+ return this[_extractAttributeName(name)] = args[0];
+ }
+
+ throw new NoSuchMethodError(this, name, args);
+ }
+
+ _extractAttributeName(name) => name.substring(4);
+}
46 lib/src/model_attributes.dart
@@ -0,0 +1,46 @@
+/**
+* Exception raised when accessing a missing attribute.
+*/
+class InvalidAttributeError extends Error {
+ var model, name;
+ InvalidAttributeError(this.model, this.name);
+}
+
+/**
+* Utility class to manage a model's attributes.
+*/
+class ModelAttributes {
+ final Model model;
+ Map map;
+
+ ModelAttributes(this.model, this.map);
+
+ hasId() => map.containsKey("id");
+
+ operator [] (String name){
+ if(! map.containsKey(name)){
+ throw new InvalidAttributeError(model, name);
+ }
+ return map[name];
+ }
+
+ operator []= (String name, value){
+ if(! map.containsKey(name)){
+ throw new InvalidAttributeError(model, name);
+ }
+
+ var oldValue = map[name];
+ map[name] = value;
+
+ if(value != oldValue){
+ var event = new ChangeEvent(model, name, oldValue, value);
+ model.on.change.dispatch(event);
+ }
+ }
+
+ void reset(Map attrs){
+ map = attrs;
+ }
+
+ asJSON() => map;
+}
47 lib/src/model_list.dart
@@ -0,0 +1,47 @@
+/**
+* The base class for model lists.
+*/
+abstract class ModelList<T extends Model> {
+ final CollectionEvents on = new CollectionEvents();
+ final List<T> models = [];
+ Storage storage;
+
+ ModelList(){
+ storage = new Storage({"readAll" : rootUrl});
+ }
+
+ /**
+ * Abstract property to be overriden by sublasses. It's used by Storage.
+ */
+ String get rootUrl;
+
+ T makeInstance(Map attrs, ModelList list);
+
+ forEach(fn(T)) => models.forEach(fn);
+
+ map(fn(T)) => models.map(fn);
+
+ void add(T model){
+ models.add(model);
+ model.modelList = this;
+ on.insert.dispatch(new CollectionInsertEvent(this, model));
+ }
+
+ void remove(T model){
+ var index = models.indexOf(model);
+ if(index == -1) return;
+
+ models.removeRange(index, 1);
+ on.remove.dispatch(new CollectionRemoveEvent(this, model));
+ }
+
+ void reset(List list){
+ models.clear();
+ models.addAll(list.map((attrs) => makeInstance(attrs, this)));
+ on.load.dispatch(new CollectionLoadEvent(this));
+ }
+
+ void fetch(){
+ storage.readAll().then(reset);
+ }
+}
35 lib/src/presenter.dart
@@ -0,0 +1,35 @@
+typedef String Template<T>(T model);
+
+/**
+* It binds a model and view.
+* Sends commands to the model when the associated view changes.
+* Updates the view when the model changes.
+*/
+class Presenter<T> {
+ final HtmlElement el;
+ final Template<T> template;
+ final T model;
+
+ Map get events => {};
+
+ Presenter(this.model, this.el, [this.template]){
+ subscribeToModelEvents();
+ subscribeToDOMEvents();
+ }
+
+ Presenter<T> render(){
+ if(template != null){
+ el.innerHTML = template(model);
+ }
+ return this;
+ }
+
+ void subscribeToDOMEvents(){
+ events.forEach((eventSelector, callback){
+ new _DelegatedEvent(eventSelector, callback).registerOn(el);
+ });
+ }
+
+ void subscribeToModelEvents(){
+ }
+}
39 lib/src/storage.dart
@@ -0,0 +1,39 @@
+/**
+* Utility class to read/update/delete models on the server.
+*/
+class Storage {
+ final _urls;
+
+ Storage(this._urls);
+
+ Future<List> readAll() => _submit("GET", _urls["readAll"], json: {});
+
+ Future<Map> read(id) => _submit("GET", _urls["read"], id: id);
+
+ Future<Map> create(ModelAttributes attrs) => _submit("POST", _urls["create"], json: attrs.asJSON());
+
+ Future<Map> update(id, ModelAttributes attrs) => _submit("PUT", _urls["update"], id: id, json: attrs.asJSON());
+
+ Future<Map> destroy(id) => _submit("DELETE", _urls["destroy"], id: id);
+
+ _submit(method, url, {id, json}){
+ var c = new Completer();
+ url = id != null ? "$url/$id" : url;
+ var req = _createRequest(method, url, (res) => c.complete(res));
+ req.send(JSON.stringify(json));
+ return c.future;
+ }
+
+ _createRequest(method, url, callback){
+ var req = new HttpRequest();
+
+ req.on.load.add((e){
+ String response = req.response;
+ var parsedResponse = response.isEmpty() ? {} : JSON.parse(response);
+ callback(parsedResponse);
+ });
+
+ req.open(method, url, true);
+ return req;
+ }
+}
7 pubspec.yaml
@@ -0,0 +1,7 @@
+name: simple_mvp
+version: 0.1.0
+description: >
+ SimpleMVP is a framework for writing single-page applications in Dart.
+dependencies:
+ unittest:
+ sdk: unittest
37 test/events_test.dart
@@ -0,0 +1,37 @@
+testEvents() {
+ group("events_test", () {
+ var listener1 = (e){};
+ var listener2 = (e){};
+ var capturer = new EventCapturer();
+
+ group("Listeners", () {
+ test("adds listeners", () {
+ var l = new Listeners();
+ l.add(listener1).add(listener2);
+ expect(l.listeners, equals([listener1, listener2]));
+ });
+
+ test("dispatches events", () {
+ var l = new Listeners();
+ l.add(capturer.callback);
+
+ l.dispatch("expected event");
+
+ expect(capturer.event, equals("expected event"));
+ });
+ });
+
+ group("EventMap", () {
+ test("stores a list of listeners for the given event type", () {
+ var e = new EventMap();
+ e.listeners("type1").add(listener1);
+ expect(e.listeners("type1").listeners, equals([listener1]));
+ });
+
+ test("creates a new list for every event type", () {
+ var e = new EventMap();
+ expect(e.listeners("type1"), isNot(equals(e.listeners("type2"))));
+ });
+ });
+ });
+}
91 test/model_attributes_test.dart
@@ -0,0 +1,91 @@
+testModelAttributes() {
+ group("model_attributes_test", () {
+ var capturer;
+ var model;
+ var attrs;
+
+ group("operator[]", () {
+ setUp(() {
+ model = new TestModel({});
+ attrs = new ModelAttributes(model, {"key": "value"});
+ capturer = new EventCapturer();
+ });
+
+ test("returns the attribute's value", () {
+ expect(attrs["key"], equals("value"));
+ });
+
+ test("raises an exception when invalid attribute name", () {
+ expect(() => attrs["invalid"], throws);
+ });
+ });
+
+ group("operator[]=", () {
+ setUp(() {
+ model = new TestModel({});
+ attrs = new ModelAttributes(model, {"key": "value"});
+ capturer = new EventCapturer();
+ });
+
+ test("updates the attribute's value", () {
+ attrs["key"] = "newValue";
+ expect(attrs["key"], equals("newValue"));
+ });
+
+ test("raises an exception when invalid attribute name", () {
+ expect(() => attrs["invalid"] = "value", throws);
+ });
+
+ test("raises an event when the value has changed", () {
+ model.on.change.add(capturer.callback);
+ attrs["key"] = "newValue";
+
+ expect(capturer.event.attrName, equals("key"));
+ expect(capturer.event.oldValue, equals("value"));
+ expect(capturer.event.newValue, equals("newValue"));
+ });
+
+ test("does not raise an event when the new value is the same", () {
+ model.on.change.add(capturer.callback);
+ attrs["key"] = attrs["key"];
+
+ expect(capturer.event, isNull);
+ });
+ });
+
+ group("hasId", (){
+ test("is true when attributes containt id", (){
+ attrs = new ModelAttributes(null, {"id": "value"});
+ expect(attrs.hasId(), isTrue);
+ });
+
+ test("is false otherwise", (){
+ attrs = new ModelAttributes(null, {});
+ expect(attrs.hasId(), isFalse);
+ });
+ });
+
+ group("reset", (){
+ setUp(() {
+ model = new TestModel({});
+ attrs = new ModelAttributes(model, {"key": "value"});
+ capturer = new EventCapturer();
+ });
+
+ test("updates existing attribitues", (){
+ attrs.reset({"key": "newValue"});
+ expect(attrs["key"], equals("newValue"));
+ });
+
+ test("creates new attributes", (){
+ attrs.reset({"newKey": "newValue"});
+ expect(attrs["newKey"], equals("newValue"));
+ });
+
+ test("removes attributes", (){
+ attrs.reset({});
+ expect(() => attrs["key"], throws);
+ });
+ });
+ });
+}
57 test/model_list_test.dart
@@ -0,0 +1,57 @@
+testModelLists() {
+
+ group("model_list_test", () {
+ var capturer = new EventCapturer();
+ var list;
+ var model;
+
+ group("add", () {
+ setUp(() {
+ list = new TestModelList();
+ model = new TestModel({});
+ });
+
+ test("adds the element to the collection", () {
+ list.add(model);
+ expect(list.models, equals([model]));
+ });
+
+ test("sets the modelList property on the model", () {
+ list.add(model);
+ expect(model.modelList, equals(list));
+ });
+
+ test("raises an event", () {
+ list.on.insert.add(capturer.callback);
+ list.add(model);
+
+ expect(capturer.event.model, equals(model));
+ });
+ });
+
+ group("remove", () {
+ setUp(() {
+ list = new TestModelList();
+ model = new TestModel({});
+ list.add(model);
+ });
+
+ test("removes the element from the collection", () {
+ list.remove(model);
+ expect(list.models, equals([]));
+ });
+
+ test("does nothing when cannot find the element", () {
+ list.remove(model);
+ list.remove(model);
+ });
+
+ test("raises an event", () {
+ list.on.insert.add(capturer.callback);
+ list.add(model);
+
+ expect(capturer.event.model, equals(model));
+ });
+ });
+ });
+}
99 test/model_test.dart
@@ -0,0 +1,99 @@
+testModels() {
+ group("model_test", () {
+ var capturer;
+ var model;
+
+ setUp(() {
+ capturer = new EventCapturer();
+ });
+
+ group("saved", () {
+ test("is true when id is set", () {
+ model = new TestModel({"id": 1});
+ expect(model.saved, isTrue);
+ });
+
+ test("is false otherwise", () {
+ model = new TestModel({});
+ expect(model.saved, isFalse);
+ });
+ });
+
+ group("attributes", () {
+ setUp(() {
+ model = new TestModel({"key": "value"});
+ });
+
+ test("returns the attribute's value", () {
+ expect(model.key, equals("value"));
+ });
+
+ test("updates the attribute's value", () {
+ model.key = "newValue";
+ expect(model.key, equals("newValue"));
+ });
+
+ test("supports map syntax", () {
+ model["key"] = "newValue";
+ expect(model["key"], equals("newValue"));
+ });
+ });
+
+ group("save", () {
+ var dummyFuture = new Future.immediate({"key": "newValue"});
+
+ test("creates the element in the storage", () {
+ model = new TestModel({"key": "value"});
+ model.storage.when(callsTo('create', model.attributes)).alwaysReturn(dummyFuture);
+ model.save();
+ });
+
+ test("updates the element in the storage", () {
+ model = new TestModel({"id": 1, "key": "value"});
+ model.storage.when(callsTo('update', 1, model.attributes)).alwaysReturn(dummyFuture);
+ model.save();
+ });
+
+ test("resets the model's attributes after receiving a response from the server", () {
+ model = new TestModel({"key": "value"});
+ model.storage.when(callsTo('create')).alwaysReturn(dummyFuture);
+ model.save();
+ expect(model.key, equals("newValue"));
+ });
+
+ test("returns a future", () {
+ model = new TestModel({"key": "value"});
+ model.storage.when(callsTo('create')).alwaysReturn(dummyFuture);
+ var future = model.save();
+ expect(future, equals(dummyFuture));
+ });
+ });
+
+ group("destroy", () {
+ var list;
+ var dummyFuture = new Future.immediate("dummy");
+
+ setUp(() {
+ model = new TestModel({"id": 1});
+ list = new TestModelList();
+ });
+
+ test("removes the element from the storage", () {
+ model.destroy();
+ model.storage.getLogs(callsTo('destroy', 1)).verify(happenedExactly(1));
+ });
+
+ test("returns a future", () {
+ model.storage.when(callsTo('destroy')).alwaysReturn(dummyFuture);
+ var future = model.destroy();
+ expect(future, equals(dummyFuture));
+ });
+
+ test("removes itself from the model list", () {
+ list.add(model);
+ model.destroy();
+ expect(list.models, equals([]));
+ });
+ });
+ });
+}
13 test/run.html
@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>run_tests</title>
+</head>
+<body>
+<h1>Tests</h1>
+<script type="application/dart" src="simple_mvp_test.dart"></script>
+<script src="http://dart.googlecode.com/svn/branches/bleeding_edge/dart/client/dart.js"></script>
+</body>
+</html>
22 test/simple_mvp_test.dart
@@ -0,0 +1,22 @@
+#library("simple_mvp_test");
+
+#import("package:unittest/unittest.dart");
+#import("package:unittest/html_enhanced_config.dart");
+#import('../lib/simple_mvp.dart');
+
+#import('dart:html');
+
+#source("utils.dart");
+#source("events_test.dart");
+#source("model_attributes_test.dart");
+#source("model_test.dart");
+#source("model_list_test.dart");
+
+main(){
+ useHtmlEnhancedConfiguration();
+
+ testModels();
+ testEvents();
+ testModelAttributes();
+ testModelLists();
+}
26 test/utils.dart
@@ -0,0 +1,26 @@
+class EventCapturer {
+ var event;
+
+ callback(e){
+ event = e;
+ }
+}
+
+class MockStorage extends Mock implements Storage {}
+
+class TestModel extends Model {
+ TestModel(attrs, [list]): super(attrs, list) {
+ storage = new MockStorage();
+ }
+ final rootUrl = "url";
+}
+
+class TestModelList extends ModelList<TestModel> {
+ TestModelList(){
+ storage = new MockStorage();
+ }
+
+ final rootUrl = "url";
+
+ makeInstance(attrs, list) => new TestModel(attrs, list);
+}

0 comments on commit 22c0fba

Please sign in to comment.
Something went wrong with that request. Please try again.