Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ dev = [
"pytest-mock",
"pytest-watcher",
"pytest-xdist",
"syrupy",
# Coverage
"codecov",
"coverage",
Expand Down
3 changes: 3 additions & 0 deletions tests/textframe/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""TextFrame ASCII terminal frame testing prototype."""

from __future__ import annotations
45 changes: 45 additions & 0 deletions tests/textframe/__snapshots__/test_core.ambr
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# serializer version: 1
# name: test_frame_rendering[basic_success]
'''
+----------+
|hello |
|world |
+----------+
'''
# ---
# name: test_frame_rendering[empty_frame]
'''
+-----+
| |
| |
+-----+
'''
# ---
# name: test_frame_rendering[overflow_width]
'''
+---------------------+
|this line is too long|
|row 2 |
|row 3 |
+---------------------+
+---------------------+
| ...........|
| ...........|
|.....................|
+---------------------+
'''
# ---
# name: test_nested_serialization
list([
'''
+-----+
|one |
+-----+
''',
'''
+-----+
|two |
+-----+
''',
])
# ---
25 changes: 25 additions & 0 deletions tests/textframe/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""Pytest configuration for TextFrame tests."""

from __future__ import annotations

import pytest
from syrupy.assertion import SnapshotAssertion

from .plugin import TextFrameExtension


@pytest.fixture
def snapshot(snapshot: SnapshotAssertion) -> SnapshotAssertion:
"""Override default snapshot fixture to use TextFrameExtension.

Parameters
----------
snapshot : SnapshotAssertion
The default syrupy snapshot fixture.

Returns
-------
SnapshotAssertion
Snapshot configured with TextFrame serialization.
"""
return snapshot.use_extension(TextFrameExtension)
160 changes: 160 additions & 0 deletions tests/textframe/core.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
"""TextFrame - ASCII terminal frame simulator.

This module provides a fixed-size ASCII frame for visualizing terminal content
with overflow detection and diagnostic rendering.
"""

from __future__ import annotations

import typing as t
from dataclasses import dataclass, field

if t.TYPE_CHECKING:
from collections.abc import Sequence


class ContentOverflowError(ValueError):
"""Raised when content does not fit into the configured frame dimensions.

Attributes
----------
overflow_visual : str
A diagnostic ASCII visualization showing the content and a mask
of the valid/invalid areas.
"""

def __init__(self, message: str, overflow_visual: str) -> None:
super().__init__(message)
self.overflow_visual = overflow_visual


@dataclass(slots=True)
class TextFrame:
"""A fixed-size ASCII terminal frame simulator.

Attributes
----------
content_width : int
Width of the inner content area.
content_height : int
Height of the inner content area.
fill_char : str
Character to pad empty space. Defaults to space.
content : list[str]
The current content lines.

Examples
--------
>>> frame = TextFrame(content_width=10, content_height=2)
>>> frame.set_content(["hello", "world"])
>>> print(frame.render())
+----------+
|hello |
|world |
+----------+
"""

content_width: int
content_height: int
fill_char: str = " "
content: list[str] = field(default_factory=list)

def set_content(self, lines: Sequence[str]) -> None:
"""Set content, applying validation logic.

Parameters
----------
lines : Sequence[str]
Lines of content to set.

Raises
------
ContentOverflowError
If content exceeds frame dimensions.
"""
input_lines = list(lines)

# Calculate dimensions
max_w = max((len(line) for line in input_lines), default=0)
max_h = len(input_lines)

is_overflow = max_w > self.content_width or max_h > self.content_height

if is_overflow:
visual = self._render_overflow(input_lines, max_w, max_h)
raise ContentOverflowError(
f"Content ({max_w}x{max_h}) exceeds frame "
f"({self.content_width}x{self.content_height})",
overflow_visual=visual,
)

self.content = input_lines

def render(self) -> str:
"""Render the frame as ASCII art.

Returns
-------
str
The rendered frame with borders.
"""
return self._draw_frame(self.content, self.content_width, self.content_height)

def _render_overflow(self, lines: list[str], max_w: int, max_h: int) -> str:
"""Render the diagnostic overflow view (Reality vs Mask).

Parameters
----------
lines : list[str]
The overflow content lines.
max_w : int
Maximum width of content.
max_h : int
Maximum height of content.

Returns
-------
str
A visualization showing content frame and valid/invalid mask.
"""
display_w = max(self.content_width, max_w)
display_h = max(self.content_height, max_h)

# 1. Reality Frame - shows actual content
reality = self._draw_frame(lines, display_w, display_h)

# 2. Mask Frame - shows valid vs invalid areas
mask_lines = []
for r in range(display_h):
row = []
for c in range(display_w):
is_valid = r < self.content_height and c < self.content_width
row.append(" " if is_valid else ".")
mask_lines.append("".join(row))

mask = self._draw_frame(mask_lines, display_w, display_h)
return f"{reality}\n{mask}"

def _draw_frame(self, lines: list[str], w: int, h: int) -> str:
"""Draw a bordered frame around content.

Parameters
----------
lines : list[str]
Content lines to frame.
w : int
Frame width (excluding borders).
h : int
Frame height (excluding borders).

Returns
-------
str
Bordered ASCII frame.
"""
border = f"+{'-' * w}+"
body = []
for r in range(h):
line = lines[r] if r < len(lines) else ""
body.append(f"|{line.ljust(w, self.fill_char)}|")
return "\n".join([border, *body, border])
70 changes: 70 additions & 0 deletions tests/textframe/plugin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""Syrupy snapshot extension for TextFrame objects.

This module provides a custom serializer that renders TextFrame objects
and ContentOverflowError exceptions as ASCII art in snapshot files.
"""

from __future__ import annotations

import typing as t

from syrupy.extensions.amber import AmberSnapshotExtension
from syrupy.extensions.amber.serializer import AmberDataSerializer

from .core import ContentOverflowError, TextFrame


class TextFrameSerializer(AmberDataSerializer):
"""Custom serializer that renders TextFrame objects as ASCII frames.

This serializer intercepts TextFrame and ContentOverflowError objects,
converting them to their ASCII representation before passing them
to the base serializer for formatting.

Notes
-----
By subclassing AmberDataSerializer, we ensure TextFrame objects are
correctly rendered even when nested inside lists, dicts, or other
data structures.
"""

@classmethod
def _serialize(
cls,
data: t.Any,
*,
depth: int = 0,
**kwargs: t.Any,
) -> str:
"""Serialize data, converting TextFrame objects to ASCII.

Parameters
----------
data : Any
The data to serialize.
depth : int
Current indentation depth.
**kwargs : Any
Additional serialization options.

Returns
-------
str
Serialized representation.
"""
# Intercept TextFrame: Render it to ASCII
if isinstance(data, TextFrame):
return super()._serialize(data.render(), depth=depth, **kwargs)

# Intercept ContentOverflowError: Render the visual diff
if isinstance(data, ContentOverflowError):
return super()._serialize(data.overflow_visual, depth=depth, **kwargs)

# Default behavior for all other types
return super()._serialize(data, depth=depth, **kwargs)


class TextFrameExtension(AmberSnapshotExtension):
"""Syrupy extension that uses the TextFrameSerializer."""

serializer_class = TextFrameSerializer
Loading
Loading