Skip to content

Commit 6cbe7c0

Browse files
committed
Upgraded tests and fixes in the topology editing feature
1 parent 309ec46 commit 6cbe7c0

9 files changed

Lines changed: 567 additions & 108 deletions

File tree

.clinerules

Lines changed: 0 additions & 6 deletions
This file was deleted.

panther/webapp/Topology.md

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -260,31 +260,55 @@ Native NiceGUI implementation for visualizing experiment configuration topologie
260260

261261
## Testing
262262

263-
### New Topology Tests
263+
### Topology Test Suite
264264

265265
1. `tests/unit/test_webapp/test_topology_service.py`
266-
- Verifies YAML -> graph transformation behavior:
266+
- Verifies YAML -> graph transformation behavior and topology metadata:
267267
- server-only node creation
268268
- server+client node + directed edge extraction
269+
- multiple clients targeting one server
269270
- no edge when `target` does not exist
271+
- empty config behavior (`tests: []`)
270272
- aggregated deduplication (`test_count`, edge `count`, merged network and execution environments)
271273
- per-test graph isolation and stable test indexes
274+
- shadow network metadata extraction (`latency`, `jitter`, `packet_loss`, `stop_time`)
275+
- node sizing rules and hard cap
276+
- scaling thresholds (4 / 8 / 12 / 13+ nodes)
277+
- default fallbacks for missing optional fields
278+
- category normalization (`iut`/`testers` -> `IUT`/`TESTERS`)
272279

273280
2. `tests/unit/test_webapp/test_topology_renderer_navigation.py`
274-
- Verifies node-click navigation logic:
281+
- Verifies renderer behavior and node-click navigation logic:
275282
- aggregated click stores first matching test index
276283
- per-test click uses explicit test index override
277284
- unknown aggregated service defaults to test index `0`
285+
- navigation query parameters are URL-encoded correctly
286+
- aggregated display labels are sanitized before navigation state is stored
287+
- dict-style event payload compatibility
288+
- ECharts option generation and scaling behavior
289+
- edge label suppression on dense graphs
278290
- validates required `topology_nav` fields:
279291
`config_path`, `test_index`, `service_id`, `service_name`, `source`
280292

281-
### How To Run
293+
### Commands (Topology Only)
282294

283-
1. Fast topology unit checks:
295+
1. Run the topology test suite:
284296
```bash
285297
.venv/bin/pytest tests/unit/test_webapp/test_topology_service.py tests/unit/test_webapp/test_topology_renderer_navigation.py -q -c pyproject.toml -n0 --no-cov
286298
```
287299

300+
2. Show collected topology tests:
301+
```bash
302+
.venv/bin/pytest tests/unit/test_webapp/test_topology_service.py tests/unit/test_webapp/test_topology_renderer_navigation.py --collect-only -q -c pyproject.toml -n0 --no-cov
303+
```
304+
305+
### Avoiding Common Errors
306+
307+
- Use `.venv/bin/pytest` so the same virtual environment is used as the project.
308+
- Keep topology validation scoped to the two files above to avoid unrelated webapp failures.
309+
- Use `--no-cov` for quick, stable local checks when you only need pass/fail status.
310+
- Browser/Selenium integration tests are intentionally excluded from the topology suite because they require additional host dependencies (Chrome/ChromeDriver/system libraries) and often fail in restricted environments.
311+
288312
---
289313

290314
## Future Enhancements

