8990360a7d
Library: - ThreadClient.fetchRootPostURL: follows relay parentChain (oldest-first) to return the root post URL of any thread; works for 0/1/2-deep chains and cross-domain URLs with +02:00 timezone format - Fix ThreadFetcher bug: relay already returns parentChain oldest-first, remove incorrect .reversed() that was displaying ancestors in wrong order - 6 new integration tests for root resolution with verified relay fixtures UI: - PostRowView: replace static Reply badge with action bar; lazy-fetch root URL and reply count in parallel via withTaskGroup; "View thread" NavigationLink appears once root is resolved; "Reply" button opens ComposeView sheet pre-filled with replyTo - ComposeViewModel/ComposeView: accept optional replyTo, show timestamp context indicator, title changes to "Reply" when replyTo is set
181 lines
7.2 KiB
Swift
181 lines
7.2 KiB
Swift
import Foundation
|
||
import Testing
|
||
@testable import OrgSocialKit
|
||
|
||
private let relay = URL(string: "https://relay.org-social.org")!
|
||
// Known post from shom with 2+ direct replies and nested children (verified live)
|
||
private let postWithReplies = "https://shom.dev/social.org#2025-09-06T22:36:20-0500"
|
||
private let postWithNested = "https://shom.dev/social.org#2025-09-06T22:36:20-0500"
|
||
|
||
// Root resolution test fixtures (verified live against relay):
|
||
// shom is root → adsan replied to shom → andros replied to adsan (2-deep chain)
|
||
private let rootPost = "https://shom.dev/social.org#2025-09-06T22:36:20-0500"
|
||
// adsan's reply to shom (1-deep, uses +02:00 timezone in URL)
|
||
private let reply1Deep = "https://adsan.dev/social.org#2025-09-07T15:45:33+02:00"
|
||
// andros's reply to adsan (2-deep cross-domain)
|
||
private let reply2Deep = "https://host.org-social.org/andros/social.org#2025-09-07T16:27:11+0200"
|
||
// adsan post that is both a reply (to andros.dev) and has its own reply (middle of chain)
|
||
private let replyMiddle = "https://adsan.dev/social.org#2025-10-12T20:24:12+02:00"
|
||
private let replyMiddleRoot = "https://andros.dev/static/social.org#2025-10-12T11:30:47+0200"
|
||
|
||
@Suite("ThreadClient – interactions", .serialized)
|
||
struct ThreadClientInteractionTests {
|
||
|
||
let client = ThreadClient()
|
||
|
||
@Test("returns interactions for post with known replies")
|
||
func fetchInteractions() async throws {
|
||
let interactions = try await client.fetchInteractions(for: postWithReplies, from: relay)
|
||
#expect(interactions.replyCount >= 2)
|
||
}
|
||
|
||
@Test("hasActivity is true when there are replies")
|
||
func hasActivity() async throws {
|
||
let interactions = try await client.fetchInteractions(for: postWithReplies, from: relay)
|
||
#expect(interactions.hasActivity)
|
||
}
|
||
|
||
@Test("replyURLs are non-empty strings")
|
||
func replyURLsValid() async throws {
|
||
let interactions = try await client.fetchInteractions(for: postWithReplies, from: relay)
|
||
#expect(!interactions.replyURLs.isEmpty)
|
||
for url in interactions.replyURLs {
|
||
#expect(!url.isEmpty)
|
||
}
|
||
}
|
||
|
||
@Test("fetchInteractions returns valid struct for any known post")
|
||
func interactionsStructureValid() async throws {
|
||
let interactions = try await client.fetchInteractions(for: postWithReplies, from: relay)
|
||
#expect(interactions.replyCount >= 0)
|
||
#expect(interactions.reactionCount >= 0)
|
||
#expect(interactions.boostCount >= 0)
|
||
}
|
||
}
|
||
|
||
@Suite("ThreadClient – raw thread", .serialized)
|
||
struct ThreadClientRawTests {
|
||
|
||
let client = ThreadClient()
|
||
|
||
@Test("raw thread parentURL matches requested post")
|
||
func parentURLMatches() async throws {
|
||
let raw = try await client.fetchRawThread(for: postWithReplies, from: relay)
|
||
#expect(raw.parentURL == postWithReplies)
|
||
}
|
||
|
||
@Test("raw thread has expected reply count")
|
||
func replyCount() async throws {
|
||
let raw = try await client.fetchRawThread(for: postWithReplies, from: relay)
|
||
#expect(raw.replies.count >= 2)
|
||
}
|
||
|
||
@Test("nested thread has children")
|
||
func nestedChildren() async throws {
|
||
let raw = try await client.fetchRawThread(for: postWithNested, from: relay)
|
||
let hasChildren = raw.replies.contains { !$0.children.isEmpty }
|
||
#expect(hasChildren)
|
||
}
|
||
|
||
@Test("all reply post URLs contain # separator")
|
||
func replyURLsHaveHash() async throws {
|
||
let raw = try await client.fetchRawThread(for: postWithReplies, from: relay)
|
||
for node in raw.replies {
|
||
#expect(node.postURL.contains("#"))
|
||
}
|
||
}
|
||
}
|
||
|
||
@Suite("ThreadFetcher – full resolution", .serialized)
|
||
struct ThreadFetcherTests {
|
||
|
||
let fetcher = ThreadFetcher()
|
||
|
||
@Test("resolved focal post timestamp matches requested post")
|
||
func focalPostTimestamp() async throws {
|
||
let thread = try await fetcher.fetchThread(for: postWithReplies, from: relay)
|
||
let expectedTimestamp = postWithReplies.components(separatedBy: "#").last ?? ""
|
||
#expect(thread.focalPost.timestamp == expectedTimestamp)
|
||
}
|
||
|
||
@Test("thread has at least two resolved replies")
|
||
func repliesCount() async throws {
|
||
let thread = try await fetcher.fetchThread(for: postWithReplies, from: relay)
|
||
#expect(thread.replyCount >= 2)
|
||
}
|
||
|
||
@Test("all resolved reply posts have non-empty timestamps")
|
||
func replyTimestampsNonEmpty() async throws {
|
||
let thread = try await fetcher.fetchThread(for: postWithReplies, from: relay)
|
||
func check(_ nodes: [OrgSocialThreadNode]) {
|
||
for node in nodes {
|
||
#expect(!node.post.timestamp.isEmpty)
|
||
check(node.children)
|
||
}
|
||
}
|
||
check(thread.replies)
|
||
}
|
||
|
||
@Test("nested thread resolves children")
|
||
func nestedResolution() async throws {
|
||
let thread = try await fetcher.fetchThread(for: postWithNested, from: relay)
|
||
let hasChildren = thread.replies.contains { !$0.children.isEmpty }
|
||
#expect(hasChildren)
|
||
}
|
||
|
||
@Test("replyCount helper counts all depths")
|
||
func replyCountHelper() async throws {
|
||
let thread = try await fetcher.fetchThread(for: postWithNested, from: relay)
|
||
#expect(thread.replyCount >= 1)
|
||
}
|
||
|
||
@Test("parent chain is ordered oldest-first (root at index 0)")
|
||
func parentChainOrderedOldestFirst() async throws {
|
||
// Opening a 2-deep reply: parentChain should be [shom, adsan] (root first)
|
||
let thread = try await fetcher.fetchThread(for: reply2Deep, from: relay)
|
||
#expect(thread.parentChain.count >= 2)
|
||
// shom (Sept 6) is older than adsan (Sept 7) → shom must come first
|
||
let firstAncestorTimestamp = thread.parentChain.first?.timestamp ?? ""
|
||
let lastAncestorTimestamp = thread.parentChain.last?.timestamp ?? ""
|
||
#expect(firstAncestorTimestamp < lastAncestorTimestamp)
|
||
}
|
||
}
|
||
|
||
@Suite("ThreadClient – root resolution", .serialized)
|
||
struct ThreadClientRootTests {
|
||
|
||
let client = ThreadClient()
|
||
|
||
@Test("root post returns its own URL")
|
||
func rootPostReturnsItself() async throws {
|
||
let root = try await client.fetchRootPostURL(for: rootPost, from: relay)
|
||
#expect(root == rootPost)
|
||
}
|
||
|
||
@Test("1-deep reply returns the root URL")
|
||
func reply1DeepReturnsRoot() async throws {
|
||
let root = try await client.fetchRootPostURL(for: reply1Deep, from: relay)
|
||
#expect(root == rootPost)
|
||
}
|
||
|
||
@Test("2-deep reply returns the root URL (cross-domain)")
|
||
func reply2DeepReturnsRoot() async throws {
|
||
let root = try await client.fetchRootPostURL(for: reply2Deep, from: relay)
|
||
#expect(root == rootPost)
|
||
}
|
||
|
||
@Test("middle-of-chain post returns its root (different domain, +02:00 timezone in URL)")
|
||
func replyMiddleReturnsRoot() async throws {
|
||
let root = try await client.fetchRootPostURL(for: replyMiddle, from: relay)
|
||
#expect(root == replyMiddleRoot)
|
||
}
|
||
|
||
@Test("root URL always contains # separator")
|
||
func rootURLContainsHash() async throws {
|
||
for postURL in [rootPost, reply1Deep, reply2Deep, replyMiddle] {
|
||
let root = try await client.fetchRootPostURL(for: postURL, from: relay)
|
||
#expect(root.contains("#"), "Root URL missing #: \(root)")
|
||
}
|
||
}
|
||
}
|