loading
Generated 2023-10-12T13:50:07+00:00

All Files ( 99.91% covered at 8.13 hits/line )

90 files in total.
3281 relevant lines, 3278 lines covered and 3 lines missed. ( 99.91% )
190 total branches, 180 branches covered and 10 branches missed. ( 94.74% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line Branch Coverage Branches Covered branches Missed branches
lib/sheetah.rb 100.00 % 31 5 5 0 1.00 100.00 % 0 0 0
lib/sheetah/attribute.rb 97.78 % 96 45 44 1 12.60 63.64 % 11 7 4
lib/sheetah/backends.rb 100.00 % 27 14 14 0 4.14 100.00 % 2 2 0
lib/sheetah/backends/csv.rb 100.00 % 130 58 58 0 8.71 100.00 % 16 16 0
lib/sheetah/backends/wrapper.rb 100.00 % 57 29 29 0 14.79 100.00 % 8 8 0
lib/sheetah/backends/xlsx.rb 100.00 % 94 47 47 0 9.83 100.00 % 14 14 0
lib/sheetah/backends_registry.rb 100.00 % 27 13 13 0 8.00 100.00 % 2 2 0
lib/sheetah/column.rb 100.00 % 29 13 13 0 37.38 100.00 % 0 0 0
lib/sheetah/errors/error.rb 100.00 % 47 25 25 0 9.24 100.00 % 4 4 0
lib/sheetah/errors/spec_error.rb 100.00 % 10 4 4 0 1.00 100.00 % 0 0 0
lib/sheetah/errors/type_error.rb 100.00 % 10 4 4 0 1.00 100.00 % 0 0 0
lib/sheetah/headers.rb 100.00 % 87 46 46 0 15.17 100.00 % 14 14 0
lib/sheetah/messaging.rb 100.00 % 195 89 89 0 32.94 100.00 % 11 11 0
lib/sheetah/row_processor.rb 100.00 % 41 19 19 0 16.05 100.00 % 0 0 0
lib/sheetah/row_processor_result.rb 100.00 % 20 9 9 0 8.56 100.00 % 0 0 0
lib/sheetah/row_value_builder.rb 100.00 % 53 30 30 0 29.07 100.00 % 4 4 0
lib/sheetah/sheet.rb 100.00 % 103 53 53 0 40.17 100.00 % 2 2 0
lib/sheetah/sheet/col_converter.rb 100.00 % 62 34 34 0 164.56 100.00 % 7 7 0
lib/sheetah/sheet_processor.rb 100.00 % 61 33 33 0 8.06 100.00 % 2 2 0
lib/sheetah/sheet_processor_result.rb 100.00 % 18 8 8 0 7.38 100.00 % 0 0 0
lib/sheetah/specification.rb 96.67 % 64 30 29 1 22.20 100.00 % 10 10 0
lib/sheetah/template.rb 95.83 % 64 24 23 1 8.67 50.00 % 2 1 1
lib/sheetah/template_config.rb 100.00 % 22 11 11 0 21.00 100.00 % 2 2 0
lib/sheetah/types/cast.rb 100.00 % 20 9 9 0 2.78 100.00 % 0 0 0
lib/sheetah/types/cast_chain.rb 100.00 % 49 26 26 0 30.35 100.00 % 2 2 0
lib/sheetah/types/composites/array.rb 100.00 % 15 7 7 0 4.00 100.00 % 2 2 0
lib/sheetah/types/composites/array_compact.rb 100.00 % 13 6 6 0 1.00 100.00 % 0 0 0
lib/sheetah/types/composites/composite.rb 100.00 % 32 16 16 0 12.31 100.00 % 2 2 0
lib/sheetah/types/container.rb 100.00 % 81 45 45 0 10.89 100.00 % 4 4 0
lib/sheetah/types/scalars/boolsy.rb 100.00 % 12 6 6 0 1.00 100.00 % 0 0 0
lib/sheetah/types/scalars/boolsy_cast.rb 100.00 % 33 19 19 0 2.32 100.00 % 4 4 0
lib/sheetah/types/scalars/date_string.rb 100.00 % 12 6 6 0 1.00 100.00 % 0 0 0
lib/sheetah/types/scalars/date_string_cast.rb 100.00 % 43 23 23 0 3.04 100.00 % 7 7 0
lib/sheetah/types/scalars/email.rb 100.00 % 12 6 6 0 1.00 100.00 % 0 0 0
lib/sheetah/types/scalars/email_cast.rb 100.00 % 29 15 15 0 3.40 100.00 % 2 2 0
lib/sheetah/types/scalars/scalar.rb 100.00 % 29 15 15 0 11.40 100.00 % 2 2 0
lib/sheetah/types/scalars/scalar_cast.rb 100.00 % 47 24 24 0 19.25 100.00 % 8 8 0
lib/sheetah/types/scalars/string.rb 100.00 % 17 7 7 0 5.43 100.00 % 2 2 0
lib/sheetah/types/type.rb 100.00 % 103 53 53 0 13.43 100.00 % 10 10 0
lib/sheetah/utils/cell_string_cleaner.rb 100.00 % 29 16 16 0 11.94 100.00 % 0 0 0
lib/sheetah/utils/monadic_result.rb 100.00 % 174 82 82 0 12.29 100.00 % 10 10 0
spec/sheetah/attribute_spec.rb 100.00 % 7 3 3 0 1.00 100.00 % 0 0 0
spec/sheetah/backends/csv_spec.rb 100.00 % 271 134 134 0 3.51 100.00 % 2 2 0
spec/sheetah/backends/wrapper_spec.rb 100.00 % 159 79 79 0 7.37 100.00 % 6 6 0
spec/sheetah/backends/xlsx_spec.rb 100.00 % 160 78 78 0 1.64 100.00 % 0 0 0
spec/sheetah/backends_registry_spec.rb 100.00 % 65 35 35 0 2.17 66.67 % 12 8 4
spec/sheetah/backends_spec.rb 100.00 % 43 24 24 0 1.38 100.00 % 0 0 0
spec/sheetah/column_spec.rb 100.00 % 78 36 36 0 2.11 100.00 % 0 0 0
spec/sheetah/errors/error_spec.rb 100.00 % 115 60 60 0 1.42 100.00 % 0 0 0
spec/sheetah/errors/spec_error_spec.rb 100.00 % 9 4 4 0 1.00 100.00 % 0 0 0
spec/sheetah/errors/type_error_spec.rb 100.00 % 9 4 4 0 1.00 100.00 % 0 0 0
spec/sheetah/headers_spec.rb 100.00 % 137 54 54 0 4.22 100.00 % 0 0 0
spec/sheetah/messaging/message_spec.rb 100.00 % 127 55 55 0 2.00 100.00 % 0 0 0
spec/sheetah/messaging/messenger_spec.rb 100.00 % 453 215 215 0 2.71 100.00 % 0 0 0
spec/sheetah/row_processor_result_spec.rb 100.00 % 31 19 19 0 1.58 100.00 % 0 0 0
spec/sheetah/row_processor_spec.rb 100.00 % 82 34 34 0 1.12 100.00 % 0 0 0
spec/sheetah/row_value_builder_spec.rb 100.00 % 141 75 75 0 1.69 100.00 % 0 0 0
spec/sheetah/sheet_processor_result_spec.rb 100.00 % 24 14 14 0 1.36 100.00 % 0 0 0
spec/sheetah/sheet_processor_spec.rb 100.00 % 259 103 103 0 2.44 100.00 % 0 0 0
spec/sheetah/sheet_spec.rb 100.00 % 282 143 143 0 2.73 100.00 % 0 0 0
spec/sheetah/specification_spec.rb 100.00 % 159 81 81 0 1.94 100.00 % 0 0 0
spec/sheetah/template_config_spec.rb 100.00 % 7 3 3 0 1.00 100.00 % 0 0 0
spec/sheetah/template_spec.rb 100.00 % 7 3 3 0 1.00 100.00 % 0 0 0
spec/sheetah/types/cast_chain_spec.rb 100.00 % 147 77 77 0 1.60 100.00 % 0 0 0
spec/sheetah/types/composites/array_compact_spec.rb 100.00 % 34 17 17 0 1.18 100.00 % 0 0 0
spec/sheetah/types/composites/array_spec.rb 100.00 % 49 24 24 0 1.33 100.00 % 0 0 0
spec/sheetah/types/composites/composite_spec.rb 100.00 % 18 9 9 0 1.00 100.00 % 0 0 0
spec/sheetah/types/container_spec.rb 100.00 % 163 84 84 0 1.39 50.00 % 2 1 1
spec/sheetah/types/scalars/boolsy_cast_spec.rb 100.00 % 82 43 43 0 1.44 100.00 % 0 0 0
spec/sheetah/types/scalars/boolsy_spec.rb 100.00 % 20 9 9 0 1.00 100.00 % 0 0 0
spec/sheetah/types/scalars/date_string_cast_spec.rb 100.00 % 104 54 54 0 1.44 100.00 % 0 0 0
spec/sheetah/types/scalars/date_string_spec.rb 100.00 % 24 11 11 0 1.27 100.00 % 0 0 0
spec/sheetah/types/scalars/email_cast_spec.rb 100.00 % 49 25 25 0 1.44 100.00 % 0 0 0
spec/sheetah/types/scalars/email_spec.rb 100.00 % 20 9 9 0 1.00 100.00 % 0 0 0
spec/sheetah/types/scalars/scalar_cast_spec.rb 100.00 % 106 56 56 0 1.34 100.00 % 0 0 0
spec/sheetah/types/scalars/scalar_spec.rb 100.00 % 20 9 9 0 1.00 100.00 % 0 0 0
spec/sheetah/types/scalars/string_spec.rb 100.00 % 54 27 27 0 1.33 100.00 % 0 0 0
spec/sheetah/types/type_spec.rb 100.00 % 250 129 129 0 1.53 100.00 % 0 0 0
spec/sheetah/utils/cell_string_cleaner_spec.rb 100.00 % 21 12 12 0 1.42 100.00 % 0 0 0
spec/sheetah/utils/monadic_result/failure_spec.rb 100.00 % 188 94 94 0 1.47 100.00 % 0 0 0
spec/sheetah/utils/monadic_result/success_spec.rb 100.00 % 186 93 93 0 1.43 100.00 % 0 0 0
spec/sheetah/utils/monadic_result/unit_spec.rb 100.00 % 17 8 8 0 1.50 100.00 % 0 0 0
spec/sheetah/utils/monadic_result_spec.rb 100.00 % 108 57 57 0 1.67 100.00 % 0 0 0
spec/sheetah_spec.rb 100.00 % 207 66 66 0 3.08 100.00 % 0 0 0
spec/support/fixtures.rb 100.00 % 15 7 7 0 6.14 100.00 % 0 0 0
spec/support/monadic_result.rb 100.00 % 7 3 3 0 1.00 100.00 % 0 0 0
spec/support/shared/cast_class.rb 100.00 % 23 11 11 0 7.64 100.00 % 2 2 0
spec/support/shared/composite_type.rb 100.00 % 69 33 33 0 4.55 100.00 % 0 0 0
spec/support/shared/scalar_type.rb 100.00 % 47 22 22 0 6.64 100.00 % 0 0 0
spec/support/shared/sheet_factories.rb 100.00 % 25 12 12 0 18.67 100.00 % 0 0 0

lib/sheetah.rb

100.0% lines covered

100.0% branches covered

5 relevant lines. 5 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # {Sheetah} is a library designed to process tabular data according to a
  3. # {Sheetah::Template developer-defined structure}. It will turn each row into a
  4. # object whose keys and types are specified by the structure.
  5. #
  6. # It can work with tabular data presented in different formats by delegating
  7. # the parsing of documents to specialized backends
  8. # ({Sheetah::Backends::Xlsx}, {Sheetah::Backends::Csv}, etc...).
  9. #
  10. # Given a tabular document and a specification of the document structure,
  11. # Sheetah may process the document by handling the following tasks:
  12. #
  13. # - validation of the document's actual structure
  14. # - arbitrary complex typecasting of each row into a validated object,
  15. # according to the document specification
  16. # - fine-grained error handling (at the sheet/row/col/cell level)
  17. # - all of the above done so that internationalization of messages is easy
  18. #
  19. # Sheetah is designed with memory efficiency in mind by processing documents
  20. # one row at a time, thus not requiring parsing and loading the whole document
  21. # in memory upfront (depending on the backend). The memory consumption of the
  22. # library should therefore theoretically stay stable during the processing of a
  23. # document, disregarding how many rows it may have.
  24. 1 module Sheetah
  25. end
  26. 1 require "sheetah/template"
  27. 1 require "sheetah/template_config"
  28. 1 require "sheetah/sheet_processor"
  29. 1 require "sheetah/backends/wrapper"

lib/sheetah/attribute.rb

97.78% lines covered

63.64% branches covered

45 relevant lines. 44 lines covered and 1 lines missed.
11 total branches, 7 branches covered and 4 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative "column"
  3. 1 module Sheetah
  4. 1 class Attribute
  5. 1 def initialize(key:, type:)
  6. 18 @key = key
  7. @type =
  8. 18 case type
  9. when: 9 when Hash
  10. 9 CompositeType.new(**type)
  11. when: 0 when Array
  12. CompositeType.new(composite: :array, scalars: type)
  13. else: 9 else
  14. 9 ScalarType.new(type)
  15. end
  16. 18 freeze
  17. end
  18. 1 attr_reader :key, :type
  19. 1 def each_column(config)
  20. 18 else: 18 then: 0 return enum_for(:each_column, config) unless block_given?
  21. 18 compiled_type = type.compile(config.types)
  22. 18 type.each_column do |index, required|
  23. 54 header, header_pattern = config.header(key, index)
  24. 54 yield Column.new(
  25. key: key,
  26. type: compiled_type,
  27. index: index,
  28. header: header,
  29. header_pattern: header_pattern,
  30. required: required
  31. )
  32. end
  33. end
  34. 1 class Scalar
  35. 1 def initialize(name)
  36. 54 @required = name.end_with?("!")
  37. 54 then: 18 else: 36 @name = (@required ? name.slice(0..-2) : name).to_sym
  38. end
  39. 1 attr_reader :name, :required
  40. end
  41. 1 class ScalarType
  42. 1 def initialize(scalar)
  43. 9 @scalar = Scalar.new(scalar)
  44. 9 freeze
  45. end
  46. 1 def compile(container)
  47. 9 container.scalar(@scalar.name)
  48. end
  49. 1 def each_column
  50. 9 else: 9 then: 0 return enum_for(:each_column) { 1 } unless block_given?
  51. 9 yield nil, @scalar.required
  52. 9 self
  53. end
  54. end
  55. 1 class CompositeType
  56. 1 def initialize(composite:, scalars:)
  57. 9 @composite = composite
  58. 54 @scalars = scalars.map { |scalar| Scalar.new(scalar) }.freeze
  59. 9 freeze
  60. end
  61. 1 def compile(container)
  62. 9 container.composite(@composite, @scalars.map(&:name))
  63. end
  64. 1 def each_column
  65. 9 else: 9 then: 0 return enum_for(:each_column) { @scalars.size } unless block_given?
  66. 9 @scalars.each_with_index do |scalar, index|
  67. 45 yield index, scalar.required
  68. end
  69. 9 self
  70. end
  71. end
  72. 1 private_constant :Scalar, :ScalarType, :CompositeType
  73. end
  74. end

lib/sheetah/backends.rb

100.0% lines covered

100.0% branches covered

14 relevant lines. 14 lines covered and 0 lines missed.
2 total branches, 2 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative "backends_registry"
  3. 1 require_relative "utils/monadic_result"
  4. 1 module Sheetah
  5. 1 module Backends
  6. 1 @registry = BackendsRegistry.new
  7. 1 SimpleError = Struct.new(:msg_code)
  8. 1 private_constant :SimpleError
  9. 1 class << self
  10. 1 attr_reader :registry
  11. 1 def open(*args, **opts, &block)
  12. 16 backend = opts.delete(:backend) || registry.get(*args, **opts)
  13. 16 then: 1 else: 15 if backend.nil?
  14. 1 return Utils::MonadicResult::Failure.new(SimpleError.new("no_applicable_backend"))
  15. end
  16. 15 backend.open(*args, **opts, &block)
  17. end
  18. end
  19. end
  20. end

lib/sheetah/backends/csv.rb

100.0% lines covered

100.0% branches covered

58 relevant lines. 58 lines covered and 0 lines missed.
16 total branches, 16 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "csv"
  3. 1 require_relative "../sheet"
  4. 1 require_relative "../backends"
  5. 1 module Sheetah
  6. 1 module Backends
  7. # Expect:
  8. # - UTF-8 without BOM, or the correct encoding given explicitly
  9. # - line endings as \n or \r\n
  10. # - comma-separated
  11. # - quoted with "
  12. 1 class Csv
  13. 1 include Sheet
  14. 1 class ArgumentError < Error
  15. end
  16. 1 class EncodingError < Error
  17. end
  18. 1 CSV_OPTS = {
  19. col_sep: ",",
  20. quote_char: '"',
  21. }.freeze
  22. 1 private_constant :CSV_OPTS
  23. 1 def self.register(registry = Backends.registry)
  24. 4 registry.set(self) do |args, opts|
  25. 8 else: 7 then: 1 next false unless args.empty?
  26. case opts
  27. 7 in { io: _, **nil } | \
  28. { io: _, encoding: String | Encoding, **nil } | \
  29. { path: /\.csv$/i, **nil } | \
  30. { path: /\.csv$/i, encoding: String | Encoding, **nil }
  31. in: 6 then
  32. 6 true
  33. else: 1 else
  34. 1 false
  35. end
  36. end
  37. end
  38. 1 def initialize(io: nil, path: nil, encoding: nil)
  39. 22 io = setup_io(io, path, encoding)
  40. 19 @csv = CSV.new(io, **CSV_OPTS)
  41. 19 @headers = detect_headers(@csv)
  42. 17 @cols_count = @headers.size
  43. end
  44. 1 def each_header
  45. 15 else: 9 then: 5 return to_enum(:each_header) { @cols_count } unless block_given?
  46. 9 @headers.each_with_index do |header, col_idx|
  47. 48 col = Sheet.int2col(col_idx + 1)
  48. 48 yield Header.new(col: col, value: header)
  49. end
  50. 9 self
  51. end
  52. 1 def each_row
  53. 6 else: 5 then: 1 return to_enum(:each_row) unless block_given?
  54. 5 @csv.each.with_index(1) do |raw, row|
  55. 9 value = Array.new(@cols_count) do |col_idx|
  56. 36 col = Sheet.int2col(col_idx + 1)
  57. 36 Cell.new(row: row, col: col, value: raw[col_idx])
  58. end
  59. 9 yield Row.new(row: row, value: value)
  60. end
  61. 5 self
  62. end
  63. 1 def close
  64. 2 @csv.close
  65. nil
  66. end
  67. 1 private
  68. 1 def setup_io(io, path, encoding)
  69. 22 then: 3 if io.nil? && !path.nil?
  70. 3 else: 19 setup_io_from_path(path, encoding)
  71. 19 then: 16 elsif !io.nil? && path.nil?
  72. 16 setup_io_from_io(io, encoding)
  73. else: 3 else
  74. 3 raise ArgumentError, "Expected either IO or path"
  75. end
  76. end
  77. 1 def setup_io_from_io(io, encoding)
  78. 16 then: 1 else: 15 io.set_encoding(encoding, Encoding::UTF_8) if encoding
  79. 16 io
  80. end
  81. 1 def setup_io_from_path(path, encoding)
  82. 3 opts = { mode: "r" }
  83. 3 then: 1 else: 2 if encoding
  84. 1 opts[:external_encoding] = encoding
  85. 1 opts[:internal_encoding] = Encoding::UTF_8
  86. end
  87. 3 File.new(path, **opts)
  88. end
  89. 1 def detect_headers(csv)
  90. headers =
  91. begin
  92. 19 csv.shift
  93. rescue CSV::MalformedCSVError
  94. 2 raise EncodingError
  95. end
  96. 17 headers || []
  97. end
  98. end
  99. end
  100. end

lib/sheetah/backends/wrapper.rb

100.0% lines covered

100.0% branches covered

29 relevant lines. 29 lines covered and 0 lines missed.
8 total branches, 8 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative "../sheet"
  3. 1 module Sheetah
  4. 1 module Backends
  5. 1 class Wrapper
  6. 1 include Sheet
  7. 1 def initialize(table)
  8. 21 then: 1 else: 20 raise Error if table.nil?
  9. 20 @table = table
  10. 20 then: 18 if (table_size = @table.size).positive?
  11. 18 @headers = @table[0]
  12. 18 @rows_count = table_size - 1
  13. 18 @cols_count = @headers.size
  14. else: 2 else
  15. 2 @headers = []
  16. 2 @rows_count = 0
  17. 2 @cols_count = 0
  18. end
  19. end
  20. 1 def each_header
  21. 16 else: 14 then: 1 return to_enum(:each_header) { @cols_count } unless block_given?
  22. 14 1.upto(@cols_count) do |col|
  23. 50 yield Header.new(col: Sheet.int2col(col), value: @headers[col - 1])
  24. end
  25. 14 self
  26. end
  27. 1 def each_row
  28. 11 else: 10 then: 1 return to_enum(:each_row) unless block_given?
  29. 10 1.upto(@rows_count) do |row|
  30. 24 raw = @table[row]
  31. 24 value = Array.new(@cols_count) do |col_idx|
  32. 102 Cell.new(row: row, col: Sheet.int2col(col_idx + 1), value: raw[col_idx])
  33. end
  34. 24 yield Row.new(row: row, value: value)
  35. end
  36. 10 self
  37. end
  38. 1 def close
  39. # nothing to do here
  40. end
  41. end
  42. end
  43. end

lib/sheetah/backends/xlsx.rb

100.0% lines covered

100.0% branches covered

47 relevant lines. 47 lines covered and 0 lines missed.
14 total branches, 14 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # NOTE: As reference:
  3. # - {Roo::Excelx::Cell#cell_value} => the "raw" value before Excel's typecasts
  4. # - {Roo::Excelx::Cell#value} => the "user" value, after Excel's typecasts
  5. 1 require "roo"
  6. 1 require_relative "../sheet"
  7. 1 require_relative "../backends"
  8. 1 module Sheetah
  9. 1 module Backends
  10. 1 class Xlsx
  11. 1 include Sheet
  12. 1 def self.register(registry = Backends.registry)
  13. 3 registry.set(self) do |args, opts|
  14. 3 else: 2 then: 1 next false unless args.empty?
  15. case opts
  16. 2 in: 1 in { path: /\.xlsx$/i, **nil }
  17. 1 true
  18. else: 1 else
  19. 1 false
  20. end
  21. end
  22. end
  23. 1 def initialize(path:)
  24. 14 then: 1 else: 13 raise Error if path.nil?
  25. 13 @roo = Roo::Excelx.new(path)
  26. 13 @is_empty = worksheet.first_row.nil?
  27. 13 @headers = detect_headers
  28. 13 @cols_count = @headers.size
  29. end
  30. 1 def each_header
  31. 10 else: 7 then: 2 return to_enum(:each_header) { @cols_count } unless block_given?
  32. 7 @headers.each_with_index do |header, col_idx|
  33. 30 col = Sheet.int2col(col_idx + 1)
  34. 30 yield Header.new(col: col, value: header)
  35. end
  36. 7 self
  37. end
  38. 1 def each_row
  39. 7 else: 6 then: 1 return to_enum(:each_row) unless block_given?
  40. 6 then: 1 else: 5 return if @is_empty
  41. 5 first_row = 2
  42. 5 last_row = worksheet.last_row
  43. 5 row = 0
  44. 5 first_row.upto(last_row) do |cursor|
  45. 13 raw = worksheet.row(cursor)
  46. 13 row += 1
  47. 13 value = Array.new(@cols_count) do |col_idx|
  48. 65 col = Sheet.int2col(col_idx + 1)
  49. 65 Cell.new(row: row, col: col, value: raw[col_idx])
  50. end
  51. 13 yield Row.new(row: row, value: value)
  52. end
  53. 5 self
  54. end
  55. 1 def close
  56. 1 @roo.close
  57. nil
  58. end
  59. 1 private
  60. 1 def worksheet
  61. 42 @worksheet ||= @roo.sheet_for(@roo.default_sheet)
  62. end
  63. 1 def detect_headers
  64. 13 then: 2 else: 11 return [] if @is_empty
  65. 11 worksheet.row(1) || []
  66. end
  67. end
  68. end
  69. end

lib/sheetah/backends_registry.rb

100.0% lines covered

100.0% branches covered

13 relevant lines. 13 lines covered and 0 lines missed.
2 total branches, 2 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 module Sheetah
  3. 1 class BackendsRegistry
  4. 1 def initialize
  5. 14 @registry = {}
  6. end
  7. 1 def set(backend, &matcher)
  8. 22 @registry[backend] = matcher
  9. 21 self
  10. end
  11. 1 def get(*args, **opts)
  12. 16 @registry.each do |backend, matcher|
  13. 19 then: 11 else: 8 return backend if matcher.call(args, opts)
  14. end
  15. nil
  16. end
  17. 1 def freeze
  18. 3 @registry.freeze
  19. 3 super
  20. end
  21. end
  22. end

lib/sheetah/column.rb

100.0% lines covered

100.0% branches covered

13 relevant lines. 13 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 module Sheetah
  3. 1 class Column
  4. 1 def initialize(
  5. key:,
  6. type:,
  7. index:,
  8. header:,
  9. header_pattern: nil,
  10. required: false
  11. )
  12. 61 @key = key
  13. 61 @type = type
  14. 61 @index = index
  15. 61 @header = header
  16. 61 @header_pattern = (header_pattern || header.dup).freeze
  17. 61 @required = required
  18. 61 freeze
  19. end
  20. 1 attr_reader :key, :type, :index, :header, :header_pattern
  21. 1 def required?
  22. 54 @required
  23. end
  24. end
  25. end

lib/sheetah/errors/error.rb

100.0% lines covered

100.0% branches covered

25 relevant lines. 25 lines covered and 0 lines missed.
4 total branches, 4 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 module Sheetah
  3. 1 module Errors
  4. 1 class Error < StandardError
  5. 1 class << self
  6. 1 def inherited(klass)
  7. 24 super
  8. 24 then: 11 else: 13 klass.msg_code! if klass.detect_msg_code?
  9. end
  10. 1 attr_reader :msg_code
  11. 1 def detect_msg_code?
  12. 41 name && /^[a-z0-9:]+$/i.match?(name)
  13. end
  14. 1 def msg_code!(msg_code = build_msg_code)
  15. 19 @msg_code = msg_code
  16. end
  17. 1 private
  18. 1 def build_msg_code
  19. 17 else: 15 then: 2 unless detect_msg_code?
  20. 2 raise ::TypeError, "Cannot build msg_code from anonymous exception: #{inspect}"
  21. end
  22. 15 msg_code = name.dup
  23. 15 msg_code.gsub!("::", ".")
  24. 15 msg_code.gsub!(/([A-Z\d]+)([A-Z][a-z])/, '\1_\2')
  25. 15 msg_code.gsub!(/([a-z\d])([A-Z])/, '\1_\2')
  26. 15 msg_code.downcase!
  27. 15 msg_code
  28. end
  29. end
  30. 1 msg_code!
  31. 1 def msg_code
  32. 2 self.class.msg_code
  33. end
  34. end
  35. end
  36. end

lib/sheetah/errors/spec_error.rb

100.0% lines covered

100.0% branches covered

4 relevant lines. 4 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative "error"
  3. 1 module Sheetah
  4. 1 module Errors
  5. 1 class SpecError < Error
  6. end
  7. end
  8. end

lib/sheetah/errors/type_error.rb

100.0% lines covered

100.0% branches covered

4 relevant lines. 4 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative "error"
  3. 1 module Sheetah
  4. 1 module Errors
  5. 1 class TypeError < Error
  6. end
  7. end
  8. end

lib/sheetah/headers.rb

100.0% lines covered

100.0% branches covered

46 relevant lines. 46 lines covered and 0 lines missed.
14 total branches, 14 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "set"
  3. 1 module Sheetah
  4. 1 class Headers
  5. 1 include Utils::MonadicResult
  6. 1 class Header
  7. 1 def initialize(sheet_header, spec_column)
  8. 47 @header = sheet_header
  9. 47 @column = spec_column
  10. end
  11. 1 attr_reader :header, :column
  12. 1 def ==(other)
  13. 3 other.is_a?(self.class) &&
  14. header == other.header &&
  15. column == other.column
  16. end
  17. 1 def row_value_index
  18. 60 header.row_value_index
  19. end
  20. end
  21. 1 def initialize(specification:, messenger:)
  22. 16 @specification = specification
  23. 16 @messenger = messenger
  24. 16 @headers = []
  25. 16 @columns = Set.new
  26. 16 @failure = false
  27. end
  28. 1 def add(header)
  29. 54 @messenger.scope_col!(header.col) do
  30. 54 column = @specification.get(header.value)
  31. 54 else: 46 then: 8 return unless add_ensure_column_is_specified(header, column)
  32. 46 else: 44 then: 2 return unless add_ensure_column_is_unique(header, column)
  33. 44 @headers << Header.new(header, column)
  34. end
  35. end
  36. 1 def result
  37. 13 missing_columns = @specification.required_columns - @columns.to_a
  38. 13 else: 11 then: 2 unless missing_columns.empty?
  39. 2 @failure = true
  40. 2 missing_columns.each do |column|
  41. 4 @messenger.error("missing_column", column.header)
  42. end
  43. end
  44. 13 then: 6 if @failure
  45. 6 Failure()
  46. else: 7 else
  47. 7 Success(@headers)
  48. end
  49. end
  50. 1 private
  51. 1 def add_ensure_column_is_specified(header, column)
  52. 54 else: 8 then: 46 return true unless column.nil?
  53. 8 else: 2 then: 6 unless @specification.ignore_unspecified_columns?
  54. 6 @failure = true
  55. 6 @messenger.error("invalid_header", header.value)
  56. end
  57. 8 false
  58. end
  59. 1 def add_ensure_column_is_unique(header, column)
  60. 46 then: 44 else: 2 return true if @columns.add?(column)
  61. 2 @failure = true
  62. 2 @messenger.error("duplicated_header", header.value)
  63. 2 false
  64. end
  65. end
  66. end

lib/sheetah/messaging.rb

100.0% lines covered

100.0% branches covered

89 relevant lines. 89 lines covered and 0 lines missed.
11 total branches, 11 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 module Sheetah
  3. 1 module Messaging
  4. 1 module SCOPES
  5. 1 SHEET = "SHEET"
  6. 1 ROW = "ROW"
  7. 1 COL = "COL"
  8. 1 CELL = "CELL"
  9. end
  10. 1 module SEVERITIES
  11. 1 WARN = "WARN"
  12. 1 ERROR = "ERROR"
  13. end
  14. # TODO: list all possible message code in a systematic way,
  15. # so that i18n do not miss any by mistake.
  16. 1 class Message
  17. 1 def initialize(
  18. code:,
  19. code_data: nil,
  20. scope: nil,
  21. scope_data: nil,
  22. severity: nil
  23. )
  24. 46 @code = code
  25. 46 @code_data = code_data || nil
  26. 46 @scope = scope || SCOPES::SHEET
  27. 46 @scope_data = scope_data || nil
  28. 46 @severity = severity || SEVERITIES::WARN
  29. end
  30. 1 attr_reader(
  31. :code,
  32. :code_data,
  33. :scope,
  34. :scope_data,
  35. :severity
  36. )
  37. 1 def ==(other)
  38. 12 other.is_a?(self.class) &&
  39. code == other.code &&
  40. code_data == other.code_data &&
  41. scope == other.scope &&
  42. scope_data == other.scope_data &&
  43. severity == other.severity
  44. end
  45. 1 def to_s
  46. 6 parts = [scoping_to_s, "#{severity}: #{code}", code_data]
  47. 6 parts.compact!
  48. 6 parts.join(" ")
  49. end
  50. 1 private
  51. 1 def scoping_to_s
  52. 6 when: 2 else: 1 case scope
  53. 2 when: 1 when SCOPES::SHEET then "[#{scope}]"
  54. 1 when: 1 when SCOPES::ROW then "[#{scope}: #{scope_data[:row]}]"
  55. 1 when: 1 when SCOPES::COL then "[#{scope}: #{scope_data[:col]}]"
  56. 1 when SCOPES::CELL then "[#{scope}: #{scope_data[:col]}#{scope_data[:row]}]"
  57. end
  58. end
  59. end
  60. 1 class Messenger
  61. 1 def initialize(
  62. scope: SCOPES::SHEET,
  63. scope_data: nil
  64. )
  65. 83 @scope = scope.freeze
  66. 83 @scope_data = scope_data.freeze
  67. 83 @messages = []
  68. end
  69. 1 attr_reader :scope, :scope_data, :messages
  70. 1 def ==(other)
  71. 2 other.is_a?(self.class) &&
  72. scope == other.scope &&
  73. scope_data == other.scope_data &&
  74. messages == other.messages
  75. end
  76. 1 def dup
  77. 23 self.class.new(
  78. scope: @scope,
  79. scope_data: @scope_data
  80. )
  81. end
  82. 1 def scoping!(scope, scope_data, &block)
  83. 139 scope = scope.freeze
  84. 139 scope_data = scope_data.freeze
  85. 139 then: 137 if block
  86. 137 replace_scoping_block(scope, scope_data, &block)
  87. else: 2 else
  88. 2 replace_scoping_noblock(scope, scope_data)
  89. end
  90. end
  91. 1 def scoping(...)
  92. 2 dup.scoping!(...)
  93. end
  94. 1 def scope_row!(row, &block)
  95. 24 scope = case @scope
  96. when: 4 when SCOPES::COL, SCOPES::CELL
  97. 4 SCOPES::CELL
  98. else: 20 else
  99. 20 SCOPES::ROW
  100. end
  101. 24 scope_data = @scope_data.dup || {}
  102. 24 scope_data[:row] = row
  103. 24 scoping!(scope, scope_data, &block)
  104. end
  105. 1 def scope_col!(col, &block)
  106. 125 scope = case @scope
  107. when: 67 when SCOPES::ROW, SCOPES::CELL
  108. 67 SCOPES::CELL
  109. else: 58 else
  110. 58 SCOPES::COL
  111. end
  112. 125 scope_data = @scope_data.dup || {}
  113. 125 scope_data[:col] = col
  114. 125 scoping!(scope, scope_data, &block)
  115. end
  116. 1 def scope_row(...)
  117. 2 dup.scope_row!(...)
  118. end
  119. 1 def scope_col(...)
  120. 2 dup.scope_col!(...)
  121. end
  122. 1 def warn(code, data = nil)
  123. 3 add(SEVERITIES::WARN, code, data)
  124. end
  125. 1 def error(code, data = nil)
  126. 23 add(SEVERITIES::ERROR, code, data)
  127. end
  128. 1 def exception(error)
  129. 2 error(error.msg_code)
  130. end
  131. 1 private
  132. 1 def add(severity, code, data)
  133. 26 messages << Message.new(
  134. code: code,
  135. code_data: data,
  136. scope: @scope,
  137. scope_data: @scope_data,
  138. severity: severity
  139. )
  140. 26 self
  141. end
  142. 1 def replace_scoping_noblock(new_scope, new_scope_data)
  143. 2 @scope = new_scope
  144. 2 @scope_data = new_scope_data
  145. 2 self
  146. end
  147. 1 def replace_scoping_block(new_scope, new_scope_data)
  148. 137 prev_scope = @scope
  149. 137 prev_scope_data = @scope_data
  150. 137 @scope = new_scope
  151. 137 @scope_data = new_scope_data
  152. begin
  153. 137 yield self
  154. ensure
  155. 137 @scope = prev_scope
  156. 137 @scope_data = prev_scope_data
  157. end
  158. end
  159. end
  160. end
  161. end

lib/sheetah/row_processor.rb

100.0% lines covered

100.0% branches covered

19 relevant lines. 19 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative "row_processor_result"
  3. 1 require_relative "row_value_builder"
  4. 1 module Sheetah
  5. 1 class RowProcessor
  6. 1 def initialize(headers:, messenger:)
  7. 6 @headers = headers
  8. 6 @messenger = messenger
  9. end
  10. 1 def call(row)
  11. 16 messenger = @messenger.dup
  12. 16 builder = RowValueBuilder.new(messenger)
  13. 16 messenger.scope_row!(row.row) do
  14. 16 @headers.each do |header|
  15. 63 cell = row.value[header.row_value_index]
  16. 63 messenger.scope_col!(cell.col) do
  17. 63 builder.add(header.column, cell.value)
  18. end
  19. end
  20. end
  21. 16 build_result(row, builder, messenger)
  22. end
  23. 1 private
  24. 1 def build_result(row, builder, messenger)
  25. 16 RowProcessorResult.new(
  26. row: row.row,
  27. result: builder.result,
  28. messages: messenger.messages
  29. )
  30. end
  31. end
  32. end

lib/sheetah/row_processor_result.rb

100.0% lines covered

100.0% branches covered

9 relevant lines. 9 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 module Sheetah
  3. 1 class RowProcessorResult
  4. 1 def initialize(row:, result:, messages: [])
  5. 23 @row = row
  6. 23 @result = result
  7. 23 @messages = messages
  8. end
  9. 1 attr_reader :row, :result, :messages
  10. 1 def ==(other)
  11. 3 other.is_a?(self.class) &&
  12. row == other.row &&
  13. result == other.result &&
  14. messages == other.messages
  15. end
  16. end
  17. end

lib/sheetah/row_value_builder.rb

100.0% lines covered

100.0% branches covered

30 relevant lines. 30 lines covered and 0 lines missed.
4 total branches, 4 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "set"
  3. 1 require_relative "utils/monadic_result"
  4. 1 module Sheetah
  5. 1 class RowValueBuilder
  6. 1 include Utils::MonadicResult
  7. 1 def initialize(messenger)
  8. 21 @messenger = messenger
  9. 21 @data = {}
  10. 21 @composites = Set.new
  11. 21 @failure = false
  12. end
  13. 1 def add(column, value)
  14. 68 key = column.key
  15. 68 type = column.type
  16. 68 index = column.index
  17. 68 result = type.scalar(index, value, @messenger)
  18. 68 result.bind do |scalar|
  19. 61 then: 44 if type.composite?
  20. 44 @composites << [key, type]
  21. 44 @data[key] ||= []
  22. 44 @data[key][index] = scalar
  23. else: 17 else
  24. 17 @data[key] = scalar
  25. end
  26. end
  27. 75 result.or { @failure = true }
  28. 68 result
  29. end
  30. 1 def result
  31. 21 then: 7 else: 14 return Failure() if @failure
  32. 14 Do() do
  33. 14 @composites.each do |key, type|
  34. 13 value = type.composite(@data[key], @messenger).unwrap
  35. 12 @data[key] = value
  36. end
  37. 13 Success(@data)
  38. end
  39. end
  40. end
  41. end

lib/sheetah/sheet.rb

100.0% lines covered

100.0% branches covered

53 relevant lines. 53 lines covered and 0 lines missed.
2 total branches, 2 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative "sheet/col_converter"
  3. 1 require_relative "errors/error"
  4. 1 require_relative "utils/monadic_result"
  5. 1 module Sheetah
  6. 1 module Sheet
  7. 1 def self.included(mod)
  8. 29 mod.extend(ClassMethods)
  9. end
  10. 1 def self.col2int(...)
  11. 92 COL_CONVERTER.col2int(...)
  12. end
  13. 1 def self.int2col(...)
  14. 415 COL_CONVERTER.int2col(...)
  15. end
  16. 1 module ClassMethods
  17. 1 def open(*args, **opts)
  18. 20 handle_sheet_error do
  19. 20 sheet = new(*args, **opts)
  20. 17 else: 16 then: 1 next sheet unless block_given?
  21. begin
  22. 16 yield sheet
  23. ensure
  24. 16 sheet.close
  25. end
  26. end
  27. end
  28. 1 private
  29. 1 def handle_sheet_error
  30. 20 Utils::MonadicResult::Success.new(yield)
  31. rescue Error => e
  32. 3 Utils::MonadicResult::Failure.new(e)
  33. end
  34. end
  35. 1 class Error < Errors::Error
  36. end
  37. 1 class Header
  38. 1 def initialize(col:, value:)
  39. 158 @col = col
  40. 158 @value = value
  41. end
  42. 1 attr_reader :col, :value
  43. 1 def ==(other)
  44. 28 other.is_a?(self.class) && col == other.col && value == other.value
  45. end
  46. 1 def row_value_index
  47. 60 Sheet.col2int(col) - 1
  48. end
  49. end
  50. 1 class Row
  51. 1 def initialize(row:, value:)
  52. 66 @row = row
  53. 66 @value = value
  54. end
  55. 1 attr_reader :row, :value
  56. 1 def ==(other)
  57. 18 other.is_a?(self.class) && row == other.row && value == other.value
  58. end
  59. end
  60. 1 class Cell
  61. 1 def initialize(row:, col:, value:)
  62. 275 @row = row
  63. 275 @col = col
  64. 275 @value = value
  65. end
  66. 1 attr_reader :row, :col, :value
  67. 1 def ==(other)
  68. 70 other.is_a?(self.class) && row == other.row && col == other.col && value == other.value
  69. end
  70. end
  71. 1 def each_header
  72. 1 raise NoMethodError, "You must implement #{self.class}#each_header => self"
  73. end
  74. 1 def each_row
  75. 1 raise NoMethodError, "You must implement #{self.class}#each_row => self"
  76. end
  77. 1 def close
  78. 1 raise NoMethodError, "You must implement #{self.class}#close => nil"
  79. end
  80. end
  81. end

lib/sheetah/sheet/col_converter.rb

100.0% lines covered

100.0% branches covered

34 relevant lines. 34 lines covered and 0 lines missed.
7 total branches, 7 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 module Sheetah
  3. 1 module Sheet
  4. 1 class ColConverter
  5. 1 CHARSET = ("A".."Z").to_a.freeze
  6. 1 CHARSET_SIZE = CHARSET.size
  7. 1 CHAR_TO_INT = CHARSET.map.with_index(1).to_h.freeze
  8. 1 INT_TO_CHAR = CHAR_TO_INT.invert.freeze
  9. 1 def col2int(col)
  10. 92 else: 90 then: 2 raise ArgumentError unless col.is_a?(String) && !col.empty?
  11. 90 int = 0
  12. 90 col.each_char.reverse_each.with_index do |char, pow|
  13. 103 int += char2int(char) * (CHARSET_SIZE**pow)
  14. end
  15. 88 int
  16. end
  17. 1 def int2col(int)
  18. 415 else: 411 then: 4 raise ArgumentError unless int.is_a?(Integer) && int.positive?
  19. 411 x = int
  20. 411 y = CHARSET_SIZE
  21. 411 col = +""
  22. 411 body: 424 until x.zero?
  23. 424 q, r = x.divmod(y)
  24. 424 then: 7 else: 417 if r.zero?
  25. 7 q -= 1
  26. 7 r = y
  27. end
  28. 424 x = q
  29. 424 col << int2char(r)
  30. end
  31. 411 col.reverse!
  32. 411 col.freeze
  33. end
  34. 1 private
  35. 1 def char2int(char)
  36. 103 CHAR_TO_INT[char] || raise(ArgumentError, char.inspect)
  37. end
  38. 1 def int2char(int)
  39. 424 INT_TO_CHAR[int] || raise(ArgumentError, int.inspect)
  40. end
  41. end
  42. 1 private_constant :ColConverter
  43. 1 COL_CONVERTER = ColConverter.new.freeze
  44. end
  45. end

lib/sheetah/sheet_processor.rb

100.0% lines covered

100.0% branches covered

33 relevant lines. 33 lines covered and 0 lines missed.
2 total branches, 2 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative "backends"
  3. 1 require_relative "headers"
  4. 1 require_relative "messaging"
  5. 1 require_relative "row_processor"
  6. 1 require_relative "sheet"
  7. 1 require_relative "sheet_processor_result"
  8. 1 require_relative "utils/monadic_result"
  9. 1 module Sheetah
  10. 1 class SheetProcessor
  11. 1 include Utils::MonadicResult
  12. 1 def initialize(specification)
  13. 15 @specification = specification
  14. end
  15. 1 def call(*args, **opts)
  16. 15 messenger = Messaging::Messenger.new
  17. 15 result = Do() do
  18. 15 Backends.open(*args, **opts) do |sheet|
  19. 12 row_processor = build_row_processor(sheet, messenger)
  20. 7 sheet.each_row do |row|
  21. 21 yield row_processor.call(row)
  22. end
  23. end
  24. end
  25. 15 handle_result(result, messenger)
  26. end
  27. 1 private
  28. 1 def parse_headers(sheet, messenger)
  29. 12 headers = Headers.new(specification: @specification, messenger: messenger)
  30. 12 sheet.each_header do |header|
  31. 44 headers.add(header)
  32. end
  33. 12 headers.result
  34. end
  35. 1 def build_row_processor(sheet, messenger)
  36. 12 headers = parse_headers(sheet, messenger).unwrap
  37. 7 RowProcessor.new(headers: headers, messenger: messenger)
  38. end
  39. 1 def handle_result(result, messenger)
  40. 15 result.or do |failure|
  41. 6 then: 1 else: 5 messenger.error(failure.msg_code) if failure.respond_to?(:msg_code)
  42. end
  43. 15 SheetProcessorResult.new(result: result.discard, messages: messenger.messages)
  44. end
  45. end
  46. end

lib/sheetah/sheet_processor_result.rb

100.0% lines covered

100.0% branches covered

8 relevant lines. 8 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 module Sheetah
  3. 1 class SheetProcessorResult
  4. 1 def initialize(result:, messages: [])
  5. 24 @result = result
  6. 24 @messages = messages
  7. end
  8. 1 attr_reader :result, :messages
  9. 1 def ==(other)
  10. 6 other.is_a?(self.class) &&
  11. result == other.result &&
  12. messages == other.messages
  13. end
  14. end
  15. end

lib/sheetah/specification.rb

96.67% lines covered

100.0% branches covered

30 relevant lines. 29 lines covered and 1 lines missed.
10 total branches, 10 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative "errors/spec_error"
  3. 1 module Sheetah
  4. 1 class Specification
  5. 1 class InvalidPatternError < Errors::SpecError
  6. end
  7. 1 class MutablePatternError < Errors::SpecError
  8. end
  9. 1 class DuplicatedPatternError < Errors::SpecError
  10. end
  11. 1 def initialize(ignore_unspecified_columns: false)
  12. 21 @column_by_pattern = {}
  13. 21 @ignore_unspecified_columns = ignore_unspecified_columns
  14. end
  15. 1 def set(pattern, column)
  16. 83 then: 1 else: 82 if pattern.nil?
  17. 1 raise InvalidPatternError, pattern.inspect
  18. end
  19. 82 else: 81 then: 1 unless pattern.frozen?
  20. 1 raise MutablePatternError, pattern.inspect
  21. end
  22. 81 then: 2 else: 79 if @column_by_pattern.key?(pattern)
  23. 2 raise DuplicatedPatternError, pattern.inspect
  24. end
  25. 79 @column_by_pattern[pattern] = column
  26. end
  27. 1 def get(header)
  28. 46 then: 1 else: 45 return if header.nil?
  29. 45 @column_by_pattern.each do |pattern, column|
  30. 154 then: 37 else: 117 return column if pattern === header # rubocop:disable Style/CaseEquality
  31. end
  32. nil
  33. end
  34. 1 def required_columns
  35. 9 @column_by_pattern.each_value.select(&:required?)
  36. end
  37. 1 def optional_columns
  38. @column_by_pattern.each_value.reject(&:required?)
  39. end
  40. 1 def ignore_unspecified_columns?
  41. 6 @ignore_unspecified_columns
  42. end
  43. 1 def freeze
  44. 11 @column_by_pattern.freeze
  45. 11 super
  46. end
  47. end
  48. end

lib/sheetah/template.rb

95.83% lines covered

50.0% branches covered

24 relevant lines. 23 lines covered and 1 lines missed.
2 total branches, 1 branches covered and 1 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "set"
  3. 1 require_relative "attribute"
  4. 1 require_relative "specification"
  5. 1 require_relative "errors/spec_error"
  6. 1 module Sheetah
  7. # A {Template} represents the abstract structure of a tabular document.
  8. #
  9. # The main component of the structure is the object obtained by processing a
  10. # row. A template therefore specifies all possible attributes of that object
  11. # as a list of (key, abstract type) pairs.
  12. #
  13. # Each attribute will eventually be compiled into as many concrete columns as
  14. # necessary with the help of a {TemplateConfig config} to produce a
  15. # {Specification specification}.
  16. #
  17. # In other words, a {Template} specifies the structure of the processing
  18. # result (its attributes), whereas a {Specification} specifies the columns
  19. # that may be involved into building the processing result.
  20. #
  21. # {Attribute Attributes} may either be _composite_ (their value is a
  22. # composition of multiple values) or _scalar_ (their value is a single
  23. # value). Scalar attributes will thus produce a single column in the
  24. # specification, and composite attributes will produce as many columns as
  25. # required by the number of scalar values they hold.
  26. 1 class Template
  27. 1 def initialize(attributes:, ignore_unspecified_columns: false)
  28. 9 @attributes = build_attributes(attributes)
  29. 9 @ignore_unspecified_columns = ignore_unspecified_columns
  30. end
  31. 1 def apply(config)
  32. 9 specification = Specification.new(ignore_unspecified_columns: @ignore_unspecified_columns)
  33. 9 @attributes.each do |attribute|
  34. 18 attribute.each_column(config) do |column|
  35. 54 specification.set(column.header_pattern, column)
  36. end
  37. end
  38. 9 specification.freeze
  39. end
  40. 1 private
  41. 1 def build_attributes(attributes)
  42. 9 uniq_keys = Set.new
  43. 9 uniq_attributes = attributes.map do |kwargs|
  44. 18 attribute = Attribute.new(**kwargs)
  45. 18 else: 18 then: 0 unless uniq_keys.add?(attribute.key)
  46. raise Errors::SpecError, "Duplicated key: #{attribute.key.inspect}"
  47. end
  48. 18 attribute
  49. end
  50. 9 uniq_attributes.freeze
  51. end
  52. end
  53. end

lib/sheetah/template_config.rb

100.0% lines covered

100.0% branches covered

11 relevant lines. 11 lines covered and 0 lines missed.
2 total branches, 2 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative "types/container"
  3. 1 module Sheetah
  4. 1 class TemplateConfig
  5. 1 def initialize(types: Types::Container.new)
  6. 9 @types = types
  7. end
  8. 1 attr_reader :types
  9. 1 def header(key, index)
  10. 54 header = key.to_s.capitalize
  11. 54 then: 45 else: 9 header = "#{header} #{index + 1}" if index
  12. 54 pattern = /^#{header}$/i
  13. 54 [header, pattern]
  14. end
  15. end
  16. end

lib/sheetah/types/cast.rb

100.0% lines covered

100.0% branches covered

9 relevant lines. 9 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 module Sheetah
  3. 1 module Types
  4. # @private
  5. 1 module Cast
  6. 1 def ==(other)
  7. 3 other.is_a?(self.class) && other.config == config
  8. end
  9. 1 protected
  10. 1 def config
  11. 6 instance_variables.each_with_object({}) do |ivar, acc|
  12. 10 acc[ivar] = instance_variable_get(ivar)
  13. end
  14. end
  15. end
  16. end
  17. end

lib/sheetah/types/cast_chain.rb

100.0% lines covered

100.0% branches covered

26 relevant lines. 26 lines covered and 0 lines missed.
2 total branches, 2 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative "../utils/monadic_result"
  3. 1 module Sheetah
  4. 1 module Types
  5. 1 class CastChain
  6. 1 include Utils::MonadicResult
  7. 1 def initialize(casts = [])
  8. 73 @casts = casts
  9. end
  10. 1 attr_reader :casts
  11. 1 def prepend(cast)
  12. 1 @casts.unshift(cast)
  13. 1 self
  14. end
  15. 1 def append(cast)
  16. 102 @casts.push(cast)
  17. 102 self
  18. end
  19. 1 def freeze
  20. 17 @casts.each(&:freeze)
  21. 17 @casts.freeze
  22. 17 super
  23. end
  24. 1 def call(value, messenger)
  25. 75 failure = catch(:failure) do
  26. 75 success = catch(:success) do
  27. 75 @casts.reduce(value) do |prev_value, cast|
  28. 141 cast.call(prev_value, messenger)
  29. end
  30. end
  31. 68 return Success(success)
  32. end
  33. 7 then: 1 else: 6 messenger.error(failure) if failure
  34. 7 Failure()
  35. end
  36. end
  37. end
  38. end

lib/sheetah/types/composites/array.rb

100.0% lines covered

100.0% branches covered

7 relevant lines. 7 lines covered and 0 lines missed.
2 total branches, 2 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative "composite"
  3. 1 module Sheetah
  4. 1 module Types
  5. 1 module Composites
  6. 1 Array = Composite.cast do |value, _messenger|
  7. 12 else: 11 then: 1 throw :failure, "must_be_array" unless value.is_a?(::Array)
  8. 11 value
  9. end
  10. end
  11. end
  12. end

lib/sheetah/types/composites/array_compact.rb

100.0% lines covered

100.0% branches covered

6 relevant lines. 6 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative "array"
  3. 1 module Sheetah
  4. 1 module Types
  5. 1 module Composites
  6. 1 ArrayCompact = Array.cast do |value, _messenger|
  7. 1 value.compact
  8. end
  9. end
  10. end
  11. end

lib/sheetah/types/composites/composite.rb

100.0% lines covered

100.0% branches covered

16 relevant lines. 16 lines covered and 0 lines missed.
2 total branches, 2 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative "../../errors/type_error"
  3. 1 require_relative "../type"
  4. 1 module Sheetah
  5. 1 module Types
  6. 1 module Composites
  7. 1 class Composite < Type
  8. 1 def initialize(types, **opts)
  9. 21 super(**opts)
  10. 21 @types = types
  11. end
  12. 1 def composite?
  13. 43 true
  14. end
  15. 1 def scalar(index, value, messenger)
  16. 51 then: 48 if (type = @types[index])
  17. 48 type.scalar(nil, value, messenger)
  18. else: 3 else
  19. 3 raise Errors::TypeError, "Invalid index: #{index.inspect}"
  20. end
  21. end
  22. 1 alias composite cast
  23. end
  24. end
  25. end
  26. end

lib/sheetah/types/container.rb

100.0% lines covered

100.0% branches covered

45 relevant lines. 45 lines covered and 0 lines missed.
4 total branches, 4 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative "../errors/type_error"
  3. 1 require_relative "scalars/scalar"
  4. 1 require_relative "scalars/string"
  5. 1 require_relative "scalars/email"
  6. 1 require_relative "scalars/boolsy"
  7. 1 require_relative "scalars/date_string"
  8. 1 require_relative "composites/array"
  9. 1 require_relative "composites/array_compact"
  10. 1 module Sheetah
  11. 1 module Types
  12. 1 class Container
  13. 1 scalar = Scalars::Scalar.new!
  14. 1 string = Scalars::String.new!
  15. 1 email = Scalars::Email.new!
  16. 1 boolsy = Scalars::Boolsy.new!
  17. 1 date_string = Scalars::DateString.new!
  18. DEFAULTS = {
  19. 1 scalars: {
  20. 29 scalar: -> { scalar },
  21. 13 string: -> { string },
  22. 13 email: -> { email },
  23. 2 boolsy: -> { boolsy },
  24. 2 date_string: -> { date_string },
  25. }.freeze,
  26. composites: {
  27. 10 array: ->(types) { Composites::Array.new!(types) },
  28. 1 array_compact: ->(types) { Composites::ArrayCompact.new!(types) },
  29. }.freeze,
  30. }.freeze
  31. 1 def initialize(scalars: nil, composites: nil, defaults: DEFAULTS)
  32. @scalars =
  33. 23 then: 11 else: 12 (scalars ? defaults[:scalars].merge(scalars) : defaults[:scalars]).freeze
  34. @composites =
  35. 23 then: 2 else: 21 (composites ? defaults[:composites].merge(composites) : defaults[:composites]).freeze
  36. end
  37. 1 def scalars
  38. 3 @scalars.keys
  39. end
  40. 1 def composites
  41. 3 @composites.keys
  42. end
  43. 1 def scalar(scalar_name)
  44. 76 builder = fetch_scalar_builder(scalar_name)
  45. 74 builder.call
  46. end
  47. 1 def composite(composite_name, scalar_names)
  48. 16 builder = fetch_composite_builder(composite_name)
  49. 68 scalars = scalar_names.map { |scalar_name| scalar(scalar_name) }
  50. 14 builder.call(scalars)
  51. end
  52. 1 private
  53. 1 def fetch_scalar_builder(type)
  54. 76 @scalars.fetch(type) do
  55. 2 raise Errors::TypeError, "Invalid scalar type: #{type.inspect}"
  56. end
  57. end
  58. 1 def fetch_composite_builder(type)
  59. 16 @composites.fetch(type) do
  60. 1 raise Errors::TypeError, "Invalid composite type: #{type.inspect}"
  61. end
  62. end
  63. end
  64. end
  65. end

lib/sheetah/types/scalars/boolsy.rb

100.0% lines covered

100.0% branches covered

6 relevant lines. 6 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative "scalar"
  3. 1 require_relative "boolsy_cast"
  4. 1 module Sheetah
  5. 1 module Types
  6. 1 module Scalars
  7. 1 Boolsy = Scalar.cast(BoolsyCast)
  8. end
  9. end
  10. end

lib/sheetah/types/scalars/boolsy_cast.rb

100.0% lines covered

100.0% branches covered

19 relevant lines. 19 lines covered and 0 lines missed.
4 total branches, 4 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative "../cast"
  3. 1 module Sheetah
  4. 1 module Types
  5. 1 module Scalars
  6. 1 class BoolsyCast
  7. 1 include Cast
  8. 1 TRUTHY = [].freeze
  9. 1 FALSY = [].freeze
  10. 1 private_constant :TRUTHY, :FALSY
  11. 1 def initialize(truthy: TRUTHY, falsy: FALSY, **)
  12. 12 @truthy = truthy
  13. 12 @falsy = falsy
  14. end
  15. 1 def call(value, messenger)
  16. 3 then: 1 if @truthy.include?(value)
  17. 1 else: 2 true
  18. 2 then: 1 elsif @falsy.include?(value)
  19. 1 false
  20. else: 1 else
  21. 1 messenger.error("must_be_boolsy", value: value.inspect)
  22. 1 throw :failure
  23. end
  24. end
  25. end
  26. end
  27. end
  28. end

lib/sheetah/types/scalars/date_string.rb

100.0% lines covered

100.0% branches covered

6 relevant lines. 6 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative "scalar"
  3. 1 require_relative "date_string_cast"
  4. 1 module Sheetah
  5. 1 module Types
  6. 1 module Scalars
  7. 1 DateString = Scalar.cast(DateStringCast)
  8. end
  9. end
  10. end

lib/sheetah/types/scalars/date_string_cast.rb

100.0% lines covered

100.0% branches covered

23 relevant lines. 23 lines covered and 0 lines missed.
7 total branches, 7 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "date"
  3. 1 require_relative "../cast"
  4. 1 module Sheetah
  5. 1 module Types
  6. 1 module Scalars
  7. 1 class DateStringCast
  8. 1 include Cast
  9. 1 DATE_FMT = "%Y-%m-%d"
  10. 1 private_constant :DATE_FMT
  11. 1 def initialize(date_fmt: DATE_FMT, accept_date: true, **)
  12. 15 @date_fmt = date_fmt
  13. 15 @accept_date = accept_date
  14. end
  15. 1 def call(value, messenger)
  16. 6 else: 1 case value
  17. when: 2 when ::Date
  18. 2 then: 1 else: 1 return value if @accept_date
  19. when: 3 when ::String
  20. 3 date = parse_date_string(value)
  21. 3 then: 1 else: 2 return date if date
  22. end
  23. 4 messenger.error("must_be_date", format: @date_fmt)
  24. 4 throw :failure
  25. end
  26. 1 private
  27. 1 def parse_date_string(value)
  28. 3 ::Date.strptime(value, @date_fmt)
  29. rescue ::TypeError, ::Date::Error
  30. 2 nil
  31. end
  32. end
  33. end
  34. end
  35. end

lib/sheetah/types/scalars/email.rb

100.0% lines covered

100.0% branches covered

6 relevant lines. 6 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative "string"
  3. 1 require_relative "email_cast"
  4. 1 module Sheetah
  5. 1 module Types
  6. 1 module Scalars
  7. 1 Email = String.cast(EmailCast)
  8. end
  9. end
  10. end

lib/sheetah/types/scalars/email_cast.rb

100.0% lines covered

100.0% branches covered

15 relevant lines. 15 lines covered and 0 lines missed.
2 total branches, 2 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "uri"
  3. 1 require_relative "../cast"
  4. 1 module Sheetah
  5. 1 module Types
  6. 1 module Scalars
  7. 1 class EmailCast
  8. 1 include Cast
  9. 1 EMAIL_REGEXP = ::URI::MailTo::EMAIL_REGEXP
  10. 1 private_constant :EMAIL_REGEXP
  11. 1 def initialize(email_matcher: EMAIL_REGEXP, **)
  12. 11 @email_matcher = email_matcher
  13. end
  14. 1 def call(value, messenger)
  15. 17 then: 11 else: 6 return value if @email_matcher.match?(value)
  16. 6 messenger.error("must_be_email", value: value.inspect)
  17. 6 throw :failure
  18. end
  19. end
  20. end
  21. end
  22. end

lib/sheetah/types/scalars/scalar.rb

100.0% lines covered

100.0% branches covered

15 relevant lines. 15 lines covered and 0 lines missed.
2 total branches, 2 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative "../../errors/type_error"
  3. 1 require_relative "../type"
  4. 1 require_relative "scalar_cast"
  5. 1 module Sheetah
  6. 1 module Types
  7. 1 module Scalars
  8. 1 class Scalar < Type
  9. 1 self.cast_classes += [ScalarCast]
  10. 1 def composite?
  11. 20 false
  12. end
  13. 1 def composite(_value, _messenger)
  14. 5 raise Errors::TypeError, "A scalar type cannot act as a composite"
  15. end
  16. 1 def scalar(index, value, messenger)
  17. 70 else: 65 then: 5 raise Errors::TypeError, "A scalar type cannot be indexed" unless index.nil?
  18. 65 cast_chain.call(value, messenger)
  19. end
  20. end
  21. end
  22. end
  23. end

lib/sheetah/types/scalars/scalar_cast.rb

100.0% lines covered

100.0% branches covered

24 relevant lines. 24 lines covered and 0 lines missed.
8 total branches, 8 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative "../../utils/cell_string_cleaner"
  3. 1 require_relative "../cast"
  4. 1 module Sheetah
  5. 1 module Types
  6. 1 module Scalars
  7. 1 class ScalarCast
  8. 1 include Cast
  9. 1 def initialize(nullable: true, clean_string: true, **)
  10. 43 @nullable = nullable
  11. 43 @clean_string = clean_string
  12. end
  13. 1 def call(value, messenger)
  14. 67 handle_nil(value)
  15. 50 handle_garbage(value, messenger)
  16. end
  17. 1 private
  18. 1 def handle_nil(value)
  19. 67 else: 17 then: 50 return unless value.nil?
  20. 17 then: 16 if @nullable
  21. 16 throw :success, nil
  22. else: 1 else
  23. 1 throw :failure, "must_exist"
  24. end
  25. end
  26. 1 def handle_garbage(value, messenger)
  27. 50 else: 32 then: 18 return value unless @clean_string && value.is_a?(::String)
  28. 32 clean_string = Utils::CellStringCleaner.call(value)
  29. 32 then: 1 else: 31 messenger.warn("cleaned_string") if clean_string != value
  30. 32 clean_string
  31. end
  32. end
  33. end
  34. end
  35. end

lib/sheetah/types/scalars/string.rb

100.0% lines covered

100.0% branches covered

7 relevant lines. 7 lines covered and 0 lines missed.
2 total branches, 2 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative "scalar"
  3. 1 module Sheetah
  4. 1 module Types
  5. 1 module Scalars
  6. 1 String = Scalar.cast do |value, _messenger|
  7. # value.to_s, because we want the native, underlying string when value
  8. # is an instance of a String subclass
  9. 32 then: 31 else: 1 next value.to_s if value.is_a?(::String)
  10. 1 throw :failure, "must_be_string"
  11. end
  12. end
  13. end
  14. end

lib/sheetah/types/type.rb

100.0% lines covered

100.0% branches covered

53 relevant lines. 53 lines covered and 0 lines missed.
10 total branches, 10 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative "cast_chain"
  3. 1 module Sheetah
  4. 1 module Types
  5. 1 class Type
  6. 1 class << self
  7. 1 def all(&block)
  8. 6 else: 3 then: 3 return enum_for(:all) unless block
  9. 3 ObjectSpace.each_object(singleton_class, &block)
  10. nil
  11. end
  12. 1 def cast_classes
  13. 186 then: 140 else: 46 defined?(@cast_classes) ? @cast_classes : superclass.cast_classes
  14. end
  15. 1 attr_writer :cast_classes
  16. 1 def cast(cast_class = nil, &cast_block)
  17. 21 then: 1 if cast_class && cast_block
  18. 1 else: 20 raise ArgumentError, "Expected either a Class or a block, got both"
  19. 20 then: 1 else: 19 elsif !(cast_class || cast_block)
  20. 1 raise ArgumentError, "Expected either a Class or a block, got none"
  21. end
  22. 19 type = Class.new(self)
  23. 19 type.cast_classes += [cast_class || SimpleCast.new(cast_block)]
  24. 19 type
  25. end
  26. 1 def freeze
  27. 8 else: 5 then: 3 @cast_classes = cast_classes.dup unless defined?(@cast_classes)
  28. 8 @cast_classes.freeze
  29. 8 super
  30. end
  31. 1 def new!(...)
  32. 15 new(...).freeze
  33. end
  34. end
  35. 1 self.cast_classes = []
  36. 1 def initialize(**opts)
  37. 63 @cast_chain = CastChain.new
  38. 63 self.class.cast_classes.each do |cast_class|
  39. 101 @cast_chain.append(cast_class.new(**opts))
  40. end
  41. end
  42. # @private
  43. 1 attr_reader :cast_chain
  44. 1 def cast(...)
  45. 11 @cast_chain.call(...)
  46. end
  47. 1 def scalar?
  48. 1 raise NoMethodError, "You must implement this method in a subclass"
  49. end
  50. 1 def composite?
  51. 1 raise NoMethodError, "You must implement this method in a subclass"
  52. end
  53. 1 def scalar(_index, _value, _messenger)
  54. 1 raise NoMethodError, "You must implement this method in a subclass"
  55. end
  56. 1 def composite(_value, _messenger)
  57. 1 raise NoMethodError, "You must implement this method in a subclass"
  58. end
  59. 1 def freeze
  60. 16 @cast_chain.freeze
  61. 16 super
  62. end
  63. # @private
  64. 1 class SimpleCast
  65. 1 def initialize(cast)
  66. 16 @cast = cast
  67. end
  68. 1 def new(**)
  69. 61 @cast
  70. end
  71. 1 def ==(other)
  72. 1 other.is_a?(self.class) && other.cast == cast
  73. end
  74. 1 protected
  75. 1 attr_reader :cast
  76. end
  77. end
  78. end
  79. end

lib/sheetah/utils/cell_string_cleaner.rb

100.0% lines covered

100.0% branches covered

16 relevant lines. 16 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 module Sheetah
  3. 1 module Utils
  4. 1 class CellStringCleaner
  5. 1 garbage = "(?:[^[:print:]]|[[:space:]])+"
  6. 1 GARBAGE_PREFIX = /\A#{garbage}/.freeze
  7. 1 GARBAGE_SUFFIX = /#{garbage}\Z/.freeze
  8. 1 private_constant :GARBAGE_PREFIX, :GARBAGE_SUFFIX
  9. 1 def self.call(...)
  10. 36 DEFAULT.call(...)
  11. end
  12. 1 def call(value)
  13. 36 value = value.dup
  14. # TODO: benchmarks
  15. 36 value.sub!(GARBAGE_PREFIX, "")
  16. 36 value.sub!(GARBAGE_SUFFIX, "")
  17. 36 value
  18. end
  19. 1 DEFAULT = new.freeze
  20. 1 private_constant :DEFAULT
  21. end
  22. end
  23. end

lib/sheetah/utils/monadic_result.rb

100.0% lines covered

100.0% branches covered

82 relevant lines. 82 lines covered and 0 lines missed.
10 total branches, 10 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 module Sheetah
  3. 1 module Utils
  4. 1 module MonadicResult
  5. # {Unit} is a singleton, and is used when there is no other meaningful
  6. # value that could be returned.
  7. #
  8. # It allows the {Result} implementation to distinguish between *a null
  9. # value* (i.e. `nil`) and *the lack of a value*, to provide adequate
  10. # behavior in each case.
  11. #
  12. # The {Result} API should not expose {Unit} directly to its consumers.
  13. #
  14. # @see https://en.wikipedia.org/wiki/Unit_type
  15. 1 Unit = Object.new
  16. 1 def Unit.to_s
  17. 2 "Unit"
  18. end
  19. 1 def Unit.inspect
  20. 1 "Unit"
  21. end
  22. 1 Unit.freeze
  23. 1 DO_TOKEN = :MonadicResultDo
  24. 1 private_constant :DO_TOKEN
  25. 1 module Result
  26. 1 UnwrapError = Class.new(StandardError)
  27. 1 VariantError = Class.new(UnwrapError)
  28. 1 ValueError = Class.new(UnwrapError)
  29. 1 def initialize(value = Unit)
  30. 263 @wrapped = value
  31. end
  32. 1 def empty?
  33. 141 wrapped == Unit
  34. end
  35. 1 def ==(other)
  36. 56 other.is_a?(self.class) && other.wrapped == wrapped
  37. end
  38. 1 def inspect
  39. 4 then: 2 if empty?
  40. 2 "#{variant}()"
  41. else: 2 else
  42. 2 "#{variant}(#{wrapped.inspect})"
  43. end
  44. end
  45. 1 alias to_s inspect
  46. 1 def discard
  47. 19 then: 9 else: 10 empty? ? self : self.class.new
  48. end
  49. 1 protected
  50. 1 attr_reader :wrapped
  51. 1 private
  52. 1 def value
  53. 5 then: 2 else: 3 raise ValueError, "There is no value within the result" if empty?
  54. 3 wrapped
  55. end
  56. 1 def value?
  57. 25 else: 1 then: 24 wrapped unless empty?
  58. end
  59. 1 def open
  60. 82 then: 16 if empty?
  61. 16 yield
  62. else: 66 else
  63. 66 yield wrapped
  64. end
  65. end
  66. end
  67. 1 class Success
  68. 1 include Result
  69. 1 def success?
  70. 3 true
  71. end
  72. 1 def failure?
  73. 1 false
  74. end
  75. 1 def success
  76. 2 value
  77. end
  78. 1 def failure
  79. 1 raise VariantError, "Not a Failure"
  80. end
  81. 1 def unwrap
  82. 25 value?
  83. end
  84. 1 alias bind open
  85. 1 public :bind
  86. 1 alias or itself
  87. 1 private
  88. 1 def variant
  89. 2 "Success"
  90. end
  91. end
  92. 1 class Failure
  93. 1 include Result
  94. 1 def success?
  95. 1 false
  96. end
  97. 1 def failure?
  98. 3 true
  99. end
  100. 1 def success
  101. 1 raise VariantError, "Not a Success"
  102. end
  103. 1 def failure
  104. 3 value
  105. end
  106. 1 def unwrap
  107. 10 throw DO_TOKEN, self
  108. end
  109. 1 alias bind itself
  110. 1 alias or open
  111. 1 public :or
  112. 1 private
  113. 1 def variant
  114. 2 "Failure"
  115. end
  116. end
  117. # rubocop:disable Naming/MethodName
  118. 1 def Success(...)
  119. 133 Success.new(...)
  120. end
  121. 1 def Failure(...)
  122. 49 Failure.new(...)
  123. end
  124. 1 def Do(&block)
  125. 33 catch(DO_TOKEN, &block)
  126. end
  127. # rubocop:enable Naming/MethodName
  128. end
  129. end
  130. end

spec/sheetah/attribute_spec.rb

100.0% lines covered

100.0% branches covered

3 relevant lines. 3 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "sheetah/attribute"
  3. 1 RSpec.describe Sheetah::Attribute do
  4. 1 pending "TODO"
  5. end

spec/sheetah/backends/csv_spec.rb

100.0% lines covered

100.0% branches covered

134 relevant lines. 134 lines covered and 0 lines missed.
2 total branches, 2 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "sheetah/backends/csv"
  3. 1 require "support/shared/sheet_factories"
  4. 1 require "csv"
  5. 1 require "stringio"
  6. 1 require "tempfile"
  7. 1 RSpec.describe Sheetah::Backends::Csv do
  8. 1 include_context "sheet_factories"
  9. 1 let(:raw_table) do
  10. 9 Array.new(4) do |row|
  11. 36 Array.new(4) do |col|
  12. 144 "(#{row},#{col})"
  13. end.freeze
  14. end.freeze
  15. end
  16. 1 let(:raw_sheet) do
  17. 9 stub_sheet(raw_table)
  18. end
  19. 1 let(:sheet) do
  20. 9 described_class.new(io: raw_sheet)
  21. end
  22. 1 def stub_sheet(table)
  23. 14 then: 1 else: 13 return if table.nil?
  24. 13 csv = CSV.generate do |csv_io|
  25. 13 table.each do |row|
  26. 38 csv_io << row
  27. end
  28. end
  29. 13 StringIO.new(csv, "r")
  30. end
  31. 1 def new_sheet(...)
  32. 5 described_class.new(io: stub_sheet(...))
  33. end
  34. 1 describe "::register" do
  35. 5 let(:registry) { Sheetah::BackendsRegistry.new }
  36. 1 before do
  37. 4 described_class.register(registry)
  38. end
  39. 1 it "matches any so-called IO and an optional encoding" do
  40. 1 io = double
  41. 1 expect(registry.get(io: io)).to eq(described_class)
  42. 1 expect(registry.get(io: io, encoding: "UTF-8")).to eq(described_class)
  43. 1 expect(registry.get(io: io, encoding: Encoding::UTF_8)).to eq(described_class)
  44. end
  45. 1 it "matches a CSV path and an optional encoding" do
  46. 1 expect(registry.get(path: "foo.csv")).to eq(described_class)
  47. 1 expect(registry.get(path: "foo.csv", encoding: "UTF-8")).to eq(described_class)
  48. 1 expect(registry.get(path: "foo.csv", encoding: Encoding::UTF_8)).to eq(described_class)
  49. end
  50. 1 it "doesn't match any other path" do
  51. 1 expect(registry.get(path: "foo.tsv")).to be_nil
  52. end
  53. 1 it "doesn't match extra args" do
  54. 1 expect(registry.get(2, path: "foo.csv")).to be_nil
  55. end
  56. end
  57. 1 describe "#initialize" do
  58. 3 let(:utf8_path) { fixture_path("csv/utf8.csv") }
  59. 5 let(:latin9_path) { fixture_path("csv/latin9.csv") }
  60. 1 let(:headers) do
  61. 4 [
  62. "Matricule",
  63. "Nom",
  64. "Prénom",
  65. "Email",
  66. "Date de naissance",
  67. "Entrée en entreprise",
  68. "Administrateur",
  69. "Bio",
  70. "Service",
  71. ]
  72. end
  73. 1 context "when no io nor path is given" do
  74. 1 it "fails" do
  75. 1 expect do
  76. 1 described_class.new
  77. end.to raise_error(described_class::ArgumentError)
  78. end
  79. end
  80. 1 context "when both an io and a path are given" do
  81. 1 it "fails" do
  82. 1 expect do
  83. 1 described_class.new(io: double, path: double)
  84. end.to raise_error(described_class::ArgumentError)
  85. end
  86. end
  87. 1 context "when only an io is given" do
  88. 4 let(:io) { File.new(io_path) }
  89. 1 context "when the default encoding is valid" do
  90. 1 alias_method :io_path, :utf8_path
  91. 1 it "can read CSV data" do
  92. 1 sheet = described_class.new(io: io)
  93. 1 expect(sheet.each_header.map(&:value)).to eq(headers)
  94. end
  95. end
  96. 1 context "when the default encoding is invalid" do
  97. 1 alias_method :io_path, :latin9_path
  98. 1 it "fails" do
  99. 1 expect do
  100. 1 described_class.new(io: io)
  101. end.to raise_error(described_class::EncodingError)
  102. end
  103. 1 it "can read CSV data once given a valid encoding" do
  104. 1 sheet = described_class.new(io: io, encoding: Encoding::ISO_8859_15)
  105. 1 expect(sheet.each_header.map(&:value)).to eq(headers)
  106. end
  107. end
  108. end
  109. 1 context "when only a path is given" do
  110. 1 context "when the default encoding is valid" do
  111. 1 alias_method :path, :utf8_path
  112. 1 it "can read CSV data" do
  113. 1 sheet = described_class.new(path: path)
  114. 1 expect(sheet.each_header.map(&:value)).to eq(headers)
  115. end
  116. end
  117. 1 context "when the default encoding is invalid" do
  118. 1 alias_method :path, :latin9_path
  119. 1 it "fails" do
  120. 1 expect do
  121. 1 described_class.new(path: path)
  122. end.to raise_error(described_class::EncodingError)
  123. end
  124. 1 it "can read CSV data once given a valid encoding" do
  125. 1 sheet = described_class.new(path: path, encoding: Encoding::ISO_8859_15)
  126. 1 expect(sheet.each_header.map(&:value)).to eq(headers)
  127. end
  128. end
  129. end
  130. end
  131. 1 describe "#each_header" do
  132. 1 let(:expected_headers) do
  133. [
  134. 2 header(value: raw_table[0][0], col: "A"),
  135. header(value: raw_table[0][1], col: "B"),
  136. header(value: raw_table[0][2], col: "C"),
  137. header(value: raw_table[0][3], col: "D"),
  138. ]
  139. end
  140. 1 context "with a block" do
  141. 1 it "yields each header, with its letter-based index" do
  142. 2 expect { |b| sheet.each_header(&b) }.to yield_successive_args(*expected_headers)
  143. end
  144. 1 it "returns self" do
  145. 5 expect(sheet.each_header { double }).to be(sheet)
  146. end
  147. end
  148. 1 context "without a block" do
  149. 1 it "returns an enumerator" do
  150. 1 enum = sheet.each_header
  151. 1 expect(enum).to be_a(Enumerator)
  152. 1 expect(enum.size).to be(4)
  153. 1 expect(enum.to_a).to eq(expected_headers)
  154. end
  155. end
  156. end
  157. 1 describe "#each_row" do
  158. 1 let(:expected_rows) do
  159. [
  160. 2 row(row: 1, value: cells(raw_table[1], row: 1)),
  161. row(row: 2, value: cells(raw_table[2], row: 2)),
  162. row(row: 3, value: cells(raw_table[3], row: 3)),
  163. ]
  164. end
  165. 1 context "with a block" do
  166. 1 it "yields each row, with its 1-based index" do
  167. 2 expect { |b| sheet.each_row(&b) }.to yield_successive_args(*expected_rows)
  168. end
  169. 1 it "returns self" do
  170. 4 expect(sheet.each_row { double }).to be(sheet)
  171. end
  172. end
  173. 1 context "without a block" do
  174. 1 it "returns an enumerator" do
  175. 1 enum = sheet.each_row
  176. 1 expect(enum).to be_a(Enumerator)
  177. 1 expect(enum.size).to be_nil
  178. 1 expect(enum.to_a).to eq(expected_rows)
  179. end
  180. end
  181. end
  182. 1 describe "#close" do
  183. 1 it "returns nil" do
  184. 1 expect(sheet.close).to be_nil
  185. end
  186. 1 it "closes the underlying sheet" do
  187. 2 expect { sheet.close }.to change(raw_sheet, :closed?).from(false).to(true)
  188. end
  189. end
  190. 1 context "when the input table is odd" do
  191. 1 shared_examples "empty_sheet" do
  192. 2 it "doesn't enumerate any header" do
  193. 4 expect { |b| sheet.each_header(&b) }.not_to yield_control
  194. end
  195. 2 it "doesn't enumerate any row" do
  196. 4 expect { |b| sheet.each_row(&b) }.not_to yield_control
  197. end
  198. end
  199. 1 context "when the input table is nil" do
  200. 1 it "raises an error" do
  201. 2 expect { new_sheet(nil) }.to raise_error(Sheetah::Sheet::Error)
  202. end
  203. end
  204. 1 context "when the input table is empty" do
  205. 3 let(:sheet) { new_sheet [] }
  206. 1 include_examples "empty_sheet"
  207. end
  208. 1 context "when the input table headers are empty" do
  209. 3 let(:sheet) { new_sheet [[]] }
  210. 1 include_examples "empty_sheet"
  211. end
  212. end
  213. 1 describe "CSV options" do
  214. 1 it "requires a specific col_sep and quote_char" do
  215. 1 expect(CSV).to receive(:new).with(raw_sheet, col_sep: ",", quote_char: '"').and_call_original
  216. 1 sheet
  217. end
  218. end
  219. end

spec/sheetah/backends/wrapper_spec.rb

100.0% lines covered

100.0% branches covered

79 relevant lines. 79 lines covered and 0 lines missed.
6 total branches, 6 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "sheetah/backends/wrapper"
  3. 1 require "support/shared/sheet_factories"
  4. 1 RSpec.describe Sheetah::Backends::Wrapper do
  5. 1 include_context "sheet_factories"
  6. 1 let(:raw_table) do
  7. 7 Array.new(4) do |row|
  8. 28 Array.new(4) do |col|
  9. 112 instance_double(Object, "(#{row},#{col})")
  10. end.freeze
  11. end.freeze
  12. end
  13. 1 let(:sheet) do
  14. 7 new_sheet(raw_table)
  15. end
  16. 1 let(:table_interface) do
  17. 12 Module.new do
  18. 12 def [](_); end
  19. 12 def size; end
  20. end
  21. end
  22. 1 let(:headers_interface) do
  23. 9 Module.new do
  24. 9 def [](_); end
  25. 9 def size; end
  26. end
  27. end
  28. 1 let(:values_interfaces) do
  29. 7 Module.new do
  30. 7 def [](_); end
  31. end
  32. end
  33. 1 def stub_table(source, target = instance_double(table_interface)) # rubocop:disable Metrics/AbcSize
  34. 12 then: 1 else: 11 return if source.nil?
  35. 11 source.each_with_index do |source_row, y|
  36. 30 then: 9 else: 21 target_row = instance_double(y.zero? ? headers_interface : values_interfaces)
  37. 30 allow(target).to receive(:[]).with(y).and_return(target_row)
  38. 30 source_row.each_with_index do |source_cell, x|
  39. 112 allow(target_row).to receive(:[]).with(x).and_return(source_cell)
  40. end
  41. end
  42. 11 allow(target).to receive(:size).with(no_args).and_return(source.size)
  43. 11 else: 2 then: 9 allow(target[0]).to receive(:size).with(no_args).and_return(source[0].size) unless source.empty?
  44. 11 target
  45. end
  46. 1 def new_sheet(...)
  47. 12 described_class.new(stub_table(...))
  48. end
  49. 1 describe "#each_header" do
  50. 1 let(:expected_headers) do
  51. [
  52. 2 header(value: raw_table[0][0], col: "A"),
  53. header(value: raw_table[0][1], col: "B"),
  54. header(value: raw_table[0][2], col: "C"),
  55. header(value: raw_table[0][3], col: "D"),
  56. ]
  57. end
  58. 1 context "with a block" do
  59. 1 it "yields each header, with its letter-based index" do
  60. 2 expect { |b| sheet.each_header(&b) }.to yield_successive_args(*expected_headers)
  61. end
  62. 1 it "returns self" do
  63. 5 expect(sheet.each_header { double }).to be(sheet)
  64. end
  65. end
  66. 1 context "without a block" do
  67. 1 it "returns an enumerator" do
  68. 1 enum = sheet.each_header
  69. 1 expect(enum).to be_a(Enumerator)
  70. 1 expect(enum.size).to be(4)
  71. 1 expect(enum.to_a).to eq(expected_headers)
  72. end
  73. end
  74. end
  75. 1 describe "#each_row" do
  76. 1 let(:expected_rows) do
  77. [
  78. 2 row(row: 1, value: cells(raw_table[1], row: 1)),
  79. row(row: 2, value: cells(raw_table[2], row: 2)),
  80. row(row: 3, value: cells(raw_table[3], row: 3)),
  81. ]
  82. end
  83. 1 context "with a block" do
  84. 1 it "yields each row, with its 1-based index" do
  85. 2 expect { |b| sheet.each_row(&b) }.to yield_successive_args(*expected_rows)
  86. end
  87. 1 it "returns self" do
  88. 4 expect(sheet.each_row { double }).to be(sheet)
  89. end
  90. end
  91. 1 context "without a block" do
  92. 1 it "returns an enumerator" do
  93. 1 enum = sheet.each_row
  94. 1 expect(enum).to be_a(Enumerator)
  95. 1 expect(enum.size).to be_nil
  96. 1 expect(enum.to_a).to eq(expected_rows)
  97. end
  98. end
  99. end
  100. 1 describe "#close" do
  101. 1 it "returns nil" do
  102. 1 expect(sheet.close).to be_nil
  103. end
  104. end
  105. 1 context "when the input table is odd" do
  106. 1 shared_examples "empty_sheet" do
  107. 2 it "doesn't enumerate any header" do
  108. 4 expect { |b| sheet.each_header(&b) }.not_to yield_control
  109. end
  110. 2 it "doesn't enumerate any row" do
  111. 4 expect { |b| sheet.each_row(&b) }.not_to yield_control
  112. end
  113. end
  114. 1 context "when the input table is nil" do
  115. 1 it "raises an error" do
  116. 2 expect { new_sheet(nil) }.to raise_error(Sheetah::Sheet::Error)
  117. end
  118. end
  119. 1 context "when the input table is empty" do
  120. 3 let(:sheet) { new_sheet [] }
  121. 1 include_examples "empty_sheet"
  122. end
  123. 1 context "when the input table headers are empty" do
  124. 3 let(:sheet) { new_sheet [[]] }
  125. 1 include_examples "empty_sheet"
  126. end
  127. end
  128. end

spec/sheetah/backends/xlsx_spec.rb

100.0% lines covered

100.0% branches covered

78 relevant lines. 78 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "sheetah/backends/xlsx"
  3. 1 require "support/shared/sheet_factories"
  4. 1 RSpec.describe Sheetah::Backends::Xlsx do
  5. 1 include_context "sheet_factories"
  6. 1 let(:sheet) do
  7. 7 new_sheet("xlsx/regular.xlsx")
  8. end
  9. 1 def new_sheet(path)
  10. 14 described_class.new(path: path && fixture_path(path))
  11. end
  12. 1 describe "::register" do
  13. 4 let(:registry) { Sheetah::BackendsRegistry.new }
  14. 1 before do
  15. 3 described_class.register(registry)
  16. end
  17. 1 it "matches a XLSX path" do
  18. 1 expect(registry.get(path: "foo.xlsx")).to eq(described_class)
  19. end
  20. 1 it "doesn't match any other path" do
  21. 1 expect(registry.get(path: "foo.xls")).to be_nil
  22. end
  23. 1 it "doesn't match extra args" do
  24. 1 expect(registry.get(2, path: "foo.xlsx")).to be_nil
  25. end
  26. end
  27. 1 describe "#each_header" do
  28. 1 let(:expected_headers) do
  29. [
  30. 2 header(value: "matricule", col: "A"),
  31. header(value: "nom", col: "B"),
  32. header(value: "prénom", col: "C"),
  33. header(value: "date de naissance", col: "D"),
  34. header(value: "email", col: "E"),
  35. ]
  36. end
  37. 1 context "with a block" do
  38. 1 it "yields each header, with its letter-based index" do
  39. 2 expect { |b| sheet.each_header(&b) }.to yield_successive_args(*expected_headers)
  40. end
  41. 1 it "returns self" do
  42. 6 expect(sheet.each_header { double }).to be(sheet)
  43. end
  44. end
  45. 1 context "without a block" do
  46. 1 it "returns an enumerator" do
  47. 1 enum = sheet.each_header
  48. 1 expect(enum).to be_a(Enumerator)
  49. 1 expect(enum.size).to be(5)
  50. 1 expect(enum.to_a).to eq(expected_headers)
  51. end
  52. end
  53. end
  54. 1 describe "#each_row" do
  55. 1 let(:row1_cells) do
  56. 2 ["004774", "Ytärd", "Glœuiçe", "28/04/1998", "foo@bar.com"]
  57. end
  58. 1 let(:row2_cells) do
  59. 2 [664_623, "Goulijambon", "Carasmine", Date.new(1976, 1, 20), "foo@bar.com"]
  60. end
  61. 1 let(:expected_rows) do
  62. [
  63. 2 row(row: 1, value: cells(row1_cells, row: 1)),
  64. row(row: 2, value: cells(row2_cells, row: 2)),
  65. ]
  66. end
  67. 1 context "with a block" do
  68. 1 it "yields each row, with its 1-based index" do
  69. 2 expect { |b| sheet.each_row(&b) }.to yield_successive_args(*expected_rows)
  70. end
  71. 1 it "returns self" do
  72. 3 expect(sheet.each_row { double }).to be(sheet)
  73. end
  74. end
  75. 1 context "without a block" do
  76. 1 it "returns an enumerator" do
  77. 1 enum = sheet.each_row
  78. 1 expect(enum).to be_a(Enumerator)
  79. 1 expect(enum.size).to be_nil
  80. 1 expect(enum.to_a).to eq(expected_rows)
  81. end
  82. end
  83. end
  84. 1 describe "#close" do
  85. 1 it "returns nil" do
  86. 1 expect(sheet.close).to be_nil
  87. end
  88. end
  89. 1 context "when the input table is odd" do
  90. 1 shared_examples "empty_sheet" do
  91. 1 it "doesn't enumerate any header" do
  92. 2 expect { |b| sheet.each_header(&b) }.not_to yield_control
  93. end
  94. 1 it "doesn't enumerate any row" do
  95. 2 expect { |b| sheet.each_row(&b) }.not_to yield_control
  96. end
  97. end
  98. 1 context "when the input table is nil" do
  99. 1 it "raises an error" do
  100. 2 expect { new_sheet(nil) }.to raise_error(Sheetah::Sheet::Error)
  101. end
  102. end
  103. 1 context "when the input table is empty" do
  104. 3 let(:sheet) { new_sheet("xlsx/empty.xlsx") }
  105. 1 include_examples "empty_sheet"
  106. end
  107. 1 context "when the input table includes empty lines around the content" do
  108. 3 let(:sheet) { new_sheet("xlsx/empty_lines_around.xlsx") }
  109. 1 it "doesn't ignore them when detecting the headers" do
  110. 2 expect { |b| sheet.each_header(&b) }.to yield_control.exactly(5).times
  111. 1 expect(sheet.each_header.map(&:value)).to all(be_nil)
  112. end
  113. 1 it "ignores them when detecting the rows" do
  114. 2 expect { |b| sheet.each_row(&b) }.to yield_control.exactly(3).times
  115. end
  116. end
  117. 1 context "when the input table includes empty lines within the content" do
  118. 3 let(:sheet) { new_sheet("xlsx/empty_lines_within.xlsx") }
  119. 1 it "doesn't impact the detection of headers" do
  120. 2 expect { |b| sheet.each_header(&b) }.to yield_control.exactly(5).times
  121. end
  122. 1 it "doesn't ignore them when detecting the rows" do
  123. 2 expect { |b| sheet.each_row(&b) }.to yield_control.exactly(4).times
  124. end
  125. end
  126. end
  127. end

spec/sheetah/backends_registry_spec.rb

100.0% lines covered

66.67% branches covered

35 relevant lines. 35 lines covered and 0 lines missed.
12 total branches, 8 branches covered and 4 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "sheetah/backends_registry"
  3. 1 RSpec.describe Sheetah::BackendsRegistry do
  4. 7 let(:registry) { described_class.new }
  5. 7 let(:backend0) { double(:backend0) }
  6. 7 let(:backend1) { double(:backend1) }
  7. 1 before do
  8. 6 registry.set(backend0) do |args, opts|
  9. 4 in: 1 else: 3 ok = (case args; in [[1, 2, Symbol]] then true; else false; end)
  10. 4 in: 1 else: 0 ok && (case opts; in { foo: Hash } then true; else false; end)
  11. end
  12. 6 registry.set(backend1) do |args, opts|
  13. 3 in: 2 else: 1 ok = (case args; in [] then true; else false; end)
  14. 3 in: 2 else: 0 ok && (case opts; in { path: /\.csv$/ } then true; else false; end)
  15. end
  16. end
  17. 1 describe "#get / #set" do
  18. 1 it "can set a new backend with a matcher" do
  19. 1 expect(registry.get([1, 2, :ozij], foo: { 1 => 2 })).to be(backend0)
  20. 1 expect(registry.get(path: "file.csv")).to be(backend1)
  21. 1 expect(registry.get(double, path: "file.csv")).to be_nil
  22. end
  23. 1 it "can overwrite a previous backend matcher" do
  24. 1 registry.set(backend0) do |args, opts|
  25. 1 in: 1 else: 0 ok = (case args; in ["foo"] then true; else false; end)
  26. 1 in: 1 else: 0 ok && (case opts; in {} then true; else false; end)
  27. end
  28. 1 expect(registry.get("foo")).to be(backend0)
  29. end
  30. end
  31. 1 describe "#set" do
  32. 1 it "returns the registry itself" do
  33. 1 result = registry.set(backend0) {}
  34. 1 expect(result).to be(registry)
  35. end
  36. end
  37. 1 describe "#freeze" do
  38. 4 before { registry.freeze }
  39. 1 it "freezes the registry" do
  40. 1 expect(registry).to be_frozen
  41. end
  42. 1 it "prevents further modifications" do
  43. 1 expect do
  44. 1 registry.set(backend0) {}
  45. end.to raise_error(FrozenError)
  46. end
  47. 1 it "doesn't prevent further readings" do
  48. 1 expect(registry.get(path: "foo.csv")).to be(backend1)
  49. end
  50. end
  51. end

spec/sheetah/backends_spec.rb

100.0% lines covered

100.0% branches covered

24 relevant lines. 24 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "sheetah/backends"
  3. 1 RSpec.describe Sheetah::Backends do
  4. 1 describe "::registry" do
  5. 1 it "is a registry of backends" do
  6. 1 expect(described_class.registry).to be_a(Sheetah::BackendsRegistry)
  7. end
  8. end
  9. 1 describe "::open" do
  10. 1 let(:backend) do
  11. 2 double
  12. end
  13. 4 let(:foo) { double }
  14. 4 let(:bar) { double }
  15. 3 let(:res) { double }
  16. 1 it "may open with an explicit backend" do
  17. 1 allow(backend).to receive(:open).with(foo, bar: bar).and_return(res)
  18. 1 expect(described_class.registry).not_to receive(:get)
  19. 1 expect(described_class.open(foo, backend: backend, bar: bar)).to be(res)
  20. end
  21. 1 it "may open with an implicit backend" do
  22. 1 allow(backend).to receive(:open).with(foo, bar: bar).and_return(res)
  23. 1 allow(described_class.registry).to receive(:get).with(foo, bar: bar).and_return(backend)
  24. 1 expect(described_class.open(foo, bar: bar)).to be(res)
  25. end
  26. 1 it "may miss a backend to open" do
  27. 1 allow(described_class.registry).to receive(:get).with(foo, bar: bar).and_return(nil)
  28. 1 result = described_class.open(foo, bar: bar)
  29. 1 expect(result).to be_failure
  30. 1 expect(result.failure).to have_attributes(msg_code: "no_applicable_backend")
  31. end
  32. end
  33. end

spec/sheetah/column_spec.rb

100.0% lines covered

100.0% branches covered

36 relevant lines. 36 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "sheetah/column"
  3. 1 RSpec.describe Sheetah::Column do
  4. 8 let(:key) { double }
  5. 8 let(:type) { double }
  6. 8 let(:index) { double }
  7. 8 let(:header) { double }
  8. 7 let(:header_pattern) { Object.new }
  9. 1 let(:col) do
  10. 6 described_class.new(
  11. key: key,
  12. type: type,
  13. index: index,
  14. header: header,
  15. header_pattern: header_pattern
  16. )
  17. end
  18. 1 it "is frozen" do
  19. 1 expect(col).to be_frozen
  20. end
  21. 1 describe "#key" do
  22. 1 it "reads the attribute" do
  23. 1 expect(col.key).to be(key)
  24. end
  25. end
  26. 1 describe "#type" do
  27. 1 it "reads the attribute" do
  28. 1 expect(col.type).to be(type)
  29. end
  30. end
  31. 1 describe "#index" do
  32. 1 it "reads the attribute" do
  33. 1 expect(col.index).to be(index)
  34. end
  35. end
  36. 1 describe "#header" do
  37. 1 it "reads the attribute" do
  38. 1 expect(col.header).to be(header)
  39. end
  40. end
  41. 1 describe "#header_pattern" do
  42. 1 it "reads a frozen attribute" do
  43. 1 expect(col.header_pattern).to be(header_pattern)
  44. 1 expect(col.header_pattern).to be_frozen
  45. end
  46. 1 context "when the value is not given" do
  47. 2 let(:header_copy) { Object.new }
  48. 1 let(:col) do
  49. 1 described_class.new(
  50. key: key,
  51. type: type,
  52. index: index,
  53. header: header
  54. )
  55. end
  56. 1 before do
  57. 1 allow(header).to receive(:dup).and_return(header_copy)
  58. end
  59. 1 it "defaults to a frozen copy of the header value" do
  60. 1 expect(col.header).not_to be_frozen
  61. 1 expect(col.header_pattern).to be(header_copy) & be_frozen
  62. end
  63. end
  64. end
  65. end

spec/sheetah/errors/error_spec.rb

100.0% lines covered

100.0% branches covered

60 relevant lines. 60 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "sheetah/errors/error"
  3. 1 RSpec.describe Sheetah::Errors::Error do
  4. 1 it "is some kind of StandardError" do
  5. 1 expect(described_class.superclass).to be(StandardError)
  6. end
  7. 1 describe "class msg_code" do
  8. 1 it "has a msg_code" do
  9. 1 expect(described_class.msg_code).to eq("sheetah.errors.error")
  10. end
  11. 1 context "when inherited" do
  12. 1 context "when first defined anonymously" do
  13. 1 let(:subclass) do
  14. 6 Class.new(described_class)
  15. end
  16. 1 context "when kept anonymous" do
  17. 1 it "doesn't have a msg_code by default" do
  18. 1 expect(subclass.msg_code).to be_nil
  19. end
  20. 1 it "cannot deduce a msg_code" do
  21. 1 expect do
  22. 1 subclass.msg_code!
  23. end.to raise_error(TypeError, /cannot build msg_code/i)
  24. end
  25. 1 it "may have a custom msg_code" do
  26. 1 subclass.msg_code! "foo.bar.baz"
  27. 1 expect(subclass.msg_code).to eq("foo.bar.baz")
  28. end
  29. end
  30. 1 context "when named afterwards" do
  31. 1 before do
  32. 3 stub_const("Foizjeofijow::OIJDFO834", subclass)
  33. end
  34. 1 it "doesn't have a msg_code by default" do
  35. 1 expect(subclass.msg_code).to be_nil
  36. end
  37. 1 it "can deduce a msg_code" do
  38. 1 subclass.msg_code!
  39. 1 expect(subclass.msg_code).to eq("foizjeofijow.oijdfo834")
  40. end
  41. 1 it "may have a custom msg_code" do
  42. 1 subclass.msg_code! "foo.bar.baz"
  43. 1 expect(subclass.msg_code).to eq("foo.bar.baz")
  44. end
  45. end
  46. end
  47. 1 context "when first defined with a name" do
  48. 7 let(:namespace) { Module.new }
  49. 1 let(:subclass) do
  50. 6 class namespace::OIJDFO834 < described_class # rubocop:disable RSpec/LeakyConstantDeclaration,Lint/ConstantDefinitionInBlock,Style/ClassAndModuleChildren
  51. 6 self
  52. end
  53. end
  54. 1 context "when fully named" do
  55. 1 before do
  56. 3 stub_const("Foizjeofijow", namespace)
  57. end
  58. 1 it "has a msg_code by default" do
  59. 1 expect(subclass.msg_code).to eq("foizjeofijow.oijdfo834")
  60. end
  61. 1 it "can deduce the same msg_code" do
  62. 1 expect do
  63. 1 subclass.msg_code!
  64. end.not_to change(subclass, :msg_code)
  65. end
  66. 1 it "may have a custom msg_code" do
  67. 1 subclass.msg_code! "foo.bar.baz"
  68. 1 expect(subclass.msg_code).to eq("foo.bar.baz")
  69. end
  70. end
  71. 1 context "when not fully named" do
  72. 1 it "doesn't have a msg_code by default" do
  73. 1 expect(subclass.msg_code).to be_nil
  74. end
  75. 1 it "cannot deduce a msg_code" do
  76. 1 expect do
  77. 1 subclass.msg_code!
  78. end.to raise_error(TypeError, /cannot build msg_code/i)
  79. end
  80. 1 it "may have a custom msg_code" do
  81. 1 subclass.msg_code! "foo.bar.baz"
  82. 1 expect(subclass.msg_code).to eq("foo.bar.baz")
  83. end
  84. end
  85. end
  86. end
  87. end
  88. 1 describe "#msg_code" do
  89. 1 it "delegates to the class" do
  90. 1 allow(described_class).to receive(:msg_code).and_return(msg_code = double)
  91. 1 expect(subject.msg_code).to be(msg_code)
  92. end
  93. end
  94. end

spec/sheetah/errors/spec_error_spec.rb

100.0% lines covered

100.0% branches covered

4 relevant lines. 4 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "sheetah/errors/spec_error"
  3. 1 RSpec.describe Sheetah::Errors::SpecError do
  4. 1 it "has a msg_code" do
  5. 1 expect(described_class.msg_code).to eq("sheetah.errors.spec_error")
  6. end
  7. end

spec/sheetah/errors/type_error_spec.rb

100.0% lines covered

100.0% branches covered

4 relevant lines. 4 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "sheetah/errors/type_error"
  3. 1 RSpec.describe Sheetah::Errors::TypeError do
  4. 1 it "is some kind of Error" do
  5. 1 expect(described_class.superclass).to be(Sheetah::Errors::Error)
  6. end
  7. end

spec/sheetah/headers_spec.rb

100.0% lines covered

100.0% branches covered

54 relevant lines. 54 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "sheetah/headers"
  3. 1 require "sheetah/column"
  4. 1 require "sheetah/messaging"
  5. 1 require "sheetah/sheet"
  6. 1 require "sheetah/specification"
  7. 1 RSpec.describe Sheetah::Headers, monadic_result: true do
  8. 1 let(:specification) do
  9. 7 instance_double(
  10. Sheetah::Specification,
  11. required_columns: [],
  12. ignore_unspecified_columns?: false
  13. )
  14. end
  15. 1 let(:columns) do
  16. 7 Array.new(10) do
  17. 70 instance_double(Sheetah::Column)
  18. end
  19. end
  20. 1 let(:messenger) do
  21. 7 Sheetah::Messaging::Messenger.new
  22. end
  23. 1 let(:sheet_headers) do
  24. 7 Array.new(5) do
  25. 35 instance_double(Sheetah::Sheet::Header, col: double, value: double)
  26. end
  27. end
  28. 1 let(:headers) do
  29. 7 described_class.new(specification: specification, messenger: messenger)
  30. end
  31. 1 def stub_specification(column_by_header)
  32. 7 column_by_header = column_by_header.transform_keys(&:value)
  33. 7 allow(specification).to receive(:get) do |header_value|
  34. 16 column_by_header[header_value]
  35. end
  36. end
  37. 1 before do
  38. 7 stub_specification(
  39. sheet_headers[0] => columns[4],
  40. sheet_headers[1] => columns[1],
  41. sheet_headers[2] => columns[7],
  42. sheet_headers[3] => columns[1]
  43. )
  44. end
  45. 1 describe "#result" do
  46. 1 context "without any #add" do
  47. 1 it "is a success with no items" do
  48. 1 expect(headers.result).to eq(Success([]))
  49. end
  50. end
  51. 1 context "with some successful #add" do
  52. 1 before do
  53. 2 headers.add(sheet_headers[1])
  54. 2 headers.add(sheet_headers[2])
  55. 2 headers.add(sheet_headers[0])
  56. end
  57. 1 it "is a success and preserve #add order" do
  58. 1 expect(headers.result).to eq(
  59. Success(
  60. [
  61. Sheetah::Headers::Header.new(sheet_headers[1], columns[1]),
  62. Sheetah::Headers::Header.new(sheet_headers[2], columns[7]),
  63. Sheetah::Headers::Header.new(sheet_headers[0], columns[4]),
  64. ]
  65. )
  66. )
  67. end
  68. 1 it "doesn't message" do
  69. 1 expect(messenger.messages).to be_empty
  70. end
  71. end
  72. 1 context "when a header doesn't match a column" do
  73. 1 before do
  74. 2 headers.add(sheet_headers[0])
  75. 2 headers.add(sheet_headers[4])
  76. end
  77. 1 it "is a failure" do
  78. 1 expect(headers.result).to eq(Failure())
  79. end
  80. 1 it "messages the error" do
  81. 1 expect(messenger.messages).to eq(
  82. [
  83. Sheetah::Messaging::Message.new(
  84. severity: "ERROR",
  85. code: "invalid_header",
  86. code_data: sheet_headers[4].value,
  87. scope: "COL",
  88. scope_data: { col: sheet_headers[4].col }
  89. ),
  90. ]
  91. )
  92. end
  93. end
  94. 1 context "when there is a duplicate" do
  95. 1 before do
  96. 2 headers.add(sheet_headers[0])
  97. 2 headers.add(sheet_headers[3])
  98. 2 headers.add(sheet_headers[1])
  99. end
  100. 1 it "is a failure" do
  101. 1 expect(headers.result).to eq(Failure())
  102. end
  103. 1 it "considers the underlying column, not the header" do
  104. 1 expect(messenger.messages).to eq(
  105. [
  106. Sheetah::Messaging::Message.new(
  107. severity: "ERROR",
  108. code: "duplicated_header",
  109. code_data: sheet_headers[1].value,
  110. scope: "COL",
  111. scope_data: { col: sheet_headers[1].col }
  112. ),
  113. ]
  114. )
  115. end
  116. end
  117. end
  118. end

spec/sheetah/messaging/message_spec.rb

100.0% lines covered

100.0% branches covered

55 relevant lines. 55 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "sheetah/messaging"
  3. 1 RSpec.describe Sheetah::Messaging::Message do
  4. 5 let(:code) { double }
  5. 4 let(:code_data) { double }
  6. 4 let(:scope) { double }
  7. 5 let(:scope_data) { double }
  8. 4 let(:severity) { double }
  9. 1 let(:message) do
  10. 9 described_class.new(
  11. code: code,
  12. code_data: code_data,
  13. scope: scope,
  14. scope_data: scope_data,
  15. severity: severity
  16. )
  17. end
  18. 1 it "needs at least a code" do
  19. 2 expect { described_class.new }.to raise_error(ArgumentError, /missing keyword: :code/i)
  20. end
  21. 1 it "may have only a custom code and some defaults attributes" do
  22. 1 expect(described_class.new(code: code)).to have_attributes(
  23. code: code,
  24. code_data: nil,
  25. scope: Sheetah::Messaging::SCOPES::SHEET,
  26. scope_data: nil,
  27. severity: Sheetah::Messaging::SEVERITIES::WARN
  28. )
  29. end
  30. 1 it "may have completely custom attributes" do
  31. 1 expect(message).to have_attributes(
  32. code: code,
  33. code_data: code_data,
  34. scope: scope,
  35. scope_data: scope_data,
  36. severity: severity
  37. )
  38. end
  39. 1 it "is equivalent to a message having the same attributes" do
  40. 1 other_message = described_class.new(
  41. code: code,
  42. code_data: code_data,
  43. scope: scope,
  44. scope_data: scope_data,
  45. severity: severity
  46. )
  47. 1 expect(message).to eq(other_message)
  48. end
  49. 1 it "is not equivalent to a message having different attributes" do
  50. 1 other_message = described_class.new(
  51. code: code,
  52. code_data: code_data,
  53. scope: scope,
  54. scope_data: double,
  55. severity: severity
  56. )
  57. 1 expect(message).not_to eq(other_message)
  58. end
  59. 1 describe "#to_s" do
  60. 7 let(:code) { "foo_is_bar" }
  61. 6 let(:code_data) { nil }
  62. 7 let(:severity) { "ERROR" }
  63. 1 context "when scoped to the sheet" do
  64. 2 let(:scope) { Sheetah::Messaging::SCOPES::SHEET }
  65. 2 let(:scope_data) { nil }
  66. 1 it "can be reduced to a string" do
  67. 1 expect(message.to_s).to eq("[SHEET] ERROR: foo_is_bar")
  68. end
  69. end
  70. 1 context "when scoped to a row" do
  71. 2 let(:scope) { Sheetah::Messaging::SCOPES::ROW }
  72. 2 let(:scope_data) { { row: 42 } }
  73. 1 it "can be reduced to a string" do
  74. 1 expect(message.to_s).to eq("[ROW: 42] ERROR: foo_is_bar")
  75. end
  76. end
  77. 1 context "when scoped to a col" do
  78. 2 let(:scope) { Sheetah::Messaging::SCOPES::COL }
  79. 2 let(:scope_data) { { col: "AA" } }
  80. 1 it "can be reduced to a string" do
  81. 1 expect(message.to_s).to eq("[COL: AA] ERROR: foo_is_bar")
  82. end
  83. end
  84. 1 context "when scoped to a cell" do
  85. 2 let(:scope) { Sheetah::Messaging::SCOPES::CELL }
  86. 2 let(:scope_data) { { row: 42, col: "AA" } }
  87. 1 it "can be reduced to a string" do
  88. 1 expect(message.to_s).to eq("[CELL: AA42] ERROR: foo_is_bar")
  89. end
  90. end
  91. 1 context "when the scope doesn't make sense" do
  92. 2 let(:scope) { "oiqjzfoi" }
  93. 1 it "can be reduced to a string" do
  94. 1 expect(message.to_s).to eq("ERROR: foo_is_bar")
  95. end
  96. end
  97. 1 context "when there is some data associated with the code" do
  98. 2 let(:scope) { Sheetah::Messaging::SCOPES::SHEET }
  99. 2 let(:scope_data) { nil }
  100. 2 let(:code_data) { { foo: "bar" } }
  101. 1 it "can be reduced to a string" do
  102. 1 expect(message.to_s).to eq("[SHEET] ERROR: foo_is_bar {:foo=>\"bar\"}")
  103. end
  104. end
  105. end
  106. end

spec/sheetah/messaging/messenger_spec.rb

100.0% lines covered

100.0% branches covered

215 relevant lines. 215 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "sheetah/messaging"
  3. 1 RSpec.describe Sheetah::Messaging::Messenger do
  4. 18 let(:scopes) { Sheetah::Messaging::SCOPES }
  5. 6 let(:severities) { Sheetah::Messaging::SEVERITIES }
  6. 15 let(:row) { double }
  7. 15 let(:col) { double }
  8. 1 def be_the_frozen(obj)
  9. 10 be(obj) & be_frozen
  10. end
  11. 1 describe "building methods" do
  12. 4 let(:scope) { Object.new }
  13. 4 let(:scope_data) { Object.new }
  14. 1 describe "#initialize" do
  15. 1 it "has some default attributes" do
  16. 1 messenger = described_class.new
  17. 1 expect(messenger).to have_attributes(
  18. scope: scopes::SHEET,
  19. scope_data: nil,
  20. messages: []
  21. )
  22. end
  23. 1 it "may have some custom, frozen attributes" do
  24. 1 messenger = described_class.new(scope: scope, scope_data: scope_data)
  25. 1 expect(messenger).to have_attributes(
  26. scope: be_the_frozen(scope),
  27. scope_data: be_the_frozen(scope_data),
  28. messages: []
  29. )
  30. end
  31. end
  32. 1 describe "#dup" do
  33. 1 let(:messenger1) do
  34. 2 described_class.new(scope: scope, scope_data: scope_data)
  35. end
  36. 1 it "returns a new instance" do
  37. 1 messenger2 = messenger1.dup
  38. 1 expect(messenger2).to eq(messenger1)
  39. 1 expect(messenger2).not_to be(messenger1)
  40. end
  41. 1 it "preserves some attributes" do
  42. 1 messenger1.messages << "foobar"
  43. 1 messenger2 = messenger1.dup
  44. 1 expect(messenger2.messages).to be_empty
  45. 1 expect(messenger2.messages).not_to be(messenger1.messages)
  46. end
  47. end
  48. end
  49. 1 describe "#scoping!" do
  50. 1 subject do
  51. 12 ->(&block) { messenger.scoping!(new_scope, new_scope_data, &block) }
  52. end
  53. 7 let(:old_scope) { Object.new }
  54. 7 let(:old_scope_data) { Object.new }
  55. 7 let(:new_scope) { Object.new }
  56. 7 let(:new_scope_data) { Object.new }
  57. 1 let(:messenger) do
  58. 6 described_class.new(scope: old_scope, scope_data: old_scope_data)
  59. end
  60. 1 context "without a block" do
  61. 1 it "returns the receiver" do
  62. 1 expect(subject.call).to be(messenger)
  63. end
  64. 1 it "scopes the receiver" do
  65. 1 subject.call
  66. 1 expect(messenger).to have_attributes(
  67. scope: be_the_frozen(new_scope),
  68. scope_data: be_the_frozen(new_scope_data)
  69. )
  70. end
  71. end
  72. 1 context "with a block" do
  73. 1 it "returns the block value" do
  74. 1 foo = double
  75. 2 expect(subject.call { foo }).to eq(foo)
  76. end
  77. 1 it "scopes and yields the receiver" do
  78. 2 expect { |b| subject.call(&b) }.to yield_with_args(
  79. be(messenger) & have_attributes(
  80. scope: be_the_frozen(new_scope),
  81. scope_data: be_the_frozen(new_scope_data)
  82. )
  83. )
  84. end
  85. 1 it "unscopes the receiver after the block" do
  86. 1 subject.call {}
  87. 1 expect(messenger).to have_attributes(
  88. scope: be_the_frozen(old_scope),
  89. scope_data: be_the_frozen(old_scope_data)
  90. )
  91. end
  92. 1 it "unscopes the receiver after the block, even if it raises" do
  93. 1 e = StandardError.new
  94. 3 expect { subject.call { raise(e) } }.to raise_error(e)
  95. 1 expect(messenger).to have_attributes(
  96. scope: be_the_frozen(old_scope),
  97. scope_data: be_the_frozen(old_scope_data)
  98. )
  99. end
  100. end
  101. end
  102. 1 describe "#scoping! variants" do
  103. 12 let(:scoping_block) { proc {} }
  104. 1 let(:scoping_result) { double }
  105. 1 def allow_method_call_checking_block(receiver, method_name, *args, **opts, &block)
  106. 22 result = double
  107. 22 allow(receiver).to receive(method_name) do |*a, **o, &b|
  108. 22 expect(a).to eq(args)
  109. 22 expect(o).to eq(opts)
  110. 22 expect(b).to eq(block)
  111. 22 result
  112. end
  113. 22 result
  114. end
  115. 1 def stub_scoping!(receiver, *args, &block)
  116. 18 allow_method_call_checking_block(receiver, :scoping!, *args, &block)
  117. end
  118. 1 describe "#scoping" do
  119. 3 let(:messenger) { described_class.new }
  120. 3 let(:messenger_dup) { messenger.dup }
  121. 3 let(:scope) { double }
  122. 3 let(:scope_data) { double }
  123. 1 before do
  124. 2 allow(messenger).to receive(:dup).and_return(messenger_dup)
  125. end
  126. 1 it "applies the correct scoping to a receiver duplicate (with a block)" do
  127. 1 scoping_result = stub_scoping!(messenger_dup, scope, scope_data, &scoping_block)
  128. 1 expect(messenger.scoping(scope, scope_data, &scoping_block)).to eq(scoping_result)
  129. end
  130. 1 it "applies the correct scoping to a receiver duplicate (without a block)" do
  131. 1 scoping_result = stub_scoping!(messenger_dup, scope, scope_data)
  132. 1 expect(messenger.scoping(scope, scope_data)).to eq(scoping_result)
  133. end
  134. end
  135. 1 describe "#scope_row!" do
  136. 1 context "when the messenger is unscoped" do
  137. 3 let(:messenger) { described_class.new }
  138. 1 it "scopes the messenger to the given row (with a block)" do
  139. 1 scoping_result = stub_scoping!(messenger, scopes::ROW, { row: row }, &scoping_block)
  140. 1 expect(messenger.scope_row!(row, &scoping_block)).to eq(scoping_result)
  141. end
  142. 1 it "scopes the messenger to the given row (without a block)" do
  143. 1 scoping_result = stub_scoping!(messenger, scopes::ROW, { row: row })
  144. 1 expect(messenger.scope_row!(row)).to eq(scoping_result)
  145. end
  146. end
  147. 1 context "when the messenger is scoped to another row" do
  148. 3 let(:other_row) { double }
  149. 3 let(:messenger) { described_class.new(scope: scopes::ROW, scope_data: { row: other_row }) }
  150. 1 it "scopes the messenger to the given row (with a block)" do
  151. 1 scoping_result = stub_scoping!(messenger, scopes::ROW, { row: row }, &scoping_block)
  152. 1 expect(messenger.scope_row!(row, &scoping_block)).to eq(scoping_result)
  153. end
  154. 1 it "scopes the messenger to the given row (without a block)" do
  155. 1 scoping_result = stub_scoping!(messenger, scopes::ROW, { row: row })
  156. 1 expect(messenger.scope_row!(row)).to eq(scoping_result)
  157. end
  158. end
  159. 1 context "when the messenger is scoped to a col" do
  160. 3 let(:messenger) { described_class.new(scope: scopes::COL, scope_data: { col: col }) }
  161. 1 it "scopes the messenger to the appropriate cell (with a block)" do
  162. scoping_result =
  163. 1 stub_scoping!(messenger, scopes::CELL, { col: col, row: row }, &scoping_block)
  164. 1 expect(messenger.scope_row!(row, &scoping_block)).to eq(scoping_result)
  165. end
  166. 1 it "scopes the messenger to the appropriate cell (without a block)" do
  167. 1 scoping_result = stub_scoping!(messenger, scopes::CELL, { col: col, row: row })
  168. 1 expect(messenger.scope_row!(row)).to eq(scoping_result)
  169. end
  170. end
  171. 1 context "when the messenger is scoped to a cell" do
  172. 3 let(:other_row) { double }
  173. 1 let(:messenger) do
  174. 2 described_class.new(scope: scopes::CELL, scope_data: { col: col, row: other_row })
  175. end
  176. 1 it "scopes the messenger to the new appropriate cell (with a block)" do
  177. scoping_result =
  178. 1 stub_scoping!(messenger, scopes::CELL, { col: col, row: row }, &scoping_block)
  179. 1 expect(messenger.scope_row!(row, &scoping_block)).to eq(scoping_result)
  180. end
  181. 1 it "scopes the messenger to the new appropriate cell (without a block)" do
  182. 1 scoping_result = stub_scoping!(messenger, scopes::CELL, { col: col, row: row })
  183. 1 expect(messenger.scope_row!(row)).to eq(scoping_result)
  184. end
  185. end
  186. end
  187. 1 describe "#scope_row" do
  188. 1 def stub_scope_row!(receiver, *args, &block)
  189. 2 allow_method_call_checking_block(receiver, :scope_row!, *args, &block)
  190. end
  191. 3 let(:messenger) { described_class.new }
  192. 3 let(:messenger_dup) { messenger.dup }
  193. 1 before do
  194. 2 allow(messenger).to receive(:dup).and_return(messenger_dup)
  195. end
  196. 1 it "applies the correct scoping to a receiver duplicate (with a block)" do
  197. 1 scoping_result = stub_scope_row!(messenger_dup, row, &scoping_block)
  198. 1 expect(messenger.scope_row(row, &scoping_block)).to eq(scoping_result)
  199. end
  200. 1 it "applies the correct scoping to a receiver duplicate (without a block)" do
  201. 1 scoping_result = stub_scope_row!(messenger_dup, row)
  202. 1 expect(messenger.scope_row(row)).to eq(scoping_result)
  203. end
  204. end
  205. 1 describe "#scope_col!" do
  206. 1 context "when the messenger is unscoped" do
  207. 3 let(:messenger) { described_class.new }
  208. 1 it "scopes the messenger to the given col (with a block)" do
  209. 1 scoping_result = stub_scoping!(messenger, scopes::COL, { col: col }, &scoping_block)
  210. 1 expect(messenger.scope_col!(col, &scoping_block)).to eq(scoping_result)
  211. end
  212. 1 it "scopes the messenger to the given col (without a block)" do
  213. 1 scoping_result = stub_scoping!(messenger, scopes::COL, { col: col })
  214. 1 expect(messenger.scope_col!(col)).to eq(scoping_result)
  215. end
  216. end
  217. 1 context "when the messenger is scoped to another col" do
  218. 3 let(:other_col) { double }
  219. 3 let(:messenger) { described_class.new(scope: scopes::COL, scope_data: { col: other_col }) }
  220. 1 it "scopes the messenger to the given col (with a block)" do
  221. 1 scoping_result = stub_scoping!(messenger, scopes::COL, { col: col }, &scoping_block)
  222. 1 expect(messenger.scope_col!(col, &scoping_block)).to eq(scoping_result)
  223. end
  224. 1 it "scopes the messenger to the given col (without a block)" do
  225. 1 scoping_result = stub_scoping!(messenger, scopes::COL, { col: col })
  226. 1 expect(messenger.scope_col!(col)).to eq(scoping_result)
  227. end
  228. end
  229. 1 context "when the messenger is scoped to a row" do
  230. 3 let(:messenger) { described_class.new(scope: scopes::ROW, scope_data: { row: row }) }
  231. 1 it "scopes the messenger to the appropriate cell (with a block)" do
  232. scoping_result =
  233. 1 stub_scoping!(messenger, scopes::CELL, { row: row, col: col }, &scoping_block)
  234. 1 expect(messenger.scope_col!(col, &scoping_block)).to eq(scoping_result)
  235. end
  236. 1 it "scopes the messenger to the appropriate cell (without a block)" do
  237. 1 scoping_result = stub_scoping!(messenger, scopes::CELL, { row: row, col: col })
  238. 1 expect(messenger.scope_col!(col)).to eq(scoping_result)
  239. end
  240. end
  241. 1 context "when the messenger is scoped to a cell" do
  242. 3 let(:other_col) { double }
  243. 1 let(:messenger) do
  244. 2 described_class.new(scope: scopes::CELL, scope_data: { row: row, col: other_col })
  245. end
  246. 1 it "scopes the messenger to the new appropriate cell (with a block)" do
  247. scoping_result =
  248. 1 stub_scoping!(messenger, scopes::CELL, { row: row, col: col }, &scoping_block)
  249. 1 expect(messenger.scope_col!(col, &scoping_block)).to eq(scoping_result)
  250. end
  251. 1 it "scopes the messenger to the new appropriate cell (without a block)" do
  252. 1 scoping_result = stub_scoping!(messenger, scopes::CELL, { row: row, col: col })
  253. 1 expect(messenger.scope_col!(col)).to eq(scoping_result)
  254. end
  255. end
  256. end
  257. 1 describe "#scope_col" do
  258. 1 def stub_scope_col!(receiver, *args, &block)
  259. 2 allow_method_call_checking_block(receiver, :scope_col!, *args, &block)
  260. end
  261. 3 let(:messenger) { described_class.new }
  262. 3 let(:messenger_dup) { messenger.dup }
  263. 1 before do
  264. 2 allow(messenger).to receive(:dup).and_return(messenger_dup)
  265. end
  266. 1 it "applies the correct scoping to a receiver duplicate (with a block)" do
  267. 1 scoping_result = stub_scope_col!(messenger_dup, col, &scoping_block)
  268. 1 expect(messenger.scope_col(col, &scoping_block)).to eq(scoping_result)
  269. end
  270. 1 it "applies the correct scoping to a receiver duplicate (without a block)" do
  271. 1 scoping_result = stub_scope_col!(messenger_dup, col)
  272. 1 expect(messenger.scope_col(col)).to eq(scoping_result)
  273. end
  274. end
  275. end
  276. 1 describe "adding messages" do
  277. 9 let(:scope) { Object.new }
  278. 9 let(:scope_data) { Object.new }
  279. 9 let(:code) { double }
  280. 5 let(:code_data) { double }
  281. 9 let(:messenger) { described_class.new(scope: scope, scope_data: scope_data) }
  282. 1 describe "#warn" do
  283. 1 it "returns the receiver" do
  284. 1 expect(messenger.warn(code, code_data)).to be(messenger)
  285. end
  286. 1 it "adds the code & code_data as a warning" do
  287. 1 messenger.warn(code, code_data)
  288. 1 expect(messenger.messages).to contain_exactly(
  289. Sheetah::Messaging::Message.new(
  290. code: code,
  291. code_data: code_data,
  292. scope: scope,
  293. scope_data: scope_data,
  294. severity: severities::WARN
  295. )
  296. )
  297. end
  298. 1 it "may do without code_data" do
  299. 1 messenger.warn(code)
  300. 1 expect(messenger.messages).to contain_exactly(
  301. Sheetah::Messaging::Message.new(
  302. code: code,
  303. code_data: nil,
  304. scope: scope,
  305. scope_data: scope_data,
  306. severity: severities::WARN
  307. )
  308. )
  309. end
  310. end
  311. 1 describe "#error" do
  312. 1 it "returns the receiver" do
  313. 1 expect(messenger.error(code, code_data)).to be(messenger)
  314. end
  315. 1 it "adds the code & code_data as an error" do
  316. 1 messenger.error(code, code_data)
  317. 1 expect(messenger.messages).to contain_exactly(
  318. Sheetah::Messaging::Message.new(
  319. code: code,
  320. code_data: code_data,
  321. scope: scope,
  322. scope_data: scope_data,
  323. severity: severities::ERROR
  324. )
  325. )
  326. end
  327. 1 it "may do without code_data" do
  328. 1 messenger.error(code)
  329. 1 expect(messenger.messages).to contain_exactly(
  330. Sheetah::Messaging::Message.new(
  331. code: code,
  332. code_data: nil,
  333. scope: scope,
  334. scope_data: scope_data,
  335. severity: severities::ERROR
  336. )
  337. )
  338. end
  339. end
  340. 1 describe "#exception" do
  341. 3 let(:e) { double }
  342. 1 before do
  343. 2 allow(e).to receive(:msg_code).and_return(code)
  344. end
  345. 1 it "returns the receiver" do
  346. 1 expect(messenger.exception(e)).to be(messenger)
  347. end
  348. 1 it "adds the exception's msg_code as an error" do
  349. 1 messenger.exception(e)
  350. 1 expect(messenger.messages).to contain_exactly(
  351. Sheetah::Messaging::Message.new(
  352. code: code,
  353. code_data: nil,
  354. scope: scope,
  355. scope_data: scope_data,
  356. severity: severities::ERROR
  357. )
  358. )
  359. end
  360. end
  361. end
  362. end

spec/sheetah/row_processor_result_spec.rb

100.0% lines covered

100.0% branches covered

19 relevant lines. 19 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "sheetah/row_processor_result"
  3. 1 RSpec.describe Sheetah::RowProcessorResult do
  4. 5 let(:row) { double }
  5. 5 let(:result) { double }
  6. 4 let(:messages) { double }
  7. 1 it "wraps a result with messages" do
  8. 1 processor_result = described_class.new(row: row, result: result, messages: messages)
  9. 1 expect(processor_result).to have_attributes(row: row, result: result, messages: messages)
  10. end
  11. 1 it "is equivalent to a similar result with similar messages" do
  12. 1 processor_result0 = described_class.new(row: row, result: result, messages: messages)
  13. 1 processor_result1 = described_class.new(row: row, result: result, messages: messages)
  14. 1 expect(processor_result0).to eq(processor_result1)
  15. end
  16. 1 it "is different from a similar result with a different row" do
  17. 1 processor_result0 = described_class.new(row: row, result: result, messages: messages)
  18. 1 processor_result1 = described_class.new(row: double, result: result, messages: messages)
  19. 1 expect(processor_result0).not_to eq(processor_result1)
  20. end
  21. 1 it "may wrap a result with implicit messages" do
  22. 1 processor_result = described_class.new(row: row, result: result)
  23. 1 expect(processor_result).to have_attributes(row: row, result: result, messages: [])
  24. end
  25. end

spec/sheetah/row_processor_spec.rb

100.0% lines covered

100.0% branches covered

34 relevant lines. 34 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "sheetah/messaging"
  3. 1 require "sheetah/headers"
  4. 1 require "sheetah/row_processor"
  5. 1 require "sheetah/sheet"
  6. 1 RSpec.describe Sheetah::RowProcessor, monadic_result: true do
  7. 1 let(:messenger) do
  8. 1 instance_double(Sheetah::Messaging::Messenger, dup: row_messenger)
  9. end
  10. 1 let(:row_messenger) do
  11. 1 Sheetah::Messaging::Messenger.new
  12. end
  13. 1 let(:headers) do
  14. [
  15. 1 instance_double(Sheetah::Headers::Header, column: double, row_value_index: 0),
  16. instance_double(Sheetah::Headers::Header, column: double, row_value_index: 1),
  17. instance_double(Sheetah::Headers::Header, column: double, row_value_index: 2),
  18. ]
  19. end
  20. 1 let(:cells) do
  21. [
  22. 1 instance_double(Sheetah::Sheet::Cell, value: double, col: double),
  23. instance_double(Sheetah::Sheet::Cell, value: double, col: double),
  24. instance_double(Sheetah::Sheet::Cell, value: double, col: double),
  25. ]
  26. end
  27. 1 let(:row) do
  28. 1 instance_double(Sheetah::Sheet::Row, row: double, value: cells)
  29. end
  30. 1 let(:row_value_builder) do
  31. 1 instance_double(Sheetah::RowValueBuilder)
  32. end
  33. 1 let(:row_value_builder_result) do
  34. 1 double
  35. end
  36. 1 let(:processor) do
  37. 1 described_class.new(headers: headers, messenger: messenger)
  38. end
  39. 1 def expect_headers_add(header, cell)
  40. 3 expect(row_value_builder).to receive(:add).with(header.column, cell.value).ordered do
  41. 3 expect(row_messenger).to have_attributes(
  42. scope: Sheetah::Messaging::SCOPES::CELL,
  43. scope_data: { row: row.row, col: cell.col }
  44. )
  45. end
  46. end
  47. 1 def expect_headers_result
  48. 1 expect(row_value_builder).to receive(:result).ordered.and_return(row_value_builder_result)
  49. end
  50. 1 before do
  51. 1 allow(Sheetah::RowValueBuilder).to(
  52. receive(:new).with(row_messenger).and_return(row_value_builder)
  53. )
  54. end
  55. 1 it "processes the row and wraps the result with a dedicated set of messages" do
  56. 1 expect_headers_add(headers[0], cells[0])
  57. 1 expect_headers_add(headers[1], cells[1])
  58. 1 expect_headers_add(headers[2], cells[2])
  59. 1 expect_headers_result
  60. 1 expect(processor.call(row)).to eq(
  61. Sheetah::RowProcessorResult.new(
  62. row: row.row,
  63. result: row_value_builder_result,
  64. messages: row_messenger.messages
  65. )
  66. )
  67. end
  68. end

spec/sheetah/row_value_builder_spec.rb

100.0% lines covered

100.0% branches covered

75 relevant lines. 75 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "sheetah/row_value_builder"
  3. 1 require "sheetah/column"
  4. 1 require "sheetah/types/scalars/scalar"
  5. 1 RSpec.describe Sheetah::RowValueBuilder, monadic_result: true do
  6. 1 let(:builder) do
  7. 6 described_class.new(messenger)
  8. end
  9. 7 let(:messenger) { double }
  10. 4 let(:scalar_type) { instance_double(Sheetah::Types::Type, composite?: false) }
  11. 4 let(:scalar_key) { double }
  12. 5 let(:composite_type) { instance_double(Sheetah::Types::Type, composite?: true) }
  13. 5 let(:composite_key) { double }
  14. 1 def stub_scalar(column, value, result)
  15. 8 allow(column.type).to receive(:scalar).with(column.index, value, messenger).and_return(result)
  16. end
  17. 1 def stub_composite(type, value, result)
  18. 3 allow(type).to receive(:composite).with(value, messenger).and_return(result)
  19. end
  20. 1 context "when the column type is scalar" do
  21. 1 let(:column) do
  22. 2 instance_double(Sheetah::Column, type: scalar_type, key: scalar_key, index: nil)
  23. end
  24. 3 let(:input) { double }
  25. 2 let(:output) { double }
  26. 1 context "when the scalar type casting succeeds" do
  27. 2 before { stub_scalar(column, input, Success(output)) }
  28. 1 it "returns Success results wrapping type casted values" do
  29. 1 result = builder.add(column, input)
  30. 1 expect(result).to eq(Success(output))
  31. 1 expect(builder.result).to eq(Success(column.key => output))
  32. end
  33. end
  34. 1 context "when the scalar type casting fails" do
  35. 2 before { stub_scalar(column, input, Failure()) }
  36. 1 it "returns Failure results" do
  37. 1 result = builder.add(column, input)
  38. 1 expect(result).to eq(Failure())
  39. 1 expect(builder.result).to eq(Failure())
  40. end
  41. end
  42. end
  43. 1 context "when the column type is composite" do
  44. 1 let(:column) do
  45. 3 instance_double(Sheetah::Column, type: composite_type, key: composite_key, index: 0)
  46. end
  47. 4 let(:scalar_input) { double }
  48. 3 let(:scalar_output) { double }
  49. 1 context "when the scalar type casting succeeds" do
  50. 3 before { stub_scalar(column, scalar_input, Success(scalar_output)) }
  51. 1 context "when the composite type casting succeeds" do
  52. 2 let(:composite_output) { double }
  53. 1 before do
  54. 1 stub_composite(composite_type, [scalar_output], Success(composite_output))
  55. end
  56. 1 it "returns Success results wrapping type casted values" do
  57. 1 result = builder.add(column, scalar_input)
  58. 1 expect(result).to eq(Success(scalar_output))
  59. 1 expect(builder.result).to eq(Success(column.key => composite_output))
  60. end
  61. end
  62. 1 context "when the composite type casting fails" do
  63. 2 before { stub_composite(composite_type, [scalar_output], Failure()) }
  64. 1 it "returns Success and Failure appropriately" do
  65. 1 result = builder.add(column, scalar_input)
  66. 1 expect(result).to eq(Success(scalar_output))
  67. 1 expect(builder.result).to eq(Failure())
  68. end
  69. end
  70. end
  71. 1 context "when the scalar type casting fails" do
  72. 2 before { stub_scalar(column, scalar_input, Failure()) }
  73. 1 it "returns Failure results" do
  74. 1 result = builder.add(column, scalar_input)
  75. 1 expect(result).to eq(Failure())
  76. 1 expect(builder.result).to eq(Failure())
  77. end
  78. end
  79. end
  80. 1 context "when handling multiple columns in any order" do
  81. 1 let(:column0) do
  82. 1 instance_double(Sheetah::Column, type: composite_type, key: composite_key, index: 2)
  83. end
  84. 1 let(:column1) do
  85. 1 instance_double(Sheetah::Column, type: composite_type, key: composite_key, index: 1)
  86. end
  87. 1 let(:column2) do
  88. 1 instance_double(Sheetah::Column, type: scalar_type, key: scalar_key, index: nil)
  89. end
  90. 1 it "reduces them to a correctly typed aggregate" do
  91. 1 stub_scalar(column0, in0 = double, Success(out0 = double))
  92. 1 stub_scalar(column1, in1 = double, Success(out1 = double))
  93. 1 stub_scalar(column2, in2 = double, Success(out2 = double))
  94. 1 builder.add(column0, in0)
  95. 1 builder.add(column2, in2)
  96. 1 builder.add(column1, in1)
  97. 1 stub_composite(composite_type, [nil, out1, out0], Success(composite_out = double))
  98. 1 expect(builder.result).to eq(
  99. Success(
  100. composite_key => composite_out,
  101. scalar_key => out2
  102. )
  103. )
  104. end
  105. end
  106. end

spec/sheetah/sheet_processor_result_spec.rb

100.0% lines covered

100.0% branches covered

14 relevant lines. 14 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "sheetah/sheet_processor_result"
  3. 1 RSpec.describe Sheetah::SheetProcessorResult do
  4. 4 let(:result) { double }
  5. 3 let(:messages) { double }
  6. 1 it "wraps a result with messages" do
  7. 1 processor_result = described_class.new(result: result, messages: messages)
  8. 1 expect(processor_result).to have_attributes(result: result, messages: messages)
  9. end
  10. 1 it "is equivalent to a similar result with similar messages" do
  11. 1 processor_result0 = described_class.new(result: result, messages: messages)
  12. 1 processor_result1 = described_class.new(result: result, messages: messages)
  13. 1 expect(processor_result0).to eq(processor_result1)
  14. end
  15. 1 it "may wrap a result with implicit messages" do
  16. 1 processor_result = described_class.new(result: result)
  17. 1 expect(processor_result).to have_attributes(result: result, messages: [])
  18. end
  19. end

spec/sheetah/sheet_processor_spec.rb

100.0% lines covered

100.0% branches covered

103 relevant lines. 103 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "sheetah/sheet_processor"
  3. 1 require "sheetah/specification"
  4. 1 RSpec.describe Sheetah::SheetProcessor, monadic_result: true do
  5. 1 let(:specification) do
  6. 6 instance_double(Sheetah::Specification)
  7. end
  8. 1 let(:processor) do
  9. 6 described_class.new(specification)
  10. end
  11. 1 let(:sheet_class) do
  12. 10 Class.new { include Sheetah::Sheet }
  13. end
  14. 1 let(:backend_args) do
  15. 6 [double, double]
  16. end
  17. 1 let(:backend_opts) do
  18. 6 { foo: double, bar: double }
  19. end
  20. 1 let(:sheet) do
  21. 3 instance_double(sheet_class)
  22. end
  23. 1 def call(&block)
  24. 4 block ||= proc {} # stub a dummy proc
  25. 4 processor.call(*backend_args, backend: sheet_class, **backend_opts, &block)
  26. end
  27. 1 def stub_sheet_open_ok(success = double)
  28. 3 allow(sheet_class).to(
  29. receive(:open)
  30. .with(*backend_args, **backend_opts)
  31. .and_yield(sheet)
  32. .and_return(Success(success))
  33. )
  34. 3 success
  35. end
  36. 1 def stub_sheet_open_ko(failure = double)
  37. 1 allow(sheet_class).to(
  38. receive(:open)
  39. .with(*backend_args, **backend_opts)
  40. .and_return(Failure(failure))
  41. )
  42. 1 failure
  43. end
  44. 1 describe "backend detection" do
  45. 1 it "can rely on the explicit argument" do
  46. 1 actual_args = backend_args
  47. 1 actual_opts = backend_opts.merge(backend: sheet_class)
  48. 1 expect(Sheetah::Backends).to(
  49. receive(:open)
  50. .with(*actual_args, **actual_opts)
  51. .and_return(Success())
  52. )
  53. 1 result = processor.call(*actual_args, **actual_opts)
  54. 1 expect(result).to eq(
  55. Sheetah::SheetProcessorResult.new(
  56. result: Success(),
  57. messages: []
  58. )
  59. )
  60. end
  61. 1 it "can rely on the implicit detection" do
  62. 1 actual_args = backend_args
  63. 1 actual_opts = backend_opts
  64. 1 expect(Sheetah::Backends).to(
  65. receive(:open)
  66. .with(*actual_args, **actual_opts)
  67. .and_return(Success())
  68. )
  69. 1 result = processor.call(*actual_args, **actual_opts)
  70. 1 expect(result).to eq(
  71. Sheetah::SheetProcessorResult.new(
  72. result: Success(),
  73. messages: []
  74. )
  75. )
  76. end
  77. end
  78. 1 context "when there is a sheet error" do
  79. 1 let(:error_class) do
  80. 1 klass = Class.new(Sheetah::Sheet::Error)
  81. 1 stub_const("Foo::Bar::BazError", klass)
  82. 1 klass.msg_code!
  83. 1 klass
  84. end
  85. 1 let(:error) do
  86. 1 error_class.exception
  87. end
  88. 1 before do
  89. 1 stub_sheet_open_ko(error)
  90. end
  91. 1 it "is an empty failure, with messages" do
  92. 1 expect(call).to eq(
  93. Sheetah::SheetProcessorResult.new(
  94. result: Failure(),
  95. messages: [
  96. Sheetah::Messaging::Message.new(
  97. code: "foo.bar.baz_error",
  98. code_data: nil,
  99. scope: "SHEET",
  100. scope_data: nil,
  101. severity: "ERROR"
  102. ),
  103. ]
  104. )
  105. )
  106. end
  107. end
  108. 1 shared_context "when there is no sheet error" do
  109. 2 let(:sheet_headers) do
  110. 9 Array.new(2) { instance_double(Sheetah::Sheet::Header) }
  111. end
  112. 2 let(:sheet_rows) do
  113. 12 Array.new(3) { instance_double(Sheetah::Sheet::Row) }
  114. end
  115. 2 let(:messenger) do
  116. 3 instance_double(Sheetah::Messaging::Messenger, messages: double)
  117. end
  118. 2 let(:headers) do
  119. 3 instance_double(Sheetah::Headers)
  120. end
  121. 2 def stub_messenger
  122. 3 allow(Sheetah::Messaging::Messenger).to(
  123. receive(:new)
  124. .with(no_args)
  125. .and_return(messenger)
  126. )
  127. end
  128. 2 def stub_enumeration(obj, method_name, enumerable)
  129. 6 enum = Enumerator.new do |yielder|
  130. 17 enumerable.each { |item| yielder << item }
  131. 5 obj
  132. end
  133. 6 allow(obj).to receive(method_name).with(no_args) do |&block|
  134. 5 enum.each(&block)
  135. end
  136. end
  137. 2 def stub_headers
  138. 3 allow(Sheetah::Headers).to(
  139. receive(:new)
  140. .with(specification: specification, messenger: messenger)
  141. .and_return(headers)
  142. )
  143. end
  144. 2 def stub_headers_ops(result)
  145. 3 sheet_headers.each do |sheet_header|
  146. 6 expect(headers).to receive(:add).with(sheet_header).ordered
  147. end
  148. 3 expect(headers).to receive(:result).and_return(result).ordered
  149. end
  150. 2 before do
  151. 3 stub_messenger
  152. 3 stub_headers
  153. 3 stub_sheet_open_ok
  154. 3 stub_enumeration(sheet, :each_header, sheet_headers)
  155. 3 stub_enumeration(sheet, :each_row, sheet_rows)
  156. end
  157. end
  158. 1 context "when there is a header error" do
  159. 1 include_context "when there is no sheet error"
  160. 1 before do
  161. 1 stub_headers_ops(Failure())
  162. end
  163. 1 it "is an empty failure, with messages" do
  164. 1 result = call
  165. 1 expect(result).to eq(
  166. Sheetah::SheetProcessorResult.new(
  167. result: Failure(),
  168. messages: messenger.messages
  169. )
  170. )
  171. end
  172. end
  173. 1 context "when there is no error" do
  174. 1 include_context "when there is no sheet error"
  175. 1 let(:headers_spec) do
  176. 2 double
  177. end
  178. 1 let(:processed_rows) do
  179. 8 Array.new(sheet_rows.size) { double }
  180. end
  181. 1 def stub_row_processing
  182. 2 allow(Sheetah::RowProcessor).to(
  183. receive(:new)
  184. .with(headers: headers_spec, messenger: messenger)
  185. .and_return(row_processor = instance_double(Sheetah::RowProcessor))
  186. )
  187. 2 sheet_rows.zip(processed_rows) do |row, processed_row|
  188. 6 allow(row_processor).to receive(:call).with(row).and_return(processed_row)
  189. end
  190. end
  191. 1 before do
  192. 2 stub_headers_ops(Success(headers_spec))
  193. 2 stub_row_processing
  194. end
  195. 1 it "is an empty success, with messages" do
  196. 1 result = call
  197. 1 expect(result).to eq(
  198. Sheetah::SheetProcessorResult.new(
  199. result: Success(),
  200. messages: messenger.messages
  201. )
  202. )
  203. end
  204. 1 it "yields each processed row" do
  205. 2 expect { |b| call(&b) }.to yield_successive_args(*processed_rows)
  206. end
  207. end
  208. end

spec/sheetah/sheet_spec.rb

100.0% lines covered

100.0% branches covered

143 relevant lines. 143 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "sheetah/sheet"
  3. 1 RSpec.describe Sheetah::Sheet, monadic_result: true do
  4. 1 let(:sheet_class) do
  5. 21 c = Class.new do
  6. 21 def initialize(foo, bar:)
  7. 3 @foo = foo
  8. 3 @bar = bar
  9. end
  10. end
  11. 21 c.include(described_class)
  12. 21 stub_const("SheetClass", c)
  13. 21 c
  14. end
  15. 1 let(:sheet) do
  16. 3 sheet_class.new(foo, bar: bar)
  17. end
  18. 15 let(:foo) { double }
  19. 15 let(:bar) { double }
  20. 1 describe "::Error" do
  21. 2 subject { sheet_class::Error }
  22. 1 it "exposes some kind of Sheetah::Errors::Error" do
  23. 1 expect(subject.superclass).to be(Sheetah::Errors::Error)
  24. end
  25. end
  26. 1 describe "::Header" do
  27. 3 let(:col) { double }
  28. 3 let(:val) { double }
  29. 3 let(:wrapper) { sheet_class::Header.new(col: col, value: val) }
  30. 1 it "exposes a header wrapper" do
  31. 1 expect(wrapper).to have_attributes(col: col, value: val)
  32. end
  33. 1 it "is comparable" do
  34. 1 expect(wrapper).to eq(sheet_class::Header.new(col: col, value: val))
  35. 1 expect(wrapper).not_to eq(sheet_class::Header.new(col: double, value: val))
  36. end
  37. end
  38. 1 describe "::Row" do
  39. 3 let(:row) { double }
  40. 3 let(:val) { double }
  41. 3 let(:wrapper) { sheet_class::Row.new(row: row, value: val) }
  42. 1 it "exposes a row wrapper" do
  43. 1 expect(wrapper).to have_attributes(row: row, value: val)
  44. end
  45. 1 it "is comparable" do
  46. 1 expect(wrapper).to eq(sheet_class::Row.new(row: row, value: val))
  47. 1 expect(wrapper).not_to eq(sheet_class::Row.new(row: double, value: val))
  48. end
  49. end
  50. 1 describe "::Cell" do
  51. 3 let(:row) { double }
  52. 3 let(:col) { double }
  53. 3 let(:val) { double }
  54. 3 let(:wrapper) { sheet_class::Cell.new(row: row, col: col, value: val) }
  55. 1 it "exposes a row wrapper" do
  56. 1 expect(wrapper).to have_attributes(row: row, col: col, value: val)
  57. end
  58. 1 it "is comparable" do
  59. 1 expect(wrapper).to eq(sheet_class::Cell.new(row: row, col: col, value: val))
  60. 1 expect(wrapper).not_to eq(sheet_class::Cell.new(row: double, col: col, value: val))
  61. end
  62. end
  63. 1 describe "singleton class methods" do
  64. 1 let(:samples) do
  65. [
  66. 2 ["A", 1],
  67. ["B", 2],
  68. ["Z", 26],
  69. ["AA", 27],
  70. ["AZ", 52],
  71. ["BA", 53],
  72. ["ZA", 677],
  73. ["ZZ", 702],
  74. ["AAA", 703],
  75. ["AAZ", 728],
  76. ["ABA", 729],
  77. ["BZA", 2029],
  78. ]
  79. end
  80. 1 describe "::col2int" do
  81. 1 it "turns letter-based indexes into integer-based indexes" do
  82. 1 samples.each do |(col, int)|
  83. 12 res = described_class.col2int(col)
  84. 12 expect(res).to eq(int), "Expected #{col} => #{int}, got: #{res}"
  85. end
  86. end
  87. 1 it "fails on invalid inputs" do
  88. 2 expect { described_class.col2int(nil) }.to raise_error(ArgumentError)
  89. 2 expect { described_class.col2int("") }.to raise_error(ArgumentError)
  90. 2 expect { described_class.col2int("a") }.to raise_error(ArgumentError)
  91. 2 expect { described_class.col2int("€") }.to raise_error(ArgumentError)
  92. end
  93. end
  94. 1 describe "::int2col" do
  95. 1 it "turns integer-based indexes into letter-based indexes" do
  96. 1 samples.each do |(col, int)|
  97. 12 res = described_class.int2col(int)
  98. 12 expect(res).to eq(col), "Expected #{int} => #{col}, got: #{res}"
  99. end
  100. end
  101. 1 it "fails on invalid inputs" do
  102. 2 expect { described_class.int2col(nil) }.to raise_error(ArgumentError)
  103. 2 expect { described_class.int2col(0) }.to raise_error(ArgumentError)
  104. 2 expect { described_class.int2col(-12) }.to raise_error(ArgumentError)
  105. 2 expect { described_class.int2col(27.0) }.to raise_error(ArgumentError)
  106. end
  107. end
  108. end
  109. 1 describe "::open" do
  110. 1 let(:sheet) do
  111. 11 instance_double(sheet_class)
  112. end
  113. 1 before do
  114. 11 allow(sheet_class).to receive(:new).with(foo, bar: bar).and_return(sheet)
  115. end
  116. 1 context "without a block" do
  117. 1 it "returns a new sheet wrapped as a Success" do
  118. 1 expect(sheet_class.open(foo, bar: bar)).to eq(Success(sheet))
  119. end
  120. end
  121. 1 context "with a block" do
  122. 1 before do
  123. 10 allow(sheet).to receive(:close)
  124. end
  125. 1 it "yields a new sheet" do
  126. 1 yielded = false
  127. 1 sheet_class.open(foo, bar: bar) do |opened_sheet|
  128. 1 yielded = true
  129. 1 expect(opened_sheet).to be(sheet)
  130. end
  131. 1 expect(yielded).to be(true)
  132. end
  133. 1 it "returns the value of the block, wrapped as a success" do
  134. 1 block_result = double
  135. 2 actual_block_result = sheet_class.open(foo, bar: bar) { block_result }
  136. 1 expect(actual_block_result).to eq(Success(block_result))
  137. end
  138. 1 it "closes after yielding" do
  139. 1 sheet_class.open(foo, bar: bar) do
  140. 1 expect(sheet).not_to have_received(:close)
  141. end
  142. 1 expect(sheet).to have_received(:close)
  143. end
  144. 1 context "when an exception is raised" do
  145. 3 let(:exception) { Class.new(Exception) } # rubocop:disable Lint/InheritException
  146. 3 let(:error) { Class.new(StandardError) }
  147. 4 let(:sheet_error) { Class.new(Sheetah::Sheet::Error) }
  148. 1 context "without yielding control" do
  149. 1 it "doesn't rescue an exception" do
  150. 1 allow(sheet_class).to receive(:new).and_raise(exception)
  151. 1 expect do
  152. 1 sheet_class.open(foo, bar: bar)
  153. end.to raise_error(exception)
  154. end
  155. 1 it "doesn't rescue a standard error" do
  156. 1 allow(sheet_class).to receive(:new).and_raise(error)
  157. 1 expect do
  158. 1 sheet_class.open(foo, bar: bar)
  159. end.to raise_error(error)
  160. end
  161. 1 it "rescues and wraps a sheet error in a failure" do
  162. 1 allow(sheet_class).to receive(:new).and_raise(e = sheet_error.exception)
  163. 1 result = sheet_class.open(foo, bar: bar)
  164. 1 expect(result).to eq(Failure(e))
  165. end
  166. end
  167. 1 context "while yielding control" do
  168. 1 it "doesn't rescue but closes after an exception is raised" do
  169. 1 expect do
  170. 1 sheet_class.open(foo, bar: bar) do
  171. 1 expect(sheet).not_to have_received(:close)
  172. 1 raise exception
  173. end
  174. end.to raise_error(exception)
  175. 1 expect(sheet).to have_received(:close)
  176. end
  177. 1 it "doesn't rescue but closes after a standard error is raised" do
  178. 1 expect do
  179. 1 sheet_class.open(foo, bar: bar) do
  180. 1 expect(sheet).not_to have_received(:close)
  181. 1 raise error
  182. end
  183. end.to raise_error(error)
  184. 1 expect(sheet).to have_received(:close)
  185. end
  186. 1 it "rescues and closes after a sheet error is raised" do
  187. 1 sheet_class.open(foo, bar: bar) do
  188. 1 expect(sheet).not_to have_received(:close)
  189. 1 raise sheet_error
  190. end
  191. 1 expect(sheet).to have_received(:close)
  192. end
  193. 1 it "returns the exception, wrapped as a failure, after a sheet error is raised" do
  194. 1 e = sheet_error.exception # raise the instance directly to simplify result matching
  195. 1 result = sheet_class.open(foo, bar: bar) do
  196. 1 raise e
  197. end
  198. 1 expect(result).to eq(Failure(e))
  199. end
  200. end
  201. end
  202. end
  203. end
  204. 1 describe "#each_header" do
  205. 1 it "is abstract" do
  206. 2 expect { sheet.each_header }.to raise_error(
  207. NoMethodError, "You must implement SheetClass#each_header => self"
  208. )
  209. end
  210. end
  211. 1 describe "#each_row" do
  212. 1 it "is abstract" do
  213. 2 expect { sheet.each_row }.to raise_error(
  214. NoMethodError, "You must implement SheetClass#each_row => self"
  215. )
  216. end
  217. end
  218. 1 describe "#close" do
  219. 1 it "is abstract" do
  220. 2 expect { sheet.close }.to raise_error(
  221. NoMethodError, "You must implement SheetClass#close => nil"
  222. )
  223. end
  224. end
  225. end

spec/sheetah/specification_spec.rb

100.0% lines covered

100.0% branches covered

81 relevant lines. 81 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "sheetah/specification"
  3. 1 RSpec.describe Sheetah::Specification do
  4. 1 let(:spec) do
  5. 12 described_class.new
  6. end
  7. 1 describe "#set" do
  8. 1 it "rejects nil patterns" do
  9. 1 pattern = nil
  10. 1 column = double
  11. 1 expect do
  12. 1 spec.set(pattern, column)
  13. end.to raise_error(described_class::InvalidPatternError, "nil")
  14. end
  15. 1 it "rejects mutable patterns" do
  16. 1 pattern = instance_double(Object, frozen?: false, inspect: "mutable_pattern_inspect")
  17. 1 column = double
  18. 1 expect do
  19. 1 spec.set(pattern, column)
  20. end.to raise_error(described_class::MutablePatternError, "mutable_pattern_inspect")
  21. end
  22. 1 it "rejects duplicated patterns" do
  23. 1 pattern = instance_double(Object, frozen?: true, inspect: "pattern_dup")
  24. 1 column0 = double
  25. 1 column1 = double
  26. 1 spec.set(pattern, column0)
  27. 1 expect do
  28. 1 spec.set(pattern, column0)
  29. end.to raise_error(described_class::DuplicatedPatternError, "pattern_dup")
  30. 1 expect do
  31. 1 spec.set(pattern, column1)
  32. end.to raise_error(described_class::DuplicatedPatternError, "pattern_dup")
  33. end
  34. 1 it "accepts unique, frozen patterns" do
  35. 1 pattern1 = instance_double(Object, frozen?: true, inspect: "pattern1")
  36. 1 pattern2 = instance_double(Object, frozen?: true, inspect: "pattern2")
  37. 1 column = double
  38. 1 expect do
  39. 1 spec.set(pattern1, column)
  40. 1 spec.set(pattern2, column)
  41. end.not_to raise_error
  42. end
  43. 1 context "when frozen" do
  44. 1 it "cannot set new patterns" do
  45. 1 spec.freeze
  46. 1 pattern = instance_double(Object, frozen?: true)
  47. 1 column = double
  48. 1 expect do
  49. 1 spec.set(pattern, column)
  50. end.to raise_error(FrozenError)
  51. end
  52. end
  53. end
  54. 1 describe "#get" do
  55. 1 let(:regexp_pattern) do
  56. 7 /foo\d{3}bar/i.freeze
  57. end
  58. 1 let(:string_pattern) do
  59. 7 "Doubitchou"
  60. end
  61. 1 let(:other_pattern) do
  62. 7 instance_double(Object, frozen?: true)
  63. end
  64. 1 let(:columns) do
  65. 28 Array.new(3) { double }
  66. end
  67. 1 before do
  68. 7 spec.set(string_pattern, columns[0])
  69. 7 spec.set(regexp_pattern, columns[1])
  70. 7 spec.set(other_pattern, columns[2])
  71. end
  72. 1 it "returns nil when header is nil" do
  73. 1 expect(spec.get(nil)).to be_nil
  74. end
  75. 1 context "with a Regexp pattern" do
  76. 1 it "returns the matching column" do
  77. 1 expect(spec.get("foo123bar")).to eq(columns[1])
  78. 1 expect(spec.get("Foo480BAR")).to eq(columns[1])
  79. end
  80. end
  81. 1 context "with a String pattern" do
  82. 1 it "returns the matching column" do
  83. 1 expect(spec.get("Doubitchou")).to eq(columns[0])
  84. end
  85. 1 it "matches case-sensitively" do
  86. 1 expect(spec.get("doubitchou")).to be_nil
  87. end
  88. end
  89. 1 context "with any other pattern" do
  90. 3 let(:header) { "boudoudou" }
  91. 1 it "matches an equivalent header" do
  92. 1 allow(other_pattern).to receive(:==).with(header).and_return(true)
  93. 1 expect(spec.get(header)).to eq(columns[2])
  94. end
  95. 1 it "doesn't match a non-equivalent header" do
  96. 1 allow(other_pattern).to receive(:==).with(header).and_return(false)
  97. 1 expect(spec.get(header)).to be_nil
  98. end
  99. end
  100. 1 context "when frozen" do
  101. 1 it "can get existing patterns" do
  102. 1 spec.freeze
  103. 1 expect(spec.get("Doubitchou")).to eq(columns[0])
  104. end
  105. end
  106. end
  107. 1 describe "errors" do
  108. 1 example "invalid pattern" do
  109. 1 expect(described_class::InvalidPatternError).to have_attributes(
  110. superclass: Sheetah::Errors::SpecError,
  111. msg_code: "sheetah.specification.invalid_pattern_error"
  112. )
  113. end
  114. 1 example "mutable pattern" do
  115. 1 expect(described_class::MutablePatternError).to have_attributes(
  116. superclass: Sheetah::Errors::SpecError,
  117. msg_code: "sheetah.specification.mutable_pattern_error"
  118. )
  119. end
  120. 1 example "duplicated pattern" do
  121. 1 expect(described_class::DuplicatedPatternError).to have_attributes(
  122. superclass: Sheetah::Errors::SpecError,
  123. msg_code: "sheetah.specification.duplicated_pattern_error"
  124. )
  125. end
  126. end
  127. end

spec/sheetah/template_config_spec.rb

100.0% lines covered

100.0% branches covered

3 relevant lines. 3 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "sheetah/template_config"
  3. 1 RSpec.describe Sheetah::TemplateConfig do
  4. 1 pending "TODO"
  5. end

spec/sheetah/template_spec.rb

100.0% lines covered

100.0% branches covered

3 relevant lines. 3 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "sheetah/template"
  3. 1 RSpec.describe Sheetah::Template do
  4. 1 pending "TODO"
  5. end

spec/sheetah/types/cast_chain_spec.rb

100.0% lines covered

100.0% branches covered

77 relevant lines. 77 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "sheetah/types/cast_chain"
  3. 1 RSpec.describe Sheetah::Types::CastChain do
  4. 1 let(:cast_interface) do
  5. 9 Class.new do
  6. 9 def call(_value, _messenger); end
  7. end
  8. end
  9. 1 let(:cast) do
  10. 1 cast_interface.new
  11. end
  12. 5 let(:cast0) { cast_double }
  13. 5 let(:cast1) { cast_double }
  14. 4 let(:cast2) { cast_double }
  15. 1 let(:chain) do
  16. 2 described_class.new([cast0, cast1])
  17. end
  18. 1 def cast_double
  19. 15 instance_double(cast_interface)
  20. end
  21. 1 describe "#initialize" do
  22. 1 it "builds an empty chain by default" do
  23. 1 chain = described_class.new
  24. 1 expect(chain.casts).to be_empty
  25. end
  26. 1 it "builds a non-empty chain using the optional parameter" do
  27. 1 chain = described_class.new([cast0, cast1])
  28. 1 expect(chain.casts).to eq([cast0, cast1])
  29. end
  30. end
  31. 1 describe "#prepend" do
  32. 1 it "prepends a cast to the chain" do
  33. 1 chain.prepend(cast2)
  34. 1 expect(chain.casts).to eq([cast2, cast0, cast1])
  35. end
  36. end
  37. 1 describe "#appends" do
  38. 1 it "appends a cast to the chain" do
  39. 1 chain.append(cast2)
  40. 1 expect(chain.casts).to eq([cast0, cast1, cast2])
  41. end
  42. end
  43. 1 describe "#freeze" do
  44. 1 it "freezes the whole chain" do
  45. 1 chain = described_class.new([cast.dup, cast.dup])
  46. 1 chain.freeze
  47. 1 expect(chain.casts).to all(be_frozen)
  48. 1 expect(chain.casts).to be_frozen
  49. 1 expect(chain).to be_frozen
  50. end
  51. end
  52. 1 describe "#call", monadic_result: true do
  53. 1 let(:messenger) do
  54. 5 double
  55. end
  56. 1 it "maps the value and passes the messenger to all casts" do
  57. 1 value0 = double
  58. 1 expect(cast0).to(receive(:call).with(value0, messenger).and_return(value1 = double))
  59. 1 expect(cast1).to(receive(:call).with(value1, messenger).and_return(value2 = double))
  60. 1 expect(cast2).to(receive(:call).with(value2, messenger).and_return(value3 = double))
  61. 1 chain = described_class.new([cast0, cast1, cast2])
  62. 1 result = chain.call(value0, messenger)
  63. 1 expect(result).to eq(Success(value3))
  64. end
  65. 1 context "when a cast throws :success without value" do
  66. 1 it "halts the chain and returns Success(nil)" do
  67. 1 chain = described_class.new [
  68. 1 ->(value, _messenger) { value.capitalize },
  69. 1 ->(_value, _messenger) { throw :success },
  70. cast_double,
  71. ]
  72. 1 result = chain.call("foo", messenger)
  73. 1 expect(result).to eq(Success(nil))
  74. end
  75. end
  76. 1 context "when a cast throws :success with a value" do
  77. 1 it "halts the chain and returns Success(<value>)" do
  78. 1 chain = described_class.new [
  79. 1 ->(value, _messenger) { value.capitalize },
  80. 1 ->(_value, _messenger) { throw :success, "bar" },
  81. cast_double,
  82. ]
  83. 1 result = chain.call("foo", messenger)
  84. 1 expect(result).to eq(Success("bar"))
  85. end
  86. end
  87. 1 context "when a cast throws :failure without a value" do
  88. 1 it "halts the chain and returns Failure()" do
  89. 1 chain = described_class.new [
  90. 1 ->(value, _messenger) { value.capitalize },
  91. 1 ->(_value, _messenger) { throw :failure },
  92. cast_double,
  93. ]
  94. 1 result = chain.call("foo", messenger)
  95. 1 expect(result).to eq(Failure())
  96. end
  97. end
  98. 1 context "when a cast throws :failure with a value" do
  99. 1 it "halts the chain, adds a <value> message as an error and returns Failure()" do
  100. 1 chain = described_class.new [
  101. 1 ->(value, _messenger) { value.capitalize },
  102. 1 ->(_value, _messenger) { throw :failure, "some_code" },
  103. cast_double,
  104. ]
  105. 1 allow(messenger).to receive(:error)
  106. 1 result = chain.call("foo", messenger)
  107. 1 expect(result).to eq(Failure())
  108. 1 expect(messenger).to have_received(:error).with("some_code")
  109. end
  110. end
  111. end
  112. end

spec/sheetah/types/composites/array_compact_spec.rb

100.0% lines covered

100.0% branches covered

17 relevant lines. 17 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "sheetah/types/composites/array_compact"
  3. 1 require "support/shared/composite_type"
  4. 1 require "support/shared/cast_class"
  5. 1 RSpec.describe Sheetah::Types::Composites::ArrayCompact do
  6. 1 include_examples "composite_type"
  7. 1 it "inherits from the composite array type" do
  8. 1 expect(described_class.superclass).to be(Sheetah::Types::Composites::Array)
  9. end
  10. 1 describe "custom cast class" do
  11. 1 subject(:cast_class) do
  12. 4 described_class.cast_classes.last
  13. end
  14. 1 it "is appended to the superclass' cast classes" do
  15. 1 expect(described_class.cast_classes).to eq(
  16. described_class.superclass.cast_classes + [cast_class]
  17. )
  18. end
  19. 1 include_examples "cast_class"
  20. 1 describe "#call" do
  21. 1 it "compacts the given value" do
  22. 1 allow(value).to receive(:compact).and_return(compact_value = double)
  23. 1 expect(cast.call(value, messenger)).to eq(compact_value)
  24. end
  25. end
  26. end
  27. end

spec/sheetah/types/composites/array_spec.rb

100.0% lines covered

100.0% branches covered

24 relevant lines. 24 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "sheetah/types/composites/array"
  3. 1 require "support/shared/composite_type"
  4. 1 require "support/shared/cast_class"
  5. 1 RSpec.describe Sheetah::Types::Composites::Array do
  6. 1 include_examples "composite_type"
  7. 1 it "inherits from the basic composite type" do
  8. 1 expect(described_class.superclass).to be(Sheetah::Types::Composites::Composite)
  9. end
  10. 1 describe "custom cast class" do
  11. 1 subject(:cast_class) do
  12. 5 described_class.cast_classes.last
  13. end
  14. 1 it "is appended to the superclass' cast classes" do
  15. 1 expect(described_class.cast_classes).to eq(
  16. described_class.superclass.cast_classes + [cast_class]
  17. )
  18. end
  19. 1 include_examples "cast_class"
  20. 1 describe "#call" do
  21. 1 before do
  22. 2 allow(value).to receive(:is_a?).with(Array).and_return(value_is_array)
  23. end
  24. 1 context "when the value is an array" do
  25. 2 let(:value_is_array) { true }
  26. 1 it "is a success" do
  27. 1 expect(cast.call(value, messenger)).to eq(value)
  28. end
  29. end
  30. 1 context "when the value is not an array" do
  31. 2 let(:value_is_array) { false }
  32. 1 it "is a failure" do
  33. 2 expect { cast.call(value, messenger) }.to throw_symbol(:failure, "must_be_array")
  34. end
  35. end
  36. end
  37. end
  38. end

spec/sheetah/types/composites/composite_spec.rb

100.0% lines covered

100.0% branches covered

9 relevant lines. 9 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "sheetah/types/composites/composite"
  3. 1 require "support/shared/composite_type"
  4. 1 RSpec.describe Sheetah::Types::Composites::Composite do
  5. 1 include_examples "composite_type"
  6. 1 it "inherits from the basic type" do
  7. 1 expect(described_class.superclass).to be(Sheetah::Types::Type)
  8. end
  9. 1 describe "::cast_classes" do
  10. 1 it "is empty" do
  11. 1 expect(described_class.cast_classes).to be_empty
  12. end
  13. end
  14. end

spec/sheetah/types/container_spec.rb

100.0% lines covered

50.0% branches covered

84 relevant lines. 84 lines covered and 0 lines missed.
2 total branches, 1 branches covered and 1 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "sheetah/types/container"
  3. 1 RSpec.describe Sheetah::Types::Container do
  4. 1 let(:default_scalars) do
  5. 3 %i[scalar string email boolsy date_string]
  6. end
  7. 1 let(:default_composites) do
  8. 3 %i[array array_compact]
  9. end
  10. 1 context "when used by default" do
  11. 9 subject(:container) { described_class.new }
  12. 1 it "knows about some types" do
  13. 1 expect(container.scalars).to match_array(default_scalars)
  14. 1 expect(container.composites).to match_array(default_composites)
  15. end
  16. 1 describe "typemap" do
  17. 1 let(:scalar_type) do
  18. 1 Sheetah::Types::Scalars::Scalar
  19. end
  20. 1 let(:string_type) do
  21. 3 Sheetah::Types::Scalars::String
  22. end
  23. 1 let(:email_type) do
  24. 3 Sheetah::Types::Scalars::Email
  25. end
  26. 1 let(:boolsy_type) do
  27. 1 Sheetah::Types::Scalars::Boolsy
  28. end
  29. 1 let(:date_string_type) do
  30. 1 Sheetah::Types::Scalars::DateString
  31. end
  32. 1 def stub_new_type(klass, *args)
  33. 2 then: 0 else: 2 args << no_args if args.empty?
  34. 2 allow(klass).to receive(:new!).with(*args).and_return(instance = double)
  35. 2 instance
  36. end
  37. 1 it "is readable" do
  38. 1 expect(described_class::DEFAULTS).to match(
  39. scalars: include(*default_scalars) & be_frozen,
  40. composites: include(*default_composites) & be_frozen
  41. ) & be_frozen
  42. end
  43. 1 example "scalars: scalar" do
  44. 1 expect(scalar = container.scalar(:scalar)).to be_a(scalar_type) & be_frozen
  45. 1 expect(container.scalar(:scalar)).to be(scalar)
  46. end
  47. 1 example "scalars: string" do
  48. 1 expect(string = container.scalar(:string)).to be_a(string_type) & be_frozen
  49. 1 expect(container.scalar(:string)).to be(string)
  50. end
  51. 1 example "scalars: email" do
  52. 1 expect(email = container.scalar(:email)).to be_a(email_type) & be_frozen
  53. 1 expect(container.scalar(:email)).to be(email)
  54. end
  55. 1 example "scalars: boolsy" do
  56. 1 expect(boolsy = container.scalar(:boolsy)).to be_a(boolsy_type) & be_frozen
  57. 1 expect(container.scalar(:boolsy)).to be(boolsy)
  58. end
  59. 1 example "scalars: date_string" do
  60. 1 expect(date_string = container.scalar(:date_string)).to be_a(date_string_type) & be_frozen
  61. 1 expect(container.scalar(:date_string)).to be(date_string)
  62. end
  63. 1 example "composites: array" do
  64. 1 type = stub_new_type(Sheetah::Types::Composites::Array, [string_type, email_type])
  65. 1 expect(container.composite(:array, %i[string email])).to be(type)
  66. end
  67. 1 example "composites: array_composite" do
  68. 1 type = stub_new_type(Sheetah::Types::Composites::ArrayCompact, [string_type, email_type])
  69. 1 expect(container.composite(:array_compact, %i[string email])).to be(type)
  70. end
  71. end
  72. end
  73. 1 context "when extended" do
  74. 4 let(:foo_type) { double }
  75. 3 let(:bar_type) { double }
  76. 2 let(:baz_type) { double }
  77. 2 let(:oof_type) { double }
  78. 1 let(:container) do
  79. 2 described_class.new(
  80. scalars: {
  81. 3 foo: -> { foo_type },
  82. 1 string: -> { bar_type },
  83. },
  84. composites: {
  85. 1 baz: ->(_types) { baz_type },
  86. 1 array: ->(_types) { oof_type },
  87. }
  88. )
  89. end
  90. 1 it "can use custom scalars and composites" do
  91. 1 expect(container.scalars).to contain_exactly(*default_scalars, :foo)
  92. 1 expect(container.scalar(:foo)).to be(foo_type)
  93. 1 expect(container.composites).to contain_exactly(*default_composites, :baz)
  94. 1 expect(container.composite(:baz, %i[foo])).to be(baz_type)
  95. end
  96. 1 it "can override default type definitions" do
  97. 1 expect(container.scalar(:string)).to be(bar_type)
  98. 1 expect(container.composite(:array, %i[foo])).to be(oof_type)
  99. end
  100. 1 it "can override the default type map" do
  101. 1 container = described_class.new(
  102. defaults: {
  103. 2 scalars: { foo: -> { foo_type } },
  104. 1 composites: { bar: ->(_types) { bar_type } },
  105. }
  106. )
  107. 1 expect(container.scalars).to contain_exactly(:foo)
  108. 1 expect(container.scalar(:foo)).to be(foo_type)
  109. 1 expect(container.composites).to contain_exactly(:bar)
  110. 1 expect(container.composite(:bar, %i[foo])).to be(bar_type)
  111. end
  112. end
  113. 1 context "when a scalar definition doesn't exist" do
  114. 1 it "raises an error when used as a scalar" do
  115. 2 expect { subject.scalar(:foo) }.to raise_error(
  116. Sheetah::Errors::TypeError,
  117. "Invalid scalar type: :foo"
  118. )
  119. end
  120. 1 it "raises an error when used in a composite" do
  121. 2 expect { subject.composite(:array, [:foo]) }.to raise_error(
  122. Sheetah::Errors::TypeError,
  123. "Invalid scalar type: :foo"
  124. )
  125. end
  126. end
  127. 1 context "when a composite definition doesn't exist" do
  128. 1 it "raises an error" do
  129. 2 expect { subject.composite(:foo, []) }.to raise_error(
  130. Sheetah::Errors::TypeError,
  131. "Invalid composite type: :foo"
  132. )
  133. end
  134. end
  135. end

spec/sheetah/types/scalars/boolsy_cast_spec.rb

100.0% lines covered

100.0% branches covered

43 relevant lines. 43 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "sheetah/types/scalars/boolsy_cast"
  3. 1 require "sheetah/messaging"
  4. 1 require "support/shared/cast_class"
  5. 1 RSpec.describe Sheetah::Types::Scalars::BoolsyCast do
  6. 1 it_behaves_like "cast_class"
  7. 1 describe "#initialize" do
  8. 1 it "setups blank boolsy values by default" do
  9. 1 expect(described_class.new).to eq(
  10. described_class.new(truthy: [], falsy: [])
  11. )
  12. end
  13. end
  14. 1 describe "#call" do
  15. 4 subject(:cast) { described_class.new(truthy: truthy, falsy: falsy) }
  16. 4 let(:value) { instance_double(Object, inspect: double) }
  17. 4 let(:messenger) { instance_double(Sheetah::Messaging::Messenger) }
  18. 1 let(:truthy) do
  19. 3 instance_double(Array)
  20. end
  21. 1 let(:falsy) do
  22. 3 instance_double(Array)
  23. end
  24. 1 def stub_inclusion(set, value, bool)
  25. 6 allow(set).to receive(:include?).with(value).and_return(bool)
  26. end
  27. 1 def expect_truthy(value = self.value)
  28. 1 expect(cast.call(value, messenger)).to be(true)
  29. end
  30. 1 def expect_falsy(value = self.value)
  31. 1 expect(cast.call(value, messenger)).to be(false)
  32. end
  33. 1 def expect_failure(value = self.value)
  34. 1 expect(messenger).to receive(:error).with("must_be_boolsy", value: value.inspect)
  35. 2 expect { cast.call(value, messenger) }.to throw_symbol(:failure, nil)
  36. end
  37. 1 context "when the value is truthy" do
  38. 1 before do
  39. 1 stub_inclusion(truthy, value, true)
  40. 1 stub_inclusion(falsy, value, false)
  41. end
  42. 1 it "returns true" do
  43. 1 expect_truthy
  44. end
  45. end
  46. 1 context "when the value is falsy" do
  47. 1 before do
  48. 1 stub_inclusion(truthy, value, false)
  49. 1 stub_inclusion(falsy, value, true)
  50. end
  51. 1 it "returns false" do
  52. 1 expect_falsy
  53. end
  54. end
  55. 1 context "when the value isn't truthy nor falsy" do
  56. 1 before do
  57. 1 stub_inclusion(truthy, value, false)
  58. 1 stub_inclusion(falsy, value, false)
  59. end
  60. 1 it "fails with a message" do
  61. 1 expect_failure
  62. end
  63. end
  64. end
  65. end

spec/sheetah/types/scalars/boolsy_spec.rb

100.0% lines covered

100.0% branches covered

9 relevant lines. 9 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "sheetah/types/scalars/boolsy"
  3. 1 require "support/shared/scalar_type"
  4. 1 RSpec.describe Sheetah::Types::Scalars::Boolsy do
  5. 1 include_examples "scalar_type"
  6. 1 it "inherits from the basic scalar type" do
  7. 1 expect(described_class.superclass).to be(Sheetah::Types::Scalars::Scalar)
  8. end
  9. 1 describe "::cast_classes" do
  10. 1 it "extends the superclass' ones" do
  11. 1 expect(described_class.cast_classes).to eq(
  12. described_class.superclass.cast_classes + [Sheetah::Types::Scalars::BoolsyCast]
  13. )
  14. end
  15. end
  16. end

spec/sheetah/types/scalars/date_string_cast_spec.rb

100.0% lines covered

100.0% branches covered

54 relevant lines. 54 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "sheetah/types/scalars/date_string_cast"
  3. 1 require "sheetah/messaging"
  4. 1 require "support/shared/cast_class"
  5. 1 RSpec.describe Sheetah::Types::Scalars::DateStringCast do
  6. 1 let(:default_fmt) do
  7. 3 "%Y-%m-%d"
  8. end
  9. 1 it_behaves_like "cast_class"
  10. 1 describe "#initialize" do
  11. 1 it "setups a default, conventional date format and accepts native dates" do
  12. 1 expect(described_class.new).to eq(
  13. described_class.new(date_fmt: default_fmt, accept_date: true)
  14. )
  15. end
  16. end
  17. 1 describe "#call" do
  18. 4 let(:value) { double }
  19. 7 let(:messenger) { instance_double(Sheetah::Messaging::Messenger) }
  20. 1 context "when value is a Date" do
  21. 1 subject(:cast) do
  22. 2 described_class.new(accept_date: accept_date)
  23. end
  24. 1 before do
  25. 2 allow(Date).to receive(:===).with(value).and_return(true)
  26. end
  27. 1 context "when accepting Date" do
  28. 2 let(:accept_date) { true }
  29. 1 it "returns the value" do
  30. 1 expect(cast.call(value, messenger)).to eq(value)
  31. end
  32. end
  33. 1 context "when not accepting Date" do
  34. 2 let(:accept_date) { false }
  35. 1 it "fails with an error" do
  36. 1 expect(messenger).to receive(:error).with("must_be_date", format: default_fmt)
  37. 2 expect { cast.call(value, messenger) }.to throw_symbol(:failure, nil)
  38. end
  39. end
  40. end
  41. 1 context "when value is a string" do
  42. 1 subject(:cast) do
  43. 3 described_class.new(date_fmt: date_fmt)
  44. end
  45. 4 let(:date_fmt) { "%d/%m/%Y" }
  46. 1 context "when it fits the format" do
  47. 1 let(:value) do
  48. 1 "07/03/2020"
  49. end
  50. 1 it "returns a Date" do
  51. 1 expect(cast.call(value, messenger)).to eq(Date.new(2020, 3, 7))
  52. end
  53. end
  54. 1 context "when it doesn't make sense" do
  55. 1 let(:value) do
  56. 1 "47/03/2020"
  57. end
  58. 1 it "fails with an error" do
  59. 1 expect(messenger).to receive(:error).with("must_be_date", format: date_fmt)
  60. 2 expect { cast.call(value, messenger) }.to throw_symbol(:failure, nil)
  61. end
  62. end
  63. 1 context "when it doesn't fit the format" do
  64. 1 let(:value) do
  65. 1 "2020-01-12"
  66. end
  67. 1 it "fails with an error" do
  68. 1 expect(messenger).to receive(:error).with("must_be_date", format: date_fmt)
  69. 2 expect { cast.call(value, messenger) }.to throw_symbol(:failure, nil)
  70. end
  71. end
  72. end
  73. 1 context "when value is anything else" do
  74. 1 subject(:cast) do
  75. 1 described_class.new
  76. end
  77. 1 it "fails with an error" do
  78. 1 expect(messenger).to receive(:error).with("must_be_date", format: default_fmt)
  79. 2 expect { cast.call(value, messenger) }.to throw_symbol(:failure, nil)
  80. end
  81. end
  82. end
  83. end

spec/sheetah/types/scalars/date_string_spec.rb

100.0% lines covered

100.0% branches covered

11 relevant lines. 11 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "sheetah/types/scalars/date_string"
  3. 1 require "support/shared/scalar_type"
  4. 1 RSpec.describe Sheetah::Types::Scalars::DateString do
  5. 1 subject do
  6. 4 described_class.new(date_fmt: "foobar")
  7. end
  8. 1 include_examples "scalar_type"
  9. 1 it "inherits from the basic scalar type" do
  10. 1 expect(described_class.superclass).to be(Sheetah::Types::Scalars::Scalar)
  11. end
  12. 1 describe "::cast_classes" do
  13. 1 it "extends the superclass' ones" do
  14. 1 expect(described_class.cast_classes).to eq(
  15. described_class.superclass.cast_classes + [Sheetah::Types::Scalars::DateStringCast]
  16. )
  17. end
  18. end
  19. end

spec/sheetah/types/scalars/email_cast_spec.rb

100.0% lines covered

100.0% branches covered

25 relevant lines. 25 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "sheetah/types/scalars/email_cast"
  3. 1 require "sheetah/messaging"
  4. 1 require "support/shared/cast_class"
  5. 1 RSpec.describe Sheetah::Types::Scalars::EmailCast do
  6. 1 it_behaves_like "cast_class"
  7. 1 describe "#initialize" do
  8. 1 it "setups a default, conventional e-mail matcher" do
  9. 1 expect(described_class.new).to eq(
  10. described_class.new(email_matcher: URI::MailTo::EMAIL_REGEXP)
  11. )
  12. end
  13. end
  14. 1 describe "#call" do
  15. 3 subject(:cast) { described_class.new(email_matcher: email_matcher) }
  16. 1 let(:email_matcher) do
  17. 2 instance_double(Regexp)
  18. end
  19. 3 let(:value) { instance_double(Object, inspect: double) }
  20. 3 let(:messenger) { instance_double(Sheetah::Messaging::Messenger) }
  21. 1 before do
  22. 2 allow(email_matcher).to receive(:match?).with(value).and_return(value_is_email)
  23. end
  24. 1 context "when the value is an email address" do
  25. 2 let(:value_is_email) { true }
  26. 1 it "returns the value" do
  27. 1 expect(cast.call(value, messenger)).to eq(value)
  28. end
  29. end
  30. 1 context "when the value isn't an email address" do
  31. 2 let(:value_is_email) { false }
  32. 1 it "adds an error message and throws :failure" do
  33. 1 expect(messenger).to receive(:error).with("must_be_email", value: value.inspect)
  34. 2 expect { cast.call(value, messenger) }.to throw_symbol(:failure, nil)
  35. end
  36. end
  37. end
  38. end

spec/sheetah/types/scalars/email_spec.rb

100.0% lines covered

100.0% branches covered

9 relevant lines. 9 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "sheetah/types/scalars/email"
  3. 1 require "support/shared/scalar_type"
  4. 1 RSpec.describe Sheetah::Types::Scalars::Email do
  5. 1 include_examples "scalar_type"
  6. 1 it "inherits from the scalar string type" do
  7. 1 expect(described_class.superclass).to be(Sheetah::Types::Scalars::String)
  8. end
  9. 1 describe "::cast_classes" do
  10. 1 it "extends the superclass' ones" do
  11. 1 expect(described_class.cast_classes).to eq(
  12. described_class.superclass.cast_classes + [Sheetah::Types::Scalars::EmailCast]
  13. )
  14. end
  15. end
  16. end

spec/sheetah/types/scalars/scalar_cast_spec.rb

100.0% lines covered

100.0% branches covered

56 relevant lines. 56 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "sheetah/types/scalars/scalar_cast"
  3. 1 require "sheetah/messaging"
  4. 1 require "support/shared/cast_class"
  5. 1 RSpec.describe Sheetah::Types::Scalars::ScalarCast do
  6. 1 it_behaves_like "cast_class"
  7. 1 describe "#call" do
  8. 2 subject(:cast) { described_class.new }
  9. 1 let(:messenger) do
  10. 7 instance_double(Sheetah::Messaging::Messenger)
  11. end
  12. 1 context "when given nil" do
  13. 1 context "when nullable" do
  14. 2 subject(:cast) { described_class.new(nullable: true) }
  15. 1 it "halts with a success and nil as value" do
  16. 1 expect do
  17. 1 cast.call(nil, messenger)
  18. end.to throw_symbol(:success, nil)
  19. end
  20. end
  21. 1 context "when non-nullable" do
  22. 2 subject(:cast) { described_class.new(nullable: false) }
  23. 1 it "halts with a failure and an appropriate error code" do
  24. 1 expect do
  25. 1 cast.call(nil, messenger)
  26. end.to throw_symbol(:failure, "must_exist")
  27. end
  28. end
  29. end
  30. 1 context "when given a String" do
  31. 3 let(:string_with_garbage) { " string_foo " }
  32. 4 let(:string_without_garbage) { "string_foo" }
  33. 1 before do
  34. 4 allow(messenger).to receive(:warn)
  35. end
  36. 1 context "when cleaning strings" do
  37. 1 subject(:cast) do
  38. 2 described_class.new(clean_string: true)
  39. end
  40. 1 context "when string contains garbage" do
  41. 1 it "removes garbage around the value and warns about it" do
  42. 1 value = cast.call(string_with_garbage, messenger)
  43. 1 expect(value).to eq(string_without_garbage)
  44. 1 expect(messenger).to have_received(:warn).with("cleaned_string")
  45. end
  46. end
  47. 1 context "when string doesn't contain garbage" do
  48. 1 it "returns the string as is" do
  49. 1 value = cast.call(string_without_garbage, messenger)
  50. 1 expect(value).to eq(string_without_garbage)
  51. 1 expect(messenger).not_to have_received(:warn)
  52. end
  53. end
  54. end
  55. 1 context "when not cleaning strings" do
  56. 1 subject(:cast) do
  57. 2 described_class.new(clean_string: false)
  58. end
  59. 1 context "when string contains garbage" do
  60. 1 it "returns the string as is" do
  61. 1 value = cast.call(string_with_garbage, messenger)
  62. 1 expect(value).to eq(string_with_garbage)
  63. 1 expect(messenger).not_to have_received(:warn)
  64. end
  65. end
  66. 1 context "when string doesn't contain garbage" do
  67. 1 it "returns the string as is" do
  68. 1 value = cast.call(string_without_garbage, messenger)
  69. 1 expect(value).to eq(string_without_garbage)
  70. 1 expect(messenger).not_to have_received(:warn)
  71. end
  72. end
  73. end
  74. end
  75. 1 context "when given something else" do
  76. 1 it "returns the string as is" do
  77. 1 something_else = double
  78. 1 value = cast.call(something_else, messenger)
  79. 1 expect(value).to eq(something_else)
  80. end
  81. end
  82. end
  83. end

spec/sheetah/types/scalars/scalar_spec.rb

100.0% lines covered

100.0% branches covered

9 relevant lines. 9 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "sheetah/types/scalars/scalar"
  3. 1 require "support/shared/scalar_type"
  4. 1 RSpec.describe Sheetah::Types::Scalars::Scalar do
  5. 1 include_examples "scalar_type"
  6. 1 it "inherits from the basic type" do
  7. 1 expect(described_class.superclass).to be(Sheetah::Types::Type)
  8. end
  9. 1 describe "::cast_classes" do
  10. 1 it "includes a basic cast class" do
  11. 1 expect(described_class.cast_classes).to eq(
  12. [Sheetah::Types::Scalars::ScalarCast]
  13. )
  14. end
  15. end
  16. end

spec/sheetah/types/scalars/string_spec.rb

100.0% lines covered

100.0% branches covered

27 relevant lines. 27 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "sheetah/types/scalars/string"
  3. 1 require "support/shared/scalar_type"
  4. 1 require "support/shared/cast_class"
  5. 1 RSpec.describe Sheetah::Types::Scalars::String do
  6. 1 include_examples "scalar_type"
  7. 1 it "inherits from the basic scalar type" do
  8. 1 expect(described_class.superclass).to be(Sheetah::Types::Scalars::Scalar)
  9. end
  10. 1 describe "custom cast class" do
  11. 1 subject(:cast_class) do
  12. 5 described_class.cast_classes.last
  13. end
  14. 1 it "is appended to the superclass' cast classes" do
  15. 1 expect(
  16. described_class.superclass.cast_classes + [cast_class]
  17. ).to eq(described_class.cast_classes)
  18. end
  19. 1 include_examples "cast_class"
  20. 1 describe "#call" do
  21. 1 before do
  22. 2 allow(value).to receive(:is_a?).with(String).and_return(value_is_string)
  23. end
  24. 1 context "when the value is a string" do
  25. 2 let(:value_is_string) { true }
  26. 2 let(:native_string) { double }
  27. 1 before do
  28. 1 allow(value).to receive(:to_s).and_return(native_string)
  29. end
  30. 1 it "is a success, returning a native string" do
  31. 1 expect(cast.call(value, messenger)).to eq(native_string)
  32. end
  33. end
  34. 1 context "when the value is not a string" do
  35. 2 let(:value_is_string) { false }
  36. 1 it "is a failure" do
  37. 2 expect { cast.call(value, messenger) }.to throw_symbol(:failure, "must_be_string")
  38. end
  39. end
  40. end
  41. end
  42. end

spec/sheetah/types/type_spec.rb

100.0% lines covered

100.0% branches covered

129 relevant lines. 129 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "sheetah/types/type"
  3. 1 RSpec.describe Sheetah::Types::Type do
  4. 1 describe "class API" do
  5. 15 let(:klass0) { Class.new(described_class) }
  6. 9 let(:klass1) { Class.new(klass0) }
  7. 6 let(:klass2) { Class.new(klass1) }
  8. 1 describe "::all" do
  9. 1 it "returns an enumerator for self and known descendant types" do
  10. 1 enum = klass0.all
  11. 1 expect(enum).to be_a(Enumerator) & contain_exactly(klass0)
  12. 1 klass1
  13. 1 klass2
  14. 1 expect(klass0.all.to_a).to contain_exactly(klass0, klass1, klass2)
  15. 1 expect(klass1.all.to_a).to contain_exactly(klass1, klass2)
  16. end
  17. end
  18. 1 describe "::cast_classes" do
  19. 1 it "is an empty array" do
  20. 1 expect(described_class.cast_classes).to eq([])
  21. end
  22. 1 it "is inheritable" do
  23. 1 expect([klass0, klass1, klass2]).to all(
  24. have_attributes(cast_classes: described_class.cast_classes)
  25. )
  26. end
  27. end
  28. 1 describe "::cast_classes=" do
  29. 5 let(:cast_classes0) { [double, double] }
  30. 3 let(:cast_classes1) { [double, double] }
  31. 1 it "mutates the class instance" do
  32. 1 klass0.cast_classes = cast_classes0
  33. 1 expect(klass0).to have_attributes(cast_classes: cast_classes0)
  34. end
  35. 1 it "applies to the inherited children" do
  36. 1 klass0.cast_classes = cast_classes0
  37. 1 expect([klass0, klass1, klass2].map(&:cast_classes)).to eq(
  38. [cast_classes0, cast_classes0, cast_classes0]
  39. )
  40. end
  41. 1 it "doesn't apply to the children mutated afterwards" do
  42. 1 klass0.cast_classes = cast_classes0
  43. 1 klass1.cast_classes = cast_classes1
  44. 1 expect([klass0, klass1, klass2].map(&:cast_classes)).to eq(
  45. [cast_classes0, cast_classes1, cast_classes1]
  46. )
  47. end
  48. 1 it "doesn't apply to the children mutated beforehand" do
  49. 1 klass1.cast_classes = cast_classes1
  50. 1 klass0.cast_classes = cast_classes0
  51. 1 expect([klass0, klass1, klass2].map(&:cast_classes)).to eq(
  52. [cast_classes0, cast_classes1, cast_classes1]
  53. )
  54. end
  55. end
  56. 1 describe "::freeze" do
  57. 1 it "freezes the instance and its cast classes" do
  58. 1 klass1.freeze
  59. 1 expect([klass1, klass1.cast_classes]).to all(be_frozen)
  60. end
  61. 1 it "doesn't freeze a warm superclass nor its cast classes" do
  62. 1 klass1.freeze
  63. 1 expect([klass0, klass0.cast_classes]).not_to include(be_frozen)
  64. end
  65. 1 it "doesn't freeze a warm subclass nor its *own* cast classes" do
  66. 1 klass0.freeze
  67. 1 expect(klass1).not_to be_frozen
  68. 1 expect(klass1.cast_classes).to be_frozen
  69. 1 klass1.cast_classes += [double]
  70. 1 expect(klass1.cast_classes).not_to be_frozen
  71. end
  72. end
  73. 1 describe "::cast" do
  74. 6 let(:cast_class0) { double }
  75. 2 let(:cast_class1) { double }
  76. 1 before do
  77. 5 klass0.cast_classes = [cast_class0]
  78. 5 klass0.freeze
  79. end
  80. 1 context "when given a class" do
  81. 1 it "creates a new type appending the given cast" do
  82. 1 type_class = klass0.cast(cast_class1)
  83. 1 expect(type_class).to have_attributes(
  84. class: Class,
  85. superclass: klass0,
  86. cast_classes: [cast_class0, cast_class1]
  87. )
  88. end
  89. end
  90. 1 context "when given a block" do
  91. 3 let(:cast) { -> {} }
  92. 3 let(:type_class) { klass0.cast(&cast) }
  93. 1 it "creates a new type appending the given cast" do
  94. 1 expect(type_class).to have_attributes(
  95. class: Class,
  96. superclass: klass0,
  97. cast_classes: [cast_class0, described_class::SimpleCast.new(cast)]
  98. )
  99. end
  100. 1 it "still behaves as a cast class" do
  101. 1 block_cast_class = type_class.cast_classes.last
  102. 1 expect(block_cast_class.new(foo: :bar)).to be(cast)
  103. end
  104. end
  105. 1 context "when given both a class and a block" do
  106. 1 it "raises an error" do
  107. 2 expect { described_class.cast(cast_class0) { double } }.to raise_error(
  108. ArgumentError, "Expected either a Class or a block, got both"
  109. )
  110. end
  111. end
  112. 1 context "when given neither a class nor a block" do
  113. 1 it "raises an error" do
  114. 2 expect { described_class.cast }.to raise_error(
  115. ArgumentError, "Expected either a Class or a block, got none"
  116. )
  117. end
  118. end
  119. end
  120. 1 describe "::new!" do
  121. 1 it "initializes a new, frozen type" do
  122. 1 type = described_class.new!
  123. 1 expect(type).to be_a(described_class) & be_frozen
  124. end
  125. end
  126. end
  127. 1 describe "instance API" do
  128. 1 describe "#initialize" do
  129. 1 let(:cast_class0) do
  130. 1 instance_double(Class)
  131. end
  132. 1 let(:cast_class1) do
  133. 1 instance_double(Class)
  134. end
  135. 1 let(:cast_block) do
  136. 1 -> {}
  137. end
  138. 1 let(:type_class) do
  139. 1 klass = described_class.cast(&cast_block)
  140. 1 klass.cast_classes << cast_class0
  141. 1 klass.cast_classes << cast_class1
  142. 1 klass
  143. end
  144. 1 def stub_new_casts(opts)
  145. 1 type_class.cast_classes.map do |cast_class|
  146. 3 allow(cast_class).to receive(:new).with(**opts).and_return(cast = double)
  147. 3 cast
  148. end
  149. end
  150. 1 it "builds a cast chain of all casts initialized with the opts" do
  151. 1 opts = { foo: :bar, hello: "world" }
  152. 1 casts = stub_new_casts(opts)
  153. 1 type = type_class.new(**opts)
  154. 1 expect(type.cast_chain).to(
  155. be_a(Sheetah::Types::CastChain) &
  156. have_attributes(casts: casts)
  157. )
  158. end
  159. end
  160. 1 describe "#cast" do
  161. 1 it "delegates the task to the cast chain" do
  162. 1 type = described_class.new
  163. 1 value = double
  164. 1 messenger = double
  165. 1 result = double
  166. 1 expect(type.cast_chain).to receive(:call).with(value, messenger).and_return(result)
  167. 1 expect(type.cast(value, messenger)).to be(result)
  168. end
  169. end
  170. 1 describe "#freeze" do
  171. 1 it "freezes self and the cast chain" do
  172. 1 type = described_class.new
  173. 1 type.freeze
  174. 1 expect(type).to be_frozen
  175. 1 expect(type.cast_chain).to be_frozen
  176. end
  177. end
  178. 1 describe "abstract API" do
  179. 5 let(:type) { described_class.new }
  180. 1 def raise_abstract_method_error
  181. 4 raise_error(NoMethodError, /you must implement this method/i)
  182. end
  183. 1 describe "#scalar?" do
  184. 1 it "is abstract" do
  185. 2 expect { type.scalar? }.to raise_abstract_method_error
  186. end
  187. end
  188. 1 describe "#composite?" do
  189. 1 it "is abstract" do
  190. 2 expect { type.composite? }.to raise_abstract_method_error
  191. end
  192. end
  193. 1 describe "#scalar" do
  194. 1 it "is abstract" do
  195. 2 expect { type.scalar(double, double, double) }.to raise_abstract_method_error
  196. end
  197. end
  198. 1 describe "#composite" do
  199. 1 it "is abstract" do
  200. 2 expect { type.composite(double, double) }.to raise_abstract_method_error
  201. end
  202. end
  203. end
  204. end
  205. end

spec/sheetah/utils/cell_string_cleaner_spec.rb

100.0% lines covered

100.0% branches covered

12 relevant lines. 12 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "sheetah/utils/cell_string_cleaner"
  3. 1 RSpec.describe Sheetah::Utils::CellStringCleaner do
  4. 2 subject(:cleaner) { described_class }
  5. 2 let(:spaces) { " \t\r\n" }
  6. 2 let(:nonprints) { "\x00\x1B" }
  7. 2 let(:garbage) { spaces + nonprints }
  8. # NOTE: the line return and newline characters act as traps for single-line regexes
  9. 2 let(:string) { "foo#{spaces}\r\n#{nonprints}bar" }
  10. 1 it "removes spaces & non-printable characters around a string" do
  11. 1 expect(cleaner.call(garbage)).to eq("")
  12. 1 expect(cleaner.call(garbage + string)).to eq(string)
  13. 1 expect(cleaner.call(string + garbage)).to eq(string)
  14. 1 expect(cleaner.call(garbage + string + garbage)).to eq(string)
  15. end
  16. end

spec/sheetah/utils/monadic_result/failure_spec.rb

100.0% lines covered

100.0% branches covered

94 relevant lines. 94 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "sheetah/utils/monadic_result"
  3. 1 RSpec.describe Sheetah::Utils::MonadicResult::Failure, monadic_result: true do
  4. 12 subject(:result) { described_class.new(value0) }
  5. 1 let(:value0) do
  6. 11 instance_double(Object, to_s: "value0_to_s", inspect: "value0_inspect")
  7. end
  8. 1 let(:value1) do
  9. 1 instance_double(Object, to_s: "value1_to_s", inspect: "value1_inspect")
  10. end
  11. 1 it "is a result" do
  12. 1 expect(result).to be_a(Sheetah::Utils::MonadicResult::Result)
  13. end
  14. 1 describe "#initialize" do
  15. 1 it "is empty by default" do
  16. 1 expect(described_class.new).to be_empty
  17. end
  18. end
  19. 1 describe "#empty?" do
  20. 1 it "is true by default of an explicitly wrapped value" do
  21. 1 expect(described_class.new).to be_empty
  22. end
  23. 1 it "is false otherwise" do
  24. 1 expect(result).not_to be_empty
  25. end
  26. end
  27. 1 describe "#failure?" do
  28. 1 it "is true" do
  29. 1 expect(result).to be_failure
  30. end
  31. end
  32. 1 describe "#success?" do
  33. 1 it "is false" do
  34. 1 expect(result).not_to be_success
  35. end
  36. end
  37. 1 describe "#failure" do
  38. 1 it "can unwrap the value" do
  39. 1 expect(result).to have_attributes(failure: value0)
  40. end
  41. 1 it "cannot unwrap a value when empty" do
  42. 1 empty_result = described_class.new
  43. 2 expect { empty_result.failure }.to raise_error(
  44. described_class::ValueError, "There is no value within the result"
  45. )
  46. end
  47. end
  48. 1 describe "#success" do
  49. 1 it "can't unwrap the value" do
  50. 2 expect { result.success }.to raise_error(
  51. described_class::VariantError, "Not a Success"
  52. )
  53. end
  54. end
  55. 1 describe "#==" do
  56. 1 it "is equivalent to a similar Failure" do
  57. 1 expect(result).to eq(Failure(value0))
  58. end
  59. 1 it "is not equivalent to a different Failure" do
  60. 1 expect(result).not_to eq(Failure(value1))
  61. end
  62. 1 it "is not equivalent to a similar Success" do
  63. 1 expect(result).not_to eq(Success(value0))
  64. end
  65. end
  66. 1 describe "#inspect" do
  67. 1 it "inspects the result" do
  68. 1 expect(result.inspect).to eq("Failure(value0_inspect)")
  69. end
  70. 1 context "when empty" do
  71. 2 subject(:result) { described_class.new }
  72. 1 it "inspects nothing" do
  73. 1 expect(result.inspect).to eq("Failure()")
  74. end
  75. end
  76. end
  77. 1 describe "#to_s" do
  78. 1 it "inspects the result" do
  79. 1 expect(result.method(:to_s)).to eq(result.method(:inspect))
  80. end
  81. end
  82. 1 describe "#unwrap" do
  83. 3 let(:do_token) { :MonadicResultDo }
  84. 1 context "when empty" do
  85. 1 it "returns nil" do
  86. 1 result = described_class.new
  87. 2 expect { result.unwrap }.to throw_symbol(do_token, result)
  88. end
  89. end
  90. 1 context "when non-empty" do
  91. 1 it "returns the wrapped value" do
  92. 1 result = described_class.new(double)
  93. 2 expect { result.unwrap }.to throw_symbol(do_token, result)
  94. end
  95. end
  96. end
  97. 1 describe "#discard" do
  98. 1 it "returns the same variant, without a value" do
  99. 1 empty_result = described_class.new
  100. 1 filled_result = described_class.new(double)
  101. 1 expect(empty_result.discard).to eq(empty_result)
  102. 1 expect(filled_result.discard).to eq(empty_result)
  103. end
  104. end
  105. 1 describe "#bind" do
  106. 1 context "when empty" do
  107. 3 let(:result) { described_class.new }
  108. 1 it "doesn't yield" do
  109. 2 expect { |b| result.bind(&b) }.not_to yield_control
  110. end
  111. 1 it "returns self" do
  112. 1 expect(result.bind { double }).to be(result)
  113. end
  114. end
  115. 1 context "when filled" do
  116. 3 let(:result) { described_class.new(double) }
  117. 1 it "doesn't yield" do
  118. 2 expect { |b| result.bind(&b) }.not_to yield_control
  119. end
  120. 1 it "returns self" do
  121. 1 expect(result.bind { double }).to be(result)
  122. end
  123. end
  124. end
  125. 1 describe "#or" do
  126. 1 context "when empty" do
  127. 3 let(:result) { described_class.new }
  128. 1 it "yields nil" do
  129. 2 expect { |b| result.or(&b) }.to yield_with_no_args
  130. end
  131. 1 it "returns the block result" do
  132. 1 block_value = double
  133. 2 expect(result.or { block_value }).to eq(block_value)
  134. end
  135. end
  136. 1 context "when filled" do
  137. 3 let(:value) { double }
  138. 3 let(:result) { described_class.new(value) }
  139. 1 it "yields the value" do
  140. 2 expect { |b| result.or(&b) }.to yield_with_args(value)
  141. end
  142. 1 it "returns the block result" do
  143. 1 block_value = double
  144. 2 expect(result.or { block_value }).to eq(block_value)
  145. end
  146. end
  147. end
  148. end

spec/sheetah/utils/monadic_result/success_spec.rb

100.0% lines covered

100.0% branches covered

93 relevant lines. 93 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "sheetah/utils/monadic_result"
  3. 1 RSpec.describe Sheetah::Utils::MonadicResult::Success, monadic_result: true do
  4. 12 subject(:result) { described_class.new(value0) }
  5. 1 let(:value0) do
  6. 11 instance_double(Object, to_s: "value0_to_s", inspect: "value0_inspect")
  7. end
  8. 1 let(:value1) do
  9. 1 instance_double(Object, to_s: "value1_to_s", inspect: "value1_inspect")
  10. end
  11. 1 it "is a result" do
  12. 1 expect(result).to be_a(Sheetah::Utils::MonadicResult::Result)
  13. end
  14. 1 describe "#initialize" do
  15. 1 it "is empty by default" do
  16. 1 expect(described_class.new).to be_empty
  17. end
  18. end
  19. 1 describe "#empty?" do
  20. 1 it "is true by default of an explicitly wrapped value" do
  21. 1 expect(described_class.new).to be_empty
  22. end
  23. 1 it "is false otherwise" do
  24. 1 expect(result).not_to be_empty
  25. end
  26. end
  27. 1 describe "#success?" do
  28. 1 it "is true" do
  29. 1 expect(result).to be_success
  30. end
  31. end
  32. 1 describe "#failure?" do
  33. 1 it "is false" do
  34. 1 expect(result).not_to be_failure
  35. end
  36. end
  37. 1 describe "#success" do
  38. 1 it "can unwrap the value" do
  39. 1 expect(result).to have_attributes(success: value0)
  40. end
  41. 1 it "cannot unwrap a value when empty" do
  42. 1 empty_result = described_class.new
  43. 2 expect { empty_result.success }.to raise_error(
  44. described_class::ValueError, "There is no value within the result"
  45. )
  46. end
  47. end
  48. 1 describe "#failure" do
  49. 1 it "can't unwrap the value" do
  50. 2 expect { result.failure }.to raise_error(
  51. described_class::VariantError, "Not a Failure"
  52. )
  53. end
  54. end
  55. 1 describe "#==" do
  56. 1 it "is equivalent to a similar Success" do
  57. 1 expect(result).to eq(Success(value0))
  58. end
  59. 1 it "is not equivalent to a different Success" do
  60. 1 expect(result).not_to eq(Success(value1))
  61. end
  62. 1 it "is not equivalent to a similar Failure" do
  63. 1 expect(result).not_to eq(Failure(value0))
  64. end
  65. end
  66. 1 describe "#inspect" do
  67. 1 it "inspects the result" do
  68. 1 expect(result.inspect).to eq("Success(value0_inspect)")
  69. end
  70. 1 context "when empty" do
  71. 2 subject(:result) { described_class.new }
  72. 1 it "inspects nothing" do
  73. 1 expect(result.inspect).to eq("Success()")
  74. end
  75. end
  76. end
  77. 1 describe "#to_s" do
  78. 1 it "inspects the result" do
  79. 1 expect(result.method(:to_s)).to eq(result.method(:inspect))
  80. end
  81. end
  82. 1 describe "#unwrap" do
  83. 1 context "when empty" do
  84. 1 it "returns nil" do
  85. 1 result = described_class.new
  86. 1 expect(result.unwrap).to be_nil
  87. end
  88. end
  89. 1 context "when non-empty" do
  90. 1 it "returns the wrapped value" do
  91. 1 result = described_class.new(wrapped = double)
  92. 1 expect(result.unwrap).to be(wrapped)
  93. end
  94. end
  95. end
  96. 1 describe "#discard" do
  97. 1 it "returns the same variant, without a value" do
  98. 1 empty_result = described_class.new
  99. 1 filled_result = described_class.new(double)
  100. 1 expect(empty_result.discard).to eq(empty_result)
  101. 1 expect(filled_result.discard).to eq(empty_result)
  102. end
  103. end
  104. 1 describe "#bind" do
  105. 1 context "when empty" do
  106. 3 let(:result) { described_class.new }
  107. 1 it "yields nil" do
  108. 2 expect { |b| result.bind(&b) }.to yield_with_no_args
  109. end
  110. 1 it "returns the block result" do
  111. 1 block_value = double
  112. 2 expect(result.bind { block_value }).to eq(block_value)
  113. end
  114. end
  115. 1 context "when filled" do
  116. 3 let(:value) { double }
  117. 3 let(:result) { described_class.new(value) }
  118. 1 it "yields the value" do
  119. 2 expect { |b| result.bind(&b) }.to yield_with_args(value)
  120. end
  121. 1 it "returns the block result" do
  122. 1 block_value = double
  123. 2 expect(result.bind { block_value }).to eq(block_value)
  124. end
  125. end
  126. end
  127. 1 describe "#or" do
  128. 1 context "when empty" do
  129. 3 let(:result) { described_class.new }
  130. 1 it "doesn't yield" do
  131. 2 expect { |b| result.or(&b) }.not_to yield_control
  132. end
  133. 1 it "returns self" do
  134. 1 expect(result.or { double }).to be(result)
  135. end
  136. end
  137. 1 context "when filled" do
  138. 3 let(:result) { described_class.new(double) }
  139. 1 it "doesn't yield" do
  140. 2 expect { |b| result.or(&b) }.not_to yield_control
  141. end
  142. 1 it "returns self" do
  143. 1 expect(result.or { double }).to be(result)
  144. end
  145. end
  146. end
  147. end

spec/sheetah/utils/monadic_result/unit_spec.rb

100.0% lines covered

100.0% branches covered

8 relevant lines. 8 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "sheetah/utils/monadic_result"
  3. 1 RSpec.describe Sheetah::Utils::MonadicResult::Unit do
  4. 4 subject(:unit) { described_class }
  5. 2 it { is_expected.to be_frozen }
  6. 1 it "can be stringified" do
  7. 1 expect(unit.to_s).to eq("Unit")
  8. end
  9. 1 it "can be inspected" do
  10. 1 expect(unit.inspect).to eq("Unit")
  11. end
  12. end

spec/sheetah/utils/monadic_result_spec.rb

100.0% lines covered

100.0% branches covered

57 relevant lines. 57 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "sheetah/utils/monadic_result"
  3. 1 RSpec.describe Sheetah::Utils::MonadicResult do
  4. 1 let(:klass) do
  5. 20 Class.new.tap { |c| c.include(described_class) }
  6. end
  7. 10 let(:builder) { klass.new }
  8. 3 let(:value) { double }
  9. 1 it "includes some constants" do
  10. 1 expect(klass.constants - Object.constants).to contain_exactly(
  11. :Unit, :Result, :Success, :Failure
  12. )
  13. 1 expect(klass::Unit).to be(described_class::Unit)
  14. 1 expect(klass::Result).to be(described_class::Result)
  15. 1 expect(klass::Success).to be(described_class::Success)
  16. 1 expect(klass::Failure).to be(described_class::Failure)
  17. end
  18. 1 it "includes three builder methods" do
  19. 1 expect(builder.methods - Object.methods).to contain_exactly(
  20. :Success, :Failure, :Do
  21. )
  22. end
  23. 1 describe "#Success" do
  24. 1 it "may wrap no value in a Success instance" do
  25. 1 expect(builder.Success()).to eq(described_class::Success.new)
  26. end
  27. 1 it "may wrap a value in a Success instance" do
  28. 1 expect(builder.Success(value)).to eq(described_class::Success.new(value))
  29. end
  30. end
  31. 1 describe "#Failure" do
  32. 1 it "may wrap no value in a Failure instance" do
  33. 1 expect(builder.Failure()).to eq(described_class::Failure.new)
  34. end
  35. 1 it "may wrap a value in a Failure instance" do
  36. 1 expect(builder.Failure(value)).to eq(described_class::Failure.new(value))
  37. end
  38. end
  39. 1 describe "#Do" do
  40. 4 let(:v1) { double }
  41. 4 let(:v2) { double }
  42. 3 let(:v3) { double }
  43. 1 let(:v4) { double }
  44. 1 let(:v5) { double }
  45. 1 it "returns the last expression of the block" do
  46. 1 result = builder.Do do
  47. 1 v1
  48. 1 v2
  49. 1 v3
  50. end
  51. 1 expect(result).to be(v3)
  52. end
  53. 1 it "continues the sequence when unwrapping a Success" do
  54. 1 v = nil
  55. 1 result = builder.Do do
  56. 1 v = builder.Success(v1).unwrap
  57. 1 v = builder.Success(v2).unwrap
  58. 1 v = builder.Success(v3).unwrap
  59. end
  60. 1 expect(result).to be(v3)
  61. 1 expect(v).to be(v3)
  62. end
  63. 1 it "aborts the sequence when unwrapping a Failure" do
  64. 1 v = nil
  65. 1 result = builder.Do do
  66. 1 v = builder.Success(v1).unwrap
  67. 1 v = builder.Failure(v2).unwrap
  68. skipped # :nocov:
  69. skipped v = builder.Success(v3).unwrap
  70. skipped # :nocov:
  71. end
  72. 1 expect(result).to eq(builder.Failure(v2))
  73. 1 expect(v).to be(v1)
  74. end
  75. 1 it "is compatible with ensure" do
  76. 1 ensured = false
  77. 1 builder.Do do
  78. 1 builder.Failure().unwrap
  79. ensure
  80. 1 ensured = true
  81. end
  82. 1 expect(ensured).to be(true)
  83. end
  84. end
  85. end

spec/sheetah_spec.rb

100.0% lines covered

100.0% branches covered

66 relevant lines. 66 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "sheetah"
  3. 1 RSpec.describe Sheetah, monadic_result: true do
  4. 1 let(:types) do
  5. 24 reverse_string = Sheetah::Types::Scalars::String.cast { |v, _m| v.reverse }
  6. 9 Sheetah::Types::Container.new(
  7. scalars: {
  8. reverse_string: reverse_string.method(:new),
  9. }
  10. )
  11. end
  12. 1 let(:template_opts) do
  13. {
  14. 9 attributes: [
  15. {
  16. key: :foo,
  17. type: :reverse_string!,
  18. },
  19. {
  20. key: :bar,
  21. type: {
  22. composite: :array,
  23. scalars: %i[
  24. string
  25. scalar
  26. email
  27. scalar
  28. scalar!
  29. ],
  30. },
  31. },
  32. ],
  33. }
  34. end
  35. 1 let(:template) do
  36. 9 Sheetah::Template.new(**template_opts)
  37. end
  38. 1 let(:template_config) do
  39. 9 Sheetah::TemplateConfig.new(types: types)
  40. end
  41. 1 let(:specification) do
  42. 9 template.apply(template_config)
  43. end
  44. 1 let(:processor) do
  45. 9 Sheetah::SheetProcessor.new(specification)
  46. end
  47. 1 let(:input) do
  48. [
  49. 9 ["foo", "bar 3", "bar 5", "bar 1"],
  50. ["hello", "foo@bar.baz", Float, nil],
  51. ["world", "foo@bar.baz", Float, nil],
  52. ["world", "boudiou !", Float, nil],
  53. ]
  54. end
  55. 1 def process(*args, **opts, &block)
  56. 5 processor.call(*args, backend: Sheetah::Backends::Wrapper, **opts, &block)
  57. end
  58. 1 def process_to_a(*args, **opts)
  59. 4 a = []
  60. 16 processor.call(*args, backend: Sheetah::Backends::Wrapper, **opts) { |result| a << result }
  61. 4 a
  62. end
  63. 1 context "when there is no sheet error" do
  64. 1 it "is a success without errors" do
  65. 1 result = process(input) {}
  66. 1 expect(result).to have_attributes(result: Success(), messages: [])
  67. end
  68. 1 it "yields a commented result for each valid and invalid row" do
  69. 1 results = process_to_a(input)
  70. 1 expect(results).to have_attributes(size: 3)
  71. 1 expect(results[0]).to have_attributes(result: be_success, messages: be_empty)
  72. 1 expect(results[1]).to have_attributes(result: be_success, messages: be_empty)
  73. 1 expect(results[2]).to have_attributes(result: be_failure, messages: have_attributes(size: 1))
  74. end
  75. 1 it "yields the successful value for each valid row" do
  76. 1 results = process_to_a(input)
  77. 1 expect(results[0].result).to eq(
  78. Success(foo: "olleh", bar: [nil, nil, "foo@bar.baz", nil, Float])
  79. )
  80. 1 expect(results[1].result).to eq(
  81. Success(foo: "dlrow", bar: [nil, nil, "foo@bar.baz", nil, Float])
  82. )
  83. end
  84. 1 it "yields the failure data for each invalid row" do
  85. 1 results = process_to_a(input)
  86. 1 expect(results[2].result).to eq(Failure())
  87. 1 expect(results[2].messages).to contain_exactly(
  88. have_attributes(
  89. code: "must_be_email",
  90. code_data: { value: "boudiou !".inspect },
  91. scope: Sheetah::Messaging::SCOPES::CELL,
  92. scope_data: { row: 3, col: "B" },
  93. severity: Sheetah::Messaging::SEVERITIES::ERROR
  94. )
  95. )
  96. end
  97. end
  98. 1 context "when there are unspecified columns in the sheet" do
  99. 1 before do
  100. 3 input.each_index do |idx|
  101. 12 input[idx] = input[idx][0..1] + ["oof"] + input[idx][2..] + ["rab"]
  102. end
  103. end
  104. 1 context "when the template allows it" do
  105. 2 before { template_opts[:ignore_unspecified_columns] = true }
  106. 1 it "ignores the unspecified columns" do
  107. 1 results = process_to_a(input)
  108. 1 expect(results[0].result).to eq(
  109. Success(foo: "olleh", bar: [nil, nil, "foo@bar.baz", nil, Float])
  110. )
  111. 1 expect(results[1].result).to eq(
  112. Success(foo: "dlrow", bar: [nil, nil, "foo@bar.baz", nil, Float])
  113. )
  114. end
  115. end
  116. 1 context "when the template doesn't allow it" do
  117. 3 before { template_opts[:ignore_unspecified_columns] = false }
  118. 1 it "doesn't yield any row" do
  119. 2 expect { |b| process(input, &b) }.not_to yield_control
  120. end
  121. 1 it "returns a failure with data" do # rubocop:disable RSpec/ExampleLength
  122. 1 expect(process(input) {}).to have_attributes(
  123. result: Failure(),
  124. messages: contain_exactly(
  125. have_attributes(
  126. code: "invalid_header",
  127. code_data: "oof",
  128. scope: Sheetah::Messaging::SCOPES::COL,
  129. scope_data: { col: "C" },
  130. severity: Sheetah::Messaging::SEVERITIES::ERROR
  131. ),
  132. have_attributes(
  133. code: "invalid_header",
  134. code_data: "rab",
  135. scope: Sheetah::Messaging::SCOPES::COL,
  136. scope_data: { col: "F" },
  137. severity: Sheetah::Messaging::SEVERITIES::ERROR
  138. )
  139. )
  140. )
  141. end
  142. end
  143. end
  144. 1 context "when there are missing columns" do
  145. 1 before do
  146. 2 input.each do |input|
  147. 8 input.delete_at(2)
  148. 8 input.delete_at(0)
  149. end
  150. end
  151. 1 it "doesn't yield any row" do
  152. 2 expect { |b| process(input, &b) }.not_to yield_control
  153. end
  154. 1 it "returns a failure with data" do # rubocop:disable RSpec/ExampleLength
  155. 1 expect(process(input) {}).to have_attributes(
  156. result: Failure(),
  157. messages: contain_exactly(
  158. have_attributes(
  159. code: "missing_column",
  160. code_data: "Foo",
  161. scope: Sheetah::Messaging::SCOPES::SHEET,
  162. scope_data: nil,
  163. severity: Sheetah::Messaging::SEVERITIES::ERROR
  164. ),
  165. have_attributes(
  166. code: "missing_column",
  167. code_data: "Bar 5",
  168. scope: Sheetah::Messaging::SCOPES::SHEET,
  169. scope_data: nil,
  170. severity: Sheetah::Messaging::SEVERITIES::ERROR
  171. )
  172. )
  173. )
  174. end
  175. end
  176. end

spec/support/fixtures.rb

100.0% lines covered

100.0% branches covered

7 relevant lines. 7 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 mod = Module.new do
  3. 1 def fixtures_path
  4. 19 @fixtures_path ||= File.expand_path("./fixtures", __dir__)
  5. end
  6. 1 def fixture_path(path)
  7. 19 File.join(fixtures_path, path)
  8. end
  9. end
  10. 1 RSpec.configure do |config|
  11. 1 config.include(mod)
  12. end

spec/support/monadic_result.rb

100.0% lines covered

100.0% branches covered

3 relevant lines. 3 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "sheetah/utils/monadic_result"
  3. 1 RSpec.configure do |config|
  4. 1 config.include(Sheetah::Utils::MonadicResult, monadic_result: true)
  5. end

spec/support/shared/cast_class.rb

100.0% lines covered

100.0% branches covered

11 relevant lines. 11 lines covered and 0 lines missed.
2 total branches, 2 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 RSpec.shared_examples "cast_class" do
  3. 15 else: 3 then: 4 subject(:cast_class) { described_class } unless method_defined?(:cast_class) # rubocop:disable RSpec/LeadingSubject
  4. 7 let(:cast) do
  5. 12 cast_class.new
  6. end
  7. 7 describe "#initialize" do
  8. 7 it "tolerates any kwargs" do
  9. 7 expect do
  10. 7 cast_class.new(foo: double, qoifzj: double)
  11. end.not_to raise_error
  12. end
  13. end
  14. 7 describe "#call" do
  15. 7 it "has the right cast signature" do
  16. 7 expect(cast).to respond_to(:call).with(2).arguments
  17. end
  18. end
  19. end

spec/support/shared/composite_type.rb

100.0% lines covered

100.0% branches covered

33 relevant lines. 33 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "sheetah/types/type"
  3. 1 require "sheetah/types/scalars/scalar"
  4. 1 RSpec.shared_examples "composite_type" do
  5. 3 subject(:type) do
  6. 12 described_class.new(scalars)
  7. end
  8. 15 let(:scalars) { instance_double(Array) }
  9. 12 let(:value) { double }
  10. 12 let(:messenger) { double }
  11. 3 it "is a type" do
  12. 3 expect(described_class.ancestors).to include(Sheetah::Types::Type)
  13. end
  14. 3 describe "#composite?" do
  15. 3 it "is true" do
  16. 3 expect(subject).to be_composite
  17. end
  18. end
  19. 3 describe "#composite" do
  20. 3 it "is an alias to #cast" do
  21. 3 expect(subject.method(:composite)).to eq(subject.method(:cast))
  22. end
  23. end
  24. 3 describe "#scalar" do
  25. 9 let(:type_index) { double }
  26. 3 def stub_scalar_index(type = double)
  27. 6 allow(scalars).to receive(:[]).with(type_index).and_return(type)
  28. 6 type
  29. end
  30. 3 context "when the index refers to a scalar type" do
  31. 6 let(:scalar_type) { instance_double(Sheetah::Types::Scalars::Scalar) }
  32. 3 before do
  33. 3 stub_scalar_index(scalar_type)
  34. end
  35. 3 it "casts the value to the scalar type" do
  36. 3 expect(scalar_type).to(
  37. receive(:scalar).with(nil, value, messenger).and_return(casted_value = double)
  38. )
  39. 3 expect(subject.scalar(type_index, value, messenger)).to be(casted_value)
  40. end
  41. end
  42. 3 context "when the index doesn't refer to a scalar type" do
  43. 3 before do
  44. 3 stub_scalar_index(nil)
  45. end
  46. 3 it "raises an error" do
  47. 6 expect { subject.scalar(type_index, value, messenger) }.to raise_error(
  48. Sheetah::Errors::TypeError,
  49. "Invalid index: #{type_index.inspect}"
  50. )
  51. end
  52. end
  53. end
  54. end

spec/support/shared/scalar_type.rb

100.0% lines covered

100.0% branches covered

22 relevant lines. 22 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "sheetah/types/type"
  3. 1 RSpec.shared_examples "scalar_type" do
  4. 22 let(:value) { double }
  5. 22 let(:messenger) { double }
  6. 5 it "is a type" do
  7. 5 expect(described_class.ancestors).to include(Sheetah::Types::Type)
  8. end
  9. 5 describe "#composite?" do
  10. 5 it "is false" do
  11. 5 expect(subject).not_to be_composite
  12. end
  13. end
  14. 5 describe "#composite" do
  15. 5 it "fails" do
  16. 10 expect { subject.composite(value, messenger) }.to raise_error(
  17. Sheetah::Errors::TypeError, "A scalar type cannot act as a composite"
  18. )
  19. end
  20. end
  21. 5 describe "#scalar" do
  22. 5 context "when the value is not indexed" do
  23. 5 it "delegates the task to the cast chain" do
  24. 5 result = double
  25. 5 expect(subject.cast_chain).to receive(:call).with(value, messenger).and_return(result)
  26. 5 expect(subject.scalar(nil, value, messenger)).to be(result)
  27. end
  28. end
  29. 5 context "when the value is indexed" do
  30. 5 it "fails" do
  31. 5 index = double
  32. 10 expect { subject.scalar(index, value, messenger) }.to raise_error(
  33. Sheetah::Errors::TypeError, "A scalar type cannot be indexed"
  34. )
  35. end
  36. end
  37. end
  38. end

spec/support/shared/sheet_factories.rb

100.0% lines covered

100.0% branches covered

12 relevant lines. 12 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "sheetah/sheet"
  3. 1 RSpec.shared_context "sheet_factories" do
  4. 3 def header(...)
  5. 26 Sheetah::Sheet::Header.new(...)
  6. end
  7. 3 def row(...)
  8. 16 Sheetah::Sheet::Row.new(...)
  9. end
  10. 3 def cell(...)
  11. 68 Sheetah::Sheet::Cell.new(...)
  12. end
  13. 3 def cells(values, row:, col: "A")
  14. 16 int = Sheetah::Sheet.col2int(col)
  15. 16 values.map.with_index(int) do |value, index|
  16. 68 cell(row: row, col: Sheetah::Sheet.int2col(index), value: value)
  17. end
  18. end
  19. end