| One frontend where there were three, and a core that is not Clojure 438b247 nandi 19h ago | 1 | ## The IRC wire format, tested against the cases that cost something. |
| 2 | ## |
| 3 | ## These are not a transcription of the Clojure's tests, because it had none — |
| 4 | ## which is half the argument for the move. Every case here is either a rule |
| 5 | ## IRCv3 states or a bug the comments in `common/frq/irc/parse.cljc` record |
| 6 | ## having been paid for once already. |
| 7 | |
| 8 | import std/unittest |
| 9 | import frq/ircparse |
| 10 | |
| 11 | suite "parseLine": |
| 12 | test "a bare line": |
| 13 | let p = parseLine("PING :12345") |
| 14 | check p.command == "PING" |
| 15 | check p.params == @["12345"] |
| 16 | check not p.hasPrefix |
| 17 | check not p.hasTags |
| 18 | |
| 19 | test "a prefix is split off and the command upcased": |
| 20 | let p = parseLine(":nick!user@host privmsg #chan :hello there") |
| 21 | check p.prefix == "nick!user@host" |
| 22 | check p.hasPrefix |
| 23 | check p.command == "PRIVMSG" |
| 24 | check p.params == @["#chan", "hello there"] |
| 25 | |
| 26 | test "the trailing parameter keeps its spaces and its colons": |
| 27 | let p = parseLine(":a!b@c PRIVMSG #chan :look: a b c") |
| 28 | check p.params == @["#chan", "look: a b c"] |
| 29 | |
| 30 | test "no trailing parameter at all": |
| 31 | let p = parseLine(":a!b@c JOIN #chan") |
| 32 | check p.params == @["#chan"] |
| 33 | |
| 34 | test "an empty trailing parameter is a parameter": |
| 35 | # `:` with nothing after it is how a client sends an empty topic, and |
| 36 | # dropping it turns a clear into a no-op. |
| 37 | let p = parseLine(":a!b@c TOPIC #chan :") |
| 38 | check p.params == @["#chan", ""] |
| 39 | |
| 40 | test "tags are taken off the front": |
| 41 | let p = parseLine("@time=2026-01-01T00:00:00Z;account=alice :a!b@c PRIVMSG #chan :hi") |
| 42 | check p.hasTags |
| 43 | check p.tags == "time=2026-01-01T00:00:00Z;account=alice" |
| 44 | check p.account == "alice" |
| 45 | check p.command == "PRIVMSG" |
| 46 | check p.params == @["#chan", "hi"] |
| 47 | |
| 48 | test "a line with tags and no account": |
| 49 | let p = parseLine("@time=x :a!b@c PRIVMSG #chan :hi") |
| 50 | check p.hasTags |
| 51 | check not p.hasAccount |
| 52 | |
| 53 | test "trailing whitespace is trimmed but leading structure is not": |
| 54 | let p = parseLine("PING :12345\r\n") |
| 55 | check p.raw == "PING :12345" |
| 56 | check p.params == @["12345"] |
| 57 | |
| 58 | test "raw is what arrived, not what we made of it": |
| 59 | let p = parseLine("@a=1 :n!u@h PRIVMSG #c :x ") |
| 60 | check p.raw == "@a=1 :n!u@h PRIVMSG #c :x" |
| 61 | |
| 62 | test "runs of spaces in the head do not become empty parameters": |
| 63 | let p = parseLine(":a!b@c PRIVMSG #chan :hi") |
| 64 | check p.params == @["#chan", "hi"] |
| 65 | |
| 66 | test "a line that is only tags does not throw": |
| 67 | # The Clojure indexes with `subs` on a nil `index-of` here and dies. It |
| 68 | # cannot arrive from a conforming server, but a parser reading a socket |
| 69 | # answers malformed input with a value rather than an exception. |
| 70 | let p = parseLine("@only=tags") |
| 71 | check p.hasTags |
| 72 | check p.tags == "only=tags" |
| 73 | check p.command == "" |
| 74 | |
| 75 | test "an empty line": |
| 76 | let p = parseLine("") |
| 77 | check p.command == "" |
| 78 | check p.params.len == 0 |
| 79 | |
| 80 | suite "unescapeTag": |
| 81 | test "the five escapes": |
| 82 | check unescapeTag("a\\:b") == "a;b" |
| 83 | check unescapeTag("a\\sb") == "a b" |
| 84 | check unescapeTag("a\\\\b") == "a\\b" |
| 85 | check unescapeTag("a\\rb") == "a\rb" |
| 86 | check unescapeTag("a\\nb") == "a\nb" |
| 87 | |
| 88 | test "an unknown escape is the character itself": |
| 89 | check unescapeTag("a\\qb") == "aqb" |
| 90 | |
| 91 | test "a trailing backslash is kept rather than eating the terminator": |
| 92 | check unescapeTag("ab\\") == "ab\\" |
| 93 | |
| 94 | test "a reaction tally survives the trip": |
| 95 | # The bug this function exists for: three tallies, not one. |
| 96 | check unescapeTag("x\\:alice\\:bob") == "x;alice;bob" |
| 97 | |
| 98 | suite "escapeTagValue": |
| 99 | test "round-trips everything unescapeTag undoes": |
| 100 | for s in ["plain", "a;b", "a b", "a\\b", "a\r\nb", "", "😀", "a;;b"]: |
| 101 | check unescapeTag(escapeTagValue(s)) == s |
| 102 | |
| 103 | test "a backslash is not double-escaped": |
| 104 | check escapeTagValue("a\\b") == "a\\\\b" |
| 105 | |
| 106 | suite "tagValue": |
| 107 | test "a present value": |
| 108 | check tagValue("a=1;b=2", "b") == ("2", true) |
| 109 | |
| 110 | test "an absent tag": |
| 111 | check tagValue("a=1", "b") == ("", false) |
| 112 | |
| 113 | test "an empty value and a bare key are both absent": |
| 114 | # IRCv3 says `key` and `key=` mean the same thing, and a caller asking |
| 115 | # `tagValue` is asking whether a fact is there. `+reply=` on a line |
| 116 | # answering nothing used to put a reply chip above it. |
| 117 | check tagValue("a=;b=2", "a") == ("", false) |
| 118 | check tagValue("a;b=2", "a") == ("", false) |
| 119 | |
| 120 | test "the value is unescaped": |
| 121 | check tagValue("t=a\\sb", "t") == ("a b", true) |
| 122 | |
| 123 | test "a key that is a prefix of another does not match it": |
| 124 | check tagValue("account-x=1;account=2", "account") == ("2", true) |
| 125 | |
| 126 | test "a value containing = keeps the rest of it": |
| 127 | check tagValue("t=a=b", "t") == ("a=b", true) |
| 128 | |
| 129 | suite "nickOf": |
| 130 | test "a full prefix": |
| 131 | check nickOf("nick!user@host") == "nick" |
| 132 | |
| 133 | test "a prefix that is only a nick": |
| 134 | check nickOf("nick") == "nick" |
| 135 | |
| 136 | test "a server prefix has no bang and comes back whole": |
| 137 | check nickOf("irc.freeq.at") == "irc.freeq.at" |