test(api): свод 2 — Python-оракул экрана, сверенный с векторами JS
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
adcb9d1d3b
commit
174ec73732
177
api/tests/svod2_oracle.py
Normal file
177
api/tests/svod2_oracle.py
Normal 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
|
||||||
51
api/tests/unit/test_svod2_oracle.py
Normal file
51
api/tests/unit/test_svod2_oracle.py
Normal 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
|
||||||
Loading…
x
Reference in New Issue
Block a user