test(api): свод 2 — Python-оракул экрана, сверенный с векторами JS

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-28 21:11:17 +04:00
parent adcb9d1d3b
commit 174ec73732
2 changed files with 228 additions and 0 deletions

177
api/tests/svod2_oracle.py Normal file
View File

@ -0,0 +1,177 @@
"""Python-порт оракула экрана «до» свода 2 (perf/analysis/svod2_search_oracle.mjs).
Поведение сверяется с векторами, снятыми в Node.js (tests/fixtures/*.json).
"""
from __future__ import annotations
import math
from decimal import Decimal
FILTER_COLUMNS = ("smeta_type", "smeta_direction", "org_name")
# ECMAScript WhiteSpace + LineTerminator (design § 3).
_JS_WHITESPACE = (
"\u0009\u000a\u000b\u000c\u000d   "
"           "
"

   "
)
def js_trim(s: str) -> str:
return s.strip(_JS_WHITESPACE)
def js_number_text(x: int | float) -> str:
"""ECMAScript Number::toString (radix 10)."""
f = float(x)
if math.isnan(f):
return "NaN"
if f == 0:
return "0"
if math.isinf(f):
return "Infinity" if f > 0 else "-Infinity"
sign = "-" if f < 0 else ""
# Кратчайшая запись цифр: repr(float) -> Decimal -> (digits, exponent).
t = Decimal(repr(abs(f))).as_tuple()
digits = "".join(map(str, t.digits)).rstrip("0")
# значение = 0.d1d2...dk * 10^n
n = len(t.digits) + t.exponent
k = len(digits)
if not digits:
return "0"
# убрать ведущие нули (Decimal('0.001') даёт digits (1,), без лишних; страховка)
stripped = digits.lstrip("0")
n -= len(digits) - len(stripped)
digits, k = stripped, len(stripped)
if k <= n <= 21:
body = digits + "0" * (n - k)
elif 0 < n <= 21:
body = digits[:n] + "." + digits[n:]
elif -6 < n <= 0:
body = "0." + "0" * (-n) + digits
else:
e = n - 1
es = ("+" if e >= 0 else "-") + str(abs(e))
body = (digits[0] + ("." + digits[1:] if k > 1 else "")) + "e" + es
return sign + body
def js_string(value: object) -> str:
"""String(value ?? '')."""
if value is None:
return ""
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, (int, float)):
return js_number_text(value)
return str(value)
def matches_search(row: dict, query: str) -> bool:
data = row.get("data") or {}
merged = {
**(data.get("header") or {}),
**(data.get("contract_summary") or {}),
**(data.get("contract_detail") or {}),
}
return any(query in js_string(v).lower() for v in merged.values())
def _by_search(rows: list[dict], search: str) -> list[dict]:
q = js_trim(search or "").lower()
return [r for r in rows if matches_search(r, q)] if q else rows
def _cell(row: dict, col: str):
return ((row.get("data") or {}).get("header") or {}).get(col)
def _equals(cell, filter_value: str) -> bool:
if cell is None:
return False
return js_trim(js_string(cell).lower()) == js_trim(filter_value.lower())
def _by_filters(rows: list[dict], filters: dict[str, str], skip: str | None) -> list[dict]:
active = [c for c in FILTER_COLUMNS if c != skip and filters.get(c)]
return [r for r in rows if all(_equals(_cell(r, c), filters[c]) for c in active)]
def screen_rows(rows, search: str = "", filters=None) -> list[dict]:
return _by_filters(_by_search(rows, search), filters or {}, None)
def screen_found(rows, search: str) -> tuple[int, int]:
return len(_by_search(rows, search)), len(rows)
def screen_facets(rows, search: str = "", filters=None) -> dict:
filters = filters or {}
searched = _by_search(rows, search)
result: dict = {"total": len(rows), "search_total": len(searched)}
for col in FILTER_COLUMNS:
counts: dict = {}
for r in _by_filters(searched, filters, col):
v = _cell(r, col)
if v is not None:
counts[v] = counts.get(v, 0) + 1
result[col] = dict(sorted(counts.items()))
return result
_Q = ("adj_current", "adj_ssp", "adj_rf", "adj_reserve", "payment_date", "payment_amount",
"payment_amount_ho", "payment_amount_rf", "payment_act", "booking_amount",
"actual_m1", "actual_m2", "actual_m3")
def _build_paths() -> tuple[str, ...]:
p = ["line_id"]
p += [f"header.{f}" for f in ("source_form", "source_id", "smeta_type", "smeta_direction",
"org_name", "name", "item_id", "num_group", "justification")]
p += [f"plan.plan_q{i}" for i in range(1, 5)]
p += ["allocation.internal_order", "allocation.property_object"]
p += [f"sequestration.DFIP.adj_q{i}" for i in range(1, 5)]
p += [f"reserve.amount_q{i}" for i in range(1, 5)]
p += [f"approved.approved_q{i}" for i in range(1, 5)]
p += [f"contract_detail.{f}" for f in ("counterparty", "reference", "subject", "currency",
"ceiling_amount")]
p += [f"contract_detail.expenses_q{i}" for i in range(1, 5)]
p += ["contract_detail.vat_rate", "contract_detail.deadline"]
for q in range(1, 5):
p += [f"q{q}.{f}" for f in _Q]
if q == 4:
p.append("q4.actual_spod")
if q <= 3:
p += [f"q{q}.transfer_to_q{k}" for k in range(q + 1, 5)]
p.append(f"q{q}.transfer_to_economy")
if q >= 2:
p += [f"q{q}.{f}" for f in ("plan_revision_eco_change", "plan_revision_item_adj",
"plan_revision_increase", "plan_revision_sequester")]
return tuple(p)
COMPACT_PATHS: tuple[str, ...] = _build_paths()
def compact_projection(data: dict) -> dict:
"""jsonb_strip_nulls(jsonb_build_object(...)) по COMPACT_PATHS.
Группы и подгруппы присутствуют всегда; листья None/отсутствующие опускаются.
"""
out: dict = {}
for path in COMPACT_PATHS:
parts = path.split(".")
src = data
for part in parts[:-1]:
src = src.get(part) if isinstance(src, dict) else None
dst = out
for part in parts[:-1]:
dst = dst.setdefault(part, {})
leaf = src.get(parts[-1]) if isinstance(src, dict) else None
if len(parts) == 1:
if leaf is not None:
out[parts[0]] = leaf
elif leaf is not None:
dst[parts[-1]] = leaf
return out

