Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions tests/common/snappi_tests/common_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1486,6 +1486,85 @@ def get_pfcQueueGroupSize(default=8):
return default


def get_queue_scheduler_weight_dict(host_ans, asic_value=None, port=None,
qos_map_profile=None):
"""
Build a per-queue scheduler/weight map for an interface by joining the
``QUEUE`` and ``SCHEDULER`` config-DB tables, and optionally annotating
each queue with one of its DSCP values via ``DSCP_TO_TC_MAP`` and
``TC_TO_QUEUE_MAP``.

Args:
host_ans: Ansible host instance of the device.
asic_value: asic namespace; pass ``None`` (default) or the string
``"None"`` for single-asic devices.
port (str, optional): interface name to read ``QUEUE`` for. Defaults
to the first interface present in ``QUEUE``.
qos_map_profile (str, optional): name of the profile inside
``DSCP_TO_TC_MAP`` / ``TC_TO_QUEUE_MAP`` to use for the ``dscp``
field. Defaults to the first profile (typically ``"AZURE"``).
If the maps are missing, ``dscp`` is set to ``None``.

Returns:
dict[int, dict]: ``{queue: {"scheduler": <name>, "type": <DWRR/...>,
"weight": <int>, "dscp": <int|None>}}``. The result always covers
queues 0-7; any queue not present in the per-port ``QUEUE`` config
(or whose scheduler is missing from ``SCHEDULER``) falls back to a
default entry with equal DWRR weight 15.
"""
if asic_value in (None, "None"):
config_facts = host_ans.config_facts(host=host_ans.hostname,
source="running")["ansible_facts"]
else:
config_facts = host_ans.config_facts(host=host_ans.hostname,
source="running",
namespace=asic_value)["ansible_facts"]

queue_cfg_all = config_facts.get("QUEUE") or {}
scheduler_cfg = config_facts.get("SCHEDULER") or {}

dscp_to_tc = config_facts.get("DSCP_TO_TC_MAP") or {}
tc_to_queue = config_facts.get("TC_TO_QUEUE_MAP") or {}
if qos_map_profile is None and dscp_to_tc:
qos_map_profile = next(iter(dscp_to_tc))
dscp_to_tc_map = dscp_to_tc.get(qos_map_profile, {}) if qos_map_profile else {}
tc_to_queue_map = tc_to_queue.get(qos_map_profile, {}) if qos_map_profile else {}

queue_to_dscp = {}
for dscp, tc in dscp_to_tc_map.items():
q = tc_to_queue_map.get(str(tc))
if q is not None:
queue_to_dscp.setdefault(int(q), int(dscp))

# default entry with equal DWRR weight 15
result = {
q: {"scheduler": None, "type": "DWRR", "weight": 15,
"dscp": queue_to_dscp.get(q)}
for q in range(8)
}

if not queue_cfg_all:
return result

if port is None:
port = next(iter(queue_cfg_all))
if port not in queue_cfg_all:
raise KeyError("Port {} not found in QUEUE config (available: {})".format(port, sorted(queue_cfg_all)))

for q, value in queue_cfg_all[port].items():
scheduler = value.get("scheduler")
if scheduler is None or scheduler not in scheduler_cfg:
continue
sched = scheduler_cfg[scheduler]
result[int(q)] = {
"scheduler": scheduler,
"type": sched.get("type"),
"weight": int(sched["weight"]),
"dscp": queue_to_dscp.get(int(q)),
}
return result


@lru_cache
def get_testbed_from_args():
parser = ArgumentParser()
Expand Down
48 changes: 48 additions & 0 deletions tests/common/unit_tests/snappi_tests/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Unit Tests for `tests/common/snappi_tests/`

This directory contains unit tests for modules under
`tests/common/snappi_tests/`.

## Running Unit Tests

### Run all unit tests in this directory
```bash
# From repository root
python3 -m pytest --noconftest tests/common/unit_tests/snappi_tests/ -v
```

### Run a specific test file
```bash
python3 -m pytest --noconftest \
tests/common/unit_tests/snappi_tests/unit_test_common_helpers.py -v
```

### Run a specific test case
```bash
python3 -m pytest --noconftest \
tests/common/unit_tests/snappi_tests/unit_test_common_helpers.py::test_get_queue_scheduler_weight_dict \
-v
```

## Why `--noconftest`

`tests/conftest.py` pulls in integration-test dependencies (for example,
`paramiko`) that are not needed for these isolated unit tests. Using
`--noconftest` keeps the run lightweight and avoids unrelated import
failures.

The target module `tests/common/snappi_tests/common_helpers.py` itself
imports heavy dependencies (`tests.conftest`, `ipaddr`, mellanox helpers,
etc.) at import time. To stay dependency-free, the unit tests load only the
function under test by parsing the source with `ast` and `exec`-ing the
single function definition in an isolated namespace. No real DUT, Ansible
inventory, or Snappi environment is required.

If your environment has the full sonic-mgmt test dependencies installed and
you intentionally want global fixtures, you can remove `--noconftest`.

## Requirements

