Browse Source

Initial import, various supporting classes.

tags/v0.1.0
Chris Smith 5 years ago
commit
602c5e8b7a

+ 7
- 0
.gitignore View File

@@ -0,0 +1,7 @@
1
+# IDEA files
2
+/.idea
3
+/*.iml
4
+
5
+# Gradle intermediates
6
+/.gradle
7
+/out

+ 51
- 0
build.gradle.kts View File

@@ -0,0 +1,51 @@
1
+import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
2
+
3
+plugins {
4
+    kotlin("jvm") version "1.3.0-rc-80"
5
+}
6
+
7
+repositories {
8
+    jcenter()
9
+    mavenCentral()
10
+    maven("http://dl.bintray.com/kotlin/kotlin-eap")
11
+    maven("https://dl.bintray.com/kotlin/ktor")
12
+}
13
+
14
+dependencies {
15
+    implementation(kotlin("stdlib-jdk8"))
16
+    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:0.30.0-eap13")
17
+    implementation("io.ktor:ktor-network:0.9.6-alpha-1-rc13")
18
+
19
+    testCompile("org.junit.jupiter:junit-jupiter-api:5.3.1")
20
+    testCompile("org.junit.jupiter:junit-jupiter-params:5.3.1")
21
+    testRuntime("org.junit.jupiter:junit-jupiter-engine:5.3.1")
22
+    testImplementation("com.nhaarman.mockitokotlin2:mockito-kotlin:2.0.0-RC3")
23
+}
24
+
25
+java {
26
+    sourceCompatibility = JavaVersion.VERSION_1_8
27
+    targetCompatibility = JavaVersion.VERSION_1_8
28
+}
29
+
30
+tasks.withType<KotlinCompile> {
31
+    kotlinOptions {
32
+        jvmTarget = "1.8"
33
+    }
34
+}
35
+
36
+tasks.withType<Test> {
37
+    useJUnitPlatform()
38
+    testLogging {
39
+        events("passed", "skipped", "failed")
40
+    }
41
+    systemProperty("junit.jupiter.execution.parallel.enabled", "true")
42
+    systemProperty("junit.jupiter.execution.parallel.config.dynamic.factor", "5")
43
+}
44
+
45
+configurations.all {
46
+    resolutionStrategy.eachDependency {
47
+        if (requested.group == "org.jetbrains.kotlin") {
48
+            useVersion("1.3.0-rc-80")
49
+        }
50
+    }
51
+}

BIN
gradle/wrapper/gradle-wrapper.jar View File


+ 5
- 0
gradle/wrapper/gradle-wrapper.properties View File

@@ -0,0 +1,5 @@
1
+distributionBase=GRADLE_USER_HOME
2
+distributionPath=wrapper/dists
3
+distributionUrl=https\://services.gradle.org/distributions/gradle-4.8-bin.zip
4
+zipStoreBase=GRADLE_USER_HOME
5
+zipStorePath=wrapper/dists

+ 172
- 0
gradlew View File

@@ -0,0 +1,172 @@
1
+#!/usr/bin/env sh
2
+
3
+##############################################################################
4
+##
5
+##  Gradle start up script for UN*X
6
+##
7
+##############################################################################
8
+
9
+# Attempt to set APP_HOME
10
+# Resolve links: $0 may be a link
11
+PRG="$0"
12
+# Need this for relative symlinks.
13
+while [ -h "$PRG" ] ; do
14
+    ls=`ls -ld "$PRG"`
15
+    link=`expr "$ls" : '.*-> \(.*\)$'`
16
+    if expr "$link" : '/.*' > /dev/null; then
17
+        PRG="$link"
18
+    else
19
+        PRG=`dirname "$PRG"`"/$link"
20
+    fi
21
+done
22
+SAVED="`pwd`"
23
+cd "`dirname \"$PRG\"`/" >/dev/null
24
+APP_HOME="`pwd -P`"
25
+cd "$SAVED" >/dev/null
26
+
27
+APP_NAME="Gradle"
28
+APP_BASE_NAME=`basename "$0"`
29
+
30
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
31
+DEFAULT_JVM_OPTS=""
32
+
33
+# Use the maximum available, or set MAX_FD != -1 to use that value.
34
+MAX_FD="maximum"
35
+
36
+warn () {
37
+    echo "$*"
38
+}
39
+
40
+die () {
41
+    echo
42
+    echo "$*"
43
+    echo
44
+    exit 1
45
+}
46
+
47
+# OS specific support (must be 'true' or 'false').
48
+cygwin=false
49
+msys=false
50
+darwin=false
51
+nonstop=false
52
+case "`uname`" in
53
+  CYGWIN* )
54
+    cygwin=true
55
+    ;;
56
+  Darwin* )
57
+    darwin=true
58
+    ;;
59
+  MINGW* )
60
+    msys=true
61
+    ;;
62
+  NONSTOP* )
63
+    nonstop=true
64
+    ;;
65
+esac
66
+
67
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
68
+
69
+# Determine the Java command to use to start the JVM.
70
+if [ -n "$JAVA_HOME" ] ; then
71
+    if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
72
+        # IBM's JDK on AIX uses strange locations for the executables
73
+        JAVACMD="$JAVA_HOME/jre/sh/java"
74
+    else
75
+        JAVACMD="$JAVA_HOME/bin/java"
76
+    fi
77
+    if [ ! -x "$JAVACMD" ] ; then
78
+        die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
79
+
80
+Please set the JAVA_HOME variable in your environment to match the
81
+location of your Java installation."
82
+    fi
83
+else
84
+    JAVACMD="java"
85
+    which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
86
+
87
+Please set the JAVA_HOME variable in your environment to match the
88
+location of your Java installation."
89
+fi
90
+
91
+# Increase the maximum file descriptors if we can.
92
+if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
93
+    MAX_FD_LIMIT=`ulimit -H -n`
94
+    if [ $? -eq 0 ] ; then
95
+        if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
96
+            MAX_FD="$MAX_FD_LIMIT"
97
+        fi
98
+        ulimit -n $MAX_FD
99
+        if [ $? -ne 0 ] ; then
100
+            warn "Could not set maximum file descriptor limit: $MAX_FD"
101
+        fi
102
+    else
103
+        warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
104
+    fi
105
+fi
106
+
107
+# For Darwin, add options to specify how the application appears in the dock
108
+if $darwin; then
109
+    GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
110
+fi
111
+
112
+# For Cygwin, switch paths to Windows format before running java
113
+if $cygwin ; then
114
+    APP_HOME=`cygpath --path --mixed "$APP_HOME"`
115
+    CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
116
+    JAVACMD=`cygpath --unix "$JAVACMD"`
117
+
118
+    # We build the pattern for arguments to be converted via cygpath
119
+    ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
120
+    SEP=""
121
+    for dir in $ROOTDIRSRAW ; do
122
+        ROOTDIRS="$ROOTDIRS$SEP$dir"
123
+        SEP="|"
124
+    done
125
+    OURCYGPATTERN="(^($ROOTDIRS))"
126
+    # Add a user-defined pattern to the cygpath arguments
127
+    if [ "$GRADLE_CYGPATTERN" != "" ] ; then
128
+        OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
129
+    fi
130
+    # Now convert the arguments - kludge to limit ourselves to /bin/sh
131
+    i=0
132
+    for arg in "$@" ; do
133
+        CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
134
+        CHECK2=`echo "$arg"|egrep -c "^-"`                                 ### Determine if an option
135
+
136
+        if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then                    ### Added a condition
137
+            eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
138
+        else
139
+            eval `echo args$i`="\"$arg\""
140
+        fi
141
+        i=$((i+1))
142
+    done
143
+    case $i in
144
+        (0) set -- ;;
145
+        (1) set -- "$args0" ;;
146
+        (2) set -- "$args0" "$args1" ;;
147
+        (3) set -- "$args0" "$args1" "$args2" ;;
148
+        (4) set -- "$args0" "$args1" "$args2" "$args3" ;;
149
+        (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
150
+        (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
151
+        (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
152
+        (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
153
+        (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
154
+    esac
155
+fi
156
+
157
+# Escape application args
158
+save () {
159
+    for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
160
+    echo " "
161
+}
162
+APP_ARGS=$(save "$@")
163
+
164
+# Collect all arguments for the java command, following the shell quoting and substitution rules
165
+eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
166
+
167
+# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
168
+if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
169
+  cd "$(dirname "$0")"
170
+fi
171
+
172
+exec "$JAVACMD" "$@"

+ 84
- 0
gradlew.bat View File

@@ -0,0 +1,84 @@
1
+@if "%DEBUG%" == "" @echo off
2
+@rem ##########################################################################
3
+@rem
4
+@rem  Gradle startup script for Windows
5
+@rem
6
+@rem ##########################################################################
7
+
8
+@rem Set local scope for the variables with windows NT shell
9
+if "%OS%"=="Windows_NT" setlocal
10
+
11
+set DIRNAME=%~dp0
12
+if "%DIRNAME%" == "" set DIRNAME=.
13
+set APP_BASE_NAME=%~n0
14
+set APP_HOME=%DIRNAME%
15
+
16
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
17
+set DEFAULT_JVM_OPTS=
18
+
19
+@rem Find java.exe
20
+if defined JAVA_HOME goto findJavaFromJavaHome
21
+
22
+set JAVA_EXE=java.exe
23
+%JAVA_EXE% -version >NUL 2>&1
24
+if "%ERRORLEVEL%" == "0" goto init
25
+
26
+echo.
27
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
28
+echo.
29
+echo Please set the JAVA_HOME variable in your environment to match the
30
+echo location of your Java installation.
31
+
32
+goto fail
33
+
34
+:findJavaFromJavaHome
35
+set JAVA_HOME=%JAVA_HOME:"=%
36
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
37
+
38
+if exist "%JAVA_EXE%" goto init
39
+
40
+echo.
41
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
42
+echo.
43
+echo Please set the JAVA_HOME variable in your environment to match the
44
+echo location of your Java installation.
45
+
46
+goto fail
47
+
48
+:init
49
+@rem Get command-line arguments, handling Windows variants
50
+
51
+if not "%OS%" == "Windows_NT" goto win9xME_args
52
+
53
+:win9xME_args
54
+@rem Slurp the command line arguments.
55
+set CMD_LINE_ARGS=
56
+set _SKIP=2
57
+
58
+:win9xME_args_slurp
59
+if "x%~1" == "x" goto execute
60
+
61
+set CMD_LINE_ARGS=%*
62
+
63
+:execute
64
+@rem Setup the command line
65
+
66
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
67
+
68
+@rem Execute Gradle
69
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
70
+
71
+:end
72
+@rem End local scope for the variables with windows NT shell
73
+if "%ERRORLEVEL%"=="0" goto mainEnd
74
+
75
+:fail
76
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
77
+rem the _cmd.exe /c_ return code!
78
+if  not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
79
+exit /b 1
80
+
81
+:mainEnd
82
+if "%OS%"=="Windows_NT" endlocal
83
+
84
+:omega

+ 6
- 0
settings.gradle.kts View File

@@ -0,0 +1,6 @@
1
+pluginManagement {
2
+    repositories {
3
+        gradlePluginPortal()
4
+        maven("http://dl.bintray.com/kotlin/kotlin-eap")
5
+    }
6
+}

+ 26
- 0
src/main/kotlin/com/dmdirc/ktirc/io/CaseMapping.kt View File

@@ -0,0 +1,26 @@
1
+package com.dmdirc.ktirc.io
2
+
3
+enum class CaseMapping(private val lowerToUpperMapping: Pair<IntRange, IntRange>) {
4
+
5
+    Ascii(97..122 to 65..90),
6
+    Rfc(97..126 to 65..94),
7
+    RfcStrict(97..125 to 65..93);
8
+
9
+    companion object {
10
+        fun fromName(name: String) = when(name.toLowerCase()) {
11
+            "ascii" -> Ascii
12
+            "rfc1459" -> Rfc
13
+            "rfc1459-strict" -> RfcStrict
14
+            else -> Rfc
15
+        }
16
+    }
17
+
18
+    fun areEquivalent(string1: String, string2: String): Boolean {
19
+        return string1.length == string2.length
20
+                && string1.zip(string2).all { (c1, c2) -> areEquivalent(c1, c2) }
21
+    }
22
+
23
+    private fun areEquivalent(char1: Char, char2: Char) = char1 == char2 || char1.toUpper() == char2.toUpper()
24
+    private fun Char.toUpper() = this + if (this.toInt() in lowerToUpperMapping.first) lowerToUpperMapping.second.start - lowerToUpperMapping.first.start else 0
25
+
26
+}

+ 91
- 0
src/main/kotlin/com/dmdirc/ktirc/io/LineBufferedSocket.kt View File

@@ -0,0 +1,91 @@
1
+package com.dmdirc.ktirc.io
2
+
3
+import io.ktor.network.selector.ActorSelectorManager
4
+import io.ktor.network.sockets.Socket
5
+import io.ktor.network.sockets.aSocket
6
+import io.ktor.network.sockets.openReadChannel
7
+import io.ktor.network.sockets.openWriteChannel
8
+import io.ktor.network.util.ioCoroutineDispatcher
9
+import kotlinx.coroutines.CoroutineScope
10
+import kotlinx.coroutines.channels.ReceiveChannel
11
+import kotlinx.coroutines.channels.produce
12
+import kotlinx.coroutines.io.ByteReadChannel
13
+import kotlinx.coroutines.io.ByteWriteChannel
14
+import java.net.InetSocketAddress
15
+
16
+interface LineBufferedSocket {
17
+
18
+    // TODO: This is a bit pants.
19
+    var debugReceiver: ((String) -> Unit)?
20
+
21
+    suspend fun connect()
22
+    fun disconnect()
23
+
24
+    suspend fun sendLine(line: ByteArray, offset: Int = 0, length: Int = line.size)
25
+    suspend fun sendLine(line: String)
26
+
27
+    fun readLines(coroutineScope: CoroutineScope): ReceiveChannel<ByteArray>
28
+
29
+}
30
+
31
+/**
32
+ * Asynchronous socket that buffers incoming data and emits individual lines.
33
+ */
34
+// TODO: TLS options
35
+class KtorLineBufferedSocket(private val host: String, private val port: Int): LineBufferedSocket {
36
+
37
+    companion object {
38
+        const val CARRIAGE_RETURN = '\r'.toByte()
39
+        const val LINE_FEED = '\n'.toByte()
40
+    }
41
+
42
+    // TODO: This is a bit pants.
43
+    override var debugReceiver: ((String) -> Unit)? = null
44
+
45
+    private lateinit var socket: Socket
46
+    private lateinit var readChannel: ByteReadChannel
47
+    private lateinit var writeChannel: ByteWriteChannel
48
+
49
+    override suspend fun connect() {
50
+        socket = aSocket(ActorSelectorManager(ioCoroutineDispatcher)).tcp().connect(InetSocketAddress(host, port))
51
+        readChannel = socket.openReadChannel()
52
+        writeChannel = socket.openWriteChannel()
53
+    }
54
+
55
+    override fun disconnect() {
56
+        socket.close()
57
+    }
58
+
59
+    override suspend fun sendLine(line: ByteArray, offset: Int, length: Int) {
60
+        with (writeChannel) {
61
+            debugReceiver?.let { it(">>> ${String(line, offset, length)}") }
62
+            writeAvailable(line, offset, length)
63
+            writeByte(CARRIAGE_RETURN)
64
+            writeByte(LINE_FEED)
65
+            flush()
66
+        }
67
+    }
68
+
69
+    override suspend fun sendLine(line: String) = sendLine(line.toByteArray())
70
+
71
+    override fun readLines(coroutineScope: CoroutineScope) = coroutineScope.produce {
72
+        val lineBuffer = ByteArray(4096)
73
+        var index = 0
74
+        while (!readChannel.isClosedForRead) {
75
+            var start = index
76
+            val count = readChannel.readAvailable(lineBuffer, index, lineBuffer.size - index)
77
+            for (i in index until index + count) {
78
+                if (lineBuffer[i] == CARRIAGE_RETURN || lineBuffer[i] == LINE_FEED) {
79
+                    if (start < i) {
80
+                        val line = lineBuffer.sliceArray(start until i)
81
+                        debugReceiver?.let { it("<<< ${String(line)}") }
82
+                        send(line)
83
+                    }
84
+                    start = i + 1
85
+                }
86
+            }
87
+            lineBuffer.copyInto(lineBuffer, 0, start)
88
+            index = count + index - start
89
+        }
90
+    }
91
+}

