diff --git a/perf/analysis/compare_responses.py b/perf/analysis/compare_responses.py new file mode 100644 index 0000000..3c8a694 --- /dev/null +++ b/perf/analysis/compare_responses.py @@ -0,0 +1,169 @@ +"""Сравнение ответов тяжёлых GET до и после задач A и B (AURORA-1848). + +Порядок использования (D7.1): + 1. `dump` на базовом коммите (до A): python analysis/compare_responses.py dump --scale 1 --out /path/base + 2. `dump` на коммите A: ... dump --scale 1 --out /path/a + 3. `compare /path/base /path/a --mode exact` - A не меняет ответы ни на байт значения + 4. `dump` на коммите B: ... dump --scale 1 --out /path/b + 5. `compare /path/base /path/b --mode strip-nulls` - B: листы и своды = «до» с вырезанными null в data + (семантика jsonb_strip_nulls PG 15), остальные ответы сравниваются точно. +Снимок: /<имя>.json.gz (распакованное тело) и /index.json ({имя: статус}). Код возврата compare: 1 при расхождении. +Стенд запускает человек; тесты работают на tmp_path и MockTransport. +""" +from __future__ import annotations + +import argparse +import gzip +import json +import sys +from pathlib import Path + +import httpx + +PERF = Path(__file__).resolve().parents[1] + + +def build_requests(man: dict) -> list[tuple[str, str, str]]: + """Список (роль, имя, путь с query) по манифесту.""" + out: list[tuple[str, str, str]] = [] + sheets = man.get("sheets", []) + for s in sheets: + role = "rf" if s.get("owner", "rf") == "rf" else "dfip" + d = s.get("direction") + path = f"/api/v1/form/{s['form_id']}/sheet/{s['sheet']}" + (f"?direction={d}" if d is not None else "") + name = f"sheet-{s['form_type']}-{s['sheet']}-{d or 'none'}-{s['form_id']}" + out.append((role, name, path)) + if s["form_type"] == "FORM_2" and s["sheet"] == "AHR" and role == "rf": + sep = "&" if "?" in path else "?" + out.append((role, f"{name}-sections", f"{path}{sep}sections=plan,q1")) + f2 = next((s for s in sheets if s["form_type"] == "FORM_2" and s["sheet"] == "AHR" and s.get("owner", "rf") == "rf"), None) + year = (f2 or sheets[0])["year"] if sheets else None + if year is not None: + for k in ("FORM_1", "FORM_2", "FORM_3", "FORM_4", "MAIN"): + out.append(("dfip", f"svod1-{k}", f"/api/v1/svod/1/{k}/{year}")) + for k in ("GO", "RF"): + out.append(("dfip", f"svod2-{k}", f"/api/v1/svod/2/{k}/{year}")) + for kind in ("plan", "fact", "detail"): + out.append(("dfip", f"projects-{kind}-summary", f"/api/v1/projects/{kind}-summary?year={year}")) + for r in man.get("project_reports", []): + out.append(("rf", f"report-{r['project_id']}-{r['year']}-{r['report_type']}", + f"/api/v1/projects/{r['project_id']}/report/{r['year']}/{r['report_type']}")) + for pid in dict.fromkeys(r["project_id"] for r in man.get("project_reports", [])): + out.append(("rf", f"project-summary-{pid}", f"/api/v1/projects/{pid}/summary")) + return out + + +def strip_nulls(value): + """Как jsonb_strip_nulls: убирает None-поля объектов рекурсивно; None в списках и {} остаются.""" + if isinstance(value, dict): + return {k: strip_nulls(v) for k, v in value.items() if v is not None} + if isinstance(value, list): + return [strip_nulls(v) for v in value] + return value + + +def strip_response(body: dict) -> dict: + """Копия тела, где strip_nulls применён только к row['data'] строк result.""" + out = dict(body) + out["result"] = [{**row, "data": strip_nulls(row["data"])} if isinstance(row, dict) and "data" in row else row + for row in body.get("result") or []] + return out + + +def is_stripped(name: str) -> bool: + return name.startswith(("sheet-", "svod1-", "svod2-")) + + +def first_diff(a, b, path: str = "$") -> str | None: + """Путь до первого отличия с обоими значениями; None при равенстве.""" + if isinstance(a, dict) and isinstance(b, dict): + if set(a) != set(b): + return f"{path}: ключи {sorted(set(a) ^ set(b))} есть только в одном из ответов" + for k in a: + d = first_diff(a[k], b[k], f"{path}.{k}") + if d: + return d + return None + if isinstance(a, list) and isinstance(b, list): + if len(a) != len(b): + return f"{path}: длина {len(a)} != {len(b)}" + for i, (x, y) in enumerate(zip(a, b)): + d = first_diff(x, y, f"{path}[{i}]") + if d: + return d + return None + if type(a) is type(b) and a == b: + return None + return f"{path}: {a!r} != {b!r}" + + +def _load(d: Path, name: str): + return json.loads(gzip.decompress((d / f"{name}.json.gz").read_bytes())) + + +def compare(before: Path, after: Path, mode: str) -> tuple[int, list[str]]: + bi = json.loads((before / "index.json").read_text()) + ai_p = after / "index.json" + ai = json.loads(ai_p.read_text()) if ai_p.exists() else {} + ok, bad = 0, [] + for name, st in bi.items(): + if name not in ai or not (after / f"{name}.json.gz").exists(): + bad.append(f"{name}: нет в after") + continue + if st != ai[name]: + bad.append(f"{name}: статус {st} != {ai[name]}") + continue + b, a = _load(before, name), _load(after, name) + if mode == "strip-nulls" and is_stripped(name): + b = strip_response(b) + d = first_diff(b, a) + if d: + bad.append(f"{name}: {d}") + else: + ok += 1 + return ok, bad + + +def dump(man: dict, api: str, out: Path) -> None: + out.mkdir(parents=True, exist_ok=True) + index, tokens = {}, {} + reqs = build_requests(man) + with httpx.Client(base_url=api, timeout=300) as c: + for role in sorted({r for r, _, _ in reqs}): + u = man["users"][role] + r = c.post("/api/v1/auth/login", json={"username": u["username"], "password": u["password"]}) + r.raise_for_status() + tokens[role] = r.json()["access_token"] + for role, name, path in reqs: + r = c.get(path, headers={"Authorization": f"Bearer {tokens[role]}"}) + (out / f"{name}.json.gz").write_bytes(gzip.compress(r.content)) + index[name] = r.status_code + print(name, r.status_code, len(r.content), flush=True) + (out / "index.json").write_text(json.dumps(index, ensure_ascii=False, indent=1)) + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser() + sub = ap.add_subparsers(dest="cmd", required=True) + d = sub.add_parser("dump") + d.add_argument("--scale", type=int, default=1) + d.add_argument("--out", required=True) + d.add_argument("--api", default="http://localhost:8000") + c = sub.add_parser("compare") + c.add_argument("before") + c.add_argument("after") + c.add_argument("--mode", choices=("exact", "strip-nulls"), default="exact") + a = ap.parse_args(argv) + if a.cmd == "dump": + man = json.loads((PERF / "results" / f"manifest-s{a.scale}.json").read_text()) + dump(man, a.api, Path(a.out)) + return 0 + ok, bad = compare(Path(a.before), Path(a.after), a.mode) + print(f"совпало {ok} из {ok + len(bad)}") + for line in bad: + print(line) + return 1 if bad else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/perf/tests/test_compare_responses.py b/perf/tests/test_compare_responses.py new file mode 100644 index 0000000..e6644c4 --- /dev/null +++ b/perf/tests/test_compare_responses.py @@ -0,0 +1,94 @@ +"""Самопроверка compare_responses (без стенда).""" +import gzip +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "analysis")) + +import compare_responses as cr # noqa: E402 + + +def test_strip_nulls_pg_semantics(): + v = {"a": None, "b": {"c": None}, "d": [None, {"e": None, "f": 1}], "g": {}} + assert cr.strip_nulls(v) == {"b": {}, "d": [None, {"f": 1}], "g": {}} + + +def test_strip_response_keeps_wrapper_and_sort_order(): + body = {"success": True, "message": None, "count": 1, + "result": [{"row_type": "INPUT", "depth": 3, "sort_order": None, "data": {"x": None, "y": 1}}]} + out = cr.strip_response(body) + assert out["message"] is None + assert out["result"][0]["sort_order"] is None + assert out["result"][0]["data"] == {"y": 1} + assert body["result"][0]["data"] == {"x": None, "y": 1} # исходное не изменено + + +def test_first_diff_path(): + a = {"result": [{"data": {"q1": {"m1": 1}}}]} + b = {"result": [{"data": {"q1": {"m1": 2}}}]} + assert cr.first_diff(a, b).startswith("$.result[0].data.q1.m1") + assert cr.first_diff(a, a) is None + assert cr.first_diff({"a": [1]}, {"a": [1, 2]}).startswith("$.a") + assert cr.first_diff({"a": 1}, {"a": 1, "b": 2}).startswith("$") + + +def _write(d: Path, name: str, body: dict, status: int = 200): + d.mkdir(parents=True, exist_ok=True) + (d / f"{name}.json.gz").write_bytes(gzip.compress(json.dumps(body).encode())) + idx = d / "index.json" + cur = json.loads(idx.read_text()) if idx.exists() else {} + cur[name] = status + idx.write_text(json.dumps(cur)) + + +def _body(data): + return {"success": True, "message": None, "count": 1, + "result": [{"row_type": "INPUT", "depth": 1, "sort_order": 1, "data": data}]} + + +def test_compare_exact_and_strip_modes(tmp_path): + b, a = tmp_path / "before", tmp_path / "after" + for name in ("sheet-FORM_2-AHR-none-1", "report-5-2026-LIMIT"): + _write(b, name, _body({"x": None, "y": 1})) + _write(a, name, _body({"y": 1})) + n, bad = cr.compare(b, a, "exact") + assert n == 0 and len(bad) == 2 + n, bad = cr.compare(b, a, "strip-nulls") + assert n == 1 and len(bad) == 1 and "report-5-2026-LIMIT" in bad[0] + + +def test_compare_status_mismatch(tmp_path): + b, a = tmp_path / "before", tmp_path / "after" + _write(b, "svod1-MAIN", _body({"y": 1}), 200) + _write(a, "svod1-MAIN", _body({"y": 1}), 500) + n, bad = cr.compare(b, a, "exact") + assert n == 0 and len(bad) == 1 + + +def test_compare_missing_after(tmp_path): + b, a = tmp_path / "before", tmp_path / "after" + _write(b, "svod1-MAIN", _body({})) + a.mkdir() + n, bad = cr.compare(b, a, "exact") + assert n == 0 and len(bad) == 1 + + +def test_build_requests_minimal_manifest(): + man = {"sheets": [ + {"form_id": 1, "form_type": "FORM_2", "sheet": "AHR", "direction": None, "year": 2026, "url": "", "rows": 1, "owner": "rf"}, + {"form_id": 2, "form_type": "FORM_1", "sheet": "AHR", "direction": "Support", "year": 2026, "url": "", "rows": 1, "owner": "ssp"}, + ], "project_reports": [{"project_id": 5, "year": 2026, "report_type": "LIMIT", "url": "", "rows": 1}]} + reqs = cr.build_requests(man) + by = {n: (r, p) for r, n, p in reqs} + assert by["sheet-FORM_2-AHR-none-1"] == ("rf", "/api/v1/form/1/sheet/AHR") + assert by["sheet-FORM_2-AHR-none-1-sections"] == ("rf", "/api/v1/form/1/sheet/AHR?sections=plan,q1") + assert by["sheet-FORM_1-AHR-Support-2"] == ("dfip", "/api/v1/form/2/sheet/AHR?direction=Support") + assert sum(n.startswith("svod1-") for n in by) == 5 + assert sum(n.startswith("svod2-") for n in by) == 2 + assert by["svod1-MAIN"] == ("dfip", "/api/v1/svod/1/MAIN/2026") + assert by["report-5-2026-LIMIT"][0] == "rf" + assert by["project-summary-5"] == ("rf", "/api/v1/projects/5/summary") + assert {"projects-plan-summary", "projects-fact-summary", "projects-detail-summary"} <= set(by) + assert not any("rollup" in n for n in by) + assert cr.is_stripped("sheet-x") and cr.is_stripped("svod2-GO") and not cr.is_stripped("report-1")