"""Tests for the pptx skill's package editors.
The shared `office/` tree is covered by the contract. What is specific here is
slide bookkeeping: `add_slide` has to pick a free slide number and a legal
slide id, and `clean` has to delete orphaned parts without deleting anything
the presentation still references.
`clean` is the dangerous one -- it removes files -- so the tests concentrate on
its refusal path. A package whose `sldIdLst` cannot be read must make the tool
stop rather than conclude that every slide is an orphan and delete the deck.
"""
from __future__ import annotations
import re
import sys
import tempfile
import unittest
from pathlib import Path
import pytest
import skill_contract
SKILL_ROOT = Path(__file__).resolve().parents[2] / "skills" / "pptx"
SCRIPTS = SKILL_ROOT / "scripts"
sys.path.insert(0, str(SCRIPTS))
pytest.importorskip("defusedxml", reason="pptx scripts need defusedxml")
import add_slide # noqa: E402
import clean # noqa: E402
OfficeTests = skill_contract.office.office_test_case(SKILL_ROOT)
CliHelpTests = skill_contract.cli.help_test_case(SKILL_ROOT)
RELATIONSHIPS_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
SLIDE_TYPE = (
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide"
)
class Deck:
"""A minimal unpacked .pptx directory with `count` registered slides."""
def __init__(self, root: Path, count: int, *, orphans: int = 0) -> None:
self.root = root
self.slides = root / "ppt" / "slides"
self.slides.mkdir(parents=True)
(self.slides / "_rels").mkdir()
(root / "ppt" / "_rels").mkdir(parents=True)
total = count + orphans
for number in range(1, total + 1):
(self.slides / f"slide{number}.xml").write_text("", encoding="utf-8")
(self.slides / "_rels" / f"slide{number}.xml.rels").write_text(
f'', encoding="utf-8"
)
# Only the first `count` slides are registered in the presentation.
relationships = "".join(
f''
for n in range(1, total + 1)
)
(root / "ppt" / "_rels" / "presentation.xml.rels").write_text(
f'{relationships}',
encoding="utf-8",
)
slide_ids = "".join(
f'' for n in range(1, count + 1)
)
(root / "ppt" / "presentation.xml").write_text(
''
f"{slide_ids}",
encoding="utf-8",
)
class DeckTestCase(unittest.TestCase):
def setUp(self) -> None:
self._temporary = tempfile.TemporaryDirectory()
self.addCleanup(self._temporary.cleanup)
self.root = Path(self._temporary.name)
class SlideNumberingTests(DeckTestCase):
def test_the_first_slide_in_an_empty_deck_is_one(self) -> None:
slides = self.root / "slides"
slides.mkdir()
self.assertEqual(add_slide.get_next_slide_number(slides), 1)
def test_the_next_number_follows_the_highest_existing_slide(self) -> None:
slides = self.root / "slides"
slides.mkdir()
for number in (1, 2, 7):
(slides / f"slide{number}.xml").write_text("", encoding="utf-8")
# 8, not 4: reusing a gap would collide with a relationship target.
self.assertEqual(add_slide.get_next_slide_number(slides), 8)
def test_files_that_are_not_slides_are_ignored(self) -> None:
slides = self.root / "slides"
slides.mkdir()
(slides / "slide1.xml").write_text("", encoding="utf-8")
(slides / "slideLayout9.xml").write_text("", encoding="utf-8")
(slides / "notes.xml").write_text("", encoding="utf-8")
self.assertEqual(add_slide.get_next_slide_number(slides), 2)
class SourceParsingTests(unittest.TestCase):
def test_a_layout_filename_is_recognised(self) -> None:
self.assertEqual(
add_slide.parse_source("slideLayout2.xml"), ("layout", "slideLayout2.xml")
)
def test_anything_else_is_treated_as_a_slide_source(self) -> None:
for source in ("deck.pptx", "slideLayout2", "layout.xml", ""):
with self.subTest(source=source):
self.assertEqual(add_slide.parse_source(source), ("slide", None))
class SlideIdTests(unittest.TestCase):
def test_the_id_range_matches_the_ooxml_specification(self) -> None:
# Outside 256..2147483647 PowerPoint refuses to open the file.
self.assertEqual(add_slide.SLIDE_ID_MIN, 256)
self.assertEqual(add_slide.SLIDE_ID_MAX, 2147483647)
def test_the_minimal_slide_xml_is_well_formed(self) -> None:
import defusedxml.ElementTree as ElementTree
ElementTree.fromstring(add_slide.MINIMAL_SLIDE_XML)
def test_shared_part_types_are_the_documented_set(self) -> None:
self.assertEqual(
set(add_slide.SHARED_PART_TYPES),
{"chart", "diagramData", "oleObject", "package"},
)
def test_the_relationship_pattern_matches_both_element_forms(self) -> None:
both = (
''
''
)
self.assertEqual(len(add_slide.RELATIONSHIP_RE.findall(both)), 2)
class OrphanDetectionTests(DeckTestCase):
def test_registered_slides_are_reported_as_referenced(self) -> None:
Deck(self.root, count=3)
self.assertEqual(
clean.get_slides_in_sldidlst(self.root),
{"slide1.xml", "slide2.xml", "slide3.xml"},
)
def test_an_unregistered_slide_is_not_reported_as_referenced(self) -> None:
Deck(self.root, count=2, orphans=1)
referenced = clean.get_slides_in_sldidlst(self.root)
self.assertEqual(referenced, {"slide1.xml", "slide2.xml"})
def test_only_the_orphan_is_removed(self) -> None:
deck = Deck(self.root, count=2, orphans=1)
removed = clean.remove_orphaned_slides(self.root)
# The slide and its relationship part are both reported.
self.assertEqual(
sorted(Path(name).name for name in removed),
["slide3.xml", "slide3.xml.rels"],
)
self.assertTrue((deck.slides / "slide1.xml").is_file())
self.assertTrue((deck.slides / "slide2.xml").is_file())
self.assertFalse((deck.slides / "slide3.xml").is_file())
def test_a_deck_with_no_orphans_loses_nothing(self) -> None:
deck = Deck(self.root, count=3)
self.assertEqual(clean.remove_orphaned_slides(self.root), [])
self.assertEqual(len(list(deck.slides.glob("slide*.xml"))), 3)
def test_a_package_with_no_slides_directory_is_a_no_op(self) -> None:
self.assertEqual(clean.remove_orphaned_slides(self.root), [])
def test_an_unreadable_presentation_refuses_rather_than_deleting_everything(
self,
) -> None:
# If sldIdLst cannot be parsed, every slide looks orphaned. Deleting
# them would destroy the deck, so the tool must refuse instead.
deck = Deck(self.root, count=3)
# sldIdLst still lists three slides, but every r:id now points at a
# relationship that does not exist -- exactly what a parse failure
# looks like from here.
(self.root / "ppt" / "presentation.xml").write_text(
''
""
''
''
''
"",
encoding="utf-8",
)
with self.assertRaises(clean.RefusedToClean):
clean.remove_orphaned_slides(self.root)
self.assertEqual(len(list(deck.slides.glob("slide*.xml"))), 3)
def test_the_rels_file_goes_with_its_slide(self) -> None:
deck = Deck(self.root, count=1, orphans=1)
clean.remove_orphaned_slides(self.root)
self.assertFalse((deck.slides / "_rels" / "slide2.xml.rels").is_file())
self.assertTrue((deck.slides / "_rels" / "slide1.xml.rels").is_file())
class TrashRemovalTests(DeckTestCase):
def test_a_trash_directory_is_removed(self) -> None:
# The directory PowerPoint leaves behind is literally named "[trash]".
trash = self.root / "[trash]"
trash.mkdir()
(trash / "leftover.xml").write_text("", encoding="utf-8")
removed = clean.remove_trash_directory(self.root)
self.assertEqual([Path(name).name for name in removed], ["leftover.xml"])
self.assertFalse(trash.exists())
def test_a_package_without_one_is_untouched(self) -> None:
self.assertEqual(clean.remove_trash_directory(self.root), [])
class ThumbnailConstantTests(unittest.TestCase):
def test_the_grid_constants_are_sane(self) -> None:
import thumbnail
self.assertGreater(thumbnail.THUMBNAIL_WIDTH, 0)
self.assertGreater(thumbnail.CONVERSION_DPI, 0)
self.assertLessEqual(thumbnail.DEFAULT_COLS, thumbnail.MAX_COLS)
self.assertTrue(0 < thumbnail.JPEG_QUALITY <= 100)
if __name__ == "__main__":
unittest.main()