Add editor class with buffer, commands, and undo
This commit is contained in:
parent
5e255f192c
commit
23507d0e70
2 changed files with 719 additions and 0 deletions
373
src/mudlib/editor.py
Normal file
373
src/mudlib/editor.py
Normal file
|
|
@ -0,0 +1,373 @@
|
|||
"""Text editor for in-game content editing."""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class EditorResponse:
|
||||
"""Response from editor input handling."""
|
||||
|
||||
output: str # text to send back to the player
|
||||
done: bool # True if editor should be closed (quit/save-and-quit)
|
||||
saved: bool = False # True if save was performed
|
||||
|
||||
|
||||
@dataclass
|
||||
class Editor:
|
||||
"""Line-based text editor for in-game content.
|
||||
|
||||
Doesn't know about telnet — takes string input, returns string output.
|
||||
Will be integrated into the shell loop in a later step.
|
||||
"""
|
||||
|
||||
buffer: list[str] = field(default_factory=list)
|
||||
cursor: int = 0 # current line (0-indexed internally)
|
||||
undo_stack: list[list[str]] = field(default_factory=list)
|
||||
save_callback: Callable[[str], Awaitable[None]] | None = None
|
||||
content_type: str = "text" # hint for syntax highlighting later
|
||||
dirty: bool = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
initial_content: str = "",
|
||||
save_callback: Callable[[str], Awaitable[None]] | None = None,
|
||||
content_type: str = "text",
|
||||
):
|
||||
"""Initialize editor with optional content."""
|
||||
self.save_callback = save_callback
|
||||
self.content_type = content_type
|
||||
self.cursor = 0
|
||||
self.undo_stack = []
|
||||
self.dirty = False
|
||||
|
||||
if initial_content:
|
||||
self.buffer = initial_content.split("\n")
|
||||
else:
|
||||
self.buffer = []
|
||||
|
||||
def _save_undo_state(self) -> None:
|
||||
"""Save current buffer state to undo stack."""
|
||||
self.undo_stack.append(self.buffer.copy())
|
||||
# Cap undo stack at 50 entries
|
||||
if len(self.undo_stack) > 50:
|
||||
self.undo_stack.pop(0)
|
||||
|
||||
def _mark_dirty(self) -> None:
|
||||
"""Mark buffer as modified."""
|
||||
self.dirty = True
|
||||
|
||||
def _view_buffer(self) -> str:
|
||||
"""Return formatted view of entire buffer."""
|
||||
if not self.buffer:
|
||||
return "(empty buffer)"
|
||||
|
||||
# Calculate padding for line numbers
|
||||
max_line_num = len(self.buffer)
|
||||
padding = len(str(max_line_num))
|
||||
|
||||
lines = []
|
||||
for i, line in enumerate(self.buffer, 1):
|
||||
lines.append(f"{i:>{padding}} {line}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _view_line(self, line_num: int) -> str:
|
||||
"""Return formatted view of specific line with context."""
|
||||
if line_num < 1 or line_num > len(self.buffer):
|
||||
return f"Error: Line {line_num} does not exist"
|
||||
|
||||
# Show 3 lines of context on each side
|
||||
context = 3
|
||||
start = max(0, line_num - 1 - context)
|
||||
end = min(len(self.buffer), line_num + context)
|
||||
|
||||
# Calculate padding for line numbers
|
||||
max_line_num = end
|
||||
padding = len(str(max_line_num))
|
||||
|
||||
lines = []
|
||||
for i in range(start, end):
|
||||
display_num = i + 1
|
||||
lines.append(f"{display_num:>{padding}} {self.buffer[i]}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _insert_line(self, line_num: int, text: str) -> str:
|
||||
"""Insert text at specified line number."""
|
||||
if line_num < 1:
|
||||
return "Error: Line number must be >= 1"
|
||||
|
||||
self._save_undo_state()
|
||||
|
||||
# Insert at end if line_num is beyond buffer
|
||||
if line_num > len(self.buffer):
|
||||
self.buffer.append(text)
|
||||
else:
|
||||
self.buffer.insert(line_num - 1, text)
|
||||
|
||||
self._mark_dirty()
|
||||
return f"Inserted at line {line_num}"
|
||||
|
||||
def _delete_line(self, start: int, end: int | None = None) -> str:
|
||||
"""Delete line(s) at specified range."""
|
||||
if start < 1 or start > len(self.buffer):
|
||||
return f"Error: Line {start} does not exist"
|
||||
|
||||
self._save_undo_state()
|
||||
|
||||
if end is None:
|
||||
# Delete single line
|
||||
self.buffer.pop(start - 1)
|
||||
self._mark_dirty()
|
||||
return f"Deleted line {start}"
|
||||
else:
|
||||
# Delete range
|
||||
if end < start or end > len(self.buffer):
|
||||
return f"Error: Invalid range {start}-{end}"
|
||||
|
||||
# Delete from start to end (inclusive)
|
||||
del self.buffer[start - 1 : end]
|
||||
self._mark_dirty()
|
||||
return f"Deleted lines {start}-{end}"
|
||||
|
||||
def _replace_line(self, line_num: int, text: str) -> str:
|
||||
"""Replace line at specified number with new text."""
|
||||
if line_num < 1 or line_num > len(self.buffer):
|
||||
return f"Error: Line {line_num} does not exist"
|
||||
|
||||
self._save_undo_state()
|
||||
self.buffer[line_num - 1] = text
|
||||
self._mark_dirty()
|
||||
return f"Replaced line {line_num}"
|
||||
|
||||
def _search_replace(self, old: str, new: str, replace_all: bool = False) -> str:
|
||||
"""Search and replace text in buffer."""
|
||||
self._save_undo_state()
|
||||
count = 0
|
||||
|
||||
for i, line in enumerate(self.buffer):
|
||||
if old in line:
|
||||
if replace_all:
|
||||
self.buffer[i] = line.replace(old, new)
|
||||
count += line.count(old)
|
||||
else:
|
||||
self.buffer[i] = line.replace(old, new, 1)
|
||||
count = 1
|
||||
self._mark_dirty()
|
||||
return f"Replaced '{old}' with '{new}' on line {i + 1}"
|
||||
|
||||
if count > 0:
|
||||
self._mark_dirty()
|
||||
return f"Replaced {count} occurrence(s) of '{old}' with '{new}'"
|
||||
else:
|
||||
# Restore undo state since nothing changed
|
||||
self.undo_stack.pop()
|
||||
return f"Not found: '{old}'"
|
||||
|
||||
def _undo(self) -> str:
|
||||
"""Undo last buffer change."""
|
||||
if not self.undo_stack:
|
||||
return "Nothing to undo"
|
||||
|
||||
self.buffer = self.undo_stack.pop()
|
||||
# Undo might restore clean state, but we'll keep dirty flag
|
||||
# (conservative approach - user can save to clear it)
|
||||
return "Undone last change"
|
||||
|
||||
async def _save(self) -> str:
|
||||
"""Save buffer content via callback."""
|
||||
if self.save_callback is None:
|
||||
return "Error: Cannot save (no save callback configured)"
|
||||
|
||||
content = "\n".join(self.buffer)
|
||||
await self.save_callback(content)
|
||||
self.dirty = False
|
||||
return "Saved"
|
||||
|
||||
def _help(self) -> str:
|
||||
"""Return help text."""
|
||||
return """Editor Commands:
|
||||
: - view entire buffer with line numbers
|
||||
:N - view line N with context
|
||||
:h - show this help
|
||||
:i N text - insert 'text' at line N
|
||||
:d N - delete line N
|
||||
:d N M - delete lines N through M
|
||||
:r N text - replace line N with 'text'
|
||||
:s old new - search and replace first occurrence
|
||||
:sa old new - search and replace all occurrences
|
||||
:w - save
|
||||
:wq - save and quit
|
||||
:q - quit (warns if unsaved changes)
|
||||
:q! - force quit without saving
|
||||
:u - undo last change
|
||||
. - save and quit (MOO convention)
|
||||
|
||||
Any line not starting with : is appended to the buffer."""
|
||||
|
||||
async def handle_input(self, line: str) -> EditorResponse:
|
||||
"""Handle editor input and return response."""
|
||||
# Special case: dot on its own saves and quits
|
||||
if line == ".":
|
||||
if self.save_callback is None:
|
||||
return EditorResponse(
|
||||
output="Error: Cannot save (no save callback configured)",
|
||||
done=False,
|
||||
)
|
||||
save_msg = await self._save()
|
||||
return EditorResponse(output=save_msg, done=True, saved=True)
|
||||
|
||||
# Check if it's a command
|
||||
if not line.startswith(":"):
|
||||
# Append to buffer
|
||||
self._save_undo_state()
|
||||
self.buffer.append(line)
|
||||
self._mark_dirty()
|
||||
return EditorResponse(output="", done=False)
|
||||
|
||||
# Parse command
|
||||
cmd = line[1:].strip()
|
||||
|
||||
# Empty command - view buffer
|
||||
if not cmd:
|
||||
output = self._view_buffer()
|
||||
return EditorResponse(output=output, done=False)
|
||||
|
||||
# Check for numeric line view
|
||||
if cmd.isdigit():
|
||||
line_num = int(cmd)
|
||||
output = self._view_line(line_num)
|
||||
return EditorResponse(output=output, done=False)
|
||||
|
||||
# Parse command parts
|
||||
parts = cmd.split(None, 1)
|
||||
command = parts[0]
|
||||
args = parts[1] if len(parts) > 1 else ""
|
||||
|
||||
# Help
|
||||
if command == "h":
|
||||
output = self._help()
|
||||
return EditorResponse(output=output, done=False)
|
||||
|
||||
# Insert
|
||||
if command == "i":
|
||||
if not args:
|
||||
return EditorResponse(
|
||||
output="Error: :i requires line number and text", done=False
|
||||
)
|
||||
parts = args.split(None, 1)
|
||||
if len(parts) < 2:
|
||||
return EditorResponse(
|
||||
output="Error: :i requires line number and text", done=False
|
||||
)
|
||||
try:
|
||||
line_num = int(parts[0])
|
||||
text = parts[1]
|
||||
output = self._insert_line(line_num, text)
|
||||
return EditorResponse(output=output, done=False)
|
||||
except ValueError:
|
||||
return EditorResponse(output="Error: Invalid line number", done=False)
|
||||
|
||||
# Delete
|
||||
if command == "d":
|
||||
if not args:
|
||||
return EditorResponse(
|
||||
output="Error: :d requires line number(s)", done=False
|
||||
)
|
||||
parts = args.split()
|
||||
try:
|
||||
start = int(parts[0])
|
||||
end = int(parts[1]) if len(parts) > 1 else None
|
||||
output = self._delete_line(start, end)
|
||||
return EditorResponse(output=output, done=False)
|
||||
except (ValueError, IndexError):
|
||||
return EditorResponse(
|
||||
output="Error: Invalid line number(s)", done=False
|
||||
)
|
||||
|
||||
# Replace
|
||||
if command == "r":
|
||||
if not args:
|
||||
return EditorResponse(
|
||||
output="Error: :r requires line number and text", done=False
|
||||
)
|
||||
parts = args.split(None, 1)
|
||||
if len(parts) < 2:
|
||||
return EditorResponse(
|
||||
output="Error: :r requires line number and text", done=False
|
||||
)
|
||||
try:
|
||||
line_num = int(parts[0])
|
||||
text = parts[1]
|
||||
output = self._replace_line(line_num, text)
|
||||
return EditorResponse(output=output, done=False)
|
||||
except ValueError:
|
||||
return EditorResponse(output="Error: Invalid line number", done=False)
|
||||
|
||||
# Search and replace
|
||||
if command == "s":
|
||||
parts = args.split(None, 1)
|
||||
if len(parts) < 2:
|
||||
return EditorResponse(
|
||||
output="Error: :s requires old and new text", done=False
|
||||
)
|
||||
old_new = parts[1].split(None, 1) if len(parts) > 1 else []
|
||||
if len(old_new) < 2:
|
||||
# Try parsing the whole args as "old new"
|
||||
split_args = args.split(None, 1)
|
||||
if len(split_args) < 2:
|
||||
return EditorResponse(
|
||||
output="Error: :s requires old and new text", done=False
|
||||
)
|
||||
old = split_args[0]
|
||||
new = split_args[1]
|
||||
else:
|
||||
old = parts[0]
|
||||
new = old_new[1] if len(old_new) > 1 else old_new[0]
|
||||
output = self._search_replace(old, new, replace_all=False)
|
||||
return EditorResponse(output=output, done=False)
|
||||
|
||||
# Search and replace all
|
||||
if command == "sa":
|
||||
parts = args.split(None, 1)
|
||||
if len(parts) < 2:
|
||||
return EditorResponse(
|
||||
output="Error: :sa requires old and new text", done=False
|
||||
)
|
||||
old = parts[0]
|
||||
new = parts[1]
|
||||
output = self._search_replace(old, new, replace_all=True)
|
||||
return EditorResponse(output=output, done=False)
|
||||
|
||||
# Undo
|
||||
if command == "u":
|
||||
output = self._undo()
|
||||
return EditorResponse(output=output, done=False)
|
||||
|
||||
# Save
|
||||
if command == "w":
|
||||
output = await self._save()
|
||||
return EditorResponse(output=output, done=False, saved=True)
|
||||
|
||||
# Save and quit
|
||||
if command == "wq":
|
||||
output = await self._save()
|
||||
return EditorResponse(output=output, done=True, saved=True)
|
||||
|
||||
# Quit
|
||||
if command == "q":
|
||||
if self.dirty:
|
||||
return EditorResponse(
|
||||
output=(
|
||||
"Unsaved changes! Use :wq to save and quit, "
|
||||
"or :q! to force quit"
|
||||
),
|
||||
done=False,
|
||||
)
|
||||
return EditorResponse(output="", done=True)
|
||||
|
||||
# Force quit
|
||||
if command == "q!":
|
||||
return EditorResponse(output="", done=True)
|
||||
|
||||
# Unknown command
|
||||
return EditorResponse(output=f"Unknown command: :{command}", done=False)
|
||||
346
tests/test_editor.py
Normal file
346
tests/test_editor.py
Normal file
|
|
@ -0,0 +1,346 @@
|
|||
"""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)
|
||||
Loading…
Reference in a new issue