"""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 . import datetime import io import itertools import pytest 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 from . import testutil from conservancy_beancount import rtutil from conservancy_beancount.reports import core 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], ] 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) class ODSTester(core.BaseODS[tuple, str]): def section_key(self, row): return row[0] @pytest.fixture def spreadsheet(): return BaseTester() @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 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 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_meta_links_cell(ods_writer): rt_client = testutil.RTClient() ods_writer.rt_wrapper = rtutil.RT(rt_client) rt_url = rt_client.DEFAULT_URL[:-10] meta_links = [ 'rt://ticket/1', 'rt://ticket/2/attachments/9', 'rt:1/5', 'Invoices/0123.pdf', ] cell = ods_writer.meta_links_cell(meta_links, stylename='meta1') assert cell.getAttribute('valuetype') == 'string' assert cell.getAttribute('stylename') == 'meta1' children = iter(get_children(cell, odf.text.A)) child = next(children) assert child.getAttribute('type') == 'simple' expect_url = f'{rt_url}/Ticket/Display.html?id=1' assert child.getAttribute('href') == expect_url assert get_text(child) == 'rt:1' child = next(children) assert child.getAttribute('type') == 'simple' expect_url = f'{rt_url}/Ticket/Display.html?id=2#txn-7' assert child.getAttribute('href') == expect_url assert get_text(child) == 'rt:2/9' child = next(children) assert child.getAttribute('type') == 'simple' expect_url = f'{rt_url}/Ticket/Attachment/1/5/photo.jpg' assert child.getAttribute('href') == expect_url assert get_text(child) == 'photo.jpg' child = next(children) assert child.getAttribute('type') == 'simple' expect_url = f'../{meta_links[3]}' assert child.getAttribute('href') == expect_url assert get_text(child) == '0123.pdf' 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 href) 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 href) @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)