about summary refs log tree commit homepage
path: root/test
diff options
context:
space:
mode:
authorEric Wong <normalperson@yhbt.net>2013-08-24 09:54:45 +0000
committerEric Wong <normalperson@yhbt.net>2013-08-24 09:54:45 +0000
commit3e09ac0c10c95bb24a08af62393b4f761e2743d0 (patch)
tree778dffa2ba8798503fc047db0feef6d65426ea22 /test
downloaddtas-3e09ac0c10c95bb24a08af62393b4f761e2743d0.tar.gz
Diffstat (limited to 'test')
-rw-r--r--test/covshow.rb30
-rw-r--r--test/helper.rb76
-rw-r--r--test/player_integration.rb121
-rw-r--r--test/test_buffer.rb216
-rw-r--r--test/test_format.rb61
-rw-r--r--test/test_format_change.rb49
-rw-r--r--test/test_player.rb37
-rw-r--r--test/test_player_client_handler.rb86
-rw-r--r--test/test_player_integration.rb199
-rw-r--r--test/test_rg_integration.rb117
-rw-r--r--test/test_rg_state.rb32
-rw-r--r--test/test_sink.rb32
-rw-r--r--test/test_sink_reader_play.rb49
-rw-r--r--test/test_sink_tee_integration.rb34
-rw-r--r--test/test_source.rb102
-rw-r--r--test/test_unixserver.rb66
-rw-r--r--test/test_util.rb15
17 files changed, 1322 insertions, 0 deletions
diff --git a/test/covshow.rb b/test/covshow.rb
new file mode 100644
index 0000000..e50c368
--- /dev/null
+++ b/test/covshow.rb
@@ -0,0 +1,30 @@
+# -*- encoding: binary -*-
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+#
+# this works with the __covmerge method in test/helper.rb
+# run this file after all tests are run
+
+# load the merged dump data
+res = Marshal.load(IO.binread("coverage.dump"))
+
+# Dirty little text formatter.  I tried simplecov but the default
+# HTML+JS is unusable without a GUI (I hate GUIs :P) and it would've
+# taken me longer to search the Internets to find a plain-text
+# formatter I like...
+res.keys.sort.each do |filename|
+  cov = res[filename]
+  puts "==> #{filename} <=="
+  File.readlines(filename).each_with_index do |line, i|
+    n = cov[i]
+    if n == 0 # BAD
+      print("  *** 0 #{line}")
+    elsif n
+      printf("% 7u %s", n, line)
+    elsif line =~ /\S/ # probably a line with just "end" in it
+      print("        #{line}")
+    else # blank line
+      print "\n" # don't output trailing whitespace on blank lines
+    end
+  end
+end
diff --git a/test/helper.rb b/test/helper.rb
new file mode 100644
index 0000000..e4643ae
--- /dev/null
+++ b/test/helper.rb
@@ -0,0 +1,76 @@
+# -*- encoding: binary -*-
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+$stdout.sync = $stderr.sync = Thread.abort_on_exception = true
+
+# fork-aware coverage data gatherer, see also test/covshow.rb
+if ENV["COVERAGE"]
+  require "coverage"
+  COVMATCH = %r{/lib/dtas\b.*rb\z}
+  COVTMP = File.open("coverage.dump", IO::CREAT|IO::RDWR)
+  COVTMP.binmode
+  COVTMP.sync = true
+
+  def __covmerge
+    res = Coverage.result
+
+    # we own this file (at least until somebody tries to use NFS :x)
+    COVTMP.flock(File::LOCK_EX)
+
+    COVTMP.rewind
+    prev = COVTMP.read
+    prev = prev.empty? ? {} : Marshal.load(prev)
+    res.each do |filename, counts|
+      # filter out stuff that's not in our project
+      COVMATCH =~ filename or next
+
+      merge = prev[filename] || []
+      merge = merge
+      counts.each_with_index do |count, i|
+        count or next
+        merge[i] = (merge[i] || 0) + count
+      end
+      prev[filename] = merge
+    end
+    COVTMP.rewind
+    COVTMP.truncate(0)
+    COVTMP.write(Marshal.dump(prev))
+  ensure
+    COVTMP.flock(File::LOCK_UN)
+  end
+
+  Coverage.start
+  at_exit { __covmerge }
+end
+
+gem 'minitest'
+require 'minitest/autorun'
+require "tempfile"
+
+FIFOS = []
+at_exit { FIFOS.each { |(pid,path)| File.unlink(path) if $$ == pid } }
+def tmpfifo
+  tmp = Tempfile.new(%w(dtas-test .fifo))
+  path = tmp.path
+  tmp.close!
+  assert system(*%W(mkfifo #{path})), "mkfifo #{path}"
+  FIFOS << [ $$, path ]
+  path
+end
+
+require 'tmpdir'
+class Dir
+  require 'fileutils'
+  def Dir.mktmpdir
+    begin
+      d = "#{Dir.tmpdir}/#$$.#{rand}"
+      Dir.mkdir(d)
+    rescue Errno::EEXIST
+    end while true
+    begin
+      yield d
+    ensure
+      FileUtils.remove_entry(d)
+    end
+  end
+end unless Dir.respond_to?(:mktmpdir)
diff --git a/test/player_integration.rb b/test/player_integration.rb
new file mode 100644
index 0000000..6580194
--- /dev/null
+++ b/test/player_integration.rb
@@ -0,0 +1,121 @@
+# -*- encoding: binary -*-
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require './test/helper'
+require 'dtas/player'
+require 'dtas/state_file'
+require 'yaml'
+require 'tempfile'
+require 'shellwords'
+require 'timeout'
+
+module PlayerIntegration
+  def setup
+    sock_tmp = Tempfile.new(%w(dtas-test .sock))
+    @state_tmp = Tempfile.new(%w(dtas-test .yml))
+    @sock_path = sock_tmp.path
+    sock_tmp.close!
+    @player = DTAS::Player.new
+    @player.socket = @sock_path
+    @player.state_file = DTAS::StateFile.new(@state_tmp.path)
+    @player.bind
+    @out = Tempfile.new(%w(dtas-test .out))
+    @err = Tempfile.new(%w(dtas-test .err))
+    @out.sync = @err.sync = true
+    @pid = fork do
+      at_exit { @player.close }
+      ENV["SOX_OPTS"] = "#{ENV['SOX_OPTS']} -R"
+      unless $DEBUG
+        $stdout.reopen(@out)
+        $stderr.reopen(@err)
+      end
+      @player.run
+    end
+
+    # null playback device with delay to simulate a real device
+    @fmt = DTAS::Format.new
+    @period = 0.01
+    @period_size = @fmt.bytes_per_sample * @fmt.channels * @fmt.rate * @period
+    @cmd = "exec 2>/dev/null " \
+           "ruby -e " \
+           "\"b=%q();loop{STDIN.readpartial(#@period_size,b);sleep(#@period)}\""
+
+    # FIXME gross...
+    @player.instance_eval do
+      @sink_buf.close!
+    end
+  end
+
+  module PlayerClient
+    def preq(args)
+      args = Shellwords.join(args) if Array === args
+      send(args, Socket::MSG_EOR)
+    end
+  end
+
+  def client_socket
+    s = Socket.new(:AF_UNIX, :SOCK_SEQPACKET, 0)
+    s.connect(Socket.pack_sockaddr_un(@sock_path))
+    s.extend(PlayerClient)
+    s
+  end
+
+  def wait_pid_dead(pid, time = 5)
+    Timeout.timeout(time) do
+      begin
+        Process.kill(0, pid)
+        sleep(0.01)
+      rescue Errno::ESRCH
+        return
+      end while true
+    end
+  end
+
+  def wait_files_not_empty(*files)
+    files = Array(files)
+    Timeout.timeout(5) { sleep(0.01) until files.all? { |f| f.size > 0 } }
+  end
+
+  def default_sink_pid(s)
+    default_pid = Tempfile.new(%w(dtas-test .pid))
+    pf = "echo $$ >> #{default_pid.path}; "
+    s.send("sink ed default command='#{pf}#@cmd'", Socket::MSG_EOR)
+    assert_equal "OK", s.readpartial(666)
+    default_pid
+  end
+
+  def teardown
+    Process.kill(:TERM, @pid) if @pid
+    Process.waitall
+    refute File.exist?(@sock_path)
+    @state_tmp.close!
+    @out.close! if @out
+    @err.close! if @err
+  end
+
+  def read_pid_file(file)
+    file.rewind
+    pid = file.read.to_i
+    assert_operator pid, :>, 0
+    pid
+  end
+
+  def tmp_noise(len = 5)
+    noise = Tempfile.open(%w(junk .sox))
+    cmd = %W(sox -R -n -r44100 -c2 #{noise.path} synth #{len} pluck)
+    assert system(*cmd), cmd
+    [ noise, len ]
+  end
+
+  def dethrottle_decoder(s)
+    s.send("sink ed default active=false", Socket::MSG_EOR)
+    assert_equal "OK", s.readpartial(666)
+  end
+
+  def stop_playback(pid_file, s)
+    s.send("skip", Socket::MSG_EOR)
+    assert_equal "OK", s.readpartial(666)
+    pid = read_pid_file(pid_file)
+    wait_pid_dead(pid)
+  end
+end
diff --git a/test/test_buffer.rb b/test/test_buffer.rb
new file mode 100644
index 0000000..32ae986
--- /dev/null
+++ b/test/test_buffer.rb
@@ -0,0 +1,216 @@
+# -*- encoding: binary -*-
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require './test/helper'
+require 'stringio'
+require 'dtas/buffer'
+
+class TestBuffer < Minitest::Unit::TestCase
+  def teardown
+    @to_close.each { |io| io.close unless io.closed? }
+  end
+
+  def setup
+    @to_close = []
+  end
+
+  def pipe
+    ret = IO.pipe
+    @to_close.concat(ret)
+    ret
+  end
+
+  def tmperr
+    olderr = $stderr
+    $stderr = newerr = StringIO.new
+    yield
+    newerr
+  ensure
+    $stderr = olderr
+  end
+
+  def new_buffer
+    buf = DTAS::Buffer.new
+    @to_close << buf.to_io
+    @to_close << buf.wr
+    buf
+  end
+
+  def test_set_buffer_size
+    buf = new_buffer
+    buf.buffer_size = DTAS::Buffer::MAX_SIZE
+    assert_equal DTAS::Buffer::MAX_SIZE, buf.buffer_size
+  end if defined?(DTAS::Buffer::MAX_SIZE)
+
+  def test_buffer_size
+    buf = new_buffer
+    assert_operator buf.buffer_size, :>, 128
+    buf.buffer_size = DTAS::Buffer::MAX_SIZE
+    assert_equal DTAS::Buffer::MAX_SIZE, buf.buffer_size
+  end if defined?(DTAS::Buffer::MAX_SIZE)
+
+  def test_broadcast_1
+    buf = new_buffer
+    r, w = IO.pipe
+    assert_equal :wait_readable, buf.broadcast([w])
+    assert_equal 0, buf.bytes_xfer
+    buf.wr.write "HIHI"
+    assert_equal :wait_readable, buf.broadcast([w])
+    assert_equal 4, buf.bytes_xfer
+    assert_equal :wait_readable, buf.broadcast([w])
+    assert_equal 4, buf.bytes_xfer
+    tmp = [w]
+    r.close
+    buf.wr.write "HIHI"
+    newerr = tmperr { assert_nil buf.broadcast(tmp) }
+    assert_equal [], tmp
+    assert_match(%r{dropping}, newerr.string)
+  end
+
+  def test_broadcast_tee
+    buf = new_buffer
+    return unless buf.respond_to?(:__broadcast_tee)
+    blocked = []
+    a = pipe
+    b = pipe
+    buf.wr.write "HELLO"
+    assert_equal 4, buf.__broadcast_tee(blocked, [a[1], b[1]], 4)
+    assert_empty blocked
+    assert_equal "HELL", a[0].read(4)
+    assert_equal "HELL", b[0].read(4)
+    assert_equal 5, buf.__broadcast_tee(blocked, [a[1], b[1]], 5)
+    assert_empty blocked
+    assert_equal "HELLO", a[0].read(5)
+    assert_equal "HELLO", b[0].read(5)
+    max = '*' * a[0].pipe_size
+    assert_equal max.size, a[1].write(max)
+    assert_equal a[0].nread, a[0].pipe_size
+    a[1].nonblock = true
+    assert_equal 5, buf.__broadcast_tee(blocked, [a[1], b[1]], 5)
+    assert_equal [a[1]], blocked
+    a[1].nonblock = false
+    b[0].read(b[0].nread)
+    b[1].write(max)
+    t = Thread.new do
+      sleep 0.005
+      [ a[0].read(max.size).size, b[0].read(max.size).size ]
+    end
+    assert_equal 5, buf.__broadcast_tee(blocked, [a[1], b[1]], 5)
+    assert_equal [a[1]], blocked
+    assert_equal [ max.size, max.size ], t.value
+    b[0].close
+    tmp = [a[1], b[1]]
+
+    newerr = tmperr { assert_equal 5, buf.__broadcast_tee(blocked, tmp, 5) }
+    assert_equal [a[1]], blocked
+    assert_match(%r{dropping}, newerr.string)
+    assert_equal [a[1]], tmp
+  end
+
+  def test_broadcast
+    a = pipe
+    b = pipe
+    buf = new_buffer
+    buf.wr.write "HELLO"
+    assert_equal :wait_readable, buf.broadcast([a[1], b[1]])
+    assert_equal 5, buf.bytes_xfer
+    assert_equal "HELLO", a[0].read(5)
+    assert_equal "HELLO", b[0].read(5)
+    assert_equal :wait_readable, buf.broadcast([a[1], b[1]])
+    assert_equal 5, buf.bytes_xfer
+
+    b[1].nonblock = true
+    b[1].write('*' * b[1].pipe_size)
+    buf.wr.write "BYE"
+    assert_equal :wait_readable, buf.broadcast([a[1], b[1]])
+    assert_equal 8, buf.bytes_xfer
+
+    buf.wr.write "DROP"
+    b[0].close
+    tmp = [a[1], b[1]]
+    newerr = tmperr { assert_equal :wait_readable, buf.broadcast(tmp) }
+    assert_equal 12, buf.bytes_xfer
+    assert_equal [a[1]], tmp
+    assert_match(%r{dropping}, newerr.string)
+  end
+
+  def test_broadcast_total_fail
+    a = pipe
+    b = pipe
+    buf = new_buffer
+    buf.wr.write "HELLO"
+    a[0].close
+    b[0].close
+    tmp = [a[1], b[1]]
+    newerr = tmperr { assert_nil buf.broadcast(tmp) }
+    assert_equal [], tmp
+    assert_match(%r{dropping}, newerr.string)
+  end
+
+  def test_broadcast_mostly_fail
+    a = pipe
+    b = pipe
+    c = pipe
+    buf = new_buffer
+    buf.wr.write "HELLO"
+    b[0].close
+    c[0].close
+    tmp = [a[1], b[1], c[1]]
+    newerr = tmperr { assert_equal :wait_readable, buf.broadcast(tmp) }
+    assert_equal 5, buf.bytes_xfer
+    assert_equal [a[1]], tmp
+    assert_match(%r{dropping}, newerr.string)
+  end
+
+  def test_broadcast_all_full
+    a = pipe
+    b = pipe
+    buf = new_buffer
+    a[1].write('*' * a[1].pipe_size)
+    b[1].write('*' * b[1].pipe_size)
+
+    a[1].nonblock = true
+    b[1].nonblock = true
+    tmp = [a[1], b[1]]
+
+    buf.wr.write "HELLO"
+    assert_equal tmp, buf.broadcast(tmp)
+    assert_equal [a[1], b[1]], tmp
+  end
+
+  def test_serialize
+    buf = new_buffer
+    hash = buf.to_hsh
+    assert_empty hash
+    buf.buffer_size = 4096
+    hash = buf.to_hsh
+    assert_equal %w(buffer_size), hash.keys
+    assert_kind_of Integer, hash["buffer_size"]
+    assert_operator hash["buffer_size"], :>, 0
+  end
+
+  def test_close
+    buf = DTAS::Buffer.new
+    buf.wr.write "HI"
+    assert_equal 2, buf.inflight
+    buf.close
+    assert_equal 0, buf.inflight
+    assert_nil buf.close!
+  end
+
+  def test_load_nil
+    buf = DTAS::Buffer.load(nil)
+    buf.close!
+  end
+
+  def test_load_empty
+    buf = DTAS::Buffer.load({})
+    buf.close!
+  end
+
+  def test_load_size
+    buf = DTAS::Buffer.load({"buffer_size" => 4096})
+    assert_equal 4096, buf.buffer_size
+    buf.close!
+  end
+end
diff --git a/test/test_format.rb b/test/test_format.rb
new file mode 100644
index 0000000..ba039a7
--- /dev/null
+++ b/test/test_format.rb
@@ -0,0 +1,61 @@
+# -*- encoding: binary -*-
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require './test/helper'
+require 'tempfile'
+require 'dtas/format'
+
+class TestFormat < Minitest::Unit::TestCase
+  def test_initialize
+    fmt = DTAS::Format.new
+    assert_equal %w(-ts32 -c2 -r44100), fmt.to_sox_arg
+    hash = fmt.to_hsh
+    assert_equal({}, hash)
+  end
+
+  def test_nonstandard
+    fmt = DTAS::Format.new
+    fmt.type = "s16"
+    fmt.rate = 48000
+    fmt.channels = 4
+    hash = fmt.to_hsh
+    assert_kind_of Hash, hash
+    assert_equal %w(channels rate type), hash.keys.sort
+    assert_equal "s16", hash["type"]
+    assert_equal 48000, hash["rate"]
+    assert_equal 4, hash["channels"]
+
+    # back to stereo
+    fmt.channels = 2
+    hash = fmt.to_hsh
+    assert_equal %w(rate type), hash.keys.sort
+    assert_equal "s16", hash["type"]
+    assert_equal 48000, hash["rate"]
+    assert_nil hash["channels"]
+  end
+
+  def test_from_file
+    Tempfile.open(%w(tmp .wav)) do |tmp|
+      # generate an empty file with 1s of audio
+      cmd = %W(sox -r 96000 -b 24 -c 2 -n #{tmp.path} trim 0 1)
+      system(*cmd)
+      assert $?.success?, "#{cmd.inspect} failed: #$?"
+      fmt = DTAS::Format.new
+      fmt.from_file tmp.path
+      assert_equal 96000, fmt.rate
+      assert_equal 2, fmt.channels
+      tmp.unlink
+    end
+  end
+
+  def test_bytes_per_sample
+    fmt = DTAS::Format.new
+    assert_equal 4, fmt.bytes_per_sample
+    fmt.type = "f64"
+    assert_equal 8, fmt.bytes_per_sample
+    fmt.type = "f32"
+    assert_equal 4, fmt.bytes_per_sample
+    fmt.type = "s16"
+    assert_equal 2, fmt.bytes_per_sample
+  end
+end
diff --git a/test/test_format_change.rb b/test/test_format_change.rb
new file mode 100644
index 0000000..ed0e7a2
--- /dev/null
+++ b/test/test_format_change.rb
@@ -0,0 +1,49 @@
+# -*- encoding: binary -*-
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require './test/player_integration'
+require 'tmpdir'
+class TestFormatChange < Minitest::Unit::TestCase
+  include PlayerIntegration
+
+  def test_format_change
+    s = client_socket
+    default_pid = default_sink_pid(s)
+    Dir.mktmpdir do |dir|
+      d = "#{dir}/dump.$CHANNELS.$RATE"
+      f44100 = File.open("#{dir}/dump.2.44100", IO::RDWR|IO::CREAT)
+      f88200 = File.open("#{dir}/dump.2.88200", IO::RDWR|IO::CREAT)
+      s.preq("sink ed dump active=true command='cat > #{d}'")
+      assert_equal "OK", s.readpartial(666)
+      noise, len = tmp_noise
+      s.preq(%W(enq #{noise.path}))
+      assert_equal "OK", s.readpartial(666)
+      wait_files_not_empty(default_pid, f44100)
+
+      s.preq("format rate=88200")
+      assert_equal "OK", s.readpartial(666)
+
+      wait_files_not_empty(f88200)
+
+      dethrottle_decoder(s)
+
+      Timeout.timeout(len) do
+        begin
+          s.preq("current")
+          cur = YAML.load(s.readpartial(6666))
+        end while cur["sinks"] && sleep(0.01)
+      end
+
+      c = "sox -R -ts32 -c2 -r88200 #{dir}/dump.2.88200 " \
+          "-ts32 -c2 -r44100 #{dir}/part2"
+      assert(system(c), c)
+
+      c = "sox -R -ts32 -c2 -r44100 #{dir}/dump.2.44100 " \
+          "-ts32 -c2 -r44100 #{dir}/part2 #{dir}/res.sox"
+      assert(system(c), c)
+
+      assert_equal `soxi -s #{dir}/res.sox`, `soxi -s #{noise.path}`
+      File.unlink(*Dir["#{dir}/*"].to_a)
+    end
+  end
+end
diff --git a/test/test_player.rb b/test/test_player.rb
new file mode 100644
index 0000000..18a8a9e
--- /dev/null
+++ b/test/test_player.rb
@@ -0,0 +1,37 @@
+# -*- encoding: binary -*-
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require './test/helper'
+require 'tempfile'
+require 'dtas/player'
+
+class TestPlayer < Minitest::Unit::TestCase
+  def setup
+    @player = nil
+    tmp = Tempfile.new(%w(dtas-player-test .sock))
+    @path = tmp.path
+    File.unlink(@path)
+  end
+
+  def teardown
+    @player.close if @player
+  end
+
+  def test_player_new
+    player = DTAS::Player.new
+    player.socket = @path
+    player.bind
+    assert File.socket?(@path)
+  ensure
+    player.close
+    refute File.socket?(@path)
+  end
+
+  def test_player_serialize
+    @player = DTAS::Player.new
+    @player.socket = @path
+    @player.bind
+    hash = @player.to_hsh
+    assert_equal({"socket" => @path}, hash)
+  end
+end
diff --git a/test/test_player_client_handler.rb b/test/test_player_client_handler.rb
new file mode 100644
index 0000000..9324101
--- /dev/null
+++ b/test/test_player_client_handler.rb
@@ -0,0 +1,86 @@
+# -*- encoding: binary -*-
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require './test/helper'
+require 'dtas/player'
+
+class TestPlayerClientHandler < Minitest::Unit::TestCase
+  class MockIO < Array
+    alias emit push
+  end
+
+  include DTAS::Player::ClientHandler
+
+  def setup
+    @sinks = {}
+    @io = MockIO.new
+    @srv = nil # unused mock
+  end
+
+  def test_delete
+    @sinks["default"] = DTAS::Sink.new
+    @targets = []
+    sink_handler(@io, %w(rm default))
+    assert @sinks.empty?
+    assert_equal %w(OK), @io.to_a
+  end
+
+  def test_delete_noexist
+    sink_handler(@io, %w(rm default))
+    assert @sinks.empty?
+    assert_equal ["ERR default not found"], @io.to_a
+  end
+
+  def test_env
+    sink_handler(@io, %w(ed default env.FOO=bar))
+    assert_equal "bar", @sinks["default"].env["FOO"]
+    sink_handler(@io, %w(ed default env.FOO=))
+    assert_equal "", @sinks["default"].env["FOO"]
+    sink_handler(@io, %w(ed default env#FOO))
+    assert_nil @sinks["default"].env["FOO"]
+  end
+
+  def test_sink_ed
+    command = 'sox -t $SOX_FILETYPE -r $RATE -c $CHANNELS - \
+      -t s$SINK_BITS -r $SINK_RATE -c $SINK_CHANNELS - | \
+    aplay -D hw:DAC_1 -v -q -M --buffer-size=500000 --period-size=500 \
+      --disable-softvol --start-delay=100 \
+      --disable-format --disable-resample --disable-channels \
+      -t raw -c $SINK_CHANNELS -f S${SINK_BITS}_3LE -r $SINK_RATE
+    '
+    sink_handler(@io, %W(ed foo command=#{command}))
+    assert_equal command, @sinks["foo"].command
+    assert_empty @sinks["foo"].env
+    sink_handler(@io, %W(ed foo env.SINK_BITS=24))
+    sink_handler(@io, %W(ed foo env.SINK_CHANNELS=2))
+    sink_handler(@io, %W(ed foo env.SINK_RATE=48000))
+    expect = {
+      "SINK_BITS" => "24",
+      "SINK_CHANNELS" => "2",
+      "SINK_RATE" => "48000",
+    }
+    assert_equal expect, @sinks["foo"].env
+    @io.all? { |s| assert_equal "OK", s }
+    assert_equal 4, @io.size
+  end
+
+  def test_cat
+    sink = DTAS::Sink.new
+    sink.name = "default"
+    sink.command += "dither -s"
+    @sinks["default"] = sink
+    sink_handler(@io, %W(cat default))
+    assert_equal 1, @io.size
+    hsh = YAML.load(@io[0])
+    assert_kind_of Hash, hsh
+    assert_equal "default", hsh["name"]
+    assert_match("dither -s", hsh["command"])
+  end
+
+  def test_ls
+    expect = %w(a b c d)
+    expect.each { |s| @sinks[s] = true }
+    sink_handler(@io, %W(ls))
+    assert_equal expect, Shellwords.split(@io[0])
+  end
+end
diff --git a/test/test_player_integration.rb b/test/test_player_integration.rb
new file mode 100644
index 0000000..f7b1306
--- /dev/null
+++ b/test/test_player_integration.rb
@@ -0,0 +1,199 @@
+# -*- encoding: binary -*-
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require './test/player_integration'
+class TestPlayerIntegration < Minitest::Unit::TestCase
+  include PlayerIntegration
+
+  def test_cmd_rate
+    pid = fork do
+      @fmt.to_env.each { |k,v| ENV[k] = v }
+      exec("sox -n $SOXFMT - synth 3 pinknoise | #@cmd")
+    end
+    t = Time.now
+    _, _ = Process.waitpid2(pid)
+    elapsed = Time.now - t
+    assert_in_delta 3.0, elapsed, 0.5
+  end if ENV["MATH_IS_HARD"] # ensure our @cmd timing is accurate
+
+  def test_sink_close_after_play
+    s = client_socket
+    @cmd = "cat >/dev/null"
+    default_pid = default_sink_pid(s)
+    Tempfile.open('junk') do |junk|
+      pink = "sox -n $SOXFMT - synth 0.0001 pinknoise | tee -i #{junk.path}"
+      s.send("enq-cmd \"#{pink}\"", Socket::MSG_EOR)
+      wait_files_not_empty(junk)
+      assert_equal "OK", s.readpartial(666)
+    end
+    wait_files_not_empty(default_pid)
+    pid = read_pid_file(default_pid)
+    wait_pid_dead(pid)
+  end
+
+  def test_sink_killed_during_play
+    s = client_socket
+    default_pid = default_sink_pid(s)
+    cmd = Tempfile.new(%w(sox-cmd .pid))
+    pink = "echo $$ > #{cmd.path}; sox -n $SOXFMT - synth 100 pinknoise"
+    s.send("enq-cmd \"#{pink}\"", Socket::MSG_EOR)
+    assert_equal "OK", s.readpartial(666)
+    wait_files_not_empty(cmd, default_pid)
+    pid = read_pid_file(default_pid)
+    Process.kill(:KILL, pid)
+    cmd_pid = read_pid_file(cmd)
+    wait_pid_dead(cmd_pid)
+  end
+
+  def test_sink_activate
+    s = client_socket
+    s.send("sink ls", Socket::MSG_EOR)
+    assert_equal "default", s.readpartial(666)
+
+    # setup two outputs
+
+    # make the default sink trickle
+    default_pid = Tempfile.new(%w(dtas-test .pid))
+    pf = "echo $$ >> #{default_pid.path}; "
+    s.send("sink ed default command='#{pf}#@cmd'", Socket::MSG_EOR)
+    assert_equal "OK", s.readpartial(666)
+
+    # make a sleepy sink trickle, too
+    sleepy_pid = Tempfile.new(%w(dtas-test .pid))
+    pf = "echo $$ >> #{sleepy_pid.path};"
+    s.send("sink ed sleepy command='#{pf}#@cmd' active=true", Socket::MSG_EOR)
+    assert_equal "OK", s.readpartial(666)
+
+    # ensure both sinks were created
+    s.send("sink ls", Socket::MSG_EOR)
+    assert_equal "default sleepy", s.readpartial(666)
+
+    # generate pinknoise
+    pinknoise = "sox -n -r 44100 -c 2 -t s32 - synth 0 pinknoise"
+    s.send("enq-cmd \"#{pinknoise}\"", Socket::MSG_EOR)
+    assert_equal "OK", s.readpartial(666)
+
+    # wait for sinks to start
+    wait_files_not_empty(sleepy_pid, default_pid)
+
+    # deactivate sleepy sink and ensure it's gone
+    sleepy = File.read(sleepy_pid).to_i
+    assert_operator sleepy, :>, 0
+    Process.kill(0, sleepy)
+    s.send("sink ed sleepy active=false", Socket::MSG_EOR)
+    assert_equal "OK", s.readpartial(666)
+    wait_pid_dead(sleepy)
+
+    # ensure default sink is still alive
+    default = File.read(default_pid).to_i
+    assert_operator default, :>, 0
+    Process.kill(0, default)
+
+    # restart sleepy sink
+    sleepy_pid.sync = true
+    sleepy_pid.seek(0)
+    sleepy_pid.truncate(0)
+    s.send("sink ed sleepy active=true", Socket::MSG_EOR)
+    assert_equal "OK", s.readpartial(666)
+
+    # wait for sleepy sink
+    wait_files_not_empty(sleepy_pid)
+
+    # check sleepy restarted
+    sleepy = File.read(sleepy_pid).to_i
+    assert_operator sleepy, :>, 0
+    Process.kill(0, sleepy)
+
+    # stop playing current track
+    s.send("skip", Socket::MSG_EOR)
+    assert_equal "OK", s.readpartial(666)
+
+    wait_pid_dead(sleepy)
+    wait_pid_dead(default)
+  end
+
+  def test_env_change
+    s = client_socket
+    tmp = Tempfile.new(%w(env .txt))
+    s.preq("sink ed default active=true command='cat >/dev/null'")
+    assert_equal "OK", s.readpartial(666)
+
+    s.preq("env FOO=BAR")
+    assert_equal "OK", s.readpartial(666)
+    s.preq(["enq-cmd", "echo $FOO | tee #{tmp.path}"])
+    assert_equal "OK", s.readpartial(666)
+    wait_files_not_empty(tmp)
+    assert_equal "BAR\n", tmp.read
+
+    tmp.rewind
+    tmp.truncate(0)
+    s.preq("env FOO#")
+    assert_equal "OK", s.readpartial(666)
+    s.preq(["enq-cmd", "echo -$FOO- | tee #{tmp.path}"])
+    assert_equal "OK", s.readpartial(666)
+    wait_files_not_empty(tmp)
+    assert_equal "--\n", tmp.read
+  end
+
+  def test_sink_env
+    s = client_socket
+    tmp = Tempfile.new(%w(env .txt))
+    s.preq("sink ed default active=true command='echo -$FOO- > #{tmp.path}'")
+    assert_equal "OK", s.readpartial(666)
+
+    s.preq("sink ed default env.FOO=BAR")
+    assert_equal "OK", s.readpartial(666)
+    s.preq(["enq-cmd", "echo HI"])
+    assert_equal "OK", s.readpartial(666)
+    wait_files_not_empty(tmp)
+    assert_equal "-BAR-\n", tmp.read
+
+    tmp.rewind
+    tmp.truncate(0)
+    s.preq("sink ed default env#FOO")
+    assert_equal "OK", s.readpartial(666)
+
+    Timeout.timeout(5) do
+      begin
+        s.preq("current")
+        yaml = s.readpartial(66666)
+        cur = YAML.load(yaml)
+      end while cur["sinks"] && sleep(0.01)
+    end
+
+    s.preq(["enq-cmd", "echo HI"])
+    assert_equal "OK", s.readpartial(666)
+    wait_files_not_empty(tmp)
+    assert_equal "--\n", tmp.read
+  end
+
+  def test_enq_head
+    s = client_socket
+    default_sink_pid(s)
+    dump = Tempfile.new(%W(d .sox))
+    s.preq "sink ed dump active=true command='sox $SOXFMT - #{dump.path}'"
+    assert_equal "OK", s.readpartial(666)
+    noise, len = tmp_noise
+    s.preq("enq-head #{noise.path}")
+    assert_equal "OK", s.readpartial(666)
+    s.preq("enq-head #{noise.path} 4")
+    assert_equal "OK", s.readpartial(666)
+    s.preq("enq-head #{noise.path} 3")
+    assert_equal "OK", s.readpartial(666)
+    dethrottle_decoder(s)
+    expect = Tempfile.new(%W(expect .sox))
+
+    c = "sox #{noise.path} -t sox '|sox #{noise.path} -p trim 3' " \
+            "-t sox '|sox #{noise.path} -p trim 4' #{expect.path}"
+    assert system(c)
+    Timeout.timeout(len) do
+      begin
+        s.preq("current")
+        yaml = s.readpartial(66666)
+        cur = YAML.load(yaml)
+      end while cur["sinks"] && sleep(0.01)
+    end
+    assert(system("cmp", dump.path, expect.path),
+           "files don't match #{dump.path} != #{expect.path}")
+  end
+end
diff --git a/test/test_rg_integration.rb b/test/test_rg_integration.rb
new file mode 100644
index 0000000..d8a8b85
--- /dev/null
+++ b/test/test_rg_integration.rb
@@ -0,0 +1,117 @@
+# -*- encoding: binary -*-
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require './test/player_integration'
+class TestRgIntegration < Minitest::Unit::TestCase
+  include PlayerIntegration
+
+  def tmp_pluck(len = 5)
+    pluck = Tempfile.open(%w(pluck .flac))
+    cmd = %W(sox -R -n -r44100 -c2 -C0 #{pluck.path} synth #{len} pluck)
+    assert system(*cmd), cmd
+    cmd = %W(metaflac
+             --set-tag=REPLAYGAIN_TRACK_GAIN=-2
+             --set-tag=REPLAYGAIN_ALBUM_GAIN=-3.0
+             --set-tag=REPLAYGAIN_TRACK_PEAK=0.666
+             --set-tag=REPLAYGAIN_ALBUM_PEAK=0.999
+             #{pluck.path})
+    assert system(*cmd), cmd
+    [ pluck, len ]
+  end
+
+  def test_rg_changes_added
+    s = client_socket
+    pluck, len = tmp_pluck
+
+    # create the default sink, as well as a dumper
+    dumper = Tempfile.open(%w(dump .sox))
+    dump_pid = Tempfile.new(%w(dump .pid))
+    default_pid = default_sink_pid(s)
+    dump_cmd = "echo $$ > #{dump_pid.path}; sox $SOXFMT - #{dumper.path}"
+    s.send("sink ed dump active=true command='#{dump_cmd}'", Socket::MSG_EOR)
+    assert_equal("OK", s.readpartial(666))
+
+    # start playback!
+    s.send("enq \"#{pluck.path}\"", Socket::MSG_EOR)
+    assert_equal "OK", s.readpartial(666)
+
+    # wait for playback to start
+    yaml = cur = nil
+    Timeout.timeout(5) do
+      begin
+        s.send("current", Socket::MSG_EOR)
+        cur = YAML.load(yaml = s.readpartial(1666))
+      end while cur["current_offset"] == 0 && sleep(0.01)
+    end
+
+    assert_nil cur["current"]["env"]["RGFX"]
+
+    assert_equal DTAS::Format.new.rate * len, cur["current_expect"]
+
+    wait_files_not_empty(dump_pid)
+    pid = read_pid_file(dump_pid)
+
+    check_gain = proc do |expect, mode|
+      s.send("rg mode=#{mode}", Socket::MSG_EOR)
+      assert_equal "OK", s.readpartial(666)
+      Timeout.timeout(5) do
+        begin
+          s.send("current", Socket::MSG_EOR)
+          cur = YAML.load(yaml = s.readpartial(3666))
+        end while cur["current"]["env"]["RGFX"] !~ expect && sleep(0.01)
+      end
+      assert_match expect, cur["current"]["env"]["RGFX"]
+    end
+
+    check_gain.call(%r{vol -3dB}, "album_gain")
+    check_gain.call(%r{vol -2dB}, "track_gain")
+    check_gain.call(%r{vol 1\.3}, "track_peak")
+    check_gain.call(%r{vol 1\.0}, "album_peak")
+
+    s.send("rg preamp+=1", Socket::MSG_EOR)
+    assert_equal "OK", s.readpartial(666)
+    s.send("rg", Socket::MSG_EOR)
+    rg = YAML.load(yaml = s.readpartial(3666))
+    assert_equal 1, rg["preamp"]
+
+    s.send("rg preamp-=1", Socket::MSG_EOR)
+    assert_equal "OK", s.readpartial(666)
+    s.send("rg", Socket::MSG_EOR)
+    rg = YAML.load(yaml = s.readpartial(3666))
+    assert_nil rg["preamp"]
+
+    s.send("rg preamp=2", Socket::MSG_EOR)
+    assert_equal "OK", s.readpartial(666)
+    s.send("rg", Socket::MSG_EOR)
+    rg = YAML.load(yaml = s.readpartial(3666))
+    assert_equal 2, rg["preamp"]
+
+    s.send("rg preamp-=0.3", Socket::MSG_EOR)
+    assert_equal "OK", s.readpartial(666)
+    s.send("rg", Socket::MSG_EOR)
+    rg = YAML.load(yaml = s.readpartial(3666))
+    assert_equal 1.7, rg["preamp"]
+
+    s.send("rg preamp-=-0.3", Socket::MSG_EOR)
+    assert_equal "OK", s.readpartial(666)
+    s.send("rg", Socket::MSG_EOR)
+    rg = YAML.load(yaml = s.readpartial(3666))
+    assert_equal 2.0, rg["preamp"]
+
+    s.send("rg preamp-=+0.3", Socket::MSG_EOR)
+    assert_equal "OK", s.readpartial(666)
+    s.send("rg", Socket::MSG_EOR)
+    rg = YAML.load(yaml = s.readpartial(3666))
+    assert_equal 1.7, rg["preamp"]
+
+    dethrottle_decoder(s)
+
+    # ensure we did not change audio length
+    wait_pid_dead(pid, len)
+    samples = `soxi -s #{dumper.path}`.to_i
+    assert_equal cur["current_expect"], samples
+    assert_equal `soxi -d #{dumper.path}`, `soxi -d #{pluck.path}`
+
+    stop_playback(default_pid, s)
+  end
+end
diff --git a/test/test_rg_state.rb b/test/test_rg_state.rb
new file mode 100644
index 0000000..f591ac8
--- /dev/null
+++ b/test/test_rg_state.rb
@@ -0,0 +1,32 @@
+# -*- encoding: binary -*-
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require './test/helper'
+require 'dtas/rg_state'
+
+class TestRGState < Minitest::Unit::TestCase
+
+  def test_rg_state
+    rg = DTAS::RGState.new
+    assert_equal({}, rg.to_hsh)
+    rg.preamp = 0.1
+    assert_equal({"preamp" => 0.1}, rg.to_hsh)
+    rg.preamp = 0
+    assert_equal({}, rg.to_hsh)
+  end
+
+  def test_load
+    rg = DTAS::RGState.load("preamp" => 0.666)
+    assert_equal({"preamp" => 0.666}, rg.to_hsh)
+  end
+
+  def test_mode_set
+    rg = DTAS::RGState.new
+    orig = rg.mode
+    assert_equal DTAS::RGState::RG_DEFAULT["mode"], orig
+    %w(album_gain track_gain album_peak track_peak).each do |t|
+      rg.mode = t
+      assert_equal t, rg.mode
+    end
+  end
+end
diff --git a/test/test_sink.rb b/test/test_sink.rb
new file mode 100644
index 0000000..959bbf0
--- /dev/null
+++ b/test/test_sink.rb
@@ -0,0 +1,32 @@
+# -*- encoding: binary -*-
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require './test/helper'
+require 'dtas/sink'
+require 'yaml'
+
+class TestSink < Minitest::Unit::TestCase
+  def test_serialize_reload
+    sink = DTAS::Sink.new
+    sink.name = "DAC"
+    hash = sink.to_hsh
+    assert_kind_of Hash, hash
+    refute_match(%r{ruby}i, hash.to_yaml, "ruby guts exposed: #{hash}")
+
+    s2 = DTAS::Sink.load(hash)
+    assert_equal sink.to_hsh, s2.to_hsh
+    assert_equal hash, s2.to_hsh
+  end
+
+  def test_name
+    sink = DTAS::Sink.new
+    sink.name = "dac1"
+    assert_equal({"name" => "dac1"}, sink.to_hsh)
+  end
+
+  def test_inactive_load
+    orig = { "active" => false }.freeze
+    tmp = orig.to_yaml
+    assert_equal orig, YAML.load(tmp)
+  end
+end
diff --git a/test/test_sink_reader_play.rb b/test/test_sink_reader_play.rb
new file mode 100644
index 0000000..03aa979
--- /dev/null
+++ b/test/test_sink_reader_play.rb
@@ -0,0 +1,49 @@
+# -*- encoding: binary -*-
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require 'dtas/sink_reader_play'
+require './test/helper'
+
+class TestSinkReaderPlay < Minitest::Unit::TestCase
+  FMT = "\rIn:%-5s %s [%s] Out:%-5s [%6s|%-6s] %s Clip:%-5s"
+  ZERO = "\rIn:0.00% 00:00:00.00 [00:00:00.00] Out:0     " \
+         "[      |      ]        Clip:0    "
+
+  def setup
+    @srp = DTAS::SinkReaderPlay.new
+  end
+
+  def teardown
+    @srp.close
+  end
+
+  def test_sink_reader_play
+    @srp.wr.write(ZERO)
+    assert_equal :wait_readable, @srp.readable_iter
+    assert_equal "0", @srp.clips
+    assert_equal nil, @srp.headroom
+    assert_equal "[      |      ]", @srp.meter
+    assert_equal "0", @srp.out
+    assert_equal "00:00:00.00", @srp.time
+
+    noheadroom = sprintf(FMT, '0.00%', '00:00:37.34', '00:00:00.00',
+                         '1.65M', ' -====', '====  ', ' ' * 6, '3M')
+    @srp.wr.write(noheadroom)
+    assert_equal :wait_readable, @srp.readable_iter
+    assert_equal '3M', @srp.clips
+    assert_equal nil, @srp.headroom
+    assert_equal '[ -====|====  ]', @srp.meter
+    assert_equal '1.65M', @srp.out
+    assert_equal '00:00:37.34', @srp.time
+
+    headroom = sprintf(FMT, '0.00%', '00:00:37.43', '00:00:00.00',
+                         '1.66M', ' =====', '===== ', 'Hd:1.2', '3.1M')
+    @srp.wr.write(headroom)
+    assert_equal :wait_readable, @srp.readable_iter
+    assert_equal '3.1M', @srp.clips
+    assert_equal '1.2', @srp.headroom
+    assert_equal '[ =====|===== ]', @srp.meter
+    assert_equal '1.66M', @srp.out
+    assert_equal '00:00:37.43', @srp.time
+  end
+end
diff --git a/test/test_sink_tee_integration.rb b/test/test_sink_tee_integration.rb
new file mode 100644
index 0000000..8c20adb
--- /dev/null
+++ b/test/test_sink_tee_integration.rb
@@ -0,0 +1,34 @@
+# -*- encoding: binary -*-
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require './test/player_integration'
+class TestSinkTeeIntegration < Minitest::Unit::TestCase
+  include PlayerIntegration
+
+  def test_tee_integration
+    s = client_socket
+    default_sink_pid(s)
+    tee_pid = Tempfile.new(%w(dtas-test .pid))
+    orig = Tempfile.new(%w(orig .junk))
+    ajunk = Tempfile.new(%w(a .junk))
+    bjunk = Tempfile.new(%w(b .junk))
+    cmd = "echo $$ > #{tee_pid.path}; " \
+          "cat /dev/fd/a > #{ajunk.path} & " \
+          "cat /dev/fd/b > #{bjunk.path}; wait"
+    s.send("sink ed split active=true command='#{cmd}'", Socket::MSG_EOR)
+    assert_equal("OK", s.readpartial(666))
+    pluck = "sox -n $SOXFMT - synth 3 pluck | tee #{orig.path}"
+    s.send("enq-cmd \"#{pluck}\"", Socket::MSG_EOR)
+    assert_equal "OK", s.readpartial(666)
+
+    wait_files_not_empty(tee_pid)
+    pid = read_pid_file(tee_pid)
+    dethrottle_decoder(s)
+    wait_pid_dead(pid)
+    assert_equal ajunk.size, bjunk.size
+    assert_equal orig.size, bjunk.size
+    assert_equal ajunk.read, bjunk.read
+    bjunk.rewind
+    assert_equal orig.read, bjunk.read
+  end
+end
diff --git a/test/test_source.rb b/test/test_source.rb
new file mode 100644
index 0000000..21a56ac
--- /dev/null
+++ b/test/test_source.rb
@@ -0,0 +1,102 @@
+# -*- encoding: binary -*-
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require './test/helper'
+require 'dtas/source'
+require 'tempfile'
+
+class TestSource < Minitest::Unit::TestCase
+  def teardown
+    @tempfiles.each { |tmp| tmp.close! }
+  end
+
+  def setup
+    @tempfiles = []
+  end
+
+  def x(cmd)
+    system(*cmd)
+    assert $?.success?, cmd.inspect
+  end
+
+  def new_file(suffix)
+    tmp = Tempfile.new(%W(tmp .#{suffix}))
+    @tempfiles << tmp
+    cmd = %W(sox -r 44100 -b 16 -c 2 -n #{tmp.path} trim 0 1)
+    return tmp if system(*cmd)
+    nil
+  end
+
+  def test_flac
+    return if `which metaflac`.strip.size == 0
+    tmp = new_file('flac') or return
+
+    source = DTAS::Source.new(tmp.path)
+    x(%W(metaflac --set-tag=FOO=BAR #{tmp.path}))
+    x(%W(metaflac --add-replay-gain #{tmp.path}))
+    assert_equal source.comments["FOO"], "BAR"
+    rg = source.replaygain
+    assert_kind_of DTAS::ReplayGain, rg
+    assert_in_delta 0.0, rg.track_peak.to_f, 0.00000001
+    assert_in_delta 0.0, rg.album_peak.to_f, 0.00000001
+    assert_operator rg.album_gain.to_f, :>, 1
+    assert_operator rg.track_gain.to_f, :>, 1
+  end
+
+  def test_mp3gain
+    return if `which mp3gain`.strip.size == 0
+    a = new_file('mp3') or return
+    b = new_file('mp3') or return
+
+    source = DTAS::Source.new(a.path)
+
+    # redirect stdout to /dev/null temporarily, mp3gain is noisy
+    File.open("/dev/null", "w") do |null|
+      old_out = $stdout.dup
+      $stdout.reopen(null)
+      begin
+        x(%W(mp3gain -q #{a.path} #{b.path}))
+      ensure
+        $stdout.reopen(old_out)
+        old_out.close
+      end
+    end
+
+    rg = source.replaygain
+    assert_kind_of DTAS::ReplayGain, rg
+    assert_in_delta 0.0, rg.track_peak.to_f, 0.00000001
+    assert_in_delta 0.0, rg.album_peak.to_f, 0.00000001
+    assert_operator rg.album_gain.to_f, :>, 1
+    assert_operator rg.track_gain.to_f, :>, 1
+  end
+
+  def test_offset
+    tmp = new_file('sox') or return
+    source = DTAS::Source.new(*%W(#{tmp.path} 5s))
+    assert_equal 5, source.offset_samples
+
+    source = DTAS::Source.new(*%W(#{tmp.path} 1:00:00.5))
+    expect = 1 * 60 * 60 * 44100 + (44100/2)
+    assert_equal expect, source.offset_samples
+
+    source = DTAS::Source.new(*%W(#{tmp.path} 1:10.5))
+    expect = 1 * 60 * 44100 + (10 * 44100) + (44100/2)
+    assert_equal expect, source.offset_samples
+
+    source = DTAS::Source.new(*%W(#{tmp.path} 10.03))
+    expect = (10 * 44100) + (44100 * 3/100.0)
+    assert_equal expect, source.offset_samples
+  end
+
+  def test_offset_us
+    tmp = new_file('sox') or return
+    source = DTAS::Source.new(*%W(#{tmp.path} 441s))
+    assert_equal 10000.0, source.offset_us
+
+    source = DTAS::Source.new(*%W(#{tmp.path} 22050s))
+    assert_equal 500000.0, source.offset_us
+
+    source = DTAS::Source.new(tmp.path, '1')
+    assert_equal 1000000.0, source.offset_us
+  end
+end
diff --git a/test/test_unixserver.rb b/test/test_unixserver.rb
new file mode 100644
index 0000000..46ddb49
--- /dev/null
+++ b/test/test_unixserver.rb
@@ -0,0 +1,66 @@
+# -*- encoding: binary -*-
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require './test/helper'
+require 'tempfile'
+require 'dtas/unix_server'
+require 'stringio'
+
+class TestUNIXServer < Minitest::Unit::TestCase
+  def setup
+    @tmp = Tempfile.new(%w(dtas-unix_server-test .sock))
+    File.unlink(@tmp.path)
+    @clients = []
+    @srv = DTAS::UNIXServer.new(@tmp.path)
+  end
+
+  def test_close
+    assert File.exist?(@tmp.path)
+    assert_nil @srv.close
+    refute File.exist?(@tmp.path)
+  end
+
+  def teardown
+    @clients.each { |io| io.close unless io.closed? }
+    if File.exist?(@tmp.path)
+      @tmp.close!
+    else
+      @tmp.close
+    end
+  end
+
+  def new_client
+    c = Socket.new(:AF_UNIX, :SEQPACKET, 0)
+    @clients << c
+    c.connect(Socket.pack_sockaddr_un(@tmp.path))
+    c
+  end
+
+  def test_server_loop
+    client = new_client
+    @srv.run_once # nothing
+    msgs = []
+    clients = []
+    client.send("HELLO", Socket::MSG_EOR)
+    @srv.run_once do |c, msg|
+      clients << c
+      msgs << msg
+    end
+    assert_equal %w(HELLO), msgs, clients.inspect
+    assert_equal 1, clients.size
+    c = clients[0]
+    c.emit "HIHI"
+    assert_equal "HIHI", client.recv(4)
+
+    err = nil
+    500.times do
+      rc = c.emit "SPAM"
+      case rc
+      when StandardError
+        err = rc
+        break
+      end
+    end
+    assert_kind_of RuntimeError, err
+  end
+end
diff --git a/test/test_util.rb b/test/test_util.rb
new file mode 100644
index 0000000..9c0e218
--- /dev/null
+++ b/test/test_util.rb
@@ -0,0 +1,15 @@
+# -*- encoding: binary -*-
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require './test/helper'
+require 'dtas/util'
+
+class TestUtil < Minitest::Unit::TestCase
+  include DTAS::Util
+  def test_util
+    orig = 6.0
+    lin = db_to_linear(orig)
+    db = linear_to_db(lin)
+    assert_in_delta orig, db, 0.00000001
+  end
+end