| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373 |
- """月度20日均分、每日限额、冻结清单和磁盘恢复;不连接网关或模型。"""
- import asyncio
- from datetime import datetime
- import json
- from types import SimpleNamespace
- from zoneinfo import ZoneInfo
- import pytest
- import pytest_asyncio
- import config
- from extensions.main_fan import batch_service, mine_catalog, monthly_schedule
- TIMEZONE = ZoneInfo('Asia/Shanghai')
- def at(day, *, month=11, hour=2, microsecond=0):
- return datetime(2026, month, day, hour, tzinfo=TIMEZONE, microsecond=microsecond)
- @pytest_asyncio.fixture
- async def monthly(monkeypatch, tmp_path):
- monkeypatch.setattr(config, 'ROOT', tmp_path)
- for key, value in {'MAIN_FAN_BATCH_OUTPUT_DIR': str(tmp_path / 'batches'),
- 'MAIN_FAN_BATCH_SPREAD_DAYS': '20', 'MAIN_FAN_BATCH_MAX_MINES': '0',
- 'MAIN_FAN_BATCH_ALLOW_ALL': 'True', 'MAIN_FAN_BATCH_DEPT_IDS': 'all',
- 'MAIN_FAN_BATCH_RUN_ON_STARTUP': 'False', 'MAIN_FAN_BATCH_CRON': '0 2 1 * *',
- 'MAIN_FAN_REPORT_MODE': 'crawler', 'MAIN_FAN_REPORT_API_URL': 'http://reports.invalid'}.items():
- monkeypatch.setenv(key, value)
- state = SimpleNamespace(now=at(1), refreshes=0, calls=[], controllers=[], gate=None, status='completed')
- state.snapshot = {'snapshot_id': 'a' * 32, 'mines': [
- {'dept_id': str(1000 + i), 'mine_id': f'{1000 + i:08d}', 'mine_name': f'测试矿{i}'}
- for i in range(403)]}
- class Clock(datetime):
- @classmethod
- def now(cls, tz=None):
- return state.now.astimezone(tz)
- monkeypatch.setattr(monthly_schedule, 'datetime', Clock)
- async def refresh():
- state.refreshes += 1
- await asyncio.sleep(0)
- mine_catalog._write(mine_catalog._root() / state.snapshot['snapshot_id'] / 'selected-mines.json',
- state.snapshot)
- return state.snapshot
- class Runner:
- def __init__(self, folder, pdf, **kwargs):
- self.folder = folder
- async def run(self, request):
- state.calls.append(request.model_dump(mode='json'))
- assert mine_catalog.load(request.snapshot_id)['snapshot_id'] == 'a' * 32
- if state.gate is not None:
- await state.gate.wait()
- result = {'status': state.status}
- mine_catalog._write(self.folder / 'batch.json', result)
- return result
- monkeypatch.setattr(mine_catalog, 'refresh', refresh)
- monkeypatch.setattr(batch_service, 'BatchRunner', Runner)
- def controller():
- item = batch_service.LocalBatchController()
- state.controllers.append(item)
- return item
- async def drain():
- await asyncio.gather(*(task for item in state.controllers for task in list(item.tasks.values())))
- state.controller, state.drain = controller, drain
- yield state
- for item in state.controllers:
- await item.close()
- @pytest.mark.asyncio
- async def test_403_mines_are_frozen_and_processed_once_over_20_days(monthly):
- controller = monthly.controller()
- monthly.now = at(1, microsecond=850000)
- first = await controller.monthly.start(['all'])
- await monthly.drain()
- plan_path = controller.monthly.root / (first['plan_id'] + '.json')
- plan = json.loads(plan_path.read_text())
- assert [len(row['dept_ids']) for row in plan['days']] == [21] * 3 + [20] * 17
- assert first['spread_days'] == 20 and first['mine_count'] == 403
- monthly.snapshot['mines'].reverse() # 当月后续投递不能重新按上游清单切片。
- for day in range(1, 21):
- monthly.now = at(day)
- await controller.monthly.dispatch()
- await monthly.drain()
- repeated = await controller.monthly.start(['all'])
- assert repeated['plan_id'] == first['plan_id']
- assert len(monthly.calls) == day, '同一天完成后也不能领取下一份'
- assert monthly.refreshes == 1
- assert [dept for call in monthly.calls for dept in call['dept_ids']] == [str(1000 + i) for i in range(403)]
- assert {call['snapshot_id'] for call in monthly.calls} == {'a' * 32}
- assert all(call['cutoff'] is None for call in monthly.calls), '每份在实际执行时冻结自己的监测截止时间'
- monthly.now = at(21)
- assert await controller.monthly.dispatch() is None
- assert not controller.tasks
- @pytest.mark.asyncio
- @pytest.mark.parametrize('count', [0, 3, 40])
- async def test_empty_small_and_divisible_catalogs_have_no_invalid_or_missing_batches(monthly, count):
- monthly.snapshot['mines'] = monthly.snapshot['mines'][:count]
- controller = monthly.controller()
- receipt = await controller.monthly.start(['all'])
- for day in range(1, 21):
- monthly.now = at(day)
- await controller.monthly.dispatch()
- await monthly.drain()
- assert receipt['mine_count'] == count
- assert sum(len(call['dept_ids']) for call in monthly.calls) == count
- assert len(monthly.calls) == min(count, 20)
- assert all(call['dept_ids'] for call in monthly.calls)
- @pytest.mark.asyncio
- async def test_monthly_and_daily_triggers_racing_only_submit_one_portion(monthly):
- controller = monthly.controller()
- await asyncio.gather(controller.monthly.start(['all']), controller.monthly.start(['all']),
- controller.monthly.dispatch())
- await monthly.drain()
- assert monthly.refreshes == 1 and len(monthly.calls) == 1
- @pytest.mark.asyncio
- async def test_registered_callbacks_share_plan_and_daily_quota(monthly, monkeypatch):
- controller = monthly.controller()
- monkeypatch.setattr('extensions.get_service', lambda _: controller)
- first = await batch_service.monthly_callback()
- await monthly.drain()
- monthly.now = at(2)
- second = await batch_service.daily_callback()
- await monthly.drain()
- assert second['batch_id'] != first['daily_batch']['batch_id']
- assert monthly.refreshes == 1 and len(monthly.calls) == 2
- @pytest.mark.asyncio
- async def test_restart_after_completion_does_not_fetch_catalog_or_take_next_portion(monthly):
- original = monthly.controller()
- receipt = await original.monthly.start(['all'])
- await monthly.drain()
- await original.close()
- restarted = monthly.controller()
- restarted.start_on_startup()
- await restarted.monthly_task
- assert restarted.startup_task is None
- repeated = await restarted.monthly.start(['all'])
- assert repeated['daily_batch']['batch_id'] == receipt['daily_batch']['batch_id']
- assert repeated['daily_batch']['status'] == 'completed'
- assert not restarted.tasks
- assert monthly.refreshes == 1 and len(monthly.calls) == 1
- @pytest.mark.asyncio
- async def test_restart_resumes_interrupted_portion_with_same_id_and_consumes_that_days_quota(monthly):
- original = monthly.controller()
- receipt = await original.monthly.start(['all'])
- identifier = receipt['daily_batch']['batch_id']
- await original.close() # worker 尚未开始;job.json 仍必须可恢复。
- assert original.get(identifier)['status'] == 'interrupted'
- monthly.now = at(5)
- restarted = monthly.controller()
- restarted.start_on_startup()
- await restarted.monthly_task
- await monthly.drain()
- assert restarted.get(identifier)['status'] == 'completed'
- assert len(monthly.calls) == 1
- await restarted.monthly.dispatch()
- await monthly.drain()
- assert len(monthly.calls) == 1, '恢复上一份的当天不能再增加新份额'
- monthly.now = at(6)
- await restarted.monthly.dispatch()
- await monthly.drain()
- assert monthly.calls[1]['dept_ids'][0] == '1021'
- @pytest.mark.asyncio
- async def test_downtime_defers_missed_portions_one_per_day_without_burst(monthly):
- controller = monthly.controller()
- await controller.monthly.start(['all'])
- await monthly.drain()
- monthly.now = at(5)
- await controller.monthly.dispatch()
- await monthly.drain()
- await controller.monthly.dispatch()
- await monthly.drain()
- assert len(monthly.calls) == 2
- assert monthly.calls[1]['dept_ids'] == [str(i) for i in range(1021, 1042)]
- monthly.now = at(6)
- await controller.monthly.dispatch()
- await monthly.drain()
- assert len(monthly.calls) == 3
- @pytest.mark.asyncio
- async def test_slow_portion_does_not_accumulate_next_days_in_queue(monthly):
- monthly.gate = asyncio.Event()
- controller = monthly.controller()
- receipt = await controller.monthly.start(['all'])
- await asyncio.sleep(0)
- monthly.now = at(2)
- second = await controller.monthly.dispatch()
- assert second['batch_id'] == receipt['daily_batch']['batch_id']
- assert len(controller.tasks) == 1 and len(monthly.calls) == 1
- monthly.gate.set()
- await monthly.drain()
- await controller.monthly.dispatch()
- assert len(monthly.calls) == 1
- monthly.now = at(3)
- await controller.monthly.dispatch()
- await monthly.drain()
- assert len(monthly.calls) == 2
- @pytest.mark.asyncio
- @pytest.mark.parametrize('status', ['partial', 'failed'])
- async def test_failed_mines_are_recorded_without_blocking_the_next_days_portion(monthly, status):
- monthly.status = status
- controller = monthly.controller()
- first = await controller.monthly.start(['all'])
- await monthly.drain()
- assert controller.get(first['daily_batch']['batch_id'])['status'] == status
- monthly.now = at(2)
- await controller.monthly.dispatch()
- await monthly.drain()
- assert len(monthly.calls) == 2
- assert monthly.calls[1]['dept_ids'][0] == '1021'
- @pytest.mark.asyncio
- async def test_prior_month_backlog_and_new_plan_share_one_daily_quota(monthly):
- controller = monthly.controller()
- await controller.monthly.start(['all'])
- await monthly.drain()
- monthly.now = at(1, month=12)
- december = await controller.monthly.start(['all'])
- await monthly.drain()
- assert december['month'] == '2026-12'
- assert monthly.calls[-1]['dept_ids'][0] == '1021', '先处理上月积压,而非叠加两个月的日份额'
- await controller.monthly.dispatch()
- await monthly.drain()
- assert len(monthly.calls) == 2
- @pytest.mark.asyncio
- async def test_daily_or_startup_without_monthly_trigger_does_not_create_a_plan(monthly):
- controller = monthly.controller()
- monthly.now = at(15)
- controller.start_on_startup()
- await controller.monthly_task
- assert await controller.monthly.dispatch() is None
- assert monthly.refreshes == 0 and not monthly.calls
- @pytest.mark.asyncio
- async def test_empty_cron_disables_startup_recovery_of_an_existing_plan(monthly, monkeypatch):
- original = monthly.controller()
- await original.monthly.start(['all'])
- await original.close()
- monkeypatch.setenv('MAIN_FAN_BATCH_CRON', '')
- restarted = monthly.controller()
- restarted.start_on_startup()
- assert restarted.monthly_task is None and restarted.startup_task is None
- assert not restarted.tasks and not monthly.calls
- @pytest.mark.asyncio
- async def test_explicit_scope_keeps_order_and_rejects_unknown_or_duplicate_mines(monthly):
- controller = monthly.controller()
- for depts in (['1000', '1000'], ['999999']):
- with pytest.raises(ValueError, match='部门编号重复或不在'):
- await controller.monthly.start(depts)
- receipt = await controller.monthly.start(['1005', '1001', '1003'])
- await monthly.drain()
- plan = json.loads((controller.monthly.root / (receipt['plan_id'] + '.json')).read_text())
- assert [row['dept_ids'] for row in plan['days'][:3]] == [['1005'], ['1001'], ['1003']]
- @pytest.mark.asyncio
- async def test_daily_limit_checked_before_plan_creation_and_again_on_dispatch(monthly, monkeypatch):
- monkeypatch.setenv('MAIN_FAN_BATCH_MAX_MINES', '20')
- controller = monthly.controller()
- with pytest.raises(ValueError, match='每日批次超过'):
- await controller.monthly.start(['all'])
- assert not list(controller.monthly.root.glob('*.json')) and not controller.tasks
- controller.max_mines = 21
- await controller.monthly.start(['all'])
- await monthly.drain()
- controller.max_mines = 20
- monthly.now = at(2)
- with pytest.raises(ValueError, match='每日批次超过'):
- await controller.monthly.dispatch()
- assert len(monthly.calls) == 1
- @pytest.mark.asyncio
- async def test_all_mines_permission_is_not_bypassed_by_explicit_daily_slices(monthly, monkeypatch):
- controller = monthly.controller()
- monkeypatch.setenv('MAIN_FAN_BATCH_ALLOW_ALL', 'False')
- with pytest.raises(ValueError, match='全矿研判未启用'):
- await controller.monthly.start(['all'])
- assert monthly.refreshes == 0
- monkeypatch.setenv('MAIN_FAN_BATCH_ALLOW_ALL', 'True')
- await controller.monthly.start(['all'])
- await monthly.drain()
- monthly.now = at(2)
- monkeypatch.setenv('MAIN_FAN_BATCH_ALLOW_ALL', 'False')
- with pytest.raises(ValueError, match='全矿研判未启用'):
- await controller.monthly.dispatch()
- assert len(monthly.calls) == 1
- @pytest.mark.parametrize('days', [0, -1, 29])
- def test_invalid_spread_days_fail_early(monthly, monkeypatch, days):
- monkeypatch.setenv('MAIN_FAN_BATCH_SPREAD_DAYS', str(days))
- with pytest.raises(ValueError, match='1~28'):
- monthly.controller()
- @pytest.mark.asyncio
- @pytest.mark.parametrize(('spread_days', 'cron', 'count'), [(20, '15 3 1 * *', 2),
- (1, '15 3 1 * *', 1), (20, '', 0)])
- async def test_scheduler_uses_beijing_monthly_and_daily_triggers(monthly, monkeypatch, spread_days, cron, count):
- from scheduler import scheduler
- monkeypatch.setenv('MAIN_FAN_LOCAL_BATCH_ENABLED', 'True')
- monkeypatch.setenv('MAIN_FAN_BATCH_SPREAD_DAYS', str(spread_days))
- monkeypatch.setenv('MAIN_FAN_BATCH_CRON', cron)
- try:
- assert await scheduler.start() == count
- jobs = {job.id: job for job in scheduler.get_scheduler().get_jobs()}
- if count:
- job = jobs['main-fan:monthly-local']
- assert job.args == (batch_service.monthly_callback,)
- assert job.trigger.get_next_fire_time(None, at(2, hour=0)).isoformat() == '2026-12-01T03:15:00+08:00'
- if count == 2:
- job = jobs['main-fan:daily-local']
- assert job.args == (batch_service.daily_callback,)
- assert job.trigger.get_next_fire_time(None, at(2, hour=0)).isoformat() == '2026-11-02T03:15:00+08:00'
- finally:
- await scheduler.shutdown()
- await asyncio.sleep(0)
- @pytest.mark.asyncio
- async def test_scheduler_shutdown_cancels_plan_creation_before_closing_dependencies(monthly, monkeypatch):
- from scheduler import scheduler
- controller = monthly.controller()
- monkeypatch.setattr('extensions.get_service', lambda _: controller)
- monkeypatch.setenv('MAIN_FAN_LOCAL_BATCH_ENABLED', 'True')
- entered = asyncio.Event()
- async def waiting_refresh():
- entered.set()
- await asyncio.Event().wait()
- monkeypatch.setattr(mine_catalog, 'refresh', waiting_refresh)
- try:
- await scheduler.start()
- invocation = asyncio.create_task(scheduler._invoke_batch(batch_service.monthly_callback))
- await asyncio.wait_for(entered.wait(), timeout=3)
- await scheduler.shutdown()
- assert invocation.cancelled() and not scheduler._active
- assert not controller.tasks and not list(controller.monthly.root.glob('*.json'))
- finally:
- await scheduler.shutdown()
- await asyncio.sleep(0)
|