Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions tests/test_chart_cells_golden.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}


Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 = """
Expand Down Expand Up @@ -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 = """
Expand Down Expand Up @@ -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"]
111 changes: 111 additions & 0 deletions tests/test_chart_engine_smoke.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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