Lab047: transmit JPEG over real RF link
This commit is contained in:
@@ -428,6 +428,211 @@ def test_cursor_is_strictly_monotonic(tmp_path: Path) -> None:
|
||||
assert all(after > before for before, after in zip(result.cursor_history, result.cursor_history[1:]))
|
||||
|
||||
|
||||
def test_streaming_more_than_ten_thousand_candidates_reach_strict_stage(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
capture = _capture_file(tmp_path / "many_candidates.cs8", 250_000)
|
||||
candidate_count = 10_001
|
||||
|
||||
def blocks(raw, interval, chunk_samples):
|
||||
del raw, interval, chunk_samples
|
||||
scores = np.ones(candidate_count, dtype=np.float32)
|
||||
yield 0, scores, np.arange(candidate_count), 250_000, 1_000
|
||||
|
||||
processed = 0
|
||||
|
||||
def reject(raw, sample_count, candidate, number):
|
||||
nonlocal processed
|
||||
del raw, sample_count, number
|
||||
processed += 1
|
||||
return _candidate_outcome(
|
||||
candidate,
|
||||
accepted=False,
|
||||
next_sample=candidate.sample_offset + 1,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(offline, "_score_blocks", blocks)
|
||||
coarse, strict = offline.stream_process_capture(
|
||||
capture,
|
||||
offline.CandidateRule("fixed", 0.999, 0.5),
|
||||
max_candidates=20_000,
|
||||
decoder=reject,
|
||||
)
|
||||
|
||||
assert coarse.candidate_count == candidate_count
|
||||
assert coarse.max_candidates_reached is False
|
||||
assert strict.counters.candidates_processed == candidate_count
|
||||
assert processed == candidate_count
|
||||
assert strict.outcomes_truncated is True
|
||||
|
||||
|
||||
def test_streaming_candidates_are_checked_before_next_block(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
capture = _capture_file(tmp_path / "stream_order.cs8", 500)
|
||||
events: list[str] = []
|
||||
|
||||
def blocks(raw, interval, chunk_samples):
|
||||
del raw, interval, chunk_samples
|
||||
yield 0, np.ones(2, np.float32), np.array([0, 1]), 100, 100
|
||||
assert events == ["decode:0", "decode:20"]
|
||||
events.append("second_block")
|
||||
yield 100, np.ones(1, np.float32), np.array([0]), 100, 100
|
||||
|
||||
def reject(raw, sample_count, candidate, number):
|
||||
del raw, sample_count, number
|
||||
events.append(f"decode:{candidate.sample_offset}")
|
||||
return _candidate_outcome(candidate, accepted=False, next_sample=0)
|
||||
|
||||
monkeypatch.setattr(offline, "_score_blocks", blocks)
|
||||
offline.stream_process_capture(
|
||||
capture,
|
||||
offline.CandidateRule("fixed", 0.999, 0.5),
|
||||
decoder=reject,
|
||||
)
|
||||
|
||||
assert events == ["decode:0", "decode:20", "second_block", "decode:100"]
|
||||
|
||||
|
||||
def test_streaming_accepted_frame_owns_following_local_maxima(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
capture = _capture_file(tmp_path / "owned_event.cs8", 1_000)
|
||||
|
||||
def blocks(raw, interval, chunk_samples):
|
||||
del raw, interval, chunk_samples
|
||||
scores = np.ones(31, np.float32)
|
||||
yield 0, scores, np.array([5, 6, 10, 24, 30]), 1_000, 100
|
||||
|
||||
def decoder(raw, sample_count, candidate, number):
|
||||
del raw, sample_count, number
|
||||
return _candidate_outcome(
|
||||
candidate,
|
||||
accepted=candidate.sample_offset == 100,
|
||||
next_sample=500 if candidate.sample_offset == 100 else 0,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(offline, "_score_blocks", blocks)
|
||||
coarse, strict = offline.stream_process_capture(
|
||||
capture,
|
||||
offline.CandidateRule("fixed", 0.999, 0.5),
|
||||
decoder=decoder,
|
||||
)
|
||||
|
||||
assert strict.counters.valid_frames == 1
|
||||
assert strict.counters.candidates_processed == 2
|
||||
assert coarse.candidates_skipped_as_same_local_event == 3
|
||||
|
||||
|
||||
def test_streaming_exact_overlap_duplicate_is_checked_once(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
capture = _capture_file(tmp_path / "exact_overlap.cs8", 500)
|
||||
|
||||
def blocks(raw, interval, chunk_samples):
|
||||
del raw, interval, chunk_samples
|
||||
yield 0, np.ones(6, np.float32), np.array([5]), 100, 100
|
||||
yield 100, np.ones(1, np.float32), np.array([0]), 100, 100
|
||||
|
||||
monkeypatch.setattr(offline, "_score_blocks", blocks)
|
||||
coarse, strict = offline.stream_process_capture(
|
||||
capture,
|
||||
offline.CandidateRule("fixed", 0.999, 0.5),
|
||||
decoder=lambda raw, count, candidate, number: _candidate_outcome(
|
||||
candidate,
|
||||
accepted=False,
|
||||
next_sample=0,
|
||||
),
|
||||
)
|
||||
|
||||
assert coarse.candidate_count == 1
|
||||
assert coarse.exact_duplicate_candidates_skipped == 1
|
||||
assert strict.counters.candidates_processed == 1
|
||||
|
||||
|
||||
def test_streaming_bad_candidate_cannot_hide_later_valid_frame(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
capture = _capture_file(tmp_path / "bad_then_valid.cs8", 500)
|
||||
|
||||
def blocks(raw, interval, chunk_samples):
|
||||
del raw, interval, chunk_samples
|
||||
yield 0, np.ones(3, np.float32), np.array([0, 1, 2]), 100, 100
|
||||
|
||||
def decoder(raw, sample_count, candidate, number):
|
||||
del raw, sample_count, number
|
||||
return _candidate_outcome(
|
||||
candidate,
|
||||
accepted=candidate.sample_offset == 20,
|
||||
next_sample=10_000,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(offline, "_score_blocks", blocks)
|
||||
_, strict = offline.stream_process_capture(
|
||||
capture,
|
||||
offline.CandidateRule("fixed", 0.999, 0.5),
|
||||
decoder=decoder,
|
||||
)
|
||||
|
||||
assert strict.counters.candidates_processed == 2
|
||||
assert strict.counters.valid_frames == 1
|
||||
assert strict.accepted_frames[0].refined_start_sample == 20
|
||||
|
||||
|
||||
def test_streaming_max_candidates_limits_strict_checks(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
capture = _capture_file(tmp_path / "stream_limit.cs8", 500)
|
||||
|
||||
def blocks(raw, interval, chunk_samples):
|
||||
del raw, interval, chunk_samples
|
||||
yield 0, np.ones(4, np.float32), np.arange(4), 100, 100
|
||||
|
||||
monkeypatch.setattr(offline, "_score_blocks", blocks)
|
||||
coarse, strict = offline.stream_process_capture(
|
||||
capture,
|
||||
offline.CandidateRule("fixed", 0.999, 0.5),
|
||||
max_candidates=2,
|
||||
decoder=lambda raw, count, candidate, number: _candidate_outcome(
|
||||
candidate,
|
||||
accepted=False,
|
||||
next_sample=0,
|
||||
),
|
||||
)
|
||||
|
||||
assert coarse.max_candidates_reached is True
|
||||
assert coarse.termination_reason == "MAX_CANDIDATES_REACHED"
|
||||
assert strict.counters.candidates_processed == 2
|
||||
assert strict.max_search_iterations_reached is True
|
||||
|
||||
|
||||
def test_streaming_exhausted_blocks_cover_full_capture_not_last_correlation_start(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
capture = _capture_file(tmp_path / "full_coverage.cs8", 500)
|
||||
|
||||
def blocks(raw, interval, chunk_samples):
|
||||
del raw, interval, chunk_samples
|
||||
yield 0, np.zeros(1, np.float32), np.array([], dtype=np.int64), 500, 100
|
||||
|
||||
monkeypatch.setattr(offline, "_score_blocks", blocks)
|
||||
coarse, strict = offline.stream_process_capture(
|
||||
capture,
|
||||
offline.CandidateRule("fixed", 0.999, 0.5),
|
||||
)
|
||||
|
||||
assert coarse.processed_until_sample == 500
|
||||
assert coarse.termination_reason == "END_OF_CAPTURE"
|
||||
assert strict.termination_reason == "END_OF_CAPTURE"
|
||||
|
||||
|
||||
def test_max_candidates_fails_closed(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
@@ -572,9 +777,26 @@ def test_safety_limited_independent_run_does_not_unlock_tx_reference(
|
||||
"MAX_CANDIDATES_REACHED",
|
||||
True,
|
||||
)
|
||||
strict = offline.StrictSearchResult(
|
||||
counters=offline.StrictCounters(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
|
||||
accepted_frames=(),
|
||||
outcomes=(),
|
||||
cursor_history=(0,),
|
||||
elapsed_seconds=0.0,
|
||||
average_candidate_seconds=0.0,
|
||||
maximum_local_window_samples=0,
|
||||
maximum_local_window_bytes=0,
|
||||
termination_reason="MAX_CANDIDATES_REACHED",
|
||||
max_search_iterations_reached=True,
|
||||
max_valid_frames_reached=False,
|
||||
)
|
||||
monkeypatch.setattr(offline, "calibrate_candidate_rule", lambda path: (rule, background))
|
||||
monkeypatch.setattr(offline, "evaluate_control_background", lambda path, fixed: background)
|
||||
monkeypatch.setattr(offline, "coarse_search_capture", lambda path, fixed: coarse)
|
||||
monkeypatch.setattr(
|
||||
offline,
|
||||
"stream_process_capture",
|
||||
lambda path, fixed: (coarse, strict),
|
||||
)
|
||||
result = offline.run_independent_recovery(capture)
|
||||
assert result.completed is False
|
||||
with pytest.raises(RuntimeError, match="verdict"):
|
||||
|
||||
434
tests/test_lab047_jpeg_object_transport.py
Normal file
434
tests/test_lab047_jpeg_object_transport.py
Normal file
@@ -0,0 +1,434 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
import pytest
|
||||
|
||||
from experiments import lab047_jpeg_object_transport as lab047
|
||||
|
||||
|
||||
def _test_source(path: Path) -> Path:
|
||||
y, x = np.mgrid[0:96, 0:128]
|
||||
pixels = np.stack(
|
||||
((3 * x + y) % 256, (x + 5 * y) % 256, (7 * x + 2 * y) % 256),
|
||||
axis=-1,
|
||||
).astype(np.uint8)
|
||||
Image.fromarray(pixels, mode="RGB").save(path, format="PNG")
|
||||
return path
|
||||
|
||||
|
||||
def test_fixed_jpeg_preparation_is_reproducible(tmp_path: Path) -> None:
|
||||
source = _test_source(tmp_path / "source.png")
|
||||
first = lab047.prepare_jpeg(source, tmp_path / "first.jpg")
|
||||
second = lab047.prepare_jpeg(source, tmp_path / "second.jpg")
|
||||
|
||||
assert first.sha256 == second.sha256
|
||||
assert first.file_bytes == second.file_bytes
|
||||
assert (first.width, first.height, first.mode, first.format) == (320, 240, "RGB", "JPEG")
|
||||
|
||||
|
||||
def test_selected_photo_produces_target_size_reproducibly(tmp_path: Path) -> None:
|
||||
first = lab047.prepare_jpeg(lab047.SOURCE_IMAGE_PATH, tmp_path / "first.jpg")
|
||||
second = lab047.prepare_jpeg(lab047.SOURCE_IMAGE_PATH, tmp_path / "second.jpg")
|
||||
|
||||
assert 8 * 1024 <= first.file_bytes <= 15 * 1024
|
||||
assert first.sha256 == second.sha256
|
||||
|
||||
|
||||
def test_jpeg_passes_full_software_transport(tmp_path: Path) -> None:
|
||||
source = _test_source(tmp_path / "source.png")
|
||||
jpeg_path = tmp_path / "tx.jpg"
|
||||
lab047.prepare_jpeg(source, jpeg_path)
|
||||
transmitted = jpeg_path.read_bytes()
|
||||
|
||||
result = lab047.software_roundtrip_jpeg(transmitted)
|
||||
|
||||
assert result.object_complete is True
|
||||
assert result.crc_pass == len(lab047.lab046.build_object_frames(transmitted, lab047.OBJECT_ID))
|
||||
assert result.crc_fail == 0
|
||||
assert result.sha_match is True
|
||||
assert result.byte_errors == 0
|
||||
assert result.bit_errors == 0
|
||||
assert result.recovered_bytes == transmitted
|
||||
recovered_path = tmp_path / "recovered.jpg"
|
||||
recovered_path.write_bytes(result.recovered_bytes or b"")
|
||||
assert lab047.validate_jpeg(recovered_path).format == "JPEG"
|
||||
|
||||
|
||||
def test_frame_is_not_accepted_without_crc_pass(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
data = bytes(245)
|
||||
monkeypatch.setattr(
|
||||
lab047.lab045,
|
||||
"analyze_post_preamble_soft",
|
||||
lambda soft: SimpleNamespace(
|
||||
analysis={"crc": lab047.lab045.CRC_FAIL},
|
||||
payload_bytes=bytes(lab047.lab045.PAYLOAD_SIZE_BYTES),
|
||||
),
|
||||
)
|
||||
|
||||
result = lab047.software_roundtrip_jpeg(data)
|
||||
|
||||
assert result.crc_pass == 0
|
||||
assert result.crc_fail == 2
|
||||
assert result.valid_fragments == 0
|
||||
assert result.object_complete is False
|
||||
|
||||
|
||||
def test_accounting_uses_actual_object_size() -> None:
|
||||
accounting = lab047.transport_accounting(bytes(8_724))
|
||||
|
||||
assert accounting.fragment_count == 36
|
||||
assert accounting.rf_frame_count == 36
|
||||
assert accounting.samples_per_frame == 2_306_400
|
||||
assert accounting.tx_sample_count == 83_030_400
|
||||
assert accounting.tx_file_bytes == 166_060_800
|
||||
assert accounting.estimated_airtime_seconds == pytest.approx(10.3788)
|
||||
|
||||
|
||||
def test_dynamic_threshold_is_q999_plus_four_local_peak_mad(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
scores = np.linspace(0.001, 0.02, 20, dtype=np.float32)
|
||||
peaks = np.array([0.01, 0.02, 0.03, 0.04, 0.05], dtype=np.float32)
|
||||
monkeypatch.setattr(
|
||||
lab047.offline,
|
||||
"_background_peak_scores",
|
||||
lambda capture_path, interval: (scores, peaks, 0.25),
|
||||
)
|
||||
|
||||
rule, statistics = lab047.calibrate_dynamic_candidate_rule(tmp_path / "unused.cs8")
|
||||
expected = lab047.offline.candidate_threshold_from_local_peaks(
|
||||
peaks,
|
||||
quantile=0.999,
|
||||
mad_multiplier=4.0,
|
||||
)
|
||||
|
||||
assert rule.threshold == expected
|
||||
assert rule.local_peak_quantile == 0.999
|
||||
assert statistics.interval == lab047.CALIBRATION_INTERVAL
|
||||
assert statistics.local_peak_mad == pytest.approx(0.01)
|
||||
|
||||
|
||||
def test_independent_receiver_api_has_no_tx_oracles() -> None:
|
||||
parameters = set(inspect.signature(lab047.run_independent_capture_recovery).parameters)
|
||||
|
||||
assert parameters == {"capture_path", "output_directory"}
|
||||
assert not parameters & {
|
||||
"tx_offsets",
|
||||
"expected_frame_count",
|
||||
"tx_image",
|
||||
"tx_packets",
|
||||
"tx_coded_bits",
|
||||
}
|
||||
assert all(value is False for value in lab047.receiver_oracle_declaration().values())
|
||||
reprocess_parameters = set(
|
||||
inspect.signature(lab047.run_saved_capture_streaming_recovery).parameters
|
||||
)
|
||||
assert reprocess_parameters == {
|
||||
"capture_path",
|
||||
"original_result_path",
|
||||
"output_directory",
|
||||
}
|
||||
assert "expected_frame_count" not in reprocess_parameters
|
||||
|
||||
|
||||
def test_historical_strict_threshold_and_fixed_hardware_are_unchanged() -> None:
|
||||
assert lab047.lab046b.IQ_PREAMBLE_THRESHOLD == 0.65
|
||||
assert lab047.hardware_parameters() == {
|
||||
"master_tx_serial": "000000000000000097c063dc323b97d3",
|
||||
"slave_rx_serial": "000000000000000097c063dc2e5327d3",
|
||||
"frequency_hz": 1_400_000_000,
|
||||
"sample_rate_hz": 8_000_000,
|
||||
"symbol_rate": 20_000,
|
||||
"samples_per_symbol": 400,
|
||||
"digital_carrier_hz": 50_000,
|
||||
"tx_vga_db": 10,
|
||||
"tx_rf_amp": False,
|
||||
"tx_bias_tee": False,
|
||||
"rx_lna_db": 24,
|
||||
"rx_vga_db": 24,
|
||||
"rx_rf_amp": False,
|
||||
"rx_bias_tee": False,
|
||||
"master_clkout_to_slave_clkin": True,
|
||||
"slave_clkout": False,
|
||||
"expected_reg29": 0x26,
|
||||
}
|
||||
|
||||
|
||||
def test_hardware_function_is_closed_without_new_authorization(tmp_path: Path) -> None:
|
||||
called = False
|
||||
|
||||
def runner(command: list[str]) -> subprocess.CompletedProcess[str]:
|
||||
nonlocal called
|
||||
called = True
|
||||
return subprocess.CompletedProcess(command, 0, "", "")
|
||||
|
||||
with pytest.raises(PermissionError, match="отдельного разрешения"):
|
||||
lab047.run_hardware_capture_once(tmp_path, runner=runner)
|
||||
assert called is False
|
||||
|
||||
|
||||
def test_hardware_cli_requires_all_three_keys(tmp_path: Path) -> None:
|
||||
script = Path(lab047.__file__).resolve()
|
||||
completed = subprocess.run(
|
||||
[sys.executable, str(script), "--hardware", "--output-directory", str(tmp_path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert completed.returncode == 2
|
||||
assert "RF TX = 0" in completed.stdout
|
||||
|
||||
|
||||
def test_tx_reference_diagnostics_require_saved_complete_verdict(tmp_path: Path) -> None:
|
||||
verdict_path = tmp_path / "independent_result.json"
|
||||
verdict_path.write_text(
|
||||
json.dumps({"completed": True, "object_verdict": {"object_complete": False}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="не разрешает"):
|
||||
lab047.diagnose_jpeg_after_independent_verdict(
|
||||
verdict_path,
|
||||
tmp_path / "recovered.jpg",
|
||||
tmp_path / "tx.jpg",
|
||||
)
|
||||
|
||||
|
||||
def _original_incomplete_result(capture_path: Path) -> dict:
|
||||
background_common = {
|
||||
"correlation_score_count": 100,
|
||||
"local_peak_count": 10,
|
||||
"correlation_median": 0.006,
|
||||
"correlation_mad": 0.002,
|
||||
"local_peak_quantiles": {
|
||||
"0.99": 0.018,
|
||||
"0.999": 0.020810436457395554,
|
||||
"1.0": 0.025,
|
||||
},
|
||||
"candidate_count": 0,
|
||||
"candidates_per_second": 0.0,
|
||||
"elapsed_seconds": 0.1,
|
||||
"local_peak_median": 0.008,
|
||||
"local_peak_mad": 0.002538509201258421,
|
||||
}
|
||||
return {
|
||||
"completed": False,
|
||||
"capture_path": str(capture_path),
|
||||
"rule": {
|
||||
"method": "pre-TX local-peak Q99.9 + 4 * local-peak MAD",
|
||||
"local_peak_quantile": 0.999,
|
||||
"threshold": lab047.FIXED_HARDWARE_CANDIDATE_THRESHOLD,
|
||||
},
|
||||
"calibration": {
|
||||
**background_common,
|
||||
"interval": {
|
||||
"name": "PRE_TX_BACKGROUND_LAB047",
|
||||
"start_sample": 0,
|
||||
"end_sample": 4_000_000,
|
||||
},
|
||||
},
|
||||
"control": {
|
||||
**background_common,
|
||||
"interval": {
|
||||
"name": "POST_TX_BACKGROUND_LAB047",
|
||||
"start_sample": 96_000_000,
|
||||
"end_sample": 120_000_000,
|
||||
},
|
||||
},
|
||||
"coarse": {
|
||||
"termination_reason": "MAX_CANDIDATES_REACHED",
|
||||
"max_candidates_reached": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_saved_hardware_threshold_is_loaded_without_change(tmp_path: Path) -> None:
|
||||
capture_path = tmp_path / "capture.cs8"
|
||||
capture_path.write_bytes(bytes(20))
|
||||
result_path = tmp_path / "independent_result.json"
|
||||
result_path.write_text(
|
||||
json.dumps(_original_incomplete_result(capture_path)),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
rule, calibration, control = lab047.load_fixed_hardware_candidate_rule(
|
||||
capture_path,
|
||||
result_path,
|
||||
)
|
||||
|
||||
assert rule.threshold == 0.030964473262429237
|
||||
assert calibration.interval == lab047.CALIBRATION_INTERVAL
|
||||
assert control.interval == lab047.CONTROL_INTERVAL
|
||||
|
||||
|
||||
def test_changed_saved_hardware_threshold_is_rejected(tmp_path: Path) -> None:
|
||||
capture_path = tmp_path / "capture.cs8"
|
||||
capture_path.write_bytes(bytes(20))
|
||||
value = _original_incomplete_result(capture_path)
|
||||
value["rule"]["threshold"] = 0.031
|
||||
result_path = tmp_path / "independent_result.json"
|
||||
result_path.write_text(json.dumps(value), encoding="utf-8")
|
||||
|
||||
with pytest.raises(RuntimeError, match="threshold был изменён"):
|
||||
lab047.load_fixed_hardware_candidate_rule(capture_path, result_path)
|
||||
|
||||
|
||||
def test_streaming_limit_is_not_derived_from_expected_jpeg_frames() -> None:
|
||||
assert lab047.offline.MAX_CANDIDATES == 100_000
|
||||
source = inspect.getsource(lab047.offline.stream_process_capture)
|
||||
assert "36" not in source
|
||||
assert "expected_frame_count" not in source
|
||||
|
||||
|
||||
def test_reprocessing_persists_verdict_before_tx_diagnostics() -> None:
|
||||
source = inspect.getsource(lab047.main)
|
||||
independent_position = source.index("run_saved_capture_streaming_recovery")
|
||||
diagnostic_position = source.index("diagnose_hardware_jpeg_after_independent_verdict")
|
||||
assert independent_position < diagnostic_position
|
||||
|
||||
|
||||
def test_completion_accounting_can_be_corrected_without_decoder_rerun(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
capture_path = tmp_path / "capture.cs8"
|
||||
capture_path.write_bytes(bytes(1_000))
|
||||
original_path = tmp_path / "reprocess.json"
|
||||
original = {
|
||||
"completed": False,
|
||||
"capture_path": str(capture_path),
|
||||
"capture_samples": 500,
|
||||
"rule": {"threshold": lab047.FIXED_HARDWARE_CANDIDATE_THRESHOLD},
|
||||
"coarse": {
|
||||
"termination_reason": "END_OF_CAPTURE",
|
||||
"max_candidates_reached": False,
|
||||
"blocks_processed": 1,
|
||||
"processed_until_sample": 400,
|
||||
},
|
||||
"strict": {
|
||||
"termination_reason": "END_OF_CAPTURE",
|
||||
"max_search_iterations_reached": False,
|
||||
"max_valid_frames_reached": False,
|
||||
},
|
||||
}
|
||||
original_path.write_text(json.dumps(original), encoding="utf-8")
|
||||
corrected_path = tmp_path / "corrected.json"
|
||||
|
||||
corrected = lab047.correct_saved_streaming_completion(
|
||||
original_path,
|
||||
corrected_path,
|
||||
capture_path,
|
||||
)
|
||||
|
||||
assert corrected["completed"] is True
|
||||
assert corrected["coarse"]["processed_until_sample"] == 500
|
||||
assert corrected["completion_metadata_correction"]["full_decoder_rerun"] is False
|
||||
assert json.loads(original_path.read_text(encoding="utf-8"))["completed"] is False
|
||||
|
||||
|
||||
def test_completion_accounting_refuses_different_capture_with_same_size(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
saved_capture = tmp_path / "saved_capture.cs8"
|
||||
other_capture = tmp_path / "other_capture.cs8"
|
||||
saved_capture.write_bytes(bytes(1_000))
|
||||
other_capture.write_bytes(bytes([1]) * 1_000)
|
||||
original_path = tmp_path / "reprocess.json"
|
||||
original_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"completed": False,
|
||||
"capture_path": str(saved_capture),
|
||||
"capture_samples": 500,
|
||||
"rule": {"threshold": lab047.FIXED_HARDWARE_CANDIDATE_THRESHOLD},
|
||||
"coarse": {
|
||||
"termination_reason": "END_OF_CAPTURE",
|
||||
"max_candidates_reached": False,
|
||||
"blocks_processed": 1,
|
||||
"processed_until_sample": 400,
|
||||
},
|
||||
"strict": {
|
||||
"termination_reason": "END_OF_CAPTURE",
|
||||
"max_search_iterations_reached": False,
|
||||
"max_valid_frames_reached": False,
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
corrected_path = tmp_path / "corrected.json"
|
||||
|
||||
with pytest.raises(RuntimeError, match="не доказывает полное покрытие"):
|
||||
lab047.correct_saved_streaming_completion(
|
||||
original_path,
|
||||
corrected_path,
|
||||
other_capture,
|
||||
)
|
||||
|
||||
assert not corrected_path.exists()
|
||||
|
||||
|
||||
def test_finalize_mode_corrects_result_before_opening_tx_reference() -> None:
|
||||
source = inspect.getsource(lab047.main)
|
||||
correction_position = source.index("correct_saved_streaming_completion")
|
||||
diagnostic_position = source.index("diagnose_saved_reprocess_after_corrected_verdict")
|
||||
assert correction_position < diagnostic_position
|
||||
|
||||
|
||||
def test_saved_diagnostics_refuse_changed_threshold_before_capture_access(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
corrected_path = tmp_path / "corrected.json"
|
||||
corrected_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"completed": True,
|
||||
"rule": {"threshold": lab047.FIXED_HARDWARE_CANDIDATE_THRESHOLD},
|
||||
"object_verdict": {"object_complete": True},
|
||||
"completion_metadata_correction": {
|
||||
"full_decoder_rerun": False,
|
||||
"threshold_changed": True,
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def fail_if_capture_is_touched(*_args: object, **_kwargs: object) -> None:
|
||||
raise AssertionError("RX-запись не должна открываться")
|
||||
|
||||
monkeypatch.setattr(lab047.offline, "_validate_capture", fail_if_capture_is_touched)
|
||||
with pytest.raises(RuntimeError, match="Исправленный полный verdict не подтверждён"):
|
||||
lab047.diagnose_saved_reprocess_after_corrected_verdict(
|
||||
corrected_path,
|
||||
tmp_path / "capture.cs8",
|
||||
tmp_path / "recovered.jpg",
|
||||
tmp_path / "tx.jpg",
|
||||
)
|
||||
|
||||
|
||||
def test_hardware_diagnostics_refuse_incomplete_in_memory_verdict(tmp_path: Path) -> None:
|
||||
independent = object.__new__(lab047.offline.IndependentRecoveryResult)
|
||||
object.__setattr__(independent, "completed", False)
|
||||
with pytest.raises(RuntimeError, match="не открывает TX reference"):
|
||||
lab047.diagnose_hardware_jpeg_after_independent_verdict(
|
||||
independent,
|
||||
tmp_path / "tx.jpg",
|
||||
tmp_path / "recovered.jpg",
|
||||
)
|
||||
|
||||
|
||||
def test_functional_checks() -> None:
|
||||
lab047.run_functional_tests()
|
||||
Reference in New Issue
Block a user