Skip to content

Commit 53f4a6e

Browse files
committed
tests(textframe): Add TextFrame ASCII frame prototype
why: Validate Syrupy snapshot testing for terminal frame visualization what: - Add TextFrame dataclass with content overflow detection - Add ContentOverflowError with Reality vs Mask visual - Add TextFrameSerializer extending AmberDataSerializer - Add TextFrameExtension for Syrupy integration - Add parametrized tests for rendering and nested serialization
1 parent c427cf7 commit 53f4a6e

File tree

5 files changed

+358
-0
lines changed

5 files changed

+358
-0
lines changed

tests/textframe/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
"""TextFrame ASCII terminal frame testing prototype."""
2+
3+
from __future__ import annotations

tests/textframe/conftest.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
"""Pytest configuration for TextFrame tests."""
2+
3+
from __future__ import annotations
4+
5+
import pytest
6+
from syrupy.assertion import SnapshotAssertion
7+
8+
from .plugin import TextFrameExtension
9+
10+
11+
@pytest.fixture
12+
def snapshot(snapshot: SnapshotAssertion) -> SnapshotAssertion:
13+
"""Override default snapshot fixture to use TextFrameExtension.
14+
15+
Parameters
16+
----------
17+
snapshot : SnapshotAssertion
18+
The default syrupy snapshot fixture.
19+
20+
Returns
21+
-------
22+
SnapshotAssertion
23+
Snapshot configured with TextFrame serialization.
24+
"""
25+
return snapshot.use_extension(TextFrameExtension)

