Merge AURORA-1838 (#223) в ветку B: updateCells вставляет отсутствующие ключи
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
ef6df97f1f
@ -30,7 +30,6 @@ export const useWebSocket = (url, onMessage) => {
|
|||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
console.log('WebSocket message received:', data);
|
|
||||||
onMessage(data);
|
onMessage(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to parse WebSocket message:', 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) {
|
export function setNewValueToData(data, colId, rowId, value) {
|
||||||
let targetObject = data;
|
let targetObject = data;
|
||||||
@ -43,60 +43,140 @@ export function setNewValueToData(data, colId, rowId, value) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateCells(data, newRows) {
|
const isPlainObject = (v) => v && typeof v === 'object' && !Array.isArray(v);
|
||||||
const updatedRows = structuredClone(data);
|
|
||||||
|
|
||||||
function deepMerge(target, source) {
|
// Неизменяющее слияние. Семантика прежнего deepMerge: объекты сливаются рекурсивно,
|
||||||
if (!source || typeof source !== 'object') {
|
// массивы и примитивы заменяются (кроме массива верхнего уровня — он, как и раньше,
|
||||||
return source;
|
// сливается по индексам), null записывается. Если ничего не изменилось —
|
||||||
}
|
// возвращается тот же target. `fresh` — узлы, уже созданные в текущем вызове
|
||||||
if (!target || typeof target !== 'object') {
|
// updateCells: их можно менять на месте, повторно не копируем.
|
||||||
target = {};
|
function mergeImmutable(target, source, fresh) {
|
||||||
}
|
if (!source || typeof source !== 'object') {
|
||||||
|
return source;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
let base = target && typeof target === 'object' ? target : null;
|
||||||
newRows.forEach((update) => {
|
let result = base;
|
||||||
const [rowType, depth, sortOrder, newData] = update;
|
for (const key of Object.keys(source)) {
|
||||||
|
const srcVal = source[key];
|
||||||
// Рекурсивная функция для поиска и обновления строки
|
const curVal = base ? base[key] : undefined;
|
||||||
function findAndUpdate(rows) {
|
const nextVal = isPlainObject(srcVal) ? mergeImmutable(curVal, srcVal, fresh) : srcVal;
|
||||||
for (let i = 0; i < rows.length; i++) {
|
if (result && key in result && Object.is(result[key], nextVal)) continue;
|
||||||
const row = rows[i];
|
if (!result || result === base) {
|
||||||
|
if (!result) {
|
||||||
if (row.row_type === rowType && row.depth === depth && row.sort_order === sortOrder) {
|
result = {};
|
||||||
if (newData && typeof newData === 'object') {
|
fresh.add(result);
|
||||||
Object.keys(newData).forEach((key) => {
|
} else if (!fresh.has(result)) {
|
||||||
if (row.data && row.data[key] !== undefined) {
|
result = Array.isArray(result) ? [...result] : { ...result };
|
||||||
row.data[key] = deepMerge(row.data[key], newData[key]);
|
fresh.add(result);
|
||||||
} 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
result[key] = nextVal;
|
||||||
|
}
|
||||||
|
return result ?? (base || {});
|
||||||
|
}
|
||||||
|
|
||||||
findAndUpdate(updatedRows);
|
function findRowPath(rows, rowType, depth, sortOrder, path) {
|
||||||
});
|
for (let i = 0; i < rows.length; i++) {
|
||||||
return updatedRows;
|
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) {
|
export function addRootRow(data, newRowRoot) {
|
||||||
|
|||||||
202
web/src/components/RealtimeTable/utils/cellUtils.test.js
Normal file
202
web/src/components/RealtimeTable/utils/cellUtils.test.js
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user