Skip to content

Commit c412f09

Browse files
committed
docs(textframe): Document assertion customization patterns
why: Provide reference for TextFrame usage and architectural decisions. what: - Document syrupy integration (SingleFileSnapshotExtension) - Document pytest_assertrepr_compare hook pattern - Document overflow_behavior modes - Include examples and architectural insights from syrupy/pytest/CPython - Add to internals toctree
1 parent 45b58ee commit c412f09

File tree

2 files changed

+191
-0
lines changed

2 files changed

+191
-0
lines changed

docs/internals/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ dataclasses
1313
query_list
1414
constants
1515
sparse_array
16+
textframe
1617
```
1718

1819
## Environmental variables

docs/internals/textframe.md

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
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

Comments
 (0)