+ 121
- 0
src/main/kotlin/com/dmdirc/ktirc/io/MessageParser.kt View File

@@ -0,0 +1,121 @@
1
+package com.dmdirc.ktirc.io
2
+
3
+/**
4
+ * Parses a message received from an IRC server.
5
+ *
6
+ * IRC messages consist of:
7
+ *
8
+ * - Optionally, IRCv3 message tags. Identified with an '@' character
9
+ * - Optionally, a prefix, identified with an ':' character
10
+ * - A command in the form of a consecutive sequence of letters or exactly three numbers
11
+ * - Some number of space-separated parameters
12
+ * - Optionally, a final 'trailing' parameter prefixed with a ':' character
13
+ *
14
+ * For example:
15
+ *
16
+ * @aaa=bbb;ccc;example.com/ddd=eee :nick!ident@host.com PRIVMSG #someChannel :This is a test message
17
+ * ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^ ^^^^^^^ ^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^
18
+ * IRCv3 tags                       Prefix               Cmd     Param #1     Trailing parameter
19
+ *
20
+ * or:
21
+ *
22
+ * PING 12345678
23
+ * ^^^^ ^^^^^^^^
24
+ * Cmd  Param #1
25
+ */
26
+class MessageParser {
27
+
28
+    companion object {
29
+        private const val AT = '@'.toByte()
30
+        private const val COLON = ':'.toByte()
31
+    }
32
+
33
+    fun parse(message: ByteArray) = CursorByteArray(message).run {
34
+        IrcMessage(takeTags(), takePrefix(), String(takeWord()), takeParams().toList())
35
+    }
36
+
37
+    /**
38
+     * Attempts to read IRCv3 tags from the message.
39
+     */
40
+    private fun CursorByteArray.takeTags() = takeOptionalPrefixedSection(AT)
41
+
42
+    /**
43
+     * Attempts to read a prefix from the message.
44
+     */
45
+    private fun CursorByteArray.takePrefix() = takeOptionalPrefixedSection(COLON)
46
+
47
+    /**
48
+     * Read a single parameter from the message. If the parameter is a trailing one, the entire message will be
49
+     * consumed.
50
+     */
51
+    private fun CursorByteArray.takeParam() = when (peek()) {
52
+        COLON -> takeRemaining(skip = 1)
53
+        else -> takeWord()
54
+    }
55
+
56
+    /**
57
+     * Reads all remaining parameters from the message.
58
+     */
59
+    private fun CursorByteArray.takeParams() = sequence {
60
+        while (!exhausted()) {
61
+            yield(takeParam())
62
+        }
63
+    }
64
+
65
+    private fun CursorByteArray.takeOptionalPrefixedSection(prefix: Byte) = when {
66
+        exhausted() -> null
67
+        peek() == prefix -> takeWord(skip = 1)
68
+        else -> null
69
+    }
70
+
71
+}
72
+
73
+class IrcMessage(val tags: ByteArray?, val prefix: ByteArray?, val command: String, val params: List<ByteArray>)
74
+
75
+/**
76
+ * A ByteArray with a 'cursor' that tracks the current read position.
77
+ */
78
+internal class CursorByteArray(private val data: ByteArray, var cursor: Int = 0) {
79
+
80
+    companion object {
81
+        private const val SPACE = ' '.toByte()
82
+    }
83
+
84
+    /**
85
+     * Returns whether or not the cursor has reached the end of the array.
86
+     */
87
+    fun exhausted() = cursor >= data.size
88
+
89
+    /**
90
+     * Returns the next byte in the array without advancing the cursor.
91
+     *
92
+     * @throws ArrayIndexOutOfBoundsException If the array is [exhausted].
93
+     */
94
+    @Throws(ArrayIndexOutOfBoundsException::class)
95
+    fun peek() = data[cursor]
96
+
97
+    /**
98
+     * Returns the next "word" in the byte array - that is, all non-space characters up until the next space.
99
+     *
100
+     * After calling this method, the cursor will be advanced to the start of the next word (i.e., it will skip over
101
+     * any number of space characters).
102
+     *
103
+     * @param skip Number of bytes to omit from the start of the word
104
+     */
105
+    fun takeWord(skip: Int = 0) = data.sliceArray(cursor + skip until seekTo { it == SPACE }).apply { seekTo { it != SPACE } }
106
+
107
+    /**
108
+     * Takes all remaining bytes from the cursor until the end of the array.
109
+     *
110
+     * @param skip Number of bytes to omit from the start of the remainder
111
+     */
112
+    fun takeRemaining(skip: Int = 0) = data.sliceArray(cursor + skip until data.size).apply { cursor = data.size }
113
+
114
+    private fun seekTo(matcher: (Byte) -> Boolean): Int {
115
+        while (!exhausted() && !matcher(peek())) {
116
+            cursor++
117
+        }
118
+        return cursor
119
+    }
120
+
121
+}

