2026-10-21 10:07:35 +08:00
""" 开发机: python scripts/unit_test_guard.py scripts/test_batch43_units.py。隔离禁网, 真实SQL事务。 """
import unit_test_guard
import os , sys , copy , tempfile , json
from datetime import datetime , timedelta
from types import SimpleNamespace
from concurrent . futures import ThreadPoolExecutor
from unittest . mock import patch
import unittest
sys . path . insert ( 0 , os . path . dirname ( os . path . dirname ( os . path . abspath ( __file__ ) ) ) )
from sqlalchemy import create_engine , text , event
from app . db import session
from app . repo import pms_repo
from app . services import plan_feed as pf , plan_recheck as rc , proposal_service as ps , portfolio , command_service , param_store , market , execution_conflicts
from app . core import plan_revision as pr , tradedays as td
from app . web import main
import requests
CODE = ' 300693.SZ '
class AcceptancePathTest ( unittest . TestCase ) :
def setUp ( self ) :
self . tmp = tempfile . TemporaryDirectory ( ) ; self . addCleanup ( self . tmp . cleanup )
self . engine = create_engine ( ' sqlite:/// ' + self . tmp . name + ' /state.sqlite ' , connect_args = { ' check_same_thread ' : False } )
self . addCleanup ( self . engine . dispose )
def use ( target , * * kw ) :
p = patch ( target , * * kw ) ; v = p . start ( ) ; self . addCleanup ( p . stop ) ; return v
use ( ' app.db.session.get_engine ' , return_value = self . engine ) ; use ( ' app.db.session._check_cooldown ' )
use ( ' app.services.param_store.get ' , side_effect = lambda k , d = None : d )
use ( ' app.services.param_store.get_int ' , side_effect = lambda k , d = 0 : d )
use ( ' app.services.param_store.get_float ' , side_effect = lambda k , d = 0 : d )
use ( ' app.services.param_store.get_list ' , side_effect = lambda k , d = None : d or [ ] )
use ( ' app.services.param_store.get_bool ' , side_effect = lambda k , d = False : True if k == ' PMS_PLAN_VERSION_RECHECK ' else d )
use ( ' app.web.main._oplog ' ) ; use ( ' app.web.main._actor ' , return_value = ' test ' )
self . params = pf . _params ( ) ; self . params . update ( base = ' http://bridge ' , tiers = [ ' 强传导 ' ] )
use ( ' app.services.plan_feed._params ' , return_value = self . params )
self . view = { ' held ' : [ ] , ' params ' : { ' max_names ' : 40 , ' scale ' : 2000000 , ' portfolio_cap ' : 0.7 } ,
' totals ' : { ' names_count ' : 0 , ' portfolio_mv ' : 0 , ' cash_source ' : portfolio . CASH_WS , ' cash_avail ' : 100000 } }
self . commitments = { ' names ' : set ( ) , ' amount ' : 0 , ' basis ' : [ ] }
self . scan = { ' open_route_by_verdict ' : True }
use ( ' app.services.portfolio.positions_view ' , return_value = self . view )
use ( ' app.services.command_service.effective_stock_params ' , return_value = { } )
use ( ' app.services.proposal_service._scan_params ' , return_value = self . scan )
use ( ' app.services.proposal_service._inflight_open_commitments ' , return_value = self . commitments )
use ( ' app.services.execution_conflicts.buy_blocker ' , return_value = None )
use ( ' app.services.market.get_price ' , return_value = 34.18 )
use ( ' app.services.market.day_snapshot ' , return_value = { } ) ; use ( ' app.services.market.get_ma5 ' , return_value = 34 )
use ( ' app.services.proposal_service.check_candidate_rules ' , return_value = { ' passed ' : True } )
use ( ' app.core.tradedays.window_deadline ' , return_value = ' 2026-10-23 ' )
use ( ' app.services.proposal_service._scan_open ' , side_effect = AssertionError ( ' 不能重扫 ' ) )
use ( ' app.services.proposal_service._route_one ' , side_effect = AssertionError ( ' 不能重问模型 ' ) )
use ( ' app.repo.pms_repo.insert_plan_snapshot ' , side_effect = AssertionError ( ' 采纳不能写新消费 ' ) )
self . raw = { ' date ' : datetime . now ( ) . date ( ) . isoformat ( ) , ' schema_version ' : 2 , ' decision ' : { ' id ' : ' live ' } ,
' check_code ' : ' SZ300693 ' , ' found ' : True , ' refresh_status ' : { ' pending_codes ' : [ ] } ,
' main ' : [ { ' code ' : ' SZ300693 ' , ' name ' : ' 盛弘股份 ' , ' score ' : 230 , ' tier ' : ' 强传导 ' , ' rank ' : 555 ,
' verdict ' : ' 候选 ' , ' risk ' : [ ] , ' company_review_text ' : ' 研究依据 ' } ] , ' observe ' : [ ] }
def response ( url , * * kw ) :
self . assertEqual ( url , ' http://bridge/plan/check ' ) ; self . assertEqual ( kw [ ' params ' ] , { ' code ' : CODE } )
return SimpleNamespace ( raise_for_status = lambda : None , json = lambda : copy . deepcopy ( self . raw ) )
self . http = use ( ' requests.get ' , side_effect = response )
selected = pf . candidates ( record = False , recheck_code = CODE , plan_override = pf . parse_plan ( self . raw ) , route_by_verdict = True )
h = { ' price ' : 34.18 , ' sector ' : ' 电气设备 ' , ' preparation ' : { ' status ' : ' ready ' } , ' proposal_revision ' : ' shown ' ,
' consumption_id ' : ' already-saved ' , ' decision ' : { ' id ' : ' prepared ' } , ' plan_basis ' : pr . basis ( selected [ ' items ' ] [ 0 ] ) ,
' scan_params_hash ' : pr . fingerprint ( self . scan ) , ' portfolio_context_hash ' : pr . portfolio_context ( self . view , self . commitments ) ,
' prepared_judgement ' : { ' verdict ' : ' UNAVAILABLE ' , ' reason ' : ' 原人工确认依据 ' , ' degraded ' : True } }
self . hard = h ; self . pid = ' PRP_20261021_300693SZ_OPEN '
ddl = [
' CREATE TABLE pms_proposal(id INTEGER PRIMARY KEY,proposal_id TEXT UNIQUE,ts_code TEXT,action TEXT,qty INTEGER,hard_numbers_json TEXT,status TEXT,expire_at TEXT,decided_at TEXT,judge_verdict TEXT,judge_reason TEXT,archived_at TEXT) ' ,
' CREATE TABLE pms_action_ledger(ts_code TEXT,decided_at TEXT,action TEXT,arbiter TEXT,verdict TEXT,price_at REAL,hard_numbers_json TEXT,failed_checks_json TEXT,reason TEXT,ref_id TEXT,outcome_scored INTEGER) ' ,
' CREATE TABLE pms_instruction(id INTEGER PRIMARY KEY,instruction_id TEXT UNIQUE,origin_type TEXT,origin_id TEXT,ts_code TEXT,action TEXT,side TEXT,qty INTEGER,limit_price REAL,window_tdays INTEGER,status TEXT,exec_qty INTEGER,progress_json TEXT,created_at TEXT,updated_at TEXT) ' ]
with self . engine . begin ( ) as c :
for sql in ddl : c . execute ( text ( sql ) )
c . execute ( text ( " INSERT INTO pms_proposal(proposal_id,ts_code,action,qty,hard_numbers_json,status,expire_at) VALUES (:pid,:code, ' OPEN ' ,500,:hn, ' WAIT_USER ' ,:expiry) " ) ,
{ ' pid ' : self . pid , ' code ' : CODE , ' hn ' : pms_repo . _dumps ( h ) , ' expiry ' : datetime . now ( ) + timedelta ( hours = 1 ) } )
def decide ( self , revision = ' shown ' ) :
return main . api_decide ( None , self . pid , { ' decision ' : ' ACCEPTED ' , ' reason ' : ' 用户确认 ' , ' proposal_revision ' : revision } )
def counts ( self ) :
with self . engine . connect ( ) as c : return tuple ( c . execute ( text ( ' SELECT COUNT(*) FROM ' + t ) ) . scalar ( ) for t in ( ' pms_instruction ' , ' pms_action_ledger ' ) )
def test_full_api_to_sql_accepts_prepared_quantity_and_pairs_original_source ( self ) :
r = self . decide ( ) ; self . assertTrue ( r [ ' ok ' ] , r ) ; self . assertEqual ( self . counts ( ) , ( 1 , 1 ) )
with self . engine . connect ( ) as c :
row = dict ( c . execute ( text ( ' SELECT * FROM pms_instruction ' ) ) . mappings ( ) . one ( ) )
self . assertEqual ( row [ ' qty ' ] , 500 ) ; p = json . loads ( row [ ' progress_json ' ] ) [ ' plan ' ]
self . assertEqual ( p [ ' consumption_id ' ] , ' already-saved ' ) ; self . assertEqual ( p [ ' decision ' ] [ ' id ' ] , ' prepared ' )
self . assertEqual ( p [ ' version_check ' ] [ ' checked_decision ' ] [ ' id ' ] , ' live ' )
def test_transport_timeout_keeps_ready_and_retry_uses_same_confirmation ( self ) :
self . http . side_effect = requests . exceptions . ReadTimeout ( ' 3 seconds ' )
r = self . decide ( ) ; self . assertFalse ( r [ ' ok ' ] ) ; self . assertTrue ( r [ ' retryable ' ] ) ; self . assertEqual ( self . counts ( ) , ( 0 , 0 ) )
self . assertEqual ( pms_repo . get_proposal ( self . pid ) [ ' hard_numbers ' ] [ ' preparation ' ] [ ' status ' ] , ' ready ' )
self . http . side_effect = lambda * a , * * k : SimpleNamespace ( raise_for_status = lambda : None , json = lambda : self . raw )
self . assertTrue ( self . decide ( ) [ ' ok ' ] ) ; self . assertEqual ( self . counts ( ) , ( 1 , 1 ) )
def test_response_lost_retry_returns_exact_existing_instruction ( self ) :
a = self . decide ( ) ; self . http . side_effect = AssertionError ( ' 重试已采纳不再查来源 ' )
b = self . decide ( ) ; self . assertTrue ( b [ ' already_accepted ' ] ) ; self . assertEqual ( a [ ' instruction_id ' ] , b [ ' instruction_id ' ] )
self . assertEqual ( self . counts ( ) , ( 1 , 1 ) )
def test_two_concurrent_confirmations_create_one_instruction_and_audit ( self ) :
with ThreadPoolExecutor ( 2 ) as pool : r = list ( pool . map ( lambda _ : self . decide ( ) , range ( 2 ) ) )
self . assertTrue ( all ( x [ ' ok ' ] for x in r ) , r ) ; self . assertEqual ( len ( { x [ ' instruction_id ' ] for x in r } ) , 1 )
self . assertEqual ( self . counts ( ) , ( 1 , 1 ) )
def test_instruction_or_audit_failure_rolls_back_confirmation ( self ) :
for table in ( ' pms_instruction ' , ' pms_action_ledger ' ) :
def fail ( conn , cursor , statement , params , context , many ) :
if statement . startswith ( ' INSERT INTO ' + table ) : raise RuntimeError ( ' 故障注入 ' )
event . listen ( self . engine , ' before_cursor_execute ' , fail )
try : r = self . decide ( )
finally : event . remove ( self . engine , ' before_cursor_execute ' , fail )
self . assertFalse ( r [ ' ok ' ] ) ; self . assertEqual ( self . counts ( ) , ( 0 , 0 ) )
self . assertEqual ( pms_repo . get_proposal ( self . pid ) [ ' status ' ] , ' WAIT_USER ' )
self . assertTrue ( self . decide ( ) [ ' ok ' ] )
def test_stale_confirmation_cannot_accept ( self ) :
self . assertFalse ( self . decide ( ' old ' ) [ ' ok ' ] ) ; self . assertEqual ( self . counts ( ) , ( 0 , 0 ) )
def test_unknown_shape_or_wrong_stock_does_not_withdraw_or_accept ( self ) :
for bad in ( { } , dict ( self . raw , check_code = ' SH600000 ' ) , dict ( self . raw , found = False ) , dict ( self . raw , main = None ) ) :
self . http . side_effect = lambda * a , * * k : SimpleNamespace ( raise_for_status = lambda : None , json = lambda : bad )
self . assertFalse ( self . decide ( ) [ ' ok ' ] ) ; self . assertEqual ( self . counts ( ) , ( 0 , 0 ) )
self . assertEqual ( pms_repo . get_proposal ( self . pid ) [ ' status ' ] , ' WAIT_USER ' )
def test_known_research_pending_is_not_transport_retry ( self ) :
self . raw [ ' refresh_status ' ] [ ' pending_codes ' ] = [ ' SZ300693 ' ]
r = self . decide ( ) ; self . assertTrue ( r [ ' pending ' ] ) ; self . assertEqual ( self . counts ( ) , ( 0 , 0 ) )
self . assertEqual ( pms_repo . get_proposal ( self . pid ) [ ' hard_numbers ' ] [ ' preparation ' ] [ ' status ' ] , ' pending ' )
def test_missing_qualification_withdraws ( self ) :
self . raw . update ( found = False , main = [ ] )
r = self . decide ( ) ; self . assertTrue ( r [ ' withdrawn ' ] , r ) ; self . assertEqual ( self . counts ( ) , ( 0 , 1 ) )
self . assertEqual ( pms_repo . get_proposal ( self . pid ) [ ' status ' ] , ' EXPIRED ' )
def test_material_change_waits_for_background_not_accept ( self ) :
self . raw [ ' main ' ] [ 0 ] [ ' risk ' ] = [ ' 重大风险变化 ' ]
self . assertTrue ( self . decide ( ) [ ' pending ' ] ) ; self . assertEqual ( self . counts ( ) , ( 0 , 0 ) )
def test_existing_quantity_still_requires_funds ( self ) :
self . view [ ' totals ' ] [ ' cash_avail ' ] = 100
r = self . decide ( ) ; self . assertFalse ( r [ ' ok ' ] ) ; self . assertIn ( ' 资金不足 ' , r [ ' error ' ] ) ; self . assertEqual ( self . counts ( ) , ( 0 , 0 ) )
2026-10-21 10:14:20 +08:00
def test_ready_portfolio_price_changes_keep_confirmation_when_quantity_fits ( self ) :
self . view [ ' totals ' ] [ ' cash_avail ' ] = 90000
before = pms_repo . get_proposal ( self . pid )
self . assertTrue ( rc . refresh_one ( before ) [ ' ok ' ] )
self . assertEqual ( pms_repo . get_proposal ( self . pid ) [ ' hard_numbers_json ' ] , before [ ' hard_numbers_json ' ] )
self . assertEqual ( self . counts ( ) , ( 0 , 0 ) )
self . assertTrue ( self . decide ( ) [ ' ok ' ] )
2026-10-21 10:07:35 +08:00
def test_source_recovery_restores_original_content_without_scan ( self ) :
p = pms_repo . get_proposal ( self . pid ) ; h = dict ( p [ ' hard_numbers ' ] , preparation = { ' status ' : ' pending ' , ' stage ' : ' source ' } )
pms_repo . record_proposal_check ( p , h )
self . assertTrue ( rc . refresh_one ( pms_repo . get_proposal ( self . pid ) ) [ ' ok ' ] )
after = pms_repo . get_proposal ( self . pid )
self . assertEqual ( after [ ' qty ' ] , 500 ) ; self . assertEqual ( after [ ' hard_numbers ' ] [ ' proposal_revision ' ] , ' shown ' )
self . assertTrue ( self . decide ( ) [ ' ok ' ] ) ; self . assertEqual ( self . counts ( ) , ( 1 , 2 ) )
def test_expiry_during_checks_prevents_commit ( self ) :
original = rc . fast_checks
def expire ( p , c ) :
session . execute ( " UPDATE pms_proposal SET expire_at=:at WHERE proposal_id=:pid " , { ' at ' : datetime . now ( ) - timedelta ( seconds = 1 ) , ' pid ' : self . pid } )
return original ( p , c )
with patch . object ( rc , ' fast_checks ' , side_effect = expire ) : r = self . decide ( )
self . assertFalse ( r [ ' ok ' ] ) ; self . assertEqual ( self . counts ( ) , ( 0 , 0 ) )
if __name__ == ' __main__ ' : unittest . main ( )