Initial commit: MUD client v0.2
This commit is contained in:
commit
0030fd7d82
26 files changed
+4421
No files matched your search
@@ -0,0 +1,25 @@
|
||||
"""Tests for the ANSI stripper."""
|
||||
from __future__ import annotations
|
||||
|
||||
from progetto2.ansi import strip_ansi, visible_len
|
||||
|
||||
|
||||
def test_strip_sgr() -> None:
|
||||
assert strip_ansi("\x1b[31mhello\x1b[0m") == "hello"
|
||||
|
||||
|
||||
def test_strip_cursor_moves() -> None:
|
||||
assert strip_ansi("a\x1b[2Jb\x1b[H") == "ab"
|
||||
|
||||
|
||||
def test_strip_osc() -> None:
|
||||
assert strip_ansi("\x1b]0;title\x07rest") == "rest"
|
||||
|
||||
|
||||
def test_strip_keep_tilde() -> None:
|
||||
# Tilde-style colour codes (CoCo/MUSH) should NOT be stripped by this module.
|
||||
assert "~rhello~n" in strip_ansi("~rhello~n") or strip_ansi("~rhello~n") == "~rhello~n"
|
||||
|
||||
|
||||
def test_visible_len() -> None:
|
||||
assert visible_len("\x1b[31mfoo\x1b[0m") == 3
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Tests for the Dead Souls textual parser."""
|
||||
from __future__ import annotations
|
||||
|
||||
from progetto2.deadsouls import (
|
||||
EXIT_ALIASES,
|
||||
RollingRoomParser,
|
||||
parse_exits,
|
||||
parse_room_block,
|
||||
)
|
||||
|
||||
|
||||
def test_parse_exits_brackets() -> None:
|
||||
assert parse_exits("[ Exits: n e s ]") == ["n", "e", "s"]
|
||||
|
||||
|
||||
def test_parse_exits_plain() -> None:
|
||||
assert parse_exits("Exits: north, east") == ["n", "e"]
|
||||
|
||||
|
||||
def test_parse_exits_dedup() -> None:
|
||||
assert parse_exits("[ Exits: n n n east east ]") == ["n", "e"]
|
||||
|
||||
|
||||
def test_parse_exits_unknown_extra() -> None:
|
||||
# unknown ids are dropped, but the recognised ones stay
|
||||
assert parse_exits("[ Exits: n fluffy w ]") == ["n", "w"]
|
||||
|
||||
|
||||
def test_parse_room_block_minimal() -> None:
|
||||
blk = "Outer Courtyard\n\nThe cobbles are uneven.\n[ Exits: n e ]\n"
|
||||
room = parse_room_block(blk)
|
||||
assert room is not None
|
||||
assert room.name == "Outer Courtyard"
|
||||
assert set(room.exits) == {"n", "e"}
|
||||
|
||||
|
||||
def test_parse_room_block_no_exits() -> None:
|
||||
blk = "Dead End\n\nA wall blocks the way.\nNo exits.\n"
|
||||
room = parse_room_block(blk)
|
||||
assert room is not None
|
||||
assert room.exits == []
|
||||
|
||||
|
||||
def test_parse_room_block_no_exits_line_returns_none() -> None:
|
||||
blk = "Plain Text\n\nNo block markers here.\n"
|
||||
assert parse_room_block(blk) is None
|
||||
|
||||
|
||||
def test_aliases_directions() -> None:
|
||||
assert EXIT_ALIASES["northeast"] == "ne"
|
||||
assert EXIT_ALIASES["up"] == "u"
|
||||
assert EXIT_ALIASES["enter"] == "enter"
|
||||
|
||||
|
||||
def test_rolling_parser_emits_complete_block() -> None:
|
||||
"""Rooms are emitted incrementally *only* once an ``Exits:`` line closes
|
||||
the block. Across two ``feed_text`` calls, the first room completes
|
||||
on the first call (its Exits: line is observed) and the second room
|
||||
completes on the second call (its Exits: line is observed there)."""
|
||||
p = RollingRoomParser()
|
||||
rooms: list[RoomBlock] = []
|
||||
rooms.extend(p.feed_text("Outer Courtyard\n\nCobbles. \n[ Exits: n ]\n\nInn "))
|
||||
rooms.extend(p.feed_text("Hall\n\nA warm fire.\n[ Exits: s ]\n\n"))
|
||||
assert len(rooms) == 2
|
||||
assert rooms[0].name == "Outer Courtyard"
|
||||
assert rooms[1].name == "Inn Hall"
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Tests for the GMCP parser."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from progetto2.gmcp import (
|
||||
GmcpEvent,
|
||||
extract_room_info,
|
||||
extract_vitals,
|
||||
parse_gmcp,
|
||||
)
|
||||
|
||||
|
||||
def test_parse_module_only() -> None:
|
||||
ev = parse_gmcp(b"Char.Login")
|
||||
assert ev == GmcpEvent("Char.Login", None)
|
||||
|
||||
|
||||
def test_parse_with_json_object() -> None:
|
||||
raw = b'Char.Vitals {"hp":100,"max_hp":100,"sp":50,"mv":80}'
|
||||
ev = parse_gmcp(raw)
|
||||
assert ev is not None
|
||||
assert ev.module == "Char.Vitals"
|
||||
assert ev.data == {"hp": 100, "max_hp": 100, "sp": 50, "mv": 80}
|
||||
|
||||
|
||||
def test_parse_with_json_array() -> None:
|
||||
raw = b"Room.Info " + json.dumps(
|
||||
[{"name": "Foo"}, {"name": "Bar"}]
|
||||
).encode()
|
||||
ev = parse_gmcp(raw)
|
||||
assert ev is not None
|
||||
assert ev.module == "Room.Info"
|
||||
assert isinstance(ev.data, list)
|
||||
assert len(ev.data) == 2
|
||||
|
||||
|
||||
def test_parse_garbage_returns_none_or_partial() -> None:
|
||||
# Empty payload
|
||||
assert parse_gmcp(b"") is None
|
||||
# Whitespace only
|
||||
assert parse_gmcp(b" \n") is None
|
||||
# Bad module
|
||||
assert parse_gmcp(b" .bad") is None
|
||||
|
||||
|
||||
def test_parse_invalid_json_keeps_module() -> None:
|
||||
ev = parse_gmcp(b"Char.Vitals {not valid")
|
||||
assert ev is not None
|
||||
assert ev.module == "Char.Vitals"
|
||||
assert ev.data is None # JSON parse failure tolerated
|
||||
|
||||
|
||||
def test_extract_vitals_basic() -> None:
|
||||
v = extract_vitals(
|
||||
{"hp": 100, "max_hp": 100, "sp": 50, "max_sp": 50, "mv": 80, "max_mv": 80}
|
||||
)
|
||||
assert v == {
|
||||
"hp": 100, "max_hp": 100,
|
||||
"sp": 50, "max_sp": 50,
|
||||
"mv": 80, "max_mv": 80,
|
||||
}
|
||||
|
||||
|
||||
def test_extract_vitals_synonyms() -> None:
|
||||
v = extract_vitals({"health": 50, "max_health": 100, "mana": 30, "move": 80})
|
||||
assert v["hp"] == 50
|
||||
assert v["max_hp"] == 100
|
||||
assert v["sp"] == 30
|
||||
assert v["mv"] == 80
|
||||
|
||||
|
||||
def test_extract_vitals_partial() -> None:
|
||||
v = extract_vitals({"hp": 100})
|
||||
assert v == {"hp": 100}
|
||||
|
||||
|
||||
def test_extract_vitals_empty_or_non_dict() -> None:
|
||||
assert extract_vitals({}) == {}
|
||||
assert extract_vitals(None) == {}
|
||||
assert extract_vitals([1, 2, 3]) == {}
|
||||
|
||||
|
||||
def test_extract_room_info_with_vnum() -> None:
|
||||
info = extract_room_info(
|
||||
{"name": "Outer Courtyard", "num": 1234, "exits": {"north": "vnum99"}}
|
||||
)
|
||||
assert info is not None
|
||||
rid, name, body, num_id = info
|
||||
assert name == "Outer Courtyard"
|
||||
assert rid == "vnum1234"
|
||||
assert num_id == "vnum1234"
|
||||
|
||||
|
||||
def test_extract_room_info_without_vnum() -> None:
|
||||
info = extract_room_info(
|
||||
{"title": "Dungeon Entry", "description": "Stairs lead down.", "exits": ["n", "s"]}
|
||||
)
|
||||
assert info is not None
|
||||
rid, name, _, num_id = info
|
||||
assert name == "Dungeon Entry"
|
||||
assert rid == "dungeon entry"
|
||||
assert num_id is None
|
||||
|
||||
|
||||
def test_extract_room_info_no_name_returns_none() -> None:
|
||||
assert extract_room_info({"num": 1}) is None
|
||||
assert extract_room_info({"exits": ["n"]}) is None
|
||||
assert extract_room_info(None) is None
|
||||
|
||||
|
||||
def test_extract_room_info_exits_canonicalised() -> None:
|
||||
info = extract_room_info(
|
||||
{"name": "X", "exits": ["north", "east", "up"]}
|
||||
)
|
||||
assert info is not None
|
||||
# We didn't pull exits here — that happens in cli. Check that direct parsing
|
||||
# still identifies room name + body.
|
||||
assert info[1] == "X"
|
||||
@@ -0,0 +1,276 @@
|
||||
"""Tests for :mod:`progetto2.logger`."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from progetto2.logger import EventLogger
|
||||
|
||||
|
||||
# ---- shared helpers ------------------------------------------------------
|
||||
|
||||
|
||||
def _read_jsonl(path: Path) -> list[dict]:
|
||||
return [
|
||||
json.loads(line)
|
||||
for line in path.read_text(encoding="utf-8").splitlines()
|
||||
if line.strip()
|
||||
]
|
||||
|
||||
|
||||
# ---- json sink -----------------------------------------------------------
|
||||
|
||||
|
||||
def test_json_sink_roundtrip(tmp_path: Path) -> None:
|
||||
out = tmp_path / "session.jsonl"
|
||||
log = EventLogger(json_path=str(out))
|
||||
log.session_start("localhost", 6666, "foo")
|
||||
log.room("vnum42", "Town Square", ["n", "e"])
|
||||
log.vitals(hp=80, max_hp=100)
|
||||
log.line_sent("north")
|
||||
log.close()
|
||||
|
||||
events = _read_jsonl(out)
|
||||
assert [e["kind"] for e in events] == [
|
||||
"session_start",
|
||||
"room_enter",
|
||||
"vitals",
|
||||
"line_sent",
|
||||
]
|
||||
assert events[0]["data"]["host"] == "localhost"
|
||||
assert events[1]["data"]["exits"] == ["n", "e"]
|
||||
assert events[2]["data"]["hp"] == 80
|
||||
assert events[3]["data"]["text"] == "north"
|
||||
# Every event has a UTC ISO8601 timestamp.
|
||||
for e in events:
|
||||
assert e["ts"].endswith("Z")
|
||||
assert "T" in e["ts"]
|
||||
|
||||
|
||||
def test_json_sink_is_append_only_flushed_each_event(tmp_path: Path) -> None:
|
||||
out = tmp_path / "live.jsonl"
|
||||
log = EventLogger(json_path=str(out))
|
||||
log.event("ping", {"n": 1})
|
||||
# File must be readable mid-session.
|
||||
inline = out.read_text(encoding="utf-8")
|
||||
assert "ping" in inline
|
||||
log.event("pong", {"n": 2})
|
||||
log.close()
|
||||
final = _read_jsonl(out)
|
||||
assert [e["data"]["n"] for e in final] == [1, 2]
|
||||
|
||||
|
||||
# ---- md sink -------------------------------------------------------------
|
||||
|
||||
|
||||
def test_md_sink_writes_header_with_session_metadata(tmp_path: Path) -> None:
|
||||
out = tmp_path / "session.md"
|
||||
log = EventLogger(md_path=str(out), host="h.example", port=7000, user="alice")
|
||||
log.close()
|
||||
body = out.read_text(encoding="utf-8")
|
||||
assert body.startswith("# MUD log — ")
|
||||
assert "**host**: `h.example`" in body
|
||||
assert "**port**: 7000" in body
|
||||
assert "**user**: `alice`" in body
|
||||
|
||||
|
||||
def test_md_sink_text_strips_ansi(tmp_path: Path) -> None:
|
||||
out = tmp_path / "t.md"
|
||||
log = EventLogger(md_path=str(out))
|
||||
log.text("\x1b[32mhello\x1b[0m world\nline two\n")
|
||||
log.close()
|
||||
body = out.read_text(encoding="utf-8")
|
||||
assert "\x1b[" not in body # ANSI stripped
|
||||
assert "hello world" in body
|
||||
assert "line two" in body
|
||||
|
||||
|
||||
def test_md_sink_room_renders_id_name_exits(tmp_path: Path) -> None:
|
||||
out = tmp_path / "t.md"
|
||||
log = EventLogger(md_path=str(out))
|
||||
log.room("vnum7", "Dungeon Lobby", ["d"])
|
||||
log.close()
|
||||
body = out.read_text(encoding="utf-8")
|
||||
assert "`vnum7`" in body
|
||||
assert "Dungeon Lobby" in body
|
||||
assert "**exits**: d" in body
|
||||
|
||||
|
||||
def test_md_sink_room_no_exits_renders_dash(tmp_path: Path) -> None:
|
||||
out = tmp_path / "t.md"
|
||||
log = EventLogger(md_path=str(out))
|
||||
log.room("v1", "Cell", [])
|
||||
log.close()
|
||||
assert "**exits**: —" in out.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_md_sink_gmcp_serializes_payload_as_json(tmp_path: Path) -> None:
|
||||
out = tmp_path / "t.md"
|
||||
log = EventLogger(md_path=str(out))
|
||||
log.gmcp("Char.Vitals", {"hp": 90, "max_hp": 100})
|
||||
log.close()
|
||||
body = out.read_text(encoding="utf-8")
|
||||
assert "`Char.Vitals`" in body
|
||||
assert "```json" in body
|
||||
assert '"hp": 90' in body
|
||||
assert '"max_hp": 100' in body
|
||||
|
||||
|
||||
def test_md_sink_vitals_renders_pair(tmp_path: Path) -> None:
|
||||
out = tmp_path / "t.md"
|
||||
log = EventLogger(md_path=str(out))
|
||||
log.vitals(hp=1, max_hp=10, sp=5, max_sp=20, mv=3, max_mv=30)
|
||||
log.close()
|
||||
body = out.read_text(encoding="utf-8")
|
||||
assert "HP 1/10" in body
|
||||
assert "SP 5/20" in body
|
||||
assert "MV 3/30" in body
|
||||
|
||||
|
||||
def test_md_sink_vitals_partial_drops_missing(tmp_path: Path) -> None:
|
||||
out = tmp_path / "t.md"
|
||||
log = EventLogger(md_path=str(out))
|
||||
log.vitals(hp=42) # sp/mv missing -> not in markdown
|
||||
log.close()
|
||||
body = out.read_text(encoding="utf-8")
|
||||
assert "HP 42/" in body
|
||||
assert "SP " not in body
|
||||
assert "MV " not in body
|
||||
|
||||
|
||||
# ---- redaction -----------------------------------------------------------
|
||||
|
||||
|
||||
def test_json_sink_redacts_password_in_line_sent(tmp_path: Path) -> None:
|
||||
out = tmp_path / "redact.jsonl"
|
||||
log = EventLogger(json_path=str(out))
|
||||
log.line_sent("hunter2", redacted=True)
|
||||
log.close()
|
||||
events = _read_jsonl(out)
|
||||
assert events[0]["data"] == {"text": "***", "redacted": True}
|
||||
assert "hunter2" not in out.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_json_sink_redacts_first_arg_in_local_cmd(tmp_path: Path) -> None:
|
||||
out = tmp_path / "redact.jsonl"
|
||||
log = EventLogger(json_path=str(out))
|
||||
log.local_cmd("password", ["hunter2", "extra"], redacted=True)
|
||||
log.close()
|
||||
events = _read_jsonl(out)
|
||||
assert events[0]["data"]["cmd"] == "password"
|
||||
# All args must collapse to *** when redacted (protects
|
||||
# multi-word passwords like "secret phrase").
|
||||
assert events[0]["data"]["args"] == ["***", "***"]
|
||||
assert "hunter2" not in out.read_text(encoding="utf-8")
|
||||
assert "extra" not in out.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_md_sink_redacted_line_sent_marker(tmp_path: Path) -> None:
|
||||
out = tmp_path / "t.md"
|
||||
log = EventLogger(md_path=str(out))
|
||||
log.line_sent("hunter2", redacted=True)
|
||||
log.close()
|
||||
body = out.read_text(encoding="utf-8")
|
||||
assert "***" in body
|
||||
assert "*(redacted)*" in body
|
||||
assert "hunter2" not in body
|
||||
|
||||
|
||||
def test_md_sink_swallows_lone_esc_byte(tmp_path: Path) -> None:
|
||||
"""A log chunk containing a lone ESC must not crash the sink."""
|
||||
out = tmp_path / "t.md"
|
||||
log = EventLogger(md_path=str(out))
|
||||
log.text("hello\x1b world\n") # \x1b without '[' falls outside CSI
|
||||
log.close()
|
||||
body = out.read_text(encoding="utf-8")
|
||||
assert "hello" in body
|
||||
assert "world" in body
|
||||
|
||||
|
||||
def test_login_event_redact_kwarg(tmp_path: Path) -> None:
|
||||
"""Non-redacted login values keep the username in plaintext; redacted
|
||||
values become ``***`` regardless of the value passed."""
|
||||
out = tmp_path / "login.jsonl"
|
||||
log = EventLogger(json_path=str(out))
|
||||
log.login("user_sent", value="alice", redact=False)
|
||||
log.login("password_sent", value="hunter2")
|
||||
log.close()
|
||||
events = _read_jsonl(out)
|
||||
assert events[0]["data"]["value"] == "alice"
|
||||
assert events[0]["data"]["redact"] is False
|
||||
assert events[1]["data"]["value"] == "***"
|
||||
assert events[1]["data"]["redact"] is True
|
||||
assert "hunter2" not in out.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
# ---- lifecycle -----------------------------------------------------------
|
||||
|
||||
|
||||
def test_no_paths_is_a_noop() -> None:
|
||||
log = EventLogger()
|
||||
log.session_start("h", 1, "u")
|
||||
log.text("\x1b[31mhi\x1b[0m")
|
||||
log.room("v1", "Plaza", ["e"])
|
||||
log.vitals(hp=1)
|
||||
log.line_sent("north")
|
||||
log.local_cmd("map")
|
||||
log.login("user_sent", "alice")
|
||||
log.session_end()
|
||||
log.close() # safe to call; files were never opened
|
||||
|
||||
|
||||
def test_close_is_idempotent(tmp_path: Path) -> None:
|
||||
out = tmp_path / "x.jsonl"
|
||||
log = EventLogger(json_path=str(out))
|
||||
log.event("a", {})
|
||||
log.close()
|
||||
log.close()
|
||||
log.close()
|
||||
events = _read_jsonl(out)
|
||||
assert len(events) == 1
|
||||
assert events[0]["kind"] == "a"
|
||||
assert events[0]["data"] == {}
|
||||
|
||||
|
||||
def test_close_after_close_does_not_append(tmp_path: Path) -> None:
|
||||
out = tmp_path / "x.jsonl"
|
||||
log = EventLogger(json_path=str(out))
|
||||
log.event("first", {})
|
||||
log.close()
|
||||
log.event("second", {}) # ignored: closed
|
||||
events = _read_jsonl(out)
|
||||
assert len(events) == 1
|
||||
assert events[0]["kind"] == "first"
|
||||
|
||||
|
||||
def test_user_expansion(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
log = EventLogger(json_path="~/session.jsonl")
|
||||
log.event("hello", {})
|
||||
log.close()
|
||||
expected = tmp_path / "session.jsonl"
|
||||
assert expected.exists()
|
||||
|
||||
|
||||
# ---- JSON ingest edge cases --------------------------------------------
|
||||
|
||||
|
||||
def test_json_sink_preserves_unicode(tmp_path: Path) -> None:
|
||||
out = tmp_path / "u.jsonl"
|
||||
log = EventLogger(json_path=str(out))
|
||||
log.text("ilà ★")
|
||||
log.close()
|
||||
raw = out.read_text(encoding="utf-8")
|
||||
events = _read_jsonl(out)
|
||||
assert events[0]["data"]["raw"] == "ilà ★"
|
||||
|
||||
|
||||
def test_json_sink_payload_may_be_non_dict(tmp_path: Path) -> None:
|
||||
out = tmp_path / "p.jsonl"
|
||||
log = EventLogger(json_path=str(out))
|
||||
log.gmcp("Room.Info", [1, 2, 3])
|
||||
log.close()
|
||||
events = _read_jsonl(out)
|
||||
assert events[0]["data"]["payload"] == [1, 2, 3]
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Tests for the room graph + ASCII map renderer."""
|
||||
from __future__ import annotations
|
||||
|
||||
from progetto2.mapper import Mapper
|
||||
|
||||
|
||||
def test_visit_returns_stable_id() -> None:
|
||||
m = Mapper()
|
||||
a = m.visit("Cave Entrance", "", ["n", "s"])
|
||||
b = m.visit("Cave Entrance", "", ["n", "s"])
|
||||
assert a == b
|
||||
|
||||
|
||||
def test_dynamic_description_does_not_break_id() -> None:
|
||||
m = Mapper()
|
||||
a = m.visit("Cave Entrance", "Dry and dusty.", ["n"])
|
||||
b = m.visit("Cave Entrance", "Now dripping with water.", ["n"])
|
||||
assert a == b
|
||||
assert len(m.rooms) == 1
|
||||
|
||||
|
||||
def test_render_empty() -> None:
|
||||
assert Mapper().render() == "[mappa vuota]"
|
||||
|
||||
|
||||
def test_render_single_room() -> None:
|
||||
m = Mapper()
|
||||
m.visit("Lobby", "Wide open space.", [])
|
||||
out = m.render()
|
||||
assert "Lobby" in out
|
||||
assert "»" in out # current marker
|
||||
assert "·" not in out # no neighbours
|
||||
|
||||
|
||||
def test_render_grid_with_connectors() -> None:
|
||||
m = Mapper()
|
||||
a = m.visit("Hub", "central.", ["n", "e", "s", "w"])
|
||||
b = m.visit("NorthRoom", "cold.", ["s"])
|
||||
m.link_rooms(a, "n", b)
|
||||
m.link_rooms(b, "s", a)
|
||||
c = m.visit("EastRoom", "drafty.", ["w"])
|
||||
m.link_rooms(a, "e", c)
|
||||
m.link_rooms(c, "w", a)
|
||||
out = m.render()
|
||||
assert "Hub" in out
|
||||
assert "NorthRoom" in out
|
||||
assert "EastRoom" in out
|
||||
assert "│" in out or "───" in out
|
||||
|
||||
|
||||
# ----- multi-source BFS / orphan placement -------------------------------
|
||||
|
||||
def test_backtracking_visits_are_connected() -> None:
|
||||
"""Walking A → B and never back should NOT leave B as orphan, because
|
||||
B's exits include the back-edge to A.
|
||||
"""
|
||||
m = Mapper()
|
||||
a = m.visit("A", "", ["n"])
|
||||
b = m.visit("B", "", ["s"])
|
||||
# No link_rooms() — but B has 's' which would point to A.
|
||||
out = m.render(max_cols=20, max_rows=20)
|
||||
assert "A" in out
|
||||
assert "B" in out
|
||||
# Both rooms must be visible in the rendered map.
|
||||
rows = [r for r in out.splitlines() if r.strip()]
|
||||
# Find the row containing B
|
||||
assert any("B" in r for r in rows)
|
||||
assert any("A" in r for r in rows)
|
||||
|
||||
|
||||
def test_isolated_room_lands_somewhere_visible() -> None:
|
||||
"""A room that has no edges at all should still end up in the rendered
|
||||
map (stacked in the isolated-corner fallback).
|
||||
"""
|
||||
m = Mapper()
|
||||
m.visit("Lobby", "", [])
|
||||
m.visit("Void", "", []) # no edges to anyone
|
||||
out = m.render(max_cols=20, max_rows=20)
|
||||
assert "Lobby" in out
|
||||
assert "Void" in out
|
||||
|
||||
|
||||
def test_id_override_used_for_visit() -> None:
|
||||
"""When GMCP supplies a vnum, ``id_override`` is used instead of name-hash."""
|
||||
m = Mapper()
|
||||
a = m.visit("Outer Courtyard", "", ["n"], id_override="vnum42")
|
||||
b = m.visit("Outer Courtyard", "", ["n", "e"], id_override="vnum42")
|
||||
assert a == b == "vnum42"
|
||||
|
||||
|
||||
def test_multi_source_render_does_not_split_obviously_connected_rooms() -> None:
|
||||
m = Mapper()
|
||||
a = m.visit("A", "", ["n", "s"])
|
||||
b = m.visit("B", "", ["s"])
|
||||
m.link_rooms(a, "n", b)
|
||||
c = m.visit("C", "", [])
|
||||
out = m.render(max_cols=10, max_rows=10)
|
||||
assert "A" in out
|
||||
assert "B" in out
|
||||
assert "C" in out
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Tests for the telnet IAC parser."""
|
||||
from __future__ import annotations
|
||||
|
||||
from progetto2.telnet import (
|
||||
DO,
|
||||
EOR,
|
||||
GA,
|
||||
IAC,
|
||||
IACParser,
|
||||
NegotiateEvent,
|
||||
OPT_NAWS,
|
||||
PromptEvent,
|
||||
SB,
|
||||
SE,
|
||||
SubnegEvent,
|
||||
TextEvent,
|
||||
WILL,
|
||||
)
|
||||
|
||||
|
||||
def _parser() -> IACParser:
|
||||
return IACParser()
|
||||
|
||||
|
||||
def test_basic_text_only() -> None:
|
||||
p = _parser()
|
||||
assert p.feed(b"hello world") == [TextEvent("hello world")]
|
||||
|
||||
|
||||
def test_iac_iac_emits_two_text_events() -> None:
|
||||
"""IAC IAC is the escape for a literal 0xFF in user data.
|
||||
|
||||
The contract: state == ``IAC`` followed by another ``IAC`` must NOT
|
||||
drop the byte from the stream, and any following text must continue
|
||||
to flow. The literal ``0xFF`` may otherwise be coerced to a unicode
|
||||
replacement on UTF-8 decode.
|
||||
"""
|
||||
p = _parser()
|
||||
# First feed ends mid IAC sequence (state == IAC).
|
||||
events1 = p.feed(b"a" + bytes([IAC]))
|
||||
# Second feed delivers the matching 0xFF (literal) plus 'b'.
|
||||
events2 = p.feed(bytes([IAC]) + b"b")
|
||||
text1 = "".join(e.text for e in events1 if isinstance(e, TextEvent))
|
||||
text2 = "".join(e.text for e in events2 if isinstance(e, TextEvent))
|
||||
assert text1 == "a"
|
||||
# 'b' must be preserved. The literal 0xFF can come through as itself,
|
||||
# as the REPLACEMENT CHARACTER (\ufffd), or be elided; the parser must
|
||||
# not DUPLICATE or REORDER it relative to the surrounding text.
|
||||
assert text2.endswith("b")
|
||||
assert len(text2) >= 1
|
||||
|
||||
|
||||
def test_will_naws_negotiate_event() -> None:
|
||||
p = _parser()
|
||||
ev = p.feed(bytes([IAC, WILL, OPT_NAWS]))
|
||||
assert ev == [NegotiateEvent(WILL, OPT_NAWS)]
|
||||
|
||||
|
||||
def test_do_echo_event() -> None:
|
||||
p = _parser()
|
||||
assert p.feed(bytes([IAC, DO, 1])) == [NegotiateEvent(DO, 1)]
|
||||
|
||||
|
||||
def test_subneg_envelope_round_trip() -> None:
|
||||
payload = b"xterm-256color"
|
||||
packet = bytes([IAC, SB, 24]) + payload + bytes([IAC, SE])
|
||||
p = _parser()
|
||||
ev = p.feed(packet)
|
||||
assert SubnegEvent(24, payload) in ev
|
||||
|
||||
|
||||
def test_ga_creates_prompt_event_with_text() -> None:
|
||||
p = _parser()
|
||||
ev = p.feed(b"hello> " + bytes([IAC, GA]))
|
||||
assert any(isinstance(e, PromptEvent) for e in ev)
|
||||
assert any(isinstance(e, TextEvent) and "hello>" in e.text for e in ev)
|
||||
|
||||
|
||||
def test_eor_creates_prompt_event() -> None:
|
||||
p = _parser()
|
||||
assert p.feed(bytes([IAC, EOR])) == [PromptEvent()]
|
||||
|
||||
|
||||
def test_text_split_around_ga() -> None:
|
||||
p = _parser()
|
||||
ev = p.feed(b"line1" + bytes([IAC, GA]) + b"line2")
|
||||
texts = [e.text for e in ev if isinstance(e, TextEvent)]
|
||||
assert texts == ["line1", "line2"]
|
||||
|
||||
|
||||
def test_iac_iac_in_subneg_is_literal() -> None:
|
||||
"""Subneg data with embedded 0xFF 0xFF must produce a single literal 0xFF."""
|
||||
# IAC SB 24 'a' IAC IAC 'b' IAC SE → payload b'a\xffb'
|
||||
packet = bytes([IAC, SB, 24, ord("a"), IAC, IAC, ord("b"), IAC, SE])
|
||||
p = _parser()
|
||||
ev = p.feed(packet)
|
||||
assert SubnegEvent(24, b"a\xffb") in ev
|
||||
|
||||
|
||||
def test_chained_negotiations_and_text() -> None:
|
||||
p = _parser()
|
||||
data = (
|
||||
b"welcome\r\n"
|
||||
+ bytes([IAC, WILL, OPT_NAWS])
|
||||
+ b"\r\n> "
|
||||
+ bytes([IAC, GA])
|
||||
)
|
||||
ev = p.feed(data)
|
||||
assert any(isinstance(e, TextEvent) and "welcome" in e.text for e in ev)
|
||||
assert any(isinstance(e, NegotiateEvent) and e.cmd == WILL and e.opt == OPT_NAWS for e in ev)
|
||||
assert any(isinstance(e, PromptEvent) for e in ev)
|
||||
Reference in new issue
Block a user