epub_tools 0.3.1 → 0.4.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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +2 -0
  3. data/.rubocop.yml +41 -0
  4. data/Gemfile +13 -9
  5. data/Gemfile.lock +49 -0
  6. data/README.md +16 -0
  7. data/bin/epub-tools +3 -109
  8. data/epub_tools.gemspec +6 -8
  9. data/lib/epub_tools/add_chapters.rb +41 -19
  10. data/lib/epub_tools/cli/command_registry.rb +47 -0
  11. data/lib/epub_tools/cli/option_builder.rb +164 -0
  12. data/lib/epub_tools/cli/runner.rb +164 -0
  13. data/lib/epub_tools/cli.rb +45 -0
  14. data/lib/epub_tools/compile_book.rb +60 -28
  15. data/lib/epub_tools/epub_initializer.rb +38 -28
  16. data/lib/epub_tools/loggable.rb +11 -0
  17. data/lib/epub_tools/pack_ebook.rb +18 -12
  18. data/lib/epub_tools/split_chapters.rb +31 -21
  19. data/lib/epub_tools/{text_style_class_finder.rb → style_finder.rb} +21 -17
  20. data/lib/epub_tools/unpack_ebook.rb +17 -12
  21. data/lib/epub_tools/version.rb +1 -1
  22. data/lib/epub_tools/xhtml_cleaner.rb +17 -13
  23. data/lib/epub_tools/xhtml_extractor.rb +20 -11
  24. data/lib/epub_tools.rb +2 -1
  25. data/test/add_chapters_test.rb +12 -5
  26. data/test/cli/command_registry_test.rb +66 -0
  27. data/test/cli/option_builder_test.rb +173 -0
  28. data/test/cli/runner_test.rb +91 -0
  29. data/test/cli_commands_test.rb +100 -0
  30. data/test/cli_test.rb +4 -0
  31. data/test/cli_version_test.rb +5 -3
  32. data/test/compile_book_test.rb +11 -2
  33. data/test/epub_initializer_test.rb +51 -31
  34. data/test/pack_ebook_test.rb +14 -8
  35. data/test/split_chapters_test.rb +22 -1
  36. data/test/{text_style_class_finder_test.rb → style_finder_test.rb} +7 -6
  37. data/test/test_helper.rb +4 -5
  38. data/test/unpack_ebook_test.rb +21 -5
  39. data/test/xhtml_cleaner_test.rb +13 -7
  40. data/test/xhtml_extractor_test.rb +17 -1
  41. metadata +19 -36
  42. data/lib/epub_tools/cli_helper.rb +0 -31
@@ -1,42 +1,48 @@
1
1
  #!/usr/bin/env ruby
2
2
  require 'nokogiri'
3
3
  require 'yaml'
4
- require_relative 'text_style_class_finder'
4
+ require 'fileutils'
5
+ require_relative 'loggable'
6
+ require_relative 'style_finder'
5
7
  require_relative 'xhtml_cleaner'
6
8
 
7
9
  module EpubTools
8
10
  # Takes a Google Docs generated, already extracted from their EPUB, XHTML files with multiple
9
11
  # chapters and it:
10
- # - Extracts classes using {TextStyleClassFinder}[rdoc-ref:EpubTools::TextStyleClassFinder]
12
+ # - Extracts classes using {StyleFinder}[rdoc-ref:EpubTools::StyleFinder]
11
13
  # - Looks for tags that say something like Chapter XX or Prologue and splits the text there
12
14
  # - Creates new chapter_XX.xhtml files that are cleaned using
13
15
  # {XHTMLCleaner}[rdoc-ref:EpubTools::XHTMLCleaner]
14
16
  # - Saves those files to +output_dir+
15
17
  class SplitChapters
