excel_templating 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.document +3 -0
- data/.gitignore +4 -0
- data/.rspec +1 -0
- data/.rubocop.hound.yml +261 -0
- data/.rubocop.ph.yml +44 -0
- data/.rubocop.yml +3 -0
- data/.yardopts +1 -0
- data/ChangeLog.md +8 -0
- data/Gemfile +10 -0
- data/LICENSE.txt +3 -0
- data/README.md +133 -0
- data/Rakefile +43 -0
- data/excel_templating.gemspec +32 -0
- data/lib/excel_templating/document/data_source_registry/registry_list.rb +48 -0
- data/lib/excel_templating/document/data_source_registry/registry_renderer.rb +74 -0
- data/lib/excel_templating/document/data_source_registry.rb +64 -0
- data/lib/excel_templating/document/sheet/repeated_row.rb +39 -0
- data/lib/excel_templating/document/sheet.rb +133 -0
- data/lib/excel_templating/document.rb +71 -0
- data/lib/excel_templating/document_dsl.rb +85 -0
- data/lib/excel_templating/excel_abstraction/active_cell_reference.rb +59 -0
- data/lib/excel_templating/excel_abstraction/cell.rb +23 -0
- data/lib/excel_templating/excel_abstraction/cell_range.rb +26 -0
- data/lib/excel_templating/excel_abstraction/cell_reference.rb +39 -0
- data/lib/excel_templating/excel_abstraction/date.rb +36 -0
- data/lib/excel_templating/excel_abstraction/row.rb +29 -0
- data/lib/excel_templating/excel_abstraction/sheet.rb +102 -0
- data/lib/excel_templating/excel_abstraction/spread_sheet.rb +28 -0
- data/lib/excel_templating/excel_abstraction/time.rb +42 -0
- data/lib/excel_templating/excel_abstraction/work_book.rb +47 -0
- data/lib/excel_templating/excel_abstraction.rb +16 -0
- data/lib/excel_templating/render_helper.rb +14 -0
- data/lib/excel_templating/renderer.rb +251 -0
- data/lib/excel_templating/rspec_excel_matcher.rb +129 -0
- data/lib/excel_templating/version.rb +4 -0
- data/lib/excel_templating.rb +4 -0
- data/spec/assets/alphalist_7_4.mustache.xlsx +0 -0
- data/spec/assets/alphalist_seven_four_expected.xlsx +0 -0
- data/spec/assets/valid_cell.mustache.xlsx +0 -0
- data/spec/assets/valid_cell_expected.xlsx +0 -0
- data/spec/assets/valid_cell_expected_inline.xlsx +0 -0
- data/spec/assets/valid_column_expected.xlsx +0 -0
- data/spec/cell_validation_spec.rb +114 -0
- data/spec/column_validation_spec.rb +47 -0
- data/spec/excel_abstraction/active_cell_reference_spec.rb +73 -0
- data/spec/excel_abstraction/cell_range_spec.rb +36 -0
- data/spec/excel_abstraction/cell_reference_spec.rb +69 -0
- data/spec/excel_abstraction/cell_spec.rb +54 -0
- data/spec/excel_abstraction/date_spec.rb +27 -0
- data/spec/excel_abstraction/row_spec.rb +42 -0
- data/spec/excel_abstraction/sheet_spec.rb +83 -0
- data/spec/excel_abstraction/spread_sheet_spec.rb +35 -0
- data/spec/excel_abstraction/time_spec.rb +27 -0
- data/spec/excel_abstraction/work_book_spec.rb +22 -0
- data/spec/excel_helper.rb +16 -0
- data/spec/excel_templating_spec.rb +141 -0
- data/spec/spec_helper.rb +13 -0
- metadata +281 -0
@@ -0,0 +1,102 @@
|
|
1
|
+
module ExcelAbstraction
|
2
|
+
class Sheet < SimpleDelegator
|
3
|
+
attr_reader :active_cell_reference
|
4
|
+
|
5
|
+
def initialize(sheet, workbook)
|
6
|
+
super(sheet)
|
7
|
+
@workbook = workbook
|
8
|
+
@active_cell_reference = ExcelAbstraction::ActiveCellReference.new
|
9
|
+
end
|
10
|
+
|
11
|
+
def header(value, options = {})
|
12
|
+
cell(value, {bold: 1}.merge(options))
|
13
|
+
self
|
14
|
+
end
|
15
|
+
|
16
|
+
def headers(array, options = {})
|
17
|
+
Array(array).each { |element| header(element, options) }
|
18
|
+
self
|
19
|
+
end
|
20
|
+
|
21
|
+
# Fills the cell at the current pointer with the value and format or options specified
|
22
|
+
# @param [Object] value Value to place in the cell.
|
23
|
+
# @param [Object] options (optional) options to create a format object
|
24
|
+
# @param [Object] format (optional) The format to use when creating this cell
|
25
|
+
def cell(value, format: nil, type: :auto, **options)
|
26
|
+
format = format || _format(options)
|
27
|
+
value = Float(value) if value.is_a?(ExcelAbstraction::Time) || value.is_a?(ExcelAbstraction::Date)
|
28
|
+
|
29
|
+
writer = case type
|
30
|
+
when :string then method(:write_string)
|
31
|
+
else method(:write)
|
32
|
+
end
|
33
|
+
|
34
|
+
writer.call(active_cell_reference.row, active_cell_reference.col, value, format)
|
35
|
+
|
36
|
+
if options[:new_row]
|
37
|
+
next_row
|
38
|
+
else
|
39
|
+
active_cell_reference.right
|
40
|
+
end
|
41
|
+
self
|
42
|
+
end
|
43
|
+
|
44
|
+
# Advance the pointer to the next row.
|
45
|
+
# @return nil
|
46
|
+
def next_row
|
47
|
+
active_cell_reference.newline
|
48
|
+
end
|
49
|
+
|
50
|
+
def cells(array, options = {})
|
51
|
+
Array(array).each { |value| cell(value, options) }
|
52
|
+
self
|
53
|
+
end
|
54
|
+
|
55
|
+
def merge(length, value, options = {})
|
56
|
+
format = _format(options)
|
57
|
+
merge_range(active_cell_reference.row, active_cell_reference.col, active_cell_reference.row, active_cell_reference.col + length, value, format)
|
58
|
+
active_cell_reference.right(length + 1)
|
59
|
+
self
|
60
|
+
end
|
61
|
+
|
62
|
+
def style_row(row, properties = {})
|
63
|
+
height = properties[:height]
|
64
|
+
format = _format(properties[:format] || {})
|
65
|
+
hidden = properties[:hidden] || 0
|
66
|
+
outline_level = properties[:level] || 0
|
67
|
+
if outline_level
|
68
|
+
raise "Outline level can only be between 0 and 7" unless (0..7) === outline_level
|
69
|
+
end
|
70
|
+
collapse = properties[:collapse] || 0
|
71
|
+
set_row(row, height, format, hidden, outline_level, collapse)
|
72
|
+
self
|
73
|
+
end
|
74
|
+
|
75
|
+
# Style the numbered column
|
76
|
+
# @param [Integer] col 0 based index of the column.
|
77
|
+
# @param [Integer] width numeric width of the column (30 = 2.6 inches)
|
78
|
+
# @param [Hash] format Properties to set for the format of the column
|
79
|
+
# @param [Integer] level Outline level
|
80
|
+
# @param [Integer] collapse 1 = collapse, 0 = do not collapse
|
81
|
+
def style_col(col, width: nil, format: {}, level: 0, collapse: 0, hidden: nil)
|
82
|
+
format = _format(format || {})
|
83
|
+
if level
|
84
|
+
raise "Outline level can only be between 0 and 7" unless (0..7) === level
|
85
|
+
end
|
86
|
+
set_column(col, col, width, format, hidden, level, collapse)
|
87
|
+
self
|
88
|
+
end
|
89
|
+
|
90
|
+
private
|
91
|
+
|
92
|
+
def _format(options)
|
93
|
+
format = @workbook.add_format
|
94
|
+
format.set_align('valign')
|
95
|
+
options.each do |key, value|
|
96
|
+
method = "set_#{key.to_s}".to_sym
|
97
|
+
format.send(method, value) if format.respond_to?(method)
|
98
|
+
end
|
99
|
+
format
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
require 'tempfile'
|
2
|
+
|
3
|
+
module ExcelAbstraction
|
4
|
+
class SpreadSheet < DelegateClass(Tempfile)
|
5
|
+
attr_reader :workbook
|
6
|
+
|
7
|
+
def initialize(format: :xls, skip_default_sheet: false)
|
8
|
+
extension = format == :xls ? ".xls" : ".xlsx"
|
9
|
+
tmp_file = Tempfile.new(["temp_spreadsheet_#{::Time.now.to_i}", extension])
|
10
|
+
super(tmp_file)
|
11
|
+
@workbook = ExcelAbstraction::WorkBook.new(tmp_file.path, format: format, skip_default_sheet: skip_default_sheet)
|
12
|
+
end
|
13
|
+
|
14
|
+
def close
|
15
|
+
workbook.close
|
16
|
+
yield if block_given?
|
17
|
+
super
|
18
|
+
end
|
19
|
+
|
20
|
+
def to_s
|
21
|
+
data = nil
|
22
|
+
close do
|
23
|
+
data = read
|
24
|
+
end
|
25
|
+
data
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
module ExcelAbstraction
|
2
|
+
class Time < SimpleDelegator
|
3
|
+
ADJUSTMENT = "1900-03-01 00:00 +00:00"
|
4
|
+
REFERENCE = "1900-01-01 00:00 +00:00"
|
5
|
+
|
6
|
+
attr_reader :value
|
7
|
+
|
8
|
+
def initialize(raw_value)
|
9
|
+
super(convert(raw_value))
|
10
|
+
end
|
11
|
+
|
12
|
+
def to_excel_time
|
13
|
+
self
|
14
|
+
end
|
15
|
+
|
16
|
+
private
|
17
|
+
|
18
|
+
def adjustment
|
19
|
+
::Time.parse(ADJUSTMENT)
|
20
|
+
end
|
21
|
+
|
22
|
+
def reference
|
23
|
+
::Time.parse(REFERENCE)
|
24
|
+
end
|
25
|
+
|
26
|
+
def adjust(raw_value)
|
27
|
+
adjustment < raw_value ? two_days : one_day
|
28
|
+
end
|
29
|
+
|
30
|
+
def two_days
|
31
|
+
one_day * 2.0
|
32
|
+
end
|
33
|
+
|
34
|
+
def one_day
|
35
|
+
60 * 60 * 24.to_f
|
36
|
+
end
|
37
|
+
|
38
|
+
def convert(raw_value)
|
39
|
+
(raw_value - reference + adjust(raw_value)).to_f / one_day
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
module ExcelAbstraction
|
2
|
+
class WorkBook < SimpleDelegator
|
3
|
+
attr_accessor :active_sheet
|
4
|
+
|
5
|
+
def initialize(file, format: :xls, skip_default_sheet: false)
|
6
|
+
@format = format
|
7
|
+
@file = file
|
8
|
+
super(workbook)
|
9
|
+
unless skip_default_sheet
|
10
|
+
@active_sheet = ExcelAbstraction::Sheet.new(workbook.add_worksheet, workbook)
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
|
15
|
+
def title(text)
|
16
|
+
set_properties(title: text)
|
17
|
+
self
|
18
|
+
end
|
19
|
+
|
20
|
+
def organization(name)
|
21
|
+
set_properties(company: name)
|
22
|
+
self
|
23
|
+
end
|
24
|
+
|
25
|
+
private
|
26
|
+
|
27
|
+
attr_reader :format, :file
|
28
|
+
|
29
|
+
def default_options
|
30
|
+
{
|
31
|
+
:font => 'Calibri',
|
32
|
+
:size => 12,
|
33
|
+
:align => 'center',
|
34
|
+
:text_wrap => 1
|
35
|
+
}
|
36
|
+
end
|
37
|
+
|
38
|
+
def workbook
|
39
|
+
@workbook ||= if format == :xlsx
|
40
|
+
WriteXLSX.new(file, default_options)
|
41
|
+
else
|
42
|
+
WriteExcel.new(file, default_options)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
require 'write_xlsx'
|
2
|
+
require 'writeexcel'
|
3
|
+
|
4
|
+
require_relative 'excel_abstraction/active_cell_reference'
|
5
|
+
require_relative 'excel_abstraction/cell'
|
6
|
+
require_relative 'excel_abstraction/cell_range'
|
7
|
+
require_relative 'excel_abstraction/cell_reference'
|
8
|
+
require_relative 'excel_abstraction/date'
|
9
|
+
require_relative 'excel_abstraction/row'
|
10
|
+
require_relative 'excel_abstraction/sheet'
|
11
|
+
require_relative 'excel_abstraction/spread_sheet'
|
12
|
+
require_relative 'excel_abstraction/time'
|
13
|
+
require_relative 'excel_abstraction/work_book'
|
14
|
+
|
15
|
+
module ExcelAbstraction
|
16
|
+
end
|
@@ -0,0 +1,251 @@
|
|
1
|
+
require_relative 'excel_abstraction'
|
2
|
+
require 'mustache'
|
3
|
+
|
4
|
+
module ExcelTemplating
|
5
|
+
# Render class for ExcelTemplating Documents. Used by the Document to render the defined document
|
6
|
+
# with the data to a new file. Responsible for reading the template and applying the data to it
|
7
|
+
class Renderer
|
8
|
+
extend Forwardable
|
9
|
+
|
10
|
+
# @param [ExcelTemplating::Document] document Document to render with
|
11
|
+
def initialize(document)
|
12
|
+
@template_document = document
|
13
|
+
@data_source_registry = document.class.data_source_registry
|
14
|
+
end
|
15
|
+
|
16
|
+
# Render the document provided. Yields the path to the tempfile created.
|
17
|
+
def render
|
18
|
+
@spreadsheet = ExcelAbstraction::SpreadSheet.new(format: :xlsx)
|
19
|
+
@template = Roo::Spreadsheet.open(template_path)
|
20
|
+
@registry_renderer = data_source_registry.renderer(data: data[:all_sheets])
|
21
|
+
apply_document_level_items
|
22
|
+
apply_data_to_sheets
|
23
|
+
protect_spreadsheet
|
24
|
+
registry_renderer.write_sheet(@spreadsheet.workbook)
|
25
|
+
|
26
|
+
@spreadsheet.close
|
27
|
+
yield(spreadsheet.path)
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
attr_reader :template_document, :spreadsheet, :template, :registry_renderer, :data_source_registry, :current_sheet
|
33
|
+
delegate [:workbook] => :spreadsheet
|
34
|
+
delegate [:data] => :template_document
|
35
|
+
delegate [:active_sheet] => :workbook
|
36
|
+
delegate [:active_cell_reference] => :active_sheet
|
37
|
+
|
38
|
+
def current_row
|
39
|
+
active_cell_reference.row
|
40
|
+
end
|
41
|
+
|
42
|
+
def current_col
|
43
|
+
active_cell_reference.col
|
44
|
+
end
|
45
|
+
|
46
|
+
def template_path
|
47
|
+
template_document.class.template_path
|
48
|
+
end
|
49
|
+
|
50
|
+
def default_format_styling
|
51
|
+
template_document.class.document_default_styling
|
52
|
+
end
|
53
|
+
|
54
|
+
def protected?
|
55
|
+
template_document.class.protected?
|
56
|
+
end
|
57
|
+
|
58
|
+
def sheets
|
59
|
+
template_document.class.sheets
|
60
|
+
end
|
61
|
+
|
62
|
+
def apply_document_level_items
|
63
|
+
workbook.title mustachify(template_document.class.document_title, locals: common_data_variables)
|
64
|
+
workbook.organization mustachify(template_document.class.document_organization, locals: common_data_variables)
|
65
|
+
end
|
66
|
+
|
67
|
+
def common_data_variables
|
68
|
+
stringify_keys(data[:all_sheets])
|
69
|
+
end
|
70
|
+
|
71
|
+
def stringify_keys(hash)
|
72
|
+
Hash[hash.map { |k, v| [k.to_s, v] } ]
|
73
|
+
end
|
74
|
+
|
75
|
+
def style_columns(sheet, template_sheet)
|
76
|
+
default_style = sheet.default_column_style
|
77
|
+
column_styles = sheet.column_styles
|
78
|
+
if column_styles || default_style
|
79
|
+
roo_columns(template_sheet).each do |column_number|
|
80
|
+
style = column_styles[column_number] || default_style
|
81
|
+
active_sheet.style_col(column_number - 1, style) # Note: Styling columns is zero indexed
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
def style_rows(sheet, template_sheet)
|
87
|
+
default_style = sheet.default_row_style
|
88
|
+
row_styles = sheet.row_styles
|
89
|
+
if row_styles || default_style
|
90
|
+
roo_rows(template_sheet).each do |row_number|
|
91
|
+
style = row_styles[row_number] || default_style
|
92
|
+
active_sheet.style_row(row_number - 1, style) # Note: Styling rows is zero indexed
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
def roo_columns(roo_sheet)
|
98
|
+
(roo_sheet.first_column .. roo_sheet.last_column)
|
99
|
+
end
|
100
|
+
|
101
|
+
def roo_rows(roo_sheet)
|
102
|
+
(roo_sheet.first_row .. roo_sheet.last_row)
|
103
|
+
end
|
104
|
+
|
105
|
+
def protect_spreadsheet
|
106
|
+
active_sheet.protect if protected?
|
107
|
+
end
|
108
|
+
|
109
|
+
def apply_data_to_sheets
|
110
|
+
sheets.each_with_index do |sheet, sheet_number|
|
111
|
+
@current_sheet = sheet
|
112
|
+
sheet_data = sheet.sheet_data(data)
|
113
|
+
template_sheet = template.sheet(sheet_number)
|
114
|
+
|
115
|
+
# column and row styles should be applied before writing any data to the sheet
|
116
|
+
style_columns(sheet, template_sheet)
|
117
|
+
# row styles have priority over column styles
|
118
|
+
style_rows(sheet, template_sheet)
|
119
|
+
|
120
|
+
roo_rows(template_sheet).each do |row_number|
|
121
|
+
sheet.each_row_at(row_number, sheet_data) do |row_data|
|
122
|
+
local_data = stringify_keys(data[:all_sheets]).merge(stringify_keys(row_data))
|
123
|
+
roo_columns(template_sheet).each do |column_number|
|
124
|
+
apply_data_to_cell(local_data, template_sheet, row_number, column_number)
|
125
|
+
if sheet.validated_cell?(row_number, column_number)
|
126
|
+
add_validation(sheet, row_number, column_number)
|
127
|
+
end
|
128
|
+
end
|
129
|
+
active_sheet.next_row
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
def apply_data_to_cell(local_data, template_sheet, row_number, column_number)
|
136
|
+
template_cell = template_sheet.cell(row_number, column_number)
|
137
|
+
font = template_sheet.font(row_number, column_number)
|
138
|
+
format = format_for(font, row_number, column_number)
|
139
|
+
value = mustachify(template_cell, locals: local_data)
|
140
|
+
|
141
|
+
active_sheet.cell(
|
142
|
+
value,
|
143
|
+
type: type_for_value(value),
|
144
|
+
format: format
|
145
|
+
)
|
146
|
+
end
|
147
|
+
|
148
|
+
def type_for_value(value)
|
149
|
+
looks_like_a_separator?(value) ? :string : :auto
|
150
|
+
end
|
151
|
+
|
152
|
+
def looks_like_a_separator?(value)
|
153
|
+
value.is_a?(String) && value =~ /^==/
|
154
|
+
end
|
155
|
+
|
156
|
+
def format_for(font, row_number, column_number)
|
157
|
+
format = font_formats(font)
|
158
|
+
|
159
|
+
set_column_lock(format, column_number) if column_is_locked?(column_number)
|
160
|
+
set_row_lock(format, row_number) if row_is_locked?(row_number)
|
161
|
+
|
162
|
+
format
|
163
|
+
end
|
164
|
+
|
165
|
+
def column_is_locked?(column_number)
|
166
|
+
row_or_column_is_locked? current_sheet.column_styles[column_number]
|
167
|
+
end
|
168
|
+
|
169
|
+
def row_is_locked?(row_number)
|
170
|
+
row_or_column_is_locked? current_sheet.row_styles[row_number]
|
171
|
+
end
|
172
|
+
|
173
|
+
def row_or_column_is_locked?(row_or_column)
|
174
|
+
row_or_column && row_or_column[:format] && row_or_column[:format].has_key?(:locked)
|
175
|
+
end
|
176
|
+
|
177
|
+
def row_or_column_lock_attribute(row_or_column)
|
178
|
+
row_or_column && row_or_column[:format] && row_or_column[:format][:locked]
|
179
|
+
end
|
180
|
+
|
181
|
+
def set_row_lock(format, row_number)
|
182
|
+
format.set_locked row_or_column_lock_attribute(current_sheet.row_styles[row_number])
|
183
|
+
end
|
184
|
+
|
185
|
+
def set_column_lock(format, column_number)
|
186
|
+
format.set_locked row_or_column_lock_attribute(current_sheet.column_styles[column_number])
|
187
|
+
end
|
188
|
+
|
189
|
+
def font_formats(font=nil)
|
190
|
+
template_font = font || Roo::Font.new
|
191
|
+
|
192
|
+
format_details = {
|
193
|
+
bold: template_font.bold? ? 1 : 0,
|
194
|
+
italic: template_font.italic? ? 1 : 0,
|
195
|
+
underline: template_font.underline? ? 1 : 0
|
196
|
+
}.merge(default_format_styling)
|
197
|
+
|
198
|
+
workbook.add_format format_details
|
199
|
+
end
|
200
|
+
|
201
|
+
def mustachify(inline_template, locals: {})
|
202
|
+
if whole_cell_template?(inline_template)
|
203
|
+
locals.fetch(extract_key_from_template(inline_template))
|
204
|
+
elsif no_mustache_found?(inline_template)
|
205
|
+
inline_template
|
206
|
+
else
|
207
|
+
MustacheRenderer.render(inline_template, locals)
|
208
|
+
end
|
209
|
+
end
|
210
|
+
|
211
|
+
def no_mustache_found?(value)
|
212
|
+
!value.is_a?(String) || !(value.match(/{{.+}}/))
|
213
|
+
end
|
214
|
+
|
215
|
+
def extract_key_from_template(template)
|
216
|
+
template[whole_cell_template_matcher, 1]
|
217
|
+
end
|
218
|
+
|
219
|
+
def whole_cell_template_matcher
|
220
|
+
/^{{([^{}]+)}}$/
|
221
|
+
end
|
222
|
+
|
223
|
+
def whole_cell_template?(template)
|
224
|
+
template =~ whole_cell_template_matcher
|
225
|
+
end
|
226
|
+
|
227
|
+
def add_validation(sheet, row_number, column_number)
|
228
|
+
raise ArgumentError, "No :data_sources defined for validation!" unless data_source_registry
|
229
|
+
source = sheet.validation_source_name(row_number, column_number)
|
230
|
+
#Use current_row and current_col here because row_number and column_number refer to the template
|
231
|
+
#sheet and we want to write a reference to the cell we just wrote
|
232
|
+
active_sheet.data_validation absolute_reference(current_row + 1, current_col),
|
233
|
+
registry_renderer.absolute_reference_for(source)
|
234
|
+
end
|
235
|
+
|
236
|
+
def absolute_reference(row_number, column_number)
|
237
|
+
"$#{RenderHelper.letter_for(column_number)}$#{row_number}"
|
238
|
+
end
|
239
|
+
|
240
|
+
class MustacheRenderer < Mustache
|
241
|
+
|
242
|
+
def self.render(template, locals)
|
243
|
+
renderer = new
|
244
|
+
renderer.template = template
|
245
|
+
renderer.raise_on_context_miss = true
|
246
|
+
renderer.render(locals)
|
247
|
+
end
|
248
|
+
|
249
|
+
end
|
250
|
+
end
|
251
|
+
end
|
@@ -0,0 +1,129 @@
|
|
1
|
+
require 'roo'
|
2
|
+
require "roo-xls"
|
3
|
+
|
4
|
+
# Matcher for rspec 'match_excel_content'
|
5
|
+
# @example
|
6
|
+
# expect do
|
7
|
+
# subject.render do |path|
|
8
|
+
# expect(path).to match_excel_content('spec/assets/spreadsheets/seven_three_expected.xlsx')
|
9
|
+
# end
|
10
|
+
# end
|
11
|
+
RSpec::Matchers.define :match_excel_content do |expected_excel_path|
|
12
|
+
match do |excel_path|
|
13
|
+
@excel_matcher = RSpec::Matchers::ExcelMatcher.new
|
14
|
+
@excel_matcher.expected_roo = Roo::Spreadsheet.open(expected_excel_path)
|
15
|
+
@excel_matcher.actual_roo = Roo::Spreadsheet.open(excel_path)
|
16
|
+
@excel_matcher.match?
|
17
|
+
end
|
18
|
+
|
19
|
+
failure_message do |excel_path|
|
20
|
+
messages = ["expected excel:#{expected_excel_path} to exactly match the content of #{excel_path} but did not."]
|
21
|
+
@excel_matcher.errors.group_by(&:first).each do |sheet_name, errors|
|
22
|
+
messages << "#{sheet_name}:"
|
23
|
+
errors.each do |_, error|
|
24
|
+
messages << " #{error}:"
|
25
|
+
end
|
26
|
+
end
|
27
|
+
messages.join("\n")
|
28
|
+
end
|
29
|
+
|
30
|
+
failure_message_when_negated do |excel_path|
|
31
|
+
"expected excel:#{expected_excel_path} to not exactly match the content of #{excel_path} but it did."
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
# Specific Matcher class helper for comparing two excel documents.
|
36
|
+
class RSpec::Matchers::ExcelMatcher
|
37
|
+
|
38
|
+
def initialize
|
39
|
+
@errors = []
|
40
|
+
end
|
41
|
+
|
42
|
+
def match?
|
43
|
+
sheet_count_equal? && all_sheets_equal?
|
44
|
+
end
|
45
|
+
|
46
|
+
attr_accessor :errors, :expected_roo, :actual_roo
|
47
|
+
|
48
|
+
private
|
49
|
+
|
50
|
+
def check(sheet_name, check_result, fail_msg)
|
51
|
+
if check_result
|
52
|
+
true
|
53
|
+
else
|
54
|
+
errors << [sheet_name, fail_msg]
|
55
|
+
false
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def sheet_count_equal?
|
60
|
+
check(:base, expected_roo.sheets.count == actual_roo.sheets.count, "Number of sheets do not match.")
|
61
|
+
end
|
62
|
+
|
63
|
+
def all_sheets_equal?
|
64
|
+
sheets(expected_roo).zip(sheets(actual_roo)).all? do |expected_sheet, actual_sheet|
|
65
|
+
sheets_equal?(expected_sheet, actual_sheet)
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
def sheets(roo)
|
70
|
+
(0 .. (roo.sheets.count - 1)).map do |i|
|
71
|
+
roo.sheet(i)
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
def sheets_equal?(expected_workbook, actual_workbook)
|
76
|
+
expected_workbook.sheets.all? do |sheet_name|
|
77
|
+
expected = expected_workbook.sheet(sheet_name)
|
78
|
+
actual = begin
|
79
|
+
actual_workbook.sheet(sheet_name)
|
80
|
+
rescue
|
81
|
+
nil
|
82
|
+
end
|
83
|
+
|
84
|
+
check(sheet_name, actual, "Sheet names do not match.") if actual != nil
|
85
|
+
# check(sheet_name, expected.first_row == actual.first_row, "Number of rows do not match.")
|
86
|
+
# check(sheet_name, expected.last_row == actual.last_row, "Number of rows do not match")
|
87
|
+
# check(sheet_name, expected.first_column == actual.first_column, "Number of columns do not match")
|
88
|
+
# check(sheet_name, expected.last_column == actual.last_column, "Number of columns do not match")
|
89
|
+
check_cells(sheet_name, expected, actual)
|
90
|
+
|
91
|
+
errors.empty?
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
def check_cells(sheet_name, sheet1, sheet2)
|
96
|
+
(sheet1.first_row .. sheet1.last_row).each do |row|
|
97
|
+
(sheet1.first_column .. sheet1.last_column).each do |col|
|
98
|
+
check(sheet_name, compare_value(col, row, sheet1, sheet2), discrepancy_message(col, row, sheet1, sheet2))
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
def compare_value(col, row, sheet1, sheet2)
|
104
|
+
value2 = sheet2.cell(row, col)
|
105
|
+
value1 = sheet1.cell(row, col)
|
106
|
+
if value2.class == Float
|
107
|
+
value2 == value1.to_f
|
108
|
+
elsif value1.class == Float
|
109
|
+
value1 == value2.to_f
|
110
|
+
else
|
111
|
+
value2 == value1
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
def discrepancy_message(col, row, sheet1, sheet2)
|
116
|
+
cell_id = "#{column_letter(col)}#{row}"
|
117
|
+
actual_value = sheet2.cell(row, col).inspect
|
118
|
+
expected_value = sheet1.cell(row, col).inspect
|
119
|
+
"Cell #{cell_id} actual:#{actual_value} expected:#{expected_value}"
|
120
|
+
end
|
121
|
+
|
122
|
+
def column_letter(col_number)
|
123
|
+
column_letters[col_number - 1]
|
124
|
+
end
|
125
|
+
|
126
|
+
def column_letters
|
127
|
+
@column_letters ||= ('A' .. 'ZZ').to_a
|
128
|
+
end
|
129
|
+
end
|
Binary file
|
Binary file
|
Binary file
|
Binary file
|
Binary file
|
Binary file
|