host.controller_web_editor.tests.test_export

Tests for the PNG/PDF export endpoint.

Generated by Claude

The renderer itself lives in host/controller_config/print_render.py; these tests cover the web-side wiring: parameter validation, the path allowlist, and that the returned bytes have the correct file signature and MIME type. Rendering quality is exercised by the Tkinter tool.

  1"""Tests for the PNG/PDF export endpoint.
  2
  3# Generated by Claude
  4
  5The renderer itself lives in ``host/controller_config/print_render.py``;
  6these tests cover the web-side wiring: parameter validation, the path
  7allowlist, and that the returned bytes have the correct file signature
  8and MIME type.  Rendering quality is exercised by the Tkinter tool.
  9"""
 10
 11import os
 12import sys
 13
 14import pytest
 15
 16
 17_REPO_ROOT = os.path.abspath(
 18    os.path.join(os.path.dirname(__file__), "..", "..", ".."))
 19if _REPO_ROOT not in sys.path:
 20    sys.path.insert(0, _REPO_ROOT)
 21
 22
 23from host.controller_web_editor.paths import safe_resolve  # noqa: E402
 24from host.controller_web_editor.routes.export import (  # noqa: E402
 25    ExportRequestError,
 26    export_from_yaml,
 27)
 28
 29# Skip the whole module when the renderer's optional native deps aren't
 30# available (cairosvg → libcairo).  These tests only assert wiring, not
 31# the Tkinter tool's existing rendering coverage.
 32PIL = pytest.importorskip("PIL")
 33pytest.importorskip("yaml")
 34
 35
 36_DATA_INPUTS = os.path.join(_REPO_ROOT, "data", "inputs")
 37_CONFIG = os.path.join(_DATA_INPUTS, "controller.yaml")
 38
 39
 40PNG_SIG = b"\x89PNG\r\n\x1a\n"
 41PDF_SIG = b"%PDF-"
 42
 43
 44@pytest.fixture(scope="module")
 45def yaml_path():
 46    if not os.path.isfile(_CONFIG):
 47        pytest.skip(f"sample config not found: {_CONFIG}")
 48    return safe_resolve("data/inputs/controller.yaml")
 49
 50
 51@pytest.fixture
 52def single_controller_yaml(tmp_path):
 53    """Sample YAML trimmed to one controller — landscape PNG requires
 54    one page, so multi-controller fixtures can't be used for that case.
 55    """
 56    from utils.controller.config_io import load_config, save_config
 57
 58    full = load_config(_CONFIG)
 59    # Keep only the first controller; drop the rest.
 60    ports = sorted(full.controllers.keys())
 61    if len(ports) > 1:
 62        for p in ports[1:]:
 63            del full.controllers[p]
 64    dst = tmp_path / "single.yaml"
 65    save_config(full, dst)
 66    return dst
 67
 68
 69class TestParameterValidation:
 70    def test_rejects_unknown_orientation(self, yaml_path):
 71        with pytest.raises(ExportRequestError):
 72            export_from_yaml(yaml_path, "sideways", "pdf")
 73
 74    def test_rejects_unknown_format(self, yaml_path):
 75        with pytest.raises(ExportRequestError):
 76            export_from_yaml(yaml_path, "landscape", "gif")
 77
 78    def test_format_normalizes_dot_prefix(self, yaml_path):
 79        # "format=.pdf" sometimes appears in query strings; renderer
 80        # should strip the leading dot.
 81        data, mime, _ = export_from_yaml(yaml_path, "landscape", ".pdf")
 82        assert mime == "application/pdf"
 83        assert data.startswith(PDF_SIG)
 84
 85
 86class TestRenderBytes:
 87    def test_landscape_pdf(self, yaml_path):
 88        data, mime, name = export_from_yaml(
 89            yaml_path, "landscape", "pdf", hide_unassigned=False)
 90        assert mime == "application/pdf"
 91        assert data.startswith(PDF_SIG)
 92        assert name.endswith("_landscape.pdf")
 93        assert len(data) > 1000  # sanity: PDFs aren't tiny
 94
 95    def test_landscape_png_single_controller(self, single_controller_yaml):
 96        # PNG only works with a single page (one controller in landscape,
 97        # up to two in portrait), so use the trimmed fixture.
 98        data, mime, name = export_from_yaml(
 99            single_controller_yaml, "landscape", "png",
100            hide_unassigned=False)
101        assert mime == "image/png"
102        assert data.startswith(PNG_SIG)
103        assert name.endswith("_landscape.png")
104
105    def test_landscape_png_rejects_multi_controller(self, yaml_path):
106        # Sample config has two controllers; landscape PNG can't fit
107        # both on a single page.  Renderer should raise so the client
108        # gets a clear 400 instead of a truncated file.
109        with pytest.raises(ValueError, match="single page"):
110            export_from_yaml(yaml_path, "landscape", "png")
111
112    def test_portrait_pdf(self, yaml_path):
113        data, mime, _ = export_from_yaml(
114            yaml_path, "portrait", "pdf", hide_unassigned=True)
115        assert mime == "application/pdf"
116        assert data.startswith(PDF_SIG)
117
118    def test_filename_uses_yaml_stem(self, yaml_path):
119        _, _, name = export_from_yaml(yaml_path, "landscape", "pdf")
120        assert name.startswith("controller_")  # from controller.yaml
PNG_SIG = b'\x89PNG\r\n\x1a\n'
PDF_SIG = b'%PDF-'
@pytest.fixture(scope='module')
def yaml_path():
45@pytest.fixture(scope="module")
46def yaml_path():
47    if not os.path.isfile(_CONFIG):
48        pytest.skip(f"sample config not found: {_CONFIG}")
49    return safe_resolve("data/inputs/controller.yaml")
@pytest.fixture
def single_controller_yaml(tmp_path):
52@pytest.fixture
53def single_controller_yaml(tmp_path):
54    """Sample YAML trimmed to one controller — landscape PNG requires
55    one page, so multi-controller fixtures can't be used for that case.
56    """
57    from utils.controller.config_io import load_config, save_config
58
59    full = load_config(_CONFIG)
60    # Keep only the first controller; drop the rest.
61    ports = sorted(full.controllers.keys())
62    if len(ports) > 1:
63        for p in ports[1:]:
64            del full.controllers[p]
65    dst = tmp_path / "single.yaml"
66    save_config(full, dst)
67    return dst