panther/webapp/components/forms/dict_list_widgets.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,9 @@ def _refresh(self):
197197
ui.button(
198198
icon="edit",
199199
on_click=lambda _, k=key: self._open_edit_dialog(k),
200-
).props("flat dense round size=sm")
200+
).props("flat dense round size=sm").classes(
201+
"panther-entry-edit-button"
202+
)
201203
ui.button(
202204
icon="close",
203205
on_click=lambda _, k=key: self._remove_entry(k),

panther/webapp/components/forms/test_list_editor.py

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ def __init__(self) -> None:
4747
self._test_data: list[dict[str, Any]] = []
4848
self._forms: list[Any] = [] # PydanticForm instances
4949
self._expansions: list[Any] = []
50+
self._preferred_open_index: int | None = None
5051
with ui.column().classes("w-full"):
5152
self._container = ui.column().classes("w-full gap-2 panther-test-list")
5253
ui.button(
@@ -68,12 +69,25 @@ def update_form_field(self, test_idx: int, field_name: str, value: Any) -> None:
6869
if test_idx < len(self._forms):
6970
self._forms[test_idx].set_field_value(field_name, value)
7071

71-
def set_value(self, tests: list[dict[str, Any]]) -> None:
72+
def set_value(
73+
self, tests: list[dict[str, Any]], open_index: int | None = None
74+
) -> None:
7275
"""Load a list of test dicts (e.g. from YAML import/load)."""
76+
self.set_open_test_index(open_index)
7377
self._test_data = list(tests) if tests else []
7478
self._forms = []
7579
self._rebuild()
7680

81+
def set_open_test_index(self, test_idx: int | None) -> None:
82+
"""Set which test panel should open after the next rebuild."""
83+
if test_idx is None:
84+
self._preferred_open_index = None
85+
return
86+
try:
87+
self._preferred_open_index = max(0, int(test_idx))
88+
except (TypeError, ValueError):
89+
self._preferred_open_index = None
90+
7791
def open_test(self, test_idx: int) -> bool:
7892
"""Open the expansion panel for a specific test index."""
7993
if not 0 <= test_idx < len(self._expansions):
@@ -87,6 +101,7 @@ def open_test(self, test_idx: int) -> bool:
87101
expansion = self._expansions[test_idx]
88102
if hasattr(expansion, "open"):
89103
expansion.open()
104+
self.set_open_test_index(test_idx)
90105
return True
91106
return False
92107

@@ -113,6 +128,10 @@ def _rebuild(self) -> None:
113128
)
114129
return
115130

131+
open_index = self._preferred_open_index
132+
if open_index is None or not 0 <= open_index < len(self._test_data):
133+
open_index = 0
134+
116135
for idx, test_data in enumerate(self._test_data):
117136
label = self._get_label(idx, test_data)
118137
exp = ui.expansion(
@@ -149,17 +168,16 @@ def _rebuild(self) -> None:
149168
form.set_value(test_data)
150169
self._forms.append(form)
151170

152-
# Auto-open the first (or only) panel
153-
if idx == 0:
171+
# Auto-open the requested panel, falling back to the first test.
172+
if idx == open_index:
154173
exp.open()
155174

156175
def _add_test(self) -> None:
157176
"""Append an empty test and rebuild."""
158177
self._snapshot()
159178
self._test_data.append({})
179+
self.set_open_test_index(len(self._test_data) - 1)
160180
self._rebuild()
161-
# Open the newly added panel (last one)
162-
self._open_last()
163181

164182
def _duplicate_test(self, idx: int) -> None:
165183
"""Deep-copy test at *idx*, append, and rebuild."""
@@ -171,25 +189,20 @@ def _duplicate_test(self, idx: int) -> None:
171189
if name:
172190
cloned["name"] = f"{name} (copy)"
173191
self._test_data.append(cloned)
192+
self.set_open_test_index(len(self._test_data) - 1)
174193
self._rebuild()
175-
self._open_last()
176194

177195
def _remove_test(self, idx: int) -> None:
178196
"""Remove test at *idx* and rebuild."""
179197
self._snapshot()
180198
if idx < len(self._test_data):
181199
self._test_data.pop(idx)
200+
if self._test_data:
201+
self.set_open_test_index(min(idx, len(self._test_data) - 1))
202+
else:
203+
self.set_open_test_index(None)
182204
self._rebuild()
183205

184-
def _open_last(self) -> None:
185-
"""Open the last expansion panel (newly added test)."""
186-
# The container's children are the expansion panels
187-
children = list(self._container)
188-
if children:
189-
last = children[-1]
190-
if hasattr(last, "open"):
191-
last.open()
192-
193206
@staticmethod
194207
def _get_label(idx: int, data: dict[str, Any]) -> str:
195208
"""Generate a display label for the expansion header."""

panther/webapp/pages/config_builder.py

Lines changed: 85 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,9 @@ def _dom_token(value: Any) -> str:
7171
return token.lower() or "unnamed"
7272

7373

74-
def _populate_forms_from_dict(panels: dict[str, Any], config_dict: dict) -> None:
74+
def _populate_forms_from_dict(
75+
panels: dict[str, Any], config_dict: dict, open_test_index: int | None = None
76+
) -> None:
7577
"""Populate all form panels from a parsed config dict.
7678
7779
Walks the three panel groups (``global``, ``tests``, ``metadata``)
@@ -85,6 +87,8 @@ def _populate_forms_from_dict(panels: dict[str, Any], config_dict: dict) -> None
8587
config_dict: A parsed experiment configuration dictionary,
8688
typically the output of ``yaml.safe_load()`` or
8789
``ConfigService.yaml_to_dict()``.
90+
open_test_index: Optional test panel to open while rebuilding
91+
the test editor.
8892
"""
8993
# Global sections
9094
for field_name, panel in panels.get("global", {}).items():
@@ -96,7 +100,9 @@ def _populate_forms_from_dict(panels: dict[str, Any], config_dict: dict) -> None
96100
tests = config_dict.get("tests")
97101
test_editor = panels.get("tests")
98102
if tests and isinstance(tests, list) and test_editor:
99-
test_editor.set_value([t for t in tests if isinstance(t, dict)])
103+
test_editor.set_value(
104+
[t for t in tests if isinstance(t, dict)], open_index=open_test_index
105+
)
100106

101107
# Metadata
102108
meta = config_dict.get("metadata")
@@ -717,7 +723,7 @@ def _auto_navigate_from_topology(
717723
# Populate forms from loaded data, then open the target test panel
718724
panels = _yaml_editor_ref.get("panels")
719725
if panels:
720-
_populate_forms_from_dict(panels, data)
726+
_populate_forms_from_dict(panels, data, open_test_index=test_index)
721727
test_editor = panels.get("tests")
722728
if test_editor and hasattr(test_editor, "open_test"):
723729
test_editor.open_test(test_index)
@@ -772,43 +778,86 @@ def _auto_navigate_from_topology(
772778
}}
773779
}});
774780
775-
// Phase 2: After expansion animation completes, find and highlight the service
776-
setTimeout(function() {{
777-
var target = document.querySelector({js_service_selector});
778-
if (!target && {js_service_id}) {{
779-
var allElements = targetExp.querySelectorAll('*:not(script):not(style)');
780-
for (var i = 0; i < allElements.length; i++) {{
781-
var el = allElements[i];
782-
var text = (el.textContent || el.value || '').toLowerCase();
783-
if (text.includes({js_service_id_lower}) || text.includes({js_service_name_lower})) {{
784-
target = el.closest('.panther-field-services-entry') ||
785-
el.closest('.q-field') ||
786-
el.closest('.q-item') ||
787-
el;
788-
break;
789-
}}
781+
function expandNestedSections() {{
782+
targetExp.querySelectorAll('.q-expansion__header').forEach(function(header, idx) {{
783+
if (idx === 0) return;
784+
var expanded = header.getAttribute('aria-expanded');
785+
var item = header.closest('.q-expansion-item');
786+
var isExpanded = expanded === 'true' ||
787+
(item && item.classList.contains('q-expansion-item--expanded'));
788+
if (!isExpanded) {{
789+
header.click();
790+
}}
791+
}});
792+
}}
793+
794+
function highlightTarget(target) {{
795+
target.scrollIntoView({{behavior: 'smooth', block: 'center'}});
796+
target.style.backgroundColor = '#fff3cd';
797+
target.style.boxShadow = '0 0 0 2px #ffc107';
798+
target.style.borderRadius = '4px';
799+
target.style.transition = 'background-color 2s, box-shadow 2s';
800+
setTimeout(function() {{
801+
target.style.backgroundColor = '';
802+
target.style.boxShadow = '';
803+
}}, 4000);
804+
}}
805+
806+
function findFallbackTarget() {{
807+
if (!{js_service_id}) return null;
808+
var allElements = targetExp.querySelectorAll('*:not(script):not(style)');
809+
for (var i = 0; i < allElements.length; i++) {{
810+
var el = allElements[i];
811+
var text = (el.textContent || el.value || '').toLowerCase();
812+
if (text.includes({js_service_id_lower}) || text.includes({js_service_name_lower})) {{
813+
return el.closest('.panther-field-services-entry') ||
814+
el.closest('.q-field') ||
815+
el.closest('.q-item') ||
816+
el;
817+
}}
818+
}}
819+
return null;
820+
}}
821+
822+
// Phase 2: wait for nested sections and service rows to render, then exact-match the clicked service.
823+
var attempts = 0;
824+
var maxAttempts = 20;
825+
var servicePoll = setInterval(function() {{
826+
attempts += 1;
827+
expandNestedSections();
828+
829+
var exactServiceRow = targetExp.querySelector({js_service_selector});
830+
if (exactServiceRow) {{
831+
clearInterval(servicePoll);
832+
highlightTarget(exactServiceRow);
833+
console.log("Highlighted exact service field:", {js_service_id});
834+
835+
var editButton = exactServiceRow.querySelector('.panther-entry-edit-button');
836+
if (editButton) {{
837+
setTimeout(function() {{
838+
editButton.click();
839+
console.log("Opened edit dialog for exact service:", {js_service_id});
840+
}}, 150);
841+
}} else {{
842+
console.log("Exact service row found, but no edit button was available:", {js_service_id});
790843
}}
844+
return;
791845
}}
792846
793-
if (target) {{
794-
target.scrollIntoView({{behavior: 'smooth', block: 'center'}});
795-
target.style.backgroundColor = '#fff3cd';
796-
target.style.boxShadow = '0 0 0 2px #ffc107';
797-
target.style.borderRadius = '4px';
798-
target.style.transition = 'background-color 2s, box-shadow 2s';
799-
setTimeout(function() {{
800-
target.style.backgroundColor = '';
801-
target.style.boxShadow = '';
802-
}}, 4000);
803-
console.log("Highlighted service field:", {js_service_id});
804-
}} else {{
805-
// Keep the user at the correct test even if the service row is not rendered.
806-
var rect = targetExp.getBoundingClientRect();
807-
var scrollTarget = window.scrollY + rect.top - 100;
808-
window.scrollTo({{top: scrollTarget, behavior: 'smooth'}});
809-
console.log("Could not find service field for:", {js_service_id_lower});
847+
if (attempts >= maxAttempts) {{
848+
clearInterval(servicePoll);
849+
var fallbackTarget = findFallbackTarget();
850+
if (fallbackTarget) {{
851+
highlightTarget(fallbackTarget);
852+
console.log("Highlighted fallback service field:", {js_service_id});
853+
}} else {{
854+
var rect = targetExp.getBoundingClientRect();
855+
var scrollTarget = window.scrollY + rect.top - 100;
856+
window.scrollTo({{top: scrollTarget, behavior: 'smooth'}});
857+
console.log("Could not find service field for:", {js_service_id_lower});
858+
}}
810859
}}
811-
}}, 700);
860+
}}, 250);
812861
}} else {{
813862
console.warn("Could not find test panel selector:", {js_test_panel_selector});
814863
var testsContainer = document.querySelector('.panther-test-list');

0 commit comments

Comments
 (0)