brainstem-js 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. data/.gitignore +8 -0
  2. data/.pairs +21 -0
  3. data/.ruby-gemset +1 -0
  4. data/.ruby-version +1 -0
  5. data/.tm_properties +1 -0
  6. data/.travis.yml +12 -0
  7. data/Assetfile +79 -0
  8. data/Gemfile +6 -0
  9. data/Gemfile.lock +50 -0
  10. data/LICENSE.txt +22 -0
  11. data/README.md +143 -0
  12. data/Rakefile +25 -0
  13. data/brainstemjs.gemspec +24 -0
  14. data/lib/brainstem/js/engine.rb +5 -0
  15. data/lib/brainstem/js/version.rb +5 -0
  16. data/lib/brainstem/js.rb +10 -0
  17. data/spec/brainstem-collection-spec.coffee +141 -0
  18. data/spec/brainstem-model-spec.coffee +283 -0
  19. data/spec/brainstem-sync-spec.coffee +22 -0
  20. data/spec/brainstem-utils-spec.coffee +12 -0
  21. data/spec/brianstem-expectation-spec.coffee +209 -0
  22. data/spec/helpers/builders.coffee +80 -0
  23. data/spec/helpers/jquery-matchers.js +137 -0
  24. data/spec/helpers/models/post.coffee +14 -0
  25. data/spec/helpers/models/project.coffee +13 -0
  26. data/spec/helpers/models/task.coffee +14 -0
  27. data/spec/helpers/models/time-entry.coffee +13 -0
  28. data/spec/helpers/models/user.coffee +8 -0
  29. data/spec/helpers/spec-helper.coffee +79 -0
  30. data/spec/storage-manager-spec.coffee +613 -0
  31. data/spec/support/.DS_Store +0 -0
  32. data/spec/support/console-runner.js +103 -0
  33. data/spec/support/headless.coffee +47 -0
  34. data/spec/support/headless.html +60 -0
  35. data/spec/support/runner.html +85 -0
  36. data/spec/vendor/backbone-factory.js +62 -0
  37. data/spec/vendor/backbone.js +1571 -0
  38. data/spec/vendor/inflection.js +448 -0
  39. data/spec/vendor/jquery-1.7.js +9300 -0
  40. data/spec/vendor/jquery.cookie.js +47 -0
  41. data/spec/vendor/minispade.js +67 -0
  42. data/spec/vendor/sinon-1.3.4.js +3561 -0
  43. data/spec/vendor/underscore.js +1221 -0
  44. data/vendor/assets/.DS_Store +0 -0
  45. data/vendor/assets/javascripts/.DS_Store +0 -0
  46. data/vendor/assets/javascripts/brainstem/brainstem-collection.coffee +53 -0
  47. data/vendor/assets/javascripts/brainstem/brainstem-expectation.coffee +65 -0
  48. data/vendor/assets/javascripts/brainstem/brainstem-inflections.js +449 -0
  49. data/vendor/assets/javascripts/brainstem/brainstem-model.coffee +141 -0
  50. data/vendor/assets/javascripts/brainstem/brainstem-sync.coffee +76 -0
  51. data/vendor/assets/javascripts/brainstem/index.js +1 -0
  52. data/vendor/assets/javascripts/brainstem/iso8601.js +41 -0
  53. data/vendor/assets/javascripts/brainstem/loading-mixin.coffee +13 -0
  54. data/vendor/assets/javascripts/brainstem/storage-manager.coffee +275 -0
  55. data/vendor/assets/javascripts/brainstem/utils.coffee +35 -0
  56. metadata +198 -0
