Compare commits
8 commits
269026259c
...
d3df09f4de
| Author | SHA1 | Date | |
|---|---|---|---|
| d3df09f4de | |||
| 0574457404 | |||
| a799b6716c | |||
| 23507d0e70 | |||
| 5e255f192c | |||
| b0fcb080d3 | |||
| 6549d09683 | |||
| 388e693f8c |
19 changed files with 1799 additions and 31 deletions
|
|
@ -43,12 +43,26 @@ for MUD clients — they prefer MTTS.
|
|||
|
||||
meanwhile, in the same negotiation, tintin++ already told us via MTTS:
|
||||
|
||||
ttype3: MTTS 2825
|
||||
bit 0 (1) = ANSI color
|
||||
bit 3 (8) = UTF-8
|
||||
bit 8 (256) = 256 colors
|
||||
bit 9 (512) = OSC color palette
|
||||
bit 11 (2048) = true color
|
||||
ttype3: MTTS 2825 = 0b101100001001
|
||||
bit 0 (1) = ANSI
|
||||
bit 1 (2) = VT100
|
||||
bit 2 (4) = UTF-8
|
||||
bit 3 (8) = 256 COLORS [SET]
|
||||
bit 4 (16) = MOUSE TRACKING
|
||||
bit 5 (32) = OSC COLOR PALETTE
|
||||
bit 6 (64) = SCREEN READER
|
||||
bit 7 (128) = PROXY
|
||||
bit 8 (256) = TRUECOLOR [SET]
|
||||
bit 9 (512) = MNES [SET]
|
||||
bit 10 (1024) = MSLP
|
||||
bit 11 (2048) = SSL [SET]
|
||||
|
||||
for MTTS 2825: bits 0, 3, 8, 9, 11 are set
|
||||
|
||||
NOTE: The original version of this doc had incorrect MTTS bit mappings (copied
|
||||
from an unreliable source). This was corrected on 2026-02-07 to match the
|
||||
actual MTTS spec from tintin.mudhalla.net/protocols/mtts/. The wrong values
|
||||
caused a bug in caps.py that misinterpreted client capabilities.
|
||||
|
||||
two protocols, one answer. MTTS (via TTYPE round 3) is what the MUD ecosystem
|
||||
uses. RFC 2066 CHARSET is technically correct but practically ignored.
|
||||
|
|
|
|||
8
justfile
8
justfile
|
|
@ -6,13 +6,7 @@ typecheck:
|
|||
uvx ty check
|
||||
|
||||
test:
|
||||
uv run pytest
|
||||
|
||||
test-fast:
|
||||
uv run pytest --testmon
|
||||
|
||||
test-parallel:
|
||||
uv run pytest -n auto
|
||||
uv run pytest -n auto --testmon
|
||||
|
||||
check: lint typecheck test
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ description = "a telnet mud engine"
|
|||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"telnetlib3 @ file:///home/jtm/src/telnetlib3",
|
||||
"pygments>=2.17.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
|
|
|||
90
src/mudlib/caps.py
Normal file
90
src/mudlib/caps.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
"""MTTS (Mud Terminal Type Standard) capability parsing.
|
||||
|
||||
Parses MTTS bitfield values from telnetlib3's ttype3 into a structured dataclass.
|
||||
MTTS is advertised via TTYPE round 3 as "MTTS <number>" where the number is a
|
||||
bitfield of client capabilities.
|
||||
|
||||
See docs/lessons/charset-vs-mtts.txt for protocol details.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClientCaps:
|
||||
"""Parsed client capabilities from MTTS bitfield."""
|
||||
|
||||
ansi: bool = False
|
||||
vt100: bool = False
|
||||
utf8: bool = False
|
||||
colors_256: bool = False
|
||||
mouse_tracking: bool = False
|
||||
osc_color_palette: bool = False
|
||||
screen_reader: bool = False
|
||||
proxy: bool = False
|
||||
truecolor: bool = False
|
||||
mnes: bool = False
|
||||
mslp: bool = False
|
||||
ssl: bool = False
|
||||
|
||||
@property
|
||||
def color_depth(self) -> Literal["truecolor", "256", "16"]:
|
||||
"""Return best available color mode: truecolor, 256, or 16."""
|
||||
if self.truecolor:
|
||||
return "truecolor"
|
||||
if self.colors_256:
|
||||
return "256"
|
||||
return "16"
|
||||
|
||||
|
||||
def parse_mtts(ttype3: str | None) -> ClientCaps:
|
||||
"""Parse MTTS capability string into ClientCaps dataclass.
|
||||
|
||||
Args:
|
||||
ttype3: String from writer.get_extra_info("ttype3"), e.g., "MTTS 2825"
|
||||
|
||||
Returns:
|
||||
ClientCaps with parsed capabilities. Returns defaults for
|
||||
None/empty/invalid input.
|
||||
|
||||
Bit values from MTTS spec (tintin.mudhalla.net/protocols/mtts/):
|
||||
Bit 0 (1) = ANSI
|
||||
Bit 1 (2) = VT100
|
||||
Bit 2 (4) = UTF-8
|
||||
Bit 3 (8) = 256 COLORS
|
||||
Bit 4 (16) = MOUSE TRACKING
|
||||
Bit 5 (32) = OSC COLOR PALETTE
|
||||
Bit 6 (64) = SCREEN READER
|
||||
Bit 7 (128) = PROXY
|
||||
Bit 8 (256) = TRUECOLOR
|
||||
Bit 9 (512) = MNES
|
||||
Bit 10 (1024) = MSLP
|
||||
Bit 11 (2048) = SSL
|
||||
"""
|
||||
if not ttype3:
|
||||
return ClientCaps()
|
||||
|
||||
parts = ttype3.split()
|
||||
if len(parts) != 2 or parts[0] != "MTTS":
|
||||
return ClientCaps()
|
||||
|
||||
try:
|
||||
value = int(parts[1])
|
||||
except ValueError:
|
||||
return ClientCaps()
|
||||
|
||||
return ClientCaps(
|
||||
ansi=bool(value & 1),
|
||||
vt100=bool(value & 2),
|
||||
utf8=bool(value & 4),
|
||||
colors_256=bool(value & 8),
|
||||
mouse_tracking=bool(value & 16),
|
||||
osc_color_palette=bool(value & 32),
|
||||
screen_reader=bool(value & 64),
|
||||
proxy=bool(value & 128),
|
||||
truecolor=bool(value & 256),
|
||||
mnes=bool(value & 512),
|
||||
mslp=bool(value & 1024),
|
||||
ssl=bool(value & 2048),
|
||||
)
|
||||
29
src/mudlib/commands/edit.py
Normal file
29
src/mudlib/commands/edit.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"""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",
|
||||
color_depth=player.color_depth,
|
||||
)
|
||||
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"))
|
||||
|
|
@ -64,10 +64,10 @@ async def cmd_look(player: Player, args: str) -> None:
|
|||
for x, tile in enumerate(row):
|
||||
# Check if this is the player's position
|
||||
if x == center_x and y == center_y:
|
||||
line.append(colorize_terrain("@"))
|
||||
line.append(colorize_terrain("@", player.color_depth))
|
||||
# Check if this is another player's position
|
||||
elif (x, y) in other_player_positions:
|
||||
line.append(colorize_terrain("*"))
|
||||
line.append(colorize_terrain("*", player.color_depth))
|
||||
else:
|
||||
# Check for active effects at this world position
|
||||
world_x, world_y = world.wrap(
|
||||
|
|
@ -80,7 +80,7 @@ async def cmd_look(player: Player, args: str) -> None:
|
|||
e = effects[-1]
|
||||
line.append(f"{e.color}{e.char}{RESET}")
|
||||
else:
|
||||
line.append(colorize_terrain(tile))
|
||||
line.append(colorize_terrain(tile, player.color_depth))
|
||||
output_lines.append("".join(line))
|
||||
|
||||
# Send to player
|
||||
|
|
|
|||
403
src/mudlib/editor.py
Normal file
403
src/mudlib/editor.py
Normal file
|
|
@ -0,0 +1,403 @@
|
|||
"""Text editor for in-game content editing."""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from mudlib.render.highlight import highlight
|
||||
|
||||
|
||||
@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
|
||||
color_depth: str = "16" # "16", "256", or "truecolor"
|
||||
dirty: bool = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
initial_content: str = "",
|
||||
save_callback: Callable[[str], Awaitable[None]] | None = None,
|
||||
content_type: str = "text",
|
||||
color_depth: str = "16",
|
||||
):
|
||||
"""Initialize editor with optional content."""
|
||||
self.save_callback = save_callback
|
||||
self.content_type = content_type
|
||||
self.color_depth = color_depth
|
||||
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)"
|
||||
|
||||
# Use syntax highlighting if content_type is not "text"
|
||||
if self.content_type != "text":
|
||||
content = "\n".join(self.buffer)
|
||||
highlighted = highlight(
|
||||
content,
|
||||
language=self.content_type,
|
||||
color_depth=self.color_depth,
|
||||
line_numbers=True,
|
||||
)
|
||||
# If highlight() returned the original (unknown language), fall through
|
||||
if "\033[" in highlighted:
|
||||
return highlighted
|
||||
|
||||
# Fall back to plain line-number formatting
|
||||
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)
|
||||
|
||||
# Use syntax highlighting if content_type is not "text"
|
||||
if self.content_type != "text":
|
||||
# Get the context window
|
||||
context_lines = self.buffer[start:end]
|
||||
content = "\n".join(context_lines)
|
||||
highlighted = highlight(
|
||||
content,
|
||||
language=self.content_type,
|
||||
color_depth=self.color_depth,
|
||||
line_numbers=True,
|
||||
)
|
||||
# If highlight() returned highlighted content, use it
|
||||
if "\033[" in highlighted:
|
||||
return highlighted
|
||||
|
||||
# Fall back to plain line-number formatting
|
||||
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)
|
||||
self.cursor = len(self.buffer) - 1
|
||||
else:
|
||||
self.buffer.insert(line_num - 1, text)
|
||||
self.cursor = line_num - 1
|
||||
|
||||
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.cursor = min(start - 1, len(self.buffer) - 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.cursor = min(start - 1, len(self.buffer) - 1)
|
||||
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()
|
||||
# Conservative approach: if we undid something, the buffer is now different
|
||||
# from what was saved (even if we undo past a save point)
|
||||
self.dirty = True
|
||||
# Reset cursor to end of buffer after undo
|
||||
self.cursor = len(self.buffer) - 1 if self.buffer else 0
|
||||
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.cursor = len(self.buffer) - 1
|
||||
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":
|
||||
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]
|
||||
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)
|
||||
|
|
@ -1,10 +1,16 @@
|
|||
"""Player state and registry."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
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):
|
||||
|
|
@ -14,12 +20,19 @@ class Player(Entity):
|
|||
reader: Any = None # telnetlib3 TelnetReader for reading input
|
||||
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:
|
||||
"""Current mode is the top of the stack."""
|
||||
return self.mode_stack[-1]
|
||||
|
||||
@property
|
||||
def color_depth(self) -> str:
|
||||
"""Best available color mode: truecolor, 256, or 16."""
|
||||
return self.caps.color_depth
|
||||
|
||||
async def send(self, message: str) -> None:
|
||||
"""Send a message to the player via their telnet writer."""
|
||||
self.writer.write(message)
|
||||
|
|
|
|||
|
|
@ -24,7 +24,68 @@ BRIGHT_MAGENTA = "\033[95m"
|
|||
BRIGHT_CYAN = "\033[96m"
|
||||
BRIGHT_WHITE = "\033[97m"
|
||||
|
||||
# terrain color mapping
|
||||
|
||||
def fg_256(n: int) -> str:
|
||||
"""Generate 256-color foreground escape code.
|
||||
|
||||
Args:
|
||||
n: Color index (0-255). Values outside range are clamped.
|
||||
|
||||
Returns:
|
||||
ANSI escape sequence for 256-color foreground.
|
||||
"""
|
||||
clamped = max(0, min(255, n))
|
||||
return f"\033[38;5;{clamped}m"
|
||||
|
||||
|
||||
def bg_256(n: int) -> str:
|
||||
"""Generate 256-color background escape code.
|
||||
|
||||
Args:
|
||||
n: Color index (0-255). Values outside range are clamped.
|
||||
|
||||
Returns:
|
||||
ANSI escape sequence for 256-color background.
|
||||
"""
|
||||
clamped = max(0, min(255, n))
|
||||
return f"\033[48;5;{clamped}m"
|
||||
|
||||
|
||||
def fg_rgb(r: int, g: int, b: int) -> str:
|
||||
"""Generate truecolor foreground escape code.
|
||||
|
||||
Args:
|
||||
r: Red component (0-255). Values outside range are clamped.
|
||||
g: Green component (0-255). Values outside range are clamped.
|
||||
b: Blue component (0-255). Values outside range are clamped.
|
||||
|
||||
Returns:
|
||||
ANSI escape sequence for truecolor foreground.
|
||||
"""
|
||||
r_clamped = max(0, min(255, r))
|
||||
g_clamped = max(0, min(255, g))
|
||||
b_clamped = max(0, min(255, b))
|
||||
return f"\033[38;2;{r_clamped};{g_clamped};{b_clamped}m"
|
||||
|
||||
|
||||
def bg_rgb(r: int, g: int, b: int) -> str:
|
||||
"""Generate truecolor background escape code.
|
||||
|
||||
Args:
|
||||
r: Red component (0-255). Values outside range are clamped.
|
||||
g: Green component (0-255). Values outside range are clamped.
|
||||
b: Blue component (0-255). Values outside range are clamped.
|
||||
|
||||
Returns:
|
||||
ANSI escape sequence for truecolor background.
|
||||
"""
|
||||
r_clamped = max(0, min(255, r))
|
||||
g_clamped = max(0, min(255, g))
|
||||
b_clamped = max(0, min(255, b))
|
||||
return f"\033[48;2;{r_clamped};{g_clamped};{b_clamped}m"
|
||||
|
||||
|
||||
# terrain color mapping (16-color baseline)
|
||||
TERRAIN_COLORS = {
|
||||
".": GREEN, # grass
|
||||
"^": BRIGHT_BLACK, # mountain
|
||||
|
|
@ -35,19 +96,63 @@ TERRAIN_COLORS = {
|
|||
"*": BOLD + BRIGHT_RED, # other entity
|
||||
}
|
||||
|
||||
# 256-color terrain palette
|
||||
TERRAIN_COLORS_256 = {
|
||||
".": fg_256(34), # grass - richer green
|
||||
"^": fg_256(242), # mountain - gray
|
||||
"~": fg_256(27), # water - deeper blue
|
||||
"T": fg_256(22), # forest - darker green
|
||||
":": fg_256(221), # sand - warm yellow
|
||||
"@": BOLD + fg_256(231), # player - bright white
|
||||
"*": BOLD + fg_256(196), # other entity - bright red
|
||||
}
|
||||
|
||||
# truecolor terrain palette
|
||||
TERRAIN_COLORS_RGB = {
|
||||
".": fg_rgb(76, 175, 80), # grass - material green
|
||||
"^": fg_rgb(96, 125, 139), # mountain - blue-gray
|
||||
"~": fg_rgb(33, 150, 243), # water - material blue
|
||||
"T": fg_rgb(27, 94, 32), # forest - dark green
|
||||
":": fg_rgb(255, 235, 59), # sand - bright yellow
|
||||
"@": BOLD + fg_rgb(255, 255, 255), # player - pure white
|
||||
"*": BOLD + fg_rgb(244, 67, 54), # other entity - material red
|
||||
}
|
||||
|
||||
|
||||
def colorize_terrain(char: str, color_depth: str = "16") -> str:
|
||||
"""Return ANSI-colored version of terrain character.
|
||||
|
||||
Args:
|
||||
char: Terrain character to colorize
|
||||
color_depth: Color mode - "16", "256", or "truecolor"
|
||||
|
||||
Returns:
|
||||
ANSI-colored character with reset code, or unchanged char if no color mapping.
|
||||
"""
|
||||
if color_depth == "truecolor":
|
||||
color = TERRAIN_COLORS_RGB.get(char, "")
|
||||
elif color_depth == "256":
|
||||
color = TERRAIN_COLORS_256.get(char, "")
|
||||
else:
|
||||
color = TERRAIN_COLORS.get(char, "")
|
||||
|
||||
def colorize_terrain(char: str) -> str:
|
||||
"""Return ANSI-colored version of terrain character."""
|
||||
color = TERRAIN_COLORS.get(char, "")
|
||||
if color:
|
||||
return f"{color}{char}{RESET}"
|
||||
return char
|
||||
|
||||
|
||||
def colorize_map(grid: list[list[str]]) -> str:
|
||||
"""Colorize a 2D grid of terrain and return as string."""
|
||||
def colorize_map(grid: list[list[str]], color_depth: str = "16") -> str:
|
||||
"""Colorize a 2D grid of terrain and return as string.
|
||||
|
||||
Args:
|
||||
grid: 2D list of terrain characters
|
||||
color_depth: Color mode - "16", "256", or "truecolor"
|
||||
|
||||
Returns:
|
||||
Newline-separated string of colorized terrain rows.
|
||||
"""
|
||||
lines = []
|
||||
for row in grid:
|
||||
colored_row = "".join(colorize_terrain(char) for char in row)
|
||||
colored_row = "".join(colorize_terrain(char, color_depth) for char in row)
|
||||
lines.append(colored_row)
|
||||
return "\n".join(lines)
|
||||
|
|
|
|||
78
src/mudlib/render/highlight.py
Normal file
78
src/mudlib/render/highlight.py
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
"""Syntax highlighting using Pygments with ANSI color depth awareness."""
|
||||
|
||||
from pygments import highlight as pygments_highlight
|
||||
from pygments.formatters.terminal import TerminalFormatter
|
||||
from pygments.formatters.terminal256 import Terminal256Formatter
|
||||
from pygments.lexers import get_lexer_by_name
|
||||
from pygments.util import ClassNotFound
|
||||
|
||||
|
||||
def highlight(
|
||||
code: str,
|
||||
language: str = "python",
|
||||
color_depth: str = "16",
|
||||
line_numbers: bool = False,
|
||||
) -> str:
|
||||
"""Highlight code with syntax coloring appropriate for client's terminal.
|
||||
|
||||
Args:
|
||||
code: Source code to highlight
|
||||
language: Language name (python, toml, etc.)
|
||||
color_depth: Client color capability ("truecolor", "256", or "16")
|
||||
line_numbers: Whether to prefix lines with line numbers
|
||||
|
||||
Returns:
|
||||
ANSI-colored code string, or original code if language is unknown.
|
||||
Trailing newlines are stripped for clean telnet display.
|
||||
"""
|
||||
if not code:
|
||||
return ""
|
||||
|
||||
# Try to get the appropriate lexer
|
||||
try:
|
||||
lexer = get_lexer_by_name(language)
|
||||
except ClassNotFound:
|
||||
# Unknown language, return original text
|
||||
return code
|
||||
|
||||
# Choose formatter based on color depth
|
||||
# Use 256-color formatter for both "256" and "truecolor"
|
||||
# Pygments doesn't have a true truecolor formatter, 256 is the best we have
|
||||
formatter = TerminalFormatter() if color_depth == "16" else Terminal256Formatter()
|
||||
|
||||
# Apply syntax highlighting
|
||||
result = pygments_highlight(code, lexer, formatter)
|
||||
|
||||
# Strip trailing newline that Pygments adds
|
||||
if result.endswith("\n"):
|
||||
result = result[:-1]
|
||||
|
||||
# Add line numbers if requested
|
||||
if line_numbers:
|
||||
result = _add_line_numbers(result)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _add_line_numbers(highlighted_code: str) -> str:
|
||||
"""Add line numbers to highlighted code using ANSI dim styling.
|
||||
|
||||
Args:
|
||||
highlighted_code: ANSI-colored code string
|
||||
|
||||
Returns:
|
||||
Code with line numbers prefixed to each line
|
||||
"""
|
||||
lines = highlighted_code.split("\n")
|
||||
numbered_lines = []
|
||||
|
||||
# ANSI dim styling for line numbers
|
||||
dim = "\033[2m"
|
||||
reset = "\033[0m"
|
||||
|
||||
for i, line in enumerate(lines, start=1):
|
||||
# Format: dim line number, reset, then the code
|
||||
numbered_line = f"{dim}{i:3d}{reset} {line}"
|
||||
numbered_lines.append(numbered_line)
|
||||
|
||||
return "\n".join(numbered_lines)
|
||||
|
|
@ -11,10 +11,12 @@ 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
|
||||
import mudlib.commands.quit
|
||||
from mudlib.caps import parse_mtts
|
||||
from mudlib.combat.commands import register_combat_commands
|
||||
from mudlib.combat.engine import process_combat
|
||||
from mudlib.content import load_commands
|
||||
|
|
@ -267,6 +269,16 @@ async def shell(
|
|||
reader=_reader,
|
||||
)
|
||||
|
||||
# Parse and store client capabilities from MTTS
|
||||
ttype3 = _writer.get_extra_info("ttype3")
|
||||
player.caps = parse_mtts(ttype3)
|
||||
log.debug(
|
||||
"%s capabilities: %s (color_depth=%s)",
|
||||
player_name,
|
||||
player.caps,
|
||||
player.color_depth,
|
||||
)
|
||||
|
||||
# Register player
|
||||
players[player_name] = player
|
||||
log.info(
|
||||
|
|
@ -286,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)
|
||||
|
|
@ -294,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():
|
||||
|
|
|
|||
|
|
@ -1,4 +1,12 @@
|
|||
from mudlib.render.ansi import RESET, colorize_map, colorize_terrain
|
||||
from mudlib.render.ansi import (
|
||||
RESET,
|
||||
bg_256,
|
||||
bg_rgb,
|
||||
colorize_map,
|
||||
colorize_terrain,
|
||||
fg_256,
|
||||
fg_rgb,
|
||||
)
|
||||
|
||||
|
||||
def test_colorize_terrain_grass():
|
||||
|
|
@ -45,3 +53,109 @@ def test_colorize_map():
|
|||
|
||||
# should have ANSI codes
|
||||
assert "\033[" in result
|
||||
|
||||
|
||||
def test_fg_256_basic():
|
||||
"""Generate 256-color foreground code."""
|
||||
assert fg_256(0) == "\033[38;5;0m"
|
||||
assert fg_256(15) == "\033[38;5;15m"
|
||||
assert fg_256(42) == "\033[38;5;42m"
|
||||
assert fg_256(255) == "\033[38;5;255m"
|
||||
|
||||
|
||||
def test_bg_256_basic():
|
||||
"""Generate 256-color background code."""
|
||||
assert bg_256(0) == "\033[48;5;0m"
|
||||
assert bg_256(15) == "\033[48;5;15m"
|
||||
assert bg_256(42) == "\033[48;5;42m"
|
||||
assert bg_256(255) == "\033[48;5;255m"
|
||||
|
||||
|
||||
def test_fg_256_clamping():
|
||||
"""Clamp 256-color values to valid range (0-255)."""
|
||||
assert fg_256(-1) == "\033[38;5;0m"
|
||||
assert fg_256(256) == "\033[38;5;255m"
|
||||
assert fg_256(999) == "\033[38;5;255m"
|
||||
|
||||
|
||||
def test_bg_256_clamping():
|
||||
"""Clamp 256-color values to valid range (0-255)."""
|
||||
assert bg_256(-1) == "\033[48;5;0m"
|
||||
assert bg_256(256) == "\033[48;5;255m"
|
||||
assert bg_256(999) == "\033[48;5;255m"
|
||||
|
||||
|
||||
def test_fg_rgb_basic():
|
||||
"""Generate truecolor foreground code."""
|
||||
assert fg_rgb(0, 0, 0) == "\033[38;2;0;0;0m"
|
||||
assert fg_rgb(255, 255, 255) == "\033[38;2;255;255;255m"
|
||||
assert fg_rgb(128, 64, 192) == "\033[38;2;128;64;192m"
|
||||
|
||||
|
||||
def test_bg_rgb_basic():
|
||||
"""Generate truecolor background code."""
|
||||
assert bg_rgb(0, 0, 0) == "\033[48;2;0;0;0m"
|
||||
assert bg_rgb(255, 255, 255) == "\033[48;2;255;255;255m"
|
||||
assert bg_rgb(128, 64, 192) == "\033[48;2;128;64;192m"
|
||||
|
||||
|
||||
def test_fg_rgb_clamping():
|
||||
"""Clamp RGB values to valid range (0-255)."""
|
||||
assert fg_rgb(-1, 0, 0) == "\033[38;2;0;0;0m"
|
||||
assert fg_rgb(0, 300, 0) == "\033[38;2;0;255;0m"
|
||||
assert fg_rgb(0, 0, 999) == "\033[38;2;0;0;255m"
|
||||
assert fg_rgb(-10, 300, 500) == "\033[38;2;0;255;255m"
|
||||
|
||||
|
||||
def test_bg_rgb_clamping():
|
||||
"""Clamp RGB values to valid range (0-255)."""
|
||||
assert bg_rgb(-1, 0, 0) == "\033[48;2;0;0;0m"
|
||||
assert bg_rgb(0, 300, 0) == "\033[48;2;0;255;0m"
|
||||
assert bg_rgb(0, 0, 999) == "\033[48;2;0;0;255m"
|
||||
assert bg_rgb(-10, 300, 500) == "\033[48;2;0;255;255m"
|
||||
|
||||
|
||||
def test_colorize_terrain_default_16color():
|
||||
"""Default color_depth uses 16-color palette."""
|
||||
result = colorize_terrain(".")
|
||||
assert "\033[32m" in result # GREEN
|
||||
assert "." in result
|
||||
assert RESET in result
|
||||
|
||||
|
||||
def test_colorize_terrain_256color():
|
||||
"""color_depth=256 uses 256-color palette."""
|
||||
result = colorize_terrain(".", "256")
|
||||
assert "\033[38;5;" in result # 256-color code
|
||||
assert "." in result
|
||||
assert RESET in result
|
||||
|
||||
|
||||
def test_colorize_terrain_truecolor():
|
||||
"""color_depth=truecolor uses RGB palette."""
|
||||
result = colorize_terrain(".", "truecolor")
|
||||
assert "\033[38;2;" in result # truecolor code
|
||||
assert "." in result
|
||||
assert RESET in result
|
||||
|
||||
|
||||
def test_colorize_map_default_16color():
|
||||
"""colorize_map with default uses 16-color palette."""
|
||||
grid = [[".", "~"], ["^", "T"]]
|
||||
result = colorize_map(grid)
|
||||
assert "\033[32m" in result # GREEN for grass
|
||||
assert "\033[34m" in result # BLUE for water
|
||||
|
||||
|
||||
def test_colorize_map_256color():
|
||||
"""colorize_map with color_depth=256 uses 256-color palette."""
|
||||
grid = [[".", "~"]]
|
||||
result = colorize_map(grid, "256")
|
||||
assert "\033[38;5;" in result # 256-color codes present
|
||||
|
||||
|
||||
def test_colorize_map_truecolor():
|
||||
"""colorize_map with color_depth=truecolor uses RGB palette."""
|
||||
grid = [[".", "~"]]
|
||||
result = colorize_map(grid, "truecolor")
|
||||
assert "\033[38;2;" in result # truecolor codes present
|
||||
|
|
|
|||
117
tests/test_caps.py
Normal file
117
tests/test_caps.py
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
"""Tests for MTTS capability parsing."""
|
||||
|
||||
from mudlib.caps import parse_mtts
|
||||
|
||||
|
||||
def test_parse_mtts_tintin():
|
||||
"""Parse tintin++ MTTS 2825 correctly.
|
||||
|
||||
2825 in binary = 0b101100001001
|
||||
Bits set: 0, 3, 8, 9, 11
|
||||
= ANSI(1) + 256colors(8) + truecolor(256) + MNES(512) + SSL(2048)
|
||||
"""
|
||||
caps = parse_mtts("MTTS 2825")
|
||||
assert caps.ansi is True
|
||||
assert caps.vt100 is False
|
||||
assert caps.utf8 is False
|
||||
assert caps.colors_256 is True
|
||||
assert caps.mouse_tracking is False
|
||||
assert caps.osc_color_palette is False
|
||||
assert caps.screen_reader is False
|
||||
assert caps.proxy is False
|
||||
assert caps.truecolor is True
|
||||
assert caps.mnes is True
|
||||
assert caps.mslp is False
|
||||
assert caps.ssl is True
|
||||
assert caps.color_depth == "truecolor"
|
||||
|
||||
|
||||
def test_parse_mtts_basic():
|
||||
"""Parse MTTS 137 (ANSI + 256colors + proxy)."""
|
||||
# 137 = 1 (ANSI) + 8 (256colors) + 128 (proxy)
|
||||
caps = parse_mtts("MTTS 137")
|
||||
assert caps.ansi is True
|
||||
assert caps.utf8 is False
|
||||
assert caps.colors_256 is True
|
||||
assert caps.proxy is True
|
||||
assert caps.truecolor is False
|
||||
assert caps.color_depth == "256"
|
||||
|
||||
|
||||
def test_parse_mtts_zero():
|
||||
"""Parse MTTS 0 (nothing supported)."""
|
||||
caps = parse_mtts("MTTS 0")
|
||||
assert caps.ansi is False
|
||||
assert caps.utf8 is False
|
||||
assert caps.colors_256 is False
|
||||
assert caps.truecolor is False
|
||||
assert caps.color_depth == "16"
|
||||
|
||||
|
||||
def test_parse_mtts_256_colors_only():
|
||||
"""Parse MTTS with JUST 256 colors (no truecolor)."""
|
||||
# 9 = 1 (ANSI) + 8 (256colors)
|
||||
caps = parse_mtts("MTTS 9")
|
||||
assert caps.ansi is True
|
||||
assert caps.utf8 is False
|
||||
assert caps.colors_256 is True
|
||||
assert caps.truecolor is False
|
||||
assert caps.color_depth == "256"
|
||||
|
||||
|
||||
def test_parse_mtts_utf8_no_colors():
|
||||
"""Parse MTTS with UTF-8 but no extended colors."""
|
||||
# 5 = 1 (ANSI) + 4 (UTF-8)
|
||||
caps = parse_mtts("MTTS 5")
|
||||
assert caps.ansi is True
|
||||
assert caps.utf8 is True
|
||||
assert caps.colors_256 is False
|
||||
assert caps.truecolor is False
|
||||
assert caps.color_depth == "16"
|
||||
|
||||
|
||||
def test_parse_mtts_empty():
|
||||
"""Handle empty string gracefully."""
|
||||
caps = parse_mtts("")
|
||||
assert caps.ansi is False
|
||||
assert caps.utf8 is False
|
||||
assert caps.color_depth == "16"
|
||||
|
||||
|
||||
def test_parse_mtts_none():
|
||||
"""Handle None gracefully."""
|
||||
caps = parse_mtts(None)
|
||||
assert caps.ansi is False
|
||||
assert caps.utf8 is False
|
||||
assert caps.color_depth == "16"
|
||||
|
||||
|
||||
def test_parse_mtts_non_mtts():
|
||||
"""Handle non-MTTS ttype3 strings (e.g., UNKNOWN-TERMINAL)."""
|
||||
caps = parse_mtts("UNKNOWN-TERMINAL")
|
||||
assert caps.ansi is False
|
||||
assert caps.utf8 is False
|
||||
assert caps.color_depth == "16"
|
||||
|
||||
|
||||
def test_parse_mtts_malformed():
|
||||
"""Handle malformed MTTS strings."""
|
||||
caps = parse_mtts("MTTS")
|
||||
assert caps.ansi is False
|
||||
assert caps.utf8 is False
|
||||
assert caps.color_depth == "16"
|
||||
|
||||
|
||||
def test_color_depth_priority():
|
||||
"""Verify color_depth returns best available mode."""
|
||||
# truecolor wins
|
||||
caps = parse_mtts("MTTS 2825")
|
||||
assert caps.color_depth == "truecolor"
|
||||
|
||||
# 256 wins over 16
|
||||
caps = parse_mtts("MTTS 9")
|
||||
assert caps.color_depth == "256"
|
||||
|
||||
# fallback to 16
|
||||
caps = parse_mtts("MTTS 5") # 1 (ANSI) + 4 (UTF-8)
|
||||
assert caps.color_depth == "16"
|
||||
|
|
@ -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, "")
|
||||
|
||||
|
|
|
|||
436
tests/test_editor.py
Normal file
436
tests/test_editor.py
Normal file
|
|
@ -0,0 +1,436 @@
|
|||
"""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()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_undo_past_save_marks_dirty():
|
||||
"""Undoing after save should mark buffer dirty again."""
|
||||
saved = []
|
||||
|
||||
async def cb(content: str):
|
||||
saved.append(content)
|
||||
|
||||
editor = Editor(initial_content="line 1", save_callback=cb)
|
||||
await editor.handle_input("line 2") # dirty
|
||||
await editor.handle_input(":w") # clean
|
||||
assert not editor.dirty
|
||||
await editor.handle_input(":u") # undo the append - should be dirty
|
||||
assert editor.dirty
|
||||
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")
|
||||
|
||||
# After appending, cursor should be at the last line (1 in 0-indexed)
|
||||
# This test verifies the cursor field exists and can be used for prompts
|
||||
assert player.editor.cursor == 1
|
||||
# Shell loop prompt: f" {player.editor.cursor + 1}> " = " 2> "
|
||||
125
tests/test_highlight.py
Normal file
125
tests/test_highlight.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
"""Tests for syntax highlighting module."""
|
||||
|
||||
from mudlib.render.highlight import highlight
|
||||
|
||||
|
||||
def test_highlight_python_returns_ansi_codes():
|
||||
"""Test that Python code returns string with ANSI escape codes."""
|
||||
code = "def foo():\n return 42"
|
||||
result = highlight(code, language="python")
|
||||
# Should contain ANSI escape codes
|
||||
assert "\033[" in result
|
||||
# Should contain the original code somewhere
|
||||
assert "def" in result
|
||||
assert "foo" in result
|
||||
|
||||
|
||||
def test_highlight_toml_returns_ansi_codes():
|
||||
"""Test that TOML code returns string with ANSI escape codes."""
|
||||
code = '[section]\nkey = "value"'
|
||||
result = highlight(code, language="toml")
|
||||
# Should contain ANSI escape codes
|
||||
assert "\033[" in result
|
||||
# Should contain the original code somewhere
|
||||
assert "section" in result
|
||||
assert "key" in result
|
||||
|
||||
|
||||
def test_highlight_truecolor_depth():
|
||||
"""Test with color_depth=truecolor uses appropriate formatter."""
|
||||
code = "x = 1"
|
||||
result = highlight(code, language="python", color_depth="truecolor")
|
||||
# Should contain ANSI codes (either 256-color or truecolor format)
|
||||
assert "\033[" in result
|
||||
# Should contain the code
|
||||
assert "x" in result
|
||||
|
||||
|
||||
def test_highlight_256_depth():
|
||||
"""Test with color_depth=256 uses 256-color formatter."""
|
||||
code = "x = 1"
|
||||
result = highlight(code, language="python", color_depth="256")
|
||||
# Should contain ANSI codes
|
||||
assert "\033[" in result
|
||||
# Should contain the code
|
||||
assert "x" in result
|
||||
|
||||
|
||||
def test_highlight_16_depth():
|
||||
"""Test with color_depth=16 uses basic terminal formatter."""
|
||||
code = "x = 1"
|
||||
result = highlight(code, language="python", color_depth="16")
|
||||
# Should contain ANSI codes
|
||||
assert "\033[" in result
|
||||
# Should contain the code
|
||||
assert "x" in result
|
||||
|
||||
|
||||
def test_highlight_unknown_language_returns_original():
|
||||
"""Test unknown language returns original text unmodified (no crash)."""
|
||||
code = "some random text"
|
||||
result = highlight(code, language="unknown_language_xyz")
|
||||
# Should return original text unchanged
|
||||
assert result == code
|
||||
# Should not contain ANSI codes
|
||||
assert "\033[" not in result
|
||||
|
||||
|
||||
def test_highlight_empty_string():
|
||||
"""Test empty string input returns empty string."""
|
||||
result = highlight("", language="python")
|
||||
assert result == ""
|
||||
|
||||
|
||||
def test_highlight_no_extra_trailing_newlines():
|
||||
"""Test that output doesn't end with extra newlines."""
|
||||
code = "x = 1"
|
||||
result = highlight(code, language="python")
|
||||
# Pygments tends to add a trailing newline, we should strip it
|
||||
# The original code has no trailing newline, so neither should the result
|
||||
assert not result.endswith("\n\n")
|
||||
# A single trailing newline might be ok if original had one,
|
||||
# but our test input doesn't, so result shouldn't either
|
||||
assert not result.endswith("\n")
|
||||
|
||||
|
||||
def test_highlight_with_line_numbers():
|
||||
"""Test highlighting with line numbers enabled."""
|
||||
code = "def foo():\n return 42\n return 99"
|
||||
result = highlight(code, language="python", line_numbers=True)
|
||||
# Should contain line numbers
|
||||
assert "1" in result
|
||||
assert "2" in result
|
||||
assert "3" in result
|
||||
# Should still contain ANSI codes
|
||||
assert "\033[" in result
|
||||
# Should contain the code (split by ANSI codes, so check separately)
|
||||
assert "def" in result
|
||||
assert "foo" in result
|
||||
assert "return" in result
|
||||
assert "42" in result
|
||||
|
||||
|
||||
def test_highlight_line_numbers_use_ansi_dim():
|
||||
"""Test that line numbers use ANSI dim/gray styling."""
|
||||
code = "x = 1\ny = 2"
|
||||
result = highlight(code, language="python", line_numbers=True)
|
||||
# Line numbers should have some ANSI styling
|
||||
# We'll check that there are escape codes before the digits
|
||||
assert "\033[" in result
|
||||
# This is a bit fragile, but we can check that the result
|
||||
# starts with an escape code (for line number 1)
|
||||
lines = result.split("\n")
|
||||
# First line should start with escape code or digit
|
||||
assert lines[0][0] in ("\033", "1", " ")
|
||||
|
||||
|
||||
def test_highlight_preserves_code_with_trailing_newline():
|
||||
"""Test code with trailing newline is handled correctly."""
|
||||
code = "x = 1\n"
|
||||
result = highlight(code, language="python")
|
||||
# Should contain the code
|
||||
assert "x" in result
|
||||
# Should not accumulate extra newlines beyond what Pygments naturally adds
|
||||
# We strip the trailing newline, so even input with \n shouldn't get extra
|
||||
assert not result.endswith("\n\n")
|
||||
48
tests/test_player_caps.py
Normal file
48
tests/test_player_caps.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
"""Tests for Player client capabilities integration."""
|
||||
|
||||
from mudlib.caps import ClientCaps
|
||||
from mudlib.player import Player
|
||||
|
||||
|
||||
def test_player_default_caps():
|
||||
"""Player has default 16-color ANSI caps when created without explicit caps."""
|
||||
player = Player(name="TestPlayer", x=0, y=0)
|
||||
assert player.caps.ansi is True
|
||||
assert player.caps.colors_256 is False
|
||||
assert player.caps.truecolor is False
|
||||
assert player.color_depth == "16"
|
||||
|
||||
|
||||
def test_player_caps_truecolor():
|
||||
"""Player with truecolor caps reports truecolor depth."""
|
||||
caps = ClientCaps(ansi=True, utf8=True, colors_256=True, truecolor=True)
|
||||
player = Player(name="TestPlayer", x=0, y=0, caps=caps)
|
||||
assert player.color_depth == "truecolor"
|
||||
|
||||
|
||||
def test_player_caps_256():
|
||||
"""Player with 256-color caps reports 256 depth."""
|
||||
caps = ClientCaps(ansi=True, utf8=True, colors_256=True, truecolor=False)
|
||||
player = Player(name="TestPlayer", x=0, y=0, caps=caps)
|
||||
assert player.color_depth == "256"
|
||||
|
||||
|
||||
def test_player_caps_16():
|
||||
"""Player with basic ANSI caps reports 16 depth."""
|
||||
caps = ClientCaps(ansi=True, utf8=True)
|
||||
player = Player(name="TestPlayer", x=0, y=0, caps=caps)
|
||||
assert player.color_depth == "16"
|
||||
|
||||
|
||||
def test_player_caps_property_delegates():
|
||||
"""Player.color_depth delegates to caps.color_depth."""
|
||||
player = Player(name="TestPlayer", x=0, y=0)
|
||||
# Modify caps and verify color_depth reflects the change
|
||||
player.caps = ClientCaps(ansi=True, colors_256=True, truecolor=True)
|
||||
assert player.color_depth == "truecolor"
|
||||
|
||||
player.caps = ClientCaps(ansi=True, colors_256=True, truecolor=False)
|
||||
assert player.color_depth == "256"
|
||||
|
||||
player.caps = ClientCaps(ansi=True)
|
||||
assert player.color_depth == "16"
|
||||
6
uv.lock
6
uv.lock
|
|
@ -108,6 +108,7 @@ name = "mudlib"
|
|||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "pygments" },
|
||||
{ name = "telnetlib3" },
|
||||
]
|
||||
|
||||
|
|
@ -121,7 +122,10 @@ dev = [
|
|||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [{ name = "telnetlib3", directory = "../../../../src/telnetlib3" }]
|
||||
requires-dist = [
|
||||
{ name = "pygments", specifier = ">=2.17.0" },
|
||||
{ name = "telnetlib3", directory = "../../src/telnetlib3" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
|
|
|
|||
Loading…
Reference in a new issue