Celebrating 10 Years!

profile picture

How To Write IRC: Part 5

August 25, 2017 - Roundwall Software

This is a continuation of the article published yesterday: How To Write IRC: Part 4

More Tests!

Next larger feature I wanted to test was joining a channel. As I planned before, joining a channel should create a new object to give developers a way to interact. For this I modified the first test I wrote which had the basic parts laid out already. It didn't really test anything anyway, so I made it do something useful.

func testJoiningAChannel() {
  let user = IRCUser(username: "sgoodwin", realName: "Samuel Goodwin", nick: "mukman")
  let server = IRCServer.connect("127.0.0.1", port: 6667, user: user, session: fakeSession)

  let channel = server.join("clearlyafakechannel")

  struct ChannelDelegate: IRCChannelDelegate {
    let expectation = XCTestExpectation(description: "Any message receieved")

    func didReceieveMessage(_ channel: IRCChannel, message: String) {
      expectation.fulfill()
    }
  }

  let channelDelegate = ChannelDelegate()
  channel.delegate = channelDelegate

  wait(for: [channelDelegate.expectation], timeout: 1.0)
}

After clearing out everything not directly related, I was left with this. It connects to a server, tries to join a channel, and expects at least one channel message to pass through. This would test that my channel objects are connected to everything else properly. After I had everything connected as planned, I found that my tests for the input parser were insufficient. It turns out the streaming task does not split the chunks of data it receive by newlines as I had assumed. When I thought you would only get messages like this:

:mukman!~sgoodwin@188.202.247.233 JOIN #clearlyatestchannel\r\n

it turns out you can actually get chunks of data more like this:

:mukman!~sgoodwin@188.202.247.233 JOIN #clearlyatestchannel\r\n\r\n:development.irc.roundwallsoftware.com 353 mukman = #clearlyafakechannel :mukman @sgoodwin\r\n:development.irc.roundwallsoftware.com 366 mukman #clearlyafakechannel :End of /NAMES list.

all at once in a streaming task. This told me that I either needed to split input before feeding it to my parser or I needed to make the parser return an array of results instead of just one result. I decided it would involve the least amount of change to split the incoming data before parsing it, so I did. The second chunk of that message also turned out to be a type of message I had not considered. When you first join a channel, the server sends a list of all the users currently already in that channel. I needed to add this "user list" message to my input message enum. To get all my tests to pass, I cheated out a bit on the user list. Later when I have a better example of what a longer list of users looks like, I can improve the implementation to be more accurate. For now it only cares about the first user in the list. With all tests working, I knew that my plans for how things should be connected are pretty much settled. Changes were scattered all around, so rather than show it all here, you can check this commit for all the gory details.

From here I updated my terrible sample app from my first experiments to use the new code. The view controller became smaller, how convenient. I could connect to a server, join a channel, see what is said in the channel, but I noticed two important things were missing:

  1. I wasn't handling PING messages properly. Turns out they look like PING :development.irc.roundwallsoftware.com and not simply PING as I had expected before.
  2. I can't yet send messages to the channel.

The first wasn't too bad to fix, I updated the unit test for my input parser. I could make all my tests pass again by changing the part that looks for the PING message to

if message.hasPrefix("PING") {
  return .ping
}

Then second I decided to try to fix with a mock object. Essentially what I wanted to verify was: if I use a channel's send method, it should use its related server's send method with the proper input to say "the user said this from this channel". I could test this by mocking the IRCServer that the channel was created with.

func testSendingAChannelMessage() {
  class MockServer: IRCServer {
    var sentMessage: String?

    override func send(_ message: String) {
      sentMessage = message
    }
  }
        
  let user = IRCUser(username: "sgoodwin", realName: "Samuel Goodwin", nick: "mukman")
  let server = MockServer.connect("127.0.0.1", port: 6667, user: user)
  let channel = server.join("clearlyafakechannel")
  channel.send("hey sup")
        
  XCTAssertEqual(server.sentMessage, "PRIVMSG #clearlyafakechannel :hey sup")
}

This test took advantage of Swift's ability to arbitrarily make new classes inside functions to make a special-case IRCServer. We only needed it for this one test. For this to work I needed to modify IRCServer's init to be internal and not private, but that's ok (read about the difference here if you're curious). The test does not initially pass because right now IRCChannel's send method does nothing. So I fixed that.

func send(_ text: String) {
  server.send("PRIVMSG #\(name) :\(text)")
}

With this change, the test passed and I went back to my demo app to play with it. It's quite a bit of fun carrying on a conversation with yourself over two irc clients, especially when you wrote one of them. This makes all the basic functionality I wanted to implement complete! With my code you can connect to a server, join a channel, and talk back and forth with your new super-cool friends on that channel. Hooray! Fireworks! The only thing left to do is move all the IRC code into a framework so that other people can use it without needing to touch my awful demo app. I'll show you that in our exciting conclusion tomorrow.

The work finishes in How To Write IRC: Part 6