16
- # [input_file] path to the source XHTML
17
- # [book_title] title to use in HTML <title> tags
18
- # [output_dir] where to write chapter files
19
- # [output_prefix] filename prefix. Defaults to 'chapter' and you should never need to change it
20
- # [verbose] whether to print progress to STDOUT.
21
- def initialize(input_file, book_title, output_dir = './chapters', output_prefix = 'chapter', verbose = false)
22
- @input_file = input_file
23
- @book_title = book_title
24
- @output_dir = output_dir
25
- @output_prefix = output_prefix
26
- @verbose = verbose
18
+ include Loggable
19
+ # Initializes the class
20
+ # @param options [Hash] Configuration options
21
+ # @option options [String] :input_file Path to the source XHTML (required)
22
+ # @option options [String] :book_title Title to use in HTML <title> tags (required)
23
+ # @option options [String] :output_dir Where to write chapter files (default: './chapters')
24
+ # @option options [String] :output_prefix Filename prefix for chapter files (default: 'chapter')
25
+ # @option options [Boolean] :verbose Whether to print progress to STDOUT (default: false)
26
+ def initialize(options = {})
27
+ @input_file = options.fetch(:input_file)
28
+ @book_title = options.fetch(:book_title)
29
+ @output_dir = options[:output_dir] || './chapters'
30
+ @output_prefix = options[:output_prefix] || 'chapter'
31
+ @verbose = options[:verbose] || false
27
32
  end
28
33
 
29
34
  # Runs the splitter
35
+ # @return [Array<String>] List of generated chapter file paths
30
36
  def run
31
37
  # Prepare output dir
32
- Dir.mkdir(@output_dir) unless Dir.exist?(@output_dir)
38
+ FileUtils.mkdir_p(@output_dir)
33
39
 
34
40
  # Read the doc
35
41
  raw_content = read_and_strip_problematic_tags
36
42
  doc = Nokogiri::HTML(raw_content)
37
43
 
38
44
  # Find Style Classes
39
- TextStyleClassFinder.new(@input_file, verbose: @verbose).call
45
+ StyleFinder.new({ file_path: @input_file, verbose: @verbose }).run
40
46
 
41
47
  chapters = extract_chapters(doc)
42
48
  write_chapter_files(chapters)
@@ -45,7 +51,7 @@ module EpubTools
45
51
  private
46
52
 
47
53
  def read_and_strip_problematic_tags
48
- File.read(@input_file).gsub(/<hr\b[^>]*\/?>/i, '').gsub(/<br\b[^>]*\/?>/i, '')
54
+ File.read(@input_file).gsub(%r{<hr\b[^>]*/?>}i, '').gsub(%r{<br\b[^>]*/?>}i, '')
49
55
  end
50
56
 
51
57
  def extract_chapters(doc)
@@ -74,9 +80,12 @@ module EpubTools
74
80
  end
75
81
 
76
82
  def write_chapter_files(chapters)
83
+ chapter_files = []
77
84
  chapters.each do |number, content|
78
- write_chapter_file(number, content)
85
+ filename = write_chapter_file(number, content)
86
+ chapter_files << filename
79
87
  end
88
+ chapter_files
80
89
  end
81
90
 
82
91
  def write_chapter_file(label, content)
@@ -95,20 +104,21 @@ module EpubTools
95
104
  </body>
96
105
  </html>
97
106
  HTML
98
- XHTMLCleaner.new(filename).call
99
- puts "Extracted: #{filename}" if @verbose
107
+ XHTMLCleaner.new({ filename: filename }).run
108
+ log("Extracted: #{filename}")
109
+ filename
100
110
  end
101
111
 
102
112
  def display_label(label)
103
- label > 0 ? "Chapter #{label}" : "Prologue"
113
+ label.positive? ? "Chapter #{label}" : 'Prologue'
104
114
  end
105
115
 
106
116
  # Detect a bolded Prologue marker
107
117
  def prologue_marker?(node)
108
118
  return false unless %w[h3 h4].include?(node.name)
109
119
  return false unless node.text.strip =~ /\APrologue\z/i
120
+
110
121
  true
111
122
  end
112
-
113
123
  end
114
124
  end
@@ -1,24 +1,29 @@
1
1
  #!/usr/bin/env ruby
2
2
  require 'nokogiri'
3
3
  require 'yaml'
4
+ require_relative 'loggable'
4
5
 
5
6
  module EpubTools
6
7
  # Finds css classes for bold and italic texts in Google Docs-generated EPUBs. Used by
7
8
  # {XHTMLCleaner}[rdoc-ref:EpubTools::XHTMLCleaner] and
8
9
  # {SplitChapters}[rdoc-ref:EpubTools::SplitChapters].