+ 51
- 0
src/main/kotlin/com/dmdirc/ktirc/messages/ISupportProcessor.kt View File

@@ -0,0 +1,51 @@
1
+package com.dmdirc.ktirc.messages
2
+
3
+import com.dmdirc.ktirc.io.CaseMapping
4
+import com.dmdirc.ktirc.io.IrcMessage
5
+import com.dmdirc.ktirc.state.ServerFeature
6
+import com.dmdirc.ktirc.state.ServerState
7
+import com.dmdirc.ktirc.state.serverFeatures
8
+import kotlin.reflect.KClass
9
+
10
+class ISupportProcessor(val serverState: ServerState) : MessageProcessor {
11
+
12
+    override val commands = arrayOf("005")
13
+
14
+    override fun process(message: IrcMessage) {
15
+        // Ignore the first (nickname) and last ("are supported by this server") params
16
+        for (i in 1 until message.params.size - 1) {
17
+            parseParam(message.params[i])
18
+        }
19
+    }
20
+
21
+    private fun parseParam(param: ByteArray) = when (param[0]) {
22
+        '-'.toByte() -> resetFeature(param.sliceArray(1 until param.size))
23
+        else -> when (val equals = param.indexOf('='.toByte())) {
24
+            -1 -> enableFeatureWithDefault(param)
25
+            else -> enableFeature(param.sliceArray(0 until equals), param.sliceArray(equals + 1 until param.size))
26
+        }
27
+    }
28
+
29
+    private fun resetFeature(name: ByteArray) = name.asFeature()?.let { serverState.resetFeature(it) }
30
+
31
+    @Suppress("UNCHECKED_CAST")
32
+    private fun enableFeature(name: ByteArray, value: ByteArray) {
33
+        name.asFeature()?.let { feature ->
34
+            serverState.setFeature(feature, value.cast(feature.type))
35
+        }
36
+    }
37
+
38
+    private fun enableFeatureWithDefault(name: ByteArray) {
39
+        TODO("not implemented")
40
+    }
41
+
42
+    private fun ByteArray.asFeature() = serverFeatures[String(this)]
43
+
44
+    private fun ByteArray.cast(to: KClass<out Any>): Any = when (to) {
45
+        Int::class -> String(this).toInt()
46
+        String::class -> String(this)
47
+        CaseMapping::class -> CaseMapping.fromName(String(this))
48
+        else -> TODO("not implemented")
49
+    }
50
+
51
+}

