Files
andros 8990360a7d Add fetchRootPostURL, fix parentChain order, reply action buttons
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
2026-04-19 09:29:25 +02:00

181 lines
7.2 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)")
}
}
}