# Copyright (c) 2023-2026 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Test anta._runner.py."""

from __future__ import annotations

import logging
import os
from collections import defaultdict
from pathlib import Path
from typing import ClassVar
from unittest.mock import AsyncMock, patch

import pytest
import respx
from pydantic import ValidationError

from anta._runner import AntaRunContext, AntaRunFilters, AntaRunner
from anta.catalog import AntaCatalog, AntaTestDefinition
from anta.device import AsyncEOSDevice
from anta.inventory import AntaInventory
from anta.models import AntaCommand, AntaTemplate, AntaTest
from anta.result_manager import ResultManager
from anta.result_manager.models import TestResult as AntaTestResult
from anta.settings import DEFAULT_MAX_CONCURRENCY, DEFAULT_NOFILE, AntaRunnerSettings
from anta.tests.routing.generic import VerifyRoutingTableEntry
from tests.units.test_models import FakeTest

DATA_DIR: Path = Path(__file__).parent.parent.resolve() / "data"


# pylint: disable=too-many-public-methods
class TestAntaRunner:
    """Test AntaRunner class."""

    def test_init_with_default_settings(self, caplog: pytest.LogCaptureFixture) -> None:
        """Test initialization with default settings."""
        caplog.set_level(logging.DEBUG)
        default_settings = {"nofile": DEFAULT_NOFILE, "max_concurrency": DEFAULT_MAX_CONCURRENCY}

        runner = AntaRunner()

        assert f"AntaRunner initialized with settings: {default_settings}" in caplog.messages
        assert runner._settings

    def test_init_with_custom_env_settings(self, caplog: pytest.LogCaptureFixture, setenvvar: pytest.MonkeyPatch) -> None:
        """Test initialization with custom env settings."""
        caplog.set_level(logging.DEBUG)
        desired_settings = {"nofile": 1048576, "max_concurrency": 10000}
        setenvvar.setenv("ANTA_NOFILE", str(desired_settings["nofile"]))
        setenvvar.setenv("ANTA_MAX_CONCURRENCY", str(desired_settings["max_concurrency"]))

        runner = AntaRunner()

        assert f"AntaRunner initialized with settings: {desired_settings}" in caplog.messages
        assert runner._settings

    def test_init_with_provided_settings(self, caplog: pytest.LogCaptureFixture) -> None:
        """Test initialization with provided settings."""
        caplog.set_level(logging.DEBUG)
        desired_settings = AntaRunnerSettings(nofile=1048576, max_concurrency=10000)

        runner = AntaRunner(settings=desired_settings)

        assert f"AntaRunner initialized with settings: {desired_settings.model_dump()}" in caplog.messages
        assert runner._settings

    async def test_dry_run(self, caplog: pytest.LogCaptureFixture) -> None:
        """Test AntaRunner.run() in dry-run."""
        caplog.set_level(logging.INFO)

        inventory = AntaInventory.parse(filename=DATA_DIR / "test_inventory_with_tags.yml", username="anta", password="anta")
        catalog = AntaCatalog.parse(filename=DATA_DIR / "test_catalog_with_tags.yml")
        runner = AntaRunner()
        ctx = await runner.run(inventory, catalog, dry_run=True)

        # Validate the final context attributes
        assert ctx.selected_inventory == ctx.inventory == inventory
        assert len(ctx.manager) > 0
        assert ctx.manager.status == "unset"
        assert ctx.total_tests_scheduled > 0
        assert ctx.total_devices_filtered_by_tags == 0
        assert ctx.total_devices_unreachable == 0
        assert ctx.total_devices_selected_for_testing == ctx.total_devices_in_inventory == len(inventory)
        assert ctx.duration is not None

        assert "Dry-run mode, exiting before running the tests." in caplog.messages

    @pytest.mark.parametrize(
        ("filters", "expected_devices", "expected_tests"),
        [
            pytest.param(
                AntaRunFilters(devices=None, tests=None, tags=None),
                3,
                27,
                id="all-tests",
            ),
            pytest.param(
                AntaRunFilters(devices=None, tests=None, tags={"leaf"}),
                2,
                6,
                id="1-tag",
            ),
            pytest.param(
                AntaRunFilters(devices=None, tests=None, tags={"leaf", "spine"}),
                3,
                9,
                id="2-tags",
            ),
            pytest.param(
                AntaRunFilters(devices=None, tests={"VerifyMlagStatus", "VerifyUptime"}, tags=None),
                3,
                5,
                id="filtered-tests",
            ),
            pytest.param(
                AntaRunFilters(devices=None, tests={"VerifyMlagStatus", "VerifyUptime"}, tags={"leaf"}),
                2,
                4,
                id="1-tag-filtered-tests",
            ),
            pytest.param(
                AntaRunFilters(devices={"leaf1"}, tests=None, tags=None),
                1,
                9,
                id="filtered-devices",
            ),
            pytest.param(
                AntaRunFilters(devices=None, tests=None, tags={"invalid"}),
                0,
                0,
                id="invalid-tag",
            ),
            pytest.param(
                AntaRunFilters(devices={"invalid"}, tests=None, tags=None),
                0,
                0,
                id="invalid-device",
            ),
            pytest.param(
                AntaRunFilters(devices=None, tests={"invalid"}, tags=None),
                3,
                0,
                id="invalid-test",
            ),
            pytest.param(
                AntaRunFilters(devices=None, tests=None, tags={"dc1"}),
                1,
                0,
                id="device-tag-no-tests",
            ),
        ],
    )
    async def test_run_filters(self, caplog: pytest.LogCaptureFixture, filters: AntaRunFilters, expected_devices: int, expected_tests: int) -> None:
        """Test AntaRunner.run() with different filters."""
        caplog.set_level(logging.WARNING)

        inventory = AntaInventory.parse(filename=DATA_DIR / "test_inventory_with_tags.yml", username="anta", password="anta")
        catalog = AntaCatalog.parse(filename=DATA_DIR / "test_catalog_with_tags.yml")
        runner = AntaRunner()
        ctx = await runner.run(inventory, catalog, filters=filters, dry_run=True)

        # Gather the warning message
        msg = None
        if expected_devices == 0:
            msg = "The inventory is empty after filtering by tags/devices. "
            if filters.devices:
                msg += f"Devices filter: {', '.join(sorted(filters.devices))}. "
            if filters.tags:
                msg += f"Tags filter: {', '.join(sorted(filters.tags))}. "
            msg += "Exiting ..."
        elif expected_tests == 0:
            msg = "No tests scheduled to run after filtering by tags/tests. "
            if filters.tests:
                msg += f"Tests filter: {', '.join(sorted(filters.tests))}. "
            if filters.tags:
                msg += f"Tags filter: {', '.join(sorted(filters.tags))}. "
            msg += "Exiting ..."

        if msg is not None:
            assert msg in ctx.warnings_at_setup
            assert msg in caplog.messages

        assert ctx.total_tests_scheduled == expected_tests
        assert ctx.total_devices_selected_for_testing == expected_devices

    async def test_run_invalid_filters(self) -> None:
        """Test AntaRunner.run() with invalid filters."""
        inventory = AntaInventory()
        catalog = AntaCatalog()
        runner = AntaRunner()

        with pytest.raises(ValidationError, match="1 validation error for AntaRunFilters"):
            await runner.run(inventory, catalog, filters=AntaRunFilters(devices="invalid"), dry_run=True)  # type: ignore[arg-type]

    async def test_run_provided_manager(self) -> None:
        """Test AntaRunner.run() with a provided ResultManager instance."""
        inventory = AntaInventory.parse(filename=DATA_DIR / "test_inventory_with_tags.yml", username="anta", password="anta")
        catalog = AntaCatalog.parse(filename=DATA_DIR / "test_catalog_with_tags.yml")
        manager = ResultManager()
        runner = AntaRunner()

        ctx = await runner.run(inventory, catalog, manager, dry_run=True)
        assert isinstance(ctx.manager, ResultManager)
        assert ctx.manager is manager
        assert len(manager) == 27

    async def test_run_provided_manager_not_empty(self, caplog: pytest.LogCaptureFixture) -> None:
        """Test AntaRunner.run() with a provided non-empty ResultManager instance."""
        caplog.set_level(logging.WARNING)

        inventory = AntaInventory.parse(filename=DATA_DIR / "test_inventory_with_tags.yml", username="anta", password="anta")
        catalog = AntaCatalog.parse(filename=DATA_DIR / "test_catalog_with_tags.yml")
        manager = ResultManager()
        test = AntaTestResult(name="DC1-LEAF1A", test="VerifyNTP", categories=["system"], description="NTP Test")
        runner = AntaRunner()
        manager.add(test)

        ctx = await runner.run(inventory, catalog, manager, dry_run=True)
        assert isinstance(ctx.manager, ResultManager)
        assert ctx.manager is manager
        assert len(manager) == 28
        assert len(manager.device_stats) == ctx.total_devices_selected_for_testing + 1

        warning_msg = (
            "Appending new results to the provided ResultManager which already holds 1 results. Statistics in this run context are for the current execution only."
        )
        assert warning_msg in ctx.warnings_at_setup
        assert warning_msg in caplog.messages

    async def test_run_empty_catalog(self, caplog: pytest.LogCaptureFixture) -> None:
        """Test AntaRunner.run() with an empty AntaCatalog."""
        caplog.set_level(logging.WARNING)

        inventory = AntaInventory.parse(filename=DATA_DIR / "test_inventory_with_tags.yml", username="anta", password="anta")
        catalog = AntaCatalog()
        runner = AntaRunner()

        ctx = await runner.run(inventory, catalog)

        warning_msg = "The list of tests is empty. Exiting ..."
        assert warning_msg in ctx.warnings_at_setup
        assert warning_msg in caplog.messages

    async def test_run_empty_inventory(self, caplog: pytest.LogCaptureFixture) -> None:
        """Test AntaRunner.run() with an empty AntaInventory."""
        caplog.set_level(logging.WARNING)

        inventory = AntaInventory()
        catalog = AntaCatalog.parse(filename=DATA_DIR / "test_catalog_with_tags.yml")
        runner = AntaRunner()

        ctx = await runner.run(inventory, catalog)

        warning_msg = "The initial inventory is empty. Exiting ..."
        assert warning_msg in ctx.warnings_at_setup
        assert warning_msg in caplog.messages

    @pytest.mark.parametrize("inventory", [{"reachable": False}], indirect=True)
    async def test_run_no_reachable_devices(self, caplog: pytest.LogCaptureFixture, inventory: AntaInventory) -> None:
        """Test AntaRunner.run() with an empty AntaInventory."""
        caplog.set_level(logging.WARNING)

        catalog = AntaCatalog.parse(filename=DATA_DIR / "test_catalog_with_tags.yml")
        runner = AntaRunner()

        ctx = await runner.run(inventory, catalog)
        assert ctx.total_devices_unreachable == ctx.total_devices_in_inventory
        assert "device-0" in ctx.devices_unreachable_at_setup

        warning_msg = "No reachable devices found for testing after connectivity checks. Exiting ..."
        assert warning_msg in ctx.warnings_at_setup
        assert warning_msg in caplog.messages

    @pytest.mark.parametrize(("inventory"), [{"count": 2, "reachable": False}], indirect=True)
    async def test_run_disconnect_closes_unreachable_devices(self, inventory: AntaInventory) -> None:
        """Test that disconnect closes devices touched during setup even when none are selected."""
        catalog = AntaCatalog.parse(filename=DATA_DIR / "test_catalog_with_tags.yml")
        runner = AntaRunner()

        ctx = await runner.run(inventory, catalog, disconnect=True)

        assert len(ctx.selected_inventory) == 0
        assert all(isinstance(device, AsyncEOSDevice) and device._client.is_closed for device in inventory.devices)

    async def test_run_disconnect_closes_reachable_and_unreachable_devices(self) -> None:
        """Test that disconnect closes every connected device, not only devices selected for tests."""
        reachable = AsyncEOSDevice(host="reachable.example.com", username="admin", password="password", name="reachable", disable_cache=True)
        unreachable = AsyncEOSDevice(host="unreachable.example.com", username="admin", password="password", name="unreachable", disable_cache=True)
        inventory = AntaInventory()
        inventory.add_device(reachable)
        inventory.add_device(unreachable)
        catalog = AntaCatalog.from_list([(FakeTest, None)])
        runner = AntaRunner()

        async def refresh_reachable() -> None:
            reachable.is_online = True
            reachable.established = True
            reachable.hw_model = "pytest"

        async def refresh_unreachable() -> None:
            unreachable.is_online = False
            unreachable.established = False

        with (
            patch.object(reachable, "refresh", new=AsyncMock(side_effect=refresh_reachable)),
            patch.object(unreachable, "refresh", new=AsyncMock(side_effect=refresh_unreachable)),
        ):
            ctx = await runner.run(inventory, catalog, disconnect=True)

        assert list(ctx.selected_inventory) == ["reachable"]
        assert len(ctx.manager) == 1
        assert reachable._client.is_closed
        assert unreachable._client.is_closed

    async def test_run_disconnect_excludes_filtered_devices(self) -> None:
        """Test that disconnect only targets devices matching device/tag filters."""
        included = AsyncEOSDevice(host="included.example.com", username="admin", password="password", name="included", disable_cache=True)
        excluded = AsyncEOSDevice(host="excluded.example.com", username="admin", password="password", name="excluded", disable_cache=True)
        inventory = AntaInventory()
        inventory.add_device(included)
        inventory.add_device(excluded)
        catalog = AntaCatalog.from_list([(FakeTest, None)])
        runner = AntaRunner()

        async def refresh_included() -> None:
            included.is_online = True
            included.established = True
            included.hw_model = "pytest"

        with (
            patch.object(included, "refresh", new=AsyncMock(side_effect=refresh_included)) as included_refresh,
            patch.object(excluded, "refresh", new=AsyncMock()) as excluded_refresh,
            patch.object(included, "disconnect", new=AsyncMock()) as included_disconnect,
            patch.object(excluded, "disconnect", new=AsyncMock()) as excluded_disconnect,
        ):
            ctx = await runner.run(inventory, catalog, filters=AntaRunFilters(devices={"included"}), disconnect=True)

        assert list(ctx.selected_inventory) == ["included"]
        assert len(ctx.manager) == 1
        included_refresh.assert_awaited_once()
        excluded_refresh.assert_not_awaited()
        included_disconnect.assert_awaited_once()
        excluded_disconnect.assert_not_awaited()

    async def test_run_disconnect_runs_with_empty_catalog(self, inventory: AntaInventory) -> None:
        """Test that disconnect still runs when the run exits before inventory setup."""
        catalog = AntaCatalog()
        runner = AntaRunner()

        with patch.object(inventory, "disconnect_inventory", new=AsyncMock()) as disconnect_inventory:
            ctx = await runner.run(inventory, catalog, disconnect=True)

        assert len(ctx.selected_inventory) == 0
        disconnect_inventory.assert_awaited_once()

    async def test_run_disconnect_on_test_execution_exception(self) -> None:
        """Test that disconnect runs when test execution raises unexpectedly."""
        device = AsyncEOSDevice(host="device.example.com", username="admin", password="password", name="device", disable_cache=True)
        inventory = AntaInventory()
        inventory.add_device(device)
        catalog = AntaCatalog.from_list([(FakeTest, None)])
        runner = AntaRunner()

        async def refresh() -> None:
            device.is_online = True
            device.established = True
            device.hw_model = "pytest"

        async def raise_during_execution() -> AntaTestResult:
            msg = "test execution failed"
            raise RuntimeError(msg)

        with (
            patch.object(device, "refresh", new=AsyncMock(side_effect=refresh)),
            patch.object(runner, "_get_test_coroutines", return_value=[raise_during_execution()]),
            pytest.raises(RuntimeError, match="test execution failed"),
        ):
            await runner.run(inventory, catalog, disconnect=True)

        assert device._client.is_closed

    async def test_run_invalid_anta_test(self, caplog: pytest.LogCaptureFixture) -> None:
        """Test AntaRunner.run() with a provided non-empty ResultManager instance."""
        caplog.set_level(logging.CRITICAL)

        class InvalidTest(AntaTest):
            """ANTA test that raises an exception when test is called."""

            categories: ClassVar[list[str]] = []
            commands: ClassVar[list[AntaCommand | AntaTemplate]] = []

            def test(self) -> None:  # type: ignore[override]
                """Test function."""
                msg = "Test not implemented"
                raise NotImplementedError(msg)

        inventory = AntaInventory.parse(filename=DATA_DIR / "test_inventory_with_tags.yml", username="anta", password="anta")
        test_definition = AntaTestDefinition(test=InvalidTest, inputs=None)
        catalog = AntaCatalog(tests=[test_definition])
        runner = AntaRunner()

        ctx = await runner.run(inventory, catalog, dry_run=True)
        assert len(ctx.manager) == 0

        error_msg = (
            f"There is an error when creating test {__name__}.InvalidTest.\n"
            "If this is not a custom test implementation: "
            "Please reach out to the maintainer team or open an issue on Github: https://github.com/aristanetworks/anta.\n"
            "NotImplementedError: Test not implemented"
        )
        assert error_msg in caplog.messages

    async def test_log_run_information_default(self, caplog: pytest.LogCaptureFixture) -> None:
        """Test AntaRunner._log_run_information with default settings."""
        caplog.set_level(logging.INFO)

        inventory = AntaInventory.parse(filename=DATA_DIR / "test_inventory_with_tags.yml", username="anta", password="anta")
        catalog = AntaCatalog.parse(filename=DATA_DIR / "test_catalog_with_tags.yml")
        runner = AntaRunner()
        await runner.run(inventory, catalog, dry_run=True)

        expected_output = [
            "Initial inventory contains 3 devices",
            "3 devices selected for testing",
            "27 total tests scheduled across all selected devices",
        ]
        for line in expected_output:
            assert line in caplog.text

    async def test_log_run_information_filters(self, caplog: pytest.LogCaptureFixture) -> None:
        """Test AntaRunner._log_run_information with filters."""
        caplog.set_level(logging.INFO)

        inventory = AntaInventory.parse(filename=DATA_DIR / "test_inventory_with_tags.yml", username="anta", password="anta")
        catalog = AntaCatalog.parse(filename=DATA_DIR / "test_catalog_with_tags.yml")
        runner = AntaRunner()
        filters = AntaRunFilters(devices={"spine1"})
        await runner.run(inventory, catalog, filters=filters, dry_run=True)

        expected_output = [
            "Initial inventory contains 3 devices",
            "2 devices excluded by name/tag filters: leaf1, leaf2",
            "1 devices selected for testing",
            "9 total tests scheduled across all selected devices",
        ]
        for line in expected_output:
            assert line in caplog.text

    async def test_log_run_information_concurrency_limit(self, caplog: pytest.LogCaptureFixture) -> None:
        """Test AntaRunner._log_run_information with higher tests count than concurrency limit."""
        caplog.set_level(logging.WARNING)

        inventory = AntaInventory.parse(filename=DATA_DIR / "test_inventory_with_tags.yml", username="anta", password="anta")
        catalog = AntaCatalog.parse(filename=DATA_DIR / "test_catalog_with_tags.yml")
        runner_settings = AntaRunnerSettings(max_concurrency=20)
        runner = AntaRunner(settings=runner_settings)

        ctx = await runner.run(inventory, catalog, dry_run=True)

        warning_msg = "Tests count (27) exceeds concurrent limit (20). Tests will be throttled. Please consult the ANTA FAQ."
        assert warning_msg in ctx.warnings_at_setup
        assert warning_msg in caplog.messages

    @pytest.mark.skipif(os.name != "posix", reason="Very unlikely to happen on non-POSIX systems due to sys.maxsize")
    async def test_log_run_information_file_descriptor_limit(self, caplog: pytest.LogCaptureFixture) -> None:
        """Test AntaRunner._log_run_information with higher connections count than file descriptor limit."""
        caplog.set_level(logging.WARNING)

        inventory = AntaInventory.parse(filename=DATA_DIR / "test_inventory_with_tags.yml", username="anta", password="anta")
        catalog = AntaCatalog.parse(filename=DATA_DIR / "test_catalog_with_tags.yml")
        runner_settings = AntaRunnerSettings(nofile=128)
        runner = AntaRunner(settings=runner_settings)

        ctx = await runner.run(inventory, catalog, dry_run=True)

        warning_msg = "Potential connections (300) exceeds file descriptor limit (128). Connection errors may occur. Please consult the ANTA FAQ."
        assert warning_msg in ctx.warnings_at_setup
        assert warning_msg in caplog.messages

    async def test_log_run_information_from_context(self, caplog: pytest.LogCaptureFixture) -> None:
        """Test AntaRunner._log_run_information from a fake context."""
        caplog.set_level(logging.INFO)

        inventory = AntaInventory.parse(filename=DATA_DIR / "test_inventory_with_tags.yml", username="anta", password="anta")
        catalog = AntaCatalog.parse(filename=DATA_DIR / "test_catalog_with_tags.yml")
        manager = ResultManager()
        filters = AntaRunFilters()
        context = AntaRunContext(
            inventory=inventory,
            catalog=catalog,
            manager=manager,
            filters=filters,
        )
        context.devices_filtered_at_setup = ["leaf1"]
        context.devices_unreachable_at_setup = ["leaf2"]
        context.selected_inventory.add_device(inventory["spine1"])

        AntaRunner()._log_run_information(context)

        expected_output = [
            "Initial inventory contains 3 devices",
            "1 devices excluded by name/tag filters: leaf1",
            "1 devices found unreachable after connection attempts: leaf2",
            "1 devices selected for testing",
            "0 total tests scheduled across all selected devices",
        ]
        for line in expected_output:
            assert line in caplog.text

    @pytest.mark.parametrize(("inventory"), [{"count": 3}], indirect=True)
    @respx.mock
    async def test_run(self, inventory: AntaInventory) -> None:
        """Test AntaRunner.run()."""
        # Mock the eAPI requests
        respx.post(path="/command-api", headers={"Content-Type": "application/json-rpc"}, json__params__cmds__0__cmd="show ip route vrf default").respond(
            json={"result": [{"vrfs": {"default": {"routes": {}}}}]}
        )
        tests = [AntaTestDefinition(test=VerifyRoutingTableEntry, inputs={"routes": [f"10.1.0.{i}"], "collect": "all"}) for i in range(5)]
        catalog = AntaCatalog(tests=tests)
        runner = AntaRunner()

        ctx = await runner.run(inventory, catalog)

        assert ctx.total_devices_selected_for_testing == 3
        assert ctx.total_tests_scheduled == 15
        assert len(ctx.warnings_at_setup) == 0
        assert len(ctx.manager) == 15
        for result in ctx.manager.results:
            assert result.result == "failure"

    async def test_run_disconnect_called_when_enabled(self, caplog: pytest.LogCaptureFixture) -> None:
        """Test that disconnect_inventory is called after the run when disconnect=True."""
        caplog.set_level(logging.DEBUG)
        inventory = AntaInventory()
        catalog = AntaCatalog()
        runner = AntaRunner()

        with patch.object(AntaInventory, "disconnect_inventory", new_callable=AsyncMock) as mock_disconnect:
            await runner.run(inventory, catalog, disconnect=True)
            mock_disconnect.assert_called_once()
            assert "Disconnecting from devices ..." in caplog.messages

    async def test_run_disconnect_not_called_when_disabled(self) -> None:
        """Test that disconnect_inventory is not called when disconnect=False."""
        inventory = AntaInventory()
        catalog = AntaCatalog()
        runner = AntaRunner()

        with patch.object(AntaInventory, "disconnect_inventory", new_callable=AsyncMock) as mock_disconnect:
            await runner.run(inventory, catalog, disconnect=False)
            mock_disconnect.assert_not_called()


