Repository: osa1/tiny (language: rust)

Issue:
PRIVMSG (when none of target or sender is us) is shown with incorrect sender
I just got this message in Freenode:

```rust
Msg {
    pfx: Some(
        User { 
            nick: "e",
            user: "e@freenode/staff/spy.edk"
        }
    ),
    cmd: PRIVMSG {
        target: User("$$*"),
        msg: "...",
        is_notice: true,
        ctcp: None
    }
}
```

Shown as:

![Screenshot_2020-11-30_09-40-48](https://user-images.githubusercontent.com/448274/100577120-92b7f000-32f0-11eb-99ca-52a015a68fc2.png)

Here the tab name is right (a PRIVMSG sent to `$$*`, it looks weird but there's no other way to show this message I think) but sender should be `e`, not my nick.

I suspect this is introduced with db38123f4021d994845cc1e8248ddc8be913d90f.

We should first start testing this stuff (#273) before making any more changes to showing PRIVMSGs, as the logic is already complex enough.


Discussion / hints:

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6ef07e32..1d1a9452 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,7 @@
   is not set. (#280)
 - Multi-line pastes now inserted into the cursor location. Previously the text
   would be inserted at the end of the current line.
+- Fixed a bug when showing messages with host mask as message target. (#278)
 
 # 2020/09/20: 0.7.0
 
diff --git a/Cargo.lock b/Cargo.lock
index 736b72e4..36425722 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1119,6 +1119,8 @@ dependencies = [
  "rustc_tools_util",
  "serde",
  "serde_yaml",
+ "term_input",
+ "termbox_simple",
  "time",
  "tokio",
 ]
diff --git a/libtiny_tui/src/lib.rs b/libtiny_tui/src/lib.rs
index 2bb8d4ef..6fcf1c82 100644
--- a/libtiny_tui/src/lib.rs
+++ b/libtiny_tui/src/lib.rs
@@ -13,9 +13,9 @@ mod notifier;
 mod statusline;
 mod tab;
 mod termbox;
+pub mod test_utils;
 #[doc(hidden)]
 pub mod trie; // Public for benchmarks
-#[doc(hidden)]
 pub mod tui; // Public for benchmarks
 mod utils;
 mod widget;
@@ -36,6 +36,7 @@ use std::rc::{Rc, Weak};
 use term_input::Input;
 use time::Tm;
 use tokio::signal::unix::{signal, SignalKind};
+use tokio::stream::Stream;
 use tokio::sync::mpsc;
 use tokio::task::spawn_local;
 
@@ -61,10 +62,36 @@ impl TUI {
         spawn_local(sigwinch_handler(inner.clone(), rcv_abort));
 
         // Spawn input handler task
-        spawn_local(input_handler(tui, snd_ev, snd_abort));
+        let input = Input::new();
+        spawn_local(input_handler(input, tui, snd_ev, snd_abort));
+
+        (TUI { inner }, rcv_ev)
+    }
+
+    /// Create a test instance that doesn't render to the terminal, just updates the termbox
+    /// buffer. Useful for testing. See also [`get_front_buffer`](TUI::get_front_buffer).
+    pub fn run_test<S>(width: u16, height: u16, input_stream: S) -> (TUI, mpsc::Receiver<Event>)
+    where
+        S: Stream<Item = std::io::Result<term_input::Event>> + Unpin + 'static,
+    {
+        let tui = Rc::new(RefCell::new(tui::TUI::new_test(width, height)));
+        let inner = Rc::downgrade(&tui);
+
+        let (snd_ev, rcv_ev) = mpsc::channel(10);
+
+        // We don't need to handle SIGWINCH in testing so the receiver end is not used
+        let (snd_abort, _rcv_abort) = mpsc::channel::<()>(1);
+
+        // Spawn input handler task
+        spawn_local(input_handler(input_stream, tui, snd_ev, snd_abort));
 
         (TUI { inner }, rcv_ev)
     }
+
+    /// Get termbox front buffer. Useful for testing rendering.
+    pub fn get_front_buffer(&self) -> termbox_simple::CellBuf {
+        self.inner.upgrade().unwrap().borrow().get_front_buffer()
+    }
 }
 
 async fn sigwinch_handler(tui: Weak<RefCell<tui::TUI>>, rcv_abort: mpsc::Receiver<()>) {
@@ -98,13 +125,14 @@ async fn sigwinch_handler(tui: Weak<RefCell<tui::TUI>>, rcv_abort: mpsc::Receive
     }
 }
 
-async fn input_handler(
+async fn input_handler<S>(
+    mut input_stream: S,
     tui: Rc<RefCell<tui::TUI>>,
     snd_ev: mpsc::Sender<Event>,
     snd_abort: mpsc::Sender<()>,
-) {
-    let mut input = Input::new();
-
+) where
+    S: Stream<Item = std::io::Result<term_input::Event>> + Unpin,
+{
     // See module documentation of `editor` for how editor stuff works
     let mut rcv_editor_ret: Option<editor::ResultReceiver> = None;
 
@@ -132,7 +160,7 @@ async fn input_handler(
             tui.borrow_mut().draw();
         }
 
-        match input.next().await {
+        match input_stream.next().await {
             None => {
                 break;
             }
diff --git a/libtiny_tui/src/tui.rs b/libtiny_tui/src/tui.rs
index 63a92066..cc4ade8d 100644
--- a/libtiny_tui/src/tui.rs
+++ b/libtiny_tui/src/tui.rs
@@ -19,7 +19,7 @@ use crate::{MsgSource, MsgTarget};
 
 use libtiny_common::ChanNameRef;
 use term_input::{Arrow, Event, Key};
-use termbox_simple::Termbox;
+pub use termbox_simple::{CellBuf, Termbox};
 
 #[derive(Debug)]
 pub(crate) enum TUIRet {
@@ -106,14 +106,16 @@ impl TUI {
         TUI::new_tb(Some(config_path), tb)
     }
 
+    /// Create a test instance. Does not render to the screen, just updates the termbox buffer.
+    /// Useful for testing rendering. See also [`get_front_buffer`](TUI::get_front_buffer).
     pub fn new_test(w: u16, h: u16) -> TUI {
         let tb = Termbox::init_test(w, h);
         TUI::new_tb(None, tb)
     }
 
-    #[cfg(test)]
-    pub(crate) fn get_tb(&self) -> &Termbox {
-        &self.tb
+    /// Get termbox front buffer. Useful for testing rendering.
+    pub(crate) fn get_front_buffer(&self) -> CellBuf {
+        self.tb.get_front_buffer()
     }
 
     pub(crate) fn activate(&mut self) {
diff --git a/libtiny_wire/src/lib.rs b/libtiny_wire/src/lib.rs
index 05047f62..0801077b 100644
--- a/libtiny_wire/src/lib.rs
+++ b/libtiny_wire/src/lib.rs
@@ -1,6 +1,9 @@
 #![allow(clippy::write_with_newline)]
 
 //! IRC wire protocol message parsers and generators. Incomplete; new messages are added as needed.
+//!
+//! This library is for implementing clients rather than servers or services, and does not support
+//! the IRC message format in full generality.
 
 use std::str;
 
@@ -165,6 +168,12 @@ fn parse_pfx(pfx: &str) -> Pfx {
 }
 
 /// Target of a message
+///
+/// Masks are not parsed, as rules for masks are not clear in RFC 2818 (for example, `#x.y` can be
+/// a channel name or a host mask, there is no way to disambiguate), and in practice servers use
+/// masks that are not valid according to the RFC (for example, I've observed Freenode sending
+/// PRIVMSGs to `$$*`). The rules we follow is: if a target starts with `#` it's a `Chan`,
+/// otherwise it's a `User`.
 #[derive(Debug, PartialEq, Eq)]
 pub enum MsgTarget {
     Chan(ChanName),
diff --git a/rfcs/rfc2812.txt b/rfcs/rfc2812.txt
index f8b6d339..b4a24f2a 100644
--- a/rfcs/rfc2812.txt
+++ b/rfcs/rfc2812.txt
@@ -458,8 +458,8 @@ RFC 2812          Internet Relay Chat: Client Protocol        April 2000
    The Augmented BNF syntax for this is:
 
     mask       =  *( nowild / noesc wildone / noesc wildmany )
-    wildone    =  %x3F
-    wildmany   =  %x2A
+    wildone    =  %x3F ; (osa): "?"
+    wildmany   =  %x2A ; (osa): "*"
     nowild     =  %x01-29 / %x2B-3E / %x40-FF
                     ; any octet except NUL, "*", "?"
     noesc      =  %x01-5B / %x5D-FF
diff --git a/tiny/Cargo.toml b/tiny/Cargo.toml
index f81ee30a..08cf5e7c 100644
--- a/tiny/Cargo.toml
+++ b/tiny/Cargo.toml
@@ -31,5 +31,9 @@ serde_yaml = "0.8.14"
 time = "0.1"
 tokio = { version = "0.3.5", default-features = false, features = [] }
 
+[dev-dependencies]
+term_input = { path = "../term_input" }
+termbox_simple = { path = "../termbox" }
+
 [build-dependencies]
 rustc_tools_util = "0.2.0"
diff --git a/tiny/src/cmd.rs b/tiny/src/cmd.rs
index e022c820..2c22303a 100644
--- a/tiny/src/cmd.rs
+++ b/tiny/src/cmd.rs
@@ -275,7 +275,7 @@ fn connect_(
     // Spawn UI task
     let ui_clone = libtiny_ui::clone_box(&**ui);
     let client_clone = client.clone();
-    tokio::task::spawn_local(crate::conn::task(rcv_ev, ui_clone, client_clone));
+    tokio::task::spawn_local(crate::conn::task(rcv_ev, ui_clone, Box::new(client_clone)));
 
     clients.push(client);
 }
diff --git a/tiny/src/conn.rs b/tiny/src/conn.rs
index d2f533e1..c9e38f3f 100644
--- a/tiny/src/conn.rs
+++ b/tiny/src/conn.rs
@@ -6,23 +6,44 @@
 use futures::stream::StreamExt;
 use tokio::sync::mpsc;
 
-use libtiny_client::Client;
 use libtiny_common::ChanNameRef;
 use libtiny_ui::{MsgTarget, TabStyle, UI};
 use libtiny_wire as wire;
 
+pub(crate) trait Client {
+    fn get_serv_name(&self) -> &str;
+
+    fn get_nick(&self) -> String;
+
+    fn is_nick_accepted(&self) -> bool;
+}
+
+impl Client for libtiny_client::Client {
+    fn get_serv_name(&self) -> &str {
+        self.get_serv_name()
+    }
+
+    fn get_nick(&self) -> String {
+        self.get_nick()
+    }
+
+    fn is_nick_accepted(&self) -> bool {
+        self.is_nick_accepted()
+    }
+}
+
 pub(crate) async fn task(
     mut rcv_ev: mpsc::Receiver<libtiny_client::Event>,
     ui: Box<dyn UI>,
-    client: Client,
+    client: Box<dyn Client>,
 ) {
     while let Some(ev) = rcv_ev.next().await {
-        handle_conn_ev(&*ui, &client, ev);
+        handle_conn_ev(&*ui, &*client, ev);
         ui.draw();
     }
 }
 
-fn handle_conn_ev(ui: &dyn UI, client: &Client, ev: libtiny_client::Event) {
+fn handle_conn_ev(ui: &dyn UI, client: &dyn Client, ev: libtiny_client::Event) {
     use libtiny_client::Event::*;
     match ev {
         ResolvingHost => {
@@ -123,7 +144,7 @@ fn handle_conn_ev(ui: &dyn UI, client: &Client, ev: libtiny_client::Event) {
     }
 }
 
-fn handle_irc_msg(ui: &dyn UI, client: &Client, msg: wire::Msg) {
+fn handle_irc_msg(ui: &dyn UI, client: &dyn Client, msg: wire::Msg) {
     use wire::Cmd::*;
     use wire::Pfx::*;
 
@@ -213,47 +234,78 @@ fn handle_irc_msg(ui: &dyn UI, client: &Client, msg: wire::Msg) {
                                 ui.set_tab_style(TabStyle::Highlight, &msg_target);
                                 ui.add_privmsg(nick, &msg, ts, &msg_target, false, is_action);
                             } else {
-                                // PRIVMSG not sent to us. This case can happen when using a
-                                // bouncer, see #271. When multiple clients connect to the same
-                                // bouncer and one of them sends a PRIVMSG, the message is relayed
-                                // to the other clients. E.g.
+                                // PRIVMSG not sent to us. This case can happen in a few cases:
+                                //
+                                // - When using a bouncer, see #271. When multiple clients connect
+                                //   to the same bouncer and one of them sends a PRIVMSG, the
+                                //   message is relayed to the other clients. Example:
+                                //
+                                //       <our_nick> PRIVMSG <target> :...
+                                //
+                                //   In this case (when the sender is us) we show the message in
+                                //   the target's tab and our nick as the sender.
+                                //
+                                // - When the message target is a "host mask" (e.g. message was
+                                //   sent to all users matching a mask), see #278. Example:
                                 //
-                                //     PRIVMSG <our_nick> <target> :...
+                                //       <some prefix> PRIVMSG $$* :...
                                 //
-                                if log_enabled!(log::Level::Warn) {
-                                    match pfx {
-                                        Server(_) => {
-                                            warn!(
-                                                "PRIVMSG target is not us, but prefix is server: {:?}",
-                                                msg
+                                //    In this case (when the sender is not us) we show the message
+                                //    in the target's tab as the prefix as the sender.
+
+                                match pfx {
+                                    Server(ref sender) => {
+                                        // A message sent by a server to someone other than us?
+                                        // The target is probably a host mask. Show the message in
+                                        // the server tab.
+                                        let msg_target = MsgTarget::Server { serv };
+                                        ui.add_privmsg(
+                                            sender,
+                                            &msg,
+                                            ts,
+                                            &msg_target,
+                                            false,
+                                            is_action,
+                                        );
+                                        ui.set_tab_style(TabStyle::Highlight, &msg_target);
+                                    }
+                                    User { ref nick, .. } | Ambiguous(ref nick) => {
+                                        if nick == &client.get_nick() {
+                                            // Case (1). Don't highlight the tab as `Highlight`:
+                                            // the message was sent by us so the tab probably
+                                            // doesn't need that much attention. Highlight as
+                                            // `NewMsg` instead.
+                                            let msg_target = MsgTarget::User {
+                                                serv,
+                                                nick: &target,
+                                            };
+                                            ui.add_privmsg(
+                                                &client.get_nick(),
+                                                &msg,
+                                                ts,
+                                                &msg_target,
+                                                false,
+                                                is_action,
                                             );
-                                        }
-                                        User { ref nick, .. } | Ambiguous(ref nick) => {
-                                            if *nick != client.get_nick() {
-                                                warn!(
-                                                    "PRIVMSG sender or target is not us: {:?}",
-                                                    msg
-                                                );
-                                            }
+                                            // Don't highlight the tab as `Highlight`: the message was sent by us so
+                                            // the tab probably doesn't need that much attention. Highlight as `NewMsg`
+                                            // instead.
+                                            ui.set_tab_style(TabStyle::NewMsg, &msg_target);
+                                        } else {
+                                            // Case (2)
+                                            let msg_target = MsgTarget::User { serv, nick };
+                                            ui.add_privmsg(
+                                                nick,
+                                                &msg,
+                                                ts,
+                                                &msg_target,
+                                                false,
+                                                is_action,
+                                            );
+                                            ui.set_tab_style(TabStyle::Highlight, &msg_target);
                                         }
                                     }
-                                }
-                                let msg_target = MsgTarget::User {
-                                    serv,
-                                    nick: &target,
                                 };
-                                ui.add_privmsg(
-                                    &client.get_nick(),
-                                    &msg,
-                                    ts,
-                                    &msg_target,
-                                    false,
-                                    is_action,
-                                );
-                                // Don't highlight the tab as `Highlight`: the message was sent by us so
-                                // the tab probably doesn't need that much attention. Highlight as `NewMsg`
-                                // instead.
-                                ui.set_tab_style(TabStyle::NewMsg, &msg_target);
                             }
                         }
                     }
diff --git a/tiny/src/main.rs b/tiny/src/main.rs
index 8b602905..25307cca 100644
--- a/tiny/src/main.rs
+++ b/tiny/src/main.rs
@@ -8,6 +8,9 @@ mod debug_logging;
 mod ui;
 mod utils;
 
+#[cfg(test)]
+mod tests;
+
 use libtiny_client::{Client, ServerInfo};
 use libtiny_common::ChanNameRef;
 use libtiny_logger::{Logger, LoggerInitError};
@@ -165,7 +168,7 @@ fn run(
             let client_clone = client.clone();
 
             // Spawn a task to handle connection events
-            tokio::task::spawn_local(conn::task(rcv_conn_ev, tui_clone, client_clone));
+            tokio::task::spawn_local(conn::task(rcv_conn_ev, tui_clone, Box::new(client_clone)));
 
             clients.push(client);
         }


Test patch:
diff --git a/libtiny_tui/src/test_utils.rs b/libtiny_tui/src/test_utils.rs
new file mode 100644
index 00000000..9cdcda30
--- /dev/null
+++ b/libtiny_tui/src/test_utils.rs
@@ -0,0 +1,70 @@
+use std::panic::Location;
+
+use termbox_simple::CellBuf;
+
+pub fn buffer_str(buf: &CellBuf, w: u16, h: u16) -> String {
+    let w = usize::from(w);
+    let h = usize::from(h);
+
+    let mut ret = String::with_capacity(w * h);
+
+    for y in 0..h {
+        for x in 0..w {
+            let ch = buf.cells[(y * w) + x].ch;
+            ret.push(ch);
+        }
+        if y != h - 1 {
+            ret.push('\n');
+        }
+    }
+
+    ret
+}
+
+pub fn expect_screen(
+    screen: &str,
+    front_buffer: &CellBuf,
+    w: u16,
+    h: u16,
+    caller: &'static Location<'static>,
+) {
+    let mut screen_filtered = String::with_capacity(screen.len());
+
+    let mut in_screen = false;
+    for c in screen.chars() {
+        if in_screen {
+            if c == '|' {
+                screen_filtered.push('\n');
+                in_screen = false;
+            } else {
+                screen_filtered.push(c);
+            }
+        } else if c == '|' {
+            in_screen = true;
+        }
+    }
+    let _ = screen_filtered.pop(); // pop the last '\n'
+
+    let found = buffer_str(front_buffer, w, h);
+
+    let mut line = String::new();
+    for _ in 0..w {
+        line.push('-');
+    }
+
+    if screen_filtered != found {
+        panic!(
+            "Unexpected screen\n\
+            Expected:\n\
+            {}\n\
+            {}\n\
+            {}\n\
+            Found:\n\
+            {}\n\
+            {}\n\
+            {}\n\
+            Called by: {}\n",
+            line, screen_filtered, line, line, found, line, caller
+        );
+    }
+}
diff --git a/libtiny_tui/src/tests/mod.rs b/libtiny_tui/src/tests/mod.rs
index 8ed86df4..54e16fbf 100644
--- a/libtiny_tui/src/tests/mod.rs
+++ b/libtiny_tui/src/tests/mod.rs
@@ -1,75 +1,14 @@
 use crate::tui::TUI;
 
+use crate::test_utils::expect_screen;
 use libtiny_common::ChanNameRef;
 use libtiny_ui::*;
 use term_input::{Event, Key};
-use termbox_simple::CellBuf;
 
 use std::fs::File;
 use std::io::{BufRead, BufReader};
 use std::panic::Location;
 
-fn buffer_str(buf: &CellBuf, w: u16, h: u16) -> String {
-    let w = usize::from(w);
-    let h = usize::from(h);
-
-    let mut ret = String::with_capacity(w * h);
-
-    for y in 0..h {
-        for x in 0..w {
-            let ch = buf.cells[(y * w) + x].ch;
-            ret.push(ch);
-        }
-        if y != h - 1 {
-            ret.push('\n');
-        }
-    }
-
-    ret
-}
-
-fn expect_screen(screen: &str, tui: &TUI, w: u16, h: u16, caller: &'static Location<'static>) {
-    let mut screen_filtered = String::with_capacity(screen.len());
-
-    let mut in_screen = false;
-    for c in screen.chars() {
-        if in_screen {
-            if c == '|' {
-                screen_filtered.push('\n');
-                in_screen = false;
-            } else {
-                screen_filtered.push(c);
-            }
-        } else if c == '|' {
-            in_screen = true;
-        }
-    }
-    let _ = screen_filtered.pop(); // pop the last '\n'
-
-    let found = buffer_str(&tui.get_tb().get_front_buffer(), w, h);
-
-    let mut line = String::new();
-    for _ in 0..w {
-        line.push('-');
-    }
-
-    if screen_filtered != found {
-        panic!(
-            "Unexpected screen\n\
-            Expected:\n\
-            {}\n\
-            {}\n\
-            {}\n\
-            Found:\n\
-            {}\n\
-            {}\n\
-            {}\n\
-            Called by: {}\n",
-            line, screen_filtered, line, line, found, line, caller
-        );
-    }
-}
-
 fn enter_string(tui: &mut TUI, s: &str) {
     for c in s.chars() {
         tui.handle_input_event(Event::Key(Key::Char(c)), &mut None);
@@ -87,7 +26,7 @@ fn init_screen() {
          |will be listed here.|
          |                    |
          |mentions            |";
-    expect_screen(screen, &tui, 20, 4, Location::caller());
+    expect_screen(screen, &tui.get_front_buffer(), 20, 4, Location::caller());
 }
 
 #[test]
@@ -106,7 +45,7 @@ fn close_rightmost_tab() {
          |                    |
          |                    |
          |< irc.server_2.org  |";
-    expect_screen(screen, &tui, 20, 4, Location::caller());
+    expect_screen(screen, &tui.get_front_buffer(), 20, 4, Location::caller());
 
     // Should scroll left when the server tab is closed. Left arrow should still be visible as
     // there are still tabs to the left.
@@ -119,7 +58,7 @@ fn close_rightmost_tab() {
          |                    |
          |                    |
          |< irc.server_1.org  |";
-    expect_screen(screen, &tui, 20, 4, Location::caller());
+    expect_screen(screen, &tui.get_front_buffer(), 20, 4, Location::caller());
 
     // Scroll left again, left arrow should disappear this time.
     tui.close_server_tab("irc.server_1.org");
@@ -131,7 +70,7 @@ fn close_rightmost_tab() {
          |will be listed here.|
          |                    |
          |mentions            |";
-    expect_screen(screen, &tui, 20, 4, Location::caller());
+    expect_screen(screen, &tui.get_front_buffer(), 20, 4, Location::caller());
 }
 
 #[test]
@@ -158,7 +97,7 @@ fn small_screen_1() {
          |osa1:                |
          |< #chan              |";
 
-    expect_screen(screen, &tui, 21, 3, Location::caller());
+    expect_screen(screen, &tui.get_front_buffer(), 21, 3, Location::caller());
 
     tui.set_size(24, 3);
     tui.draw();
@@ -169,7 +108,7 @@ fn small_screen_1() {
          |osa1:                   |
          |< irc.server_1.org #chan|";
 
-    expect_screen(screen, &tui, 24, 3, Location::caller());
+    expect_screen(screen, &tui.get_front_buffer(), 24, 3, Location::caller());
 
     tui.set_size(31, 3);
     tui.draw();
@@ -180,7 +119,7 @@ fn small_screen_1() {
          |osa1:                          |
          |mentions irc.server_1.org #chan|";
 
-    expect_screen(screen, &tui, 31, 3, Location::caller());
+    expect_screen(screen, &tui.get_front_buffer(), 31, 3, Location::caller());
 }
 
 #[test]
@@ -206,7 +145,7 @@ fn small_screen_2() {
          |00:00 Blah blah blah-|
          |osa1:                |
          |< #chan              |";
-    expect_screen(screen, &tui, 21, 4, Location::caller());
+    expect_screen(screen, &tui.get_front_buffer(), 21, 4, Location::caller());
 
     tui.add_nick("123456", Some(ts), &target);
     tui.draw();
@@ -217,7 +156,7 @@ fn small_screen_2() {
          |+123456              |
          |osa1:                |
          |< #chan              |";
-    expect_screen(screen, &tui, 21, 4, Location::caller());
+    expect_screen(screen, &tui.get_front_buffer(), 21, 4, Location::caller());
 }
 
 #[test]
@@ -240,7 +179,7 @@ fn ctrl_w() {
         "|                              |
          |osa1: dkf aslkdfj aslkdfj asf |
          |< irc.server_1.org #chan      |";
-    expect_screen(screen, &tui, 30, 3, Location::caller());
+    expect_screen(screen, &tui.get_front_buffer(), 30, 3, Location::caller());
 
     tui.handle_input_event(Event::Key(Key::Ctrl('w')), &mut None);
     tui.draw();
@@ -251,7 +190,7 @@ fn ctrl_w() {
          |osa1: asldkf aslkdfj aslkdfj  |
          |< irc.server_1.org #chan      |";
 
-    expect_screen(screen, &tui, 30, 3, Location::caller());
+    expect_screen(screen, &tui.get_front_buffer(), 30, 3, Location::caller());
 
     println!("~~~~~~~~~~~~~~~~~~~~~~");
     tui.handle_input_event(Event::Key(Key::Ctrl('w')), &mut None);
@@ -273,7 +212,7 @@ fn ctrl_w() {
          |osa1:  asldkf asldkf aslkdfj  |
          |< irc.server_1.org #chan      |";
 
-    expect_screen(screen, &tui, 30, 3, Location::caller());
+    expect_screen(screen, &tui.get_front_buffer(), 30, 3, Location::caller());
 
     tui.handle_input_event(Event::Key(Key::Ctrl('w')), &mut None);
     tui.draw();
@@ -284,7 +223,7 @@ fn ctrl_w() {
          |osa1: alskdfj asldkf asldkf   |
          |< irc.server_1.org #chan      |";
 
-    expect_screen(screen, &tui, 30, 3, Location::caller());
+    expect_screen(screen, &tui.get_front_buffer(), 30, 3, Location::caller());
 }
 
 // Tests text field wrapping (text_field_wrap setting)
@@ -327,7 +266,7 @@ fn test_text_field_wrap() {
      |bbbbb                                   |
      |mentions chat.freenode.net              |";
 
-    expect_screen(screen, &tui, 40, 8, Location::caller());
+    expect_screen(screen, &tui.get_front_buffer(), 40, 8, Location::caller());
 
     // Test resizing
     tui.set_size(46, 8);
@@ -344,7 +283,7 @@ fn test_text_field_wrap() {
      |x: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbb |
      |mentions chat.freenode.net                    |";
 
-    expect_screen(screen, &tui, 46, 8, Location::caller());
+    expect_screen(screen, &tui.get_front_buffer(), 46, 8, Location::caller());
 
     // Reset size
     tui.set_size(40, 8);
@@ -369,7 +308,7 @@ fn test_text_field_wrap() {
      |x: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa |
      |mentions chat.freenode.net              |";
 
-    expect_screen(screen, &tui, 40, 8, Location::caller());
+    expect_screen(screen, &tui.get_front_buffer(), 40, 8, Location::caller());
 
     // On making screen smaller we should fall back to scrolling
     tui.set_size(30, 8);
@@ -390,7 +329,7 @@ fn test_text_field_wrap() {
      |x: aaaaaaaaaaaaaaaaaaaaabbbbb |
      |mentions chat.freenode.net    |";
 
-    expect_screen(screen, &tui, 30, 8, Location::caller());
+    expect_screen(screen, &tui.get_front_buffer(), 30, 8, Location::caller());
 
     tui.set_size(40, 8);
     tui.draw();
@@ -406,7 +345,7 @@ fn test_text_field_wrap() {
      |bbbb                                    |
      |mentions chat.freenode.net              |";
 
-    expect_screen(screen, &tui, 40, 8, Location::caller());
+    expect_screen(screen, &tui.get_front_buffer(), 40, 8, Location::caller());
 
     // Wrapping on words - splits lines on whitespace
     for _ in 0..6 {
@@ -436,7 +375,7 @@ fn test_text_field_wrap() {
      |bbbbb                                   |
      |mentions chat.freenode.net              |";
 
-    expect_screen(screen, &tui, 40, 8, Location::caller());
+    expect_screen(screen, &tui.get_front_buffer(), 40, 8, Location::caller());
 
     // TODO: Test changing nick (osa: I don't understand how nick length is taken into account when
     // falling back to scrolling)
@@ -467,7 +406,7 @@ fn test_join_part_overflow() {
          |osa1:                |
          |< #chan              |";
 
-    expect_screen(screen, &tui, 21, 4, Location::caller());
+    expect_screen(screen, &tui.get_front_buffer(), 21, 4, Location::caller());
 }
 
 #[test]
diff --git a/tiny/src/tests.rs b/tiny/src/tests.rs
new file mode 100644
index 00000000..0ad9de89
--- /dev/null
+++ b/tiny/src/tests.rs
@@ -0,0 +1,248 @@
+use crate::conn;
+use libtiny_tui::test_utils::expect_screen;
+use libtiny_tui::{TUI, UI};
+use libtiny_wire::{Cmd, Msg, MsgTarget, Pfx};
+use term_input;
+use termbox_simple::CellBuf;
+
+use libtiny_client as client;
+use term_input as input;
+
+use tokio::stream::StreamExt;
+use tokio::sync::mpsc;
+
+use std::future::Future;
+use std::panic::Location;
+
+struct TestClient {
+    nick: String,
+}
+
+impl conn::Client for TestClient {
+    fn get_serv_name(&self) -> &str {
+        SERV_NAME
+    }
+
+    fn get_nick(&self) -> String {
+        self.nick.clone()
+    }
+
+    fn is_nick_accepted(&self) -> bool {
+        true
+    }
+}
+
+static SERV_NAME: &str = "x.y.z";
+const DEFAULT_TUI_WIDTH: u16 = 40;
+const DEFAULT_TUI_HEIGHT: u16 = 5;
+
+struct TestSetup {
+    /// TUI test instance
+    tui: Box<TUI>,
+    /// Send input events to the TUI using this channel
+    snd_input_ev: mpsc::Sender<input::Event>,
+    /// Send connection events to connection handler (`conn::task`) using this channel
+    snd_conn_ev: mpsc::Sender<client::Event>,
+}
+
+fn run_test<F, Fut>(nick: String, test: F)
+where
+    F: Fn(TestSetup) -> Fut,
+    Fut: Future<Output = ()>,
+{
+    let runtime = tokio::runtime::Builder::new_current_thread()
+        .enable_all()
+        .build()
+        .unwrap();
+    let local = tokio::task::LocalSet::new();
+
+    local.block_on(&runtime, async move {
+        // Create test TUI
+        let (snd_input_ev, rcv_input_ev) = mpsc::channel::<term_input::Event>(100);
+        let (tui, _rcv_tui_ev) = TUI::run_test(
+            DEFAULT_TUI_WIDTH,
+            DEFAULT_TUI_HEIGHT,
+            rcv_input_ev.map(|ev| Ok(ev)),
+        );
+
+        let tui = Box::new(tui);
+
+        // Create test connection event channel
+        let (snd_conn_ev, rcv_conn_ev) = mpsc::channel::<client::Event>(100);
+
+        // Spawn connection event handler task
+        tokio::task::spawn_local(conn::task(
+            rcv_conn_ev,
+            tui.clone(),
+            Box::new(TestClient { nick }),
+        ));
+
+        tui.new_server_tab(SERV_NAME, None);
+        tui.draw();
+
+        test(TestSetup {
+            tui,
+            snd_input_ev,
+            snd_conn_ev,
+        })
+        .await;
+    });
+}
+
+#[test]
+fn test_bouncer_relay_issue_271() {
+    run_test(
+        "osa1-soju".to_owned(),
+        |TestSetup {
+             tui,
+             snd_input_ev,
+             snd_conn_ev,
+         }| async move {
+            snd_conn_ev.send(client::Event::Connected).await.unwrap();
+            snd_conn_ev
+                .send(client::Event::NickChange {
+                    new_nick: "osa1-soju".to_owned(),
+                })
+                .await
+                .unwrap();
+
+            let msg = Msg {
+                pfx: Some(Pfx::User {
+                    nick: "osa1-soju".to_owned(),
+                    user: "osa1-soju@127.0.0.1".to_owned(),
+                }),
+                cmd: Cmd::PRIVMSG {
+                    target: MsgTarget::User("osa1/oftc".to_owned()),
+                    msg: "blah blah".to_owned(),
+                    is_notice: false,
+                    ctcp: None,
+                },
+            };
+
+            snd_conn_ev.send(client::Event::Msg(msg)).await.unwrap();
+
+            yield_(5).await;
+            tui.draw();
+
+            next_tab(&snd_input_ev).await; // server tab
+            next_tab(&snd_input_ev).await; // privmsg tab
+            yield_(5).await;
+
+            tui.draw();
+
+            #[rustfmt::skip]
+            let screen =
+            "|                                        |
+             |                                        |
+             |00:00 osa1-soju: blah blah              |
+             |osa1-soju:                              |
+             |mentions x.y.z osa1/oftc                |";
+
+            let mut front_buffer = tui.get_front_buffer();
+            normalize_timestamps(&mut front_buffer, DEFAULT_TUI_WIDTH, DEFAULT_TUI_HEIGHT);
+            expect_screen(
+                screen,
+                &front_buffer,
+                DEFAULT_TUI_WIDTH,
+                DEFAULT_TUI_HEIGHT,
+                Location::caller(),
+            );
+        },
+    );
+}
+
+#[test]
+fn test_privmsg_targetmask_issue_278() {
+    run_test(
+        "osa1".to_owned(),
+        |TestSetup {
+             tui,
+             snd_input_ev,
+             snd_conn_ev,
+         }| async move {
+            next_tab(&snd_input_ev).await;
+            snd_conn_ev.send(client::Event::Connected).await.unwrap();
+            snd_conn_ev
+                .send(client::Event::NickChange {
+                    new_nick: "osa1".to_owned(),
+                })
+                .await
+                .unwrap();
+
+            snd_conn_ev
+                .send(client::Event::Msg(Msg {
+                    pfx: Some(Pfx::User {
+                        nick: "e".to_owned(),
+                        user: "e@a/b/c.d".to_owned(),
+                    }),
+                    cmd: Cmd::PRIVMSG {
+                        target: MsgTarget::User("$$*".to_owned()),
+                        msg: "blah blah blah".to_owned(),
+                        is_notice: true,
+                        ctcp: None,
+                    },
+                }))
+                .await
+                .unwrap();
+
+            yield_(3).await;
+
+            next_tab(&snd_input_ev).await;
+
+            tui.draw();
+
+            yield_(3).await;
+
+            #[rustfmt::skip]
+            let screen =
+            "|                                        |
+             |                                        |
+             |00:00 e: blah blah blah                 |
+             |osa1:                                   |
+             |mentions x.y.z e                        |";
+
+            let mut front_buffer = tui.get_front_buffer();
+            normalize_timestamps(&mut front_buffer, DEFAULT_TUI_WIDTH, DEFAULT_TUI_HEIGHT);
+            expect_screen(
+                screen,
+                &front_buffer,
+                DEFAULT_TUI_WIDTH,
+                DEFAULT_TUI_HEIGHT,
+                Location::caller(),
+            );
+        },
+    );
+}
+
+async fn next_tab(snd_input_ev: &mpsc::Sender<input::Event>) {
+    snd_input_ev
+        .send(term_input::Event::Key(term_input::Key::Ctrl('n')))
+        .await
+        .unwrap();
+}
+
+async fn yield_(n: usize) {
+    for _ in 0..n {
+        tokio::task::yield_now().await;
+    }
+}
+
+/// Makes all timestamps 00:00
+fn normalize_timestamps(cells: &mut CellBuf, w: u16, h: u16) {
+    let cells = &mut cells.cells;
+    for y in 0..h {
+        let x = (w * y) as usize;
+        if cells[x].ch.is_ascii_digit()
+            && cells[x + 1].ch.is_ascii_digit()
+            && cells[x + 2].ch == ':'
+            && cells[x + 3].ch.is_ascii_digit()
+            && cells[x + 4].ch.is_ascii_digit()
+        {
+            cells[x].ch = '0';
+            cells[x + 1].ch = '0';
+            cells[x + 2].ch = ':';
+            cells[x + 3].ch = '0';
+            cells[x + 4].ch = '0';
+        }
+    }
+}