import copy,json,sys,unittest
from pathlib import Path
sys.path.insert(0,'/evidence/2026-11-01_contextra-fall-spatial-28clips_r01/helpers')
from common import OUT,read,save
from fall_candidate import SpatialState
from depth_candidate import decide
from depth_features import measure
C=read(OUT/'conditions.json');R=read('/evidence/2026-11-01_contextra-fall-spatial-28clips_r01/conditions.json')
BASE=dict(eligible=True,torso={'angle':30.,'length_ratio':.2,'top':[50,20],'bottom':[60,60]},hit=False)
X=dict(available=True,aspect=1.3,torso_angle=30.,torso_ratio=.2,shoulder_below_hip=-.2,
 legs=[dict(knee_angle=170.,length_ratio=.3,ankle_drop=.3,knee_drop=.15)])
def inverted():
 x=copy.deepcopy(X);x['shoulder_below_hip']=.1;x['legs'][0].update(knee_angle=90.,ankle_drop=-.1,knee_drop=-.05);return x
def p(hit,query=0):return dict(hit=hit,eligible=True,observable=True,query=query,bbox=[0,0,10,10],candidate=hit)
class Tests(unittest.TestCase):
 def test_depth_requires_combination(self):self.assertTrue(decide(BASE,X,C)['hit'])
 def test_inversion(self):self.assertEqual(decide(BASE,inverted(),C)['added_rules'],['D2_REVERSED_ORDER'])
 def test_original_subset_missing_extra(self):self.assertTrue(decide(dict(BASE,hit=True),dict(available=False),C)['hit'])
 def test_original_subset_invalid_extra(self):
  x=copy.deepcopy(X);x['aspect']=float('nan');self.assertTrue(decide(dict(BASE,hit=True),x,C)['hit'])
 def test_standing(self):
  x=copy.deepcopy(X);x.update(aspect=.4,torso_angle=0.);self.assertFalse(decide(BASE,x,C)['hit'])
 def test_wide_box_alone(self):
  x=copy.deepcopy(X);x['torso_angle']=0.;self.assertFalse(decide(BASE,x,C)['hit'])
 def test_bending_support(self):
  x=copy.deepcopy(X);x['legs'][0]['ankle_drop']=.6;self.assertFalse(decide(BASE,x,C)['hit'])
 def test_squat(self):
  x=copy.deepcopy(X);x['legs'][0]['knee_angle']=80.;self.assertFalse(decide(BASE,x,C)['hit'])
 def test_missing_leg(self):
  x=copy.deepcopy(X);x.update(available=False,legs=[]);self.assertFalse(decide(BASE,x,C)['hit'])
 def test_one_leg_available_allowed(self):self.assertTrue(decide(BASE,X,C)['hit'])
 def test_observed_second_leg_disagrees(self):
  x=copy.deepcopy(X);x['legs'].append(dict(x['legs'][0],knee_angle=90.));self.assertFalse(decide(BASE,x,C)['hit'])
 def test_inversion_must_use_same_leg(self):
  x=inverted();x['legs']=[dict(x['legs'][0],knee_drop=.2),dict(x['legs'][0],ankle_drop=.2)];self.assertFalse(decide(BASE,x,C)['hit'])
 def test_missing_not_zero_normal(self):self.assertTrue(decide(BASE,dict(available=False),C)['insufficient'])
 def test_threshold_boundaries(self):
  x=copy.deepcopy(X);x.update(aspect=1.1,torso_angle=20.,torso_ratio=.25);x['legs'][0].update(knee_angle=140.,length_ratio=.4,ankle_drop=.4)
  self.assertTrue(decide(BASE,x,C)['hit']);x['torso_angle']=19.999;self.assertFalse(decide(BASE,x,C)['hit'])
 def test_order_and_tracking_irrelevance(self):
  self.assertEqual(decide(dict(BASE,track_id=1,query=8),X,C),decide(dict(BASE,track_id=None,query=99),X,C))
 def test_no_cross_person_combination(self):
  a=copy.deepcopy(X);b=copy.deepcopy(X);a['aspect']=.3;b['legs'][0]['knee_angle']=70.
  self.assertFalse(any(decide(BASE,x,C)['hit'] for x in (a,b)))
 def test_local_only_suppression(self):
  bad=copy.deepcopy(X);bad['aspect']=.3;self.assertTrue(any(decide(BASE,x,C)['hit'] for x in (bad,X)))
 def test_dedup_roi_event(self):
  s=SpatialState(R,'dedupe');self.assertIsNotNone(s.evaluate(0,[p(True),p(True,1)],monotonic_now=0)['event']);self.assertEqual(s.counter,1)
 def test_rearm_hold_cooldown(self):
  s=SpatialState(R,'policy');s.evaluate(0,[p(True)],monotonic_now=0)
  self.assertTrue(s.evaluate(1,[],monotonic_now=1)['display_hold']);r=s.evaluate(2,[],monotonic_now=2);self.assertTrue(r['rearmed']);self.assertIsNone(r['event'])
  r=s.evaluate(3,[p(True)],monotonic_now=3);self.assertIsNone(r['event']);self.assertFalse(r['latched'])
  self.assertIsNotNone(s.evaluate(10,[p(True)],monotonic_now=10)['event'])
 def test_past_hit_not_scheduled(self):
  s=SpatialState(R,'no-queue');s.evaluate(0,[p(True)],monotonic_now=0);s.evaluate(3,[p(True)],monotonic_now=3);self.assertIsNone(s.evaluate(11,[],monotonic_now=11)['event'])
 def test_causal_current(self):
  r=decide(BASE,X,C);decide(BASE,inverted(),C);self.assertEqual(r,decide(BASE,X,C))
 def test_pts_and_run_boundary(self):
  s=SpatialState(R,'P02');s.evaluate(105.352669,[],monotonic_now=0)
  with self.assertRaises(ValueError):s.evaluate(104.,[],monotonic_now=1)
  self.assertIsNone(SpatialState(R,'fresh').last_hit)
 def test_mock_retry_idempotence(self):
  s=SpatialState(R,'retry');e=s.evaluate(0,[p(True)],monotonic_now=0)['event'];ledger={}
  for _ in range(3):ledger.setdefault(e['id'],e)
  self.assertEqual(len(ledger),1)
 def test_geometry_uses_current_scores(self):
  k=[[0,0,0] for _ in range(17)];k[11]=[50,50,.1];k[13]=[50,70,.1];k[15]=[50,90,.1]
  person=dict(bbox=[0,0,100,100],keypoints=k)
  self.assertTrue(measure(person,BASE)['available']);k[13][2]=.0999;self.assertFalse(measure(person,BASE)['available'])

if __name__=='__main__':
 result=unittest.TextTestRunner(verbosity=2).run(unittest.defaultTestLoader.loadTestsFromTestCase(Tests))
 save(OUT/'reports/synthetic_validation.json',dict(status='PASS' if result.wasSuccessful() else 'FAIL',tests=result.testsRun,failures=len(result.failures),errors=len(result.errors),actual_delivery=False))
 raise SystemExit(0 if result.wasSuccessful() else 1)