View File

@ -0,0 +1,51 @@
import json
from pathlib import Path
import pytest
from tests.svod2_oracle import (
COMPACT_PATHS,
FILTER_COLUMNS,
compact_projection,
js_number_text,
screen_facets,
screen_found,
screen_rows,
)
FIX = Path(__file__).resolve().parents[1] / "fixtures"
NUM = json.loads((FIX / "js_number_text_vectors.json").read_text())
SCREEN = json.loads((FIX / "svod2_screen_vectors.json").read_text())
def test_js_number_text_matches_node_vectors():
for v in NUM:
assert js_number_text(json.loads(v["input"])) == v["js"], v
@pytest.mark.parametrize("case", SCREEN["cases"], ids=lambda c: f"{c['search']!r}-{c['filters']}")
def test_screen_port_matches_node(case):
rows = SCREEN["rows"]
assert [r["data"]["line_id"] for r in screen_rows(rows, case["search"], case["filters"])] == case["line_ids"]
assert screen_found(rows, case["search"]) == (case["found"]["found"], case["found"]["total"])
f = screen_facets(rows, case["search"], case["filters"])
assert (f["total"], f["search_total"]) == (case["facets"]["total"], case["facets"]["search_total"])
for col in FILTER_COLUMNS:
assert f[col] == dict(case["facets"][col])
def test_compact_projection_keeps_zero_drops_null_and_unknown():
out = compact_projection({"line_id": 1, "header": {"source_form": "FORM_1", "name": None, "vsp_id": 5},
"plan": {"plan_q1": 0, "plan_q2": None}})
assert out["line_id"] == 1
assert out["header"] == {"source_form": "FORM_1"}
assert out["plan"] == {"plan_q1": 0}
assert out["sequestration"] == {"DFIP": {}}
assert "transfer_to_q1" not in out["q1"] and set(out) >= {"q1", "q2", "q3", "q4", "contract_detail"}
def test_compact_paths_shape():
assert len(COMPACT_PATHS) == len(set(COMPACT_PATHS))
assert "q4.actual_spod" in COMPACT_PATHS and "q1.actual_spod" not in COMPACT_PATHS
assert "q1.transfer_to_q2" in COMPACT_PATHS and "q3.transfer_to_q4" in COMPACT_PATHS
assert "q4.transfer_to_economy" not in COMPACT_PATHS and "q1.plan_revision_increase" not in COMPACT_PATHS