"""Tests for the custom probe checker.

Tests the exec and HTTP probe execution, failure tracking, threshold
logic, and integration with the HealthChecker. All external I/O is
mocked — no subprocesses or network calls.
"""

from __future__ import annotations

import subprocess
import time
import urllib.error
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock, patch

import pytest

from citadel_operator.k3s.custom_probes import (
    CustomProbeChecker,
    CustomProbeReport,
    CustomProbeSpec,
    ExecProbeConfig,
    HttpProbeConfig,
    ProbeResult,
    ProbeStatus,
    ProbeType,
    parse_custom_probes,
)
from citadel_operator.k3s.health import HealthChecker, HealthState
from citadel_operator.k3s.node import NodeInfo


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _make_exec_probe(
    name: str = "test-exec",
    command: list[str] | None = None,
    timeout_seconds: int = 10,
    failure_threshold: int = 3,
    period_seconds: int = 0,
) -> CustomProbeSpec:
    """Build an exec probe spec for testing."""
    return CustomProbeSpec(
        name=name,
        probe_type=ProbeType.EXEC,
        failure_threshold=failure_threshold,
        period_seconds=period_seconds,
        exec_config=ExecProbeConfig(
            command=command or ["/bin/sh", "-c", "echo ok"],
            timeout_seconds=timeout_seconds,
        ),
    )


def _make_http_probe(
    name: str = "test-http",
    path: str = "/healthz",
    port: int = 8080,
    scheme: str = "http",
    host: str = "localhost",
    failure_threshold: int = 3,
    period_seconds: int = 0,
) -> CustomProbeSpec:
    """Build an HTTP probe spec for testing."""
    return CustomProbeSpec(
        name=name,
        probe_type=ProbeType.HTTP,
        failure_threshold=failure_threshold,
        period_seconds=period_seconds,
        http_config=HttpProbeConfig(
            path=path,
            port=port,
            scheme=scheme,
            host=host,
        ),
    )


def _make_node(
    name: str = "test-node",
    age_hours: float = 10.0,
    ready: bool = True,
    schedulable: bool = True,
    conditions: dict | None = None,
) -> NodeInfo:
    """Helper to create a NodeInfo for testing."""
    default_conditions = {
        "Ready": "True" if ready else "False",
        "DiskPressure": "False",
        "MemoryPressure": "False",
        "PIDPressure": "False",
    }
    if conditions:
        default_conditions.update(conditions)
    return NodeInfo(
        name=name,
        creation_timestamp=datetime.now(timezone.utc) - timedelta(hours=age_hours),
        age_hours=age_hours,
        ready=ready,
        schedulable=schedulable,
        conditions=default_conditions,
    )


# ---------------------------------------------------------------------------
# Tests: CustomProbeSpec validation
# ---------------------------------------------------------------------------

class TestCustomProbeSpec:
    """Validate probe spec construction and constraints."""

    def test_exec_probe_requires_config(self):
        with pytest.raises(ValueError, match="exec probe requires exec_config"):
            CustomProbeSpec(
                name="bad",
                probe_type=ProbeType.EXEC,
                exec_config=None,
            )

    def test_http_probe_requires_config(self):
        with pytest.raises(ValueError, match="http probe requires http_config"):
            CustomProbeSpec(
                name="bad",
                probe_type=ProbeType.HTTP,
                http_config=None,
            )

    def test_exec_probe_valid(self):
        probe = _make_exec_probe(name="valid-exec")
        assert probe.probe_type == ProbeType.EXEC
        assert probe.exec_config is not None
        assert probe.exec_config.command == ["/bin/sh", "-c", "echo ok"]

    def test_http_probe_valid(self):
        probe = _make_http_probe(name="valid-http")
        assert probe.probe_type == ProbeType.HTTP
        assert probe.http_config is not None
        assert probe.http_config.url == "http://localhost:8080/healthz"

    def test_http_probe_url_construction(self):
        config = HttpProbeConfig(
            path="/ready",
            port=9090,
            scheme="https",
            host="10.0.0.1",
        )
        assert config.url == "https://10.0.0.1:9090/ready"

    def test_default_thresholds(self):
        probe = _make_exec_probe(failure_threshold=3, period_seconds=0)
        assert probe.failure_threshold == 3
        assert probe.period_seconds == 0


