cukedep 0.0.1
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.
- checksums.yaml +15 -0
- data/.rspec +1 -0
- data/.ruby-gemset +1 -0
- data/.ruby-version +1 -0
- data/.simplecov +7 -0
- data/.travis.yml +15 -0
- data/.yardopts +6 -0
- data/CHANGELOG.md +3 -0
- data/Gemfile +12 -0
- data/LICENSE.txt +19 -0
- data/README.md +64 -0
- data/Rakefile +32 -0
- data/bin/cukedep +14 -0
- data/cucumber.yml +10 -0
- data/lib/cukedep.rb +9 -0
- data/lib/cukedep/application.rb +112 -0
- data/lib/cukedep/cli/application.rb +0 -0
- data/lib/cukedep/cli/cmd-line.rb +115 -0
- data/lib/cukedep/config.rb +31 -0
- data/lib/cukedep/constants.rb +29 -0
- data/lib/cukedep/feature-model.rb +285 -0
- data/lib/cukedep/feature-rep.rb +49 -0
- data/lib/cukedep/gherkin-listener.rb +98 -0
- data/lib/macros4cuke.rb +8 -0
- data/sample/features/step_definitions/steps.rb +105 -0
- data/sample/features/support/env.rb +12 -0
- data/sample/model/model.rb +216 -0
- data/spec/cukedep/application_spec.rb +81 -0
- data/spec/cukedep/cli/cmd-line_spec.rb +91 -0
- data/spec/cukedep/feature-model_spec.rb +103 -0
- data/spec/cukedep/feature-rep_spec.rb +53 -0
- data/spec/cukedep/file-parsing.rb +40 -0
- data/spec/cukedep/gherkin-listener_spec.rb +59 -0
- data/spec/cukedep/sample_features/a_few_tests.feature +24 -0
- data/spec/cukedep/sample_features/more_tests.feature +24 -0
- data/spec/cukedep/sample_features/other_tests.feature +15 -0
- data/spec/cukedep/sample_features/some_tests.feature +13 -0
- data/spec/cukedep/sample_features/standalone.feature +19 -0
- data/spec/cukedep/sample_features/still_other_tests.feature +24 -0
- data/spec/cukedep/sample_features/yet_other_tests.feature +19 -0
- data/spec/spec_helper.rb +18 -0
- data/templates/rake.erb +163 -0
- metadata +165 -0
@@ -0,0 +1,31 @@
|
|
1
|
+
# File: config.rb
|
2
|
+
|
3
|
+
module Cukedep # Module used as a namespace
|
4
|
+
|
5
|
+
FileMetaData = Struct.new(:name)
|
6
|
+
|
7
|
+
Config = Struct.new(
|
8
|
+
:proj_dir, # The directory of the cucumber project
|
9
|
+
:feature2id, # Meta-data about the feature => feature id report
|
10
|
+
:id2feature, # Meta-data about the feature id => feature report
|
11
|
+
:graph_file, # Meta-data about the dependency graph file
|
12
|
+
:rake_file, # Name of the output rake file
|
13
|
+
:cucumber_args # Command-line syntax to use for the cucumber application
|
14
|
+
)
|
15
|
+
|
16
|
+
# Re-open the class for further customisation
|
17
|
+
|
18
|
+
# Configuration object for the Cukedep application.
|
19
|
+
class Config
|
20
|
+
# Factory method. Build a config object with default settings.
|
21
|
+
def self.default()
|
22
|
+
Config.new(nil, FileMetaData.new('feature2id.csv'),
|
23
|
+
FileMetaData.new('feature2id.csv'), FileMetaData.new('dependencies.dot'),
|
24
|
+
'cukedep.rake', [])
|
25
|
+
end
|
26
|
+
|
27
|
+
end # class
|
28
|
+
|
29
|
+
end # module
|
30
|
+
|
31
|
+
# End of file
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# File: constants.rb
|
2
|
+
# Purpose: definition of Cukedep constants.
|
3
|
+
|
4
|
+
module Cukedep # Module used as a namespace
|
5
|
+
# The version number of the gem.
|
6
|
+
Version = '0.0.1'
|
7
|
+
|
8
|
+
# Brief description of the gem.
|
9
|
+
Description = 'Manage dependencies between Cucumber feature files'
|
10
|
+
|
11
|
+
# Constant Cukedep::RootDir contains the absolute path of Rodent's
|
12
|
+
# root directory. Note: it also ends with a slash character.
|
13
|
+
unless defined?(RootDir)
|
14
|
+
# The initialisation of constant RootDir is guarded in order
|
15
|
+
# to avoid multiple initialisation (not allowed for constants)
|
16
|
+
|
17
|
+
# The root folder of Cukedep.
|
18
|
+
RootDir = begin
|
19
|
+
require 'pathname' # Load Pathname class from standard library
|
20
|
+
rootdir = Pathname(__FILE__).dirname.parent.parent.expand_path
|
21
|
+
rootdir.to_s + '/' # Append trailing slash character to it
|
22
|
+
end
|
23
|
+
|
24
|
+
# The file name for the user's settings
|
25
|
+
YMLFilename = '.cukedep.yml'
|
26
|
+
end
|
27
|
+
end # module
|
28
|
+
|
29
|
+
# End of file
|
@@ -0,0 +1,285 @@
|
|
1
|
+
# File: feature-model.rb
|
2
|
+
|
3
|
+
require 'tsort'
|
4
|
+
require 'csv'
|
5
|
+
require 'erb'
|
6
|
+
require 'pathname'
|
7
|
+
|
8
|
+
module Cukedep # This module is used as a namespace
|
9
|
+
|
10
|
+
|
11
|
+
# The internal representation of a set of feature files.
|
12
|
+
# Dependencies: use topological sort
|
13
|
+
# TSort module http://ruby-doc.org/stdlib-1.9.3/libdoc/tsort/rdoc/TSort.html
|
14
|
+
# See also: Is this topological sort in Ruby flawed?
|
15
|
+
class FeatureModel
|
16
|
+
|
17
|
+
FeatureDependencies = Struct.new(:dependee, :dependents)
|
18
|
+
|
19
|
+
|
20
|
+
class DepGraph
|
21
|
+
include TSort # Mix-in module for topological sorting
|
22
|
+
|
23
|
+
attr_reader(:dependencies)
|
24
|
+
|
25
|
+
# Inverse lookup: from the feature file => FeatureDependencies
|
26
|
+
attr_reader(:lookup)
|
27
|
+
|
28
|
+
def initialize(theDependencies)
|
29
|
+
@dependencies = theDependencies
|
30
|
+
@lookup = dependencies.each_with_object({}) do |f_dependencies, sub_result|
|
31
|
+
sub_result[f_dependencies.dependee] = f_dependencies
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
# Method required by TSort module.
|
36
|
+
# It is used to iterate over all the nodes of the dependency graph
|
37
|
+
def tsort_each_node(&aBlock)
|
38
|
+
return dependencies.each(&aBlock)
|
39
|
+
end
|
40
|
+
|
41
|
+
# Method required by TSort module.
|
42
|
+
# It is used to iterate over all the children nodes of the given node.
|
43
|
+
def tsort_each_child(aDependency, &aBlock)
|
44
|
+
dependents = aDependency.dependents
|
45
|
+
children = dependents.map { |feature| lookup[feature] }
|
46
|
+
children.each(&aBlock)
|
47
|
+
end
|
48
|
+
|
49
|
+
end # class
|
50
|
+
|
51
|
+
|
52
|
+
attr_reader(:feature_files)
|
53
|
+
|
54
|
+
# An Array of FeatureDependencies
|
55
|
+
attr_reader(:dependencies)
|
56
|
+
|
57
|
+
def initialize(theFeatureFiles)
|
58
|
+
@feature_files = validated_model(theFeatureFiles)
|
59
|
+
end
|
60
|
+
|
61
|
+
# Retrieve the feature file matching the given feature identifiers
|
62
|
+
# theIds one or more Strings, each being one feature identifier
|
63
|
+
def select_by_ids(*theIds)
|
64
|
+
features_by_ids = id2features()
|
65
|
+
selection = theIds.each_with_object([]) do |an_id, sub_result|
|
66
|
+
found_feature = features_by_ids[an_id]
|
67
|
+
if found_feature.nil?
|
68
|
+
fail(StandardError, "No feature file with identifier '#{an_id}'.")
|
69
|
+
end
|
70
|
+
sub_result << found_feature
|
71
|
+
end
|
72
|
+
|
73
|
+
return selection
|
74
|
+
end
|
75
|
+
|
76
|
+
# The list of feature files without identifiers
|
77
|
+
def anonymous_features()
|
78
|
+
feature_files.select { |ff| ff.feature.anonymous? }
|
79
|
+
end
|
80
|
+
|
81
|
+
# Build an array of FileDependencies objects.
|
82
|
+
def dependency_links()
|
83
|
+
if @dependencies.nil?
|
84
|
+
# Build the mapping: feature identifier => feature
|
85
|
+
features_by_id = id2features()
|
86
|
+
|
87
|
+
# Resolve the dependency tags
|
88
|
+
resolve_dependencies(features_by_id)
|
89
|
+
end
|
90
|
+
|
91
|
+
return @dependencies
|
92
|
+
end
|
93
|
+
|
94
|
+
|
95
|
+
# Sort the feature files by dependency order.
|
96
|
+
def sort_features_by_dep()
|
97
|
+
dep_links = dependency_links()
|
98
|
+
graph = DepGraph.new(dep_links)
|
99
|
+
sorted_deps = graph.tsort()
|
100
|
+
|
101
|
+
all_sorted = sorted_deps.map(&:dependee)
|
102
|
+
@sorted_features = all_sorted.reject { |f| f.feature.anonymous? }
|
103
|
+
end
|
104
|
+
|
105
|
+
# Generate CSV files detailing the feature to identifier mapping
|
106
|
+
# and vise versa
|
107
|
+
def mapping_reports(fileFeature2id, fileId2Feature, isVerbose = false)
|
108
|
+
puts " #{fileFeature2id}" if isVerbose
|
109
|
+
# Generate the feature file name => feature identifier report
|
110
|
+
CSV.open(fileFeature2id, 'wb') do |f|
|
111
|
+
f << ['Feature file', 'Identifier']
|
112
|
+
feature_files.each do |ff|
|
113
|
+
identifier = ff.feature.identifier
|
114
|
+
filename = File.basename(ff.filepath)
|
115
|
+
f << [filename, identifier.nil? ? 'nil' : identifier]
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
# Generate the feature file name => feature identifier report
|
120
|
+
puts " #{fileId2Feature}" if isVerbose
|
121
|
+
CSV.open(fileId2Feature, 'wb') do |f|
|
122
|
+
f << ['identifier', 'feature file']
|
123
|
+
feature_files.each do |ff|
|
124
|
+
identifier = ff.feature.identifier
|
125
|
+
filename = File.basename(ff.filepath)
|
126
|
+
f << [identifier, filename] unless identifier.nil?
|
127
|
+
end
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
# Create a graphical representation of the dependencies.
|
132
|
+
# The result is a DOT file that can be rendered via the DOT
|
133
|
+
# application from the GraphViz distribution.
|
134
|
+
def draw_dependency_graph(theDOTfile, isVerbose = false)
|
135
|
+
puts " #{theDOTfile}" if isVerbose
|
136
|
+
dot_file = File.open(theDOTfile, 'w')
|
137
|
+
emit_heading(dot_file)
|
138
|
+
emit_body(dot_file)
|
139
|
+
emit_trailing(dot_file)
|
140
|
+
end
|
141
|
+
|
142
|
+
|
143
|
+
def emit_heading(anIO)
|
144
|
+
dir = File.dirname(File.absolute_path(feature_files[0].filepath))
|
145
|
+
heading =<<-EOS
|
146
|
+
// Graph of dependencies of feature files in directory:
|
147
|
+
// '#{dir}'
|
148
|
+
// This file uses the DOT syntax, a free utility from the Graphviz toolset.
|
149
|
+
// Graphviz is available at: www.graphviz.org
|
150
|
+
// File generated on #{Time.now.asctime}.
|
151
|
+
|
152
|
+
digraph g {
|
153
|
+
size = "7, 11"; // Dimensions in inches...
|
154
|
+
center = true;
|
155
|
+
rankdir = BT; // Draw from bottom to top
|
156
|
+
label = "\\nDependency graph of '#{dir}'";
|
157
|
+
|
158
|
+
// Nodes represent feature files
|
159
|
+
EOS
|
160
|
+
anIO.write heading
|
161
|
+
end
|
162
|
+
|
163
|
+
# Output the nodes as graph vertices + their edges with parent node
|
164
|
+
def emit_body(anIO)
|
165
|
+
anIO.puts <<-EOS
|
166
|
+
subgraph island {
|
167
|
+
node [shape = box, style=filled, color=lightgray];
|
168
|
+
EOS
|
169
|
+
feature_files.each_with_index { |ff, i| draw_node(anIO, ff, i) if ff.feature.anonymous? }
|
170
|
+
|
171
|
+
anIO.puts <<-EOS
|
172
|
+
label = "Isolated features";
|
173
|
+
}
|
174
|
+
|
175
|
+
subgraph dependencies {
|
176
|
+
node [shape = box, fillcolor = none];
|
177
|
+
EOS
|
178
|
+
feature_files.each_with_index { |ff, i| draw_node(anIO, ff, i) unless ff.feature.anonymous? }
|
179
|
+
anIO.puts <<-EOS
|
180
|
+
label = "Dependencies";
|
181
|
+
}
|
182
|
+
|
183
|
+
// The edges represent dependencies
|
184
|
+
EOS
|
185
|
+
dependencies.each {|a_dep| draw_edge(anIO, a_dep) }
|
186
|
+
end
|
187
|
+
|
188
|
+
# Output the closing part of the graph drawing
|
189
|
+
def emit_trailing(anIO)
|
190
|
+
anIO.puts "} // End of graph"
|
191
|
+
end
|
192
|
+
|
193
|
+
# Draw a refinement node in DOT format
|
194
|
+
def draw_node(anIO, aFeatureFile, anIndex)
|
195
|
+
basename = File.basename(aFeatureFile.filepath, '.feature')
|
196
|
+
identifier_suffix = aFeatureFile.feature.anonymous? ? '' : " -- #{aFeatureFile.feature.identifier}"
|
197
|
+
anIO.puts %Q| node_#{anIndex} [label = "#{basename}#{identifier_suffix}"];|
|
198
|
+
end
|
199
|
+
|
200
|
+
# Draw an edge between feature files having dependencies.
|
201
|
+
def draw_edge(anIO, aDependency)
|
202
|
+
source_id = feature_files.find_index(aDependency.dependee)
|
203
|
+
target_ids = aDependency.dependents.map do |a_target|
|
204
|
+
feature_files.find_index(a_target)
|
205
|
+
end
|
206
|
+
|
207
|
+
target_ids.each do |t_id|
|
208
|
+
anIO.puts "\tnode_#{source_id} -> node_#{t_id};"
|
209
|
+
end
|
210
|
+
end
|
211
|
+
|
212
|
+
|
213
|
+
def generate_rake_tasks(rakefile, theProjDir)
|
214
|
+
puts " #{rakefile}"
|
215
|
+
template_filepath = Pathname.new(File.dirname(__FILE__)).parent.parent + './templates/rake.erb'
|
216
|
+
template_source = File.read(template_filepath)
|
217
|
+
|
218
|
+
# Create one template engine instance
|
219
|
+
engine = ERB.new(template_source)
|
220
|
+
|
221
|
+
source_dir = File.absolute_path(Dir.getwd())
|
222
|
+
proj_dir = File.absolute_path(theProjDir)
|
223
|
+
anonymous = anonymous_features.map {|ff| ff.basename}
|
224
|
+
feature_ids = feature_files.map { |ff| ff.feature.identifier }
|
225
|
+
feature_ids.compact!
|
226
|
+
deps = dependencies.reject {|dep| dep.dependee.feature.anonymous?}
|
227
|
+
|
228
|
+
# Generate the text representation with given context
|
229
|
+
file_source = engine.result(binding)
|
230
|
+
File.open(rakefile, 'w') { |f| f.write(file_source) }
|
231
|
+
end
|
232
|
+
|
233
|
+
|
234
|
+
protected
|
235
|
+
def validated_model(theFeatureFiles)
|
236
|
+
return theFeatureFiles
|
237
|
+
end
|
238
|
+
|
239
|
+
# Build the mapping: feature identifier => feature
|
240
|
+
def id2features()
|
241
|
+
mapping = feature_files.each_with_object({}) do |file, mp|
|
242
|
+
feature_id = file.feature.identifier
|
243
|
+
mp[feature_id] = file unless feature_id.nil?
|
244
|
+
end
|
245
|
+
|
246
|
+
return mapping
|
247
|
+
end
|
248
|
+
|
249
|
+
# Given a feature identifier => feature mapping,
|
250
|
+
# resolve the dependency tags; that is,
|
251
|
+
# Establish links between a feature file object and its
|
252
|
+
# dependent feature file objects.
|
253
|
+
def resolve_dependencies(aMapping)
|
254
|
+
@dependencies = []
|
255
|
+
|
256
|
+
feature_files.each do |feature_file|
|
257
|
+
feature = feature_file.feature
|
258
|
+
its_id = feature.identifier
|
259
|
+
dep_tags = feature.dependency_tags
|
260
|
+
# Complain when self dependency detected
|
261
|
+
if dep_tags.include?(its_id)
|
262
|
+
msg = "Feature #{} with identifier #{its_id} depends on itself!"
|
263
|
+
fail(StandardError, msg)
|
264
|
+
end
|
265
|
+
|
266
|
+
# Complain when dependency tag refers to an unknown feature
|
267
|
+
dependents = dep_tags.map do |a_tag|
|
268
|
+
unless aMapping.include?(a_tag)
|
269
|
+
msg = "Feature with identifier '#{its_id}' depends on unknown feature '#{a_tag}'"
|
270
|
+
fail(StandardError, msg)
|
271
|
+
end
|
272
|
+
aMapping[a_tag]
|
273
|
+
end
|
274
|
+
|
275
|
+
@dependencies << FeatureDependencies.new(feature_file, dependents)
|
276
|
+
end
|
277
|
+
|
278
|
+
return @dependencies
|
279
|
+
end
|
280
|
+
|
281
|
+
end # class
|
282
|
+
|
283
|
+
end # module
|
284
|
+
|
285
|
+
# end of file
|
@@ -0,0 +1,49 @@
|
|
1
|
+
# File: feature-rep.rb
|
2
|
+
|
3
|
+
module Cukedep # This module is used as a namespace
|
4
|
+
|
5
|
+
|
6
|
+
# A FeatureRep is the internal representation of a Gherkin feature.
|
7
|
+
class FeatureRep
|
8
|
+
# Constant that specifies how feature identifier tags should begin
|
9
|
+
FeatureIdPrefix = /^feature:/
|
10
|
+
|
11
|
+
# Constant that specifies how dependency tags should begin
|
12
|
+
DependencyPrefix = /^depends_on:/
|
13
|
+
|
14
|
+
# The sorted list of all tags of the feature.
|
15
|
+
# The @ prefix is stripped from each tag text.
|
16
|
+
attr_reader(:tags)
|
17
|
+
|
18
|
+
# The identifier of the feature.
|
19
|
+
# It comes from a tag with the following syntax '@feature_' + identifier.
|
20
|
+
# Note that the @feature_ prefix is removed.
|
21
|
+
attr_reader(:identifier)
|
22
|
+
|
23
|
+
|
24
|
+
# theTags the tags objects from the Gherkin parser
|
25
|
+
def initialize(theTags)
|
26
|
+
# Strip first character of tag literal.
|
27
|
+
@tags = theTags.map { |t| t[1..-1] }
|
28
|
+
|
29
|
+
@identifier = tags.find { |tg| tg =~ FeatureIdPrefix }
|
30
|
+
@identifier = @identifier.sub(FeatureIdPrefix, '') unless identifier.nil?
|
31
|
+
end
|
32
|
+
|
33
|
+
public
|
34
|
+
# The list of all feature identifiers retrieved from the dependency tags
|
35
|
+
def dependency_tags()
|
36
|
+
dep_tags = tags.select { |t| t =~ DependencyPrefix }
|
37
|
+
return dep_tags.map { |t| t.sub(DependencyPrefix, '') }
|
38
|
+
end
|
39
|
+
|
40
|
+
# Return true iff the identifier of the feature is nil.
|
41
|
+
def anonymous?()
|
42
|
+
return identifier.nil?
|
43
|
+
end
|
44
|
+
|
45
|
+
end # class
|
46
|
+
|
47
|
+
end # module
|
48
|
+
|
49
|
+
# End of file
|
@@ -0,0 +1,98 @@
|
|
1
|
+
# File: gherkin-listener.rb
|
2
|
+
|
3
|
+
require_relative 'feature-rep'
|
4
|
+
|
5
|
+
|
6
|
+
module Cukedep # This module is used as a namespace
|
7
|
+
|
8
|
+
|
9
|
+
class FeatureFileRep
|
10
|
+
attr_reader(:filepath)
|
11
|
+
attr(:feature, true)
|
12
|
+
|
13
|
+
def initialize(aFilepath)
|
14
|
+
@filepath = aFilepath
|
15
|
+
end
|
16
|
+
|
17
|
+
def basename()
|
18
|
+
File.basename(filepath)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
# A ParserListener listens to all the formatting events
|
23
|
+
# emitted by the Gherkin parser.
|
24
|
+
# It converts the received the feature file elements and builds
|
25
|
+
# a representation of the feature files that is appropriate
|
26
|
+
# for the Cukedep application.
|
27
|
+
class GherkinListener
|
28
|
+
# The list of feature files encountered so far
|
29
|
+
attr_reader(:feature_files)
|
30
|
+
|
31
|
+
# Internal representation of the feature being parsed
|
32
|
+
attr(:current_feature, true)
|
33
|
+
|
34
|
+
def initialize()
|
35
|
+
@feature_files = []
|
36
|
+
end
|
37
|
+
|
38
|
+
######################################
|
39
|
+
# Event listening methods
|
40
|
+
######################################
|
41
|
+
|
42
|
+
# Called when beginning the parsing of a feature file.
|
43
|
+
# featureURI: path + filename of feature file.
|
44
|
+
def uri(featureURI)
|
45
|
+
new_file = FeatureFileRep.new(featureURI)
|
46
|
+
feature_files << new_file
|
47
|
+
end
|
48
|
+
|
49
|
+
# aFeature is a Gherkin::Formatter::Model::Feature instance
|
50
|
+
def feature(aFeature)
|
51
|
+
tag_names = aFeature.tags.map(&:name)
|
52
|
+
@current_feature = feature_files.last.feature = FeatureRep.new(tag_names)
|
53
|
+
end
|
54
|
+
|
55
|
+
# aBackground is a Gherkin::Formatter::Model::Background instance
|
56
|
+
def background(aBackground)
|
57
|
+
; # Do nothing
|
58
|
+
end
|
59
|
+
|
60
|
+
# aScenario is a Gherkin::Formatter::Model::Scenario instance
|
61
|
+
def scenario(aScenario)
|
62
|
+
; # Do nothing
|
63
|
+
end
|
64
|
+
|
65
|
+
# aScenarioOutline is a Gherkin::Formatter::Model::ScenarioOutline instance
|
66
|
+
def scenario_outline(aScenarioOutline)
|
67
|
+
; # Do nothing
|
68
|
+
end
|
69
|
+
|
70
|
+
# theExamples is a Gherkin::Formatter::Model::Examples instance
|
71
|
+
def examples(theExamples)
|
72
|
+
; # Do nothing
|
73
|
+
end
|
74
|
+
|
75
|
+
# aStep is a Gherkin::Formatter::Model::Step instance
|
76
|
+
def step(aStep)
|
77
|
+
; # Do nothing
|
78
|
+
end
|
79
|
+
|
80
|
+
# End of feature file notification.
|
81
|
+
def eof()
|
82
|
+
; # Do nothing
|
83
|
+
end
|
84
|
+
|
85
|
+
|
86
|
+
# Catch all method
|
87
|
+
def method_missing(message, *args)
|
88
|
+
puts "Method #{message} is not implemented (yet)."
|
89
|
+
end
|
90
|
+
|
91
|
+
end # class
|
92
|
+
|
93
|
+
end # module
|
94
|
+
|
95
|
+
|
96
|
+
|
97
|
+
|
98
|
+
# End of file
|