Merge AURORA-1838 (#223) в ветку B: updateCells вставляет отсутствующие ключи

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-28 21:48:10 +04:00
commit ef6df97f1f
3 changed files with 332 additions and 51 deletions

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,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);
});