# ---------------------------------------------------------------------------
# Tests: parse_custom_probes
# ---------------------------------------------------------------------------

class TestParseCustomProbes:
    """Test CRD YAML/JSON parsing into CustomProbeSpec objects."""

    def test_parse_exec_probe(self):
        raw = [
            {
                "name": "stuck-volume",
                "type": "exec",
                "exec": {
                    "command": ["/bin/sh", "-c", "ls /var/lib/kubelet/pods/*/volumes/"],
                    "timeoutSeconds": 10,
                },
                "failureThreshold": 3,
                "periodSeconds": 60,
            }
        ]
        probes = parse_custom_probes(raw)
        assert len(probes) == 1
        assert probes[0].name == "stuck-volume"
        assert probes[0].probe_type == ProbeType.EXEC
        assert probes[0].exec_config.command[0] == "/bin/sh"
        assert probes[0].exec_config.timeout_seconds == 10
        assert probes[0].failure_threshold == 3
        assert probes[0].period_seconds == 60

    def test_parse_http_probe(self):
        raw = [
            {
                "name": "gpu-health",
                "type": "http",
                "http": {
                    "path": "/healthz",
                    "port": 8080,
                },
                "failureThreshold": 2,
                "periodSeconds": 30,
            }
        ]
        probes = parse_custom_probes(raw)
        assert len(probes) == 1
        assert probes[0].name == "gpu-health"
        assert probes[0].probe_type == ProbeType.HTTP
        assert probes[0].http_config.path == "/healthz"
        assert probes[0].http_config.port == 8080
        assert probes[0].http_config.scheme == "http"
        assert probes[0].http_config.host == "localhost"

    def test_parse_multiple_probes(self):
        raw = [
            {
                "name": "exec-probe",
                "type": "exec",
                "exec": {"command": ["true"]},
            },
            {
                "name": "http-probe",
                "type": "http",
                "http": {"path": "/health", "port": 9090},
            },
        ]
        probes = parse_custom_probes(raw)
        assert len(probes) == 2
        assert probes[0].probe_type == ProbeType.EXEC
        assert probes[1].probe_type == ProbeType.HTTP

    def test_parse_defaults_applied(self):
        raw = [
            {
                "name": "minimal",
                "type": "exec",
                "exec": {"command": ["true"]},
            }
        ]
        probes = parse_custom_probes(raw)
        assert probes[0].failure_threshold == 3
        assert probes[0].period_seconds == 60
        assert probes[0].exec_config.timeout_seconds == 10

    def test_parse_empty_list(self):
        probes = parse_custom_probes([])
        assert probes == []


# ---------------------------------------------------------------------------
# Tests: CustomProbeChecker — exec probes
# ---------------------------------------------------------------------------

