Ctrl K
ComunidadGuías…

EchoUITests/ComposerUITests.swift

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import XCTest

/// The message field's conveniences, with no server: a draft per conversation, Return that sends
/// when asked to, the page of its own for a long message, the microphone, and a pasted picture.
@MainActor
final class ComposerUITests: XCTestCase {
    private let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")

    /// On the OpenAI-compatible connection, pointed at nothing: messages can be sent (and fail),
    /// and the conversation list is the phone's own.
    private func launch(_ extra: [String] = []) -> XCUIApplication {
        let app = XCUIApplication()
        app.launchArguments = XCUIApplication.straightToChat + XCUIApplication.chatsSection
            + ["-transport", "chatCompletions", "-fastLaneURL", "http://127.0.0.1:9", "-fastLaneModel", "test"] + extra
        if extra.contains("-echo.fresh") { app.launchArguments.append("-echo.clearDrafts") }   // nothing left by an earlier test
        app.launch()
        return app
    }

    /// The message field (on an iPad the sidebar's search field comes first).
    private func field(_ app: XCUIApplication) -> XCUIElement {
        let field = app.textFields["composer"]
        XCTAssertTrue(field.waitForExistence(timeout: 20), "No message field")
        return field
    }

    private func keep(_ name: String) {
        let shot = XCTAttachment(screenshot: XCUIScreen.main.screenshot())
        shot.name = name
        shot.lifetime = .keepAlways
        add(shot)
    }

    /// Taps the field until it has the keyboard's focus: right after a launch the first tap can
    /// land while the app is still settling. (Not "until the keyboard is on screen": with a
    /// hardware keyboard attached to the simulator there may be none to see.)
    private func focus(_ field: XCUIElement, in app: XCUIApplication) {
        for _ in 0 ..< 4 {
            field.tap()
            for _ in 0 ..< 6 {
                if (field.value(forKey: "hasKeyboardFocus") as? Bool) == true || app.keyboards.firstMatch.exists { return }
                usleep(500_000)
            }
        }
        XCTFail("The message field didn't take the keyboard's focus")
    }

    private func type(_ text: String, into field: XCUIElement, in app: XCUIApplication) {
        focus(field, in: app)
        field.typeText(text)
    }

