lib/sippy_cup/scenario.rb in sippy_cup-0.4.1 vs lib/sippy_cup/scenario.rb in sippy_cup-0.5.0
- old
+ new
@@ -1,19 +1,21 @@
# encoding: utf-8
require 'nokogiri'
require 'psych'
require 'active_support/core_ext/hash'
require 'tempfile'
+require 'set'
module SippyCup
#
# A representation of a SippyCup scenario from a manifest or created in code. Allows building a scenario from a set of basic primitives, and then exporting to SIPp scenario files, including the XML scenario and PCAP audio.
#
class Scenario
USER_AGENT = "SIPp/sippy_cup"
VALID_DTMF = %w{0 1 2 3 4 5 6 7 8 9 0 * # A B C D}.freeze
MSEC = 1_000
+ DEFAULT_RETRANS = 500
#
# Build a scenario based on either a manifest string or a file handle. Manifests are supplied in YAML format.
# All manifest keys can be overridden by passing in a Hash of corresponding values.
#
@@ -77,10 +79,11 @@
# @option options [String] :name The name of the scenario, used for the XML scenario and for determining the compiled filenames. Defaults to 'My Scenario'.
# @option options [String] :filename The name of the files to be saved to disk.
# @option options [String] :source The source IP/hostname with which to invoke SIPp.
# @option options [String, Numeric] :source_port The source port to bind SIPp to (defaults to 8836).
# @option options [String] :destination The target system at which to direct traffic.
+ # @option options [String] :advertise_address The IP address to advertise in SIP and SDP if different from the bind IP (defaults to the bind IP).
# @option options [String] :from_user The SIP user from which traffic should appear.
# @option options [String] :to_user The SIP user to send requests to.
# @option options [Integer] :media_port The RTCP (media) port to bind to locally.
# @option options [String, Numeric] :max_concurrent The maximum number of concurrent calls to execute.
# @option options [String, Numeric] :number_of_calls The maximum number of calls to execute in the test run.
@@ -100,14 +103,17 @@
parse_args args
@scenario_options = args.merge name: name
@filename = args[:filename] || name.downcase.gsub(/\W+/, '_')
@filename = File.expand_path @filename, Dir.pwd
- @media = Media.new '127.0.0.255', 55555, '127.255.255.255', 5060
+ @media = nil
@message_variables = 0
+ # Reference variables don't generate warnings/errors if unused in the scenario
+ @reference_variables = Set.new
@media_nodes = []
@errors = []
+ @adv_ip = args[:advertise_address] || "[local_ip]"
instance_eval &block if block_given?
end
# @return [true, false] the validity of the scenario. Will be false if errors were encountered while building the scenario from a manifest
@@ -122,15 +128,14 @@
#
def build(steps)
raise ArgumentError, "Must provide scenario steps" unless steps
steps.each_with_index do |step, index|
begin
- instruction, arg = step.split ' ', 2
- if arg && !arg.empty?
- # Strip leading/trailing quotes if present
- arg.gsub!(/^'|^"|'$|"$/, '')
- self.__send__ instruction, arg
+ instruction, args = step.split ' ', 2
+ args = split_quoted_string args
+ if args && !args.empty?
+ self.__send__ instruction, *args
else
self.__send__ instruction
end
rescue => e
@errors << {step: index + 1, message: "#{step}: #{e.message}"}
@@ -147,35 +152,54 @@
#
def invite(opts = {})
opts[:retrans] ||= 500
# FIXME: The DTMF mapping (101) is hard-coded. It would be better if we could
# get this from the DTMF payload generator
+ from_addr = "#{@from_user}@#{@adv_ip}:[local_port]"
+ to_addr = "[service]@[remote_ip]:[remote_port]"
msg = <<-MSG
-INVITE sip:[service]@[remote_ip]:[remote_port] SIP/2.0
-Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch]
-From: "#{@from_user}" <sip:#{@from_user}@[local_ip]>;tag=[call_number]
-To: <sip:[service]@[remote_ip]:[remote_port]>
+INVITE sip:#{to_addr} SIP/2.0
+Via: SIP/2.0/[transport] #{@adv_ip}:[local_port];branch=[branch]
+From: "#{@from_user}" <sip:#{from_addr}>;tag=[call_number]
+To: <sip:#{to_addr}>
Call-ID: [call_id]
CSeq: [cseq] INVITE
-Contact: <sip:#{@from_user}@[local_ip]:[local_port];transport=[transport]>
+Contact: <sip:#{from_addr};transport=[transport]>
Max-Forwards: 100
User-Agent: #{USER_AGENT}
Content-Type: application/sdp
Content-Length: [len]
#{opts.has_key?(:headers) ? opts.delete(:headers).sub(/\n*\Z/, "\n") : ''}
v=0
-o=user1 53655765 2353687637 IN IP[local_ip_type] [local_ip]
+o=user1 53655765 2353687637 IN IP[local_ip_type] #{@adv_ip}
s=-
c=IN IP[media_ip_type] [media_ip]
t=0 0
m=audio [media_port] RTP/AVP 0 101
a=rtpmap:0 PCMU/8000
a=rtpmap:101 telephone-event/8000
a=fmtp:101 0-15
MSG
- send msg, opts
+ send msg, opts do |send|
+ send << doc.create_element('action') do |action|
+ action << doc.create_element('assignstr') do |assignstr|
+ assignstr['assign_to'] = "remote_addr"
+ assignstr['value'] = to_addr
+ end
+ action << doc.create_element('assignstr') do |assignstr|
+ assignstr['assign_to'] = "local_addr"
+ assignstr['value'] = from_addr
+ end
+ action << doc.create_element('assignstr') do |assignstr|
+ assignstr['assign_to'] = "call_addr"
+ assignstr['value'] = to_addr
+ end
+ end
+ end
+ # These variables will only be used if we initiate a hangup
+ @reference_variables += %w(remote_addr local_addr call_addr)
end
#
# Send a REGISTER message with the specified credentials
#
@@ -188,21 +212,147 @@
#
# @example Register without authentication or a domain
# s.register 'frank'
#
def register(user, password = nil, opts = {})
- opts[:retrans] ||= 500
+ send_opts = opts.dup
+ send_opts[:retrans] ||= DEFAULT_RETRANS
user, domain = parse_user user
- msg = if password
- register_auth domain, user, password
+ if password
+ send register_message(domain, user), send_opts
+ recv opts.merge(response: 401, auth: true, optional: false)
+ send register_auth(domain, user, password), send_opts
+ receive_ok opts.merge(optional: false)
else
- register_message domain, user
+ send register_message(domain, user), send_opts
end
+ end
+
+ #
+ # Expect to receive a SIP INVITE
+ #
+ # @param [Hash] opts A set of options containing SIPp <recv> element attributes
+ #
+ def receive_invite(opts = {})
+ recv(opts.merge(request: 'INVITE', rrs: true)) do |recv|
+ action = doc.create_element('action') do |action|
+ action << doc.create_element('ereg') do |ereg|
+ ereg['regexp'] = '<sip:(.*)>.*;tag=([^;]*)'
+ ereg['search_in'] = 'hdr'
+ ereg['header'] = 'From:'
+ ereg['assign_to'] = 'dummy,remote_addr,remote_tag'
+ end
+ action << doc.create_element('ereg') do |ereg|
+ ereg['regexp'] = '<sip:(.*)>'
+ ereg['search_in'] = 'hdr'
+ ereg['header'] = 'To:'
+ ereg['assign_to'] = 'dummy,local_addr'
+ end
+ action << doc.create_element('assignstr') do |assignstr|
+ assignstr['assign_to'] = "call_addr"
+ assignstr['value'] = "[$local_addr]"
+ end
+ end
+ recv << action
+ end
+ # These variables (except dummy) will only be used if we initiate a hangup
+ @reference_variables += %w(dummy remote_addr remote_tag local_addr call_addr)
+ end
+ alias :wait_for_call :receive_invite
+
+ #
+ # Send a "100 Trying" response
+ #
+ # @param [Hash] opts A set of options containing SIPp <recv> element attributes
+ #
+ def send_trying(opts = {})
+ msg = <<-MSG
+
+SIP/2.0 100 Trying
+[last_Via:]
+From: <sip:[$remote_addr]>;tag=[$remote_tag]
+To: <sip:[$local_addr]>;tag=[call_number]
+[last_Call-ID:]
+[last_CSeq:]
+Server: #{USER_AGENT}
+Contact: <sip:[$local_addr];transport=[transport]>
+Content-Length: 0
+ MSG
send msg, opts
end
+ alias :send_100 :send_trying
#
+ # Send a "180 Ringing" response
+ #
+ # @param [Hash] opts A set of options containing SIPp <recv> element attributes
+ #
+ def send_ringing(opts = {})
+ msg = <<-MSG
+
+SIP/2.0 180 Ringing
+[last_Via:]
+From: <sip:[$remote_addr]>;tag=[$remote_tag]
+To: <sip:[$local_addr]>;tag=[call_number]
+[last_Call-ID:]
+[last_CSeq:]
+Server: #{USER_AGENT}
+Contact: <sip:[$local_addr];transport=[transport]>
+Content-Length: 0
+ MSG
+ send msg, opts
+ end
+ alias :send_180 :send_ringing
+
+ #
+ # Answer an incoming call
+ #
+ # @param [Hash] opts A set of options containing SIPp <send> element attributes
+ #
+ def send_answer(opts = {})
+ opts[:retrans] ||= DEFAULT_RETRANS
+ msg = <<-MSG
+
+SIP/2.0 200 Ok
+[last_Via:]
+From: <sip:[$remote_addr]>;tag=[$remote_tag]
+To: <sip:[$local_addr]>;tag=[call_number]
+[last_Call-ID:]
+[last_CSeq:]
+Server: #{USER_AGENT}
+Contact: <sip:[$local_addr];transport=[transport]>
+Content-Type: application/sdp
+[routes]
+Content-Length: [len]
+
+v=0
+o=user1 53655765 2353687637 IN IP[local_ip_type] #{@adv_ip}
+s=-
+c=IN IP[media_ip_type] [media_ip]
+t=0 0
+m=audio [media_port] RTP/AVP 0
+a=rtpmap:0 PCMU/8000
+ MSG
+ start_media
+ send msg, opts
+ end
+
+ #
+ # Helper method to answer an INVITE and expect the ACK
+ #
+ # @param [Hash] opts A set of options containing SIPp element attributes - will be passed to both the <send> and <recv> elements
+ #
+ def answer(opts = {})
+ send_answer opts
+ receive_ack opts
+ end
+
+ def receive_ack(opts = {})
+ recv opts.merge request: 'ACK'
+ end
+
+ #
# Sets an expectation for a SIP 100 message from the remote party
#
# @param [Hash] opts A set of options to modify the expectation
# @option opts [true, false] :optional Whether or not receipt of the message is optional. Defaults to true.
#
@@ -240,55 +390,70 @@
# @param [Hash] opts A set of options to modify the expectation
# @option opts [true, false] :optional Whether or not receipt of the message is optional. Defaults to false.
#
def receive_answer(opts = {})
options = {
- rrs: true, # Record Record Set: Make the Route headers available via [route] later
+ rrs: true, # Record Record Set: Make the Route headers available via [routes] later
rtd: true # Response Time Duration: Record the response time
}
- receive_200 options.merge(opts)
+ receive_200(options.merge(opts)) do |recv|
+ recv << doc.create_element('action') do |action|
+ action << doc.create_element('ereg') do |ereg|
+ ereg['regexp'] = '<sip:(.*)>.*;tag=([^;]*)'
+ ereg['search_in'] = 'hdr'
+ ereg['header'] = 'To:'
+ ereg['assign_to'] = 'dummy,remote_addr,remote_tag'
+ end
+ end
+ end
+ # These variables will only be used if we initiate a hangup
+ @reference_variables += %w(dummy remote_addr remote_tag)
end
#
# Sets an expectation for a SIP 200 message from the remote party
#
# @param [Hash] opts A set of options to modify the expectation
# @option opts [true, false] :optional Whether or not receipt of the message is optional. Defaults to false.
#
- def receive_ok(opts = {})
- recv({ response: 200 }.merge(opts))
+ def receive_ok(opts = {}, &block)
+ recv({ response: 200 }.merge(opts), &block)
end
alias :receive_200 :receive_ok
#
- # Shortcut that sets expectations for optional SIP 100, 180 and 183, followed by a required 200.
+ # Convenience method to wait for an answer from the called party
#
+ # This sets expectations for optional SIP 100, 180 and 183,
+ # followed by a required 200 and sending the acknowledgement.
+ #
# @param [Hash] opts A set of options to modify the expectations
#
def wait_for_answer(opts = {})
receive_trying opts
receive_ringing opts
receive_progress opts
receive_answer opts
+ ack_answer opts
end
#
- # Acknowledge a received answer message (SIP 200) and start media playback
+ # Acknowledge a received answer message and start media playback
#
# @param [Hash] opts A set of options to modify the message parameters
#
def ack_answer(opts = {})
msg = <<-BODY
ACK [next_url] SIP/2.0
-Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch]
-From: "#{@from_user}" <sip:#{@from_user}@[local_ip]>;tag=[call_number]
+Via: SIP/2.0/[transport] #{@adv_ip}:[local_port];branch=[branch]
+From: "#{@from_user}" <sip:#{@from_user}@#{@adv_ip}:[local_port]>;tag=[call_number]
To: <sip:[service]@[remote_ip]:[remote_port]>[peer_tag_param]
Call-ID: [call_id]
CSeq: [cseq] ACK
-Contact: <sip:#{@from_user}@[local_ip]:[local_port];transport=[transport]>
+Contact: <sip:[$local_addr];transport=[transport]>
Max-Forwards: 100
User-Agent: #{USER_AGENT}
Content-Length: 0
[routes]
BODY
@@ -302,11 +467,11 @@
# @param [Numeric] seconds The duration of the pause in seconds
#
def sleep(seconds)
milliseconds = (seconds.to_f * MSEC).to_i
pause milliseconds
- @media << "silence:#{milliseconds}"
+ @media << "silence:#{milliseconds}" if @media
end
#
# Send DTMF digits
#
@@ -317,10 +482,11 @@
#
# @example Enter a pin number
# send_digits '1234'
#
def send_digits(digits)
+ raise "Media not started" unless @media
delay = (0.250 * MSEC).to_i # FIXME: Need to pass this down to the media layer
digits.split('').each do |digit|
raise ArgumentError, "Invalid DTMF digit requested: #{digit}" unless VALID_DTMF.include? digit
case @dtmf_mode
@@ -329,16 +495,16 @@
@media << "silence:#{delay}"
when :info
info = <<-INFO
INFO [next_url] SIP/2.0
-Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch]
-From: "#{@from_user}" <sip:#{@from_user}@[local_ip]>;tag=[call_number]
+Via: SIP/2.0/[transport] #{@adv_ip}:[local_port];branch=[branch]
+From: "#{@from_user}" <sip:#{@from_user}@#{@adv_ip}:[local_port]>;tag=[call_number]
To: <sip:[service]@[remote_ip]:[remote_port]>[peer_tag_param]
Call-ID: [call_id]
CSeq: [cseq] INFO
-Contact: <sip:#{@from_user}@[local_ip]:[local_port];transport=[transport]>
+Contact: <sip:[$local_addr];transport=[transport]>
Max-Forwards: 100
User-Agent: #{USER_AGENT}
[routes]
Content-Length: [len]
Content-Type: application/dtmf-relay
@@ -368,22 +534,21 @@
scenario_node << recv
if regexp
action = Nokogiri::XML::Node.new 'action', doc
ereg = Nokogiri::XML::Node.new 'ereg', doc
- ref = Nokogiri::XML::Node.new 'Reference', doc
ereg['regexp'] = regexp
ereg['search_in'] = 'body'
ereg['check_it'] = true
var = "message_#{@message_variables += 1}"
- ereg['assign_to'] = ref['variables'] = var
+ ereg['assign_to'] = var
+ @reference_variables << var
action << ereg
recv << action
- scenario_node << ref
end
okay
end
@@ -393,17 +558,17 @@
# @param [Hash] opts A set of options to modify the message parameters
#
def send_bye(opts = {})
msg = <<-MSG
-BYE [next_url] SIP/2.0
-Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch]
-From: "#{@from_user}" <sip:#{@from_user}@[local_ip]>;tag=[call_number]
-To: <sip:[service]@[remote_ip]:[remote_port]>[peer_tag_param]
+BYE sip:[$call_addr] SIP/2.0
+Via: SIP/2.0/[transport] #{@adv_ip}:[local_port];branch=[branch]
+From: <sip:[$local_addr]>;tag=[call_number]
+To: <sip:[$remote_addr]>;tag=[$remote_tag]
+Contact: <sip:[$local_addr];transport=[transport]>
Call-ID: [call_id]
CSeq: [cseq] BYE
-Contact: <sip:#{@from_user}@[local_ip]:[local_port];transport=[transport]>
Max-Forwards: 100
User-Agent: #{USER_AGENT}
Content-Length: 0
[routes]
MSG
@@ -431,11 +596,11 @@
[last_Via:]
[last_From:]
[last_To:]
[last_Call-ID:]
[last_CSeq:]
-Contact: <sip:#{@from_user}@[local_ip]:[local_port];transport=[transport]>
+Contact: <sip:[$local_addr];transport=[transport]>
Max-Forwards: 100
User-Agent: #{USER_AGENT}
Content-Length: 0
[routes]
ACK
@@ -452,10 +617,38 @@
receive_bye(opts)
ack_bye(opts)
end
#
+ # Shortcut to send a BYE and wait for the acknowledgement
+ #
+ # @param [Hash] opts A set of options containing SIPp <recv> element attributes - will be passed to both the <send> and <recv> elements
+ #
+ def hangup(opts = {})
+ send_bye opts
+ receive_ok opts
+ end
+
+ # Create partition table for Call Length
+ #
+ # @param [Integer] min An value specifying the minimum time in milliseconds for the table
+ # @param [Integer] max An value specifying the maximum time in milliseconds for the table
+ # @param [Integer] interval An value specifying the interval in milliseconds for the table
+ def call_length_repartition(min, max, interval)
+ partition_table 'CallLengthRepartition', min.to_i, max.to_i, interval.to_i
+ end
+
+ # Create partition table for Response Time
+ #
+ # @param [Integer] min An value specifying the minimum time in milliseconds for the table
+ # @param [Integer] max An value specifying the maximum time in milliseconds for the table
+ # @param [Integer] interval An value specifying the interval in milliseconds for the table
+ def response_time_repartition(min, max, interval)
+ partition_table 'ResponseTimeRepartition', min.to_i, max.to_i, interval.to_i
+ end
+
+ #
# Dump the scenario to a SIPp XML string
#
# @return [String] the SIPp XML scenario
def to_xml(options = {})
pcap_path = options[:pcap_path]
@@ -472,10 +665,17 @@
exec = nopdup.xpath("./action/exec").first
exec['play_pcap_audio'] = pcap_path
end
end
+ unless @reference_variables.empty?
+ scenario_node = docdup.xpath('scenario').first
+ scenario_node << docdup.create_element('Reference') do |ref|
+ ref[:variables] = @reference_variables.to_a.join ','
+ end
+ end
+
docdup.to_xml
end
#
# Compile the scenario and its media to disk
@@ -492,11 +692,11 @@
# @example Export a scenario to a calculated filename
# scenario = Scenario.new 'Test Scenario'
# scenario.compile! # Leaves files at test_scenario.xml and test_scenario.pcap
#
def compile!
- unless @media.empty?
+ unless @media.nil?
print "Compiling media to #{@filename}.pcap..."
compile_media.to_file filename: "#{@filename}.pcap"
puts "done."
end
@@ -518,12 +718,13 @@
# @return [Hash<Symbol => Tempfile>] handles to created Tempfiles at :scenario and :media
#
# @see http://www.ruby-doc.org/stdlib-1.9.3/libdoc/tempfile/rdoc/Tempfile.html
#
def to_tmpfiles
- unless @media.empty?
+ unless @media.nil? || @media.empty?
media_file = Tempfile.new 'media'
+ media_file.binmode
media_file.write compile_media.to_s
media_file.rewind
end
scenario_file = Tempfile.new 'scenario'
@@ -542,10 +743,16 @@
user, domain = user.split("@")
domain ||= "[remote_ip]"
[user, domain]
end
+ # Split a string into space-delimited components, optionally allowing quoted groups
+ # Example: cars "cats and dogs" fish 'hammers' => ["cars", "cats and dogs", "fish", "hammers"]
+ def split_quoted_string(args)
+ args.to_s.scan(/'.+?'|".+?"|[^ ]+/).map { |s| s.gsub /^['"]|['"]$/, '' }
+ end
+
def doc
@doc ||= begin
Nokogiri::XML::Builder.new do |xml|
xml.scenario name: @scenario_options[:name] do
@scenario_node = xml.parent
@@ -558,66 +765,64 @@
doc
@scenario_node
end
def parse_args(args)
- raise ArgumentError, "Must include source IP:PORT" unless args.has_key? :source
- raise ArgumentError, "Must include destination IP:PORT" unless args.has_key? :destination
-
if args[:dtmf_mode]
@dtmf_mode = args[:dtmf_mode].to_sym
raise ArgumentError, "dtmf_mode must be rfc2833 or info" unless [:rfc2833, :info].include?(@dtmf_mode)
else
@dtmf_mode = :rfc2833
end
- @from_addr, @from_port = args[:source].split ':'
- @to_addr, @to_port = args[:destination].split ':'
+ @from_addr, @from_port = args[:source].split ':' if args[:source]
+ @to_addr, @to_port = args[:destination].split ':' if args[:destination]
@from_user = args[:from_user] || "sipp"
end
def compile_media
+ raise "Media not started" unless @media
@media.compile!
end
- def register_message(domain, user, opts = {})
+ def register_message(domain, user)
<<-BODY
REGISTER sip:#{domain} SIP/2.0
-Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch]
+Via: SIP/2.0/[transport] #{@adv_ip}:[local_port];branch=[branch]
From: <sip:#{user}@#{domain}>;tag=[call_number]
To: <sip:#{user}@#{domain}>
Call-ID: [call_id]
CSeq: [cseq] REGISTER
-Contact: <sip:#{@from_user}@[local_ip]:[local_port];transport=[transport]>
+Contact: <sip:#{@from_user}@#{@adv_ip}:[local_port];transport=[transport]>
Max-Forwards: 10
Expires: 120
User-Agent: #{USER_AGENT}
Content-Length: 0
BODY
end
- def register_auth(domain, user, password, opts = {})
- recv response: '401', auth: true, optional: false
+ def register_auth(domain, user, password)
<<-AUTH
REGISTER sip:#{domain} SIP/2.0
-Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch]
+Via: SIP/2.0/[transport] #{@adv_ip}:[local_port];branch=[branch]
From: <sip:#{user}@#{domain}>;tag=[call_number]
To: <sip:#{user}@#{domain}>
Call-ID: [call_id]
CSeq: [cseq] REGISTER
-Contact: <sip:#{@from_user}@[local_ip]:[local_port];transport=[transport]>
+Contact: <sip:#{@from_user}@#{@adv_ip}:[local_port];transport=[transport]>
Max-Forwards: 20
Expires: 3600
[authentication username=#{user} password=#{password}]
User-Agent: #{USER_AGENT}
Content-Length: 0
AUTH
end
def start_media
+ @media = Media.new '127.0.0.255', 55555, '127.255.255.255', 44444
nop = doc.create_element('nop') { |nop|
nop << doc.create_element('action') { |action|
action << doc.create_element('exec')
}
}
@@ -638,27 +843,36 @@
send[k.to_s] = v
end
send << "\n"
send << Nokogiri::XML::CDATA.new(doc, msg)
send << "\n" #Newlines are required before and after CDATA so SIPp will parse properly
+ yield send if block_given?
scenario_node << send
end
- def recv(opts = {})
+ def recv(opts = {}, &block)
raise ArgumentError, "Receive must include either a response or a request" unless opts.keys.include?(:response) || opts.keys.include?(:request)
recv = Nokogiri::XML::Node.new 'recv', doc
opts.each do |k,v|
recv[k.to_s] = v
end
+ yield recv if block_given?
scenario_node << recv
end
def optional_recv(opts)
opts[:optional] = true if opts[:optional].nil?
recv opts
end
def handle_response(code, opts)
optional_recv opts.merge(response: code)
+ end
+
+ def partition_table(name, min, max, interval)
+ range = Range.new(min, max).step interval
+ partition_table = Nokogiri::XML::Node.new name, doc
+ partition_table[:value] = range.inject{ |n,m| "#{n},#{m}"}
+ scenario_node << partition_table
end
end
end