Fix business compare history, search, sampling and exports

This commit is contained in:
oliver 2026-10-10 23:20:50 +08:00
parent afa8e06b2d
commit 4d5101ec3a
19 changed files with 1598 additions and 274 deletions

View file

@ -0,0 +1,87 @@
"""Export must page lazily, keep tied sequence rows, and produce valid UTF-8 CSV."""
from __future__ import annotations
import csv
import io
import unittest
import zipfile
from unittest.mock import patch
from sqlalchemy import create_engine, event
from sqlalchemy.orm import Session
from netx_api.biz_state.compare_service import _iter_sheet_diffs, export_run_zip
from netx_api.db import Base
from netx_api.models import BizCompareDiff, BizCompareRun
class CompareExportTests(unittest.TestCase):
def setUp(self):
self.engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(self.engine)
self.db = Session(self.engine)
self.sheet = {"sheet_id": "sheet", "metric_id": "test", "key_fields": ["id"],
"compare_fields": ["note"], "display_fields": ["id", "note"],
"summary": {"unchanged": 7}, "status": "done"}
self.run = BizCompareRun(id="run", metric_id="test", status="success",
summary_json={"sheets": [self.sheet]})
self.db.add(self.run)
for seq, ids in ((0, "cba"), (1, "ed"), (2, "gf")):
for rid in ids:
note = '中文, "quoted"\nsecond line'
self.db.add(BizCompareDiff(id=rid, run_id="run", metric_id="sheet", seq=seq,
kind="unchanged", key_json={"id": rid},
before_json={"id": rid, "note": note},
after_json={"id": rid, "note": note}))
self.db.commit()
def tearDown(self):
self.db.close()
self.engine.dispose()
def test_cursor_handles_tied_sequence_numbers_across_chunks(self):
statements = []
def record(_conn, _cursor, statement, _parameters, _context, _executemany):
if "ORDER BY biz_compare_diff.seq" in statement:
statements.append(statement)
event.listen(self.engine, "before_cursor_execute", record)
with patch("netx_api.biz_state.compare_service._DIFF_CHUNK", 2):
iterator = _iter_sheet_diffs(self.db, "run", "sheet")
self.assertEqual(statements, [])
self.assertEqual(next(iterator)["key"]["id"], "a")
self.assertEqual(next(iterator)["key"]["id"], "b")
self.assertEqual(len(statements), 1, "do not load the next chunk early")
self.assertEqual([d["key"]["id"] for d in iterator], list("cdefg"))
self.assertEqual(len(statements), 4)
self.assertTrue(all("biz_compare_diff.seq >" in q and "biz_compare_diff.id >" in q
for q in statements[1:]))
def test_export_csv_escapes_unicode_quotes_commas_and_newlines(self):
with patch("netx_api.biz_state.compare_service._DIFF_CHUNK", 2):
archive_bytes = export_run_zip(self.db, "run")
with zipfile.ZipFile(io.BytesIO(archive_bytes)) as archive:
payload = archive.read("tables/sheet.csv")
self.assertTrue(payload.startswith(b"\xef\xbb\xbf"))
rows = list(csv.DictReader(io.StringIO(payload.decode("utf-8-sig"))))
self.assertEqual([r["id"] for r in rows], list("abcdefg"))
self.assertTrue(all(r["note__pre"] == '中文, "quoted"\nsecond line' for r in rows))
self.assertTrue(all(r["note__pre"] == r["note__post"] for r in rows))
def test_other_sheets_do_not_leak_into_export(self):
self.db.add(BizCompareDiff(id="other", run_id="run", metric_id="another", seq=0,
kind="added", key_json={"id": "other"}, after_json={"note": "other"}))
self.db.commit()
with patch("netx_api.biz_state.compare_service._DIFF_CHUNK", 2):
self.assertEqual([d["key"]["id"] for d in _iter_sheet_diffs(self.db, "run", "sheet")], list("abcdefg"))
def test_legacy_inline_and_empty_exports(self):
self.db.query(BizCompareDiff).delete()
inline = {"kind": "added", "key": {"id": "legacy"}, "after": {"note": "value"}}
self.run.summary_json = {"sheets": [{**self.sheet, "diffs": [inline]}]}
self.db.commit()
self.assertEqual(list(_iter_sheet_diffs(self.db, "run", "sheet")), [inline])
self.run.summary_json = {"sheets": [self.sheet]}
self.db.commit()
with zipfile.ZipFile(io.BytesIO(export_run_zip(self.db, "run"))) as archive:
self.assertEqual(archive.read("tables/sheet.csv").decode("utf-8-sig"), "kind,id,note__pre,note__post\n")

