| 1 | import XCTest |
| 2 | @testable import LChatCore |
| 3 | |
| 4 | // π€ The Swift half of the flow-interpreter drift gate: runs the REAL bundled |
| 5 | // flow documents (Flows/*.json β byte-derived from *.flow.js, enforced by |
| 6 | // test/flow.test.mjs) through FlowEngine with a scripted io, mirroring |
| 7 | // test/flow.test.mjs's harness. The native runs here are the ones the app |
| 8 | // ships: env.native skips every passkey step. |
| 9 | @MainActor |
| 10 | final class FlowEngineTests: XCTestCase { |
| 11 | |
| 12 | // MARK: harness (the Swift twin of flow.test.mjs's harness()) |
| 13 | |
| 14 | final class Harness: FlowIO { |
| 15 | struct Line { |
| 16 | let text: String |
| 17 | let chips: [FlowChip]? |
| 18 | let extra: FlowSayExtra |
| 19 | } |
| 20 | var transcript: [Line] = [] |
| 21 | var records: [(step: String, label: String, value: String)] = [] |
| 22 | var verbLog: [(String, [FlowJSON])] = [] |
| 23 | var doneSteps: [String: Bool] = [:] |
| 24 | var answerHandler: ((FlowAnswer) -> Void)? |
| 25 | var cancelled = 0 |
| 26 | var pendingDelays: [(ms: Double, fn: () -> Void)] = [] |
| 27 | var verbResults: [String: FlowJSON] = [:] |
| 28 | var ended = false |
| 29 | |
| 30 | func say(_ text: String, chips: [FlowChip]?, extra: FlowSayExtra) async { |
| 31 | transcript.append(Line(text: text, chips: chips, extra: extra)) |
| 32 | } |
| 33 | func ask(_ handler: @escaping (FlowAnswer) -> Void) { answerHandler = handler } |
| 34 | func cancelAnswer() { |
| 35 | cancelled += 1 |
| 36 | answerHandler = nil |
| 37 | } |
| 38 | func verb(_ name: String, args: [FlowJSON]) async -> FlowJSON { |
| 39 | verbLog.append((name, args)) |
| 40 | return verbResults[name] ?? .null |
| 41 | } |
| 42 | func record(step: String, label: String, value: String) async { |
| 43 | records.append((step, label, value)) |
| 44 | doneSteps[step] = true |
| 45 | } |
| 46 | func done(_ step: String) -> Bool { doneSteps[step] ?? false } |
| 47 | func delay(ms: Double, _ fn: @escaping () -> Void) { pendingDelays.append((ms, fn)) } |
| 48 | |
| 49 | var lastChips: [FlowChip]? { |
| 50 | transcript.reversed().first(where: { $0.chips != nil })?.chips |
| 51 | } |
| 52 | func tap(_ label: String) async { |
| 53 | guard let chip = lastChips?.first(where: { $0.label == label }) else { |
| 54 | return XCTFail("no chip \(label); have \(lastChips?.map(\.label) ?? [])") |
| 55 | } |
| 56 | chip.run() |
| 57 | await settle() |
| 58 | } |
| 59 | func answer(_ text: String, image: [String: String]? = nil) async { |
| 60 | guard let h = answerHandler else { return XCTFail("no answer waiter armed") } |
| 61 | answerHandler = nil |
| 62 | h(FlowAnswer(text: text, imageAttachment: image)) |
| 63 | await settle() |
| 64 | } |
| 65 | // Let the engine's fire-and-forget Tasks drain (all MainActor). |
| 66 | func settle() async { |
| 67 | for _ in 0..<40 { await Task.yield() } |
| 68 | } |
| 69 | } |
| 70 | |
| 71 | // The Flows resources live in the LChatCore module bundle (AppFlows reads |
| 72 | // Bundle.module from inside the library, which resolves correctly here). |
| 73 | func moduleFlow(_ name: String) throws -> FlowJSON { |
| 74 | try XCTUnwrap(AppFlows.flow(name), "bundled flow \(name).json") |
| 75 | } |
| 76 | |
| 77 | func nativeEnv(name: String = "", space: String = "Test Space", |
| 78 | homeSignedIn: Bool = true) -> FlowEnv { |
| 79 | let env = FlowEnv() |
| 80 | env.flags["native"] = .bool(true) |
| 81 | env.flags["suggested"] = .string("") |
| 82 | env.flags["inviterName"] = .string("") |
| 83 | env.flags["adminName"] = .string("") |
| 84 | env.flags["hasPendingThread"] = .bool(false) |
| 85 | env.space = ["name": .string(space), "nameSet": .bool(!space.isEmpty)] |
| 86 | env.live["bridge.myName"] = { .string(name) } |
| 87 | env.live["bridge.enrolled"] = { .bool(false) } |
| 88 | env.live["bridge.fromHome"] = { .bool(false) } |
| 89 | // Matches the JS harness default. Without it every flow read |
| 90 | // "!bridge.canEnrol" as TRUE β a self-hosted space with no sign-in to |
| 91 | // offer β so the welcome access step recorded "your access lives in |
| 92 | // this browser" and skipped past its chips, quietly rotting |
| 93 | // testQuickFormTrailingChip (red on main since the #1377 gate landed). |
| 94 | env.live["bridge.canEnrol"] = { .bool(true) } |
| 95 | // Mirrors ElevenConversationModel: both read the live home session. |
| 96 | env.live["bridge.homeSignedIn"] = { .bool(homeSignedIn) } |
| 97 | env.live["accessBackedUp"] = { .bool(homeSignedIn) } |
| 98 | return env |
| 99 | } |
| 100 | |
| 101 | // MARK: template + condition mini-language (mirrors the JS test) |
| 102 | |
| 103 | func testTemplateAndConditions() throws { |
| 104 | let env = FlowEnv() |
| 105 | env.vars["x"] = .string("hi") |
| 106 | env.space = ["name": .string("")] |
| 107 | env.ack = "A" |
| 108 | let engine = FlowEngine(flow: .object([:]), env: env, io: Harness()) |
| 109 | XCTAssertEqual(engine.renderText(.string("{vars.x} {space.name|fallback}")), "hi fallback") |
| 110 | XCTAssertEqual(engine.renderText(.string("{ack?}rest")), "A\n\nrest") |
| 111 | XCTAssertEqual(engine.renderText(.string("{ack?}rest")), "rest", "ack is consumed") |
| 112 | XCTAssertEqual(engine.renderText(.array([.string("a"), |
| 113 | .object(["if": .string("vars.x"), "text": .string("-b")]), |
| 114 | .object(["if": .string("vars.missing"), "text": .string("-c")])])), "a-b") |
| 115 | XCTAssertEqual(engine.renderText(.object(["if": .string("vars.missing"), "then": .string("T"), "else": .string("E")])), "E") |
| 116 | XCTAssertTrue(engine.evalCond(.string("!vars.missing"))) |
| 117 | } |
| 118 | |
| 119 | // MARK: the native creator setup run (mirrors the JS native test) |
| 120 | |
| 121 | func testNativeSetupRun() async throws { |
| 122 | let flow = try moduleFlow("setup") |
| 123 | let io = Harness() |
| 124 | io.verbResults["setGroupName"] = .string("ok") |
| 125 | let env = nativeEnv(name: "Paul") |
| 126 | let engine = FlowEngine(flow: flow, env: env, io: io) |
| 127 | engine.start() |
| 128 | await io.settle() |
| 129 | defer { withExtendedLifetime(engine) {} } |
| 130 | |
| 131 | // First run WITH a name: greeted (not "resumed"), name step skipped. |
| 132 | XCTAssertTrue(io.transcript.first?.text.hasPrefix("Hi β I'm Eleven! π") ?? false) |
| 133 | XCTAssertFalse(io.transcript.contains { $0.text.contains("Welcome back") }) |
| 134 | XCTAssertFalse(io.transcript.contains { $0.text.contains("what should everyone here call you") }) |
| 135 | XCTAssertTrue(io.transcript.contains { $0.extra.key == "q-group" }) |
| 136 | |
| 137 | await io.answer("Team Awesome") |
| 138 | env.space["name"] = .string("Team Awesome") // the web bridge updates space; the harness mirrors it |
| 139 | await io.tap("Skip for now") // email; the photo step auto-skips under native |
| 140 | XCTAssertFalse(io.transcript.contains { $0.extra.key == "q-photo" }) |
| 141 | await io.tap("Members can invite people") |
| 142 | // The extras trio (mirrors the JS native run): the one-tap common path |
| 143 | // records and writes no gates β defaults are already on server-side. |
| 144 | await io.tap("Keep both on") |
| 145 | XCTAssertTrue(io.records.contains { $0.step == "extras" && $0.value == "all-on" }) |
| 146 | XCTAssertFalse(io.verbLog.contains { $0.0 == "setFeature" }) |
| 147 | // The AI opt-in step (#877) runs under native too β mirror the JS twin. |
| 148 | await io.tap("Not now") |
| 149 | |
| 150 | // Keys: never pitched β recorded as covered by the Eleven account. |
| 151 | XCTAssertFalse(io.transcript.contains { $0.text.contains("Now the most important thing of all") }) |
| 152 | XCTAssertFalse(io.verbLog.contains { $0.0 == "savePasskey" }) |
| 153 | let keys = io.records.first { $0.step == "keys" } |
| 154 | XCTAssertEqual(keys?.label, "Your space's key is saved with your Eleven account") |
| 155 | XCTAssertEqual(keys?.value, "native") |
| 156 | |
| 157 | // Finish: completion marker, setup marked complete, no backup nag, no |
| 158 | // cross-thread admin chip. |
| 159 | XCTAssertEqual(io.records.last?.step, "finish") |
| 160 | XCTAssertEqual(io.records.last?.value, "done") |
| 161 | XCTAssertTrue(io.verbLog.contains { $0.0 == "markSetupComplete" }) |
| 162 | XCTAssertFalse(io.verbLog.contains { $0.0 == "deferBackupReminders" }) |
| 163 | XCTAssertFalse(io.transcript.contains { $0.text.contains("One more reminder") }) |
| 164 | // The tour stays web-only; Admin hops natively now. |
| 165 | let allChips = io.transcript.flatMap { $0.chips ?? [] }.map(\.label) |
| 166 | XCTAssertFalse(allChips.contains("Take the tour")) |
| 167 | XCTAssertTrue(allChips.contains("Open admin")) |
| 168 | } |
| 169 | |
| 170 | // MARK: native without an Eleven session |
| 171 | |
| 172 | // Twin of "setup: native without an Eleven session says where the key |
| 173 | // really is". Claiming a space pushes it into the account's vault through |
| 174 | // HomeSync, which no-ops silently when signed out β so the conversation |
| 175 | // must say where the key actually is instead of naming an account. |
| 176 | func testNativeWithoutHomeSessionIsHonest() async throws { |
| 177 | let flow = try moduleFlow("setup") |
| 178 | let io = Harness() |
| 179 | io.verbResults["setGroupName"] = .string("ok") |
| 180 | let env = nativeEnv(name: "Paul", homeSignedIn: false) |
| 181 | // A host that wired NO signInToEleven (an iOS 17 device, say): the |
| 182 | // model reports canEnrol false and the flow must record where the key |
| 183 | // is rather than pitch something that goes nowhere. |
| 184 | env.live["bridge.canEnrol"] = { .bool(false) } |
| 185 | let engine = FlowEngine(flow: flow, env: env, io: io) |
| 186 | engine.start() |
| 187 | await io.settle() |
| 188 | defer { withExtendedLifetime(engine) {} } |
| 189 | |
| 190 | await io.answer("Team Awesome") |
| 191 | env.space["name"] = .string("Team Awesome") |
| 192 | await io.tap("Skip for now") |
| 193 | await io.tap("Members can invite people") |
| 194 | await io.tap("Keep both on") |
| 195 | await io.tap("Not now") |
| 196 | |
| 197 | let keys = io.records.first { $0.step == "keys" } |
| 198 | XCTAssertEqual(keys?.label, "This device holds this space's key") |
| 199 | XCTAssertEqual(keys?.value, "local") |
| 200 | XCTAssertFalse(io.verbLog.contains { $0.0 == "savePasskey" }, "no dead sign-in chip") |
| 201 | XCTAssertFalse(io.transcript.flatMap { $0.chips ?? [] }.map(\.label).contains("Sign in")) |
| 202 | XCTAssertTrue(io.verbLog.contains { $0.0 == "markSetupComplete" }, "still reaches finish") |
| 203 | } |
| 204 | |
| 205 | // A native host that CAN sign in (signInToEleven wired β canEnrol true) |
| 206 | // pitches it in the app's own words, and settles the moment the session |
| 207 | // lands β no "Continue" tap. Twin of "setup: native signed out β pitched |
| 208 | // in device words, settles on the sign-in". |
| 209 | func testNativeSignedOutIsPitchedAndSettles() async throws { |
| 210 | let flow = try moduleFlow("setup") |
| 211 | let io = Harness() |
| 212 | io.verbResults["setGroupName"] = .string("ok") |
| 213 | var signedIn = false |
| 214 | let env = nativeEnv(name: "Paul", space: "") |
| 215 | env.live["bridge.homeSignedIn"] = { .bool(signedIn) } |
| 216 | env.live["accessBackedUp"] = { .bool(signedIn) } |
| 217 | env.live["bridge.canEnrol"] = { .bool(true) } |
| 218 | let engine = FlowEngine(flow: flow, env: env, io: io) |
| 219 | engine.start() |
| 220 | await io.settle() |
| 221 | defer { withExtendedLifetime(engine) {} } |
| 222 | |
| 223 | await io.answer("Team Awesome") |
| 224 | env.space["name"] = .string("Team Awesome") |
| 225 | await io.tap("Skip for now") |
| 226 | await io.tap("Members can invite people") |
| 227 | await io.tap("Keep both on") |
| 228 | await io.tap("Not now") |
| 229 | |
| 230 | let pitch = io.transcript.first { $0.text.contains("Now the most important thing of all") } |
| 231 | XCTAssertNotNil(pitch, "the key pitch runs") |
| 232 | XCTAssertTrue(pitch?.text.contains("this device holds the only key") ?? false, |
| 233 | "device words, not browser words") |
| 234 | |
| 235 | await io.tap("Sign in") |
| 236 | XCTAssertTrue(io.verbLog.contains { $0.0 == "savePasskey" }, |
| 237 | "the host is asked to open its sign-in") |
| 238 | |
| 239 | // The app signs in; the step's settle poll notices on its own. |
| 240 | signedIn = true |
| 241 | let poll = io.pendingDelays.last { $0.0 == 700 } |
| 242 | XCTAssertNotNil(poll, "the keys step arms a settle poll") |
| 243 | poll?.1() |
| 244 | await io.settle() |
| 245 | let keys = io.records.first { $0.step == "keys" } |
| 246 | XCTAssertEqual(keys?.label, "Your space's key is saved with your Eleven account") |
| 247 | XCTAssertEqual(keys?.value, "native") |
| 248 | XCTAssertTrue(io.verbLog.contains { $0.0 == "markSetupComplete" }, "carried on to finish") |
| 249 | } |
| 250 | |
| 251 | // MARK: input.confirm β an email answered as a name |
| 252 | |
| 253 | // Twin of "setup: an email answered as a name is confirmed, not swallowed" |
| 254 | // in test/flow.test.mjs. Both engines must hold the answer back until the |
| 255 | // person confirms it: nothing recorded, no setMyName, no move on. |
| 256 | func testEmailAnsweredAsNameIsConfirmed() async throws { |
| 257 | let flow = try moduleFlow("setup") |
| 258 | let io = Harness() |
| 259 | io.verbResults["setMyName"] = .string("ok") |
| 260 | io.verbResults["setGroupName"] = .string("ok") |
| 261 | let env = nativeEnv(name: "", space: "") // no name yet: the question is asked |
| 262 | let engine = FlowEngine(flow: flow, env: env, io: io) |
| 263 | engine.start() |
| 264 | await io.settle() |
| 265 | defer { withExtendedLifetime(engine) {} } |
| 266 | |
| 267 | await io.answer("paul@rslw.com") |
| 268 | XCTAssertTrue(io.transcript.last?.text.contains("That's an email address") ?? false, |
| 269 | "the confirm step must come up") |
| 270 | XCTAssertFalse(io.verbLog.contains { $0.0 == "setMyName" }, "nothing set yet") |
| 271 | XCTAssertFalse(io.records.contains { $0.step == "name" }, "nothing recorded yet") |
| 272 | |
| 273 | await io.tap("Use it anyway") |
| 274 | XCTAssertTrue(io.verbLog.contains { $0.0 == "setMyName" }) |
| 275 | XCTAssertEqual(io.records.filter { $0.step == "name" }.count, 1, |
| 276 | "the confirm records the NAME step, once") |
| 277 | XCTAssertTrue(io.transcript.contains { $0.extra.key == "q-group" }, |
| 278 | "carried on to the space name") |
| 279 | } |
| 280 | |
| 281 | // Twin of the looksLikeEmail cases in test/flow.test.mjs. |
| 282 | func testLooksLikeEmail() { |
| 283 | for yes in ["paul@rslw.com", " Ada@example.co.uk ", "a.b+c@sub.domain.org"] { |
| 284 | XCTAssertTrue(looksLikeEmail(yes), yes) |
| 285 | } |
| 286 | for no in ["Ada", "Ada @ the shop", "@ada", "ada@", "ada@localhost", "", "two@parts@here.com"] { |
| 287 | XCTAssertFalse(looksLikeEmail(no), no) |
| 288 | } |
| 289 | } |
| 290 | |
| 291 | // MARK: the native joiner welcome run |
| 292 | |
| 293 | // Twin of the cleanNameAnswer cases in test/flow.test.mjs β both engines |
| 294 | // must clean a conversational name reply identically. |
| 295 | func testCleanNameAnswer() { |
| 296 | XCTAssertEqual(cleanNameAnswer("I'm Dermot"), "Dermot") |
| 297 | XCTAssertEqual(cleanNameAnswer("I\u{2019}m Dermot"), "Dermot") // iOS smart apostrophe |
| 298 | XCTAssertEqual(cleanNameAnswer("i am dermot"), "dermot") // case kept as typed |
| 299 | XCTAssertEqual(cleanNameAnswer("My name is Ana."), "Ana") |
| 300 | XCTAssertEqual(cleanNameAnswer("Hi, I'm Dermot!"), "Dermot") |
| 301 | XCTAssertEqual(cleanNameAnswer("hello my name's Bo"), "Bo") |
| 302 | XCTAssertEqual(cleanNameAnswer("you can call me Dermo"), "Dermo") |
| 303 | XCTAssertEqual(cleanNameAnswer("It's Maud"), "Maud") |
| 304 | XCTAssertEqual(cleanNameAnswer("Im Dermot"), "Dermot") // apostrophe skipped |
| 305 | |
| 306 | XCTAssertEqual(cleanNameAnswer("Dermot"), "Dermot") |
| 307 | XCTAssertEqual(cleanNameAnswer(" Dermot "), "Dermot") |
| 308 | XCTAssertEqual(cleanNameAnswer("Hilary"), "Hilary") // "hi" needs a boundary |
| 309 | XCTAssertEqual(cleanNameAnswer("Ima Jean"), "Ima Jean") // "im" needs a space |
| 310 | XCTAssertEqual(cleanNameAnswer("Mary-Kate O'Brien"), "Mary-Kate O'Brien") |
| 311 | // Stripping must never clean a name to nothing β keep the original. |
| 312 | XCTAssertEqual(cleanNameAnswer("I'm"), "I'm") |
| 313 | XCTAssertEqual(cleanNameAnswer(""), "") |
| 314 | } |
| 315 | |
| 316 | func testNativeWelcomeRun() async throws { |
| 317 | let flow = try moduleFlow("welcome") |
| 318 | let io = Harness() |
| 319 | let env = nativeEnv() |
| 320 | env.flags["inviterName"] = .string("Ciara") |
| 321 | let engine = FlowEngine(flow: flow, env: env, io: io) |
| 322 | engine.start() |
| 323 | await io.settle() |
| 324 | defer { withExtendedLifetime(engine) {} } |
| 325 | |
| 326 | // Greeting names the inviter; the passkey ask never renders; access |
| 327 | // records the native reason; the name question is armed. |
| 328 | XCTAssertTrue(io.transcript.first?.text.contains("Ciara invited you to Test Space") ?? false) |
| 329 | XCTAssertFalse(io.transcript.contains { $0.text.contains("Sign in with Eleven and your access is safe") }) |
| 330 | let access = io.records.first |
| 331 | XCTAssertEqual(access?.step, "access") |
| 332 | XCTAssertEqual(access?.value, "native") |
| 333 | XCTAssertTrue(io.transcript.contains { $0.extra.key == "q-name" }) |
| 334 | |
| 335 | // A conversational reply is cleaned to the bare name (input.clean). |
| 336 | await io.answer("Hi, I'm Paul!") |
| 337 | XCTAssertTrue(io.verbLog.contains { $0.0 == "setMyName" && $0.1.first == .string("Paul") }) |
| 338 | // The photo step auto-skips under native β finish follows directly. |
| 339 | XCTAssertFalse(io.transcript.contains { $0.extra.key == "q-photo" }) |
| 340 | |
| 341 | // Finish: no backup nag (access is the sign-in), completion recorded. |
| 342 | let fin = io.transcript.first { $0.extra.key == "q-finish" } |
| 343 | XCTAssertNotNil(fin) |
| 344 | XCTAssertFalse(fin?.text.contains("One more reminder") ?? true) |
| 345 | XCTAssertEqual(io.records.last?.step, "finish") |
| 346 | XCTAssertEqual(io.records.last?.value, "done") |
| 347 | XCTAssertTrue(io.ended == false) // onEnd fires on env, not io β checked below |
| 348 | } |
| 349 | |
| 350 | // MARK: the quick-form fast path (mirrors the JS trailing-chip test) |
| 351 | |
| 352 | func testQuickFormTrailingChip() async throws { |
| 353 | // A web-shaped env (native=false): the access step renders its chips β |
| 354 | // including the trailing quick-form chip, whose tap fires the |
| 355 | // openQuickForm verb WITHOUT consuming the step. |
| 356 | let flow = try moduleFlow("welcome") |
| 357 | let io = Harness() |
| 358 | let env = nativeEnv() |
| 359 | env.flags["native"] = .bool(false) |
| 360 | let engine = FlowEngine(flow: flow, env: env, io: io) |
| 361 | engine.start() |
| 362 | await io.settle() |
| 363 | defer { withExtendedLifetime(engine) {} } |
| 364 | |
| 365 | let chip = io.lastChips?.first { $0.label == "Fill in a form instead" } |
| 366 | XCTAssertNotNil(chip, "the welcome access step offers the quick-form chip") |
| 367 | XCTAssertEqual(chip?.trailing, true, "flagged trailing β the renderer right-aligns it") |
| 368 | XCTAssertEqual(io.lastChips?.first { $0.label == "Sign in (recommended)" }?.trailing, false, |
| 369 | "primary chips stay untagged") |
| 370 | await io.tap("Fill in a form instead") |
| 371 | XCTAssertTrue(io.verbLog.contains { $0.0 == "openQuickForm" }) |
| 372 | await io.tap("Sign in (recommended)") // non-advancing: the step is still live |
| 373 | XCTAssertTrue(io.verbLog.contains { $0.0 == "savePasskey" }) |
| 374 | |
| 375 | // Setup: the name step carries the same chip. |
| 376 | let flow2 = try moduleFlow("setup") |
| 377 | let io2 = Harness() |
| 378 | let env2 = nativeEnv() |
| 379 | env2.flags["native"] = .bool(false) |
| 380 | let engine2 = FlowEngine(flow: flow2, env: env2, io: io2) |
| 381 | engine2.start() |
| 382 | await io2.settle() |
| 383 | defer { withExtendedLifetime(engine2) {} } |
| 384 | let chip2 = io2.lastChips?.first { $0.label == "Fill in a form instead" } |
| 385 | XCTAssertNotNil(chip2, "the setup name step offers the quick-form chip") |
| 386 | XCTAssertEqual(chip2?.trailing, true) |
| 387 | await io2.tap("Fill in a form instead") |
| 388 | XCTAssertTrue(io2.verbLog.contains { $0.0 == "openQuickForm" }) |
| 389 | } |
| 390 | |
| 391 | // MARK: resume semantics |
| 392 | |
| 393 | func testResumeSkipsAnsweredSteps() async throws { |
| 394 | let flow = try moduleFlow("welcome") |
| 395 | let io = Harness() |
| 396 | io.doneSteps = ["access": true, "name": true] |
| 397 | let env = nativeEnv(name: "Paul") |
| 398 | var ended = false |
| 399 | env.onEnd = { ended = true } |
| 400 | let engine = FlowEngine(flow: flow, env: env, io: io) |
| 401 | engine.start() |
| 402 | await io.settle() |
| 403 | defer { withExtendedLifetime(engine) {} } |
| 404 | |
| 405 | // Resumed: the re-greeting, hello skipped; photo auto-skips under |
| 406 | // native, so the flow runs straight to finish and ends. |
| 407 | XCTAssertTrue(io.transcript.first?.text.hasPrefix("Welcome back") ?? false) |
| 408 | XCTAssertTrue(io.transcript.first?.extra.local ?? false) |
| 409 | XCTAssertFalse(io.transcript.contains { $0.text.hasPrefix("Welcome! π") }) |
| 410 | XCTAssertFalse(io.transcript.contains { $0.extra.key == "q-photo" }) |
| 411 | XCTAssertTrue(io.records.contains { $0.step == "finish" }) |
| 412 | XCTAssertTrue(ended, "end step fires env.onEnd") |
| 413 | } |
| 414 | |
| 415 | // MARK: a finished conversation never replays |
| 416 | |
| 417 | func testFinishedConversationEntersAndEndsQuietly() async throws { |
| 418 | let flow = try moduleFlow("welcome") |
| 419 | let io = Harness() |
| 420 | io.doneSteps = ["access": true, "name": true, "photo": true, "finish": true] |
| 421 | let env = nativeEnv(name: "Paul") |
| 422 | let engine = FlowEngine(flow: flow, env: env, io: io) |
| 423 | engine.start() |
| 424 | await io.settle() |
| 425 | defer { withExtendedLifetime(engine) {} } |
| 426 | // Finish is already recorded: no new records, and the only lines are |
| 427 | // the resume re-greeting + finish bubbles (chips stay live). |
| 428 | XCTAssertFalse(io.records.contains { $0.step != "finish" }) |
| 429 | XCTAssertEqual(io.records.count, 0) |
| 430 | } |
| 431 | } |
| 432 | |