+ 7
- 0
src/main/kotlin/com/dmdirc/ktirc/messages/MessageBuilders.kt View File

@@ -0,0 +1,7 @@
1
+package com.dmdirc.ktirc.messages
2
+
3
+fun joinMessage(channel: String) = "JOIN :$channel"
4
+fun nickMessage(nick: String) = "NICK :$nick"
5
+fun passwordMessage(password: String) = "PASS :$password"
6
+fun pongMessage(nonce: ByteArray) = "PONG :${String(nonce)}"
7
+fun userMessage(userName: String, localHostName: String, serverHostName: String, realName: String) = "USER $userName $localHostName $serverHostName :$realName"

+ 17
- 0
src/main/kotlin/com/dmdirc/ktirc/messages/MessageProcessor.kt View File

@@ -0,0 +1,17 @@
1
+package com.dmdirc.ktirc.messages
2
+
3
+import com.dmdirc.ktirc.io.IrcMessage
4
+
5
+interface MessageProcessor {
6
+
7
+    /**
8
+     * The messages which this handler can process.
9
+     */
10
+    val commands: Array<String>
11
+
12
+    /**
13
+     * Processes the given message.
14
+     */
15
+    fun process(message: IrcMessage)
16
+
17
+}

+ 3
- 0
src/main/kotlin/com/dmdirc/ktirc/model/Profile.kt View File

@@ -0,0 +1,3 @@
1
+package com.dmdirc.ktirc.model
2
+
3
+data class Profile(val initialNick: String, val realName: String, val userName: String)

+ 3
- 0
src/main/kotlin/com/dmdirc/ktirc/model/Server.kt View File

@@ -0,0 +1,3 @@
1
+package com.dmdirc.ktirc.model
2
+
3
+data class Server(val host: String, val port: Int, val ssl: Boolean = false, val password: String? = null)

+ 44
- 0
src/main/kotlin/com/dmdirc/ktirc/state/ServerState.kt View File

@@ -0,0 +1,44 @@
1
+package com.dmdirc.ktirc.state
2
+
3
+import com.dmdirc.ktirc.io.CaseMapping
4
+import kotlin.reflect.KClass
5
+
6
+interface ServerState {
7
+
8
+    var localNickname: String
9
+
10
+    fun <T : Any> getFeature(feature: ServerFeature<T>): T?
11
+    fun setFeature(feature: ServerFeature<*>, value: Any)
12
+    fun resetFeature(feature: ServerFeature<*>): Any?
13
+
14
+}
15
+
16
+class IrcServerState(initialNickname: String) : ServerState {
17
+
18
+    override var localNickname: String = initialNickname
19
+
20
+    private val features = HashMap<ServerFeature<*>, Any>()
21
+
22
+    @Suppress("UNCHECKED_CAST")
23
+    override fun <T : Any> getFeature(feature: ServerFeature<T>) = features.getOrDefault(feature, feature.default) as? T?
24
+
25
+    override fun setFeature(feature: ServerFeature<*>, value: Any) {
26
+        require(feature.type.isInstance(value))
27
+        features[feature] = value
28
+    }
29
+
30
+    override fun resetFeature(feature: ServerFeature<*>) = features.remove(feature)
31
+
32
+}
33
+
34
+
35
+sealed class ServerFeature<T : Any>(val name: String, val type: KClass<T>, val default: T? = null) {
36
+    object ServerCaseMapping : ServerFeature<CaseMapping>("CASEMAPPING", CaseMapping::class, CaseMapping.Rfc)
37
+    object MaximumChannels : ServerFeature<Int>("CHANLIMIT", Int::class)
38
+    object ChannelModes : ServerFeature<String>("CHANMODES", String::class)
39
+    object MaximumChannelNameLength : ServerFeature<Int>("CHANNELLEN", Int::class, 200)
40
+}
41
+
42
+val serverFeatures: Map<String, ServerFeature<*>> by lazy {
43
+    ServerFeature::class.nestedClasses.map { it.objectInstance as ServerFeature<*> }.associateBy { it.name }
44
+}

+ 100
- 0
src/test/kotlin/com/dmdirc/ktirc/IrcClientTest.kt View File