# pylint: disable=too-few-public-methods
class TestAntaRunContext:
    """Test AntaRunContext class."""

    def test_init(self) -> None:
        """Test initialization."""
        inventory = AntaInventory()
        catalog = AntaCatalog()
        manager = ResultManager()
        filters = AntaRunFilters()

        ctx = AntaRunContext(inventory, catalog, manager, filters)

        # Test initialized attributes
        assert ctx.inventory is inventory
        assert ctx.catalog is catalog
        assert ctx.manager is manager
        assert ctx.filters is filters
        assert not ctx.dry_run
        assert not ctx.disconnect

        assert isinstance(ctx.selected_inventory, AntaInventory)
        assert len(ctx.selected_inventory) == 0
        assert ctx.filtered_inventory is inventory
        assert isinstance(ctx.selected_tests, defaultdict)
        assert len(ctx.selected_tests) == 0
        assert isinstance(ctx.devices_filtered_at_setup, list)
        assert len(ctx.devices_filtered_at_setup) == 0
        assert isinstance(ctx.devices_unreachable_at_setup, list)
        assert len(ctx.devices_unreachable_at_setup) == 0
        assert isinstance(ctx.warnings_at_setup, list)
        assert len(ctx.warnings_at_setup) == 0
        assert ctx.start_time is None
        assert ctx.end_time is None

        # Test properties
        assert ctx.total_devices_in_inventory == 0
        assert ctx.total_devices_filtered_by_tags == 0
        assert ctx.total_devices_unreachable == 0
        assert ctx.total_devices_selected_for_testing == 0
        assert ctx.total_tests_scheduled == 0
        assert ctx.duration is None