class TestExecProbeExecution:
    """Test exec probe execution with mocked subprocess."""

    @patch("citadel_operator.k3s.custom_probes.subprocess.run")
    def test_exec_probe_success(self, mock_run):
        mock_run.return_value = MagicMock(returncode=0, stderr="")
        checker = CustomProbeChecker()
        probe = _make_exec_probe()

        report = checker.check("worker-1", [probe])

        assert len(report.results) == 1
        assert report.results[0].status == ProbeStatus.PASSING
        assert report.results[0].consecutive_failures == 0
        assert not report.any_failed

    @patch("citadel_operator.k3s.custom_probes.subprocess.run")
    def test_exec_probe_failure(self, mock_run):
        mock_run.return_value = MagicMock(returncode=1, stderr="disk full")
        checker = CustomProbeChecker()
        probe = _make_exec_probe(failure_threshold=3)

        report = checker.check("worker-1", [probe])

        assert report.results[0].status == ProbeStatus.UNKNOWN  # Below threshold
        assert report.results[0].consecutive_failures == 1
        assert not report.any_failed

    @patch("citadel_operator.k3s.custom_probes.subprocess.run")
    def test_exec_probe_threshold_exceeded(self, mock_run):
        mock_run.return_value = MagicMock(returncode=1, stderr="error")
        checker = CustomProbeChecker()
        probe = _make_exec_probe(failure_threshold=3)

        # Fail 3 times to hit the threshold
        for _ in range(3):
            report = checker.check("worker-1", [probe])

        assert report.results[0].status == ProbeStatus.FAILING
        assert report.results[0].consecutive_failures == 3
        assert report.results[0].threshold_exceeded
        assert report.any_failed

    @patch("citadel_operator.k3s.custom_probes.subprocess.run")
    def test_exec_probe_resets_on_success(self, mock_run):
        checker = CustomProbeChecker()
        probe = _make_exec_probe(failure_threshold=3)

        # Fail twice
        mock_run.return_value = MagicMock(returncode=1, stderr="")
        checker.check("worker-1", [probe])
        checker.check("worker-1", [probe])
        assert checker.get_failure_count("worker-1", "test-exec") == 2

        # Then succeed — counter resets
        mock_run.return_value = MagicMock(returncode=0, stderr="")
        report = checker.check("worker-1", [probe])

        assert report.results[0].status == ProbeStatus.PASSING
        assert report.results[0].consecutive_failures == 0
        assert checker.get_failure_count("worker-1", "test-exec") == 0

    @patch("citadel_operator.k3s.custom_probes.subprocess.run")
    def test_exec_probe_timeout(self, mock_run):
        mock_run.side_effect = subprocess.TimeoutExpired(cmd="test", timeout=10)
        checker = CustomProbeChecker()
        probe = _make_exec_probe(timeout_seconds=10)

        report = checker.check("worker-1", [probe])

        assert report.results[0].status == ProbeStatus.UNKNOWN
        assert "timed out" in report.results[0].message

    @patch("citadel_operator.k3s.custom_probes.subprocess.run")
    def test_exec_probe_command_not_found(self, mock_run):
        mock_run.side_effect = FileNotFoundError("No such file")
        checker = CustomProbeChecker()
        probe = _make_exec_probe(command=["/nonexistent"])

        report = checker.check("worker-1", [probe])

        assert report.results[0].status == ProbeStatus.UNKNOWN
        assert "command not found" in report.results[0].message


# ---------------------------------------------------------------------------
# Tests: CustomProbeChecker — HTTP probes
# ---------------------------------------------------------------------------

class TestHttpProbeExecution:
    """Test HTTP probe execution with mocked urllib."""

    @patch("citadel_operator.k3s.custom_probes.urllib.request.urlopen")
    def test_http_probe_success(self, mock_urlopen):
        mock_response = MagicMock()
        mock_response.status = 200
        mock_response.__enter__ = MagicMock(return_value=mock_response)
        mock_response.__exit__ = MagicMock(return_value=False)
        mock_urlopen.return_value = mock_response

        checker = CustomProbeChecker()
        probe = _make_http_probe()

        report = checker.check("worker-1", [probe])

        assert report.results[0].status == ProbeStatus.PASSING
        assert "HTTP 200" in report.results[0].message

    @patch("citadel_operator.k3s.custom_probes.urllib.request.urlopen")
    def test_http_probe_server_error(self, mock_urlopen):
        mock_urlopen.side_effect = urllib.error.HTTPError(
            url="http://localhost:8080/healthz",
            code=500,
            msg="Internal Server Error",
            hdrs=None,
            fp=None,
        )

        checker = CustomProbeChecker()
        probe = _make_http_probe(failure_threshold=1)

        report = checker.check("worker-1", [probe])

        assert report.results[0].status == ProbeStatus.FAILING
        assert "HTTP 500" in report.results[0].message

    @patch("citadel_operator.k3s.custom_probes.urllib.request.urlopen")
    def test_http_probe_connection_refused(self, mock_urlopen):
        mock_urlopen.side_effect = urllib.error.URLError(
            reason="Connection refused"
        )

        checker = CustomProbeChecker()
        probe = _make_http_probe()

        report = checker.check("worker-1", [probe])

        assert report.results[0].status == ProbeStatus.UNKNOWN
        assert "connection failed" in report.results[0].message

    @patch("citadel_operator.k3s.custom_probes.urllib.request.urlopen")
    def test_http_probe_threshold_exceeded(self, mock_urlopen):
        mock_urlopen.side_effect = urllib.error.HTTPError(
            url="", code=503, msg="", hdrs=None, fp=None,
        )

        checker = CustomProbeChecker()
        probe = _make_http_probe(failure_threshold=2)

        checker.check("worker-1", [probe])
        report = checker.check("worker-1", [probe])

        assert report.results[0].status == ProbeStatus.FAILING
        assert report.results[0].threshold_exceeded
        assert report.any_failed


