mud/tests/test_editor.py

420 lines
13 KiB
Python

"""Tests for the text editor."""
import pytest
from mudlib.editor import Editor
@pytest.mark.asyncio
async def test_create_editor_with_empty_buffer():
"""Editor starts with empty buffer."""
editor = Editor()
assert editor.buffer == []
assert editor.cursor == 0
assert editor.dirty is False
@pytest.mark.asyncio
async def test_create_editor_with_initial_content():
"""Editor can be initialized with content."""
editor = Editor(initial_content="line 1\nline 2\nline 3")
assert editor.buffer == ["line 1", "line 2", "line 3"]
assert editor.dirty is False
@pytest.mark.asyncio
async def test_append_line():
"""Typing text that isn't a command appends to buffer."""
editor = Editor()
response = await editor.handle_input("hello world")
assert response.output == ""
assert response.done is False
assert editor.buffer == ["hello world"]
assert editor.dirty is True
@pytest.mark.asyncio
async def test_append_multiple_lines():
"""Multiple lines can be appended."""
editor = Editor()
await editor.handle_input("line 1")
await editor.handle_input("line 2")
await editor.handle_input("line 3")
assert editor.buffer == ["line 1", "line 2", "line 3"]
assert editor.dirty is True
@pytest.mark.asyncio
async def test_view_buffer():
"""':' shows the entire buffer with line numbers."""
editor = Editor(initial_content="line 1\nline 2\nline 3")
response = await editor.handle_input(":")
assert "1 line 1" in response.output
assert "2 line 2" in response.output
assert "3 line 3" in response.output
@pytest.mark.asyncio
async def test_view_empty_buffer():
"""':' on empty buffer shows appropriate message."""
editor = Editor()
response = await editor.handle_input(":")
assert "empty" in response.output.lower() or response.output == ""
@pytest.mark.asyncio
async def test_view_specific_line():
"""':5' shows line 5 with context."""
editor = Editor(initial_content="\n".join([f"line {i}" for i in range(1, 11)]))
response = await editor.handle_input(":5")
# Should show lines 2-8 (3 lines of context on each side)
assert "5 line 5" in response.output
assert "2 line 2" in response.output
assert "8 line 8" in response.output
@pytest.mark.asyncio
async def test_view_specific_line_at_start():
"""Viewing line near start shows available context."""
editor = Editor(initial_content="line 1\nline 2\nline 3")
response = await editor.handle_input(":1")
assert "1 line 1" in response.output
@pytest.mark.asyncio
async def test_insert_at_line():
"""':i 5 text' inserts 'text' at line 5."""
editor = Editor(initial_content="\n".join([f"line {i}" for i in range(1, 6)]))
await editor.handle_input(":i 3 inserted text")
assert editor.buffer[2] == "inserted text"
assert editor.buffer[3] == "line 3"
assert len(editor.buffer) == 6
assert editor.dirty is True
@pytest.mark.asyncio
async def test_insert_at_end():
"""':i' can insert at end of buffer."""
editor = Editor(initial_content="line 1\nline 2")
await editor.handle_input(":i 3 line 3")
assert editor.buffer[2] == "line 3"
@pytest.mark.asyncio
async def test_delete_single_line():
"""':d 5' deletes line 5."""
editor = Editor(initial_content="\n".join([f"line {i}" for i in range(1, 6)]))
await editor.handle_input(":d 3")
assert editor.buffer == ["line 1", "line 2", "line 4", "line 5"]
assert editor.dirty is True
@pytest.mark.asyncio
async def test_delete_range():
"""':d 5 8' deletes lines 5 through 8."""
editor = Editor(initial_content="\n".join([f"line {i}" for i in range(1, 11)]))
await editor.handle_input(":d 3 6")
expected = ["line 1", "line 2", "line 7", "line 8", "line 9", "line 10"]
assert editor.buffer == expected
assert editor.dirty is True
@pytest.mark.asyncio
async def test_delete_invalid_line():
"""Deleting invalid line shows error."""
editor = Editor(initial_content="line 1\nline 2")
response = await editor.handle_input(":d 10")
assert "error" in response.output.lower() or "invalid" in response.output.lower()
assert editor.buffer == ["line 1", "line 2"]
@pytest.mark.asyncio
async def test_replace_line():
"""':r 5 text' replaces line 5 with 'text'."""
editor = Editor(initial_content="\n".join([f"line {i}" for i in range(1, 6)]))
await editor.handle_input(":r 3 replaced text")
assert editor.buffer[2] == "replaced text"
assert len(editor.buffer) == 5
assert editor.dirty is True
@pytest.mark.asyncio
async def test_search_and_replace_first():
"""':s old new' replaces first occurrence."""
editor = Editor(initial_content="hello world\nhello again\nhello there")
await editor.handle_input(":s hello hi")
assert editor.buffer[0] == "hi world"
assert editor.buffer[1] == "hello again"
assert editor.dirty is True
@pytest.mark.asyncio
async def test_search_and_replace_all():
"""':sa old new' replaces all occurrences."""
editor = Editor(initial_content="hello world\nhello again\nhello there")
await editor.handle_input(":sa hello hi")
assert editor.buffer[0] == "hi world"
assert editor.buffer[1] == "hi again"
assert editor.buffer[2] == "hi there"
assert editor.dirty is True
@pytest.mark.asyncio
async def test_search_replace_no_match():
"""Search/replace with no match shows message."""
editor = Editor(initial_content="hello world")
response = await editor.handle_input(":s foo bar")
assert (
"not found" in response.output.lower() or "no match" in response.output.lower()
)
@pytest.mark.asyncio
async def test_undo():
"""':u' undoes last buffer change."""
editor = Editor(initial_content="line 1\nline 2")
await editor.handle_input("line 3")
assert len(editor.buffer) == 3
response = await editor.handle_input(":u")
assert editor.buffer == ["line 1", "line 2"]
assert "undo" in response.output.lower()
@pytest.mark.asyncio
async def test_undo_multiple_times():
"""Undo can be called multiple times."""
editor = Editor(initial_content="line 1")
await editor.handle_input("line 2")
await editor.handle_input("line 3")
await editor.handle_input(":u")
await editor.handle_input(":u")
assert editor.buffer == ["line 1"]
@pytest.mark.asyncio
async def test_undo_when_empty():
"""Undo with no history shows message."""
editor = Editor()
response = await editor.handle_input(":u")
assert (
"nothing to undo" in response.output.lower()
or "no undo" in response.output.lower()
)
@pytest.mark.asyncio
async def test_save():
"""':w' calls save callback."""
saved_content = None
async def save_callback(content: str):
nonlocal saved_content
saved_content = content
editor = Editor(initial_content="line 1\nline 2", save_callback=save_callback)
await editor.handle_input("line 3")
response = await editor.handle_input(":w")
assert response.saved is True
assert response.done is False
assert saved_content == "line 1\nline 2\nline 3"
assert editor.dirty is False
@pytest.mark.asyncio
async def test_save_without_callback():
"""':w' without callback shows error."""
editor = Editor(initial_content="line 1")
response = await editor.handle_input(":w")
assert (
"cannot save" in response.output.lower() or "no save" in response.output.lower()
)
@pytest.mark.asyncio
async def test_save_and_quit():
"""':wq' saves and quits."""
saved_content = None
async def save_callback(content: str):
nonlocal saved_content
saved_content = content
editor = Editor(initial_content="line 1", save_callback=save_callback)
response = await editor.handle_input(":wq")
assert response.saved is True
assert response.done is True
assert saved_content == "line 1"
@pytest.mark.asyncio
async def test_quit_when_dirty_warns():
"""':q' when dirty warns and doesn't quit."""
editor = Editor(initial_content="line 1")
await editor.handle_input("line 2")
response = await editor.handle_input(":q")
assert response.done is False
assert "unsaved" in response.output.lower() or "save" in response.output.lower()
@pytest.mark.asyncio
async def test_quit_when_clean():
"""':q' when clean quits successfully."""
editor = Editor(initial_content="line 1")
response = await editor.handle_input(":q")
assert response.done is True
@pytest.mark.asyncio
async def test_force_quit():
"""':q!' quits without saving."""
editor = Editor(initial_content="line 1")
await editor.handle_input("line 2")
response = await editor.handle_input(":q!")
assert response.done is True
assert response.saved is False
@pytest.mark.asyncio
async def test_dot_saves_and_quits():
"""'.' on its own line saves and quits (MOO convention)."""
saved_content = None
async def save_callback(content: str):
nonlocal saved_content
saved_content = content
editor = Editor(initial_content="line 1", save_callback=save_callback)
await editor.handle_input("line 2")
response = await editor.handle_input(".")
assert response.done is True
assert response.saved is True
assert saved_content == "line 1\nline 2"
@pytest.mark.asyncio
async def test_dirty_flag_tracking():
"""Dirty flag is properly tracked."""
editor = Editor(initial_content="line 1")
assert editor.dirty is False
await editor.handle_input("line 2")
assert editor.dirty is True
# Mock save
saved_content = None
async def save_callback(content: str):
nonlocal saved_content
saved_content = content
editor.save_callback = save_callback
await editor.handle_input(":w")
assert editor.dirty is False
@pytest.mark.asyncio
async def test_help():
"""':h' shows help."""
editor = Editor()
response = await editor.handle_input(":h")
assert ":w" in response.output
assert ":q" in response.output
assert ":i" in response.output
assert ":d" in response.output
@pytest.mark.asyncio
async def test_undo_stack_size_limit():
"""Undo stack is capped at 50 entries."""
editor = Editor()
# Add 60 lines
for i in range(60):
await editor.handle_input(f"line {i}")
# Undo stack should be capped
assert len(editor.undo_stack) <= 50
@pytest.mark.asyncio
async def test_line_numbers_padded():
"""Line numbers in view are left-padded."""
editor = Editor(initial_content="\n".join([f"line {i}" for i in range(1, 12)]))
response = await editor.handle_input(":")
# Line 1 should have padding, line 11 should not have extra padding
lines = response.output.split("\n")
# Find lines with numbers
assert any(" 1 line 1" in line for line in lines)
assert any("11 line 11" in line for line in lines)
# Syntax highlighting tests
@pytest.mark.asyncio
async def test_view_buffer_with_python_includes_ansi():
"""Viewing buffer with content_type=python includes ANSI codes."""
editor = Editor(
initial_content="def foo():\n return 42",
content_type="python",
color_depth="16",
)
response = await editor.handle_input(":")
# Should contain ANSI escape codes from syntax highlighting
assert "\033[" in response.output
# Should contain the code
assert "def" in response.output
assert "foo" in response.output
@pytest.mark.asyncio
async def test_view_buffer_with_text_no_ansi():
"""Viewing buffer with content_type=text does NOT include ANSI codes."""
editor = Editor(
initial_content="plain text line 1\nplain text line 2", content_type="text"
)
response = await editor.handle_input(":")
# Should NOT contain ANSI escape codes (plain line numbers)
assert "\033[" not in response.output
# Should contain plain line numbers
assert "1 plain text line 1" in response.output
assert "2 plain text line 2" in response.output
@pytest.mark.asyncio
async def test_view_specific_line_with_python_includes_ansi():
"""Viewing a specific line with python content includes ANSI codes."""
content = "\n".join([f"def func{i}():\n return {i}" for i in range(10)])
editor = Editor(
initial_content=content,
content_type="python",
color_depth="16",
)
response = await editor.handle_input(":5")
# Should contain ANSI escape codes from syntax highlighting
assert "\033[" in response.output
# Should contain code from the context window
assert "def" in response.output
@pytest.mark.asyncio
async def test_color_depth_passed_to_highlight():
"""Color depth is passed through to highlight function."""
editor = Editor(
initial_content="x = 1\ny = 2",
content_type="python",
color_depth="256",
)
response = await editor.handle_input(":")
# Should contain ANSI codes (256-color formatter)
assert "\033[" in response.output
# Should contain the code
assert "x" in response.output
assert "y" in response.output
@pytest.mark.asyncio
async def test_empty_buffer_with_python_content_type():
"""Empty buffer with content_type=python still works."""
editor = Editor(content_type="python", color_depth="16")
response = await editor.handle_input(":")
# Should show empty buffer message
assert "empty" in response.output.lower()