Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 24 additions & 17 deletions Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
110 changes: 110 additions & 0 deletions FlipcashUITests/Smoke/ComposerClearSmokeTests.swift
Original file line number Diff line number Diff line change
@@ -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)"
}
}
39 changes: 39 additions & 0 deletions FlipcashUITests/Support/Screens/ConversationUIScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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")
Expand Down
Loading