From 99e660afc3f7851178c6d2e1dc84b287c6632e11 Mon Sep 17 00:00:00 2001 From: agessaman Date: Sun, 29 Mar 2026 11:02:30 -0700 Subject: [PATCH] Enhance path formatting in MultitestCommand with new grouping logic - Updated the path formatting logic in `MultitestCommand` to support nested branches using shared prefixes and horizontal continuations. - Introduced new utility functions for grouping and formatting suffix lines, improving the clarity of output paths. - Modified the configuration example to clarify the behavior of the `condense_paths` option. - Expanded test coverage to validate the new path formatting behavior, ensuring accurate representation of unique paths. --- config.ini.example | 2 +- modules/commands/multitest_command.py | 113 +++++++++++++++++++---- tests/test_multitest_command.py | 124 +++++++++++++++++++++++++- 3 files changed, 217 insertions(+), 22 deletions(-) diff --git a/config.ini.example b/config.ini.example index 3d246ff..995912c 100644 --- a/config.ini.example +++ b/config.ini.example @@ -974,7 +974,7 @@ enabled = false # Leave empty to use default format # Example: "Found {path_count} unique path(s) for @[{sender}]:\n{paths}" response_format = @[{sender}] found {path_count} unique path(s):\n{paths} -# When true, {paths} uses shared-prefix lines plus tree branches (├ U+251C / └ U+2514) instead of repeating full paths +# When true, {paths} uses shared-prefix lines plus tree branches (├ U+251C / └ U+2514; continuations may use ├─ / └─ with U+2500) condense_paths = false [Greeter_Command] diff --git a/modules/commands/multitest_command.py b/modules/commands/multitest_command.py index c96cc5c..064567d 100644 --- a/modules/commands/multitest_command.py +++ b/modules/commands/multitest_command.py @@ -7,6 +7,7 @@ Listens for a period of time and collects all unique paths from incoming message import asyncio import re import time +from collections import defaultdict from dataclasses import dataclass from typing import Optional @@ -16,10 +17,14 @@ from .base_command import BaseCommand _BRANCH_INTER = "\u251c" # ├ (intermediate branch) _BRANCH_LAST = "\u2514" # └ (last branch) +_HORIZ = "\u2500" # ─ (BOX DRAWINGS LIGHT HORIZONTAL) +# Second-level branches: ├─ / └─ (horizontal continues the tee) +_BRANCH_CHILD_INTER = f"{_BRANCH_INTER}{_HORIZ} " +_BRANCH_CHILD_LAST = f"{_BRANCH_LAST}{_HORIZ} " -def _tree_branch_lines(suffixes: list[str]) -> list[str]: - """Format branch rows: ├ for all but the last, └ for the last; space after the tree char.""" +def _tree_branch_lines_flat(suffixes: list[str]) -> list[str]: + """Format branch rows: ├ for all but the last, └ for the last; space after the tee only.""" if not suffixes: return [] n = len(suffixes) @@ -30,6 +35,61 @@ def _tree_branch_lines(suffixes: list[str]) -> list[str]: return out +def _grouped_suffix_line_specs(non_empty: list[list[str]]) -> list[tuple[str, str]]: + """Build (line_kind, text) rows: 'head' uses ├/└ + space; 'nest' uses ├─/└─ + text.""" + by_first: dict[str, list[list[str]]] = defaultdict(list) + for suf in non_empty: + by_first[suf[0]].append(suf[1:]) + + specs: list[tuple[str, str]] = [] + for ft in sorted(by_first.keys()): + rests = by_first[ft] + if len(rests) == 1: + r = rests[0] + if not r: + specs.append(("head", ft)) + else: + specs.append(("head", ",".join([ft, *r]))) + continue + specs.append(("head", ft)) + for rem in sorted((r for r in rests if r), key=lambda x: ",".join(x)): + specs.append(("nest", ",".join(rem))) + return specs + + +def _apply_tee_prefixes(specs: list[tuple[str, str]]) -> list[str]: + """Assign ├/└ and ├─/└─ from flattened order; only the final row uses └/└─.""" + n = len(specs) + out: list[str] = [] + for i, (kind, text) in enumerate(specs): + last = i == n - 1 + if kind == "head": + p = _BRANCH_LAST if last else _BRANCH_INTER + out.append(f"{p} {text}") + else: + p = _BRANCH_CHILD_LAST if last else _BRANCH_CHILD_INTER + out.append(f"{p}{text}") + return out + + +def _format_suffix_branch_lines(suffix_tokens: list[list[str]], short_ellipsis: bool) -> list[str]: + """Format suffixes after display LCP: group by first hop, nest continuations with U+2500.""" + non_empty = [s for s in suffix_tokens if s] + if not non_empty: + return _tree_branch_lines_flat(["..."]) if short_ellipsis else [] + + if len(non_empty) == 1: + parts = [",".join(non_empty[0])] + if short_ellipsis: + parts.append("...") + return _tree_branch_lines_flat(parts) + + specs = _grouped_suffix_line_specs(non_empty) + if short_ellipsis: + specs.append(("head", "...")) + return _apply_tee_prefixes(specs) + + def _path_to_tokens(path: str) -> list[str]: """Split a comma-separated path into non-empty hex token strings.""" return [p.strip() for p in path.split(",") if p.strip()] @@ -62,8 +122,32 @@ def _longest_common_prefix(token_lists: list[list[str]]) -> list[str]: return list(first[: min(len(tl) for tl in token_lists)]) +def _shrink_display_lcp(maximal: list[list[str]], lcp: list[str]) -> list[str]: + """Shorten displayed LCP when one path ends exactly at LCP and another continues past it. + + Avoids showing the shorter path as the 'trunk' with only the tail as a branch (e.g. …0101 + on the common line and └ 0970), which reads like a single endpoint plus an offshoot. + """ + lcp = list(lcp) + while len(lcp) > 1: + has_exact = any(t == lcp for t in maximal) + has_extend = any(_is_strict_prefix(lcp, t) for t in maximal) + if has_exact and has_extend: + lcp.pop() + else: + break + return lcp + + def _format_path_cluster(token_lists: list[list[str]], use_brackets: bool) -> list[str]: - """Format a cluster of token lists into condensed lines (common prefix + ├/└ branches).""" + """Format a cluster into condensed lines (common prefix + ├/└ and ├─/└─). + + Suffixes are grouped by their first hop after the display LCP; multiple variants under the same + hop use one ├ line for that hop and ├─/└─ for continuations. The last line of the block uses └/└─. + + If one path stops exactly where another continues, the displayed LCP is shortened so the shared + segment is not mistaken for a single endpoint (e.g. only └ tail after a full shorter path). + """ token_lists = [t for t in token_lists if t] if not token_lists: return [] @@ -75,29 +159,24 @@ def _format_path_cluster(token_lists: list[list[str]], use_brackets: bool) -> li short_ellipsis = len(maximal) < len(token_lists) if not maximal: - return _tree_branch_lines(["..."]) if short_ellipsis else [] + return _tree_branch_lines_flat(["..."]) if short_ellipsis else [] - lcp = _longest_common_prefix(maximal) + raw_lcp = _longest_common_prefix(maximal) + lcp = _shrink_display_lcp(maximal, raw_lcp) if len(lcp) > 0: + suffix_tokens = [t[len(lcp) :] for t in maximal] lines = [",".join(lcp)] - branches: list[str] = [] - for t in maximal: - suf = t[len(lcp) :] - if suf: - branches.append(",".join(suf)) - suffix_parts = sorted(branches) - if short_ellipsis: - suffix_parts.append("...") - if suffix_parts: - lines.extend(_tree_branch_lines(suffix_parts)) + branch_lines = _format_suffix_branch_lines(suffix_tokens, short_ellipsis) + if branch_lines: + lines.extend(branch_lines) return lines if len(maximal) == 1: s = ",".join(maximal[0]) lines = [f"[{s}]"] if use_brackets else [s] if short_ellipsis: - lines.extend(_tree_branch_lines(["..."])) + lines.extend(_tree_branch_lines_flat(["..."])) return lines groups: dict[str, list[list[str]]] = {} @@ -110,7 +189,7 @@ def _format_path_cluster(token_lists: list[list[str]], use_brackets: bool) -> li sub_lines = _format_path_cluster(groups[ft], use_brackets=multi) lines.extend(sub_lines) if short_ellipsis: - lines.extend(_tree_branch_lines(["..."])) + lines.extend(_tree_branch_lines_flat(["..."])) return lines diff --git a/tests/test_multitest_command.py b/tests/test_multitest_command.py index fd9bb9c..9998e51 100644 --- a/tests/test_multitest_command.py +++ b/tests/test_multitest_command.py @@ -12,6 +12,9 @@ from tests.conftest import mock_message _INTER = "\u251c" _LAST = "\u2514" +_HORIZ = "\u2500" +_CHILD_INTER = f"{_INTER}{_HORIZ} " +_CHILD_LAST = f"{_LAST}{_HORIZ} " def _make_bot(): @@ -162,7 +165,7 @@ class TestCondensePathLines: "e6,0c,85,82,28,1a,cd,7e", f"{_INTER} 01", f"{_INTER} 7a", - f"{_INTER} 7a,09", + f"{_CHILD_INTER}09", f"{_LAST} ...", ] ) @@ -177,11 +180,124 @@ class TestCondensePathLines: ] ) out = _condense_path_lines(paths) + # LCP shrinks so cc is not the whole “trunk” while dd/ee branch off expected = "\n".join( [ - "aa,bb,cc", - f"{_INTER} dd", - f"{_LAST} ee", + "aa,bb", + f"{_INTER} cc", + f"{_CHILD_INTER}dd", + f"{_CHILD_LAST}ee", + ] + ) + assert out == expected + + def test_one_path_ends_at_lcp_other_extends(self): + """Shorter LCP so both 0101 and 0101,0970 appear as branches, not one hidden on the trunk.""" + paths = sorted(["cdf1,7e76,0101", "cdf1,7e76,0101,0970"]) + out = _condense_path_lines(paths) + expected = "\n".join( + [ + "cdf1,7e76", + f"{_INTER} 0101", + f"{_CHILD_LAST}0970", + ] + ) + assert out == expected + + def test_overlapping_suffix_branches_under_common_prefix(self): + paths = sorted( + [ + "cdf119,860cca,010101", + "cdf119,860cca,e0eed9", + "cdf119,860cca,e0eed9,1ed612", + ] + ) + out = _condense_path_lines(paths) + expected = "\n".join( + [ + "cdf119,860cca", + f"{_INTER} 010101", + f"{_INTER} e0eed9", + f"{_CHILD_LAST}1ed612", + ] + ) + assert out == expected + + def test_divergent_routes_with_shared_mid_prefix(self): + """TRM-style: group by first hop (13) so 01 vs 01,1e nest under ├─.""" + paths = sorted(["41,96,13,01", "41,96,13,01,1e", "41,96,83,09"]) + out = _condense_path_lines(paths) + expected = "\n".join( + [ + "41,96", + f"{_INTER} 13", + f"{_CHILD_INTER}01", + f"{_CHILD_INTER}01,1e", + f"{_LAST} 83,09", + ] + ) + assert out == expected + + def test_mixed_first_hops_nest_per_group(self): + """Ill Eagle-style: 01 vs 01,1e share a group; 09 and e0 are separate top-level branches.""" + paths = sorted( + [ + "e2,ab,1f,ef,55,21,01", + "e2,ab,1f,ef,55,21,01,1e", + "e2,ab,1f,ef,55,21,09", + "e2,ab,1f,ef,55,21,e0", + ] + ) + out = _condense_path_lines(paths) + expected = "\n".join( + [ + "e2,ab,1f,ef,55,21", + f"{_INTER} 01", + f"{_CHILD_INTER}1e", + f"{_INTER} 09", + f"{_LAST} e0", + ] + ) + assert out == expected + + def test_shorter_path_one_extra_hop_still_trees(self): + """860cca vs 860cca,010101: shrink trunk so both show as branches.""" + paths = sorted( + [ + "d38a05,c4a86a,067b75,cafee0,1ffbd6,e8154b,860cca,010101", + "d38a05,c4a86a,067b75,cafee0,1ffbd6,e8154b,860cca", + ] + ) + out = _condense_path_lines(paths) + expected = "\n".join( + [ + "d38a05,c4a86a,067b75,cafee0,1ffbd6,e8154b", + f"{_INTER} 860cca", + f"{_CHILD_LAST}010101", + ] + ) + assert out == expected + + def test_shared_hop_then_horiz_continuations(self): + """All paths share first hop after LCP → one ├ hop line then ├─/└─ remainders (U+2500).""" + paths = sorted( + [ + "d38a05,479198,a837bc,7e7662,e0eed9", + "d38a05,479198,a837bc,7e7662,e0eed9,010101", + "d38a05,479198,a837bc,7e7662,e0eed9,0970d6", + "d38a05,479198,a837bc,7e7662,e0eed9,1ed612", + "d38a05,479198,a837bc,7e7662,e0eed9,f", + ] + ) + out = _condense_path_lines(paths) + expected = "\n".join( + [ + "d38a05,479198,a837bc,7e7662", + f"{_INTER} e0eed9", + f"{_CHILD_INTER}010101", + f"{_CHILD_INTER}0970d6", + f"{_CHILD_INTER}1ed612", + f"{_CHILD_LAST}f", ] ) assert out == expected