@@ -0,0 +1,100 @@
1
+package com.dmdirc.ktirc
2
+
3
+import com.dmdirc.ktirc.io.KtorLineBufferedSocket
4
+import com.dmdirc.ktirc.io.LineBufferedSocket
5
+import com.dmdirc.ktirc.model.Profile
6
+import com.dmdirc.ktirc.model.Server
7
+import com.nhaarman.mockitokotlin2.*
8
+import kotlinx.coroutines.channels.ArrayChannel
9
+import kotlinx.coroutines.channels.Channel
10
+import kotlinx.coroutines.channels.ReceiveChannel
11
+import kotlinx.coroutines.runBlocking
12
+import org.junit.jupiter.api.BeforeEach
13
+import org.junit.jupiter.api.Test
14
+import org.junit.jupiter.api.assertThrows
15
+import java.lang.IllegalStateException
16
+
17
+internal class IrcClientTest {
18
+
19
+    companion object {
20
+        private const val HOST = "thegibson.com"
21
+        private const val PORT = 12345
22
+        private const val NICK = "AcidBurn"
23
+        private const val REAL_NAME = "Kate Libby"
24
+        private const val USER_NAME = "acidb"
25
+        private const val PASSWORD = "HackThePlanet"
26
+    }
27
+
28
+    private val readLineChannel = Channel<ByteArray>(10)
29
+
30
+    private val mockSocket = mock<LineBufferedSocket> {
31
+        on { readLines(any()) } doReturn readLineChannel
32
+    }
33
+
34
+    private val mockSocketFactory = mock<(String, Int) -> LineBufferedSocket> {
35
+        on { invoke(HOST, PORT) } doReturn mockSocket
36
+    }
37
+
38
+    @Test
39
+    fun `IrcClient uses socket factory to create a new socket on connect`() {
40
+        runBlocking {
41
+            val client = IrcClient(Server(HOST, PORT), Profile(NICK, REAL_NAME, USER_NAME))
42
+            client.socketFactory = mockSocketFactory
43
+            readLineChannel.close()
44
+
45
+            client.connect()
46
+
47
+            verify(mockSocketFactory).invoke(HOST, PORT)
48
+        }
49
+    }
50
+
51
+    @Test
52
+    fun `IrcClient throws if socket already exists`() {
53
+        runBlocking {
54
+            val client = IrcClient(Server(HOST, PORT), Profile(NICK, REAL_NAME, USER_NAME))
55
+            client.socketFactory = mockSocketFactory
56
+            readLineChannel.close()
57
+
58
+            client.connect()
59
+
60
+            assertThrows<IllegalStateException> {
61
+                runBlocking {
62
+                    client.connect()
63
+                }
64
+            }
65
+        }
66
+    }
67
+
68
+    @Test
69
+    fun `IrcClient sends basic connection strings`() {
70
+        runBlocking {
71
+            val client = IrcClient(Server(HOST, PORT), Profile(NICK, REAL_NAME, USER_NAME))
72
+            client.socketFactory = mockSocketFactory
73
+            readLineChannel.close()
74
+
75
+            client.connect()
76
+
77
+            with(inOrder(mockSocket).verify(mockSocket)) {
78
+                sendLine("NICK :$NICK")
79
+                sendLine("USER $USER_NAME localhost $HOST :$REAL_NAME")
80
+            }
81
+        }
82
+    }
83
+
84
+    @Test
85
+    fun `IrcClient sends password first, when present`() {
86
+        runBlocking {
87
+            val client = IrcClient(Server(HOST, PORT, password = PASSWORD), Profile(NICK, REAL_NAME, USER_NAME))
88
+            client.socketFactory = mockSocketFactory
89
+            readLineChannel.close()
90
+
91
+            client.connect()
92
+
93
+            with(inOrder(mockSocket).verify(mockSocket)) {
94
+                sendLine("PASS :$PASSWORD")
95
+                sendLine("NICK :$NICK")
96
+            }
97
+        }
98
+    }
99
+
100
+}

+ 68
- 0
src/test/kotlin/com/dmdirc/ktirc/io/CaseMappingTest.kt View File

@@ -0,0 +1,68 @@
1
+package com.dmdirc.ktirc.io
2
+
3
+import org.junit.jupiter.api.Assertions.*
4
+import org.junit.jupiter.api.Test
5
+
6
+internal class CaseMappingTest {
7
+
8
+    @Test
9
+    fun `Equal unicode strings are equivalent regardless of case mapping`() {
10
+        val unicode = "\uD83D\uDC69\u200D\uD83C\uDF73 \uD83D\uDC68\u200D\uD83C\uDF73 \uD83D\uDC69\u200D\uD83C\uDF93 \uD83D\uDC68\u200D\uD83C\uDF93"
11
+        assertTrue(CaseMapping.Ascii.areEquivalent(unicode, unicode))
12
+        assertTrue(CaseMapping.Rfc.areEquivalent(unicode, unicode))
13
+        assertTrue(CaseMapping.RfcStrict.areEquivalent(unicode, unicode))
14
+    }
15
+
16
+    @Test
17
+    fun `Different length strings are always not equivalent`() {
18
+        val left = "abc"
19
+        val right = "ABC "
20
+        assertFalse(CaseMapping.Ascii.areEquivalent(left, right))
21
+        assertFalse(CaseMapping.Rfc.areEquivalent(left, right))
22
+        assertFalse(CaseMapping.RfcStrict.areEquivalent(left, right))
23
+    }
24
+
25
+    @Test
26
+    fun `ASCII characters are equivalent for all mappings`() {
27
+        val left = "the Quick Brown fox Jumps over the lazy dog"
28
+        val right = "THE qUICK bROWN fox jUMPS OVER THE LAZY DOG"
29
+        assertTrue(CaseMapping.Ascii.areEquivalent(left, right))
30
+        assertTrue(CaseMapping.Rfc.areEquivalent(left, right))
31
+        assertTrue(CaseMapping.RfcStrict.areEquivalent(left, right))
32
+    }
33
+
34
+    @Test
35
+    fun `RFC characters are equivalent for RFC mappings not ASCII`() {
36
+        val left = "[Hello\\There}"
37
+        val right = "{Hello|There]"
38
+        assertFalse(CaseMapping.Ascii.areEquivalent(left, right))
39
+        assertTrue(CaseMapping.Rfc.areEquivalent(left, right))
40
+        assertTrue(CaseMapping.RfcStrict.areEquivalent(left, right))
41
+    }
42
+
43
+    @Test
44
+    fun `Tilde and caret are equivalent only for RFC mapping`() {
45
+        val left = "~~^~~"
46
+        val right = "~^^^^"
47
+        assertFalse(CaseMapping.Ascii.areEquivalent(left, right))
48
+        assertTrue(CaseMapping.Rfc.areEquivalent(left, right))
49
+        assertFalse(CaseMapping.RfcStrict.areEquivalent(left, right))
50
+    }
51
+
52
+    @Test
53
+    fun `FromName returns matching mapping`() {
54
+        assertEquals(CaseMapping.Ascii, CaseMapping.fromName("ascii"))
55
+        assertEquals(CaseMapping.Ascii, CaseMapping.fromName("Ascii"))
56
+        assertEquals(CaseMapping.Rfc, CaseMapping.fromName("rfc1459"))
57
+        assertEquals(CaseMapping.Rfc, CaseMapping.fromName("RFC1459"))
58
+        assertEquals(CaseMapping.RfcStrict, CaseMapping.fromName("rfc1459-strict"))
59
+        assertEquals(CaseMapping.RfcStrict, CaseMapping.fromName("Rfc1459-Strict"))
60
+    }
61
+
62
+    @Test
63
+    fun `FromName falls back to RFC`() {
64
+        assertEquals(CaseMapping.Rfc, CaseMapping.fromName(""))
65
+        assertEquals(CaseMapping.Rfc, CaseMapping.fromName("foo"))
66
+    }
67
+
68
+}

+ 215
- 0
src/test/kotlin/com/dmdirc/ktirc/io/KtorLineBufferedSocketTest.kt View File

