diff --git a/tests/test_chart_cells_golden.py b/tests/test_chart_cells_golden.py index de96947..aa2fe7e 100644 --- a/tests/test_chart_cells_golden.py +++ b/tests/test_chart_cells_golden.py @@ -33,6 +33,7 @@ class _GenerateArgs: _NS = { "a": "http://schemas.openxmlformats.org/drawingml/2006/main", "c": "http://schemas.openxmlformats.org/drawingml/2006/chart", + "p": "http://schemas.openxmlformats.org/presentationml/2006/main", } @@ -64,6 +65,13 @@ def _chart_xml(path: Path, chart_index: int = 1) -> Any: return etree.fromstring(xml_blob) +def _slide_xml(path: Path, slide_index: int = 1) -> Any: + slide_part = f"ppt/slides/slide{slide_index}.xml" + with zipfile.ZipFile(path) as archive: + xml_blob = archive.read(slide_part) + return etree.fromstring(xml_blob) + + def _chart_types(path: Path) -> list[int]: prs = Presentation(str(path)) return presentation_chart_types(prs) @@ -95,6 +103,15 @@ def _connector_count(path: Path) -> int: return count +def _find_text_shape_with_tokens(slide_xml: Any, tokens: set[str]) -> Any | None: + for shape in slide_xml.xpath(".//p:sp", namespaces=_NS): + text_values = shape.xpath(".//a:t/text()", namespaces=_NS) + text_tokens = {str(value).strip() for value in text_values} + if tokens.issubset(text_tokens): + return shape + return None + + def test_horizontal_chart_cells_keep_manual_offsets_and_format(tmp_path: Path) -> None: """Regression: horizontal chart-cell labels keep gap + custom format.""" yaml_text = """ @@ -170,6 +187,48 @@ def test_horizontal_chart_cells_zero_decimal_python_format_maps_to_excel_numeric assert num_formats == ["0", "0"] +def test_horizontal_chart_cells_keep_distinct_offsets_for_negative_and_long_values( + tmp_path: Path, +) -> None: + """Regression: offsets remain positive and scale with long/negative labels.""" + yaml_text = """ +title: Horizontal offset stress regression +charts: + rev: + dir: horizontal + values: [-1234.5, 98765.43, -44.0, 1200000.0] + format: "{:,.1f}" + color: accent1 + +table: + rows: 4 + cols: 1 + has_col_header: false + cells: + - [rev-1] + - [rev-2] + - [rev-3] + - [rev-4] +""" + + output_path = _run_generate(tmp_path, "horizontal-offset-stress-regression", yaml_text) + chart_xml = _chart_xml(output_path) + labels = chart_xml.xpath(".//c:barChart/c:ser/c:dLbls/c:dLbl", namespaces=_NS) + assert len(labels) == 4 + + x_offsets: list[float] = [] + for label in labels: + assert label.xpath("./c:dLblPos/@val", namespaces=_NS) == ["ctr"] + x_values = label.xpath("./c:layout/c:manualLayout/c:x/@val", namespaces=_NS) + assert len(x_values) == 1 + x_offset = float(x_values[0]) + assert x_offset > 0 + x_offsets.append(x_offset) + + assert max(x_offsets) - min(x_offsets) > 0.2 + assert x_offsets[3] == max(x_offsets) + + def test_waterfall_chart_cells_keep_connectors_and_formatted_labels(tmp_path: Path) -> None: """Regression: waterfall chart-cells keep overlays + connector lines.""" yaml_text = """ @@ -207,3 +266,70 @@ def test_waterfall_chart_cells_keep_connectors_and_formatted_labels(tmp_path: Pa assert {"1", "2", "3", "4"}.isdisjoint(set(texts)) assert _connector_count(output_path) >= 3 + + +def test_grouped_chart_cells_render_sub_headers_on_new_line_with_body_color(tmp_path: Path) -> None: + """Regression: grouped chart-cell headers keep ``sub`` on a styled second line.""" + yaml_text = """ +title: Grouped chart cells with sub +charts: + wf: + type: waterfall + dir: horizontal + values: [954, 13, -45, 1209] + totals: [1, 4] + decreases: [3] + format: "{:,.0f}" + connector: true + +table: + cols: 2 + has_col_header: false + row_groups: + - header: + text: EBITDAaL bridge + sub: (€,m) + rows: + - ["Start", wf-1] + - ["Growth", wf-2] + - header: + text: Cost bridge + sub: (€,m) + rows: + - ["Costs", wf-3] + - ["End", wf-4] +""" + + output_path = _run_generate(tmp_path, "grouped-chart-cells-sub-regression", yaml_text) + + assert _connector_count(output_path) >= 3 + + texts = _non_placeholder_texts(output_path) + assert any("EBITDAaL bridge" in text for text in texts) + assert any("Cost bridge" in text for text in texts) + assert {"Start", "Growth", "Costs", "End"}.issubset(set(texts)) + + slide_xml = _slide_xml(output_path) + header_shape = _find_text_shape_with_tokens(slide_xml, {"EBITDAaL bridge", "(€,m)"}) + assert header_shape is not None + + cost_shape = _find_text_shape_with_tokens(slide_xml, {"Cost bridge", "(€,m)"}) + assert cost_shape is not None + + for shape in [header_shape, cost_shape]: + run_texts = shape.xpath("./p:txBody/a:p/a:r/a:t/text()", namespaces=_NS) + assert len(run_texts) >= 2 + assert run_texts[1] == "(€,m)" + + assert shape.xpath("./p:txBody/a:p/a:br", namespaces=_NS) + + main_bold = shape.xpath("./p:txBody/a:p/a:r[1]/a:rPr/@b", namespaces=_NS) + sub_bold = shape.xpath("./p:txBody/a:p/a:r[2]/a:rPr/@b", namespaces=_NS) + assert main_bold == ["1"] + assert sub_bold != ["1"] + + sub_color = shape.xpath( + "./p:txBody/a:p/a:r[2]/a:rPr/a:solidFill/a:schemeClr/@val", + namespaces=_NS, + ) + assert sub_color == ["tx1"] diff --git a/tests/test_chart_engine_smoke.py b/tests/test_chart_engine_smoke.py index 2bf5511..be7d1a9 100644 --- a/tests/test_chart_engine_smoke.py +++ b/tests/test_chart_engine_smoke.py @@ -1,9 +1,11 @@ from __future__ import annotations +import zipfile from pathlib import Path from pptx import Presentation from pptx.enum.chart import XL_CHART_TYPE +from pptx.enum.shapes import MSO_SHAPE_TYPE from clean_slides.chart_engine.builder import build_chart from clean_slides.pptx_access import ( @@ -33,6 +35,47 @@ def _shape_texts(path: Path) -> list[str]: return texts +def _pptx_part(path: Path, part_name: str) -> bytes: + with zipfile.ZipFile(path) as archive: + return archive.read(part_name) + + +def _blank_auto_shape_count(path: Path) -> int: + prs = Presentation(str(path)) + count = 0 + for shape in iter_shapes(prs.slides[0]): + shape_type = getattr(shape, "shape_type", None) + if shape_type != MSO_SHAPE_TYPE.AUTO_SHAPE: + continue + + text = shape_text_frame_text(shape) + if text is not None and text.strip(): + continue + + count += 1 + return count + + +def _waterfall_overlay_spec(connector_style: str, connector_dash_style: str) -> dict[str, object]: + return { + "type": "waterfall", + "categories": ["Start", "A", "B", "C", "End"], + "series": [ + {"name": "Values", "values": [100, 25, -15, 10, 120], "color": "accent1"}, + ], + "show_data_labels": True, + "add_overlay_labels": True, + "waterfall": { + "orientation": "horizontal", + "total_categories": ["Start", "End"], + "decrease_categories": ["B"], + "connector_value": "totals", + "connector_style": connector_style, + "connector_dash_style": connector_dash_style, + }, + } + + def test_build_chart_bar_with_overlays_smoke(tmp_path: Path) -> None: output_path = tmp_path / "bar-smoke.pptx" @@ -117,3 +160,71 @@ def test_build_chart_defers_chart_template_copy_replacement(tmp_path: Path) -> N replacement = replacements[0] assert replacement.template_path == template_path assert replacement.chart_part.startswith("ppt/charts/chart") + + +def test_build_chart_applies_chart_template_copy_immediately_when_saving(tmp_path: Path) -> None: + template_path = tmp_path / "template-chart.pptx" + template_spec: dict[str, object] = { + "type": "clustered", + "categories": ["A", "B"], + "series": [{"name": "S1", "values": [1, 2], "color": "accent1"}], + "show_data_labels": True, + "data_labels": {"format": "0.00", "font_size": 18}, + } + build_chart(Presentation(), template_spec, template_path) + + output_path = tmp_path / "immediate-template-copy.pptx" + target_spec: dict[str, object] = { + "type": "clustered", + "categories": ["A", "B"], + "series": [{"name": "S1", "values": [9, 8], "color": "accent2"}], + "show_data_labels": True, + "data_labels": {"format": "0", "font_size": 10}, + "bar": { + "chart_template": str(template_path), + "chart_template_copy": True, + }, + } + + replacements = build_chart( + Presentation(), + target_spec, + output_path, + save=True, + defer_template_copy=False, + ) + + assert replacements == [] + + template_chart_xml = _pptx_part(template_path, "ppt/charts/chart1.xml") + output_chart_xml = _pptx_part(output_path, "ppt/charts/chart1.xml") + assert output_chart_xml == template_chart_xml + + template_chart_rels = _pptx_part(template_path, "ppt/charts/_rels/chart1.xml.rels") + output_chart_rels = _pptx_part(output_path, "ppt/charts/_rels/chart1.xml.rels") + assert output_chart_rels == template_chart_rels + + +def test_build_chart_waterfall_connector_styles_change_overlay_segment_counts( + tmp_path: Path, +) -> None: + gap_solid_path = tmp_path / "waterfall-gap-solid.pptx" + step_solid_path = tmp_path / "waterfall-step-solid.pptx" + gap_dash_path = tmp_path / "waterfall-gap-dash.pptx" + gap_dot_path = tmp_path / "waterfall-gap-dot.pptx" + + build_chart(Presentation(), _waterfall_overlay_spec("gap", "solid"), gap_solid_path) + build_chart(Presentation(), _waterfall_overlay_spec("step", "solid"), step_solid_path) + build_chart(Presentation(), _waterfall_overlay_spec("gap", "long_dash"), gap_dash_path) + build_chart(Presentation(), _waterfall_overlay_spec("gap", "dot"), gap_dot_path) + + gap_solid_blank_shapes = _blank_auto_shape_count(gap_solid_path) + step_solid_blank_shapes = _blank_auto_shape_count(step_solid_path) + gap_dash_blank_shapes = _blank_auto_shape_count(gap_dash_path) + gap_dot_blank_shapes = _blank_auto_shape_count(gap_dot_path) + + assert _chart_types(gap_solid_path) == [int(XL_CHART_TYPE.BAR_STACKED)] + + assert step_solid_blank_shapes > gap_solid_blank_shapes + assert gap_dash_blank_shapes > gap_solid_blank_shapes + assert gap_dot_blank_shapes > gap_dash_blank_shapes