@@ -0,0 +1,613 @@
1
+ describe 'Brainstem Storage Manager', ->
2
+ manager = null
3
+
4
+ beforeEach ->
5
+ manager = new Brainstem.StorageManager()
6
+
7
+ describe 'addCollection and getCollectionDetails', ->
8
+ it "tracks a named collection", ->
9
+ manager.addCollection 'time_entries', App.Collections.TimeEntries
10
+ expect(manager.getCollectionDetails("time_entries").klass).toBe App.Collections.TimeEntries
11
+
12
+ it "raises an error if the named collection doesn't exist", ->
13
+ expect(-> manager.getCollectionDetails('foo')).toThrow()
14
+
15
+ describe "storage", ->
16
+ beforeEach ->
17
+ manager.addCollection 'time_entries', App.Collections.TimeEntries
18
+
19
+ it "accesses a cached collection of the appropriate type", ->
20
+ expect(manager.storage('time_entries') instanceof App.Collections.TimeEntries).toBeTruthy()
21
+ expect(manager.storage('time_entries').length).toBe 0
22
+
23
+ it "raises an error if the named collection doesn't exist", ->
24
+ expect(-> manager.storage('foo')).toThrow()
25
+
26
+ describe "reset", ->
27
+ it "should clear all storage and sort lengths", ->
28
+ buildAndCacheTask()
29
+ buildAndCacheProject()
30
+ expect(base.data.storage("projects").length).toEqual 1
31
+ expect(base.data.storage("tasks").length).toEqual 1
32
+ base.data.collections["projects"].cache = { "foo": "bar" }
33
+ base.data.reset()
34
+ expect(base.data.collections["projects"].cache).toEqual {}
35
+ expect(base.data.storage("projects").length).toEqual 0
36
+ expect(base.data.storage("tasks").length).toEqual 0
37
+
38
+ describe "loadModel", ->
39
+ beforeEach ->
40
+ tasks = [buildTask(id: 2, title: "a task", project_id: 15)]
41
+ projects = [buildProject(id: 15)]
42
+ timeEntries = [buildTimeEntry(id: 1, task_id: 2, project_id: 15, title: "a time entry")]
43
+ respondWith server, "/api/time_entries?only=1", resultsFrom: "time_entries", data: { time_entries: timeEntries }
44
+ respondWith server, "/api/time_entries?include=project%2Ctask&only=1", resultsFrom: "time_entries", data: { time_entries: timeEntries, tasks: tasks, projects: projects }
45
+
46
+ it "loads a single model from the server, including associations", ->
47
+ model = base.data.loadModel "time_entry", 1, include: ["project", "task"]
48
+ expect(model.loaded).toBe false
49
+ server.respond()
50
+ expect(model.loaded).toBe true
51
+ expect(model.id).toEqual "1"
52
+ expect(model.get("title")).toEqual "a time entry"
53
+ expect(model.get('task').get('title')).toEqual "a task"
54
+ expect(model.get('project').id).toEqual "15"
55
+
56
+ it "works even when the server returned associations of the same type", ->
57
+ posts = [buildPost(id: 2, reply: true), buildPost(id: 3, reply: true), buildPost(id: 1, reply: false, reply_ids: [2, 3])]
58
+ respondWith server, "/api/posts?include=replies&only=1", data: { results: [{ key: "posts", id: 1 }], posts: posts }
59
+ model = base.data.loadModel "post", 1, include: ["replies"]
60
+ expect(model.loaded).toBe false
61
+ server.respond()
62
+ expect(model.loaded).toBe true
63
+ expect(model.id).toEqual "1"
64
+ expect(model.get("replies").pluck("id")).toEqual ["2", "3"]
65
+
66
+ it "updates associations before the primary model", ->
67
+ events = []
68
+ base.data.storage('time_entries').on "add", -> events.push "time_entries"
69
+ base.data.storage('tasks').on "add", -> events.push "tasks"
70
+ base.data.loadModel "time_entry", 1, include: ["project", "task"]
71
+ server.respond()
72
+ expect(events).toEqual ["tasks", "time_entries"]
73
+
74
+ it "triggers changes", ->
75
+ model = base.data.loadModel "time_entry", 1, include: ["project", "task"]
76
+ spy = jasmine.createSpy().andCallFake ->
77
+ expect(model.loaded).toBe true
78
+ expect(model.get("title")).toEqual "a time entry"
79
+ expect(model.get('task').get('title')).toEqual "a task"
80
+ expect(model.get('project').id).toEqual "15"
81
+ model.bind "change", spy
82
+ expect(spy).not.toHaveBeenCalled()
83
+ server.respond()
84
+ expect(spy).toHaveBeenCalled()
85
+ expect(spy.callCount).toEqual 1
86
+
87
+ it "accepts a success function", ->
88
+ spy = jasmine.createSpy().andCallFake (model) ->
89
+ expect(model.loaded).toBe true
90
+ model = base.data.loadModel "time_entry", 1, success: spy
91
+ server.respond()
92
+ expect(spy).toHaveBeenCalled()
93
+
94
+ it "can disbale caching", ->
95
+ spy = spyOn(base.data, 'loadCollection')
96
+ model = base.data.loadModel "time_entry", 1, cache: false
97
+ expect(spy.mostRecentCall.args[1]['cache']).toBe(false)
98
+
99
+ describe 'loadCollection', ->
100
+ it "loads a collection of models", ->
101
+ timeEntries = [buildTimeEntry(), buildTimeEntry()]
102
+ respondWith server, "/api/time_entries?per_page=20&page=1", resultsFrom: "time_entries", data: { time_entries: timeEntries }
103
+ collection = base.data.loadCollection "time_entries"
104
+ expect(collection.length).toBe 0
105
+ server.respond()
106
+ expect(collection.length).toBe 2
107
+
108
+ it "accepts a success function", ->
109
+ timeEntries = [buildTimeEntry(), buildTimeEntry()]
110
+ respondWith server, "/api/time_entries?per_page=20&page=1", resultsFrom: "time_entries", data: { time_entries: timeEntries }
111
+ spy = jasmine.createSpy().andCallFake (collection) ->
112
+ expect(collection.loaded).toBe true
113
+ collection = base.data.loadCollection "time_entries", success: spy
114
+ server.respond()
115
+ expect(spy).toHaveBeenCalledWith(collection)
116
+
117
+ it "saves it's options onto the returned collection", ->
118
+ collection = base.data.loadCollection "time_entries", order: "baz:desc", filters: { bar: 2 }
119
+ expect(collection.lastFetchOptions.order).toEqual "baz:desc"
120
+ expect(collection.lastFetchOptions.filters).toEqual { bar: 2 }
121
+ expect(collection.lastFetchOptions.collection).toBeFalsy()
122
+
123
+ describe "passing an optional collection", ->
124
+ it "accepts an optional collection instead of making a new one", ->
125
+ timeEntry = buildTimeEntry()
126
+ respondWith server, "/api/time_entries?per_page=20&page=1", data: { results: [{ key: "time_entries", id: timeEntry.id }], time_entries: [timeEntry] }
127
+ collection = new App.Collections.TimeEntries([buildTimeEntry(), buildTimeEntry()])
128
+ collection.setLoaded true
129
+ base.data.loadCollection "time_entries", collection: collection
130
+ expect(collection.lastFetchOptions.collection).toBeFalsy()
131
+ expect(collection.loaded).toBe false
132
+ expect(collection.length).toEqual 2
133
+ server.respond()
134
+ expect(collection.loaded).toBe true
135
+ expect(collection.length).toEqual 3
136
+
137
+ it "can take an optional reset command to reset the collection before using it", ->
138
+ timeEntry = buildTimeEntry()
139
+ respondWith server, "/api/time_entries?per_page=20&page=1", data: { results: [{ key: "time_entries", id: timeEntry.id }], time_entries: [timeEntry] }
140
+ collection = new App.Collections.TimeEntries([buildTimeEntry(), buildTimeEntry()])
141
+ collection.setLoaded true
142
+ spyOn(collection, 'reset').andCallThrough()
143
+ base.data.loadCollection "time_entries", collection: collection, reset: true
144
+ expect(collection.reset).toHaveBeenCalled()
145
+ expect(collection.lastFetchOptions.collection).toBeFalsy()
146
+ expect(collection.loaded).toBe false
147
+ expect(collection.length).toEqual 0
148
+ server.respond()
149
+ expect(collection.loaded).toBe true
150
+ expect(collection.length).toEqual 1
151
+
152
+ it "accepts filters", ->
153
+ posts = [buildPost(project_id: 15, id: 1), buildPost(project_id: 15, id: 2)]
154
+ respondWith server, "/api/posts?filter1=true&filter2=false&filter3=true&filter4=false&filter5=2&filter6=baz&per_page=20&page=1", data: { results: [{ key: "posts", id: 1}], posts: posts }
155
+ collection = base.data.loadCollection "posts", filters: { filter1: true, filter2: false, filter3: "true", filter4: "false", filter5: 2, filter6: "baz" }
156
+ server.respond()
157
+
158
+ it "triggers reset", ->
159
+ timeEntry = buildTimeEntry()
160
+ respondWith server, "/api/time_entries?per_page=20&page=1", data: { results: [{ key: "time_entries", id: timeEntry.id}], time_entries: [timeEntry] }
161
+ collection = base.data.loadCollection "time_entries"
162
+ expect(collection.loaded).toBe false
163
+ spy = jasmine.createSpy().andCallFake ->
164
+ expect(collection.loaded).toBe true
165
+ collection.bind "reset", spy
166
+ server.respond()
167
+ expect(spy).toHaveBeenCalled()
168
+
169
+ it "ignores count and honors results", ->
170
+ server.respondWith "GET", "/api/time_entries?per_page=20&page=1", [ 200, {"Content-Type": "application/json"}, JSON.stringify(count: 2, results: [{ key: "time_entries", id: 2 }], time_entries: [buildTimeEntry(), buildTimeEntry()]) ]
171
+ collection = base.data.loadCollection "time_entries"
172
+ server.respond()
173
+ expect(collection.length).toEqual(1)
174
+
175
+ it "works with an empty response", ->
176
+ exceptionSpy = spyOn(sinon, 'logError').andCallThrough()
177
+ respondWith server, "/api/time_entries?per_page=20&page=1", resultsFrom: "time_entries", data: { time_entries: [] }
178
+ base.data.loadCollection "time_entries"
179
+ server.respond()
180
+ expect(exceptionSpy).not.toHaveBeenCalled()
181
+
182
+ describe "fetching of associations", ->
183
+ json = null
184
+
185
+ beforeEach ->
186
+ tasks = [buildTask(id: 2, title: "a task")]
187
+ projects = [buildProject(id: 15), buildProject(id: 10)]
188
+ timeEntries = [buildTimeEntry(task_id: 2, project_id: 15, id: 1), buildTimeEntry(task_id: null, project_id: 10, id: 2)]
189
+
190
+ respondWith server, /\/api\/time_entries\?include=project%2Ctask&per_page=\d+&page=\d+/, resultsFrom: "time_entries", data: { time_entries: timeEntries, tasks: tasks, projects: projects }
191
+ respondWith server, /\/api\/time_entries\?include=project&per_page=\d+&page=\d+/, resultsFrom: "time_entries", data: { time_entries: timeEntries, projects: projects }
192
+
193
+ it "loads collections that should be included", ->
194
+ collection = base.data.loadCollection "time_entries", include: ["project", "task"]
195
+ spy = jasmine.createSpy().andCallFake ->
196
+ expect(collection.loaded).toBe true
197
+ expect(collection.get(1).get('task').get('title')).toEqual "a task"
198
+ expect(collection.get(2).get('task')).toBeFalsy()
199
+ expect(collection.get(1).get('project').id).toEqual "15"
200
+ expect(collection.get(2).get('project').id).toEqual "10"
201
+ collection.bind "reset", spy
202
+ expect(collection.loaded).toBe false
203
+ server.respond()
204
+ expect(collection.loaded).toBe true
205
+ expect(spy).toHaveBeenCalled()
206
+
207
+ it "applies uses the results array from the server (so that associations of the same type as the primary can be handled- posts with replies; tasks with subtasks, etc.)", ->
208
+ posts = [buildPost(project_id: 15, id: 1, reply_ids: [2]), buildPost(project_id: 15, id: 2, subject_id: 1, reply: true)]
209
+ respondWith server, "/api/posts?include=replies&parents_only=true&per_page=20&page=1", data: { results: [{ key: "posts", id: 1}], posts: posts }
210
+ collection = base.data.loadCollection "posts", include: ["replies"], filters: { parents_only: "true" }
211
+ server.respond()
212
+ expect(collection.pluck("id")).toEqual ["1"]
213
+ expect(collection.get(1).get('replies').pluck("id")).toEqual ["2"]
214
+
215
+ describe "fetching multiple levels of associations", ->
216
+ it "seperately requests each layer of associations", ->
217
+ projectOneTimeEntryTask = buildTask()
218
+ projectOneTimeEntry = buildTimeEntry(title: "without task"); projectOneTimeEntryWithTask = buildTimeEntry(id: projectOneTimeEntry.id, task_id: projectOneTimeEntryTask.id, title: "with task")
219
+ projectOne = buildProject(); projectOneWithTimeEntries = buildProject(id: projectOne.id, time_entry_ids: [projectOneTimeEntry.id])
220
+ projectTwo = buildProject(); projectTwoWithTimeEntries = buildProject(id: projectTwo.id, time_entry_ids: [])
221
+ taskOneAssignee = buildUser()
222
+ taskTwoAssignee = buildUser()
223
+ taskOneSubAssignee = buildUser()
224
+ taskOneSub = buildTask(project_id: projectOne.id, parent_id: 10); taskOneSubWithAssignees = buildTask(id: taskOneSub.id, assignee_ids: [taskOneSubAssignee.id], parent_id: 10)
225
+ taskTwoSub = buildTask(project_id: projectTwo.id, parent_id: 11); taskTwoSubWithAssignees = buildTask(id: taskTwoSub.id, assignee_ids: [taskTwoAssignee.id], parent_id: 11)
226
+ taskOne = buildTask(id: 10, project_id: projectOne.id, assignee_ids: [taskOneAssignee.id], sub_task_ids: [taskOneSub.id])
227
+ taskTwo = buildTask(id: 11, project_id: projectTwo.id, assignee_ids: [taskTwoAssignee.id], sub_task_ids: [taskTwoSub.id])
228
+ respondWith server, "/api/tasks.json?include=assignees%2Cproject%2Csub_tasks&parents_only=true&per_page=20&page=1", data: { results: resultsArray("tasks", [taskOne, taskTwo]), tasks: resultsObject([taskOne, taskTwo, taskOneSub, taskTwoSub]), users: resultsObject([taskOneAssignee, taskTwoAssignee]), projects: resultsObject([projectOne, projectTwo]) }
229
+ respondWith server, "/api/tasks.json?include=assignees&only=#{taskOneSub.id}%2C#{taskTwoSub.id}", data: { results: resultsArray("tasks", [taskOneSub, taskTwoSub]), tasks: resultsObject([taskOneSubWithAssignees, taskTwoSubWithAssignees]), users: resultsObject([taskOneSubAssignee, taskTwoAssignee]) }
230
+ respondWith server, "/api/projects?include=time_entries&only=#{projectOne.id}%2C#{projectTwo.id}", data: { results: resultsArray("projects", [projectOne, projectTwo]), projects: resultsObject([projectOneWithTimeEntries, projectTwoWithTimeEntries]), time_entries: resultsObject([projectOneTimeEntry]) }
231
+ respondWith server, "/api/time_entries?include=task&only=#{projectOneTimeEntry.id}", data: { results: resultsArray("time_entries", [projectOneTimeEntry]), time_entries: resultsObject([projectOneTimeEntryWithTask]), tasks: resultsObject([projectOneTimeEntryTask]) }
232
+
233
+ callCount = 0
234
+ checkStructure = (collection) ->
235
+ expect(collection.pluck("id").sort()).toEqual [taskOne.id, taskTwo.id]
236
+ expect(collection.get(taskOne.id).get("project").id).toEqual projectOne.id
237
+ expect(collection.get(taskOne.id).get("assignees").pluck("id")).toEqual [taskOneAssignee.id]
238
+ expect(collection.get(taskTwo.id).get("assignees").pluck("id")).toEqual [taskTwoAssignee.id]
239
+ expect(collection.get(taskOne.id).get("sub_tasks").pluck("id")).toEqual [taskOneSub.id]
240
+ expect(collection.get(taskTwo.id).get("sub_tasks").pluck("id")).toEqual [taskTwoSub.id]
241
+ expect(collection.get(taskOne.id).get("sub_tasks").get(taskOneSub.id).get("assignees").pluck("id")).toEqual [taskOneSubAssignee.id]
242
+ expect(collection.get(taskTwo.id).get("sub_tasks").get(taskTwoSub.id).get("assignees").pluck("id")).toEqual [taskTwoAssignee.id]
243
+ expect(collection.get(taskOne.id).get("project").get("time_entries").pluck("id")).toEqual [projectOneTimeEntry.id]
244
+ expect(collection.get(taskOne.id).get("project").get("time_entries").models[0].get("task").id).toEqual projectOneTimeEntryTask.id
245
+ callCount += 1
246
+
247
+ success = jasmine.createSpy().andCallFake checkStructure
248
+ collection = base.data.loadCollection "tasks", filters: { parents_only: "true" }, success: success, include: [
249
+ "assignees",
250
+ "project": ["time_entries": "task"],
251
+ "sub_tasks": ["assignees"]
252
+ ]
253
+ collection.bind "loaded", checkStructure
254
+ collection.bind "reset", checkStructure
255
+ expect(success).not.toHaveBeenCalled()
256
+ server.respond()
257
+ expect(success).toHaveBeenCalledWith(collection)
258
+ expect(callCount).toEqual 3
259
+
260
+ describe "caching", ->
261
+ describe "without ordering", ->
262
+ it "doesn't go to the server when it already has the data", ->
263
+ collection1 = base.data.loadCollection "time_entries", include: ["project", "task"], page: 1, perPage: 2
264
+ server.respond()
265
+ expect(collection1.loaded).toBe true
266
+ expect(collection1.get(1).get('project').id).toEqual "15"
267
+ expect(collection1.get(2).get('project').id).toEqual "10"
268
+ spy = jasmine.createSpy()
269
+ collection2 = base.data.loadCollection "time_entries", include: ["project", "task"], page: 1, perPage: 2, success: spy
270
+ expect(spy).toHaveBeenCalled()
271
+ expect(collection2.loaded).toBe true
272
+ expect(collection2.get(1).get('task').get('title')).toEqual "a task"
273
+ expect(collection2.get(2).get('task')).toBeFalsy()
274
+ expect(collection2.get(1).get('project').id).toEqual "15"
275
+ expect(collection2.get(2).get('project').id).toEqual "10"
276
+
277
+ it "does go to the server when more records are requested than it has previously requested, and remembers previously requested pages", ->
278
+ collection1 = base.data.loadCollection "time_entries", include: ["project", "task"], page: 1, perPage: 2
279
+ server.respond()
280
+ expect(collection1.loaded).toBe true
281
+ collection2 = base.data.loadCollection "time_entries", include: ["project", "task"], page: 2, perPage: 2
282
+ expect(collection2.loaded).toBe false
283
+ server.respond()
284
+ expect(collection2.loaded).toBe true
285
+ collection3 = base.data.loadCollection "time_entries", include: ["project"], page: 1, perPage: 2
286
+ expect(collection3.loaded).toBe true
287
+
288
+ it "does go to the server when some associations are missing, when otherwise it would have the data", ->
289
+ collection1 = base.data.loadCollection "time_entries", include: ["project"], page: 1, perPage: 2
290
+ server.respond()
291
+ expect(collection1.loaded).toBe true
292
+ collection2 = base.data.loadCollection "time_entries", include: ["project", "task"], page: 1, perPage: 2
293
+ expect(collection2.loaded).toBe false
294
+
295
+ describe "with ordering and filtering", ->
296
+ now = ws10 = ws11 = te1Ws10 = te2Ws10 = te1Ws11 = te2Ws11 = null
297
+
298
+ beforeEach ->
299
+ now = (new Date()).getTime()
300
+ ws10 = buildProject(id: 10)
301
+ ws11 = buildProject(id: 11)
302
+ te1Ws10 = buildTimeEntry(task_id: null, project_id: 10, id: 1, created_at: now - 20 * 1000, updated_at: now - 10 * 1000)
303
+ te2Ws10 = buildTimeEntry(task_id: null, project_id: 10, id: 2, created_at: now - 10 * 1000, updated_at: now - 5 * 1000)
304
+ te1Ws11 = buildTimeEntry(task_id: null, project_id: 11, id: 3, created_at: now - 100 * 1000, updated_at: now - 4 * 1000)
305
+ te2Ws11 = buildTimeEntry(task_id: null, project_id: 11, id: 4, created_at: now - 200 * 1000, updated_at: now - 12 * 1000)
306
+
307
+ it "goes to the server for pages of data and updates the collection", ->
308
+ respondWith server, "/api/time_entries?order=created_at%3Aasc&per_page=2&page=1", data: { results: resultsArray("time_entries", [te2Ws11, te1Ws11]), time_entries: [te2Ws11, te1Ws11] }
309
+ respondWith server, "/api/time_entries?order=created_at%3Aasc&per_page=2&page=2", data: { results: resultsArray("time_entries", [te1Ws10, te2Ws10]), time_entries: [te1Ws10, te2Ws10] }
310
+ collection = base.data.loadCollection "time_entries", order: "created_at:asc", page: 1, perPage: 2
311
+ server.respond()
312
+ expect(collection.pluck("id")).toEqual [te2Ws11.id, te1Ws11.id]
313
+ base.data.loadCollection "time_entries", collection: collection, order: "created_at:asc", page: 2, perPage: 2
314
+ server.respond()
315
+ expect(collection.pluck("id")).toEqual [te2Ws11.id, te1Ws11.id, te1Ws10.id, te2Ws10.id]
316
+
317
+ it "does not re-sort the results", ->
318
+ respondWith server, "/api/time_entries?order=created_at%3Adesc&per_page=2&page=1", data: { results: resultsArray("time_entries", [te2Ws11, te1Ws11]), time_entries: [te1Ws11, te2Ws11] }
319
+ # it's really created_at:asc
320
+ collection = base.data.loadCollection "time_entries", order: "created_at:desc", page: 1, perPage: 2
321
+ server.respond()
322
+ expect(collection.pluck("id")).toEqual [te2Ws11.id, te1Ws11.id]
323
+
324
+ it "seperately caches data requested by different sort orders and filters", ->
325
+ server.responses = []
326
+ respondWith server, "/api/time_entries?include=project%2Ctask&order=updated_at%3Adesc&project_id=10&per_page=2&page=1",
327
+ data: { results: resultsArray("time_entries", [te2Ws10, te1Ws10]), time_entries: [te2Ws10, te1Ws10], tasks: [], projects: [ws10] }
328
+ respondWith server, "/api/time_entries?include=project%2Ctask&order=updated_at%3Adesc&project_id=11&per_page=2&page=1",
329
+ data: { results: resultsArray("time_entries", [te1Ws11, te2Ws11]), time_entries: [te1Ws11, te2Ws11], tasks: [], projects: [ws11] }
330
+ respondWith server, "/api/time_entries?include=project%2Ctask&order=created_at%3Aasc&project_id=11&per_page=2&page=1",
331
+ data: { results: resultsArray("time_entries", [te2Ws11, te1Ws11]), time_entries: [te2Ws11, te1Ws11], tasks: [], projects: [ws11] }
332
+ respondWith server, "/api/time_entries?include=project%2Ctask&order=created_at%3Aasc&per_page=4&page=1",
333
+ data: { results: resultsArray("time_entries", [te2Ws11, te1Ws11, te1Ws10, te2Ws10]), time_entries: [te2Ws11, te1Ws11, te1Ws10, te2Ws10], tasks: [], projects: [ws10, ws11] }
334
+ respondWith server, "/api/time_entries?include=project%2Ctask&per_page=4&page=1",
335
+ data: { results: resultsArray("time_entries", [te1Ws11, te2Ws10, te1Ws10, te2Ws11]), time_entries: [te1Ws11, te2Ws10, te1Ws10, te2Ws11], tasks: [], projects: [ws10, ws11] }
336
+ # Make a server request
337
+ collection1 = base.data.loadCollection "time_entries", include: ["project", "task"], order: "updated_at:desc", filters: { project_id: 10 }, page: 1, perPage: 2
338
+ expect(collection1.loaded).toBe false
339
+ server.respond()
340
+ expect(collection1.loaded).toBe true
341
+ expect(collection1.pluck("id")).toEqual [te2Ws10.id, te1Ws10.id] # Show that it came back in the explicit order setup above
342
+ # Make another request, this time handled by the cache.
343
+ collection2 = base.data.loadCollection "time_entries", include: ["project", "task"], order: "updated_at:desc", filters: { project_id: 10 }, page: 1, perPage: 2
344
+ expect(collection2.loaded).toBe true
345
+
346
+ # Do it again, this time with a different filter.
347
+ collection3 = base.data.loadCollection "time_entries", include: ["project", "task"], order: "updated_at:desc", filters: { project_id: 11 }, page: 1, perPage: 2
348
+ expect(collection3.loaded).toBe false
349
+ server.respond()
350
+ expect(collection3.loaded).toBe true
351
+ expect(collection3.pluck("id")).toEqual [te1Ws11.id, te2Ws11.id]
352
+ collection4 = base.data.loadCollection "time_entries", include: ["project"], order: "updated_at:desc", filters: { project_id: 11 }, page: 1, perPage: 2
353
+ expect(collection4.loaded).toBe true
354
+ expect(collection4.pluck("id")).toEqual [te1Ws11.id, te2Ws11.id]
355
+
356
+ # Do it again, this time with a different order.
357
+ collection5 = base.data.loadCollection "time_entries", include: ["project", "task"], order: "created_at:asc", filters: { project_id: 11 } , page: 1, perPage: 2
358
+ expect(collection5.loaded).toBe false
359
+ server.respond()
360
+ expect(collection5.loaded).toBe true
361
+ expect(collection5.pluck("id")).toEqual [te2Ws11.id, te1Ws11.id]
362
+ collection6 = base.data.loadCollection "time_entries", include: ["task"], order: "created_at:asc", filters: { project_id: 11 }, page: 1, perPage: 2
363
+ expect(collection6.loaded).toBe true
364
+ expect(collection6.pluck("id")).toEqual [te2Ws11.id, te1Ws11.id]
365
+
366
+ # Do it again, this time without a filter.
367
+ collection7 = base.data.loadCollection "time_entries", include: ["project", "task"], order: "created_at:asc", page: 1, perPage: 4
368
+ expect(collection7.loaded).toBe false
369
+ server.respond()
370
+ expect(collection7.loaded).toBe true
371
+ expect(collection7.pluck("id")).toEqual [te2Ws11.id, te1Ws11.id, te1Ws10.id, te2Ws10.id]
372
+
373
+ # Do it again, this time without an order, so it should use the default (updated_at:desc).
374
+ collection9 = base.data.loadCollection "time_entries", include: ["project", "task"], page: 1, perPage: 4
375
+ expect(collection9.loaded).toBe false
376
+ server.respond()
377
+ expect(collection9.loaded).toBe true
378
+ expect(collection9.pluck("id")).toEqual [te1Ws11.id, te2Ws10.id, te1Ws10.id, te2Ws11.id]
379
+
380
+ describe "handling of only", ->
381
+ describe "when getting data from the server", ->
382
+ it "returns the requested ids with includes, triggering reset and success", ->
383
+ respondWith server, "/api/time_entries?include=project%2Ctask&only=2",
384
+ resultsFrom: "time_entries", data: { time_entries: [buildTimeEntry(task_id: null, project_id: 10, id: 2)], tasks: [], projects: [buildProject(id: 10)] }
385
+ spy2 = jasmine.createSpy().andCallFake (collection) ->
386
+ expect(collection.loaded).toBe true
387
+ collection = base.data.loadCollection "time_entries", include: ["project", "task"], only: 2, success: spy2
388
+ spy = jasmine.createSpy().andCallFake ->
389
+ expect(collection.loaded).toBe true
390
+ expect(collection.get(2).get('task')).toBeFalsy()
391
+ expect(collection.get(2).get('project').id).toEqual "10"
392
+ expect(collection.length).toEqual 1
393
+ collection.bind "reset", spy
394
+ expect(collection.loaded).toBe false
395
+ server.respond()
396
+ expect(collection.loaded).toBe true
397
+ expect(spy).toHaveBeenCalled()
398
+ expect(spy2).toHaveBeenCalled()
399
+
400
+ it "only requests ids that we don't already have", ->
401
+ respondWith server, "/api/time_entries?include=project%2Ctask&only=2",
402
+ resultsFrom: "time_entries", data: { time_entries: [buildTimeEntry(task_id: null, project_id: 10, id: 2)], tasks: [], projects: [buildProject(id: 10)] }
403
+ respondWith server, "/api/time_entries?include=project%2Ctask&only=3",
404
+ resultsFrom: "time_entries", data: { time_entries: [buildTimeEntry(task_id: null, project_id: 11, id: 3)], tasks: [], projects: [buildProject(id: 11)] }
405
+
406
+ collection = base.data.loadCollection "time_entries", include: ["project", "task"], only: 2
407
+ expect(collection.loaded).toBe false
408
+ server.respond()
409
+ expect(collection.loaded).toBe true
410
+ expect(collection.get(2).get('project').id).toEqual "10"
411
+ collection2 = base.data.loadCollection "time_entries", include: ["project", "task"], only: ["2", "3"]
412
+ expect(collection2.loaded).toBe false
413
+ server.respond()
414
+ expect(collection2.loaded).toBe true
415
+ expect(collection2.length).toEqual 2
416
+ expect(collection2.get(2).get('project').id).toEqual "10"
417
+ expect(collection2.get(3).get('project').id).toEqual "11"
418
+
419
+ it "does request ids from the server again when they don't have all associations loaded yet", ->
420
+ respondWith server, "/api/time_entries?include=project&only=2",
421
+ resultsFrom: "time_entries", data: { time_entries: [buildTimeEntry(project_id: 10, id: 2, task_id: 5)], projects: [buildProject(id: 10)] }
422
+ respondWith server, "/api/time_entries?include=project%2Ctask&only=2",
423
+ resultsFrom: "time_entries", data: { time_entries: [buildTimeEntry(project_id: 10, id: 2, task_id: 5)], tasks: [buildTask(id: 5)], projects: [buildProject(id: 10)] }
424
+ respondWith server, "/api/time_entries?include=project%2Ctask&only=3",
425
+ resultsFrom: "time_entries", data: { time_entries: [buildTimeEntry(project_id: 11, id: 3, task_id: null)], tasks: [], projects: [buildProject(id: 11)] }
426
+
427
+ base.data.loadCollection "time_entries", include: ["project"], only: 2
428
+ server.respond()
429
+ base.data.loadCollection "time_entries", include: ["project", "task"], only: 3
430
+ server.respond()
431
+ collection2 = base.data.loadCollection "time_entries", include: ["project", "task"], only: [2, 3]
432
+ expect(collection2.loaded).toBe false
433
+ server.respond()
434
+ expect(collection2.loaded).toBe true
435
+ expect(collection2.get(2).get('task').id).toEqual "5"
436
+ expect(collection2.length).toEqual 2
437
+
438
+ it "doesn't go to the server if it doesn't need to", ->
439
+ respondWith server, "/api/time_entries?include=project%2Ctask&only=2%2C3",
440
+ resultsFrom: "time_entries", data: { time_entries: [buildTimeEntry(project_id: 10, id: 2, task_id: null), buildTimeEntry(project_id: 11, id: 3)], tasks: [], projects: [buildProject(id: 10), buildProject(id: 11)] }
441
+ collection = base.data.loadCollection "time_entries", include: ["project", "task"], only: [2, 3]
442
+ expect(collection.loaded).toBe false
443
+ server.respond()
444
+ expect(collection.loaded).toBe true
445
+ expect(collection.get(2).get('project').id).toEqual "10"
446
+ expect(collection.get(3).get('project').id).toEqual "11"
447
+ expect(collection.length).toEqual 2
448
+ spy = jasmine.createSpy()
449
+ collection2 = base.data.loadCollection "time_entries", include: ["project"], only: [2, 3], success: spy
450
+ expect(spy).toHaveBeenCalled()
451
+ expect(collection2.loaded).toBe true
452
+ expect(collection2.get(2).get('project').id).toEqual "10"
453
+ expect(collection2.get(3).get('project').id).toEqual "11"
454
+ expect(collection2.length).toEqual 2
455
+
456
+ it "returns an empty collection when passed in an empty array", ->
457
+ timeEntries = [buildTimeEntry(task_id: 2, project_id: 15, id: 1), buildTimeEntry(project_id: 10, id: 2)]
458
+ respondWith server, "/api/time_entries?per_page=20&page=1", resultsFrom: "time_entries", data: { time_entries: timeEntries }
459
+ collection = base.data.loadCollection "time_entries", only: []
460
+ expect(collection.loaded).toBe true
461
+ expect(collection.length).toEqual 0
462
+
463
+ collection = base.data.loadCollection "time_entries", only: null
464
+ server.respond()
465
+ expect(collection.loaded).toBe true
466
+ expect(collection.length).toEqual 2
467
+
468
+ it "accepts a success function that gets triggered on cache hit", ->
469
+ respondWith server, "/api/time_entries?include=project%2Ctask&only=2%2C3",
470
+ resultsFrom: "time_entries", data: { time_entries: [buildTimeEntry(project_id: 10, id: 2, task_id: null), buildTimeEntry(project_id: 11, id: 3, task_id: null)], tasks: [], projects: [buildProject(id: 10), buildProject(id: 11)] }
471
+ base.data.loadCollection "time_entries", include: ["project", "task"], only: [2, 3]
472
+ server.respond()
473
+ spy = jasmine.createSpy().andCallFake (collection) ->
474
+ expect(collection.loaded).toBe true
475
+ expect(collection.get(2).get('project').id).toEqual "10"
476
+ expect(collection.get(3).get('project').id).toEqual "11"
477
+ collection2 = base.data.loadCollection "time_entries", include: ["project"], only: [2, 3], success: spy
478
+ expect(spy).toHaveBeenCalled()
479
+
480
+ it "does not cache only queries", ->
481
+ respondWith server, "/api/time_entries?include=project%2Ctask&only=2%2C3",
482
+ resultsFrom: "time_entries", data: { time_entries: [buildTimeEntry(project_id: 10, id: 2, task_id: null), buildTimeEntry(project_id: 11, id: 3, task_id: null)], tasks: [], projects: [buildProject(id: 10), buildProject(id: 11)] }
483
+ collection = base.data.loadCollection "time_entries", include: ["project", "task"], only: [2, 3]
484
+ expect(Object.keys base.data.getCollectionDetails("time_entries")["cache"]).toEqual []
485
+ server.respond()
486
+ expect(Object.keys base.data.getCollectionDetails("time_entries")["cache"]).toEqual []
487
+
488
+ it "does go to the server on a repeat request if an association is missing", ->
489
+ respondWith server, "/api/time_entries?include=project&only=2",
490
+ resultsFrom: "time_entries", data: { time_entries: [buildTimeEntry(project_id: 10, id: 2, task_id: 6)], projects: [buildProject(id: 10)] }
491
+ respondWith server, "/api/time_entries?include=project%2Ctask&only=2",
492
+ resultsFrom: "time_entries", data: { time_entries: [buildTimeEntry(project_id: 10, id: 2, task_id: 6)], tasks: [buildTask(id: 6)], projects: [buildProject(id: 10)] }
493
+ collection = base.data.loadCollection "time_entries", include: ["project"], only: 2
494
+ expect(collection.loaded).toBe false
495
+ server.respond()
496
+ expect(collection.loaded).toBe true
497
+ collection2 = base.data.loadCollection "time_entries", include: ["project", "task"], only: 2
498
+ expect(collection2.loaded).toBe false
499
+
500
+ describe "disabling caching", ->
501
+ item = null
502
+
503
+ beforeEach ->
504
+ item = buildTask()
505
+ respondWith server, "/api/tasks.json?per_page=20&page=1", resultsFrom: "tasks", data: { tasks: [item] }
506
+
507
+ it "goes to server even if we have matching items in cache", ->
508
+ syncSpy = spyOn(Backbone, 'sync')
509
+ collection = base.data.loadCollection "tasks", cache: false, only: item.id
510
+ expect(syncSpy).toHaveBeenCalled()
511
+
512
+ it "still adds results to the cache", ->
513
+ spy = spyOn(base.data.storage('tasks'), 'update')
514
+ collection = base.data.loadCollection "tasks", cache: false
515
+ server.respond()
516
+ expect(spy).toHaveBeenCalled()
517
+
518
+ describe "searching", ->
519
+ it 'turns off caching', ->
520
+ spy = spyOn(base.data, '_loadCollectionWithFirstLayer')
521
+ collection = base.data.loadCollection "tasks", search: "the meaning of life"
522
+ expect(spy.mostRecentCall.args[0]['cache']).toBe(false)
523
+
524
+ it "returns the matching items with includes, triggering reset and success", ->
525
+ task = buildTask()
526
+ respondWith server, "/api/tasks.json?per_page=20&page=1&search=go+go+gadget+search",
527
+ data: { results: [{key: "tasks", id: task.id}], tasks: [task] }
528
+ spy2 = jasmine.createSpy().andCallFake (collection) ->
529
+ expect(collection.loaded).toBe true
530
+ collection = base.data.loadCollection "tasks", search: "go go gadget search", success: spy2
531
+ spy = jasmine.createSpy().andCallFake ->
532
+ expect(collection.loaded).toBe true
533
+ collection.bind "reset", spy
534
+ expect(collection.loaded).toBe false
535
+ server.respond()
536
+ expect(collection.loaded).toBe true
537
+ expect(spy).toHaveBeenCalled()
538
+ expect(spy2).toHaveBeenCalled()
539
+
540
+ it 'does not blow up when no results are returned', ->
541
+ respondWith server, "/api/tasks.json?per_page=20&page=1&search=go+go+gadget+search", data: { results: [], tasks: [] }
542
+ collection = base.data.loadCollection "tasks", search: "go go gadget search"
543
+ server.respond()
544
+
545
+ it 'acts as if no search options were passed if the search string is blank', ->
546
+ respondWith server, "/api/tasks.json?per_page=20&page=1", data: { results: [], tasks: [] }
547
+ collection = base.data.loadCollection "tasks", search: ""
548
+ server.respond()
549
+
550
+ describe "createNewCollection", ->
551
+ it "makes a new collection of the appropriate type", ->
552
+ expect(base.data.createNewCollection("tasks", [buildTask(), buildTask()]) instanceof App.Collections.Tasks).toBe true
553
+
554
+ it "can accept a 'loaded' flag", ->
555
+ collection = base.data.createNewCollection("tasks", [buildTask(), buildTask()])
556
+ expect(collection.loaded).toBe false
557
+ collection = base.data.createNewCollection("tasks", [buildTask(), buildTask()], loaded: true)
558
+ expect(collection.loaded).toBe true
559
+
560
+ describe "_wrapObjects", ->
561
+ it "wraps elements in an array with objects unless they are already objects", ->
562
+ expect(base.data._wrapObjects([])).toEqual []
563
+ expect(base.data._wrapObjects(['a', 'b'])).toEqual [{a: []}, {b: []}]
564
+ expect(base.data._wrapObjects(['a', 'b': []])).toEqual [{a: []}, {b: []}]
565
+ expect(base.data._wrapObjects(['a', 'b': 'c'])).toEqual [{a: []}, {b: [{c: []}]}]
566
+ expect(base.data._wrapObjects([{'a':[], b: 'c', d: 'e' }])).toEqual [{a: []}, {b: [{c: []}]}, {d: [{e: []}]}]
567
+ expect(base.data._wrapObjects(['a', { b: 'c', d: 'e' }])).toEqual [{a: []}, {b: [{c: []}]}, {d: [{e: []}]}]
568
+ expect(base.data._wrapObjects([{'a': []}, {'b': ['c', d: []]}])).toEqual [{a: []}, {b: [{c: []}, {d: []}]}]
569
+
570
+ describe "_countRequiredServerRequests", ->
571
+ it "should count the number of loads needed to get the date", ->
572
+ expect(base.data._countRequiredServerRequests(['a'])).toEqual 1
573
+ expect(base.data._countRequiredServerRequests(['a', 'b', 'c': []])).toEqual 1
574
+ expect(base.data._countRequiredServerRequests([{'a': ['d']}, 'b', 'c': ['e']])).toEqual 3
575
+ expect(base.data._countRequiredServerRequests([{'a': ['d']}, 'b', 'c': ['e': []]])).toEqual 3
576
+ expect(base.data._countRequiredServerRequests([{'a': ['d']}, 'b', 'c': ['e': ['f']]])).toEqual 4
577
+ expect(base.data._countRequiredServerRequests([{'a': ['d']}, 'b', 'c': ['e': ['f', 'g': ['h']]]])).toEqual 5
578
+ expect(base.data._countRequiredServerRequests([{'a': ['d': ['h']]}, { 'b':['g'] }, 'c': ['e': ['f', 'i']]])).toEqual 6
579
+
580
+ describe "error handling", ->
581
+ describe "setting a storage manager default error handler", ->
582
+ it "allows an error interceptor to be set on construction", ->
583
+ interceptor = (handler, modelOrCollection, options, jqXHR, requestParams) -> 5
584
+ manager = new Brainstem.StorageManager(errorInterceptor: interceptor)
585
+ expect(manager.errorInterceptor).toEqual interceptor
586
+
587
+ it "allows an error interceptor to be set later", ->
588
+ spy = jasmine.createSpy()
589
+ base.data.setErrorInterceptor (handler, modelOrCollection, options, jqXHR) -> spy(modelOrCollection, jqXHR)
590
+ server.respondWith "GET", "/api/time_entries?per_page=20&page=1", [ 401, {"Content-Type": "application/json"}, JSON.stringify({ errors: ["Invalid OAuth 2 Request"]}) ]
591
+ base.data.loadCollection 'time_entries'
592
+ server.respond()
593
+ expect(spy).toHaveBeenCalled()
594
+
595
+ describe "passing in a custom error handler when loading a collection", ->
596
+ it "gets called when there is an error", ->
597
+ customHandler = jasmine.createSpy('customHandler')
598
+ server.respondWith "GET", "/api/time_entries?per_page=20&page=1", [ 401, {"Content-Type": "application/json"}, JSON.stringify({ errors: ["Invalid OAuth 2 Request"]}) ]
599
+ base.data.loadCollection('time_entries', error: customHandler)
600
+ server.respond()
601
+ expect(customHandler).toHaveBeenCalled()
602
+
603
+ describe "when no storage manager error interceptor is given", ->
604
+ it "has a default error interceptor", ->
605
+ manager = new Brainstem.StorageManager()
606
+ expect(manager.errorInterceptor).not.toBeUndefined()
607
+
608
+ it "does nothing on unhandled errors", ->
609
+ spyOn(sinon, 'logError').andCallThrough()
610
+ server.respondWith "GET", "/api/time_entries?per_page=20&page=1", [ 401, {"Content-Type": "application/json"}, JSON.stringify({ errors: ["Invalid OAuth 2 Request"]}) ]
611
+ base.data.loadCollection 'time_entries'
612
+ server.respond()
613
+ expect(sinon.logError).not.toHaveBeenCalled()
Binary file