View file

@ -0,0 +1,103 @@
"""Bounded sampling regressions and an opt-in comparison memory benchmark."""
from __future__ import annotations
import gc
import hashlib
import json
import os
import random
import subprocess
import time
import tracemalloc
import types
from collections import defaultdict, deque
from copy import deepcopy
import pytest
from netx_api.biz_state.compare_engine import _StratifiedSample, compare_rows, stratify_take
def reference_sample(items, limit):
if len(items) <= limit:
return list(items)
buckets = defaultdict(deque)
for item in items:
buckets[item[0]].append(item)
out = []
while buckets and len(out) < limit:
for key in list(buckets):
out.append(buckets[key].popleft())
if not buckets[key]:
del buckets[key]
if len(out) >= limit:
break
return out
def test_streaming_sample_preserves_round_robin_and_small_input_order():
rng = random.Random(42)
for _ in range(100):
items = [(str(rng.randrange(20)), i) for i in range(rng.randrange(100))]
for limit in (0, 1, 5, 20, 100):
expected = reference_sample(items, limit)
assert stratify_take(iter(items), limit, key_fn=lambda item: item[0]) == expected
def test_sample_storage_is_bounded_for_both_many_rows_and_many_strata():
sample = _StratifiedSample(50)
for i in range(20_000):
sample.add(i, str(i) if i >= 10_000 else "one-large-peer")
assert len(sample.heap) == 50
assert len(sample.strata) == 50
assert sample.picked() == [0, *range(10_000, 10_049)]
@pytest.mark.parametrize("iface_fields,rules,pmap", [
([], [], {}),
(["interface"], [], {}),
(["interface"], [{"from": "GE", "to": "gei"}], {"gei-1": "gei-2"}),
])
def test_compare_does_not_mutate_inputs_when_reusing_rows(iface_fields, rules, pmap):
before = [{"id": "1", "interface": "GE-1", "state": "up"}]
after = [{"id": "1", "interface": "GE-2", "state": "up"}]
original = deepcopy((before, after))
compare_rows(before_rows=before, after_rows=after, key_fields=["id"],
iface_fields=iface_fields, compare_fields=["state"], port_map=pmap,
iface_normalize_rules=rules, include_unchanged=True, unchanged_limit=1)
assert (before, after) == original
def test_memory_benchmark_against_git_baseline():
"""Run explicitly with NETX_BENCH_ROWS=1000000; not part of routine CI."""
n = int(os.environ.get("NETX_BENCH_ROWS", "0"))
if not n:
pytest.skip("opt-in large synthetic benchmark")
baseline_ref = os.environ.get("NETX_BENCH_REF", "HEAD")
source = subprocess.run(["git", "show", f"{baseline_ref}:netx_api/biz_state/compare_engine.py"],
check=True, capture_output=True, text=True, encoding="utf-8").stdout
baseline = types.ModuleType("netx_api.biz_state._benchmark_baseline")
baseline.__package__ = "netx_api.biz_state"
exec(compile(source, "baseline_compare_engine.py", "exec"), baseline.__dict__)
before = [{"id": str(i), "neighbor": str(i % 50), "state": "up", "afi": "vpnv4"} for i in range(n)]
after = [dict(row) for row in before]
records = []
digests = []
for name, compare in (("baseline", baseline.compare_rows), ("optimized", compare_rows)):
gc.collect()
tracemalloc.start()
started = time.perf_counter()
result = compare(before_rows=before, after_rows=after, key_fields=["id"], iface_fields=[],
compare_fields=["state"], include_unchanged=True, unchanged_limit=5000,
compact_unchanged=True)
elapsed = time.perf_counter() - started
_, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
digests.append(hashlib.sha256(json.dumps(result, sort_keys=True).encode()).hexdigest())
records.append({"engine": name, "rows_per_side": n, "peak_engine_mib": round(peak / 2**20, 2),
"seconds_with_tracemalloc": round(elapsed, 3)})
del result
assert digests[0] == digests[1], "large-sheet results and sample order must match"
print("\n" + json.dumps({"baseline_ref": baseline_ref, "input_allocations_included": False,
"results": records}, indent=2))

