import json
import pytest
from jsonschema import Draft202012Validator
from importlib.resources import files
from viano_bug_analysis.case import CaseBuilder, CaseAssembler, CaseBuilderLimits, CaseBuildError, render_summary
from viano_bug_analysis.lifecycle import CheckpointStore, LifecycleError, State, Workspace
from viano_bug_analysis.package import PackageReader, PackageValidator, PackageWriter
@pytest.fixture
def case_setup(manifest, make_package, tmp_path):
manifest["artifacts"]["case_inputs"] = "arbitrary/index.data"
report = {"format": "viano-bug-report", "format_version": 1, "title": "Generic problem", "summary": "Reported behavior",
"observations": [{"text": "The switch remains off", "author": "Tester A", "observed_at": "2026-01-01T01:00:00Z"}],
"comments": [{"text": "Seen twice"}], "tester_hypotheses": [{"text": "Perhaps a timing issue"}],
"expected_behavior": [{"text": "The switch turns on"}], "expected_behavior_complete": True,
"entities": [{"type": "component", "id": 42, "roles": ["focus"]}]}
reproduction = {"format": "viano-reproduction", "format_version": 1, "steps": ["Start", "Press switch"],
"observations": [{"text": "Still off"}], "events_ref": "raw/events", "attachments": ["raw/binary"],
"facts": [{"name": "attempt", "value": 2}], "entities": [{"type": "component", "id": "42", "roles": ["observed"]}]}
index = {"format": "viano-case-inputs", "format_version": 1, "reports": ["anything/report.data"],
"reproductions": ["anything/repro.data"], "context": {"source": ["raw/source"], "database": ["raw/db"], "runtime": ["raw/runtime"], "research": ["raw/research"]}}
entries = {"arbitrary/index.data": json.dumps(index), "anything/report.data": json.dumps(report), "anything/repro.data": json.dumps(reproduction),
"raw/events": b"many events", "raw/binary": b"\x00\xff", "raw/source": b"source raw", "raw/db": b"db raw", "raw/runtime": b"runtime raw", "raw/research": b"supplied external notes", "unknown/vendor": b"opaque"}
source = make_package(entries=entries)
store = CheckpointStore(Workspace(tmp_path / "service"))
store.intake(source)
return store, CaseBuilder(store), source, entries, report, index
@pytest.mark.parametrize("contract", ["case", "case-inputs", "report", "reproduction"])
def test_contract_schemas_valid(contract):
schema = json.loads(files("viano_bug_analysis.schemas").joinpath(f"{contract}-v1.schema.json").read_text())
Draft202012Validator.check_schema(schema)
def test_full_build_preserves_separation_and_provenance(case_setup):
store, builder, source, entries, _, _ = case_setup
original = source.read_bytes()
result = builder.build("case-A", "stable-package")
assert not result.reused
assert result.analysis_revision == 1
assert PackageValidator().validate(result.package).valid
assert source.read_bytes() == original
with PackageReader(result.package) as reader:
manifest = reader.manifest
assert manifest["state"] == State.BUILDING_CASE
assert manifest["package_generation"] == 3
case = json.loads(reader.read_bytes(result.case_ref))
assert case["case"]["source_generation"] == 2
assert case["human_observations"]["observations"][0]["text"] == "The switch remains off"
assert len(case["human_observations"]["observations"]) == 2
assert case["human_observations"]["tester_hypotheses"][0]["text"] == "Perhaps a timing issue"
assert case["expected_behavior"]["completeness"] == "reported_complete"
assert case["human_observations"]["observations"][0]["provenance"] == {"kind": "tester", "source_ref": "anything/report.data", "json_pointer": "/observations/0", "author": "Tester A", "observed_at": "2026-01-01T01:00:00Z"}
assert case["runtime_facts"]["facts"][0]["id"] == "F0001"
assert case["runtime_facts"]["facts"][0]["provenance"]["json_pointer"] == "/facts/0"
assert case["entities"][0]["id"] == "42"
assert case["entities"][0]["roles"] == ["focus", "observed"]
assert len(case["entities"][0]["provenance"]) == 2
assert case["research"]["performed"] is False
assert [item["path"] for item in case["inputs"]["unclassified"]] == ["unknown/vendor"]
assert reader.read_bytes(result.summary_ref).decode() == render_summary(case)
for path, value in entries.items():
assert reader.read_bytes(path) == (value.encode() if isinstance(value, str) else value)
def test_completed_build_is_idempotent(case_setup):
store, builder, _, _, _, _ = case_setup
first = builder.build("case-A", "stable-package")
again = CaseBuilder(CheckpointStore(store.workspace)).build("case-A", "stable-package")
assert again.reused and again.package == first.package
assert len(list(first.package.parent.glob("g*.zip"))) == 3
def test_missing_index_marks_gaps_without_guessing(make_package, tmp_path):
store = CheckpointStore(Workspace(tmp_path / "service"))
store.intake(make_package(entries={"original/bug_report.json": b"not a declared report"}))
result = CaseBuilder(store).build("case-A", "stable-package")
with PackageReader(result.package) as reader:
case = json.loads(reader.read_bytes(result.case_ref))
codes = {issue["code"] for issue in case["data_quality"]["issues"]}
assert {"MISSING_INPUT_INDEX", "MISSING_REPORT", "MISSING_OBSERVATIONS", "MISSING_REPRODUCTION", "EXPECTED_BEHAVIOR_MISSING"} <= codes
assert case["bug"]["reports"] == []
assert case["inputs"]["unclassified"][0]["path"] == "original/bug_report.json"
def rewrite_input(case_setup, tmp_path, changes, updates=None):
store = case_setup[0]
current = store.latest("case-A", "stable-package")
incoming = tmp_path / "updated-input"
PackageWriter().write(current, incoming, additions={name: json.dumps(data).encode() for name, data in changes.items()}, manifest_updates=updates)
store.intake(incoming)
return current
def test_reopen_inherits_previous_reports_and_facts(case_setup, tmp_path):
store, builder, _, _, _, _ = case_setup
first = builder.build("case-A", "stable-package")
for index, target in enumerate([State.INTERPRETING_CASE, State.GENERATING_HYPOTHESES, State.INVESTIGATING_HYPOTHESES, State.BUILDING_DIAGNOSIS, State.OUTPUT_READY, State.HANDED_OFF, State.REOPENED]):
store.advance("case-A", "stable-package", target, step_id=f"test-stage-{index}")
report = {"format": "viano-bug-report", "format_version": 1, "observations": [{"text": "New feedback"}]}
index = {"format": "viano-case-inputs", "format_version": 1, "reports": ["feedback/new-report"]}
rewrite_input(case_setup, tmp_path, {"feedback/new-report": report, "inputs/new-index": index}, {"artifacts": {"case_inputs": "inputs/new-index"}})
result = builder.build("case-A", "stable-package")
assert result.analysis_revision == 2
with PackageReader(result.package) as reader:
new = json.loads(reader.read_bytes(result.case_ref))
old = json.loads(reader.read_bytes(first.case_ref))
assert new["human_observations"]["observations"][:2] == old["human_observations"]["observations"]
assert new["human_observations"]["observations"][-1]["text"] == "New feedback"
assert new["runtime_facts"]["facts"] == old["runtime_facts"]["facts"]
assert new["inputs"]["prior_cases"][0]["path"] == first.case_ref
assert reader.read_bytes(first.summary_ref) == render_summary(old).encode()
def test_new_data_after_completion_requires_reopen(case_setup, tmp_path):
_, builder, _, _, _, _ = case_setup
builder.build("case-A", "stable-package")
rewrite_input(case_setup, tmp_path, {"feedback/new": {"anything": "new"}})
with pytest.raises(CaseBuildError) as error:
builder.build("case-A", "stable-package")
assert error.value.code == "STALE_CASE"
def test_completion_crash_reuses_output(case_setup, monkeypatch):
store, builder, _, _, _, _ = case_setup
original_index = store._index
calls = 0
def crash(*args):
nonlocal calls
calls += 1
if calls == 2:
raise LifecycleError("SIMULATED_CRASH", "after case publication")
original_index(*args)
monkeypatch.setattr(store, "_index", crash)
with pytest.raises(CaseBuildError):
builder.build("case-A", "stable-package")
result = CaseBuilder(CheckpointStore(store.workspace)).build("case-A", "stable-package")
assert result.reused
assert len(list(result.package.parent.glob("g*.zip"))) == 3
def test_unconfirmed_build_retries_without_duplicate_start(case_setup, monkeypatch):
store, builder, _, _, _, _ = case_setup
original_write = store.writer.write
calls = 0
def crash(*args, **kwargs):
nonlocal calls
calls += 1
if calls == 2:
raise LifecycleError("SIMULATED_CRASH", "before case publication")
return original_write(*args, **kwargs)
monkeypatch.setattr(store.writer, "write", crash)
with pytest.raises(CaseBuildError):
builder.build("case-A", "stable-package")
result = CaseBuilder(CheckpointStore(store.workspace)).build("case-A", "stable-package")
assert not result.reused
assert len(list(result.package.parent.glob("g*.zip"))) == 3
def test_portable_completed_case(case_setup, tmp_path):
_, builder, _, _, _, _ = case_setup
first = builder.build("case-A", "stable-package")
other = CheckpointStore(Workspace(tmp_path / "other-service"))
other.intake(first.package)
result = CaseBuilder(other).build("case-A", "stable-package")
assert result.reused
assert result.package == other.latest("case-A", "stable-package")
def test_summary_deterministic_and_escapes_untrusted_text(case_setup):
_, builder, _, _, _, _ = case_setup
result = builder.build("case-A", "stable-package")
with PackageReader(result.package) as reader:
case = json.loads(reader.read_bytes(result.case_ref))
case["human_observations"]["observations"][0]["text"] = " [link](javascript:bad)\n# fake heading"
summary = render_summary(case)
assert summary == render_summary(case)
assert "