summaryrefslogtreecommitdiff
path: root/HutchTests
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-07-15 23:27:03 -0500
committerGitHub <[email protected]>2026-07-15 23:27:03 -0500
commit0ad430c79eedac4140680e3aaca238969ac711b1 (patch)
treee4928ae266acb2282a12e8212b82635890f50089 /HutchTests
parent922502a2c74c66034f3ec2db6612f2a36d242042 (diff)
parent97e8b49fcb56751fa0e80a332484db8252753417 (diff)
downloadhutch-0ad430c79eedac4140680e3aaca238969ac711b1.tar.gz
hutch-0ad430c79eedac4140680e3aaca238969ac711b1.tar.bz2
hutch-0ad430c79eedac4140680e3aaca238969ac711b1.zip
Merge pull request #5 from zerolabsco/inbox-unread-baseline
Start a new account at zero unread
Diffstat (limited to 'HutchTests')
-rw-r--r--HutchTests/InboxViewModelTests.swift104
-rw-r--r--HutchTests/MailingListActivityTests.swift77
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
+ )
+ )
+ }
+}