test(unit): improve taskmgr tests to test real classes

U-004 improved: Tests now import and test actual classes:
- TaskContext: new(), trace(), to_dict(), placeholder()
- TaskWrapper: task creation, context, exception/result capture, cancel, to_dict
- AsyncTaskManager: create_task, create_user_task, cancel_task, cancel_by_scope
- Task pruning behavior

Uses pre-mocking technique:
- Mock langbot.pkg.core.app before import (breaks circular chain)
- Mock langbot.pkg.core.entities with proper Enum

All 24 tests now test real class behavior, not patterns.
taskmgr.py coverage should improve significantly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
huanghuoguoguo
2026-05-08 20:31:29 +08:00
parent 9908dc7800
commit 3780a68dfa
+408 -213
View File
@@ -8,7 +8,7 @@ Tests cover async task lifecycle management:
- Task cancellation - Task cancellation
- Multiple task isolation - Multiple task isolation
Uses module mocking to break circular import chain. Uses module pre-mocking to break circular import chain.
""" """
from __future__ import annotations from __future__ import annotations
@@ -16,314 +16,509 @@ from __future__ import annotations
import pytest import pytest
import asyncio import asyncio
import sys import sys
import enum
from unittest.mock import MagicMock from unittest.mock import MagicMock
from importlib import import_module from importlib import import_module
# Break the circular import chain before importing taskmgr: # Pre-mock app module BEFORE importing taskmgr to break circular chain:
# taskmgr → app → http_controller → groups/knowledge/migration → taskmgr (partial) # taskmgr → app → http_controller → groups/knowledge/migration → taskmgr (partial)
_mock_app = MagicMock() class FakeMinimalApp:
_mock_app.AsyncTaskManager = object """Minimal app that only provides event_loop."""
_mock_app.TaskWrapper = object
_mock_app.TaskContext = object def __init__(self, event_loop):
sys.modules.setdefault('langbot.pkg.core.app', _mock_app) self.event_loop = event_loop
self.instance_config = MagicMock()
self.instance_config.data = {}
# Pre-register mock app module
_mock_app_module = MagicMock()
_mock_app_module.Application = FakeMinimalApp
sys.modules['langbot.pkg.core.app'] = _mock_app_module
# Pre-register mock entities module - use proper Enum
class LifecycleControlScope(enum.Enum):
APPLICATION = 'application'
PLATFORM = 'platform'
PLUGIN = 'plugin'
PROVIDER = 'provider'
_mock_entities_module = MagicMock()
_mock_entities_module.LifecycleControlScope = LifecycleControlScope
sys.modules['langbot.pkg.core.entities'] = _mock_entities_module
def get_taskmgr(): def get_taskmgr():
"""Import taskmgr with circular import workaround.""" """Import taskmgr after pre-mocking."""
return import_module('langbot.pkg.core.taskmgr') return import_module('langbot.pkg.core.taskmgr')
def get_entities(): def get_entities():
"""Import entities.""" """Get pre-registered mock entities module."""
return import_module('langbot.pkg.core.entities') return sys.modules['langbot.pkg.core.entities']
class TestTaskContextBasic: class TestTaskContextReal:
"""Basic tests for TaskContext behavior.""" """Tests for real TaskContext class (no circular import)."""
def test_task_context_trace_format(self): @pytest.mark.asyncio
"""TaskContext trace should format log entries.""" async def test_task_context_new(self):
# Import TaskContext class definition directly from source """TaskContext.new() creates instance."""
import datetime taskmgr = get_taskmgr()
# Simulate TaskContext behavior without importing the module ctx = taskmgr.TaskContext.new()
# (since the circular import breaks the class definition)
# We can test the logic inline assert ctx.current_action == 'default'
log = '' assert ctx.log == ''
current_action = 'default' assert ctx.metadata == {}
def trace(msg: str, action: str = None): @pytest.mark.asyncio
nonlocal current_action, log async def test_task_context_trace(self):
if action is not None: """TaskContext.trace adds formatted log."""
current_action = action taskmgr = get_taskmgr()
log += f'{datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")} | {current_action} | {msg}\n'
trace('test message', action='test_action') ctx = taskmgr.TaskContext.new()
ctx.trace('test message', action='test_action')
assert current_action == 'test_action' assert ctx.current_action == 'test_action'
assert 'test message' in log assert 'test message' in ctx.log
assert 'test_action' in log assert 'test_action' in ctx.log
# Contains timestamp format
assert '|' in ctx.log
def test_task_context_to_dict_format(self): @pytest.mark.asyncio
"""TaskContext to_dict should include expected fields.""" async def test_task_context_multiple_traces(self):
# Expected fields based on source code """TaskContext accumulates multiple traces."""
expected_keys = ['current_action', 'log', 'metadata'] taskmgr = get_taskmgr()
# Simulate ctx = taskmgr.TaskContext.new()
result = { ctx.trace('first')
'current_action': 'default', ctx.trace('second')
'log': 'test log',
'metadata': {},
}
for key in expected_keys: assert 'first' in ctx.log
assert key in result assert 'second' in ctx.log
@pytest.mark.asyncio
async def test_task_context_to_dict(self):
"""TaskContext.to_dict returns all fields."""
taskmgr = get_taskmgr()
ctx = taskmgr.TaskContext.new()
ctx.trace('log entry')
result = ctx.to_dict()
assert 'current_action' in result
assert 'log' in result
assert 'metadata' in result
assert result['log'] == ctx.log
@pytest.mark.asyncio
async def test_task_context_set_current_action(self):
"""set_current_action updates action."""
taskmgr = get_taskmgr()
ctx = taskmgr.TaskContext.new()
ctx.set_current_action('new_action')
assert ctx.current_action == 'new_action'
@pytest.mark.asyncio
async def test_task_context_metadata(self):
"""TaskContext metadata can be set."""
taskmgr = get_taskmgr()
ctx = taskmgr.TaskContext.new()
ctx.metadata['key'] = 'value'
assert ctx.metadata['key'] == 'value'
assert ctx.to_dict()['metadata']['key'] == 'value'
def test_task_context_placeholder_singleton(self):
"""placeholder returns same instance."""
taskmgr = get_taskmgr()
ctx1 = taskmgr.TaskContext.placeholder()
ctx2 = taskmgr.TaskContext.placeholder()
assert ctx1 is ctx2
class TestTaskWrapperBehavior: class TestTaskWrapperReal:
"""Tests for TaskWrapper behavior patterns.""" """Tests for real TaskWrapper class."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_task_wrapper_creates_task(self): async def test_task_wrapper_creates_task(self):
"""TaskWrapper should create asyncio.Task.""" """TaskWrapper creates and wraps asyncio.Task."""
# Test the pattern without importing taskmgr = get_taskmgr()
async def coro():
return 42
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
task = loop.create_task(coro()) app = FakeMinimalApp(loop)
assert isinstance(task, asyncio.Task) async def simple_coro():
return 42
result = await task wrapper = taskmgr.TaskWrapper(app, simple_coro(), name='test')
assert wrapper.name == 'test'
assert wrapper.task is not None
assert isinstance(wrapper.task, asyncio.Task)
result = await wrapper.task
assert result == 42 assert result == 42
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_task_exception_capture_pattern(self): async def test_task_wrapper_with_custom_context(self):
"""Task exception can be captured via task.exception().""" """TaskWrapper uses provided TaskContext."""
async def failing(): taskmgr = get_taskmgr()
raise ValueError('error')
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
task = loop.create_task(failing()) app = FakeMinimalApp(loop)
# Let it fail ctx = taskmgr.TaskContext.new()
await asyncio.sleep(0.01) ctx.set_current_action('custom')
# Capture exception
try:
exception = task.exception()
assert isinstance(exception, ValueError)
except asyncio.CancelledError:
pass # Task was cancelled
@pytest.mark.asyncio
async def test_task_cancel_pattern(self):
"""Task can be cancelled."""
async def long_running():
await asyncio.sleep(10)
return 'done'
loop = asyncio.get_running_loop()
task = loop.create_task(long_running())
task.cancel()
await asyncio.sleep(0.01)
assert task.cancelled() or task.done()
class TestAsyncTaskManagerPatterns:
"""Tests for AsyncTaskManager behavior patterns."""
@pytest.mark.asyncio
async def test_task_tracking_pattern(self):
"""Manager tracks created tasks."""
# Simulate manager behavior pattern
tasks = []
async def coro(): async def coro():
return 'result' return 'done'
loop = asyncio.get_running_loop() wrapper = taskmgr.TaskWrapper(app, coro(), context=ctx)
wrapper = {'id': 0, 'name': 'test', 'task': loop.create_task(coro())}
tasks.append(wrapper)
assert wrapper in tasks assert wrapper.task_context.current_action == 'custom'
assert len(tasks) == 1
await wrapper['task'] await wrapper.task
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_multiple_tasks_isolated(self): async def test_task_wrapper_exception_capture(self):
"""Multiple tasks run independently.""" """TaskWrapper captures exception from failed task."""
results = [] taskmgr = get_taskmgr()
async def task_a():
await asyncio.sleep(0.01)
results.append('a')
async def task_b():
await asyncio.sleep(0.01)
results.append('b')
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
task_a_inst = loop.create_task(task_a()) app = FakeMinimalApp(loop)
task_b_inst = loop.create_task(task_b())
await asyncio.gather(task_a_inst, task_b_inst) async def failing_coro():
raise ValueError('test error')
assert 'a' in results wrapper = taskmgr.TaskWrapper(app, failing_coro())
assert 'b' in results
# Let task complete with exception
await asyncio.sleep(0.01)
exception = wrapper.assume_exception()
assert exception is not None
assert isinstance(exception, ValueError)
assert 'test error' in str(exception)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_cancel_by_id_pattern(self): async def test_task_wrapper_result_capture(self):
"""Task can be cancelled by ID lookup.""" """TaskWrapper captures result from completed task."""
tasks = [] taskmgr = get_taskmgr()
loop = asyncio.get_running_loop()
app = FakeMinimalApp(loop)
async def coro():
return 'result_value'
wrapper = taskmgr.TaskWrapper(app, coro())
await wrapper.task
result = wrapper.assume_result()
assert result == 'result_value'
@pytest.mark.asyncio
async def test_task_wrapper_cancel(self):
"""TaskWrapper.cancel cancels the task."""
taskmgr = get_taskmgr()
loop = asyncio.get_running_loop()
app = FakeMinimalApp(loop)
async def long_coro(): async def long_coro():
await asyncio.sleep(10) await asyncio.sleep(10)
return 'done' return 'done'
loop = asyncio.get_running_loop() wrapper = taskmgr.TaskWrapper(app, long_coro())
wrapper = {'id': 1, 'task': loop.create_task(long_coro())}
tasks.append(wrapper)
# Cancel by ID wrapper.cancel()
task_id = 1
for w in tasks:
if w['id'] == task_id:
w['task'].cancel()
await asyncio.sleep(0.01) await asyncio.sleep(0.01)
assert wrapper['task'].cancelled() or wrapper['task'].done() assert wrapper.task.cancelled() or wrapper.task.done()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_stats_calculation_pattern(self): async def test_task_wrapper_to_dict(self):
"""Stats count running/completed tasks.""" """TaskWrapper.to_dict serializes task info."""
tasks = [] taskmgr = get_taskmgr()
async def quick():
return 'done'
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
for i in range(3): app = FakeMinimalApp(loop)
wrapper = {'id': i, 'task': loop.create_task(quick())}
tasks.append(wrapper)
await wrapper['task']
completed = sum(1 for w in tasks if w['task'].done()) async def coro():
return 42
assert completed == 3 wrapper = taskmgr.TaskWrapper(app, coro(), name='dict_test', label='Test')
await wrapper.task
result = wrapper.to_dict()
assert result['name'] == 'dict_test'
assert result['label'] == 'Test'
assert 'runtime' in result
assert result['runtime']['done'] is True
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_pruning_completed_tasks(self): async def test_task_wrapper_id_increment(self):
"""Completed tasks are pruned when over limit.""" """TaskWrapper IDs increment."""
completed_limit = 5 taskmgr = get_taskmgr()
tasks = []
async def quick():
return 'done'
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
for i in range(10): app = FakeMinimalApp(loop)
wrapper = {'id': i, 'task': loop.create_task(quick())}
tasks.append(wrapper)
await wrapper['task']
# Prune async def coro():
completed = [w for w in tasks if w['task'].done()] return 1
overflow = len(completed) - completed_limit
if overflow > 0:
remove_ids = {w['id'] for w in completed[:overflow]}
tasks = [w for w in tasks if w['id'] not in remove_ids]
remaining_completed = sum(1 for w in tasks if w['task'].done()) wrapper1 = taskmgr.TaskWrapper(app, coro())
assert remaining_completed <= completed_limit wrapper2 = taskmgr.TaskWrapper(app, coro())
assert wrapper2.id > wrapper1.id
class TestScopeBasedCancellation: class TestAsyncTaskManagerReal:
"""Tests for scope-based cancellation pattern.""" """Tests for real AsyncTaskManager class."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_cancel_by_scope_pattern(self): async def test_manager_create_task(self):
"""Tasks can be filtered and cancelled by scope.""" """AsyncTaskManager creates and tracks tasks."""
tasks = [] taskmgr = get_taskmgr()
loop = asyncio.get_running_loop()
app = FakeMinimalApp(loop)
manager = taskmgr.AsyncTaskManager(app)
async def coro():
return 'result'
wrapper = manager.create_task(coro(), name='test')
assert wrapper in manager.tasks
assert wrapper.name == 'test'
await wrapper.task
@pytest.mark.asyncio
async def test_manager_create_user_task(self):
"""create_user_task creates user-type task."""
taskmgr = get_taskmgr()
loop = asyncio.get_running_loop()
app = FakeMinimalApp(loop)
manager = taskmgr.AsyncTaskManager(app)
async def coro():
return 'user_result'
wrapper = manager.create_user_task(coro())
assert wrapper.task_type == 'user'
await wrapper.task
@pytest.mark.asyncio
async def test_manager_multiple_tasks_isolated(self):
"""Multiple tasks run independently."""
taskmgr = get_taskmgr()
loop = asyncio.get_running_loop()
app = FakeMinimalApp(loop)
manager = taskmgr.AsyncTaskManager(app)
results = []
async def task_a():
results.append('a')
async def task_b():
results.append('b')
w1 = manager.create_task(task_a(), name='a')
w2 = manager.create_task(task_b(), name='b')
await asyncio.gather(w1.task, w2.task)
assert 'a' in results
assert 'b' in results
@pytest.mark.asyncio
async def test_manager_get_task_by_id(self):
"""get_task_by_id finds task."""
taskmgr = get_taskmgr()
loop = asyncio.get_running_loop()
app = FakeMinimalApp(loop)
manager = taskmgr.AsyncTaskManager(app)
async def coro():
return 1
wrapper = manager.create_task(coro())
found = manager.get_task_by_id(wrapper.id)
assert found is wrapper
not_found = manager.get_task_by_id(99999)
assert not_found is None
@pytest.mark.asyncio
async def test_manager_cancel_task(self):
"""cancel_task cancels specific task."""
taskmgr = get_taskmgr()
loop = asyncio.get_running_loop()
app = FakeMinimalApp(loop)
manager = taskmgr.AsyncTaskManager(app)
async def long(): async def long():
await asyncio.sleep(10) await asyncio.sleep(10)
return 'done'
loop = asyncio.get_running_loop() wrapper = manager.create_task(long())
# Create tasks with different scopes manager.cancel_task(wrapper.id)
platform_task = {
'id': 1,
'scopes': ['platform'],
'task': loop.create_task(long()),
}
app_task = {
'id': 2,
'scopes': ['application'],
'task': loop.create_task(long()),
}
tasks.extend([platform_task, app_task])
# Cancel platform scope
scope = 'platform'
for w in tasks:
if not w['task'].done() and scope in w['scopes']:
w['task'].cancel()
await asyncio.sleep(0.01) await asyncio.sleep(0.01)
assert platform_task['task'].cancelled() or platform_task['task'].done() assert wrapper.task.cancelled() or wrapper.task.done()
# App task still running (pending)
class TestTaskTypeFiltering:
"""Tests for filtering tasks by type/kind."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_filter_by_type_pattern(self): async def test_manager_cancel_by_scope(self):
"""Tasks can be filtered by type.""" """cancel_by_scope cancels matching scope tasks."""
tasks = [ taskmgr = get_taskmgr()
{'id': 1, 'task_type': 'system', 'kind': 'internal'}, entities = get_entities()
{'id': 2, 'task_type': 'user', 'kind': 'user_action'},
{'id': 3, 'task_type': 'system', 'kind': 'maintenance'},
]
system_tasks = [t for t in tasks if t['task_type'] == 'system'] loop = asyncio.get_running_loop()
user_tasks = [t for t in tasks if t['task_type'] == 'user'] app = FakeMinimalApp(loop)
assert len(system_tasks) == 2 manager = taskmgr.AsyncTaskManager(app)
assert len(user_tasks) == 1
assert all(t['task_type'] == 'system' for t in system_tasks)
async def long():
await asyncio.sleep(10)
class TestWaitAllTasks: async def app_long():
"""Tests for waiting for all tasks.""" await asyncio.sleep(10)
# Create task with PLATFORM scope
platform_wrapper = manager.create_task(
long(),
scopes=[entities.LifecycleControlScope.PLATFORM],
)
# Create task with APPLICATION scope
manager.create_task(
app_long(),
scopes=[entities.LifecycleControlScope.APPLICATION],
)
manager.cancel_by_scope(entities.LifecycleControlScope.PLATFORM)
await asyncio.sleep(0.01)
# Platform task cancelled
assert platform_wrapper.task.cancelled() or platform_wrapper.task.done()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_wait_all_pattern(self): async def test_manager_get_stats(self):
"""Can wait for all tasks to complete.""" """get_stats returns task counts."""
tasks = [] taskmgr = get_taskmgr()
loop = asyncio.get_running_loop()
app = FakeMinimalApp(loop)
manager = taskmgr.AsyncTaskManager(app)
async def quick():
return 1
for _ in range(3):
w = manager.create_task(quick())
await w.task
stats = manager.get_stats()
assert stats['total'] >= 3
assert stats['completed'] >= 3
@pytest.mark.asyncio
async def test_manager_get_tasks_dict(self):
"""get_tasks_dict filters by type."""
taskmgr = get_taskmgr()
loop = asyncio.get_running_loop()
app = FakeMinimalApp(loop)
manager = taskmgr.AsyncTaskManager(app)
async def coro():
return 1
system_w = manager.create_task(coro(), task_type='system')
user_w = manager.create_user_task(coro())
await asyncio.gather(system_w.task, user_w.task)
system_tasks = manager.get_tasks_dict(type='system')
assert all(t['task_type'] == 'system' for t in system_tasks['tasks'])
@pytest.mark.asyncio
async def test_manager_wait_all(self):
"""wait_all waits for all tasks."""
taskmgr = get_taskmgr()
loop = asyncio.get_running_loop()
app = FakeMinimalApp(loop)
manager = taskmgr.AsyncTaskManager(app)
async def delayed(): async def delayed():
await asyncio.sleep(0.05) await asyncio.sleep(0.05)
return 'delayed'
for _ in range(3):
manager.create_task(delayed())
await manager.wait_all()
stats = manager.get_stats()
assert stats['running'] == 0
class TestTaskPruningReal:
"""Tests for real task pruning behavior."""
@pytest.mark.asyncio
async def test_prune_completed_tasks(self):
"""Completed tasks are pruned when exceeding limit."""
taskmgr = get_taskmgr()
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
for i in range(3): app = FakeMinimalApp(loop)
wrapper = {'id': i, 'task': loop.create_task(delayed())} app.instance_config.data = {'system': {'task_retention': {'completed_limit': 3}}}
tasks.append(wrapper)
# Wait for all manager = taskmgr.AsyncTaskManager(app)
await asyncio.gather(*[w['task'] for w in tasks], return_exceptions=True)
# All done async def quick():
assert all(w['task'].done() for w in tasks) return 1
# Create more than limit
for _ in range(5):
w = manager.create_task(quick())
await w.task
await asyncio.sleep(0.01)
# Completed count should be <= limit
completed = sum(1 for w in manager.tasks if w.task.done())
assert completed <= 3