fix(tests): согласовать AURORA-1844/1846/1850 с null-strip из AURORA-1848
AURORA-1848 (#239/#242) убрал null-поля из GET листа/сводов (jsonb_strip_nulls), оставив PATCH как раньше (задокументированный breaking-change в PR, не баг). После слияния веток в test это разошлось с тестами, написанными до #239/#242: - sheet_contract.assert_patch_rows_match_sheet сравнивал PATCH и GET побайтно — добавлен strip_nulls() на стороне PATCH перед сравнением. - test_form4_sheet_sums.py сравнивал ответ API с "сырым" v3.jsonb_sum (с null-полями) и ожидал явный null в некоторых полях ответа — эталон теперь тоже прогоняется через strip_nulls, а прямые ожидания null заменены на .get(...) is None / отсутствие ключа. Без изменений контракта API: PATCH продолжает отдавать null явно, как в #242. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
8c467804be
commit
d5cbd151c5
@ -3,6 +3,10 @@
|
|||||||
Каждая строка ответа PATCH /cell(s) должна совпасть (data и sort_order) со строкой
|
Каждая строка ответа PATCH /cell(s) должна совпасть (data и sort_order) со строкой
|
||||||
того же (row_type, depth, sort_order) из последующего GET /sheet.
|
того же (row_type, depth, sort_order) из последующего GET /sheet.
|
||||||
Используется в AURORA-1844 и далее (AURORA-1850: пересчёт только затронутого поддерева).
|
Используется в 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"])
|
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:
|
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}
|
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:
|
for row in patch_rows:
|
||||||
key = row_key(row)
|
key = row_key(row)
|
||||||
assert key in sheet_by_key, f"строки {key} нет в GET /sheet"
|
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 ---
|
# --- AURORA-1850: эталон (фильтр 0050 поверх полного v_form_view) и хелперы SQL ---
|
||||||
|
|||||||
@ -12,6 +12,8 @@ from itertools import count
|
|||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from sheet_contract import strip_nulls
|
||||||
|
|
||||||
SHEET = "CAP"
|
SHEET = "CAP"
|
||||||
CAP_ITEM = 525 # статья CAP depth=2
|
CAP_ITEM = 525 # статья CAP depth=2
|
||||||
BLOCKS = [
|
BLOCKS = [
|
||||||
@ -172,14 +174,17 @@ def check_sums(f4, sections=None):
|
|||||||
root, groups, items, inputs = _split(rows)
|
root, groups, items, inputs = _split(rows)
|
||||||
ref = f4.reference(sections)
|
ref = f4.reference(sections)
|
||||||
|
|
||||||
# Эталон 1a: ответ API совпадает с v3.jsonb_sum (числа сравниваются по значению)
|
# Эталон 1a: ответ API (без null-полей, AURORA-1848) совпадает с v3.jsonb_sum
|
||||||
assert canon_num({b: root[b] for b in BLOCKS}) == canon_num(ref[("R", 0)])
|
# после 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():
|
for pid, data in groups.items():
|
||||||
if ("P", pid) in ref:
|
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():
|
for jid, data in items.items():
|
||||||
if ("J", jid) in ref:
|
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 точно, включая масштаб чисел
|
# Эталон 1b: прямой вызов функции совпадает с v3.jsonb_sum точно, включая масштаб чисел
|
||||||
direct = f4.sql(
|
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}"
|
assert canon({b: data[b] for b in BLOCKS}) == canon(ref[("J", jid)]), f"project {jid}"
|
||||||
|
|
||||||
# Эталон 2: сумма числовых листьев на Python
|
# Эталон 2: сумма числовых листьев на Python
|
||||||
|
# AURORA-1848 вырезает null-поля из GET — целиком null-блок строки отсутствует,
|
||||||
|
# отсутствующий header.program_id/project_id (строка не на этом уровне) тоже.
|
||||||
def expect(level_inputs, agg):
|
def expect(level_inputs, agg):
|
||||||
for b in BLOCKS:
|
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:
|
for path in paths:
|
||||||
nums = [
|
nums = [
|
||||||
Decimal(str(v))
|
Decimal(str(v))
|
||||||
for inp in level_inputs
|
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 p == path and isinstance(v, (int, Decimal)) and not isinstance(v, bool)
|
||||||
]
|
]
|
||||||
if nums:
|
if nums:
|
||||||
@ -211,11 +218,11 @@ def check_sums(f4, sections=None):
|
|||||||
|
|
||||||
expect(inputs, root)
|
expect(inputs, root)
|
||||||
for pid, data in groups.items():
|
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:
|
if lvl_inputs:
|
||||||
expect(lvl_inputs, data)
|
expect(lvl_inputs, data)
|
||||||
for jid, data in items.items():
|
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:
|
if lvl_inputs:
|
||||||
expect(lvl_inputs, data)
|
expect(lvl_inputs, data)
|
||||||
return root, groups, items, inputs
|
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)
|
_, groups, items, _ = check_sums(f4)
|
||||||
assert items[single]["contract"]["counterparty"] == "ООО Один"
|
assert items[single]["contract"]["counterparty"] == "ООО Один"
|
||||||
assert items[single]["q1"]["adj_comment"] == "комментарий"
|
assert items[single]["q1"]["adj_comment"] == "комментарий"
|
||||||
assert items[pair]["contract"]["counterparty"] is None
|
# AURORA-1848: null-поля в GET вырезаны (jsonb_strip_nulls) — ключа может не быть вовсе
|
||||||
assert items[pair]["q1"]["adj_comment"] is None
|
assert items[pair]["contract"].get("counterparty") is None
|
||||||
assert groups[prog]["contract"]["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):
|
def test_single_line_program_keeps_text(f4):
|
||||||
@ -275,9 +283,10 @@ def test_empty_program_and_project(f4):
|
|||||||
empty_prog = f4.program()
|
empty_prog = f4.program()
|
||||||
empty_prj = f4.project(empty_prog)
|
empty_prj = f4.project(empty_prog)
|
||||||
_, groups, items, _ = check_sums(f4)
|
_, 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}
|
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(groups[empty_prog]["plan"]) == canon(strip_nulls(zero_plan))
|
||||||
assert canon(items[empty_prj]["plan"]) == canon(zero_plan)
|
assert canon(items[empty_prj]["plan"]) == canon(strip_nulls(zero_plan))
|
||||||
|
|
||||||
|
|
||||||
def test_decimals_negative_and_null(f4):
|
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
|
assert Decimal(str(items[prj]["booking"]["y2026"]["q1"])) == 30
|
||||||
for b in BLOCKS:
|
for b in BLOCKS:
|
||||||
if b not in ("plan", "booking"):
|
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):
|
def test_cell_patch_returns_updated_ancestor_sums(f4, client):
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user