Merge pull request 'AURORA-1848: лист и своды без null-полей + фронт (B)' (#242) from feat/233-aurora-1848-sheet-json-no-pydantic-b into test
This commit is contained in:
commit
8c467804be
@ -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) {
|
||||
|
||||
@ -200,3 +200,48 @@ test('ожидаемое отличие от старой реализации:
|
||||
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