| 1 | import XCTest |
| 2 | @testable import LChatCore |
| 3 | |
| 4 | // 🤖 The dismiss-a-labelled-ask contract (#1375), the native twin of the web's |
| 5 | // "every cancel fires the cancelled hook" test: waving the question off clears |
| 6 | // the composer pill (label + hint) AND disarms the flow's waiter, so the next |
| 7 | // plain message is an ordinary message — never a swallowed answer. The parked |
| 8 | // step re-arms on the next cold open, exactly like the web after a reload. |
| 9 | @MainActor |
| 10 | final class ElevenConversationModelTests: XCTestCase { |
| 11 | |
| 12 | // origin "" → no baseURL → api/socket are nil and every network path is a |
| 13 | // quiet no-op, so the model exercises pure conversation state. |
| 14 | private func makeModel() throws -> ElevenConversationModel { |
| 15 | let instance = SavedInstance(origin: "", deviceToken: "t", screenName: "Paul", |
| 16 | userPub: "", userPriv: "") |
| 17 | let thread = ChatThread(id: "t1", kind: "self", title: "Eleven", preview: "", |
| 18 | lastAt: 0, unread: false, pinned: false, hidden: false, |
| 19 | muted: nil, members: [], threadKeyB64: "x", avatar: nil, |
| 20 | isApp: true, appId: "setup", glyph: nil, badge: nil) |
| 21 | return try XCTUnwrap(ElevenConversationModel(instance: instance, thread: thread, |
| 22 | myUserId: "u1")) |
| 23 | } |
| 24 | |
| 25 | func testDismissClearsPillAndDisarmsPendingAnswer() async throws { |
| 26 | let model = try makeModel() |
| 27 | model.composerLabel = "Your name:" |
| 28 | model.composerHint = "Type your name" |
| 29 | var answered: [String] = [] |
| 30 | model.ask { answered.append($0.text) } |
| 31 | |
| 32 | model.cancelAnswer() |
| 33 | |
| 34 | XCTAssertNil(model.composerLabel, "dismiss drops the inset label") |
| 35 | XCTAssertNil(model.composerHint, "dismiss drops the hint with it") |
| 36 | await model.send("just a message") |
| 37 | XCTAssertEqual(answered, [], "a later plain message is never a swallowed answer") |
| 38 | guard case .mine(let text)? = model.rows.last?.kind else { |
| 39 | return XCTFail("the plain message still lands in the transcript") |
| 40 | } |
| 41 | XCTAssertEqual(text, "just a message") |
| 42 | } |
| 43 | |
| 44 | func testDismissWithNoWaiterStillClearsThePill() throws { |
| 45 | // The web twin: cancelAnswer on an idle thread still clears the label. |
| 46 | let model = try makeModel() |
| 47 | model.composerLabel = "Your name:" |
| 48 | model.composerHint = "Type your name" |
| 49 | model.cancelAnswer() |
| 50 | XCTAssertNil(model.composerLabel) |
| 51 | XCTAssertNil(model.composerHint) |
| 52 | } |
| 53 | |
| 54 | func testSendDeliversAnArmedAnswerAndDropsThePill() async throws { |
| 55 | // The undismissed path keeps working: an armed ask consumes the send. |
| 56 | let model = try makeModel() |
| 57 | model.composerLabel = "Your name:" |
| 58 | var answered: [String] = [] |
| 59 | model.ask { answered.append($0.text) } |
| 60 | await model.send("Paul") |
| 61 | XCTAssertEqual(answered, ["Paul"]) |
| 62 | XCTAssertNil(model.composerLabel, "the answered ask retires its label") |
| 63 | } |
| 64 | } |
| 65 | |