Files
NODEDC_MISSION_CORE/tests/test_planning_projects.py
DCCONSTRUCTIONS e515ab1b8c feat(planning): consolidate recorded-route localization and spatial scene
Preserve the completed teach-and-repeat laboratory stage: reference preparation, cascaded acquisition, local tracking and recovery, recording lifecycle, replay qualification, and persistent Rerun scene controls. Document the open grid-picking regression and Rerun upgrade contract. No autonomous driving or loop-closure optimization is claimed.
2026-09-21 08:47:19 +03:00

197 lines
11 KiB
Python

"""Project browsing binds to existing evidence and has no acquisition authority."""
import hashlib
import json
from pathlib import Path
from types import SimpleNamespace
from uuid import uuid4
import numpy as np
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from k1link.missions.projects import PlanningProjects
from k1link.web.mission_registration_api import build_mission_registration_router
def fixture(tmp_path):
draft = dict(id=str(uuid4()), revision=1, name='Reference experiment', updated_at_utc='2026-09-11T10:00:00Z',
zone=dict(label='A'), route=dict(length_m=30, points=[dict(position=[0,0,0]), dict(position=[30,0,0])]))
root=tmp_path/'recorded'; root.mkdir()
live_root=tmp_path/'live'; live_root.mkdir()
def read(identity): return json.loads((root/identity/'report.json').read_text())
runs=SimpleNamespace(root=root,get=read,directory=lambda identity:root/identity,
drafts=SimpleNamespace(list=lambda:[draft],get=lambda identity:draft))
live=SimpleNamespace(root=live_root,directory=lambda identity:live_root/identity)
return draft,runs,live,PlanningProjects(runs,live)
def save(owner,doc):
directory=owner.directory(doc['id']);directory.mkdir()
(directory/'report.json').write_text(json.dumps(doc))
return directory
def test_each_result_is_a_project_and_uses_frozen_draft(tmp_path):
draft,runs,live,projects=fixture(tmp_path)
ids=[str(uuid4()),str(uuid4())]
for n,identity in enumerate(ids):
save(runs,dict(id=identity,draft=dict(draft),state='ready',revision=1,created_at_utc=f'2026-09-11T10:0{n}:00Z',
scene_url=f'/recorded/{identity}.rrd',result=dict(status='candidate',matched_query_indices=[1,2])))
draft.update(name='Later edited name',revision=8)
items=projects.list()
assert len(items)==2 and items[0]['key']=='recorded:'+ids[1]
assert all(p['name']=='Reference experiment' and p['revision']==1 for p in items)
detail=projects.get('recorded',ids[0])
assert detail['draft']['revision']==1 and detail['scene_url']==f'/recorded/{ids[0]}.rrd'
assert 'matched_query_indices' not in detail['result']
assert not detail['vehicle_control'] and not detail['localization_confirmed']
def test_unstarted_draft_and_failed_live_are_honest_states(tmp_path):
draft,runs,live,projects=fixture(tmp_path)
assert projects.list()[0]['kind']=='draft'
identity=str(uuid4())
save(live,dict(id=identity,draft=draft,state='error',query_session_id='B',created_at_utc='2026-09-11T11:00:00Z',message='No fit',result=None))
assert len(projects.list())==1
detail=projects.get('live',identity)
assert detail['scene_url'] is None and detail['result'] is None and detail['message']=='No fit'
with pytest.raises(ValueError): projects.live_scene(identity)
def test_preparation_only_probe_is_not_a_passage_project(tmp_path):
draft,runs,live,projects=fixture(tmp_path)
save(live,dict(id=str(uuid4()),draft=draft,state='cancelled',query_session_id=None,created_at_utc='2026-09-11T11:00:00Z'))
assert [p['kind'] for p in projects.list()]==['draft']
def test_scene_hash_is_verified_and_browsing_router_never_starts_work(tmp_path):
draft,runs,live,projects=fixture(tmp_path)
identity=str(uuid4());payload=b'frozen-scene'
directory=save(runs,dict(id=identity,draft=draft,state='ready',created_at_utc='2026-09-11T11:00:00Z',
result={'status':'candidate'},artifacts={'scene.rrd':hashlib.sha256(payload).hexdigest()}))
(directory/'scene.rrd').write_bytes(payload)
app=FastAPI();app.include_router(build_mission_registration_router(runs,live))
with TestClient(app) as client:
assert client.get('/api/v1/mission-planner/projects').json()['items'][0]['key']=='recorded:'+identity
assert client.get('/api/v1/mission-planner/projects/recorded/'+identity).status_code==200
assert client.get('/api/v1/mission-planner/registration-runs/'+identity+'/scene.rrd').content==payload
(directory/'scene.rrd').write_bytes(b'different-scene')
assert client.get('/api/v1/mission-planner/registration-runs/'+identity+'/scene.rrd').status_code==409
assert client.get('/api/v1/mission-planner/projects/unknown/'+identity).status_code==404
def test_completed_live_uses_committed_fit_not_unregistered_terminal_preview(tmp_path,monkeypatch):
import k1link.missions.registration_scene as renderer
draft,runs,live,projects=fixture(tmp_path)
identity=str(uuid4());transform=np.eye(4);transform[0,3]=3
result={'status':'candidate','T_reference_query':transform.tolist(),'matched_query_indices':[0]}
doc=dict(id=identity,draft=draft,state='completed',created_at_utc='2026-09-11T11:00:00Z',query_session_id='B',
result=result,result_source_sequence=8,artifacts={})
directory=save(live,doc);step=directory/'step-001';step.mkdir()
(step/'source.json').write_text(json.dumps({'sequence':8,'query_path':[[1,2,3],[4,5,6]]}))
(step/'registration-result.json').write_text(json.dumps(result))
np.savez(step/'registration-input.npz',reference=np.zeros((2,3)),query=np.ones((2,3)),initial=np.eye(4))
for path in step.iterdir(): doc['artifacts'][str(path.relative_to(directory))]=hashlib.sha256(path.read_bytes()).hexdigest()
(directory/'report.json').write_text(json.dumps(doc))
before=(directory/'report.json').read_bytes()
captured=[]
def writer(path,run_id,reference,query,actual,ref_path,query_path):
captured.append((run_id,query.copy(),actual,query_path.copy()));path.write_bytes(b'scene')
monkeypatch.setattr(renderer,'write_scene',writer)
scene=projects.live_scene(identity)
assert scene.read_bytes()==b'scene' and len(captured)==1
assert captured[0][2]['T_reference_query']==transform.tolist()
np.testing.assert_equal(captured[0][1],np.ones((2,3)))
assert (directory/'report.json').read_bytes()==before
projects.live_scene(identity);assert len(captured)==1
(step/'registration-result.json').write_text('{}')
with pytest.raises(ValueError,match='целостности'): projects.live_scene(identity)
def test_spatial_renderer_transforms_query_cloud_and_path_once(tmp_path,monkeypatch):
import k1link.missions.registration_scene as renderer
logged={}
class Recording:
def __init__(self,*args,**kwargs): pass
def log(self,name,value,**kwargs): logged[name]=value
def save(self,*args): pass
def send_blueprint(self,*args): pass
def flush(self): pass
def disconnect(self): pass
monkeypatch.setattr(renderer.rr,'RecordingStream',Recording)
monkeypatch.setattr(renderer.rr,'Points3D',lambda xyz,**kw:np.asarray(xyz))
monkeypatch.setattr(renderer.rr,'LineStrips3D',lambda xyz,**kw:np.asarray(xyz))
t=np.eye(4);t[:3,3]=[3,-2,1]
points=np.array([[1.,2,3],[4,5,6]])
renderer.write_scene(tmp_path/'scene.rrd','test',points,points,{'status':'rejected','T_reference_query':t.tolist()},points,points)
np.testing.assert_equal(logged['world/reference'],points)
np.testing.assert_equal(logged['world/query'],points+[3,-2,1])
np.testing.assert_equal(logged['world/query_path'][0],points+[3,-2,1])
@pytest.mark.parametrize('kind,state', [('recorded', 'ready'), ('recorded', 'error'),
('live', 'completed'), ('live', 'cancelled'), ('live', 'error'), ('live', 'interrupted')])
def test_delete_one_project_preserves_evidence_and_survives_restart(tmp_path, kind, state):
draft, runs, live, projects = fixture(tmp_path)
owner = runs if kind == 'recorded' else live
ids = [str(uuid4()), str(uuid4())]
preserved = {}
for identity in ids:
directory = save(owner, dict(id=identity, draft=draft, state=state,
query_session_id='original-passage', created_at_utc='2026-09-11T11:00:00Z'))
(directory / 'scene.rrd').write_bytes(b'original-derived-scene')
for path in directory.iterdir(): preserved[path] = path.read_bytes()
key = kind + ':' + ids[0]
app = FastAPI(); app.include_router(build_mission_registration_router(runs, live))
with TestClient(app) as client:
url = '/api/v1/mission-planner/projects/' + kind + '/' + ids[0]
for _ in range(2):
reply = client.request('DELETE', url, json={'revision': 1})
assert reply.status_code == 200 and reply.json() == {'key': key, 'deleted': True}
assert client.get(url).status_code == 404
assert [item['key'] for item in client.get('/api/v1/mission-planner/projects').json()['items']] == [kind + ':' + ids[1]]
# Same-name sibling remains; the shared draft cannot resurface after the last deletion.
projects.remove(kind, ids[1], 1)
assert PlanningProjects(runs, live).list() == []
assert all(path.read_bytes() == data for path, data in preserved.items())
assert runs.drafts.get(draft['id']) == draft
def test_delete_draft_revision_and_project_kind_are_exact(tmp_path):
draft, runs, live, projects = fixture(tmp_path)
with pytest.raises(ValueError, match='изменён'):
projects.remove('draft', draft['id'], 9)
with pytest.raises(KeyError): projects.remove('unknown', draft['id'], 1)
assert len(projects.list()) == 1
projects.remove('draft', draft['id'], 1)
assert PlanningProjects(runs, live).list() == []
assert runs.drafts.get(draft['id']) == draft
# A later immutable run has an independent identity, not the draft tombstone.
save(runs, dict(id=draft['id'], draft=draft, state='ready', created_at_utc='2026-09-11T11:00:00Z'))
assert projects.list()[0]['kind'] == 'recorded'
@pytest.mark.parametrize('kind,state', [('recorded', 'queued'), ('recorded', 'running'),
('live', 'preparing'), ('live', 'waiting'), ('live', 'running'), ('live', 'unknown')])
def test_delete_fails_closed_for_active_or_unknown_states(tmp_path, kind, state):
draft, runs, live, projects = fixture(tmp_path)
identity = str(uuid4())
save(runs if kind == 'recorded' else live, dict(id=identity, draft=draft,
state=state, created_at_utc='2026-09-11T11:00:00Z'))
app = FastAPI(); app.include_router(build_mission_registration_router(runs, live))
with TestClient(app) as client:
url = '/api/v1/mission-planner/projects/' + kind + '/' + identity
assert client.request('DELETE', url, json={'revision': 1}).status_code == 409
assert client.request('DELETE', url, json={'revision': 1, 'delete_sources': True}).status_code == 422
assert client.request('DELETE', '/api/v1/mission-planner/projects/draft/' + draft['id'], json={'revision': 1}).status_code == 409
assert client.get(url).status_code == 200
assert len(projects.list()) == 1
def test_delete_api_rejects_missing_and_invalid_identity(tmp_path):
_, runs, live, _ = fixture(tmp_path)
app = FastAPI(); app.include_router(build_mission_registration_router(runs, live))
with TestClient(app) as client:
for kind in ['unknown', 'live']:
assert client.request('DELETE', f'/api/v1/mission-planner/projects/{kind}/{uuid4()}', json={'revision': 1}).status_code == 404
assert client.request('DELETE', '/api/v1/mission-planner/projects/live/not-a-uuid', json={'revision': 1}).status_code == 422