9
- class TextStyleClassFinder
10
- # [file_path] XHTML file to be analyzed.
11
- # [output_path] Defaults to +text_style_classes.yaml+. You should never need to change this.
12
- # [verbose] Whether to print progress or not
13
- def initialize(file_path, output_path = 'text_style_classes.yaml', verbose: false)
14
- @file_path = file_path
15
- @output_path = output_path
16
- @verbose = verbose
10
+ class StyleFinder
11
+ include Loggable
12
+ # Initializes the class
13
+ # @param options [Hash] Configuration options
14
+ # @option options [String] :file_path XHTML file to be analyzed (required)
15
+ # @option options [String] :output_path Path to write the YAML file (default: 'text_style_classes.yaml')
16
+ # @option options [Boolean] :verbose Whether to print progress to STDOUT (default: false)
17
+ def initialize(options = {})
18
+ @file_path = options.fetch(:file_path)
19
+ @output_path = options[:output_path] || 'text_style_classes.yaml'
20
+ @verbose = options[:verbose] || false
17
21
  raise ArgumentError, "File does not exist: #{@file_path}" unless File.exist?(@file_path)
18
22
  end
19
23
 
20
24
  # Runs the finder
21
- def call
25
+ # @return [Hash] Data containing the extracted style classes (italics and bolds)
26
+ def run
22
27
  doc = Nokogiri::HTML(File.read(@file_path))
23
28
  style_blocks = doc.xpath('//style').map(&:text).join("\n")
24
29
 
@@ -28,10 +33,11 @@ module EpubTools
28
33
  print_summary(italics, bolds) if @verbose
29
34
 
30
35
  data = {
31
- "italics" => italics,
32
- "bolds" => bolds
36
+ 'italics' => italics,
37
+ 'bolds' => bolds
33
38
  }
34
39
  File.write(@output_path, data.to_yaml)
40
+ data
35
41
  end
36
42
 
37
43
  private
@@ -42,13 +48,11 @@ module EpubTools
42
48
  end
43
49
 
44
50
  def print_summary(italics, bolds)
45
- unless italics.empty?
46
- puts "Classes with font-style: italic: #{italics.join(", ")}"
47
- end
51
+ log "Classes with font-style: italic: #{italics.join(', ')}" unless italics.empty?
48
52
 
49
- unless bolds.empty?
50
- puts "Classes with font-weight: 700: #{bolds.join(", ")}"
51
- end
53
+ return if bolds.empty?
54
+
55
+ log "Classes with font-weight: 700: #{bolds.join(', ')}"
52
56
  end
53
57
  end
54
58
  end
@@ -1,16 +1,21 @@
1
1
  require 'zip'
2
2
  require 'fileutils'
3
+ require_relative 'loggable'
3
4
 
4
5
  module EpubTools
5
6
  # Unpacks an EPUB (.epub file) into a directory
6
7
  class UnpackEbook
7
- # [epub_file] path to the .epub file
8
- # [output_dir] Directory to extract into; defaults to basename of epub_file without .epub
9
- # [verbose] Whether to log things to $stdout while the class runs or not
10
- def initialize(epub_file, output_dir = nil, verbose: false)
11
- @epub_file = File.expand_path(epub_file)
12
- @output_dir = (output_dir.nil? || output_dir.empty?) ? default_dir: output_dir
13
- @verbose = verbose
8
+ include Loggable
9
+ # Initializes the class
10
+ # @param options [Hash] Configuration options
11
+ # @option options [String] :epub_file Path to the .epub file to unpack (required)
12
+ # @option options [String] :output_dir Directory to extract into (default: basename of epub_file without .epub)
13
+ # @option options [Boolean] :verbose Whether to print progress to STDOUT (default: false)
14
+ def initialize(options = {})
15
+ @epub_file = File.expand_path(options.fetch(:epub_file))
16
+ output_dir = options[:output_dir]
17
+ @output_dir = output_dir.nil? || output_dir.empty? ? default_dir : output_dir
18
+ @verbose = options[:verbose] || false
14
19
  end
15
20
 
16
21
  # Extracts all entries from the EPUB into the output directory. Returns the output
@@ -29,20 +34,20 @@ module EpubTools
29
34
  end
30
35
  end
31
36
  end
