|
| 1 | +# TextFrame - ASCII Frame Simulator |
| 2 | + |
| 3 | +:::{warning} |
| 4 | +This is a testing utility in `tests/textframe/`. It is **not** part of the public API. |
| 5 | +::: |
| 6 | + |
| 7 | +TextFrame provides a fixed-size ASCII frame simulator for visualizing terminal content with overflow detection and diagnostic rendering. It integrates with [syrupy](https://github.com/tophat/syrupy) for snapshot testing and pytest for rich assertion output. |
| 8 | + |
| 9 | +## Overview |
| 10 | + |
| 11 | +TextFrame is designed for testing terminal UI components. It provides: |
| 12 | + |
| 13 | +- Fixed-dimension ASCII frames with borders |
| 14 | +- Configurable overflow behavior (error or truncate) |
| 15 | +- Syrupy snapshot testing with `.frame` files |
| 16 | +- Rich pytest assertion output for frame comparisons |
| 17 | + |
| 18 | +## Core Components |
| 19 | + |
| 20 | +### TextFrame Dataclass |
| 21 | + |
| 22 | +```python |
| 23 | +from tests.textframe.core import TextFrame, ContentOverflowError |
| 24 | + |
| 25 | +# Create a frame with fixed dimensions |
| 26 | +frame = TextFrame(content_width=10, content_height=2) |
| 27 | +frame.set_content(["hello", "world"]) |
| 28 | +print(frame.render()) |
| 29 | +``` |
| 30 | + |
| 31 | +Output: |
| 32 | +``` |
| 33 | ++----------+ |
| 34 | +|hello | |
| 35 | +|world | |
| 36 | ++----------+ |
| 37 | +``` |
| 38 | + |
| 39 | +### Overflow Behavior |
| 40 | + |
| 41 | +TextFrame supports two overflow behaviors: |
| 42 | + |
| 43 | +**Error mode (default):** Raises `ContentOverflowError` with a visual diagnostic showing the content and a mask of valid/invalid areas. |
| 44 | + |
| 45 | +```python |
| 46 | +frame = TextFrame(content_width=5, content_height=2, overflow_behavior="error") |
| 47 | +frame.set_content(["this line is too long"]) # Raises ContentOverflowError |
| 48 | +``` |
| 49 | + |
| 50 | +The exception includes an `overflow_visual` attribute showing: |
| 51 | +1. A "Reality" frame with the actual content |
| 52 | +2. A "Mask" frame showing valid (space) vs invalid (dot) areas |
| 53 | + |
| 54 | +**Truncate mode:** Silently clips content to fit the frame dimensions. |
| 55 | + |
| 56 | +```python |
| 57 | +frame = TextFrame(content_width=5, content_height=1, overflow_behavior="truncate") |
| 58 | +frame.set_content(["hello world", "extra row"]) |
| 59 | +print(frame.render()) |
| 60 | +``` |
| 61 | + |
| 62 | +Output: |
| 63 | +``` |
| 64 | ++-----+ |
| 65 | +|hello| |
| 66 | ++-----+ |
| 67 | +``` |
| 68 | + |
| 69 | +## Syrupy Integration |
| 70 | + |
| 71 | +### SingleFileSnapshotExtension |
| 72 | + |
| 73 | +TextFrame uses syrupy's `SingleFileSnapshotExtension` to store each snapshot in its own `.frame` file. This provides: |
| 74 | + |
| 75 | +- Cleaner git diffs (one file per test vs all-in-one `.ambr`) |
| 76 | +- Easier code review of snapshot changes |
| 77 | +- Human-readable ASCII art in snapshot files |
| 78 | + |
| 79 | +### Extension Implementation |
| 80 | + |
| 81 | +```python |
| 82 | +# tests/textframe/plugin.py |
| 83 | +from syrupy.extensions.single_file import SingleFileSnapshotExtension, WriteMode |
| 84 | + |
| 85 | +class TextFrameExtension(SingleFileSnapshotExtension): |
| 86 | + _write_mode = WriteMode.TEXT |
| 87 | + file_extension = "frame" |
| 88 | + |
| 89 | + def serialize(self, data, **kwargs): |
| 90 | + if isinstance(data, TextFrame): |
| 91 | + return data.render() |
| 92 | + if isinstance(data, ContentOverflowError): |
| 93 | + return data.overflow_visual |
| 94 | + return str(data) |
| 95 | +``` |
| 96 | + |
| 97 | +Key design decisions: |
| 98 | + |
| 99 | +1. **`file_extension = "frame"`**: Uses `.frame` suffix for snapshot files instead of the default `.raw` |
| 100 | +2. **`_write_mode = WriteMode.TEXT`**: Stores snapshots as text (not binary) |
| 101 | +3. **Custom serialization**: Renders TextFrame objects and ContentOverflowError exceptions as ASCII art |
| 102 | + |
| 103 | +### Fixture Override Pattern |
| 104 | + |
| 105 | +The snapshot fixture is overridden in `conftest.py` using syrupy's `use_extension()` pattern: |
| 106 | + |
| 107 | +```python |
| 108 | +# tests/textframe/conftest.py |
| 109 | +@pytest.fixture |
| 110 | +def snapshot(snapshot: SnapshotAssertion) -> SnapshotAssertion: |
| 111 | + return snapshot.use_extension(TextFrameExtension) |
| 112 | +``` |
| 113 | + |
| 114 | +This pattern works because pytest fixtures that request themselves receive the parent scope's version. |
| 115 | + |
| 116 | +### Snapshot Directory Structure |
| 117 | + |
| 118 | +``` |
| 119 | +tests/textframe/__snapshots__/ |
| 120 | + test_core/ |
| 121 | + test_frame_rendering[basic_success].frame |
| 122 | + test_frame_rendering[overflow_width].frame |
| 123 | + test_frame_rendering[empty_frame].frame |
| 124 | + ... |
| 125 | +``` |
| 126 | + |
| 127 | +## Pure pytest Assertion Hook |
| 128 | + |
| 129 | +For TextFrame-to-TextFrame comparisons (without syrupy), a `pytest_assertrepr_compare` hook provides rich diff output: |
| 130 | + |
| 131 | +```python |
| 132 | +# tests/textframe/conftest.py |
| 133 | +def pytest_assertrepr_compare(config, op, left, right): |
| 134 | + if not isinstance(left, TextFrame) or not isinstance(right, TextFrame): |
| 135 | + return None |
| 136 | + if op != "==": |
| 137 | + return None |
| 138 | + |
| 139 | + lines = ["TextFrame comparison failed:"] |
| 140 | + |
| 141 | + # Dimension mismatch |
| 142 | + if left.content_width != right.content_width: |
| 143 | + lines.append(f" width: {left.content_width} != {right.content_width}") |
| 144 | + if left.content_height != right.content_height: |
| 145 | + lines.append(f" height: {left.content_height} != {right.content_height}") |
| 146 | + |
| 147 | + # Content diff using difflib.ndiff |
| 148 | + left_render = left.render().splitlines() |
| 149 | + right_render = right.render().splitlines() |
| 150 | + if left_render != right_render: |
| 151 | + lines.append("") |
| 152 | + lines.append("Content diff:") |
| 153 | + lines.extend(ndiff(right_render, left_render)) |
| 154 | + |
| 155 | + return lines |
| 156 | +``` |
| 157 | + |
| 158 | +This hook intercepts `assert frame1 == frame2` comparisons and shows: |
| 159 | +- Dimension mismatches (width/height) |
| 160 | +- Line-by-line diff using `difflib.ndiff` |
| 161 | + |
| 162 | +## Architecture Patterns |
| 163 | + |
| 164 | +### From syrupy |
| 165 | + |
| 166 | +- **Extension hierarchy**: `SingleFileSnapshotExtension` extends `AbstractSyrupyExtension` |
| 167 | +- **Serialization**: Override `serialize()` for custom data types |
| 168 | +- **File naming**: `file_extension` class attribute controls snapshot file suffix |
| 169 | + |
| 170 | +### From pytest |
| 171 | + |
| 172 | +- **`pytest_assertrepr_compare` hook**: Return `list[str]` for custom assertion output |
| 173 | +- **Fixture override pattern**: Request same-named fixture to get parent scope's version |
| 174 | +- **`ndiff` for diffs**: Character-level diff with `+`/`-` prefixes |
| 175 | + |
| 176 | +### From CPython dataclasses |
| 177 | + |
| 178 | +- **`@dataclass(slots=True)`**: Memory-efficient, prevents accidental attribute assignment |
| 179 | +- **`__post_init__`**: Validation after dataclass initialization |
| 180 | +- **Type aliases**: `OverflowBehavior = Literal["error", "truncate"]` |
| 181 | + |
| 182 | +## Files |
| 183 | + |
| 184 | +| File | Purpose | |
| 185 | +|------|---------| |
| 186 | +| `tests/textframe/core.py` | `TextFrame` dataclass and `ContentOverflowError` | |
| 187 | +| `tests/textframe/plugin.py` | Syrupy `TextFrameExtension` | |
| 188 | +| `tests/textframe/conftest.py` | Fixture override and `pytest_assertrepr_compare` hook | |
| 189 | +| `tests/textframe/test_core.py` | Parametrized tests with snapshot assertions | |
| 190 | +| `tests/textframe/__snapshots__/test_core/*.frame` | Snapshot baselines | |
0 commit comments