Add editor mode shell integration and edit command
Integrates the Editor class into the MUD server's shell loop, allowing players to enter and use the text editor from the game. Changes: - Add editor field to Player dataclass - Modify shell input loop to check player mode and route to editor - Add edit command to enter editor mode from normal mode - Use inp (not command.strip()) for editor to preserve indentation - Show line-numbered prompt in editor mode - Pop mode and clear editor when done=True - Add comprehensive integration tests - Fix test isolation issue in test_movement_updates_position
This commit is contained in:
parent
23507d0e70
commit
a799b6716c
5 changed files with 225 additions and 5 deletions
28
src/mudlib/commands/edit.py
Normal file
28
src/mudlib/commands/edit.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
"""Edit command for entering the text editor."""
|
||||
|
||||
from mudlib.commands import CommandDefinition, register
|
||||
from mudlib.editor import Editor
|
||||
from mudlib.player import Player
|
||||
|
||||
|
||||
async def cmd_edit(player: Player, args: str) -> None:
|
||||
"""Enter the text editor.
|
||||
|
||||
Args:
|
||||
player: The player executing the command
|
||||
args: Command arguments (unused for now)
|
||||
"""
|
||||
|
||||
async def save_callback(content: str) -> None:
|
||||
await player.send("Content saved.\r\n")
|
||||
|
||||
player.editor = Editor(
|
||||
save_callback=save_callback,
|
||||
content_type="text",
|
||||
)
|
||||
player.mode_stack.append("editor")
|
||||
await player.send("Entering editor. Type :h for help.\r\n")
|
||||
|
||||
|
||||
# Register the edit command
|
||||
register(CommandDefinition("edit", cmd_edit, mode="normal"))
|
||||
|
|
@ -1,11 +1,16 @@
|
|||
"""Player state and registry."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from mudlib.caps import ClientCaps
|
||||
from mudlib.entity import Entity
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mudlib.editor import Editor
|
||||
|
||||
|
||||
@dataclass
|
||||
class Player(Entity):
|
||||
|
|
@ -16,6 +21,7 @@ class Player(Entity):
|
|||
flying: bool = False
|
||||
mode_stack: list[str] = field(default_factory=lambda: ["normal"])
|
||||
caps: ClientCaps = field(default_factory=lambda: ClientCaps(ansi=True))
|
||||
editor: Editor | None = None
|
||||
|
||||
@property
|
||||
def mode(self) -> str:
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import telnetlib3
|
|||
from telnetlib3.server_shell import readline2
|
||||
|
||||
import mudlib.commands
|
||||
import mudlib.commands.edit
|
||||
import mudlib.commands.fly
|
||||
import mudlib.commands.look
|
||||
import mudlib.commands.movement
|
||||
|
|
@ -297,7 +298,11 @@ async def shell(
|
|||
# Command loop
|
||||
try:
|
||||
while not _writer.is_closing():
|
||||
_writer.write("mud> ")
|
||||
# Show appropriate prompt based on mode
|
||||
if player.mode == "editor" and player.editor:
|
||||
_writer.write(f" {player.editor.cursor + 1}> ")
|
||||
else:
|
||||
_writer.write("mud> ")
|
||||
await _writer.drain()
|
||||
|
||||
inp = await readline2(_reader, _writer)
|
||||
|
|
@ -305,11 +310,20 @@ async def shell(
|
|||
break
|
||||
|
||||
command = inp.strip()
|
||||
if not command:
|
||||
if not command and player.mode != "editor":
|
||||
continue
|
||||
|
||||
# Dispatch command
|
||||
await mudlib.commands.dispatch(player, command)
|
||||
# Handle editor mode
|
||||
if player.mode == "editor" and player.editor:
|
||||
response = await player.editor.handle_input(inp)
|
||||
if response.output:
|
||||
await player.send(response.output)
|
||||
if response.done:
|
||||
player.editor = None
|
||||
player.mode_stack.pop()
|
||||
else:
|
||||
# Dispatch normal command
|
||||
await mudlib.commands.dispatch(player, command)
|
||||
|
||||
# Check if writer was closed by quit command
|
||||
if _writer.is_closing():
|
||||
|
|
|
|||
|
|
@ -138,6 +138,11 @@ async def test_movement_updates_position(player, mock_world):
|
|||
movement.world = mock_world
|
||||
look.world = mock_world
|
||||
|
||||
# Clear players registry to avoid test pollution
|
||||
from mudlib.player import players
|
||||
|
||||
players.clear()
|
||||
|
||||
original_x, original_y = player.x, player.y
|
||||
await movement.move_north(player, "")
|
||||
|
||||
|
|
|
|||
167
tests/test_editor_integration.py
Normal file
167
tests/test_editor_integration.py
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
"""Tests for editor integration with the shell and command system."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from mudlib import commands
|
||||
from mudlib.commands.edit import cmd_edit
|
||||
from mudlib.editor import Editor
|
||||
from mudlib.player import Player
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_writer():
|
||||
writer = MagicMock()
|
||||
writer.write = MagicMock()
|
||||
writer.drain = AsyncMock()
|
||||
return writer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_reader():
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def player(mock_reader, mock_writer):
|
||||
return Player(name="TestPlayer", x=5, y=5, reader=mock_reader, writer=mock_writer)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_command_enters_editor_mode(player):
|
||||
"""Test that edit command sets up editor mode."""
|
||||
assert player.mode == "normal"
|
||||
assert player.editor is None
|
||||
|
||||
await cmd_edit(player, "")
|
||||
|
||||
assert player.mode == "editor"
|
||||
assert player.editor is not None
|
||||
assert isinstance(player.editor, Editor)
|
||||
assert player.mode_stack == ["normal", "editor"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_command_sends_welcome_message(player, mock_writer):
|
||||
"""Test that edit command sends welcome message."""
|
||||
await cmd_edit(player, "")
|
||||
|
||||
assert mock_writer.write.called
|
||||
output = "".join([call[0][0] for call in mock_writer.write.call_args_list])
|
||||
assert "editor" in output.lower()
|
||||
assert ":h" in output
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_command_sets_save_callback(player):
|
||||
"""Test that edit command sets up a save callback."""
|
||||
await cmd_edit(player, "")
|
||||
|
||||
assert player.editor is not None
|
||||
assert player.editor.save_callback is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_editor_handle_input_preserves_whitespace(player):
|
||||
"""Test that editor receives input with whitespace preserved."""
|
||||
await cmd_edit(player, "")
|
||||
|
||||
# Simulate code with indentation
|
||||
response = await player.editor.handle_input(" def foo():")
|
||||
|
||||
assert player.editor.buffer == [" def foo():"]
|
||||
assert response.done is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_editor_done_clears_editor_and_pops_mode(player):
|
||||
"""Test that when editor returns done=True, mode is popped and editor cleared."""
|
||||
await cmd_edit(player, "")
|
||||
|
||||
assert player.mode == "editor"
|
||||
assert player.editor is not None
|
||||
|
||||
# Quit the editor
|
||||
response = await player.editor.handle_input(":q")
|
||||
assert response.done is True
|
||||
|
||||
# Simulate what the shell loop should do
|
||||
player.editor = None
|
||||
player.mode_stack.pop()
|
||||
|
||||
assert player.mode == "normal"
|
||||
assert player.editor is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_editor_save_callback_sends_message(player, mock_writer):
|
||||
"""Test that save callback sends confirmation to player."""
|
||||
await cmd_edit(player, "")
|
||||
mock_writer.reset_mock()
|
||||
|
||||
await player.editor.handle_input("test line")
|
||||
response = await player.editor.handle_input(":w")
|
||||
|
||||
assert response.saved is True
|
||||
# Save callback should have sent a message
|
||||
assert mock_writer.write.called
|
||||
output = "".join([call[0][0] for call in mock_writer.write.call_args_list])
|
||||
assert "saved" in output.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_command_only_works_in_normal_mode(player):
|
||||
"""Test that edit command has mode='normal' restriction."""
|
||||
# Push a different mode onto the stack
|
||||
player.mode_stack.append("combat")
|
||||
|
||||
# Try to invoke edit command through dispatch
|
||||
await commands.dispatch(player, "edit")
|
||||
|
||||
# Should be blocked by mode check
|
||||
assert player.mode == "combat"
|
||||
assert player.editor is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mode_stack_push_and_pop(player):
|
||||
"""Test mode stack mechanics for editor mode."""
|
||||
assert player.mode_stack == ["normal"]
|
||||
assert player.mode == "normal"
|
||||
|
||||
# Enter editor mode
|
||||
player.mode_stack.append("editor")
|
||||
assert player.mode == "editor"
|
||||
assert player.mode_stack == ["normal", "editor"]
|
||||
|
||||
# Exit editor mode
|
||||
player.mode_stack.pop()
|
||||
assert player.mode == "normal"
|
||||
assert player.mode_stack == ["normal"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_input_allowed_in_editor(player):
|
||||
"""Test that empty lines are valid in editor mode (for blank lines in code)."""
|
||||
await cmd_edit(player, "")
|
||||
|
||||
# Empty line should be appended to buffer
|
||||
response = await player.editor.handle_input("")
|
||||
assert response.done is False
|
||||
assert player.editor.buffer == [""]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_editor_prompt_uses_cursor(player):
|
||||
"""Test that editor prompt should show line number from cursor."""
|
||||
await cmd_edit(player, "")
|
||||
|
||||
# Add some lines
|
||||
await player.editor.handle_input("line 1")
|
||||
await player.editor.handle_input("line 2")
|
||||
|
||||
# Cursor starts at 0. The prompt shows "cursor + 1" for 1-indexed display
|
||||
# This test verifies the cursor field exists and can be used for prompts
|
||||
assert player.editor.cursor == 0
|
||||
# Shell loop prompt: f" {player.editor.cursor + 1}> " = " 1> "
|
||||
Loading…
Reference in a new issue