32
- puts "Unpacked #{File.basename(@epub_file)} to #{@output_dir}" if @verbose
37
+ log "Unpacked #{File.basename(@epub_file)} to #{@output_dir}"
33
38
  @output_dir
34
39
  end
35
40
 
36
41
  private
37
42
 
38
43
  def default_dir
39
- [File.dirname(@epub_file), File.basename(@epub_file, '.epub')].join("/")
44
+ [File.dirname(@epub_file), File.basename(@epub_file, '.epub')].join('/')
40
45
  end
41
46
 
42
47
  def validate!
43
- unless File.file?(@epub_file)
44
- raise ArgumentError, "EPUB file '#{@epub_file}' does not exist"
45
- end
48
+ return if File.file?(@epub_file)
49
+
50
+ raise ArgumentError, "EPUB file '#{@epub_file}' does not exist"
46
51
  end
47
52
  end
48
53
  end
@@ -1,4 +1,4 @@
1
1
  module EpubTools
2
2
  # Ruby Gem version number
3
- VERSION = '0.3.1'
3
+ VERSION = '0.4.0'.freeze
4
4
  end
@@ -16,17 +16,20 @@ module EpubTools
16
16
  # - Unwraps any <tt><span></tt> tags that have no classes assigned.
17
17
  # - Outputs everything to a cleanly formatted +.xhtml+
18
18
  class XHTMLCleaner
19
- # [filename] The path to the xhtml to clean
20
- # [class_config] A YAML containing the bold and italic classes to check. It defaults to
21
- # +text_style_classes.yaml+ since that's the one that
22
- # {TextStyleClassFinder}[rdoc-ref:EpubTools::TextStyleClassFinder] uses.
23
- def initialize(filename, class_config = 'text_style_classes.yaml')
24
- @filename = filename
19
+ # Initializes the class
20
+ # @param options [Hash] Configuration options
21
+ # @option options [String] :filename The path to the xhtml to clean (required)
22
+ # @option options [String] :class_config Path to a YAML file containing the bold and italic classes to check
23
+ # (default: 'text_style_classes.yaml')
24
+ def initialize(options = {})
25
+ @filename = options.fetch(:filename)
26
+ class_config = options[:class_config] || 'text_style_classes.yaml'
25
27
  @classes = YAML.load_file(class_config).transform_keys(&:to_sym)
26
28
  end
27
29
 
28
- # Calls the service class
29
- def call
30
+ # Runs the cleaner
31
+ # @return [String] Path to the cleaned file
32
+ def run
30
33
  raw_content = read_and_strip_problematic_hr
31
34
  doc = parse_xml(raw_content)
32
35
  remove_empty_paragraphs(doc)
@@ -34,24 +37,25 @@ module EpubTools
34
37
  replace_italic_spans(doc)
35
38
  unwrap_remaining_spans(doc)
36
39
  write_pretty_output(doc)
40
+ @filename
37
41
  end
38
42
 
39
43
  private
40
44
 
41
45
  def read_and_strip_problematic_hr
42
- File.read(@filename).gsub(/<hr\b[^>]*\/?>/i, '').gsub(/<br\b[^>]*\/?>/i, '')
46
+ File.read(@filename).gsub(%r{<hr\b[^>]*/?>}i, '').gsub(%r{<br\b[^>]*/?>}i, '')
43
47
  end
44
48
 
45
49
  def parse_xml(content)
46
50
  Nokogiri::XML(content) { |config| config.default_xml.noblanks }
47
- rescue => e
51
+ rescue StandardError => e
48
52
  abort "Error parsing XML: #{e.message}"
49
53
  end
50
54
 
51
55
  def remove_empty_paragraphs(doc)
52
56
  doc.css('p').each do |p|
53
57
  content = p.inner_html.strip
54
- if content.empty? || content =~ /\A(<span[^>]*>\s*<\/span>\s*)+\z/
58
+ if content.empty? || content =~ %r{\A(<span[^>]*>\s*</span>\s*)+\z}
55
59
  p.remove
56
60
  else
57
61
  p.remove_attribute('class')
@@ -70,14 +74,14 @@ module EpubTools
70
74
  def replace_italic_spans(doc)
71
75
  @classes[:italics].each do |class_name|
72
76
  doc.css("span.#{class_name}").each do |node|
