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:
ykcheungab
2026-10-17 10:44:30 +08:00
parent d7540f52ec
commit 19426c3c4a
32 changed files with 5461 additions and 191 deletions
+175
View File
@@ -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 "&lt;script&gt;" 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)