|
| 1 | +"""Gherkin step implementations for ChartEx Phase-C features (issue #14): |
| 2 | +writers for Treemap/Sunburst/Funnel/BoxWhisker/Histogram/Pareto + replace_data. |
| 3 | +""" |
| 4 | + |
| 5 | +from __future__ import annotations |
| 6 | + |
| 7 | +import io |
| 8 | +import zipfile |
| 9 | + |
| 10 | +from behave import then, when |
| 11 | + |
| 12 | +from pptx import Presentation |
| 13 | +from pptx.chart.data import ( |
| 14 | + BoxWhiskerChartData, |
| 15 | + FunnelChartData, |
| 16 | + HistogramChartData, |
| 17 | + ParetoChartData, |
| 18 | + SunburstChartData, |
| 19 | + TreemapChartData, |
| 20 | + WaterfallChartData, |
| 21 | +) |
| 22 | +from pptx.enum.chart import XL_CHART_TYPE |
| 23 | +from pptx.util import Inches |
| 24 | + |
| 25 | + |
| 26 | +def _data_for(member_name): |
| 27 | + m = member_name.strip() |
| 28 | + if m == "WATERFALL": |
| 29 | + cd = WaterfallChartData() |
| 30 | + cd.categories = ["Q1", "Q2", "Total"] |
| 31 | + cd.add_series("R", [10, 20, 30], subtotals=[2]) |
| 32 | + return XL_CHART_TYPE.WATERFALL, cd |
| 33 | + if m in ("TREEMAP", "SUNBURST"): |
| 34 | + cls = TreemapChartData if m == "TREEMAP" else SunburstChartData |
| 35 | + cd = cls() |
| 36 | + cd.add_level(["A", "A", "B", "B"]) |
| 37 | + cd.add_level(["a1", "a2", "b1", "b2"]) |
| 38 | + cd.add_series("Rev", [40, 30, 20, 10]) |
| 39 | + return getattr(XL_CHART_TYPE, m), cd |
| 40 | + if m in ("FUNNEL", "BOX_WHISKER"): |
| 41 | + cls = FunnelChartData if m == "FUNNEL" else BoxWhiskerChartData |
| 42 | + cd = cls() |
| 43 | + cd.categories = ["Leads", "Qualified", "Won"] |
| 44 | + cd.add_series("Pipe", [100, 60, 25]) |
| 45 | + return getattr(XL_CHART_TYPE, m), cd |
| 46 | + if m == "HISTOGRAM": |
| 47 | + cd = HistogramChartData() |
| 48 | + cd.add_series("Scores", [55, 62, 71, 73, 88, 91, 64, 78], bin_count=4) |
| 49 | + return XL_CHART_TYPE.HISTOGRAM, cd |
| 50 | + if m == "PARETO": |
| 51 | + # PowerPoint Pareto is categorical (ground truth, issue #14). |
| 52 | + cd = ParetoChartData() |
| 53 | + cd.categories = ["Defect A", "Defect B", "Defect C", "Defect D"] |
| 54 | + cd.add_series("Count", [45, 30, 15, 10]) |
| 55 | + return XL_CHART_TYPE.PARETO, cd |
| 56 | + raise KeyError(m) |
| 57 | + |
| 58 | + |
| 59 | +def _cx_parts(blob): |
| 60 | + z = zipfile.ZipFile(io.BytesIO(blob)) |
| 61 | + return [n for n in z.namelist() if "chartEx" in n and n.endswith(".xml")] |
| 62 | + |
| 63 | + |
| 64 | +# when ==================================================== |
| 65 | + |
| 66 | + |
| 67 | +@when("I add a ChartEx {member_name} chart") |
| 68 | +def when_i_add_a_chartex_member_chart(context, member_name): |
| 69 | + ct, cd = _data_for(member_name) |
| 70 | + context.cx_member = member_name.strip() |
| 71 | + context.cx_data = cd |
| 72 | + context.cx_frame = context.slide.shapes.add_chart( |
| 73 | + ct, Inches(1), Inches(1), Inches(6), Inches(4), cd |
| 74 | + ) |
| 75 | + |
| 76 | + |
| 77 | +@when("I replace the ChartEx {member_name} data with a smaller dataset") |
| 78 | +def when_i_replace_chartex_data(context, member_name): |
| 79 | + _, new_cd = _data_for(member_name) |
| 80 | + # shrink it so the change is observable |
| 81 | + if hasattr(new_cd, "levels"): |
| 82 | + nd = type(new_cd)() |
| 83 | + nd.add_level(["Z", "Z"]) |
| 84 | + nd.add_level(["z1", "z2"]) |
| 85 | + nd.add_series("New", [7, 3]) |
| 86 | + elif hasattr(new_cd, "categories"): |
| 87 | + nd = type(new_cd)() |
| 88 | + nd.categories = ["Only"] |
| 89 | + nd.add_series("New", [42]) |
| 90 | + else: |
| 91 | + nd = type(new_cd)() |
| 92 | + nd.add_series("New", [1, 2, 3, 4], bin_count=2) |
| 93 | + context.cx_replacement = nd |
| 94 | + context.cx_frame.chartex.replace_data(nd) |
| 95 | + |
| 96 | + |
| 97 | +@when("I attempt to replace a {a_type} ChartEx with {b_type} data") |
| 98 | +def when_attempt_mismatch_replace(context, a_type, b_type): |
| 99 | + ct, cd = _data_for(a_type) |
| 100 | + frame = context.slide.shapes.add_chart(ct, Inches(1), Inches(1), Inches(6), Inches(4), cd) |
| 101 | + _, bad = _data_for(b_type) |
| 102 | + context.cx_replace_error = None |
| 103 | + try: |
| 104 | + frame.chartex.replace_data(bad) |
| 105 | + except ValueError as e: |
| 106 | + context.cx_replace_error = e |
| 107 | + |
| 108 | + |
| 109 | +# then ==================================================== |
| 110 | + |
| 111 | + |
| 112 | +@then("the slide has a ChartEx graphic frame") |
| 113 | +def then_slide_has_a_chartex_frame(context): |
| 114 | + frames = [s for s in context.slide.shapes if getattr(s, "has_chartex", False)] |
| 115 | + assert len(frames) >= 1, "no ChartEx graphic frame on slide" |
| 116 | + |
| 117 | + |
| 118 | +@then("the saved package contains a ChartEx part") |
| 119 | +def then_saved_package_contains_chartex_part(context): |
| 120 | + buf = io.BytesIO() |
| 121 | + context.prs.save(buf) |
| 122 | + assert _cx_parts(buf.getvalue()), "no chartEx part in saved package" |
| 123 | + |
| 124 | + |
| 125 | +@then("the ChartEx round-trips preserving its part") |
| 126 | +def then_chartex_round_trips(context): |
| 127 | + buf = io.BytesIO() |
| 128 | + context.prs.save(buf) |
| 129 | + before = sorted(_cx_parts(buf.getvalue())) |
| 130 | + prs2 = Presentation(io.BytesIO(buf.getvalue())) |
| 131 | + prs2.slides.add_slide(prs2.slide_layouts[0]) # unrelated edit (layout 0 always exists) |
| 132 | + buf2 = io.BytesIO() |
| 133 | + prs2.save(buf2) |
| 134 | + after = sorted(_cx_parts(buf2.getvalue())) |
| 135 | + assert before and before == after, f"{before!r} != {after!r}" |
| 136 | + rt = [s for s in prs2.slides[0].shapes if getattr(s, "has_chartex", False)] |
| 137 | + assert len(rt) == 1 |
| 138 | + |
| 139 | + |
| 140 | +@then("the reopened ChartEx reflects the replaced data") |
| 141 | +def then_reopened_reflects_replaced(context): |
| 142 | + buf = io.BytesIO() |
| 143 | + context.prs.save(buf) |
| 144 | + prs2 = Presentation(io.BytesIO(buf.getvalue())) |
| 145 | + z = zipfile.ZipFile(io.BytesIO(buf.getvalue())) |
| 146 | + name = next( |
| 147 | + n for n in z.namelist() if "chartEx" in n and n.endswith(".xml") and "_rels" not in n |
| 148 | + ) |
| 149 | + xml = z.read(name).decode() |
| 150 | + nd = context.cx_replacement |
| 151 | + token = "New" |
| 152 | + assert token in xml, "replaced series name not found after reopen" |
| 153 | + assert prs2 is not None |
| 154 | + |
| 155 | + |
| 156 | +@then("a chart-type mismatch error is raised") |
| 157 | +def then_mismatch_error_raised(context): |
| 158 | + assert context.cx_replace_error is not None |
| 159 | + assert "cannot change chart type" in str(context.cx_replace_error) |
0 commit comments