@@ -0,0 +1,215 @@
1
+package com.dmdirc.ktirc.io
2
+
3
+import kotlinx.coroutines.GlobalScope
4
+import kotlinx.coroutines.async
5
+import kotlinx.coroutines.launch
6
+import kotlinx.coroutines.runBlocking
7
+import org.junit.jupiter.api.Assertions.assertEquals
8
+import org.junit.jupiter.api.Assertions.assertNotNull
9
+import org.junit.jupiter.api.Test
10
+import org.junit.jupiter.api.parallel.Execution
11
+import org.junit.jupiter.api.parallel.ExecutionMode
12
+import java.net.ServerSocket
13
+
14
+@Execution(ExecutionMode.SAME_THREAD)
15
+internal class KtorLineBufferedSocketTest {
16
+
17
+    @Test
18
+    fun `KtorLineBufferedSocket can connect to a server`() = runBlocking {
19
+        ServerSocket(12321).use { serverSocket ->
20
+            val socket = KtorLineBufferedSocket("localhost", 12321)
21
+            val clientSocketAsync = GlobalScope.async { serverSocket.accept() }
22
+
23
+            socket.connect()
24
+
25
+            assertNotNull(clientSocketAsync.await())
26
+        }
27
+    }
28
+
29
+    @Test
30
+    fun `KtorLineBufferedSocket can send a whole byte array to a server`() = runBlocking {
31
+        ServerSocket(12321).use { serverSocket ->
32
+            val socket = KtorLineBufferedSocket("localhost", 12321)
33
+            val clientBytesAsync = GlobalScope.async {
34
+                ByteArray(13).apply {
35
+                    serverSocket.accept().getInputStream().read(this)
36
+                }
37
+            }
38
+
39
+            socket.connect()
40
+            socket.sendLine("Hello World".toByteArray())
41
+
42
+            val bytes = clientBytesAsync.await()
43
+            assertNotNull(bytes)
44
+            assertEquals("Hello World\r\n", String(bytes))
45
+        }
46
+    }
47
+
48
+    @Test
49
+    fun `KtorLineBufferedSocket can send a string to a server`() = runBlocking {
50
+        ServerSocket(12321).use { serverSocket ->
51
+            val socket = KtorLineBufferedSocket("localhost", 12321)
52
+            val clientBytesAsync = GlobalScope.async {
53
+                ByteArray(13).apply {
54
+                    serverSocket.accept().getInputStream().read(this)
55
+                }
56
+            }
57
+
58
+            socket.connect()
59
+            socket.sendLine("Hello World")
60
+
61
+            val bytes = clientBytesAsync.await()
62
+            assertNotNull(bytes)
63
+            assertEquals("Hello World\r\n", String(bytes))
64
+        }
65
+    }
66
+
67
+    @Test
68
+    fun `KtorLineBufferedSocket can send a partial byte array to a server`() = runBlocking {
69
+        ServerSocket(12321).use { serverSocket ->
70
+            val socket = KtorLineBufferedSocket("localhost", 12321)
71
+            val clientBytesAsync = GlobalScope.async {
72
+                ByteArray(7).apply {
73
+                    serverSocket.accept().getInputStream().read(this)
74
+                }
75
+            }
76
+
77
+            socket.connect()
78
+            socket.sendLine("Hello World".toByteArray(), 6, 5)
79
+
80
+            val bytes = clientBytesAsync.await()
81
+            assertNotNull(bytes)
82
+            assertEquals("World\r\n", String(bytes))
83
+        }
84
+    }
85
+
86
+    @Test
87
+    fun `KtorLineBufferedSocket can receive a line of CRLF delimited text`() = runBlocking {
88
+        ServerSocket(12321).use { serverSocket ->
89
+            val socket = KtorLineBufferedSocket("localhost", 12321)
90
+            GlobalScope.launch {
91
+                serverSocket.accept().getOutputStream().write("Hi there\r\n".toByteArray())
92
+            }
93
+
94
+            socket.connect()
95
+            assertEquals("Hi there", String(socket.readLines(GlobalScope).receive()))
96
+        }
97
+    }
98
+
99
+    @Test
100
+    fun `KtorLineBufferedSocket can receive a line of LF delimited text`() = runBlocking {
101
+        ServerSocket(12321).use { serverSocket ->
102
+            val socket = KtorLineBufferedSocket("localhost", 12321)
103
+            GlobalScope.launch {
104
+                serverSocket.accept().getOutputStream().write("Hi there\n".toByteArray())
105
+            }
106
+
107
+            socket.connect()
108
+            assertEquals("Hi there", String(socket.readLines(GlobalScope).receive()))
109
+        }
110
+    }
111
+
112
+    @Test
113
+    fun `KtorLineBufferedSocket can receive multiple lines of text in one packet`() = runBlocking {
114
+        ServerSocket(12321).use { serverSocket ->
115
+            val socket = KtorLineBufferedSocket("localhost", 12321)
116
+            GlobalScope.launch {
117
+                serverSocket.accept().getOutputStream().write("Hi there\nThis is a test\r".toByteArray())
118
+            }
119
+
120
+            socket.connect()
121
+            val lineProducer = socket.readLines(GlobalScope)
122
+            assertEquals("Hi there", String(lineProducer.receive()))
123
+            assertEquals("This is a test", String(lineProducer.receive()))
124
+        }
125
+    }
126
+
127
+    @Test
128
+    fun `KtorLineBufferedSocket can receive one line of text over multiple packets`() = runBlocking {
129
+        ServerSocket(12321).use { serverSocket ->
130
+            val socket = KtorLineBufferedSocket("localhost", 12321)
131
+            GlobalScope.launch {
132
+                with(serverSocket.accept().getOutputStream()) {
133
+                    write("Hi".toByteArray())
134
+                    flush()
135
+                    write(" t".toByteArray())
136
+                    flush()
137
+                    write("here\r\n".toByteArray())
138
+                    flush()
139
+                }
140
+            }
141
+
142
+            socket.connect()
143
+            val lineProducer = socket.readLines(GlobalScope)
144
+            assertEquals("Hi there", String(lineProducer.receive()))
145
+        }
146
+    }
147
+
148
+    @Test
149
+    fun `KtorLineBufferedSocket returns from readLines when socket is closed`() = runBlocking {
150
+        ServerSocket(12321).use { serverSocket ->
151
+            val socket = KtorLineBufferedSocket("localhost", 12321)
152
+            GlobalScope.launch {
153
+                with(serverSocket.accept()) {
154
+                    getOutputStream().write("Hi there\r\n".toByteArray())
155
+                    close()
156
+                }
157
+            }
158
+
159
+            socket.connect()
160
+            val lineProducer = socket.readLines(GlobalScope)
161
+            assertEquals("Hi there", String(lineProducer.receive()))
162
+        }
163
+    }
164
+
165
+    @Test
166
+    fun `KtorLineBufferedSocket disconnects from server`() = runBlocking {
167
+        ServerSocket(12321).use { serverSocket ->
168
+            val socket = KtorLineBufferedSocket("localhost", 12321)
169
+            val clientSocketAsync = GlobalScope.async { serverSocket.accept() }
170
+
171
+            socket.connect()
172
+            socket.disconnect()
173
+
174
+            assertEquals(-1, clientSocketAsync.await().getInputStream().read()) { "Server socket should EOF after KtorLineBufferedSocket disconnects" }
175
+        }
176
+    }
177
+
178
+    @Test
179
+    fun `KtorLineBufferedSocket reports sent lines to debug receiver`() = runBlocking {
180
+        ServerSocket(12321).use { serverSocket ->
181
+            val socket = KtorLineBufferedSocket("localhost", 12321)
182
+            GlobalScope.launch {
183
+                serverSocket.accept()
184
+            }
185
+
186
+            var received = ""
187
+            socket.debugReceiver = { str -> received = str }
188
+            socket.connect()
189
+            socket.sendLine("Test 123")
190
+
191
+            assertEquals(">>> Test 123", received)
192
+        }
193
+    }
194
+
195
+    @Test
196
+    fun `KtorLineBufferedSocket reports received lines to debug receiver`() = runBlocking {
197
+        ServerSocket(12321).use { serverSocket ->
198
+            val socket = KtorLineBufferedSocket("localhost", 12321)
199
+            GlobalScope.launch {
200
+                with(serverSocket.accept()) {
201
+                    getOutputStream().write("Hi there\r\n".toByteArray())
202
+                    close()
203
+                }
204
+            }
205
+
206
+            var received = ""
207
+            socket.debugReceiver = { str -> received = str }
208
+            socket.connect()
209
+            socket.readLines(this).receive()
210
+
211
+            assertEquals("<<< Hi there", received)
212
+        }
213
+    }
214
+
215
+}