- Python 3
- `pytest`
- `unittest.mock` (built into Python standard library)
111 changes: 111 additions & 0 deletions tests/common/unit_tests/snappi_tests/unit_test_common_helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
"""Unit test for ``get_queue_scheduler_weight_dict`` in
``tests/common/snappi_tests/common_helpers.py``.

The target module imports heavy sonic-mgmt deps at import time, so we
extract the function under test via ``ast`` and exec it in an isolated
namespace.

Run with::

python3 -m pytest --noconftest \\
tests/common/unit_tests/snappi_tests/unit_test_common_helpers.py -v
"""

import ast
from pathlib import Path
from unittest.mock import MagicMock


MODULE_PATH = (Path(__file__).resolve().parents[3] /
"common/snappi_tests/common_helpers.py")


def _load(name):
tree = ast.parse(MODULE_PATH.read_text())
for node in tree.body:
if isinstance(node, ast.FunctionDef) and node.name == name:
ns = {}
exec(compile(ast.Module(body=[node], type_ignores=[]),
str(MODULE_PATH), "exec"), ns)
return ns[name]
raise LookupError(name)


# Minimal config_facts modeling the str-msn2700-22 (Mellanox-SN2700) DUT,
# trimmed to the keys read by ``get_queue_scheduler_weight_dict``:
# scheduler.0 -> lossy queues, DWRR weight 14
# scheduler.1 -> lossless queues 3 & 4, DWRR weight 15
CONFIG_FACTS = {
"QUEUE": {
"Ethernet100": {
"0": {"scheduler": "scheduler.0"},
"1": {"scheduler": "scheduler.0"},
"2": {"scheduler": "scheduler.0"},
"3": {"scheduler": "scheduler.1",
"wred_profile": "AZURE_LOSSLESS"},
"4": {"scheduler": "scheduler.1",
"wred_profile": "AZURE_LOSSLESS"},
"5": {"scheduler": "scheduler.0"},
"6": {"scheduler": "scheduler.0"},
},
},
"SCHEDULER": {
"scheduler.0": {"type": "DWRR", "weight": "14"},
"scheduler.1": {"type": "DWRR", "weight": "15"},
},
"DSCP_TO_TC_MAP": {
Comment thread
ediwibowo-msft marked this conversation as resolved.
"AZURE": {
"0": "1", "1": "1", "10": "1", "11": "1", "12": "1", "13": "1",
"14": "1", "15": "1", "16": "1", "17": "1", "18": "1", "19": "1",
"2": "1", "20": "1", "21": "1", "22": "1", "23": "1", "24": "1",
"25": "1", "26": "1", "27": "1", "28": "1", "29": "1", "3": "3",
"30": "1", "31": "1", "32": "1", "33": "1", "34": "1", "35": "1",
"36": "1", "37": "1", "38": "1", "39": "1", "4": "4", "40": "1",
"41": "1", "42": "1", "43": "1", "44": "1", "45": "1", "46": "5",
"47": "1", "48": "6", "49": "1", "5": "2", "50": "1", "51": "1",
"52": "1", "53": "1", "54": "1", "55": "1", "56": "1", "57": "1",
"58": "1", "59": "1", "6": "1", "60": "1", "61": "1", "62": "1",
"63": "1", "7": "1", "8": "0", "9": "1",
},
},
"TC_TO_QUEUE_MAP": {
"AZURE": {str(i): str(i) for i in range(8)},
},
}

EXPECTED = {
0: {"scheduler": "scheduler.0", "type": "DWRR", "weight": 14, "dscp": 8},
1: {"scheduler": "scheduler.0", "type": "DWRR", "weight": 14, "dscp": 0},
2: {"scheduler": "scheduler.0", "type": "DWRR", "weight": 14, "dscp": 5},
3: {"scheduler": "scheduler.1", "type": "DWRR", "weight": 15, "dscp": 3},
4: {"scheduler": "scheduler.1", "type": "DWRR", "weight": 15, "dscp": 4},
5: {"scheduler": "scheduler.0", "type": "DWRR", "weight": 14, "dscp": 46},
6: {"scheduler": "scheduler.0", "type": "DWRR", "weight": 14, "dscp": 48},
# Queue 7 is not in the per-port QUEUE config; falls back to default.
7: {"scheduler": None, "type": "DWRR", "weight": 15, "dscp": None},
}


def test_get_queue_scheduler_weight_dict():
host = MagicMock()
host.hostname = "test_dut"
host.config_facts.return_value = {"ansible_facts": CONFIG_FACTS}

assert _load("get_queue_scheduler_weight_dict")(host) == EXPECTED


def test_get_queue_scheduler_weight_dict_defaults_when_unconfigured():
"""When QUEUE/SCHEDULER are absent, fall back to 8 queues w/ equal weights."""
host = MagicMock()
host.hostname = "test_dut"
facts = {k: v for k, v in CONFIG_FACTS.items()
if k not in ("QUEUE", "SCHEDULER")}
host.config_facts.return_value = {"ansible_facts": facts}

result = _load("get_queue_scheduler_weight_dict")(host)
assert set(result) == set(range(8))
assert {v["weight"] for v in result.values()} == {15}
assert {v["type"] for v in result.values()} == {"DWRR"}
# DSCP annotations from DSCP_TO_TC_MAP / TC_TO_QUEUE_MAP still apply.
assert result[0]["dscp"] == 8
assert result[3]["dscp"] == 3
Loading
Loading