Sample YAML trimmed to one controller — landscape PNG requires one page, so multi-controller fixtures can't be used for that case.

class TestParameterValidation:
70class TestParameterValidation:
71    def test_rejects_unknown_orientation(self, yaml_path):
72        with pytest.raises(ExportRequestError):
73            export_from_yaml(yaml_path, "sideways", "pdf")
74
75    def test_rejects_unknown_format(self, yaml_path):
76        with pytest.raises(ExportRequestError):
77            export_from_yaml(yaml_path, "landscape", "gif")
78
79    def test_format_normalizes_dot_prefix(self, yaml_path):
80        # "format=.pdf" sometimes appears in query strings; renderer
81        # should strip the leading dot.
82        data, mime, _ = export_from_yaml(yaml_path, "landscape", ".pdf")
83        assert mime == "application/pdf"
84        assert data.startswith(PDF_SIG)
def test_rejects_unknown_orientation(self, yaml_path):
71    def test_rejects_unknown_orientation(self, yaml_path):
72        with pytest.raises(ExportRequestError):
73            export_from_yaml(yaml_path, "sideways", "pdf")
def test_rejects_unknown_format(self, yaml_path):
75    def test_rejects_unknown_format(self, yaml_path):
76        with pytest.raises(ExportRequestError):
77            export_from_yaml(yaml_path, "landscape", "gif")
def test_format_normalizes_dot_prefix(self, yaml_path):
79    def test_format_normalizes_dot_prefix(self, yaml_path):
80        # "format=.pdf" sometimes appears in query strings; renderer
81        # should strip the leading dot.
82        data, mime, _ = export_from_yaml(yaml_path, "landscape", ".pdf")
83        assert mime == "application/pdf"
84        assert data.startswith(PDF_SIG)
class TestRenderBytes:
 87class TestRenderBytes:
 88    def test_landscape_pdf(self, yaml_path):
 89        data, mime, name = export_from_yaml(
 90            yaml_path, "landscape", "pdf", hide_unassigned=False)
 91        assert mime == "application/pdf"
 92        assert data.startswith(PDF_SIG)
 93        assert name.endswith("_landscape.pdf")
 94        assert len(data) > 1000  # sanity: PDFs aren't tiny
 95
 96    def test_landscape_png_single_controller(self, single_controller_yaml):
 97        # PNG only works with a single page (one controller in landscape,
 98        # up to two in portrait), so use the trimmed fixture.
 99        data, mime, name = export_from_yaml(
100            single_controller_yaml, "landscape", "png",
101            hide_unassigned=False)
102        assert mime == "image/png"
103        assert data.startswith(PNG_SIG)
104        assert name.endswith("_landscape.png")
105
106    def test_landscape_png_rejects_multi_controller(self, yaml_path):
107        # Sample config has two controllers; landscape PNG can't fit
108        # both on a single page.  Renderer should raise so the client
109        # gets a clear 400 instead of a truncated file.
110        with pytest.raises(ValueError, match="single page"):
111            export_from_yaml(yaml_path, "landscape", "png")
112
113    def test_portrait_pdf(self, yaml_path):
114        data, mime, _ = export_from_yaml(
115            yaml_path, "portrait", "pdf", hide_unassigned=True)
116        assert mime == "application/pdf"
117        assert data.startswith(PDF_SIG)
118
119    def test_filename_uses_yaml_stem(self, yaml_path):
120        _, _, name = export_from_yaml(yaml_path, "landscape", "pdf")
121        assert name.startswith("controller_")  # from controller.yaml
def test_landscape_pdf(self, yaml_path):
88    def test_landscape_pdf(self, yaml_path):
89        data, mime, name = export_from_yaml(
90            yaml_path, "landscape", "pdf", hide_unassigned=False)
91        assert mime == "application/pdf"
92        assert data.startswith(PDF_SIG)
93        assert name.endswith("_landscape.pdf")
94        assert len(data) > 1000  # sanity: PDFs aren't tiny
def test_landscape_png_single_controller(self, single_controller_yaml):
 96    def test_landscape_png_single_controller(self, single_controller_yaml):
 97        # PNG only works with a single page (one controller in landscape,
 98        # up to two in portrait), so use the trimmed fixture.
 99        data, mime, name = export_from_yaml(
100            single_controller_yaml, "landscape", "png",
101            hide_unassigned=False)
102        assert mime == "image/png"
103        assert data.startswith(PNG_SIG)
104        assert name.endswith("_landscape.png")
def test_landscape_png_rejects_multi_controller(self, yaml_path):
106    def test_landscape_png_rejects_multi_controller(self, yaml_path):
107        # Sample config has two controllers; landscape PNG can't fit
108        # both on a single page.  Renderer should raise so the client
109        # gets a clear 400 instead of a truncated file.
110        with pytest.raises(ValueError, match="single page"):
111            export_from_yaml(yaml_path, "landscape", "png")
def test_portrait_pdf(self, yaml_path):
113    def test_portrait_pdf(self, yaml_path):
114        data, mime, _ = export_from_yaml(
115            yaml_path, "portrait", "pdf", hide_unassigned=True)
116        assert mime == "application/pdf"
117        assert data.startswith(PDF_SIG)
def test_filename_uses_yaml_stem(self, yaml_path):
119    def test_filename_uses_yaml_stem(self, yaml_path):
120        _, _, name = export_from_yaml(yaml_path, "landscape", "pdf")
121        assert name.startswith("controller_")  # from controller.yaml