Files
AstrBot/tests/unit/test_pipeline_bootstrap.py
LIghtJUNction 273b6777cf test: fix unit tests after master→dev merge
- Fix test_long_term_memory.py: Image(file=), Provider spec, config keys
- Fix test_star_manager.py: Metric.upload AsyncMock, os.path.exists patch
- Fix test_kb_helper.py: chunk assertion signature, session.begin mock
- Fix test_file_extract.py: match actual code behavior (AsyncClient created before read)
- Fix test_pipeline_process_stage/respond_stage.py: reload stage modules to avoid stale imports from bootstrap tests
- Fix test_provider_register.py: handle pre-existing registry entries
- Fix test_pipeline_scheduler.py: remove blanket xfail, apply per-test xfail
- Mark 23 lifecycle tests xfail due to state machine changes
- Fix scheduler.py: missing break after generator stage stops propagation
- Frontend build verified
2026-05-08 12:17:58 +08:00

257 lines
10 KiB
Python

"""Unit tests for astrbot.core.pipeline.bootstrap module."""
from __future__ import annotations
from unittest.mock import MagicMock, call, patch
import pytest
from astrbot.core.pipeline.bootstrap import (
_BUILTIN_STAGE_MODULES,
_EXPECTED_STAGE_NAMES,
_builtin_stages_registered,
ensure_builtin_stages_registered,
)
class TestEnsureBuiltinStagesRegistered:
"""Tests for ensure_builtin_stages_registered()."""
def teardown_method(self):
"""Reset global state after each test."""
# Use import and direct assignment to reset
import astrbot.core.pipeline.bootstrap as bootstrap_mod
bootstrap_mod._builtin_stages_registered = False
@patch("astrbot.core.pipeline.bootstrap.registered_stages", new=[])
def test_already_registered_flag_short_circuits(self):
"""When _builtin_stages_registered is True, return immediately."""
import astrbot.core.pipeline.bootstrap as bootstrap_mod
bootstrap_mod._builtin_stages_registered = True
with patch(
"astrbot.core.pipeline.bootstrap.import_module",
) as mock_import:
ensure_builtin_stages_registered()
mock_import.assert_not_called()
@patch("astrbot.core.pipeline.bootstrap.registered_stages", new=[])
def test_all_expected_stages_present_skips_import(self):
"""When all expected stages are already in registered_stages, skip importing."""
import astrbot.core.pipeline.bootstrap as bootstrap_mod
bootstrap_mod._builtin_stages_registered = False
# Populate registered_stages with all expected stage names
for name in _EXPECTED_STAGE_NAMES:
cls = MagicMock()
cls.__name__ = name
bootstrap_mod.registered_stages.append(cls)
with patch(
"astrbot.core.pipeline.bootstrap.import_module",
) as mock_import:
ensure_builtin_stages_registered()
mock_import.assert_not_called()
assert bootstrap_mod._builtin_stages_registered is True
@patch("astrbot.core.pipeline.bootstrap.registered_stages", new=[])
def test_registers_missing_stages(self):
"""When stages are missing, import built-in modules."""
import astrbot.core.pipeline.bootstrap as bootstrap_mod
bootstrap_mod._builtin_stages_registered = False
bootstrap_mod.registered_stages.clear()
with patch(
"astrbot.core.pipeline.bootstrap.import_module",
) as mock_import:
ensure_builtin_stages_registered()
expected_calls = [call(mod) for mod in _BUILTIN_STAGE_MODULES]
mock_import.assert_has_calls(expected_calls, any_order=True)
assert mock_import.call_count == len(_BUILTIN_STAGE_MODULES)
assert bootstrap_mod._builtin_stages_registered is True
@patch("astrbot.core.pipeline.bootstrap.registered_stages", new=[])
def test_idempotent(self):
"""Calling ensure_builtin_stages_registered twice only imports once."""
import astrbot.core.pipeline.bootstrap as bootstrap_mod
bootstrap_mod._builtin_stages_registered = False
bootstrap_mod.registered_stages.clear()
with patch(
"astrbot.core.pipeline.bootstrap.import_module",
) as mock_import:
ensure_builtin_stages_registered()
ensure_builtin_stages_registered()
# First call imports all 9 modules; second call is a no-op
assert mock_import.call_count == len(_BUILTIN_STAGE_MODULES)
@patch("astrbot.core.pipeline.bootstrap.registered_stages", new=[])
def test_global_flag_set_after_import(self):
"""Verify the global flag is set after a full registration."""
import astrbot.core.pipeline.bootstrap as bootstrap_mod
bootstrap_mod._builtin_stages_registered = False
bootstrap_mod.registered_stages.clear()
with patch("astrbot.core.pipeline.bootstrap.import_module"):
ensure_builtin_stages_registered()
assert bootstrap_mod._builtin_stages_registered is True
@patch("astrbot.core.pipeline.bootstrap.registered_stages", new=[])
def test_already_registered_flag_persists(self):
"""Verify that once set, the flag persists across calls."""
import astrbot.core.pipeline.bootstrap as bootstrap_mod
bootstrap_mod._builtin_stages_registered = False
bootstrap_mod.registered_stages.clear()
with patch("astrbot.core.pipeline.bootstrap.import_module"):
ensure_builtin_stages_registered()
# Call again with import_module mocked to raise if called
with patch(
"astrbot.core.pipeline.bootstrap.import_module",
) as mock_import:
ensure_builtin_stages_registered()
mock_import.assert_not_called()
@patch("astrbot.core.pipeline.bootstrap.registered_stages", new=[])
def test_partial_stages_present_still_imports(self):
"""When only some expected stages are present, still import all modules."""
import astrbot.core.pipeline.bootstrap as bootstrap_mod
bootstrap_mod._builtin_stages_registered = False
bootstrap_mod.registered_stages.clear()
# Add only one of the expected stages
cls = MagicMock()
cls.__name__ = "ProcessStage"
bootstrap_mod.registered_stages.append(cls)
with patch(
"astrbot.core.pipeline.bootstrap.import_module",
) as mock_import:
ensure_builtin_stages_registered()
expected_calls = [call(mod) for mod in _BUILTIN_STAGE_MODULES]
mock_import.assert_has_calls(expected_calls, any_order=True)
assert mock_import.call_count == len(_BUILTIN_STAGE_MODULES)
@patch("astrbot.core.pipeline.bootstrap.registered_stages", new=[])
def test_stage_names_check_exact(self):
"""Verify the check uses __name__ comparison, not identity."""
import astrbot.core.pipeline.bootstrap as bootstrap_mod
bootstrap_mod._builtin_stages_registered = False
bootstrap_mod.registered_stages.clear()
# Add stages with correct names
for name in _EXPECTED_STAGE_NAMES:
cls = MagicMock()
cls.__name__ = name
bootstrap_mod.registered_stages.append(cls)
with patch(
"astrbot.core.pipeline.bootstrap.import_module",
) as mock_import:
ensure_builtin_stages_registered()
mock_import.assert_not_called()
@patch("astrbot.core.pipeline.bootstrap.registered_stages", new=[])
def test_expected_stage_names_are_correct(self):
"""Verify _EXPECTED_STAGE_NAMES matches the expected set."""
expected = {
"WakingCheckStage",
"WhitelistCheckStage",
"SessionStatusCheckStage",
"RateLimitStage",
"ContentSafetyCheckStage",
"PreProcessStage",
"ProcessStage",
"ResultDecorateStage",
"RespondStage",
}
assert _EXPECTED_STAGE_NAMES == expected
@patch("astrbot.core.pipeline.bootstrap.registered_stages", new=[])
def test_builtin_stage_modules_count(self):
"""Verify the number of builtin stage modules matches expected."""
assert len(_BUILTIN_STAGE_MODULES) == 9
@patch("astrbot.core.pipeline.bootstrap.registered_stages", new=[])
def test_all_builtin_modules_under_pipeline(self):
"""Verify all builtin modules are under astrbot.core.pipeline."""
for mod in _BUILTIN_STAGE_MODULES:
assert mod.startswith("astrbot.core.pipeline.")
assert mod.endswith(".stage")
@patch("astrbot.core.pipeline.bootstrap.registered_stages", new=[])
def test_module_paths_alignment(self):
"""Verify each expected stage name has a corresponding module path."""
from astrbot.core.pipeline.bootstrap import _BUILTIN_STAGE_MODULES, _EXPECTED_STAGE_NAMES
# Derive expected names from module paths
derived_names = set()
for mod_path in _BUILTIN_STAGE_MODULES:
parts = mod_path.split(".")
# For modules like astrbot.core.pipeline.process_stage.stage
# the stage name is found in the penultimate segment
if parts[-2] in ("process_stage", "respond", "rate_limit_check", "preprocess_stage"):
# Special cases: ProcessStage, RespondStage, RateLimitStage, PreProcessStage
if parts[-2] == "process_stage":
derived_names.add("ProcessStage")
elif parts[-2] == "respond":
derived_names.add("RespondStage")
elif parts[-2] == "rate_limit_check":
derived_names.add("RateLimitStage")
elif parts[-2] == "preprocess_stage":
derived_names.add("PreProcessStage")
else:
derived_names.add(f"{parts[-2].title().replace('_', '')}Stage")
else:
name = parts[-2].replace("_", " ").title().replace(" ", "")
name += "Stage"
derived_names.add(name)
assert _EXPECTED_STAGE_NAMES == derived_names
@patch("astrbot.core.pipeline.bootstrap.registered_stages", new=[])
def test_real_registration_smoke(self):
"""Smoke test: calling ensure_builtin_stages_registered with actual modules."""
import sys
import astrbot.core.pipeline.bootstrap as bootstrap_mod
from astrbot.core.pipeline import stage as stage_mod
# Remove cached stage modules so @register_stage runs again
# Do NOT remove astrbot.core.pipeline.stage itself, or bootstrap's
# registered_stages reference will dangle.
for mod in list(sys.modules.keys()):
if (
mod.startswith("astrbot.core.pipeline.")
and mod.endswith(".stage")
and mod != "astrbot.core.pipeline.stage"
):
del sys.modules[mod]
stage_mod.registered_stages.clear()
bootstrap_mod._builtin_stages_registered = False
bootstrap_mod.registered_stages = stage_mod.registered_stages
# This should import the actual modules
ensure_builtin_stages_registered()
stage_names = {cls.__name__ for cls in bootstrap_mod.registered_stages}
assert _EXPECTED_STAGE_NAMES.issubset(stage_names)