mcaxl/tests/test_wildcard.py
Ryan Malloy ca6956e826 Rename to mcaxl + scrub for public PyPI release
Renames the package from `mcp-cucm-axl` to `mcaxl` to fit the
operator's mc<interface> naming convention (mcusb, mcaxl, …),
and scrubs Bingham-specific defaults so the package works for
anyone, anywhere.

Rename:
  - pyproject.toml: name, scripts entry point, description
  - src/mcp_cucm_axl/ → src/mcaxl/ (git mv preserves history)
  - All Python imports updated via sed
  - Cache directory: ~/.cache/mcp-cucm-axl/ → ~/.cache/mcaxl/
  - Log prefix [mcp-cucm-axl] → [mcaxl]
  - Package version lookup: importlib.metadata.version("mcaxl")
  - .mcp.json command updated to invoke `mcaxl` script
  - All 155 tests pass under the new name (verified)

Bingham-specific scrubs:
  - docs_loader._DEFAULT_INDEX_DIR: hardcoded /home/rpm/bingham/...
    path removed; defaults to None. Operators set CISCO_DOCS_INDEX_PATH
    env var; without it, prompts gracefully degrade with a fallback
    notice instructing the LLM to use the cisco-docs MCP search_docs
    tool instead.
  - prompts/_common.docs_or_empty_msg: removed the explicit
    /home/rpm/bingham/... path from the fallback message text.
  - server.py: removed dead-code copy of _docs_or_empty_msg() that
    was leftover from before the prompts package extraction.
  - README.md: completely rewritten as a public-facing readme. Lead
    paragraph names CUCM as the target platform, install instructions
    cover uvx / pip / Claude Code MCP add. Recommends cisco-cucm-mcp
    as the operations counterpart.

PyPI metadata:
  - Initial CalVer version: 2026.04.27
  - License: MIT (LICENSE file added)
  - Project URLs: Homepage / Source / Issues / Changelog all point
    at git.supported.systems/mcp/mcaxl (newly-created Gitea repo
    in the mcp/ org for PyPI releases)
  - Classifiers: Beta / Telecommunications Industry / Topic:Telephony
  - Keywords: mcp, cisco, cucm, axl, risport, voip, sip, audit
  - sdist excludes: CLAUDE.md, .env*, axlsqltoolkit.zip, audits/,
    tests/, pytest/ruff caches. Verified clean: wheel ships only the
    mcaxl/ source tree + LICENSE + METADATA + entry_points.

CHANGELOG.md added with a 2026.04.27 initial-release entry,
documenting tool/prompt counts, structural read-only guarantees,
Hamilton review closure, live-cluster verification, and known
limitations.

Build verification:
  - `uv build` produces clean wheel + sdist
  - Wheel: 22 source files, 195KB total, no Bingham-specific files
  - Sdist excludes verified: no CLAUDE.md, no axlsqltoolkit.zip
  - Entry point: `mcaxl = mcaxl.server:main`
  - Package installs as mcaxl==2026.4.27
2026-04-27 12:53:54 -06:00

155 lines
5.6 KiB
Python

