eleven / messenger Public

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

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") -> 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