AURORA-1848: лист и своды без null-полей + фронт (B) #242
@ -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={
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)}",
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 | Нет |
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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])
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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) {
|
||||
|
||||
247
web/src/components/RealtimeTable/utils/cellUtils.test.js
Normal file
247
web/src/components/RealtimeTable/utils/cellUtils.test.js
Normal 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 });
|
||||
});
|
||||
10
web/src/components/RealtimeTable/utils/messageUtils.js
Normal file
10
web/src/components/RealtimeTable/utils/messageUtils.js
Normal 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,
|
||||
},
|
||||
});
|
||||
17
web/src/components/RealtimeTable/utils/messageUtils.test.js
Normal file
17
web/src/components/RealtimeTable/utils/messageUtils.test.js
Normal 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);
|
||||
}
|
||||
});
|
||||
@ -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) {
|
||||
|
||||
@ -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'));
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user