diff --git a/api/tests/integration/sheet_contract.py b/api/tests/integration/sheet_contract.py index f93ea17..10ad83e 100644 --- a/api/tests/integration/sheet_contract.py +++ b/api/tests/integration/sheet_contract.py @@ -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 --- diff --git a/api/tests/integration/test_form4_sheet_sums.py b/api/tests/integration/test_form4_sheet_sums.py index 7b8423a..5efa30b 100644 --- a/api/tests/integration/test_form4_sheet_sums.py +++ b/api/tests/integration/test_form4_sheet_sums.py @@ -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):