eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1import 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
10final 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