Mailspring/spec/stores/thread-counts-store-spec.coffee

247 lines
9.7 KiB
CoffeeScript
Raw Normal View History

feat(counts): Unread counts for all folders and labels across all accounts Summary: This diff replaces the UnreadCountStore with a better approach that is able to track unread counts for all folders/labels without continuous (and cripplingly slow) SELECT COUNT(*) queries. When models are written to the database, we currently don't send out notifications with the "previous" state of those objects in the database. This makes it hard to determine how to update counters. (In the future, we may need to do this for live queries). Unfortunately, getting the "previous" state is going to be very hard, because multiple windows write to the database and the "previous" state we have might be outdated. We'd almost have to run a "SELECT" right before every "REPLACE INTO". I created an API that allows you to register observers around persistModel and unpersistModel. With this API, you can run queries before and after the database changes are made and pluck just the "before" state you're interested in. The `ThreadCountsStore` uses this API to determine the impact of persisting a set of threads on the unread counts of different labels. Before the threads are saved, it says "how much do these thread IDs contribute to unread counts currently?". After the write is complete it looks at the models and computes the difference between the old count impact and the new count impact, and updates the counters. I decided not to attach the unread count to the Label objects themselves because 1) they update frequently and 2) most things observing the DatabaseStore for categories do not care about counts, so they would be updating unnecessarily. The AccountSidebar now listens to the ThreadCountsStore as well as the CategoryStore, and there's a new preference in the General tab for turning off the counts. Test Plan: Tests are a work in progress, want to get feedback first! Reviewers: juan, evan Reviewed By: evan Differential Revision: https://phab.nylas.com/D2232
2015-11-24 09:12:22 +08:00
_ = require 'underscore'
DatabaseStore = require '../../src/flux/stores/database-store'
ThreadCountsStore = require '../../src/flux/stores/thread-counts-store'
Thread = require '../../src/flux/models/thread'
Folder = require '../../src/flux/models/folder'
Label = require '../../src/flux/models/label'
Matcher = require '../../src/flux/attributes/matcher'
describe "ThreadCountsStore", ->
describe "unreadCountForCategoryId", ->
it "returns null if no count exists for the category id", ->
expect(ThreadCountsStore.unreadCountForCategoryId('nan')).toBe(null)
it "returns the count plus any unsaved deltas", ->
ThreadCountsStore._counts =
'b': 3
'a': 5
ThreadCountsStore._deltas =
'a': -1
expect(ThreadCountsStore.unreadCountForCategoryId('a')).toBe(4)
expect(ThreadCountsStore.unreadCountForCategoryId('b')).toBe(3)
describe "when the mutation observer reports count changes", ->
it "should merge count deltas into existing count detlas", ->
ThreadCountsStore._deltas =
'l1': -1
'l2': 2
ThreadCountsStore._onCountsChanged({'l1': -1, 'l2': 1, 'l3': 2})
expect(ThreadCountsStore._deltas).toEqual({
'l1': -2,
'l2': 3,
'l3': 2
})
it "should queue a save of the counts", ->
spyOn(ThreadCountsStore, '_saveCountsSoon')
ThreadCountsStore._onCountsChanged({'l1': -1, 'l2': 1, 'l3': 2})
expect(ThreadCountsStore._saveCountsSoon).toHaveBeenCalled()
describe "when a folder or label is persisted", ->
beforeEach ->
@lExisting = new Label(id: "l1", name: "inbox", displayName: "Inbox")
ThreadCountsStore._categories = [@lExisting]
@lCreated = new Label(id: "lNew", displayName: "Hi there!")
@lUpdated = @lExisting.clone()
@lUpdated.displayName = "Inbox Edited"
spyOn(ThreadCountsStore, '_fetchCountsMissing')
describe "in the work window", ->
beforeEach ->
spyOn(NylasEnv, 'isWorkWindow').andReturn(true)
it "should add or update it in it's local categories cache", ->
ThreadCountsStore._onDatabaseChanged({objectClass: Label.name, objects: [@lCreated]})
expect(ThreadCountsStore._categories).toEqual([@lExisting, @lCreated])
ThreadCountsStore._onDatabaseChanged({objectClass: Label.name, objects: [@lUpdated]})
expect(ThreadCountsStore._categories).toEqual([@lUpdated, @lCreated])
ThreadCountsStore._categories = []
ThreadCountsStore._onDatabaseChanged({objectClass: Label.name, objects: [@lCreated, @lUpdated]})
expect(ThreadCountsStore._categories).toEqual([@lCreated, @lUpdated])
it "should run _fetchCountsMissing", ->
ThreadCountsStore._onDatabaseChanged({objectClass: Label.name, objects: [@lUpdated]})
expect(ThreadCountsStore._fetchCountsMissing).toHaveBeenCalled()
describe "in other windows", ->
beforeEach ->
spyOn(NylasEnv, 'isWorkWindow').andReturn(false)
it "should do nothing", ->
ThreadCountsStore._onDatabaseChanged({objectClass: Label.name, objects: [@lCreated]})
expect(ThreadCountsStore._categories).toEqual([@lExisting])
expect(ThreadCountsStore._fetchCountsMissing).not.toHaveBeenCalled()
describe "when counts are persisted", ->
it "should update it's _counts cache and trigger", ->
newCounts = {
'abc': 1
}
spyOn(ThreadCountsStore, 'trigger')
ThreadCountsStore._onDatabaseChanged({objectClass: 'JSONObject', objects: [{key: 'UnreadCounts', json: newCounts}]})
expect(ThreadCountsStore._counts).toEqual(newCounts)
expect(ThreadCountsStore.trigger).toHaveBeenCalled()
describe "_fetchCountsMissing", ->
beforeEach ->
ThreadCountsStore._categories = [
new Label(id: "l1", name: "inbox", displayName: "Inbox", accountId: 'a1'),
new Label(id: "l2", name: "archive", displayName: "Archive", accountId: 'a1'),
new Label(id: "l3", displayName: "Happy Days", accountId: 'a1'),
new Label(id: "l4", displayName: "Sad Days", accountId: 'a1')
]
ThreadCountsStore._counts =
l1: 10
l2: 0
it "should call _fetchCountForCategory for the first category not already in the counts cache", ->
spyOn(ThreadCountsStore, '_fetchCountForCategory').andCallFake ->
new Promise (resolve, reject) ->
ThreadCountsStore._fetchCountsMissing()
calls = ThreadCountsStore._fetchCountForCategory.calls
expect(calls.length).toBe(1)
expect(calls[0].args[0]).toBe(ThreadCountsStore._categories[2])
describe "when the count promsie finishes", ->
beforeEach ->
@countResolve = null
@countReject = null
spyOn(ThreadCountsStore, '_fetchCountForCategory').andCallFake =>
new Promise (resolve, reject) =>
@countResolve = resolve
@countReject = reject
it "should add it to the count cache", ->
ThreadCountsStore._fetchCountsMissing()
advanceClock()
@countResolve(4)
advanceClock()
expect(ThreadCountsStore._counts).toEqual({
l1: 10
l2: 0
l3: 4
})
it "should call _fetchCountsMissing again to populate the next missing count", ->
ThreadCountsStore._fetchCountsMissing()
spyOn(ThreadCountsStore, '_fetchCountsMissing')
advanceClock()
@countResolve(4)
advanceClock()
expect(ThreadCountsStore._fetchCountsMissing).toHaveBeenCalled()
describe "when a count fails", ->
it "should not immediately try to count any other categories", ->
ThreadCountsStore._fetchCountsMissing()
spyOn(ThreadCountsStore, '_fetchCountsMissing')
spyOn(console, 'error')
advanceClock()
@countReject(new Error("Oh man something really bad."))
advanceClock()
expect(ThreadCountsStore._fetchCountsMissing).not.toHaveBeenCalled()
describe "_fetchCountForCategory", ->
it "should make the appropriate label or folder database query", ->
spyOn(DatabaseStore, 'count')
Matcher.muid = 0
ThreadCountsStore._fetchCountForCategory(new Label(id: 'l1', accountId: 'a1'))
Matcher.muid = 0
expect(DatabaseStore.count).toHaveBeenCalledWith(Thread, [
Thread.attributes.accountId.equal('a1'),
Thread.attributes.unread.equal(true),
Thread.attributes.labels.contains('l1')
])
Matcher.muid = 0
ThreadCountsStore._fetchCountForCategory(new Folder(id: 'l1', accountId: 'a1'))
Matcher.muid = 0
expect(DatabaseStore.count).toHaveBeenCalledWith(Thread, [
Thread.attributes.accountId.equal('a1'),
Thread.attributes.unread.equal(true),
Thread.attributes.folders.contains('l1')
])
describe "_saveCounts", ->
beforeEach ->
ThreadCountsStore._counts =
'b': 3
'a': 5
ThreadCountsStore._deltas =
'a': -1
'c': 2
it "should merge the deltas into the counts and reset the deltas, ignoring any deltas for which the initial count has not been run", ->
ThreadCountsStore._saveCounts()
expect(ThreadCountsStore._counts).toEqual({
'b': 3
'a': 4
})
it "should persist the new counts to the database", ->
spyOn(DatabaseStore, 'persistJSONObject')
ThreadCountsStore._saveCounts()
expect(DatabaseStore.persistJSONObject).toHaveBeenCalledWith('UnreadCounts', ThreadCountsStore._counts)
describe "CategoryDatabaseMutationObserver", ->
beforeEach ->
@label1 = new Label(id: "l1", name: "inbox", displayName: "Inbox")
@label2 = new Label(id: "l2", name: "archive", displayName: "Archive")
@label3 = new Label(id: "l3", displayName: "Happy Days")
@label4 = new Label(id: "l4", displayName: "Sad Days")
@threadA = new Thread
id: "A"
unread: true
labels: [@label1, @label4]
@threadB = new Thread
id: "B"
unread: true
labels: [@label3]
@threadC = new Thread
id: "C"
unread: false
labels: [@label1, @label3]
describe "given a set of modifying models", ->
it "should call countsDidChange with the folder / label membership deltas", ->
queryResolves = []
query = jasmine.createSpy('query').andCallFake =>
new Promise (resolve, reject) ->
queryResolves.push(resolve)
countsDidChange = jasmine.createSpy('countsDidChange')
m = new ThreadCountsStore.CategoryDatabaseMutationObserver(countsDidChange)
beforePromise = m.beforeDatabaseChange(query, [@threadA, @threadB, @threadC], [@threadA.id, @threadB.id, @threadC.id])
expect(query.callCount).toBe(2)
expect(query.calls[0].args[0]).toEqual("SELECT `Thread`.id as id, `Thread-Label`.`value` as catId FROM `Thread` INNER JOIN `Thread-Label` ON `Thread`.`id` = `Thread-Label`.`id` WHERE `Thread`.id IN ('A','B','C') AND `Thread`.unread = 1")
expect(query.calls[1].args[0]).toEqual("SELECT `Thread`.id as id, `Thread-Folder`.`value` as catId FROM `Thread` INNER JOIN `Thread-Folder` ON `Thread`.`id` = `Thread-Folder`.`id` WHERE `Thread`.id IN ('A','B','C') AND `Thread`.unread = 1")
queryResolves[0]([
{id: @threadA.id, catId: @label1.id},
{id: @threadA.id, catId: @label3.id},
{id: @threadB.id, catId: @label2.id},
{id: @threadB.id, catId: @label3.id},
])
queryResolves[1]([])
waitsForPromise =>
beforePromise.then (result) =>
expect(result).toEqual({
categories: {
l1: -1,
l3: -2,
l2: -1
}
})
m.afterDatabaseChange(query, [@threadA, @threadB, @threadC], [@threadA.id, @threadB.id, @threadC.id], result)
expect(countsDidChange).toHaveBeenCalledWith({
l3: -1,
l2: -1,
l4: 1
})