tests/test_powerpoint_skill.py
tests/test_powerpoint_skill.pyBrowse 8 files
5,467 tokens
19,524 bytes
Token encoding: o200k_base
Snapshot 24fd22b
← Back to SKILL.md
1"""End-to-end tests for the powerpoint skill helper scripts.2 3Runs entirely offline. Exercises create, read, template-fill (with4non-ASCII values), and edit (text + chart data + remove/move slide).5"""6import json7import os8import subprocess9import sys10 11import pytest12 13SKILL = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))14SCRIPTS = os.path.join(SKILL, "scripts")15 16 17def run(script, *args):18 env = dict(os.environ, LC_ALL="C", PYTHONIOENCODING="utf-8")19 proc = subprocess.run(20 [sys.executable, os.path.join(SCRIPTS, script), *args],21 capture_output=True, text=True, encoding="utf-8", env=env)22 assert proc.returncode == 0, f"{script} failed: {proc.stderr}"23 return json.loads(proc.stdout)24 25 26def write_json(path, obj):27 with open(path, "w", encoding="utf-8") as fh:28 json.dump(obj, fh, ensure_ascii=False)29 30 31@pytest.fixture(scope="module")32def workdir(tmp_path_factory):33 return tmp_path_factory.mktemp("pptx")34 35 36@pytest.fixture(scope="module")37def sample_png(workdir):38 # 1x1 red PNG, hardcoded bytes -> no Pillow dependency.39 import base6440 data = base64.b64decode(41 "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4"42 "z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==")43 path = workdir / "red.png"44 path.write_bytes(data)45 return str(path)46 47 48@pytest.fixture(scope="module")49def deck(workdir, sample_png):50 """Create a deck exercising every create feature."""51 spec = {52 "slide_size": "16:9",53 "slides": [54 {"layout": "title", "title": "Annual Review",55 "subtitle": "Fiscal year 2026",56 "notes": "Welcome the audience."},57 {"layout": "title_content", "title": "Agenda",58 "bullets": [59 "Overview",60 {"text": "Details", "level": 1, "bold": True,61 "size": 20, "color": "CC0000", "font": "Arial"},62 {"text": "Deep dive", "level": 2, "italic": True},63 ],64 "notes": "Keep this under two minutes."},65 {"layout": "blank", "title": None,66 "images": [{"path": sample_png, "left": 0.5, "top": 0.5,67 "width": 2, "height": 2}],68 "tables": [{"left": 3, "top": 1, "width": 6, "height": 2,69 "rows": [["Region", "Sales"],70 ["North", "120"], ["South", "80"]]}],71 "shapes": [{"type": "rounded_rectangle", "left": 10,72 "top": 1, "width": 2.5, "height": 1,73 "fill": "4472C4", "text": "Callout",74 "text_color": "FFFFFF"}]},75 {"layout": "title_only", "title": "Charts",76 "charts": [77 {"type": "bar", "left": 0.5, "top": 1.5, "width": 4,78 "height": 3, "title": "Sales",79 "categories": ["Q1", "Q2"],80 "series": {"North": [10, 20], "South": [7, 13]}},81 {"type": "line", "left": 4.7, "top": 1.5, "width": 4,82 "height": 3, "categories": ["Jan", "Feb", "Mar"],83 "series": {"Trend": [1, 3, 2]}},84 {"type": "pie", "left": 8.9, "top": 1.5, "width": 4,85 "height": 3, "categories": ["A", "B", "C"],86 "series": {"Share": [50, 30, 20]}}]},87 ],88 }89 spec_path = workdir / "deck.json"90 write_json(spec_path, spec)91 out = workdir / "deck.pptx"92 result = run("pptx_create.py", str(spec_path), str(out))93 assert result["ok"] and result["slides"] == 494 return str(out)95 96 97def test_create_and_outline(deck):98 outline = run("pptx_read.py", deck, "--outline")99 assert outline["slide_count"] == 4100 assert outline["slide_size_inches"][0] == pytest.approx(13.333, abs=0.01)101 s0, s1, s2, s3 = outline["slides"]102 assert "Annual Review" in s0["texts"]103 assert "Fiscal year 2026" in s0["texts"]104 assert s0["notes"] == "Welcome the audience."105 # bullets present on slide 1106 assert any("Deep dive" in t for t in s1["texts"])107 # table content108 assert s2["tables"][0][0] == ["Region", "Sales"]109 assert s2["tables"][0][2] == ["South", "80"]110 # image inventory + shape text111 assert len(s2["images"]) == 1 and s2["images"][0]["ext"] == "png"112 assert "Callout" in s2["texts"]113 # three charts with correct data114 assert len(s3["charts"]) == 3115 bar = s3["charts"][0]116 assert bar["categories"] == ["Q1", "Q2"]117 north = next(s for s in bar["series"] if s["name"] == "North")118 assert north["values"] == [10.0, 20.0]119 120 121def test_create_43_size(workdir):122 spec_path = workdir / "small.json"123 write_json(spec_path, {"slide_size": "4:3",124 "slides": [{"layout": "title", "title": "T"}]})125 out = workdir / "small.pptx"126 run("pptx_create.py", str(spec_path), str(out))127 outline = run("pptx_read.py", str(out))128 assert outline["slide_size_inches"] == [10.0, 7.5]129 130 131def test_notes_mode(deck):132 result = run("pptx_read.py", deck, "--notes")133 assert result["notes"][1] == "Keep this under two minutes."134 135 136def test_image_export(deck, workdir):137 out_dir = workdir / "exported"138 result = run("pptx_read.py", deck, "--images", str(out_dir))139 assert len(result["exported"]) == 1140 exported = result["exported"][0]141 assert os.path.getsize(exported) > 0142 with open(exported, "rb") as fh:143 assert fh.read(8) == b"\x89PNG\r\n\x1a\n"144 145 146def test_template_fill_non_ascii(workdir):147 """Template token fill with non-ASCII values, forced under LC_ALL=C."""148 tpl_spec = workdir / "tpl.json"149 write_json(tpl_spec, {"slides": [150 {"layout": "title", "title": "{{city}} report",151 "subtitle": "Prepared by {{author}}",152 "notes": "Deck for {{city}}."},153 {"layout": "title_content", "title": "Data",154 "tables": [{"rows": [["Site", "{{city}}"]]}]},155 ]})156 template = workdir / "template.pptx"157 run("pptx_create.py", str(tpl_spec), str(template))158 159 values = workdir / "values.json"160 write_json(values, {"city": "Z\u00fcrich \u2014 \u2018Bericht\u2019",161 "author": "Beispiel GmbH"})162 out = workdir / "filled.pptx"163 result = run("pptx_from_template.py", str(template), str(out),164 "--values", str(values))165 assert result["tokens_filled"] == 4166 167 outline = run("pptx_read.py", str(out))168 expected = "Z\u00fcrich \u2014 \u2018Bericht\u2019"169 assert f"{expected} report" in outline["slides"][0]["texts"]170 assert outline["slides"][0]["notes"] == f"Deck for {expected}."171 assert outline["slides"][1]["tables"][0][0][1] == expected172 173 174def test_template_add_slides(workdir):175 template = workdir / "template.pptx"176 add_spec = workdir / "add.json"177 write_json(add_spec, {"slides": [178 {"layout": 1, "title": "Appended", "bullets": ["from layout"],179 "notes": "appended slide"}]})180 out = workdir / "appended.pptx"181 run("pptx_from_template.py", str(template), str(out),182 "--add-slides", str(add_spec))183 outline = run("pptx_read.py", str(out))184 assert outline["slide_count"] == 3185 assert "Appended" in outline["slides"][2]["texts"]186 187 188def test_edit_replace_text(deck, workdir):189 edited = workdir / "edited.pptx"190 result = run("pptx_edit.py", deck, "--output", str(edited),191 "--replace-text", "Annual Review", "Semi-Annual Review",192 "--replace-text", "South", "West")193 assert result["replacements"] == 2 # title + table cell194 outline = run("pptx_read.py", str(edited))195 assert "Semi-Annual Review" in outline["slides"][0]["texts"]196 assert outline["slides"][2]["tables"][0][2][0] == "West"197 # formatting survives a run-level replace: red bold bullet untouched198 assert any("Deep dive" in t for t in outline["slides"][1]["texts"])199 200 201def test_edit_chart_data(deck, workdir):202 spec = workdir / "chart_update.json"203 write_json(spec, {"slide": 3, "chart": 0,204 "categories": ["Q3", "Q4"],205 "series": {"North": [30, 40], "South": [21, 34]}})206 edited = workdir / "chart_edited.pptx"207 run("pptx_edit.py", deck, "--output", str(edited),208 "--chart-data", str(spec))209 outline = run("pptx_read.py", str(edited))210 bar = outline["slides"][3]["charts"][0]211 assert bar["categories"] == ["Q3", "Q4"]212 north = next(s for s in bar["series"] if s["name"] == "North")213 assert north["values"] == [30.0, 40.0]214 215 216def test_edit_remove_and_move_slide(deck, workdir):217 edited = workdir / "reordered.pptx"218 run("pptx_edit.py", deck, "--output", str(edited),219 "--remove-slide", "2", "--move-slide", "2", "0")220 outline = run("pptx_read.py", str(edited))221 assert outline["slide_count"] == 3222 # charts slide (was index 3, then 2 after removal) moved to front223 assert len(outline["slides"][0]["charts"]) == 3224 assert "Annual Review" in outline["slides"][1]["texts"]225 226 227def test_edit_swap_image(deck, workdir):228 import base64229 blue = base64.b64decode(230 "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNg"231 "YPj/HwADAgH/p5UronAAAAAASUVORK5CYII=")232 blue_path = workdir / "blue.png"233 blue_path.write_bytes(blue)234 outline = run("pptx_read.py", deck)235 # find picture shape name via python-pptx directly236 sys.path.insert(0, SCRIPTS)237 from pptx import Presentation238 from pptx.enum.shapes import MSO_SHAPE_TYPE239 prs = Presentation(deck)240 name = next(s.name for s in prs.slides[2].shapes241 if s.shape_type == MSO_SHAPE_TYPE.PICTURE)242 edited = workdir / "swapped.pptx"243 run("pptx_edit.py", deck, "--output", str(edited),244 "--swap-image", "2", name, str(blue_path))245 out_dir = workdir / "swapped_images"246 result = run("pptx_read.py", str(edited), "--images", str(out_dir))247 with open(result["exported"][0], "rb") as fh:248 assert fh.read() == blue249 250 251def test_help_flags():252 for script in ("pptx_create.py", "pptx_read.py", "pptx_edit.py",253 "pptx_from_template.py"):254 proc = subprocess.run(255 [sys.executable, os.path.join(SCRIPTS, script), "--help"],256 capture_output=True, text=True, encoding="utf-8")257 assert proc.returncode == 0 and "usage" in proc.stdout.lower()258 259 260# ---------------------------------------------------------------------------261# parity features: render, run-merge replace, chart ops, duplication,262# polish (background/hyperlink/slide-number/footer), notes editing263# ---------------------------------------------------------------------------264 265def run_raw(script, *args):266 env = dict(os.environ, LC_ALL="C", PYTHONIOENCODING="utf-8")267 return subprocess.run(268 [sys.executable, os.path.join(SCRIPTS, script), *args],269 capture_output=True, text=True, encoding="utf-8", env=env)270 271 272def test_render_all_slides(workdir):273 import shutil274 spec_path = workdir / "render_spec.json"275 write_json(spec_path, {"slides": [276 {"layout": "title", "title": "One"},277 {"layout": "title_content", "title": "Two", "bullets": ["b"]},278 {"layout": "blank"}]})279 deck3 = workdir / "render_me.pptx"280 run("pptx_create.py", str(spec_path), str(deck3))281 out_dir = workdir / "render_out"282 result = run("pptx_render.py", str(deck3), "--outdir", str(out_dir))283 have_tools = shutil.which("soffice") and (284 shutil.which("pdftoppm") or shutil.which("pdftocairo"))285 if have_tools:286 assert result["rendered"] is True287 assert len(result["files"]) == 3288 for png in result["files"]:289 with open(png, "rb") as fh:290 assert fh.read(8) == b"\x89PNG\r\n\x1a\n"291 else:292 assert result["rendered"] is False293 assert result["missing"]294 assert "guidance" in result295 296 297def test_replace_across_identically_formatted_runs(workdir):298 """A match split mid-word into equal-format runs keeps formatting."""299 import copy as cp300 from pptx import Presentation301 from pptx.dml.color import RGBColor302 from pptx.util import Inches, Pt303 304 prs = Presentation()305 slide = prs.slides.add_slide(prs.slide_layouts[6])306 box = slide.shapes.add_textbox(Inches(1), Inches(1),307 Inches(6), Inches(1))308 para = box.text_frame.paragraphs[0]309 run_obj = para.add_run()310 run_obj.text = "Say TotalWord now"311 run_obj.font.bold = True312 run_obj.font.size = Pt(20)313 run_obj.font.color.rgb = RGBColor.from_string("CC0000")314 # simulate PowerPoint's spell-check split: same rPr, seam mid-match315 second = cp.deepcopy(run_obj._r)316 run_obj._r.addnext(second)317 run_obj.text = "Say Total"318 para.runs[1].text = "Word now"319 path = workdir / "split_runs.pptx"320 prs.save(str(path))321 322 edited = workdir / "split_runs_edited.pptx"323 result = run("pptx_edit.py", str(path), "--output", str(edited),324 "--replace-text", "TotalWord", "MergedWord")325 assert result["replacements"] == 1326 327 prs2 = Presentation(str(edited))328 para2 = prs2.slides[0].shapes[0].text_frame.paragraphs[0]329 assert "".join(r.text for r in para2.runs) == "Say MergedWord now"330 for r in para2.runs:331 assert r.font.bold is True332 assert r.font.size == Pt(20)333 assert str(r.font.color.rgb) == "CC0000"334 335 336def test_chart_surgical_ops(deck, workdir):337 from pptx import Presentation338 spec = workdir / "chart_ops.json"339 write_json(spec, {"slide": 3, "chart": 0, "ops": [340 {"op": "update_series", "name": "North", "values": [99, 88]},341 {"op": "add_series", "name": "East", "values": [1, 2]},342 {"op": "remove_series", "name": "South"},343 {"op": "rename_category", "from": "Q1", "to": "Q1 FY26"},344 {"op": "set_title", "title": "Updated Sales"}]})345 edited = workdir / "chart_ops.pptx"346 run("pptx_edit.py", deck, "--output", str(edited),347 "--chart-data", str(spec))348 outline = run("pptx_read.py", str(edited))349 bar = outline["slides"][3]["charts"][0]350 assert bar["categories"] == ["Q1 FY26", "Q2"]351 names = [s["name"] for s in bar["series"]]352 assert "South" not in names and "East" in names353 north = next(s for s in bar["series"] if s["name"] == "North")354 assert north["values"] == [99.0, 88.0]355 east = next(s for s in bar["series"] if s["name"] == "East")356 assert east["values"] == [1.0, 2.0]357 chart = next(s.chart for s in Presentation(str(edited)).slides[3].shapes358 if s.has_chart)359 assert chart.chart_title.text_frame.text == "Updated Sales"360 361 362def test_chart_op_unknown_series_fails(deck, workdir):363 spec = workdir / "chart_bad.json"364 write_json(spec, {"slide": 3, "chart": 0, "ops": [365 {"op": "update_series", "name": "Nowhere", "values": [1, 2]}]})366 proc = run_raw("pptx_edit.py", deck, "--output",367 str(workdir / "never.pptx"), "--chart-data", str(spec))368 assert proc.returncode != 0369 assert "Nowhere" in proc.stderr370 371 372def test_duplicate_image_slide_is_independent(deck, workdir):373 import base64374 dup = workdir / "dup.pptx"375 result = run("pptx_edit.py", deck, "--output", str(dup),376 "--duplicate-slide", "2")377 assert result["duplicated_to"] == 4378 outline = run("pptx_read.py", str(dup)) # re-opens cleanly379 assert outline["slide_count"] == 5380 s4 = outline["slides"][4]381 assert len(s4["images"]) == 1382 assert s4["tables"][0][0] == ["Region", "Sales"]383 assert "Callout" in s4["texts"]384 385 # independence: swap the image on the COPY, original stays red386 blue = base64.b64decode(387 "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNg"388 "YPj/HwADAgH/p5UronAAAAAASUVORK5CYII=")389 blue_path = workdir / "blue2.png"390 blue_path.write_bytes(blue)391 from pptx import Presentation392 from pptx.enum.shapes import MSO_SHAPE_TYPE393 prs = Presentation(str(dup))394 name = next(s.name for s in prs.slides[4].shapes395 if s.shape_type == MSO_SHAPE_TYPE.PICTURE)396 run("pptx_edit.py", str(dup), "--swap-image", "4", name,397 str(blue_path))398 prs = Presentation(str(dup))399 orig = next(s for s in prs.slides[2].shapes400 if s.shape_type == MSO_SHAPE_TYPE.PICTURE)401 copy_pic = next(s for s in prs.slides[4].shapes402 if s.shape_type == MSO_SHAPE_TYPE.PICTURE)403 assert copy_pic.image.blob == blue404 assert orig.image.blob != blue405 406 407def test_duplicate_chart_slide_refused(deck, workdir):408 proc = run_raw("pptx_edit.py", deck, "--output",409 str(workdir / "never2.pptx"), "--duplicate-slide", "3")410 assert proc.returncode != 0411 assert "chart" in proc.stderr.lower()412 413 414def test_create_polish_features(workdir):415 from pptx import Presentation416 spec_path = workdir / "polish.json"417 write_json(spec_path, {"slides": [418 {"layout": "title_content", "title": "Polished",419 "background": "112233", "footer": "Confidential draft",420 "slide_number": True,421 "bullets": [{"text": "Visit example",422 "link": "https://example.com/info"}]}]})423 out = workdir / "polish.pptx"424 run("pptx_create.py", str(spec_path), str(out))425 prs = Presentation(str(out))426 slide = prs.slides[0]427 assert str(slide.background.fill.fore_color.rgb) == "112233"428 ph_idx = [ph.placeholder_format.idx for ph in slide.placeholders]429 assert 12 in ph_idx # slide number enabled430 footer = next(ph for ph in slide.placeholders431 if ph.placeholder_format.idx == 11)432 assert footer.text_frame.text == "Confidential draft"433 link_runs = [r for sh in slide.shapes if sh.has_text_frame434 for p in sh.text_frame.paragraphs for r in p.runs435 if r.hyperlink.address]436 assert link_runs[0].hyperlink.address == "https://example.com/info"437 438 439def test_edit_polish_features(deck, workdir):440 from pptx import Presentation441 edited = workdir / "polish_edit.pptx"442 result = run("pptx_edit.py", deck, "--output", str(edited),443 "--set-background", "0", "004400",444 "--hyperlink", "1", "Overview", "https://example.com/x",445 "--enable-slide-number", "1",446 "--set-footer", "1", "Footer via edit")447 assert result["background_set"] and result["footer_set"]448 assert result["hyperlinked_runs"] == 1449 assert result["slide_number_enabled"] is True450 prs = Presentation(str(edited))451 assert str(prs.slides[0].background.fill.fore_color.rgb) == "004400"452 s1 = prs.slides[1]453 assert any(ph.placeholder_format.idx == 12 for ph in s1.placeholders)454 footer = next(ph for ph in s1.placeholders455 if ph.placeholder_format.idx == 11)456 assert footer.text_frame.text == "Footer via edit"457 links = [r.hyperlink.address for sh in s1.shapes if sh.has_text_frame458 for p in sh.text_frame.paragraphs for r in p.runs459 if r.hyperlink.address]460 assert links == ["https://example.com/x"]461 462 463def test_set_and_append_notes(deck, workdir):464 edited = workdir / "notes_edit.pptx"465 result = run("pptx_edit.py", deck, "--output", str(edited),466 "--set-notes", "0", "Fresh notes")467 assert result["notes_set"]468 run("pptx_edit.py", str(edited), "--append-notes", "0", "Second line")469 notes = run("pptx_read.py", str(edited), "--notes")470 assert notes["notes"][0] == "Fresh notes\nSecond line"471 472 473def test_render_help_flag():474 proc = run_raw("pptx_render.py", "--help")475 assert proc.returncode == 0 and "usage" in proc.stdout.lower()476