conservancy_beancount/tests/test_reports_spreadsheet.py

542 lines
19 KiB
Python
Raw Normal View History

2020-06-03 22:54:49 +00:00
"""test_reports_spreadsheet - Unit tests for spreadsheet classes"""
# Copyright © 2020 Brett Smith
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
2020-06-03 23:03:02 +00:00
import datetime
import io
import itertools
2020-06-03 22:54:49 +00:00
import pytest
2020-06-03 23:03:02 +00:00
import babel.core
import babel.numbers
import odf.config
import odf.number
import odf.style
import odf.table
import odf.text
from decimal import Decimal
2020-06-03 22:54:49 +00:00
from . import testutil
from conservancy_beancount.reports import core
2020-06-03 23:03:02 +00:00
EN_US = babel.core.Locale('en', 'US')
XML_NAMES_LIST = [None, 'ce2', 'xml_testname']
XML_NAMES = itertools.cycle(XML_NAMES_LIST)
CURRENCY_CELL_DATA = [
(Decimal('10.101010'), 'BRL'),
(Decimal('-50.50'), 'GBP'),
]
LINK_CELL_DATA = [
'https://example.org',
('https://example.net', None),
('https://example.com', 'Example Site'),
]
NUMERIC_CELL_DATA = [
42,
42.42,
Decimal('42.42'),
]
STRING_CELL_DATA = [
'Example String',
LINK_CELL_DATA[0],
]
2020-06-03 22:54:49 +00:00
class BaseTester(core.BaseSpreadsheet[tuple, str]):
def __init__(self):
self.start_call = None
self.end_call = None
self.started_sections = []
self.ended_sections = []
self.written_rows = []
def section_key(self, row):
return row[0]
def start_spreadsheet(self):
self.start_call = self.started_sections.copy()
def start_section(self, key):
self.started_sections.append(key)
def end_section(self, key):
self.ended_sections.append(key)
def end_spreadsheet(self):
self.end_call = self.ended_sections.copy()
def write_row(self, key):
self.written_rows.append(key)
2020-06-03 23:03:02 +00:00
class ODSTester(core.BaseODS[tuple, str]):
def section_key(self, row):
return row[0]
2020-06-03 22:54:49 +00:00
@pytest.fixture
def spreadsheet():
return BaseTester()
2020-06-03 23:03:02 +00:00
@pytest.fixture
def ods_writer():
retval = ODSTester()
retval.locale = EN_US
return retval
def get_children(parent, child_type, **kwargs):
return [elem for elem in parent.getElementsByType(child_type)
if all(elem.getAttribute(k) == v for k, v in kwargs.items())]
def get_child(parent, child_type, index=-1, **kwargs):
try:
return get_children(parent, child_type, **kwargs)[index]
except IndexError:
raise ValueError("no matching child found") from None
def iter_text(parent):
for child in parent.childNodes:
if isinstance(child, odf.element.Text):
yield child.data
else:
yield from iter_text(child)
def get_text(parent, joiner=''):
return joiner.join(iter_text(parent))
def check_currency_style(curr_style):
child_names = {child.tagName for child in curr_style.childNodes}
assert odf.number.Number().tagName in child_names
assert len(child_names) > 1
2020-06-03 22:54:49 +00:00
def test_spreadsheet(spreadsheet):
rows = [(ch, ii) for ii, ch in enumerate('aabbcc', 1)]
spreadsheet.write(iter(rows))
assert spreadsheet.written_rows == rows
assert spreadsheet.ended_sections == spreadsheet.started_sections
assert spreadsheet.started_sections == list('abc')
assert spreadsheet.start_call == []
assert spreadsheet.end_call == spreadsheet.ended_sections
def test_empty_spreadsheet(spreadsheet):
empty_list = []
spreadsheet.write(iter(empty_list))
assert spreadsheet.start_call == empty_list
assert spreadsheet.end_call == empty_list
assert spreadsheet.started_sections == empty_list
assert spreadsheet.ended_sections == empty_list
assert spreadsheet.written_rows == empty_list
def test_one_section_spreadsheet(spreadsheet):
rows = [('A', n) for n in range(1, 4)]
spreadsheet.write(iter(rows))
assert spreadsheet.written_rows == rows
assert spreadsheet.ended_sections == spreadsheet.started_sections
assert spreadsheet.started_sections == list('A')
assert spreadsheet.start_call == []
assert spreadsheet.end_call == spreadsheet.ended_sections
2020-06-03 23:03:02 +00:00
def test_ods_writer(ods_writer):
rows = [(ch, ii) for ii, ch in enumerate('aabbcc', 1)]
ods_writer.write(iter(rows))
sheets = ods_writer.document.getElementsByType(odf.table.Table)
assert len(sheets) == 1
for exp_row, act_row in zip(rows, testutil.ODSCell.from_sheet(sheets[0])):
expected1, expected2 = exp_row
actual1, actual2 = act_row
assert actual1.value_type == 'string'
assert actual1.text == expected1
assert actual2.value_type == 'float'
assert actual2.value == expected2
assert actual2.text == str(expected2)
@pytest.mark.parametrize('save_type', ['file', 'path'])
def test_ods_writer_save(tmp_path, save_type):
rows = list(zip('ABC', 'abc'))
ods_writer = ODSTester()
ods_writer.write(iter(rows))
if save_type == 'file':
ods_output = io.BytesIO()
ods_writer.save_file(ods_output)
ods_output.seek(0)
else:
ods_output = tmp_path / 'SavePathTest.ods'
ods_writer.save_path(ods_output)
for exp_row, act_row in zip(rows, testutil.ODSCell.from_ods_file(ods_output)):
assert len(exp_row) == len(act_row)
for expected, actual in zip(exp_row, act_row):
assert actual.value_type == 'string'
assert actual.value is None
assert actual.text == expected
def test_ods_writer_use_sheet(ods_writer):
names = ['One', 'Two']
for name in names:
ods_writer.use_sheet(name)
ods_writer.write([(name,)])
ods_writer.use_sheet('End')
sheets = ods_writer.document.getElementsByType(odf.table.Table)
assert len(sheets) == len(names) + 1
for name, sheet in zip(names, sheets):
texts = [cell.text for row in testutil.ODSCell.from_sheet(sheet)
for cell in row]
assert texts == [name]
def test_ods_writer_use_sheet_returns_to_prior_sheets(ods_writer):
names = ['One', 'Two']
sheets = []
for name in names:
sheets.append(ods_writer.use_sheet(name))
ods_writer.write([(name,)])
for name, expected in zip(names, sheets):
actual = ods_writer.use_sheet(name)
assert actual is expected
texts = [cell.text for row in testutil.ODSCell.from_sheet(actual)
for cell in row]
assert texts == [name]
def test_ods_writer_use_sheet_discards_unused_sheets(ods_writer):
ods_writer.use_sheet('Three')
ods_writer.use_sheet('Two')
ods_writer.use_sheet('One')
sheets = ods_writer.document.getElementsByType(odf.table.Table)
assert len(sheets) == 1
assert sheets[0].getAttribute('name') == 'One'
@pytest.mark.parametrize('currency_code', [
'USD',
'EUR',
'BRL',
])
def test_ods_currency_style(ods_writer, currency_code):
style = ods_writer.currency_style(currency_code)
assert style.getAttribute('family') == 'table-cell'
curr_style = get_child(
ods_writer.document.styles,
odf.number.CurrencyStyle,
name=style.getAttribute('datastylename'),
)
check_currency_style(curr_style)
mappings = get_children(curr_style, odf.style.Map)
assert mappings
for mapping in mappings:
check_currency_style(get_child(
ods_writer.document.styles,
odf.number.CurrencyStyle,
name=mapping.getAttribute('applystylename'),
))
def test_ods_currency_style_caches(ods_writer):
expected = ods_writer.currency_style('USD')
_ = ods_writer.currency_style('EUR')
actual = ods_writer.currency_style('USD')
assert actual is expected
def test_ods_currency_style_cache_considers_properties(ods_writer):
bold_text = odf.style.TextProperties(fontweight='bold')
plain = ods_writer.currency_style('USD')
bold = ods_writer.currency_style('USD', positive_properties=bold_text)
assert plain is not bold
assert plain.getAttribute('name') != bold.getAttribute('name')
assert plain.getAttribute('datastylename') != bold.getAttribute('datastylename')
@pytest.mark.parametrize('attr_name,child_type,checked_attr', [
('style_bold', odf.style.TextProperties, 'fontweight'),
('style_centertext', odf.style.ParagraphProperties, 'textalign'),
('style_dividerline', odf.style.TableCellProperties, 'borderbottom'),
('style_endtext', odf.style.ParagraphProperties, 'textalign'),
('style_starttext', odf.style.ParagraphProperties, 'textalign'),
])
def test_ods_writer_style(ods_writer, attr_name, child_type, checked_attr):
style = getattr(ods_writer, attr_name)
actual = get_child(
ods_writer.document.styles,
odf.style.Style,
name=style.getAttribute('name'),
)
assert actual is style
child = get_child(actual, child_type)
assert child.getAttribute(checked_attr)
def test_ods_writer_merge_styles(ods_writer):
style = ods_writer.merge_styles(ods_writer.style_bold, ods_writer.style_dividerline)
actual = get_child(
ods_writer.document.styles,
odf.style.Style,
name=style.getAttribute('name'),
)
assert actual is style
assert actual.getAttribute('family') == 'table-cell'
text_props = get_child(actual, odf.style.TextProperties)
assert text_props.getAttribute('fontweight') == 'bold'
cell_props = get_child(actual, odf.style.TableCellProperties)
assert cell_props.getAttribute('borderbottom')
def test_ods_writer_merge_styles_with_children_and_attributes(ods_writer):
jpy_style = ods_writer.currency_style('JPY')
style = ods_writer.merge_styles(ods_writer.style_bold, jpy_style)
actual = get_child(
ods_writer.document.styles,
odf.style.Style,
name=style.getAttribute('name'),
)
assert actual is style
assert actual.getAttribute('family') == 'table-cell'
assert actual.getAttribute('datastylename') == jpy_style.getAttribute('datastylename')
text_props = get_child(actual, odf.style.TextProperties)
assert text_props.getAttribute('fontweight') == 'bold'
def test_ods_writer_merge_styles_caches(ods_writer):
sources = [ods_writer.style_bold, ods_writer.style_dividerline]
style1 = ods_writer.merge_styles(*sources)
style2 = ods_writer.merge_styles(*reversed(sources))
assert style1 is style2
assert get_child(
ods_writer.document.styles,
odf.style.Style,
name=style1.getAttribute('name'),
)
def test_ods_writer_layer_merge_styles(ods_writer):
usd_style = ods_writer.currency_style('USD')
layer1 = ods_writer.merge_styles(ods_writer.style_bold, ods_writer.style_dividerline)
layer2 = ods_writer.merge_styles(layer1, usd_style)
style_name = layer2.getAttribute('name')
assert style_name.count('Merge_') == 1
actual = get_child(
ods_writer.document.styles,
odf.style.Style,
name=style_name,
)
assert actual is layer2
assert actual.getAttribute('family') == 'table-cell'
assert actual.getAttribute('datastylename') == usd_style.getAttribute('datastylename')
text_props = get_child(actual, odf.style.TextProperties)
assert text_props.getAttribute('fontweight') == 'bold'
cell_props = get_child(actual, odf.style.TableCellProperties)
assert cell_props.getAttribute('borderbottom')
def test_ods_writer_merge_one_style(ods_writer):
actual = ods_writer.merge_styles(None, ods_writer.style_bold)
assert actual is ods_writer.style_bold
def test_ods_writer_merge_no_styles(ods_writer):
assert ods_writer.merge_styles() is None
def test_ods_writer_merge_nonexistent_style(ods_writer):
name = 'Non Existent Style'
with pytest.raises(ValueError, match=repr(name)):
ods_writer.merge_styles(ods_writer.style_bold, name)
def test_ods_writer_merge_conflicting_styles(ods_writer):
sources = [ods_writer.currency_style(code) for code in ['USD', 'EUR']]
with pytest.raises(ValueError, match='conflicting datastylename'):
ods_writer.merge_styles(*sources)
def test_ods_writer_date_style(ods_writer):
data_style_name = ods_writer.style_date.getAttribute('datastylename')
actual = get_child(
ods_writer.document.styles,
odf.style.Style,
family='table-cell',
datastylename=data_style_name,
)
assert actual is ods_writer.style_date
data_style = get_child(
ods_writer.document.styles,
odf.number.DateStyle,
name=data_style_name,
)
assert len(data_style.childNodes) == 5
year, t1, month, t2, day = data_style.childNodes
assert year.qname[1] == 'year'
assert year.getAttribute('style') == 'long'
assert get_text(t1) == '-'
assert month.qname[1] == 'month'
assert month.getAttribute('style') == 'long'
assert get_text(t2) == '-'
assert day.qname[1] == 'day'
assert day.getAttribute('style') == 'long'
def test_ods_lock_first_row(ods_writer):
ods_writer.lock_first_row()
view_settings = get_child(
ods_writer.document.settings,
odf.config.ConfigItemSet,
name='ooo:view-settings',
)
views = get_child(view_settings, odf.config.ConfigItemMapIndexed, name='Views')
view1 = get_child(views, odf.config.ConfigItemMapEntry, index=0)
config_map = get_child(view1, odf.config.ConfigItemMapNamed, name='Tables')
sheet_name = ods_writer.sheet.getAttribute('name')
config_entry = get_child(config_map, odf.config.ConfigItemMapEntry, name=sheet_name)
for name, ctype, value in [
('PositionBottom', 'int', '1'),
('VerticalSplitMode', 'short', '2'),
('VerticalSplitPosition', 'short', '1'),
]:
child = get_child(config_entry, odf.config.ConfigItem, name=name)
assert child.getAttribute('type') == ctype
assert child.firstChild.data == value
@pytest.mark.parametrize('style_name', XML_NAMES_LIST)
def test_ods_writer_add_row(ods_writer, style_name):
cell1 = ods_writer.string_cell('one')
cell2 = ods_writer.float_cell(42.0)
row = ods_writer.add_row(cell1, cell2, defaultcellstylename=style_name)
assert ods_writer.sheet.lastChild is row
assert row.getAttribute('defaultcellstylename') == style_name
assert row.firstChild is cell1
assert row.lastChild is cell2
def test_ods_writer_add_row_single_cell(ods_writer):
cell = ods_writer.multilink_cell(LINK_CELL_DATA[:1])
row = ods_writer.add_row(cell)
assert ods_writer.sheet.lastChild is row
assert row.firstChild is cell
assert row.lastChild is cell
def test_ods_writer_add_row_empty(ods_writer):
row = ods_writer.add_row(stylename='blank')
assert ods_writer.sheet.lastChild is row
assert row.firstChild is None
assert row.getAttribute('stylename') == 'blank'
def test_ods_writer_balance_cell_empty(ods_writer):
balance = core.Balance()
cell = ods_writer.balance_cell(balance)
assert cell.value_type != 'string'
assert float(cell.value) == 0
def test_ods_writer_balance_cell_single_currency(ods_writer):
number = 250
currency = 'EUR'
balance = core.Balance([testutil.Amount(number, currency)])
cell = ods_writer.balance_cell(balance)
assert cell.value_type == 'currency'
assert Decimal(cell.value) == number
assert cell.text == babel.numbers.format_currency(
number, currency, locale=EN_US, format_type='accounting',
)
def test_ods_writer_balance_cell_multi_currency(ods_writer):
amounts = [testutil.Amount(num, code) for num, code in [
(2500, 'RUB'),
(3500, 'BRL'),
]]
balance = core.Balance(amounts)
cell = ods_writer.balance_cell(balance)
assert cell.text == '\0'.join(babel.numbers.format_currency(
number, currency, locale=EN_US, format_type='accounting',
) for number, currency in amounts)
@pytest.mark.parametrize('cell_source,style_name', testutil.combine_values(
CURRENCY_CELL_DATA,
XML_NAMES,
))
def test_ods_writer_currency_cell(ods_writer, cell_source, style_name):
cell = ods_writer.currency_cell(cell_source, stylename=style_name)
number, currency = cell_source
assert cell.getAttribute('valuetype') == 'currency'
assert cell.getAttribute('value') == str(number)
assert cell.getAttribute('stylename') == style_name
expected = babel.numbers.format_currency(
number, currency, locale=EN_US, format_type='accounting',
)
assert get_text(cell) == expected
@pytest.mark.parametrize('date,style_name', testutil.combine_values(
[datetime.date(1980, 2, 5), datetime.date(2030, 10, 30)],
XML_NAMES_LIST,
))
def test_ods_writer_date_cell(ods_writer, date, style_name):
if style_name is None:
expect_style = ods_writer.style_date.getAttribute('name')
cell = ods_writer.date_cell(date)
else:
expect_style = style_name
cell = ods_writer.date_cell(date, stylename=style_name)
date_s = date.isoformat()
assert cell.getAttribute('valuetype') == 'date'
assert cell.getAttribute('datevalue') == date_s
assert cell.getAttribute('stylename') == expect_style
assert get_text(cell) == date_s
@pytest.mark.parametrize('cell_source,style_name', testutil.combine_values(
NUMERIC_CELL_DATA,
XML_NAMES,
))
def test_ods_writer_float_cell(ods_writer, cell_source, style_name):
cell = ods_writer.float_cell(cell_source, stylename=style_name)
assert cell.getAttribute('valuetype') == 'float'
assert cell.getAttribute('stylename') == style_name
expected = str(cell_source)
assert cell.getAttribute('value') == expected
assert get_text(cell) == expected
def test_ods_writer_multiline_cell(ods_writer):
cell = ods_writer.multiline_cell(iter(STRING_CELL_DATA))
assert cell.getAttribute('valuetype') == 'string'
children = get_children(cell, odf.text.P)
for expected, child in itertools.zip_longest(STRING_CELL_DATA, children):
assert get_text(child) == expected
@pytest.mark.parametrize('cell_source,style_name', testutil.combine_values(
LINK_CELL_DATA,
XML_NAMES,
))
def test_ods_writer_multilink_singleton(ods_writer, cell_source, style_name):
cell = ods_writer.multilink_cell([cell_source], stylename=style_name)
assert cell.getAttribute('valuetype') == 'string'
assert cell.getAttribute('stylename') == style_name
try:
href, text = cell_source
except ValueError:
href = cell_source
text = None
anchor = get_child(cell, odf.text.A, type='simple', href=href)
assert get_text(anchor) == (text or '')
def test_ods_writer_multilink_cell(ods_writer):
cell = ods_writer.multilink_cell(iter(LINK_CELL_DATA))
assert cell.getAttribute('valuetype') == 'string'
children = get_children(cell, odf.text.A)
for source, child in itertools.zip_longest(LINK_CELL_DATA, children):
try:
href, text = source
except ValueError:
href = source
text = None
assert child.getAttribute('type') == 'simple'
assert child.getAttribute('href') == href
assert get_text(child) == (text or '')
@pytest.mark.parametrize('cell_source,style_name', testutil.combine_values(
STRING_CELL_DATA,
XML_NAMES,
))
def test_ods_writer_string_cell(ods_writer, cell_source, style_name):
cell = ods_writer.string_cell(cell_source, stylename=style_name)
assert cell.getAttribute('valuetype') == 'string'
assert cell.getAttribute('stylename') == style_name
assert get_text(cell) == str(cell_source)