about summary refs log tree commit diff
path: root/IRCSharp.Tests
diff options
context:
space:
mode:
authorBen Harris <ben@tilde.team>2020-11-10 18:35:21 -0500
committerBen Harris <ben@tilde.team>2020-11-10 18:35:21 -0500
commit35bbd30c2506b3d0b18397ef1443fb18c0d893d6 (patch)
tree893862078b9045fbfb73296a0290d16f245b2c2c /IRCSharp.Tests
parentb8e2634193eef0b7a4db417144fe7f38a5140c3b (diff)
Move tests to a separate project
Diffstat (limited to 'IRCSharp.Tests')
-rw-r--r--IRCSharp.Tests/IRCSharp.Tests.csproj31
-rw-r--r--IRCSharp.Tests/State/Cap.cs131
-rw-r--r--IRCSharp.Tests/State/Casemap.cs58
-rw-r--r--IRCSharp.Tests/State/Channel.cs202
-rw-r--r--IRCSharp.Tests/State/Emit.cs117
-rw-r--r--IRCSharp.Tests/State/ISupport.cs210
-rw-r--r--IRCSharp.Tests/State/Mode.cs179
-rw-r--r--IRCSharp.Tests/State/Motd.cs23
-rw-r--r--IRCSharp.Tests/State/Sasl.cs38
-rw-r--r--IRCSharp.Tests/State/User.cs298
-rw-r--r--IRCSharp.Tests/State/Who.cs61
-rw-r--r--IRCSharp.Tests/Tokenization/Data/JoinModel.cs30
-rw-r--r--IRCSharp.Tests/Tokenization/Data/SplitModel.cs15
-rw-r--r--IRCSharp.Tests/Tokenization/Data/msg-join.yaml221
-rw-r--r--IRCSharp.Tests/Tokenization/Data/msg-split.yaml343
-rw-r--r--IRCSharp.Tests/Tokenization/Format.cs105
-rw-r--r--IRCSharp.Tests/Tokenization/Hostmask.cs64
-rw-r--r--IRCSharp.Tests/Tokenization/Parser.cs55
-rw-r--r--IRCSharp.Tests/Tokenization/StatefulDecoder.cs88
-rw-r--r--IRCSharp.Tests/Tokenization/StatefulEncoder.cs84
-rw-r--r--IRCSharp.Tests/Tokenization/Tokenization.cs133
21 files changed, 2486 insertions, 0 deletions
diff --git a/IRCSharp.Tests/IRCSharp.Tests.csproj b/IRCSharp.Tests/IRCSharp.Tests.csproj
new file mode 100644
index 0000000..e068b60
--- /dev/null
+++ b/IRCSharp.Tests/IRCSharp.Tests.csproj
@@ -0,0 +1,31 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <PropertyGroup>
+    <TargetFramework>net5.0</TargetFramework>
+
+    <IsPackable>false</IsPackable>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.7.1" />
+    <PackageReference Include="MSTest.TestAdapter" Version="2.1.1" />
+    <PackageReference Include="MSTest.TestFramework" Version="2.1.1" />
+    <PackageReference Include="coverlet.collector" Version="1.3.0" />
+    <PackageReference Include="YamlDotNet" Version="8.1.2" />
+  </ItemGroup>
+
+  <ItemGroup>
+    <ProjectReference Include="..\IRCStates\IRCStates.csproj" />
+    <ProjectReference Include="..\IRCTokens\IRCTokens.csproj" />
+  </ItemGroup>
+
+  <ItemGroup>
+    <None Update="Tokenization\Data\msg-join.yaml">
+      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+    </None>
+    <None Update="Tokenization\Data\msg-split.yaml">
+      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+    </None>
+  </ItemGroup>
+
+</Project>
diff --git a/IRCSharp.Tests/State/Cap.cs b/IRCSharp.Tests/State/Cap.cs
new file mode 100644
index 0000000..3c0faba
--- /dev/null
+++ b/IRCSharp.Tests/State/Cap.cs
@@ -0,0 +1,131 @@
+using System.Collections.Generic;
+using IRCTokens;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace IRCStates.Tests
+{
+    [TestClass]
+    public class Cap
+    {
+        private Server _server;
+
+        [TestInitialize]
+        public void TestInitialize()
+        {
+            _server = new Server("test");
+        }
+
+        [TestMethod]
+        public void LSOneLine()
+        {
+            Assert.IsFalse(_server.HasCap);
+            CollectionAssert.AreEqual(new Dictionary<string, string>(), _server.AvailableCaps);
+            _server.Parse(new Line("CAP * LS :a b"));
+            CollectionAssert.AreEqual(new Dictionary<string, string> {{"a", ""}, {"b", ""}}, _server.AvailableCaps);
+        }
+
+        [TestMethod]
+        public void LSTwoLines()
+        {
+            _server.Parse(new Line("CAP * LS * :a b"));
+            CollectionAssert.AreEqual(new Dictionary<string, string>(), _server.AvailableCaps);
+            _server.Parse(new Line("CAP * LS :c"));
+            Assert.IsTrue(_server.AvailableCaps.ContainsKey("a"));
+            Assert.IsTrue(_server.AvailableCaps.ContainsKey("b"));
+            Assert.IsTrue(_server.AvailableCaps.ContainsKey("c"));
+        }
+
+        [TestMethod]
+        public void LSValues()
+        {
+            _server.Parse(new Line("CAP * LS :a b= c=1"));
+            CollectionAssert.AreEqual(new Dictionary<string, string> {{"a", ""}, {"b", ""}, {"c", "1"}},
+                _server.AvailableCaps);
+        }
+
+        [TestMethod]
+        public void ACKOneLine()
+        {
+            _server.Parse(new Line("CAP * LS :a b"));
+            _server.Parse(new Line("CAP * ACK :a b"));
+            CollectionAssert.AreEqual(new List<string> {"a", "b"}, _server.AgreedCaps);
+        }
+
+        [TestMethod]
+        public void ACKTwoLines()
+        {
+            _server.Parse(new Line("CAP * LS :a b c"));
+            _server.Parse(new Line("CAP * ACK * :a b"));
+            _server.Parse(new Line("CAP * ACK :c"));
+            CollectionAssert.AreEqual(new List<string> {"a", "b", "c"}, _server.AgreedCaps);
+        }
+
+        [TestMethod]
+        public void ACKNotLS()
+        {
+            _server.Parse(new Line("CAP * LS a"));
+            _server.Parse(new Line("CAP * ACK b"));
+            CollectionAssert.AreEqual(new List<string>(), _server.AgreedCaps);
+        }
+
+        [TestMethod]
+        public void NEWNoLS()
+        {
+            _server.Parse(new Line("CAP * NEW :a"));
+            CollectionAssert.AreEqual(new Dictionary<string, string> {{"a", ""}}, _server.AvailableCaps);
+        }
+
+        [TestMethod]
+        public void NEWOneLine()
+        {
+            _server.Parse(new Line("CAP * LS :a"));
+            _server.Parse(new Line("CAP * NEW :b"));
+            CollectionAssert.AreEqual(new Dictionary<string, string> {{"a", ""}, {"b", ""}}, _server.AvailableCaps);
+        }
+
+        [TestMethod]
+        public void NEWTwoLines()
+        {
+            _server.Parse(new Line("CAP * LS :a"));
+            _server.Parse(new Line("CAP * NEW :b c"));
+            CollectionAssert.AreEqual(new Dictionary<string, string> {{"a", ""}, {"b", ""}, {"c", ""}},
+                _server.AvailableCaps);
+        }
+
+        [TestMethod]
+        public void DELNotAcked()
+        {
+            _server.Parse(new Line("CAP * DEL a"));
+        }
+
+        [TestMethod]
+        public void DELOneLS()
+        {
+            _server.Parse(new Line("CAP * LS :a"));
+            _server.Parse(new Line("CAP * ACK :a"));
+            _server.Parse(new Line("CAP * DEL :a"));
+            CollectionAssert.AreEqual(new Dictionary<string, string>(), _server.AvailableCaps);
+            CollectionAssert.AreEqual(new List<string>(), _server.AgreedCaps);
+        }
+
+        [TestMethod]
+        public void DELTwoLS()
+        {
+            _server.Parse(new Line("CAP * LS :a b"));
+            _server.Parse(new Line("CAP * ACK :a b"));
+            _server.Parse(new Line("CAP * DEL :a"));
+            CollectionAssert.AreEqual(new Dictionary<string, string> {{"b", ""}}, _server.AvailableCaps);
+            CollectionAssert.AreEqual(new List<string> {"b"}, _server.AgreedCaps);
+        }
+
+        [TestMethod]
+        public void DELTwoDEL()
+        {
+            _server.Parse(new Line("CAP * LS :a b"));
+            _server.Parse(new Line("CAP * ACK :a b"));
+            _server.Parse(new Line("CAP * DEL :a b"));
+            CollectionAssert.AreEqual(new Dictionary<string, string>(), _server.AvailableCaps);
+            CollectionAssert.AreEqual(new List<string>(), _server.AgreedCaps);
+        }
+    }
+}
diff --git a/IRCSharp.Tests/State/Casemap.cs b/IRCSharp.Tests/State/Casemap.cs
new file mode 100644
index 0000000..4a02444
--- /dev/null
+++ b/IRCSharp.Tests/State/Casemap.cs
@@ -0,0 +1,58 @@
+using IRCTokens;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace IRCStates.Tests
+{
+    [TestClass]
+    public class Casemap
+    {
+        [TestMethod]
+        public void Rfc1459()
+        {
+            var lower = IRCStates.Casemap.CaseFold(IRCStates.Casemap.CaseMapping.Rfc1459, @"ÀTEST[]~\");
+            Assert.AreEqual("Àtest{}^|", lower);
+        }
+
+        [TestMethod]
+        public void Ascii()
+        {
+            var lower = IRCStates.Casemap.CaseFold(IRCStates.Casemap.CaseMapping.Ascii, @"ÀTEST[]~\");
+            Assert.AreEqual(@"Àtest[]~\", lower);
+        }
+
+        [TestMethod]
+        public void CommandJoin()
+        {
+            var server = new Server("test");
+            server.Parse(new Line("001 nickname"));
+            server.Parse(new Line(":Nickname JOIN #Chan"));
+            server.Parse(new Line(":Other JOIN #Chan"));
+
+            Assert.IsTrue(server.Users.ContainsKey("nickname"));
+            Assert.IsFalse(server.Users.ContainsKey("Nickname"));
+            Assert.IsTrue(server.Users.ContainsKey("other"));
+            Assert.IsFalse(server.Users.ContainsKey("Other"));
+            Assert.IsTrue(server.Channels.ContainsKey("#chan"));
+            Assert.IsFalse(server.Channels.ContainsKey("#Chan"));
+
+            var channel = server.Channels["#chan"];
+            Assert.AreEqual("#Chan", channel.Name);
+        }
+
+        [TestMethod]
+        public void CommandNick()
+        {
+            var server = new Server("test");
+            server.Parse(new Line("001 nickname"));
+            server.Parse(new Line(":nickname JOIN #chan"));
+            var user = server.Users["nickname"];
+            server.Parse(new Line(":nickname NICK NewNickname"));
+            Assert.AreEqual(1, server.Users.Count);
+            Assert.IsTrue(server.Users.ContainsKey("newnickname"));
+            Assert.AreEqual("NewNickname", user.NickName);
+            Assert.AreEqual("newnickname", user.NickNameLower);
+            Assert.AreEqual("NewNickname", server.NickName);
+            Assert.AreEqual("newnickname", server.NickNameLower);
+        }
+    }
+}
diff --git a/IRCSharp.Tests/State/Channel.cs b/IRCSharp.Tests/State/Channel.cs
new file mode 100644
index 0000000..6868e0f
--- /dev/null
+++ b/IRCSharp.Tests/State/Channel.cs
@@ -0,0 +1,202 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using IRCTokens;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace IRCStates.Tests
+{
+    [TestClass]
+    public class Channel
+    {
+        private Server _server;
+
+        [TestInitialize]
+        public void TestInitialize()
+        {
+            _server = new Server("test");
+            _server.Parse(new Line("001 nickname"));
+            _server.Parse(new Line(":nickname JOIN #chan"));
+        }
+
+        [TestMethod]
+        public void JoinSelf()
+        {
+            Assert.IsTrue(_server.Channels.ContainsKey("#chan"));
+            Assert.IsTrue(_server.Users.ContainsKey("nickname"));
+            Assert.AreEqual(1, _server.Channels.Count);
+            Assert.AreEqual(1, _server.Users.Count);
+
+            var user = _server.Users["nickname"];
+            var chan = _server.Channels["#chan"];
+            Assert.IsTrue(chan.Users.ContainsKey(user.NickNameLower));
+            var chanUser = chan.Users[user.NickNameLower];
+            CollectionAssert.AreEqual(new List<string> {chan.NameLower}, user.Channels.ToList());
+        }
+
+        [TestMethod]
+        public void JoinOther()
+        {
+            _server.Parse(new Line(":other JOIN #chan"));
+
+            Assert.AreEqual(2, _server.Users.Count);
+            Assert.IsTrue(_server.Users.ContainsKey("other"));
+
+            var channel = _server.Channels["#chan"];
+            Assert.AreEqual(2, channel.Users.Count);
+
+            var user = _server.Users["other"];
+            CollectionAssert.AreEqual(new List<string> {channel.NameLower}, user.Channels.ToList());
+        }
+
+        [TestMethod]
+        public void PartSelf()
+        {
+            _server.Parse(new Line(":nickname PART #chan"));
+
+            Assert.AreEqual(0, _server.Users.Count);
+            Assert.AreEqual(0, _server.Channels.Count);
+        }
+
+        [TestMethod]
+        public void PartOther()
+        {
+            _server.Parse(new Line(":other JOIN #chan"));
+            _server.Parse(new Line(":other PART #chan"));
+
+            var user     = _server.Users["nickname"];
+            var channel  = _server.Channels["#chan"];
+            var chanUser = channel.Users[user.NickNameLower];
+
+            Assert.AreEqual(channel.NameLower, user.Channels.Single());
+            CollectionAssert.AreEqual(new Dictionary<string, IRCStates.User> {{"nickname", user}}, _server.Users);
+            CollectionAssert.AreEqual(new Dictionary<string, IRCStates.Channel> {{"#chan", channel}}, _server.Channels);
+            CollectionAssert.AreEqual(new Dictionary<string, ChannelUser> {{"nickname", chanUser}}, channel.Users);
+        }
+
+        [TestMethod]
+        public void KickSelf()
+        {
+            _server.Parse(new Line(":nickname KICK #chan nickname"));
+
+            Assert.AreEqual(0, _server.Users.Count);
+            Assert.AreEqual(0, _server.Channels.Count);
+        }
+
+        [TestMethod]
+        public void KickOther()
+        {
+            _server.Parse(new Line(":other JOIN #chan"));
+            _server.Parse(new Line(":nickname KICK #chan other"));
+
+            var user     = _server.Users["nickname"];
+            var channel  = _server.Channels["#chan"];
+            var chanUser = channel.Users[user.NickNameLower];
+
+            Assert.AreEqual(1, _server.Users.Count);
+            Assert.AreEqual(1, _server.Channels.Count);
+            Assert.AreEqual(channel.NameLower, user.Channels.Single());
+            CollectionAssert.AreEqual(new Dictionary<string, ChannelUser> {{user.NickNameLower, chanUser}},
+                channel.Users);
+        }
+
+        [TestMethod]
+        public void QuitSelf()
+        {
+            _server.Parse(new Line("QUIT :i'm outta here"));
+            Assert.IsFalse(_server.Users.Any());
+            Assert.IsFalse(_server.Channels.Any());
+        }
+
+        [TestMethod]
+        public void QuitSelfWithSource()
+        {
+            _server.Parse(new Line(":nickname QUIT :i'm outta here"));
+            Assert.IsFalse(_server.Users.Any());
+            Assert.IsFalse(_server.Channels.Any());
+        }
+
+        [TestMethod]
+        public void QuitOther()
+        {
+            _server.Parse(new Line(":other JOIN #chan"));
+            _server.Parse(new Line(":other QUIT :see ya"));
+            Assert.IsFalse(_server.Users.ContainsKey("other"));
+        }
+
+        [TestMethod]
+        public void TopicText()
+        {
+            _server.Parse(new Line("332 * #chan :test"));
+            Assert.AreEqual("test", _server.Channels["#chan"].Topic);
+        }
+
+        [TestMethod]
+        public void TopicSetByAt()
+        {
+            var dt = DateTimeOffset.FromUnixTimeSeconds(1584023277).DateTime;
+            _server.Parse(new Line("333 * #chan other 1584023277"));
+
+            var channel = _server.Channels["#chan"];
+
+            Assert.AreEqual("other", channel.TopicSetter);
+            Assert.AreEqual(dt, channel.TopicTime);
+        }
+
+        [TestMethod]
+        public void TopicCommand()
+        {
+            _server.Parse(new Line("TOPIC #chan :hello there"));
+            Assert.AreEqual("hello there", _server.Channels["#chan"].Topic);
+        }
+
+        [TestMethod]
+        public void CreationDate()
+        {
+            _server.Parse(new Line("329 * #chan 1584041889"));
+            Assert.AreEqual(DateTimeOffset.FromUnixTimeSeconds(1584041889).DateTime, _server.Channels["#chan"].Created);
+        }
+
+        [TestMethod]
+        public void NamesCommand()
+        {
+            _server.Parse(new Line("353 * * #chan :nickname @+other"));
+            Assert.IsTrue(_server.Users.ContainsKey("nickname"));
+            Assert.IsTrue(_server.Users.ContainsKey("other"));
+
+            var user      = _server.Users["other"];
+            var channel   = _server.Channels["#chan"];
+            var chanUser1 = channel.Users[user.NickNameLower];
+            var chanUser2 = channel.Users[_server.NickNameLower];
+
+            Assert.AreEqual(2, channel.Users.Count);
+            CollectionAssert.AreEqual(chanUser1.Modes, channel.Users[user.NickNameLower].Modes);
+            CollectionAssert.AreEqual(chanUser2.Modes, channel.Users[_server.NickNameLower].Modes);
+            CollectionAssert.AreEqual(new List<string> {"o", "v"}, chanUser1.Modes);
+            Assert.AreEqual(channel.NameLower, user.Channels.Single());
+        }
+
+        [TestMethod]
+        public void UserhostInNames()
+        {
+            _server.Parse(new Line("353 * * #chan :nickname!user@host other!user2@host2"));
+            Assert.AreEqual("user", _server.UserName);
+            Assert.AreEqual("host", _server.HostName);
+
+            var user = _server.Users["other"];
+            Assert.AreEqual("user2", user.UserName);
+            Assert.AreEqual("host2", user.HostName);
+        }
+
+        [TestMethod]
+        public void NickAfterJoin()
+        {
+            var user     = _server.Users["nickname"];
+            var channel  = _server.Channels["#chan"];
+            var chanUser = channel.Users[user.NickNameLower];
+            _server.Parse(new Line(":nickname NICK nickname2"));
+            CollectionAssert.AreEqual(new Dictionary<string, ChannelUser> {{user.NickNameLower, chanUser}},
+                channel.Users);
+        }
+    }
+}
diff --git a/IRCSharp.Tests/State/Emit.cs b/IRCSharp.Tests/State/Emit.cs
new file mode 100644
index 0000000..07fea8c
--- /dev/null
+++ b/IRCSharp.Tests/State/Emit.cs
@@ -0,0 +1,117 @@
+using System.Collections.Generic;
+using IRCTokens;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace IRCStates.Tests
+{
+    [TestClass]
+    public class Emit
+    {
+        private Server _server;
+
+        [TestInitialize]
+        public void TestInitialize()
+        {
+            _server = new Server("test");
+            _server.Parse(new Line("001 nickname"));
+        }
+
+        [TestMethod]
+        public void EmitJoin()
+        {
+            var emit = _server.Parse(new Line(":nickname JOIN #chan"));
+
+            Assert.AreEqual("JOIN", emit.Command);
+            Assert.IsTrue(emit.Self);
+            Assert.AreEqual(_server.Users["nickname"], emit.User);
+            Assert.AreEqual(_server.Channels["#chan"], emit.Channel);
+
+            emit = _server.Parse(new Line(":other JOIN #chan"));
+            Assert.IsNotNull(emit);
+            Assert.AreEqual("JOIN", emit.Command);
+            Assert.IsFalse(emit.Self);
+            Assert.AreEqual(_server.Users["other"], emit.User);
+            Assert.AreEqual(_server.Channels["#chan"], emit.Channel);
+        }
+
+        [TestMethod]
+        public void EmitPrivmsg()
+        {
+            _server.Parse(new Line(":nickname JOIN #chan"));
+            var emit = _server.Parse(new Line(":nickname PRIVMSG #chan :hello"));
+
+            Assert.IsNotNull(emit);
+            Assert.AreEqual("PRIVMSG", emit.Command);
+            Assert.AreEqual("hello", emit.Text);
+            Assert.IsTrue(emit.SelfSource);
+            Assert.AreEqual(_server.Users["nickname"], emit.User);
+            Assert.AreEqual(_server.Channels["#chan"], emit.Channel);
+
+            _server.Parse(new Line(":other JOIN #chan"));
+            emit = _server.Parse(new Line(":other PRIVMSG #chan :hello2"));
+
+            Assert.IsNotNull(emit);
+            Assert.AreEqual("PRIVMSG", emit.Command);
+            Assert.AreEqual("hello2", emit.Text);
+            Assert.IsFalse(emit.SelfSource);
+            Assert.AreEqual(_server.Users["other"], emit.User);
+            Assert.AreEqual(_server.Channels["#chan"], emit.Channel);
+        }
+
+        [TestMethod]
+        public void EmitPrivmsgNoJoin()
+        {
+            _server.Parse(new Line(":nickname JOIN #chan"));
+            var emit = _server.Parse(new Line(":other PRIVMSG #chan :hello"));
+
+            Assert.IsNotNull(emit);
+            Assert.AreEqual("PRIVMSG", emit.Command);
+            Assert.AreEqual("hello", emit.Text);
+            Assert.IsFalse(emit.SelfSource);
+            Assert.IsNotNull(emit.User);
+
+            var channel = _server.Channels["#chan"];
+            Assert.AreEqual(channel, emit.Channel);
+        }
+
+        [TestMethod]
+        public void EmitKick()
+        {
+            _server.Parse(new Line(":nickname JOIN #chan"));
+
+            var user    = _server.Users["nickname"];
+            var channel = _server.Channels["#chan"];
+            _server.Parse(new Line(":other JOIN #chan"));
+            var userOther = _server.Users["other"];
+            var emit      = _server.Parse(new Line(":nickname KICK #chan other :reason"));
+
+            Assert.IsNotNull(emit);
+            Assert.AreEqual("KICK", emit.Command);
+            Assert.AreEqual("reason", emit.Text);
+            Assert.IsTrue(emit.SelfSource);
+            Assert.AreEqual(user, emit.UserSource);
+            Assert.AreEqual(userOther, emit.UserTarget);
+            Assert.AreEqual(channel, emit.Channel);
+        }
+
+        [TestMethod]
+        public void EmitMode()
+        {
+            var emit = _server.Parse(new Line("MODE nickname x+i-i+wi-wi"));
+
+            Assert.IsNotNull(emit);
+            Assert.AreEqual("MODE", emit.Command);
+            Assert.IsTrue(emit.SelfTarget);
+            CollectionAssert.AreEqual(new List<string>
+            {
+                "+x",
+                "+i",
+                "-i",
+                "+w",
+                "+i",
+                "-w",
+                "-i"
+            }, emit.Tokens);
+        }
+    }
+}
diff --git a/IRCSharp.Tests/State/ISupport.cs b/IRCSharp.Tests/State/ISupport.cs
new file mode 100644
index 0000000..5cdcc61
--- /dev/null
+++ b/IRCSharp.Tests/State/ISupport.cs
@@ -0,0 +1,210 @@
+using System.Collections.Generic;
+using IRCTokens;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+// ReSharper disable InconsistentNaming
+
+namespace IRCStates.Tests
+{
+    [TestClass]
+    public class ISupport
+    {
+        private Server _server;
+
+        [TestInitialize]
+        public void TestInitialize()
+        {
+            _server = new Server("test");
+            _server.Parse(new Line("001 nickname"));
+        }
+
+        [TestMethod]
+        public void ChanModes()
+        {
+            CollectionAssert.AreEqual(new List<string> {"b"}, _server.ISupport.ChanModes.ListModes);
+            CollectionAssert.AreEqual(new List<string> {"k"}, _server.ISupport.ChanModes.SettingBModes);
+            CollectionAssert.AreEqual(new List<string> {"l"}, _server.ISupport.ChanModes.SettingCModes);
+            CollectionAssert.AreEqual(new List<string>
+            {
+                "i",
+                "m",
+                "n",
+                "p",
+                "s",
+                "t"
+            }, _server.ISupport.ChanModes.SettingDModes);
+
+            _server.Parse(new Line("005 * CHANMODES=a,b,c,d *"));
+
+            CollectionAssert.AreEqual(new List<string> {"a"}, _server.ISupport.ChanModes.ListModes);
+            CollectionAssert.AreEqual(new List<string> {"b"}, _server.ISupport.ChanModes.SettingBModes);
+            CollectionAssert.AreEqual(new List<string> {"c"}, _server.ISupport.ChanModes.SettingCModes);
+            CollectionAssert.AreEqual(new List<string> {"d"}, _server.ISupport.ChanModes.SettingDModes);
+        }
+
+        [TestMethod]
+        public void Prefix()
+        {
+            CollectionAssert.AreEqual(new List<string> {"o", "v"}, _server.ISupport.Prefix.Modes);
+            CollectionAssert.AreEqual(new List<string> {"@", "+"}, _server.ISupport.Prefix.Prefixes);
+
+            Assert.AreEqual("@", _server.ISupport.Prefix.FromMode("o"));
+            Assert.IsNull(_server.ISupport.Prefix.FromMode("a"));
+            Assert.AreEqual("o", _server.ISupport.Prefix.FromPrefix("@"));
+            Assert.IsNull(_server.ISupport.Prefix.FromPrefix("&"));
+
+            _server.Parse(new Line("005 * PREFIX=(qaohv)~&@%+ *"));
+            CollectionAssert.AreEqual(new List<string>
+            {
+                "q",
+                "a",
+                "o",
+                "h",
+                "v"
+            }, _server.ISupport.Prefix.Modes);
+            CollectionAssert.AreEqual(new List<string>
+            {
+                "~",
+                "&",
+                "@",
+                "%",
+                "+"
+            }, _server.ISupport.Prefix.Prefixes);
+            Assert.AreEqual("&", _server.ISupport.Prefix.FromMode("a"));
+            Assert.AreEqual("a", _server.ISupport.Prefix.FromPrefix("&"));
+        }
+
+        [TestMethod]
+        public void ChanTypes()
+        {
+            CollectionAssert.AreEqual(new List<string> {"#"}, _server.ISupport.ChanTypes);
+            _server.Parse(new Line("005 * CHANTYPES=#& *"));
+            CollectionAssert.AreEqual(new List<string> {"#", "&"}, _server.ISupport.ChanTypes);
+        }
+
+        [TestMethod]
+        public void Modes()
+        {
+            Assert.AreEqual(3, _server.ISupport.Modes);
+
+            _server.Parse(new Line("005 * MODES *"));
+            Assert.AreEqual(-1, _server.ISupport.Modes);
+
+            _server.Parse(new Line("005 * MODES=5 *"));
+            Assert.AreEqual(5, _server.ISupport.Modes);
+        }
+
+        [TestMethod]
+        public void Rfc1459()
+        {
+            Assert.AreEqual(IRCStates.Casemap.CaseMapping.Rfc1459, _server.ISupport.CaseMapping);
+            _server.Parse(new Line("005 * CASEMAPPING=rfc1459 *"));
+            Assert.AreEqual(IRCStates.Casemap.CaseMapping.Rfc1459, _server.ISupport.CaseMapping);
+            var lower = _server.CaseFold(@"ÀTEST[]~\");
+            Assert.AreEqual("Àtest{}^|", lower);
+        }
+
+        [TestMethod]
+        public void Ascii()
+        {
+            _server.Parse(new Line("005 * CASEMAPPING=ascii *"));
+            Assert.AreEqual(IRCStates.Casemap.CaseMapping.Ascii, _server.ISupport.CaseMapping);
+            var lower = _server.CaseFold(@"ÀTEST[]~\");
+            Assert.AreEqual(@"Àtest[]~\", lower);
+        }
+
+        [TestMethod]
+        public void FallbackToRfc1459()
+        {
+            _server.Parse(new Line("005 * CASEMAPPING=nonexistent *"));
+            Assert.AreEqual(IRCStates.Casemap.CaseMapping.Rfc1459, _server.ISupport.CaseMapping);
+            var lower = _server.CaseFold(@"ÀTEST[]~\");
+            Assert.AreEqual("Àtest{}^|", lower);
+        }
+
+        [TestMethod]
+        public void Network()
+        {
+            Assert.IsNull(_server.ISupport.Network);
+            _server.Parse(new Line("005 * NETWORK=testnet *"));
+            Assert.AreEqual("testnet", _server.ISupport.Network);
+        }
+
+        [TestMethod]
+        public void StatusMsg()
+        {
+            CollectionAssert.AreEqual(new List<string>(), _server.ISupport.StatusMsg);
+            _server.Parse(new Line("005 * STATUSMSG=&@ *"));
+            CollectionAssert.AreEqual(new List<string> {"&", "@"}, _server.ISupport.StatusMsg);
+        }
+
+        [TestMethod]
+        public void CallerId()
+        {
+            Assert.IsNull(_server.ISupport.CallerId);
+
+            _server.Parse(new Line("005 * CALLERID=U *"));
+            Assert.AreEqual("U", _server.ISupport.CallerId);
+
+            _server.Parse(new Line("005 * CALLERID *"));
+            Assert.AreEqual("g", _server.ISupport.CallerId);
+        }
+
+        [TestMethod]
+        public void Excepts()
+        {
+            Assert.IsNull(_server.ISupport.Excepts);
+
+            _server.Parse(new Line("005 * EXCEPTS=U *"));
+            Assert.AreEqual("U", _server.ISupport.Excepts);
+
+            _server.Parse(new Line("005 * EXCEPTS *"));
+            Assert.AreEqual("e", _server.ISupport.Excepts);
+        }
+
+        [TestMethod]
+        public void Invex()
+        {
+            Assert.IsNull(_server.ISupport.Invex);
+
+            _server.Parse(new Line("005 * INVEX=U *"));
+            Assert.AreEqual("U", _server.ISupport.Invex);
+
+            _server.Parse(new Line("005 * INVEX *"));
+            Assert.AreEqual("I", _server.ISupport.Invex);
+        }
+
+        [TestMethod]
+        public void Whox()
+        {
+            Assert.IsFalse(_server.ISupport.Whox);
+
+            _server.Parse(new Line("005 * WHOX *"));
+            Assert.IsTrue(_server.ISupport.Whox);
+        }
+
+        [TestMethod]
+        public void Monitor()
+        {
+            Assert.IsNull(_server.ISupport.Monitor);
+
+            _server.Parse(new Line("005 * MONITOR=123 *"));
+            Assert.AreEqual(123, _server.ISupport.Monitor);
+
+            _server.Parse(new Line("005 * MONITOR *"));
+            Assert.AreEqual(-1, _server.ISupport.Monitor);
+        }
+
+        [TestMethod]
+        public void Watch()
+        {
+            Assert.IsNull(_server.ISupport.Watch);
+
+            _server.Parse(new Line("005 * WATCH=123 *"));
+            Assert.AreEqual(123, _server.ISupport.Watch);
+
+            _server.Parse(new Line("005 * WATCH *"));
+            Assert.AreEqual(-1, _server.ISupport.Watch);
+        }
+    }
+}
diff --git a/IRCSharp.Tests/State/Mode.cs b/IRCSharp.Tests/State/Mode.cs
new file mode 100644
index 0000000..90763fa
--- /dev/null
+++ b/IRCSharp.Tests/State/Mode.cs
@@ -0,0 +1,179 @@
+using System.Collections.Generic;
+using IRCTokens;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace IRCStates.Tests
+{
+    [TestClass]
+    public class Mode
+    {
+        private Server _server;
+
+        [TestInitialize]
+        public void TestInitialize()
+        {
+            _server = new Server("test");
+            _server.Parse(new Line("001 nickname"));
+        }
+
+        [TestMethod]
+        public void UModeAdd()
+        {
+            _server.Parse(new Line("MODE nickname +i"));
+            CollectionAssert.AreEqual(new List<string> {"i"}, _server.Modes);
+        }
+
+        [TestMethod]
+        public void UModeRemove()
+        {
+            _server.Parse(new Line("MODE nickname +i"));
+            _server.Parse(new Line("MODE nickname -i"));
+            CollectionAssert.AreEqual(new List<string>(), _server.Modes);
+        }
+
+        [TestMethod]
+        public void PrefixAdd()
+        {
+            _server.Parse(new Line(":nickname JOIN #chan"));
+            _server.Parse(new Line("MODE #chan +ov nickname nickname"));
+
+            var user        = _server.Users["nickname"];
+            var channel     = _server.Channels["#chan"];
+            var channelUser = channel.Users[user.NickNameLower];
+            CollectionAssert.AreEqual(new List<string> {"o", "v"}, channelUser.Modes);
+        }
+
+        [TestMethod]
+        public void PrefixRemove()
+        {
+            _server.Parse(new Line(":nickname JOIN #chan"));
+            _server.Parse(new Line("MODE #chan +ov nickname nickname"));
+            _server.Parse(new Line("MODE #chan -ov nickname nickname"));
+
+            var user        = _server.Users["nickname"];
+            var channel     = _server.Channels["#chan"];
+            var channelUser = channel.Users[user.NickNameLower];
+            CollectionAssert.AreEqual(new List<string>(), channelUser.Modes);
+        }
+
+        [TestMethod]
+        public void ChannelListAdd()
+        {
+            _server.Parse(new Line(":nickname JOIN #chan"));
+            _server.Parse(new Line("MODE #chan +b asd!*@*"));
+
+            var channel = _server.Channels["#chan"];
+            CollectionAssert.AreEqual(new List<string> {"asd!*@*"}, channel.ListModes["b"]);
+        }
+
+        [TestMethod]
+        public void ChannelListRemove()
+        {
+            _server.Parse(new Line(":nickname JOIN #chan"));
+            _server.Parse(new Line("MODE #chan +b asd!*@*"));
+            _server.Parse(new Line("MODE #chan -b asd!*@*"));
+
+            var channel = _server.Channels["#chan"];
+            CollectionAssert.AreEqual(new Dictionary<string, List<string>>(), channel.ListModes);
+        }
+
+        [TestMethod]
+        public void ChannelTypeBAdd()
+        {
+            _server.Parse(new Line(":nickname JOIN #chan"));
+            _server.Parse(new Line("MODE #chan +k password"));
+
+            var channel = _server.Channels["#chan"];
+            CollectionAssert.AreEqual(new Dictionary<string, string> {{"k", "password"}}, channel.Modes);
+        }
+
+        [TestMethod]
+        public void ChannelTypeBRemove()
+        {
+            _server.Parse(new Line(":nickname JOIN #chan"));
+            _server.Parse(new Line("MODE #chan +k password"));
+            _server.Parse(new Line("MODE #chan -k *"));
+
+            var channel = _server.Channels["#chan"];
+            CollectionAssert.AreEqual(new Dictionary<string, string>(), channel.Modes);
+        }
+
+        [TestMethod]
+        public void ChannelTypeCAdd()
+        {
+            _server.Parse(new Line(":nickname JOIN #chan"));
+            _server.Parse(new Line("MODE #chan +l 100"));
+
+            var channel = _server.Channels["#chan"];
+            CollectionAssert.AreEqual(new Dictionary<string, string> {{"l", "100"}}, channel.Modes);
+        }
+
+        [TestMethod]
+        public void ChannelTypeCRemove()
+        {
+            _server.Parse(new Line(":nickname JOIN #chan"));
+            _server.Parse(new Line("MODE #chan +l 100"));
+            _server.Parse(new Line("MODE #chan -l"));
+
+            var channel = _server.Channels["#chan"];
+            CollectionAssert.AreEqual(new Dictionary<string, string>(), channel.Modes);
+        }
+
+        [TestMethod]
+        public void ChannelTypeDAdd()
+        {
+            _server.Parse(new Line(":nickname JOIN #chan"));
+            _server.Parse(new Line("MODE #chan +i"));
+
+            var channel = _server.Channels["#chan"];
+            CollectionAssert.AreEqual(new Dictionary<string, string> {{"i", null}}, channel.Modes);
+        }
+
+        [TestMethod]
+        public void ChannelTypeDRemove()
+        {
+            _server.Parse(new Line(":nickname JOIN #chan"));
+            _server.Parse(new Line("MODE #chan +i"));
+            _server.Parse(new Line("MODE #chan -i"));
+
+            var channel = _server.Channels["#chan"];
+            CollectionAssert.AreEqual(new Dictionary<string, string>(), channel.Modes);
+        }
+
+        [TestMethod]
+        public void ChannelNumeric()
+        {
+            _server.Parse(new Line(":nickname JOIN #chan"));
+            _server.Parse(new Line("324 * #chan +bkli *!*@* pass 10"));
+
+            var channel = _server.Channels["#chan"];
+            CollectionAssert.AreEqual(new Dictionary<string, string> {{"k", "pass"}, {"l", "10"}, {"i", null}},
+                channel.Modes);
+            CollectionAssert.AreEqual(new List<string> {"*!*@*"}, channel.ListModes["b"]);
+        }
+
+        [TestMethod]
+        public void ChannelNumericWithoutPlus()
+        {
+            _server.Parse(new Line(":nickname JOIN #chan"));
+            _server.Parse(new Line("324 * #chan il 10"));
+
+            var channel = _server.Channels["#chan"];
+            CollectionAssert.AreEqual(new Dictionary<string, string> {{"i", null}, {"l", "10"}}, channel.Modes);
+        }
+
+        [TestMethod]
+        public void UserNumeric()
+        {
+            _server.Parse(new Line("221 * +iw"));
+            CollectionAssert.AreEqual(new List<string> {"i", "w"}, _server.Modes);
+        }
+
+        [TestMethod]
+        public void UserNumericWithoutPlus()
+        {
+            _server.Parse(new Line("221 * iw"));
+            CollectionAssert.AreEqual(new List<string> {"i", "w"}, _server.Modes);
+        }
+    }
+}
diff --git a/IRCSharp.Tests/State/Motd.cs b/IRCSharp.Tests/State/Motd.cs
new file mode 100644
index 0000000..2d75982
--- /dev/null
+++ b/IRCSharp.Tests/State/Motd.cs
@@ -0,0 +1,23 @@
+using System.Collections.Generic;
+using IRCTokens;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace IRCStates.Tests
+{
+    [TestClass]
+    public class Motd
+    {
+        [TestMethod]
+        public void MessageOfTheDay()
+        {
+            var server = new Server("test");
+            server.Parse(new Line("001 nickname"));
+            server.Parse(new Line("375 * :start of motd"));
+            server.Parse(new Line("372 * :first line of motd"));
+            server.Parse(new Line("372 * :second line of motd"));
+
+            CollectionAssert.AreEqual(new List<string> {"start of motd", "first line of motd", "second line of motd"},
+                server.Motd);
+        }
+    }
+}
diff --git a/IRCSharp.Tests/State/Sasl.cs b/IRCSharp.Tests/State/Sasl.cs
new file mode 100644
index 0000000..151ccdf
--- /dev/null
+++ b/IRCSharp.Tests/State/Sasl.cs
@@ -0,0 +1,38 @@
+using IRCTokens;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace IRCStates.Tests
+{
+    [TestClass]
+    public class Sasl
+    {
+        private Server _server;
+
+        [TestInitialize]
+        public void TestInitialize()
+        {
+            _server = new Server("test");
+            _server.Parse(new Line("900 * nick!user@host account"));
+        }
+
+        [TestMethod]
+        public void LoggedIn()
+        {
+            Assert.AreEqual("nick", _server.NickName);
+            Assert.AreEqual("user", _server.UserName);
+            Assert.AreEqual("host", _server.HostName);
+            Assert.AreEqual("account", _server.Account);
+        }
+
+        [TestMethod]
+        public void LoggedOut()
+        {
+            _server.Parse(new Line("901 * nick1!user1@host1"));
+
+            Assert.AreEqual("nick1", _server.NickName);
+            Assert.AreEqual("user1", _server.UserName);
+            Assert.AreEqual("host1", _server.HostName);
+            Assert.IsTrue(string.IsNullOrEmpty(_server.Account));
+        }
+    }
+}
diff --git a/IRCSharp.Tests/State/User.cs b/IRCSharp.Tests/State/User.cs
new file mode 100644
index 0000000..61d7157
--- /dev/null
+++ b/IRCSharp.Tests/State/User.cs
@@ -0,0 +1,298 @@
+using IRCTokens;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace IRCStates.Tests
+{
+    [TestClass]
+    public class User
+    {
+        private Server _server;
+
+        [TestInitialize]
+        public void TestInitialize()
+        {
+            _server = new Server("test");
+            _server.Parse(new Line("001 nickname"));
+        }
+
+        [TestMethod]
+        public void Welcome()
+        {
+            Assert.AreEqual("test", _server.Name);
+            Assert.AreEqual("nickname", _server.NickName);
+        }
+
+        [TestMethod]
+        public void NicknameChange()
+        {
+            _server.Parse(new Line(":nickname NICK nickname2"));
+            Assert.AreEqual("nickname2", _server.NickName);
+
+            _server.Parse(new Line(":nickname2 JOIN #chan"));
+            _server.Parse(new Line(":other JOIN #chan"));
+            Assert.IsTrue(_server.Users.ContainsKey("other"));
+
+            _server.Parse(new Line(":other NICK other2"));
+            Assert.IsFalse(_server.Users.ContainsKey("other"));
+            Assert.IsTrue(_server.Users.ContainsKey("other2"));
+        }
+
+        [TestMethod]
+        public void HostmaskJoinBoth()
+        {
+            _server.Parse(new Line(":nickname!user@host JOIN #chan"));
+            Assert.AreEqual("user", _server.UserName);
+            Assert.AreEqual("host", _server.HostName);
+
+            _server.Parse(new Line(":other!user@host JOIN #chan"));
+            var user = _server.Users["other"];
+            Assert.AreEqual("user", user.UserName);
+            Assert.AreEqual("host", user.HostName);
+        }
+
+        [TestMethod]
+        public void HostmaskJoinUser()
+        {
+            _server.Parse(new Line(":nickname!user JOIN #chan"));
+            Assert.AreEqual("user", _server.UserName);
+            Assert.IsNull(_server.HostName);
+
+            _server.Parse(new Line(":other!user JOIN #chan"));
+            var user = _server.Users["other"];
+            Assert.AreEqual("user", user.UserName);
+            Assert.IsNull(user.HostName);
+        }
+
+        [TestMethod]
+        public void HostmaskJoinHost()
+        {
+            _server.Parse(new Line(":nickname@host JOIN #chan"));
+            Assert.IsNull(_server.UserName);
+            Assert.AreEqual("host", _server.HostName);
+
+            _server.Parse(new Line(":other@host JOIN #chan"));
+            var user = _server.Users["other"];
+            Assert.IsNull(user.UserName);
+            Assert.AreEqual("host", user.HostName);
+        }
+
+        [TestMethod]
+        public void ExtendedJoinWithoutExtendedJoin()
+        {
+            _server.Parse(new Line(":nickname JOIN #chan"));
+            Assert.IsNull(_server.Account);
+            Assert.IsNull(_server.RealName);
+
+            _server.Parse(new Line(":other JOIN #chan"));
+            var user = _server.Users["other"];
+            Assert.IsNull(user.Account);
+            Assert.IsNull(user.RealName);
+        }
+
+        [TestMethod]
+        public void ExtendedJoinWithAccount()
+        {
+            _server.Parse(new Line(":nickname JOIN #chan acc :realname"));
+            Assert.AreEqual("acc", _server.Account);
+            Assert.AreEqual("realname", _server.RealName);
+
+            _server.Parse(new Line(":other JOIN #chan acc2 :realname2"));
+            var user = _server.Users["other"];
+            Assert.AreEqual("acc2", user.Account);
+            Assert.AreEqual("realname2", user.RealName);
+        }
+
+        [TestMethod]
+        public void ExtendedJoinWithoutAccount()
+        {
+            _server.Parse(new Line(":nickname JOIN #chan * :realname"));
+            Assert.AreEqual("", _server.Account);
+            Assert.AreEqual("realname", _server.RealName);
+
+            _server.Parse(new Line(":other JOIN #chan * :realname2"));
+            var user = _server.Users["other"];
+            Assert.AreEqual("", user.Account);
+            Assert.AreEqual("realname2", user.RealName);
+        }
+
+        [TestMethod]
+        public void AccountNotifyWithAccount()
+        {
+            _server.Parse(new Line(":nickname JOIN #chan"));
+            _server.Parse(new Line(":nickname ACCOUNT acc"));
+            Assert.AreEqual("acc", _server.Account);
+
+            _server.Parse(new Line(":other JOIN #chan"));
+            _server.Parse(new Line(":other ACCOUNT acc2"));
+            var user = _server.Users["other"];
+            Assert.AreEqual("acc2", user.Account);
+        }
+
+        [TestMethod]
+        public void AccountNotifyWithoutAccount()
+        {
+            _server.Parse(new Line(":nickname JOIN #chan"));
+            _server.Parse(new Line(":nickname ACCOUNT *"));
+            Assert.AreEqual("", _server.Account);
+
+            _server.Parse(new Line(":other JOIN #chan"));
+            _server.Parse(new Line(":other ACCOUNT *"));
+            var user = _server.Users["other"];
+            Assert.AreEqual("", user.Account);
+        }
+
+        [TestMethod]
+        public void HostmaskPrivmsgBoth()
+        {
+            _server.Parse(new Line(":nickname JOIN #chan"));
+            _server.Parse(new Line(":nickname!user@host PRIVMSG #chan :hi"));
+            Assert.AreEqual("user", _server.UserName);
+            Assert.AreEqual("host", _server.HostName);
+
+            _server.Parse(new Line(":other!user@host PRIVMSG #chan :hi"));
+            var user = _server.Users["other"];
+            Assert.AreEqual("user", user.UserName);
+            Assert.AreEqual("host", user.HostName);
+        }
+
+        [TestMethod]
+        public void HostmaskPrivmsgUser()
+        {
+            _server.Parse(new Line(":nickname JOIN #chan"));
+            _server.Parse(new Line(":nickname!user PRIVMSG #chan :hi"));
+            Assert.AreEqual("user", _server.UserName);
+            Assert.IsNull(_server.HostName);
+
+            _server.Parse(new Line(":other!user PRIVMSG #chan :hi"));
+            var user = _server.Users["other"];
+            Assert.AreEqual("user", user.UserName);
+            Assert.IsNull(user.HostName);
+        }
+
+        [TestMethod]
+        public void HostmaskPrivmsgHost()
+        {
+            _server.Parse(new Line(":nickname JOIN #chan"));
+            _server.Parse(new Line(":nickname@host PRIVMSG #chan :hi"));
+            Assert.IsNull(_server.UserName);
+            Assert.AreEqual("host", _server.HostName);
+
+            _server.Parse(new Line(":other@host PRIVMSG #chan :hi"));
+            var user = _server.Users["other"];
+            Assert.IsNull(user.UserName);
+            Assert.AreEqual("host", user.HostName);
+        }
+
+        [TestMethod]
+        public void VisibleHostWithoutUsername()
+        {
+            _server.Parse(new Line("396 * hostname"));
+            Assert.IsNull(_server.UserName);
+            Assert.AreEqual("hostname", _server.HostName);
+        }
+
+        [TestMethod]
+        public void VisibleHostWithUsername()
+        {
+            _server.Parse(new Line("396 * username@hostname"));
+            Assert.AreEqual("username", _server.UserName);
+            Assert.AreEqual("hostname", _server.HostName);
+        }
+
+        [TestMethod]
+        public void Who()
+        {
+            _server.Parse(new Line(":nickname JOIN #chan"));
+            _server.Parse(new Line(":other JOIN #chan"));
+            _server.Parse(new Line("352 * #chan user host * nickname * :0 real"));
+            _server.Parse(new Line("352 * #chan user2 host2 * other * :0 real2"));
+
+            Assert.AreEqual("user", _server.UserName);
+            Assert.AreEqual("host", _server.HostName);
+            Assert.AreEqual("real", _server.RealName);
+
+            var user = _server.Users["other"];
+            Assert.AreEqual("user2", user.UserName);
+            Assert.AreEqual("host2", user.HostName);
+            Assert.AreEqual("real2", user.RealName);
+        }
+
+        [TestMethod]
+        public void Chghost()
+        {
+            _server.Parse(new Line(":nickname!user@host JOIN #chan"));
+            _server.Parse(new Line(":nickname CHGHOST u h"));
+            Assert.AreEqual("u", _server.UserName);
+            Assert.AreEqual("h", _server.HostName);
+
+            _server.Parse(new Line(":other!user2@host2 JOIN #chan"));
+            _server.Parse(new Line(":other CHGHOST u2 h2"));
+            var user = _server.Users["other"];
+            Assert.AreEqual("u2", user.UserName);
+            Assert.AreEqual("h2", user.HostName);
+        }
+
+        [TestMethod]
+        public void Whois()
+        {
+            _server.Parse(new Line(":nickname JOIN #chan"));
+            _server.Parse(new Line("311 * nickname u h * :r"));
+            Assert.AreEqual("u", _server.UserName);
+            Assert.AreEqual("h", _server.HostName);
+            Assert.AreEqual("r", _server.RealName);
+
+            _server.Parse(new Line(":other JOIN #chan"));
+            _server.Parse(new Line(":other CHGHOST u2 h2"));
+            _server.Parse(new Line("311 * other u2 h2 * :r2"));
+            var user = _server.Users["other"];
+            Assert.AreEqual("u2", user.UserName);
+            Assert.AreEqual("h2", user.HostName);
+            Assert.AreEqual("r2", user.RealName);
+        }
+
+        [TestMethod]
+        public void AwaySet()
+        {
+            _server.Parse(new Line(":nickname JOIN #chan"));
+            _server.Parse(new Line(":other JOIN #chan"));
+            var user = _server.Users["other"];
+            Assert.IsNull(_server.Away);
+            Assert.IsNull(user.Away);
+
+            _server.Parse(new Line(":nickname AWAY :bye bye"));
+            _server.Parse(new Line(":other AWAY :ich geh weg"));
+            Assert.AreEqual("bye bye", _server.Away);
+            Assert.AreEqual("ich geh weg", user.Away);
+        }
+
+        [TestMethod]
+        public void AwayUnset()
+        {
+            _server.Parse(new Line(":nickname JOIN #chan"));
+            _server.Parse(new Line(":other JOIN #chan"));
+            _server.Parse(new Line(":nickname AWAY :bye bye"));
+            _server.Parse(new Line(":nickname AWAY"));
+            _server.Parse(new Line(":other AWAY :ich geh weg"));
+            _server.Parse(new Line(":other AWAY"));
+
+            var user = _server.Users["other"];
+            Assert.IsNull(_server.Away);
+            Assert.IsNull(user.Away);
+        }
+
+        [TestMethod]
+        public void Setname()
+        {
+            _server.Parse(new Line(":nickname JOIN #chan"));
+            _server.Parse(new Line(":other JOIN #chan"));
+            var user = _server.Users["other"];
+            Assert.IsNull(user.RealName);
+            Assert.IsNull(_server.RealName);
+
+            _server.Parse(new Line(":nickname SETNAME :new now know how"));
+            _server.Parse(new Line(":other SETNAME :tyrannosaurus hex"));
+            Assert.AreEqual("new now know how", _server.RealName);
+            Assert.AreEqual("tyrannosaurus hex", user.RealName);
+        }
+    }
+}
diff --git a/IRCSharp.Tests/State/Who.cs b/IRCSharp.Tests/State/Who.cs
new file mode 100644
index 0000000..d091785
--- /dev/null
+++ b/IRCSharp.Tests/State/Who.cs
@@ -0,0 +1,61 @@
+using IRCTokens;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace IRCStates.Tests
+{
+    [TestClass]
+    public class Who
+    {
+        private Server _server;
+
+        [TestInitialize]
+        public void TestInitialize()
+        {
+            _server = new Server("test");
+            _server.Parse(new Line("001 nickname"));
+            _server.Parse(new Line(":nickname JOIN #chan"));
+        }
+
+        [TestMethod]
+        public void WhoResponse()
+        {
+            _server.Parse(new Line("352 * #chan user host server nickname * :0 real"));
+            var user = _server.Users["nickname"];
+
+            Assert.AreEqual("user", user.UserName);
+            Assert.AreEqual("host", _server.HostName);
+            Assert.AreEqual("real", user.RealName);
+
+            Assert.AreEqual(user.UserName, _server.UserName);
+            Assert.AreEqual(user.HostName, _server.HostName);
+            Assert.AreEqual(user.RealName, _server.RealName);
+        }
+
+        [TestMethod]
+        public void Whox()
+        {
+            _server.Parse(new Line($"354 * {Server.WhoType} user realip host nickname account :real"));
+            var user = _server.Users["nickname"];
+
+            Assert.AreEqual("user", user.UserName);
+            Assert.AreEqual("host", user.HostName);
+            Assert.AreEqual("real", user.RealName);
+            Assert.AreEqual("account", user.Account);
+
+            Assert.AreEqual(user.UserName, _server.UserName);
+            Assert.AreEqual(user.HostName, _server.HostName);
+            Assert.AreEqual(user.RealName, _server.RealName);
+            Assert.AreEqual(user.Account, _server.Account);
+        }
+
+        [TestMethod]
+        public void WhoxNoAccount()
+        {
+            _server.Parse(new Line($"354 * {Server.WhoType} user realip host nickname 0 :real"));
+            var user = _server.Users["nickname"];
+
+            Assert.IsNull(user.Account);
+            Assert.AreEqual(user.Account, _server.Account);
+        }
+    }
+}
diff --git a/IRCSharp.Tests/Tokenization/Data/JoinModel.cs b/IRCSharp.Tests/Tokenization/Data/JoinModel.cs
new file mode 100644
index 0000000..e54f4cf
--- /dev/null
+++ b/IRCSharp.Tests/Tokenization/Data/JoinModel.cs
@@ -0,0 +1,30 @@
+using System.Collections.Generic;
+using YamlDotNet.Serialization;
+
+namespace IRCTokens.Tests.Data
+{
+    public class JoinModel
+    {
+        public List<Test> Tests { get; set; }
+
+        public class Test
+        {
+            [YamlMember(Alias = "desc")] public string Description { get; set; }
+
+            public Atoms Atoms { get; set; }
+
+            public List<string> Matches { get; set; }
+        }
+
+        public class Atoms
+        {
+            public Dictionary<string, string> Tags { get; set; }
+
+            public string Source { get; set; }
+
+            public string Verb { get; set; }
+
+            public List<string> Params { get; set; }
+        }
+    }
+}
diff --git a/IRCSharp.Tests/Tokenization/Data/SplitModel.cs b/IRCSharp.Tests/Tokenization/Data/SplitModel.cs
new file mode 100644
index 0000000..5386326
--- /dev/null
+++ b/IRCSharp.Tests/Tokenization/Data/SplitModel.cs
@@ -0,0 +1,15 @@
+using System.Collections.Generic;
+
+namespace IRCTokens.Tests.Data
+{
+    public class SplitModel
+    {
+        public List<Test> Tests { get; set; }
+
+        public class Test
+        {
+            public string Input { get; set; }
+            public JoinModel.Atoms Atoms { get; set; }
+        }
+    }
+}
diff --git a/IRCSharp.Tests/Tokenization/Data/msg-join.yaml b/IRCSharp.Tests/Tokenization/Data/msg-join.yaml
new file mode 100644
index 0000000..d1d7429
--- /dev/null
+++ b/IRCSharp.Tests/Tokenization/Data/msg-join.yaml
@@ -0,0 +1,221 @@
+# IRC parser tests
+# joining atoms into sendable messages
+
+# Written in 2015 by Daniel Oaks <daniel@danieloaks.net>
+#
+# To the extent possible under law, the author(s) have dedicated all copyright
+# and related and neighboring rights to this software to the public domain
+# worldwide. This software is distributed without any warranty.
+#
+# You should have received a copy of the CC0 Public Domain Dedication along
+# with this software. If not, see
+# <http://creativecommons.org/publicdomain/zero/1.0/>.
+
+# some of the tests here originate from grawity's test vectors, which is WTFPL v2 licensed
+#   https://github.com/grawity/code/tree/master/lib/tests
+# some of the tests here originate from Mozilla's test vectors, which is public domain
+#   https://dxr.mozilla.org/comm-central/source/chat/protocols/irc/test/test_ircMessage.js
+# some of the tests here originate from SaberUK's test vectors, which he's indicated I am free to include here
+#   https://github.com/SaberUK/ircparser/tree/master/test
+
+tests:
+  # the desc string holds a description of the test, if it exists
+
+  # the atoms dict has the keys:
+  #   * tags: tags dict
+  #       tags with no value are an empty string
+  #   * source: source string, without single leading colon
+  #   * verb: verb string
+  #   * params: params split up as a list
+  # if the params key does not exist, assume it is empty
+  # if any other keys do no exist, assume they are null
+  # a key that is null does not exist or is not specified with the
+  #   given input string
+
+  # matches is a list of messages that match
+
+  # simple tests
+  - desc: Simple test with verb and params.
+    atoms:
+      verb: "foo"
+      params:
+        - "bar"
+        - "baz"
+        - "asdf"
+    matches:
+      - "foo bar baz asdf"
+      - "foo bar baz :asdf"
+
+  # with no regular params
+  - desc: Simple test with source and no params.
+    atoms:
+      source: "src"
+      verb: "AWAY"
+    matches:
+      - ":src AWAY"
+
+  - desc: Simple test with source and empty trailing param.
+    atoms:
+      source: "src"
+      verb: "AWAY"
+      params:
+        - ""
+    matches:
+      - ":src AWAY :"
+
+  # with source
+  - desc: Simple test with source.
+    atoms:
+      source: "coolguy"
+      verb: "foo"
+      params:
+        - "bar"
+        - "baz"
+        - "asdf"
+    matches:
+      - ":coolguy foo bar baz asdf"
+      - ":coolguy foo bar baz :asdf"
+
+  # with trailing param
+  - desc: Simple test with trailing param.
+    atoms:
+      verb: "foo"
+      params:
+        - "bar"
+        - "baz"
+        - "asdf quux"
+    matches:
+      - "foo bar baz :asdf quux"
+
+  - desc: Simple test with empty trailing param.
+    atoms:
+      verb: "foo"
+      params:
+        - "bar"
+        - "baz"
+        - ""
+    matches:
+      - "foo bar baz :"
+
+  - desc: Simple test with trailing param containing colon.
+    atoms:
+      verb: "foo"
+      params:
+        - "bar"
+        - "baz"
+        - ":asdf"
+    matches:
+      - "foo bar baz ::asdf"
+
+  # with source and trailing param
+  - desc: Test with source and trailing param.
+    atoms:
+      source: "coolguy"
+      verb: "foo"
+      params:
+        - "bar"
+        - "baz"
+        - "asdf quux"
+    matches:
+      - ":coolguy foo bar baz :asdf quux"
+
+  - desc: Test with trailing containing beginning+end whitespace.
+    atoms:
+      source: "coolguy"
+      verb: "foo"
+      params:
+        - "bar"
+        - "baz"
+        - "  asdf quux "
+    matches:
+      - ":coolguy foo bar baz :  asdf quux "
+
+  - desc: Test with trailing containing what looks like another trailing param.
+    atoms:
+      source: "coolguy"
+      verb: "PRIVMSG"
+      params:
+        - "bar"
+        - "lol :) "
+    matches:
+      - ":coolguy PRIVMSG bar :lol :) "
+
+  - desc: Simple test with source and empty trailing.
+    atoms:
+      source: "coolguy"
+      verb: "foo"
+      params:
+        - "bar"
+        - "baz"
+        - ""
+    matches:
+      - ":coolguy foo bar baz :"
+
+  - desc: Trailing contains only spaces.
+    atoms:
+      source: "coolguy"
+      verb: "foo"
+      params:
+        - "bar"
+        - "baz"
+        - "  "
+    matches:
+      - ":coolguy foo bar baz :  "
+
+  - desc: Param containing tab (tab is not considered SPACE for message splitting).
+    atoms:
+      source: "coolguy"
+      verb: "foo"
+      params:
+        - "b\tar"
+        - "baz"
+    matches:
+      - ":coolguy foo b\tar baz"
+      - ":coolguy foo b\tar :baz"
+
+  # with tags
+  - desc: Tag with no value and space-filled trailing.
+    atoms:
+      tags:
+        "asd": ""
+      source: "coolguy"
+      verb: "foo"
+      params:
+        - "bar"
+        - "baz"
+        - "  "
+    matches:
+      - "@asd :coolguy foo bar baz :  "
+
+  - desc: Tags with escaped values.
+    atoms:
+      verb: "foo"
+      tags:
+        "a": "b\\and\nk"
+        "d": "gh;764"
+    matches:
+      - "@a=b\\\\and\\nk;d=gh\\:764 foo"
+      - "@d=gh\\:764;a=b\\\\and\\nk foo"
+
+  - desc: Tags with escaped values and params.
+    atoms:
+      verb: "foo"
+      tags:
+        "a": "b\\and\nk"
+        "d": "gh;764"
+      params:
+        - "par1"
+        - "par2"
+    matches:
+      - "@a=b\\\\and\\nk;d=gh\\:764 foo par1 par2"
+      - "@a=b\\\\and\\nk;d=gh\\:764 foo par1 :par2"
+      - "@d=gh\\:764;a=b\\\\and\\nk foo par1 par2"
+      - "@d=gh\\:764;a=b\\\\and\\nk foo par1 :par2"
+
+  - desc: Tag with long, strange values (including LF and newline).
+    atoms:
+      tags:
+        foo: "\\\\;\\s \r\n"
+      verb: "COMMAND"
+    matches:
+      - "@foo=\\\\\\\\\\:\\\\s\\s\\r\\n COMMAND"
diff --git a/IRCSharp.Tests/Tokenization/Data/msg-split.yaml b/IRCSharp.Tests/Tokenization/Data/msg-split.yaml
new file mode 100644
index 0000000..fa3f4aa
--- /dev/null
+++ b/IRCSharp.Tests/Tokenization/Data/msg-split.yaml
@@ -0,0 +1,343 @@
+# IRC parser tests
+# splitting messages into usable atoms
+
+# Written in 2015 by Daniel Oaks <daniel@danieloaks.net>
+#
+# To the extent possible under law, the author(s) have dedicated all copyright
+# and related and neighboring rights to this software to the public domain
+# worldwide. This software is distributed without any warranty.
+#
+# You should have received a copy of the CC0 Public Domain Dedication along
+# with this software. If not, see
+# <http://creativecommons.org/publicdomain/zero/1.0/>.
+
+# some of the tests here originate from grawity's test vectors, which is WTFPL v2 licensed
+#   https://github.com/grawity/code/tree/master/lib/tests
+# some of the tests here originate from Mozilla's test vectors, which is public domain
+#   https://dxr.mozilla.org/comm-central/source/chat/protocols/irc/test/test_ircMessage.js
+# some of the tests here originate from SaberUK's test vectors, which he's indicated I am free to include here
+#   https://github.com/SaberUK/ircparser/tree/master/test
+
+# we follow RFC1459 with regards to multiple ascii spaces splitting atoms:
+#   The prefix, command, and all parameters are
+#   separated by one (or more) ASCII space character(s) (0x20).
+# because doing it as RFC2812 says (strictly as a single ascii space) isn't sane
+
+tests:
+  # input is the string coming directly from the server to parse
+
+  # the atoms dict has the keys:
+  #   * tags: tags dict
+  #       tags with no value are an empty string
+  #   * source: source string, without single leading colon
+  #   * verb: verb string
+  #   * params: params split up as a list
+  # if the params key does not exist, assume it is empty
+  # if any other keys do no exist, assume they are null
+  # a key that is null does not exist or is not specified with the
+  #   given input string
+
+  # simple
+  - input: "foo bar baz asdf"
+    atoms:
+      verb: "foo"
+      params:
+        - "bar"
+        - "baz"
+        - "asdf"
+
+  # with source
+  - input: ":coolguy foo bar baz asdf"
+    atoms:
+      source: "coolguy"
+      verb: "foo"
+      params:
+        - "bar"
+        - "baz"
+        - "asdf"
+
+  # with trailing param
+  - input: "foo bar baz :asdf quux"
+    atoms:
+      verb: "foo"
+      params:
+        - "bar"
+        - "baz"
+        - "asdf quux"
+
+  - input: "foo bar baz :"
+    atoms:
+      verb: "foo"
+      params:
+        - "bar"
+        - "baz"
+        - ""
+
+  - input: "foo bar baz ::asdf"
+    atoms:
+      verb: "foo"
+      params:
+        - "bar"
+        - "baz"
+        - ":asdf"
+
+  # with source and trailing param
+  - input: ":coolguy foo bar baz :asdf quux"
+    atoms:
+      source: "coolguy"
+      verb: "foo"
+      params:
+        - "bar"
+        - "baz"
+        - "asdf quux"
+
+  - input: ":coolguy foo bar baz :  asdf quux "
+    atoms:
+      source: "coolguy"
+      verb: "foo"
+      params:
+        - "bar"
+        - "baz"
+        - "  asdf quux "
+
+  - input: ":coolguy PRIVMSG bar :lol :) "
+    atoms:
+      source: "coolguy"
+      verb: "PRIVMSG"
+      params:
+        - "bar"
+        - "lol :) "
+
+  - input: ":coolguy foo bar baz :"
+    atoms:
+      source: "coolguy"
+      verb: "foo"
+      params:
+        - "bar"
+        - "baz"
+        - ""
+
+  - input: ":coolguy foo bar baz :  "
+    atoms:
+      source: "coolguy"
+      verb: "foo"
+      params:
+        - "bar"
+        - "baz"
+        - "  "
+
+  # with tags
+  - input: "@a=b;c=32;k;rt=ql7 foo"
+    atoms:
+      verb: "foo"
+      tags:
+        "a": "b"
+        "c": "32"
+        "k": ""
+        "rt": "ql7"
+
+  # with escaped tags
+  - input: "@a=b\\\\and\\nk;c=72\\s45;d=gh\\:764 foo"
+    atoms:
+      verb: "foo"
+      tags:
+        "a": "b\\and\nk"
+        "c": "72 45"
+        "d": "gh;764"
+
+  # with tags and source
+  - input: "@c;h=;a=b :quux ab cd"
+    atoms:
+      tags:
+        "c": ""
+        "h": ""
+        "a": "b"
+      source: "quux"
+      verb: "ab"
+      params:
+        - "cd"
+
+  # different forms of last param
+  - input: ":src JOIN #chan"
+    atoms:
+      source: "src"
+      verb: "JOIN"
+      params:
+        - "#chan"
+
+  - input: ":src JOIN :#chan"
+    atoms:
+      source: "src"
+      verb: "JOIN"
+      params:
+        - "#chan"
+
+  # with and without last param
+  - input: ":src AWAY"
+    atoms:
+      source: "src"
+      verb: "AWAY"
+
+  - input: ":src AWAY "
+    atoms:
+      source: "src"
+      verb: "AWAY"
+
+  # tab is not considered <SPACE>
+  - input: ":cool\tguy foo bar baz"
+    atoms:
+      source: "cool\tguy"
+      verb: "foo"
+      params:
+        - "bar"
+        - "baz"
+
+  # with weird control codes in the source
+  - input: ":coolguy!ag@net\x035w\x03ork.admin PRIVMSG foo :bar baz"
+    atoms:
+      source: "coolguy!ag@net\x035w\x03ork.admin"
+      verb: "PRIVMSG"
+      params:
+        - "foo"
+        - "bar baz"
+
+  - input: ":coolguy!~ag@n\x02et\x0305w\x0fork.admin PRIVMSG foo :bar baz"
+    atoms:
+      source: "coolguy!~ag@n\x02et\x0305w\x0fork.admin"
+      verb: "PRIVMSG"
+      params:
+        - "foo"
+        - "bar baz"
+
+  - input: "@tag1=value1;tag2;vendor1/tag3=value2;vendor2/tag4= :irc.example.com COMMAND param1 param2 :param3 param3"
+    atoms:
+      tags:
+        tag1: "value1"
+        tag2: ""
+        vendor1/tag3: "value2"
+        vendor2/tag4: ""
+      source: "irc.example.com"
+      verb: "COMMAND"
+      params:
+        - "param1"
+        - "param2"
+        - "param3 param3"
+
+  - input: ":irc.example.com COMMAND param1 param2 :param3 param3"
+    atoms:
+      source: "irc.example.com"
+      verb: "COMMAND"
+      params:
+        - "param1"
+        - "param2"
+        - "param3 param3"
+
+  - input: "@tag1=value1;tag2;vendor1/tag3=value2;vendor2/tag4 COMMAND param1 param2 :param3 param3"
+    atoms:
+      tags:
+        tag1: "value1"
+        tag2: ""
+        vendor1/tag3: "value2"
+        vendor2/tag4: ""
+      verb: "COMMAND"
+      params:
+        - "param1"
+        - "param2"
+        - "param3 param3"
+
+  - input: "COMMAND"
+    atoms:
+      verb: "COMMAND"
+
+  # yaml encoding + slashes is fun
+  - input: "@foo=\\\\\\\\\\:\\\\s\\s\\r\\n COMMAND"
+    atoms:
+      tags:
+        foo: "\\\\;\\s \r\n"
+      verb: "COMMAND"
+
+  # broken messages from unreal
+  - input: ":gravel.mozilla.org 432  #momo :Erroneous Nickname: Illegal characters"
+    atoms:
+      source: "gravel.mozilla.org"
+      verb: "432"
+      params:
+        - "#momo"
+        - "Erroneous Nickname: Illegal characters"
+
+  - input: ":gravel.mozilla.org MODE #tckk +n "
+    atoms:
+      source: "gravel.mozilla.org"
+      verb: "MODE"
+      params:
+        - "#tckk"
+        - "+n"
+
+  - input: ":services.esper.net MODE #foo-bar +o foobar  "
+    atoms:
+      source: "services.esper.net"
+      verb: "MODE"
+      params:
+        - "#foo-bar"
+        - "+o"
+        - "foobar"
+
+  # tag values should be parsed char-at-a-time to prevent wayward replacements.
+  - input: "@tag1=value\\\\ntest COMMAND"
+    atoms:
+      tags:
+        tag1: "value\\ntest"
+      verb: "COMMAND"
+
+  # If a tag value has a slash followed by a character which doesn't need
+  # to be escaped, the slash should be dropped.
+  - input: "@tag1=value\\1 COMMAND"
+    atoms:
+      tags:
+        tag1: "value1"
+      verb: "COMMAND"
+
+  # A slash at the end of a tag value should be dropped
+  - input: "@tag1=value1\\ COMMAND"
+    atoms:
+      tags:
+        tag1: "value1"
+      verb: "COMMAND"
+
+  # Duplicate tags: Parsers SHOULD disregard all but the final occurence 
+  - input: "@tag1=1;tag2=3;tag3=4;tag1=5 COMMAND"
+    atoms:
+      tags:
+        tag1: "5"
+        tag2: "3"
+        tag3: "4"
+      verb: "COMMAND"
+
+  # vendored tags can have the same name as a non-vendored tag
+  - input: "@tag1=1;tag2=3;tag3=4;tag1=5;vendor/tag2=8 COMMAND"
+    atoms:
+      tags:
+        tag1: "5"
+        tag2: "3"
+        tag3: "4"
+        vendor/tag2: "8"
+      verb: "COMMAND"
+
+  # Some parsers handle /MODE in a special way, make sure they do it right
+  - input: ":SomeOp MODE #channel :+i"
+    atoms:
+      source: "SomeOp"
+      verb: "MODE"
+      params:
+      - "#channel"
+      - "+i"
+
+  - input: ":SomeOp MODE #channel +oo SomeUser :AnotherUser"
+    atoms:
+      source: "SomeOp"
+      verb: "MODE"
+      params:
+      - "#channel"
+      - "+oo"
+      - "SomeUser"
+      - "AnotherUser"
diff --git a/IRCSharp.Tests/Tokenization/Format.cs b/IRCSharp.Tests/Tokenization/Format.cs
new file mode 100644
index 0000000..7224f97
--- /dev/null
+++ b/IRCSharp.Tests/Tokenization/Format.cs
@@ -0,0 +1,105 @@
+using System;
+using System.Collections.Generic;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace IRCTokens.Tests
+{
+    [TestClass]
+    public class Format
+    {
+        [TestMethod]
+        public void Tags()
+        {
+            var line = new Line("PRIVMSG", "#channel", "hello")
+            {
+                Tags = new Dictionary<string, string> {{"id", "\\" + " " + ";" + "\r\n"}}
+            }.Format();
+
+            Assert.AreEqual("@id=\\\\\\s\\:\\r\\n PRIVMSG #channel hello", line);
+        }
+
+        [TestMethod]
+        public void MissingTag()
+        {
+            var line = new Line("PRIVMSG", "#channel", "hello").Format();
+
+            Assert.AreEqual("PRIVMSG #channel hello", line);
+        }
+
+        [TestMethod]
+        public void NullTag()
+        {
+            var line = new Line("PRIVMSG", "#channel", "hello") {Tags = new Dictionary<string, string> {{"a", null}}}
+                .Format();
+
+            Assert.AreEqual("@a PRIVMSG #channel hello", line);
+        }
+
+        [TestMethod]
+        public void EmptyTag()
+        {
+            var line = new Line("PRIVMSG", "#channel", "hello") {Tags = new Dictionary<string, string> {{"a", ""}}}
+                .Format();
+
+            Assert.AreEqual("@a PRIVMSG #channel hello", line);
+        }
+
+        [TestMethod]
+        public void Source()
+        {
+            var line = new Line("PRIVMSG", "#channel", "hello") {Source = "nick!user@host"}.Format();
+
+            Assert.AreEqual(":nick!user@host PRIVMSG #channel hello", line);
+        }
+
+        [TestMethod]
+        public void CommandLowercase()
+        {
+            var line = new Line {Command = "privmsg"}.Format();
+            Assert.AreEqual("privmsg", line);
+        }
+
+        [TestMethod]
+        public void CommandUppercase()
+        {
+            var line = new Line {Command = "PRIVMSG"}.Format();
+            Assert.AreEqual("PRIVMSG", line);
+        }
+
+        [TestMethod]
+        public void TrailingSpace()
+        {
+            var line = new Line("PRIVMSG", "#channel", "hello world").Format();
+
+            Assert.AreEqual("PRIVMSG #channel :hello world", line);
+        }
+
+        [TestMethod]
+        public void TrailingNoSpace()
+        {
+            var line = new Line("PRIVMSG", "#channel", "helloworld").Format();
+
+            Assert.AreEqual("PRIVMSG #channel helloworld", line);
+        }
+
+        [TestMethod]
+        public void TrailingDoubleColon()
+        {
+            var line = new Line("PRIVMSG", "#channel", ":helloworld").Format();
+
+            Assert.AreEqual("PRIVMSG #channel ::helloworld", line);
+        }
+
+        [TestMethod]
+        public void InvalidNonLastSpace()
+        {
+            Assert.ThrowsException<ArgumentException>(() => { new Line("USER", "user", "0 *", "real name").Format(); });
+        }
+
+        [TestMethod]
+        public void InvalidNonLastColon()
+        {
+            Assert.ThrowsException<ArgumentException>(() => { new Line("PRIVMSG", ":#channel", "hello").Format(); });
+        }
+    }
+}
diff --git a/IRCSharp.Tests/Tokenization/Hostmask.cs b/IRCSharp.Tests/Tokenization/Hostmask.cs
new file mode 100644
index 0000000..17c5ad7
--- /dev/null
+++ b/IRCSharp.Tests/Tokenization/Hostmask.cs
@@ -0,0 +1,64 @@
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace IRCTokens.Tests
+{
+    [TestClass]
+    public class Hostmask
+    {
+        [TestMethod]
+        public void FullHostmask()
+        {
+            var hostmask = new IRCTokens.Hostmask("nick!user@host");
+            Assert.AreEqual("nick", hostmask.NickName);
+            Assert.AreEqual("user", hostmask.UserName);
+            Assert.AreEqual("host", hostmask.HostName);
+        }
+
+        [TestMethod]
+        public void NoHostName()
+        {
+            var hostmask = new IRCTokens.Hostmask("nick!user");
+            Assert.AreEqual("nick", hostmask.NickName);
+            Assert.AreEqual("user", hostmask.UserName);
+            Assert.IsNull(hostmask.HostName);
+        }
+
+        [TestMethod]
+        public void NoUserName()
+        {
+            var hostmask = new IRCTokens.Hostmask("nick@host");
+            Assert.AreEqual("nick", hostmask.NickName);
+            Assert.IsNull(hostmask.UserName);
+            Assert.AreEqual("host", hostmask.HostName);
+        }
+
+        [TestMethod]
+        public void OnlyNickName()
+        {
+            var hostmask = new IRCTokens.Hostmask("nick");
+            Assert.AreEqual("nick", hostmask.NickName);
+            Assert.IsNull(hostmask.UserName);
+            Assert.IsNull(hostmask.HostName);
+        }
+
+        [TestMethod]
+        public void HostmaskFromLine()
+        {
+            var line     = new Line(":nick!user@host PRIVMSG #channel hello");
+            var hostmask = new IRCTokens.Hostmask("nick!user@host");
+            Assert.AreEqual(hostmask.ToString(), line.Hostmask.ToString());
+            Assert.AreEqual("nick", line.Hostmask.NickName);
+            Assert.AreEqual("user", line.Hostmask.UserName);
+            Assert.AreEqual("host", line.Hostmask.HostName);
+        }
+
+        [TestMethod]
+        public void EmptyHostmaskFromLine()
+        {
+            var line = new Line("PRIVMSG #channel hello");
+            Assert.IsNull(line.Hostmask.HostName);
+            Assert.IsNull(line.Hostmask.UserName);
+            Assert.IsNull(line.Hostmask.NickName);
+        }
+    }
+}
diff --git a/IRCSharp.Tests/Tokenization/Parser.cs b/IRCSharp.Tests/Tokenization/Parser.cs
new file mode 100644
index 0000000..40ff803
--- /dev/null
+++ b/IRCSharp.Tests/Tokenization/Parser.cs
@@ -0,0 +1,55 @@
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using IRCTokens.Tests.Data;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using YamlDotNet.Serialization;
+using YamlDotNet.Serialization.NamingConventions;
+
+namespace IRCTokens.Tests
+{
+    [TestClass]
+    public class Parser
+    {
+        private static T LoadYaml<T>(string path)
+        {
+            var deserializer = new DeserializerBuilder()
+                .WithNamingConvention(CamelCaseNamingConvention.Instance)
+                .Build();
+
+            return deserializer.Deserialize<T>(File.ReadAllText(path));
+        }
+
+        [TestMethod]
+        public void Split()
+        {
+            foreach (var test in LoadYaml<SplitModel>("Tokenization/Data/msg-split.yaml").Tests)
+            {
+                var tokens = new Line(test.Input);
+                var atoms  = test.Atoms;
+
+                Assert.AreEqual(atoms.Verb.ToUpper(CultureInfo.InvariantCulture), tokens.Command,
+                    $"command failed on: '{test.Input}'");
+                Assert.AreEqual(atoms.Source, tokens.Source, $"source failed on: '{test.Input}'");
+                CollectionAssert.AreEqual(atoms.Tags, tokens.Tags, $"tags failed on: '{test.Input}'");
+                CollectionAssert.AreEqual(atoms.Params ?? new List<string>(), tokens.Params,
+                    $"params failed on: '{test.Input}'");
+            }
+        }
+
+        [TestMethod]
+        public void Join()
+        {
+            foreach (var test in LoadYaml<JoinModel>("Tokenization/Data/msg-join.yaml").Tests)
+            {
+                var atoms = test.Atoms;
+                var line = new Line
+                {
+                    Command = atoms.Verb, Params = atoms.Params, Source = atoms.Source, Tags = atoms.Tags
+                }.Format();
+
+                Assert.IsTrue(test.Matches.Contains(line), test.Description);
+            }
+        }
+    }
+}
diff --git a/IRCSharp.Tests/Tokenization/StatefulDecoder.cs b/IRCSharp.Tests/Tokenization/StatefulDecoder.cs
new file mode 100644
index 0000000..4da7690
--- /dev/null
+++ b/IRCSharp.Tests/Tokenization/StatefulDecoder.cs
@@ -0,0 +1,88 @@
+using System.Collections.Generic;
+using System.Text;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace IRCTokens.Tests
+{
+    [TestClass]
+    public class StatefulDecoder
+    {
+        private IRCTokens.StatefulDecoder _decoder;
+
+        [TestInitialize]
+        public void Initialize()
+        {
+            _decoder = new IRCTokens.StatefulDecoder();
+        }
+
+        [TestMethod]
+        public void Partial()
+        {
+            var lines = _decoder.Push("PRIVMSG ");
+            Assert.AreEqual(0, lines.Count);
+
+            lines = _decoder.Push("#channel hello\r\n");
+            Assert.AreEqual(1, lines.Count);
+
+            var line = new Line("PRIVMSG #channel hello");
+            CollectionAssert.AreEqual(new List<Line> {line}, lines);
+        }
+
+        [TestMethod]
+        public void Multiple()
+        {
+            var lines = _decoder.Push("PRIVMSG #channel1 hello\r\nPRIVMSG #channel2 hello\r\n");
+            Assert.AreEqual(2, lines.Count);
+
+            var line1 = new Line("PRIVMSG #channel1 hello");
+            var line2 = new Line("PRIVMSG #channel2 hello");
+            Assert.AreEqual(line1, lines[0]);
+            Assert.AreEqual(line2, lines[1]);
+        }
+
+        [TestMethod]
+        public void EncodingIso8859()
+        {
+            var iso8859 = Encoding.GetEncoding("iso-8859-1");
+            _decoder = new IRCTokens.StatefulDecoder {Encoding = iso8859};
+            var bytes = iso8859.GetBytes("PRIVMSG #channel :hello Ç\r\n");
+            var lines = _decoder.Push(bytes, bytes.Length);
+            var line  = new Line("PRIVMSG #channel :hello Ç");
+            Assert.IsTrue(line.Equals(lines[0]));
+        }
+
+        [TestMethod]
+        public void EncodingFallback()
+        {
+            var latin1 = Encoding.GetEncoding("iso-8859-1");
+            _decoder = new IRCTokens.StatefulDecoder {Encoding = null, Fallback = latin1};
+            var bytes = latin1.GetBytes("PRIVMSG #channel hélló\r\n");
+            var lines = _decoder.Push(bytes, bytes.Length);
+            Assert.AreEqual(1, lines.Count);
+            Assert.IsTrue(new Line("PRIVMSG #channel hélló").Equals(lines[0]));
+        }
+
+        [TestMethod]
+        public void Empty()
+        {
+            var lines = _decoder.Push(string.Empty);
+            Assert.AreEqual(0, lines.Count);
+        }
+
+        [TestMethod]
+        public void BufferUnfinished()
+        {
+            _decoder.Push("PRIVMSG #channel hello");
+            var lines = _decoder.Push(string.Empty);
+            Assert.AreEqual(0, lines.Count);
+        }
+
+        [TestMethod]
+        public void Clear()
+        {
+            _decoder.Push("PRIVMSG ");
+            _decoder.Clear();
+            Assert.AreEqual(string.Empty, _decoder.Pending);
+        }
+    }
+}
diff --git a/IRCSharp.Tests/Tokenization/StatefulEncoder.cs b/IRCSharp.Tests/Tokenization/StatefulEncoder.cs
new file mode 100644
index 0000000..d1e1e3e
--- /dev/null
+++ b/IRCSharp.Tests/Tokenization/StatefulEncoder.cs
@@ -0,0 +1,84 @@
+using System.Text;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace IRCTokens.Tests
+{
+    [TestClass]
+    public class StatefulEncoder
+    {
+        private IRCTokens.StatefulEncoder _encoder;
+
+        [TestInitialize]
+        public void Initialize()
+        {
+            _encoder = new IRCTokens.StatefulEncoder();
+        }
+
+        [TestMethod]
+        public void Push()
+        {
+            var line = new Line("PRIVMSG #channel hello");
+            _encoder.Push(line);
+            Assert.AreEqual("PRIVMSG #channel hello\r\n", _encoder.Pending());
+        }
+
+        [TestMethod]
+        public void PopPartial()
+        {
+            var line = new Line("PRIVMSG #channel hello");
+            _encoder.Push(line);
+            _encoder.Pop("PRIVMSG #channel hello".Length);
+            Assert.AreEqual("\r\n", _encoder.Pending());
+        }
+
+        [TestMethod]
+        public void TestPopReturned()
+        {
+            var line = new Line("PRIVMSG #channel hello");
+            _encoder.Push(line);
+            _encoder.Push(line);
+            var lines = _encoder.Pop("PRIVMSG #channel hello\r\n".Length);
+            Assert.AreEqual(1, lines.Count);
+            Assert.AreEqual(line, lines[0]);
+        }
+
+        [TestMethod]
+        public void PopNoneReturned()
+        {
+            var line = new Line("PRIVMSG #channel hello");
+            _encoder.Push(line);
+            var lines = _encoder.Pop(1);
+            Assert.AreEqual(0, lines.Count);
+        }
+
+        [TestMethod]
+        public void PopMultipleLines()
+        {
+            var line1 = new Line("PRIVMSG #channel1 hello");
+            _encoder.Push(line1);
+            var line2 = new Line("PRIVMSG #channel2 hello");
+            _encoder.Push(line2);
+
+            var lines = _encoder.Pop(_encoder.Pending().Length);
+            Assert.AreEqual(2, lines.Count);
+            Assert.AreEqual(string.Empty, _encoder.Pending());
+        }
+
+        [TestMethod]
+        public void Clear()
+        {
+            _encoder.Push(new Line("PRIVMSG #channel hello"));
+            _encoder.Clear();
+            Assert.AreEqual(string.Empty, _encoder.Pending());
+        }
+
+        [TestMethod]
+        public void EncodingIso8859()
+        {
+            var iso8859 = Encoding.GetEncoding("iso-8859-1");
+            _encoder = new IRCTokens.StatefulEncoder {Encoding = iso8859};
+            _encoder.Push(new Line("PRIVMSG #channel :hello Ç"));
+            CollectionAssert.AreEqual(iso8859.GetBytes("PRIVMSG #channel :hello Ç\r\n"), _encoder.PendingBytes);
+        }
+    }
+}
diff --git a/IRCSharp.Tests/Tokenization/Tokenization.cs b/IRCSharp.Tests/Tokenization/Tokenization.cs
new file mode 100644
index 0000000..c4c5c5a
--- /dev/null
+++ b/IRCSharp.Tests/Tokenization/Tokenization.cs
@@ -0,0 +1,133 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace IRCTokens.Tests
+{
+    [TestClass]
+    public class Tokenization
+    {
+        [TestMethod]
+        public void TagsMissing()
+        {
+            var line = new Line("PRIVMSG #channel");
+            Assert.IsNull(line.Tags);
+        }
+
+        [TestMethod]
+        public void TagsMissingValue()
+        {
+            var line = new Line("@id= PRIVMSG #channel");
+            Assert.AreEqual(string.Empty, line.Tags["id"]);
+        }
+
+        [TestMethod]
+        public void TagsMissingEqual()
+        {
+            var line = new Line("@id PRIVMSG #channel");
+            Assert.AreEqual(string.Empty, line.Tags["id"]);
+        }
+
+        [TestMethod]
+        public void TagsUnescape()
+        {
+            var line = new Line(@"@id=1\\\:\r\n\s2 PRIVMSG #channel");
+            Assert.AreEqual("1\\;\r\n 2", line.Tags["id"]);
+        }
+
+        [TestMethod]
+        public void TagsOverlap()
+        {
+            var line = new Line(@"@id=1\\\s\\s PRIVMSG #channel");
+            Assert.AreEqual("1\\ \\s", line.Tags["id"]);
+        }
+
+        [TestMethod]
+        public void TagsLoneEndSlash()
+        {
+            var line = new Line("@id=1\\ PRIVMSG #channel");
+            Assert.AreEqual("1", line.Tags["id"]);
+        }
+
+        [TestMethod]
+        public void SourceWithoutTags()
+        {
+            var line = new Line(":nick!user@host PRIVMSG #channel");
+            Assert.AreEqual("nick!user@host", line.Source);
+        }
+
+        [TestMethod]
+        public void SourceWithTags()
+        {
+            var line = new Line("@id=123 :nick!user@host PRIVMSG #channel");
+            Assert.AreEqual("nick!user@host", line.Source);
+        }
+
+        [TestMethod]
+        public void SourceMissingWithoutTags()
+        {
+            var line = new Line("PRIVMSG #channel");
+            Assert.IsNull(line.Source);
+        }
+
+        [TestMethod]
+        public void SourceMissingWithTags()
+        {
+            var line = new Line("@id=123 PRIVMSG #channel");
+            Assert.IsNull(line.Source);
+        }
+
+        [TestMethod]
+        public void Command()
+        {
+            var line = new Line("privmsg #channel");
+            Assert.AreEqual("PRIVMSG", line.Command);
+        }
+
+        [TestMethod]
+        public void ParamsTrailing()
+        {
+            var line = new Line("PRIVMSG #channel :hello world");
+            CollectionAssert.AreEqual(new List<string> {"#channel", "hello world"}, line.Params);
+        }
+
+        [TestMethod]
+        public void ParamsOnlyTrailing()
+        {
+            var line = new Line("PRIVMSG :hello world");
+            CollectionAssert.AreEqual(new List<string> {"hello world"}, line.Params);
+        }
+
+        [TestMethod]
+        public void ParamsMissing()
+        {
+            var line = new Line("PRIVMSG");
+            Assert.AreEqual("PRIVMSG", line.Command);
+            CollectionAssert.AreEqual(new List<string>(), line.Params);
+        }
+
+        [TestMethod]
+        public void AllTokens()
+        {
+            var line = new Line("@id=123 :nick!user@host PRIVMSG #channel :hello world");
+            CollectionAssert.AreEqual(new Dictionary<string, string> {{"id", "123"}}, line.Tags);
+            Assert.AreEqual("nick!user@host", line.Source);
+            Assert.AreEqual("PRIVMSG", line.Command);
+            CollectionAssert.AreEqual(new List<string> {"#channel", "hello world"}, line.Params);
+        }
+
+        [TestMethod]
+        public void NulByte()
+        {
+            var decoder = new IRCTokens.StatefulDecoder();
+            var bytes = Encoding.UTF8.GetBytes(":nick!user@host PRIVMSG #channel :hello")
+                .Concat(Encoding.UTF8.GetBytes("\0"))
+                .Concat(Encoding.UTF8.GetBytes("world"))
+                .ToArray();
+            var line = decoder.Push(bytes, bytes.Length).First();
+            
+            CollectionAssert.AreEqual(new List<string> {"#channel", "hello"}, line.Params);
+        }
+    }
+}