Fix repeat ownership and close out capture evidence
Start each repeat after its own manifest entry, refuse packets from a restarted or stopped camera, keep a simulated repeat from reading as recorded evidence, and decide completeness from the writer report. Add the operator summary (four files plus an atomic pointer) and read-only video review of a hold window. Verified with the full Autocal suite (567 passed), the shared recorder/playback files (57 passed), the lint gate and the documentation gate; the two pre-existing panel_check layout failures also fail on a clean HEAD worktree.
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
"""Publishing a summary: one set of files, one pointer, never a partial result."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from evidence_helpers import multipoint_session
|
||||
from orisys_autocal.summary import build_summary
|
||||
from orisys_autocal.summary_export import (
|
||||
POINTER_FORMAT,
|
||||
POINTER_NAME,
|
||||
SummaryExportError,
|
||||
load_summary,
|
||||
publish_summary,
|
||||
render_html,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session(tmp_path):
|
||||
root = multipoint_session(
|
||||
tmp_path / "rec", sites=("P1", "P2"), repeats=2, levels=(5.0, 10.0))
|
||||
return root
|
||||
|
||||
|
||||
def _pointer(root):
|
||||
return json.loads((root / POINTER_NAME).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _read_csv(path):
|
||||
with path.open(encoding="utf-8-sig", newline="") as handle:
|
||||
return list(csv.DictReader(handle))
|
||||
|
||||
|
||||
def test_publishing_writes_all_four_files_and_moves_the_pointer(session):
|
||||
summary = build_summary(session)
|
||||
|
||||
published = publish_summary(session, summary)
|
||||
|
||||
assert published.is_dir()
|
||||
assert published.name == f"summary_{summary['summary_id']}"
|
||||
for name in ("summary.json", "summary.html", "baseline_load_data.csv",
|
||||
"sensitivity_values.csv"):
|
||||
assert (published / name).is_file(), name
|
||||
pointer = _pointer(session)
|
||||
assert pointer["format_version"] == POINTER_FORMAT
|
||||
assert pointer["summary_id"] == summary["summary_id"]
|
||||
assert pointer["path"] == f"derived/{published.name}"
|
||||
assert set(pointer["files"]) == {
|
||||
"summary.json", "summary.html", "baseline_load_data.csv", "sensitivity_values.csv"}
|
||||
loaded, pointer_again = load_summary(session)
|
||||
assert loaded["summary_id"] == summary["summary_id"]
|
||||
assert pointer_again["summary_id"] == summary["summary_id"]
|
||||
# The export is a snapshot of the same object, not a second computation.
|
||||
stored = json.loads((published / "summary.json").read_text(encoding="utf-8"))
|
||||
assert stored["windows"] == summary["windows"]
|
||||
assert stored["sensitivity"] == summary["sensitivity"]
|
||||
|
||||
|
||||
def test_the_html_is_self_contained_and_escapes_operator_text(session):
|
||||
summary = build_summary(session)
|
||||
summary = dict(summary)
|
||||
summary["sensor_sn"] = "<script>alert('x')</script>"
|
||||
|
||||
document = render_html(summary)
|
||||
|
||||
assert "<script>" not in document
|
||||
assert "<script>" in document
|
||||
assert "http://" not in document and "https://" not in document
|
||||
assert "not a pass/fail certificate" in document
|
||||
|
||||
|
||||
def test_a_write_failure_leaves_the_previous_pointer_in_place(session, monkeypatch):
|
||||
import orisys_autocal.summary_export as module
|
||||
|
||||
first = build_summary(session)
|
||||
publish_summary(session, first)
|
||||
before = (session / POINTER_NAME).read_text(encoding="utf-8")
|
||||
|
||||
second = build_summary(session)
|
||||
second["generated_at"] = "2999-01-01T00:00:00+00:00"
|
||||
|
||||
def explode(*args, **kwargs):
|
||||
raise OSError("no space left on device")
|
||||
|
||||
monkeypatch.setattr(module, "_write_csv", explode)
|
||||
with pytest.raises(SummaryExportError, match="could not publish"):
|
||||
publish_summary(session, second)
|
||||
|
||||
assert (session / POINTER_NAME).read_text(encoding="utf-8") == before
|
||||
assert not (session / "derived" / f"summary_{second['summary_id']}").exists()
|
||||
leftovers = [path for path in (session / "derived").iterdir() if path.name.startswith(".tmp-")]
|
||||
assert leftovers == []
|
||||
|
||||
|
||||
def test_the_pointer_never_moves_back_to_an_older_summary(session):
|
||||
first = build_summary(session)
|
||||
first["generated_at"] = "2026-10-18T00:00:00+00:00"
|
||||
publish_summary(session, first)
|
||||
older = build_summary(session)
|
||||
older["generated_at"] = "2026-10-17T00:00:00+00:00"
|
||||
|
||||
with pytest.raises(SummaryExportError, match="back"):
|
||||
publish_summary(session, older)
|
||||
|
||||
assert _pointer(session)["summary_id"] == first["summary_id"]
|
||||
|
||||
|
||||
def test_changed_inputs_refuse_to_publish(session):
|
||||
summary = build_summary(session)
|
||||
forces = session / "sites" / "P1" / "repeat-001" / "capture" / "forces.csv"
|
||||
text = forces.read_text(encoding="utf-8-sig")
|
||||
forces.write_text(text + "\n", encoding="utf-8")
|
||||
|
||||
with pytest.raises(SummaryExportError, match="changed"):
|
||||
publish_summary(session, summary)
|
||||
assert not (session / POINTER_NAME).exists()
|
||||
|
||||
|
||||
def test_a_cancelled_export_writes_nothing(session):
|
||||
summary = build_summary(session)
|
||||
with pytest.raises(SummaryExportError, match="cancelled"):
|
||||
publish_summary(session, summary, cancelled=lambda: True)
|
||||
assert not (session / POINTER_NAME).exists()
|
||||
assert not (session / "derived").exists()
|
||||
|
||||
|
||||
def test_the_same_summary_is_not_published_twice(session):
|
||||
summary = build_summary(session)
|
||||
publish_summary(session, summary)
|
||||
with pytest.raises(SummaryExportError, match="already published"):
|
||||
publish_summary(session, summary)
|
||||
|
||||
|
||||
def test_load_refuses_a_tampered_export(session):
|
||||
summary = build_summary(session)
|
||||
published = publish_summary(session, summary)
|
||||
(published / "sensitivity_values.csv").write_text("changed", encoding="utf-8")
|
||||
|
||||
with pytest.raises(SummaryExportError, match="modified"):
|
||||
load_summary(session)
|
||||
|
||||
|
||||
def test_the_csv_keeps_full_precision_and_utf8_text(session):
|
||||
summary = build_summary(session)
|
||||
summary["windows"][1]["FN_mean"] = 40.1234567890123
|
||||
summary["windows"][1]["site_id"] = "测点一"
|
||||
|
||||
published = publish_summary(session, summary)
|
||||
|
||||
rows = _read_csv(published / "baseline_load_data.csv")
|
||||
assert rows[1]["FN_mean"] == repr(40.1234567890123)
|
||||
assert float(rows[1]["FN_mean"]) == pytest.approx(40.1234567890123)
|
||||
assert rows[1]["site_id"] == "测点一"
|
||||
assert rows[1]["unit"] == "raw"
|
||||
assert rows[1]["window_method"] == "hold_second_half_v1"
|
||||
assert rows[1]["eligible"] == "1"
|
||||
assert rows[1]["samples_FN"] == "2"
|
||||
|
||||
|
||||
def test_the_sensitivity_csv_has_one_row_per_point_trial_axis(session):
|
||||
summary = build_summary(session)
|
||||
published = publish_summary(session, summary)
|
||||
|
||||
rows = _read_csv(published / "sensitivity_values.csv")
|
||||
|
||||
assert len(rows) == len(summary["sensitivity"]) == 2 * 2 * 3
|
||||
assert {row["component"] for row in rows} == {"FN", "FX", "FY"}
|
||||
assert {row["method"] for row in rows} == {"raw_on_reference_ols_v1"}
|
||||
assert {row["review_status"] for row in rows} == {"candidate"}
|
||||
fn = [row for row in rows if row["component"] == "FN"]
|
||||
assert all(float(row["slope_raw_per_N"]) == pytest.approx(40.0) for row in fn)
|
||||
Reference in New Issue
Block a user