krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v3.9.0: HutchTests/InboxViewModelTests.swift · raw

  1import Foundation
  2import Testing
  3@testable import Hutch
  4
  5struct InboxViewModelTests {
  6
  7    @Test
  8    func derivesRepositoryNameFromCommonPatchListSuffixes() {
  9        #expect(InboxThreadUtilities.deriveRepositoryName(from: "hut-devel") == "hut")
 10        #expect(InboxThreadUtilities.deriveRepositoryName(from: "git.patches") == "git")
 11        #expect(InboxThreadUtilities.deriveRepositoryName(from: "discuss") == nil)
 12    }
 13
 14    @Test
 15    func parsesMailtoDraft() {
 16        let draft = ThreadViewModel.mailComposeDraft(
 17            from: "mailto:list@example.com?cc=author@example.com&subject=Re:%20PATCH&body=LGTM"
 18        )
 19
 20        #expect(draft?.recipients == ["list@example.com"])
 21        #expect(draft?.ccRecipients == ["author@example.com"])
 22        #expect(draft?.subject == "Re: PATCH")
 23        #expect(draft?.body == "LGTM")
 24    }
 25
 26    @Test
 27    func computesLocalUnreadStateFromLastViewedMarker() {
 28        let suiteName = "InboxViewModelTests-\(UUID().uuidString)"
 29        let defaults = UserDefaults(suiteName: suiteName)!
 30        defer { defaults.removePersistentDomain(forName: suiteName) }
 31
 32        let threadID = "list#message"
 33        let lastActivity = Date(timeIntervalSince1970: 2_000)
 34
 35        #expect(InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: lastActivity, defaults: defaults))
 36
 37        InboxReadStateStore.markViewed(Date(timeIntervalSince1970: 1_000), for: threadID, defaults: defaults)
 38        #expect(InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: lastActivity, defaults: defaults))
 39
 40        InboxReadStateStore.markViewed(Date(timeIntervalSince1970: 2_500), for: threadID, defaults: defaults)
 41        #expect(!InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: lastActivity, defaults: defaults))
 42    }
 43
 44    @Test
 45    func freshAccountTreatsExistingMailAsRead() {
 46        let suiteName = "InboxViewModelTests-\(UUID().uuidString)"
 47        let defaults = UserDefaults(suiteName: suiteName)!
 48        defer { defaults.removePersistentDomain(forName: suiteName) }
 49
 50        let signIn = Date(timeIntervalSince1970: 5_000)
 51        InboxReadStateStore.establishBaselineIfNeeded(now: signIn, defaults: defaults)
 52
 53        // Years of list history should not land on a new user as unread.
 54        #expect(
 55            !InboxReadStateStore.isUnread(
 56                threadID: "list#old",
 57                lastActivityAt: Date(timeIntervalSince1970: 4_000),
 58                defaults: defaults
 59            )
 60        )
 61    }
 62
 63    @Test
 64    func mailArrivingAfterSignInIsUnread() {
 65        let suiteName = "InboxViewModelTests-\(UUID().uuidString)"
 66        let defaults = UserDefaults(suiteName: suiteName)!
 67        defer { defaults.removePersistentDomain(forName: suiteName) }
 68
 69        InboxReadStateStore.establishBaselineIfNeeded(now: Date(timeIntervalSince1970: 5_000), defaults: defaults)
 70
 71        #expect(
 72            InboxReadStateStore.isUnread(
 73                threadID: "list#new",
 74                lastActivityAt: Date(timeIntervalSince1970: 6_000),
 75                defaults: defaults
 76            )
 77        )
 78    }
 79
 80    @Test
 81    func mailExactlyAtTheBaselineIsRead() {
 82        let suiteName = "InboxViewModelTests-\(UUID().uuidString)"
 83        let defaults = UserDefaults(suiteName: suiteName)!
 84        defer { defaults.removePersistentDomain(forName: suiteName) }
 85
 86        let signIn = Date(timeIntervalSince1970: 5_000)
 87        InboxReadStateStore.establishBaselineIfNeeded(now: signIn, defaults: defaults)
 88
 89        #expect(!InboxReadStateStore.isUnread(threadID: "list#edge", lastActivityAt: signIn, defaults: defaults))
 90    }
 91
 92    @Test
 93    func baselineIsEstablishedOnceAndNotMovedBySubsequentSignIns() {
 94        let suiteName = "InboxViewModelTests-\(UUID().uuidString)"
 95        let defaults = UserDefaults(suiteName: suiteName)!
 96        defer { defaults.removePersistentDomain(forName: suiteName) }
 97
 98        InboxReadStateStore.establishBaselineIfNeeded(now: Date(timeIntervalSince1970: 5_000), defaults: defaults)
 99        // A later launch must not silently mark the backlog read.
100        InboxReadStateStore.establishBaselineIfNeeded(now: Date(timeIntervalSince1970: 9_000), defaults: defaults)
101
102        #expect(
103            InboxReadStateStore.isUnread(
104                threadID: "list#since",
105                lastActivityAt: Date(timeIntervalSince1970: 6_000),
106                defaults: defaults
107            )
108        )
109    }
110
111    @Test
112    func existingAccountsKeepTheirUnreadBacklog() {
113        let suiteName = "InboxViewModelTests-\(UUID().uuidString)"
114        let defaults = UserDefaults(suiteName: suiteName)!
115        defer { defaults.removePersistentDomain(forName: suiteName) }
116
117        // An account already carrying read state has been in use, so upgrading
118        // must not retroactively mark everything it had not read as read.
119        InboxReadStateStore.markViewed(Date(timeIntervalSince1970: 1_000), for: "list#seen", defaults: defaults)
120        InboxReadStateStore.establishBaselineIfNeeded(now: Date(timeIntervalSince1970: 5_000), defaults: defaults)
121
122        #expect(
123            InboxReadStateStore.isUnread(
124                threadID: "list#unseen",
125                lastActivityAt: Date(timeIntervalSince1970: 4_000),
126                defaults: defaults
127            )
128        )
129    }
130
131    @Test
132    func markingAnOldThreadUnreadSurvivesTheBaseline() {
133        let suiteName = "InboxViewModelTests-\(UUID().uuidString)"
134        let defaults = UserDefaults(suiteName: suiteName)!
135        defer { defaults.removePersistentDomain(forName: suiteName) }
136
137        InboxReadStateStore.establishBaselineIfNeeded(now: Date(timeIntervalSince1970: 5_000), defaults: defaults)
138        let oldActivity = Date(timeIntervalSince1970: 4_000)
139
140        #expect(!InboxReadStateStore.isUnread(threadID: "list#old", lastActivityAt: oldActivity, defaults: defaults))
141
142        // Explicitly marking it unread must stick, rather than falling back to the
143        // baseline rule and reading as read again.
144        InboxReadStateStore.markUnread(for: "list#old", defaults: defaults)
145        #expect(InboxReadStateStore.isUnread(threadID: "list#old", lastActivityAt: oldActivity, defaults: defaults))
146    }
147
148    @Test
149    func normalizesThreadSubjectsForDisplay() {
150        let summary = InboxThreadSummary(
151            rootEmailID: 1,
152            rootMessageID: "message",
153            threadRootEmailIDs: [1],
154            threadRootMessageIDs: ["message"],
155            listID: 2,
156            listRID: "list",
157            listName: "hut-devel",
158            listOwner: Entity(canonicalName: "~owner"),
159            subject: "Re: Fwd:   [PATCH] test: add parser  ",
160            latestSender: Entity(canonicalName: "~sender"),
161            lastActivityAt: Date(timeIntervalSince1970: 2_000),
162            messageCount: 3,
163            repo: "hut",
164            containsPatch: true,
165            isUnread: true
166        )
167
168        #expect(summary.displaySubject == "[PATCH] test: add parser")
169        #expect(summary.metadataLine.contains("~sender"))
170        #expect(summary.metadataLine.contains("2 replies"))
171    }
172
173    @Test
174    func keepsDistinctThreadsDistinctByRootMessageID() {
175        let baseList = InboxMailingListReference(
176            id: 1,
177            rid: "list",
178            name: "hut-devel",
179            owner: Entity(canonicalName: "~owner")
180        )
181
182        let first = InboxThreadSummary(
183            rootEmailID: 10,
184            rootMessageID: "message-1",
185            threadRootEmailIDs: [10],
186            threadRootMessageIDs: ["message-1"],
187            listID: baseList.id,
188            listRID: baseList.rid,
189            listName: baseList.name,
190            listOwner: baseList.owner,
191            subject: "[PATCH] test",
192            latestSender: Entity(canonicalName: "~a"),
193            lastActivityAt: Date(timeIntervalSince1970: 100),
194            messageCount: 1,
195            repo: "hut",
196            containsPatch: true,
197            isUnread: true
198        )
199        let second = InboxThreadSummary(
200            rootEmailID: 11,
201            rootMessageID: "message-2",
202            threadRootEmailIDs: [11],
203            threadRootMessageIDs: ["message-2"],
204            listID: baseList.id,
205            listRID: baseList.rid,
206            listName: baseList.name,
207            listOwner: baseList.owner,
208            subject: "[PATCH] test",
209            latestSender: Entity(canonicalName: "~b"),
210            lastActivityAt: Date(timeIntervalSince1970: 200),
211            messageCount: 2,
212            repo: "hut",
213            containsPatch: true,
214            isUnread: true
215        )
216
217        #expect(first.id != second.id)
218    }
219
220    @Test
221    func mailingListThreadFilterMatchesSubjectAndSender() {
222        let baseList = InboxMailingListReference(
223            id: 1,
224            rid: "list",
225            name: "hutch-devel",
226            owner: Entity(canonicalName: "~owner")
227        )
228        let threads = [
229            InboxThreadSummary(
230                rootEmailID: 10,
231                rootMessageID: "message-1",
232                threadRootEmailIDs: [10],
233                threadRootMessageIDs: ["message-1"],
234                listID: baseList.id,
235                listRID: baseList.rid,
236                listName: baseList.name,
237                listOwner: baseList.owner,
238                subject: "Re: [PATCH] add search",
239                latestSender: Entity(canonicalName: "~alice"),
240                lastActivityAt: Date(timeIntervalSince1970: 100),
241                messageCount: 1,
242                repo: "hutch",
243                containsPatch: true,
244                isUnread: true
245            ),
246            InboxThreadSummary(
247                rootEmailID: 11,
248                rootMessageID: "message-2",
249                threadRootEmailIDs: [11],
250                threadRootMessageIDs: ["message-2"],
251                listID: baseList.id,
252                listRID: baseList.rid,
253                listName: baseList.name,
254                listOwner: baseList.owner,
255                subject: "Release planning",
256                latestSender: Entity(canonicalName: "~bob"),
257                lastActivityAt: Date(timeIntervalSince1970: 200),
258                messageCount: 2,
259                repo: "hutch",
260                containsPatch: false,
261                isUnread: true
262            )
263        ]
264
265        let subjectMatches = MailingListDetailViewModel.filterThreads(threads, matching: "search")
266        let senderMatches = MailingListDetailViewModel.filterThreads(threads, matching: "~bob")
267
268        #expect(subjectMatches.map(\.rootEmailID) == [10])
269        #expect(senderMatches.map(\.rootEmailID) == [11])
270    }
271
272    @Test
273    func segmentsPatchBodyAndTreatsSignatureAsPlainText() {
274        let body = """
275        From: Christian Cleberg <root@krz.sh>
276
277        ---
278         test-patch.txt | 1 +
279         1 file changed, 1 insertion(+)
280         create mode 100644 test-patch.txt
281
282        diff --git a/test-patch.txt b/test-patch.txt
283        new file mode 100644
284        index 0000000..c7b6eed
285        --- /dev/null
286        +++ b/test-patch.txt
287        @@ -0,0 +1 @@
288        +test Wed Mar 18 23:19:03 CDT 2026
289        -- 
290        2.50.1 (Apple Git-155)
291        """
292
293        let segments = InboxThreadUtilities.segmentMessageBody(body, isPatch: true)
294
295        #expect(segments.count == 3)
296
297        guard case let .plainText(leadingPlainText) = segments[0] else {
298            Issue.record("Expected first segment to be plain text")
299            return
300        }
301        #expect(leadingPlainText.contains("From: Christian Cleberg <root@krz.sh>"))
302        #expect(leadingPlainText.contains("---"))
303        #expect(leadingPlainText.contains(" test-patch.txt | 1 +"))
304        #expect(leadingPlainText.contains(" 1 file changed, 1 insertion(+)"))
305        #expect(leadingPlainText.contains(" create mode 100644 test-patch.txt"))
306
307        guard case let .diff(diff) = segments[1] else {
308            Issue.record("Expected second segment to be diff")
309            return
310        }
311        #expect(diff.contains("diff --git a/test-patch.txt b/test-patch.txt"))
312        #expect(diff.contains("--- /dev/null"))
313        #expect(diff.contains("+++ b/test-patch.txt"))
314        #expect(diff.contains("+test Wed Mar 18 23:19:03 CDT 2026"))
315        #expect(!diff.contains("-- \n2.50.1 (Apple Git-155)"))
316
317        guard case let .plainText(trailingPlainText) = segments[2] else {
318            Issue.record("Expected third segment to be plain text")
319            return
320        }
321        #expect(trailingPlainText.contains("--"))
322        #expect(trailingPlainText.contains("2.50.1 (Apple Git-155)"))
323    }
324}