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) => {
|
||||
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) {
|
||||
|
||||
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