diff --git a/src/mcaxl/route_plan.py b/src/mcaxl/route_plan.py index 94077d5..a1a3a9d 100644 --- a/src/mcaxl/route_plan.py +++ b/src/mcaxl/route_plan.py @@ -732,12 +732,32 @@ def _suggest_failsafe_fix(client: "AxlClient", dest: str, broken_css: str) -> st then suggests either adding that partition to the broken CSS or switching the CSS to one that includes it. - Falls back to a generic message if the destination matches no - pattern in any partition (rarer; usually means the destination is - a literal extension that was deleted). + Two-stage match: + + 1. **Exact-literal match** — `np.dnorpattern = ''`. Catches + the common case where the destination string equals the + configured pattern. + 2. **Dot-stripped match** — for each pattern in `numplan` that + contains `.`, strip the dot and compare to `dest`. The CUCM + separator-dot represents access-code boundary, not a digit; + destination `10911` should match a configured pattern + `10.911`. + + Falls back to a generic message if neither match yields a + partition (rarer — usually means the destination is a wildcard + match like `60XXX` or a literal extension that was deleted, both + of which need operator investigation). + + Source: cti-audit-prompts/004 — the live Bingham smoke-test of + `912-CTI-RP` surfaced this limitation. The tool correctly flagged + the broken forward, but the suggested-fix message couldn't + identify `CER911-PT` (where pattern `10.911` lives) because the + exact-literal lookup didn't strip the separator-dot. """ safe_dest = _esc(dest) - sql = f""" + + # Stage 1: exact-literal match + exact_sql = f""" SELECT DISTINCT rp.name AS partition FROM numplan np LEFT OUTER JOIN routepartition rp ON np.fkroutepartition = rp.pkid @@ -745,33 +765,67 @@ def _suggest_failsafe_fix(client: "AxlClient", dest: str, broken_css: str) -> st AND rp.name IS NOT NULL """ try: - result = client.execute_sql_query(sql) + result = client.execute_sql_query(exact_sql) except Exception: return ( f"Destination {dest!r} is unreachable from CSS {broken_css!r}. " "Manual investigation needed to identify the correct partition." ) - partitions = [r["partition"] for r in result["rows"] if r.get("partition")] + matched_pattern_form = dest # what we'll cite in the fix message + + if not partitions: + # Stage 2: dot-stripped match. The CUCM separator-dot is purely + # visual; `10.911` and `10911` represent the same dialed digits. + # Pull all dot-containing patterns and filter Python-side rather + # than building a complex Informix REGEXP query. + dotted_sql = f""" + SELECT DISTINCT + np.dnorpattern AS pattern, + rp.name AS partition + FROM numplan np + LEFT OUTER JOIN routepartition rp ON np.fkroutepartition = rp.pkid + WHERE np.dnorpattern LIKE '%.%' + AND rp.name IS NOT NULL + """ + try: + dotted = client.execute_sql_query(dotted_sql) + except Exception: + dotted = {"rows": []} + for row in dotted["rows"]: + pat = row.get("pattern") or "" + if pat.replace(".", "") == dest: + partitions.append(row["partition"]) + matched_pattern_form = pat + # Dedupe in case the same partition holds multiple dot-variants + partitions = list(dict.fromkeys(partitions)) + if not partitions: return ( - f"Destination {dest!r} matches no exact-literal pattern in any " - f"partition. Either the destination string is wrong or it " - f"matches a wildcard pattern (use route_translation_chain to " - f"investigate further)." + f"Destination {dest!r} matches no exact-literal or " + f"dot-stripped pattern in any partition. Either the " + f"destination string is wrong or it matches a wildcard " + f"pattern (use route_translation_chain to investigate " + f"further)." ) + cited = ( + f"Pattern {matched_pattern_form!r} (matches destination {dest!r})" + if matched_pattern_form != dest + else f"Pattern {dest!r}" + ) + if len(partitions) == 1: part = partitions[0] return ( - f"Pattern {dest!r} lives in partition {part!r}. Either add " + f"{cited} lives in partition {part!r}. Either add " f"{part!r} to CSS {broken_css!r}, OR change the forward CSS " f"to a CSS that already contains {part!r}." ) return ( - f"Pattern {dest!r} exists in multiple partitions ({', '.join(partitions)}). " + f"{cited} exists in multiple partitions ({', '.join(partitions)}). " f"Identify the intended target partition, then either add it to " f"CSS {broken_css!r} or change the forward CSS accordingly." ) diff --git a/tests/test_cti_failsafe_reachability.py b/tests/test_cti_failsafe_reachability.py index 5138d87..717132c 100644 --- a/tests/test_cti_failsafe_reachability.py +++ b/tests/test_cti_failsafe_reachability.py @@ -32,7 +32,11 @@ class FakeAxlClient: - reachable_destinations: set of (destination, css) pairs that have a matching pattern (translation_chain returns match_count > 0 for these) - destination_partitions: dict {destination: [partition_name, ...]} - used by the _suggest_failsafe_fix's partition-lookup query + for the exact-literal partition-lookup query in _suggest_failsafe_fix + - dotted_patterns: list of (pattern, partition) tuples for the + dot-stripped lookup. The pattern includes literal dots (e.g. + ``"10.911"``); _suggest_failsafe_fix strips dots and compares to + the destination """ def __init__( @@ -40,10 +44,12 @@ class FakeAxlClient: cti_rp_rows: list[dict], reachable_destinations: set[tuple[str, str]] | None = None, destination_partitions: dict[str, list[str]] | None = None, + dotted_patterns: list[tuple[str, str]] | None = None, ): self._cti_rows = cti_rp_rows self._reachable = reachable_destinations or set() self._dest_partitions = destination_partitions or {} + self._dotted_patterns = dotted_patterns or [] self.queries: list[str] = [] def execute_sql_query(self, sql: str) -> dict: @@ -56,19 +62,8 @@ class FakeAxlClient: # Dispatch 2: translation_chain's reachability check # Recognizable by `tkpatternusage IN (3, 5, 7)` from route_plan.py if "tkpatternusage IN (3, 5, 7)" in sql: - # Extract the destination + CSS from the SQL to figure out - # whether to return a "match" row or no rows. The destination - # appears in the called-side filter; the CSS appears in the - # callingsearchspace WHERE clause. - # - # Simplest dispatch: scan the query for the (dest, css) pairs - # we know are reachable. If any match, return a fake matching - # pattern row. for dest, css in self._reachable: if f"name = '{css}'" in sql: - # For each reachable destination, the test fake returns - # a single pattern that exactly equals the destination - # so translation_chain's wildcard matcher resolves it. return { "row_count": 1, "rows": [{ @@ -85,9 +80,19 @@ class FakeAxlClient: } return {"row_count": 0, "rows": []} - # Dispatch 3: _suggest_failsafe_fix's partition-lookup query + # Dispatch 3a: _suggest_failsafe_fix's dot-stripped lookup + # (Stage 2 of the fix-suggestion logic — pulls all dot-containing + # patterns and filters Python-side) + if "np.dnorpattern LIKE '%.%'" in sql: + rows = [ + {"pattern": pat, "partition": part} + for pat, part in self._dotted_patterns + ] + return {"row_count": len(rows), "rows": rows} + + # Dispatch 3b: _suggest_failsafe_fix's exact-literal lookup + # (Stage 1 — exact match on np.dnorpattern) if "rp.name IS NOT NULL" in sql and "np.dnorpattern" in sql: - # Extract the dnorpattern literal from the SQL for dest, parts in self._dest_partitions.items(): if f"np.dnorpattern = '{dest}'" in sql: rows = [{"partition": p} for p in parts] @@ -277,17 +282,19 @@ class TestCtiFailsafeReachability: def test_suggested_fix_when_no_partition_holds_destination(self): # Edge case: destination doesn't match any literal pattern - # (might match a wildcard, but not an exact-literal). Suggest_fix - # falls back to a generic message. + # OR any dot-stripped variant (might match a wildcard, but not + # something exact). Falls back to the wildcard-investigation + # generic message. client = FakeAxlClient( cti_rp_rows=[ _cti_row("Wild-RP", "Generic", cfna="orphan-dest", cfna_css="BadCSS"), ], destination_partitions={}, # no partition holds 'orphan-dest' + # dotted_patterns defaults to [] → no dot-stripped match either ) result = cti_failsafe_reachability(client) fix = result["findings"][0]["suggested_fix"] - assert "matches no exact-literal pattern" in fix + assert "no exact-literal or dot-stripped pattern" in fix assert "wildcard" in fix.lower() def test_suggested_fix_when_destination_in_multiple_partitions(self): @@ -313,3 +320,104 @@ class TestCtiFailsafeReachability: # documented, and the life-safety token list is named. assert "CFB" in result["_note"] assert "emergency" in result["_note"] + + +# ─── Dot-stripped fix-suggestion (cti-audit-prompts/004 limitation) ──── +# +# The CUCM separator-dot in patterns like `10.911` is purely visual — +# it represents access-code boundary, not a digit. A destination string +# `10911` (no dot) should match a configured pattern `10.911`. The +# original _suggest_failsafe_fix only did exact-literal lookups and +# missed this; the live Bingham smoke-test surfaced the limitation on +# `912-CTI-RP`. These tests pin the dot-stripped fallback behavior. + +class TestDotStrippedFixSuggestion: + + def test_dot_stripped_match_cites_dotted_pattern(self): + # Destination "10911" should match pattern "10.911" via dot-strip + client = FakeAxlClient( + cti_rp_rows=[ + _cti_row("Test-RP", "Generic", cfna="10911", cfna_css="BadCSS"), + ], + destination_partitions={}, # no exact-literal match + dotted_patterns=[("10.911", "CER911-PT")], + ) + result = cti_failsafe_reachability(client) + fix = result["findings"][0]["suggested_fix"] + # The fix message names BOTH the pattern form and the destination + # so the operator sees what the dot-strip matched + assert "'10.911'" in fix + assert "'10911'" in fix + assert "CER911-PT" in fix + assert "BadCSS" in fix + + def test_exact_literal_takes_precedence_over_dotted(self): + # If both an exact-literal match and a dotted match exist, the + # exact-literal wins — no need to mention the dotted form + client = FakeAxlClient( + cti_rp_rows=[ + _cti_row("Test-RP", "Generic", cfna="912", cfna_css="BadCSS"), + ], + destination_partitions={"912": ["911CER-PT"]}, + dotted_patterns=[("9.12", "Decoy-PT")], # would match if dotted ran + ) + result = cti_failsafe_reachability(client) + fix = result["findings"][0]["suggested_fix"] + assert "911CER-PT" in fix + # Decoy-PT shouldn't appear — exact-literal should short-circuit + assert "Decoy-PT" not in fix + + def test_dotted_match_with_multiple_partitions(self): + # If the same dotted pattern exists in multiple partitions, the + # multi-partition message format applies — same as exact-literal + client = FakeAxlClient( + cti_rp_rows=[ + _cti_row("Test-RP", "Generic", cfna="10911", cfna_css="BadCSS"), + ], + destination_partitions={}, + dotted_patterns=[ + ("10.911", "Site-A-PT"), + ("10.911", "Site-B-PT"), + ], + ) + result = cti_failsafe_reachability(client) + fix = result["findings"][0]["suggested_fix"] + assert "multiple partitions" in fix + assert "Site-A-PT" in fix + assert "Site-B-PT" in fix + + def test_no_exact_no_dotted_falls_back_to_generic(self): + # Neither exact-literal nor dot-stripped lookup finds a match + # → fall back to the wildcard-investigation generic message + client = FakeAxlClient( + cti_rp_rows=[ + _cti_row("Test-RP", "Generic", cfna="60003", cfna_css="BadCSS"), + ], + destination_partitions={}, + dotted_patterns=[], + ) + result = cti_failsafe_reachability(client) + fix = result["findings"][0]["suggested_fix"] + assert "no exact-literal or dot-stripped pattern" in fix + assert "wildcard pattern" in fix.lower() + + def test_dotted_pattern_with_irrelevant_dot_does_not_match(self): + # Pattern "1.0911" has a dot but its dot-stripped form is "10911" + # — should match. Pattern "1.0912" stripped is "10912" — should NOT. + # This exercises the substring-equality logic. + client = FakeAxlClient( + cti_rp_rows=[ + _cti_row("Test-RP", "Generic", cfna="10911", cfna_css="BadCSS"), + ], + destination_partitions={}, + dotted_patterns=[ + ("1.0911", "Match-PT"), # strips to "10911" → matches + ("1.0912", "Nonmatch-PT"), # strips to "10912" → no match + ("10.91", "AnotherMatch-PT"), # strips to "1091" → no match + ], + ) + result = cti_failsafe_reachability(client) + fix = result["findings"][0]["suggested_fix"] + assert "Match-PT" in fix + assert "Nonmatch-PT" not in fix + assert "AnotherMatch-PT" not in fix