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 "