"""Tests for CUCM dial-plan wildcard pattern matching."""
import pytest
from mcaxl.route_plan import _pattern_matches_number, _wildcard_to_regex
class TestLiteralPatterns:
def test_exact_match(self):
assert _pattern_matches_number("1001", "1001")
def test_no_match(self):
assert not _pattern_matches_number("1001", "1002")
def test_escaped_plus(self):
assert _pattern_matches_number(r"\+15551234567", "+15551234567")
assert not _pattern_matches_number(r"\+15551234567", "15551234567")
class TestXWildcard:
def test_X_matches_any_digit(self):
assert _pattern_matches_number("XXXX", "1234")
assert _pattern_matches_number("XXXX", "9999")
def test_X_only_matches_digits(self):
assert not _pattern_matches_number("XXXX", "abc1")
assert not _pattern_matches_number("XXXX", "12") # too short
assert not _pattern_matches_number("XXXX", "12345") # too long
def test_X_mixed_with_literal(self):
assert _pattern_matches_number("9XXXX", "91234")
assert not _pattern_matches_number("9XXXX", "81234")
class TestBangWildcard:
def test_bang_matches_one_or_more(self):
assert _pattern_matches_number("9!", "91")
assert _pattern_matches_number("9!", "915551234567")
def test_bang_requires_at_least_one(self):
assert not _pattern_matches_number("9!", "9")
class TestCharacterClass:
def test_class_matches_any_in_set(self):
assert _pattern_matches_number("[2-9]XXX", "2000")
assert _pattern_matches_number("[2-9]XXX", "9999")
def test_class_excludes_outside_set(self):
assert not _pattern_matches_number("[2-9]XXX", "1000")
assert not _pattern_matches_number("[2-9]XXX", "0000")
class TestDotTerminator:
def test_dot_is_zero_width(self):
# CUCM's '.' is a marker for digit-discard, not a regex char
assert _pattern_matches_number("9.911", "9911")
assert _pattern_matches_number("10.911", "10911")
def test_dot_with_X_after(self):
assert _pattern_matches_number("9.[2-9]XXXXXXXXX", "92085551234")
class TestAtPattern:
def test_at_matches_any_digits(self):
# @ would normally apply a route filter; we treat it as "any digits"
assert _pattern_matches_number("9.@", "915551234567")
assert _pattern_matches_number("\\+.@", "+15551234567")
class TestEdgeCases:
def test_invalid_regex_returns_false(self):
# An unbalanced bracket should not raise
result = _pattern_matches_number("[", "1")
assert result is False
def test_empty_pattern(self):
assert _pattern_matches_number("", "")
assert not _pattern_matches_number("", "1")
def test_regex_anchors(self):
# Make sure we don't match a substring
assert not _pattern_matches_number("911", "1911")
assert not _pattern_matches_number("911", "9111")
class TestRegexConversion:
def test_X_to_digit_class(self):
# Bounded after Hamilton MAJOR #4 — `X` still matches a single digit
assert _wildcard_to_regex("X") == r"^\d$"
def test_bang_to_bounded_digits(self):
# Bounded after Hamilton MAJOR #4 — was \d+, now \d{1,N}.
# Adjacent !!! used to compile to (\d+)(\d+)(\d+) which has
# exponential backtracking on near-miss inputs.
regex = _wildcard_to_regex("!")
# Must be anchored, must contain a digit class with an upper bound.
assert regex.startswith("^") and regex.endswith("$")
assert r"\d{1," in regex, (
f"`!` must compile to bounded `\\d{{1,N}}` to prevent "
f"catastrophic backtracking; got: {regex}"
)
def test_anchored(self):
regex = _wildcard_to_regex("9XXX")
assert regex.startswith("^")
assert regex.endswith("$")
class TestUnclosedBracketIsExplicitError:
"""Hamilton review MAJOR #4 (part 2): unclosed `[` used to silently
fall back to treating the bracket as a literal. That produced wrong
matches with no warning. Fix: surface the malformed pattern as an
explicit error so the caller can flag it.
"""
def test_unclosed_bracket_raises(self):
import pytest as _pytest
with _pytest.raises(ValueError, match="bracket"):
_wildcard_to_regex("[0-9")
def test_unclosed_bracket_in_pattern_match_returns_false(self):
# _pattern_matches_number must catch the ValueError from the
# malformed pattern and return False (so a single bad pattern
# doesn't crash translation_chain).
assert _pattern_matches_number("[0-9", "1") is False
def test_well_formed_bracket_still_works(self):
# Sanity: the fix shouldn't break legitimate character classes
assert _pattern_matches_number("[2-9]XX", "456")
assert not _pattern_matches_number("[2-9]XX", "156")
class TestRegexBacktrackingBound:
"""Hamilton review MAJOR #4 (part 1): adjacent `!` wildcards used to
compile to `\\d+\\d+\\d+...` which is exponentially slow on near-miss
input. Bounded `\\d{1,N}` keeps it polynomial.
"""
def test_pathological_pattern_completes_quickly(self):
# 10 adjacent `!` matched against a long near-miss number.
# Pre-fix this could take seconds; bounded should finish in ms.
import time
pat = "!" * 10
# 30 digits + a trailing letter — guarantees no full match
num = "1" * 30 + "X"
t0 = time.monotonic()
result = _pattern_matches_number(pat, num)
elapsed = time.monotonic() - t0
assert result is False
assert elapsed < 0.5, (
f"pathological `!` chain must finish quickly even on near-miss; "
f"took {elapsed:.3f}s"
)