| 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") -> FlowEnv { |
| 78 | let env = FlowEnv() |
| 79 | env.flags["native"] = .bool(true) |
| 80 | env.flags["suggested"] = .string("") |
| 81 | env.flags["inviterName"] = .string("") |
| 82 | env.flags["adminName"] = .string("") |
| 83 | env.flags["hasPendingThread"] = .bool(false) |
| 84 | env.space = ["name": .string(space), "nameSet": .bool(!space.isEmpty)] |
| 85 | env.live["bridge.myName"] = { .string(name) } |
| 86 | env.live["bridge.enrolled"] = { .bool(false) } |
| 87 | env.live["bridge.fromHome"] = { .bool(false) } |
| 88 | env.live["accessBackedUp"] = { .bool(true) } // native: secured by the sign-in |
| 89 | return env |
| 90 | } |
| 91 | |
| 92 | // MARK: template + condition mini-language (mirrors the JS test) |
| 93 | |
| 94 | func testTemplateAndConditions() throws { |
| 95 | let env = FlowEnv() |
| 96 | env.vars["x"] = .string("hi") |
| 97 | env.space = ["name": .string("")] |
| 98 | env.ack = "A" |
| 99 | let engine = FlowEngine(flow: .object([:]), env: env, io: Harness()) |
| 100 | XCTAssertEqual(engine.renderText(.string("{vars.x} {space.name|fallback}")), "hi fallback") |
| 101 | XCTAssertEqual(engine.renderText(.string("{ack?}rest")), "A\n\nrest") |
| 102 | XCTAssertEqual(engine.renderText(.string("{ack?}rest")), "rest", "ack is consumed") |
| 103 | XCTAssertEqual(engine.renderText(.array([.string("a"), |
| 104 | .object(["if": .string("vars.x"), "text": .string("-b")]), |
| 105 | .object(["if": .string("vars.missing"), "text": .string("-c")])])), "a-b") |
| 106 | XCTAssertEqual(engine.renderText(.object(["if": .string("vars.missing"), "then": .string("T"), "else": .string("E")])), "E") |
| 107 | XCTAssertTrue(engine.evalCond(.string("!vars.missing"))) |
| 108 | } |
| 109 | |
| 110 | // MARK: the native creator setup run (mirrors the JS native test) |
| 111 | |
| 112 | func testNativeSetupRun() async throws { |
| 113 | let flow = try moduleFlow("setup") |
| 114 | let io = Harness() |
| 115 | io.verbResults["setGroupName"] = .string("ok") |
| 116 | let env = nativeEnv(name: "Paul") |
| 117 | let engine = FlowEngine(flow: flow, env: env, io: io) |
| 118 | engine.start() |
| 119 | await io.settle() |
| 120 | defer { withExtendedLifetime(engine) {} } |
| 121 | |
| 122 | // First run WITH a name: greeted (not "resumed"), name step skipped. |
| 123 | XCTAssertTrue(io.transcript.first?.text.hasPrefix("Hi β I'm Eleven! π") ?? false) |
| 124 | XCTAssertFalse(io.transcript.contains { $0.text.contains("Welcome back") }) |
| 125 | XCTAssertFalse(io.transcript.contains { $0.text.contains("what should everyone here call you") }) |
| 126 | XCTAssertTrue(io.transcript.contains { $0.extra.key == "q-group" }) |
| 127 | |
| 128 | await io.answer("Team Awesome") |
| 129 | env.space["name"] = .string("Team Awesome") // the web bridge updates space; the harness mirrors it |
| 130 | await io.tap("Skip for now") // email; the photo step auto-skips under native |
| 131 | XCTAssertFalse(io.transcript.contains { $0.extra.key == "q-photo" }) |
| 132 | await io.tap("Members can invite people") |
| 133 | // The extras trio (mirrors the JS native run): the one-tap common path |
| 134 | // records and writes no gates β defaults are already on server-side. |
| 135 | await io.tap("Keep both on") |
| 136 | XCTAssertTrue(io.records.contains { $0.step == "extras" && $0.value == "all-on" }) |
| 137 | XCTAssertFalse(io.verbLog.contains { $0.0 == "setFeature" }) |
| 138 | // The AI opt-in step (#877) runs under native too β mirror the JS twin. |
| 139 | await io.tap("Not now") |
| 140 | |
| 141 | // Keys: never pitched β recorded as covered by the Eleven account. |
| 142 | XCTAssertFalse(io.transcript.contains { $0.text.contains("Now the most important thing of all") }) |
| 143 | XCTAssertFalse(io.verbLog.contains { $0.0 == "savePasskey" }) |
| 144 | let keys = io.records.first { $0.step == "keys" } |
| 145 | XCTAssertEqual(keys?.label, "Your space's key is saved with your Eleven account") |
| 146 | XCTAssertEqual(keys?.value, "native") |
| 147 | |
| 148 | // Finish: completion marker, setup marked complete, no backup nag, no |
| 149 | // cross-thread admin chip. |
| 150 | XCTAssertEqual(io.records.last?.step, "finish") |
| 151 | XCTAssertEqual(io.records.last?.value, "done") |
| 152 | XCTAssertTrue(io.verbLog.contains { $0.0 == "markSetupComplete" }) |
| 153 | XCTAssertFalse(io.verbLog.contains { $0.0 == "deferBackupReminders" }) |
| 154 | XCTAssertFalse(io.transcript.contains { $0.text.contains("One more reminder") }) |
| 155 | // The tour stays web-only; Admin hops natively now. |
| 156 | let allChips = io.transcript.flatMap { $0.chips ?? [] }.map(\.label) |
| 157 | XCTAssertFalse(allChips.contains("Take the tour")) |
| 158 | XCTAssertTrue(allChips.contains("Open admin")) |
| 159 | } |
| 160 | |
| 161 | // MARK: the native joiner welcome run |
| 162 | |
| 163 | // Twin of the cleanNameAnswer cases in test/flow.test.mjs β both engines |
| 164 | // must clean a conversational name reply identically. |
| 165 | func testCleanNameAnswer() { |
| 166 | XCTAssertEqual(cleanNameAnswer("I'm Dermot"), "Dermot") |
| 167 | XCTAssertEqual(cleanNameAnswer("I\u{2019}m Dermot"), "Dermot") // iOS smart apostrophe |
| 168 | XCTAssertEqual(cleanNameAnswer("i am dermot"), "dermot") // case kept as typed |
| 169 | XCTAssertEqual(cleanNameAnswer("My name is Ana."), "Ana") |
| 170 | XCTAssertEqual(cleanNameAnswer("Hi, I'm Dermot!"), "Dermot") |
| 171 | XCTAssertEqual(cleanNameAnswer("hello my name's Bo"), "Bo") |
| 172 | XCTAssertEqual(cleanNameAnswer("you can call me Dermo"), "Dermo") |
| 173 | XCTAssertEqual(cleanNameAnswer("It's Maud"), "Maud") |
| 174 | XCTAssertEqual(cleanNameAnswer("Im Dermot"), "Dermot") // apostrophe skipped |
| 175 | |
| 176 | XCTAssertEqual(cleanNameAnswer("Dermot"), "Dermot") |
| 177 | XCTAssertEqual(cleanNameAnswer(" Dermot "), "Dermot") |
| 178 | XCTAssertEqual(cleanNameAnswer("Hilary"), "Hilary") // "hi" needs a boundary |
| 179 | XCTAssertEqual(cleanNameAnswer("Ima Jean"), "Ima Jean") // "im" needs a space |
| 180 | XCTAssertEqual(cleanNameAnswer("Mary-Kate O'Brien"), "Mary-Kate O'Brien") |
| 181 | // Stripping must never clean a name to nothing β keep the original. |
| 182 | XCTAssertEqual(cleanNameAnswer("I'm"), "I'm") |
| 183 | XCTAssertEqual(cleanNameAnswer(""), "") |
| 184 | } |
| 185 | |
| 186 | func testNativeWelcomeRun() async throws { |
| 187 | let flow = try moduleFlow("welcome") |
| 188 | let io = Harness() |
| 189 | let env = nativeEnv() |
| 190 | env.flags["inviterName"] = .string("Ciara") |
| 191 | let engine = FlowEngine(flow: flow, env: env, io: io) |
| 192 | engine.start() |
| 193 | await io.settle() |
| 194 | defer { withExtendedLifetime(engine) {} } |
| 195 | |
| 196 | // Greeting names the inviter; the passkey ask never renders; access |
| 197 | // records the native reason; the name question is armed. |
| 198 | XCTAssertTrue(io.transcript.first?.text.contains("Ciara invited you to Test Space") ?? false) |
| 199 | XCTAssertFalse(io.transcript.contains { $0.text.contains("Sign in with Eleven and your access is safe") }) |
| 200 | let access = io.records.first |
| 201 | XCTAssertEqual(access?.step, "access") |
| 202 | XCTAssertEqual(access?.value, "native") |
| 203 | XCTAssertTrue(io.transcript.contains { $0.extra.key == "q-name" }) |
| 204 | |
| 205 | // A conversational reply is cleaned to the bare name (input.clean). |
| 206 | await io.answer("Hi, I'm Paul!") |
| 207 | XCTAssertTrue(io.verbLog.contains { $0.0 == "setMyName" && $0.1.first == .string("Paul") }) |
| 208 | // The photo step auto-skips under native β finish follows directly. |
| 209 | XCTAssertFalse(io.transcript.contains { $0.extra.key == "q-photo" }) |
| 210 | |
| 211 | // Finish: no backup nag (access is the sign-in), completion recorded. |
| 212 | let fin = io.transcript.first { $0.extra.key == "q-finish" } |
| 213 | XCTAssertNotNil(fin) |
| 214 | XCTAssertFalse(fin?.text.contains("One more reminder") ?? true) |
| 215 | XCTAssertEqual(io.records.last?.step, "finish") |
| 216 | XCTAssertEqual(io.records.last?.value, "done") |
| 217 | XCTAssertTrue(io.ended == false) // onEnd fires on env, not io β checked below |
| 218 | } |
| 219 | |
| 220 | // MARK: the quick-form fast path (mirrors the JS trailing-chip test) |
| 221 | |
| 222 | func testQuickFormTrailingChip() async throws { |
| 223 | // A web-shaped env (native=false): the access step renders its chips β |
| 224 | // including the trailing quick-form chip, whose tap fires the |
| 225 | // openQuickForm verb WITHOUT consuming the step. |
| 226 | let flow = try moduleFlow("welcome") |
| 227 | let io = Harness() |
| 228 | let env = nativeEnv() |
| 229 | env.flags["native"] = .bool(false) |
| 230 | let engine = FlowEngine(flow: flow, env: env, io: io) |
| 231 | engine.start() |
| 232 | await io.settle() |
| 233 | defer { withExtendedLifetime(engine) {} } |
| 234 | |
| 235 | let chip = io.lastChips?.first { $0.label == "Fill in a form instead" } |
| 236 | XCTAssertNotNil(chip, "the welcome access step offers the quick-form chip") |
| 237 | XCTAssertEqual(chip?.trailing, true, "flagged trailing β the renderer right-aligns it") |
| 238 | XCTAssertEqual(io.lastChips?.first { $0.label == "Sign in (recommended)" }?.trailing, false, |
| 239 | "primary chips stay untagged") |
| 240 | await io.tap("Fill in a form instead") |
| 241 | XCTAssertTrue(io.verbLog.contains { $0.0 == "openQuickForm" }) |
| 242 | await io.tap("Sign in (recommended)") // non-advancing: the step is still live |
| 243 | XCTAssertTrue(io.verbLog.contains { $0.0 == "savePasskey" }) |
| 244 | |
| 245 | // Setup: the name step carries the same chip. |
| 246 | let flow2 = try moduleFlow("setup") |
| 247 | let io2 = Harness() |
| 248 | let env2 = nativeEnv() |
| 249 | env2.flags["native"] = .bool(false) |
| 250 | let engine2 = FlowEngine(flow: flow2, env: env2, io: io2) |
| 251 | engine2.start() |
| 252 | await io2.settle() |
| 253 | defer { withExtendedLifetime(engine2) {} } |
| 254 | let chip2 = io2.lastChips?.first { $0.label == "Fill in a form instead" } |
| 255 | XCTAssertNotNil(chip2, "the setup name step offers the quick-form chip") |
| 256 | XCTAssertEqual(chip2?.trailing, true) |
| 257 | await io2.tap("Fill in a form instead") |
| 258 | XCTAssertTrue(io2.verbLog.contains { $0.0 == "openQuickForm" }) |
| 259 | } |
| 260 | |
| 261 | // MARK: resume semantics |
| 262 | |
| 263 | func testResumeSkipsAnsweredSteps() async throws { |
| 264 | let flow = try moduleFlow("welcome") |
| 265 | let io = Harness() |
| 266 | io.doneSteps = ["access": true, "name": true] |
| 267 | let env = nativeEnv(name: "Paul") |
| 268 | var ended = false |
| 269 | env.onEnd = { ended = true } |
| 270 | let engine = FlowEngine(flow: flow, env: env, io: io) |
| 271 | engine.start() |
| 272 | await io.settle() |
| 273 | defer { withExtendedLifetime(engine) {} } |
| 274 | |
| 275 | // Resumed: the re-greeting, hello skipped; photo auto-skips under |
| 276 | // native, so the flow runs straight to finish and ends. |
| 277 | XCTAssertTrue(io.transcript.first?.text.hasPrefix("Welcome back") ?? false) |
| 278 | XCTAssertTrue(io.transcript.first?.extra.local ?? false) |
| 279 | XCTAssertFalse(io.transcript.contains { $0.text.hasPrefix("Welcome! π") }) |
| 280 | XCTAssertFalse(io.transcript.contains { $0.extra.key == "q-photo" }) |
| 281 | XCTAssertTrue(io.records.contains { $0.step == "finish" }) |
| 282 | XCTAssertTrue(ended, "end step fires env.onEnd") |
| 283 | } |
| 284 | |
| 285 | // MARK: a finished conversation never replays |
| 286 | |
| 287 | func testFinishedConversationEntersAndEndsQuietly() async throws { |
| 288 | let flow = try moduleFlow("welcome") |
| 289 | let io = Harness() |
| 290 | io.doneSteps = ["access": true, "name": true, "photo": true, "finish": true] |
| 291 | let env = nativeEnv(name: "Paul") |
| 292 | let engine = FlowEngine(flow: flow, env: env, io: io) |
| 293 | engine.start() |
| 294 | await io.settle() |
| 295 | defer { withExtendedLifetime(engine) {} } |
| 296 | // Finish is already recorded: no new records, and the only lines are |
| 297 | // the resume re-greeting + finish bubbles (chips stay live). |
| 298 | XCTAssertFalse(io.records.contains { $0.step != "finish" }) |
| 299 | XCTAssertEqual(io.records.count, 0) |
| 300 | } |
| 301 | } |
| 302 | |