diff --git a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift index 795140fa5..34e84172e 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift @@ -279,26 +279,33 @@ struct ConversationComposer: View { // text-field automation type, so the query has to be identifier-based, not type-based. .accessibilityIdentifier("composer-message-field") - if showsSubmit { - Button(action: submit) { - Image(systemName: submitSymbol) - .font(.default(size: 16, weight: .bold)) - .foregroundStyle(Color.textAction) - .frame(width: 34, height: 34) - .background(Color.white, in: RoundedRectangle(cornerRadius: 6)) - // Arrow and checkmark are the same button in two jobs, so the glyph swaps in - // place rather than the button popping out and a new one popping back. - .contentTransition(.symbolEffect(.replace)) + // The spring is scoped to the button, not to the row. On the row it took the field + // into the transaction as well, and `showsSubmit` falls on the same update that empties + // the draft — so the field's text update ran as an animated one against its text view, + // where it can be coalesced away. That leaves the sent text on screen with the binding + // already empty, and an unchanged binding never pushes it again. + Group { + if showsSubmit { + Button(action: submit) { + Image(systemName: submitSymbol) + .font(.default(size: 16, weight: .bold)) + .foregroundStyle(Color.textAction) + .frame(width: 34, height: 34) + .background(Color.white, in: RoundedRectangle(cornerRadius: 6)) + // Arrow and checkmark are the same button in two jobs, so the glyph swaps in + // place rather than the button popping out and a new one popping back. + .contentTransition(.symbolEffect(.replace)) + } + .buttonStyle(.plain) + .accessibilityLabel(composer.isEditing ? "Save" : "Send") + .accessibilityIdentifier("send-message-button") + // Pop from 60% + fade, so the opacity ramp actually reads + // (scaling from 0 hides the fade behind a tiny speck). + .transition(.scale(scale: 0.6).combined(with: .opacity)) } - .buttonStyle(.plain) - .accessibilityLabel(composer.isEditing ? "Save" : "Send") - .accessibilityIdentifier("send-message-button") - // Pop from 60% + fade, so the opacity ramp actually reads - // (scaling from 0 hides the fade behind a tiny speck). - .transition(.scale(scale: 0.6).combined(with: .opacity)) } + .animation(Self.sendButtonSpring, value: showsSubmit) } - .animation(Self.sendButtonSpring, value: showsSubmit) return field .padding(.leading, 14) diff --git a/FlipcashUITests/Smoke/ComposerClearSmokeTests.swift b/FlipcashUITests/Smoke/ComposerClearSmokeTests.swift new file mode 100644 index 000000000..514462070 --- /dev/null +++ b/FlipcashUITests/Smoke/ComposerClearSmokeTests.swift @@ -0,0 +1,110 @@ +// +// ComposerClearSmokeTests.swift +// FlipcashUITests +// + +import XCTest + +/// Sending has to leave the composer empty. It sometimes did not: the sent text stayed in the +/// field, so the next message had to be erased before one could be written. +/// +/// The failure is a race between the last keystroke and the send, not a property of any one +/// message, so a single send proves nothing either way. Each test sends a run of them and checks +/// the field after every one. +/// +/// **Prerequisites:** the `FLIPCASH_UI_TEST_ACCESS_KEY` account needs at least one chat +/// conversation. With none, each test skips rather than fails. +@MainActor +final class ComposerClearSmokeTests: BaseUITestCase { + + override var requiresAuthentication: Bool { true } + + /// Sends per test. Enough runs at the race to make a real failure likely, few enough that the + /// suite stays inside its time allowance — the sends are real, and each one reaches the server. + private static let sendCount = 6 + + private var conversation: ConversationUIScreen { ConversationUIScreen(app: app) } + + override func setUp() async throws { + try await super.setUp() + // A run of round-trip sends passes XCTest's 2-minute default. + executionTimeAllowance = 600 + } + + /// The plain case: type, send, and the field is empty and ready for the next message. + /// + /// Delivery is deliberately not awaited between sends. What is under test is the composer's + /// state the instant the send is dispatched, and waiting on a receipt would settle the very + /// window the failure lives in. + func testSendingMessages_leavesTheComposerEmptyEachTime() throws { + try openConversation() + + for attempt in 1...Self.sendCount { + let message = Self.uniqueText("clear \(attempt)") + conversation.sendWithoutSettling(message, from: self) + conversation.assertComposerCleared(of: message) + } + } + + /// The reply branch of the send, which empties the field through the same call while also + /// taking the composer out of replying — a second state change landing on the same update. + func testSendingAReply_leavesTheComposerEmpty() throws { + try openConversation() + + let original = Self.uniqueText("original") + conversation.sendMessage(original, from: self) + conversation.assertMessageDelivered(original) + + let answer = Self.uniqueText("answer") + conversation.beginReply(to: original, from: self) + conversation.sendWithoutSettling(answer, from: self) + conversation.assertComposerCleared(of: answer) + } + + /// A draft written and sent in one go, with no pause anywhere in it. Typing the body in + /// separate bursts leaves the field mid-edit when the send lands, which is the state the lost + /// clear needs and the one a single `typeText` is least likely to produce on its own. + func testSendingMidEdit_leavesTheComposerEmpty() throws { + try openConversation() + + for attempt in 1...Self.sendCount { + let message = Self.uniqueText("burst \(attempt)") + waitUntilHittableAndTap(conversation.messageField) + + let send = conversation.composerSendButton + for word in message.split(separator: " ") { + conversation.messageField.typeText("\(word) ") + } + send.tap() + + conversation.assertComposerCleared(of: message) + } + } + + // MARK: - Helpers + + /// Opens the account's first chat, skipping the test when it has none — the suite drives an + /// existing conversation rather than creating one, so an empty list is a missing fixture and + /// not a defect. + private func openConversation() throws { + assertMainScreenReached() + + let chats = TipsUIScreen(app: app) + chats.open(from: self) + + guard let row = chats.firstConversationRow(timeout: 30) else { + throw XCTSkip("The test account has no chat conversation — skipping the composer suite") + } + row.tap() + + XCTAssertTrue( + conversation.messageField.waitForExistence(timeout: 30), + "Expected the conversation's composer. On screen: [\(visibleText())]" + ) + } + + /// A per-run body, so a field query can never match text left behind by an earlier run. + private static func uniqueText(_ prefix: String) -> String { + "\(prefix) \(Int(Date().timeIntervalSince1970 * 1000) % 1_000_000)" + } +} diff --git a/FlipcashUITests/Support/Screens/ConversationUIScreen.swift b/FlipcashUITests/Support/Screens/ConversationUIScreen.swift index 74ebce1bb..0147318e1 100644 --- a/FlipcashUITests/Support/Screens/ConversationUIScreen.swift +++ b/FlipcashUITests/Support/Screens/ConversationUIScreen.swift @@ -30,6 +30,10 @@ struct ConversationUIScreen { var deliveredReceipt: XCUIElement { app.staticTexts["Delivered"] } + /// What the field currently shows. An empty `TextField` reports its placeholder here, so an + /// empty composer reads as "Message" (or "Reply" mid-reply), never as "". + var draftValue: String { messageField.value as? String ?? "" } + func messageBubble(_ text: String) -> XCUIElement { app.staticTexts[text] } // MARK: - Reply elements @@ -70,6 +74,18 @@ struct ConversationUIScreen { testCase.waitAndTap(composerSendButton) } + /// Types `text` and sends it without waiting on the send button first. + /// + /// `sendMessage` waits for the button to exist before tapping, which parks a beat between the + /// last keystroke and the send. That beat is exactly the window where a clear can be lost, so a + /// test hunting for it has to resolve the button up front and tap as soon as typing returns. + func sendWithoutSettling(_ text: String, from testCase: BaseUITestCase) { + testCase.waitUntilHittableAndTap(messageField) + let send = composerSendButton + messageField.typeText(text) + send.tap() + } + // MARK: - Reply actions /// Long-presses `text`'s bubble and waits for its context menu. @@ -150,6 +166,29 @@ struct ConversationUIScreen { ) } + /// Asserts the composer no longer holds `text`. + /// + /// Polls rather than reading once, so a clear that merely arrives late still passes and only a + /// clear that never arrives fails. An empty SwiftUI `TextField` reports its placeholder as its + /// value, so "empty" is "no longer contains what was sent" rather than an empty string. + /// + /// The send button's state is what makes a failure diagnosable: it is drawn from the same + /// binding the field is, so a field still showing `text` with the button already gone means the + /// binding emptied and the update never reached the text view. With the button still up, + /// nothing was sent at all. + func assertComposerCleared(of text: String, timeout: TimeInterval = 5) { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if !draftValue.contains(text) { return } + Thread.sleep(forTimeInterval: 0.25) + } + + let diagnosis = composerSendButton.exists + ? "the send button is still up, so the send never fired" + : "the send button is gone, so the draft emptied and the field never caught up" + XCTFail("Expected the composer to be empty after sending '\(text)', still shows '\(draftValue)' — \(diagnosis)") + } + /// Asserts no reply is open on the composer. func assertNoReplyStrip(timeout: TimeInterval = 5) { let gone = NSPredicate(format: "exists == false")