# ---------------------------------------------------------------------------
# Tests: CustomProbeChecker — multi-probe and multi-node
# ---------------------------------------------------------------------------

class TestMultiProbeScenarios:
    """Test scenarios with multiple probes and/or nodes."""

    @patch("citadel_operator.k3s.custom_probes.subprocess.run")
    def test_multiple_probes_per_node(self, mock_run):
        """Two probes on one node: one passes, one fails."""
        call_count = [0]

        def side_effect(*args, **kwargs):
            call_count[0] += 1
            if call_count[0] % 2 == 1:
                return MagicMock(returncode=0, stderr="")
            else:
                return MagicMock(returncode=1, stderr="fail")

        mock_run.side_effect = side_effect
        checker = CustomProbeChecker()
        probes = [
            _make_exec_probe(name="good-probe", failure_threshold=1),
            _make_exec_probe(name="bad-probe", failure_threshold=1),
        ]

        report = checker.check("worker-1", probes)

        assert len(report.results) == 2
        good = next(r for r in report.results if r.probe_name == "good-probe")
        bad = next(r for r in report.results if r.probe_name == "bad-probe")
        assert good.status == ProbeStatus.PASSING
        assert bad.status == ProbeStatus.FAILING
        assert report.any_failed

    @patch("citadel_operator.k3s.custom_probes.subprocess.run")
    def test_failure_tracking_is_per_node(self, mock_run):
        """Failure counts are tracked independently per node."""
        mock_run.return_value = MagicMock(returncode=1, stderr="")
        checker = CustomProbeChecker()
        probe = _make_exec_probe(failure_threshold=3)

        checker.check("worker-1", [probe])
        checker.check("worker-2", [probe])

        assert checker.get_failure_count("worker-1", "test-exec") == 1
        assert checker.get_failure_count("worker-2", "test-exec") == 1

    @patch("citadel_operator.k3s.custom_probes.subprocess.run")
    def test_reset_single_node(self, mock_run):
        mock_run.return_value = MagicMock(returncode=1, stderr="")
        checker = CustomProbeChecker()
        probe = _make_exec_probe()

        checker.check("worker-1", [probe])
        checker.check("worker-2", [probe])

        checker.reset("worker-1")

        assert checker.get_failure_count("worker-1", "test-exec") == 0
        assert checker.get_failure_count("worker-2", "test-exec") == 1

    @patch("citadel_operator.k3s.custom_probes.subprocess.run")
    def test_reset_all(self, mock_run):
        mock_run.return_value = MagicMock(returncode=1, stderr="")
        checker = CustomProbeChecker()
        probe = _make_exec_probe()

        checker.check("worker-1", [probe])
        checker.check("worker-2", [probe])

        checker.reset()

        assert checker.get_failure_count("worker-1", "test-exec") == 0
        assert checker.get_failure_count("worker-2", "test-exec") == 0


# ---------------------------------------------------------------------------
# Tests: ProbeResult and CustomProbeReport properties
# ---------------------------------------------------------------------------

class TestProbeReportProperties:
    """Test the report dataclass properties."""

    def test_threshold_exceeded(self):
        result = ProbeResult(
            probe_name="test",
            probe_type=ProbeType.EXEC,
            status=ProbeStatus.FAILING,
            consecutive_failures=3,
            failure_threshold=3,
            message="failed",
            timestamp=0.0,
        )
        assert result.threshold_exceeded

    def test_threshold_not_exceeded(self):
        result = ProbeResult(
            probe_name="test",
            probe_type=ProbeType.EXEC,
            status=ProbeStatus.UNKNOWN,
            consecutive_failures=2,
            failure_threshold=3,
            message="failing",
            timestamp=0.0,
        )
        assert not result.threshold_exceeded

    def test_report_any_failed(self):
        failing = ProbeResult(
            probe_name="fail",
            probe_type=ProbeType.EXEC,
            status=ProbeStatus.FAILING,
            consecutive_failures=5,
            failure_threshold=3,
            message="failed",
            timestamp=0.0,
        )
        passing = ProbeResult(
            probe_name="pass",
            probe_type=ProbeType.HTTP,
            status=ProbeStatus.PASSING,
            consecutive_failures=0,
            failure_threshold=3,
            message="ok",
            timestamp=0.0,
        )
        report = CustomProbeReport(
            node_name="worker-1",
            results=[passing, failing],
            timestamp=0.0,
        )
        assert report.any_failed
        assert len(report.failed_probes) == 1
        assert len(report.passing_probes) == 1

    def test_report_all_passing(self):
        passing = ProbeResult(
            probe_name="pass",
            probe_type=ProbeType.EXEC,
            status=ProbeStatus.PASSING,
            consecutive_failures=0,
            failure_threshold=3,
            message="ok",
            timestamp=0.0,
        )
        report = CustomProbeReport(
            node_name="worker-1",
            results=[passing],
            timestamp=0.0,
        )
        assert not report.any_failed
        assert report.failed_probes == []