    private func clear(_ field: XCUIElement) {
        let text = field.value as? String ?? ""
        guard !text.isEmpty, text != field.placeholderValue else { return }
        field.tap()
        field.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: text.count + 2))
    }

    /// Removes a conversation a test made, so the next run starts as this one did.
    private func delete(conversation title: String, in app: XCUIApplication) {
        app.buttons["Conversations"].firstMatch.tap()
        app.deleteConversation(titled: title)
    }

    /// Taps a conversation's row in the list: the one on screen, not the open conversation's
    /// header of the same title behind the list. (After the relaunch the hike is the open one,
    /// unless some newer conversation is left on the simulator.)
    private func tapRow(_ title: String, in app: XCUIApplication) {
        guard let row = app.staticTexts.matching(identifier: title).hittable(within: 5) else { return XCTFail("No row titled \(title) to tap") }
        row.tap()
    }

    func testEachConversationKeepsItsOwnDraft() {
        continueAfterFailure = false
        var app = launch(["-echo.fresh", "-echo.demoLibrary"])
        var composer = field(app)
        clear(composer)
        type("for a new conversation", into: composer, in: app)

        // Another conversation: its own draft, empty so far.
        app.buttons["Conversations"].firstMatch.tap()
        let hike = app.staticTexts["Plan the weekend hike"]
        XCTAssertTrue(hike.waitForExistence(timeout: 10), "The demo conversations aren't in the list")
        tapRow("Plan the weekend hike", in: app)
        composer = field(app)
        XCTAssertTrue(app.navigationBars.staticTexts["Plan the weekend hike"].waitForExistence(timeout: 10))
        clear(composer)
        XCTAssertNotEqual(composer.value as? String, "for a new conversation", "The new conversation's draft followed into another one")
        type("about the hike", into: composer, in: app)
        keep("a draft in an existing conversation")

        // Back to a new conversation: what was typed there is there.
        app.buttons["New conversation"].firstMatch.tap()
        XCTAssertEqual(field(app).value as? String, "for a new conversation")

        // And the hike's is still with the hike, after the app has been closed too. The list
        // says which conversation has something unsent.
        app.buttons["Conversations"].firstMatch.tap()
        XCTAssertTrue(hike.waitForExistence(timeout: 10))
        XCTAssertTrue(app.staticTexts["Has a draft"].waitForExistence(timeout: 5), "The list doesn't mark the conversation with a draft")
        XCTAssertEqual(app.staticTexts.matching(identifier: "Has a draft").count, 1, "Only the one conversation has a draft")
        keep("a draft marked in the list")
        tapRow("Plan the weekend hike", in: app)
        XCTAssertEqual(field(app).value as? String, "about the hike")
        XCUIDevice.shared.press(.home)   // drafts are written as the app leaves the screen
        sleep(1)
        app.terminate()
        app = launch()
        _ = field(app)
        app.buttons["Conversations"].firstMatch.tap()
        XCTAssertTrue(hike.waitForExistence(timeout: 10))
        tapRow("Plan the weekend hike", in: app)
        XCTAssertTrue(app.navigationBars.staticTexts["Plan the weekend hike"].waitForExistence(timeout: 10))
        XCTAssertEqual(field(app).value as? String, "about the hike", "The draft didn't survive the app being closed")
        clear(field(app))
    }

    func testReturnSendsOnlyWhenSetTo() {
        continueAfterFailure = false
        var app = launch(["-echo.fresh", "-returnSends", "NO"])
        var composer = field(app)
        clear(composer)
        type("first line\nsecond line", into: composer, in: app)
        XCTAssertEqual(composer.value as? String, "first line\nsecond line", "Return didn't start a new line")
        clear(composer)
        app.terminate()

        app = launch(["-echo.fresh", "-returnSends", "YES"])
        composer = field(app)
        clear(composer)
        type("sent by Return\n", into: composer, in: app)
        // The message is in the conversation (the header takes its first words) and the field is empty again.
        XCTAssertTrue(app.navigationBars.staticTexts["sent by Return"].waitForExistence(timeout: 10), "Return didn't send")
        let left = composer.value as? String ?? ""
        XCTAssertTrue(left.isEmpty || left == composer.placeholderValue, "The field kept \(left)")
        keep("sent by Return")
        delete(conversation: "sent by Return", in: app)
    }

    func testALongMessageOpensOnAPageOfItsOwn() {
        continueAfterFailure = false
        let app = launch(["-echo.fresh"])
        let composer = field(app)
        clear(composer)
        XCTAssertFalse(app.buttons["Open the editor"].exists, "The editor button shows for an empty field")
        type(String(repeating: "A long message needs room. ", count: 8), into: composer, in: app)
        let open = app.buttons["Open the editor"]
        XCTAssertTrue(open.waitForExistence(timeout: 5), "No editor button for a long message")
        open.tap()
        XCTAssertTrue(app.navigationBars["Message"].waitForExistence(timeout: 10), "The editor didn't open")
        let editor = app.textViews["Message"]
        XCTAssertTrue(editor.waitForExistence(timeout: 5))
        XCTAssertTrue((editor.value as? String ?? "").hasPrefix("A long message needs room."), "The editor doesn't hold the draft")
        editor.typeText("\nAnd a second paragraph.")
        keep("the editor")
        app.navigationBars["Message"].buttons["Done"].tap()
        XCTAssertTrue((field(app).value as? String ?? "").hasSuffix("And a second paragraph."), "What was written in the editor didn't come back to the field")
        clear(field(app))
    }

    func testTheMicrophoneInTheField() {
        continueAfterFailure = false
        let app = launch(["-echo.fresh"])
        _ = field(app)
        let dictate = app.buttons["Dictate"]
        XCTAssertTrue(dictate.waitForExistence(timeout: 10), "No Dictate button in the field")
        dictate.tap()
        for label in ["Allow", "OK"] where springboard.alerts.buttons[label].waitForExistence(timeout: 4) { springboard.alerts.buttons[label].tap() }
        // In the simulator the speech model may never arrive: either it listens, or it says why
        // not. Both leave the button a tap away from where it started.
        let stop = app.buttons["Stop dictating"]
        if stop.waitForExistence(timeout: 6) {
            keep("dictating")
            stop.tap()
        }
        XCTAssertTrue(dictate.waitForExistence(timeout: 15), "The microphone button didn't come back")
    }

    func testAPictureOnTheClipboardPastesAsAnAttachment() {
        continueAfterFailure = false
        // A picture, and nothing else, on the clipboard.
        let renderer = UIGraphicsImageRenderer(size: CGSize(width: 240, height: 160))
        UIPasteboard.general.image = renderer.image { context in
            UIColor.systemTeal.setFill()
            context.fill(CGRect(x: 0, y: 0, width: 240, height: 160))
        }
        let app = launch(["-echo.fresh"])
        let composer = field(app)
        clear(composer)
        focus(composer, in: app)
        sleep(1)
        composer.press(forDuration: 1.2)
        let paste = app.menuItems["Paste"]
        XCTAssertTrue(paste.waitForExistence(timeout: 5), "The field offers no Paste for a picture")
        paste.tap()
        for label in ["Allow Paste", "Allow"] where springboard.alerts.buttons[label].waitForExistence(timeout: 3) { springboard.alerts.buttons[label].tap() }
        let remove = app.buttons.matching(NSPredicate(format: "label BEGINSWITH %@", "Remove ")).firstMatch
        XCTAssertTrue(remove.waitForExistence(timeout: 10), "The pasted picture isn't attached")
        let left = composer.value as? String ?? ""
        XCTAssertTrue(left.isEmpty || left == composer.placeholderValue, "Pasting a picture put text in the field: \(left)")
        keep("a pasted picture")
        remove.tap()
    }
}