"""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