+ 105
- 0
src/test/kotlin/com/dmdirc/ktirc/io/MessageParserTest.kt View File

@@ -0,0 +1,105 @@
1
+package com.dmdirc.ktirc.io
2
+
3
+import org.junit.jupiter.api.Assertions.*
4
+import org.junit.jupiter.api.Test
5
+import org.junit.jupiter.params.ParameterizedTest
6
+import org.junit.jupiter.params.provider.Arguments
7
+import org.junit.jupiter.params.provider.Arguments.arguments
8
+import org.junit.jupiter.params.provider.MethodSource
9
+import java.util.stream.Stream
10
+
11
+
12
+internal class MessageParserTest {
13
+
14
+    companion object {
15
+        @JvmStatic
16
+        @Suppress("unused")
17
+        fun ircMessageArgumentsProvider(): Stream<Arguments> = Stream.of(
18
+                arguments("test", null, null, "test", emptyList<String>()),
19
+                arguments("test 1 2", null, null, "test", listOf("1", "2")),
20
+                arguments("test    1     2     ", null, null, "test", listOf("1", "2")),
21
+                arguments("test :1 2", null, null, "test", listOf("1 2")),
22
+                arguments("test :1 2    ", null, null, "test", listOf("1 2    ")),
23
+                arguments("123 :1 2    ", null, null, "123", listOf("1 2    ")),
24
+                arguments(":test abc 1 2    ", null, "test", "abc", listOf("1", "2")),
25
+                arguments("@tags :test abc 1 2 :three four", "tags", "test", "abc", listOf("1", "2", "three four")),
26
+                arguments("@tags abc 1 2 : three four ", "tags", null, "abc", listOf("1", "2", " three four "))
27
+        )
28
+    }
29
+
30
+    @ParameterizedTest
31
+    @MethodSource("ircMessageArgumentsProvider")
32
+    fun `Parses IRC messages`(input: String, tags: String?, prefix: String?, command: String, params: List<String>) {
33
+        val parsed = MessageParser().parse(input.toByteArray())
34
+
35
+        assertEquals(tags, parsed.tags?.let { String(it) }) { "Expected '$input' to have tags '$tags'" }
36
+        assertEquals(prefix, parsed.prefix?.let { String(it) }) { "Expected '$input' to have prefix '$prefix'" }
37
+        assertEquals(command, parsed.command) { "Expected '$input' to have command '$command'" }
38
+        assertEquals(params, parsed.params.map { String(it) }) { "Expected '$input' to have params '$params'" }
39
+    }
40
+
41
+}
42
+
43
+internal class CursorByteArrayTest {
44
+
45
+    @Test
46
+    fun `Peek returns next byte without advancing cursor`() {
47
+        val cursorByteArray = CursorByteArray(byteArrayOf(0x08, 0x09, 0x10))
48
+        assertEquals(0x08, cursorByteArray.peek()) { "Peek should return the byte at the start" }
49
+        assertEquals(0x08, cursorByteArray.peek()) { "Peek shouldn't advance the cursor" }
50
+
51
+        cursorByteArray.cursor = 2
52
+        assertEquals(0x10, cursorByteArray.peek()) { "Peek should return the byte at the current cursor" }
53
+
54
+        cursorByteArray.cursor = 3
55
+        assertThrows(ArrayIndexOutOfBoundsException::class.java, { cursorByteArray.peek() }) { "Peek should throw if cursor is out of bounds" }
56
+    }
57
+
58
+    @Test
59
+    fun `Exhausted returns true when no more bytes available`() {
60
+        val cursorByteArray = CursorByteArray(byteArrayOf(0x08, 0x09, 0x10))
61
+        assertFalse(cursorByteArray.exhausted()) { "Exhausted should be false with a new array" }
62
+
63
+        cursorByteArray.cursor = 1
64
+        assertFalse(cursorByteArray.exhausted()) { "Exhausted should be false with an in-bound cursor" }
65
+
66
+        cursorByteArray.cursor = 2
67
+        assertFalse(cursorByteArray.exhausted()) { "Exhausted should be false at the last element" }
68
+
69
+        cursorByteArray.cursor = 3
70
+        assertTrue(cursorByteArray.exhausted()) { "Exhausted should be true when past the last element" }
71
+
72
+        assertTrue(CursorByteArray(byteArrayOf()).exhausted()) { "Exhausted should be true on an empty array" }
73
+    }
74
+
75
+    @Test
76
+    fun `TakeWord reads next word and advances cursor beyond trailing whitespace`() {
77
+        val cursorByteArray = CursorByteArray("Hello this    is a    test".toByteArray())
78
+
79
+        assertEquals("Hello", String(cursorByteArray.takeWord())) { "TakeWord should read first word" }
80
+        assertEquals(6, cursorByteArray.cursor) { "TakeWord should advance cursor to next word" }
81
+
82
+        assertEquals("this", String(cursorByteArray.takeWord())) { "TakeWord should read word at cursor" }
83
+        assertEquals(14, cursorByteArray.cursor) { "TakeWord should advance cursor past trailing whitespace" }
84
+
85
+        assertEquals("s", String(cursorByteArray.takeWord(1))) { "TakeWord should skip given number of bytes" }
86
+
87
+        cursorByteArray.cursor = 22
88
+        assertEquals("test", String(cursorByteArray.takeWord())) { "TakeWord should read word at end" }
89
+        assertEquals(26, cursorByteArray.cursor) { "TakeWord should advance cursor past last word" }
90
+    }
91
+
92
+    @Test
93
+    fun `TakeRemaining takes all remaining bytes and advances the cursor to exhaustion`() {
94
+        var cursorByteArray = CursorByteArray("Test1234".toByteArray(), 4)
95
+        assertEquals("1234", String(cursorByteArray.takeRemaining())) { "TakeRemaining should return remaining bytes" }
96
+        assertEquals(8, cursorByteArray.cursor) { "TakeRemaining should advance cursor to end of array" }
97
+
98
+        cursorByteArray = CursorByteArray("Test1234".toByteArray(), 0)
99
+        assertEquals("est1234", String(cursorByteArray.takeRemaining(1))) { "TakeRemaining should skip specified number of bytes" }
100
+        assertEquals(8, cursorByteArray.cursor) { "TakeRemaining should advance cursor to end of array when skipping" }
101
+    }
102
+
103
+    private fun byteArrayOf(vararg bytes: Byte) = ByteArray(bytes.size) { i -> bytes[i] }
104
+
105
+}