# ---------------------------------------------------------------------------
# Tests: HealthChecker integration with custom probes
# ---------------------------------------------------------------------------

class TestHealthCheckerWithCustomProbes:
    """Verify that custom probe failures affect the health state."""

    def test_healthy_with_no_custom_probes(self):
        """Baseline: no custom probes, node is healthy."""
        hc = HealthChecker(max_age_hours=48)
        node = _make_node(age_hours=10)
        report = hc.check(node)

        assert report.state == HealthState.HEALTHY
        assert report.custom_probe_report is None
        assert not report.has_failed_custom_probes

    def test_healthy_with_passing_custom_probes(self):
        """Custom probes all passing should not affect healthy state."""
        hc = HealthChecker(max_age_hours=48)
        node = _make_node(age_hours=10)

        probe_report = CustomProbeReport(
            node_name="test-node",
            results=[
                ProbeResult(
                    probe_name="volume-check",
                    probe_type=ProbeType.EXEC,
                    status=ProbeStatus.PASSING,
                    consecutive_failures=0,
                    failure_threshold=3,
                    message="ok",
                    timestamp=0.0,
                ),
            ],
            timestamp=0.0,
        )

        report = hc.check(node, custom_probe_report=probe_report)

        assert report.state == HealthState.HEALTHY
        assert not report.has_failed_custom_probes
        assert not report.needs_termination

    def test_error_state_on_failed_custom_probes(self):
        """A failed custom probe should escalate state to ERROR."""
        hc = HealthChecker(max_age_hours=48)
        node = _make_node(age_hours=10)

        probe_report = CustomProbeReport(
            node_name="test-node",
            results=[
                ProbeResult(
                    probe_name="stuck-volume",
                    probe_type=ProbeType.EXEC,
                    status=ProbeStatus.FAILING,
                    consecutive_failures=3,
                    failure_threshold=3,
                    message="stuck volume detected",
                    timestamp=0.0,
                ),
            ],
            timestamp=0.0,
        )

        report = hc.check(node, custom_probe_report=probe_report)

        assert report.state == HealthState.ERROR
        assert report.has_failed_custom_probes
        assert len(report.failed_custom_probes) == 1
        assert report.failed_custom_probes[0].probe_name == "stuck-volume"

    def test_needs_termination_on_failed_custom_probes(self):
        """Failed custom probes should trigger needs_termination."""
        hc = HealthChecker(max_age_hours=48)
        node = _make_node(age_hours=10)

        probe_report = CustomProbeReport(
            node_name="test-node",
            results=[
                ProbeResult(
                    probe_name="gpu-health",
                    probe_type=ProbeType.HTTP,
                    status=ProbeStatus.FAILING,
                    consecutive_failures=5,
                    failure_threshold=2,
                    message="GPU unhealthy",
                    timestamp=0.0,
                ),
            ],
            timestamp=0.0,
        )

        report = hc.check(node, custom_probe_report=probe_report)

        assert report.needs_termination
        assert report.has_failed_custom_probes

    def test_terminal_still_takes_precedence(self):
        """A not-ready + unschedulable node should still be TERMINAL even with probes."""
        hc = HealthChecker(max_age_hours=48)
        node = _make_node(ready=False, schedulable=False)

        probe_report = CustomProbeReport(
            node_name="test-node",
            results=[
                ProbeResult(
                    probe_name="volume-check",
                    probe_type=ProbeType.EXEC,
                    status=ProbeStatus.PASSING,
                    consecutive_failures=0,
                    failure_threshold=3,
                    message="ok",
                    timestamp=0.0,
                ),
            ],
            timestamp=0.0,
        )

        report = hc.check(node, custom_probe_report=probe_report)

        assert report.state == HealthState.TERMINAL

    def test_below_threshold_does_not_escalate(self):
        """Custom probe failures below threshold should not change health state."""
        hc = HealthChecker(max_age_hours=48)
        node = _make_node(age_hours=10)

        probe_report = CustomProbeReport(
            node_name="test-node",
            results=[
                ProbeResult(
                    probe_name="volume-check",
                    probe_type=ProbeType.EXEC,
                    status=ProbeStatus.UNKNOWN,
                    consecutive_failures=1,
                    failure_threshold=3,
                    message="failing but below threshold",
                    timestamp=0.0,
                ),
            ],
            timestamp=0.0,
        )

        report = hc.check(node, custom_probe_report=probe_report)

        assert report.state == HealthState.HEALTHY
        assert not report.has_failed_custom_probes

    def test_check_all_with_custom_probes(self):
        """check_all should pass custom probe reports to individual checks."""
        hc = HealthChecker(max_age_hours=48)
        nodes = [
            _make_node(name="healthy-node", age_hours=10),
            _make_node(name="probe-failed-node", age_hours=10),
        ]

        probe_reports = {
            "probe-failed-node": CustomProbeReport(
                node_name="probe-failed-node",
                results=[
                    ProbeResult(
                        probe_name="gpu-check",
                        probe_type=ProbeType.HTTP,
                        status=ProbeStatus.FAILING,
                        consecutive_failures=5,
                        failure_threshold=2,
                        message="GPU down",
                        timestamp=0.0,
                    ),
                ],
                timestamp=0.0,
            ),
        }

        reports = hc.check_all(nodes, custom_probe_reports=probe_reports)

        assert reports[0].state == HealthState.HEALTHY
        assert reports[0].custom_probe_report is None

        assert reports[1].state == HealthState.ERROR
        assert reports[1].has_failed_custom_probes

    def test_check_all_without_custom_probes_backwards_compatible(self):
        """check_all with no custom probes should behave exactly as before."""
        hc = HealthChecker(max_age_hours=48)
        nodes = [
            _make_node(name="healthy", age_hours=10),
            _make_node(name="aged", age_hours=50),
        ]

        reports = hc.check_all(nodes)

        assert reports[0].state == HealthState.HEALTHY
        assert reports[1].age_exceeded