tests/textframe/core.py

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
"""TextFrame - ASCII terminal frame simulator.
2+
3+
This module provides a fixed-size ASCII frame for visualizing terminal content
4+
with overflow detection and diagnostic rendering.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import typing as t
10+
from dataclasses import dataclass, field
11+
12+
if t.TYPE_CHECKING:
13+
from collections.abc import Sequence
14+
15+
16+
class ContentOverflowError(ValueError):
17+
"""Raised when content does not fit into the configured frame dimensions.
18+
19+
Attributes
20+
----------
21+
overflow_visual : str
22+
A diagnostic ASCII visualization showing the content and a mask
23+
of the valid/invalid areas.
24+
"""
25+
26+
def __init__(self, message: str, overflow_visual: str) -> None:
27+
super().__init__(message)
28+
self.overflow_visual = overflow_visual
29+
30+
31+
@dataclass(slots=True)
32+
class TextFrame:
33+
"""A fixed-size ASCII terminal frame simulator.
34+
35+
Attributes
36+
----------
37+
content_width : int
38+
Width of the inner content area.
39+
content_height : int
40+
Height of the inner content area.
41+
fill_char : str
42+
Character to pad empty space. Defaults to space.
43+
content : list[str]
44+
The current content lines.
45+
46+
Examples
47+
--------
48+
>>> frame = TextFrame(content_width=10, content_height=2)
49+
>>> frame.set_content(["hello", "world"])
50+
>>> print(frame.render())
51+
+----------+
52+
|hello |
53+
|world |
54+
+----------+
55+
"""
56+
57+
content_width: int
58+
content_height: int
59+
fill_char: str = " "
60+
content: list[str] = field(default_factory=list)
61+
62+
def set_content(self, lines: Sequence[str]) -> None:
63+
"""Set content, applying validation logic.
64+
65+
Parameters
66+
----------
67+
lines : Sequence[str]
68+
Lines of content to set.
69+
70+
Raises
71+
------
72+
ContentOverflowError
73+
If content exceeds frame dimensions.
74+
"""
75+
input_lines = list(lines)
76+
77+
# Calculate dimensions
78+
max_w = max((len(line) for line in input_lines), default=0)
79+
max_h = len(input_lines)
80+
81+
is_overflow = max_w > self.content_width or max_h > self.content_height
82+
83+
if is_overflow:
84+
visual = self._render_overflow(input_lines, max_w, max_h)
85+
raise ContentOverflowError(
86+
f"Content ({max_w}x{max_h}) exceeds frame "
87+
f"({self.content_width}x{self.content_height})",
88+
overflow_visual=visual,
89+
)
90+
91+
self.content = input_lines
92+
93+
def render(self) -> str:
94+
"""Render the frame as ASCII art.
95+
96+
Returns
97+
-------
98+
str
99+
The rendered frame with borders.
100+
"""
101+
return self._draw_frame(self.content, self.content_width, self.content_height)
102+
103+
def _render_overflow(self, lines: list[str], max_w: int, max_h: int) -> str:
104+
"""Render the diagnostic overflow view (Reality vs Mask).
105+
106+
Parameters
107+
----------
108+
lines : list[str]
109+
The overflow content lines.
110+
max_w : int
111+
Maximum width of content.
112+
max_h : int
113+
Maximum height of content.
114+
115+
Returns
116+
-------
117+
str
118+
A visualization showing content frame and valid/invalid mask.
119+
"""
120+
display_w = max(self.content_width, max_w)
121+
display_h = max(self.content_height, max_h)
122+
123+
# 1. Reality Frame - shows actual content
124+
reality = self._draw_frame(lines, display_w, display_h)
125+
126+
# 2. Mask Frame - shows valid vs invalid areas
127+
mask_lines = []
128+
for r in range(display_h):
129+
row = []
130+
for c in range(display_w):
131+
is_valid = r < self.content_height and c < self.content_width
132+
row.append(" " if is_valid else ".")
133+
mask_lines.append("".join(row))
134+
135+
mask = self._draw_frame(mask_lines, display_w, display_h)
136+
return f"{reality}\n{mask}"
137+
138+
def _draw_frame(self, lines: list[str], w: int, h: int) -> str:
139+
"""Draw a bordered frame around content.
140+
141+
Parameters
142+
----------
143+
lines : list[str]
144+
Content lines to frame.
145+
w : int
146+
Frame width (excluding borders).
147+
h : int
148+
Frame height (excluding borders).
149+
150+
Returns
151+
-------
152+
str
153+
Bordered ASCII frame.
154+
"""
155+
border = f"+{'-' * w}+"
156+
body = []
157+
for r in range(h):
158+
line = lines[r] if r < len(lines) else ""
159+
body.append(f"|{line.ljust(w, self.fill_char)}|")
160+
return "\n".join([border, *body, border])

tests/textframe/plugin.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""Syrupy snapshot extension for TextFrame objects.
2+
3+
This module provides a custom serializer that renders TextFrame objects
4+
and ContentOverflowError exceptions as ASCII art in snapshot files.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import typing as t
10+
11+
from syrupy.extensions.amber import AmberSnapshotExtension
12+
from syrupy.extensions.amber.serializer import AmberDataSerializer
13+
14+
from .core import ContentOverflowError, TextFrame
15+
16+
17+
class TextFrameSerializer(AmberDataSerializer):
18+
"""Custom serializer that renders TextFrame objects as ASCII frames.
19+
20+
This serializer intercepts TextFrame and ContentOverflowError objects,
21+
converting them to their ASCII representation before passing them
22+
to the base serializer for formatting.
23+
24+
Notes
25+
-----
26+
By subclassing AmberDataSerializer, we ensure TextFrame objects are
27+
correctly rendered even when nested inside lists, dicts, or other
28+
data structures.
29+
"""
30+
31+
@classmethod
32+
def _serialize(
33+
cls,
34+
data: t.Any,
35+
*,
36+
depth: int = 0,
37+
**kwargs: t.Any,
38+
) -> str:
39+
"""Serialize data, converting TextFrame objects to ASCII.
40+
41+
Parameters
42+
----------
43+
data : Any
44+
The data to serialize.
45+
depth : int
46+
Current indentation depth.
47+
**kwargs : Any
48+
Additional serialization options.
49+
50+
Returns
51+
-------
52+
str
53+
Serialized representation.
54+
"""
55+
# Intercept TextFrame: Render it to ASCII
56+
if isinstance(data, TextFrame):
57+
return super()._serialize(data.render(), depth=depth, **kwargs)
58+
59+
# Intercept ContentOverflowError: Render the visual diff
60+
if isinstance(data, ContentOverflowError):
61+
return super()._serialize(data.overflow_visual, depth=depth, **kwargs)
62+
63+
# Default behavior for all other types
64+
return super()._serialize(data, depth=depth, **kwargs)
65+
66+
67+
class TextFrameExtension(AmberSnapshotExtension):
68+
"""Syrupy extension that uses the TextFrameSerializer."""
69+
70+
serializer_class = TextFrameSerializer

tests/textframe/test_core.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
"""Integration tests for TextFrame Syrupy snapshot testing."""
2+
3+
from __future__ import annotations
4+
5+
import typing as t
6+
from contextlib import nullcontext as does_not_raise
7+
8+
import pytest
9+
from syrupy.assertion import SnapshotAssertion
10+
11+
from .core import ContentOverflowError, TextFrame
12+
13+
if t.TYPE_CHECKING:
14+
from collections.abc import Sequence
15+
16+
17+
class Case(t.NamedTuple):
18+
"""Test case definition for parametrized tests."""
19+
20+
id: str
21+
width: int
22+
height: int
23+
lines: Sequence[str]
24+
expected_exception: type[BaseException] | None
25+
26+
27+
CASES: tuple[Case, ...] = (
28+
Case(
29+
id="basic_success",
30+
width=10,
31+
height=2,
32+
lines=["hello", "world"],
33+
expected_exception=None,
34+
),
35+
Case(
36+
id="overflow_width",
37+
width=10,
38+
height=2,
39+
lines=["this line is too long", "row 2", "row 3"],
40+
expected_exception=ContentOverflowError,
41+
),
42+
Case(
43+
id="empty_frame",
44+
width=5,
45+
height=2,
46+
lines=[],
47+
expected_exception=None,
48+
),
49+
)
50+
51+
52+
@pytest.mark.parametrize("case", CASES, ids=lambda c: c.id)
53+
def test_frame_rendering(case: Case, snapshot: SnapshotAssertion) -> None:
54+
"""Verify TextFrame rendering with Syrupy snapshot.
55+
56+
Parameters
57+
----------
58+
case : Case
59+
Test case with frame dimensions and content.
60+
snapshot : SnapshotAssertion
61+
Syrupy snapshot fixture configured with TextFrameExtension.
62+
"""
63+
frame = TextFrame(content_width=case.width, content_height=case.height)
64+
65+
ctx: t.Any = (
66+
pytest.raises(case.expected_exception)
67+
if case.expected_exception
68+
else does_not_raise()
69+
)
70+
71+
with ctx as exc_info:
72+
frame.set_content(case.lines)
73+
74+
if case.expected_exception:
75+
# The Plugin detects the Exception type and renders the ASCII visual diff
76+
assert exc_info.value == snapshot
77+
else:
78+
# The Plugin detects the TextFrame type and renders the ASCII frame
79+
assert frame == snapshot
80+
81+
82+
def test_nested_serialization(snapshot: SnapshotAssertion) -> None:
83+
"""Verify that nested TextFrame objects serialize correctly.
84+
85+
This demonstrates that the custom serializer works when TextFrame
86+
objects are inside collections (lists, dicts).
87+
88+
Parameters
89+
----------
90+
snapshot : SnapshotAssertion
91+
Syrupy snapshot fixture configured with TextFrameExtension.
92+
"""
93+
f1 = TextFrame(content_width=5, content_height=1)
94+
f1.set_content(["one"])
95+
96+
f2 = TextFrame(content_width=5, content_height=1)
97+
f2.set_content(["two"])
98+
99+
# The serializer will find the frames inside this list and render them
100+
assert [f1, f2] == snapshot

0 commit comments

Comments
 (0)