View file

@ -0,0 +1,186 @@
"""Regression tests: searching a pair must not change its comparison verdict."""
from __future__ import annotations
import unittest
from unittest.mock import patch
from fastapi import HTTPException
from sqlalchemy import create_engine
from sqlalchemy.dialects import postgresql
from sqlalchemy.orm import Session
from netx_api.biz_state.compare_engine import compare_rows
from netx_api.biz_state.compare_service import (
_load_metric_rows_for_search,
_literal_search_pattern,
_persist_sheet_diffs,
_search_stored_diffs,
list_run_diffs,
)
from netx_api.db import Base
from netx_api.models import (
BizCompareDiff, BizCompareRun, BizCompareTemplate, BizPortMappingRow,
BizStateLldpNeighbor, BizStateMetricRow,
)
class CompareSearchTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(self.engine, tables=[m.__table__ for m in (
BizCompareDiff, BizCompareRun, BizCompareTemplate, BizPortMappingRow,
BizStateLldpNeighbor, BizStateMetricRow,
)])
self.db = Session(self.engine)
def tearDown(self) -> None:
self.db.close()
self.engine.dispose()
def seed(self, before, after, *, metric="test", keys=None, fields=None,
iface=None, mapping=None, sample=False, compact=False, filters=None):
keys = keys or ["id"]
iface = iface or []
fields = fields or ["state"]
for side, rows in (("before", before), ("after", after)):
for seq, row in enumerate(rows):
rid = f"{side}-{seq}"
if metric == "lldp_neighbor":
self.db.add(BizStateLldpNeighbor(id=rid, batch_id=side, **row))
else:
self.db.add(BizStateMetricRow(id=rid, batch_id=side, metric_id=metric,
seq=seq, data_json=dict(row)))
row["_netx"] = {"row_id": rid}
if mapping:
for b, a in mapping.items():
self.db.add(BizPortMappingRow(mapping_id="map", before_if=b, after_if=a))
result = compare_rows(before_rows=before, after_rows=after, key_fields=keys,
compare_fields=fields, iface_fields=iface, port_map=mapping or {},
include_unchanged=True, compact_unchanged=compact)
sheet = {"sheet_id": "sheet", "metric_id": metric, "key_fields": keys,
"compare_fields": fields, "iface_fields": iface,
"summary": {**result["summary"], "unchanged_listed": 0 if sample
else result["summary"]["unchanged"]},
"row_filters": filters or []}
self.db.add(BizCompareRun(id="run", status="success", before_batch_id="before",
after_batch_id="after", mapping_id="map" if mapping else "",
summary_json={"sheets": [sheet]}))
self.db.commit()
_persist_sheet_diffs(self.db, run_id="run", metric_id="sheet",
diffs=[d for d in result["diffs"]
if not sample or d["kind"] != "unchanged"])
return result
def search(self, **kwargs):
return list_run_diffs(self.db, "run", metric_id="sheet", **kwargs)
def test_search_before_value_preserves_changed_verdict(self):
self.seed([{"id": "1", "state": "up"}], [{"id": "1", "state": "down"}])
with patch("netx_api.biz_state.compare_service.compare_rows") as compare:
result = self.search(kw="up", kind="changed")
compare.assert_not_called()
self.assertEqual(result["total"], 1)
self.assertEqual(result["items"][0]["after"]["state"], "down")
self.assertEqual(self.search(kw="up", kind="removed")["total"], 0)
def test_field_filter_matches_after_value_on_compact_diff(self):
self.seed([{"id": "1", "state": "up"}], [{"id": "1", "state": "down"}], compact=True)
result = self.search(qf={"state": "down"}, kind="changed")
self.assertEqual(result["total"], 1)
self.assertEqual(result["items"][0]["before"]["state"], "up")
def test_sample_lookup_completes_pair_matching_only_one_side(self):
self.seed([{"id": "1", "state": "up", "age": "old"}],
[{"id": "1", "state": "up", "age": "new"}], sample=True)
result = self.search(kw="old", kind="unchanged")
self.assertEqual(result["source"], "live")
self.assertEqual(result["total"], 1)
self.assertEqual(result["items"][0]["after"]["age"], "new")
def test_sample_lookup_does_not_turn_changed_into_removed(self):
self.seed([{"id": "1", "state": "up"}, {"id": "2", "state": "up"}],
[{"id": "1", "state": "down"}, {"id": "2", "state": "up"}], sample=True)
result = self.search(qf={"id": "1", "state": "up"}, kind="all")
self.assertEqual([d["kind"] for d in result["items"]], ["changed"])
def test_sample_lookup_keeps_duplicate_group_pairing(self):
self.seed([{"id": "1", "state": "up", "note": "first"},
{"id": "1", "state": "down", "note": "second"}],
[{"id": "1", "state": "up", "note": "other"},
{"id": "1", "state": "down", "note": "other"}], sample=True)
result = self.search(kw="second", kind="all")
self.assertEqual([d["kind"] for d in result["items"]], ["unchanged"])
self.assertEqual(result["items"][0]["after"]["state"], "down")
def test_sample_search_is_not_limited_by_persisted_search_text_size(self):
self.seed([{"id": "1", "state": "up", "note": "x" * 5000 + "needle"}],
[{"id": "1", "state": "up", "note": "other"}], sample=True)
self.assertEqual(self.search(kw="needle", kind="unchanged")["total"], 1)
def test_sample_lookup_applies_port_mapping_before_matching(self):
self.seed([{"id": "1", "interface": "old", "state": "up"}],
[{"id": "1", "interface": "new", "state": "up"}],
keys=["id", "interface"], iface=["interface"], mapping={"old": "new"}, sample=True)
result = self.search(qf={"interface": "old"}, kind="unchanged")
self.assertEqual(result["total"], 1)
self.assertEqual(result["items"][0]["after"]["interface"], "new")
def test_lldp_source_search(self):
self.seed([{"local_if": "gei-1", "remote_sys": "peer", "remote_if": "port", "remote_ip": "1"}],
[{"local_if": "gei-1", "remote_sys": "peer", "remote_if": "port", "remote_ip": "1"}],
metric="lldp_neighbor", keys=["local_if", "remote_sys"], fields=["remote_ip"],
iface=["local_if"], sample=True)
self.assertEqual(self.search(kw="peer", kind="unchanged")["total"], 1)
def test_lldp_compact_diff_field_search(self):
self.seed([{"local_if": "gei-1", "remote_sys": "peer", "remote_ip": "1"}],
[{"local_if": "gei-1", "remote_sys": "peer", "remote_ip": "2"}],
metric="lldp_neighbor", keys=["local_if", "remote_sys"], fields=["remote_ip"],
iface=["local_if"], compact=True)
self.assertEqual(self.search(qf={"remote_ip": "2"}, kind="changed")["total"], 1)
def test_exact_load_cap_is_not_truncated(self):
self.seed([{"id": "1", "state": "up"}], [{"id": "1", "state": "up"}])
rows, truncated = _load_metric_rows_for_search(self.db, batch_id="before", metric_id="test",
row_filters=[], key_fields=["id"], kw="up", cap=1)
self.assertEqual(len(rows), 1)
self.assertFalse(truncated)
def test_incomplete_duplicate_groups_are_omitted(self):
self.seed([{"id": "1", "state": "up"}, {"id": "1", "state": "down"}],
[{"id": "1", "state": "up"}, {"id": "1", "state": "down"}], sample=True)
with patch("netx_api.biz_state.compare_service._LIVE_SEARCH_GROUP_LOAD_CAP", 1):
result = self.search(kw="up", kind="all")
self.assertEqual(result["items"], [])
self.assertTrue(result["truncated"])
def test_postgres_search_pattern_treats_wildcards_literally(self):
self.assertEqual(_literal_search_pattern("a_10%"), "%a\\_10\\%%")
def test_postgres_unsupported_filter_uses_python(self):
self.seed([{"id": "1", "state": "up", "age": "H"}],
[{"id": "1", "state": "up", "age": "H"}])
with patch("netx_api.biz_state.compare_sql._dialect_is_postgres", return_value=True):
rows, _ = _load_metric_rows_for_search(self.db, batch_id="before", metric_id="test",
row_filters=[{"field": "age", "op": "age_timer"}], key_fields=["id"], kw="up")
self.assertEqual(rows, [])
def test_unknown_sheet_is_rejected(self):
self.seed([{"id": "1", "state": "up"}], [{"id": "1", "state": "up"}])
with self.assertRaises(HTTPException) as error:
list_run_diffs(self.db, "run", metric_id="missing")
self.assertEqual(error.exception.detail, "sheet_not_found")
def test_stored_search_escapes_like_wildcards(self):
self.seed([{"id": "x_1", "state": "up"}, {"id": "xy1", "state": "up"}],
[{"id": "x_1", "state": "down"}, {"id": "xy1", "state": "down"}])
self.assertEqual(self.search(qf={"id": "x_1"}, kind="changed")["total"], 1)
query = _search_stored_diffs(self.db.query(BizCompareDiff), metric_id="test",
kw="up", field_q={"id": "x_1"})
compiled = query.statement.compile(dialect=postgresql.dialect())
self.assertIn("LEFT OUTER JOIN", str(compiled))
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,170 @@
"""Historical comparisons must use the settings captured when they were queued."""
from __future__ import annotations
import csv
import io
import json
import unittest
import zipfile
from copy import deepcopy
from unittest.mock import patch
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from netx_api.biz_state.compare_service import (
_create_running_run, _execute_compare_into_run, cancel_compare_run,
export_run_zip, get_run, list_run_diffs, template_metrics,
)
from netx_api.db import Base
from netx_api.models import (
BizCompareDiff, BizCompareJob, BizCompareTemplate, BizPortMapping,
BizPortMappingRow, BizStateMetricRow,
)
class CompareSnapshotTests(unittest.TestCase):
def setUp(self):
self.engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(self.engine)
self.factory = sessionmaker(bind=self.engine, expire_on_commit=False)
self.db = self.factory()
# Progress uses a separate session in production; keep every test write local.
self.session_patch = patch("netx_api.db.SessionLocal", self.factory)
self.session_patch.start()
self.sheet = {
"sheet_id": "sheet", "metric_id": "test", "title": "Original sheet",
"key_fields": ["id", "interface"], "iface_fields": ["interface"],
"compare_fields": ["state"], "display_fields": ["id", "interface", "state"],
"row_filters": [], "ignore_port_changes": False,
}
self.tpl = BizCompareTemplate(
id="tpl", name="Original template", metrics_json=[deepcopy(self.sheet)],
iface_normalize_json=[{"from": "GE", "to": "gei"}],
)
self.job = BizCompareJob(id="job", name="Original job", template_id="tpl",
mapping_id="map", store_unchanged="sample")
self.mapping = BizPortMapping(id="map", name="Original mapping")
self.pair = BizPortMappingRow(mapping_id="map", before_if="gei-1", after_if="gei-2")
self.db.add_all([self.tpl, self.job, self.mapping, self.pair])
for side, iface, note in (("before", "GE-1.100", "old"), ("after", "GE-2.100", "new")):
self.db.add(BizStateMetricRow(id=side, batch_id=side, metric_id="test", seq=0,
data_json={"id": "1", "interface": iface,
"state": "up", "note": note}))
self.db.commit()
self.run = _create_running_run(
self.db, job=self.job, tpl=self.tpl, before_batch_id="before",
after_batch_id="after", sheets_cfg=template_metrics(self.tpl),
)
def tearDown(self):
self.session_patch.stop()
self.db.close()
self.engine.dispose()
def edit_current_settings(self):
changed = {**self.sheet, "title": "Edited sheet", "compare_fields": ["note"],
"row_filters": [{"field": "state", "op": "eq", "value": "down"}]}
self.tpl.name = "Edited template"
self.tpl.metrics_json = [changed]
self.tpl.iface_normalize_json = [{"from": "GE", "to": "other"}]
self.pair.after_if = "gei-99"
self.job.name = "Edited job"
self.job.store_unchanged = "never"
self.job.enabled_sheet_ids = ["different-sheet"]
self.mapping.name = "Edited mapping"
self.db.commit()
def execute(self):
return _execute_compare_into_run(self.db, self.run.id)
def test_queued_run_uses_original_sheet_rules_mapping_and_storage(self):
original = deepcopy(self.run.summary_json["config_snapshot"])
self.edit_current_settings()
detail = self.execute()
self.assertEqual(detail["status"], "success")
self.assertEqual(detail["summary"]["unchanged"], 1)
self.assertEqual(detail["summary"]["unchanged_listed"], 1)
self.assertEqual(detail["summary"]["store_unchanged"], "sample")
self.assertEqual(detail["sheets"][0]["compare_fields"], ["state"])
self.assertEqual(self.run.summary_json["config_snapshot"], original)
def test_compact_hydration_restores_original_normalized_values(self):
self.execute()
self.edit_current_settings()
diff = list_run_diffs(self.db, self.run.id, metric_id="sheet", kind="unchanged")["items"][0]
self.assertEqual(diff["before"]["interface"], "GE-1.100")
self.assertEqual(diff["mapped_before"]["interface"], "gei-2.100")
self.assertEqual(diff["after"]["interface"], "gei-2.100")
def test_live_search_does_not_inherit_new_filters_or_transforms(self):
with patch("netx_api.biz_state.compare_service._UNCHANGED_SAMPLE_MAX", 0):
self.execute()
self.edit_current_settings()
result = list_run_diffs(self.db, self.run.id, metric_id="sheet", kind="unchanged", kw="old")
self.assertEqual(result["source"], "live")
self.assertEqual(result["total"], 1)
self.assertEqual(result["items"][0]["mapped_before"]["interface"], "gei-2.100")
def test_snapshot_can_be_read_after_mapping_and_template_are_deleted(self):
self.execute()
self.db.query(BizPortMappingRow).delete()
self.db.delete(self.mapping)
self.db.delete(self.tpl)
self.db.commit()
detail = get_run(self.db, self.run.id)
self.assertEqual(detail["template_name"], "Original template")
self.assertEqual(detail["mapping_name"], "Original mapping")
diff = list_run_diffs(self.db, self.run.id, metric_id="sheet", kind="unchanged")["items"][0]
self.assertEqual(diff["mapped_before"]["interface"], "gei-2.100")
def test_queued_run_can_execute_after_its_template_and_mapping_are_deleted(self):
self.db.query(BizPortMappingRow).delete()
self.db.delete(self.mapping)
self.db.delete(self.tpl)
self.db.commit()
detail = self.execute()
self.assertEqual(detail["status"], "success")
self.assertEqual(detail["summary"]["unchanged"], 1)
def test_detail_returns_original_names_and_independent_template_copy(self):
self.edit_current_settings()
detail = get_run(self.db, self.run.id)
self.assertEqual(detail["job_name"], "Original job")
self.assertEqual(detail["config_snapshot_version"], 1)
self.assertEqual(detail["template"]["iface_normalize_rules"], [{"from": "GE", "to": "gei"}])
self.assertNotIn("config_snapshot", detail["summary"])
detail["template"]["iface_normalize_rules"].clear()
self.assertTrue(self.run.summary_json["config_snapshot"]["template"]["iface_normalize_rules"])
def test_export_contains_original_config_and_hydrated_csv(self):
self.execute()
self.edit_current_settings()
with zipfile.ZipFile(io.BytesIO(export_run_zip(self.db, self.run.id))) as archive:
config = json.loads(archive.read("config_snapshot.json"))
self.assertEqual(config["port_map"], {"gei-1": "gei-2"})
self.assertEqual(config["template"]["name"], "Original template")
rows = list(csv.DictReader(io.StringIO(archive.read("tables/sheet.csv").decode("utf-8-sig"))))
self.assertEqual(rows[0]["interface"], "gei-2.100")
def test_failed_and_cancelled_runs_keep_snapshot(self):
original = deepcopy(self.run.summary_json["config_snapshot"])
with patch("netx_api.biz_state.compare_service._run_sheet", side_effect=RuntimeError("test-failure")):
with self.assertRaisesRegex(RuntimeError, "test-failure"):
self.execute()
self.assertEqual(self.run.summary_json["config_snapshot"], original)
self.run.status = "running"
self.db.commit()
cancel_compare_run(self.db, self.run.id)
self.assertEqual(self.run.summary_json["config_snapshot"], original)
def test_legacy_runs_without_snapshot_remain_readable(self):
self.execute()
self.run.summary_json = {k: v for k, v in self.run.summary_json.items() if k != "config_snapshot"}
self.db.commit()
detail = get_run(self.db, self.run.id)
self.assertEqual(detail["status"], "success")
self.assertIsNone(detail["config_snapshot_version"])
self.assertEqual(list_run_diffs(self.db, self.run.id, metric_id="sheet", kind="unchanged")["total"], 1)
self.assertEqual(self.db.query(BizCompareDiff).count(), 1)