+ 55
- 0
src/test/kotlin/com/dmdirc/ktirc/messages/ISupportProcessorTest.kt View File

@@ -0,0 +1,55 @@
1
+package com.dmdirc.ktirc.messages
2
+
3
+import com.dmdirc.ktirc.io.CaseMapping
4
+import com.dmdirc.ktirc.io.IrcMessage
5
+import com.dmdirc.ktirc.state.ServerFeature
6
+import com.dmdirc.ktirc.state.ServerState
7
+import com.nhaarman.mockitokotlin2.mock
8
+import com.nhaarman.mockitokotlin2.verify
9
+import org.junit.jupiter.api.Assertions.*
10
+import org.junit.jupiter.api.Test
11
+
12
+internal class ISupportProcessorTest {
13
+
14
+    private val state = mock<ServerState>()
15
+    private val processor = ISupportProcessor(state)
16
+
17
+    @Test
18
+    fun `ISupportProcessor can handle 005s`() {
19
+        assertTrue(processor.commands.contains("005")) { "ISupportProcessor should handle 005 messages" }
20
+    }
21
+
22
+    @Test
23
+    fun `ISupportProcessor handles multiple numeric arguments`() {
24
+        processor.process(IrcMessage(null, "server.com".toByteArray(), "005",
25
+                listOf("nickname", "CHANLIMIT=123", "CHANNELLEN=456", "are supported blah blah").map { it.toByteArray() }))
26
+
27
+        verify(state).setFeature(ServerFeature.MaximumChannels, 123)
28
+        verify(state).setFeature(ServerFeature.MaximumChannelNameLength, 456)
29
+    }
30
+
31
+    @Test
32
+    fun `ISupportProcessor handles string arguments`() {
33
+        processor.process(IrcMessage(null, "server.com".toByteArray(), "005",
34
+                listOf("nickname", "CHANMODES=abcd", "are supported blah blah").map { it.toByteArray() }))
35
+
36
+        verify(state).setFeature(ServerFeature.ChannelModes, "abcd")
37
+    }
38
+
39
+    @Test
40
+    fun `ISupportProcessor handles resetting arguments`() {
41
+        processor.process(IrcMessage(null, "server.com".toByteArray(), "005",
42
+                listOf("nickname", "-CHANMODES", "are supported blah blah").map { it.toByteArray() }))
43
+
44
+        verify(state).resetFeature(ServerFeature.ChannelModes)
45
+    }
46
+
47
+    @Test
48
+    fun `ISupportProcessor handles case mapping arguments`() {
49
+        processor.process(IrcMessage(null, "server.com".toByteArray(), "005",
50
+                listOf("nickname", "CASEMAPPING=rfc1459-strict", "are supported blah blah").map { it.toByteArray() }))
51
+
52
+        verify(state).setFeature(ServerFeature.ServerCaseMapping, CaseMapping.RfcStrict)
53
+    }
54
+
55
+}

+ 23
- 0
src/test/kotlin/com/dmdirc/ktirc/messages/MessageBuildersTest.kt View File

@@ -0,0 +1,23 @@
1
+package com.dmdirc.ktirc.messages
2
+
3
+import org.junit.jupiter.api.Assertions.*
4
+import org.junit.jupiter.api.Test
5
+
6
+internal class MessageBuildersTest {
7
+
8
+    @Test
9
+    fun `JoinMessage creates correct JOIN message`() = assertEquals("JOIN :#Test123", joinMessage("#Test123"))
10
+
11
+    @Test
12
+    fun `NickMessage creates correct NICK message`() = assertEquals("NICK :AcidBurn", nickMessage("AcidBurn"))
13
+
14
+    @Test
15
+    fun `PasswordMessage creates correct PASS message`() = assertEquals("PASS :abcdef", passwordMessage("abcdef"))
16
+
17
+    @Test
18
+    fun `PongMessage creates correct PONG message`() = assertEquals("PONG :abcdef", pongMessage("abcdef".toByteArray()))
19
+
20
+    @Test
21
+    fun `UserMessage creates correct USER message`() = assertEquals("USER AcidBurn localhost gibson :Kate", userMessage("AcidBurn", "localhost", "gibson", "Kate"))
22
+
23
+}

+ 50
- 0
src/test/kotlin/com/dmdirc/ktirc/state/IrcServerStateTest.kt View File

@@ -0,0 +1,50 @@
1
+package com.dmdirc.ktirc.state
2
+
3
+import org.junit.jupiter.api.Assertions.*
4
+import org.junit.jupiter.api.Test
5
+import java.lang.IllegalArgumentException
6
+
7
+internal class IrcServerStateTest {
8
+
9
+    @Test
10
+    fun `IrcServerState should return defaults for unspecified features`() {
11
+        val serverState = IrcServerState("")
12
+        assertEquals(200, serverState.getFeature(ServerFeature.MaximumChannelNameLength))
13
+    }
14
+
15
+    @Test
16
+    fun `IrcServerState should return null for unspecified features with no default`() {
17
+        val serverState = IrcServerState("")
18
+        assertNull(serverState.getFeature(ServerFeature.ChannelModes))
19
+    }
20
+
21
+    @Test
22
+    fun `IrcServerState should return previously set value for features`() {
23
+        val serverState = IrcServerState("")
24
+        serverState.setFeature(ServerFeature.MaximumChannels, 123)
25
+        assertEquals(123, serverState.getFeature(ServerFeature.MaximumChannels))
26
+    }
27
+
28
+    @Test
29
+    fun `IrcServerState should return default set value for features that were reset`() {
30
+        val serverState = IrcServerState("")
31
+        serverState.setFeature(ServerFeature.MaximumChannels, 123)
32
+        serverState.resetFeature(ServerFeature.MaximumChannels)
33
+        assertNull(serverState.getFeature(ServerFeature.MaximumChannels))
34
+    }
35
+
36
+    @Test
37
+    fun `IrcServerState should throw if a feature is set with the wrong type`() {
38
+        val serverState = IrcServerState("")
39
+        assertThrows(IllegalArgumentException::class.java) {
40
+            serverState.setFeature(ServerFeature.MaximumChannels, "123")
41
+        }
42
+    }
43
+
44
+    @Test
45
+    fun `IrcServerState should use the initial nickname as local nickname`() {
46
+        val serverState = IrcServerState("acidBurn")
47
+        assertEquals("acidBurn", serverState.localNickname)
48
+    }
49
+
50
+}

Loading…
Cancel
Save