minx 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/.gitignore +24 -0
- data/LICENSE +20 -0
- data/README.md +50 -0
- data/Rakefile +56 -0
- data/VERSION +1 -0
- data/benchmarks/ring.rb +31 -0
- data/lib/minx.rb +73 -0
- data/lib/minx/channel.rb +74 -0
- data/lib/minx/process.rb +24 -0
- data/test/channel_test.rb +50 -0
- data/test/choice_test.rb +69 -0
- data/test/helper.rb +10 -0
- data/test/process_test.rb +41 -0
- metadata +80 -0
data/.gitignore
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright (c) 2010 Daniel Schierbeck
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,50 @@
|
|
1
|
+
Minx
|
2
|
+
====
|
3
|
+
|
4
|
+
Massive and pervasive concurrency with Minx!
|
5
|
+
|
6
|
+
Minx uses the powerful concurrency primitives outlined by Tony Hoare in his
|
7
|
+
famous book "Communicating Sequential Processes".
|
8
|
+
|
9
|
+
|
10
|
+
Usage
|
11
|
+
-----
|
12
|
+
|
13
|
+
Minx lets you easily create concurrent programs using the notion of *processes*
|
14
|
+
and *channels*.
|
15
|
+
|
16
|
+
# Very contrived example...
|
17
|
+
chan = Minx.channel
|
18
|
+
|
19
|
+
Minx.spawn { chan.send("Hello, World!") }
|
20
|
+
Minx.spawn { puts chan.receive("Hello, World!") }
|
21
|
+
|
22
|
+
These primitives, although simple, are incredibly powerful. When reading from
|
23
|
+
or writing to a channel, a process yields execution -- and thus blocks until
|
24
|
+
another process also participates in the communication. An example of when
|
25
|
+
this would be useful is a simple network server:
|
26
|
+
|
27
|
+
# Create a channel for the incoming requests.
|
28
|
+
requests = Minx.channel
|
29
|
+
|
30
|
+
# Spawn 10 workers.
|
31
|
+
10.times do
|
32
|
+
Minx.spawn do
|
33
|
+
requests.each {|request| handle_request(request) }
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
In the near future, evented IO will be implemented, allowing for highly
|
38
|
+
performant network and file applications.
|
39
|
+
|
40
|
+
|
41
|
+
Documentation
|
42
|
+
-------------
|
43
|
+
|
44
|
+
See [the full documentation](http://yardoc.org/docs/dasch-minx/file:README.md).
|
45
|
+
|
46
|
+
|
47
|
+
Copyright
|
48
|
+
---------
|
49
|
+
|
50
|
+
Copyright (c) 2010 Daniel Schierbeck. See {file:LICENSE} for details.
|
data/Rakefile
ADDED
@@ -0,0 +1,56 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'rake'
|
3
|
+
|
4
|
+
begin
|
5
|
+
require 'jeweler'
|
6
|
+
Jeweler::Tasks.new do |gem|
|
7
|
+
gem.name = "minx"
|
8
|
+
gem.summary = %Q{Massive and pervasive concurrency}
|
9
|
+
gem.description = %Q{An implementation of the CSP concurrency primitives}
|
10
|
+
gem.email = "daniel.schierbeck@gmail.com"
|
11
|
+
gem.homepage = "http://github.com/dasch/minx"
|
12
|
+
gem.authors = ["Daniel Schierbeck"]
|
13
|
+
gem.add_development_dependency "shoulda", ">= 0"
|
14
|
+
end
|
15
|
+
Jeweler::GemcutterTasks.new
|
16
|
+
rescue LoadError
|
17
|
+
puts "Jeweler (or a dependency) not available. Install it with: gem install jeweler"
|
18
|
+
end
|
19
|
+
|
20
|
+
require 'rake/testtask'
|
21
|
+
Rake::TestTask.new(:test) do |test|
|
22
|
+
test.libs << 'lib' << 'test'
|
23
|
+
test.pattern = 'test/**/*_test.rb'
|
24
|
+
test.verbose = true
|
25
|
+
end
|
26
|
+
|
27
|
+
begin
|
28
|
+
require 'rcov/rcovtask'
|
29
|
+
Rcov::RcovTask.new do |test|
|
30
|
+
test.libs << 'test'
|
31
|
+
test.pattern = 'test/**/*_test.rb'
|
32
|
+
test.verbose = true
|
33
|
+
end
|
34
|
+
rescue LoadError
|
35
|
+
task :rcov do
|
36
|
+
abort "RCov is not available. In order to run rcov, you must: sudo gem install spicycode-rcov"
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
task :test => :check_dependencies
|
41
|
+
|
42
|
+
task :default => :test
|
43
|
+
|
44
|
+
begin
|
45
|
+
require 'yard'
|
46
|
+
require 'yard/rake/yardoc_task'
|
47
|
+
YARD::Rake::YardocTask.new do |t|
|
48
|
+
extra_files = %w(LICENSE)
|
49
|
+
t.files = ['lib/**/*.rb']
|
50
|
+
t.options = ["--files=#{extra_files.join(',')}"]
|
51
|
+
end
|
52
|
+
rescue LoadError
|
53
|
+
task :yard do
|
54
|
+
abort "YARD is not available. In order to run yard, you must: sudo gem install yard"
|
55
|
+
end
|
56
|
+
end
|
data/VERSION
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
0.1.0
|
data/benchmarks/ring.rb
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
|
2
|
+
require 'benchmark'
|
3
|
+
|
4
|
+
$:.unshift(File.dirname(__FILE__) + "/../lib/")
|
5
|
+
|
6
|
+
require 'minx'
|
7
|
+
|
8
|
+
|
9
|
+
# Number of processes.
|
10
|
+
N = 10000
|
11
|
+
|
12
|
+
# Number of rounds.
|
13
|
+
M = 10
|
14
|
+
|
15
|
+
channels = (0...N).map { Minx::Channel.new }
|
16
|
+
processes = (0...N).each do |i|
|
17
|
+
Minx.spawn do
|
18
|
+
M.times do
|
19
|
+
value = channels[i].receive
|
20
|
+
channels[(i + 1) % N].send(value + 1)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
Benchmark.bmbm do |bm|
|
26
|
+
bm.report("Sending a value #{M} times around a #{N}-length ring") do
|
27
|
+
Minx.spawn do
|
28
|
+
channels[0].send(0)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
data/lib/minx.rb
ADDED
@@ -0,0 +1,73 @@
|
|
1
|
+
|
2
|
+
require 'fiber'
|
3
|
+
|
4
|
+
Minx = Module.new
|
5
|
+
|
6
|
+
require 'minx/channel'
|
7
|
+
require 'minx/process'
|
8
|
+
|
9
|
+
module Minx
|
10
|
+
# Spawn a new process.
|
11
|
+
#
|
12
|
+
# @return [Process] a new process
|
13
|
+
def self.spawn(&block)
|
14
|
+
Process.new(&block).spawn
|
15
|
+
end
|
16
|
+
|
17
|
+
# Create a new channel.
|
18
|
+
#
|
19
|
+
# @return [Channel] a new channel
|
20
|
+
def self.channel
|
21
|
+
Channel.new
|
22
|
+
end
|
23
|
+
|
24
|
+
# Yield control to another process.
|
25
|
+
#
|
26
|
+
# The calling process will be resumed at a later point.
|
27
|
+
def self.yield(*args)
|
28
|
+
Fiber.yield(*args)
|
29
|
+
end
|
30
|
+
|
31
|
+
# Wait for the processes to yield execution.
|
32
|
+
#
|
33
|
+
# @return [nil]
|
34
|
+
def self.join(*processes)
|
35
|
+
processes.each do |process|
|
36
|
+
process.resume
|
37
|
+
end
|
38
|
+
|
39
|
+
return nil
|
40
|
+
end
|
41
|
+
|
42
|
+
# Select from a list of channels.
|
43
|
+
#
|
44
|
+
# The channels will be enumerated in order; the first one carrying a message
|
45
|
+
# will be picked, and the message will be returned.
|
46
|
+
#
|
47
|
+
# If none of the channels are readable, the calling process will yield until
|
48
|
+
# a channel is written to, unless <code>:skip => true</code> is passed as
|
49
|
+
# an option, in which case the call will just return +nil+.
|
50
|
+
#
|
51
|
+
# @example Non-blocking select
|
52
|
+
# Minx.select(chan1, chan2, :skip => true)
|
53
|
+
#
|
54
|
+
# @param choices [Channel] the channels to be selected among
|
55
|
+
# @return the first message received from any of the channels
|
56
|
+
def self.select(*choices)
|
57
|
+
options = choices.last.is_a?(Hash) ? choices.pop : {}
|
58
|
+
|
59
|
+
# If a choice is readable, just receive from that one.
|
60
|
+
choices.each do |choice|
|
61
|
+
return choice.receive if choice.readable?
|
62
|
+
end
|
63
|
+
|
64
|
+
# Return immediately if :skip => true
|
65
|
+
return if options[:skip]
|
66
|
+
|
67
|
+
# ... otherwise, wait for a channel to become readable.
|
68
|
+
choices.each do |choice|
|
69
|
+
choice.receive(:async => true)
|
70
|
+
end
|
71
|
+
Minx.yield
|
72
|
+
end
|
73
|
+
end
|
data/lib/minx/channel.rb
ADDED
@@ -0,0 +1,74 @@
|
|
1
|
+
|
2
|
+
module Minx
|
3
|
+
# A Channel is used to transmit messages between processes in a synchronized
|
4
|
+
# manner.
|
5
|
+
class Channel
|
6
|
+
def initialize
|
7
|
+
@readers = []
|
8
|
+
@writers = []
|
9
|
+
end
|
10
|
+
|
11
|
+
# Write a message to the channel.
|
12
|
+
#
|
13
|
+
# If no readers are waiting, the calling process will block until one comes
|
14
|
+
# along.
|
15
|
+
#
|
16
|
+
# @param message the message to be transmitted
|
17
|
+
# @return [nil]
|
18
|
+
def send(message)
|
19
|
+
if @readers.empty?
|
20
|
+
@writers << Fiber.current
|
21
|
+
|
22
|
+
# Yield control
|
23
|
+
Minx.yield
|
24
|
+
|
25
|
+
# Yield a message back to a reader.
|
26
|
+
Minx.yield(message)
|
27
|
+
else
|
28
|
+
@readers.shift.resume(message)
|
29
|
+
end
|
30
|
+
|
31
|
+
return nil
|
32
|
+
end
|
33
|
+
|
34
|
+
alias :<< :send
|
35
|
+
|
36
|
+
# Read a message off the channel.
|
37
|
+
#
|
38
|
+
# If no messages have been written to the channel, the calling process will
|
39
|
+
# block, only resuming when a write occurs. This behavior can be suppressed
|
40
|
+
# by calling +receive+ with <code>:async => true</code>, in which case the
|
41
|
+
# call will return immediately; the next time the calling process yields,
|
42
|
+
# it may be resumed with a message from the channel.
|
43
|
+
#
|
44
|
+
# @option options [Boolean] :async (false) whether or not to block
|
45
|
+
# @return a message
|
46
|
+
def receive(options = {})
|
47
|
+
if @writers.empty?
|
48
|
+
@readers << Fiber.current
|
49
|
+
Minx.yield unless options[:async]
|
50
|
+
else
|
51
|
+
@writers.shift.resume
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
# Enumerate over the messages sent to the channel.
|
56
|
+
#
|
57
|
+
# @example Iterating over channel messages
|
58
|
+
# chan.each do |message|
|
59
|
+
# puts "Got #{message}!"
|
60
|
+
# end
|
61
|
+
#
|
62
|
+
# @yield [message]
|
63
|
+
def each
|
64
|
+
yield receive while true
|
65
|
+
end
|
66
|
+
|
67
|
+
# Returns whether there are any processes waiting to write.
|
68
|
+
#
|
69
|
+
# @return +true+ if you can receive a message from the channel
|
70
|
+
def readable?
|
71
|
+
return !@writers.empty?
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
data/lib/minx/process.rb
ADDED
@@ -0,0 +1,24 @@
|
|
1
|
+
|
2
|
+
module Minx
|
3
|
+
class Process
|
4
|
+
def initialize(&block)
|
5
|
+
raise ArgumentError unless block_given?
|
6
|
+
@fiber = Fiber.new { block.call }
|
7
|
+
end
|
8
|
+
|
9
|
+
# Spawn the process.
|
10
|
+
#
|
11
|
+
# The process will immediately take over execution, and the current
|
12
|
+
# fiber will yield.
|
13
|
+
def spawn
|
14
|
+
@fiber.resume
|
15
|
+
end
|
16
|
+
|
17
|
+
# Resume the process.
|
18
|
+
#
|
19
|
+
# This yields execution to the process.
|
20
|
+
def resume
|
21
|
+
@fiber.resume
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
require 'helper'
|
2
|
+
|
3
|
+
class ChannelTest < Test::Unit::TestCase
|
4
|
+
context "A Channel" do
|
5
|
+
setup do
|
6
|
+
@channel = Minx.channel
|
7
|
+
@data = []
|
8
|
+
@p1 = Minx::Process.new { @channel.send(:foo) }
|
9
|
+
@p2 = Minx::Process.new { @data << @channel.receive }
|
10
|
+
end
|
11
|
+
|
12
|
+
context "with first a reader, then a writer" do
|
13
|
+
setup do
|
14
|
+
@p2.spawn
|
15
|
+
@p1.spawn
|
16
|
+
end
|
17
|
+
|
18
|
+
should "be able to transmit a message" do
|
19
|
+
assert_equal :foo, @data.first
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
context "with first a writer, then a reader" do
|
24
|
+
setup do
|
25
|
+
@p1.spawn
|
26
|
+
@p2.spawn
|
27
|
+
end
|
28
|
+
|
29
|
+
should "be able to transmit a message" do
|
30
|
+
assert_equal :foo, @data.first
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
should "send a message with #<<" do
|
35
|
+
Minx.spawn { @channel << :bar }
|
36
|
+
assert_equal :bar, @channel.receive
|
37
|
+
end
|
38
|
+
|
39
|
+
should "iterate over messages on #each" do
|
40
|
+
Minx.spawn { [:foo, :bar, :baz].each {|msg| @channel.send(msg) } }
|
41
|
+
|
42
|
+
values = [:foo, :bar, :baz]
|
43
|
+
Minx.spawn do
|
44
|
+
@channel.each do |message|
|
45
|
+
assert_equal values.shift, message
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
data/test/choice_test.rb
ADDED
@@ -0,0 +1,69 @@
|
|
1
|
+
|
2
|
+
require 'helper'
|
3
|
+
|
4
|
+
class ChoiceTest < Test::Unit::TestCase
|
5
|
+
context "A simple choice between two channels" do
|
6
|
+
setup do
|
7
|
+
@chan1 = Minx.channel
|
8
|
+
@chan2 = Minx.channel
|
9
|
+
end
|
10
|
+
|
11
|
+
context "with a single writer" do
|
12
|
+
setup do
|
13
|
+
Minx.spawn { @chan2.send(42) }
|
14
|
+
end
|
15
|
+
|
16
|
+
should "receive from the channel with a writer" do
|
17
|
+
Minx.spawn do
|
18
|
+
assert_equal 42, Minx.select(@chan1, @chan2)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
context "with writers on both channels" do
|
24
|
+
setup do
|
25
|
+
Minx.spawn { @chan1.send(666) }
|
26
|
+
Minx.spawn { @chan2.send(42) }
|
27
|
+
end
|
28
|
+
|
29
|
+
should "receive from the first channel specified" do
|
30
|
+
Minx.spawn do
|
31
|
+
assert_equal 666, Minx.select(@chan1, @chan2)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
should "not receive from the second channel specified" do
|
36
|
+
Minx.spawn do
|
37
|
+
Minx.select(@chan1, @chan2)
|
38
|
+
assert_equal 42, @chan2.receive
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
context "with no writers" do
|
44
|
+
setup do
|
45
|
+
Minx.spawn { @value = Minx.select(@chan1, @chan2) }
|
46
|
+
end
|
47
|
+
|
48
|
+
should "block until a writer comes along" do
|
49
|
+
Minx.spawn { @chan1.send(42) }
|
50
|
+
assert_equal 42, @value
|
51
|
+
end
|
52
|
+
|
53
|
+
should "also block until the second channel gets written to" do
|
54
|
+
Minx.spawn { @chan2.send(666) }
|
55
|
+
assert_equal 666, @value
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
context "with :skip => true" do
|
60
|
+
setup do
|
61
|
+
Minx.spawn { @value = Minx.select(@chan1, @chan2, :skip => true) }
|
62
|
+
end
|
63
|
+
|
64
|
+
should "not block" do
|
65
|
+
assert_nil @value
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
data/test/helper.rb
ADDED
@@ -0,0 +1,41 @@
|
|
1
|
+
require 'helper'
|
2
|
+
|
3
|
+
class ProcessTest < Test::Unit::TestCase
|
4
|
+
should "Raise ArgumentError if no block is given" do
|
5
|
+
assert_raise(ArgumentError) { Minx::Process.new }
|
6
|
+
end
|
7
|
+
|
8
|
+
context "A Minx process" do
|
9
|
+
setup do
|
10
|
+
@data = ""
|
11
|
+
@process = Minx::Process.new { @data.replace("foo") }
|
12
|
+
end
|
13
|
+
|
14
|
+
should "not execute initially" do
|
15
|
+
assert_equal "", @data
|
16
|
+
end
|
17
|
+
|
18
|
+
should "execute on #spawn" do
|
19
|
+
@process.spawn
|
20
|
+
|
21
|
+
assert_equal "foo", @data
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
context "A Minx process that yields initially" do
|
26
|
+
setup do
|
27
|
+
@process = Minx::Process.new { Minx.yield; @value = 42 }
|
28
|
+
end
|
29
|
+
|
30
|
+
should "be rescheduled and resumed" do
|
31
|
+
Minx.spawn do
|
32
|
+
@process.spawn
|
33
|
+
Minx.yield
|
34
|
+
end
|
35
|
+
|
36
|
+
Minx.join(@process)
|
37
|
+
|
38
|
+
assert_equal 42, @value
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
metadata
ADDED
@@ -0,0 +1,80 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: minx
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Daniel Schierbeck
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
|
12
|
+
date: 2010-02-07 00:00:00 +01:00
|
13
|
+
default_executable:
|
14
|
+
dependencies:
|
15
|
+
- !ruby/object:Gem::Dependency
|
16
|
+
name: shoulda
|
17
|
+
type: :development
|
18
|
+
version_requirement:
|
19
|
+
version_requirements: !ruby/object:Gem::Requirement
|
20
|
+
requirements:
|
21
|
+
- - ">="
|
22
|
+
- !ruby/object:Gem::Version
|
23
|
+
version: "0"
|
24
|
+
version:
|
25
|
+
description: An implementation of the CSP concurrency primitives
|
26
|
+
email: daniel.schierbeck@gmail.com
|
27
|
+
executables: []
|
28
|
+
|
29
|
+
extensions: []
|
30
|
+
|
31
|
+
extra_rdoc_files:
|
32
|
+
- LICENSE
|
33
|
+
- README.md
|
34
|
+
files:
|
35
|
+
- .gitignore
|
36
|
+
- LICENSE
|
37
|
+
- README.md
|
38
|
+
- Rakefile
|
39
|
+
- VERSION
|
40
|
+
- benchmarks/ring.rb
|
41
|
+
- lib/minx.rb
|
42
|
+
- lib/minx/channel.rb
|
43
|
+
- lib/minx/process.rb
|
44
|
+
- test/channel_test.rb
|
45
|
+
- test/choice_test.rb
|
46
|
+
- test/helper.rb
|
47
|
+
- test/process_test.rb
|
48
|
+
has_rdoc: true
|
49
|
+
homepage: http://github.com/dasch/minx
|
50
|
+
licenses: []
|
51
|
+
|
52
|
+
post_install_message:
|
53
|
+
rdoc_options:
|
54
|
+
- --charset=UTF-8
|
55
|
+
require_paths:
|
56
|
+
- lib
|
57
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: "0"
|
62
|
+
version:
|
63
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
64
|
+
requirements:
|
65
|
+
- - ">="
|
66
|
+
- !ruby/object:Gem::Version
|
67
|
+
version: "0"
|
68
|
+
version:
|
69
|
+
requirements: []
|
70
|
+
|
71
|
+
rubyforge_project:
|
72
|
+
rubygems_version: 1.3.5
|
73
|
+
signing_key:
|
74
|
+
specification_version: 3
|
75
|
+
summary: Massive and pervasive concurrency
|
76
|
+
test_files:
|
77
|
+
- test/choice_test.rb
|
78
|
+
- test/channel_test.rb
|
79
|
+
- test/helper.rb
|
80
|
+
- test/process_test.rb
|