# ---------------------------------------------------------------------------
# Tests: Period-based probe skipping
# ---------------------------------------------------------------------------

class TestProbePeriodSkipping:
    """Test that probes respect their period_seconds interval."""

    @patch("citadel_operator.k3s.custom_probes.subprocess.run")
    @patch("citadel_operator.k3s.custom_probes.time.monotonic")
    def test_probe_skipped_within_period(self, mock_time, mock_run):
        """A probe should not re-execute before its period elapses."""
        mock_run.return_value = MagicMock(returncode=0, stderr="")

        # First call at t=0
        mock_time.return_value = 0.0
        checker = CustomProbeChecker()
        probe = _make_exec_probe(period_seconds=60)

        checker.check("worker-1", [probe])
        assert mock_run.call_count == 1

        # Second call at t=30 (within period) — probe should be skipped
        mock_time.return_value = 30.0
        report = checker.check("worker-1", [probe])

        assert mock_run.call_count == 1  # Not called again
        assert report.results[0].message == "Skipped (period not elapsed)"

    @patch("citadel_operator.k3s.custom_probes.subprocess.run")
    @patch("citadel_operator.k3s.custom_probes.time.monotonic")
    def test_probe_re_executes_after_period(self, mock_time, mock_run):
        """A probe should re-execute after its period elapses."""
        mock_run.return_value = MagicMock(returncode=0, stderr="")

        mock_time.return_value = 0.0
        checker = CustomProbeChecker()
        probe = _make_exec_probe(period_seconds=60)

        checker.check("worker-1", [probe])
        assert mock_run.call_count == 1

        # After period elapses
        mock_time.return_value = 61.0
        checker.check("worker-1", [probe])

        assert mock_run.call_count == 2
