| 1 | import 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. |
| 9 | final 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. |
| 187 | final 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 | |