From 9ad7f8f49272095a6dd8b817141b6fc77f1b3d25 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Wed, 3 Jun 2026 21:14:00 +0800 Subject: [PATCH] fix: unpairing issue and pr title generation style enforcement --- RxCode/App/AppState+PullRequest.swift | 54 ++++++++++++- RxCode/App/AppState+SessionLifecycle.swift | 6 +- .../FoundationModelSummarizationService.swift | 18 +---- .../Services/OpenAISummarizationService.swift | 12 ++- .../app/rxlab/rxcode/state/MobileAppState.kt | 16 +++- .../app/rxlab/rxcode/store/PairedDesktop.kt | 35 +++++++-- .../rxcode/ui/onboarding/OnboardingScreen.kt | 37 ++++++++- .../rxcode/store/PairedDesktopUnpairTest.kt | 75 +++++++++++++++++++ .../State/MobileAppState+Inbound.swift | 10 ++- RxCodeMobile/State/MobileAppState.swift | 27 ++++++- .../Views/MobileBriefingDetailView.swift | 30 ++++---- RxCodeMobile/Views/MobileBriefingView.swift | 17 +++-- .../PairedDesktopUnpairTests.swift | 70 +++++++++++++++++ RxCodeTests/AppStateTests.swift | 19 +++++ 14 files changed, 362 insertions(+), 64 deletions(-) create mode 100644 RxCodeAndroid/app/src/test/java/app/rxlab/rxcode/store/PairedDesktopUnpairTest.kt create mode 100644 RxCodeMobileTests/PairedDesktopUnpairTests.swift diff --git a/RxCode/App/AppState+PullRequest.swift b/RxCode/App/AppState+PullRequest.swift index fbbd54a5..c40f3301 100644 --- a/RxCode/App/AppState+PullRequest.swift +++ b/RxCode/App/AppState+PullRequest.swift @@ -1,4 +1,5 @@ import Foundation +import os import RxCodeCore /// Errors surfaced while opening a pull request from a briefing card. @@ -51,8 +52,7 @@ extension AppState { let briefing = threadStore.allBranchBriefingItems() .first(where: { $0.projectId == project.id && $0.branch == branch })? .briefing ?? "" - let raw = await generatePullRequestContent(briefing: briefing, branch: branch) - let (title, body) = Self.parsePullRequestContent(raw, branch: branch) + let (title, body) = await generateValidatedPullRequestContent(briefing: briefing, branch: branch) // 3. Open the PR via autopilot. let response: CreatePullRequestResponse @@ -130,6 +130,56 @@ extension AppState { } } + /// Generate PR content and guarantee the title is a valid Conventional + /// Commit (its `` is one of ``conventionalCommitTypes``). The model + /// occasionally returns a non-conforming title (e.g. `feature:` or a plain + /// sentence); when it does we re-prompt up to `maxAttempts` times before + /// falling back to a safe `chore:` title while keeping the generated body. + func generateValidatedPullRequestContent( + briefing: String, + branch: String, + maxAttempts: Int = 3 + ) async -> (title: String, body: String) { + var lastBody = "" + for attempt in 1...maxAttempts { + let raw = await generatePullRequestContent(briefing: briefing, branch: branch) + let (title, body) = Self.parsePullRequestContent(raw, branch: branch) + if Self.isConventionalCommitTitle(title) { + return (title, body) + } + lastBody = body + logger.warning("PR title is not a valid Conventional Commit (attempt \(attempt)/\(maxAttempts)); retrying: \(title, privacy: .public)") + } + logger.warning("PR title still invalid after \(maxAttempts) attempts; using fallback title") + return ("chore: update \(branch)", lastBody) + } + + /// Conventional Commit `` tokens accepted in commit and PR titles. + /// Single source of truth shared across title generation, normalization, and + /// validation. + static let conventionalCommitTypes: Set = [ + "feat", "fix", "docs", "style", "refactor", "perf", + "test", "build", "ci", "chore", "revert" + ] + + /// True when `title` matches `(): ` and + /// `` is one of ``conventionalCommitTypes``. Used to gate generated PR + /// titles so a non-conforming title triggers a model retry. + static func isConventionalCommitTitle(_ title: String) -> Bool { + let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return false } + let pattern = #"^([A-Za-z]+)(\([^)\n]+\))?!?\s*:\s+\S.*$"# + guard let regex = try? NSRegularExpression(pattern: pattern), + let match = regex.firstMatch( + in: trimmed, + range: NSRange(trimmed.startIndex.. String? { guard !threadSummaries.isEmpty else { return nil } - let joined = threadSummaries.map { item -> String in - let title = item.title.trimmingCharacters(in: .whitespacesAndNewlines) - let summary = String(item.summary.prefix(1500)).trimmingCharacters(in: .whitespacesAndNewlines) - return "### \(title.isEmpty ? "Untitled thread" : title)\n\(summary)" - }.joined(separator: "\n\n") - - let prompt = """ - Write a concise overall briefing for one git branch by synthesizing the per-thread summaries below into a single coherent overview. - Cover the main themes, completed work, important decisions, files or areas touched, and unresolved follow-ups across the whole branch. - Do not list threads individually — produce a unified summary. Use 4-8 short bullet points. Reply with only the briefing. - - Thread summaries (newest first): - - \(joined) - """ + // Share the exact prompt used by the other providers so the categorized + // briefing format stays consistent regardless of backend. + let prompt = OpenAISummarizationService.branchBriefingPrompt(threadSummaries: threadSummaries) let raw = await respond( instructions: "You maintain concise local project summaries.", prompt: prompt diff --git a/RxCode/Services/OpenAISummarizationService.swift b/RxCode/Services/OpenAISummarizationService.swift index f788d876..70aafcb5 100644 --- a/RxCode/Services/OpenAISummarizationService.swift +++ b/RxCode/Services/OpenAISummarizationService.swift @@ -293,9 +293,15 @@ actor OpenAISummarizationService { }.joined(separator: "\n\n") return """ - Write a concise overall briefing for one git branch by synthesizing the per-thread summaries below into a single coherent overview. - Cover the main themes, completed work, important decisions, files or areas touched, and unresolved follow-ups across the whole branch. - Do not list threads individually — produce a unified summary. Use 4-8 short bullet points. Reply with only the briefing. + Write a concise briefing for one git branch by synthesizing the per-thread summaries below into a single coherent overview, grouped into clearly labelled categories so it is easy to scan. + + Format rules (MUST follow exactly): + - Use GitHub-flavored markdown. + - Group related work under `###` category headings. Use ONLY these headings, in this order, and INCLUDE A HEADING ONLY when there is real work for it: Features, Fixes, Improvements, Docs, Refactors, Decisions, Follow-ups. + - Under each heading, write `- ` bullet points (1-5 per category), each a short, factual, past-tense statement. Mention the key files or areas touched inline within the relevant bullet. + - Synthesize across threads — do NOT list threads individually, and do NOT repeat the same point under more than one heading. + - Put unresolved or pending work under `### Follow-ups`. + - Reply with only the briefing markdown. No preamble, no closing remarks, no surrounding code fences. Thread summaries (newest first): diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/MobileAppState.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/MobileAppState.kt index 4f24e8a4..fcf362a7 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/MobileAppState.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/MobileAppState.kt @@ -280,9 +280,21 @@ class MobileAppState @Inject constructor( } private suspend fun handleUnpair(fromHex: String) { - val desktop = _state.value.pairedDesktops.firstOrNull { it.pubkeyHex == fromHex } ?: return + // The unpair arrived over the relay this client is currently connected to, + // so it targets the pairing for that specific relay. Matching by pubkey + // alone would remove an entry for the same Mac on a *different* relay + // (see `PairedDesktop.matchForUnpair`). + val desktop = PairedDesktop.matchForUnpair( + desktops = _state.value.pairedDesktops, + fromHex = fromHex, + currentRelay = _state.value.relayUrl, + ) ?: return store.remove(desktop.id) - client.removePeer(fromHex) + // Only forget the crypto peer if no other pairing uses the same pubkey + // (e.g. the same Mac reached through another relay). + if (_state.value.pairedDesktops.none { it.pubkeyHex == fromHex && it.id != desktop.id }) { + client.removePeer(fromHex) + } } private fun handleSnapshot(fromHex: String, snap: Payload.Snapshot) { diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/store/PairedDesktop.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/store/PairedDesktop.kt index 73e07a35..0f9e7393 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/store/PairedDesktop.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/store/PairedDesktop.kt @@ -16,11 +16,34 @@ data class PairedDesktop( val relayUrl: String? = null, ) { val id: String - get() { - val normRelay = (relayUrl ?: "") - .trim() - .lowercase() - .trim('/') - return "$pubkeyHex::$normRelay" + get() = compositeId(pubkeyHex, relayUrl) + + companion object { + /** Normalize a relay URL for comparison (trim whitespace/slashes, lowercase). */ + fun normalizeRelay(relayUrl: String?): String = + (relayUrl ?: "").trim().lowercase().trim('/') + + /** Build the composite id (pubkey + normalized relay) without an instance. */ + fun compositeId(pubkeyHex: String, relayUrl: String?): String = + "$pubkeyHex::${normalizeRelay(relayUrl)}" + + /** + * Selects the pairing an inbound unpair targets. The unpair arrives over + * the relay this client is currently connected to, so it identifies the + * entry for that specific relay — matching by pubkey alone would remove an + * entry for the same Mac on a *different* relay. Falls back to the sole + * pairing for a Mac when there is only one (covers legacy entries that + * predate stored relay URLs), and returns null when the choice is + * ambiguous so we never remove the wrong relay's entry. + */ + fun matchForUnpair( + desktops: List, + fromHex: String, + currentRelay: String?, + ): PairedDesktop? { + val samePubkey = desktops.filter { it.pubkeyHex == fromHex } + val targetId = compositeId(fromHex, currentRelay) + return samePubkey.firstOrNull { it.id == targetId } ?: samePubkey.singleOrNull() } + } } diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/onboarding/OnboardingScreen.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/onboarding/OnboardingScreen.kt index fd8c77b2..5ff96d18 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/onboarding/OnboardingScreen.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/onboarding/OnboardingScreen.kt @@ -402,6 +402,9 @@ private fun PairingCameraScreen( var scanLocked by remember { mutableStateOf(false) } var scannerKey by remember { mutableStateOf(0) } var scanError by remember { mutableStateOf(null) } + // Manual scanning: the camera previews continuously, but barcode analysis + // only runs after the user taps "Scan" (not automatically on every frame). + var scanArmed by remember { mutableStateOf(false) } LaunchedEffect(camPermission.status.isGranted) { pairingLog("camera permission granted=${camPermission.status.isGranted}") @@ -430,17 +433,20 @@ private fun PairingCameraScreen( QRCameraPreview( modifier = Modifier.fillMaxSize(), scanKey = scannerKey, - enabled = !scanLocked && pairing !is PairingStatus.InProgress, + enabled = scanArmed && !scanLocked && pairing !is PairingStatus.InProgress, onToken = { token -> pairingLog("camera QR accepted: ${token.logSummary()}") scanLocked = true + scanArmed = false scanError = null onToken(token) }, onInvalidQr = { if (!scanLocked) { Log.w(TAG, "camera QR rejected: not an RxCode pairing token") - scanError = "That QR code is not an RxCode pairing code." + // Stop scanning and let the user reposition and tap again. + scanArmed = false + scanError = "That QR code is not an RxCode pairing code. Tap Scan to try again." } }, ) @@ -477,11 +483,18 @@ private fun PairingCameraScreen( PairingCameraStatus( pairing = pairing, scanError = scanError, + scanArmed = scanArmed, + onScan = { + pairingLog("user armed camera QR scan") + scanError = null + scanArmed = true + }, onRetry = { pairingLog("retrying camera QR scan") onRetry() scanError = null scanLocked = false + scanArmed = false scannerKey += 1 }, modifier = Modifier @@ -512,6 +525,8 @@ private fun CameraShade() { private fun PairingCameraStatus( pairing: PairingStatus, scanError: String?, + scanArmed: Boolean, + onScan: () -> Unit, onRetry: () -> Unit, modifier: Modifier = Modifier, ) { @@ -524,8 +539,22 @@ private fun PairingCameraStatus( Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { when (pairing) { PairingStatus.Idle -> { - Text("Align the QR code inside the frame.", style = MaterialTheme.typography.titleMedium) - scanError?.let { Text(it, color = MaterialTheme.colorScheme.error) } + if (scanArmed) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Outlined.QrCodeScanner, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + Spacer(Modifier.width(10.dp)) + Text("Scanning…", style = MaterialTheme.typography.titleMedium) + } + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } else { + Text("Position the QR code inside the frame, then tap Scan.", style = MaterialTheme.typography.titleMedium) + scanError?.let { Text(it, color = MaterialTheme.colorScheme.error) } + Button(onClick = onScan, modifier = Modifier.fillMaxWidth()) { + Icon(Icons.Outlined.QrCodeScanner, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text("Scan") + } + } } PairingStatus.InProgress -> { Row(verticalAlignment = Alignment.CenterVertically) { diff --git a/RxCodeAndroid/app/src/test/java/app/rxlab/rxcode/store/PairedDesktopUnpairTest.kt b/RxCodeAndroid/app/src/test/java/app/rxlab/rxcode/store/PairedDesktopUnpairTest.kt new file mode 100644 index 00000000..fb274a01 --- /dev/null +++ b/RxCodeAndroid/app/src/test/java/app/rxlab/rxcode/store/PairedDesktopUnpairTest.kt @@ -0,0 +1,75 @@ +package app.rxlab.rxcode.store + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * Regression tests for relay-aware unpair matching. The bug: when the same Mac + * was paired through two relays, an inbound unpair matched by pubkey alone and + * removed the *other* relay's entry. [PairedDesktop.matchForUnpair] must resolve + * the entry for the relay the unpair actually arrived on. + */ +class PairedDesktopUnpairTest { + + private fun desktop(pubkey: String, relay: String?) = + PairedDesktop(pubkeyHex = pubkey, displayName = "Mac", pairedAtEpochMs = 0L, relayUrl = relay) + + @Test + fun picksEntryForArrivingRelay() { + val a = desktop("PUB", "wss://relay1.example.com/ws") + val b = desktop("PUB", "wss://relay2.example.com/ws") + + val match = PairedDesktop.matchForUnpair(listOf(a, b), "PUB", "wss://relay2.example.com/ws") + + assertEquals(b.id, match?.id) + } + + @Test + fun normalizesRelayBeforeMatching() { + val a = desktop("PUB", "wss://relay1.example.com/ws") + val b = desktop("PUB", "wss://relay2.example.com/ws") + + val match = PairedDesktop.matchForUnpair(listOf(a, b), "PUB", "WSS://Relay2.Example.com/ws/") + + assertEquals(b.id, match?.id) + } + + @Test + fun singleEntryFallbackForLegacyPairing() { + val legacy = desktop("PUB", null) + + val match = PairedDesktop.matchForUnpair(listOf(legacy), "PUB", "wss://relay1.example.com/ws") + + assertEquals(legacy.id, match?.id) + } + + @Test + fun ambiguousRelayDoesNotGuess() { + val a = desktop("PUB", "wss://relay1.example.com/ws") + val b = desktop("PUB", "wss://relay2.example.com/ws") + + val match = PairedDesktop.matchForUnpair(listOf(a, b), "PUB", "wss://relay3.example.com/ws") + + assertNull(match) + } + + @Test + fun ignoresOtherMacs() { + val mine = desktop("PUB", "wss://relay1.example.com/ws") + val other = desktop("OTHER", "wss://relay1.example.com/ws") + + val match = PairedDesktop.matchForUnpair(listOf(mine, other), "PUB", "wss://relay1.example.com/ws") + + assertEquals(mine.id, match?.id) + } + + @Test + fun noMatchForUnknownPubkey() { + val a = desktop("PUB", "wss://relay1.example.com/ws") + + val match = PairedDesktop.matchForUnpair(listOf(a), "NOPE", "wss://relay1.example.com/ws") + + assertNull(match) + } +} diff --git a/RxCodeMobile/State/MobileAppState+Inbound.swift b/RxCodeMobile/State/MobileAppState+Inbound.swift index 03b6b592..2ace451f 100644 --- a/RxCodeMobile/State/MobileAppState+Inbound.swift +++ b/RxCodeMobile/State/MobileAppState+Inbound.swift @@ -55,7 +55,15 @@ extension MobileAppState { failPairing(String(localized: "Your Mac declined the pairing request.")) } case .unpair: - guard let desktop = pairedDesktops.first(where: { $0.pubkeyHex == inbound.fromHex }) else { return } + // The unpair arrived over the relay this client is currently connected + // to, so it targets the pairing for that specific relay. Matching by + // pubkey alone would remove an entry for the same Mac on a *different* + // relay (see `PairedDesktop.matchForUnpair`). + guard let desktop = PairedDesktop.matchForUnpair( + in: pairedDesktops, + fromHex: inbound.fromHex, + currentRelay: relayURL.absoluteString + ) else { return } Task { await self.removePairedDesktopAfterRemoteUnpair(desktop) } case .snapshot(let snap): guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "snapshot") else { return } diff --git a/RxCodeMobile/State/MobileAppState.swift b/RxCodeMobile/State/MobileAppState.swift index 4158a269..d6397bc2 100644 --- a/RxCodeMobile/State/MobileAppState.swift +++ b/RxCodeMobile/State/MobileAppState.swift @@ -28,11 +28,34 @@ struct PairedDesktop: Codable, Identifiable, Equatable, Hashable { /// Composite id: same Mac paired via different relays produces distinct entries. var id: String { - let normalizedRelay = (relayURL ?? "") + Self.compositeID(pubkeyHex: pubkeyHex, relayURL: relayURL) + } + + /// Normalize a relay URL string for comparison (trim whitespace/slashes, lowercase). + static func normalizeRelay(_ relayURL: String?) -> String { + (relayURL ?? "") .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() .trimmingCharacters(in: CharacterSet(charactersIn: "/")) - return "\(pubkeyHex)::\(normalizedRelay)" + } + + /// Build the composite id (pubkey + normalized relay) without an instance, + /// so inbound handlers can locate the entry for a specific relay. + static func compositeID(pubkeyHex: String, relayURL: String?) -> String { + "\(pubkeyHex)::\(normalizeRelay(relayURL))" + } + + /// Selects the pairing an inbound unpair targets. The unpair arrives over the + /// relay this client is currently connected to, so it identifies the entry for + /// that specific relay — matching by pubkey alone would remove an entry for the + /// same Mac on a *different* relay. Falls back to the sole pairing for a Mac + /// when there is only one (covers legacy entries that predate stored relay + /// URLs), and returns `nil` when the choice is ambiguous so we never remove the + /// wrong relay's entry. + static func matchForUnpair(in desktops: [PairedDesktop], fromHex: String, currentRelay: String?) -> PairedDesktop? { + let samePubkey = desktops.filter { $0.pubkeyHex == fromHex } + let targetID = compositeID(pubkeyHex: fromHex, relayURL: currentRelay) + return samePubkey.first(where: { $0.id == targetID }) ?? (samePubkey.count == 1 ? samePubkey.first : nil) } /// Human-readable relay host for display (e.g. "relay.example.com"). diff --git a/RxCodeMobile/Views/MobileBriefingDetailView.swift b/RxCodeMobile/Views/MobileBriefingDetailView.swift index ddadfa14..8c2990bf 100644 --- a/RxCodeMobile/Views/MobileBriefingDetailView.swift +++ b/RxCodeMobile/Views/MobileBriefingDetailView.swift @@ -306,14 +306,12 @@ struct MobileBriefingDetailView: View { } if let summary = group?.briefing?.briefing, !summary.isEmpty { - ChatTextContentView( - markdown: summary, - size: 15, - color: .primary, - lineSpacing: 4 - ) - .frame(maxWidth: .infinity, alignment: .leading) - .fixedSize(horizontal: false, vertical: true) + // Full block markdown so the categorized briefing (### Fixes, + // ### Improvements, bullet lists, …) renders as formatted text + // rather than raw markdown markers. + MarkdownContentView(text: summary) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) } else { HStack(spacing: 10) { Image(systemName: "text.justify.leading") @@ -456,15 +454,13 @@ struct MobileBriefingThreadCard: View { .multilineTextAlignment(.leading) if !thread.summary.isEmpty { - ChatTextContentView( - markdown: thread.summary, - size: 13, - color: .secondary, - lineSpacing: 2, - maximumNumberOfLines: 3 - ) - .frame(maxWidth: .infinity, alignment: .leading) - .fixedSize(horizontal: false, vertical: true) + // Render the thread description as block markdown (bullets / + // emphasis) instead of inline-only text with raw markers. + MarkdownContentView(text: thread.summary) + .font(.system(size: 13)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) } if isStreaming { diff --git a/RxCodeMobile/Views/MobileBriefingView.swift b/RxCodeMobile/Views/MobileBriefingView.swift index cb244702..960e0879 100644 --- a/RxCodeMobile/Views/MobileBriefingView.swift +++ b/RxCodeMobile/Views/MobileBriefingView.swift @@ -670,13 +670,16 @@ private struct BriefingCard: View { // Summary content VStack(alignment: .leading, spacing: 12) { if let summary = group.briefing?.briefing, !summary.isEmpty { - ChatTextContentView( - markdown: summary, - size: 14, - color: .secondary, - lineSpacing: 3, - maximumNumberOfLines: 4 - ) + // Compact, uniform list-card preview: strip the markdown + // markers (headings / bullets) to a clean snippet that + // truncates cleanly. The full markdown renders on the detail + // screen. + Text(stripMarkdown(summary)) + .font(.system(size: 14)) + .foregroundStyle(.secondary) + .lineSpacing(3) + .lineLimit(4) + .frame(maxWidth: .infinity, alignment: .leading) } else { Text("No summary available yet") .font(.subheadline) diff --git a/RxCodeMobileTests/PairedDesktopUnpairTests.swift b/RxCodeMobileTests/PairedDesktopUnpairTests.swift new file mode 100644 index 00000000..f39cbc39 --- /dev/null +++ b/RxCodeMobileTests/PairedDesktopUnpairTests.swift @@ -0,0 +1,70 @@ +import XCTest +@testable import RxCodeMobile + +/// Regression tests for the relay-aware unpair matching. The bug: when the same +/// Mac was paired through two relays, an inbound unpair matched by pubkey alone +/// and removed the *other* relay's entry. `matchForUnpair` must resolve the entry +/// for the relay the unpair actually arrived on. +final class PairedDesktopUnpairTests: XCTestCase { + + private func desktop(_ pubkey: String, relay: String?) -> PairedDesktop { + PairedDesktop(pubkeyHex: pubkey, displayName: "Mac", pairedAt: .init(timeIntervalSince1970: 0), lastSeen: nil, relayURL: relay) + } + + func testPicksEntryForArrivingRelay() { + let a = desktop("PUB", relay: "wss://relay1.example.com/ws") + let b = desktop("PUB", relay: "wss://relay2.example.com/ws") + + let match = PairedDesktop.matchForUnpair(in: [a, b], fromHex: "PUB", currentRelay: "wss://relay2.example.com/ws") + + XCTAssertEqual(match?.id, b.id, "Unpair over relay2 must remove the relay2 entry, not relay1.") + } + + func testNormalizesRelayBeforeMatching() { + let a = desktop("PUB", relay: "wss://relay1.example.com/ws") + let b = desktop("PUB", relay: "wss://relay2.example.com/ws") + + // Trailing slash + different casing must still resolve to relay2. + let match = PairedDesktop.matchForUnpair(in: [a, b], fromHex: "PUB", currentRelay: "WSS://Relay2.Example.com/ws/") + + XCTAssertEqual(match?.id, b.id) + } + + func testSingleEntryFallbackForLegacyPairing() { + // Legacy entry with no stored relay URL; unpair should still resolve it + // since there is exactly one pairing for this Mac. + let legacy = desktop("PUB", relay: nil) + + let match = PairedDesktop.matchForUnpair(in: [legacy], fromHex: "PUB", currentRelay: "wss://relay1.example.com/ws") + + XCTAssertEqual(match?.id, legacy.id) + } + + func testAmbiguousRelayDoesNotGuess() { + // Two relays, but the unpair arrived on a relay matching neither entry. + // Removing either would be a guess, so match nothing. + let a = desktop("PUB", relay: "wss://relay1.example.com/ws") + let b = desktop("PUB", relay: "wss://relay2.example.com/ws") + + let match = PairedDesktop.matchForUnpair(in: [a, b], fromHex: "PUB", currentRelay: "wss://relay3.example.com/ws") + + XCTAssertNil(match, "An unpair from an unknown relay must not remove an arbitrary entry.") + } + + func testIgnoresOtherMacs() { + let mine = desktop("PUB", relay: "wss://relay1.example.com/ws") + let other = desktop("OTHER", relay: "wss://relay1.example.com/ws") + + let match = PairedDesktop.matchForUnpair(in: [mine, other], fromHex: "PUB", currentRelay: "wss://relay1.example.com/ws") + + XCTAssertEqual(match?.id, mine.id) + } + + func testNoMatchForUnknownPubkey() { + let a = desktop("PUB", relay: "wss://relay1.example.com/ws") + + let match = PairedDesktop.matchForUnpair(in: [a], fromHex: "NOPE", currentRelay: "wss://relay1.example.com/ws") + + XCTAssertNil(match) + } +} diff --git a/RxCodeTests/AppStateTests.swift b/RxCodeTests/AppStateTests.swift index 87968129..67847c5f 100644 --- a/RxCodeTests/AppStateTests.swift +++ b/RxCodeTests/AppStateTests.swift @@ -545,6 +545,25 @@ final class AppStateTests: XCTestCase { XCTAssertEqual(result.body, "Adds the docs search flow.") } + func testIsConventionalCommitTitleAcceptsValidTitles() { + XCTAssertTrue(AppState.isConventionalCommitTitle("fix: correct the crash")) + XCTAssertTrue(AppState.isConventionalCommitTitle("feat(autopilot): add docs search")) + XCTAssertTrue(AppState.isConventionalCommitTitle("docs: update readme")) + XCTAssertTrue(AppState.isConventionalCommitTitle("feat!: drop legacy api")) + XCTAssertTrue(AppState.isConventionalCommitTitle("refactor(core)!: restructure store")) + } + + func testIsConventionalCommitTitleRejectsInvalidTitles() { + // Type not on the allowed list. + XCTAssertFalse(AppState.isConventionalCommitTitle("feature: add docs search")) + XCTAssertFalse(AppState.isConventionalCommitTitle("update: tweak things")) + // No conventional prefix at all. + XCTAssertFalse(AppState.isConventionalCommitTitle("Add a new docs search flow")) + // Missing description after the colon. + XCTAssertFalse(AppState.isConventionalCommitTitle("fix:")) + XCTAssertFalse(AppState.isConventionalCommitTitle("")) + } + // MARK: - Helpers private func makeProject(_ name: String) -> Project {