diff options
Diffstat (limited to 'HutchTests')
| -rw-r--r-- | HutchTests/InboxViewModelTests.swift | 104 | ||||
| -rw-r--r-- | HutchTests/MailingListActivityTests.swift | 77 |
2 files changed, 181 insertions, 0 deletions
diff --git a/HutchTests/InboxViewModelTests.swift b/HutchTests/InboxViewModelTests.swift index e67d317..79cf54c 100644 --- a/HutchTests/InboxViewModelTests.swift +++ b/HutchTests/InboxViewModelTests.swift @@ -42,6 +42,110 @@ struct InboxViewModelTests { } @Test + func freshAccountTreatsExistingMailAsRead() { + let suiteName = "InboxViewModelTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + + let signIn = Date(timeIntervalSince1970: 5_000) + InboxReadStateStore.establishBaselineIfNeeded(now: signIn, defaults: defaults) + + // Years of list history should not land on a new user as unread. + #expect( + !InboxReadStateStore.isUnread( + threadID: "list#old", + lastActivityAt: Date(timeIntervalSince1970: 4_000), + defaults: defaults + ) + ) + } + + @Test + func mailArrivingAfterSignInIsUnread() { + let suiteName = "InboxViewModelTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + + InboxReadStateStore.establishBaselineIfNeeded(now: Date(timeIntervalSince1970: 5_000), defaults: defaults) + + #expect( + InboxReadStateStore.isUnread( + threadID: "list#new", + lastActivityAt: Date(timeIntervalSince1970: 6_000), + defaults: defaults + ) + ) + } + + @Test + func mailExactlyAtTheBaselineIsRead() { + let suiteName = "InboxViewModelTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + + let signIn = Date(timeIntervalSince1970: 5_000) + InboxReadStateStore.establishBaselineIfNeeded(now: signIn, defaults: defaults) + + #expect(!InboxReadStateStore.isUnread(threadID: "list#edge", lastActivityAt: signIn, defaults: defaults)) + } + + @Test + func baselineIsEstablishedOnceAndNotMovedBySubsequentSignIns() { + let suiteName = "InboxViewModelTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + + InboxReadStateStore.establishBaselineIfNeeded(now: Date(timeIntervalSince1970: 5_000), defaults: defaults) + // A later launch must not silently mark the backlog read. + InboxReadStateStore.establishBaselineIfNeeded(now: Date(timeIntervalSince1970: 9_000), defaults: defaults) + + #expect( + InboxReadStateStore.isUnread( + threadID: "list#since", + lastActivityAt: Date(timeIntervalSince1970: 6_000), + defaults: defaults + ) + ) + } + + @Test + func existingAccountsKeepTheirUnreadBacklog() { + let suiteName = "InboxViewModelTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + + // An account already carrying read state has been in use, so upgrading + // must not retroactively mark everything it had not read as read. + InboxReadStateStore.markViewed(Date(timeIntervalSince1970: 1_000), for: "list#seen", defaults: defaults) + InboxReadStateStore.establishBaselineIfNeeded(now: Date(timeIntervalSince1970: 5_000), defaults: defaults) + + #expect( + InboxReadStateStore.isUnread( + threadID: "list#unseen", + lastActivityAt: Date(timeIntervalSince1970: 4_000), + defaults: defaults + ) + ) + } + + @Test + func markingAnOldThreadUnreadSurvivesTheBaseline() { + let suiteName = "InboxViewModelTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + + InboxReadStateStore.establishBaselineIfNeeded(now: Date(timeIntervalSince1970: 5_000), defaults: defaults) + let oldActivity = Date(timeIntervalSince1970: 4_000) + + #expect(!InboxReadStateStore.isUnread(threadID: "list#old", lastActivityAt: oldActivity, defaults: defaults)) + + // Explicitly marking it unread must stick, rather than falling back to the + // baseline rule and reading as read again. + InboxReadStateStore.markUnread(for: "list#old", defaults: defaults) + #expect(InboxReadStateStore.isUnread(threadID: "list#old", lastActivityAt: oldActivity, defaults: defaults)) + } + + @Test func normalizesThreadSubjectsForDisplay() { let summary = InboxThreadSummary( rootEmailID: 1, diff --git a/HutchTests/MailingListActivityTests.swift b/HutchTests/MailingListActivityTests.swift new file mode 100644 index 0000000..46208cb --- /dev/null +++ b/HutchTests/MailingListActivityTests.swift @@ -0,0 +1,77 @@ +import Foundation +import Testing +@testable import Hutch + +struct MailingListActivityTests { + + @Test + func usesTheNewestArrivalOverTheRootTimestamp() { + // The case that started this: sr.ht reports thread.updated seven seconds + // after the root email on a thread carrying four replies, so the fallback + // must lose to real arrival data. + let rootInsert = Date(timeIntervalSince1970: 1_000) + let newestReply = Date(timeIntervalSince1970: 5_000) + let activity = MailingListActivity(newestByRootEmailID: [42: newestReply]) + + #expect(activity.lastActivity(rootEmailID: 42, fallback: rootInsert) == newestReply) + } + + @Test + func fallsBackForThreadsOutsideTheScannedWindow() { + // Threads with nothing new are absent from the feed scan; they keep the + // root timestamp, which is older than any cutoff and so reads as read. + let rootInsert = Date(timeIntervalSince1970: 1_000) + let activity = MailingListActivity(newestByRootEmailID: [:]) + + #expect(activity.lastActivity(rootEmailID: 42, fallback: rootInsert) == rootInsert) + } + + @Test + func neverGoesBackwardsFromTheFallback() { + // A root inserted after the newest scanned reply must not age the thread + // backwards. + let rootInsert = Date(timeIntervalSince1970: 9_000) + let staleReply = Date(timeIntervalSince1970: 5_000) + let activity = MailingListActivity(newestByRootEmailID: [42: staleReply]) + + #expect(activity.lastActivity(rootEmailID: 42, fallback: rootInsert) == rootInsert) + } + + @Test + func tracksThreadsIndependently() { + let activity = MailingListActivity(newestByRootEmailID: [ + 1: Date(timeIntervalSince1970: 5_000), + 2: Date(timeIntervalSince1970: 7_000) + ]) + let fallback = Date(timeIntervalSince1970: 1_000) + + #expect(activity.lastActivity(rootEmailID: 1, fallback: fallback) == Date(timeIntervalSince1970: 5_000)) + #expect(activity.lastActivity(rootEmailID: 2, fallback: fallback) == Date(timeIntervalSince1970: 7_000)) + #expect(activity.lastActivity(rootEmailID: 3, fallback: fallback) == fallback) + } + + @Test + func newMailInAnOldThreadReadsAsUnread() { + let suiteName = "MailingListActivityTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + + let signIn = Date(timeIntervalSince1970: 5_000) + InboxReadStateStore.establishBaselineIfNeeded(now: signIn, defaults: defaults) + + // A thread rooted long before sign-in, with a reply after it. Keyed on + // thread.updated this reads as read, which was the bug. + let rootInsert = Date(timeIntervalSince1970: 1_000) + let replyAfterSignIn = Date(timeIntervalSince1970: 6_000) + let activity = MailingListActivity(newestByRootEmailID: [42: replyAfterSignIn]) + let lastActivityAt = activity.lastActivity(rootEmailID: 42, fallback: rootInsert) + + #expect( + InboxReadStateStore.isUnread( + threadID: "list#old thread", + lastActivityAt: lastActivityAt, + defaults: defaults + ) + ) + } +} |
