Merge pull request 'fix(tests): согласовать AURORA-1844/1846/1850 с null-strip из AURORA-1848' (#279) from fix/null-strip-test-contract into test

This commit is contained in:
aep 2026-11-01 11:40:51 +03:00
commit f407b5c296
2 changed files with 38 additions and 15 deletions

View File

@ -3,6 +3,10 @@
Каждая строка ответа PATCH /cell(s) должна совпасть (data и sort_order) со строкой
того же (row_type, depth, sort_order) из последующего GET /sheet.
Используется в AURORA-1844 и далее (AURORA-1850: пересчёт только затронутого поддерева).
AURORA-1848 убрал null-поля из data только в GET (jsonb_strip_nulls), PATCH отдаёт
null явно — осознанно, без изменений. Сравнение ниже снимает это различие на стороне
теста: strip_nulls(PATCH) должен совпасть с GET, который сервер уже отдал без null.
"""
@ -10,6 +14,15 @@ def row_key(row: dict) -> tuple:
return (row["row_type"], row["depth"], row["sort_order"])
def strip_nulls(value):
"""Python-эквивалент jsonb_strip_nulls: убирает поля объектов со значением null, рекурсивно."""
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 assert_patch_rows_match_sheet(patch_rows: list[dict], sheet_rows: list[dict]) -> None:
"""Строки ответа на правку целиком совпадают со строками листа."""
sheet_by_key = {row_key(r): r for r in sheet_rows}
@ -19,7 +32,7 @@ def assert_patch_rows_match_sheet(patch_rows: list[dict], sheet_rows: list[dict]
for row in patch_rows:
key = row_key(row)
assert key in sheet_by_key, f"строки {key} нет в GET /sheet"
assert row["data"] == sheet_by_key[key]["data"], f"data строки {key} расходится с GET"
assert strip_nulls(row["data"]) == sheet_by_key[key]["data"], f"data строки {key} расходится с GET"
# --- AURORA-1850: эталон (фильтр 0050 поверх полного v_form_view) и хелперы SQL ---

View File

@ -12,6 +12,8 @@ from itertools import count
import pytest
from sqlalchemy import text
from sheet_contract import strip_nulls
SHEET = "CAP"
CAP_ITEM = 525 # статья CAP depth=2
BLOCKS = [
@ -172,14 +174,17 @@ def check_sums(f4, sections=None):
root, groups, items, inputs = _split(rows)
ref = f4.reference(sections)
# Эталон 1a: ответ API совпадает с v3.jsonb_sum (числа сравниваются по значению)
assert canon_num({b: root[b] for b in BLOCKS}) == canon_num(ref[("R", 0)])
# Эталон 1a: ответ API (без null-полей, AURORA-1848) совпадает с v3.jsonb_sum
# после strip_nulls (числа сравниваются по значению). Блок, целиком null
# (не выбран в sections или нет значений), в ответе API отсутствует как ключ —
# проецируем обе стороны только на реально присутствующие блоки.
assert canon_num({b: root[b] for b in BLOCKS if b in root}) == canon_num(strip_nulls(ref[("R", 0)]))
for pid, data in groups.items():
if ("P", pid) in ref:
assert canon_num({b: data[b] for b in BLOCKS}) == canon_num(ref[("P", pid)]), f"program {pid}"
assert canon_num({b: data[b] for b in BLOCKS if b in data}) == canon_num(strip_nulls(ref[("P", pid)])), f"program {pid}"
for jid, data in items.items():
if ("J", jid) in ref:
assert canon_num({b: data[b] for b in BLOCKS}) == canon_num(ref[("J", jid)]), f"project {jid}"
assert canon_num({b: data[b] for b in BLOCKS if b in data}) == canon_num(strip_nulls(ref[("J", jid)])), f"project {jid}"
# Эталон 1b: прямой вызов функции совпадает с v3.jsonb_sum точно, включая масштаб чисел
direct = f4.sql(
@ -196,14 +201,16 @@ def check_sums(f4, sections=None):
assert canon({b: data[b] for b in BLOCKS}) == canon(ref[("J", jid)]), f"project {jid}"
# Эталон 2: сумма числовых листьев на Python
# AURORA-1848 вырезает null-поля из GET — целиком null-блок строки отсутствует,
# отсутствующий header.program_id/project_id (строка не на этом уровне) тоже.
def expect(level_inputs, agg):
for b in BLOCKS:
paths = {p for inp in level_inputs for p, _ in leaves(inp[b])}
paths = {p for inp in level_inputs for p, _ in leaves(inp.get(b, {}))}
for path in paths:
nums = [
Decimal(str(v))
for inp in level_inputs
for p, v in leaves(inp[b])
for p, v in leaves(inp.get(b, {}))
if p == path and isinstance(v, (int, Decimal)) and not isinstance(v, bool)
]
if nums:
@ -211,11 +218,11 @@ def check_sums(f4, sections=None):
expect(inputs, root)
for pid, data in groups.items():
lvl_inputs = [i for i in inputs if i["header"]["program_id"] == pid]
lvl_inputs = [i for i in inputs if i["header"].get("program_id") == pid]
if lvl_inputs:
expect(lvl_inputs, data)
for jid, data in items.items():
lvl_inputs = [i for i in inputs if i["header"]["project_id"] == jid]
lvl_inputs = [i for i in inputs if i["header"].get("project_id") == jid]
if lvl_inputs:
expect(lvl_inputs, data)
return root, groups, items, inputs
@ -257,9 +264,10 @@ def test_single_line_project_keeps_text_two_lines_null(f4):
_, groups, items, _ = check_sums(f4)
assert items[single]["contract"]["counterparty"] == "ООО Один"
assert items[single]["q1"]["adj_comment"] == "комментарий"
assert items[pair]["contract"]["counterparty"] is None
assert items[pair]["q1"]["adj_comment"] is None
assert groups[prog]["contract"]["counterparty"] is None
# AURORA-1848: null-поля в GET вырезаны (jsonb_strip_nulls) — ключа может не быть вовсе
assert items[pair]["contract"].get("counterparty") is None
assert items[pair]["q1"].get("adj_comment") is None
assert groups[prog]["contract"].get("counterparty") is None
def test_single_line_program_keeps_text(f4):
@ -275,9 +283,10 @@ def test_empty_program_and_project(f4):
empty_prog = f4.program()
empty_prj = f4.project(empty_prog)
_, groups, items, _ = check_sums(f4)
# comment: None — AURORA-1848 вырезает такие поля из GET (jsonb_strip_nulls)
zero_plan = {"q1": 0, "q2": 0, "q3": 0, "q4": 0, "year": 0, "comment": None}
assert canon(groups[empty_prog]["plan"]) == canon(zero_plan)
assert canon(items[empty_prj]["plan"]) == canon(zero_plan)
assert canon(groups[empty_prog]["plan"]) == canon(strip_nulls(zero_plan))
assert canon(items[empty_prj]["plan"]) == canon(strip_nulls(zero_plan))
def test_decimals_negative_and_null(f4):
@ -318,7 +327,8 @@ def test_sections_filter(f4):
assert Decimal(str(items[prj]["booking"]["y2026"]["q1"])) == 30
for b in BLOCKS:
if b not in ("plan", "booking"):
assert root[b] is None and groups[prog][b] is None and items[prj][b] is None
# AURORA-1848: не выбранный в sections блок вырезан из data, а не null
assert root.get(b) is None and groups[prog].get(b) is None and items[prj].get(b) is None
def test_cell_patch_returns_updated_ancestor_sums(f4, client):