73
- node.name = "i"
77
+ node.name = 'i'
74
78
  node.remove_attribute('class')
75
79
  end
76
80
  end
77
81
  end
78
82
 
79
83
  def unwrap_remaining_spans(doc)
80
- doc.css("span").each do |span|
84
+ doc.css('span').each do |span|
81
85
  span.add_next_sibling(span.dup.content)
82
86
  span.remove
83
87
  end
@@ -1,24 +1,32 @@
1
1
  require 'zip'
2
2
  require 'fileutils'
3
+ require_relative 'loggable'
3
4
 
4
5
  module EpubTools
5
6
  # Extracts text .xhtml files from EPUB archives, excluding nav.xhtml
6
7
  class XHTMLExtractor
7
- # [source_dir] Directory that has source .epub files
8
- # [target_dir] Directory where the extracted .xhtml files will be copied to
9
- # [verbose] Whether to print progress to +STDOUT+ or not
10
- def initialize(source_dir:, target_dir:, verbose: false)
11
- @source_dir = File.expand_path(source_dir)
12
- @target_dir = File.expand_path(target_dir)
13
- @verbose = verbose
8
+ include Loggable
9
+ # Initializes the class
10
+ # @param options [Hash] Configuration options
11
+ # @option options [String] :source_dir Directory containing source .epub files (required)
12
+ # @option options [String] :target_dir Directory where .xhtml files will be extracted (required)
13
+ # @option options [Boolean] :verbose Whether to print progress to STDOUT (default: false)
14
+ def initialize(options = {})
15
+ @source_dir = File.expand_path(options.fetch(:source_dir))
16
+ @target_dir = File.expand_path(options.fetch(:target_dir))
17
+ @verbose = options[:verbose] || false
14
18
  FileUtils.mkdir_p(@target_dir)
15
19
  end
16
20
 
17
21
  # Runs the extraction process
18
- def extract_all
22
+ # @return [Array<String>] Paths to all extracted XHTML files
23
+ def run
24
+ all_extracted_files = []
19
25
  epub_files.each do |epub_path|
20
- extract_xhtmls_from(epub_path)
26
+ extracted = extract_xhtmls_from(epub_path)
27
+ all_extracted_files.concat(extracted) if extracted
21
28
  end
29
+ all_extracted_files
22
30
  end
23
31
 
24
32
  private
@@ -29,16 +37,17 @@ module EpubTools
29
37
 
30
38
  def extract_xhtmls_from(epub_path)
31
39
  epub_name = File.basename(epub_path, '.epub')
32
- puts "Extracting from #{epub_name}.epub" if @verbose
40
+ log "Extracting from #{epub_name}.epub"
33
41
  extracted_files = []
34
42
  Zip::File.open(epub_path) do |zip_file|
35
43
  zip_file.each do |entry|
36
44
  next unless entry.name.downcase.end_with?('.xhtml')
37
45
  next if File.basename(entry.name).downcase == 'nav.xhtml'
46
+
38
47
  output_path = File.join(@target_dir, "#{epub_name}_#{File.basename(entry.name)}")
39
48
  FileUtils.mkdir_p(File.dirname(output_path))
40
49
  entry.extract(output_path) { true }
41
- puts output_path if @verbose
50
+ log output_path
42
51
  extracted_files << output_path
43
52
  end
44
53
  end
data/lib/epub_tools.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  require_relative 'epub_tools/version'
2
+ require_relative 'epub_tools/loggable'
2
3
  require_relative 'epub_tools/add_chapters'
3
- require_relative 'epub_tools/cli_helper'
4
4
  require_relative 'epub_tools/epub_initializer'
5
5
  require_relative 'epub_tools/split_chapters'
6
6
  require_relative 'epub_tools/xhtml_cleaner'
@@ -8,6 +8,7 @@ require_relative 'epub_tools/xhtml_extractor'
8
8
  require_relative 'epub_tools/pack_ebook'
9
9
  require_relative 'epub_tools/unpack_ebook'
10
10
  require_relative 'epub_tools/compile_book'
11
+ require_relative 'epub_tools/cli'
11
12
 
12
13
  # Wrapper for all the other classes
13
14
  module EpubTools
@@ -54,12 +54,18 @@ class AddChaptersTest < Minitest::Test
54
54
 
