From d619dae510a9a790c6a4ba4b963928798291eb53 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Fri, 16 Sep 2022 16:28:48 +0100 Subject: [PATCH] Snapshot testing progress --- docs/examples/guide/layout/grid_layout2.py | 2 +- poetry.lock | 104 ++-- pyproject.toml | 5 +- src/textual/_doc.py | 57 ++- tests/snapshot_tests/__init__.py | 0 .../__snapshots__/test_snapshots.ambr | 480 ++++++++++++++++++ tests/snapshot_tests/conftest.py | 178 +++++++ tests/snapshot_tests/snapshot_report.html | 412 +++++++++++++++ .../snapshot_report_template.jinja2 | 98 ++++ tests/snapshot_tests/test_snapshots.py | 14 + 10 files changed, 1294 insertions(+), 56 deletions(-) create mode 100644 tests/snapshot_tests/__init__.py create mode 100644 tests/snapshot_tests/__snapshots__/test_snapshots.ambr create mode 100644 tests/snapshot_tests/conftest.py create mode 100644 tests/snapshot_tests/snapshot_report.html create mode 100644 tests/snapshot_tests/snapshot_report_template.jinja2 create mode 100644 tests/snapshot_tests/test_snapshots.py diff --git a/docs/examples/guide/layout/grid_layout2.py b/docs/examples/guide/layout/grid_layout2.py index 825d6e1c1..ed8f697b7 100644 --- a/docs/examples/guide/layout/grid_layout2.py +++ b/docs/examples/guide/layout/grid_layout2.py @@ -4,7 +4,7 @@ from textual.widgets import Static class GridLayoutExample(App): def compose(self) -> ComposeResult: - yield Static("One", classes="box") + yield Static("Oneeee", classes="box") yield Static("Two", classes="box") yield Static("Three", classes="box") yield Static("Four", classes="box") diff --git a/poetry.lock b/poetry.lock index c6c1d2d27..ea583c97a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -50,14 +50,6 @@ category = "main" optional = false python-versions = ">=3.5" -[[package]] -name = "atomicwrites" -version = "1.4.1" -description = "Atomic file writes." -category = "dev" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" - [[package]] name = "attrs" version = "22.1.0" @@ -67,10 +59,10 @@ optional = false python-versions = ">=3.5" [package.extras] -dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"] -docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] -tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "zope.interface", "cloudpickle"] -tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "cloudpickle"] +dev = ["cloudpickle", "coverage[toml] (>=5.0.2)", "furo", "hypothesis", "mypy (>=0.900,!=0.940)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "sphinx", "sphinx-notfound-page", "zope.interface"] +docs = ["furo", "sphinx", "sphinx-notfound-page", "zope.interface"] +tests = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "zope.interface"] +tests_no_zope = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins"] [[package]] name = "black" @@ -142,6 +134,14 @@ category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +[[package]] +name = "colored" +version = "1.4.3" +description = "Simple library for color and formatting to terminal" +category = "dev" +optional = false +python-versions = "*" + [[package]] name = "commonmark" version = "0.9.1" @@ -204,7 +204,7 @@ python-versions = "*" python-dateutil = ">=2.8.1" [package.extras] -dev = ["twine", "markdown", "flake8", "wheel"] +dev = ["flake8", "markdown", "twine", "wheel"] [[package]] name = "griffe" @@ -252,9 +252,9 @@ typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} zipp = ">=0.5" [package.extras] -docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"] +docs = ["jaraco.packaging (>=9)", "rst.linker (>=1.9)", "sphinx"] perf = ["ipython"] -testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.3)", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "importlib-resources (>=1.3)"] +testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)"] [[package]] name = "iniconfig" @@ -488,8 +488,8 @@ optional = false python-versions = ">=3.7" [package.extras] -docs = ["furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)", "sphinx (>=4)"] -test = ["appdirs (==1.4.4)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)", "pytest (>=6)"] +docs = ["furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx (>=4)", "sphinx-autodoc-typehints (>=1.12)"] +test = ["appdirs (==1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)"] [[package]] name = "pluggy" @@ -562,18 +562,17 @@ optional = false python-versions = ">=3.6.8" [package.extras] -diagrams = ["railroad-diagrams", "jinja2"] +diagrams = ["jinja2", "railroad-diagrams"] [[package]] name = "pytest" -version = "6.2.5" +version = "7.1.3" description = "pytest: simple powerful testing with Python" category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [package.dependencies] -atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} attrs = ">=19.2.0" colorama = {version = "*", markers = "sys_platform == \"win32\""} importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} @@ -581,10 +580,10 @@ iniconfig = "*" packaging = "*" pluggy = ">=0.12,<2.0" py = ">=1.8.2" -toml = "*" +tomli = ">=1.0.0" [package.extras] -testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] +testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] [[package]] name = "pytest-aiohttp" @@ -615,7 +614,7 @@ pytest = ">=6.1.0" typing-extensions = {version = ">=3.7.2", markers = "python_version < \"3.8\""} [package.extras] -testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)", "flaky (>=3.5.0)", "mypy (>=0.931)", "pytest-trio (>=0.7.0)"] +testing = ["coverage (>=6.2)", "flaky (>=3.5.0)", "hypothesis (>=5.7.1)", "mypy (>=0.931)", "pytest-trio (>=0.7.0)"] [[package]] name = "pytest-cov" @@ -631,7 +630,7 @@ pytest = ">=4.6" toml = "*" [package.extras] -testing = ["fields", "hunter", "process-tests", "six", "pytest-xdist", "virtualenv"] +testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] [[package]] name = "python-dateutil" @@ -687,6 +686,18 @@ category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +[[package]] +name = "syrupy" +version = "3.0.0" +description = "PyTest Snapshot Test Utility" +category = "dev" +optional = false +python-versions = ">=3.7,<4" + +[package.dependencies] +colored = ">=1.3.92,<2.0.0" +pytest = ">=5.1.0,<8.0.0" + [[package]] name = "time-machine" version = "2.8.1" @@ -781,8 +792,8 @@ optional = false python-versions = ">=3.7" [package.extras] -docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "jaraco.tidelift (>=1.4)"] -testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.3)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"] +docs = ["jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx"] +testing = ["func-timeout", "jaraco.itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] [extras] dev = ["aiohttp", "click", "msgpack"] @@ -790,7 +801,7 @@ dev = ["aiohttp", "click", "msgpack"] [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "61db56567f708cd9ca1c27f0e4a4b4aa3dd808fc8411f80967a90995d7fdd8c8" +content-hash = "b28791133eec3bbb2debd610593c608c3ec62524be47e803fcbd31e55ddbffee" [metadata.files] aiohttp = [ @@ -879,7 +890,6 @@ asynctest = [ {file = "asynctest-0.13.0-py3-none-any.whl", hash = "sha256:5da6118a7e6d6b54d83a8f7197769d046922a44d2a99c21382f0a6e4fadae676"}, {file = "asynctest-0.13.0.tar.gz", hash = "sha256:c27862842d15d83e6a34eb0b2866c323880eb3a75e4485b079ea11748fd77fac"}, ] -atomicwrites = [] attrs = [] black = [ {file = "black-22.6.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f586c26118bc6e714ec58c09df0157fe2d9ee195c764f630eb0d8e7ccce72e69"}, @@ -914,7 +924,10 @@ cfgv = [ {file = "cfgv-3.3.1-py2.py3-none-any.whl", hash = "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426"}, {file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"}, ] -charset-normalizer = [] +charset-normalizer = [ + {file = "charset-normalizer-2.1.1.tar.gz", hash = "sha256:5a3d016c7c547f69d6f81fb0db9449ce888b418b5b9952cc5e6e66843e9dd845"}, + {file = "charset_normalizer-2.1.1-py3-none-any.whl", hash = "sha256:83e9a75d1911279afd89352c68b45348559d1fc0506b054b346651b5e7fee29f"}, +] click = [ {file = "click-8.1.2-py3-none-any.whl", hash = "sha256:24e1a4a9ec5bf6299411369b208c1df2188d9eb8d916302fe6bf03faed227f1e"}, {file = "click-8.1.2.tar.gz", hash = "sha256:479707fe14d9ec9a0757618b7a100a0ae4c4e236fac5b7f80ca68028141a1a72"}, @@ -923,19 +936,28 @@ colorama = [ {file = "colorama-0.4.5-py2.py3-none-any.whl", hash = "sha256:854bf444933e37f5824ae7bfc1e98d5bce2ebe4160d46b5edf346a89358e99da"}, {file = "colorama-0.4.5.tar.gz", hash = "sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4"}, ] +colored = [ + {file = "colored-1.4.3.tar.gz", hash = "sha256:b7b48b9f40e8a65bbb54813d5d79dd008dc8b8c5638d5bbfd30fc5a82e6def7a"}, +] commonmark = [ {file = "commonmark-0.9.1-py2.py3-none-any.whl", hash = "sha256:da2f38c92590f83de410ba1a3cbceafbc74fee9def35f9251ba9a971d6d66fd9"}, {file = "commonmark-0.9.1.tar.gz", hash = "sha256:452f9dc859be7f06631ddcb328b6919c67984aca654e5fefb3914d54691aed60"}, ] coverage = [] distlib = [] -filelock = [] +filelock = [ + {file = "filelock-3.8.0-py3-none-any.whl", hash = "sha256:617eb4e5eedc82fc5f47b6d61e4d11cb837c56cb4544e39081099fa17ad109d4"}, + {file = "filelock-3.8.0.tar.gz", hash = "sha256:55447caa666f2198c5b6b13a26d2084d26fa5b115c00d065664b2124680c4edc"}, +] frozenlist = [] ghp-import = [ {file = "ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343"}, {file = "ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619"}, ] -griffe = [] +griffe = [ + {file = "griffe-0.22.0-py3-none-any.whl", hash = "sha256:65c94cba634d6ad397c495b04ed5fd3f06d9b16c4f9f78bd63be9ea34d6b7113"}, + {file = "griffe-0.22.0.tar.gz", hash = "sha256:a3c25a2b7bf729ecee7cd455b4eff548f01c620b8f58a8097a800caad221f12e"}, +] identify = [] idna = [ {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, @@ -1008,7 +1030,10 @@ mkdocs-autorefs = [ {file = "mkdocs-autorefs-0.4.1.tar.gz", hash = "sha256:70748a7bd025f9ecd6d6feeba8ba63f8e891a1af55f48e366d6d6e78493aba84"}, {file = "mkdocs_autorefs-0.4.1-py3-none-any.whl", hash = "sha256:a2248a9501b29dc0cc8ba4c09f4f47ff121945f6ce33d760f145d6f89d313f5b"}, ] -mkdocs-material = [] +mkdocs-material = [ + {file = "mkdocs-material-8.4.2.tar.gz", hash = "sha256:704c64c3fff126a3923c2961d95f26b19be621342a6a4e49ed039f0bb7a5c540"}, + {file = "mkdocs_material-8.4.2-py2.py3-none-any.whl", hash = "sha256:166287bb0e4197804906bf0389a852d5ced43182c30127ac8b48a4e497ecd7e5"}, +] mkdocs-material-extensions = [ {file = "mkdocs-material-extensions-1.0.3.tar.gz", hash = "sha256:bfd24dfdef7b41c312ede42648f9eb83476ea168ec163b613f9abd12bbfddba2"}, {file = "mkdocs_material_extensions-1.0.3-py3-none-any.whl", hash = "sha256:a82b70e533ce060b2a5d9eb2bc2e1be201cf61f901f93704b4acf6e3d5983a44"}, @@ -1201,8 +1226,8 @@ pyparsing = [ {file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"}, ] pytest = [ - {file = "pytest-6.2.5-py3-none-any.whl", hash = "sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134"}, - {file = "pytest-6.2.5.tar.gz", hash = "sha256:131b36680866a76e6781d13f101efb86cf674ebb9762eb70d3082b6f29889e89"}, + {file = "pytest-7.1.3-py3-none-any.whl", hash = "sha256:1377bda3466d70b55e3f5cecfa55bb7cfcf219c7964629b967c37cf0bda818b7"}, + {file = "pytest-7.1.3.tar.gz", hash = "sha256:4f365fec2dff9c1162f834d9f18af1ba13062db0c708bf7b946f8a5c76180c39"}, ] pytest-aiohttp = [ {file = "pytest-aiohttp-1.0.4.tar.gz", hash = "sha256:39ff3a0d15484c01d1436cbedad575c6eafbf0f57cdf76fb94994c97b5b8c5a4"}, @@ -1261,6 +1286,10 @@ six = [ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, ] +syrupy = [ + {file = "syrupy-3.0.0-py3-none-any.whl", hash = "sha256:e5e4d376766f46a52a3585365fb90aba0295611043a41a760302d2cd7aa74488"}, + {file = "syrupy-3.0.0.tar.gz", hash = "sha256:e1e2b6504c85c871fed89b82ce1d02442cf7b622b4b88f95c1332e71e012cd86"}, +] time-machine = [] toml = [ {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, @@ -1296,7 +1325,10 @@ typed-ast = [ {file = "typed_ast-1.5.4-cp39-cp39-win_amd64.whl", hash = "sha256:0fdbcf2fef0ca421a3f5912555804296f0b0960f0418c440f5d6d3abb549f3e1"}, {file = "typed_ast-1.5.4.tar.gz", hash = "sha256:39e21ceb7388e4bb37f4c679d72707ed46c2fbf2a5609b8b8ebc4b067d977df2"}, ] -typing-extensions = [] +typing-extensions = [ + {file = "typing_extensions-4.3.0-py3-none-any.whl", hash = "sha256:25642c956049920a5aa49edcdd6ab1e06d7e5d467fc00e0506c44ac86fbfca02"}, + {file = "typing_extensions-4.3.0.tar.gz", hash = "sha256:e6d2677a32f47fc7eb2795db1dd15c1f34eff616bcaf2cfb5e997f854fa1c4a6"}, +] virtualenv = [] watchdog = [ {file = "watchdog-2.1.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a735a990a1095f75ca4f36ea2ef2752c99e6ee997c46b0de507ba40a09bf7330"}, diff --git a/pyproject.toml b/pyproject.toml index 71eb976e5..e03c8828c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,7 @@ nanoid = "^2.0.0" dev = ["aiohttp", "click", "msgpack"] [tool.poetry.dev-dependencies] -pytest = "^6.2.3" +pytest = "^7.1.3" black = "^22.3.0" mypy = "^0.950" pytest-cov = "^2.12.1" @@ -48,6 +48,7 @@ pre-commit = "^2.13.0" pytest-aiohttp = "^1.0.4" time-machine = "^2.6.0" Jinja2 = "<3.1.0" +syrupy = "^3.0.0" [tool.black] includes = "src" @@ -55,8 +56,10 @@ includes = "src" [tool.pytest.ini_options] asyncio_mode = "auto" testpaths = ["tests"] +addopts = "--strict-markers" markers = [ "integration_test: marks tests as slow integration tests (deselect with '-m \"not integration_test\"')", + "snapshot: marks test as a snapshot test", ] [build-system] diff --git a/src/textual/_doc.py b/src/textual/_doc.py index b70ce67fc..8c2901dd4 100644 --- a/src/textual/_doc.py +++ b/src/textual/_doc.py @@ -3,15 +3,16 @@ from __future__ import annotations import runpy import os import shlex -from typing import cast, TYPE_CHECKING +from typing import cast, TYPE_CHECKING, Iterable if TYPE_CHECKING: from textual.app import App + # This module defines our "Custom Fences", powered by SuperFences # @link https://facelessuser.github.io/pymdown-extensions/extensions/superfences/#custom-fences def format_svg(source, language, css_class, options, md, attrs, **kwargs) -> str: - """A superfences formatter to insert a SVG screenshot.""" + """A superfences formatter to insert an SVG screenshot.""" try: cmd: list[str] = shlex.split(attrs["path"]) @@ -21,25 +22,13 @@ def format_svg(source, language, css_class, options, md, attrs, **kwargs) -> str press = [*_press.split(",")] if _press else ["_"] title = attrs.get("title") - os.environ["COLUMNS"] = attrs.get("columns", "80") - os.environ["LINES"] = attrs.get("lines", "24") - print(f"screenshotting {path!r}") cwd = os.getcwd() try: - app_vars = runpy.run_path(path) - if "sys" in app_vars: - app_vars["sys"].argv = cmd - app: App = cast("App", app_vars["app"]) - app.run( - quit_after=5, - press=press or ["ctrl+c"], - headless=True, - screenshot=True, - screenshot_title=title, - ) - svg = app._screenshot + rows = int(attrs.get("lines", 24)) + columns = int(attrs.get("columns", 80)) + svg = take_svg_screenshot(path, press, title, terminal_size=(rows, columns)) finally: os.chdir(cwd) @@ -52,8 +41,40 @@ def format_svg(source, language, css_class, options, md, attrs, **kwargs) -> str traceback.print_exception(error) +def take_svg_screenshot( + app_path: str, + press: Iterable[str] = ("_",), + title: str | None = None, + terminal_size: tuple[int, int] = (24, 80), +) -> str: + rows, columns = terminal_size + + os.environ["COLUMNS"] = str(columns) + os.environ["LINES"] = str(rows) + + app_vars = runpy.run_path(app_path) + if "sys" in app_vars: + cmd: list[str] = shlex.split(app_path) + app_vars["sys"].argv = cmd + + app: App = cast("App", app_vars["app"]) + + if title is None: + title = app.title + + app.run( + quit_after=5, + press=press or ["ctrl+c"], + headless=True, + screenshot=True, + screenshot_title=title, + ) + svg = app._screenshot + return svg + + def rich(source, language, css_class, options, md, attrs, **kwargs) -> str: - """A superfences formatter to insert a SVG screenshot.""" + """A superfences formatter to insert an SVG screenshot.""" from rich.console import Console import io diff --git a/tests/snapshot_tests/__init__.py b/tests/snapshot_tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr new file mode 100644 index 000000000..74538c10d --- /dev/null +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -0,0 +1,480 @@ +# name: test_combining_layouts + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Textual + + + + + + + + + + + CombiningLayoutsExample14:29:12 + + ┌──────────────────────────────────────┐┌──────────────────────────────────────┐ + HorizontallyPositionedChildrenHere + Vertical layout, child 0 + + + + Vertical layout, child 1 + ▇▇ + + └──────────────────────────────────────┘ + Vertical layout, child 2┌──────────────────────────────────────┐ + Thispanelis + + + Vertical layout, child 3 + + usinggrid layout! + + Vertical layout, child 4 + + └──────────────────────────────────────┘└──────────────────────────────────────┘ + + + + + ''' +# --- +# name: test_grid_layout_basic + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Textual + + + + + + + + + + ┌────────────────────────┐┌─────────────────────────┐┌─────────────────────────┐ + OneTwoThree + + + + + + + + + + └────────────────────────┘└─────────────────────────┘└─────────────────────────┘ + ┌────────────────────────┐┌─────────────────────────┐┌─────────────────────────┐ + FourFiveSix + + + + + + + + + + └────────────────────────┘└─────────────────────────┘└─────────────────────────┘ + + + + + ''' +# --- +# name: test_grid_layout_basic_overflow + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Textual + + + + + + + + + + ┌────────────────────────┐┌─────────────────────────┐┌─────────────────────────┐ + OneeeeTwoThree + + + + + + └────────────────────────┘└─────────────────────────┘└─────────────────────────┘ + ┌────────────────────────┐┌─────────────────────────┐┌─────────────────────────┐ + FourFiveSix + + + + + + └────────────────────────┘└─────────────────────────┘└─────────────────────────┘ + ┌────────────────────────┐ + Seven + + + + + + └────────────────────────┘ + + + + + ''' +# --- +# name: test_x + ''' + 123 + 123123123 + ''' +# --- diff --git a/tests/snapshot_tests/conftest.py b/tests/snapshot_tests/conftest.py new file mode 100644 index 000000000..f24ab3512 --- /dev/null +++ b/tests/snapshot_tests/conftest.py @@ -0,0 +1,178 @@ +import difflib +from dataclasses import dataclass +from datetime import datetime +from functools import partial +from operator import attrgetter +from os import PathLike +from pathlib import Path +from typing import Union, List, Optional, Any, Callable + +import pytest +from _pytest.fixtures import FixtureDef, SubRequest, FixtureRequest +from jinja2 import Template +from rich.console import Console +from rich.panel import Panel + +from textual._doc import take_svg_screenshot + +snapshot_svg_key = pytest.StashKey[str]() +actual_svg_key = pytest.StashKey[str]() +snapshot_pass = pytest.StashKey[bool]() + + +@pytest.fixture +def snap_compare(snapshot, request: FixtureRequest) -> Callable[[str], bool]: + def compare(app_path: str, snapshot) -> bool: + node = request.node + actual_screenshot = take_svg_screenshot(app_path) + result = snapshot == actual_screenshot + + if result is False: + # The split and join below is a mad hack, sorry... + node.stash[snapshot_svg_key] = "\n".join(str(snapshot).splitlines()[1:-1]) + node.stash[actual_svg_key] = actual_screenshot + else: + node.stash[snapshot_pass] = True + + return result + + return partial(compare, snapshot=snapshot) + + +@dataclass +class SvgSnapshotDiff: + snapshot: Optional[str] + actual: Optional[str] + test_name: str + file_similarity: float + path: PathLike + line_number: int + + +# +# def pytest_runtestloop(session: "Session") -> Optional[object]: + +@pytest.hookimpl(hookwrapper=True) +def pytest_pyfunc_call(pyfuncitem: "pytest.Function") -> Optional[object]: + """Call underlying test function. + + Stops at first non-None result, see :ref:`firstresult`. + """ + # Before + yield + # After + + +def pytest_runtest_teardown(item: pytest.Item, nextitem: Optional[pytest.Item]) -> None: + """Called to perform the teardown phase for a test item. + + The default implementation runs the finalizers and calls ``teardown()`` + on ``item`` and all of its parents (which need to be torn down). This + includes running the teardown phase of fixtures required by the item (if + they go out of scope). + + :param nextitem: + The scheduled-to-be-next test item (None if no further test item is + scheduled). This argument is used to perform exact teardowns, i.e. + calling just enough finalizers so that nextitem only needs to call + setup functions. + """ + + +@pytest.hookimpl(hookwrapper=True) +def pytest_fixture_setup( + fixturedef: FixtureDef[Any], request: SubRequest +) -> Optional[object]: + """Perform fixture setup execution. + + :returns: The return value of the call to the fixture function. + + Stops at first non-None result, see :ref:`firstresult`. + + .. note:: + If the fixture function returns None, other implementations of + this hook function will continue to be called, according to the + behavior of the :ref:`firstresult` option. + """ + value = yield + value = value.get_result() + value = repr(value) + + +def pytest_sessionfinish( + session: "pytest.Session", + exitstatus: Union[int, "pytest.ExitCode"], +) -> None: + """Called after whole test run finished, right before returning the exit status to the system. + + :param pytest.Session session: The pytest session object. + :param int exitstatus: The status which pytest will return to the system. + """ + diffs: List[SvgSnapshotDiff] = [] + num_snapshots_passing = 0 + for item in session.items: + num_snapshots_passing += int(item.stash.get(snapshot_pass, False)) + snapshot_svg = item.stash.get(snapshot_svg_key, None) + actual_svg = item.stash.get(actual_svg_key, None) + if snapshot_svg and actual_svg: + path, line_index, name = item.reportinfo() + diffs.append( + SvgSnapshotDiff( + snapshot=str(snapshot_svg), + actual=str(actual_svg), + file_similarity=100 * difflib.SequenceMatcher(a=str(snapshot_svg), b=str(actual_svg)).ratio(), + test_name=name, + path=path, + line_number=line_index + 1, + ) + ) + + diff_sort_key = attrgetter("file_similarity") + diffs = sorted(diffs, key=diff_sort_key) + + conftest_path = Path(__file__) + snapshot_template_path = conftest_path.parent / "snapshot_report_template.jinja2" + snapshot_report_path = conftest_path.parent / "snapshot_report.html" + + template = Template(snapshot_template_path.read_text()) + + num_fails = len(diffs) + num_snapshot_tests = len(diffs) + num_snapshots_passing + + rendered_report = template.render( + diffs=diffs, + passes=num_snapshots_passing, + fails=num_fails, + pass_percentage=100*(num_snapshots_passing/num_snapshot_tests), + fail_percentage=100*(num_fails/num_snapshot_tests), + num_snapshot_tests=num_snapshot_tests, + now=datetime.utcnow() + ) + with open(snapshot_report_path, "wt") as snapshot_file: + snapshot_file.write(rendered_report) + + session.config._textual_snapshots = diffs + session.config._textual_snapshot_html_report = snapshot_report_path + + +def pytest_terminal_summary( + terminalreporter: "pytest.TerminalReporter", + exitstatus: pytest.ExitCode, + config: pytest.Config, +) -> None: + """Add a section to terminal summary reporting. + + :param _pytest.terminal.TerminalReporter terminalreporter: The internal terminal reporter object. + :param int exitstatus: The exit status that will be reported back to the OS. + :param pytest.Config config: The pytest config object. + + .. versionadded:: 4.2 + The ``config`` parameter. + """ + diffs = config._textual_snapshots + snapshot_report_location = config._textual_snapshot_html_report + console = Console() + summary_panel = Panel( + f"[b]Report available for {len(diffs)} snapshot test failures.[/]\n\nView the report at:\n\n[blue]{snapshot_report_location}[/]", + title="[b red]Textual Snapshot Test Summary", padding=1) + console.print(summary_panel) diff --git a/tests/snapshot_tests/snapshot_report.html b/tests/snapshot_tests/snapshot_report.html new file mode 100644 index 000000000..f853befd7 --- /dev/null +++ b/tests/snapshot_tests/snapshot_report.html @@ -0,0 +1,412 @@ + + + + + + Textual Snapshot Test Report + + + +
+
+
+

+ Textual Snapshot Tests +

+ Showing diffs for 1 mismatched snapshot(s) +
+
+
+ + 1 snapshots changed + + + · + + + 2 snapshots matched + +
+
+
+
+
+
+
+ + +
+
+
+
+
+ + test_combining_layouts + + (88.89% similar) +
+ /Users/darrenburns/code/textual/tests/snapshot_tests/test_snapshots.py:9 +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Textual + + + + + + + + + + +CombiningLayoutsExample15:50:46 + +┌──────────────────────────────────────┐┌──────────────────────────────────────┐ +HorizontallyPositionedChildrenHere +Vertical layout, child 0 + + + +Vertical layout, child 1 +▇▇ + +└──────────────────────────────────────┘ +Vertical layout, child 2┌──────────────────────────────────────┐ +Thispanelis + + +Vertical layout, child 3 + +usinggrid layout! + +Vertical layout, child 4 + +└──────────────────────────────────────┘└──────────────────────────────────────┘ + + + + +
+ Output from test +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Textual + + + + + + + + + + +CombiningLayoutsExample14:29:12 + +┌──────────────────────────────────────┐┌──────────────────────────────────────┐ +HorizontallyPositionedChildrenHere +Vertical layout, child 0 + + + +Vertical layout, child 1 +▇▇ + +└──────────────────────────────────────┘ +Vertical layout, child 2┌──────────────────────────────────────┐ +Thispanelis + + +Vertical layout, child 3 + +usinggrid layout! + +Vertical layout, child 4 + +└──────────────────────────────────────┘└──────────────────────────────────────┘ + + + + +
+ Historical snapshot +
+
+
+
+
+
+
+ + +
+
+
+
+

If you're happy with the change, run pytest with the --snapshot-update flag to update the snapshot.

+
+
+
+
+ +
+
+
+

Report generated at UTC 2022-09-16 14:50:47.993944.

+
+
+
+ +
+ + + diff --git a/tests/snapshot_tests/snapshot_report_template.jinja2 b/tests/snapshot_tests/snapshot_report_template.jinja2 new file mode 100644 index 000000000..a726b9b57 --- /dev/null +++ b/tests/snapshot_tests/snapshot_report_template.jinja2 @@ -0,0 +1,98 @@ + + + + + + Textual Snapshot Test Report + + + +
+
+
+

+ Textual Snapshot Tests +

+ Showing diffs for {{ fails }} mismatched snapshot(s) +
+
+
+ + {{ diffs | length }} snapshots changed + + + · + + + {{ passes }} snapshots matched + +
+
+
+
+
+
+
+ + {% for diff in diffs %} +
+
+
+
+
+ + {{ diff.test_name }} + + ({{ "%.2f"|format(diff.file_similarity) }}% similar) +
+ {{ diff.path }}:{{ diff.line_number }} +
+
+
+
+ {{ diff.actual }} +
+ Output from test +
+
+
+ {{ diff.snapshot }} +
+ Historical snapshot +
+
+
+
+
+
+
+ {% endfor %} + +
+
+
+
+

If you're happy with the change, run pytest with the --snapshot-update flag to update the snapshot.

+
+
+
+
+ +
+
+
+

Report generated at UTC {{ now }}.

+
+
+
+ +
+ + + diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py new file mode 100644 index 000000000..d3de89f48 --- /dev/null +++ b/tests/snapshot_tests/test_snapshots.py @@ -0,0 +1,14 @@ +def test_grid_layout_basic(snap_compare): + assert snap_compare("docs/examples/guide/layout/grid_layout1.py") + + +def test_grid_layout_basic_overflow(snap_compare): + assert snap_compare("docs/examples/guide/layout/grid_layout2.py") + + +def test_combining_layouts(snap_compare): + assert snap_compare("docs/examples/guide/layout/combining_layouts.py") + + +def test_layers(snap_compare): + assert snap_compare("docs/examples/guide/layout/layers.py")