AURORA-1848: лист и своды без null-полей + фронт (B) #242

Open
aep wants to merge 9 commits from feat/233-aurora-1848-sheet-json-no-pydantic-b into feat/233-aurora-1848-sheet-json-no-pydantic
20 changed files with 541 additions and 73 deletions

View File

@ -179,6 +179,7 @@ async def get_sheet(
sheet=sheet,
direction=direction.value if direction else None,
sections=sections,
strip_nulls=True,
)
db_ms = (time.perf_counter() - t0) * 1000
extra={

View File

@ -58,6 +58,7 @@ async def get_summary(
sheet=sheet,
user=current_user,
org_ids=_parse_org_ids(org_ids),
strip_nulls=True,
)
db_ms = (time.perf_counter() - t0) * 1000
@ -105,6 +106,7 @@ async def get_summary2(
limit=limit,
sort_by=sort_by,
sort_direction=sort_direction,
strip_nulls=True,
)
db_ms = (time.perf_counter() - t0) * 1000

View File

@ -5,11 +5,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
ROW_COLUMNS = "row_type, depth, sort_order, data"
def row_json_select(row_type: str = "row_type", depth: str = "depth") -> str:
def row_json_select(row_type: str = "row_type", depth: str = "depth", strip_nulls: bool = False) -> str:
"""json (не jsonb) сохраняет порядок ключей; ::text обходит кодек json.loads у asyncpg."""
data = "jsonb_strip_nulls(data)" if strip_nulls else "data"
return (
f"SELECT json_build_object('row_type', {row_type}, 'depth', {depth}, "
f"'data', data, 'sort_order', sort_order)::text"
f"'data', {data}, 'sort_order', sort_order)::text"
)

View File

@ -79,9 +79,10 @@ class SheetRepository:
sheet: str,
direction: str | None,
sections: list[str] | None = None,
strip_nulls: bool = False,
) -> list[str]:
func_query, params = self._view_source(form_id, sheet, direction, sections)
return await fetch_row_texts(self.db, f"{row_json_select()} FROM {func_query}", params)
return await fetch_row_texts(self.db, f"{row_json_select(strip_nulls=strip_nulls)} FROM {func_query}", params)
async def update_cell(
self,

View File

@ -72,8 +72,9 @@ class SummaryRepository:
year: int,
sheet: str,
org_ids: list[int] | None = None,
strip_nulls: bool = False,
) -> list[str]:
sql = f"{row_json_select()} {self._svod1_source()}"
sql = f"{row_json_select(strip_nulls=strip_nulls)} {self._svod1_source()}"
return await fetch_row_texts(
self.db,
sql,
@ -110,8 +111,9 @@ class SummaryRepository:
limit: int | None = None,
sort_by: str = "org_name",
sort_direction: str = "asc",
strip_nulls: bool = False,
) -> list[str]:
select = row_json_select(row_type="'INPUT'::VARCHAR", depth="3")
select = row_json_select(row_type="'INPUT'::VARCHAR", depth="3", strip_nulls=strip_nulls)
return await fetch_row_texts(
self.db,
f"{select} {self._svod2_source(sheet)}",

View File

@ -41,13 +41,15 @@ class SheetService:
direction: str | None,
sections: list[str] | None = None,
form_id: int | None = None,
form: BudgetForm | None = None
form: BudgetForm | None = None,
strip_nulls: bool = False,
) -> list[str]:
return await self.sheet_repo.get_json(
form_id=form_id,
sheet=sheet,
direction=direction,
sections=sections,
strip_nulls=strip_nulls,
)
async def update_cells(

View File

@ -58,6 +58,7 @@ class SummaryService:
sheet: str,
user: AppUser,
org_ids: list[int] | None = None,
strip_nulls: bool = False,
) -> list[str]:
org_ids = await self._allowed_org_ids(user, org_ids)
if org_ids == []:
@ -66,6 +67,7 @@ class SummaryService:
year=year,
sheet=sheet,
org_ids=org_ids,
strip_nulls=strip_nulls,
)
async def get_svod2_json(
@ -78,6 +80,7 @@ class SummaryService:
limit: int | None = None,
sort_by: str = "org_name",
sort_direction: str = "asc",
strip_nulls: bool = False,
) -> list[str]:
org_ids = await self._allowed_org_ids(user, org_ids)
if org_ids == []:
@ -90,6 +93,7 @@ class SummaryService:
limit=limit,
sort_by=sort_by,
sort_direction=sort_direction,
strip_nulls=strip_nulls,
)
async def get_svod2_go_export_rows(

View File

@ -52,9 +52,39 @@ def old_rows(client, coro_factory) -> list[dict]:
return client.portal.call(_go)
def _strip_nulls(value):
"""Как jsonb_strip_nulls: поля объектов с None удаляются рекурсивно, None в списках и {} остаются."""
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 test_pg_jsonb_strip_nulls_matches_strip_nulls(client, db_session):
literal = '{"a":{"b":null},"c":[null,{"d":null,"e":1},[{"f":null}]],"g":null,"h":{}}'
async def _go():
res = await db_session.execute(text("SELECT jsonb_strip_nulls(CAST(:v AS jsonb))"), {"v": literal})
return res.scalar_one()
pg = client.portal.call(_go)
if isinstance(pg, str):
pg = json.loads(pg)
assert pg == _strip_nulls(json.loads(literal))
assert pg == {"a": {}, "c": [None, {"e": 1}, [{}]], "h": {}}
def _has_null_field(value) -> bool:
if isinstance(value, dict):
return any(v is None or _has_null_field(v) for v in value.values())
if isinstance(value, list):
return any(_has_null_field(v) for v in value)
return False
def expected_sheet(rows: list[dict]) -> list[dict]:
"""Ожидаемый ответ листа и сводов по эталону старого пути. Фаза B (Task 13) меняет тело на strip_nulls."""
return rows
"""Ожидаемый ответ листа и сводов по эталону старого пути: data без null-полей (фаза B)."""
return [{**r, "data": _strip_nulls(r["data"])} for r in rows]
def get_ok(client, url, headers, params=None) -> list[dict]:
@ -372,3 +402,36 @@ def test_project_report_not_found_unchanged(client, admin_tokens, auth_headers,
def test_rf_rollup_foreign_branch_unchanged(client, isp_tokens, auth_headers):
r = client.get(f"/api/v1/rf-rollup/1/{YEAR}", headers=auth_headers(isp_tokens))
assert (r.status_code, r.json()) == RF_FOREIGN
def test_sheet_and_svod_have_no_null_fields(client, admin_tokens, auth_headers, svod2_plan_line):
h = auth_headers(admin_tokens)
cases = [
get_ok(client, "/api/v1/form/4/sheet/AHR", h),
get_ok(client, f"/api/v1/svod/1/MAIN/{YEAR}", h),
get_ok(client, f"/api/v1/svod/2/GO/{YEAR}", h),
]
for result in cases:
assert result
for row in result:
assert not _has_null_field(row["data"]), row
assert row.keys() == {"row_type", "depth", "sort_order", "data"}
def test_project_report_keeps_nulls(client, admin_tokens, auth_headers, project_id):
result = get_ok(client, f"/api/v1/projects/{project_id}/report/{YEAR}/LIMIT", auth_headers(admin_tokens))
assert any(_has_null_field(row["data"]) for row in result)
def test_patch_cell_null_is_explicit(client, admin_tokens, auth_headers):
"""PATCH не режет null: сначала создаём строку plan значением (upd_form_cell на пустом plan ничего не возвращает),
затем сбрасываем ячейку в null и ждём явный "q1": null в ответе."""
url = "/api/v1/form/1/sheet/AHR/cell"
params = {"direction": "Support", "sections": "plan,contract_summary"}
h = auth_headers(admin_tokens)
r = client.patch(url, params=params, json={"line_id": 1, "column": "plan.q1", "value": 225}, headers=h)
assert r.status_code == 200, r.text
r = client.patch(url, params=params, json={"line_id": 1, "column": "plan.q1", "value": None}, headers=h)
assert r.status_code == 200, r.text
rows = r.json()["result"]
assert any("q1" in (row["data"].get("plan") or {}) and row["data"]["plan"]["q1"] is None for row in rows), rows

View File

@ -39,3 +39,11 @@ def test_row_json_select_key_order():
assert row_json_select(row_type="'INPUT'::VARCHAR", depth="3").startswith(
"SELECT json_build_object('row_type', 'INPUT'::VARCHAR, 'depth', 3, "
)
def test_row_json_select_strip_nulls():
sql = row_json_select(strip_nulls=True)
assert "'data', jsonb_strip_nulls(data), " in sql
assert "'data', data," not in sql
assert row_json_select() == row_json_select(strip_nulls=False)
assert "'data', data, " in row_json_select()

View File

@ -242,3 +242,6 @@ def list_json_response(rows: list[str], headers: dict[str, str] | None = None) -
| 2026-10-28 | R-3 (design #3): для `rf-rollup` вместо сравнения с старым путём — тест фиксации ответа об ошибке; `_add_line` в тестах проектов не реализован | `v3.v_form3_rf_rollup_jsonb` на `test` обращается к несуществующей колонке `r.year` и всегда отдаёт 400 (O-3, вне объёма, миграции запрещены); POST строки проекта даёт 400/409 и рвёт транзакцию теста. Исправляет прежнее утверждение выше о покрытии `rf-rollup` интеграционным тестом эквивалентности: успешный путь `rf-rollup` не покрыт ничем до исправления O-3 | Средняя: регрессия успешного пути `rf-rollup` невидна; follow-up на исправление `r.year` и перевод теста в эквивалентность |
| 2026-10-28 | R-4 (design #4): мок в `test_api_split.py` использует `stream=httpx.ByteStream(gz)` вместо `content=gz` | С `content=` httpx не считает `num_bytes_downloaded`, `wire_bytes` был бы 0 | Нет: только тест |
| 2026-10-28 | R-5 (design #5): после ревью задачи 7 в `compare_responses.py` `dump` принимает готовый `httpx.Client`, пишет `index.json` в `finally` и записывает сетевые ошибки как `error: <Exc>`; `compare` сравнивает не-JSON тела побайтно | Ночной `dump` на стенде идёт без присмотра; падение на одном запросе не должно терять остальные | Низкая |
| 2026-10-28 | R-6 (design #6): `test_patch_cell_null_is_explicit` сначала отправляет значение 225, затем `null`, и проверяет явный `"q1": null` в ответе | PATCH `null` по несуществующей строке плана возвращает пустой результат; без предварительного значения проверка не имеет смысла | Нет: только тест |
| 2026-10-28 | R-7 (design #7): `getDataRowId` возвращает `null`, а не `undefined`, когда у строки нет `id`/`line_id` (правка по финальному ревью B, I-3) | После `jsonb_strip_nulls` у групповых строк нет ключа `line_id`; сообщение `row_deleted` уходило без `row_id`, сервер падал на `data["data"]["row_id"]` и рвал WebSocket. Это тот же класс дефекта, что D5.2: ключ в сообщении WebSocket обязан присутствовать | Низкая: функция чистая, покрыта тестом |
| 2026-10-28 | R-8 (design #8): аудит Task 15 дополняется проверкой того, что уходит на сервер (полезная нагрузка WebSocket), а не только отображения | Аудит отображения пропустил I-3 | Нет |

View File

@ -60,11 +60,27 @@ async def grow_vsp_sheet(api: Api, form_id: int, sheet: str, need: int, org_inde
return need
def form4_ids(rows: list[dict]) -> tuple[list[int], list[int]]:
"""(vsp_ids, project_ids) из строк листа FORM_4; отсутствующие ключи (data/header/vsp_id/project_id) пропускаются."""
vsp_ids: set[int] = set()
project_ids: set[int] = set()
for r in rows:
header = (r.get("data") or {}).get("header") or {}
if r.get("row_type") == "INPUT":
vid = header.get("vsp_id")
if vid is not None:
vsp_ids.add(vid)
elif r.get("row_type") == "ITEM":
pid = header.get("project_id")
if pid is not None:
project_ids.add(pid)
return sorted(vsp_ids), sorted(project_ids)
async def grow_form4_sheet(api: Api, form_id: int, sheet: str, need: int) -> int:
path = f"/form/{form_id}/sheet/{sheet}"
rows = (await api.get(path))["result"]
vsp_ids = sorted({r["data"]["header"]["vsp_id"] for r in rows if r.get("row_type") == "INPUT" and r["data"]["header"].get("vsp_id")})
project_ids = sorted({r["data"]["header"]["project_id"] for r in rows if r.get("row_type") == "ITEM"})
vsp_ids, project_ids = form4_ids(rows)
if not project_ids:
raise RuntimeError(f"{sheet}: нет проектов")
leaves = await leaf_items(api, "FORM_4", sheet)

View File

@ -1,6 +1,6 @@
import pytest
from perfkit.gen.grow import rows_to_add, TARGETS
from perfkit.gen.grow import form4_ids, rows_to_add, TARGETS
@pytest.mark.parametrize("cur,target,exp", [(31, 500, 469), (110, 300, 190), (500, 500, 0), (700, 500, 0), (0, 5, 5)])
@ -12,3 +12,15 @@ def test_targets_do_not_touch_small_pair_sheets():
# AHR_RENT и FORM_4/AHR остаются малыми (пара для @big)
assert ("FORM_2", "AHR_RENT") not in [(f, s) for f, s, _ in TARGETS]
assert ("FORM_4", "AHR") not in [(f, s) for f, s, _ in TARGETS]
def test_form4_ids_tolerates_absent_keys():
rows = [
{"row_type": "INPUT", "data": {"header": {"vsp_id": 3}}},
{"row_type": "INPUT", "data": {"header": {}}},
{"row_type": "INPUT", "data": {}},
{"row_type": "ITEM", "data": {"header": {"project_id": 11}}},
{"row_type": "ITEM", "data": {"header": {}}},
{"row_type": "ITEM", "data": {}},
]
assert form4_ids(rows) == ([3], [11])

View File

@ -1,6 +1,7 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
import { toast } from 'react-toastify';
import { useWebSocket } from '../hooks/useWebSocket';
import { buildCellUpdatedMessage } from '../utils/messageUtils';
import { getRowId } from '../utils/rowUtils';
const RealtimeActionsContext = createContext(null);
@ -310,15 +311,7 @@ export const RealtimeProvider = ({ children, formId, sheetName, direction, year,
const lineId = getRowId(row);
const columnKey = getColumnRequestKey(column);
const message = {
event: 'cell_updated',
data: {
line_id: lineId,
line_id_code: row.id,
column: columnKey,
value: value,
},
};
const message = buildCellUpdatedMessage({ lineId, lineIdCode: row.id, column: columnKey, value });
const sent = sendMessage(message);
if (!sent) {

View File

@ -30,7 +30,6 @@ export const useWebSocket = (url, onMessage) => {
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
console.log('WebSocket message received:', data);
onMessage(data);
} catch (err) {
console.error('Failed to parse WebSocket message:', err);

View File

@ -1,4 +1,4 @@
import { getDataRowId, getParentId } from './rowUtils';
import { getDataRowId, getParentId } from './rowUtils.js';
export function setNewValueToData(data, colId, rowId, value) {
let targetObject = data;
@ -43,60 +43,140 @@ export function setNewValueToData(data, colId, rowId, value) {
return true;
}
export function updateCells(data, newRows) {
const updatedRows = structuredClone(data);
const isPlainObject = (v) => v && typeof v === 'object' && !Array.isArray(v);
function deepMerge(target, source) {
if (!source || typeof source !== 'object') {
return source;
}
if (!target || typeof target !== 'object') {
target = {};
}
Object.keys(source).forEach((key) => {
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
target[key] = deepMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
});
return target;
// Неизменяющее слияние. Семантика прежнего deepMerge: объекты сливаются рекурсивно,
// массивы и примитивы заменяются (кроме массива верхнего уровня — он, как и раньше,
// сливается по индексам), null записывается. Если ничего не изменилось —
// возвращается тот же target. `fresh` — узлы, уже созданные в текущем вызове
// updateCells: их можно менять на месте, повторно не копируем.
function mergeImmutable(target, source, fresh) {
if (!source || typeof source !== 'object') {
return source;
}
newRows.forEach((update) => {
const [rowType, depth, sortOrder, newData] = update;
// Рекурсивная функция для поиска и обновления строки
function findAndUpdate(rows) {
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
if (row.row_type === rowType && row.depth === depth && row.sort_order === sortOrder) {
if (newData && typeof newData === 'object') {
Object.keys(newData).forEach((key) => {
if (row.data && row.data[key] !== undefined) {
row.data[key] = deepMerge(row.data[key], newData[key]);
} else if (row[key]) {
row[key] = newData[key];
}
});
}
return true;
}
if (row.subRows && Array.isArray(row.subRows) && row.subRows.length > 0) {
const found = findAndUpdate(row.subRows);
if (found) return true;
}
let base = target && typeof target === 'object' ? target : null;
let result = base;
for (const key of Object.keys(source)) {
const srcVal = source[key];
const curVal = base ? base[key] : undefined;
const nextVal = isPlainObject(srcVal) ? mergeImmutable(curVal, srcVal, fresh) : srcVal;
if (result && key in result && Object.is(result[key], nextVal)) continue;
if (!result || result === base) {
if (!result) {
result = {};
fresh.add(result);
} else if (!fresh.has(result)) {
result = Array.isArray(result) ? [...result] : { ...result };
fresh.add(result);
}
return false;
}
result[key] = nextVal;
}
return result ?? (base || {});
}
findAndUpdate(updatedRows);
});
return updatedRows;
function findRowPath(rows, rowType, depth, sortOrder, path) {
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
path.push(i);
if (row.row_type === rowType && row.depth === depth && row.sort_order === sortOrder) {
return true;
}
if (Array.isArray(row.subRows) && row.subRows.length > 0 && findRowPath(row.subRows, rowType, depth, sortOrder, path)) {
return true;
}
path.pop();
}
return false;
}
// Считает, во что превратится строка после слияния. Возвращает null, если менять нечего.
function mergeIntoRow(row, newData, fresh) {
let data = row.data;
let rowPatch = null;
for (const key of Object.keys(newData)) {
const hasData = data && typeof data === 'object';
if (hasData && data[key] !== undefined) {
const merged = mergeImmutable(data[key], newData[key], fresh);
if (!Object.is(merged, data[key])) {
if (!fresh.has(data)) {
data = { ...data };
fresh.add(data);
}
data[key] = merged;
}
} else if (row[key]) {
if (!Object.is(row[key], newData[key])) {
rowPatch = rowPatch || {};
rowPatch[key] = newData[key];
}
} else if (hasData) {
// Единственное осознанное отличие от прежней реализации: ключ, которого нет
// в row.data, вставляется (раньше пропускался). Нужно для AURORA-1844/1848.
if (!fresh.has(data)) {
data = { ...data };
fresh.add(data);
}
data[key] = mergeImmutable(undefined, newData[key], fresh);
}
}
if (data === row.data && !rowPatch) return null;
return { data, rowPatch };
}
// Не мутирует data. Копируются только массивы и строки на пути к затронутой строке;
// остальные строки и поддеревья сохраняют идентичность. Если ничего не изменилось,
// возвращается тот же data.
export function updateCells(data, newRows) {
let result = data;
const fresh = new WeakSet();
for (const update of newRows) {
const [rowType, depth, sortOrder, newData] = update;
if (!newData || typeof newData !== 'object') continue;
const path = [];
if (!findRowPath(result, rowType, depth, sortOrder, path)) continue;
// Находим целевую строку по пути (без копирования).
let rows = result;
let row;
for (let level = 0; level < path.length; level++) {
row = rows[path[level]];
rows = row.subRows;
}
const merged = mergeIntoRow(row, newData, fresh);
if (!merged) continue;
// Копируем путь сверху вниз, пропуская уже свежие узлы.
if (!fresh.has(result)) {
result = [...result];
fresh.add(result);
}
let level = result;
for (let d = 0; d < path.length; d++) {
const idx = path[d];
const isLast = d === path.length - 1;
let node = level[idx];
if (isLast) {
node = { ...node, ...merged.rowPatch, data: merged.data };
fresh.add(node);
level[idx] = node;
} else {
if (!fresh.has(node)) {
node = { ...node };
fresh.add(node);
level[idx] = node;
}
if (!fresh.has(node.subRows)) {
node.subRows = [...node.subRows];
fresh.add(node.subRows);
}
level = node.subRows;
}
}
}
return result;
}
export function addRootRow(data, newRowRoot) {

View File

@ -0,0 +1,247 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { updateCells } from './cellUtils.js';
// Эталон: прежняя реализация updateCells (до AURORA-1838).
function oldUpdateCells(data, newRows) {
const updatedRows = structuredClone(data);
function deepMerge(target, source) {
if (!source || typeof source !== 'object') return source;
if (!target || typeof target !== 'object') target = {};
Object.keys(source).forEach((key) => {
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
target[key] = deepMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
});
return target;
}
newRows.forEach((update) => {
const [rowType, depth, sortOrder, newData] = update;
function findAndUpdate(rows) {
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
if (row.row_type === rowType && row.depth === depth && row.sort_order === sortOrder) {
if (newData && typeof newData === 'object') {
Object.keys(newData).forEach((key) => {
if (row.data && row.data[key] !== undefined) {
row.data[key] = deepMerge(row.data[key], newData[key]);
} else if (row[key]) {
row[key] = newData[key];
}
});
}
return true;
}
if (row.subRows && Array.isArray(row.subRows) && row.subRows.length > 0 && findAndUpdate(row.subRows)) return true;
}
return false;
}
findAndUpdate(updatedRows);
});
return updatedRows;
}
function deepFreeze(o) {
if (o && typeof o === 'object' && !Object.isFrozen(o)) {
Object.freeze(o);
Object.values(o).forEach(deepFreeze);
}
return o;
}
function makeTree() {
return [
{
row_type: 'AGG',
depth: 0,
sort_order: 1,
data: { header: { name: 'A' }, sum: { y2025: 10, y2026: 20 }, tags: [1, 2] },
subRows: [
{
row_type: 'INPUT',
depth: 1,
sort_order: 2,
data: { header: { name: 'A1' }, sum: { y2025: 4, y2026: 5 }, note: 'x', tags: [] },
subRows: [],
},
{
row_type: 'INPUT',
depth: 1,
sort_order: 3,
data: { header: { name: 'A2' }, sum: { y2025: 6, y2026: 15 }, note: null },
subRows: [],
},
],
},
{
row_type: 'AGG',
depth: 0,
sort_order: 4,
data: { header: { name: 'B' }, sum: { y2025: 1, y2026: 2 } },
subRows: [{ row_type: 'INPUT', depth: 1, sort_order: 5, data: { sum: { y2025: 1 } }, subRows: [] }],
},
];
}
test('идентичность: незатронутые строки и поддеревья сохраняются, путь копируется', () => {
const data = deepFreeze(makeTree());
const next = updateCells(data, [['INPUT', 1, 2, { sum: { y2025: 99 } }]]);
assert.notEqual(next, data);
assert.notEqual(next[0], data[0]);
assert.notEqual(next[0].subRows, data[0].subRows);
assert.notEqual(next[0].subRows[0], data[0].subRows[0]);
assert.notEqual(next[0].subRows[0].data, data[0].subRows[0].data);
assert.notEqual(next[0].subRows[0].data.sum, data[0].subRows[0].data.sum);
// не затронуты
assert.equal(next[1], data[1]);
assert.equal(next[0].subRows[1], data[0].subRows[1]);
assert.equal(next[0].data, data[0].data);
assert.equal(next[0].subRows[0].data.header, data[0].subRows[0].data.header);
assert.equal(next[0].subRows[0].data.sum.y2026, 5);
assert.equal(next[0].subRows[0].data.sum.y2025, 99);
});
test('вход не мутируется (рекурсивно замороженный)', () => {
const data = deepFreeze(makeTree());
const snapshot = structuredClone(data);
updateCells(data, [
['INPUT', 1, 2, { sum: { y2025: 1 }, note: null, newKey: { a: 1 } }],
['AGG', 0, 1, { sum: { y2026: 0 } }],
]);
assert.deepEqual(data, snapshot);
});
test('ничего не изменилось: тот же data', () => {
const data = deepFreeze(makeTree());
assert.equal(updateCells(data, []), data);
assert.equal(updateCells(data, [['INPUT', 9, 9, { sum: { y2025: 1 } }]]), data);
assert.equal(updateCells(data, [['INPUT', 1, 2, { sum: { y2025: 4 }, note: 'x' }]]), data);
assert.equal(updateCells(data, [['INPUT', 1, 2, null]]), data);
});
test('строка не найдена: тот же объект, остальные обновления применяются', () => {
const data = deepFreeze(makeTree());
const next = updateCells(data, [
['INPUT', 7, 7, { sum: { y2025: 1 } }],
['INPUT', 1, 3, { note: 'z' }],
]);
assert.equal(next[0].subRows[1].data.note, 'z');
assert.equal(next[1], data[1]);
});
test('пакет: узлы копируются один раз, итог корректен', () => {
const data = deepFreeze(makeTree());
const next = updateCells(data, [
['INPUT', 1, 2, { sum: { y2025: 11 } }],
['INPUT', 1, 3, { sum: { y2025: 12 } }],
['AGG', 0, 1, { sum: { y2025: 23 } }],
]);
assert.equal(next[0].subRows[0].data.sum.y2025, 11);
assert.equal(next[0].subRows[1].data.sum.y2025, 12);
assert.equal(next[0].data.sum.y2025, 23);
assert.equal(next[0].data.sum.y2026, 20);
assert.equal(next[1], data[1]);
// ссылки на свежие узлы между итерациями не плодятся: родитель тот же в обоих детях
assert.equal(next[0].subRows.length, 2);
assert.deepEqual(
next,
oldUpdateCells(data, [
['INPUT', 1, 2, { sum: { y2025: 11 } }],
['INPUT', 1, 3, { sum: { y2025: 12 } }],
['AGG', 0, 1, { sum: { y2025: 23 } }],
]),
);
});
const parityCases = {
'вложенные объекты': [['INPUT', 1, 2, { sum: { y2025: 7 }, header: { name: 'Z' } }]],
'массивы заменяются': [
['AGG', 0, 1, { tags: [9] }],
['INPUT', 1, 2, { tags: [3, 4] }],
],
null: [['INPUT', 1, 2, { note: null, sum: { y2026: null } }]],
'null вместо объекта и объект вместо null': [
['INPUT', 1, 3, { note: { a: 1 } }],
['INPUT', 1, 2, { header: null }],
],
'ключ вне data (поле строки)': [['INPUT', 1, 2, { sort_order: 2, depth: 5 }]],
'пустой/невалидный update': [
['INPUT', 1, 2, {}],
['INPUT', 1, 2, null],
['INPUT', 1, 2, undefined],
],
'несколько в пакете': [
['INPUT', 1, 2, { sum: { y2025: 1 } }],
['INPUT', 1, 2, { sum: { y2026: 2 } }],
['AGG', 0, 4, { sum: { y2025: 3 } }],
],
'не найдена': [['X', 0, 0, { a: 1 }]],
};
for (const [name, updates] of Object.entries(parityCases)) {
test(`паритет со старой реализацией: ${name}`, () => {
const data = deepFreeze(makeTree());
assert.deepEqual(updateCells(data, updates), oldUpdateCells(data, updates));
});
}
test('ожидаемое отличие от старой реализации: отсутствующий в row.data ключ вставляется', () => {
const data = deepFreeze(makeTree());
const updates = [['INPUT', 1, 2, { fresh: { a: 1, b: null }, extra: 5 }]];
const old = oldUpdateCells(data, updates);
const next = updateCells(data, updates);
assert.equal('fresh' in old[0].subRows[0].data, false); // раньше пропускался
assert.deepEqual(next[0].subRows[0].data.fresh, { a: 1, b: null });
assert.equal(next[0].subRows[0].data.extra, 5);
// всё остальное совпадает
const stripped = structuredClone(next);
delete stripped[0].subRows[0].data.fresh;
delete stripped[0].subRows[0].data.extra;
assert.deepEqual(stripped, old);
});
const row = (data) => ({ row_type: 'INPUT', depth: 3, sort_order: 1, data });
test('AURORA-1848: нет блока q1 — блок с полем вставляется, остальное сохраняется', () => {
const data = deepFreeze([row({ line_id: 7, header: { name: 'X' } })]);
const out = updateCells(data, [['INPUT', 3, 1, { q1: { m1: 5 } }]]);
assert.equal(out[0].data.q1.m1, 5);
assert.deepEqual(out[0].data.header, { name: 'X' });
assert.equal(out[0].data.line_id, 7);
});
test('AURORA-1848: блок q1 без поля m1 — поле вставляется, остальные поля q1 сохраняются', () => {
const data = deepFreeze([row({ line_id: 7, q1: { m2: 3 } })]);
const out = updateCells(data, [['INPUT', 3, 1, { q1: { m1: 5 } }]]);
assert.deepEqual(out[0].data.q1, { m2: 3, m1: 5 });
});
test('AURORA-1848: очистка — null записывается явно', () => {
const data = deepFreeze([row({ line_id: 7, q1: { m1: 5 } })]);
const out = updateCells(data, [['INPUT', 3, 1, { q1: { m1: null } }]]);
assert.ok('m1' in out[0].data.q1);
assert.equal(out[0].data.q1.m1, null);
});
test('AURORA-1848: отсутствующий скалярный ключ верхнего уровня data вставляется', () => {
const data = deepFreeze([row({ header: { name: 'X' } })]);
const out = updateCells(data, [['INPUT', 3, 1, { line_id: 9 }]]);
assert.equal(out[0].data.line_id, 9);
});
test('AURORA-1848: блок отсутствует на двух уровнях вглубь — создаётся цепочка, вход не меняется', () => {
const data = deepFreeze([row({ line_id: 7 })]);
const out = updateCells(data, [['INPUT', 3, 1, { plan: { support: { q1: 5 } } }]]);
assert.equal(out[0].data.plan.support.q1, 5);
assert.deepEqual(data[0].data, { line_id: 7 });
});
test('AURORA-1848: два обновления одной строки в одном батче складываются', () => {
const data = deepFreeze([row({ line_id: 7 })]);
const out = updateCells(data, [
['INPUT', 3, 1, { q1: { m1: 5 } }],
['INPUT', 3, 1, { q1: { m2: 3 } }],
]);
assert.deepEqual(out[0].data.q1, { m1: 5, m2: 3 });
});

View File

@ -0,0 +1,10 @@
// value всегда присутствует: сервер читает event_data["value"], AURORA-1848 D5.2
export const buildCellUpdatedMessage = ({ lineId, lineIdCode, column, value }) => ({
event: 'cell_updated',
data: {
line_id: lineId,
line_id_code: lineIdCode,
column,
value: value === undefined ? null : value,
},
});

View File

@ -0,0 +1,17 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { buildCellUpdatedMessage } from './messageUtils.js';
const msg = (value) => buildCellUpdatedMessage({ lineId: 1, lineIdCode: '0.1', column: 'q1.m1', value });
test('buildCellUpdatedMessage: undefined → null, ключ value есть после JSON.stringify', () => {
const m = msg(undefined);
assert.deepEqual(m, { event: 'cell_updated', data: { line_id: 1, line_id_code: '0.1', column: 'q1.m1', value: null } });
assert.match(JSON.stringify(m), /"value":null/);
});
test('buildCellUpdatedMessage: прочие значения не меняются', () => {
for (const v of [null, 0, '', '1.5', 42, 'текст']) {
assert.equal(msg(v).data.value, v);
}
});

View File

@ -30,7 +30,7 @@ export function buildRowHierarchy(items, mapItem = (item) => item) {
}
export function getDataRowId(data) {
return data?.id || data?.line_id;
return data?.id || data?.line_id || null;
}
export function getRowId(row) {

View File

@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildRowHierarchy } from './rowUtils.js';
import { buildRowHierarchy, getDataRowId } from './rowUtils.js';
test('row hierarchy uses row types when depth skips a level and keeps INPUT rows as siblings', () => {
const rows = [
@ -19,3 +19,10 @@ test('row hierarchy uses row types when depth skips a level and keeps INPUT rows
assert.deepEqual(tree[0].subRows[0].subRows.map(({ id }) => id), ['input-1', 'input-2']);
assert.deepEqual(tree[0].subRows[0].subRows[0].subRows, []);
});
test('AURORA-1848: getDataRowId без id и line_id возвращает null и сериализуется в row_id:null', () => {
assert.equal(getDataRowId({ header: {} }), null);
assert.equal(getDataRowId({ id: 5 }), 5);
assert.equal(getDataRowId({ line_id: 7 }), 7);
assert.ok(JSON.stringify({ row_id: getDataRowId({ header: {} }) }).includes('"row_id":null'));
});