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// Parity + drift gates for the composer typeahead brain. The EMOJI and
5// BOT_COMMANDS gates don't use hand-copied expectations — they PARSE the JS
6// sources (web/static/emoji.js, web/static/bot-commands.js) from the repo and
7// compare every entry in both directions, so adding an emoji on the web fails
8// this test until the Swift map follows.
9final class ComposerTypeaheadTests: XCTestCase {
10 private var repoRoot: URL {
11 URL(fileURLWithPath: #filePath) // …/Tests/LChatCoreTests/this.swift
12 .deletingLastPathComponent() // Tests/LChatCoreTests
13 .deletingLastPathComponent() // Tests
14 .deletingLastPathComponent() // LChatCore
15 .deletingLastPathComponent() // ios
16 .deletingLastPathComponent() // repo
17 }
18
19 // Parse `key: "value"` / `"key": "value"` pairs from a JS object literal.
20 private func parseJSMap(_ file: String, from marker: String) throws -> [String: String] {
21 let source = try String(contentsOf: repoRoot.appending(path: file), encoding: .utf8)
22 guard let start = source.range(of: marker),
23 let end = source.range(of: "};", range: start.upperBound ..< source.endIndex) else {
24 XCTFail("couldn't find \(marker) in \(file)")
25 return [:]
26 }
27 let body = String(source[start.upperBound ..< end.lowerBound])
28 let re = try NSRegularExpression(pattern: "(?:\"([^\"]+)\"|([A-Za-z0-9_+\\-]+))\\s*:\\s*\"([^\"]+)\"")
29 var out: [String: String] = [:]
30 let ns = body as NSString
31 for m in re.matches(in: body, range: NSRange(location: 0, length: ns.length)) {
32 let key = m.range(at: 1).location != NSNotFound
33 ? ns.substring(with: m.range(at: 1))
34 : ns.substring(with: m.range(at: 2))
35 out[key] = ns.substring(with: m.range(at: 3))
36 }
37 return out
38 }
39
40 func testEmojiMapMatchesEmojiJS() throws {
41 let js = try parseJSMap("web/static/emoji.js", from: "export const EMOJI = {")
42 XCTAssertFalse(js.isEmpty)
43 XCTAssertEqual(EMOJI, js, "Swift EMOJI map has drifted from web/static/emoji.js")
44 }
45
46 func testBotCommandsMatchBotCommandsJS() throws {
47 let js = try parseJSMap("web/static/bot-commands.js", from: "export const BOT_COMMANDS = {")
48 XCTAssertEqual(Dictionary(uniqueKeysWithValues: botCommands.map { ($0.name, $0.desc) }), js,
49 "Swift botCommands has drifted from web/static/bot-commands.js")
50 }
51
52 // ---- command availability without a bot --------------------------------
53
54 private func commandNames(hasBot: Bool) -> [String] {
55 typeaheadItems(ComposerToken(kind: .command, query: "", start: 0),
56 roster: [], myUserId: "me", hasBot: hasBot)
57 .compactMap { if case .command(let name, _) = $0 { return name } else { return nil } }
58 }
59
60 func testBotlessChatStillOffersClientAnsweredCommands() {
61 // A DM with no AI agent must still offer what the client answers
62 // itself: the panel commands (/emoji, /gif — the command-host webview,
63 // #497), /me, and theming (SlashCommands.swift) — and nothing that
64 // only a bot could answer (/help would post into the void). Ordered
65 // like the web's commandCatalog: panels, /me, /integrity, then the bot floor.
66 XCTAssertEqual(commandNames(hasBot: false), ["/emoji", "/gif", "/me", "/integrity", "/theme", "/themes"])
67 }
68
69 func testBotfulChatOffersTheFullFloor() {
70 // Ten commands exist with a bot present but the typeahead caps at 8
71 // (the web's cap too) — the ordering guarantees the victims are the
72 // TAIL of the bot list (/light, /profile), never /me, /integrity, or a
73 // panel command.
74 XCTAssertEqual(commandNames(hasBot: true),
75 ["/emoji", "/gif", "/me", "/integrity", "/help", "/theme", "/themes", "/dark"])
76 // Every bot command is still reachable the moment its prefix is typed —
77 // the cap hides, it never disables.
78 for expected in botCommands.map(\.name) {
79 let q = ComposerToken(kind: .command, query: String(expected.dropFirst()), start: 0)
80 let names = typeaheadItems(q, roster: [], myUserId: "me", hasBot: true)
81 .compactMap { if case .command(let name, _) = $0 { return name } else { return nil } }
82 XCTAssertTrue(names.contains(expected), "\(expected) unreachable with a bot present")
83 }
84 }
85
86 // ---- composerToken (ui.js) parity -------------------------------------
87
88 func testCommandToken() {
89 XCTAssertEqual(composerToken("/the"), ComposerToken(kind: .command, query: "the", start: 0))
90 XCTAssertNil(composerToken("/theme lag"), "caret past the name → args, no menu")
91 XCTAssertNil(composerToken("hi /theme"), "commands only at the start")
92 // Caret before the slash: web slice(1, 0) → "" — must not trap.
93 XCTAssertEqual(composerToken("/help", caret: 0),
94 ComposerToken(kind: .command, query: "", start: 0))
95 }
96
97 func testEmojiToken() {
98 XCTAssertEqual(composerToken("so :ta"), ComposerToken(kind: .emoji, query: "ta", start: 3))
99 XCTAssertNil(composerToken("so :"), "a bare colon is punctuation")
100 XCTAssertNil(composerToken("10:30"), "mid-word colon (times) is not a trigger")
101 }
102
103 func testMentionToken() {
104 XCTAssertEqual(composerToken("hey @Ja"), ComposerToken(kind: .mention, query: "Ja", start: 4))
105 XCTAssertEqual(composerToken("@"), ComposerToken(kind: .mention, query: "", start: 0))
106 XCTAssertNil(composerToken("paul@rte"), "an email's @ is not a trigger")
107 }
108
109 func testCaretMidText() {
110 // Caret inside ":ta|da later" still finds the emoji token.
111 XCTAssertEqual(composerToken(":tada later", caret: 3),
112 ComposerToken(kind: .emoji, query: "ta", start: 0))
113 }
114
115 // ---- emojiMatches / emojify (emoji.js) parity --------------------------
116
117 func testEmojiMatchesRanking() {
118 let tada = emojiMatches("tada")
119 XCTAssertEqual(tada.first?.emoji, "🎉")
120 // Prefix hits rank before substring hits.
121 let star = emojiMatches("star")
122 XCTAssertEqual(star.first?.code, "star")
123 // Aliases de-dupe to one glyph (laughing/satisfied are both 😆).
124 let glyphs = emojiMatches("s", limit: 50).map(\.emoji)
125 XCTAssertEqual(glyphs.count, Set(glyphs).count)
126 }
127
128 func testEmojify() {
129 XCTAssertEqual(emojify("well :tada: then :+1:"), "well 🎉 then 👍")
130 XCTAssertEqual(emojify("at 10:30 sharp"), "at 10:30 sharp")
131 XCTAssertEqual(emojify(":not_a_real_code:"), ":not_a_real_code:")
132 }
133
134 // ---- typeaheadItems (shell.js) parity ----------------------------------
135
136 private let roster = [
137 RosterEntry(user_id: "u1", label: "James", label_blob: nil, public_key: "k", avatar: nil, is_agent: nil),
138 RosterEntry(user_id: "u2", label: "Paul", label_blob: nil, public_key: "k", avatar: nil, is_agent: nil),
139 RosterEntry(user_id: "u3", label: "Eleven", label_blob: nil, public_key: "k", avatar: nil, is_agent: true),
140 ]
141
142 func testMentionCandidatesExcludeSelf() {
143 let tok = ComposerToken(kind: .mention, query: "", start: 0)
144 let items = typeaheadItems(tok, roster: roster, myUserId: "u2", hasBot: true)
145 XCTAssertEqual(items.map(\.insert), ["@James ", "@Eleven "])
146 }
147
148 func testCommandCandidatesAreBotGated() {
149 // Theming is client-answered (SlashCommands.swift), so it shows with or
150 // without a bot; a bot-only command like /help gates on hasBot.
151 let the = ComposerToken(kind: .command, query: "the", start: 0)
152 XCTAssertEqual(typeaheadItems(the, roster: roster, myUserId: "u2", hasBot: true).map(\.insert),
153 ["/theme ", "/themes "])
154 XCTAssertEqual(typeaheadItems(the, roster: roster, myUserId: "u2", hasBot: false).map(\.insert),
155 ["/theme ", "/themes "])
156 let he = ComposerToken(kind: .command, query: "he", start: 0)
157 XCTAssertEqual(typeaheadItems(he, roster: roster, myUserId: "u2", hasBot: true).map(\.insert),
158 ["/help "])
159 XCTAssertTrue(typeaheadItems(he, roster: roster, myUserId: "u2", hasBot: false).isEmpty)
160 }
161
162 // App-local verbs (web commandCatalog(threadId)): they show in the owning
163 // thread with or without a bot, lead the list, and prefix-filter like the rest.
164 func testAppCommandsListedWithoutBotAndLeadWithOne() {
165 let add = AppDefaultCommand.localCommands(appID: "todo")
166 let bare = ComposerToken(kind: .command, query: "", start: 0)
167 XCTAssertEqual(typeaheadItems(bare, roster: roster, myUserId: "u2", hasBot: false,
168 appCommands: add).map(\.insert),
169 ["/add ", "/emoji ", "/gif ", "/me ", "/integrity ", "/theme ", "/themes "],
170 "no bot → the local verb leads; client-answered floor follows")
171 XCTAssertEqual(typeaheadItems(bare, roster: roster, myUserId: "u2", hasBot: true,
172 appCommands: add).map(\.insert).first,
173 "/add ", "with a bot the local verb leads the catalog")
174 let miss = ComposerToken(kind: .command, query: "he", start: 0)
175 XCTAssertTrue(typeaheadItems(miss, roster: roster, myUserId: "u2", hasBot: false,
176 appCommands: add).isEmpty,
177 "prefix filtering applies to app commands too")
178 XCTAssertEqual(AppDefaultCommand.localCommands(appID: nil).count, 0)
179 XCTAssertEqual(AppDefaultCommand.localCommands(appID: "poll").count, 0,
180 "apps with no default-command spec offer no in-thread verbs")
181 }
182}
183
184// 🤖 Phase 3 (docs/native-apps.md): a thread's live commandItems() rows drive
185// the "/" menu — the web's own per-thread catalogue — with the hard-coded
186// floor standing only until they land.
187final class TypeaheadRowsTests: XCTestCase {
188 private let rows = [
189 CommandRow(name: "/todo", desc: "add a to-do — lands in your To-do", bot: false),
190 CommandRow(name: "/board", desc: "open the whiteboard", bot: false),
191 CommandRow(name: "/help", desc: "what I can do", bot: true),
192 ]
193 private let token = ComposerToken(kind: .command, query: "", start: 0)
194
195 func testRowsDriveTheMenu() {
196 let items = typeaheadItems(token, roster: [], myUserId: "u1", hasBot: true, rows: rows)
197 let names: [String] = items.compactMap {
198 if case .command(let name, _) = $0 { return name } else { return nil }
199 }
200 // Row order is the web's ranking; /integrity (native-only, absent
201 // from the web catalogue) rides along.
202 XCTAssertEqual(names, ["/todo", "/board", "/help", "/integrity"])
203 }
204
205 func testBotRowsGateOnABot() {
206 let items = typeaheadItems(token, roster: [], myUserId: "u1", hasBot: false, rows: rows)
207 let names: [String] = items.compactMap {
208 if case .command(let name, _) = $0 { return name } else { return nil }
209 }
210 XCTAssertFalse(names.contains("/help"))
211 XCTAssertTrue(names.contains("/todo"))
212 }
213
214 func testEmptyRowsKeepTheHardCodedFloor() {
215 let items = typeaheadItems(token, roster: [], myUserId: "u1", hasBot: false, rows: [])
216 let names: [String] = items.compactMap {
217 if case .command(let name, _) = $0 { return name } else { return nil }
218 }
219 XCTAssertEqual(names, ["/emoji", "/gif", "/me", "/integrity", "/theme", "/themes"])
220 }
221}
222