55
55
  def test_run_moves_files_and_updates_opf_and_nav
56
56
  # Run the add chapters task
57
- EpubTools::AddChapters.new(@chapters_dir, @epub_dir).run
57
+ result = EpubTools::AddChapters.new(chapters_dir: @chapters_dir, epub_dir: @epub_dir).run
58
+
59
+ # Check return value is an array of moved file basenames
60
+ assert_instance_of Array, result
61
+ assert_equal 2, result.size
62
+ assert_includes result, 'chapter_0.xhtml'
63
+ assert_includes result, 'chapter_1.xhtml'
58
64
 
59
65
  # Original chapter files should be moved
60
66
  assert_empty Dir.glob(File.join(@chapters_dir, '*.xhtml'))
61
- assert File.exist?(File.join(@epub_dir, 'chapter_0.xhtml'))
62
- assert File.exist?(File.join(@epub_dir, 'chapter_1.xhtml'))
67
+ assert_path_exists File.join(@epub_dir, 'chapter_0.xhtml')
68
+ assert_path_exists File.join(@epub_dir, 'chapter_1.xhtml')
63
69
 
64
70
  # package.opf should include manifest items and spine refs
65
71
  doc = Nokogiri::XML(File.read(@opf_file)) { |cfg| cfg.default_xml.noblanks }
@@ -81,12 +87,13 @@ class AddChaptersTest < Minitest::Test
81
87
  # strip namespaces for easy querying
82
88
  nav_doc.remove_namespaces!
83
89
  links = nav_doc.xpath('//nav/ol/li/a')
90
+
84
91
  assert_equal 2, links.size
85
92
  # First is Prologue (chapter_0)
86
93
  assert_equal 'chapter_0.xhtml', links[0]['href']
87
- assert_equal 'Prologue', links[0].text
94
+ assert_equal 'Prologue', links[0].text
88
95
  # Second is Chapter 1
89
96
  assert_equal 'chapter_1.xhtml', links[1]['href']
90
- assert_equal 'Chapter 1', links[1].text
97
+ assert_equal 'Chapter 1', links[1].text
91
98
  end
92
99
  end
@@ -0,0 +1,66 @@
1
+ require_relative '../test_helper'
2
+ require_relative '../../lib/epub_tools'
3
+
4
+ class CommandRegistryTest < Minitest::Test
5
+ class DummyCommand
6
+ attr_reader :options
7
+
8
+ def initialize(options)
9
+ @options = options
10
+ end
11
+
12
+ def run
13
+ true
14
+ end
15
+ end
16
+
17
+ def setup
18
+ @registry = EpubTools::CLI::CommandRegistry.new
19
+ end
20
+
21
+ def test_registry_initializes_empty
22
+ assert_empty @registry.commands
23
+ assert_empty @registry.available_commands
24
+ end
25
+
26
+ def test_register_command
27
+ @registry.register('test', DummyCommand, [:required_option], { default: 'value' })
28
+
29
+ assert_equal 1, @registry.commands.size
30
+ assert_includes @registry.available_commands, 'test'
31
+
32
+ command = @registry.get('test')
33
+
34
+ assert_equal DummyCommand, command[:class]
35
+ assert_equal [:required_option], command[:required_keys]
36
+ assert_equal({ default: 'value' }, command[:default_options])
37
+ end
38
+
39
+ def test_register_multiple_commands
40
+ @registry.register('test1', DummyCommand)
41
+ @registry.register('test2', DummyCommand)
42
+
43
+ assert_equal 2, @registry.commands.size
44
+ assert_includes @registry.available_commands, 'test1'
45
+ assert_includes @registry.available_commands, 'test2'
46
+ end
47
+
48
+ def test_get_nonexistent_command
49
+ assert_nil @registry.get('nonexistent')
50
+ end
51
+
52
+ def test_command_exists
53
+ @registry.register('test', DummyCommand)
54
+
55
+ assert @registry.command_exists?('test')
56
+ refute @registry.command_exists?('nonexistent')
57
+ end
58
+
59
+ def test_chained_registration
60
+ result = @registry.register('test1', DummyCommand)
61
+ .register('test2', DummyCommand)
62
+
63
+ assert_equal @registry, result
64
+ assert_equal 2, @registry.commands.size
65
+ end
66
+ end