Spaces:
Sleeping
Sleeping
Graham Paasch commited on
Commit ·
e609fed
1
Parent(s): 4363b41
Make site count telemetry authoritative and tested
Browse files- agent/pipeline_engine.py +122 -69
- app.py +10 -3
- tests/test_site_counts.py +61 -0
agent/pipeline_engine.py
CHANGED
|
@@ -29,6 +29,8 @@ from .netbox_client import NetBoxClient
|
|
| 29 |
from agent.network_ops import create_gns3_project, build_network_from_description, get_lab_topology
|
| 30 |
|
| 31 |
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
| 32 |
|
| 33 |
|
| 34 |
@dataclass
|
|
@@ -249,29 +251,29 @@ class OvergrowthPipeline:
|
|
| 249 |
text_lower = text.lower()
|
| 250 |
for word, val in words.items():
|
| 251 |
if f"{word}-site" in text_lower or f"{word} site" in text_lower:
|
| 252 |
-
return max(
|
| 253 |
m = re.search(r"(\d+)[-\s]?site", text_lower)
|
| 254 |
if m:
|
| 255 |
try:
|
| 256 |
-
return max(
|
| 257 |
except Exception:
|
| 258 |
pass
|
| 259 |
m = re.search(r"(\d+)\s*sites?", text_lower)
|
| 260 |
if m:
|
| 261 |
try:
|
| 262 |
-
return max(
|
| 263 |
except Exception:
|
| 264 |
pass
|
| 265 |
m = re.search(r"(\d+)\s*(locations?|branches?)", text_lower)
|
| 266 |
if m:
|
| 267 |
try:
|
| 268 |
-
return max(
|
| 269 |
except Exception:
|
| 270 |
pass
|
| 271 |
m = re.search(r"(\d+)\s*(stores?|shops?)", text_lower)
|
| 272 |
if m:
|
| 273 |
try:
|
| 274 |
-
return max(
|
| 275 |
except Exception:
|
| 276 |
pass
|
| 277 |
return 3
|
|
@@ -280,6 +282,51 @@ class OvergrowthPipeline:
|
|
| 280 |
# Match the naming used by the GNS3 builder (store clouds)
|
| 281 |
return [f"☕-Store-{idx}" for idx in range(1, site_cnt + 1)]
|
| 282 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 283 |
def _ensure_wireguard(self):
|
| 284 |
"""
|
| 285 |
If WG_CONFIG_B64 is provided, bring up wg0 using that config.
|
|
@@ -343,6 +390,40 @@ class OvergrowthPipeline:
|
|
| 343 |
logger.error(f"WireGuard setup error: {e}")
|
| 344 |
self.wg_status = f"error: {e}"
|
| 345 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 346 |
def _prepare_lab_info(self, intent: NetworkIntent, create_project: bool = False) -> Dict[str, Any]:
|
| 347 |
"""
|
| 348 |
Return lab link info and optionally create a project via MCP to get the project_id.
|
|
@@ -1782,27 +1863,8 @@ Be specific and practical. Use RFC1918 addressing. Consider scalability and secu
|
|
| 1782 |
return []
|
| 1783 |
return []
|
| 1784 |
|
| 1785 |
-
def _count_branch_sites(nodes: List[Dict[str, Any]]) -> Dict[str, Any]:
|
| 1786 |
-
import re
|
| 1787 |
-
pattern = re.compile(r"^(?:☕-)?store[-\s]", re.IGNORECASE)
|
| 1788 |
-
cloud_sites = {
|
| 1789 |
-
str(n.get("name", ""))
|
| 1790 |
-
for n in nodes
|
| 1791 |
-
if pattern.match(str(n.get("name", "")))
|
| 1792 |
-
and (n.get("node_type") or "").lower() == "cloud"
|
| 1793 |
-
}
|
| 1794 |
-
switch_sites = {
|
| 1795 |
-
str(n.get("name", ""))
|
| 1796 |
-
for n in nodes
|
| 1797 |
-
if str(n.get("name", "")).startswith("SW-Store-")
|
| 1798 |
-
}
|
| 1799 |
-
return {
|
| 1800 |
-
"count": max(len(cloud_sites), len(switch_sites)),
|
| 1801 |
-
"cloud_sites": sorted(cloud_sites),
|
| 1802 |
-
"switch_sites": sorted(switch_sites),
|
| 1803 |
-
}
|
| 1804 |
-
|
| 1805 |
if self.gns3_server and self.enable_gns3_build and lab_info:
|
|
|
|
| 1806 |
try:
|
| 1807 |
base_desc = (
|
| 1808 |
intent.description
|
|
@@ -1819,63 +1881,54 @@ Be specific and practical. Use RFC1918 addressing. Consider scalability and secu
|
|
| 1819 |
)
|
| 1820 |
lab_info["build_result"] = build_resp
|
| 1821 |
nodes = _nodes_from_resp(build_resp)
|
| 1822 |
-
|
| 1823 |
-
|
| 1824 |
-
|
| 1825 |
-
|
| 1826 |
-
|
| 1827 |
-
|
| 1828 |
-
|
| 1829 |
-
|
| 1830 |
-
retry_resp = build_network_from_description(
|
| 1831 |
-
description=desc_retry,
|
| 1832 |
-
project_name=lab_info.get("project_name") or "overgrowth",
|
| 1833 |
-
auto_configure=True,
|
| 1834 |
-
site_count=site_count,
|
| 1835 |
-
)
|
| 1836 |
-
lab_info["build_retry"] = retry_resp
|
| 1837 |
except Exception as e:
|
| 1838 |
lab_info["build_error"] = str(e)
|
| 1839 |
|
| 1840 |
try:
|
| 1841 |
topology = get_lab_topology(lab_info.get("project_name") or "overgrowth")
|
| 1842 |
-
site_summary = _count_branch_sites(topology.get("nodes", []))
|
| 1843 |
-
|
| 1844 |
-
|
|
|
|
|
|
|
|
|
|
| 1845 |
lab_info["site_nodes"] = site_summary["cloud_sites"] or site_summary["switch_sites"]
|
| 1846 |
lab_info["topology"] = {
|
| 1847 |
"nodes": topology.get("nodes", []),
|
| 1848 |
"links": topology.get("links", []),
|
| 1849 |
}
|
|
|
|
|
|
|
| 1850 |
results["actual_site_count"] = actual_count
|
| 1851 |
-
|
| 1852 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1853 |
lab_info["site_count_mismatch"] = mismatch
|
| 1854 |
results["site_count_mismatch"] = mismatch
|
| 1855 |
-
if not lab_info.get("build_retry"):
|
| 1856 |
-
retry_resp = build_network_from_description(
|
| 1857 |
-
description=base_desc + " Retry to satisfy branch count.",
|
| 1858 |
-
project_name=lab_info.get("project_name") or "overgrowth",
|
| 1859 |
-
auto_configure=True,
|
| 1860 |
-
site_count=site_count,
|
| 1861 |
-
)
|
| 1862 |
-
lab_info["build_retry"] = retry_resp
|
| 1863 |
-
retry_topology = get_lab_topology(lab_info.get("project_name") or "overgrowth")
|
| 1864 |
-
retry_summary = _count_branch_sites(retry_topology.get("nodes", []))
|
| 1865 |
-
lab_info["actual_site_count"] = retry_summary["count"]
|
| 1866 |
-
lab_info["site_nodes"] = retry_summary["cloud_sites"] or retry_summary["switch_sites"]
|
| 1867 |
-
lab_info["topology"] = {
|
| 1868 |
-
"nodes": retry_topology.get("nodes", []),
|
| 1869 |
-
"links": retry_topology.get("links", []),
|
| 1870 |
-
}
|
| 1871 |
-
results["actual_site_count"] = retry_summary["count"]
|
| 1872 |
-
if retry_summary["count"] >= site_count:
|
| 1873 |
-
lab_info.pop("site_count_mismatch", None)
|
| 1874 |
-
results.pop("site_count_mismatch", None)
|
| 1875 |
-
else:
|
| 1876 |
-
mismatch = {"requested": site_count, "actual": retry_summary["count"]}
|
| 1877 |
-
lab_info["site_count_mismatch"] = mismatch
|
| 1878 |
-
results["site_count_mismatch"] = mismatch
|
| 1879 |
except Exception as e:
|
| 1880 |
lab_info["topology_error"] = str(e)
|
| 1881 |
results["lab"] = lab_info
|
|
|
|
| 29 |
from agent.network_ops import create_gns3_project, build_network_from_description, get_lab_topology
|
| 30 |
|
| 31 |
logger = logging.getLogger(__name__)
|
| 32 |
+
SITE_COUNT_MIN = 1
|
| 33 |
+
SITE_COUNT_MAX = 10
|
| 34 |
|
| 35 |
|
| 36 |
@dataclass
|
|
|
|
| 251 |
text_lower = text.lower()
|
| 252 |
for word, val in words.items():
|
| 253 |
if f"{word}-site" in text_lower or f"{word} site" in text_lower:
|
| 254 |
+
return max(SITE_COUNT_MIN, min(SITE_COUNT_MAX, val))
|
| 255 |
m = re.search(r"(\d+)[-\s]?site", text_lower)
|
| 256 |
if m:
|
| 257 |
try:
|
| 258 |
+
return max(SITE_COUNT_MIN, min(SITE_COUNT_MAX, int(m.group(1))))
|
| 259 |
except Exception:
|
| 260 |
pass
|
| 261 |
m = re.search(r"(\d+)\s*sites?", text_lower)
|
| 262 |
if m:
|
| 263 |
try:
|
| 264 |
+
return max(SITE_COUNT_MIN, min(SITE_COUNT_MAX, int(m.group(1))))
|
| 265 |
except Exception:
|
| 266 |
pass
|
| 267 |
m = re.search(r"(\d+)\s*(locations?|branches?)", text_lower)
|
| 268 |
if m:
|
| 269 |
try:
|
| 270 |
+
return max(SITE_COUNT_MIN, min(SITE_COUNT_MAX, int(m.group(1))))
|
| 271 |
except Exception:
|
| 272 |
pass
|
| 273 |
m = re.search(r"(\d+)\s*(stores?|shops?)", text_lower)
|
| 274 |
if m:
|
| 275 |
try:
|
| 276 |
+
return max(SITE_COUNT_MIN, min(SITE_COUNT_MAX, int(m.group(1))))
|
| 277 |
except Exception:
|
| 278 |
pass
|
| 279 |
return 3
|
|
|
|
| 282 |
# Match the naming used by the GNS3 builder (store clouds)
|
| 283 |
return [f"☕-Store-{idx}" for idx in range(1, site_cnt + 1)]
|
| 284 |
|
| 285 |
+
def _count_branch_sites(
|
| 286 |
+
self,
|
| 287 |
+
nodes: List[Dict[str, Any]],
|
| 288 |
+
expected_site_count: Optional[int] = None
|
| 289 |
+
) -> Dict[str, Any]:
|
| 290 |
+
"""
|
| 291 |
+
Count branch/store sites using strict naming from the retail builder.
|
| 292 |
+
Only counts clouds named ☕-Store-N and switches named SW-Store-N.
|
| 293 |
+
"""
|
| 294 |
+
import re
|
| 295 |
+
|
| 296 |
+
cloud_pat = re.compile(r"^☕-Store-(\d+)$")
|
| 297 |
+
sw_pat = re.compile(r"^SW-Store-(\d+)$")
|
| 298 |
+
cloud_sites: List[str] = []
|
| 299 |
+
switch_sites: List[str] = []
|
| 300 |
+
site_indices: set[int] = set()
|
| 301 |
+
|
| 302 |
+
for node in nodes or []:
|
| 303 |
+
name = str(node.get("name", "")).strip()
|
| 304 |
+
node_type = (node.get("node_type") or "").lower()
|
| 305 |
+
m_cloud = cloud_pat.match(name)
|
| 306 |
+
m_sw = sw_pat.match(name)
|
| 307 |
+
if m_cloud and node_type == "cloud":
|
| 308 |
+
idx = int(m_cloud.group(1))
|
| 309 |
+
site_indices.add(idx)
|
| 310 |
+
cloud_sites.append(name)
|
| 311 |
+
elif m_sw:
|
| 312 |
+
idx = int(m_sw.group(1))
|
| 313 |
+
site_indices.add(idx)
|
| 314 |
+
switch_sites.append(name)
|
| 315 |
+
|
| 316 |
+
site_count = len(site_indices)
|
| 317 |
+
missing: List[str] = []
|
| 318 |
+
if expected_site_count:
|
| 319 |
+
expected_names = [f"☕-Store-{i}" for i in range(1, expected_site_count + 1)]
|
| 320 |
+
missing = [name for name in expected_names if name not in cloud_sites]
|
| 321 |
+
|
| 322 |
+
return {
|
| 323 |
+
"count": site_count,
|
| 324 |
+
"indices": sorted(site_indices),
|
| 325 |
+
"cloud_sites": sorted(cloud_sites),
|
| 326 |
+
"switch_sites": sorted(switch_sites),
|
| 327 |
+
"missing": missing,
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
def _ensure_wireguard(self):
|
| 331 |
"""
|
| 332 |
If WG_CONFIG_B64 is provided, bring up wg0 using that config.
|
|
|
|
| 390 |
logger.error(f"WireGuard setup error: {e}")
|
| 391 |
self.wg_status = f"error: {e}"
|
| 392 |
|
| 393 |
+
def _extract_builder_summary(self, build_resp: Dict[str, Any]) -> Dict[str, Any]:
|
| 394 |
+
"""
|
| 395 |
+
Extract builder summary (site counts, nodes) from the MCP response.
|
| 396 |
+
Handles both parsed dicts and JSON-encoded 'text' fields.
|
| 397 |
+
"""
|
| 398 |
+
candidates: List[Dict[str, Any]] = []
|
| 399 |
+
if isinstance(build_resp, dict):
|
| 400 |
+
candidates.append(build_resp)
|
| 401 |
+
txt = build_resp.get("text")
|
| 402 |
+
if isinstance(txt, str):
|
| 403 |
+
try:
|
| 404 |
+
candidates.append(json.loads(txt))
|
| 405 |
+
except Exception:
|
| 406 |
+
pass
|
| 407 |
+
raw = build_resp.get("raw", {})
|
| 408 |
+
if isinstance(raw, dict):
|
| 409 |
+
content = raw.get("content") or []
|
| 410 |
+
if content and isinstance(content, list) and isinstance(content[0], dict):
|
| 411 |
+
txt2 = content[0].get("text")
|
| 412 |
+
if isinstance(txt2, str):
|
| 413 |
+
try:
|
| 414 |
+
candidates.append(json.loads(txt2))
|
| 415 |
+
except Exception:
|
| 416 |
+
pass
|
| 417 |
+
|
| 418 |
+
for cand in candidates:
|
| 419 |
+
if isinstance(cand, dict) and (
|
| 420 |
+
"site_count_built" in cand
|
| 421 |
+
or "site_count_requested" in cand
|
| 422 |
+
or "nodes" in cand
|
| 423 |
+
):
|
| 424 |
+
return cand
|
| 425 |
+
return {}
|
| 426 |
+
|
| 427 |
def _prepare_lab_info(self, intent: NetworkIntent, create_project: bool = False) -> Dict[str, Any]:
|
| 428 |
"""
|
| 429 |
Return lab link info and optionally create a project via MCP to get the project_id.
|
|
|
|
| 1863 |
return []
|
| 1864 |
return []
|
| 1865 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1866 |
if self.gns3_server and self.enable_gns3_build and lab_info:
|
| 1867 |
+
builder_site_count: Optional[int] = None
|
| 1868 |
try:
|
| 1869 |
base_desc = (
|
| 1870 |
intent.description
|
|
|
|
| 1881 |
)
|
| 1882 |
lab_info["build_result"] = build_resp
|
| 1883 |
nodes = _nodes_from_resp(build_resp)
|
| 1884 |
+
builder_summary = self._extract_builder_summary(build_resp)
|
| 1885 |
+
builder_site_count = builder_summary.get("site_count_built")
|
| 1886 |
+
lab_info["builder_site_count"] = builder_site_count
|
| 1887 |
+
results["builder_site_count"] = builder_site_count
|
| 1888 |
+
lab_info["builder_summary"] = builder_summary
|
| 1889 |
+
site_summary_resp = self._count_branch_sites(nodes, expected_site_count=site_count)
|
| 1890 |
+
lab_info["initial_site_count"] = site_summary_resp.get("count")
|
| 1891 |
+
lab_info["initial_missing_sites"] = site_summary_resp.get("missing", [])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1892 |
except Exception as e:
|
| 1893 |
lab_info["build_error"] = str(e)
|
| 1894 |
|
| 1895 |
try:
|
| 1896 |
topology = get_lab_topology(lab_info.get("project_name") or "overgrowth")
|
| 1897 |
+
site_summary = self._count_branch_sites(topology.get("nodes", []), expected_site_count=site_count)
|
| 1898 |
+
topology_count = site_summary["count"]
|
| 1899 |
+
missing_sites = site_summary.get("missing") or []
|
| 1900 |
+
lab_info["topology_site_count"] = topology_count
|
| 1901 |
+
results["topology_site_count"] = topology_count
|
| 1902 |
+
lab_info["missing_sites"] = missing_sites
|
| 1903 |
lab_info["site_nodes"] = site_summary["cloud_sites"] or site_summary["switch_sites"]
|
| 1904 |
lab_info["topology"] = {
|
| 1905 |
"nodes": topology.get("nodes", []),
|
| 1906 |
"links": topology.get("links", []),
|
| 1907 |
}
|
| 1908 |
+
actual_count = builder_site_count if builder_site_count is not None else topology_count
|
| 1909 |
+
lab_info["actual_site_count"] = actual_count
|
| 1910 |
results["actual_site_count"] = actual_count
|
| 1911 |
+
|
| 1912 |
+
mismatch = None
|
| 1913 |
+
if (
|
| 1914 |
+
builder_site_count is not None
|
| 1915 |
+
and builder_site_count != site_count
|
| 1916 |
+
) or (topology_count is not None and topology_count != site_count):
|
| 1917 |
+
mismatch = {
|
| 1918 |
+
"requested": site_count,
|
| 1919 |
+
"builder": builder_site_count,
|
| 1920 |
+
"topology": topology_count,
|
| 1921 |
+
}
|
| 1922 |
+
elif builder_site_count is not None and topology_count is not None and builder_site_count != topology_count:
|
| 1923 |
+
mismatch = {
|
| 1924 |
+
"requested": site_count,
|
| 1925 |
+
"builder": builder_site_count,
|
| 1926 |
+
"topology": topology_count,
|
| 1927 |
+
}
|
| 1928 |
+
|
| 1929 |
+
if mismatch:
|
| 1930 |
lab_info["site_count_mismatch"] = mismatch
|
| 1931 |
results["site_count_mismatch"] = mismatch
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1932 |
except Exception as e:
|
| 1933 |
lab_info["topology_error"] = str(e)
|
| 1934 |
results["lab"] = lab_info
|
app.py
CHANGED
|
@@ -607,16 +607,23 @@ def build_ui():
|
|
| 607 |
if isinstance(lab_info, dict):
|
| 608 |
missing_sites = lab_info.get("missing_sites") or []
|
| 609 |
requested_sites = lab_info.get("requested_site_count") or results.get("requested_site_count")
|
|
|
|
|
|
|
| 610 |
actual_sites = lab_info.get("actual_site_count") or results.get("actual_site_count")
|
| 611 |
mismatch = lab_info.get("site_count_mismatch") or results.get("site_count_mismatch")
|
| 612 |
-
if requested_sites:
|
| 613 |
-
status += f"- Requested site count: {requested_sites}\n"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 614 |
if actual_sites is not None:
|
| 615 |
status += f"- Actual site count: {actual_sites}\n"
|
| 616 |
if mismatch:
|
| 617 |
status += (
|
| 618 |
f"- Site count mismatch: requested {mismatch.get('requested')}, "
|
| 619 |
-
f"
|
|
|
|
| 620 |
)
|
| 621 |
build_err = lab_info.get("build_error") or (lab_info.get("build_result") or {}).get("error")
|
| 622 |
if build_err:
|
|
|
|
| 607 |
if isinstance(lab_info, dict):
|
| 608 |
missing_sites = lab_info.get("missing_sites") or []
|
| 609 |
requested_sites = lab_info.get("requested_site_count") or results.get("requested_site_count")
|
| 610 |
+
builder_sites = lab_info.get("builder_site_count") or results.get("builder_site_count")
|
| 611 |
+
topology_sites = lab_info.get("topology_site_count") or results.get("topology_site_count")
|
| 612 |
actual_sites = lab_info.get("actual_site_count") or results.get("actual_site_count")
|
| 613 |
mismatch = lab_info.get("site_count_mismatch") or results.get("site_count_mismatch")
|
| 614 |
+
if requested_sites is not None:
|
| 615 |
+
status += f"- Requested site count (parsed): {requested_sites}\n"
|
| 616 |
+
if builder_sites is not None:
|
| 617 |
+
status += f"- Builder site count (GNS3): {builder_sites}\n"
|
| 618 |
+
if topology_sites is not None:
|
| 619 |
+
status += f"- Topology site count (telemetry): {topology_sites}\n"
|
| 620 |
if actual_sites is not None:
|
| 621 |
status += f"- Actual site count: {actual_sites}\n"
|
| 622 |
if mismatch:
|
| 623 |
status += (
|
| 624 |
f"- Site count mismatch: requested {mismatch.get('requested')}, "
|
| 625 |
+
f"builder {mismatch.get('builder')}, "
|
| 626 |
+
f"topology {mismatch.get('topology')}\n"
|
| 627 |
)
|
| 628 |
build_err = lab_info.get("build_error") or (lab_info.get("build_result") or {}).get("error")
|
| 629 |
if build_err:
|
tests/test_site_counts.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Unit tests for site count parsing and topology counting helpers.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import unittest
|
| 7 |
+
from agent.pipeline_engine import OvergrowthPipeline
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class SiteCountTests(unittest.TestCase):
|
| 11 |
+
def setUp(self):
|
| 12 |
+
# Keep NetBox disabled for fast, offline unit tests.
|
| 13 |
+
self.pipeline = OvergrowthPipeline(use_netbox=False)
|
| 14 |
+
|
| 15 |
+
def test_parse_site_count_variants(self):
|
| 16 |
+
self.assertEqual(
|
| 17 |
+
self.pipeline._parse_site_count("Design a 4-site retail network with WiFi"),
|
| 18 |
+
4,
|
| 19 |
+
)
|
| 20 |
+
self.assertEqual(
|
| 21 |
+
self.pipeline._parse_site_count("We need a seven site deployment"), 7
|
| 22 |
+
)
|
| 23 |
+
self.assertEqual(
|
| 24 |
+
self.pipeline._parse_site_count("Build 9 stores across the region"), 9
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
def test_site_names_alignment(self):
|
| 28 |
+
self.assertEqual(
|
| 29 |
+
self.pipeline._site_names(3),
|
| 30 |
+
["☕-Store-1", "☕-Store-2", "☕-Store-3"],
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
def test_count_branch_sites_exact(self):
|
| 34 |
+
nodes = [
|
| 35 |
+
{"name": "☕-Store-1", "node_type": "cloud"},
|
| 36 |
+
{"name": "☕-Store-2", "node_type": "cloud"},
|
| 37 |
+
{"name": "SW-Store-1", "node_type": "qemu"},
|
| 38 |
+
{"name": "SW-Store-2", "node_type": "qemu"},
|
| 39 |
+
{"name": "☁️-Internet-MPLS", "node_type": "cloud"},
|
| 40 |
+
{"name": "-HQ-Building", "node_type": "cloud"},
|
| 41 |
+
]
|
| 42 |
+
summary = self.pipeline._count_branch_sites(nodes, expected_site_count=2)
|
| 43 |
+
self.assertEqual(summary["count"], 2)
|
| 44 |
+
self.assertEqual(summary["missing"], [])
|
| 45 |
+
|
| 46 |
+
def test_count_branch_sites_missing(self):
|
| 47 |
+
nodes = [
|
| 48 |
+
{"name": "☕-Store-1", "node_type": "cloud"},
|
| 49 |
+
{"name": "☕-Store-2", "node_type": "cloud"},
|
| 50 |
+
{"name": "☕-Store-3", "node_type": "cloud"},
|
| 51 |
+
{"name": "SW-Store-1", "node_type": "qemu"},
|
| 52 |
+
{"name": "SW-Store-2", "node_type": "qemu"},
|
| 53 |
+
{"name": "SW-Store-3", "node_type": "qemu"},
|
| 54 |
+
]
|
| 55 |
+
summary = self.pipeline._count_branch_sites(nodes, expected_site_count=4)
|
| 56 |
+
self.assertEqual(summary["count"], 3)
|
| 57 |
+
self.assertEqual(summary["missing"], ["☕-Store-4"])
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
if __name__ == "__main__":
|
| 61 |
+
unittest.main()
|